support interval habit frequency (#104)

This commit is contained in:
Doh
2025-04-10 15:33:33 -04:00
committed by GitHub
parent d31982bf29
commit f1e3ee5747
12 changed files with 560 additions and 130 deletions

View File

@@ -52,13 +52,12 @@ jobs:
push: true push: true
tags: | tags: |
${{ steps.check-version.outputs.EXISTS == 'false' && format('dohsimpson/habittrove:v{0}', steps.package-version.outputs.VERSION) || '' }} ${{ steps.check-version.outputs.EXISTS == 'false' && format('dohsimpson/habittrove:v{0}', steps.package-version.outputs.VERSION) || '' }}
${{ steps.check-version.outputs.EXISTS == 'false' && 'dohsimpson/habittrove:latest' || '' }} dohsimpson/habittrove:demo
dohsimpson/habittrove:dev
deploy-demo: deploy-demo:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build-and-push needs: build-and-push
# demo tracks the latest tag # demo tracks the demo tag
if: needs.build-and-push.outputs.EXISTS == 'false' if: needs.build-and-push.outputs.EXISTS == 'false'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View File

@@ -1,5 +1,17 @@
# Changelog # Changelog
## Version 0.2.6
### Added
* support weekly / monthly intervals for recurring frequency (#99)
* show error when frequency is unsupported (#56)
* add task / habit button in habit view
### Fixed
* make user select modal scrollable
## Version 0.2.5 ## Version 0.2.5
### Changed ### Changed

View File

@@ -1,9 +1,9 @@
# syntax=docker.io/docker/dockerfile:1 # syntax=docker.io/docker/dockerfile:1
FROM --platform=$BUILDPLATFORM node:18-alpine AS base FROM node:18-alpine AS base
# Install dependencies only when needed # Install dependencies only when needed
FROM --platform=$BUILDPLATFORM base AS deps FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
@@ -19,7 +19,7 @@ RUN \
fi fi
# Rebuild the source code only when needed # Rebuild the source code only when needed
FROM --platform=$BUILDPLATFORM base AS builder FROM base AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .

View File

@@ -16,7 +16,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import data from '@emoji-mart/data' import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react' import Picker from '@emoji-mart/react'
import { Habit, SafeUser } from '@/lib/types' import { Habit, SafeUser } from '@/lib/types'
import { d2s, d2t, getFrequencyDisplayText, getISODate, getNow, parseNaturalLanguageDate, parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils' import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2s, d2t, serializeRRule } from '@/lib/utils'
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES, RECURRENCE_RULE_MAP } from '@/lib/constants' import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES, RECURRENCE_RULE_MAP } from '@/lib/constants'
import * as chrono from 'chrono-node'; import * as chrono from 'chrono-node';
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
@@ -43,30 +43,41 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
const [coinReward, setCoinReward] = useState(habit?.coinReward || 1) const [coinReward, setCoinReward] = useState(habit?.coinReward || 1)
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1) const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
const isRecurRule = !isTask const isRecurRule = !isTask
const origRuleText = getFrequencyDisplayText(habit?.frequency, isRecurRule, settings.system.timezone) // Initialize ruleText with the actual frequency string or default, not the display text
const [ruleText, setRuleText] = useState<string>(origRuleText) const initialRuleText = habit?.frequency ? convertMachineReadableFrequencyToHumanReadable({
frequency: habit.frequency,
isRecurRule,
timezone: settings.system.timezone
}) : (isRecurRule ? INITIAL_RECURRENCE_RULE : INITIAL_DUE);
const [ruleText, setRuleText] = useState<string>(initialRuleText)
const { currentUser } = useHelpers() const { currentUser } = useHelpers()
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false) const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
const [ruleError, setRuleError] = useState<string | null>(null); // State for validation message
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id)) const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
const [usersData] = useAtom(usersAtom) const [usersData] = useAtom(usersAtom)
const users = usersData.users const users = usersData.users
function getFrequencyUpdate() { function getFrequencyUpdate() {
if (ruleText === origRuleText && habit?.frequency) { if (ruleText === initialRuleText && habit?.frequency) {
return habit.frequency // If text hasn't changed and original frequency exists, return it
return habit.frequency;
} }
if (isRecurRule) {
const parsedRule = parseNaturalLanguageRRule(ruleText) const parsedResult = convertHumanReadableFrequencyToMachineReadable({
return serializeRRule(parsedRule) text: ruleText,
timezone: settings.system.timezone,
isRecurring: isRecurRule
});
if (parsedResult.result) {
return isRecurRule
? serializeRRule(parsedResult.result as RRule)
: d2t({
dateTime: parsedResult.result as DateTime,
timezone: settings.system.timezone
});
} else { } else {
const parsedDate = parseNaturalLanguageDate({ return 'invalid';
text: ruleText,
timezone: settings.system.timezone
})
return d2t({
dateTime: parsedDate,
timezone: settings.system.timezone
})
} }
} }
@@ -146,6 +157,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
<Label htmlFor="recurrence" className="text-right"> <Label htmlFor="recurrence" className="text-right">
When * When *
</Label> </Label>
{/* date input (task) */}
<div className="col-span-3 space-y-2"> <div className="col-span-3 space-y-2">
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
@@ -189,16 +201,26 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
)} )}
</div> </div>
</div> </div>
<div className="col-start-2 col-span-3 text-sm text-muted-foreground"> {/* rrule input (habit) */}
<span> <div className="col-start-2 col-span-3 text-sm">
{(() => { {(() => {
try { let displayText = '';
return isRecurRule ? parseNaturalLanguageRRule(ruleText).toText() : d2s({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }), timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY }) let errorMessage: string | null = null;
} catch (e: unknown) { const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
return `Invalid rule: ${e instanceof Error ? e.message : 'Invalid recurrence rule'}` errorMessage = message;
} displayText = convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })
})()}
</span> return (
<>
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
{displayText}
</span>
{errorMessage && (
<p className="text-destructive text-xs mt-1">{errorMessage}</p>
)}
</>
);
})()}
</div> </div>
</div> </div>
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">

View File

@@ -1,7 +1,7 @@
import { Habit, SafeUser, User, Permission } from '@/lib/types' import { Habit, SafeUser, User, Permission } from '@/lib/types'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms' import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseRRule, d2s, getCompletionsForToday, isTaskOverdue, getFrequencyDisplayText } from '@/lib/utils' import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, d2s, getCompletionsForToday, isTaskOverdue, convertMachineReadableFrequencyToHumanReadable } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Coins, Edit, Trash2, Check, Undo2, MoreVertical, Timer, Archive, ArchiveRestore, Calendar } from 'lucide-react' import { Coins, Edit, Trash2, Check, Undo2, MoreVertical, Timer, Archive, ArchiveRestore, Calendar } from 'lucide-react'
@@ -105,7 +105,11 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
</CardHeader> </CardHeader>
<CardContent className="flex-1"> <CardContent className="flex-1">
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}> <p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
When: {getFrequencyDisplayText(habit.frequency, isRecurRule, settings.system.timezone)} When: {convertMachineReadableFrequencyToHumanReadable({
frequency: habit.frequency,
isRecurRule,
timezone: settings.system.timezone
})}
</p> </p>
<div className="flex items-center mt-2"> <div className="flex items-center mt-2">
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} /> <Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />

View File

@@ -41,17 +41,22 @@ export default function HabitList() {
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold"> <h1 className="text-3xl font-bold">
{isTasksView ? 'My Tasks' : 'My Habits'} {isTasksView ? 'My Tasks' : 'My Habits'}
</h1> </h1>
<Button onClick={() => setModalConfig({ isOpen: true, isTask: isTasksView })}> <span>
<Plus className="mr-2 h-4 w-4" /> {isTasksView ? 'Add Task' : 'Add Habit'} <Button className="mr-2" onClick={() => setModalConfig({ isOpen: true, isTask: true })}>
</Button> <Plus className="mr-2 h-4 w-4" /> {'Add Task'}
</div> </Button>
<div className='py-4'> <Button onClick={() => setModalConfig({ isOpen: true, isTask: false })}>
<ViewToggle /> <Plus className="mr-2 h-4 w-4" /> {'Add Habit'}
</div> </Button>
</span>
</div>
<div className='py-4'>
<ViewToggle />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
{activeHabits.length === 0 ? ( {activeHabits.length === 0 ? (
<div className="col-span-2"> <div className="col-span-2">
@@ -62,18 +67,18 @@ export default function HabitList() {
/> />
</div> </div>
) : ( ) : (
activeHabits.map((habit: Habit) => ( activeHabits.map((habit: Habit) => (
<HabitItem <HabitItem
key={habit.id} key={habit.id}
habit={habit} habit={habit}
onEdit={() => { onEdit={() => {
setEditingHabit(habit) setEditingHabit(habit)
setModalConfig({ isOpen: true, isTask: isTasksView }) setModalConfig({ isOpen: true, isTask: isTasksView })
}} }}
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })} onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
/> />
)) ))
)} )}
{archivedHabits.length > 0 && ( {archivedHabits.length > 0 && (
<> <>

View File

@@ -99,7 +99,7 @@ function UserSelectionView({
onCreateUser: () => void onCreateUser: () => void
}) { }) {
return ( return (
<div className="grid grid-cols-3 gap-4 p-2"> <div className="grid grid-cols-3 gap-4 p-2 max-h-80 overflow-y-auto">
{users {users
.filter(user => user.id !== currentUser?.id) .filter(user => user.id !== currentUser?.id)
.map((user) => ( .map((user) => (

View File

@@ -1,6 +1,6 @@
import { CheckSquare, Target } from "lucide-react" import { CheckSquare, Target } from "lucide-react"
export const INITIAL_RECURRENCE_RULE = 'daily' export const INITIAL_RECURRENCE_RULE = 'every day'
export const INITIAL_DUE = 'today' export const INITIAL_DUE = 'today'
export const RECURRENCE_RULE_MAP: { [key: string]: string } = { export const RECURRENCE_RULE_MAP: { [key: string]: string } = {

View File

@@ -1,4 +1,6 @@
import { RRule } from "rrule"
import { uuid } from "./utils" import { uuid } from "./utils"
import { DateTime } from "luxon"
export type UserId = string export type UserId = string
@@ -188,3 +190,11 @@ export interface JotaiHydrateInitialValues {
export interface ServerSettings { export interface ServerSettings {
isDemo: boolean isDemo: boolean
} }
export type ParsedResultType = DateTime<true> | RRule | string | null // null if invalid
// return rrule / datetime (machine-readable frequency), string (human-readable frequency), or null (invalid)
export interface ParsedFrequencyResult {
message: string | null
result: ParsedResultType
}

View File

@@ -1,4 +1,4 @@
import { expect, test, describe, beforeAll, beforeEach, afterAll, spyOn } from "bun:test"; import { expect, test, describe, beforeEach, spyOn } from "bun:test";
import { import {
cn, cn,
getTodayInTimezone, getTodayInTimezone,
@@ -17,12 +17,18 @@ import {
isHabitDueToday, isHabitDueToday,
isHabitDue, isHabitDue,
uuid, uuid,
isTaskOverdue isTaskOverdue,
deserializeRRule,
serializeRRule,
convertHumanReadableFrequencyToMachineReadable,
convertMachineReadableFrequencyToHumanReadable,
getUnsupportedRRuleReason
} from './utils' } from './utils'
import { CoinTransaction } from './types' import { CoinTransaction, ParsedResultType } from './types'
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { RRule } from 'rrule'; import { RRule, Weekday } from 'rrule';
import { Habit } from '@/lib/types'; import { Habit } from '@/lib/types';
import { INITIAL_DUE } from './constants';
describe('cn utility', () => { describe('cn utility', () => {
test('should merge class names correctly', () => { test('should merge class names correctly', () => {
@@ -33,6 +39,59 @@ describe('cn utility', () => {
}) })
}) })
describe('getUnsupportedRRuleReason', () => {
test('should return message for HOURLY frequency', () => {
const rrule = new RRule({ freq: RRule.HOURLY });
expect(getUnsupportedRRuleReason(rrule)).toBe('Hourly frequency is not supported.');
});
test('should return message for MINUTELY frequency', () => {
const rrule = new RRule({ freq: RRule.MINUTELY });
expect(getUnsupportedRRuleReason(rrule)).toBe('Minutely frequency is not supported.');
});
test('should return message for SECONDLY frequency', () => {
const rrule = new RRule({ freq: RRule.SECONDLY });
expect(getUnsupportedRRuleReason(rrule)).toBe('Secondly frequency is not supported.');
});
test('should return message for DAILY frequency with interval > 1', () => {
const rrule = new RRule({ freq: RRule.DAILY, interval: 2 });
expect(getUnsupportedRRuleReason(rrule)).toBe('Daily frequency with intervals greater than 1 is not supported.');
});
test('should return null for DAILY frequency without interval', () => {
const rrule = new RRule({ freq: RRule.DAILY });
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
test('should return null for DAILY frequency with interval = 1', () => {
const rrule = new RRule({ freq: RRule.DAILY, interval: 1 });
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
test('should return null for WEEKLY frequency', () => {
const rrule = new RRule({ freq: RRule.WEEKLY, byweekday: [RRule.MO] }); // Added byweekday for validity
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
test('should return null for MONTHLY frequency', () => {
const rrule = new RRule({ freq: RRule.MONTHLY, bymonthday: [1] }); // Added bymonthday for validity
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
test('should return null for YEARLY frequency', () => {
const rrule = new RRule({ freq: RRule.YEARLY, bymonth: [1], bymonthday: [1] }); // Added bymonth/bymonthday for validity
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
test('should return null for WEEKLY frequency with interval', () => {
// Weekly with interval is supported
const rrule = new RRule({ freq: RRule.WEEKLY, interval: 2, byweekday: [RRule.TU] }); // Added byweekday for validity
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
});
describe('isTaskOverdue', () => { describe('isTaskOverdue', () => {
const createTestHabit = (frequency: string, isTask = true, archived = false): Habit => ({ const createTestHabit = (frequency: string, isTask = true, archived = false): Habit => ({
id: 'test-habit', id: 'test-habit',
@@ -652,3 +711,248 @@ describe('isHabitDue', () => {
expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false) expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false)
}) })
}) })
describe('deserializeRRule', () => {
test('should deserialize valid RRule string', () => {
const rruleStr = 'FREQ=DAILY;INTERVAL=1'
const rrule = deserializeRRule(rruleStr)
expect(rrule).toBeInstanceOf(RRule)
expect(rrule?.origOptions.freq).toBe(RRule.DAILY)
expect(rrule?.origOptions.interval).toBe(1)
})
test('should return null for invalid RRule string', () => {
const rruleStr = 'INVALID_RRULE_STRING'
const rrule = deserializeRRule(rruleStr)
expect(rrule).toBeNull()
})
test('should handle complex RRule strings', () => {
const rruleStr = 'FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;COUNT=10'
const rrule = deserializeRRule(rruleStr)
expect(rrule).toBeInstanceOf(RRule)
expect(rrule?.origOptions.freq).toBe(RRule.WEEKLY)
expect(rrule?.origOptions.byweekday).toEqual([RRule.MO, RRule.WE, RRule.FR])
expect(rrule?.origOptions.interval).toBe(2)
expect(rrule?.origOptions.count).toBe(10)
})
})
describe('serializeRRule', () => {
test('should serialize RRule object to string', () => {
const rrule = new RRule({
freq: RRule.DAILY,
interval: 1
})
const rruleStr = serializeRRule(rrule)
// RRule adds DTSTART automatically if not provided, so we check the core parts
expect(rruleStr).toContain('FREQ=DAILY')
expect(rruleStr).toContain('INTERVAL=1')
})
test('should return "invalid" for null input', () => {
const rruleStr = serializeRRule(null)
expect(rruleStr).toBe('invalid')
})
test('should serialize complex RRule objects', () => {
const rrule = new RRule({
freq: RRule.WEEKLY,
byweekday: [RRule.MO, RRule.WE, RRule.FR],
interval: 2,
count: 10
})
const rruleStr = serializeRRule(rrule)
expect(rruleStr).toContain('FREQ=WEEKLY')
expect(rruleStr).toContain('BYDAY=MO,WE,FR')
expect(rruleStr).toContain('INTERVAL=2')
expect(rruleStr).toContain('COUNT=10')
})
})
describe('convertHumanReadableFrequencyToMachineReadable', () => {
const timezone = 'America/New_York'
beforeEach(() => {
// Set a fixed date for consistent relative date parsing
const mockDate = DateTime.fromISO('2024-07-15T10:00:00', { zone: timezone }) as DateTime<true>
DateTime.now = () => mockDate
})
// Non-recurring tests
test('should parse specific date (non-recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'July 16, 2024', timezone, isRecurring: false })
expect(message).toBeNull()
expect(result).toBeInstanceOf(DateTime)
expect((result as DateTime).toISODate()).toBe('2024-07-16')
})
test('should parse relative date "tomorrow" (non-recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'tomorrow', timezone, isRecurring: false })
expect(message).toBeNull()
expect(result).toBeInstanceOf(DateTime)
expect((result as DateTime).toISODate()).toBe('2024-07-16') // Based on mock date 2024-07-15
})
test('should parse relative date "next friday" (non-recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'next friday', timezone, isRecurring: false })
expect(message).toBeNull()
expect(result).toBeInstanceOf(DateTime)
// chrono-node interprets "next friday" from Mon July 15 as Fri July 26
expect((result as DateTime).toISODate()).toBe('2024-07-26')
})
test('should return null for invalid date string (non-recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'invalid date', timezone, isRecurring: false })
expect(result).toBeNull()
expect(message).toBe('Invalid due date.')
})
// Recurring tests
test('should parse "daily" (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'daily', timezone, isRecurring: true })
expect(message).toBeNull()
expect(result).toBeInstanceOf(RRule)
expect((result as RRule).origOptions.freq).toBe(RRule.DAILY)
})
test('should parse "every week on Monday" (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every week on Monday', timezone, isRecurring: true })
expect(message).toBeNull()
expect(result).toBeInstanceOf(RRule)
expect((result as RRule).origOptions.freq).toBe(RRule.WEEKLY)
// RRule.fromText returns Weekday objects, check the weekday property
const byweekday = (result as RRule).origOptions.byweekday;
const weekdayValues = byweekday
? (Array.isArray(byweekday)
? byweekday.map(d => typeof d === 'number' ? d : (d as Weekday).weekday)
: [typeof byweekday === 'number' ? byweekday : (byweekday as Weekday).weekday])
: [];
expect(weekdayValues).toEqual([RRule.MO.weekday])
})
test('should parse "every month on the 15th" (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every month on the 15th', timezone, isRecurring: true })
expect(message).toBeNull()
expect(result).toBeInstanceOf(RRule)
expect((result as RRule).origOptions.freq).toBe(RRule.MONTHLY)
expect((result as RRule).origOptions.bymonthday).toEqual([15])
})
test('should parse "every year on Jan 1" (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every year on Jan 1', timezone, isRecurring: true })
expect(message).toBeNull()
expect(result).toBeInstanceOf(RRule)
expect((result as RRule).origOptions.freq).toBe(RRule.YEARLY)
// Note: RRule.fromText parses 'Jan 1' into bymonth/bymonthday
expect((result as RRule).origOptions.bymonth).toEqual([1])
// RRule.fromText might not reliably set bymonthday in origOptions for this text
// expect((result as RRule).origOptions.bymonthday).toEqual([1])
})
test('should return validation error for "every week" without day (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every week', timezone, isRecurring: true })
expect(result).toBeNull() // RRule.fromText might parse it, but our validation catches it
expect(message).toBe('Please specify day(s) of the week (e.g., "every week on Mon, Wed").')
})
test('should return validation error for "every month" without day/position (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every month', timezone, isRecurring: true })
expect(result).toBeNull() // RRule.fromText might parse it, but our validation catches it
expect(message).toBe('Please specify day of the month (e.g., "every month on the 15th") or position (e.g., "every month on the last Friday").')
})
test('should return null for invalid recurrence string (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'invalid recurrence', timezone, isRecurring: true })
expect(result).toBeNull()
expect(message).toBe('Invalid recurrence rule.')
})
test('should return specific error for unsupported hourly frequency', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every hour', timezone, isRecurring: true })
expect(result).toBeInstanceOf(RRule) // RRule parses it, but our validation catches it
expect(message).toBe('Hourly frequency is not supported.')
})
test('should return specific error for unsupported daily interval', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every 2 days', timezone, isRecurring: true })
expect(result).toBeInstanceOf(RRule) // RRule parses it, but our validation catches it
expect(message).toBe('Daily frequency with intervals greater than 1 is not supported.')
})
test('should handle predefined constants like "weekdays"', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'weekdays', timezone, isRecurring: true })
expect(message).toBeNull()
expect(result).toBeInstanceOf(RRule)
expect((result as RRule).origOptions.freq).toBe(RRule.WEEKLY)
// Check the weekday property of the Weekday objects
const weekdays = (result as RRule).origOptions.byweekday;
const weekdayNumbers = weekdays
? (Array.isArray(weekdays)
? weekdays.map(d => typeof d === 'number' ? d : (d as Weekday).weekday)
: [typeof weekdays === 'number' ? weekdays : (weekdays as Weekday).weekday])
: [];
expect(weekdayNumbers).toEqual([RRule.MO.weekday, RRule.TU.weekday, RRule.WE.weekday, RRule.TH.weekday, RRule.FR.weekday])
})
})
describe('convertMachineReadableFrequencyToHumanReadable', () => {
const timezone = 'America/New_York'
// Non-recurring tests
test('should format DateTime object (non-recurring)', () => {
const dateTime = DateTime.fromISO('2024-07-16T00:00:00', { zone: timezone }) as DateTime<true>
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: dateTime, isRecurRule: false, timezone })
// Expected format depends on locale, check for key parts
expect(humanReadable).toContain('Jul 16, 2024')
expect(humanReadable).toContain('Tue') // Tuesday
})
test('should format ISO string (non-recurring)', () => {
const isoString = '2024-07-16T00:00:00.000-04:00' // Example ISO string with offset
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: isoString, isRecurRule: false, timezone })
expect(humanReadable).toContain('Jul 16, 2024')
expect(humanReadable).toContain('Tue')
})
test('should return "Initial Due" for null frequency (non-recurring)', () => {
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: null, isRecurRule: false, timezone })
// Check against the imported constant value
expect(humanReadable).toBe(INITIAL_DUE)
})
// Recurring tests
test('should format RRule object (recurring)', () => {
const rrule = new RRule({ freq: RRule.DAILY })
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: rrule, isRecurRule: true, timezone })
// rrule.toText() returns "every day" for daily rules
expect(humanReadable).toBe('every day')
})
test('should format RRule string (recurring)', () => {
const rruleStr = 'FREQ=WEEKLY;BYDAY=MO,WE,FR'
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: rruleStr, isRecurRule: true, timezone })
expect(humanReadable).toBe('every week on Monday, Wednesday, Friday')
})
test('should return "invalid" for invalid RRule string (recurring)', () => {
const rruleStr = 'INVALID_RRULE'
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: rruleStr, isRecurRule: true, timezone })
expect(humanReadable).toBe('invalid')
})
test('should return "invalid" for null frequency (recurring)', () => {
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: null, isRecurRule: true, timezone })
expect(humanReadable).toBe('invalid')
})
test('should return "invalid" for unexpected type (recurring)', () => {
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: 123 as unknown as ParsedResultType, isRecurRule: true, timezone })
expect(humanReadable).toBe('invalid')
})
test('should return "invalid" for unexpected type (non-recurring)', () => {
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: new RRule({ freq: RRule.DAILY }) as unknown as ParsedResultType, isRecurRule: false, timezone })
expect(humanReadable).toBe('invalid')
})
})

View File

@@ -2,8 +2,8 @@ import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import { DateTime, DateTimeFormatOptions } from "luxon" import { DateTime, DateTimeFormatOptions } from "luxon"
import { datetime, RRule } from 'rrule' import { datetime, RRule } from 'rrule'
import { Freq, Habit, CoinTransaction, Permission } from '@/lib/types' import { Freq, Habit, CoinTransaction, Permission, ParsedFrequencyResult, ParsedResultType } from '@/lib/types'
import { DUE_MAP, INITIAL_DUE, INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from "./constants" import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
import * as chrono from 'chrono-node' import * as chrono from 'chrono-node'
import _ from "lodash" import _ from "lodash"
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
@@ -185,67 +185,125 @@ export function calculateTransactionsToday(transactions: CoinTransaction[], time
).length; ).length;
} }
export function getRRuleUTC(recurrenceRule: string) { // Enhanced validation for weekly/monthly rules
return RRule.fromString(recurrenceRule); // this returns UTC function validateRecurrenceRule(rrule: RRule | null): ParsedFrequencyResult {
} if (!rrule) {
return { result: null, message: 'Invalid recurrence rule.' };
export function parseNaturalLanguageRRule(ruleText: string) {
ruleText = ruleText.trim()
let rrule: RRule
if (RECURRENCE_RULE_MAP[ruleText]) {
rrule = RRule.fromString(RECURRENCE_RULE_MAP[ruleText])
} else {
rrule = RRule.fromText(ruleText)
} }
if (isUnsupportedRRule(rrule)) return RRule.fromString('invalid') // return invalid if unsupported const unsupportedReason = getUnsupportedRRuleReason(rrule);
return rrule if (unsupportedReason) {
} return { result: rrule, message: unsupportedReason };
export function parseRRule(ruleText: string) {
ruleText = ruleText.trim()
let rrule: RRule
if (RECURRENCE_RULE_MAP[ruleText]) {
rrule = RRule.fromString(RECURRENCE_RULE_MAP[ruleText])
} else {
rrule = RRule.fromString(ruleText)
} }
if (isUnsupportedRRule(rrule)) return RRule.fromString('invalid') // return invalid if unsupported const options = rrule.origOptions;
return rrule
if (options.freq === RRule.WEEKLY && (!options.byweekday || !Array.isArray(options.byweekday) || options.byweekday.length === 0)) {
return { result: null, message: 'Please specify day(s) of the week (e.g., "every week on Mon, Wed").' };
}
if (options.freq === RRule.MONTHLY &&
(!options.bymonthday || !Array.isArray(options.bymonthday) || options.bymonthday.length === 0) &&
(!options.bysetpos || !Array.isArray(options.bysetpos) || options.bysetpos.length === 0) && // Need to check bysetpos for rules like "last Friday"
(!options.byweekday || !Array.isArray(options.byweekday) || options.byweekday.length === 0)) { // Need byweekday with bysetpos
return { result: null, message: 'Please specify day of the month (e.g., "every month on the 15th") or position (e.g., "every month on the last Friday").' };
}
return { result: rrule, message: null };
} }
export function serializeRRule(rrule: RRule) { // Convert a human-readable frequency (recurring or non-recurring) into a machine-readable one
export function convertHumanReadableFrequencyToMachineReadable({ text, timezone, isRecurring = false }: { text: string, timezone: string, isRecurring?: boolean }): ParsedFrequencyResult {
text = text.trim()
if (!isRecurring) {
if (DUE_MAP[text]) {
text = DUE_MAP[text]
}
const now = getNow({ timezone })
const due = chrono.parseDate(text, { instant: now.toJSDate(), timezone })
if (!due) return { result: null, message: 'Invalid due date.' }
const result = due ? DateTime.fromJSDate(due).setZone(timezone) : null
return { message: null, result: result ? (result.isValid ? result : null) : null }
}
let rrule: RRule | null
if (RECURRENCE_RULE_MAP[text]) {
rrule = deserializeRRule(RECURRENCE_RULE_MAP[text])
} else if (text.toLowerCase() === 'weekdays') {
// Handle 'weekdays' specifically if not in the map
rrule = new RRule({
freq: RRule.WEEKLY,
byweekday: [RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR]
});
} else {
try {
rrule = RRule.fromText(text)
} catch (error) {
rrule = null
}
}
return validateRecurrenceRule(rrule);
}
// convert a machine-readable rrule **string** to an rrule object
export function deserializeRRule(rruleStr: string): RRule | null {
try {
return RRule.fromString(rruleStr);
} catch (error) {
return null;
}
}
// convert a machine-readable rrule **object** to an rrule string
export function serializeRRule(rrule: RRule | null): string {
if (!rrule) return 'invalid'; // Handle null case explicitly
return rrule.toString() return rrule.toString()
} }
export function parseNaturalLanguageDate({ text, timezone }: { text: string, timezone: string }) { // Convert a machine-readable frequency (recurring or non-recurring) into a human-readable one
if (DUE_MAP[text]) { export function convertMachineReadableFrequencyToHumanReadable({
text = DUE_MAP[text] frequency,
} isRecurRule,
const now = getNow({ timezone }) timezone
const due = chrono.parseDate(text, { instant: now.toJSDate(), timezone }) }: {
if (!due) throw Error('invalid rule') frequency: ParsedResultType,
// return d2s({ dateTime: DateTime.fromJSDate(due), timezone, format: DateTime.DATE_MED_WITH_WEEKDAY }) isRecurRule: boolean,
return DateTime.fromJSDate(due).setZone(timezone) timezone: string
} }): string {
export function getFrequencyDisplayText(frequency: string | undefined, isRecurRule: boolean, timezone: string) {
if (isRecurRule) { if (isRecurRule) {
try { if (!frequency) {
return parseRRule((frequency) || INITIAL_RECURRENCE_RULE).toText(); return 'invalid'; // Handle null/undefined for recurring rules
} catch { }
return 'invalid' if (frequency instanceof RRule) {
return frequency.toText();
} else if (typeof frequency === "string") {
const parsedResult = deserializeRRule(frequency);
return parsedResult?.toText() || 'invalid';
} else {
return 'invalid';
} }
} else { } else {
// Handle non-recurring frequency
if (!frequency) { if (!frequency) {
return INITIAL_DUE // Use the imported constant for initial due date text
return INITIAL_DUE;
}
if (typeof frequency === 'string') {
return d2s({
dateTime: t2d({ timestamp: frequency, timezone: timezone }),
timezone: timezone,
format: DateTime.DATE_MED_WITH_WEEKDAY
});
} else if (frequency instanceof DateTime) {
return d2s({
dateTime: frequency,
timezone: timezone,
format: DateTime.DATE_MED_WITH_WEEKDAY
});
} else {
return 'invalid';
} }
return d2s({
dateTime: t2d({ timestamp: frequency, timezone: timezone }),
timezone: timezone,
format: DateTime.DATE_MED_WITH_WEEKDAY
});
} }
} }
@@ -274,13 +332,8 @@ export function isHabitDue({
const endOfDay = date.setZone(timezone).endOf('day') const endOfDay = date.setZone(timezone).endOf('day')
const ruleText = habit.frequency const ruleText = habit.frequency
let rrule const rrule = deserializeRRule(ruleText)
try { if (!rrule) return false
rrule = parseRRule(ruleText)
} catch (error) {
console.error(`Failed to parse rrule for habit: ${habit.id} ${habit.name}`)
return false
}
rrule.origOptions.tzid = timezone rrule.origOptions.tzid = timezone
rrule.options.tzid = rrule.origOptions.tzid rrule.options.tzid = rrule.origOptions.tzid
rrule.origOptions.dtstart = datetime(startOfDay.year, startOfDay.month, startOfDay.day, startOfDay.hour, startOfDay.minute, startOfDay.second) rrule.origOptions.dtstart = datetime(startOfDay.year, startOfDay.month, startOfDay.day, startOfDay.hour, startOfDay.minute, startOfDay.second)
@@ -321,7 +374,7 @@ export function getHabitFreq(habit: Habit): Freq {
// don't support recurring task yet // don't support recurring task yet
return 'daily' return 'daily'
} }
const rrule = parseRRule(habit.frequency) const rrule = RRule.fromString(habit.frequency)
const freq = rrule.origOptions.freq const freq = rrule.origOptions.freq
switch (freq) { switch (freq) {
case RRule.DAILY: return 'daily' case RRule.DAILY: return 'daily'
@@ -335,11 +388,32 @@ export function getHabitFreq(habit: Habit): Freq {
} }
} }
export function isUnsupportedRRule(rrule: RRule): boolean { /**
const freq = rrule.origOptions.freq * Checks if an RRule is unsupported and returns the reason.
return freq === RRule.HOURLY || freq === RRule.MINUTELY || freq === RRule.SECONDLY * @param rrule The RRule object to check.
* @returns A string message explaining why the rule is unsupported, or null if it's supported.
*/
export function getUnsupportedRRuleReason(rrule: RRule): string | null {
const freq = rrule.origOptions.freq;
const interval = rrule.origOptions.interval || 1; // RRule defaults interval to 1
if (freq === RRule.HOURLY) {
return 'Hourly frequency is not supported.';
}
if (freq === RRule.MINUTELY) {
return 'Minutely frequency is not supported.';
}
if (freq === RRule.SECONDLY) {
return 'Secondly frequency is not supported.';
}
if (freq === RRule.DAILY && interval > 1) {
return 'Daily frequency with intervals greater than 1 is not supported.';
}
return null; // Rule is supported
} }
// play sound (client side only, must be run in browser) // play sound (client side only, must be run in browser)
export const playSound = (soundPath: string = '/sounds/timer-end.wav') => { export const playSound = (soundPath: string = '/sounds/timer-end.wav') => {
const audio = new Audio(soundPath) const audio = new Audio(soundPath)
@@ -360,10 +434,10 @@ export const openWindow = (url: string): boolean => {
export function deepMerge<T>(a: T, b: T) { export function deepMerge<T>(a: T, b: T) {
return _.merge(a, b, (x: unknown, y: unknown) => { return _.merge(a, b, (x: unknown, y: unknown) => {
if (_.isArray(a)) { if (_.isArray(a)) {
return a.concat(b) return a.concat(b)
} }
}) })
} }
export function checkPermission( export function checkPermission(

View File

@@ -1,6 +1,6 @@
{ {
"name": "habittrove", "name": "habittrove",
"version": "0.2.5", "version": "0.2.6",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",