diff --git a/src/lib/__tests__/recurrence-instances.test.ts b/src/lib/__tests__/recurrence-instances.test.ts index 9888e494..4c5a6f5e 100644 --- a/src/lib/__tests__/recurrence-instances.test.ts +++ b/src/lib/__tests__/recurrence-instances.test.ts @@ -1,12 +1,18 @@ import { describe, it, expect } from 'vitest'; import { baseEventStoreId, + buildFallbackExcludePatch, + buildFallbackOverridePatch, + buildOccurrencePatch, hydrateRecurrenceInstances, + isBrowserExpandedOccurrence, isServerRecurrenceInstance, + isSyntheticIdMutationUnsupported, mergeOverrideParticipants, resolveOverrideKey, seriesIdOf, stableOccurrenceKey, + withNewOverrideDetails, } from '../recurrence-instances'; import { expandRecurringEvents } from '../recurrence-expansion'; import type { CalendarEvent } from '../../api/types'; @@ -150,3 +156,77 @@ describe('expandRecurringEvents with server occurrences', () => { expect(result).toEqual([occurrence]); }); }); + +describe('changing one occurrence', () => { + it('recognises a server that refuses synthetic ids', () => { + expect(isSyntheticIdMutationUnsupported(new Error('Updating synthetic ids is not yet supported.'))).toBe(true); + expect(isSyntheticIdMutationUnsupported( + new Error('Failed to destroy event s1: invalidProperties – Deleting synthetic ids is not yet supported.'), + )).toBe(true); + expect(isSyntheticIdMutationUnsupported(new Error('forbidden'))).toBe(false); + }); + + it('tells an occurrence the device expanded from a server one', () => { + const rules = [{ frequency: 'daily' } as any]; + expect(isBrowserExpandedOccurrence(ev({ id: 'm:x', originalId: 'm', recurrenceId: 'x', recurrenceRules: rules }))).toBe(true); + expect(isBrowserExpandedOccurrence(ev({ id: 's1', baseEventId: 'm', recurrenceId: 'x', recurrenceRules: rules }))).toBe(false); + expect(isBrowserExpandedOccurrence(ev({ id: 'lone', recurrenceId: 'x' }))).toBe(false); + }); + + it('keeps only what one occurrence may carry, also behind a pointer', () => { + expect(buildOccurrencePatch({ + title: 'x', + 'locations/l1/name': 'Room', + 'calendarIds/c2': true, + recurrenceRules: [], + recurrenceId: 'r', + utcStart: 'z', + useDefaultAlerts: true, + originalId: 'o', + } as any)).toEqual({ title: 'x', 'locations/l1/name': 'Room' }); + }); + + it('builds an override that keeps what the occurrence already overrides', () => { + const occurrence = ev({ + id: 'm:2026-03-03T09:00:00', originalId: 'm', recurrenceId: '2026-03-03T09:00:00', + start: '2026-03-03T11:00:00', duration: 'PT1H', recurrenceRules: [{ frequency: 'daily' } as any], + recurrenceOverrides: { + '2026-03-03T09:00:00': { start: '2026-03-03T11:00:00', title: 'Late', updated: '2026-01-01T00:00:00Z' } as any, + }, + }); + + expect(buildFallbackOverridePatch(occurrence, { description: 'Bring slides' })).toEqual({ + 'recurrenceOverrides/2026-03-03T09:00:00': { + start: '2026-03-03T11:00:00', + duration: 'PT1H', + title: 'Late', + description: 'Bring slides', + }, + }); + expect(buildFallbackExcludePatch(occurrence)).toEqual({ + 'recurrenceOverrides/2026-03-03T09:00:00': { excluded: true }, + }); + }); + + it('copies the occurrence details into a new override only', () => { + const fresh = ev({ + id: 's1', baseEventId: 'm', recurrenceId: '2026-03-02T09:00:00', title: 'Standup', + alerts: { a: {} } as any, sequence: 3, recurrenceOverrides: null, + }); + expect(withNewOverrideDetails(fresh, { start: '2026-03-02T10:00:00' })).toEqual({ + title: 'Standup', alerts: { a: {} }, sequence: 3, start: '2026-03-02T10:00:00', + }); + + const overridden = { ...fresh, recurrenceOverrides: { '2026-03-02T09:00:00': { title: 'Standup' } } }; + expect(withNewOverrideDetails(overridden, { start: '2026-03-02T10:00:00' })) + .toEqual({ start: '2026-03-02T10:00:00' }); + + // Not hydrated: the base event's overrides are unknown, so nothing is guessed. + const unknown = { ...fresh, recurrenceOverrides: undefined }; + expect(withNewOverrideDetails(unknown, { start: '2026-03-02T10:00:00' })) + .toEqual({ start: '2026-03-02T10:00:00' }); + expect(buildFallbackExcludePatch(unknown)).toEqual({ + 'recurrenceOverrides/2026-03-02T09:00:00': { excluded: true }, + }); + }); +}); diff --git a/src/lib/__tests__/recurrence-overrides.test.ts b/src/lib/__tests__/recurrence-overrides.test.ts index 1fcc0e80..b0a44ed7 100644 --- a/src/lib/__tests__/recurrence-overrides.test.ts +++ b/src/lib/__tests__/recurrence-overrides.test.ts @@ -3,7 +3,6 @@ import type { CalendarEvent } from '../../api/types'; import { buildAllScopeUpdates, buildFutureSeriesData, - buildRecurrenceOverridePatch, isRecurringSeriesMember, truncateRecurrenceRules, } from '../recurrence-overrides'; @@ -27,37 +26,6 @@ const occurrence: CalendarEvent = { recurrenceId: '2026-03-04T08:00:00.000Z', }; -describe('buildRecurrenceOverridePatch', () => { - it('writes the whole override under one recurrenceOverrides pointer', () => { - const patch = buildRecurrenceOverridePatch( - { title: 'Moved', start: '2026-03-04T10:00:00', calendarIds: { x: true }, uid: 'nope' }, - '2026-03-04T08:00:00.000Z', - ); - expect(patch).toEqual({ - 'recurrenceOverrides/2026-03-04T08:00:00.000Z': { - title: 'Moved', - start: '2026-03-04T10:00:00', - }, - }); - }); - - it('never leaks series-level keys or recurrenceId into the override', () => { - const patch = buildRecurrenceOverridePatch( - { - recurrenceRules: [{ frequency: 'weekly' }], - recurrenceOverrides: {}, - excludedRecurrenceRules: [], - recurrenceId: '2026-03-04', - id: 'x', - '@type': 'Event', - description: 'kept', - }, - '2026-03-04', - ); - expect(patch['recurrenceOverrides/2026-03-04']).toEqual({ description: 'kept' }); - }); -}); - describe('isRecurringSeriesMember', () => { it('detects masters and expanded occurrences but not plain shared events', () => { expect(isRecurringSeriesMember(master)).toBe(true); diff --git a/src/lib/recurrence-instances.ts b/src/lib/recurrence-instances.ts index 0faec66a..62e2015a 100644 --- a/src/lib/recurrence-instances.ts +++ b/src/lib/recurrence-instances.ts @@ -21,6 +21,7 @@ */ import type { CalendarEvent, Participant } from '../api/types'; +import { RECURRENCE_OVERRIDE_IMMUTABLE_KEYS } from './recurrence-overrides'; /** * A synthetic id whose event can never exist: expansion 0 of document @@ -260,3 +261,200 @@ export function hydrateRecurrenceInstances( return hydrated; }); } + +// ─── Changing one occurrence ───────────────────────────── + +/** + * 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; + +/** + * 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', + // Client-only fields. + 'originalId', + 'originalCalendarIds', + 'accountId', + 'isShared', + 'localAccountId', + ]); + const patch: Record = {}; + for (const [key, value] of Object.entries(updates)) { + if (dropped.has(firstPointerSegment(key))) continue; + patch[key] = value; + } + return patch as Partial; +} + +/** + * An occurrence of a series that the device expanded itself + * (lib/recurrence-expansion.ts), as opposed to one the server handed out. + * Changes to it are written as a recurrence override on its base event. + */ +export function isBrowserExpandedOccurrence( + event: Pick & Partial>, +): boolean { + return !!event.recurrenceId && (event.recurrenceRules?.length ?? 0) > 0 && !isServerRecurrenceInstance(event); +} + +type OccurrenceOverrideContext = Pick + & Partial>; + +/** + * The base event's overrides map as far as `occurrence` knows it, or + * `undefined` when it does not. The device's expansion copies the base + * event's map (absent means none); a server occurrence only has it once + * hydrated from its base event (`null` then means none). + */ +function knownOverrides(occurrence: Partial): OverrideMap | null | undefined { + if (isServerRecurrenceInstance({ ...occurrence, id: occurrence.id ?? '' })) return occurrence.recurrenceOverrides; + return occurrence.recurrenceOverrides ?? null; +} + +/** True when a change to `occurrence` creates its override, false when one exists or it is unknown. */ +function createsOverride(occurrence: Partial): boolean { + const overrides = knownOverrides(occurrence); + if (overrides === undefined || !occurrence.recurrenceId) return false; + const key = resolveOverrideKey({ start: occurrence.start ?? '', recurrenceId: occurrence.recurrenceId }, overrides); + return !!key && !(overrides && key in overrides); +} + +/** + * `override` as the entry `key` of the base event's overrides. A + * `recurrenceOverrides/` pointer fails ("Patch operation failed") while + * the base event has no overrides at all, so the first one is sent as the + * whole map - but only when the map is known to be empty. + */ +function overrideEntryPatch( + instance: OccurrenceOverrideContext, + key: string, + override: Record, +): Record { + const overrides = knownOverrides(instance); + if (overrides !== undefined && Object.keys(overrides ?? {}).length === 0) { + return { recurrenceOverrides: { [key]: override } }; + } + return { [`recurrenceOverrides/${key}`]: override }; +} + +/** + * A change to one occurrence expressed as a recurrence override on its base + * event: used for occurrences the device expanded, and as the fallback for + * servers that reject synthetic ids. The existing override (if any) is kept + * underneath so a partial patch (a move 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: OccurrenceOverrideContext & Pick, + patch: Partial, +): Record | null { + const key = resolveOverrideKey(instance, instance.recurrenceOverrides); + if (!key) return null; + const existing = (instance.recurrenceOverrides?.[key] ?? {}) as Record; + const { updated: _updated, ...kept } = existing; + return overrideEntryPatch(instance, key, { + ...kept, + start: instance.start, + duration: instance.duration, + ...buildOccurrencePatch(patch), + }); +} + +/** Destroying one occurrence as an override on its base event: exclude it. */ +export function buildFallbackExcludePatch( + instance: OccurrenceOverrideContext, +): Record | null { + const key = resolveOverrideKey(instance, instance.recurrenceOverrides); + if (!key) return null; + return overrideEntryPatch(instance, key, { excluded: true }); +} + +/** + * What a new override copies from its occurrence, unless the change sets it + * itself. Stalwart stores an override as a VEVENT holding only the override's + * own properties, so without these the occurrence loses its title, place, + * attendees and reminders - for other CalDAV clients as well as when Stalwart + * expands it. `sequence` keeps the override from ranking below the series: + * an override with a lower SEQUENCE does not replace its occurrence and + * Stalwart then lists that occurrence twice (the organizer's server raises + * the sequence of a changed override from this value). + */ +const NEW_OVERRIDE_COPIED_KEYS = [ + 'title', + 'description', + 'descriptionContentType', + 'locations', + 'virtualLocations', + 'links', + 'keywords', + 'categories', + 'color', + 'locale', + 'status', + 'freeBusyStatus', + 'privacy', + 'alerts', + 'organizerCalendarAddress', + 'participants', + 'sequence', +] as const; + +/** + * `patch` plus the occurrence details a new override needs + * (NEW_OVERRIDE_COPIED_KEYS). Nothing is added when the occurrence already + * has an override - an attendee may not add most of these to the organizer's + * - or when that is unknown. + */ +export function withNewOverrideDetails( + occurrence: Partial, + patch: Partial, +): Partial { + if (!createsOverride(occurrence)) return patch; + const source = occurrence as Record; + const details: Record = {}; + for (const name of NEW_OVERRIDE_COPIED_KEYS) { + if (!(name in patch) && source[name] != null) details[name] = source[name]; + } + return { ...details, ...patch } as Partial; +} diff --git a/src/lib/recurrence-overrides.ts b/src/lib/recurrence-overrides.ts index 2f0fcff1..9b37b75d 100644 --- a/src/lib/recurrence-overrides.ts +++ b/src/lib/recurrence-overrides.ts @@ -12,27 +12,6 @@ export const RECURRENCE_OVERRIDE_IMMUTABLE_KEYS = [ 'excludedRecurrenceRules', ] as const; -/** - * Build the "This event only" override patch for a recurring master. - * - * Why one pointer: a JMAP nested pointer can't create a missing intermediate, - * so the override object has to be set whole at `recurrenceOverrides/`, not - * field-by-field (#774). Mirrors webmail's lib/recurrence-overrides.ts. - */ -export function buildRecurrenceOverridePatch( - updates: Partial, - recurrenceId: string, -): Record { - const immutable = new Set(RECURRENCE_OVERRIDE_IMMUTABLE_KEYS); - const override: Record = {}; - for (const [key, value] of Object.entries(updates)) { - if (immutable.has(key)) continue; - if (key === 'recurrenceId') continue; - override[key] = value; - } - return { [`recurrenceOverrides/${recurrenceId}`]: override }; -} - /** * A recurring series member is a master (has recurrenceRules) or an expanded * occurrence (has recurrenceId) — those get the this/future/all scope dialog. diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 7a5201d6..828ef0e6 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -73,7 +73,6 @@ import { buildReplyTo } from '../lib/calendar-invitation'; import { buildAllScopeUpdates, buildFutureSeriesData, - buildRecurrenceOverridePatch, isRecurringSeriesMember, truncateRecurrenceRules, } from '../lib/recurrence-overrides'; @@ -492,13 +491,10 @@ export default function CalendarScreen() { const opts = { sendSchedulingMessages: action.sendScheduling }; switch (scope) { case 'this': { - if (event.recurrenceId) { - // Client-side expanded occurrence: write a one-shot override - // on the master instead of touching the series. - await updateEvent(event.id, buildRecurrenceOverridePatch(updates, event.recurrenceId), opts); - } else { - await updateEvent(event.id, updates, opts); - } + // The store keeps the change on this occurrence: through its + // own (synthetic) id, or as a recurrence override on the event + // it was expanded from. + await updateEvent(event.id, updates, opts); break; } case 'this_and_future': { @@ -532,13 +528,9 @@ export default function CalendarScreen() { } else { switch (scope) { case 'this': { - if (event.recurrenceId) { - await updateEvent(event.id, { - [`recurrenceOverrides/${event.recurrenceId}`]: { excluded: true }, - }); - } else { - await deleteEvent(event.id); - } + // The store destroys a server occurrence, or excludes one the + // device expanded on its base event. + await deleteEvent(event.id); break; } case 'this_and_future': { diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index 133113ea..3ca6f561 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -538,15 +538,54 @@ describe('calendar-store', () => { { id: 'ev2' } as any, ], }); + mockGetEvents.mockResolvedValueOnce([ + { id: 'ev1', start: '2026-03-02T09:00:00', recurrenceRules: [{ frequency: 'daily' }] }, + ]); mockDeleteEvents.mockResolvedValue(undefined); mockQueryEvents.mockResolvedValue([]); - await useCalendarStore.getState().deleteEvent('ev1:a'); + // "All events": the master is looked up for the tapped occurrence. + const master = await useCalendarStore.getState().getMasterEvent( + useCalendarStore.getState().events[0], + ); + await useCalendarStore.getState().deleteEvent(master!.id); expect(mockDeleteEvents).toHaveBeenCalledWith(['ev1'], undefined, undefined); // The visible range was reloaded (empty here). expect(mockQueryEvents).toHaveBeenCalled(); }); + + it('deletes just the tapped occurrence of a series expanded on the device', async () => { + useCalendarStore.setState({ + calendars: [{ id: 'cal-1', name: 'Personal' } as any], + events: [ + { + id: 'ev1:2026-03-02T09:00:00', originalId: 'ev1', recurrenceId: '2026-03-02T09:00:00', + start: '2026-03-02T09:00:00', recurrenceRules: [{ frequency: 'daily' }], + } as any, + { + id: 'ev1:2026-03-03T09:00:00', originalId: 'ev1', recurrenceId: '2026-03-03T09:00:00', + start: '2026-03-03T09:00:00', recurrenceRules: [{ frequency: 'daily' }], + recurrenceOverrides: { '2026-03-05T09:00:00': { title: 'Moved' } }, + } as any, + ], + }); + mockUpdateEvent.mockResolvedValue(undefined); + mockQueryEvents.mockResolvedValue([]); + + await useCalendarStore.getState().deleteEvent('ev1:2026-03-02T09:00:00'); + await useCalendarStore.getState().deleteEvent('ev1:2026-03-03T09:00:00'); + + expect(mockDeleteEvents).not.toHaveBeenCalled(); + // No overrides yet: a pointer into the missing map would fail, so the + // whole map is sent. + expect(mockUpdateEvent).toHaveBeenNthCalledWith( + 1, 'ev1', { recurrenceOverrides: { '2026-03-02T09:00:00': { excluded: true } } }, undefined, undefined, + ); + expect(mockUpdateEvent).toHaveBeenNthCalledWith( + 2, 'ev1', { 'recurrenceOverrides/2026-03-03T09:00:00': { excluded: true } }, undefined, undefined, + ); + }); }); describe('importEvents', () => { @@ -732,26 +771,88 @@ describe('calendar-store', () => { expect(events.every((e) => e.originalId === 'm')).toBe(true); }); - it('sends a change to a server occurrence to its base event and reloads the range', async () => { + const serverOccurrence = { + id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00', start: '2026-03-02T09:00:00', + duration: 'PT1H', title: 'Standup', recurrenceRules: weekly, recurrenceOverrides: null, + locations: { l: { name: 'Room 1' } }, sequence: 2, calendarIds: { 'cal-1': true }, + utcStart: '2026-03-02T08:00:00Z', useDefaultAlerts: false, + }; + + it('writes a change to one server occurrence through its synthetic id and reloads the range', async () => { useCalendarStore.setState({ calendars: [{ id: 'cal-1' }] as any, loadedRange: RANGE, events: [ - { id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00', recurrenceRules: weekly } as any, - { id: 's2', baseEventId: 'base', recurrenceId: '2026-03-09T09:00:00', recurrenceRules: weekly } as any, + serverOccurrence as any, + { ...serverOccurrence, id: 's2', recurrenceId: '2026-03-09T09:00:00' } as any, ], }); mockUpdateEvent.mockResolvedValue(undefined); mockQueryExpanded.mockResolvedValue([]); - await useCalendarStore.getState().updateEvent('s1', { 'recurrenceOverrides/2026-03-02T09:00:00': { title: 'x' } }); + await useCalendarStore.getState().updateEvent('s1', { + title: 'Moved standup', start: '2026-03-02T10:00:00', calendarIds: { 'cal-1': true }, + recurrenceRules: weekly as any, useDefaultAlerts: false, + }); - expect(mockUpdateEvent).toHaveBeenCalledWith( - 'base', { 'recurrenceOverrides/2026-03-02T09:00:00': { title: 'x' } }, undefined, undefined, - ); + // A new override also carries the occurrence's details; series-level + // and refused keys are left out. + expect(mockUpdateEvent).toHaveBeenCalledWith('s1', { + title: 'Moved standup', + start: '2026-03-02T10:00:00', + locations: { l: { name: 'Room 1' } }, + sequence: 2, + }, undefined, undefined); expect(mockQueryExpanded).toHaveBeenCalled(); }); + it('falls back to an override on the base event when the server refuses synthetic ids', async () => { + useCalendarStore.setState({ + calendars: [{ id: 'cal-1' }] as any, + events: [serverOccurrence as any, { ...serverOccurrence, id: 's2', recurrenceId: '2026-03-09T09:00:00' } as any], + }); + mockUpdateEvent + .mockRejectedValueOnce(new Error('Updating synthetic ids is not yet supported.')) + .mockResolvedValue(undefined); + + try { + await useCalendarStore.getState().updateEvent('s1', { title: 'Moved' }); + expect(mockUpdateEvent).toHaveBeenLastCalledWith('base', { + recurrenceOverrides: { + '2026-03-02T09:00:00': { + start: '2026-03-02T09:00:00', + duration: 'PT1H', + title: 'Moved', + locations: { l: { name: 'Room 1' } }, + sequence: 2, + }, + }, + }, undefined, undefined); + + // Remembered: a delete goes straight to the exclusion. + await useCalendarStore.getState().deleteEvent('s2'); + expect(mockDeleteEvents).not.toHaveBeenCalled(); + expect(mockUpdateEvent).toHaveBeenLastCalledWith('base', { + recurrenceOverrides: { '2026-03-09T09:00:00': { excluded: true } }, + }, undefined, undefined); + } finally { + useCalendarStore.getState().reset(); + } + }); + + it('deletes one server occurrence through its synthetic id', async () => { + useCalendarStore.setState({ + calendars: [{ id: 'cal-1' }] as any, + events: [serverOccurrence as any, { ...serverOccurrence, id: 's2', recurrenceId: '2026-03-09T09:00:00' } as any], + }); + mockDeleteEvents.mockResolvedValue(undefined); + + await useCalendarStore.getState().deleteEvent('s1'); + + expect(mockDeleteEvents).toHaveBeenCalledWith(['s1'], undefined, undefined); + expect(useCalendarStore.getState().events.map((e) => e.id)).toEqual(['s2']); + }); + it('routes a single event\'s synthetic id to the stored event on every server', async () => { useCalendarStore.setState({ calendars: [{ id: 'cal-1' }] as any, @@ -809,13 +910,20 @@ describe('calendar-store', () => { }); describe('recurring series mutations', () => { - it('updateEvent on an occurrence patches the master and refetches the range', async () => { + it('updateEvent on an occurrence writes an override on the master and refetches the range', async () => { useCalendarStore.setState({ calendars: [{ id: 'cal-1', name: 'Personal' } as any], loadedRange: { after: '2026-03-01T00:00:00Z', before: '2026-03-31T00:00:00Z' }, events: [ - { id: 'ev1:a', originalId: 'ev1', recurrenceId: 'a', title: 'Old', recurrenceRules: [{ frequency: 'daily' }] } as any, - { id: 'ev1:b', originalId: 'ev1', recurrenceId: 'b', title: 'Old', recurrenceRules: [{ frequency: 'daily' }] } as any, + { + id: 'ev1:a', originalId: 'ev1', recurrenceId: 'a', title: 'Old', start: '2026-03-02T09:00:00', + duration: 'PT30M', recurrenceRules: [{ frequency: 'daily' }], + recurrenceOverrides: { b: { title: 'Other' } }, + } as any, + { + id: 'ev1:b', originalId: 'ev1', recurrenceId: 'b', title: 'Other', recurrenceRules: [{ frequency: 'daily' }], + recurrenceOverrides: { b: { title: 'Other' } }, + } as any, ], }); mockUpdateEvent.mockResolvedValue(undefined); @@ -824,9 +932,12 @@ describe('calendar-store', () => { { id: 'ev1', title: 'New', start: '2026-03-02T09:00:00', recurrenceRules: [{ frequency: 'daily', count: 2 }], calendarIds: { 'cal-1': true } }, ]); - await useCalendarStore.getState().updateEvent('ev1:a', { 'recurrenceOverrides/a': { title: 'New' } }); + await useCalendarStore.getState().updateEvent('ev1:a', { title: 'New' }); - expect(mockUpdateEvent).toHaveBeenCalledWith('ev1', { 'recurrenceOverrides/a': { title: 'New' } }, undefined, undefined); + // Never the series itself: the change becomes this occurrence's override. + expect(mockUpdateEvent).toHaveBeenCalledWith('ev1', { + 'recurrenceOverrides/a': { start: '2026-03-02T09:00:00', duration: 'PT30M', title: 'New' }, + }, undefined, undefined); // Refetched: siblings now carry the server's state. const events = useCalendarStore.getState().events; expect(events.length).toBeGreaterThan(0); diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index 24efd330..589baaf8 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -34,8 +34,14 @@ import { expandRecurringEvents } from '../lib/recurrence-expansion'; import { isRecurringSeriesMember } from '../lib/recurrence-overrides'; import { baseEventStoreId, + buildFallbackExcludePatch, + buildFallbackOverridePatch, + buildOccurrencePatch, + isBrowserExpandedOccurrence, isServerRecurrenceInstance, + isSyntheticIdMutationUnsupported, seriesIdOf, + withNewOverrideDetails, } from '../lib/recurrence-instances'; import { findTasksOnlyCalendarIds, isTaskLikeObject } from '../lib/calendar-component-detection'; @@ -364,24 +370,130 @@ export interface MutationTarget { realId: string; /** Owning account of a shared calendar's event. */ accountId?: string; + /** `realId` is the synthetic id of one server-expanded occurrence. */ + isOccurrence: boolean; + /** `storeEvent` is one occurrence the device expanded, `realId` its base event. */ + isBrowserOccurrence?: boolean; } /** - * Where a change to the event with store id `id` goes, as a change to the - * stored event: an occurrence (expanded by the server or on the device) - * resolves to its base event, a master fetched by getMasterEvent to itself, - * and a base event that is not in the store borrows the account routing of - * a server-expanded occurrence of it in view. Mirrors the webmail's - * resolveMutationTarget for the whole-series case. + * Where a change to the event with store id `id` has to go. Mirrors the + * webmail's resolveMutationTarget. + * + * With scope 'occurrence', a server-expanded occurrence is written through + * its synthetic id (the server turns the patch into a recurrence override), + * unless it is the single instance of a non-recurring event, where the base + * event is the same thing and works on every server version; an occurrence + * the device expanded resolves to its base event, to be written as a + * recurrence override on it. Scope 'series' (an answer for the whole + * series) always targets the stored event. A master fetched by + * getMasterEvent resolves to itself, and a base event that is not in the + * store borrows the account routing of a server occurrence of it in view. */ -export function resolveSeriesTarget(events: CalendarEvent[], id: string): MutationTarget { +export function resolveMutationTarget( + events: CalendarEvent[], + id: string, + scope: 'occurrence' | 'series', +): MutationTarget { const storeEvent = events.find((e) => e.id === id) ?? knownMasters.get(id); if (storeEvent) { - return { storeEvent, realId: seriesIdOf(storeEvent), accountId: storeEvent.accountId }; + const context = { storeEvent, accountId: storeEvent.accountId }; + if (isServerRecurrenceInstance(storeEvent) && storeEvent.baseEventId) { + if (scope === 'series' || !storeEvent.recurrenceId) { + return { ...context, realId: storeEvent.baseEventId, isOccurrence: false }; + } + return { ...context, realId: storeEvent.originalId ?? storeEvent.id, isOccurrence: true }; + } + if (scope === 'occurrence' && isBrowserExpandedOccurrence(storeEvent)) { + return { ...context, realId: seriesIdOf(storeEvent), isOccurrence: false, isBrowserOccurrence: true }; + } + return { ...context, realId: seriesIdOf(storeEvent), isOccurrence: false }; } const instance = events.find((e) => baseEventStoreId(e) === id); - if (instance?.baseEventId) return { realId: instance.baseEventId, accountId: instance.accountId }; - return { realId: id }; + if (instance?.baseEventId) { + return { realId: instance.baseEventId, accountId: instance.accountId, isOccurrence: false }; + } + return { realId: id, isOccurrence: false }; +} + +// Set once CalendarEvent/set rejected a synthetic id although the probe +// said it would take them: go straight to the base-event override then. +let syntheticIdRejected = false; + +/** + * Patch one server-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, and that is + * remembered. A change that creates the override carries the occurrence's + * details along (`withNewOverrideDetails`). + */ +async function updateOccurrence( + instance: CalendarEvent, + syntheticId: string, + updates: Partial, + sendSchedulingMessages: boolean | undefined, + accountId: string | undefined, +): Promise { + const patch = withNewOverrideDetails(instance, buildOccurrencePatch(updates)); + if (!syntheticIdRejected) { + try { + await apiUpdateEvent(syntheticId, patch, sendSchedulingMessages, accountId); + return; + } catch (err) { + if (!isSyntheticIdMutationUnsupported(err)) throw err; + syntheticIdRejected = true; + } + } + const fallback = buildFallbackOverridePatch(instance, patch); + if (!fallback || !instance.baseEventId) throw new Error('Cannot resolve the occurrence to override'); + await apiUpdateEvent(instance.baseEventId, fallback, sendSchedulingMessages, accountId); +} + +/** Destroy one server-expanded occurrence; falls back to excluding it on the base event. */ +async function destroyOccurrence( + instance: CalendarEvent, + syntheticId: string, + sendSchedulingMessages: boolean | undefined, + accountId: string | undefined, +): Promise { + if (!syntheticIdRejected) { + try { + await apiDeleteEvents([syntheticId], sendSchedulingMessages, accountId); + return; + } catch (err) { + if (!isSyntheticIdMutationUnsupported(err)) throw err; + syntheticIdRejected = true; + } + } + const fallback = buildFallbackExcludePatch(instance); + if (!fallback || !instance.baseEventId) throw new Error('Cannot resolve the occurrence to exclude'); + await apiUpdateEvent(instance.baseEventId, fallback, sendSchedulingMessages, accountId); +} + +/** + * Write a change to one occurrence the device expanded as a recurrence + * override on its base event (`target.realId`) - never as a change to the + * base event itself, which would move or edit the whole series. + */ +async function updateBrowserOccurrence( + target: MutationTarget, + updates: Partial, + sendSchedulingMessages: boolean | undefined, +): Promise { + const occurrence = target.storeEvent!; + const patch = buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates)); + if (!patch) throw new Error('Cannot resolve the occurrence to override'); + await apiUpdateEvent(target.realId, patch, sendSchedulingMessages, target.accountId); +} + +/** Delete one occurrence the device expanded by excluding it on its base event. */ +async function destroyBrowserOccurrence( + target: MutationTarget, + sendSchedulingMessages: boolean | undefined, +): Promise { + const patch = buildFallbackExcludePatch(target.storeEvent!); + if (!patch) throw new Error('Cannot resolve the occurrence to exclude'); + await apiUpdateEvent(target.realId, patch, sendSchedulingMessages, target.accountId); } /** @@ -604,8 +716,11 @@ export const useCalendarStore = create()( }, updateEvent: async (id, changes, options) => { - // Resolve expanded occurrence ids back to the stored (base) event. - const { storeEvent, realId, accountId } = resolveSeriesTarget(get().events, id); + // A change to an occurrence stays on that occurrence: through its + // synthetic id, or as an override on the base event it was expanded + // from. Everything else goes to the stored event. + const target = resolveMutationTarget(get().events, id, 'occurrence'); + const { storeEvent, realId, accountId } = target; // Remap namespaced (shared-calendar) store ids in calendarIds back to the // raw server ids the owning account knows. const patch: Record = { ...changes }; @@ -628,7 +743,13 @@ export const useCalendarStore = create()( : undefined); const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent)) || hasServerOccurrencesOf(get().events, realId, accountId); - await apiUpdateEvent(realId, patch, schedule, accountId); + if (target.isOccurrence && storeEvent) { + await updateOccurrence(storeEvent, realId, patch as Partial, schedule, accountId); + } else if (target.isBrowserOccurrence) { + await updateBrowserOccurrence(target, patch as Partial, schedule); + } else { + await apiUpdateEvent(realId, patch, schedule, accountId); + } set({ events: get().events.map((e) => (e.id === id ? { ...e, ...(changes as Partial) } : e)), }); @@ -643,22 +764,30 @@ export const useCalendarStore = create()( }, deleteEvent: async (id) => { - const { storeEvent, realId, accountId } = resolveSeriesTarget(get().events, id); + // Deleting an occurrence removes just that one: through its synthetic + // id, or by excluding it on the base event it was expanded from. + const target = resolveMutationTarget(get().events, id, 'occurrence'); + const { storeEvent, realId, accountId } = target; const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent)) || hasServerOccurrencesOf(get().events, realId, accountId); - await apiDeleteEvents( - [realId], - hasSchedulingParticipants(storeEvent) ? true : undefined, - accountId, - ); - knownMasters.delete(id); - // Destroying a master removes every expanded occurrence of it, not just - // the tapped one. - set({ - events: get().events.filter((e) => - e.id !== id - && !(seriesIdOf(e) === realId && (e.accountId ?? undefined) === (accountId ?? undefined))), - }); + const schedule = hasSchedulingParticipants(storeEvent) ? true : undefined; + if (target.isOccurrence && storeEvent) { + await destroyOccurrence(storeEvent, realId, schedule, accountId); + set({ events: get().events.filter((e) => e.id !== id) }); + } else if (target.isBrowserOccurrence) { + await destroyBrowserOccurrence(target, schedule); + set({ events: get().events.filter((e) => e.id !== id) }); + } else { + await apiDeleteEvents([realId], schedule, accountId); + knownMasters.delete(id); + // Destroying a master removes every expanded occurrence of it, not + // just the tapped one. + set({ + events: get().events.filter((e) => + e.id !== id + && !(seriesIdOf(e) === realId && (e.accountId ?? undefined) === (accountId ?? undefined))), + }); + } if (touchesSeries) { await get().refresh(); } @@ -694,7 +823,7 @@ export const useCalendarStore = create()( // An event outside the loaded window (an invitation looked up by UID) // isn't in the store; the caller hands it over instead. An occurrence // answers for its whole series: the stored event is updated. - const target = resolveSeriesTarget(get().events, eventId); + const target = resolveMutationTarget(get().events, eventId, 'series'); const storeEvent = target.storeEvent ?? event; const realId = target.storeEvent || !event ? target.realId : seriesIdOf(event); const accountId = target.storeEvent ? target.accountId : event?.accountId ?? target.accountId; @@ -932,6 +1061,7 @@ export const useCalendarStore = create()( // upgraded server. resetCalendarAccessDenied(); resetSyntheticIdSupport(); + syntheticIdRejected = false; knownMasters.clear(); set({ calendars: [],