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'>;