feat: change or delete one occurrence through its own id

"This event only" wrote a `recurrenceOverrides/<id>` pointer on the
master for every occurrence. That pointer fails while the series has
no overrides yet, replaced whatever the occurrence already overrode,
and left the new override without the occurrence's title, place or
reminders. Deleting one occurrence went the same way.

The store now keeps a change to an occurrence on that occurrence, the
way the webmail's `resolveMutationTarget` does:

- a server-expanded occurrence is updated or destroyed through its
  synthetic id; a server that refuses synthetic ids gets the same
  change as an override on the base event, and is remembered
- an occurrence expanded on the device becomes an override on its base
  event that keeps the existing override, pins start and duration,
  and is sent as the whole map when the series has none yet
- a new override carries the occurrence's details and sequence
  (`withNewOverrideDetails`), so it neither loses them nor ranks below
  the series
- "All events" and "This and following" still go to the master

The calendar's scope dialog hands "This event only" to `updateEvent`
and `deleteEvent` with the occurrence's id, and
`buildRecurrenceOverridePatch` is gone.
This commit is contained in:
Linus Rath
2026-09-24 20:10:08 +02:00
parent 84548898c1
commit f397ee3e1b
7 changed files with 567 additions and 109 deletions
@@ -1,12 +1,18 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { import {
baseEventStoreId, baseEventStoreId,
buildFallbackExcludePatch,
buildFallbackOverridePatch,
buildOccurrencePatch,
hydrateRecurrenceInstances, hydrateRecurrenceInstances,
isBrowserExpandedOccurrence,
isServerRecurrenceInstance, isServerRecurrenceInstance,
isSyntheticIdMutationUnsupported,
mergeOverrideParticipants, mergeOverrideParticipants,
resolveOverrideKey, resolveOverrideKey,
seriesIdOf, seriesIdOf,
stableOccurrenceKey, stableOccurrenceKey,
withNewOverrideDetails,
} from '../recurrence-instances'; } from '../recurrence-instances';
import { expandRecurringEvents } from '../recurrence-expansion'; import { expandRecurringEvents } from '../recurrence-expansion';
import type { CalendarEvent } from '../../api/types'; import type { CalendarEvent } from '../../api/types';
@@ -150,3 +156,77 @@ describe('expandRecurringEvents with server occurrences', () => {
expect(result).toEqual([occurrence]); 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 },
});
});
});
@@ -3,7 +3,6 @@ import type { CalendarEvent } from '../../api/types';
import { import {
buildAllScopeUpdates, buildAllScopeUpdates,
buildFutureSeriesData, buildFutureSeriesData,
buildRecurrenceOverridePatch,
isRecurringSeriesMember, isRecurringSeriesMember,
truncateRecurrenceRules, truncateRecurrenceRules,
} from '../recurrence-overrides'; } from '../recurrence-overrides';
@@ -27,37 +26,6 @@ const occurrence: CalendarEvent = {
recurrenceId: '2026-03-04T08:00:00.000Z', 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', () => { describe('isRecurringSeriesMember', () => {
it('detects masters and expanded occurrences but not plain shared events', () => { it('detects masters and expanded occurrences but not plain shared events', () => {
expect(isRecurringSeriesMember(master)).toBe(true); expect(isRecurringSeriesMember(master)).toBe(true);
+198
View File
@@ -21,6 +21,7 @@
*/ */
import type { CalendarEvent, Participant } from '../api/types'; 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 * A synthetic id whose event can never exist: expansion 0 of document
@@ -260,3 +261,200 @@ export function hydrateRecurrenceInstances(
return hydrated; 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<CalendarEvent>): Partial<CalendarEvent> {
const dropped = new Set<string>([
...RECURRENCE_OVERRIDE_IMMUTABLE_KEYS,
...OCCURRENCE_REJECTED_KEYS,
'recurrenceId',
'recurrenceIdTimeZone',
// Client-only fields.
'originalId',
'originalCalendarIds',
'accountId',
'isShared',
'localAccountId',
]);
const patch: Record<string, unknown> = {};
for (const [key, value] of Object.entries(updates)) {
if (dropped.has(firstPointerSegment(key))) continue;
patch[key] = value;
}
return patch as Partial<CalendarEvent>;
}
/**
* 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<CalendarEvent, 'id' | 'recurrenceId' | 'recurrenceRules'> & Partial<Pick<CalendarEvent, 'originalId' | 'baseEventId'>>,
): boolean {
return !!event.recurrenceId && (event.recurrenceRules?.length ?? 0) > 0 && !isServerRecurrenceInstance(event);
}
type OccurrenceOverrideContext = Pick<CalendarEvent, 'id' | 'start' | 'recurrenceId' | 'recurrenceOverrides'>
& Partial<Pick<CalendarEvent, 'originalId' | 'baseEventId'>>;
/**
* 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<CalendarEvent>): 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<CalendarEvent>): 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/<key>` 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<string, unknown>,
): Record<string, unknown> {
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<CalendarEvent, 'duration'>,
patch: Partial<CalendarEvent>,
): Record<string, unknown> | null {
const key = resolveOverrideKey(instance, instance.recurrenceOverrides);
if (!key) return null;
const existing = (instance.recurrenceOverrides?.[key] ?? {}) as Record<string, unknown>;
const { updated: _updated, ...kept } = existing;
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<string, unknown> | 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<CalendarEvent>,
patch: Partial<CalendarEvent>,
): Partial<CalendarEvent> {
if (!createsOverride(occurrence)) return patch;
const source = occurrence as Record<string, unknown>;
const details: Record<string, unknown> = {};
for (const name of NEW_OVERRIDE_COPIED_KEYS) {
if (!(name in patch) && source[name] != null) details[name] = source[name];
}
return { ...details, ...patch } as Partial<CalendarEvent>;
}
-21
View File
@@ -12,27 +12,6 @@ export const RECURRENCE_OVERRIDE_IMMUTABLE_KEYS = [
'excludedRecurrenceRules', 'excludedRecurrenceRules',
] as const; ] 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/<id>`, not
* field-by-field (#774). Mirrors webmail's lib/recurrence-overrides.ts.
*/
export function buildRecurrenceOverridePatch(
updates: Partial<CalendarEvent>,
recurrenceId: string,
): Record<string, unknown> {
const immutable = new Set<string>(RECURRENCE_OVERRIDE_IMMUTABLE_KEYS);
const override: Record<string, unknown> = {};
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 * A recurring series member is a master (has recurrenceRules) or an expanded
* occurrence (has recurrenceId) — those get the this/future/all scope dialog. * occurrence (has recurrenceId) — those get the this/future/all scope dialog.
+7 -15
View File
@@ -73,7 +73,6 @@ import { buildReplyTo } from '../lib/calendar-invitation';
import { import {
buildAllScopeUpdates, buildAllScopeUpdates,
buildFutureSeriesData, buildFutureSeriesData,
buildRecurrenceOverridePatch,
isRecurringSeriesMember, isRecurringSeriesMember,
truncateRecurrenceRules, truncateRecurrenceRules,
} from '../lib/recurrence-overrides'; } from '../lib/recurrence-overrides';
@@ -492,13 +491,10 @@ export default function CalendarScreen() {
const opts = { sendSchedulingMessages: action.sendScheduling }; const opts = { sendSchedulingMessages: action.sendScheduling };
switch (scope) { switch (scope) {
case 'this': { case 'this': {
if (event.recurrenceId) { // The store keeps the change on this occurrence: through its
// Client-side expanded occurrence: write a one-shot override // own (synthetic) id, or as a recurrence override on the event
// on the master instead of touching the series. // it was expanded from.
await updateEvent(event.id, buildRecurrenceOverridePatch(updates, event.recurrenceId), opts); await updateEvent(event.id, updates, opts);
} else {
await updateEvent(event.id, updates, opts);
}
break; break;
} }
case 'this_and_future': { case 'this_and_future': {
@@ -532,13 +528,9 @@ export default function CalendarScreen() {
} else { } else {
switch (scope) { switch (scope) {
case 'this': { case 'this': {
if (event.recurrenceId) { // The store destroys a server occurrence, or excludes one the
await updateEvent(event.id, { // device expanded on its base event.
[`recurrenceOverrides/${event.recurrenceId}`]: { excluded: true }, await deleteEvent(event.id);
});
} else {
await deleteEvent(event.id);
}
break; break;
} }
case 'this_and_future': { case 'this_and_future': {
+124 -13
View File
@@ -538,15 +538,54 @@ describe('calendar-store', () => {
{ id: 'ev2' } as any, { id: 'ev2' } as any,
], ],
}); });
mockGetEvents.mockResolvedValueOnce([
{ id: 'ev1', start: '2026-03-02T09:00:00', recurrenceRules: [{ frequency: 'daily' }] },
]);
mockDeleteEvents.mockResolvedValue(undefined); mockDeleteEvents.mockResolvedValue(undefined);
mockQueryEvents.mockResolvedValue([]); 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); expect(mockDeleteEvents).toHaveBeenCalledWith(['ev1'], undefined, undefined);
// The visible range was reloaded (empty here). // The visible range was reloaded (empty here).
expect(mockQueryEvents).toHaveBeenCalled(); 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', () => { describe('importEvents', () => {
@@ -732,26 +771,88 @@ describe('calendar-store', () => {
expect(events.every((e) => e.originalId === 'm')).toBe(true); 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({ useCalendarStore.setState({
calendars: [{ id: 'cal-1' }] as any, calendars: [{ id: 'cal-1' }] as any,
loadedRange: RANGE, loadedRange: RANGE,
events: [ events: [
{ id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00', recurrenceRules: weekly } as any, serverOccurrence as any,
{ id: 's2', baseEventId: 'base', recurrenceId: '2026-03-09T09:00:00', recurrenceRules: weekly } as any, { ...serverOccurrence, id: 's2', recurrenceId: '2026-03-09T09:00:00' } as any,
], ],
}); });
mockUpdateEvent.mockResolvedValue(undefined); mockUpdateEvent.mockResolvedValue(undefined);
mockQueryExpanded.mockResolvedValue([]); 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( // A new override also carries the occurrence's details; series-level
'base', { 'recurrenceOverrides/2026-03-02T09:00:00': { title: 'x' } }, undefined, undefined, // 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(); 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 () => { it('routes a single event\'s synthetic id to the stored event on every server', async () => {
useCalendarStore.setState({ useCalendarStore.setState({
calendars: [{ id: 'cal-1' }] as any, calendars: [{ id: 'cal-1' }] as any,
@@ -809,13 +910,20 @@ describe('calendar-store', () => {
}); });
describe('recurring series mutations', () => { 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({ useCalendarStore.setState({
calendars: [{ id: 'cal-1', name: 'Personal' } as any], calendars: [{ id: 'cal-1', name: 'Personal' } as any],
loadedRange: { after: '2026-03-01T00:00:00Z', before: '2026-03-31T00:00:00Z' }, loadedRange: { after: '2026-03-01T00:00:00Z', before: '2026-03-31T00:00:00Z' },
events: [ 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); 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 } }, { 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. // Refetched: siblings now carry the server's state.
const events = useCalendarStore.getState().events; const events = useCalendarStore.getState().events;
expect(events.length).toBeGreaterThan(0); expect(events.length).toBeGreaterThan(0);
+158 -28
View File
@@ -34,8 +34,14 @@ import { expandRecurringEvents } from '../lib/recurrence-expansion';
import { isRecurringSeriesMember } from '../lib/recurrence-overrides'; import { isRecurringSeriesMember } from '../lib/recurrence-overrides';
import { import {
baseEventStoreId, baseEventStoreId,
buildFallbackExcludePatch,
buildFallbackOverridePatch,
buildOccurrencePatch,
isBrowserExpandedOccurrence,
isServerRecurrenceInstance, isServerRecurrenceInstance,
isSyntheticIdMutationUnsupported,
seriesIdOf, seriesIdOf,
withNewOverrideDetails,
} from '../lib/recurrence-instances'; } from '../lib/recurrence-instances';
import { findTasksOnlyCalendarIds, isTaskLikeObject } from '../lib/calendar-component-detection'; import { findTasksOnlyCalendarIds, isTaskLikeObject } from '../lib/calendar-component-detection';
@@ -364,24 +370,130 @@ export interface MutationTarget {
realId: string; realId: string;
/** Owning account of a shared calendar's event. */ /** Owning account of a shared calendar's event. */
accountId?: string; 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 * Where a change to the event with store id `id` has to go. Mirrors the
* stored event: an occurrence (expanded by the server or on the device) * webmail's resolveMutationTarget.
* 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 * With scope 'occurrence', a server-expanded occurrence is written through
* a server-expanded occurrence of it in view. Mirrors the webmail's * its synthetic id (the server turns the patch into a recurrence override),
* resolveMutationTarget for the whole-series case. * 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); const storeEvent = events.find((e) => e.id === id) ?? knownMasters.get(id);
if (storeEvent) { 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); const instance = events.find((e) => baseEventStoreId(e) === id);
if (instance?.baseEventId) return { realId: instance.baseEventId, accountId: instance.accountId }; if (instance?.baseEventId) {
return { realId: id }; 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<CalendarEvent>,
sendSchedulingMessages: boolean | undefined,
accountId: string | undefined,
): Promise<void> {
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<void> {
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<CalendarEvent>,
sendSchedulingMessages: boolean | undefined,
): Promise<void> {
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<void> {
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<CalendarState>()(
}, },
updateEvent: async (id, changes, options) => { updateEvent: async (id, changes, options) => {
// Resolve expanded occurrence ids back to the stored (base) event. // A change to an occurrence stays on that occurrence: through its
const { storeEvent, realId, accountId } = resolveSeriesTarget(get().events, id); // 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 // Remap namespaced (shared-calendar) store ids in calendarIds back to the
// raw server ids the owning account knows. // raw server ids the owning account knows.
const patch: Record<string, unknown> = { ...changes }; const patch: Record<string, unknown> = { ...changes };
@@ -628,7 +743,13 @@ export const useCalendarStore = create<CalendarState>()(
: undefined); : undefined);
const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent)) const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent))
|| hasServerOccurrencesOf(get().events, realId, accountId); || hasServerOccurrencesOf(get().events, realId, accountId);
await apiUpdateEvent(realId, patch, schedule, accountId); if (target.isOccurrence && storeEvent) {
await updateOccurrence(storeEvent, realId, patch as Partial<CalendarEvent>, schedule, accountId);
} else if (target.isBrowserOccurrence) {
await updateBrowserOccurrence(target, patch as Partial<CalendarEvent>, schedule);
} else {
await apiUpdateEvent(realId, patch, schedule, accountId);
}
set({ set({
events: get().events.map((e) => (e.id === id ? { ...e, ...(changes as Partial<CalendarEvent>) } : e)), events: get().events.map((e) => (e.id === id ? { ...e, ...(changes as Partial<CalendarEvent>) } : e)),
}); });
@@ -643,22 +764,30 @@ export const useCalendarStore = create<CalendarState>()(
}, },
deleteEvent: async (id) => { 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)) const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent))
|| hasServerOccurrencesOf(get().events, realId, accountId); || hasServerOccurrencesOf(get().events, realId, accountId);
await apiDeleteEvents( const schedule = hasSchedulingParticipants(storeEvent) ? true : undefined;
[realId], if (target.isOccurrence && storeEvent) {
hasSchedulingParticipants(storeEvent) ? true : undefined, await destroyOccurrence(storeEvent, realId, schedule, accountId);
accountId, set({ events: get().events.filter((e) => e.id !== id) });
); } else if (target.isBrowserOccurrence) {
knownMasters.delete(id); await destroyBrowserOccurrence(target, schedule);
// Destroying a master removes every expanded occurrence of it, not just set({ events: get().events.filter((e) => e.id !== id) });
// the tapped one. } else {
set({ await apiDeleteEvents([realId], schedule, accountId);
events: get().events.filter((e) => knownMasters.delete(id);
e.id !== id // Destroying a master removes every expanded occurrence of it, not
&& !(seriesIdOf(e) === realId && (e.accountId ?? undefined) === (accountId ?? undefined))), // just the tapped one.
}); set({
events: get().events.filter((e) =>
e.id !== id
&& !(seriesIdOf(e) === realId && (e.accountId ?? undefined) === (accountId ?? undefined))),
});
}
if (touchesSeries) { if (touchesSeries) {
await get().refresh(); await get().refresh();
} }
@@ -694,7 +823,7 @@ export const useCalendarStore = create<CalendarState>()(
// An event outside the loaded window (an invitation looked up by UID) // 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 // isn't in the store; the caller hands it over instead. An occurrence
// answers for its whole series: the stored event is updated. // 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 storeEvent = target.storeEvent ?? event;
const realId = target.storeEvent || !event ? target.realId : seriesIdOf(event); const realId = target.storeEvent || !event ? target.realId : seriesIdOf(event);
const accountId = target.storeEvent ? target.accountId : event?.accountId ?? target.accountId; const accountId = target.storeEvent ? target.accountId : event?.accountId ?? target.accountId;
@@ -932,6 +1061,7 @@ export const useCalendarStore = create<CalendarState>()(
// upgraded server. // upgraded server.
resetCalendarAccessDenied(); resetCalendarAccessDenied();
resetSyntheticIdSupport(); resetSyntheticIdSupport();
syntheticIdRejected = false;
knownMasters.clear(); knownMasters.clear();
set({ set({
calendars: [], calendars: [],