From 49beaef852ba2ec0a79c6ecd2b46257d72646946 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:07:16 +0200 Subject: [PATCH] fix: show zero-length events ending at their start instead of midnight #837 --- .../calendar-event-normalization.test.ts | 34 ++++++++++++++++++- lib/__tests__/calendar-invitation.test.ts | 1 + lib/calendar-event-normalization.ts | 15 +++++++- .../calendar-store-event-hooks.test.ts | 16 +++++++++ stores/calendar-store.ts | 5 ++- 5 files changed, 68 insertions(+), 3 deletions(-) diff --git a/lib/__tests__/calendar-event-normalization.test.ts b/lib/__tests__/calendar-event-normalization.test.ts index 846e75620..33d0642b6 100644 --- a/lib/__tests__/calendar-event-normalization.test.ts +++ b/lib/__tests__/calendar-event-normalization.test.ts @@ -84,4 +84,36 @@ describe('calendar event normalization', () => { expect(result.excludedRecurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'daily' }]); }); }); -}); \ No newline at end of file + + describe('zero-duration timed events (#837)', () => { + // Stalwart drops a DTEND equal to DTSTART and returns no duration, but a + // utcEnd at 23:59:59 of the start day. + const fromServer = { + start: '2026-09-30T10:00:00', + timeZone: 'Etc/UTC', + showWithoutTime: false, + utcStart: '2026-09-30T10:00:00Z', + utcEnd: '2026-09-30T23:59:59Z', + } as Partial; + + it('ends a server event without a duration at its start', () => { + expect(normalizeCalendarEventLike(fromServer)).toMatchObject({ + duration: 'PT0S', + utcEnd: '2026-09-30T10:00:00Z', + }); + }); + + it('keeps the end of a server event that has a duration', () => { + const event = { ...fromServer, duration: 'PT8H', utcEnd: '2026-09-30T18:00:00Z' }; + expect(normalizeCalendarEventLike(event)).toMatchObject({ + duration: 'PT8H', + utcEnd: '2026-09-30T18:00:00Z', + }); + }); + + it('leaves update patches without a duration alone', () => { + const patch = { start: '2026-09-30T11:00:00' } as Partial; + expect(sanitizeOutgoingCalendarEventData(patch)).not.toHaveProperty('duration'); + }); + }); +}); diff --git a/lib/__tests__/calendar-invitation.test.ts b/lib/__tests__/calendar-invitation.test.ts index 7b548e036..311ee4a99 100644 --- a/lib/__tests__/calendar-invitation.test.ts +++ b/lib/__tests__/calendar-invitation.test.ts @@ -344,6 +344,7 @@ describe('formatEventSummary', () => { utcStart: '2026-02-17T15:00:00Z', utcEnd: '2026-02-17T16:00:00Z', start: '2026-02-17T10:00:00', + duration: 'PT1H', }); expect(summary.start).toBe('2026-02-17T15:00:00Z'); expect(summary.end).toBe('2026-02-17T16:00:00Z'); diff --git a/lib/calendar-event-normalization.ts b/lib/calendar-event-normalization.ts index 5805875c9..6dd31bf83 100644 --- a/lib/calendar-event-normalization.ts +++ b/lib/calendar-event-normalization.ts @@ -113,12 +113,25 @@ function normalizeStalwartPropertyNames>(event: return result; } +/** + * A timed event without a duration lasts zero seconds (RFC 8984 default + * PT0S). Stalwart drops a DTEND equal to DTSTART and then ends such an + * event at 23:59:59 of its start day, so its utcEnd is wrong. Only server + * objects carry utcStart, which keeps partial update patches untouched. (#837) + */ +function withZeroDurationEnd>(event: T): T { + if (event.showWithoutTime || event.duration || !event.start || !event.utcStart) { + return event; + } + return { ...event, duration: 'PT0S', utcEnd: event.utcStart } as T; +} + export function normalizeCalendarEventLike>(event: T): T { // First normalize Stalwart's singular property names to RFC 8984 plural forms const normalized = normalizeStalwartPropertyNames(event); if (!isAllDayEventLike(normalized)) { - return normalized; + return withZeroDurationEnd(normalized); } return { diff --git a/stores/__tests__/calendar-store-event-hooks.test.ts b/stores/__tests__/calendar-store-event-hooks.test.ts index ff1c04ba9..6ebd55f0c 100644 --- a/stores/__tests__/calendar-store-event-hooks.test.ts +++ b/stores/__tests__/calendar-store-event-hooks.test.ts @@ -275,3 +275,19 @@ describe('importEvents hooks', () => { expect(after).not.toHaveBeenCalled(); }); }); + +describe('importEvents zero-duration events (#837)', () => { + it('sends an explicit zero duration for a timed event without one', async () => { + const client = fakeClient(); + // CalendarEvent/parse of DTSTART == DTEND returns no duration. + const parsed = [ + { uid: 'ci', title: 'C/I', start: '2026-09-30T10:00:00', timeZone: 'Etc/UTC' }, + { uid: 'sb', title: 'Standby', start: '2026-09-30T10:00:00', timeZone: 'Etc/UTC', duration: 'PT8H' }, + ] as Partial[]; + + await useCalendarStore.getState().importEvents(client, parsed, 'c1'); + + const batch = vi.mocked(client.batchCreateCalendarEvents).mock.calls[0][0]; + expect(batch.map((e) => e.duration)).toEqual(['PT0S', 'PT8H']); + }); +}); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 8efe1f660..56fe1e961 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -1909,7 +1909,10 @@ async function importEventsUnhooked( description: src.description, descriptionContentType: src.descriptionContentType, start: src.start, - duration: src.showWithoutTime ? normalizeAllDayDuration(src.duration) : src.duration, + // CalendarEvent/parse drops a DTEND equal to DTSTART; without an + // explicit zero duration Stalwart stores the event ending at + // 23:59:59 of its start day. (#837) + duration: src.showWithoutTime ? normalizeAllDayDuration(src.duration) : (src.duration || 'PT0S'), timeZone: src.showWithoutTime ? null : src.timeZone, showWithoutTime: src.showWithoutTime, status: src.status,