feat: use Stalwart synthetic ids for recurring occurrences #140

This commit is contained in:
Linus Rath
2026-08-29 11:03:48 +02:00
parent cc8d7d78ea
commit d800689924
11 changed files with 1355 additions and 44 deletions
+37 -4
View File
@@ -53,6 +53,8 @@ import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calen
import { ResizeHandle } from "@/components/layout/resize-handle";
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
import { buildRecurrenceOverridePatch } from "@/lib/recurrence-overrides";
import { baseEventStoreId, isServerRecurrenceInstance } from "@/lib/recurrence-instances";
import { getClientByLocalAccountId } from "@/stores/client-registry";
import { getEventStartDate } from "@/lib/calendar-utils";
import { displayNow } from "@/lib/timezone";
import { useTaskStore } from "@/stores/task-store";
@@ -676,6 +678,27 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) {
if ((occurrence.recurrenceRules?.length ?? 0) > 0 && !occurrence.recurrenceId) {
return occurrence;
}
// An occurrence expanded by the server names its base event outright.
// Fetch it through the occurrence's own account and hand it back under
// the store id the base would have, so the store routes mutations on it
// through the same account (see resolveMutationTarget).
if (isServerRecurrenceInstance(occurrence) && occurrence.baseEventId) {
const accountClient = (occurrence.localAccountId && getClientByLocalAccountId(occurrence.localAccountId)) || client;
if (!accountClient) return null;
const master = await accountClient.getCalendarEvent(occurrence.baseEventId, occurrence.accountId);
if (!master) return null;
return {
...master,
id: baseEventStoreId(occurrence) ?? master.id,
originalId: master.id,
originalCalendarIds: master.calendarIds,
calendarIds: occurrence.calendarIds,
accountId: occurrence.accountId,
accountName: occurrence.accountName,
localAccountId: occurrence.localAccountId,
isShared: occurrence.isShared,
};
}
const master = events.find(e =>
e.uid === occurrence.uid && !e.recurrenceId && (e.recurrenceRules?.length ?? 0) > 0
);
@@ -848,8 +871,14 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) {
if (type === "edit" && updates) {
switch (scope) {
case "this": {
// Synthetic IDs (from expandRecurrences) can't be updated directly.
// Patch the master event's recurrenceOverrides instead.
if (isServerRecurrenceInstance(event)) {
// A server-expanded occurrence is written through its own
// (synthetic) id; the store handles the older-server fallback.
await updateEvent(client, event.id, updates, sendScheduling);
break;
}
// Client-side expanded occurrence: patch the master event's
// recurrenceOverrides instead.
const master = await findMasterEvent(event);
if (master && event.recurrenceId) {
const patchUpdates = buildRecurrenceOverridePatch(updates, event.recurrenceId);
@@ -917,8 +946,12 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) {
} else {
switch (scope) {
case "this": {
// Synthetic IDs (from expandRecurrences) can't be destroyed directly.
// Exclude the instance via recurrenceOverrides on the master event.
if (isServerRecurrenceInstance(event)) {
await deleteEvent(client, event.id, sendScheduling);
break;
}
// Client-side expanded occurrence: exclude the instance via
// recurrenceOverrides on the master event.
const delMaster = await findMasterEvent(event);
if (delMaster && event.recurrenceId) {
await updateEvent(
@@ -0,0 +1,215 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
import { SYNTHETIC_ID_PROBE } from '../recurrence-instances';
// CalendarEvent/query?expandRecurrences=true hands out one synthetic id per
// occurrence. Stalwart accepts those ids in CalendarEvent/set from 0.16.20
// on; older releases reject them. The client probes that once (an update on
// a synthetic id that cannot exist: `notFound` on a supporting server,
// `invalidProperties` before) and only asks for server-side expansion when
// the ids are usable, then pulls the series' recurrence rule / overrides
// from the base events so an occurrence looks like the client-expanded ones.
function makeSession() {
return {
capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:calendars': {} },
accounts: {
'acct-1': { name: 'test', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:calendars': {} } },
},
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1', 'urn:ietf:params:jmap:calendars': 'acct-1' },
apiUrl: 'https://mail.example.com/jmap/api',
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
eventSourceUrl: '',
};
}
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
const RANGE = { after: '2026-09-01T00:00:00', before: '2026-09-30T00:00:00' };
// What 0.16.19 returned for the probe series (see lib/recurrence-instances.ts).
const OCCURRENCE = {
id: 'maaaaab', baseEventId: 'b', uid: 'series@example.com', title: 'Daily standup',
start: '2026-09-08T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin',
utcStart: '2026-09-08T08:00:00Z', utcEnd: '2026-09-08T09:00:00Z',
recurrenceId: '2026-09-08T10:00:00', recurrenceIdTimeZone: 'Europe/Berlin',
calendarIds: { f: true }, isDraft: false, isOrigin: true, useDefaultAlerts: true,
};
const SINGLE = {
id: 'eaaaaad', baseEventId: 'd', uid: 'single@example.com', title: 'One off',
start: '2026-09-08T09:00:00', duration: 'PT30M', timeZone: 'Europe/Berlin',
utcStart: '2026-09-08T07:00:00Z', utcEnd: '2026-09-08T07:30:00Z',
calendarIds: { f: true }, isDraft: false, isOrigin: true, useDefaultAlerts: true,
};
const BASE = {
id: 'b', duration: 'PT1H', timeZone: 'Europe/Berlin',
recurrenceRule: { frequency: 'daily', count: 5 },
recurrenceOverrides: { '2026-09-10T10:00:00': { excluded: true } },
};
const MASTER = {
...BASE, baseEventId: 'b', uid: 'series@example.com', title: 'Daily standup',
start: '2026-09-07T10:00:00', utcStart: '2026-09-07T08:00:00Z', utcEnd: '2026-09-07T09:00:00Z',
calendarIds: { f: true }, isDraft: false, isOrigin: true, useDefaultAlerts: true,
};
type Call = { method: string; args: Record<string, unknown> };
describe('server-side recurrence expansion with synthetic ids', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
let client: JMAPClient;
let calls: Call[];
let serverSupportsSyntheticIds: boolean;
let probeFails: boolean;
beforeEach(async () => {
calls = [];
serverSupportsSyntheticIds = true;
probeFails = false;
fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession()));
client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123');
await client.connect();
fetchSpy.mockReset();
fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => {
const body = JSON.parse(String(init?.body ?? '{}'));
const methodResponses: unknown[] = [];
for (const [method, args, callId] of body.methodCalls ?? []) {
calls.push({ method, args });
methodResponses.push([...answer(method, args), callId]);
}
return jsonResponse({ methodResponses });
});
function answer(method: string, args: Record<string, unknown>): [string, unknown] {
const update = args.update as Record<string, unknown> | undefined;
if (method === 'CalendarEvent/set' && update?.[SYNTHETIC_ID_PROBE]) {
if (probeFails) throw new Error('network down');
const failure = serverSupportsSyntheticIds
? { type: 'notFound' }
: { type: 'invalidProperties', description: 'Updating synthetic ids is not yet supported.', properties: ['id'] };
return ['CalendarEvent/set', { notUpdated: { [SYNTHETIC_ID_PROBE]: failure } }];
}
if (method === 'CalendarEvent/set') {
return ['CalendarEvent/set', { updated: Object.fromEntries(Object.keys(update ?? {}).map((id) => [id, null])) }];
}
if (method === 'CalendarEvent/query') {
const ids = args.expandRecurrences ? ['maaaaab', 'eaaaaad'] : ['b', 'd'];
return ['CalendarEvent/query', { ids }];
}
if (method === 'CalendarEvent/get') {
const ids = args.ids as string[];
const byId: Record<string, unknown> = { maaaaab: OCCURRENCE, eaaaaad: SINGLE, d: { ...SINGLE, id: 'd' } };
// The base-event hydration get asks only for recurrence properties.
byId.b = (args.properties as string[]).includes('title') ? MASTER : BASE;
return ['CalendarEvent/get', { list: ids.map((id) => byId[id]).filter(Boolean) }];
}
return ['Calendar/get', { list: [] }];
}
});
afterEach(() => {
client.disconnect();
fetchSpy.mockRestore();
});
const probes = () => calls.filter((c) => c.method === 'CalendarEvent/set' && c.args.update && SYNTHETIC_ID_PROBE in (c.args.update as object));
const queries = () => calls.filter((c) => c.method === 'CalendarEvent/query');
const gets = () => calls.filter((c) => c.method === 'CalendarEvent/get');
it('probes once, then asks a supporting server to expand recurrences', async () => {
const events = await client.queryCalendarEvents(RANGE);
await client.queryCalendarEvents(RANGE);
expect(probes()).toHaveLength(1);
// The probe is a side-effect-free update of an id that cannot exist.
expect(probes()[0].args).toMatchObject({ accountId: 'acct-1', update: { [SYNTHETIC_ID_PROBE]: {} } });
expect(queries()).toHaveLength(2);
for (const query of queries()) {
expect(query.args.expandRecurrences).toBe(true);
expect(query.args.filter).toEqual(RANGE);
}
expect(events.map((e) => e.id)).toEqual(['maaaaab', 'eaaaaad']);
});
it('lets the probe ride along in the first Calendar/get instead of a request of its own', async () => {
await client.getAllCalendars();
const events = await client.queryCalendarEvents(RANGE);
// One request carried both Calendar/get and the probe; the range query
// then already knew the verdict and asked for server-side expansion.
expect(probes()).toHaveLength(1);
const firstRequest = JSON.parse(String((fetchSpy.mock.calls[0][1] as RequestInit).body));
expect(firstRequest.methodCalls.map((c: unknown[]) => c[0])).toEqual(['Calendar/get', 'CalendarEvent/set']);
expect(queries()[0].args.expandRecurrences).toBe(true);
expect(events.map((e) => e.id)).toEqual(['maaaaab', 'eaaaaad']);
});
it('hydrates occurrences with the series context of their base event', async () => {
const events = await client.queryCalendarEvents(RANGE);
// One extra get for the distinct base ids of recurring occurrences only
// (the non-recurring one needs nothing), asking for recurrence data only.
const baseGet = gets().find((g) => (g.args.ids as string[]).includes('b'));
expect(baseGet).toBeDefined();
expect(baseGet!.args.ids).toEqual(['b']);
expect(baseGet!.args.properties).toEqual(expect.arrayContaining(['recurrenceRule', 'recurrenceOverrides', 'showWithoutTime']));
expect(baseGet!.args.properties).not.toContain('title');
const occurrence = events.find((e) => e.id === 'maaaaab')!;
expect(occurrence.baseEventId).toBe('b');
expect(occurrence.recurrenceId).toBe('2026-09-08T10:00:00');
expect(occurrence.recurrenceRules).toEqual([{ frequency: 'daily', count: 5 }]);
expect(occurrence.recurrenceOverrides).toEqual({ '2026-09-10T10:00:00': { excluded: true } });
expect(occurrence.start).toBe('2026-09-08T10:00:00');
const single = events.find((e) => e.id === 'eaaaaad')!;
expect(single.baseEventId).toBe('d');
expect(single.recurrenceRules ?? null).toBeNull();
});
it('keeps client-side expansion (no expandRecurrences) when the server rejects synthetic ids', async () => {
serverSupportsSyntheticIds = false;
const events = await client.queryCalendarEvents(RANGE);
await client.queryCalendarEvents(RANGE);
expect(probes()).toHaveLength(1);
for (const query of queries()) {
expect(query.args.expandRecurrences).toBeUndefined();
}
// Raw base events with real ids come back for the browser to expand -
// one full get per query, no base-event hydration get.
expect(events.map((e) => e.id)).toEqual(['b', 'd']);
expect(events[0].recurrenceRules).toEqual([{ frequency: 'daily', count: 5 }]);
expect(gets()).toHaveLength(2);
for (const get of gets()) {
expect(get.args.properties).toContain('title');
}
});
it('treats a failed probe as unsupported', async () => {
probeFails = true;
const events = await client.queryCalendarEvents(RANGE);
expect(queries()[0].args.expandRecurrences).toBeUndefined();
expect(events.map((e) => e.id)).toEqual(['b', 'd']);
});
it('never expands a query without both range bounds (the server requires them)', async () => {
await client.queryCalendarEvents({ uid: 'series@example.com' });
await client.queryCalendarEvents({ after: RANGE.after });
expect(probes()).toHaveLength(0);
for (const query of queries()) {
expect(query.args.expandRecurrences).toBeUndefined();
}
});
it('strips baseEventId from an update patch', async () => {
await client.updateCalendarEvent('maaaaab', { title: 'Renamed', baseEventId: 'b' });
const update = calls.find((c) => c.method === 'CalendarEvent/set')!;
expect(update.args.update).toEqual({ maaaaab: { title: 'Renamed' } });
});
});
@@ -99,6 +99,9 @@ describe('JMAPClient first-touch ordering (#907)', () => {
await expect(calendars).resolves.toEqual([]);
await flush();
// The synthetic-id probe (lib/recurrence-instances.ts) rides along in
// the Calendar/get request, so the query needs no request of its own first.
expect(onWire[0].methods).toEqual(['Calendar/get', 'CalendarEvent/set']);
expect(onWire.map((p) => p.methods[0])).toEqual(['Calendar/get', 'CalendarEvent/query']);
onWire[1].resolve(emptyResponse('CalendarEvent/query'));
await expect(events).resolves.toEqual([]);
+276
View File
@@ -0,0 +1,276 @@
import { describe, it, expect } from 'vitest';
import type { CalendarEvent } from '@/lib/jmap/types';
import {
SYNTHETIC_ID_PROBE,
baseEventStoreId,
buildFallbackExcludePatch,
buildFallbackOverridePatch,
buildOccurrencePatch,
hydrateRecurrenceInstances,
isServerRecurrenceInstance,
isSyntheticIdMutationUnsupported,
resolveOverrideKey,
} from '../recurrence-instances';
// Shapes below mirror what Stalwart 0.16.19 returned for
// CalendarEvent/query?expandRecurrences=true + CalendarEvent/get: the
// occurrence carries baseEventId/recurrenceId but no recurrence rule or
// overrides, all-day occurrences lose showWithoutTime and get the request
// time zone, and a moved override reports its new start as recurrenceId.
function instance(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
id: 'maaaaab',
baseEventId: 'b',
uid: 'series@example.com',
title: 'Daily standup',
start: '2026-09-08T10:00:00',
duration: 'PT1H',
timeZone: 'Europe/Berlin',
utcStart: '2026-09-08T08:00:00Z',
utcEnd: '2026-09-08T09:00:00Z',
recurrenceId: '2026-09-08T10:00:00',
recurrenceIdTimeZone: 'Europe/Berlin',
recurrenceRules: null,
recurrenceOverrides: null,
excludedRecurrenceRules: null,
showWithoutTime: false,
calendarIds: { f: true },
...overrides,
} as CalendarEvent;
}
const dailyRule = [{ '@type': 'RecurrenceRule', frequency: 'daily', count: 5 }] as unknown as CalendarEvent['recurrenceRules'];
describe('isServerRecurrenceInstance', () => {
it('is true for a synthetic occurrence id that differs from its base', () => {
expect(isServerRecurrenceInstance({ id: 'maaaaab', baseEventId: 'b' })).toBe(true);
});
it('is false for a base event, which reports itself as its own base', () => {
expect(isServerRecurrenceInstance({ id: 'b', baseEventId: 'b' })).toBe(false);
});
it('compares the raw id when the store namespaced it', () => {
expect(isServerRecurrenceInstance({ id: 'acct:maaaaab', originalId: 'maaaaab', baseEventId: 'b' })).toBe(true);
expect(isServerRecurrenceInstance({ id: 'acct:b', originalId: 'b', baseEventId: 'b' })).toBe(false);
});
it('is false without a baseEventId (older servers, demo data, client-side occurrences)', () => {
expect(isServerRecurrenceInstance({ id: 'b:2026-09-08T10:00:00', originalId: 'b' })).toBe(false);
expect(isServerRecurrenceInstance({ id: 'x', baseEventId: null })).toBe(false);
expect(isServerRecurrenceInstance(null)).toBe(false);
});
});
describe('baseEventStoreId', () => {
it('keeps the namespace prefix and swaps in the base id', () => {
expect(baseEventStoreId({ id: 'acct:maaaaab', originalId: 'maaaaab', baseEventId: 'b' })).toBe('acct:b');
expect(baseEventStoreId({ id: 'A@h::maaaaab', originalId: 'maaaaab', baseEventId: 'b' })).toBe('A@h::b');
expect(baseEventStoreId({ id: 'maaaaab', originalId: 'maaaaab', baseEventId: 'b' })).toBe('b');
});
it('is null for anything that is not an expanded occurrence', () => {
expect(baseEventStoreId({ id: 'b', baseEventId: 'b' })).toBeNull();
expect(baseEventStoreId({ id: 'b:2026-09-08T10:00:00', originalId: 'b' })).toBeNull();
});
});
describe('isSyntheticIdMutationUnsupported', () => {
it('recognises the pre-0.16.20 rejections for update and destroy', () => {
expect(isSyntheticIdMutationUnsupported(new Error('Updating synthetic ids is not yet supported.'))).toBe(true);
expect(isSyntheticIdMutationUnsupported(new Error('Deleting synthetic ids is not yet supported.'))).toBe(true);
expect(isSyntheticIdMutationUnsupported('Updating synthetic ids is not yet supported.')).toBe(true);
});
it('does not match other failures', () => {
expect(isSyntheticIdMutationUnsupported(new Error('Failed to update calendar event'))).toBe(false);
expect(isSyntheticIdMutationUnsupported(new Error('This property cannot be modified on a single occurrence.'))).toBe(false);
expect(isSyntheticIdMutationUnsupported(undefined)).toBe(false);
expect(isSyntheticIdMutationUnsupported({ message: 'synthetic ids not supported' })).toBe(false);
});
});
describe('SYNTHETIC_ID_PROBE', () => {
it('is Id::from_parts(1, u32::MAX) in Stalwart base32 (a-z then 792013)', () => {
// 0x1_FFFF_FFFF in 7 groups of 5 bits: 00111 then six times 11111.
const alphabet = 'abcdefghijklmnopqrstuvwxyz792013';
const value = 2 ** 32 + 0xffffffff; // fits in a double exactly
let encoded = '';
for (let shift = 30; shift >= 0; shift -= 5) {
encoded += alphabet[Math.floor(value / 2 ** shift) % 32];
}
expect(SYNTHETIC_ID_PROBE).toBe(encoded);
});
});
describe('buildOccurrencePatch', () => {
it('drops identity, whole-series and per-event keys, keeps the rest', () => {
const patch = buildOccurrencePatch({
id: 'maaaaab',
uid: 'series@example.com',
'@type': 'Event',
baseEventId: 'b',
calendarIds: { f: true },
recurrenceRules: dailyRule,
recurrenceOverrides: {},
excludedRecurrenceRules: null,
recurrenceId: '2026-09-08T10:00:00',
recurrenceIdTimeZone: 'Europe/Berlin',
isDraft: false,
isOrigin: true,
utcStart: '2026-09-08T08:00:00Z',
utcEnd: '2026-09-08T09:00:00Z',
useDefaultAlerts: true,
mayInviteSelf: true,
mayInviteOthers: false,
hideAttendees: false,
title: 'Renamed',
start: '2026-09-08T11:00:00',
duration: 'PT2H',
timeZone: 'Europe/Berlin',
showWithoutTime: false,
locations: { loc1: { '@type': 'Location', name: 'Room B' } },
} as unknown as Partial<CalendarEvent>);
expect(patch).toEqual({
title: 'Renamed',
start: '2026-09-08T11:00:00',
duration: 'PT2H',
timeZone: 'Europe/Berlin',
showWithoutTime: false,
locations: { loc1: { '@type': 'Location', name: 'Room B' } },
});
});
it('applies the same rule to JSON-pointer patches by their first segment', () => {
const patch = buildOccurrencePatch({
'locations/loc1/name': 'Room B',
'participants/p1/participationStatus': 'accepted',
'calendarIds/f': true,
'recurrenceOverrides/2026-09-09T10:00:00': { excluded: true },
} as unknown as Partial<CalendarEvent>);
expect(patch).toEqual({
'locations/loc1/name': 'Room B',
'participants/p1/participationStatus': 'accepted',
});
});
});
describe('resolveOverrideKey', () => {
it('uses recurrenceId when the base event has an override under it (or none at all)', () => {
const occ = { start: '2026-09-08T10:00:00', recurrenceId: '2026-09-08T10:00:00' };
expect(resolveOverrideKey(occ, null)).toBe('2026-09-08T10:00:00');
expect(resolveOverrideKey(occ, { '2026-09-08T10:00:00': { title: 'x' } })).toBe('2026-09-08T10:00:00');
expect(resolveOverrideKey(occ, { '2026-09-07T10:00:00': { title: 'x' } })).toBe('2026-09-08T10:00:00');
});
it('finds a moved override by its start when the server reported the new start as recurrenceId', () => {
const moved = { start: '2026-09-09T14:00:00', recurrenceId: '2026-09-09T14:00:00' };
const overrides = { '2026-09-09T10:00:00': { start: '2026-09-09T14:00:00', title: 'Moved' } };
expect(resolveOverrideKey(moved, overrides)).toBe('2026-09-09T10:00:00');
});
it('is null without a recurrenceId', () => {
expect(resolveOverrideKey({ start: '2026-09-08T09:00:00', recurrenceId: null }, null)).toBeNull();
});
});
describe('hydrateRecurrenceInstances', () => {
const base: Partial<CalendarEvent> = {
id: 'b',
duration: 'PT1H',
timeZone: 'Europe/Berlin',
recurrenceRules: dailyRule,
excludedRecurrenceRules: null,
recurrenceOverrides: {
'2026-09-09T10:00:00': { start: '2026-09-09T14:00:00', title: 'Moved' },
'2026-09-10T10:00:00': { start: '2026-09-10T10:00:00', duration: 'PT3H', title: 'Long one' },
},
};
const bases = new Map<string, Partial<CalendarEvent>>([[ 'b', base ]]);
it('copies the series context from the base event', () => {
const [out] = hydrateRecurrenceInstances([instance()], bases);
expect(out.recurrenceRules).toEqual(dailyRule);
expect(out.recurrenceOverrides).toEqual(base.recurrenceOverrides);
expect(out.excludedRecurrenceRules).toBeNull();
// Own occurrence data is untouched.
expect(out.id).toBe('maaaaab');
expect(out.baseEventId).toBe('b');
expect(out.start).toBe('2026-09-08T10:00:00');
expect(out.recurrenceId).toBe('2026-09-08T10:00:00');
expect(out.duration).toBe('PT1H');
});
it('restores showWithoutTime and the floating time zone of an all-day series', () => {
const allDayBase = new Map([['c', { id: 'c', duration: 'P1D', timeZone: null, showWithoutTime: true, recurrenceRules: dailyRule }]]);
const [out] = hydrateRecurrenceInstances([instance({
id: 'maaaaac', baseEventId: 'c', start: '2026-09-08T00:00:00', duration: 'P1D',
timeZone: 'Europe/Berlin', recurrenceId: '2026-09-08T00:00:00',
})], allDayBase);
expect(out.showWithoutTime).toBe(true);
expect(out.timeZone).toBeNull();
});
it('repairs the recurrenceId of a moved override reported under its new start', () => {
const [out] = hydrateRecurrenceInstances([instance({
id: 'eaaaaab', start: '2026-09-09T14:00:00', recurrenceId: '2026-09-09T14:00:00', title: 'Moved',
utcStart: '2026-09-09T12:00:00Z', utcEnd: '2026-09-09T21:59:59Z', duration: 'PT9H59M59S',
})], bases);
expect(out.recurrenceId).toBe('2026-09-09T10:00:00');
// The override sets no duration: the occurrence inherits the base's.
expect(out.duration).toBe('PT1H');
expect(out.utcEnd).toBe('2026-09-09T13:00:00.000Z');
});
it('keeps a duration the override sets itself', () => {
const [out] = hydrateRecurrenceInstances([instance({
id: 'qaaaaab', start: '2026-09-10T10:00:00', recurrenceId: '2026-09-10T10:00:00', duration: 'PT3H',
utcEnd: '2026-09-10T11:00:00Z',
})], bases);
expect(out.duration).toBe('PT3H');
expect(out.utcEnd).toBe('2026-09-10T11:00:00Z');
});
it('leaves non-recurring occurrences and occurrences of unknown bases alone', () => {
const single = instance({ id: 'eaaaaad', baseEventId: 'd', recurrenceId: null });
const orphan = instance({ id: 'maaaaaz', baseEventId: 'z' });
const base = instance({ id: 'b', baseEventId: 'b', recurrenceId: null });
expect(hydrateRecurrenceInstances([single, orphan, base], bases)).toEqual([single, orphan, base]);
});
});
describe('buildFallbackOverridePatch', () => {
it('writes the whole override object, pinning start/duration and keeping existing override fields', () => {
const occ = instance({
start: '2026-09-09T14:00:00', recurrenceId: '2026-09-09T10:00:00', duration: 'PT1H',
recurrenceOverrides: { '2026-09-09T10:00:00': { start: '2026-09-09T14:00:00', title: 'Moved', updated: '2026-08-01T00:00:00Z' } },
});
expect(buildFallbackOverridePatch(occ, { title: 'Moved, renamed', calendarIds: { g: true } } as Partial<CalendarEvent>)).toEqual({
'recurrenceOverrides/2026-09-09T10:00:00': {
start: '2026-09-09T14:00:00',
duration: 'PT1H',
title: 'Moved, renamed',
},
});
});
it('lets the patch override the pinned start (a drag) and creates a fresh override otherwise', () => {
expect(buildFallbackOverridePatch(instance(), { start: '2026-09-08T12:00:00' })).toEqual({
'recurrenceOverrides/2026-09-08T10:00:00': { start: '2026-09-08T12:00:00', duration: 'PT1H' },
});
});
it('is null for something that is not an occurrence of a series', () => {
expect(buildFallbackOverridePatch(instance({ recurrenceId: null }), { title: 'x' })).toBeNull();
});
});
describe('buildFallbackExcludePatch', () => {
it('excludes the occurrence under its override key', () => {
expect(buildFallbackExcludePatch(instance())).toEqual({
'recurrenceOverrides/2026-09-08T10:00:00': { excluded: true },
});
expect(buildFallbackExcludePatch(instance({ recurrenceId: null }))).toBeNull();
});
});
+1 -1
View File
@@ -20,7 +20,7 @@ function isMidnightValue(value: string): boolean {
return match[1] === '00' && match[2] === '00' && (match[3] ?? '00') === '00';
}
function parseDurationSeconds(duration: string | undefined): number | null {
export function parseDurationSeconds(duration: string | undefined): number | null {
if (!duration) {
return null;
}
+125 -12
View File
@@ -8,6 +8,7 @@ import { keywordPointer } from "./patch-pointer";
import { FirstTouchGate } from "./first-touch-gate";
import { debug } from "@/lib/debug";
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
import { SYNTHETIC_ID_PROBE, RECURRENCE_BASE_PROPERTIES, hydrateRecurrenceInstances, isServerRecurrenceInstance } from "@/lib/recurrence-instances";
import { findTasksOnlyCalendarIds, isTaskLikeObject, type ScannedCalendarObject } from "@/lib/calendar-component-detection";
import { DEFAULT_CALENDAR_COMPONENTS, mkCalendarCollection, newCalendarCollectionName } from "@/lib/webdav/calendar-collection";
import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox";
@@ -320,6 +321,7 @@ const CALENDAR_EVENT_PROPERTIES = [
'relatedTo',
'isDraft',
'isOrigin',
'baseEventId',
] as const;
// Task-specific properties for CalendarEvent/get when fetching Task objects
@@ -5206,12 +5208,27 @@ export class JMAPClient implements IJMAPClient {
}
}
/**
* Calendar/get for one account. The session's first one on the primary
* calendar account also carries the synthetic-id probe (see
* supportsSyntheticCalendarIds), so the range query that follows needs no
* round trip of its own for it.
*/
private requestCalendars(accountId: string): Promise<JMAPResponse> {
const methodCalls: JMAPMethodCall[] = [
["Calendar/get", { accountId, properties: CALENDAR_PROPERTIES }, "0"],
];
const carriesProbe = !this.syntheticCalendarIdSupport && accountId === this.getCalendarsAccountId();
if (carriesProbe) methodCalls.push(this.syntheticIdProbeCall(accountId));
const pending = this.request(methodCalls, this.calendarUsing());
if (carriesProbe) this.rememberSyntheticIdProbe(pending);
return pending;
}
async getCalendars(): Promise<Calendar[]> {
try {
const accountId = this.getCalendarsAccountId();
const response = await this.request([
["Calendar/get", { accountId, properties: CALENDAR_PROPERTIES }, "0"]
], this.calendarUsing());
const response = await this.requestCalendars(accountId);
if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
const calendars = (response.methodResponses[0][1].list || []) as Calendar[];
@@ -5292,9 +5309,7 @@ export class JMAPClient implements IJMAPClient {
const account = this.accounts[accountId];
try {
const response = await this.request([
["Calendar/get", { accountId, properties: CALENDAR_PROPERTIES }, "0"]
], this.calendarUsing());
const response = await this.requestCalendars(accountId);
if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
const rawCalendars = (response.methodResponses[0][1].list || []) as Calendar[];
@@ -5585,6 +5600,57 @@ export class JMAPClient implements IJMAPClient {
// for why the fan-out has to probe on suspicion).
private calendarAccessDenied = new Set<string>();
// Result of the one-off synthetic-id capability probe, shared by every
// range query on this client (see supportsSyntheticCalendarIds).
private syntheticCalendarIdSupport: Promise<boolean> | null = null;
/**
* Whether CalendarEvent/set accepts the synthetic ids that
* CalendarEvent/query?expandRecurrences=true hands out (Stalwart >= 0.16.20).
*
* Probed once per client with a side-effect-free update of a synthetic id
* whose event cannot exist (see SYNTHETIC_ID_PROBE): a server that still
* rejects synthetic ids answers `invalidProperties` before looking the
* event up, one that supports them answers `notFound`. Anything else
* (transport error, non-Stalwart server) counts as unsupported so the
* client-side expansion keeps being used. The session's first Calendar/get
* carries the probe along (getCalendars), so the usual calendar start-up
* pays no extra round trip; a range query that comes first probes itself.
*/
async supportsSyntheticCalendarIds(): Promise<boolean> {
if (!this.syntheticCalendarIdSupport) {
const accountId = this.getCalendarsAccountId();
this.rememberSyntheticIdProbe(
this.request([this.syntheticIdProbeCall(accountId)], this.calendarUsing()),
);
}
return this.syntheticCalendarIdSupport!;
}
private static readonly SYNTHETIC_ID_PROBE_CALL_ID = 'synthetic-id-probe';
private syntheticIdProbeCall(accountId: string): JMAPMethodCall {
return ["CalendarEvent/set", { accountId, update: { [SYNTHETIC_ID_PROBE]: {} } }, JMAPClient.SYNTHETIC_ID_PROBE_CALL_ID];
}
/** Derive (and cache) the probe verdict from a response that carried the probe call. */
private rememberSyntheticIdProbe(pending: Promise<JMAPResponse>): void {
this.syntheticCalendarIdSupport = pending.then(
(response) => {
const entry = response.methodResponses?.find((r) => r[2] === JMAPClient.SYNTHETIC_ID_PROBE_CALL_ID);
if (!entry || entry[0] !== "CalendarEvent/set") return false;
const failure = (entry[1] as { notUpdated?: Record<string, { type?: string }> } | undefined)?.notUpdated?.[SYNTHETIC_ID_PROBE];
const supported = failure?.type === 'notFound';
debug.log('calendar', 'Synthetic calendar id probe', { supported, failure: failure ?? null });
return supported;
},
(error) => {
debug.log('calendar', 'Synthetic calendar id probe failed; using client-side recurrence expansion', error);
return false;
},
);
}
async queryAllCalendarEvents(
filter: CalendarEventFilter,
sort?: Array<{ property: string; isAscending: boolean }>,
@@ -5647,9 +5713,16 @@ export class JMAPClient implements IJMAPClient {
if (timeZone) {
queryArgs.timeZone = timeZone;
}
// NOTE: We do NOT use expandRecurrences because Stalwart returns synthetic
// IDs that cannot be used for CalendarEvent/set (update/destroy).
// Recurrence expansion is done client-side instead.
// Let the server expand recurring series into one synthetic id per
// occurrence when it also accepts those ids in CalendarEvent/set
// (Stalwart >= 0.16.20; the server needs both range bounds for it).
// Older servers keep the client-side expansion in
// lib/recurrence-expansion.ts, which runs on whatever comes back.
const expandRecurrences = Boolean(filter.after && filter.before)
&& await this.supportsSyntheticCalendarIds();
if (expandRecurrences) {
queryArgs.expandRecurrences = true;
}
if (sort) {
queryArgs.sort = sort;
}
@@ -5693,9 +5766,12 @@ export class JMAPClient implements IJMAPClient {
}
}
const filtered = allEvents
const normalized = allEvents
.filter((event) => !isTaskObject(event))
.map((event) => normalizeCalendarEventLike(event));
const filtered = expandRecurrences
? await this.hydrateExpandedOccurrences(normalized, accountId, timeZone)
: normalized;
const eventsWithParticipants = filtered.filter(e => e.participants && Object.keys(e.participants).length > 0);
debug.log('calendar', 'queryCalendarEvents participant summary', {
@@ -5729,6 +5805,43 @@ export class JMAPClient implements IJMAPClient {
}
}
/**
* 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 client-side expansion
* used to copy from its master.
*/
private async hydrateExpandedOccurrences(
events: CalendarEvent[],
accountId: string,
timeZone?: 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 bases = new Map<string, Partial<CalendarEvent>>();
for (const batch of batched(baseIds, this.getMaxObjectsInGet())) {
const response = await this.request([
["CalendarEvent/get", {
accountId,
properties: [...RECURRENCE_BASE_PROPERTIES],
ids: batch,
...(timeZone ? { timeZone } : {}),
}, "0"],
], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] !== "CalendarEvent/get") continue;
for (const base of (response.methodResponses[0][1].list || []) as Partial<CalendarEvent>[]) {
if (base.id) bases.set(base.id, normalizeCalendarEventLike(base));
}
}
return hydrateRecurrenceInstances(events, bases);
}
async getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null> {
try {
const accountId = targetAccountId || this.getCalendarsAccountId();
@@ -5757,7 +5870,7 @@ export class JMAPClient implements IJMAPClient {
const accountId = targetAccountId || this.getCalendarsAccountId();
// Strip client-only shared fields before sending to JMAP
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent;
const { originalId: _oi, baseEventId: _be, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent;
cleanRecurrenceRules(cleanEvent as unknown as Record<string, unknown>);
debug.group('CalendarEvent/create', 'calendar');
@@ -5938,7 +6051,7 @@ export class JMAPClient implements IJMAPClient {
const accountId = targetAccountId || this.getCalendarsAccountId();
// Strip client-only and server-immutable fields before sending to JMAP
const { id: _id, uid: _uid, '@type': _typ, created: _cr, updated: _up, sequence: _sq, isOrigin: _io, isDraft: _idr, originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent;
const { id: _id, uid: _uid, '@type': _typ, created: _cr, updated: _up, sequence: _sq, isOrigin: _io, isDraft: _idr, originalId: _oi, baseEventId: _be, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent;
cleanRecurrenceRules(cleanUpdates as unknown as Record<string, unknown>);
const setArgs: Record<string, unknown> = {
+5
View File
@@ -593,6 +593,11 @@ export interface CalendarRights {
export interface CalendarEvent {
id: string;
originalId?: string;
// Set by the server on every CalendarEvent/get result. For an occurrence
// handed out by CalendarEvent/query?expandRecurrences=true (a "synthetic"
// id) this is the id of the stored base event; for a base event it equals
// `id`. See lib/recurrence-instances.ts.
baseEventId?: string | null;
calendarIds: Record<string, boolean>;
originalCalendarIds?: Record<string, boolean>;
accountId?: string;
+11 -5
View File
@@ -5,14 +5,17 @@
* recurrence rule interpretation algorithm with full byX filtering,
* implicit byX property addition, and bySetPosition support.
*
* 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
* in the browser.
* 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): raw events (with real,
* mutable ids) are fetched and recurring series are expanded into individual
* occurrences in the browser. Occurrences a server already expanded (no
* recurrence rule of their own) pass through unchanged.
*/
import { parseISO, format, addDays, addWeeks, addMonths, addYears, differenceInCalendarDays } from 'date-fns';
import type { CalendarEvent, CalendarRecurrenceRule, CalendarNDay } from '@/lib/jmap/types';
import { isServerRecurrenceInstance } from '@/lib/recurrence-instances';
const DAY_INDEX: Record<string, number> = { su: 0, mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6 };
const INDEX_TO_DAY: string[] = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'];
@@ -52,7 +55,10 @@ export function expandRecurringEvents(
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.ts
// hydrates it with that); never expand it again.
if (!event.recurrenceRules?.length || isServerRecurrenceInstance(event)) {
result.push(event);
continue;
}
+232
View File
@@ -0,0 +1,232 @@
/**
* Server-side recurrence instances ("synthetic ids").
*
* `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`: an
* update on a synthetic id writes a `recurrenceOverrides` entry for that one
* occurrence, a destroy excludes it. Older servers reject both with
* `invalidProperties` ("… synthetic ids is not yet supported."), in which
* case Bulwark keeps expanding recurrences in the browser
* (lib/recurrence-expansion.ts) and patches the base event itself.
*
* What an expanded occurrence looks like (verified 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 visible range has to be refetched after mutating one.
*/
import type { CalendarEvent } from '@/lib/jmap/types';
import { parseDurationSeconds } from '@/lib/calendar-event-normalization';
import { RECURRENCE_OVERRIDE_IMMUTABLE_KEYS } from '@/lib/recurrence-overrides';
/**
* 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;
/**
* Properties Stalwart refuses on a single occurrence ("This property cannot
* be modified on a single occurrence."). They describe the stored event, not
* one of its instances.
*/
export const OCCURRENCE_REJECTED_KEYS = [
'baseEventId',
'calendarIds',
'isDraft',
'isOrigin',
'utcStart',
'utcEnd',
'useDefaultAlerts',
'mayInviteSelf',
'mayInviteOthers',
'hideAttendees',
] 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>`,
* `<localAccountId>::<raw>` 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;
}
/**
* Whether a `CalendarEvent/set` failure is a pre-0.16.20 server refusing a
* synthetic id ("Updating/Deleting synthetic ids is not yet supported.").
*/
export function isSyntheticIdMutationUnsupported(error: unknown): boolean {
const message = error instanceof Error
? error.message
: typeof error === 'string' ? error : '';
return /synthetic ids?\b.*\bnot (?:yet )?supported/i.test(message);
}
function firstPointerSegment(key: string): string {
const slash = key.indexOf('/');
return slash === -1 ? key : key.slice(0, slash);
}
/**
* Reduce an event patch to what may be written to a single occurrence
* through its synthetic id: identity / whole-series keys and the per-event
* keys the server rejects are dropped (also when addressed through a JSON
* pointer such as `calendarIds/x`). Everything else - including pointer
* patches like `locations/loc1/name` - passes through unchanged.
*/
export function buildOccurrencePatch(updates: Partial<CalendarEvent>): Partial<CalendarEvent> {
const dropped = new Set<string>([
...RECURRENCE_OVERRIDE_IMMUTABLE_KEYS,
...OCCURRENCE_REJECTED_KEYS,
'recurrenceId',
'recurrenceIdTimeZone',
]);
const patch: Record<string, unknown> = {};
for (const [key, value] of Object.entries(updates)) {
if (dropped.has(firstPointerSegment(key))) continue;
patch[key] = value;
}
return patch as Partial<CalendarEvent>;
}
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 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 client-side expansion used to
* copy 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 instances.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;
});
}
/**
* The fallback for servers that reject synthetic ids: the same change
* expressed as a whole-object `recurrenceOverrides/<recurrenceId>` patch on
* the base event. The existing override (if any) is kept underneath so a
* partial patch (a drag that only sets `start`) does not wipe fields the
* occurrence already overrides, and `start`/`duration` are pinned the way
* the server does it for synthetic-id updates.
*/
export function buildFallbackOverridePatch(
instance: Pick<CalendarEvent, 'start' | 'duration' | 'recurrenceId' | 'recurrenceOverrides'>,
patch: Partial<CalendarEvent>,
): Partial<CalendarEvent> | null {
const key = resolveOverrideKey(instance, instance.recurrenceOverrides);
if (!key) return null;
const existing = (instance.recurrenceOverrides?.[key] ?? {}) as Record<string, unknown>;
const { updated: _updated, ...kept } = existing;
const override: Record<string, unknown> = {
...kept,
start: instance.start,
duration: instance.duration,
...buildOccurrencePatch(patch),
};
return { [`recurrenceOverrides/${key}`]: override } as Partial<CalendarEvent>;
}
/** The fallback for destroying one occurrence: exclude it on the base event. */
export function buildFallbackExcludePatch(
instance: Pick<CalendarEvent, 'start' | 'recurrenceId' | 'recurrenceOverrides'>,
): Partial<CalendarEvent> | null {
const key = resolveOverrideKey(instance, instance.recurrenceOverrides);
if (!key) return null;
return { [`recurrenceOverrides/${key}`]: { excluded: true } } as Partial<CalendarEvent>;
}
@@ -0,0 +1,247 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useCalendarStore, resolveMutationTarget } from '../calendar-store';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { CalendarEvent } from '@/lib/jmap/types';
// Occurrences produced by server-side recurrence expansion carry synthetic
// ids (lib/recurrence-instances.ts). The store writes "this occurrence only"
// changes through that id, resolves whole-series operations (RSVP) and
// non-recurring events to the base event, falls back to a recurrence
// override on the base event when the server predates synthetic-id writes,
// and reloads the visible range afterwards because the ids reshuffle.
const UNSUPPORTED = 'Updating synthetic ids is not yet supported.';
const UNSUPPORTED_DESTROY = 'Deleting synthetic ids is not yet supported.';
function occurrence(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
id: 'maaaaab',
originalId: 'maaaaab',
baseEventId: 'b',
uid: 'series@example.com',
title: 'Daily standup',
start: '2026-09-08T10:00:00',
duration: 'PT1H',
timeZone: 'Europe/Berlin',
utcStart: '2026-09-08T08:00:00Z',
utcEnd: '2026-09-08T09:00:00Z',
showWithoutTime: false,
recurrenceId: '2026-09-08T10:00:00',
recurrenceIdTimeZone: 'Europe/Berlin',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', count: 5 }] as unknown as CalendarEvent['recurrenceRules'],
recurrenceOverrides: { '2026-09-09T10:00:00': { start: '2026-09-09T14:00:00', title: 'Moved' } },
excludedRecurrenceRules: null,
calendarIds: { f: true },
participants: {
p1: { '@type': 'Participant', name: 'Me', email: 'me@example.com', participationStatus: 'needs-action' },
} as unknown as CalendarEvent['participants'],
...overrides,
} as CalendarEvent;
}
function fakeClient(overrides: Partial<Record<keyof IJMAPClient, unknown>> = {}): IJMAPClient & {
updateCalendarEvent: ReturnType<typeof vi.fn>;
deleteCalendarEvent: ReturnType<typeof vi.fn>;
queryAllCalendarEvents: ReturnType<typeof vi.fn>;
} {
return {
updateCalendarEvent: vi.fn().mockResolvedValue(undefined),
deleteCalendarEvent: vi.fn().mockResolvedValue(undefined),
queryAllCalendarEvents: vi.fn().mockResolvedValue([]),
...overrides,
} as never;
}
const RANGE = ['2026-09-01T00:00:00', '2026-09-30T00:00:00'] as const;
beforeEach(() => {
useCalendarStore.getState().clearState();
useCalendarStore.setState({ events: [], calendars: [], error: null });
});
describe('resolveMutationTarget', () => {
it('addresses an occurrence of a series by its synthetic id', () => {
const target = resolveMutationTarget([occurrence()], 'maaaaab', 'occurrence');
expect(target).toMatchObject({ realId: 'maaaaab', isOccurrence: true });
});
it('addresses a whole-series operation and a non-recurring occurrence by the base id', () => {
expect(resolveMutationTarget([occurrence()], 'maaaaab', 'series')).toMatchObject({ realId: 'b', isOccurrence: false });
const single = occurrence({ id: 'eaaaaad', originalId: 'eaaaaad', baseEventId: 'd', recurrenceId: null, recurrenceRules: null });
expect(resolveMutationTarget([single], 'eaaaaad', 'occurrence')).toMatchObject({ realId: 'd', isOccurrence: false });
});
it('routes a base event that is only in view through its occurrences with that account', () => {
const shared = occurrence({ id: 'acct2:maaaaab', originalId: 'maaaaab', accountId: 'acct2', isShared: true });
expect(resolveMutationTarget([shared], 'acct2:b', 'occurrence')).toEqual({
realId: 'b', targetAccountId: 'acct2', localAccountId: undefined, isOccurrence: false,
});
const aggregated = occurrence({ id: 'A@h::maaaaab', originalId: 'maaaaab', localAccountId: 'A@h' });
expect(resolveMutationTarget([aggregated], 'A@h::b', 'occurrence')).toMatchObject({ realId: 'b', localAccountId: 'A@h' });
});
it('leaves events without synthetic ids exactly as before', () => {
const legacy = occurrence({ id: 'x', originalId: 'x', baseEventId: 'x', recurrenceId: null });
expect(resolveMutationTarget([legacy], 'x', 'occurrence')).toMatchObject({ realId: 'x', isOccurrence: false });
const clientExpanded = occurrence({ id: 'b:2026-09-08T10:00:00', originalId: 'b', baseEventId: undefined });
expect(resolveMutationTarget([clientExpanded], 'b:2026-09-08T10:00:00', 'occurrence')).toMatchObject({ realId: 'b', isOccurrence: false });
expect(resolveMutationTarget([], 'unknown', 'occurrence')).toEqual({ realId: 'unknown', isOccurrence: false });
});
});
describe('updateEvent on an expanded occurrence', () => {
it('patches the occurrence through its synthetic id, minus per-event keys', async () => {
const client = fakeClient();
useCalendarStore.setState({ events: [occurrence()] });
await useCalendarStore.getState().updateEvent(client, 'maaaaab', {
title: 'Renamed', start: '2026-09-08T11:00:00', calendarIds: { g: true },
});
expect(client.updateCalendarEvent).toHaveBeenCalledTimes(1);
expect(client.updateCalendarEvent).toHaveBeenCalledWith(
'maaaaab', { title: 'Renamed', start: '2026-09-08T11:00:00' }, undefined, undefined,
);
const stored = useCalendarStore.getState().events[0];
expect(stored.title).toBe('Renamed');
expect(stored.utcStart).toBe('2026-09-08T09:00:00.000Z');
});
it('falls back to a recurrence override on the base event when the server rejects synthetic ids', async () => {
const client = fakeClient();
client.updateCalendarEvent.mockRejectedValueOnce(new Error(UNSUPPORTED));
useCalendarStore.setState({ events: [occurrence()] });
await useCalendarStore.getState().updateEvent(client, 'maaaaab', { title: 'Renamed' }, true);
expect(client.updateCalendarEvent).toHaveBeenCalledTimes(2);
expect(client.updateCalendarEvent).toHaveBeenNthCalledWith(1, 'maaaaab', { title: 'Renamed' }, true, undefined);
expect(client.updateCalendarEvent).toHaveBeenNthCalledWith(2, 'b', {
'recurrenceOverrides/2026-09-08T10:00:00': { start: '2026-09-08T10:00:00', duration: 'PT1H', title: 'Renamed' },
}, true, undefined);
expect(useCalendarStore.getState().events[0].title).toBe('Renamed');
// The rejection is remembered: the next change skips the doomed attempt.
client.updateCalendarEvent.mockClear();
await useCalendarStore.getState().updateEvent(client, 'maaaaab', { title: 'Again' });
expect(client.updateCalendarEvent).toHaveBeenCalledTimes(1);
expect(client.updateCalendarEvent.mock.calls[0][0]).toBe('b');
});
it('rethrows other failures without falling back', async () => {
const client = fakeClient();
client.updateCalendarEvent.mockRejectedValueOnce(new Error('This property cannot be modified on a single occurrence.'));
useCalendarStore.setState({ events: [occurrence()] });
await expect(useCalendarStore.getState().updateEvent(client, 'maaaaab', { title: 'x' })).rejects.toThrow('single occurrence');
expect(client.updateCalendarEvent).toHaveBeenCalledTimes(1);
expect(useCalendarStore.getState().error).toBe('Failed to update event');
});
it('targets the base event for the single occurrence of a non-recurring event', async () => {
const client = fakeClient();
useCalendarStore.setState({ events: [occurrence({ id: 'eaaaaad', originalId: 'eaaaaad', baseEventId: 'd', recurrenceId: null, recurrenceRules: null })] });
await useCalendarStore.getState().updateEvent(client, 'eaaaaad', { title: 'One off, renamed' });
expect(client.updateCalendarEvent).toHaveBeenCalledWith('d', { title: 'One off, renamed' }, undefined, undefined);
});
it('routes a base event addressed through an occurrence to that occurrence’s account', async () => {
const client = fakeClient();
useCalendarStore.setState({ events: [occurrence({ id: 'acct2:maaaaab', originalId: 'maaaaab', accountId: 'acct2', isShared: true })] });
await useCalendarStore.getState().updateEvent(client, 'acct2:b', { title: 'Whole series' });
expect(client.updateCalendarEvent).toHaveBeenCalledWith('b', { title: 'Whole series' }, undefined, 'acct2');
});
it('reloads the visible range silently after the change, since synthetic ids reshuffle', async () => {
const client = fakeClient();
client.queryAllCalendarEvents.mockResolvedValue([occurrence()]);
await useCalendarStore.getState().fetchEvents(client, RANGE[0], RANGE[1]);
expect(client.queryAllCalendarEvents).toHaveBeenCalledTimes(1);
const loadingStates: boolean[] = [];
const unsubscribe = useCalendarStore.subscribe((s) => loadingStates.push(s.isLoadingEvents));
await useCalendarStore.getState().updateEvent(client, 'maaaaab', { title: 'Renamed' });
unsubscribe();
expect(client.queryAllCalendarEvents).toHaveBeenCalledTimes(2);
expect(client.queryAllCalendarEvents).toHaveBeenLastCalledWith({ after: RANGE[0], before: RANGE[1] });
expect(loadingStates.every((v) => v === false)).toBe(true);
});
it('also reloads after a change to a base event whose occurrences are in view', async () => {
const client = fakeClient();
client.queryAllCalendarEvents.mockResolvedValue([occurrence()]);
await useCalendarStore.getState().fetchEvents(client, RANGE[0], RANGE[1]);
await useCalendarStore.getState().updateEvent(client, 'b', { title: 'Whole series' });
expect(client.updateCalendarEvent).toHaveBeenCalledWith('b', { title: 'Whole series' }, undefined, undefined);
expect(client.queryAllCalendarEvents).toHaveBeenCalledTimes(2);
});
it('does not reload after changing an ordinary event', async () => {
const client = fakeClient();
const plain = occurrence({ id: 'x', originalId: 'x', baseEventId: 'x', recurrenceId: null, recurrenceRules: null });
client.queryAllCalendarEvents.mockResolvedValue([plain]);
await useCalendarStore.getState().fetchEvents(client, RANGE[0], RANGE[1]);
await useCalendarStore.getState().updateEvent(client, 'x', { title: 'Renamed' });
expect(client.updateCalendarEvent).toHaveBeenCalledWith('x', { title: 'Renamed' }, undefined, undefined);
expect(client.queryAllCalendarEvents).toHaveBeenCalledTimes(1);
});
});
describe('deleteEvent on an expanded occurrence', () => {
it('destroys the occurrence through its synthetic id and drops it from the store', async () => {
const client = fakeClient();
useCalendarStore.setState({ events: [occurrence()], selectedEventId: 'maaaaab' });
await useCalendarStore.getState().deleteEvent(client, 'maaaaab', true);
expect(client.deleteCalendarEvent).toHaveBeenCalledWith('maaaaab', true, undefined);
expect(client.updateCalendarEvent).not.toHaveBeenCalled();
expect(useCalendarStore.getState().events).toEqual([]);
expect(useCalendarStore.getState().selectedEventId).toBeNull();
});
it('excludes the occurrence on the base event when the server rejects synthetic ids', async () => {
const client = fakeClient();
client.deleteCalendarEvent.mockRejectedValueOnce(new Error(UNSUPPORTED_DESTROY));
useCalendarStore.setState({ events: [occurrence()] });
await useCalendarStore.getState().deleteEvent(client, 'maaaaab');
expect(client.updateCalendarEvent).toHaveBeenCalledWith(
'b', { 'recurrenceOverrides/2026-09-08T10:00:00': { excluded: true } }, undefined, undefined,
);
expect(useCalendarStore.getState().events).toEqual([]);
});
it('deletes the base event for the single occurrence of a non-recurring event', async () => {
const client = fakeClient();
useCalendarStore.setState({ events: [occurrence({ id: 'eaaaaad', originalId: 'eaaaaad', baseEventId: 'd', recurrenceId: null, recurrenceRules: null })] });
await useCalendarStore.getState().deleteEvent(client, 'eaaaaad');
expect(client.deleteCalendarEvent).toHaveBeenCalledWith('d', undefined, undefined);
});
});
describe('rsvpEvent on an expanded occurrence', () => {
it('answers for the whole series on the base event', async () => {
const client = fakeClient();
useCalendarStore.setState({ events: [occurrence()] });
await useCalendarStore.getState().rsvpEvent(client, 'maaaaab', 'p1', 'accepted');
expect(client.updateCalendarEvent).toHaveBeenCalledWith(
'b', { 'participants/p1/participationStatus': 'accepted' }, true, undefined,
);
expect(useCalendarStore.getState().events[0].participants?.p1.participationStatus).toBe('accepted');
});
});
+203 -22
View File
@@ -8,6 +8,14 @@ import { displayNow } from '@/lib/timezone';
import { parseDuration } from '@/components/calendar/event-card';
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
import {
baseEventStoreId,
buildFallbackExcludePatch,
buildFallbackOverridePatch,
buildOccurrencePatch,
isServerRecurrenceInstance,
isSyntheticIdMutationUnsupported,
} from '@/lib/recurrence-instances';
import { parseISO } from 'date-fns';
import { generateUUID } from '@/lib/utils';
import { apiFetch } from '@/lib/browser-navigation';
@@ -49,6 +57,154 @@ function rawIdentityOf(id: string): string {
return idx >= 0 ? id.slice(idx + CROSS_ACCOUNT_ID_DELIMITER.length) : id;
}
/**
* Where a mutation addressed at store id `id` has to go.
*
* Occurrences handed out by server-side recurrence expansion carry a
* synthetic id (lib/recurrence-instances.ts). Addressed with scope
* 'occurrence' they are written through that id - the server turns the patch
* into a recurrence override - unless they are the single instance of a
* non-recurring event, where the base event is the same thing and works on
* every server version. Scope 'series' (an RSVP, a calendar move) always
* targets the base event. A base event that is not in the store itself but
* has an expanded occurrence in view (`baseEventStoreId`) borrows that
* occurrence's account routing.
*/
interface MutationTarget {
storeEvent?: CalendarEvent;
realId: string;
targetAccountId?: string;
localAccountId?: string;
/** True when `realId` is the synthetic id of one occurrence of a series. */
isOccurrence: boolean;
}
export function resolveMutationTarget(
events: CalendarEvent[],
id: string,
scope: 'occurrence' | 'series',
): MutationTarget {
const storeEvent = events.find(e => e.id === id);
if (storeEvent) {
const rawId = storeEvent.originalId || stripLocalAccountPrefix(id, storeEvent.localAccountId);
const context = {
storeEvent,
targetAccountId: storeEvent.accountId,
localAccountId: storeEvent.localAccountId,
};
if (isServerRecurrenceInstance(storeEvent) && storeEvent.baseEventId) {
if (scope === 'series' || !storeEvent.recurrenceId) {
return { ...context, realId: storeEvent.baseEventId, isOccurrence: false };
}
return { ...context, realId: rawId, isOccurrence: true };
}
return { ...context, realId: rawId, isOccurrence: false };
}
const instance = events.find(e => baseEventStoreId(e) === id);
if (instance?.baseEventId) {
return {
realId: instance.baseEventId,
targetAccountId: instance.accountId,
localAccountId: instance.localAccountId,
isOccurrence: false,
};
}
return { realId: id, isOccurrence: false };
}
/** True when the store shows server-expanded occurrences of base event `baseId` on the given account. */
function hasExpandedOccurrencesOf(events: CalendarEvent[], baseId: string, target: MutationTarget): boolean {
return events.some(e =>
isServerRecurrenceInstance(e)
&& e.baseEventId === baseId
&& e.accountId === target.targetAccountId
&& e.localAccountId === target.localAccountId,
);
}
// Clients whose CalendarEvent/set rejected a synthetic id: skip the doomed
// attempt and go straight to the base-event override for them.
const syntheticIdRejected = new WeakSet<object>();
/**
* Patch one expanded occurrence through its synthetic id. A server that
* predates synthetic-id writes rejects it; the same change is then written
* as a recurrence override on the base event instead (the way Bulwark did it
* before), and that client is remembered as needing the fallback.
*/
async function updateOccurrence(
client: IJMAPClient,
instance: CalendarEvent,
syntheticId: string,
updates: Partial<CalendarEvent>,
sendSchedulingMessages: boolean | undefined,
targetAccountId: string | undefined,
): Promise<void> {
const patch = buildOccurrencePatch(updates);
if (!syntheticIdRejected.has(client)) {
try {
await client.updateCalendarEvent(syntheticId, patch, sendSchedulingMessages, targetAccountId);
return;
} catch (error) {
if (!isSyntheticIdMutationUnsupported(error)) throw error;
syntheticIdRejected.add(client);
debug.log('calendar', 'Server rejects synthetic ids; writing a recurrence override on the base event instead');
}
}
const fallback = buildFallbackOverridePatch(instance, patch);
if (!fallback || !instance.baseEventId) {
throw new Error('Cannot resolve the occurrence to override');
}
await client.updateCalendarEvent(instance.baseEventId, fallback, sendSchedulingMessages, targetAccountId);
}
/** Destroy one expanded occurrence; falls back to excluding it on the base event like `updateOccurrence`. */
async function destroyOccurrence(
client: IJMAPClient,
instance: CalendarEvent,
syntheticId: string,
sendSchedulingMessages: boolean | undefined,
targetAccountId: string | undefined,
): Promise<void> {
if (!syntheticIdRejected.has(client)) {
try {
await client.deleteCalendarEvent(syntheticId, sendSchedulingMessages, targetAccountId);
return;
} catch (error) {
if (!isSyntheticIdMutationUnsupported(error)) throw error;
syntheticIdRejected.add(client);
debug.log('calendar', 'Server rejects synthetic ids; excluding the occurrence on the base event instead');
}
}
const fallback = buildFallbackExcludePatch(instance);
if (!fallback || !instance.baseEventId) {
throw new Error('Cannot resolve the occurrence to exclude');
}
await client.updateCalendarEvent(instance.baseEventId, fallback, sendSchedulingMessages, targetAccountId);
}
// Re-runs the most recent range fetch. Synthetic occurrence ids are
// positional and reshuffle whenever a series' overrides change, so after
// mutating an occurrence (or a base event with occurrences in view) the
// visible range is reloaded to pick up the new ids; the optimistic store
// update stays in place until then. Concurrent callers share one fetch.
let refetchVisibleRange: (() => Promise<void>) | null = null;
let refetchVisibleRangeInFlight: Promise<void> | null = null;
async function refetchAfterOccurrenceMutation(): Promise<void> {
if (!refetchVisibleRange) return;
if (!refetchVisibleRangeInFlight) {
refetchVisibleRangeInFlight = refetchVisibleRange()
.catch((error) => {
debug.error('Failed to refresh events after an occurrence change:', error);
})
.finally(() => {
refetchVisibleRangeInFlight = null;
});
}
await refetchVisibleRangeInFlight;
}
/**
* Reconcile persisted selectedCalendarIds against a freshly fetched calendars
* list. A selected id can be stale in id-*form* (not existence): the raw->
@@ -254,6 +410,11 @@ export interface CalendarAccountClient {
client: IJMAPClient;
}
export interface FetchEventsOptions {
/** Reload without flipping `isLoadingEvents` (background refresh after a mutation). */
silent?: boolean;
}
interface CalendarStore {
calendars: Calendar[];
events: CalendarEvent[];
@@ -269,9 +430,9 @@ interface CalendarStore {
setSupported: (supported: boolean) => void;
fetchCalendars: (client: IJMAPClient) => Promise<void>;
fetchEvents: (client: IJMAPClient, start: string, end: string) => Promise<void>;
fetchEvents: (client: IJMAPClient, start: string, end: string, options?: FetchEventsOptions) => Promise<void>;
fetchAllAccountsCalendars: (accounts: CalendarAccountClient[]) => Promise<void>;
fetchAllAccountsEvents: (accounts: CalendarAccountClient[], start: string, end: string) => Promise<void>;
fetchAllAccountsEvents: (accounts: CalendarAccountClient[], start: string, end: string, options?: FetchEventsOptions) => Promise<void>;
createEvent: (client: IJMAPClient, event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<CalendarEvent | null>;
updateEvent: (client: IJMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<void>;
deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
@@ -345,8 +506,9 @@ export const useCalendarStore = create<CalendarStore>()(
}
},
fetchEvents: async (client, start, end) => {
set({ isLoadingEvents: true, error: null });
fetchEvents: async (client, start, end, options) => {
refetchVisibleRange = () => get().fetchEvents(client, start, end, { silent: true });
set(options?.silent ? { error: null } : { isLoadingEvents: true, error: null });
try {
const rawEvents = await client.queryAllCalendarEvents({
after: start,
@@ -359,8 +521,10 @@ export const useCalendarStore = create<CalendarStore>()(
typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())
);
const droppedEvents = rawEvents.length - validEvents.length;
// Expand recurring events client-side (Stalwart doesn't support
// mutations on synthetic IDs from server-side expandRecurrences)
// Expand recurring series that came back unexpanded - from a server
// without synthetic-id support (lib/recurrence-instances.ts) or one
// that ignores expandRecurrences. Occurrences the server already
// expanded pass through untouched.
const events = expandRecurringEvents(validEvents, start, end);
debug.log('calendar', 'Calendar fetchEvents completed', {
start,
@@ -408,8 +572,9 @@ export const useCalendarStore = create<CalendarStore>()(
}
},
fetchAllAccountsEvents: async (accounts, start, end) => {
set({ isLoadingEvents: true, error: null });
fetchAllAccountsEvents: async (accounts, start, end, options) => {
refetchVisibleRange = () => get().fetchAllAccountsEvents(accounts, start, end, { silent: true });
set(options?.silent ? { error: null } : { isLoadingEvents: true, error: null });
try {
const results = await Promise.all(
accounts.map(async ({ client, localAccountId }) => {
@@ -526,13 +691,13 @@ export const useCalendarStore = create<CalendarStore>()(
set({ error: null });
try {
// Resolve shared event IDs and client-side expanded occurrence IDs
const storeEvent = get().events.find(e => e.id === id);
const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId);
const targetAccountId = storeEvent?.accountId;
client = resolveAccountClient(client, storeEvent?.localAccountId);
const target = resolveMutationTarget(get().events, id, 'occurrence');
const { storeEvent, realId, targetAccountId } = target;
client = resolveAccountClient(client, target.localAccountId);
debug.log('calendar', 'Calendar updateEvent', {
storeId: id,
realId,
isOccurrence: target.isOccurrence,
uid: storeEvent?.uid,
recurrenceId: storeEvent?.recurrenceId,
targetAccountId,
@@ -548,7 +713,11 @@ export const useCalendarStore = create<CalendarStore>()(
}
cleanUpdates.calendarIds = remapped;
}
await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId);
if (target.isOccurrence && storeEvent) {
await updateOccurrence(client, storeEvent, realId, cleanUpdates, sendSchedulingMessages, targetAccountId);
} else {
await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId);
}
set((state) => ({
events: state.events.map(e => {
if (e.id !== id) return e;
@@ -577,6 +746,9 @@ export const useCalendarStore = create<CalendarStore>()(
return merged;
}),
}));
if (target.isOccurrence || hasExpandedOccurrencesOf(get().events, realId, target)) {
await refetchAfterOccurrenceMutation();
}
// Update emails (iTIP REQUEST/REPLY) are sent by the server via the
// `sendSchedulingMessages` argument already passed above - a manual
// iMIP send here produced duplicate emails.
@@ -598,10 +770,11 @@ export const useCalendarStore = create<CalendarStore>()(
}
try {
// Resolve shared event IDs and client-side expanded occurrence IDs
const storeEvent = get().events.find(e => e.id === eventId);
const realId = storeEvent?.originalId || stripLocalAccountPrefix(eventId, storeEvent?.localAccountId);
const targetAccountId = storeEvent?.accountId;
client = resolveAccountClient(client, storeEvent?.localAccountId);
// An RSVP answers for the whole series, so an expanded occurrence
// is resolved to its base event here.
const { storeEvent, realId, targetAccountId, localAccountId } =
resolveMutationTarget(get().events, eventId, 'series');
client = resolveAccountClient(client, localAccountId);
// Escape per RFC 6901 (JSON Pointer): ~ → ~0, / → ~1
const escapedId = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
const patchKey = `participants/${escapedId}/participationStatus`;
@@ -816,10 +989,9 @@ export const useCalendarStore = create<CalendarStore>()(
set({ error: null });
try {
// Resolve shared event IDs and client-side expanded occurrence IDs
const storeEvent = get().events.find(e => e.id === id);
const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId);
const targetAccountId = storeEvent?.accountId;
client = resolveAccountClient(client, storeEvent?.localAccountId);
const target = resolveMutationTarget(get().events, id, 'occurrence');
const { storeEvent, realId, targetAccountId } = target;
client = resolveAccountClient(client, target.localAccountId);
// Cancellation emails (iTIP CANCEL) are sent by the server via the
// `sendSchedulingMessages` argument on the destroy below - a manual
// iMIP send here produced duplicate emails.
@@ -830,11 +1002,19 @@ export const useCalendarStore = create<CalendarStore>()(
recurrenceId: storeEvent?.recurrenceId,
targetAccountId,
});
await client.deleteCalendarEvent(realId, sendSchedulingMessages, targetAccountId);
if (target.isOccurrence && storeEvent) {
await destroyOccurrence(client, storeEvent, realId, sendSchedulingMessages, targetAccountId);
} else {
await client.deleteCalendarEvent(realId, sendSchedulingMessages, targetAccountId);
}
const hadOccurrences = hasExpandedOccurrencesOf(get().events, realId, target);
set((state) => ({
events: state.events.filter(e => e.id !== id),
selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,
}));
if (target.isOccurrence || hadOccurrences) {
await refetchAfterOccurrenceMutation();
}
} catch (error) {
debug.error('Failed to delete event:', error);
set({ error: 'Failed to delete event' });
@@ -1305,6 +1485,7 @@ export const useCalendarStore = create<CalendarStore>()(
},
clearState: () => {
refetchVisibleRange = null;
// Preserve iCal subscriptions across the account-switch teardown.
// They're now scoped per-account via sub.accountId - wiping them
// here would lose them from localStorage on every switch.