fix: keep all-day events all-day on Stalwart 0.16

This commit is contained in:
Linus Rath
2026-10-31 13:21:11 +01:00
parent 9ec58be4fb
commit 75513969ef
4 changed files with 69 additions and 6 deletions
+34 -3
View File
@@ -251,7 +251,8 @@ describe('calendar operations', () => {
id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02', start: '2026-03-02T00:00:00',
timeZone: 'Europe/Berlin', title: 'Daily',
} as any;
const single = { id: 's9', baseEventId: 'other', start: '2026-03-02T09:00:00', title: 'Once' } as any;
// Stalwart 1.0 returns a one-off event under its own id.
const single = { id: 's9', baseEventId: null, start: '2026-03-02T09:00:00', title: 'Once' } as any;
const [hydrated, untouched] = await hydrateExpandedOccurrences([occurrence, single]);
@@ -267,10 +268,40 @@ describe('calendar operations', () => {
});
it('skips the round trip when nothing recurs', async () => {
const single = { id: 's9', baseEventId: 'other', start: '2026-03-02T09:00:00' } as any;
expect(await hydrateExpandedOccurrences([single])).toEqual([single]);
const single = { id: 's9', baseEventId: null, start: '2026-03-02T09:00:00' } as any;
const own = { id: 'd', baseEventId: 'd', start: '2026-03-02T09:00:00' } as any;
expect(await hydrateExpandedOccurrences([single, own])).toEqual([single, own]);
expect(mockRequest).not.toHaveBeenCalled();
});
// Stalwart 0.16 hands a one-off event out under a synthetic id, with no
// recurrenceId and no showWithoutTime, in the query's time zone: the
// all-day "Company holiday" read as 00:00 to 00:00.
it('gives a one-off all-day event under a synthetic id its all-day flag back', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/get', {
list: [
{ id: 'c', showWithoutTime: true, duration: 'P1D' },
{ id: 'd', timeZone: 'Europe/Berlin', duration: 'PT1H' },
],
}, '0']],
});
const holiday = {
id: 'h1pceeaaaaac', baseEventId: 'c', '@type': 'Event', title: 'Company holiday',
start: '2026-11-01T00:00:00', duration: 'P1D', timeZone: 'Europe/Berlin',
utcStart: '2026-10-31T23:00:00Z', utcEnd: '2026-11-01T23:00:00Z',
} as any;
const review = {
id: 'h1pceeaaaaad', baseEventId: 'd', start: '2026-11-01T14:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin',
} as any;
const [allDay, timed] = await hydrateExpandedOccurrences([holiday, review]);
expect(mockRequest.mock.calls[0][0][0][1]).toMatchObject({ ids: ['c', 'd'] });
expect(allDay).toMatchObject({ id: 'h1pceeaaaaac', showWithoutTime: true, timeZone: null, start: '2026-11-01T00:00:00' });
expect(allDay.recurrenceRules).toBeUndefined();
expect(timed).toBe(review);
});
});
describe('getEvents for expanded occurrences', () => {
@@ -937,6 +937,28 @@ live('live Stalwart calendar', () => {
}, 60_000);
});
describe('an all-day event', () => {
// 0.16 expands a one-off event to a synthetic id without showWithoutTime;
// the detail sheet then said 00:00 - 00:00 and the editor's switch was off.
it('stays all-day on the calendar and in the editor', async () => {
await connectSingleton();
const created = await createEvent({
'@type': 'Event',
title: `${RUN} all day`,
start: '2027-11-10T00:00:00',
duration: 'P1D',
showWithoutTime: true,
} as Partial<CalendarEvent>, calendarId, false);
const uid = created.uid ?? (await getEvents([created.id]))[0].uid!;
const [event] = await loadOccurrences(uid, '2027-11-08T00:00:00Z', '2027-11-15T00:00:00Z');
expect(event, 'the event is on the calendar').toBeTruthy();
expect(event.showWithoutTime).toBe(true);
expect(event.timeZone ?? null).toBeNull();
expect(editorTimesFromEvent(event).allDay).toBe(true);
expect(getEventStartDate(event).getDate()).toBe(10);
}, 60_000);
});
describe('a subscribed feed', () => {
const FEED_URL = 'https://feeds.invalid/live.ics';
let feed = '';
+5 -2
View File
@@ -865,7 +865,10 @@ async function queryExpandedRange(
* rule / overrides (and all-day ones without showWithoutTime). Fetch those
* from the base events in one batched get and merge them in, so an
* occurrence carries the same series context the device-side expansion
* copies from its master.
* copies from its master. Stalwart 0.16 also hands a one-off event out
* under a synthetic id, without a recurrenceId and without
* showWithoutTime: an all-day one would read as 00:00 to 00:00, so its base
* is fetched as well.
*/
export async function hydrateExpandedOccurrences(
events: CalendarEvent[],
@@ -873,7 +876,7 @@ export async function hydrateExpandedOccurrences(
): Promise<CalendarEvent[]> {
const baseIds = Array.from(new Set(
events
.filter((event) => isServerRecurrenceInstance(event) && event.recurrenceId)
.filter((event) => isServerRecurrenceInstance(event))
.map((event) => event.baseEventId as string),
));
if (baseIds.length === 0) return events;
+8 -1
View File
@@ -349,9 +349,16 @@ export function hydrateRecurrenceInstances(
bases: ReadonlyMap<string, Partial<CalendarEvent>>,
): CalendarEvent[] {
return collapseDuplicateInstances(instances, bases).map((instance) => {
if (!isServerRecurrenceInstance(instance) || !instance.recurrenceId) return instance;
if (!isServerRecurrenceInstance(instance)) return instance;
const base = instance.baseEventId ? bases.get(instance.baseEventId) : undefined;
if (!base) return instance;
// Stalwart 0.16 expands a one-off event to a synthetic id too, without
// a recurrenceId and without showWithoutTime, in the query's time zone.
if (!instance.recurrenceId) {
return base.showWithoutTime
? { ...instance, showWithoutTime: true, timeZone: base.timeZone ?? null }
: instance;
}
const hydrated: CalendarEvent = {
...instance,