fix: show zero-length events ending at their start instead of midnight #837

This commit is contained in:
Linus Rath
2026-10-17 19:17:50 +02:00
parent d47f26da20
commit 49beaef852
5 changed files with 68 additions and 3 deletions
@@ -84,4 +84,36 @@ describe('calendar event normalization', () => {
expect(result.excludedRecurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'daily' }]);
});
});
});
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<CalendarEvent>;
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<CalendarEvent>;
expect(sanitizeOutgoingCalendarEventData(patch)).not.toHaveProperty('duration');
});
});
});
@@ -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');
+14 -1
View File
@@ -113,12 +113,25 @@ function normalizeStalwartPropertyNames<T extends Partial<CalendarEvent>>(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<T extends Partial<CalendarEvent>>(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<T extends Partial<CalendarEvent>>(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 {
@@ -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<CalendarEvent>[];
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']);
});
});
+4 -1
View File
@@ -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,