From 5bd03d175df5635c2f8b15ca1b5718f11f45b76b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:25:13 +0100 Subject: [PATCH] fix: show all-day events on their own day in the Calendar Agenda for viewers west of the server's time zone --- app/api/calendar-agenda/route.ts | 20 +++++++++++++++++-- lib/__tests__/calendar-agenda-route.test.ts | 22 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/app/api/calendar-agenda/route.ts b/app/api/calendar-agenda/route.ts index 272687bea..291ad022e 100644 --- a/app/api/calendar-agenda/route.ts +++ b/app/api/calendar-agenda/route.ts @@ -52,6 +52,7 @@ interface AgendaEvent { accountId: string; uid: string | null; title: string; + /** An instant (`...Z`); for an all-day event the date-time without an offset. */ start: string; end: string; allDay: boolean; @@ -97,6 +98,20 @@ function eventEnd(event: Partial): Date { return new Date(start.getTime() + parseDurationMinutes(event.duration) * 60000); } +/** + * An all-day event's start and end as the dates it is stored with + * (`2026-10-27T00:00:00`, no offset). As an instant it would be midnight in + * this server's time zone, which anyone west of the server reads as the day + * before; `new Date()` reads a date-time without an offset as the viewer's + * own local time. + */ +function allDayRange(event: Partial): { start: string; end: string } { + const start = (event.start as string).slice(0, 19); + const startMs = Date.parse(`${start}Z`); + const end = new Date(startMs + parseDurationMinutes(event.duration) * 60000).toISOString().slice(0, 19); + return { start, end }; +} + function firstLocationName(event: Partial): string | null { const locations = event.locations; if (!locations || typeof locations !== 'object') return null; @@ -318,8 +333,9 @@ export async function POST(request: NextRequest) { accountId: e.accountId, uid: e.uid ?? null, title: (e.title ?? '').trim() || '(no title)', - start: eventStart(e).toISOString(), - end: eventEnd(e).toISOString(), + ...(e.showWithoutTime + ? allDayRange(e) + : { start: eventStart(e).toISOString(), end: eventEnd(e).toISOString() }), allDay: !!e.showWithoutTime, status: e.status ?? null, color: e.color || cal?.color || null, diff --git a/lib/__tests__/calendar-agenda-route.test.ts b/lib/__tests__/calendar-agenda-route.test.ts index 31aac60c6..06d037c9f 100644 --- a/lib/__tests__/calendar-agenda-route.test.ts +++ b/lib/__tests__/calendar-agenda-route.test.ts @@ -240,6 +240,28 @@ describe.each(['0.16', '1.0'])('calendar-agenda route on Stalwa expect(events[0]).toMatchObject({ start: '2026-09-26T08:00:00.000Z', end: '2026-09-26T09:00:00.000Z' }); }); + it(`hands out an all-day event as its own date, not midnight in the server's zone, on Stalwart ${version}`, async () => { + // As the server answered live: 0.16 drops showWithoutTime and names + // Etc/UTC, 1.0 keeps showWithoutTime and names no zone. + const holiday = { + id: 'maaaaac', baseEventId: 'c', '@type': 'Event', uid: 'c@example.com', title: 'Holiday', + start: '2026-09-27T00:00:00', duration: 'P1D', + utcStart: '2026-09-27T00:00:00Z', utcEnd: '2026-09-28T00:00:00Z', calendarIds: { cal: true }, + ...(version === '1.0' ? { showWithoutTime: true } : { timeZone: 'Etc/UTC' }), + }; + answer = ({ method, args }) => method === 'CalendarEvent/get' && (args.ids as string[]).includes('maaaaac') + ? ['CalendarEvent/get', { list: [OCCURRENCES.maaaaab, holiday] }] + : undefined; + const { events } = await (await POST(makeReq())).json() as { events: Array> }; + + // The plugin files events under new Date(start) in the viewer's zone. An + // instant for midnight in the server's zone put the holiday on 26 + // September for anyone west of the server; a date-time without an offset + // is read as the viewer's own midnight. + expect(events[1]).toMatchObject({ allDay: true, start: '2026-09-27T00:00:00', end: '2026-09-28T00:00:00' }); + expect(new Date(events[1].start as string).getDate()).toBe(27); + }); + it(`answers 502 when a get fails on Stalwart ${version}`, async () => { answer = ({ method }) => method === 'CalendarEvent/get' ? ['error', { type: 'serverFail', description: 'Store unavailable' }] : undefined;