mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
fix: show zero-length events ending at their start instead of midnight #837
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user