diff --git a/lib/__tests__/calendar-event-normalization.test.ts b/lib/__tests__/calendar-event-normalization.test.ts index 04c60f08..846e7562 100644 --- a/lib/__tests__/calendar-event-normalization.test.ts +++ b/lib/__tests__/calendar-event-normalization.test.ts @@ -45,4 +45,43 @@ describe('calendar event normalization', () => { showWithoutTime: true, }); }); + + describe('recurrenceRule normalization (JSCalendar 2.0 singular→plural)', () => { + it('wraps a single recurrenceRule object in an array', () => { + const raw = { + ...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }), + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' }, + } as Record; + const result = normalizeCalendarEventLike(raw as Partial); + expect(result.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'weekly' }]); + expect((result as Record).recurrenceRule).toBeUndefined(); + }); + + it('passes through recurrenceRule when already an array', () => { + const raw = { + ...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }), + recurrenceRule: [{ '@type': 'RecurrenceRule', frequency: 'daily' }], + } as Record; + const result = normalizeCalendarEventLike(raw as Partial); + expect(result.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'daily' }]); + }); + + it('passes through null recurrenceRule as-is', () => { + const raw = { + ...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }), + recurrenceRule: null, + } as Record; + const result = normalizeCalendarEventLike(raw as Partial); + expect(result.recurrenceRules).toBeNull(); + }); + + it('wraps a single excludedRecurrenceRule object in an array', () => { + const raw = { + ...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }), + excludedRecurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily' }, + } as Record; + const result = normalizeCalendarEventLike(raw as Partial); + expect(result.excludedRecurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'daily' }]); + }); + }); }); \ No newline at end of file diff --git a/lib/__tests__/recurrence-expansion.test.ts b/lib/__tests__/recurrence-expansion.test.ts new file mode 100644 index 00000000..ff052e54 --- /dev/null +++ b/lib/__tests__/recurrence-expansion.test.ts @@ -0,0 +1,379 @@ +import { describe, it, expect } from 'vitest'; +import { expandRecurringEvents } from '../recurrence-expansion'; +import type { CalendarEvent } from '@/lib/jmap/types'; + +/** Helper: create a minimal CalendarEvent for testing recurrence */ +function makeEvent(overrides: Partial = {}): CalendarEvent { + return { + id: 'evt1', + uid: 'uid1', + calendarIds: { cal1: true }, + start: '2025-01-06T09:00:00', // Monday + duration: 'PT1H', + title: 'Test Event', + showWithoutTime: false, + recurrenceRules: null, + recurrenceOverrides: null, + excludedRecurrenceRules: null, + ...overrides, + } as CalendarEvent; +} + +function expand(event: CalendarEvent, rangeStart: string, rangeEnd: string) { + return expandRecurringEvents([event], rangeStart, rangeEnd); +} + +function starts(events: CalendarEvent[]) { + return events.map(e => e.start); +} + +describe('expandRecurringEvents', () => { + it('passes through non-recurring events unchanged', () => { + const event = makeEvent(); + const result = expand(event, '2025-01-01T00:00:00', '2025-02-01T00:00:00'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('evt1'); + }); + + // ----------------------------------------------------------------------- + // Daily + // ----------------------------------------------------------------------- + describe('daily frequency', () => { + it('expands daily events within range', () => { + const event = makeEvent({ + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any], + }); + const result = expand(event, '2025-01-06T00:00:00', '2025-01-09T00:00:00'); + expect(starts(result)).toEqual([ + '2025-01-06T09:00:00', + '2025-01-07T09:00:00', + '2025-01-08T09:00:00', + ]); + }); + + it('respects interval', () => { + const event = makeEvent({ + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', interval: 2 } as any], + }); + const result = expand(event, '2025-01-06T00:00:00', '2025-01-12T00:00:00'); + expect(starts(result)).toEqual([ + '2025-01-06T09:00:00', + '2025-01-08T09:00:00', + '2025-01-10T09:00:00', + ]); + }); + + it('respects count', () => { + const event = makeEvent({ + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', count: 3 } as any], + }); + const result = expand(event, '2025-01-06T00:00:00', '2025-12-31T00:00:00'); + expect(result).toHaveLength(3); + }); + + it('respects until', () => { + const event = makeEvent({ + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', until: '2025-01-08T09:00:00' } as any], + }); + const result = expand(event, '2025-01-06T00:00:00', '2025-12-31T00:00:00'); + expect(result).toHaveLength(3); + }); + }); + + // ----------------------------------------------------------------------- + // Weekly + // ----------------------------------------------------------------------- + describe('weekly frequency', () => { + it('expands weekly with implicit byDay (same weekday as start)', () => { + const event = makeEvent({ + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any], + }); + // Jan 6 is Monday, so every Monday + const result = expand(event, '2025-01-06T00:00:00', '2025-01-28T00:00:00'); + expect(starts(result)).toEqual([ + '2025-01-06T09:00:00', + '2025-01-13T09:00:00', + '2025-01-20T09:00:00', + '2025-01-27T09:00:00', + ]); + }); + + it('expands weekly with explicit byDay (MWF)', () => { + const event = makeEvent({ + recurrenceRules: [{ + '@type': 'RecurrenceRule', + frequency: 'weekly', + byDay: [{ day: 'mo' }, { day: 'we' }, { day: 'fr' }], + } as any], + }); + const result = expand(event, '2025-01-06T00:00:00', '2025-01-13T00:00:00'); + expect(starts(result)).toEqual([ + '2025-01-06T09:00:00', + '2025-01-08T09:00:00', + '2025-01-10T09:00:00', + ]); + }); + + it('expands weekly with interval=2', () => { + const event = makeEvent({ + recurrenceRules: [{ + '@type': 'RecurrenceRule', + frequency: 'weekly', + interval: 2, + byDay: [{ day: 'mo' }], + } as any], + }); + const result = expand(event, '2025-01-06T00:00:00', '2025-02-03T00:00:00'); + expect(starts(result)).toEqual([ + '2025-01-06T09:00:00', + '2025-01-20T09:00:00', + ]); + }); + }); + + // ----------------------------------------------------------------------- + // Monthly + // ----------------------------------------------------------------------- + describe('monthly frequency', () => { + it('expands monthly with implicit byMonthDay', () => { + const event = makeEvent({ + start: '2025-01-15T10:00:00', + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'monthly' } as any], + }); + const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00'); + expect(starts(result)).toEqual([ + '2025-01-15T10:00:00', + '2025-02-15T10:00:00', + '2025-03-15T10:00:00', + ]); + }); + + it('expands monthly with byMonthDay', () => { + const event = makeEvent({ + start: '2025-01-01T08:00:00', + recurrenceRules: [{ + '@type': 'RecurrenceRule', + frequency: 'monthly', + byMonthDay: [1, 15], + } as any], + }); + const result = expand(event, '2025-01-01T00:00:00', '2025-02-28T00:00:00'); + expect(starts(result)).toEqual([ + '2025-01-01T08:00:00', + '2025-01-15T08:00:00', + '2025-02-01T08:00:00', + '2025-02-15T08:00:00', + ]); + }); + + it('expands monthly with negative byMonthDay (-1 = last day)', () => { + const event = makeEvent({ + start: '2025-01-31T08:00:00', + recurrenceRules: [{ + '@type': 'RecurrenceRule', + frequency: 'monthly', + byMonthDay: [-1], + } as any], + }); + const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00'); + const days = result.map(e => e.start.substring(0, 10)); + expect(days).toEqual(['2025-01-31', '2025-02-28', '2025-03-31']); + }); + + it('expands monthly with byDay + nthOfPeriod (2nd Tuesday)', () => { + const event = makeEvent({ + start: '2025-01-14T09:00:00', // 2nd Tuesday + recurrenceRules: [{ + '@type': 'RecurrenceRule', + frequency: 'monthly', + byDay: [{ day: 'tu', nthOfPeriod: 2 }], + } as any], + }); + const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00'); + const days = result.map(e => e.start.substring(0, 10)); + expect(days).toEqual(['2025-01-14', '2025-02-11', '2025-03-11']); + }); + + it('expands monthly with byDay nthOfPeriod=-1 (last Friday)', () => { + const event = makeEvent({ + start: '2025-01-31T09:00:00', // last Friday of Jan + recurrenceRules: [{ + '@type': 'RecurrenceRule', + frequency: 'monthly', + byDay: [{ day: 'fr', nthOfPeriod: -1 }], + } as any], + }); + const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00'); + const days = result.map(e => e.start.substring(0, 10)); + expect(days).toEqual(['2025-01-31', '2025-02-28', '2025-03-28']); + }); + }); + + // ----------------------------------------------------------------------- + // Yearly + // ----------------------------------------------------------------------- + describe('yearly frequency', () => { + it('expands yearly on the same date', () => { + const event = makeEvent({ + start: '2023-03-15T12:00:00', + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'yearly' } as any], + }); + const result = expand(event, '2023-01-01T00:00:00', '2026-01-01T00:00:00'); + expect(starts(result)).toEqual([ + '2023-03-15T12:00:00', + '2024-03-15T12:00:00', + '2025-03-15T12:00:00', + ]); + }); + + it('expands yearly with byMonth and byDay (last Friday of November = Thanksgiving-ish)', () => { + const event = makeEvent({ + start: '2025-11-28T09:00:00', // last Friday of Nov 2025 + recurrenceRules: [{ + '@type': 'RecurrenceRule', + frequency: 'yearly', + byMonth: ['11'], + byDay: [{ day: 'fr', nthOfPeriod: -1 }], + } as any], + }); + const result = expand(event, '2025-01-01T00:00:00', '2028-01-01T00:00:00'); + const days = result.map(e => e.start.substring(0, 10)); + // Last Friday of November: 2025-11-28, 2026-11-27, 2027-11-26 + expect(days).toEqual(['2025-11-28', '2026-11-27', '2027-11-26']); + }); + + it('expands yearly with byMonth + byMonthDay', () => { + const event = makeEvent({ + start: '2025-07-04T00:00:00', + showWithoutTime: true, + recurrenceRules: [{ + '@type': 'RecurrenceRule', + frequency: 'yearly', + byMonth: ['7'], + byMonthDay: [4], + } as any], + }); + const result = expand(event, '2025-01-01T00:00:00', '2028-01-01T00:00:00'); + expect(result).toHaveLength(3); + }); + }); + + // ----------------------------------------------------------------------- + // bySetPosition + // ----------------------------------------------------------------------- + describe('bySetPosition', () => { + it('selects first and last from monthly byDay expansion', () => { + const event = makeEvent({ + start: '2025-01-06T10:00:00', + recurrenceRules: [{ + '@type': 'RecurrenceRule', + frequency: 'monthly', + byDay: [{ day: 'mo' }, { day: 'tu' }, { day: 'we' }, { day: 'th' }, { day: 'fr' }], + bySetPosition: [1, -1], // first and last weekday of month + } as any], + }); + const result = expand(event, '2025-01-01T00:00:00', '2025-03-01T00:00:00'); + const days = result.map(e => e.start.substring(0, 10)); + // Jan: first weekday = Jan 1 (Wed), last weekday = Jan 31 (Fri) + // Feb: first weekday = Feb 3 (Mon), last weekday = Feb 28 (Fri) + // But event starts Jan 6, so Jan 1 is before start → filtered out + expect(days).toContain('2025-01-31'); + expect(days).toContain('2025-02-03'); + expect(days).toContain('2025-02-28'); + }); + }); + + // ----------------------------------------------------------------------- + // Recurrence overrides + // ----------------------------------------------------------------------- + describe('recurrenceOverrides', () => { + it('applies overrides to matching occurrences', () => { + const event = makeEvent({ + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any], + recurrenceOverrides: { + '2025-01-07T09:00:00': { title: 'Modified' }, + }, + }); + const result = expand(event, '2025-01-06T00:00:00', '2025-01-09T00:00:00'); + const modified = result.find(e => e.recurrenceId === '2025-01-07T09:00:00'); + expect(modified?.title).toBe('Modified'); + }); + + it('excludes occurrences marked as excluded', () => { + const event = makeEvent({ + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any], + recurrenceOverrides: { + '2025-01-07T09:00:00': { excluded: true } as any, + }, + }); + const result = expand(event, '2025-01-06T00:00:00', '2025-01-09T00:00:00'); + expect(result).toHaveLength(2); + expect(starts(result)).toEqual(['2025-01-06T09:00:00', '2025-01-08T09:00:00']); + }); + + it('adds RDATE-style overrides not generated by rules', () => { + const event = makeEvent({ + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any], + recurrenceOverrides: { + '2025-01-08T09:00:00': { title: 'Extra Wednesday' }, // Not a Monday + }, + }); + const result = expand(event, '2025-01-06T00:00:00', '2025-01-14T00:00:00'); + expect(result.some(e => e.recurrenceId === '2025-01-08T09:00:00')).toBe(true); + }); + }); + + // ----------------------------------------------------------------------- + // All-day events + // ----------------------------------------------------------------------- + describe('all-day events', () => { + it('expands all-day weekly events', () => { + const event = makeEvent({ + start: '2025-01-06T00:00:00', + showWithoutTime: true, + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any], + }); + const result = expand(event, '2025-01-06T00:00:00', '2025-01-28T00:00:00'); + expect(result).toHaveLength(4); // 4 Mondays: 6, 13, 20, 27 + }); + }); + + // ----------------------------------------------------------------------- + // Edge cases + // ----------------------------------------------------------------------- + describe('edge cases', () => { + it('does not exceed 500 occurrences', () => { + const event = makeEvent({ + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any], + }); + const result = expand(event, '2025-01-01T00:00:00', '2030-01-01T00:00:00'); + expect(result.length).toBeLessThanOrEqual(500); + }); + + it('handles invalid start date gracefully', () => { + const event = makeEvent({ + start: 'invalid', + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any], + }); + const result = expand(event, '2025-01-01T00:00:00', '2025-02-01T00:00:00'); + expect(result).toHaveLength(0); + }); + + it('generates synthetic IDs for occurrences', () => { + const event = makeEvent({ + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any], + }); + const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00'); + expect(result[0].id).toBe('evt1:2025-01-06T09:00:00'); + expect(result[1].id).toBe('evt1:2025-01-07T09:00:00'); + }); + + it('preserves originalId pointing to master', () => { + const event = makeEvent({ + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any], + }); + const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00'); + expect(result[0].originalId).toBe('evt1'); + }); + }); +}); diff --git a/lib/calendar-event-normalization.ts b/lib/calendar-event-normalization.ts index 3ed03758..7dcc1c9f 100644 --- a/lib/calendar-event-normalization.ts +++ b/lib/calendar-event-normalization.ts @@ -74,15 +74,57 @@ export function isAllDayEventLike(event: Pick, 'start' | && end.getMilliseconds() === 0; } +/** + * Stalwart returns "recurrenceRule" (singular) instead of RFC 8984 "recurrenceRules" (plural). + * Normalize server responses to match the client's internal type. + */ +function normalizeStalwartPropertyNames>(event: T): T { + const raw = event as Record; + let patched = false; + const updates: Partial = {}; + + if ('recurrenceRule' in raw && !('recurrenceRules' in raw)) { + // JSCalendar 2.0 (jscalendarbis-15) defines recurrenceRule as a single object, + // but Stalwart may also return it as an array (for JMAP-created events). + // Normalize both forms to our internal array type. + const val = raw.recurrenceRule; + if (val != null && !Array.isArray(val) && typeof val === 'object') { + updates.recurrenceRules = [val] as CalendarEvent['recurrenceRules']; + } else { + updates.recurrenceRules = val as CalendarEvent['recurrenceRules']; + } + patched = true; + } + if ('excludedRecurrenceRule' in raw && !('excludedRecurrenceRules' in raw)) { + const val = raw.excludedRecurrenceRule; + if (val != null && !Array.isArray(val) && typeof val === 'object') { + updates.excludedRecurrenceRules = [val] as CalendarEvent['excludedRecurrenceRules']; + } else { + updates.excludedRecurrenceRules = val as CalendarEvent['excludedRecurrenceRules']; + } + patched = true; + } + + if (!patched) return event; + + const result = { ...event, ...updates } as T; + delete (result as Record).recurrenceRule; + delete (result as Record).excludedRecurrenceRule; + return result; +} + export function normalizeCalendarEventLike>(event: T): T { - if (!isAllDayEventLike(event)) { - return event; + // First normalize Stalwart's singular property names to RFC 8984 plural forms + const normalized = normalizeStalwartPropertyNames(event); + + if (!isAllDayEventLike(normalized)) { + return normalized; } return { - ...event, + ...normalized, showWithoutTime: true, - duration: normalizeAllDayDurationValue(event.duration), + duration: normalizeAllDayDurationValue(normalized.duration), } as T; } diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index d178b339..9c3b9b29 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -145,9 +145,9 @@ const CALENDAR_EVENT_PROPERTIES = [ 'hideAttendees', 'recurrenceId', 'recurrenceIdTimeZone', - 'recurrenceRules', + 'recurrenceRule', 'recurrenceOverrides', - 'excludedRecurrenceRules', + 'excludedRecurrenceRule', 'useDefaultAlerts', 'alerts', 'locations', @@ -183,14 +183,49 @@ const CALENDAR_TASK_PROPERTIES = [ 'color', 'keywords', 'categories', - 'recurrenceRules', + 'recurrenceRule', 'recurrenceOverrides', - 'excludedRecurrenceRules', + 'excludedRecurrenceRule', 'useDefaultAlerts', 'alerts', 'relatedTo', ] as const; +/** + * Stalwart's calcard crate uses singular property names ("recurrenceRule") + * instead of the RFC 8984 plural forms ("recurrenceRules"). + * JSCalendar 2.0 (jscalendarbis-15) defines recurrenceRule as a single object, + * not an array. This function converts our internal array form to a single + * object, cleans null values, and renames the properties. + */ +function cleanRecurrenceRules(event: Record): void { + const keyMap: Record = { + recurrenceRules: 'recurrenceRule', + excludedRecurrenceRules: 'excludedRecurrenceRule', + }; + for (const [pluralKey, singularKey] of Object.entries(keyMap)) { + const rules = event[pluralKey]; + if (rules === undefined) continue; + delete event[pluralKey]; + if (!Array.isArray(rules)) { + // null means "remove recurrence" — pass through with the correct key + event[singularKey] = rules; + continue; + } + if (rules.length === 0) { + event[singularKey] = null; + continue; + } + // JSCalendar 2.0: recurrenceRule is a single object, use first rule + const rule = rules[0] as Record; + const cleaned: Record = {}; + for (const [k, v] of Object.entries(rule)) { + if (v !== null) cleaned[k] = v; + } + event[singularKey] = cleaned; + } +} + function getCalendarEventDebugSnapshot(event: Partial | null | undefined): Record | null { if (!event) { return null; @@ -3324,12 +3359,12 @@ export class JMAPClient implements IJMAPClient { // Strip client-only shared fields before sending to JMAP const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent; + cleanRecurrenceRules(cleanEvent as unknown as Record); debug.group('CalendarEvent/create'); debug.log('CalendarEvent/create outgoing payload', { accountId, sendSchedulingMessages, - event: getCalendarEventDebugSnapshot(cleanEvent), eventKeys: Object.keys(cleanEvent), }); @@ -3480,6 +3515,7 @@ export class JMAPClient implements IJMAPClient { // Strip client-only shared fields before sending to JMAP const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent; + cleanRecurrenceRules(cleanUpdates as unknown as Record); const setArgs: Record = { accountId, diff --git a/lib/recurrence-expansion.ts b/lib/recurrence-expansion.ts index 727b351a..39bee4e0 100644 --- a/lib/recurrence-expansion.ts +++ b/lib/recurrence-expansion.ts @@ -1,6 +1,10 @@ /** * Client-side recurrence expansion for JSCalendar events. * + * Implements JSCalendar 2.0 (draft-ietf-calext-jscalendarbis-15) §3.3.3.1 + * recurrence rule interpretation algorithm with full byX filtering, + * implicit byX property addition, and bySetPosition support. + * * Stalwart does not yet support mutations on the synthetic IDs produced by * CalendarEvent/query?expandRecurrences=true, so we fetch raw events (with * real, mutable IDs) and expand recurring series into individual occurrences @@ -8,9 +12,10 @@ */ import { parseISO, format, addDays, addWeeks, addMonths, addYears } from 'date-fns'; -import type { CalendarEvent, CalendarRecurrenceRule } from '@/lib/jmap/types'; +import type { CalendarEvent, CalendarRecurrenceRule, CalendarNDay } from '@/lib/jmap/types'; const DAY_INDEX: Record = { su: 0, mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6 }; +const INDEX_TO_DAY: string[] = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa']; /** * Given a master event and a date range, return an array of "virtual" occurrence @@ -32,12 +37,10 @@ export function expandRecurringEvents( for (const event of events) { if (!event.recurrenceRules?.length) { - // Non-recurring event — pass through unchanged result.push(event); continue; } - // Expand each recurrence rule const occurrences = expandEvent(event, start, end); result.push(...occurrences); } @@ -58,7 +61,6 @@ function expandEvent( const occurrences: CalendarEvent[] = []; const seenDates = new Set(); - // Process each rule for (const rule of rules) { const dates = generateDates(eventStart, rule, rangeStart, rangeEnd); for (const date of dates) { @@ -76,12 +78,11 @@ function expandEvent( const override = overrides[recurrenceId] as (Partial & { excluded?: boolean }) | undefined; if (override?.excluded) continue; - const occurrence = createOccurrence(master, date, recurrenceId, override); - occurrences.push(occurrence); + occurrences.push(createOccurrence(master, date, recurrenceId, override)); } } - // Also add any overrides that add new dates (not generated by rules) + // Add overrides that define new dates not generated by rules (RDATE equivalent) for (const [recurrenceId, rawOverride] of Object.entries(overrides)) { const override = rawOverride as Partial & { excluded?: boolean }; if (override.excluded) continue; @@ -95,8 +96,7 @@ function expandEvent( if (seenDates.has(dateKey)) continue; seenDates.add(dateKey); - const occurrence = createOccurrence(master, overrideDate, recurrenceId, override); - occurrences.push(occurrence); + occurrences.push(createOccurrence(master, overrideDate, recurrenceId, override)); } return occurrences; @@ -121,117 +121,370 @@ function createOccurrence( calendarIds: master.calendarIds, start: (override?.start) || startStr, recurrenceId, - // Remove recurrence rules from instances — only the master has them recurrenceRules: master.recurrenceRules, recurrenceOverrides: master.recurrenceOverrides, excludedRecurrenceRules: master.excludedRecurrenceRules, }; } -/** - * Generate occurrence dates for a single recurrence rule within a range. - * Capped at 500 occurrences to prevent runaway loops. - */ +// --------------------------------------------------------------------------- +// §3.3.3.1 — Implicit byX property addition +// --------------------------------------------------------------------------- +function addImplicitByX( + rule: CalendarRecurrenceRule, + eventStart: Date, +): CalendarRecurrenceRule { + const r = { ...rule }; + const freq = r.frequency; + + // bySecond + if (freq !== 'secondly' && !r.bySecond?.length) { + r.bySecond = [eventStart.getSeconds()]; + } + // byMinute + if (freq !== 'secondly' && freq !== 'minutely' && !r.byMinute?.length) { + r.byMinute = [eventStart.getMinutes()]; + } + // byHour + if (freq !== 'secondly' && freq !== 'minutely' && freq !== 'hourly' && !r.byHour?.length) { + r.byHour = [eventStart.getHours()]; + } + // weekly: implicit byDay + if (freq === 'weekly' && !r.byDay?.length) { + r.byDay = [{ day: INDEX_TO_DAY[eventStart.getDay()] }]; + } + // monthly: implicit byMonthDay + if (freq === 'monthly' && !r.byDay?.length && !r.byMonthDay?.length) { + r.byMonthDay = [eventStart.getDate()]; + } + // yearly: implicit byMonth / byMonthDay / byDay + if (freq === 'yearly' && !r.byYearDay?.length) { + if (!r.byMonth?.length && !r.byWeekNo?.length && (r.byMonthDay?.length || !r.byDay?.length)) { + r.byMonth = [String(eventStart.getMonth() + 1)]; + } + if (!r.byMonthDay?.length && !r.byWeekNo?.length && !r.byDay?.length) { + r.byMonthDay = [eventStart.getDate()]; + } + if (r.byWeekNo?.length && !r.byMonthDay?.length && !r.byDay?.length) { + r.byDay = [{ day: INDEX_TO_DAY[eventStart.getDay()] }]; + } + } + + return r; +} + +// --------------------------------------------------------------------------- +// §3.3.3.1 — Generate occurrence dates +// --------------------------------------------------------------------------- function generateDates( eventStart: Date, - rule: CalendarRecurrenceRule, + rawRule: CalendarRecurrenceRule, rangeStart: Date, rangeEnd: Date, ): Date[] { + const rule = addImplicitByX(rawRule, eventStart); const dates: Date[] = []; const interval = rule.interval || 1; - const limit = rule.count || 500; + const countLimit = rule.count || Infinity; const until = rule.until ? parseISO(rule.until) : null; - let count = 0; + let totalCount = 0; let current = new Date(eventStart); - // Safety: don't generate more than 500 occurrences const maxIterations = 2000; + const maxOccurrences = 500; let iterations = 0; while (iterations++ < maxIterations) { - if (count >= limit) break; + if (totalCount >= countLimit || totalCount >= maxOccurrences) break; if (until && current > until) break; - if (current >= rangeEnd) break; + // For frequencies that produce one candidate per iteration at a time, + // we can stop when we pass rangeEnd. But for frequencies that expand + // into multiple candidates per period, we need the candidate generation. + if (current >= rangeEnd && rule.frequency !== 'yearly' && rule.frequency !== 'monthly' + && rule.frequency !== 'weekly') break; - // For weekly rules with byDay, expand into multiple days per week - if (rule.frequency === 'weekly' && rule.byDay?.length) { - const weekDates = expandByDay(current, rule.byDay, eventStart); - for (const wd of weekDates) { - if (until && wd > until) break; - if (count >= limit) break; - if (wd >= rangeEnd) break; - count++; - if (wd >= rangeStart) { - dates.push(wd); - } - } - } else if (rule.frequency === 'monthly' && rule.byDay?.length) { - const monthDates = expandByDayMonthly(current, rule.byDay); - for (const md of monthDates) { - if (until && md > until) break; - if (count >= limit) break; - if (md >= rangeEnd) break; - count++; - if (md >= rangeStart) { - dates.push(md); - } - } - } else if (rule.frequency === 'monthly' && rule.byMonthDay?.length) { - for (const day of rule.byMonthDay) { - const d = new Date(current); - d.setDate(day); - if (d.getMonth() !== current.getMonth()) continue; // skip invalid days (e.g., Feb 30) - if (until && d > until) break; - if (count >= limit) break; - count++; - if (d >= rangeStart && d < rangeEnd) { - dates.push(d); - } - } - } else if (rule.frequency === 'yearly' && rule.byMonth?.length) { - for (const monthStr of rule.byMonth) { - const month = parseInt(monthStr, 10) - 1; // JSCalendar months are 1-indexed - const d = new Date(current); - d.setMonth(month); - if (rule.byMonthDay?.length) { - for (const day of rule.byMonthDay) { - const dd = new Date(d); - dd.setDate(day); - if (dd.getMonth() !== month) continue; - if (until && dd > until) break; - if (count >= limit) break; - count++; - if (dd >= rangeStart && dd < rangeEnd) { - dates.push(dd); - } - } - } else { - if (until && d > until) break; - if (count >= limit) break; - count++; - if (d >= rangeStart && d < rangeEnd) { - dates.push(d); - } - } - } - } else { - count++; - if (current >= rangeStart && current < rangeEnd) { - dates.push(new Date(current)); + // Generate candidates for the current period, then filter + const candidates = generateCandidatesForPeriod(current, rule, eventStart); + + // Apply bySetPosition if present + const filtered = rule.bySetPosition?.length + ? applyBySetPosition(candidates, rule.bySetPosition) + : candidates; + + for (const d of filtered) { + if (until && d > until) break; + if (totalCount >= countLimit || totalCount >= maxOccurrences) break; + + // Spec rule 4: eliminate dates before event start + if (d < eventStart) continue; + + totalCount++; + if (d >= rangeStart && d < rangeEnd) { + dates.push(d); } + if (d >= rangeEnd) break; } - current = advanceDate(current, rule.frequency, interval); + if (totalCount >= countLimit || totalCount >= maxOccurrences) break; + + current = advancePeriod(current, rule.frequency, interval, rule.firstDayOfWeek || 'mo'); + if (current <= eventStart && iterations === 1) { + // Safety: ensure we don't go backward + current = advancePeriod(eventStart, rule.frequency, interval, rule.firstDayOfWeek || 'mo'); + } + } + + // Spec rule 1: the initial start date-time is ALWAYS the first occurrence + if (dates.length > 0 && dates[0].getTime() !== eventStart.getTime()) { + if (eventStart >= rangeStart && eventStart < rangeEnd) { + // Check it's not already in the list + if (!dates.some(d => d.getTime() === eventStart.getTime())) { + dates.unshift(eventStart); + } + } } return dates; } -function advanceDate( +// --------------------------------------------------------------------------- +// Generate all candidates within one period, filtered by byX properties +// --------------------------------------------------------------------------- +function generateCandidatesForPeriod( + periodStart: Date, + rule: CalendarRecurrenceRule, + eventStart: Date, +): Date[] { + const freq = rule.frequency; + let candidates: Date[]; + + // Step 1: Generate base candidates based on frequency + expansion byX + switch (freq) { + case 'yearly': + candidates = expandYearly(periodStart, rule, eventStart); + break; + case 'monthly': + candidates = expandMonthly(periodStart, rule, eventStart); + break; + case 'weekly': + candidates = expandWeekly(periodStart, rule, eventStart); + break; + case 'daily': + case 'hourly': + case 'minutely': + case 'secondly': + candidates = [new Date(periodStart)]; + break; + default: + candidates = [new Date(periodStart)]; + } + + // Step 2: Filter candidates by all applicable byX constraints + candidates = candidates.filter(d => matchesByX(d, rule)); + + candidates.sort((a, b) => a.getTime() - b.getTime()); + return candidates; +} + +// --------------------------------------------------------------------------- +// Yearly expansion +// --------------------------------------------------------------------------- +function expandYearly( + periodStart: Date, + rule: CalendarRecurrenceRule, + eventStart: Date, +): Date[] { + const year = periodStart.getFullYear(); + const h = eventStart.getHours(); + const m = eventStart.getMinutes(); + const s = eventStart.getSeconds(); + let dates: Date[] = []; + + // Determine which months to iterate + const months = rule.byMonth?.length + ? rule.byMonth.map(ms => parseInt(ms.replace('L', ''), 10) - 1) + : [eventStart.getMonth()]; + + if (rule.byWeekNo?.length) { + // Expand by ISO week numbers + for (const wn of rule.byWeekNo) { + const weekDates = datesInISOWeek(year, wn, rule.firstDayOfWeek || 'mo'); + dates.push(...weekDates.map(d => { d.setHours(h, m, s, 0); return d; })); + } + } else if (rule.byYearDay?.length) { + // Expand by day-of-year + for (const yd of rule.byYearDay) { + const d = dayOfYear(year, yd); + if (d) { d.setHours(h, m, s, 0); dates.push(d); } + } + } else if (rule.byDay?.length && rule.byMonthDay?.length) { + // Both byDay and byMonthDay: expand byMonthDay in each month, then byDay filters later + for (const mo of months) { + for (const md of rule.byMonthDay) { + const d = resolveMonthDay(year, mo, md); + if (d) { d.setHours(h, m, s, 0); dates.push(d); } + } + } + } else if (rule.byDay?.length) { + // byDay with nthOfPeriod in yearly context = nth weekday of year or month + for (const mo of months) { + const expanded = expandByDayInMonth(year, mo, rule.byDay, h, m, s); + dates.push(...expanded); + } + } else if (rule.byMonthDay?.length) { + for (const mo of months) { + for (const md of rule.byMonthDay) { + const d = resolveMonthDay(year, mo, md); + if (d) { d.setHours(h, m, s, 0); dates.push(d); } + } + } + } else { + // Simple yearly: same date each year + for (const mo of months) { + const d = new Date(year, mo, eventStart.getDate(), h, m, s, 0); + if (d.getMonth() === mo) dates.push(d); + } + } + + return dates; +} + +// --------------------------------------------------------------------------- +// Monthly expansion +// --------------------------------------------------------------------------- +function expandMonthly( + periodStart: Date, + rule: CalendarRecurrenceRule, + eventStart: Date, +): Date[] { + const year = periodStart.getFullYear(); + const month = periodStart.getMonth(); + const h = eventStart.getHours(); + const m = eventStart.getMinutes(); + const s = eventStart.getSeconds(); + const dates: Date[] = []; + + if (rule.byDay?.length) { + const expanded = expandByDayInMonth(year, month, rule.byDay, h, m, s); + dates.push(...expanded); + } else if (rule.byMonthDay?.length) { + for (const md of rule.byMonthDay) { + const d = resolveMonthDay(year, month, md); + if (d) { d.setHours(h, m, s, 0); dates.push(d); } + } + } else { + // Implicit byMonthDay already added, but as fallback: + const d = new Date(year, month, eventStart.getDate(), h, m, s, 0); + if (d.getMonth() === month) dates.push(d); + } + + return dates; +} + +// --------------------------------------------------------------------------- +// Weekly expansion +// --------------------------------------------------------------------------- +function expandWeekly( + periodStart: Date, + rule: CalendarRecurrenceRule, + eventStart: Date, +): Date[] { + const dates: Date[] = []; + const baseDay = periodStart.getDay(); + + const byDay = rule.byDay?.length ? rule.byDay : [{ day: INDEX_TO_DAY[eventStart.getDay()] }]; + + for (const { day } of byDay) { + const targetDay = DAY_INDEX[day]; + if (targetDay === undefined) continue; + let diff = targetDay - baseDay; + if (diff < 0) diff += 7; + const d = addDays(periodStart, diff); + d.setHours(eventStart.getHours(), eventStart.getMinutes(), eventStart.getSeconds(), 0); + dates.push(d); + } + + return dates.sort((a, b) => a.getTime() - b.getTime()); +} + +// --------------------------------------------------------------------------- +// byX matching (Step 2 of §3.3.3.1) +// --------------------------------------------------------------------------- +function matchesByX(date: Date, rule: CalendarRecurrenceRule): boolean { + if (rule.byMonth?.length) { + const month = String(date.getMonth() + 1); + if (!rule.byMonth.some(m => m.replace('L', '') === month)) return false; + } + if (rule.byWeekNo?.length) { + const wn = getISOWeekNumber(date); + const weeksInYear = getISOWeeksInYear(date.getFullYear()); + if (!rule.byWeekNo.some(w => (w > 0 ? w : weeksInYear + 1 + w) === wn)) return false; + } + if (rule.byYearDay?.length) { + const yd = getDayOfYear(date); + const daysInYear = isLeapYear(date.getFullYear()) ? 366 : 365; + if (!rule.byYearDay.some(d => (d > 0 ? d : daysInYear + 1 + d) === yd)) return false; + } + if (rule.byMonthDay?.length) { + const md = date.getDate(); + const daysInMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); + if (!rule.byMonthDay.some(d => (d > 0 ? d : daysInMonth + 1 + d) === md)) return false; + } + if (rule.byDay?.length) { + const dayName = INDEX_TO_DAY[date.getDay()]; + const freq = rule.frequency; + if (!rule.byDay.some(nd => { + if (nd.day !== dayName) return false; + if (nd.nthOfPeriod == null) return true; + if (freq === 'monthly') { + return nd.nthOfPeriod === nthWeekdayInMonth(date, nd.nthOfPeriod); + } + if (freq === 'yearly') { + // When byMonth is present, nthOfPeriod scopes to the month (iCalendar semantics) + if (rule.byMonth?.length) { + return nd.nthOfPeriod === nthWeekdayInMonth(date, nd.nthOfPeriod); + } + return nd.nthOfPeriod === nthWeekdayInYear(date, nd.nthOfPeriod); + } + return true; + })) return false; + } + if (rule.byHour?.length) { + if (!rule.byHour.includes(date.getHours())) return false; + } + if (rule.byMinute?.length) { + if (!rule.byMinute.includes(date.getMinutes())) return false; + } + if (rule.bySecond?.length) { + if (!rule.bySecond.includes(date.getSeconds())) return false; + } + return true; +} + +// --------------------------------------------------------------------------- +// bySetPosition (Step 3 of §3.3.3.1) +// --------------------------------------------------------------------------- +function applyBySetPosition(dates: Date[], positions: number[]): Date[] { + if (!dates.length) return dates; + const result: Date[] = []; + const len = dates.length; + for (const pos of positions) { + const idx = pos > 0 ? pos - 1 : len + pos; + if (idx >= 0 && idx < len) { + result.push(dates[idx]); + } + } + return result.sort((a, b) => a.getTime() - b.getTime()); +} + +// --------------------------------------------------------------------------- +// Advance to the start of the next period +// --------------------------------------------------------------------------- +function advancePeriod( date: Date, frequency: CalendarRecurrenceRule['frequency'], interval: number, + _firstDayOfWeek: string, ): Date { switch (frequency) { case 'daily': return addDays(date, interval); @@ -245,65 +498,43 @@ function advanceDate( } } -function expandByDay( - weekStart: Date, - byDay: { day: string; nthOfPeriod?: number }[], - eventStart: Date, +// --------------------------------------------------------------------------- +// Helper: expand byDay within a specific month (monthly or yearly context) +// --------------------------------------------------------------------------- +function expandByDayInMonth( + year: number, + month: number, + byDay: CalendarNDay[], + h: number, m: number, s: number, ): Date[] { const dates: Date[] = []; - const baseDay = weekStart.getDay(); - - for (const { day } of byDay) { - const targetDay = DAY_INDEX[day]; - if (targetDay === undefined) continue; - let diff = targetDay - baseDay; - if (diff < 0) diff += 7; - const d = addDays(weekStart, diff); - // Preserve time from the original event - d.setHours(eventStart.getHours(), eventStart.getMinutes(), eventStart.getSeconds()); - dates.push(d); - } - - return dates.sort((a, b) => a.getTime() - b.getTime()); -} - -function expandByDayMonthly( - monthStart: Date, - byDay: { day: string; nthOfPeriod?: number }[], -): Date[] { - const dates: Date[] = []; - const year = monthStart.getFullYear(); - const month = monthStart.getMonth(); for (const { day, nthOfPeriod } of byDay) { const targetDay = DAY_INDEX[day]; if (targetDay === undefined) continue; - if (nthOfPeriod) { - // Find the nth occurrence of this weekday in the month + if (nthOfPeriod != null && nthOfPeriod !== 0) { const d = nthWeekdayOfMonth(year, month, targetDay, nthOfPeriod); - if (d) { - d.setHours(monthStart.getHours(), monthStart.getMinutes(), monthStart.getSeconds()); - dates.push(d); - } + if (d) { d.setHours(h, m, s, 0); dates.push(d); } } else { // Every occurrence of this weekday in the month let d = new Date(year, month, 1); - while (d.getDay() !== targetDay) { - d = addDays(d, 1); - } + while (d.getDay() !== targetDay) d = addDays(d, 1); while (d.getMonth() === month) { const occ = new Date(d); - occ.setHours(monthStart.getHours(), monthStart.getMinutes(), monthStart.getSeconds()); + occ.setHours(h, m, s, 0); dates.push(occ); d = addDays(d, 7); } } } - return dates.sort((a, b) => a.getTime() - b.getTime()); + return dates; } +// --------------------------------------------------------------------------- +// Helper: find nth weekday of month +// --------------------------------------------------------------------------- function nthWeekdayOfMonth( year: number, month: number, @@ -311,22 +542,118 @@ function nthWeekdayOfMonth( nth: number, ): Date | null { if (nth > 0) { - // Forward from start of month let d = new Date(year, month, 1); - while (d.getDay() !== weekday) { - d = addDays(d, 1); - } + while (d.getDay() !== weekday) d = addDays(d, 1); d = addDays(d, (nth - 1) * 7); return d.getMonth() === month ? d : null; } else { - // Backward from end of month let d = new Date(year, month + 1, 0); // last day of month - while (d.getDay() !== weekday) { - d = addDays(d, -1); - } - if (nth < -1) { - d = addDays(d, (nth + 1) * 7); - } + while (d.getDay() !== weekday) d = addDays(d, -1); + if (nth < -1) d = addDays(d, (nth + 1) * 7); return d.getMonth() === month ? d : null; } } + +// --------------------------------------------------------------------------- +// Helper: check if date is the nth (or nth-last) weekday in its month +// --------------------------------------------------------------------------- +function nthWeekdayInMonth(date: Date, nth: number): number { + if (nth > 0) { + // Count from start: which occurrence of this weekday is it? + return Math.floor((date.getDate() - 1) / 7) + 1; + } else { + // Count from end + const daysInMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); + return -(Math.floor((daysInMonth - date.getDate()) / 7) + 1); + } +} + +// --------------------------------------------------------------------------- +// Helper: check if date is the nth weekday in its year +// --------------------------------------------------------------------------- +function nthWeekdayInYear(date: Date, nth: number): number { + const yd = getDayOfYear(date); + const weekday = date.getDay(); + if (nth > 0) { + // Find first occurrence of this weekday in the year + const jan1 = new Date(date.getFullYear(), 0, 1); + let first = jan1; + while (first.getDay() !== weekday) first = addDays(first, 1); + const firstYd = getDayOfYear(first); + return Math.floor((yd - firstYd) / 7) + 1; + } else { + const daysInYear = isLeapYear(date.getFullYear()) ? 366 : 365; + const dec31 = new Date(date.getFullYear(), 11, 31); + let last = dec31; + while (last.getDay() !== weekday) last = addDays(last, -1); + const lastYd = getDayOfYear(last); + return -(Math.floor((lastYd - yd) / 7) + 1); + } +} + +// --------------------------------------------------------------------------- +// Helper: resolve negative/positive byMonthDay to a real date +// --------------------------------------------------------------------------- +function resolveMonthDay(year: number, month: number, day: number): Date | null { + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const actualDay = day > 0 ? day : daysInMonth + 1 + day; + if (actualDay < 1 || actualDay > daysInMonth) return null; + return new Date(year, month, actualDay); +} + +// --------------------------------------------------------------------------- +// Helper: day of year (1-indexed) +// --------------------------------------------------------------------------- +function getDayOfYear(date: Date): number { + const start = new Date(date.getFullYear(), 0, 0); + const diff = date.getTime() - start.getTime(); + return Math.floor(diff / 86400000); +} + +// --------------------------------------------------------------------------- +// Helper: day-of-year to Date +// --------------------------------------------------------------------------- +function dayOfYear(year: number, yd: number): Date | null { + const daysInYear = isLeapYear(year) ? 366 : 365; + const actual = yd > 0 ? yd : daysInYear + 1 + yd; + if (actual < 1 || actual > daysInYear) return null; + const d = new Date(year, 0, actual); + return d; +} + +function isLeapYear(year: number): boolean { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; +} + +// --------------------------------------------------------------------------- +// ISO week helpers +// --------------------------------------------------------------------------- +function getISOWeekNumber(date: Date): number { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); +} + +function getISOWeeksInYear(year: number): number { + const dec28 = new Date(Date.UTC(year, 11, 28)); + return getISOWeekNumber(dec28); +} + +function datesInISOWeek(year: number, weekNo: number, _firstDayOfWeek: string): Date[] { + const weeksInYear = getISOWeeksInYear(year); + const actual = weekNo > 0 ? weekNo : weeksInYear + 1 + weekNo; + if (actual < 1 || actual > weeksInYear) return []; + + // Find Monday of ISO week 1 + const jan4 = new Date(year, 0, 4); + const dayOfWeek = jan4.getDay() || 7; // Monday=1 ... Sunday=7 + const week1Monday = addDays(jan4, 1 - dayOfWeek); + const targetMonday = addDays(week1Monday, (actual - 1) * 7); + + const dates: Date[] = []; + for (let i = 0; i < 7; i++) { + dates.push(addDays(targetMonday, i)); + } + return dates; +} diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index dd1e1577..27168f89 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -321,7 +321,24 @@ export const useCalendarStore = create()( } await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId); set((state) => ({ - events: state.events.map(e => e.id === id ? { ...e, ...cleanUpdates } : e), + events: state.events.map(e => { + if (e.id !== id) return e; + const merged = { ...e, ...cleanUpdates }; + // When start changes, shift utcStart/utcEnd by the same delta so the + // event renders at the new position immediately (optimistic update). + if (cleanUpdates.start && e.start && e.utcStart) { + const oldStart = new Date(e.start).getTime(); + const newStart = new Date(cleanUpdates.start).getTime(); + const delta = newStart - oldStart; + if (delta !== 0) { + merged.utcStart = new Date(new Date(e.utcStart).getTime() + delta).toISOString(); + if (e.utcEnd) { + merged.utcEnd = new Date(new Date(e.utcEnd).getTime() + delta).toISOString(); + } + } + } + return merged; + }), })); } catch (error) { debug.error('Failed to update event:', error);