mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
fix: end a zero-length event at its start instead of at midnight
This commit is contained in:
@@ -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
@@ -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'>;
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user