diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 29acf97fc..3907d377c 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -51,6 +51,7 @@ import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization"; +import { buildRecurrenceOverridePatch } from "@/lib/recurrence-overrides"; import { getEventStartDate } from "@/lib/calendar-utils"; import { useTaskStore } from "@/stores/task-store"; import { useContactStore } from "@/stores/contact-store"; @@ -826,12 +827,8 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { // Patch the master event's recurrenceOverrides instead. const master = await findMasterEvent(event); if (master && event.recurrenceId) { - const patchUpdates: Record = {}; - for (const [key, value] of Object.entries(updates)) { - if (['id', 'uid', '@type', 'calendarIds', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRules'].includes(key)) continue; - patchUpdates[`recurrenceOverrides/${event.recurrenceId}/${key}`] = value; - } - await updateEvent(client, master.id, patchUpdates as Partial, sendScheduling); + const patchUpdates = buildRecurrenceOverridePatch(updates, event.recurrenceId); + await updateEvent(client, master.id, patchUpdates, sendScheduling); } else { await updateEvent(client, event.id, updates, sendScheduling); } diff --git a/lib/__tests__/recurrence-overrides.test.ts b/lib/__tests__/recurrence-overrides.test.ts new file mode 100644 index 000000000..8fa59799c --- /dev/null +++ b/lib/__tests__/recurrence-overrides.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { buildRecurrenceOverridePatch } from '@/lib/recurrence-overrides'; +import type { CalendarEvent } from '@/lib/jmap/types'; + +const RID = '2026-08-15T10:00:00'; + +describe('buildRecurrenceOverridePatch (#774 "This event only")', () => { + it('sets the whole override at a single pointer, not nested per-field pointers', () => { + const patch = buildRecurrenceOverridePatch( + { title: 'Moved standup', start: '2026-08-15T11:00:00' } as Partial, + RID, + ); + // Exactly one top-level pointer, at the override object itself. + expect(Object.keys(patch)).toEqual([`recurrenceOverrides/${RID}`]); + // No nested `recurrenceOverrides//` pointers (the shape Stalwart + // rejected because it can't create the missing override object). + expect(Object.keys(patch).some((k) => k.startsWith(`recurrenceOverrides/${RID}/`))).toBe(false); + }); + + it('carries the per-occurrence fields into the override object', () => { + const patch = buildRecurrenceOverridePatch( + { + title: 'Moved standup', + start: '2026-08-15T11:00:00', + duration: 'PT30M', + description: 'one-off', + } as Partial, + RID, + ); + expect(patch[`recurrenceOverrides/${RID}` as keyof typeof patch]).toEqual({ + title: 'Moved standup', + start: '2026-08-15T11:00:00', + duration: 'PT30M', + description: 'one-off', + }); + }); + + it('drops identity and series-level keys that must not vary per occurrence', () => { + const patch = buildRecurrenceOverridePatch( + { + id: 'evt1', + uid: 'uid-1', + '@type': 'Event', + calendarIds: { cal1: true }, + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' }], + recurrenceOverrides: {}, + excludedRecurrenceRules: [], + title: 'Only this', + } as unknown as Partial, + RID, + ); + expect(patch[`recurrenceOverrides/${RID}` as keyof typeof patch]).toEqual({ title: 'Only this' }); + }); + + it('produces an empty override object when nothing overridable is supplied', () => { + const patch = buildRecurrenceOverridePatch({ id: 'evt1', uid: 'uid-1' } as unknown as Partial, RID); + expect(patch[`recurrenceOverrides/${RID}` as keyof typeof patch]).toEqual({}); + }); +}); diff --git a/lib/recurrence-overrides.ts b/lib/recurrence-overrides.ts new file mode 100644 index 000000000..c386f9c67 --- /dev/null +++ b/lib/recurrence-overrides.ts @@ -0,0 +1,32 @@ +import type { CalendarEvent } from '@/lib/jmap/types'; + +/** Identity / whole-series keys that must never go into a single-occurrence override. */ +export const RECURRENCE_OVERRIDE_IMMUTABLE_KEYS = [ + 'id', + 'uid', + '@type', + 'calendarIds', + 'recurrenceRules', + 'recurrenceOverrides', + '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). + */ +export function buildRecurrenceOverridePatch( + updates: Partial, + recurrenceId: string, +): Partial { + const immutable = new Set(RECURRENCE_OVERRIDE_IMMUTABLE_KEYS); + const override: Record = {}; + for (const [key, value] of Object.entries(updates)) { + if (immutable.has(key)) continue; + override[key] = value; + } + return { [`recurrenceOverrides/${recurrenceId}`]: override } as Partial; +}