diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b5b5692..1e89d77 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -52,13 +52,12 @@ jobs: push: true tags: | ${{ 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:dev + dohsimpson/habittrove:demo deploy-demo: runs-on: ubuntu-latest needs: build-and-push - # demo tracks the latest tag + # demo tracks the demo tag if: needs.build-and-push.outputs.EXISTS == 'false' steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 301f1b5..250920c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # 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 ### Changed diff --git a/Dockerfile b/Dockerfile index f19cb26..afe8e5e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ # 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 -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. RUN apk add --no-cache libc6-compat WORKDIR /app @@ -19,7 +19,7 @@ RUN \ fi # Rebuild the source code only when needed -FROM --platform=$BUILDPLATFORM base AS builder +FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . diff --git a/components/AddEditHabitModal.tsx b/components/AddEditHabitModal.tsx index d407d2d..d44c887 100644 --- a/components/AddEditHabitModal.tsx +++ b/components/AddEditHabitModal.tsx @@ -16,7 +16,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import data from '@emoji-mart/data' import Picker from '@emoji-mart/react' 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 * as chrono from 'chrono-node'; 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 [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1) const isRecurRule = !isTask - const origRuleText = getFrequencyDisplayText(habit?.frequency, isRecurRule, settings.system.timezone) - const [ruleText, setRuleText] = useState(origRuleText) + // Initialize ruleText with the actual frequency string or default, not the display text + const initialRuleText = habit?.frequency ? convertMachineReadableFrequencyToHumanReadable({ + frequency: habit.frequency, + isRecurRule, + timezone: settings.system.timezone + }) : (isRecurRule ? INITIAL_RECURRENCE_RULE : INITIAL_DUE); + const [ruleText, setRuleText] = useState(initialRuleText) const { currentUser } = useHelpers() const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false) + const [ruleError, setRuleError] = useState(null); // State for validation message const [selectedUserIds, setSelectedUserIds] = useState((habit?.userIds || []).filter(id => id !== currentUser?.id)) const [usersData] = useAtom(usersAtom) const users = usersData.users function getFrequencyUpdate() { - if (ruleText === origRuleText && habit?.frequency) { - return habit.frequency + if (ruleText === initialRuleText && habit?.frequency) { + // If text hasn't changed and original frequency exists, return it + return habit.frequency; } - if (isRecurRule) { - const parsedRule = parseNaturalLanguageRRule(ruleText) - return serializeRRule(parsedRule) + + const parsedResult = convertHumanReadableFrequencyToMachineReadable({ + 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 { - const parsedDate = parseNaturalLanguageDate({ - text: ruleText, - timezone: settings.system.timezone - }) - return d2t({ - dateTime: parsedDate, - timezone: settings.system.timezone - }) + return 'invalid'; } } @@ -146,6 +157,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad + {/* date input (task) */}
-
- - {(() => { - try { - return isRecurRule ? parseNaturalLanguageRRule(ruleText).toText() : d2s({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }), timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY }) - } catch (e: unknown) { - return `Invalid rule: ${e instanceof Error ? e.message : 'Invalid recurrence rule'}` - } - })()} - + {/* rrule input (habit) */} +
+ {(() => { + let displayText = ''; + let errorMessage: string | null = null; + const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule }); + errorMessage = message; + displayText = convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone }) + + return ( + <> + + {displayText} + + {errorMessage && ( +

{errorMessage}

+ )} + + ); + })()}
diff --git a/components/HabitItem.tsx b/components/HabitItem.tsx index 67c194e..81856af 100644 --- a/components/HabitItem.tsx +++ b/components/HabitItem.tsx @@ -1,7 +1,7 @@ import { Habit, SafeUser, User, Permission } from '@/lib/types' import { useAtom } from 'jotai' 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 { Button } from '@/components/ui/button' 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) {

- When: {getFrequencyDisplayText(habit.frequency, isRecurRule, settings.system.timezone)} + When: {convertMachineReadableFrequencyToHumanReadable({ + frequency: habit.frequency, + isRecurRule, + timezone: settings.system.timezone + })}

diff --git a/components/HabitList.tsx b/components/HabitList.tsx index 07a60a7..e6e187d 100644 --- a/components/HabitList.tsx +++ b/components/HabitList.tsx @@ -41,17 +41,22 @@ export default function HabitList() { return (
-
-

- {isTasksView ? 'My Tasks' : 'My Habits'} -

- -
-
- -
+
+

+ {isTasksView ? 'My Tasks' : 'My Habits'} +

+ + + + +
+
+ +
{activeHabits.length === 0 ? (
@@ -62,19 +67,19 @@ export default function HabitList() { />
) : ( - activeHabits.map((habit: Habit) => ( - { - setEditingHabit(habit) - setModalConfig({ isOpen: true, isTask: isTasksView }) - }} - onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })} - /> - )) - )} - + activeHabits.map((habit: Habit) => ( + { + setEditingHabit(habit) + setModalConfig({ isOpen: true, isTask: isTasksView }) + }} + onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })} + /> + )) + )} + {archivedHabits.length > 0 && ( <>
diff --git a/components/UserSelectModal.tsx b/components/UserSelectModal.tsx index a5ca17f..a841449 100644 --- a/components/UserSelectModal.tsx +++ b/components/UserSelectModal.tsx @@ -99,7 +99,7 @@ function UserSelectionView({ onCreateUser: () => void }) { return ( -
+
{users .filter(user => user.id !== currentUser?.id) .map((user) => ( diff --git a/lib/constants.ts b/lib/constants.ts index 71f0960..cd6d9af 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1,6 +1,6 @@ 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 RECURRENCE_RULE_MAP: { [key: string]: string } = { diff --git a/lib/types.ts b/lib/types.ts index 62c1a64..d4f2bbb 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,4 +1,6 @@ +import { RRule } from "rrule" import { uuid } from "./utils" +import { DateTime } from "luxon" export type UserId = string @@ -187,4 +189,12 @@ export interface JotaiHydrateInitialValues { export interface ServerSettings { isDemo: boolean +} + +export type ParsedResultType = DateTime | 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 } \ No newline at end of file diff --git a/lib/utils.test.ts b/lib/utils.test.ts index 8199c1f..3077e3c 100644 --- a/lib/utils.test.ts +++ b/lib/utils.test.ts @@ -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 { cn, getTodayInTimezone, @@ -17,12 +17,18 @@ import { isHabitDueToday, isHabitDue, uuid, - isTaskOverdue + isTaskOverdue, + deserializeRRule, + serializeRRule, + convertHumanReadableFrequencyToMachineReadable, + convertMachineReadableFrequencyToHumanReadable, + getUnsupportedRRuleReason } from './utils' -import { CoinTransaction } from './types' +import { CoinTransaction, ParsedResultType } from './types' import { DateTime } from "luxon"; -import { RRule } from 'rrule'; +import { RRule, Weekday } from 'rrule'; import { Habit } from '@/lib/types'; +import { INITIAL_DUE } from './constants'; describe('cn utility', () => { 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', () => { const createTestHabit = (frequency: string, isTask = true, archived = false): Habit => ({ id: 'test-habit', @@ -652,3 +711,248 @@ describe('isHabitDue', () => { 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 + 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 + 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') + }) +}) diff --git a/lib/utils.ts b/lib/utils.ts index 126da57..2159436 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -2,8 +2,8 @@ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" import { DateTime, DateTimeFormatOptions } from "luxon" import { datetime, RRule } from 'rrule' -import { Freq, Habit, CoinTransaction, Permission } from '@/lib/types' -import { DUE_MAP, INITIAL_DUE, INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from "./constants" +import { Freq, Habit, CoinTransaction, Permission, ParsedFrequencyResult, ParsedResultType } from '@/lib/types' +import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants" import * as chrono from 'chrono-node' import _ from "lodash" import { v4 as uuidv4 } from 'uuid' @@ -185,67 +185,125 @@ export function calculateTransactionsToday(transactions: CoinTransaction[], time ).length; } -export function getRRuleUTC(recurrenceRule: string) { - return RRule.fromString(recurrenceRule); // this returns UTC -} - -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) +// Enhanced validation for weekly/monthly rules +function validateRecurrenceRule(rrule: RRule | null): ParsedFrequencyResult { + if (!rrule) { + return { result: null, message: 'Invalid recurrence rule.' }; } - if (isUnsupportedRRule(rrule)) return RRule.fromString('invalid') // return invalid if unsupported - return rrule -} - -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) + const unsupportedReason = getUnsupportedRRuleReason(rrule); + if (unsupportedReason) { + return { result: rrule, message: unsupportedReason }; } - if (isUnsupportedRRule(rrule)) return RRule.fromString('invalid') // return invalid if unsupported - return rrule + const options = rrule.origOptions; + + 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() } -export function parseNaturalLanguageDate({ text, timezone }: { text: string, timezone: string }) { - if (DUE_MAP[text]) { - text = DUE_MAP[text] - } - const now = getNow({ timezone }) - const due = chrono.parseDate(text, { instant: now.toJSDate(), timezone }) - if (!due) throw Error('invalid rule') - // return d2s({ dateTime: DateTime.fromJSDate(due), timezone, format: DateTime.DATE_MED_WITH_WEEKDAY }) - return DateTime.fromJSDate(due).setZone(timezone) -} - -export function getFrequencyDisplayText(frequency: string | undefined, isRecurRule: boolean, timezone: string) { +// Convert a machine-readable frequency (recurring or non-recurring) into a human-readable one +export function convertMachineReadableFrequencyToHumanReadable({ + frequency, + isRecurRule, + timezone +}: { + frequency: ParsedResultType, + isRecurRule: boolean, + timezone: string +}): string { if (isRecurRule) { - try { - return parseRRule((frequency) || INITIAL_RECURRENCE_RULE).toText(); - } catch { - return 'invalid' + if (!frequency) { + return 'invalid'; // Handle null/undefined for recurring rules + } + if (frequency instanceof RRule) { + return frequency.toText(); + } else if (typeof frequency === "string") { + const parsedResult = deserializeRRule(frequency); + return parsedResult?.toText() || 'invalid'; + } else { + return 'invalid'; } } else { + // Handle non-recurring 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 ruleText = habit.frequency - let rrule - try { - rrule = parseRRule(ruleText) - } catch (error) { - console.error(`Failed to parse rrule for habit: ${habit.id} ${habit.name}`) - return false - } + const rrule = deserializeRRule(ruleText) + if (!rrule) return false rrule.origOptions.tzid = timezone rrule.options.tzid = rrule.origOptions.tzid 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 return 'daily' } - const rrule = parseRRule(habit.frequency) + const rrule = RRule.fromString(habit.frequency) const freq = rrule.origOptions.freq switch (freq) { 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 - return freq === RRule.HOURLY || freq === RRule.MINUTELY || freq === RRule.SECONDLY +/** + * Checks if an RRule is unsupported and returns the reason. + * @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) export const playSound = (soundPath: string = '/sounds/timer-end.wav') => { const audio = new Audio(soundPath) @@ -360,10 +434,10 @@ export const openWindow = (url: string): boolean => { export function deepMerge(a: T, b: T) { return _.merge(a, b, (x: unknown, y: unknown) => { - if (_.isArray(a)) { - return a.concat(b) - } - }) + if (_.isArray(a)) { + return a.concat(b) + } + }) } export function checkPermission( @@ -372,7 +446,7 @@ export function checkPermission( action: 'write' | 'interact' ): boolean { if (!permissions) return false - + return permissions.some(permission => { switch (resource) { case 'habit': diff --git a/package.json b/package.json index a06de0c..544ec31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "habittrove", - "version": "0.2.5", + "version": "0.2.6", "private": true, "scripts": { "dev": "next dev --turbopack",