fix: show all-day events on their own day in the Calendar Agenda for viewers west of the server's time zone

This commit is contained in:
Linus Rath
2026-10-26 11:33:38 +01:00
parent b9fc9bc9c0
commit 5bd03d175d
2 changed files with 40 additions and 2 deletions
+18 -2
View File
@@ -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<CalendarEvent>): 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<CalendarEvent>): { 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<CalendarEvent>): 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,
@@ -240,6 +240,28 @@ describe.each<StalwartVersion>(['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<Record<string, unknown>> };
// 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;