feat: let the server expand recurring events when it can

Recurring series were always fetched whole and expanded on the device,
the webmail's fallback for Stalwart before 0.16.20. Newer servers
expand them with `expandRecurrences` and accept the synthetic
occurrence ids in `CalendarEvent/set`; the webmail uses that since
`lib/recurrence-instances.ts`.

- `supportsSyntheticCalendarIds` probes once per server and account
  with an update of an id that cannot exist (`notFound` means
  supported); the probe rides along with the first `Calendar/get`
- when supported, a range is loaded with `queryExpandedEvents`, the
  occurrences are fetched with `baseEventId` and hydrated with their
  base event's rules, overrides, all-day flag and duration; duplicate
  listings of one occurrence are collapsed like the webmail does
- a range the server refuses to expand ("expanded recurrences
  exceeds") and older servers keep the device-side expansion, which
  now passes server occurrences through untouched
- changes, deletes and RSVPs resolve a synthetic id to its base event
  (`resolveSeriesTarget`), including a master fetched for an
  occurrence of a shared calendar, and reload the range afterwards
  since synthetic ids reshuffle
- reminder keys use the base event and recurrence id, so a reshuffle
  does not reschedule them
- client-only fields are stripped from `CalendarEvent/set` payloads
This commit is contained in:
Linus Rath
2026-09-24 20:10:08 +02:00
parent 1a651a9c25
commit 84548898c1
11 changed files with 1032 additions and 53 deletions
+136 -1
View File
@@ -20,6 +20,10 @@ import {
clearCalendarEvents,
findEventsByUid,
toLocalDateTime,
supportsSyntheticCalendarIds,
resetSyntheticIdSupport,
queryExpandedEvents,
hydrateExpandedOccurrences,
} from '../calendar';
describe('toLocalDateTime', () => {
@@ -38,6 +42,7 @@ const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
resetSyntheticIdSupport();
});
describe('calendar operations', () => {
@@ -51,12 +56,142 @@ describe('calendar operations', () => {
const result = await getCalendars();
expect(result).toEqual(calendars);
expect(mockRequest).toHaveBeenCalledWith(
[['Calendar/get', { accountId: 'acc-1' }, '0']],
[
['Calendar/get', { accountId: 'acc-1' }, '0'],
// The synthetic-id probe rides along with the first Calendar/get.
['CalendarEvent/set', { accountId: 'acc-1', update: { h333333: {} } }, 'synthetic-id-probe'],
],
expect.arrayContaining(['urn:ietf:params:jmap:calendars']),
);
});
});
describe('supportsSyntheticCalendarIds', () => {
it('takes the verdict from the probe that rode along with Calendar/get', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['Calendar/get', { list: [] }, '0'],
['CalendarEvent/set', { notUpdated: { h333333: { type: 'notFound' } } }, 'synthetic-id-probe'],
],
});
await getCalendars();
expect(await supportsSyntheticCalendarIds()).toBe(true);
expect(mockRequest).toHaveBeenCalledTimes(1);
// Known now: the next Calendar/get goes out alone.
await getCalendars();
expect(mockRequest.mock.calls[1][0]).toHaveLength(1);
});
it('counts a server that refuses synthetic ids as unsupported', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/set', {
notUpdated: { h333333: { type: 'invalidProperties', description: 'Updating synthetic ids is not yet supported.' } },
}, 'synthetic-id-probe']],
});
expect(await supportsSyntheticCalendarIds()).toBe(false);
expect(await supportsSyntheticCalendarIds()).toBe(false);
expect(mockRequest).toHaveBeenCalledTimes(1);
});
it('probes again after a failed request', async () => {
mockRequest.mockRejectedValueOnce(new Error('offline'));
expect(await supportsSyntheticCalendarIds()).toBe(false);
mockRequest.mockResolvedValueOnce({
methodResponses: [['CalendarEvent/set', { notUpdated: { h333333: { type: 'notFound' } } }, 'synthetic-id-probe']],
});
expect(await supportsSyntheticCalendarIds()).toBe(true);
expect(mockRequest).toHaveBeenCalledTimes(2);
});
});
describe('queryExpandedEvents', () => {
it('asks the server to expand the range with a filter of the bounds alone', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: ['s1', 's2'] }, '0']],
});
expect(await queryExpandedEvents('2026-03-01T00:00:00Z', '2026-04-01T00:00:00Z')).toEqual(['s1', 's2']);
const args = mockRequest.mock.calls[0][0][0][1];
expect(args.expandRecurrences).toBe(true);
expect(Object.keys(args.filter).sort()).toEqual(['after', 'before']);
});
it('returns null when the range expands past the server limit', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['error', {
type: 'invalidArguments',
description: 'The number of expanded recurrences exceeds the limit of 1000',
}, '0']],
});
expect(await queryExpandedEvents('2026-01-01T00:00:00Z', '2027-01-01T00:00:00Z')).toBeNull();
});
it('throws on any other error', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['error', { type: 'forbidden' }, '0']],
});
await expect(queryExpandedEvents('2026-01-01T00:00:00Z', '2026-02-01T00:00:00Z')).rejects.toThrow('forbidden');
});
});
describe('hydrateExpandedOccurrences', () => {
it('gives server occurrences their base event\'s rules, overrides and all-day flag', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/get', {
list: [{
id: 'base',
recurrenceRule: { frequency: 'daily' },
recurrenceOverrides: { '2026-03-03': { title: 'Moved' } },
showWithoutTime: true,
timeZone: null,
duration: 'P1D',
}],
}, '0']],
});
const occurrence = {
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;
const [hydrated, untouched] = await hydrateExpandedOccurrences([occurrence, single]);
expect(mockRequest.mock.calls[0][0][0][1]).toMatchObject({ ids: ['base'] });
expect(hydrated).toMatchObject({
id: 's1',
recurrenceRules: [{ frequency: 'daily' }],
recurrenceOverrides: { '2026-03-03': { title: 'Moved' } },
showWithoutTime: true,
timeZone: null,
});
expect(untouched).toBe(single);
});
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]);
expect(mockRequest).not.toHaveBeenCalled();
});
});
describe('getEvents for expanded occurrences', () => {
it('asks for the base event id only then', async () => {
mockRequest.mockResolvedValue({ methodResponses: [['CalendarEvent/get', { list: [] }, '0']] });
await getEvents(['a'], undefined, { expanded: true });
await getEvents(['a']);
expect(mockRequest.mock.calls[0][0][0][1].properties).toContain('baseEventId');
expect(mockRequest.mock.calls[1][0][0][1].properties).not.toContain('baseEventId');
});
});
describe('queryEvents', () => {
it('should filter via singular inCalendar conditions (Stalwart-compatible)', async () => {
// Stalwart rejects the draft's plural `inCalendars` filter; calendars
+189 -6
View File
@@ -4,6 +4,12 @@ import type { Calendar, CalendarEvent, CalendarRights } from './types';
import { assertSetResult } from './jmap-result';
import { getEffectiveTimeZone } from '../lib/calendar-timezone';
import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection';
import {
RECURRENCE_BASE_PROPERTIES,
SYNTHETIC_ID_PROBE,
hydrateRecurrenceInstances,
isServerRecurrenceInstance,
} from '../lib/recurrence-instances';
const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
@@ -109,6 +115,19 @@ const CALENDAR_EVENT_PROPERTIES = [
'links', 'created', 'updated',
];
// Asked for only on server-expanded ranges: servers that expand recurrences
// for the app (see supportsSyntheticCalendarIds) know these.
const EXPANDED_EVENT_PROPERTIES = [...CALENDAR_EVENT_PROPERTIES, 'baseEventId', 'recurrenceIdTimeZone'];
// Client-side fields that must never reach CalendarEvent/set.
const CLIENT_ONLY_EVENT_KEYS = [
'originalId', 'baseEventId', 'originalCalendarIds', 'accountId', 'isShared', 'localAccountId',
];
function stripClientOnlyFields(payload: Record<string, unknown>): void {
for (const key of CLIENT_ONLY_EVENT_KEYS) delete payload[key];
}
/**
* Normalize Stalwart's singular recurrence property names to the RFC 8984
* plural array forms the client uses internally. JSCalendar 2.0 defines
@@ -233,12 +252,83 @@ function sharedCalendarAccountIds(): string[] {
.map(([id]) => id);
}
// ─── Synthetic ids ───────────────────────────────────────
// Whether CalendarEvent/set accepts the synthetic ids that
// CalendarEvent/query?expandRecurrences=true hands out (Stalwart >= 0.16.20),
// per server and account. Mirrors the webmail's supportsSyntheticCalendarIds.
const SYNTHETIC_ID_PROBE_CALL_ID = 'synthetic-id-probe';
const syntheticIdSupport = new Map<string, Promise<boolean>>();
function syntheticIdProbeKey(): string | null {
try {
return `${jmapClient.currentSession?.apiUrl ?? ''}|${jmapClient.accountId}`;
} catch {
return null;
}
}
function syntheticIdProbeCall(accountId: string): [string, Record<string, unknown>, string] {
return ['CalendarEvent/set', { accountId, update: { [SYNTHETIC_ID_PROBE]: {} } }, SYNTHETIC_ID_PROBE_CALL_ID];
}
/**
* Derive (and cache) the probe verdict from a response that carried the
* probe call. A server that still rejects synthetic ids answers
* `invalidProperties` before looking the event up, one that supports them
* answers `notFound`; anything else counts as unsupported. A request that
* failed outright is not remembered, so the next range load probes again.
*/
function rememberSyntheticIdProbe(key: string, pending: Promise<any>): void {
const verdict: Promise<boolean> = pending.then(
(res) => {
const entry = ((res?.methodResponses ?? []) as [string, any, string][]).find(
(r) => r[2] === SYNTHETIC_ID_PROBE_CALL_ID,
);
if (!entry || entry[0] !== 'CalendarEvent/set') return false;
return entry[1]?.notUpdated?.[SYNTHETIC_ID_PROBE]?.type === 'notFound';
},
() => {
if (syntheticIdSupport.get(key) === verdict) syntheticIdSupport.delete(key);
return false;
},
);
syntheticIdSupport.set(key, verdict);
}
/**
* Whether the server expands recurring events for the app: it accepts the
* synthetic occurrence ids in CalendarEvent/set, so each occurrence can be
* edited through its own id. Probed once per server and account with a
* side-effect-free update of an id that cannot exist; the first Calendar/get
* carries the probe along (getCalendars), so the usual start-up pays no
* extra round trip.
*/
export function supportsSyntheticCalendarIds(): Promise<boolean> {
const key = syntheticIdProbeKey();
if (!key) return Promise.resolve(false);
if (!syntheticIdSupport.has(key)) {
rememberSyntheticIdProbe(
key,
jmapClient.request([syntheticIdProbeCall(jmapClient.accountId)], USING),
);
}
return syntheticIdSupport.get(key)!;
}
export function resetSyntheticIdSupport(): void {
syntheticIdSupport.clear();
}
export async function getCalendars(): Promise<Calendar[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Calendar/get', { accountId }, '0']],
USING,
);
const probeKey = syntheticIdProbeKey();
const carriesProbe = !!probeKey && !syntheticIdSupport.has(probeKey);
const methodCalls: [string, Record<string, unknown>, string][] = [['Calendar/get', { accountId }, '0']];
if (carriesProbe) methodCalls.push(syntheticIdProbeCall(accountId));
const pending = jmapClient.request(methodCalls, USING);
if (carriesProbe) rememberSyntheticIdProbe(probeKey!, pending);
const res = await pending;
const own = methodResult<{ list: Calendar[] }>(res).list ?? [];
// Calendars shared with the user live in other session accounts. Failures
@@ -330,6 +420,91 @@ export async function queryEvents(
return ids;
}
/**
* Occurrence ids in a date window, with recurring series expanded by the
* server into one synthetic id per occurrence (CalendarEvent/query
* `expandRecurrences`). Only for servers that accept those ids in
* CalendarEvent/set (supportsSyntheticCalendarIds). The filter holds just
* the two range bounds, the form the server expands with (the webmail
* sends the same); the caller keeps the calendars it asked for. `null` when
* the server refuses to expand this range (more occurrences than its
* maxRecurrenceExpansions): the caller then expands on the device.
*/
export async function queryExpandedEvents(
after: string,
before: string,
accountId?: string,
): Promise<string[] | null> {
const account = accountId || jmapClient.accountId;
const timeZone = getUserTimeZone();
const filter = {
after: toLocalDateTime(after, timeZone),
before: toLocalDateTime(before, timeZone),
};
const ids: string[] = [];
for (let page = 0; page < QUERY_MAX_PAGES; page++) {
const args: Record<string, unknown> = {
accountId: account,
filter,
expandRecurrences: true,
limit: QUERY_PAGE_SIZE,
position: ids.length,
};
if (timeZone) args.timeZone = timeZone;
const res = await jmapClient.request([['CalendarEvent/query', args, '0']], USING);
const entry = res?.methodResponses?.[0];
if (
entry?.[0] === 'error'
&& entry[1]?.type === 'invalidArguments'
&& /expanded recurrences exceeds/i.test(entry[1]?.description ?? '')
) {
return null;
}
const batch = methodResult<{ ids: string[] }>(res).ids ?? [];
ids.push(...batch);
if (batch.length < QUERY_PAGE_SIZE) break;
}
return ids;
}
/**
* Server-expanded occurrences come back without their series' recurrence
* 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.
*/
export async function hydrateExpandedOccurrences(
events: CalendarEvent[],
accountId?: string,
): Promise<CalendarEvent[]> {
const baseIds = Array.from(new Set(
events
.filter((event) => isServerRecurrenceInstance(event) && event.recurrenceId)
.map((event) => event.baseEventId as string),
));
if (baseIds.length === 0) return events;
const account = accountId || jmapClient.accountId;
const timeZone = getUserTimeZone();
const batchSize = jmapClient.getMaxObjectsInGet();
const bases = new Map<string, Partial<CalendarEvent>>();
for (let i = 0; i < baseIds.length; i += batchSize) {
const res = await jmapClient.request(
[['CalendarEvent/get', {
accountId: account,
ids: baseIds.slice(i, i + batchSize),
properties: [...RECURRENCE_BASE_PROPERTIES],
...(timeZone ? { timeZone } : {}),
}, '0']],
USING,
);
for (const base of methodResult<{ list: Partial<CalendarEvent>[] }>(res).list ?? []) {
if (base.id) bases.set(base.id, normalizeRecurrenceProperties(base));
}
}
return hydrateRecurrenceInstances(events, bases);
}
/**
* Every calendar object of an account with just the properties needed to tell
* tasks from events (see lib/calendar-component-detection). Used to find
@@ -366,7 +541,12 @@ export async function scanCalendarObjects(accountId?: string): Promise<ScannedCa
return all;
}
export async function getEvents(ids: string[], accountId?: string): Promise<CalendarEvent[]> {
export async function getEvents(
ids: string[],
accountId?: string,
// `expanded`: the ids are server-expanded occurrences (queryExpandedEvents).
options?: { expanded?: boolean },
): Promise<CalendarEvent[]> {
if (ids.length === 0) return [];
const account = accountId || jmapClient.accountId;
const timeZone = getUserTimeZone();
@@ -378,7 +558,7 @@ export async function getEvents(ids: string[], accountId?: string): Promise<Cale
[['CalendarEvent/get', {
accountId: account,
ids: batch,
properties: CALENDAR_EVENT_PROPERTIES,
properties: options?.expanded ? EXPANDED_EVENT_PROPERTIES : CALENDAR_EVENT_PROPERTIES,
...(timeZone ? { timeZone } : {}),
}, '0']],
USING,
@@ -427,6 +607,7 @@ export async function createEvent(
): Promise<CalendarEvent> {
const accountId = targetAccountId || jmapClient.accountId;
const payload: Record<string, unknown> = { ...event, calendarIds: { [calendarId]: true } };
stripClientOnlyFields(payload);
cleanRecurrenceRules(payload);
const res = await jmapClient.request(
[['CalendarEvent/set', setArgs(accountId, {
@@ -476,6 +657,7 @@ export async function batchCreateEvents(
const create: Record<string, Partial<CalendarEvent>> = {};
events.forEach((e, i) => {
const payload: Record<string, unknown> = { ...e, calendarIds: { [calendarId]: true } };
stripClientOnlyFields(payload);
cleanRecurrenceRules(payload);
create[`evt-${i}`] = payload as Partial<CalendarEvent>;
});
@@ -503,6 +685,7 @@ export async function updateEvent(
): Promise<void> {
const accountId = targetAccountId || jmapClient.accountId;
const patch: Record<string, unknown> = { ...changes };
stripClientOnlyFields(patch);
cleanRecurrenceRules(patch);
const res = await jmapClient.request(
[['CalendarEvent/set', setArgs(accountId, { update: { [id]: patch } }, sendSchedulingMessages), '0']],
+6
View File
@@ -506,6 +506,12 @@ export interface CalendarEvent {
recurrenceOverrides?: Record<string, Partial<CalendarEvent>> | null;
excludedRecurrenceRules?: RecurrenceRule[] | null;
recurrenceId?: string;
// The zone of a LocalDateTime `recurrenceId` (server-expanded occurrences).
recurrenceIdTimeZone?: string | null;
// Server-expanded occurrences (CalendarEvent/query expandRecurrences) carry
// a synthetic `id`; this is the raw id of the stored event they come from.
// A stored event fetched directly carries its own id here.
baseEventId?: string | null;
originalId?: string;
// Client-only: the event's server-side calendarIds (raw, unprefixed), kept
// when `calendarIds` is remapped to namespaced store ids for shared events.
@@ -0,0 +1,152 @@
import { describe, it, expect } from 'vitest';
import {
baseEventStoreId,
hydrateRecurrenceInstances,
isServerRecurrenceInstance,
mergeOverrideParticipants,
resolveOverrideKey,
seriesIdOf,
stableOccurrenceKey,
} from '../recurrence-instances';
import { expandRecurringEvents } from '../recurrence-expansion';
import type { CalendarEvent } from '../../api/types';
function ev(partial: Partial<CalendarEvent>): CalendarEvent {
return { id: 'e', uid: 'u', title: 't', start: '2026-03-02T09:00:00', calendarIds: { c: true }, ...partial } as CalendarEvent;
}
describe('server recurrence instances', () => {
it('tells a server-expanded occurrence from a stored event', () => {
expect(isServerRecurrenceInstance(ev({ id: 's1', baseEventId: 'base' }))).toBe(true);
// Shared account: the store id is namespaced, originalId holds the raw id.
expect(isServerRecurrenceInstance(ev({ id: 'acc:s1', originalId: 's1', baseEventId: 'base' }))).toBe(true);
// A stored event fetched directly names itself.
expect(isServerRecurrenceInstance(ev({ id: 'base', baseEventId: 'base' }))).toBe(false);
expect(isServerRecurrenceInstance(ev({ id: 'acc:base', originalId: 'base', baseEventId: 'base' }))).toBe(false);
expect(isServerRecurrenceInstance(ev({ id: 'x:2026', originalId: 'x' }))).toBe(false);
});
it('names the base event under the store id it would have', () => {
expect(baseEventStoreId(ev({ id: 's1', baseEventId: 'base' }))).toBe('base');
expect(baseEventStoreId(ev({ id: 'acc:s1', originalId: 's1', baseEventId: 'base' }))).toBe('acc:base');
expect(baseEventStoreId(ev({ id: 'base' }))).toBeNull();
});
it('finds the stored event behind any store event', () => {
expect(seriesIdOf(ev({ id: 's1', baseEventId: 'base' }))).toBe('base');
expect(seriesIdOf(ev({ id: 'm:2026-03-02T09:00:00', originalId: 'm' }))).toBe('m');
expect(seriesIdOf(ev({ id: 'plain' }))).toBe('plain');
});
it('keys an occurrence by what survives a refetch', () => {
expect(stableOccurrenceKey(ev({ id: 's7', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00' })))
.toBe('base:2026-03-02T09:00:00');
expect(stableOccurrenceKey(ev({ id: 'acc:s7', originalId: 's7', accountId: 'acc', baseEventId: 'base' })))
.toBe('acc:base');
expect(stableOccurrenceKey(ev({ id: 'm:2026-03-02T09:00:00', originalId: 'm', recurrenceId: '2026-03-02T09:00:00' })))
.toBe('m:2026-03-02T09:00:00');
});
it('finds a moved occurrence\'s override by its new start on older servers', () => {
const overrides = { '2026-03-02T09:00:00': { start: '2026-03-02T11:00:00' } };
expect(resolveOverrideKey({ recurrenceId: '2026-03-02T09:00:00', start: '2026-03-02T11:00:00' }, overrides))
.toBe('2026-03-02T09:00:00');
expect(resolveOverrideKey({ recurrenceId: '2026-03-02T11:00:00', start: '2026-03-02T11:00:00' }, overrides))
.toBe('2026-03-02T09:00:00');
expect(resolveOverrideKey({ recurrenceId: '2026-03-04T09:00:00', start: '2026-03-04T09:00:00' }, overrides))
.toBe('2026-03-04T09:00:00');
expect(resolveOverrideKey({ start: '2026-03-04T09:00:00' }, overrides)).toBeNull();
});
it('completes an override\'s participants from the series', () => {
const series = {
org: { calendarAddress: 'mailto:org@x', roles: { owner: true }, participationStatus: 'accepted' },
a: { calendarAddress: 'mailto:a@x', participationStatus: 'needs-action' },
} as unknown as CalendarEvent['participants'];
const override = {
o2: { calendarAddress: 'MAILTO:org@x', participationStatus: null },
a: { participationStatus: 'declined' },
} as unknown as CalendarEvent['participants'];
expect(mergeOverrideParticipants(series, override)).toEqual({
org: { calendarAddress: 'MAILTO:org@x', roles: { owner: true }, participationStatus: 'accepted' },
a: { calendarAddress: 'mailto:a@x', participationStatus: 'declined' },
});
});
});
describe('hydrateRecurrenceInstances', () => {
const base = {
id: 'base',
recurrenceRules: [{ frequency: 'weekly' }],
recurrenceOverrides: { '2026-03-09T09:00:00': { title: 'Moved', start: '2026-03-09T11:00:00' } },
showWithoutTime: false,
timeZone: 'Europe/Berlin',
duration: 'PT1H',
} as Partial<CalendarEvent>;
const bases = new Map([['base', base]]);
it('copies the series context and the base duration of an override without one', () => {
const [plain, overridden] = hydrateRecurrenceInstances([
ev({ id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00', duration: 'PT1H' }),
ev({
id: 's2', baseEventId: 'base', recurrenceId: '2026-03-09T09:00:00', start: '2026-03-09T11:00:00',
title: 'Moved', duration: 'PT2H', utcStart: '2026-03-09T10:00:00Z', utcEnd: '2026-03-09T12:00:00Z',
}),
], bases);
expect(plain.recurrenceRules).toEqual([{ frequency: 'weekly' }]);
expect(plain.recurrenceOverrides).toBe(base.recurrenceOverrides);
expect(overridden.duration).toBe('PT1H');
expect(overridden.utcEnd).toBe('2026-03-09T11:00:00.000Z');
});
it('restores the all-day flag the expansion dropped', () => {
const allDay = new Map([['b2', { id: 'b2', showWithoutTime: true, timeZone: null, duration: 'P1D' }]]);
const [hydrated] = hydrateRecurrenceInstances(
[ev({ id: 's1', baseEventId: 'b2', recurrenceId: '2026-03-02T00:00:00', timeZone: 'Europe/Berlin' })],
allDay,
);
expect(hydrated.showWithoutTime).toBe(true);
expect(hydrated.timeZone).toBeNull();
});
it('collapses an occurrence listed twice, taking the override\'s own fields', () => {
const withReply = new Map([['base', {
...base,
recurrenceOverrides: { '2026-03-02T09:00:00': { participants: { a: { participationStatus: 'accepted' } } } },
}]]);
const series = ev({
id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00', title: 'Standup',
participants: { a: { calendarAddress: 'mailto:a@x', participationStatus: 'needs-action' } } as any,
});
const fromOverride = ev({
id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00', title: '',
participants: { a: { participationStatus: 'accepted' } } as any,
});
const result = hydrateRecurrenceInstances([series, fromOverride], withReply);
expect(result).toHaveLength(1);
expect(result[0].title).toBe('Standup');
expect(result[0].participants).toEqual({
a: { calendarAddress: 'mailto:a@x', participationStatus: 'accepted' },
});
});
it('leaves events without a known base alone', () => {
const lone = ev({ id: 's9', baseEventId: 'unknown', recurrenceId: '2026-03-02T09:00:00' });
expect(hydrateRecurrenceInstances([lone], bases)[0]).toBe(lone);
});
});
describe('expandRecurringEvents with server occurrences', () => {
it('never expands an occurrence the server already expanded', () => {
const occurrence = ev({
id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00',
recurrenceRules: [{ frequency: 'daily' } as any],
});
const result = expandRecurringEvents([occurrence], '2026-03-01T00:00:00', '2026-03-31T00:00:00');
expect(result).toEqual([occurrence]);
});
});
+8 -3
View File
@@ -1,6 +1,7 @@
import { parseISO } from 'date-fns';
import type { Alert, Calendar, CalendarEvent } from '../api/types';
import { getTaskDueDate, parseDuration } from './calendar-utils';
import { seriesIdOf, stableOccurrenceKey } from './recurrence-instances';
// Pure alert maths for calendar reminders. Port of the webmail's
// lib/calendar-alerts.ts (computeFireTime / getEffectiveAlerts /
@@ -8,7 +9,11 @@ import { getTaskDueDate, parseDuration } from './calendar-utils';
// instead of polling for alerts that just fired.
export interface ScheduledAlert {
/** `${eventId}:${alertId}:${fireTimeMs}` — stable across refetches. */
/**
* `${event}:${alertId}:${fireTimeMs}` — stable across refetches; a
* server-expanded occurrence is named by its base event and recurrence
* id, since its synthetic id is positional.
*/
key: string;
eventId: string;
alertId: string;
@@ -147,14 +152,14 @@ export function getUpcomingAlerts(
const fireTimeMs = computeFireTime(event, alert.trigger);
if (fireTimeMs === null || fireTimeMs <= window.now || fireTimeMs > until) continue;
out.push({
key: buildAlertKey(event.id, alertId, fireTimeMs),
key: buildAlertKey(stableOccurrenceKey(event), alertId, fireTimeMs),
eventId: event.id,
alertId,
fireTimeMs,
title: event.title || '(No title)',
body: Number.isNaN(startMs) ? '' : `Starts ${formatWhen(fireTimeMs, startMs)}`,
kind: 'event',
serverId: event.originalId ?? event.id,
serverId: seriesIdOf(event),
accountId: event.accountId,
recurrenceId: event.recurrenceId,
startMs: Number.isNaN(startMs) ? undefined : startMs,
+3 -7
View File
@@ -8,6 +8,7 @@ import {
} from '../stores/calendar-store';
import { useLocaleStore } from '../stores/locale-store';
import { useToastStore } from '../stores/toast-store';
import { seriesIdOf } from './recurrence-instances';
import {
usePendingCalendarOpen,
type CalendarReminderTarget,
@@ -21,14 +22,9 @@ import {
const DAY_MS = 24 * 60 * 60 * 1000;
/** The raw JMAP id of the stored event behind a store event or occurrence. */
function serverIdOf(event: CalendarEvent): string {
return event.originalId ?? event.id;
}
function matchesTarget(event: CalendarEvent, target: CalendarReminderTarget): boolean {
if (event.id === target.eventId) return true;
if (!target.serverId || serverIdOf(event) !== target.serverId) return false;
if (!target.serverId || seriesIdOf(event) !== target.serverId) return false;
return (event.accountId ?? undefined) === (target.accountId ?? undefined)
&& (event.recurrenceId ?? undefined) === (target.recurrenceId ?? undefined);
}
@@ -74,7 +70,7 @@ export async function resolveReminderTask(
const find = () => useCalendarStore.getState().tasks.find((task) =>
task.id === target.eventId
|| (!!target.serverId
&& serverIdOf(task) === target.serverId
&& seriesIdOf(task) === target.serverId
&& (task.accountId ?? undefined) === (target.accountId ?? undefined)));
const loaded = find();
if (loaded) return loaded;
+11 -5
View File
@@ -5,14 +5,17 @@
* recurrence rule interpretation: implicit byX addition, byX filtering,
* bySetPosition. Mobile port of webmail's lib/recurrence-expansion.ts.
*
* Stalwart does not yet support mutations on the synthetic IDs produced by
* CalendarEvent/query?expandRecurrences=true, so we fetch raw events (with
* real, mutable IDs) and expand recurring series into individual occurrences
* on the client.
* Used for servers that do not accept the synthetic ids produced by
* CalendarEvent/query?expandRecurrences=true in CalendarEvent/set (Stalwart
* before 0.16.20, see lib/recurrence-instances.ts), and for a range the
* server refuses to expand: raw events (with real, mutable ids) are fetched
* and recurring series are expanded into individual occurrences on the
* device. Occurrences the server already expanded pass through unchanged.
*/
import { addDays, addMonths, addWeeks, addYears, differenceInCalendarDays, format, parseISO } from 'date-fns';
import type { CalendarEvent, RecurrenceRule } from '../api/types';
import { isServerRecurrenceInstance } from './recurrence-instances';
type NDay = { day: string; nthOfPeriod?: number };
@@ -49,7 +52,10 @@ export function expandRecurringEvents(
for (const event of events) {
if (event.recurrenceId && event.uid && recurringUids.has(event.uid)) continue;
if (!event.recurrenceRules?.length) {
// An occurrence the server already expanded is a single instance even
// though it carries its series' recurrence rule (lib/recurrence-instances
// hydrates it with that); never expand it again.
if (!event.recurrenceRules?.length || isServerRecurrenceInstance(event)) {
result.push(event);
continue;
}
+262
View File
@@ -0,0 +1,262 @@
/**
* Server-side recurrence instances ("synthetic ids"). Port of the webmail's
* lib/recurrence-instances.ts.
*
* `CalendarEvent/query` with `expandRecurrences: true` returns one synthetic
* id per occurrence in the range instead of the stored base event. Since
* Stalwart 0.16.20 those ids are also accepted by `CalendarEvent/set`; older
* servers reject them, in which case the app keeps expanding recurrences on
* the device (lib/recurrence-expansion.ts) and patches the base event itself.
*
* What an expanded occurrence looks like (verified by the webmail against
* 0.16.19):
* - `id` is synthetic, `baseEventId` is the stored event's id; a
* non-recurring event in the range gets a synthetic id too.
* - `recurrenceId` / `recurrenceIdTimeZone` are set for occurrences of a
* series, `recurrenceRule` / `recurrenceOverrides` are stripped.
* - all-day occurrences lose `showWithoutTime` and are stamped with the
* request time zone.
* - the ids are positional and reshuffle whenever the series' overrides
* change, so the loaded range has to be refetched after mutating one.
*/
import type { CalendarEvent, Participant } from '../api/types';
/**
* A synthetic id whose event can never exist: expansion 0 of document
* `u32::MAX` (`Id::from_parts(1, u32::MAX)` in Stalwart's base32 alphabet).
* A pre-0.16.20 server rejects an update on it as `invalidProperties`
* before looking anything up; a server that supports synthetic ids answers
* `notFound`. Either way nothing is written, which makes it a safe probe.
*/
export const SYNTHETIC_ID_PROBE = 'h333333';
/** Base-event properties an expanded occurrence needs back (see `hydrateRecurrenceInstances`). */
export const RECURRENCE_BASE_PROPERTIES = [
'id',
'recurrenceRule',
'excludedRecurrenceRule',
'recurrenceOverrides',
'showWithoutTime',
'timeZone',
'duration',
] as const;
type EventIdentity = Pick<CalendarEvent, 'id'> & Partial<Pick<CalendarEvent, 'originalId' | 'baseEventId'>>;
/**
* True for an occurrence handed out by server-side expansion, i.e. an event
* whose (raw) id is synthetic and differs from its base event's id. A base
* event fetched directly also carries `baseEventId`, equal to its own id.
*/
export function isServerRecurrenceInstance(event: EventIdentity | null | undefined): boolean {
if (!event?.baseEventId) return false;
return event.baseEventId !== (event.originalId ?? event.id);
}
/**
* The store id under which the base event of `instance` would be addressed.
* Store ids end with the raw JMAP id (`<accountId>:<raw>` for a shared
* calendar, or just `<raw>`), so swap the trailing raw synthetic id for the
* base id and keep whatever namespace prefix is there.
*/
export function baseEventStoreId(instance: EventIdentity): string | null {
if (!isServerRecurrenceInstance(instance) || !instance.baseEventId) return null;
const raw = instance.originalId ?? instance.id;
if (!instance.id.endsWith(raw)) return instance.baseEventId;
return instance.id.slice(0, instance.id.length - raw.length) + instance.baseEventId;
}
/**
* The raw JMAP id of the stored event behind a store event: the base event
* of a server-expanded occurrence, the master of one expanded on the
* device, else the event's own id.
*/
export function seriesIdOf(event: EventIdentity): string {
return event.baseEventId || event.originalId || event.id;
}
/**
* An id for an event or occurrence that survives a refetch: synthetic ids
* are positional, so a server-expanded occurrence is named by its base
* event and recurrence id instead.
*/
export function stableOccurrenceKey(
event: EventIdentity & Partial<Pick<CalendarEvent, 'accountId' | 'recurrenceId'>>,
): string {
if (!isServerRecurrenceInstance(event)) return event.id;
return [event.accountId, event.baseEventId, event.recurrenceId].filter(Boolean).join(':');
}
type OverrideMap = NonNullable<CalendarEvent['recurrenceOverrides']>;
/**
* Given an expanded occurrence, find the key of its entry in the base
* event's `recurrenceOverrides`. Normally that is `recurrenceId`; a
* pre-0.16.20 server reports a moved occurrence's *new* start as its
* recurrenceId, in which case the entry is found by that start instead.
*/
export function resolveOverrideKey(
instance: Pick<CalendarEvent, 'start' | 'recurrenceId'>,
overrides: OverrideMap | null | undefined,
): string | null {
if (!instance.recurrenceId) return null;
if (!overrides || instance.recurrenceId in overrides) return instance.recurrenceId;
for (const [key, override] of Object.entries(overrides)) {
if (override && override.start === instance.start) return key;
}
return instance.recurrenceId;
}
function stableJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
if (value && typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableJson(v)}`).join(',')}}`;
}
return JSON.stringify(value ?? null);
}
/**
* An override's participants, each completed from the series participant
* with the same id or calendar address. The override decides who takes part;
* the series fills what it leaves out. Stalwart records an attendee's reply to
* one occurrence as an override naming the organizer under a new id and
* without a participation status, which would otherwise show the organizer as
* not having answered their own meeting.
*/
export function mergeOverrideParticipants(
series: CalendarEvent['participants'],
override: CalendarEvent['participants'],
): CalendarEvent['participants'] {
if (!override) return override;
const address = (p: Participant) => (p.calendarAddress ?? '').toLowerCase();
const from = series ?? {};
const merged: Record<string, Participant> = {};
for (const [id, participant] of Object.entries(override)) {
const match = id in from
? id
: Object.keys(from).find((key) => address(from[key]) && address(from[key]) === address(participant));
const defined = Object.fromEntries(Object.entries(participant).filter(([, v]) => v != null));
merged[match ?? id] = { ...(match ? from[match] : {}), ...defined } as Participant;
}
return merged;
}
/**
* Stalwart lists an occurrence twice under one id - once as the series
* generates it, once from its override - when the override ranks below the
* series (no or a lower SEQUENCE). It writes such overrides itself: an
* attendee's reply to one occurrence becomes, on the organizer's side, an
* override holding little more than that attendee. Collapse each pair into
* one occurrence: the fields the override defines (per the base event's
* overrides map) from the override, the rest from the series.
*/
function collapseDuplicateInstances(
instances: CalendarEvent[],
bases: ReadonlyMap<string, Partial<CalendarEvent>>,
): CalendarEvent[] {
const groups = new Map<string, CalendarEvent[]>();
for (const instance of instances) {
const group = groups.get(instance.id);
if (group) group.push(instance);
else groups.set(instance.id, [instance]);
}
if (groups.size === instances.length) return instances;
const collapse = (group: CalendarEvent[]): CalendarEvent => {
const first = group[0];
const base = first.baseEventId ? bases.get(first.baseEventId) : undefined;
const key = base ? resolveOverrideKey(first, base.recurrenceOverrides) : null;
const entry = key ? base?.recurrenceOverrides?.[key] as Record<string, unknown> | undefined : undefined;
if (!entry) {
return group.reduce((a, b) => (Object.keys(b).length > Object.keys(a).length ? b : a));
}
const overrideKeys = Object.keys(entry).filter((k) => k !== 'updated' && !k.includes('/'));
const score = (instance: CalendarEvent) => overrideKeys
.filter((k) => stableJson((instance as unknown as Record<string, unknown>)[k]) === stableJson(entry[k]))
.length;
const override = group.reduce((a, b) => (score(b) > score(a) ? b : a));
const series = group.find((instance) => instance !== override) ?? first;
const merged: Record<string, unknown> = { ...series };
for (const k of overrideKeys) {
merged[k] = k === 'participants'
? mergeOverrideParticipants(series.participants, override.participants)
: (override as unknown as Record<string, unknown>)[k];
}
return merged as unknown as CalendarEvent;
};
const seen = new Set<string>();
const result: CalendarEvent[] = [];
for (const instance of instances) {
if (seen.has(instance.id)) continue;
seen.add(instance.id);
const group = groups.get(instance.id)!;
result.push(group.length > 1 ? collapse(group) : instance);
}
return result;
}
/** An ISO 8601 duration ("PT1H30M", "P1DT2H", "P1W") in seconds; null when unparsable. */
function parseDurationSeconds(duration: string | null | undefined): number | null {
if (!duration) return null;
const match = /^-?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec(duration);
if (!match) return null;
const [, w, d, h, m, s] = match;
return (Number(w ?? 0) * 7 + Number(d ?? 0)) * 86400
+ Number(h ?? 0) * 3600 + Number(m ?? 0) * 60 + Number(s ?? 0);
}
function shiftIso(from: string | null | undefined, seconds: number): string | null {
if (!from) return null;
const ms = Date.parse(from);
if (Number.isNaN(ms)) return null;
return new Date(ms + seconds * 1000).toISOString();
}
/**
* Give expanded occurrences the base-event context the rest of the app
* expects from an occurrence (and that the device-side expansion copies
* from the master): the recurrence rules and overrides, the all-day flag,
* and - for an override that does not set its own duration - the inherited
* duration (RFC 8984 §4.3.4: an override is a patch on the base event; the
* server's own computed duration for such overrides has been seen to be
* wrong). Occurrences whose base event is not in `bases` are returned
* unchanged.
*/
export function hydrateRecurrenceInstances(
instances: CalendarEvent[],
bases: ReadonlyMap<string, Partial<CalendarEvent>>,
): CalendarEvent[] {
return collapseDuplicateInstances(instances, bases).map((instance) => {
if (!isServerRecurrenceInstance(instance) || !instance.recurrenceId) return instance;
const base = instance.baseEventId ? bases.get(instance.baseEventId) : undefined;
if (!base) return instance;
const hydrated: CalendarEvent = {
...instance,
recurrenceRules: base.recurrenceRules ?? null,
excludedRecurrenceRules: base.excludedRecurrenceRules ?? null,
recurrenceOverrides: base.recurrenceOverrides ?? null,
};
if (base.showWithoutTime) {
hydrated.showWithoutTime = true;
hydrated.timeZone = base.timeZone ?? null;
}
const overrideKey = resolveOverrideKey(instance, base.recurrenceOverrides);
if (overrideKey) hydrated.recurrenceId = overrideKey;
const override = overrideKey ? base.recurrenceOverrides?.[overrideKey] : undefined;
if (override && override.duration == null && base.duration && hydrated.duration !== base.duration) {
hydrated.duration = base.duration;
const seconds = parseDurationSeconds(base.duration);
if (seconds !== null && !hydrated.showWithoutTime) {
hydrated.utcEnd = shiftIso(hydrated.utcStart, seconds) ?? hydrated.utcEnd;
}
}
return hydrated;
});
}
+2
View File
@@ -151,6 +151,8 @@ export function buildFutureSeriesData(
delete raw.utcStart;
delete raw.utcEnd;
delete raw.originalId;
delete raw.baseEventId;
delete raw.recurrenceIdTimeZone;
delete raw.originalCalendarIds;
delete raw.accountId;
delete raw.isShared;
+134 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('../../api/calendar', () => ({
getCalendars: vi.fn(),
@@ -14,6 +14,10 @@ vi.mock('../../api/calendar', () => ({
batchCreateEvents: vi.fn(),
setDefaultCalendar: vi.fn(),
rsvpEvent: vi.fn(),
supportsSyntheticCalendarIds: vi.fn(async () => false),
queryExpandedEvents: vi.fn(),
hydrateExpandedOccurrences: vi.fn(async (events: unknown[]) => events),
resetSyntheticIdSupport: vi.fn(),
}));
vi.mock('@react-native-async-storage/async-storage', () => ({
@@ -48,6 +52,9 @@ const mockDeleteEvents = calendarApi.deleteEvents as ReturnType<typeof vi.fn>;
const mockSetDefaultCalendar = calendarApi.setDefaultCalendar as ReturnType<typeof vi.fn>;
const mockScan = calendarApi.scanCalendarObjects as ReturnType<typeof vi.fn>;
const mockRsvpEvent = calendarApi.rsvpEvent as ReturnType<typeof vi.fn>;
const mockSupportsSynthetic = calendarApi.supportsSyntheticCalendarIds as ReturnType<typeof vi.fn>;
const mockQueryExpanded = calendarApi.queryExpandedEvents as ReturnType<typeof vi.fn>;
const mockHydrate = calendarApi.hydrateExpandedOccurrences as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
@@ -675,6 +682,132 @@ describe('calendar-store', () => {
});
});
describe('server-side recurrence expansion', () => {
const RANGE = { after: '2026-03-01T00:00:00Z', before: '2026-03-31T00:00:00Z' };
const weekly = [{ frequency: 'weekly' }];
beforeEach(() => {
mockSupportsSynthetic.mockResolvedValue(true);
});
afterEach(() => {
mockSupportsSynthetic.mockResolvedValue(false);
mockHydrate.mockImplementation(async (events: unknown[]) => events);
});
it('lets a server that takes synthetic ids expand the range, keeping the asked-for calendars', async () => {
useCalendarStore.setState({ calendars: [{ id: 'cal-1' }, { id: 'cal-2' }] as any });
mockQueryExpanded.mockResolvedValue(['s1', 's2', 's3']);
mockGetEvents.mockResolvedValue([
{ id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00', start: '2026-03-02T09:00:00', calendarIds: { 'cal-1': true } },
{ id: 's2', baseEventId: 'base', recurrenceId: '2026-03-09T09:00:00', start: '2026-03-09T09:00:00', calendarIds: { 'cal-1': true } },
{ id: 's3', baseEventId: 'other', start: '2026-03-03T09:00:00', calendarIds: { 'cal-2': true } },
]);
mockHydrate.mockImplementation(async (events: any[]) =>
events.map((e) => (e.recurrenceId ? { ...e, recurrenceRules: weekly } : e)));
await useCalendarStore.getState().fetchEvents(['cal-1'], RANGE.after, RANGE.before);
expect(mockQueryExpanded).toHaveBeenCalledWith(RANGE.after, RANGE.before, undefined);
expect(mockQueryEvents).not.toHaveBeenCalled();
expect(mockGetEvents).toHaveBeenCalledWith(['s1', 's2', 's3'], undefined, { expanded: true });
expect(mockHydrate.mock.calls[0][0].map((e: any) => e.id)).toEqual(['s1', 's2']);
// Hydrated occurrences carry the rule but are not expanded again.
expect(useCalendarStore.getState().events.map((e) => e.id)).toEqual(['s1', 's2']);
});
it('expands on the device when the server will not expand the range', async () => {
useCalendarStore.setState({ calendars: [{ id: 'cal-1' }] as any });
mockQueryExpanded.mockResolvedValue(null);
mockQueryEvents.mockResolvedValue(['m']);
mockGetEvents.mockResolvedValue([
{ id: 'm', uid: 'u', start: '2026-03-02T09:00:00', recurrenceRules: weekly, calendarIds: { 'cal-1': true } },
]);
await useCalendarStore.getState().fetchEvents(['cal-1'], RANGE.after, RANGE.before);
expect(mockGetEvents).toHaveBeenCalledWith(['m'], undefined);
const events = useCalendarStore.getState().events;
expect(events.length).toBeGreaterThan(1);
expect(events.every((e) => e.originalId === 'm')).toBe(true);
});
it('sends a change to a server occurrence to its base event and reloads the range', async () => {
useCalendarStore.setState({
calendars: [{ id: 'cal-1' }] as any,
loadedRange: RANGE,
events: [
{ id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00', recurrenceRules: weekly } as any,
{ id: 's2', baseEventId: 'base', recurrenceId: '2026-03-09T09:00:00', recurrenceRules: weekly } as any,
],
});
mockUpdateEvent.mockResolvedValue(undefined);
mockQueryExpanded.mockResolvedValue([]);
await useCalendarStore.getState().updateEvent('s1', { 'recurrenceOverrides/2026-03-02T09:00:00': { title: 'x' } });
expect(mockUpdateEvent).toHaveBeenCalledWith(
'base', { 'recurrenceOverrides/2026-03-02T09:00:00': { title: 'x' } }, undefined, undefined,
);
expect(mockQueryExpanded).toHaveBeenCalled();
});
it('routes a single event\'s synthetic id to the stored event on every server', async () => {
useCalendarStore.setState({
calendars: [{ id: 'cal-1' }] as any,
events: [{ id: 's5', baseEventId: 'plain', title: 'Once' } as any],
});
mockDeleteEvents.mockResolvedValue(undefined);
await useCalendarStore.getState().deleteEvent('s5');
expect(mockDeleteEvents).toHaveBeenCalledWith(['plain'], undefined, undefined);
expect(useCalendarStore.getState().events).toEqual([]);
});
it('deletes a shared series through the master fetched for one of its occurrences', async () => {
const occurrence = {
id: 'acc-2:s1', originalId: 's1', accountId: 'acc-2', baseEventId: 'base',
recurrenceId: '2026-03-02T09:00:00', recurrenceRules: weekly, calendarIds: { 'acc-2:cal-9': true },
};
useCalendarStore.setState({
calendars: [{ id: 'acc-2:cal-9', originalId: 'cal-9', accountId: 'acc-2', isShared: true }] as any,
events: [
occurrence as any,
{ ...occurrence, id: 'acc-2:s2', originalId: 's2', recurrenceId: '2026-03-09T09:00:00' } as any,
{ id: 'mine' } as any,
],
});
mockGetEvents.mockResolvedValue([
{ id: 'base', start: '2026-03-02T09:00:00', recurrenceRules: weekly, calendarIds: { 'cal-9': true } },
]);
mockDeleteEvents.mockResolvedValue(undefined);
const master = await useCalendarStore.getState().getMasterEvent(occurrence as any);
expect(mockGetEvents).toHaveBeenCalledWith(['base'], 'acc-2');
expect(master?.id).toBe('acc-2:base');
await useCalendarStore.getState().deleteEvent(master!.id);
expect(mockDeleteEvents).toHaveBeenCalledWith(['base'], undefined, 'acc-2');
expect(useCalendarStore.getState().events.map((e) => e.id)).toEqual(['mine']);
});
it('answers an invitation for the whole series from a server occurrence', async () => {
useCalendarStore.setState({
events: [{
id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00', recurrenceRules: weekly,
organizerCalendarAddress: 'mailto:org@x', participants: { me: { participationStatus: 'needs-action' } },
} as any],
});
mockRsvpEvent.mockResolvedValue(undefined);
await useCalendarStore.getState().rsvpEvent('s1', 'me', 'accepted');
expect(mockRsvpEvent).toHaveBeenCalledWith('base', 'me', 'accepted', undefined, undefined);
});
});
describe('recurring series mutations', () => {
it('updateEvent on an occurrence patches the master and refetches the range', async () => {
useCalendarStore.setState({
+129 -30
View File
@@ -24,10 +24,19 @@ import {
rsvpEvent as apiRsvpEvent,
createCalendar as apiCreateCalendar,
setDefaultCalendar as apiSetDefaultCalendar,
supportsSyntheticCalendarIds,
queryExpandedEvents,
hydrateExpandedOccurrences,
resetSyntheticIdSupport,
} from '../api/calendar';
import { jmapClient } from '../api/jmap-client';
import { expandRecurringEvents } from '../lib/recurrence-expansion';
import { isRecurringSeriesMember } from '../lib/recurrence-overrides';
import {
baseEventStoreId,
isServerRecurrenceInstance,
seriesIdOf,
} from '../lib/recurrence-instances';
import { findTasksOnlyCalendarIds, isTaskLikeObject } from '../lib/calendar-component-detection';
// Does the event carry attendees the server should notify over iMIP? Used to
@@ -267,7 +276,9 @@ function unionRange(loaded: LoadedRange, after: string, before: string): LoadedR
/**
* The events of the given calendars (store ids) that overlap [after, before],
* mapped to store ids and with recurring series expanded over the window.
* mapped to store ids and with recurring series expanded over the window:
* by the server when it accepts the synthetic occurrence ids that gives
* (Stalwart >= 0.16.20, see lib/recurrence-instances), else on the device.
* Doesn't touch the store: fetchEvents keeps the result as the visible
* window, the reminders load their own upcoming window with it. Throws when
* the primary account fails; a failing shared account is skipped.
@@ -296,16 +307,37 @@ export async function loadEventsInRange(
}
if (groups.size === 0) groups.set(undefined, []);
const expand = !!after && !!before
&& await supportsSyntheticCalendarIds().catch(() => false);
const raw: CalendarEvent[] = [];
for (const [accountId, ids] of groups) {
if (accountId && isCalendarAccessDenied(accountId)) continue;
try {
// The window is sent as after/before so accounts with more than
// 1000 objects don't silently lose events and navigating past the
// loaded range doesn't re-download everything.
const eventIds = (await queryEvents(ids, after, before, accountId)) ?? [];
if (eventIds.length === 0) continue;
const fetched = (await fetchEvents(eventIds, accountId)) ?? [];
let fetched: CalendarEvent[] | null = null;
if (expand) {
// null: the server won't expand a range this long; the series are
// then fetched whole and expanded below.
const expandedIds = await queryExpandedEvents(after, before, accountId);
if (expandedIds) {
const occurrences = expandedIds.length > 0
? (await fetchEvents(expandedIds, accountId, { expanded: true })) ?? []
: [];
// The expanded query spans the account; keep the asked-for calendars.
const wanted = new Set(ids);
const inCalendars = wanted.size === 0
? occurrences
: occurrences.filter((e) => Object.keys(e.calendarIds || {}).some((id) => wanted.has(id)));
fetched = await hydrateExpandedOccurrences(inCalendars, accountId);
}
}
if (!fetched) {
// The window is sent as after/before so accounts with more than
// 1000 objects don't silently lose events and navigating past the
// loaded range doesn't re-download everything.
const eventIds = (await queryEvents(ids, after, before, accountId)) ?? [];
if (eventIds.length === 0) continue;
fetched = (await fetchEvents(eventIds, accountId)) ?? [];
}
raw.push(...fetched.map((e) => mapServerEventToStoreEvent(e, calendars, accountId)));
} catch (err) {
// A failing shared account must not hide the user's own events;
@@ -321,6 +353,49 @@ export async function loadEventsInRange(
return expandRecurringEvents(onlyEvents, after, before);
}
// Masters fetched by getMasterEvent: an expanded series has no master in
// `events`, so a mutation addressed at one looks up its account and raw id
// here.
const knownMasters = new Map<string, CalendarEvent>();
export interface MutationTarget {
storeEvent?: CalendarEvent;
/** Raw JMAP id the change is sent to. */
realId: string;
/** Owning account of a shared calendar's event. */
accountId?: string;
}
/**
* Where a change to the event with store id `id` goes, as a change to the
* stored event: an occurrence (expanded by the server or on the device)
* resolves to its base event, a master fetched by getMasterEvent to itself,
* and a base event that is not in the store borrows the account routing of
* a server-expanded occurrence of it in view. Mirrors the webmail's
* resolveMutationTarget for the whole-series case.
*/
export function resolveSeriesTarget(events: CalendarEvent[], id: string): MutationTarget {
const storeEvent = events.find((e) => e.id === id) ?? knownMasters.get(id);
if (storeEvent) {
return { storeEvent, realId: seriesIdOf(storeEvent), accountId: storeEvent.accountId };
}
const instance = events.find((e) => baseEventStoreId(e) === id);
if (instance?.baseEventId) return { realId: instance.baseEventId, accountId: instance.accountId };
return { realId: id };
}
/**
* Does the store show server-expanded occurrences of base event `baseId`?
* Their synthetic ids reshuffle when the series changes, so the range is
* reloaded after such a change.
*/
function hasServerOccurrencesOf(events: CalendarEvent[], baseId: string, accountId?: string): boolean {
return events.some((e) =>
isServerRecurrenceInstance(e)
&& e.baseEventId === baseId
&& (e.accountId ?? undefined) === (accountId ?? undefined));
}
export const useCalendarStore = create<CalendarState>()(
persist(
(set, get) => ({
@@ -529,9 +604,8 @@ export const useCalendarStore = create<CalendarState>()(
},
updateEvent: async (id, changes, options) => {
// Resolve client-side expanded occurrence IDs back to the master event ID.
const storeEvent = get().events.find((e) => e.id === id);
const realId = storeEvent?.originalId || id;
// Resolve expanded occurrence ids back to the stored (base) event.
const { storeEvent, realId, accountId } = resolveSeriesTarget(get().events, id);
// Remap namespaced (shared-calendar) store ids in calendarIds back to the
// raw server ids the owning account knows.
const patch: Record<string, unknown> = { ...changes };
@@ -552,48 +626,63 @@ export const useCalendarStore = create<CalendarState>()(
hasSchedulingParticipants(storeEvent)
? true
: undefined);
await apiUpdateEvent(realId, patch, schedule, storeEvent?.accountId);
const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent))
|| hasServerOccurrencesOf(get().events, realId, accountId);
await apiUpdateEvent(realId, patch, schedule, accountId);
set({
events: get().events.map((e) => (e.id === id ? { ...e, ...(changes as Partial<CalendarEvent>) } : e)),
});
// A series mutation (an occurrence override, a truncated/changed rule, a
// master edit) touches every expanded sibling, and the optimistic merge
// above only updated the tapped one — reload the visible range like
// webmail's refetchAfterOccurrenceMutation.
if (storeEvent && isRecurringSeriesMember(storeEvent)) {
// above only updated the tapped one; server-expanded occurrences also
// get new ids. Reload the visible range like webmail's
// refetchAfterOccurrenceMutation.
if (touchesSeries) {
await get().refresh();
}
},
deleteEvent: async (id) => {
const storeEvent = get().events.find((e) => e.id === id);
const realId = storeEvent?.originalId || id;
const { storeEvent, realId, accountId } = resolveSeriesTarget(get().events, id);
const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent))
|| hasServerOccurrencesOf(get().events, realId, accountId);
await apiDeleteEvents(
[realId],
hasSchedulingParticipants(storeEvent) ? true : undefined,
storeEvent?.accountId,
accountId,
);
knownMasters.delete(id);
// Destroying a master removes every expanded occurrence of it, not just
// the tapped one.
set({ events: get().events.filter((e) => e.id !== id && (e.originalId || e.id) !== realId) });
if (storeEvent && isRecurringSeriesMember(storeEvent)) {
set({
events: get().events.filter((e) =>
e.id !== id
&& !(seriesIdOf(e) === realId && (e.accountId ?? undefined) === (accountId ?? undefined))),
});
if (touchesSeries) {
await get().refresh();
}
},
getMasterEvent: async (event) => {
if (event.recurrenceRules?.length && !event.recurrenceId) return event;
// Client-side expansion replaces the master with its occurrences, each
// pointing back at the master's server id through originalId.
const realId = event.originalId || event.id;
if (event.recurrenceRules?.length && !event.recurrenceId && !isServerRecurrenceInstance(event)) {
return event;
}
// Expansion replaces the master with its occurrences, each pointing back
// at the stored event: through baseEventId when the server expanded it,
// through originalId when the device did.
const realId = seriesIdOf(event);
const inStore = get().events.find(
(e) => e.id === realId && !e.recurrenceId && !!e.recurrenceRules?.length,
(e) => !e.recurrenceId && !!e.recurrenceRules?.length && !isServerRecurrenceInstance(e)
&& seriesIdOf(e) === realId && (e.accountId ?? undefined) === (event.accountId ?? undefined),
);
if (inStore) return inStore;
const fetched = (await fetchEvents([realId], event.accountId)) ?? [];
const master = fetched[0];
if (!master) return null;
return mapServerEventToStoreEvent(master, get().calendars, event.accountId);
const mapped = mapServerEventToStoreEvent(master, get().calendars, event.accountId);
knownMasters.set(mapped.id, mapped);
return mapped;
},
rsvpEvent: async (eventId, participantId, status, replyTo, event) => {
@@ -603,16 +692,21 @@ export const useCalendarStore = create<CalendarState>()(
throw new Error('Invalid participant ID');
}
// An event outside the loaded window (an invitation looked up by UID)
// isn't in the store; the caller hands it over instead.
const storeEvent = get().events.find((e) => e.id === eventId) ?? event;
const realId = storeEvent?.originalId || eventId;
// isn't in the store; the caller hands it over instead. An occurrence
// answers for its whole series: the stored event is updated.
const target = resolveSeriesTarget(get().events, eventId);
const storeEvent = target.storeEvent ?? event;
const realId = target.storeEvent || !event ? target.realId : seriesIdOf(event);
const accountId = target.storeEvent ? target.accountId : event?.accountId ?? target.accountId;
const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent))
|| hasServerOccurrencesOf(get().events, realId, accountId);
// Repair events that are missing the organizer (e.g. imported ones) so
// Stalwart can route the REPLY; never touch an existing one.
const repair =
replyTo?.imip && storeEvent && !storeEvent.organizerCalendarAddress
? replyTo.imip
: undefined;
await apiRsvpEvent(realId, participantId, status, repair, storeEvent?.accountId);
await apiRsvpEvent(realId, participantId, status, repair, accountId);
set({
events: get().events.map((e) => {
if (e.id !== eventId || !e.participants?.[participantId]) return e;
@@ -625,6 +719,8 @@ export const useCalendarStore = create<CalendarState>()(
};
}),
});
// The other occurrences in view still show the old answer.
if (touchesSeries) await get().refresh();
},
importEvents: async (events, calendarId) => {
@@ -832,8 +928,11 @@ export const useCalendarStore = create<CalendarState>()(
},
reset: () => {
// A new session may grant access the old one lacked.
// A new session may grant access the old one lacked, or reach an
// upgraded server.
resetCalendarAccessDenied();
resetSyntheticIdSupport();
knownMasters.clear();
set({
calendars: [],
events: [],