From d8006899249f4e4898a992951a0f6bb41ba834c6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:03:48 +0200 Subject: [PATCH] feat: use Stalwart synthetic ids for recurring occurrences #140 --- components/calendar/calendar-app.tsx | 41 ++- .../jmap-calendar-synthetic-ids.test.ts | 215 ++++++++++++++ .../jmap-first-touch-ordering.test.ts | 3 + lib/__tests__/recurrence-instances.test.ts | 276 ++++++++++++++++++ lib/calendar-event-normalization.ts | 2 +- lib/jmap/client.ts | 137 ++++++++- lib/jmap/types.ts | 5 + lib/recurrence-expansion.ts | 16 +- lib/recurrence-instances.ts | 232 +++++++++++++++ .../calendar-store-synthetic-ids.test.ts | 247 ++++++++++++++++ stores/calendar-store.ts | 225 ++++++++++++-- 11 files changed, 1355 insertions(+), 44 deletions(-) create mode 100644 lib/__tests__/jmap-calendar-synthetic-ids.test.ts create mode 100644 lib/__tests__/recurrence-instances.test.ts create mode 100644 lib/recurrence-instances.ts create mode 100644 stores/__tests__/calendar-store-synthetic-ids.test.ts diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 3407d851f..11f2436db 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -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( diff --git a/lib/__tests__/jmap-calendar-synthetic-ids.test.ts b/lib/__tests__/jmap-calendar-synthetic-ids.test.ts new file mode 100644 index 000000000..145e3627d --- /dev/null +++ b/lib/__tests__/jmap-calendar-synthetic-ids.test.ts @@ -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 }; + +describe('server-side recurrence expansion with synthetic ids', () => { + let fetchSpy: ReturnType; + 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] { + const update = args.update as Record | 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 = { 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' } }); + }); +}); diff --git a/lib/__tests__/jmap-first-touch-ordering.test.ts b/lib/__tests__/jmap-first-touch-ordering.test.ts index a8b9868e7..5e6aa1586 100644 --- a/lib/__tests__/jmap-first-touch-ordering.test.ts +++ b/lib/__tests__/jmap-first-touch-ordering.test.ts @@ -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([]); diff --git a/lib/__tests__/recurrence-instances.test.ts b/lib/__tests__/recurrence-instances.test.ts new file mode 100644 index 000000000..f53bbefbb --- /dev/null +++ b/lib/__tests__/recurrence-instances.test.ts @@ -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 { + 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); + 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); + 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 = { + 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>([[ '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)).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(); + }); +}); diff --git a/lib/calendar-event-normalization.ts b/lib/calendar-event-normalization.ts index 7dcc1c9fa..5805875c9 100644 --- a/lib/calendar-event-normalization.ts +++ b/lib/calendar-event-normalization.ts @@ -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; } diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 68bfd3879..43819a72f 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -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 { + 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 { 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(); + // Result of the one-off synthetic-id capability probe, shared by every + // range query on this client (see supportsSyntheticCalendarIds). + private syntheticCalendarIdSupport: Promise | 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 { + 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): 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 } | 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 { + 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>(); + 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[]) { + if (base.id) bases.set(base.id, normalizeCalendarEventLike(base)); + } + } + return hydrateRecurrenceInstances(events, bases); + } + async getCalendarEvent(id: string, targetAccountId?: string): Promise { 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); 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); const setArgs: Record = { diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 1ecf82359..e8db31546 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -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; originalCalendarIds?: Record; accountId?: string; diff --git a/lib/recurrence-expansion.ts b/lib/recurrence-expansion.ts index 68963a896..82ef11b51 100644 --- a/lib/recurrence-expansion.ts +++ b/lib/recurrence-expansion.ts @@ -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 = { 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; } diff --git a/lib/recurrence-instances.ts b/lib/recurrence-instances.ts new file mode 100644 index 000000000..1b3735b5a --- /dev/null +++ b/lib/recurrence-instances.ts @@ -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 & Partial>; + +/** + * 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 (`:`, + * `::` or just ``), 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): Partial { + const dropped = new Set([ + ...RECURRENCE_OVERRIDE_IMMUTABLE_KEYS, + ...OCCURRENCE_REJECTED_KEYS, + 'recurrenceId', + 'recurrenceIdTimeZone', + ]); + const patch: Record = {}; + for (const [key, value] of Object.entries(updates)) { + if (dropped.has(firstPointerSegment(key))) continue; + patch[key] = value; + } + return patch as Partial; +} + +type OverrideMap = NonNullable; + +/** + * 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, + 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>, +): 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/` 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, + patch: Partial, +): Partial | null { + const key = resolveOverrideKey(instance, instance.recurrenceOverrides); + if (!key) return null; + const existing = (instance.recurrenceOverrides?.[key] ?? {}) as Record; + const { updated: _updated, ...kept } = existing; + const override: Record = { + ...kept, + start: instance.start, + duration: instance.duration, + ...buildOccurrencePatch(patch), + }; + return { [`recurrenceOverrides/${key}`]: override } as Partial; +} + +/** The fallback for destroying one occurrence: exclude it on the base event. */ +export function buildFallbackExcludePatch( + instance: Pick, +): Partial | null { + const key = resolveOverrideKey(instance, instance.recurrenceOverrides); + if (!key) return null; + return { [`recurrenceOverrides/${key}`]: { excluded: true } } as Partial; +} diff --git a/stores/__tests__/calendar-store-synthetic-ids.test.ts b/stores/__tests__/calendar-store-synthetic-ids.test.ts new file mode 100644 index 000000000..b94f135f1 --- /dev/null +++ b/stores/__tests__/calendar-store-synthetic-ids.test.ts @@ -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 { + 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> = {}): IJMAPClient & { + updateCalendarEvent: ReturnType; + deleteCalendarEvent: ReturnType; + queryAllCalendarEvents: ReturnType; +} { + 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'); + }); +}); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 0d3756e7c..96c9b363a 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -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(); + +/** + * 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, + sendSchedulingMessages: boolean | undefined, + targetAccountId: string | undefined, +): Promise { + 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 { + 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) | null = null; +let refetchVisibleRangeInFlight: Promise | null = null; + +async function refetchAfterOccurrenceMutation(): Promise { + 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; - fetchEvents: (client: IJMAPClient, start: string, end: string) => Promise; + fetchEvents: (client: IJMAPClient, start: string, end: string, options?: FetchEventsOptions) => Promise; fetchAllAccountsCalendars: (accounts: CalendarAccountClient[]) => Promise; - fetchAllAccountsEvents: (accounts: CalendarAccountClient[], start: string, end: string) => Promise; + fetchAllAccountsEvents: (accounts: CalendarAccountClient[], start: string, end: string, options?: FetchEventsOptions) => Promise; createEvent: (client: IJMAPClient, event: Partial, sendSchedulingMessages?: boolean) => Promise; updateEvent: (client: IJMAPClient, id: string, updates: Partial, sendSchedulingMessages?: boolean) => Promise; deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise; @@ -345,8 +506,9 @@ export const useCalendarStore = create()( } }, - 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()( 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()( } }, - 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()( 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()( } 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()( 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()( } 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()( 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()( 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()( }, 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.