fix: end a zero-length event at its start instead of at midnight

This commit is contained in:
Linus Rath
2026-10-29 03:38:25 +01:00
parent f83284d7e5
commit acbd085ab4
7 changed files with 92 additions and 4 deletions
+16
View File
@@ -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
@@ -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 = '';
+15 -2
View File
@@ -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<T extends Partial<CalendarEvent>>(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<T extends Partial<CalendarEvent>>(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<CalendarEvent, 'id' | 'uid' | 'calendarIds'>;
+12
View File
@@ -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<CalendarEvent>).duration;
expect(getEventEndDate(zero).getTime()).toBe(getEventStartDate(zero).getTime());
});
});
+5 -1
View File
@@ -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);
}
@@ -47,6 +47,7 @@ import {
ImportRefusedError,
CalendarAccountChangedError,
loadEventsInRange,
prepareImportedEvent,
} from '../calendar-store';
const mockGetCalendars = calendarApi.getCalendars as ReturnType<typeof vi.fn>;
@@ -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');
});
});
+6 -1
View File
@@ -141,7 +141,12 @@ export function prepareImportedEvent(event: Partial<CalendarEvent>): Partial<Cal
title: src.title,
description: src.description,
start: src.start,
duration: src.showWithoutTime ? normalizeAllDayDuration(src.duration) : src.duration,
// A timed event without a duration lasts zero seconds. CalendarEvent/parse
// drops a DTEND equal to DTSTART, and stored without a duration Stalwart
// ends the event at 23:59:59 (#837). Tasks have no duration.
duration: src.showWithoutTime
? normalizeAllDayDuration(src.duration)
: src.duration || (src['@type'] !== 'Task' && src.start ? 'PT0S' : undefined),
timeZone: src.showWithoutTime ? null : src.timeZone,
showWithoutTime: src.showWithoutTime,
status: src.status,