From 75513969ef74fdc30dceabb6d44b5feff359a644 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:21:11 +0100 Subject: [PATCH] fix: keep all-day events all-day on Stalwart 0.16 --- src/api/__tests__/calendar.test.ts | 37 +++++++++++++++++-- .../__tests__/live-stalwart-calendar.test.ts | 22 +++++++++++ src/api/calendar.ts | 7 +++- src/lib/recurrence-instances.ts | 9 ++++- 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index 9f376758..833d784c 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -251,7 +251,8 @@ describe('calendar operations', () => { id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02', start: '2026-03-02T00:00:00', timeZone: 'Europe/Berlin', title: 'Daily', } as any; - const single = { id: 's9', baseEventId: 'other', start: '2026-03-02T09:00:00', title: 'Once' } as any; + // Stalwart 1.0 returns a one-off event under its own id. + const single = { id: 's9', baseEventId: null, start: '2026-03-02T09:00:00', title: 'Once' } as any; const [hydrated, untouched] = await hydrateExpandedOccurrences([occurrence, single]); @@ -267,10 +268,40 @@ describe('calendar operations', () => { }); it('skips the round trip when nothing recurs', async () => { - const single = { id: 's9', baseEventId: 'other', start: '2026-03-02T09:00:00' } as any; - expect(await hydrateExpandedOccurrences([single])).toEqual([single]); + const single = { id: 's9', baseEventId: null, start: '2026-03-02T09:00:00' } as any; + const own = { id: 'd', baseEventId: 'd', start: '2026-03-02T09:00:00' } as any; + expect(await hydrateExpandedOccurrences([single, own])).toEqual([single, own]); expect(mockRequest).not.toHaveBeenCalled(); }); + + // Stalwart 0.16 hands a one-off event out under a synthetic id, with no + // recurrenceId and no showWithoutTime, in the query's time zone: the + // all-day "Company holiday" read as 00:00 to 00:00. + it('gives a one-off all-day event under a synthetic id its all-day flag back', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['CalendarEvent/get', { + list: [ + { id: 'c', showWithoutTime: true, duration: 'P1D' }, + { id: 'd', timeZone: 'Europe/Berlin', duration: 'PT1H' }, + ], + }, '0']], + }); + const holiday = { + id: 'h1pceeaaaaac', baseEventId: 'c', '@type': 'Event', title: 'Company holiday', + start: '2026-11-01T00:00:00', duration: 'P1D', timeZone: 'Europe/Berlin', + utcStart: '2026-10-31T23:00:00Z', utcEnd: '2026-11-01T23:00:00Z', + } as any; + const review = { + id: 'h1pceeaaaaad', baseEventId: 'd', start: '2026-11-01T14:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', + } as any; + + const [allDay, timed] = await hydrateExpandedOccurrences([holiday, review]); + + expect(mockRequest.mock.calls[0][0][0][1]).toMatchObject({ ids: ['c', 'd'] }); + expect(allDay).toMatchObject({ id: 'h1pceeaaaaac', showWithoutTime: true, timeZone: null, start: '2026-11-01T00:00:00' }); + expect(allDay.recurrenceRules).toBeUndefined(); + expect(timed).toBe(review); + }); }); describe('getEvents for expanded occurrences', () => { diff --git a/src/api/__tests__/live-stalwart-calendar.test.ts b/src/api/__tests__/live-stalwart-calendar.test.ts index 82edff70..744f40d3 100644 --- a/src/api/__tests__/live-stalwart-calendar.test.ts +++ b/src/api/__tests__/live-stalwart-calendar.test.ts @@ -937,6 +937,28 @@ live('live Stalwart calendar', () => { }, 60_000); }); + describe('an all-day event', () => { + // 0.16 expands a one-off event to a synthetic id without showWithoutTime; + // the detail sheet then said 00:00 - 00:00 and the editor's switch was off. + it('stays all-day on the calendar and in the editor', async () => { + await connectSingleton(); + const created = await createEvent({ + '@type': 'Event', + title: `${RUN} all day`, + start: '2027-11-10T00:00:00', + duration: 'P1D', + showWithoutTime: true, + } as Partial, calendarId, false); + const uid = created.uid ?? (await getEvents([created.id]))[0].uid!; + const [event] = await loadOccurrences(uid, '2027-11-08T00:00:00Z', '2027-11-15T00:00:00Z'); + expect(event, 'the event is on the calendar').toBeTruthy(); + expect(event.showWithoutTime).toBe(true); + expect(event.timeZone ?? null).toBeNull(); + expect(editorTimesFromEvent(event).allDay).toBe(true); + expect(getEventStartDate(event).getDate()).toBe(10); + }, 60_000); + }); + describe('a subscribed feed', () => { const FEED_URL = 'https://feeds.invalid/live.ics'; let feed = ''; diff --git a/src/api/calendar.ts b/src/api/calendar.ts index a21b3ea5..32f6db37 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -865,7 +865,10 @@ async function queryExpandedRange( * rule / overrides (and all-day ones without showWithoutTime). Fetch those * from the base events in one batched get and merge them in, so an * occurrence carries the same series context the device-side expansion - * copies from its master. + * copies from its master. Stalwart 0.16 also hands a one-off event out + * under a synthetic id, without a recurrenceId and without + * showWithoutTime: an all-day one would read as 00:00 to 00:00, so its base + * is fetched as well. */ export async function hydrateExpandedOccurrences( events: CalendarEvent[], @@ -873,7 +876,7 @@ export async function hydrateExpandedOccurrences( ): Promise { const baseIds = Array.from(new Set( events - .filter((event) => isServerRecurrenceInstance(event) && event.recurrenceId) + .filter((event) => isServerRecurrenceInstance(event)) .map((event) => event.baseEventId as string), )); if (baseIds.length === 0) return events; diff --git a/src/lib/recurrence-instances.ts b/src/lib/recurrence-instances.ts index 2f2a6b9d..e870bce1 100644 --- a/src/lib/recurrence-instances.ts +++ b/src/lib/recurrence-instances.ts @@ -349,9 +349,16 @@ export function hydrateRecurrenceInstances( bases: ReadonlyMap>, ): CalendarEvent[] { return collapseDuplicateInstances(instances, bases).map((instance) => { - if (!isServerRecurrenceInstance(instance) || !instance.recurrenceId) return instance; + if (!isServerRecurrenceInstance(instance)) return instance; const base = instance.baseEventId ? bases.get(instance.baseEventId) : undefined; if (!base) return instance; + // Stalwart 0.16 expands a one-off event to a synthetic id too, without + // a recurrenceId and without showWithoutTime, in the query's time zone. + if (!instance.recurrenceId) { + return base.showWithoutTime + ? { ...instance, showWithoutTime: true, timeZone: base.timeZone ?? null } + : instance; + } const hydrated: CalendarEvent = { ...instance,