From 9ee25c930e794a06ab5384a0fd0cdcadd3f2f00a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:35:26 +0200 Subject: [PATCH] fix: compute per-occurrence utcStart/utcEnd in recurrence expansion #116 --- lib/__tests__/recurrence-expansion.test.ts | 106 +++++++++++++++++++++ lib/recurrence-expansion.ts | 37 +++++++ 2 files changed, 143 insertions(+) diff --git a/lib/__tests__/recurrence-expansion.test.ts b/lib/__tests__/recurrence-expansion.test.ts index ff052e54..30edbacb 100644 --- a/lib/__tests__/recurrence-expansion.test.ts +++ b/lib/__tests__/recurrence-expansion.test.ts @@ -375,5 +375,111 @@ describe('expandRecurringEvents', () => { const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00'); expect(result[0].originalId).toBe('evt1'); }); + + it('skips server-returned override events that belong to a recurring master', () => { + // Server returns the master event plus override instances with recurrenceId set + const master = makeEvent({ + id: 'master1', + uid: 'shared-uid', + start: '2025-01-06T09:00:00', + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any], + }); + const override1 = makeEvent({ + id: 'override1', + uid: 'shared-uid', + start: '2025-01-13T09:00:00', + recurrenceId: '2025-01-13T09:00:00', + recurrenceRules: null, + }); + const override2 = makeEvent({ + id: 'override2', + uid: 'shared-uid', + start: '2025-01-20T09:00:00', + recurrenceId: '2025-01-20T09:00:00', + recurrenceRules: null, + }); + + const result = expandRecurringEvents( + [master, override1, override2], + '2025-01-06T00:00:00', + '2025-01-27T00:00:00', + ); + + // Should have 3 weekly occurrences from master expansion only (Jan 6, 13, 20) + // Override events should be skipped since they share the master's UID + expect(result).toHaveLength(3); + expect(starts(result)).toEqual([ + '2025-01-06T09:00:00', + '2025-01-13T09:00:00', + '2025-01-20T09:00:00', + ]); + }); + + it('keeps override events whose UID has no matching master', () => { + // Standalone override event with no master in the batch + const orphanOverride = makeEvent({ + id: 'orphan1', + uid: 'orphan-uid', + start: '2025-01-13T09:00:00', + recurrenceId: '2025-01-13T09:00:00', + recurrenceRules: null, + }); + + const result = expandRecurringEvents( + [orphanOverride], + '2025-01-06T00:00:00', + '2025-01-27T00:00:00', + ); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('orphan1'); + }); + }); + + // ----------------------------------------------------------------------- + // utcStart computation for occurrences + // ----------------------------------------------------------------------- + describe('utcStart per occurrence', () => { + it('computes distinct utcStart for each weekly occurrence', () => { + const event = makeEvent({ + start: '2026-09-01T12:00:00', + utcStart: '2026-09-01T10:00:00Z', + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any], + }); + const result = expand(event, '2026-09-01T00:00:00', '2026-10-01T00:00:00'); + const utcStarts = result.map(e => (e as any).utcStart); + // Each occurrence should have a unique utcStart + expect(new Set(utcStarts).size).toBe(result.length); + // First occurrence keeps master's UTC offset relationship + expect(utcStarts[0]).toContain('2026-09-01'); + expect(utcStarts[1]).toContain('2026-09-08'); + expect(utcStarts[2]).toContain('2026-09-15'); + }); + + it('computes distinct utcEnd for each weekly occurrence', () => { + const event = makeEvent({ + start: '2026-09-01T12:00:00', + duration: 'PT1H', + utcStart: '2026-09-01T10:00:00Z', + utcEnd: '2026-09-01T11:00:00Z', + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any], + }); + const result = expand(event, '2026-09-01T00:00:00', '2026-10-01T00:00:00'); + const utcEnds = result.map(e => (e as any).utcEnd); + // Each occurrence should have a unique utcEnd + expect(new Set(utcEnds).size).toBe(result.length); + expect(utcEnds[0]).toContain('2026-09-01'); + expect(utcEnds[1]).toContain('2026-09-08'); + expect(utcEnds[2]).toContain('2026-09-15'); + }); + + it('does not set utcStart when master has none', () => { + const event = makeEvent({ + start: '2025-01-06T09:00:00', + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any], + }); + const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00'); + expect(result[0].utcStart).toBeUndefined(); + }); }); }); diff --git a/lib/recurrence-expansion.ts b/lib/recurrence-expansion.ts index 39bee4e0..db14ae5c 100644 --- a/lib/recurrence-expansion.ts +++ b/lib/recurrence-expansion.ts @@ -35,7 +35,23 @@ export function expandRecurringEvents( const end = parseISO(rangeEnd); const result: CalendarEvent[] = []; + // Collect UIDs of master recurring events so we can skip their + // server-returned override instances (which have recurrenceId set). + // The master's recurrenceOverrides already accounts for them. + const recurringUids = new Set(); for (const event of events) { + if (event.recurrenceRules?.length && event.uid && !event.recurrenceId) { + recurringUids.add(event.uid); + } + } + + for (const event of events) { + // Skip override instances returned by the server — they belong to a + // master recurring event and are already handled via recurrenceOverrides. + if (event.recurrenceId && event.uid && recurringUids.has(event.uid)) { + continue; + } + if (!event.recurrenceRules?.length) { result.push(event); continue; @@ -112,6 +128,25 @@ function createOccurrence( ? format(date, "yyyy-MM-dd'T'00:00:00") : format(date, "yyyy-MM-dd'T'HH:mm:ss"); + // Compute utcStart/utcEnd for this occurrence so that getEventStartDate() + // and getEventEndDate() (which prefer utcStart/utcEnd for timed events) + // return the correct dates instead of the master's original UTC times. + let utcStart: string | undefined; + let utcEnd: string | undefined; + if (!master.showWithoutTime && master.utcStart && master.start) { + const masterLocal = parseISO(master.start); + const masterUtc = parseISO(master.utcStart); + const offsetMs = masterUtc.getTime() - masterLocal.getTime(); + utcStart = new Date(date.getTime() + offsetMs).toISOString(); + + // Shift utcEnd by the same amount as utcStart + if (master.utcEnd) { + const masterUtcEnd = parseISO(master.utcEnd); + const durationMs = masterUtcEnd.getTime() - masterUtc.getTime(); + utcEnd = new Date(date.getTime() + offsetMs + durationMs).toISOString(); + } + } + return { ...master, ...(override || {}), @@ -120,6 +155,8 @@ function createOccurrence( uid: master.uid, calendarIds: master.calendarIds, start: (override?.start) || startStr, + ...(utcStart && !override?.utcStart ? { utcStart } : {}), + ...(utcEnd && !override?.utcEnd ? { utcEnd } : {}), recurrenceId, recurrenceRules: master.recurrenceRules, recurrenceOverrides: master.recurrenceOverrides,