fix(calendar): edit a single recurring occurrence via a one-shot override patch

The "This event only" edit path built nested JMAP patch pointers
(recurrenceOverrides/<recurrenceId>/<field>). On the first edit of an
occurrence the override object doesn't exist yet, and JMAP pointers can't
create a missing intermediate object, so Stalwart returned notUpdated and
the edit failed with two error toasts.

Build the override as a single pointer recurrenceOverrides/<recurrenceId>
whose value is the occurrence's field set (dropping identity/series keys),
mirroring the working "delete this occurrence" path. Extracted as the pure,
tested helper buildRecurrenceOverridePatch.

Closes #774.
This commit is contained in:
Stefan Hildebrandt
2026-08-16 21:24:52 +02:00
parent 5ab5749306
commit b5e71e7b84
3 changed files with 94 additions and 6 deletions
+3 -6
View File
@@ -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<string, unknown> = {};
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<CalendarEvent>, sendScheduling);
const patchUpdates = buildRecurrenceOverridePatch(updates, event.recurrenceId);
await updateEvent(client, master.id, patchUpdates, sendScheduling);
} else {
await updateEvent(client, event.id, updates, sendScheduling);
}
@@ -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<CalendarEvent>,
RID,
);
// Exactly one top-level pointer, at the override object itself.
expect(Object.keys(patch)).toEqual([`recurrenceOverrides/${RID}`]);
// No nested `recurrenceOverrides/<id>/<field>` 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<CalendarEvent>,
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<CalendarEvent>,
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<CalendarEvent>, RID);
expect(patch[`recurrenceOverrides/${RID}` as keyof typeof patch]).toEqual({});
});
});
+32
View File
@@ -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/<id>`, not
* field-by-field (#774).
*/
export function buildRecurrenceOverridePatch(
updates: Partial<CalendarEvent>,
recurrenceId: string,
): Partial<CalendarEvent> {
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;
override[key] = value;
}
return { [`recurrenceOverrides/${recurrenceId}`]: override } as Partial<CalendarEvent>;
}