diff --git a/server/src/__tests__/frontend-calendar-recurrence.test.ts b/server/src/__tests__/frontend-calendar-recurrence.test.ts new file mode 100644 index 00000000..e578e44d --- /dev/null +++ b/server/src/__tests__/frontend-calendar-recurrence.test.ts @@ -0,0 +1,60 @@ +/** + * Test frontend recurrence math against the exact month-end, leap year, and + * bi-weekly test cases to ensure client calendar views stay in parity with the + * backend dispatcher. + */ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { nextOccurrenceOnOrAfter } from '../../../src/lib/recurrence.js' +import type { RecurrenceRule } from '../../../src/types.js' + +const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + +function series(seedIso: string, rule: RecurrenceRule | null, n: number): string[] { + const seed = new Date(seedIso) + const out: string[] = [] + let after = seed + for (let i = 0; i < n; i++) { + const next = nextOccurrenceOnOrAfter(seed, rule, after) + if (!next) break + out.push(next.toISOString().slice(0, 10)) + after = new Date(next.getTime() + 1000) + } + return out +} + +function weekdays(seedIso: string, rule: RecurrenceRule, n: number): string[] { + return series(seedIso, rule, n).map((d) => `${d} ${DOW[new Date(`${d}T00:00:00Z`).getUTCDay()]}`) +} + +test('frontend recurrence: monthly on the 31st clamps into short months and comes back', () => { + assert.deepEqual( + series('2026-01-31T09:00:00Z', { freq: 'monthly', interval: 1 }, 7), + ['2026-01-31', '2026-02-28', '2026-03-31', '2026-04-30', '2026-05-31', '2026-06-30', '2026-07-31'], + ) +}) + +test('frontend recurrence: yearly on Feb 29 returns at the next leap year', () => { + assert.deepEqual( + series('2028-02-29T09:00:00Z', { freq: 'yearly', interval: 1 }, 6), + ['2028-02-29', '2029-02-28', '2030-02-28', '2031-02-28', '2032-02-29', '2033-02-28'], + ) +}) + +test('frontend recurrence: every-2-weeks on Mon and Wed keeps both days in the same week', () => { + assert.deepEqual( + weekdays('2026-01-05T09:00:00Z', { freq: 'weekly', interval: 2, byweekday: [1, 3] }, 6), + [ + '2026-01-05 Mon', '2026-01-07 Wed', + '2026-01-19 Mon', '2026-01-21 Wed', + '2026-02-02 Mon', '2026-02-04 Wed', + ], + ) +}) + +test('frontend recurrence: non-recurring event fires once then terminates', () => { + const seed = new Date('2026-04-14T09:00:00Z') + assert.deepEqual(nextOccurrenceOnOrAfter(seed, null, seed), seed) + assert.equal(nextOccurrenceOnOrAfter(seed, null, new Date(seed.getTime() + 1000)), null) +}) + diff --git a/src/desktop/CalendarView.tsx b/src/desktop/CalendarView.tsx index 46f8cad1..dbfcff77 100644 --- a/src/desktop/CalendarView.tsx +++ b/src/desktop/CalendarView.tsx @@ -26,6 +26,7 @@ import { IPlus, ICalendar, IClock, IRepeat, ITrash } from '@/components/icons' import { EventEditor, type EventEditorPrefill } from '@/components/EventEditor' import { useT } from '@/lib/i18n' import { cn } from '@/lib/utils' +import { nextOccurrenceOnOrAfter } from '@/lib/recurrence' import type { CalendarEvent, RecurrenceRule } from '@/types' interface AgendaItem { @@ -77,47 +78,9 @@ function addDays(d: Date, n: number): Date { const out = new Date(d); out.setDat /* ─────────────────────────── recurrence (client mirror) ─────────────────────────── */ -function stepRule(from: Date, rule: RecurrenceRule): Date { - const interval = Math.max(1, Math.floor(rule.interval || 1)) - switch (rule.freq) { - case 'daily': - return new Date(from.getTime() + interval * DAY_MS) - case 'weekly': { - const days = rule.byweekday && rule.byweekday.length ? [...rule.byweekday].sort((a, b) => a - b) : null - if (!days) return new Date(from.getTime() + interval * 7 * DAY_MS) - let cand = new Date(from.getTime() + ((interval - 1) * 7 + 1) * DAY_MS) - for (let i = 0; i < 14; i++) { - if (days.includes(cand.getDay())) return cand - cand = new Date(cand.getTime() + DAY_MS) - } - return cand - } - case 'monthly': { - const out = new Date(from); out.setMonth(out.getMonth() + interval); return out - } - case 'yearly': { - const out = new Date(from); out.setFullYear(out.getFullYear() + interval); return out - } - } -} - function nextOccurrence(event: CalendarEvent, from: Date): Date | null { - const startAt = new Date(event.startAt) if (event.status !== 'active') return null - if (!event.recurrence) return startAt.getTime() >= from.getTime() ? startAt : null - const rule = event.recurrence - const untilTs = rule.until ? new Date(rule.until).getTime() : Infinity - const maxCount = rule.count ?? Infinity - let current = new Date(startAt) - let fired = 1 - for (let i = 0; i < 5000; i++) { - if (current.getTime() > untilTs) return null - if (fired > maxCount) return null - if (current.getTime() >= from.getTime()) return current - current = stepRule(current, rule) - fired += 1 - } - return null + return nextOccurrenceOnOrAfter(new Date(event.startAt), event.recurrence ?? null, from) } function describeRecurrence(r: RecurrenceRule | null, t: ReturnType): string { diff --git a/src/lib/recurrence.ts b/src/lib/recurrence.ts new file mode 100644 index 00000000..5e222ba3 --- /dev/null +++ b/src/lib/recurrence.ts @@ -0,0 +1,109 @@ +/** + * Pure recurrence math for frontend calendar views. + * + * Mirrors the indexed recurrence calculations from server/src/recurrence.ts + * so desktop and mobile calendar views display the exact same dates where the + * backend dispatcher fires them. + * + * Every occurrence is computed FROM THE SEED by index rather than stepping + * from the previous occurrence. Stepping caused unrepresentable dates + * (e.g. Jan 31 + 1 month overflowing to March 3 in setUTCMonth) to permanently + * displace all future occurrences. + */ +import type { RecurrenceRule } from '../types.js' + +export type { RecurrenceRule } + +/** Add N days to a Date without mutating the input. */ +function addDays(d: Date, n: number): Date { + const out = new Date(d.getTime()) + out.setUTCDate(out.getUTCDate() + n) + return out +} + +/** + * `seed` shifted by N months, with the day-of-month CLAMPED into the target + * month rather than overflowing into the next one. + * + * `setUTCMonth` overflows: a Jan 31 date asked for February becomes March 3. + * Anchoring on the seed and clamping keeps Jan 31 -> Feb 28 -> Mar 31. + */ +function addMonthsClamped(seed: Date, n: number): Date { + const day = seed.getUTCDate() + const target = new Date(Date.UTC( + seed.getUTCFullYear(), seed.getUTCMonth() + n, 1, + seed.getUTCHours(), seed.getUTCMinutes(), seed.getUTCSeconds(), seed.getUTCMilliseconds(), + )) + const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate() + target.setUTCDate(Math.min(day, lastDay)) + return target +} + +/** + * The weekdays a weekly rule fires on, or null when it should just use the + * seed's own weekday. Sorted and de-duplicated so the ordering below is + * stable regardless of how the caller wrote the rule. + */ +function weeklyDays(rule: RecurrenceRule): number[] | null { + const raw = rule.byweekday + if (!raw || raw.length === 0) return null + const days = [...new Set(raw.filter((d): d is number => Number.isInteger(d) && d >= 0 && d <= 6))].sort((a, b) => a - b) + return days.length > 0 ? days : null +} + +/** + * The `index`-th occurrence of a rule, counted from the seed (index 0 IS the + * seed). Every case is computed FROM THE SEED rather than from the previous + * occurrence, keeping unrepresentable dates from permanently displacing the series. + */ +function occurrenceAt(seed: Date, rule: RecurrenceRule, index: number): Date { + const interval = Math.max(1, Math.floor(rule.interval || 1)) + switch (rule.freq) { + case 'daily': + return addDays(seed, index * interval) + case 'weekly': { + const days = weeklyDays(rule) + if (!days) return addDays(seed, index * interval * 7) + const seedDow = seed.getUTCDay() + const firstWeek = days.filter((d) => d >= seedDow) + if (index < firstWeek.length) return addDays(seed, firstWeek[index] - seedDow) + const rest = index - firstWeek.length + const week = Math.floor(rest / days.length) + 1 + const dow = days[rest % days.length] + const weekStart = addDays(seed, -seedDow) + return addDays(weekStart, week * interval * 7 + dow) + } + case 'monthly': + return addMonthsClamped(seed, index * interval) + case 'yearly': + return addMonthsClamped(seed, index * interval * 12) + default: + return seed + } +} + +/** + * Compute the next firing time strictly >= `after`, walking forward from + * the event's seed `startAt`. Returns null if: + * - the series has no recurrence and startAt < after (already fired) + * - rule.until is earlier than the next computed slot + * - rule.count is exhausted + */ +export function nextOccurrenceOnOrAfter( + startAt: Date, + recurrence: RecurrenceRule | null, + after: Date, +): Date | null { + if (!recurrence) { + return startAt.getTime() >= after.getTime() ? startAt : null + } + const untilTs = recurrence.until ? new Date(recurrence.until).getTime() : Infinity + const maxCount = recurrence.count ?? Infinity + for (let index = 0; index < 5000; index++) { + if (index + 1 > maxCount) return null + const current = occurrenceAt(startAt, recurrence, index) + if (current.getTime() > untilTs) return null + if (current.getTime() >= after.getTime()) return current + } + return null +} diff --git a/src/mobile/MobileCalendar.tsx b/src/mobile/MobileCalendar.tsx index 04dc47ab..21402ef0 100644 --- a/src/mobile/MobileCalendar.tsx +++ b/src/mobile/MobileCalendar.tsx @@ -17,7 +17,8 @@ import { ICalendar, IClock, IRepeat } from '@/components/icons' import { tapHaptic } from '@/lib/native' import { useT } from '@/lib/i18n' import { cn } from '@/lib/utils' -import type { CalendarEvent, RecurrenceRule } from '@/types' +import { nextOccurrenceOnOrAfter } from '@/lib/recurrence' +import type { CalendarEvent } from '@/types' const DAY_MS = 86_400_000 const WEEK = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] @@ -42,47 +43,9 @@ function formatTime(d: Date): string { return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` } -function stepRule(from: Date, rule: RecurrenceRule): Date { - const interval = Math.max(1, Math.floor(rule.interval || 1)) - switch (rule.freq) { - case 'daily': - return new Date(from.getTime() + interval * DAY_MS) - case 'weekly': { - const days = rule.byweekday && rule.byweekday.length ? [...rule.byweekday].sort((a, b) => a - b) : null - if (!days) return new Date(from.getTime() + interval * 7 * DAY_MS) - let cand = new Date(from.getTime() + ((interval - 1) * 7 + 1) * DAY_MS) - for (let i = 0; i < 14; i++) { - if (days.includes(cand.getDay())) return cand - cand = new Date(cand.getTime() + DAY_MS) - } - return cand - } - case 'monthly': { - const out = new Date(from); out.setMonth(out.getMonth() + interval); return out - } - case 'yearly': { - const out = new Date(from); out.setFullYear(out.getFullYear() + interval); return out - } - } -} - function nextOccurrence(event: CalendarEvent, from: Date): Date | null { - const startAt = new Date(event.startAt) if (event.status !== 'active') return null - if (!event.recurrence) return startAt.getTime() >= from.getTime() ? startAt : null - const rule = event.recurrence - const untilTs = rule.until ? new Date(rule.until).getTime() : Infinity - const maxCount = rule.count ?? Infinity - let current = new Date(startAt) - let fired = 1 - for (let i = 0; i < 5000; i++) { - if (current.getTime() > untilTs) return null - if (fired > maxCount) return null - if (current.getTime() >= from.getTime()) return current - current = stepRule(current, rule) - fired += 1 - } - return null + return nextOccurrenceOnOrAfter(new Date(event.startAt), event.recurrence ?? null, from) } function expandToRange(events: CalendarEvent[], start: Date, end: Date): AgendaItem[] {