From acbd085ab4012dfee428bf9953e41344d124d9cd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:38:25 +0100 Subject: [PATCH] fix: end a zero-length event at its start instead of at midnight --- src/api/__tests__/calendar.test.ts | 16 +++++++++++ .../__tests__/live-stalwart-calendar.test.ts | 27 +++++++++++++++++++ src/api/calendar.ts | 17 ++++++++++-- src/lib/__tests__/calendar-utils.test.ts | 12 +++++++++ src/lib/calendar-utils.ts | 6 ++++- src/stores/__tests__/calendar-store.test.ts | 11 ++++++++ src/stores/calendar-store.ts | 7 ++++- 7 files changed, 92 insertions(+), 4 deletions(-) diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index 6d550db1..ea16e878 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -395,6 +395,22 @@ describe('calendar operations', () => { }); describe('getEvents', () => { + // #837: Stalwart stores a DTEND equal to DTSTART as no duration and ends + // the event at 23:59:59 of its start day. + it('ends a timed event without a duration at its start, and leaves tasks alone', async () => { + mockRequest.mockResolvedValue(getAnswer([ + { id: 'z', '@type': 'Event', start: '2026-03-02T09:00:00', utcStart: '2026-03-02T08:00:00Z', utcEnd: '2026-03-02T22:59:59Z' }, + { id: 't', '@type': 'Task', start: '2026-03-02T09:00:00', utcStart: '2026-03-02T08:00:00Z', due: '2026-03-03T09:00:00' }, + { id: 'a', '@type': 'Event', start: '2026-03-02T00:00:00', showWithoutTime: true }, + ])); + + const [zero, task, allDay] = await getEvents(['z', 't', 'a']); + + expect(zero).toMatchObject({ duration: 'PT0S', utcEnd: '2026-03-02T08:00:00Z' }); + expect(task).not.toHaveProperty('duration'); + expect(allDay).not.toHaveProperty('duration'); + }); + it('should fetch events by id with singular JSCalendar 2.0 recurrence properties', async () => { // Stalwart (calcard) names the property `recurrenceRule` (singular, // single object) — requesting the RFC 8984 plural form returns no diff --git a/src/api/__tests__/live-stalwart-calendar.test.ts b/src/api/__tests__/live-stalwart-calendar.test.ts index 6912afaa..3482cac8 100644 --- a/src/api/__tests__/live-stalwart-calendar.test.ts +++ b/src/api/__tests__/live-stalwart-calendar.test.ts @@ -698,6 +698,33 @@ live('live Stalwart calendar', () => { }, 60_000); }); + describe('a zero-length event (#837)', () => { + it('ends at its start, stored without a duration or imported from an .ics', async () => { + await connectSingleton(); + // As another client stores it: no duration at all. + const [[, made]] = await raw(client, [['CalendarEvent/set', { + accountId: client.accountId, + create: { z: { '@type': 'Event', title: `${RUN} zero`, start: '2027-12-01T09:00:00', timeZone: 'Europe/Berlin', calendarIds: { [calendarId]: true } } }, + }, '0']]); + const id = made.created?.z?.id; + expect(id, JSON.stringify(made.notCreated)).toBeTruthy(); + const [read] = await getEvents([id]); + expect(read.utcStart).toBe('2027-12-01T08:00:00Z'); + expect(read.utcEnd).toBe(read.utcStart); + + // An .ics whose DTEND equals its DTSTART, imported. + const uid = `${RUN}-zero-import`; + await useCalendarStore.getState().fetchCalendars(); + const { imported } = await useCalendarStore.getState().importEvents([ + { uid, title: `${RUN} zero imported`, start: '2027-12-02T09:00:00', timeZone: 'Europe/Berlin' }, + ], calendarId); + expect(imported).toBe(1); + const [stored] = await seriesByUid(client, uid); + const [[, got]] = await raw(client, [['CalendarEvent/get', { accountId: client.accountId, ids: [stored.id], properties: ['duration', 'utcStart', 'utcEnd'] }, '0']]); + expect(got.list[0].utcEnd).toBe(got.list[0].utcStart); + }, 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 aea75595..1f52497a 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -3,7 +3,7 @@ import { CAPABILITIES } from './types'; import type { Calendar, CalendarEvent, CalendarRights } from './types'; import { JMAPSetError, assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result'; import { formatWallClock, getEffectiveTimeZone, getTimeZoneOffsetMs } from '../lib/calendar-timezone'; -import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection'; +import { SCAN_PROPERTIES, isTaskLikeObject, type ScannedCalendarObject } from '../lib/calendar-component-detection'; import { RECURRENCE_BASE_PROPERTIES, SYNTHETIC_ID_PROBE, @@ -302,6 +302,18 @@ export function normalizeTaskProgress>(event: T : { ...event, progress: normalized }; } +/** + * A timed event without a duration lasts zero seconds (RFC 8984's 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 (#837). Tasks carry + * no duration and are left alone. Mirrors the webmail's withZeroDurationEnd. + */ +export function withZeroDurationEnd>(event: T): T { + if (event.showWithoutTime || event.duration || !event.start || !event.utcStart) return event; + if (isTaskLikeObject(event as ScannedCalendarObject)) return event; + return { ...event, duration: 'PT0S', utcEnd: event.utcStart }; +} + /** * Convert the client's plural recurrence arrays to the singular JSCalendar 2.0 * properties Stalwart expects on write, dropping null rule fields. An empty @@ -970,7 +982,8 @@ export async function getEvents( options?.expanded ? EXPANDED_EVENT_PROPERTIES : CALENDAR_EVENT_PROPERTIES, { withOverrides: !options?.expanded, timeZone }, )); - return (list as unknown as CalendarEvent[]).map((e) => normalizeTaskProgress(normalizeRecurrenceProperties(e))); + return (list as unknown as CalendarEvent[]).map((e) => + withZeroDurationEnd(normalizeTaskProgress(normalizeRecurrenceProperties(e)))); } export type EventIdentityFields = Pick; diff --git a/src/lib/__tests__/calendar-utils.test.ts b/src/lib/__tests__/calendar-utils.test.ts index 054db5f5..088a8626 100644 --- a/src/lib/__tests__/calendar-utils.test.ts +++ b/src/lib/__tests__/calendar-utils.test.ts @@ -332,6 +332,7 @@ describe('getTimedEventBoundsForDay on DST change days', () => { timeZone: 'Europe/Berlin', utcStart: '2026-10-25T11:51:00Z', utcEnd: '2026-10-25T12:16:00Z', + duration: 'PT25M', }); expect(getTimedEventBoundsForDay(fallBack, new Date(2026, 9, 25))).toMatchObject({ startMinutes: 12 * 60 + 51, @@ -344,6 +345,7 @@ describe('getTimedEventBoundsForDay on DST change days', () => { timeZone: 'Europe/Berlin', utcStart: '2026-03-29T07:00:00Z', utcEnd: '2026-03-29T08:00:00Z', + duration: 'PT1H', }); expect(getTimedEventBoundsForDay(springForward, new Date(2026, 2, 29))).toMatchObject({ startMinutes: 9 * 60, @@ -354,3 +356,13 @@ describe('getTimedEventBoundsForDay on DST change days', () => { } }); }); + +describe('getEventEndDate', () => { + // #837: Stalwart stores a DTEND equal to DTSTART as no duration and ends + // the event at 23:59:59 of its day. + it('ends a timed event without a duration at its start, whatever utcEnd says', () => { + const zero = ev({ start: '2026-03-02T09:00:00', utcStart: '2026-03-02T09:00:00Z', utcEnd: '2026-03-02T23:59:59Z' }); + delete (zero as Partial).duration; + expect(getEventEndDate(zero).getTime()).toBe(getEventStartDate(zero).getTime()); + }); +}); diff --git a/src/lib/calendar-utils.ts b/src/lib/calendar-utils.ts index 1e85dfed..9cbb7cbc 100644 --- a/src/lib/calendar-utils.ts +++ b/src/lib/calendar-utils.ts @@ -113,7 +113,11 @@ export function addDaysToLocalDateTime(value: string, days: number): string { /** End of an event as a display date - see getEventStartDate. */ export function getEventEndDate(event: CalendarEvent): Date { - if (!event.showWithoutTime && event.utcEnd) { + // A timed event without a duration lasts zero seconds (RFC 8984's default + // PT0S). Stalwart stores a DTEND equal to DTSTART as no duration and gives + // the event a utcEnd of 23:59:59 on its start day, so it filled the rest + // of the day in the grid (#837). + if (!event.showWithoutTime && event.utcEnd && event.duration) { const utc = parseISO(event.utcEnd); if (!isNaN(utc.getTime())) return toDisplayDate(utc); } diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index c6e2fed5..ab33af99 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -47,6 +47,7 @@ import { ImportRefusedError, CalendarAccountChangedError, loadEventsInRange, + prepareImportedEvent, } from '../calendar-store'; const mockGetCalendars = calendarApi.getCalendars as ReturnType; @@ -1445,3 +1446,13 @@ describe('editing one occurrence of a series', () => { expect(mockUpdateEvent).toHaveBeenCalledTimes(1); }); }); + +describe('prepareImportedEvent', () => { + // #837: CalendarEvent/parse drops a DTEND equal to DTSTART, and stored + // without a duration Stalwart ends the event at 23:59:59. + it('imports a timed event without a duration as lasting zero seconds', () => { + expect(prepareImportedEvent({ uid: 'z', start: '2026-03-02T09:00:00', timeZone: 'Europe/Berlin' }).duration).toBe('PT0S'); + expect(prepareImportedEvent({ uid: 'h', start: '2026-03-02T09:00:00', duration: 'PT1H' }).duration).toBe('PT1H'); + expect(prepareImportedEvent({ uid: 't', '@type': 'Task', start: '2026-03-02T09:00:00' })).not.toHaveProperty('duration'); + }); +}); diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index 0838ebcd..18146512 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -141,7 +141,12 @@ export function prepareImportedEvent(event: Partial): Partial