diff --git a/components/calendar/__tests__/event-modal-organizer.test.tsx b/components/calendar/__tests__/event-modal-organizer.test.tsx
index fc054e74f..55796a8a4 100644
--- a/components/calendar/__tests__/event-modal-organizer.test.tsx
+++ b/components/calendar/__tests__/event-modal-organizer.test.tsx
@@ -4,6 +4,7 @@ import { EventModal } from '../event-modal';
import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
import { AUTO_TIME_ZONE } from '@/lib/timezone';
import { useSettingsStore } from '@/stores/settings-store';
+import { getEventStartDate } from '@/lib/calendar-utils';
// The editor rewrote the organizer on every save of an event with attendees,
// to the user's first address, and put every attendee back to needs-action.
@@ -163,3 +164,28 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal answers after a
expect(statuses(data)).toEqual({ boss: 'accepted', mine: 'accepted', bob: 'declined' });
});
});
+
+// A save for every occurrence of a series applies what the user changed on
+// the occurrence the editor shows (lib/recurrence-scope-edit.ts): the save
+// carries the attendees the editor opened and saved with, and its dates.
+describe('EventModal editor state on save', () => {
+ it('passes the attendees it opened with, those it saved with, and when the event happens', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ const event = meeting('me@example.com', {
+ org: participant('1.0', 'me@example.com', true, 'accepted'),
+ bob: participant('1.0', 'bob@example.org', false, 'accepted'),
+ });
+ render();
+ fireEvent.click(screen.getByText('events.edit'));
+ fireEvent.change(screen.getByPlaceholderText('email_placeholder'), { target: { value: 'carol@example.org' } });
+ fireEvent.click(screen.getByText('form.save'));
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
+
+ const editor = onSave.mock.calls[0][2];
+ expect(editor.initialAttendees).toEqual([{ name: '', email: 'bob@example.org' }]);
+ expect(editor.attendees).toEqual([{ name: '', email: 'bob@example.org' }, { name: '', email: 'carol@example.org' }]);
+ expect(editor.when.allDay).toBe(false);
+ expect(editor.when.start.getTime()).toBe(getEventStartDate(event).getTime());
+ expect(editor.when.end.getTime() - editor.when.start.getTime()).toBe(3_600_000);
+ });
+});
diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx
index a3f4a5fd6..3e9d611a1 100644
--- a/components/calendar/calendar-app.tsx
+++ b/components/calendar/calendar-app.tsx
@@ -55,12 +55,12 @@ import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normaliz
import {
baseEventStoreId,
buildFallbackExcludePatch,
- buildFallbackOverridePatch,
isBrowserExpandedOccurrence,
isServerRecurrenceInstance,
overrideContextOf,
- withNewOverrideDetails,
} from "@/lib/recurrence-instances";
+import { saveOccurrenceEdit, truncateSeriesAt, type EventEditorState } from "@/lib/recurrence-scope-edit";
+import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
import { getClientByLocalAccountId } from "@/stores/client-registry";
import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message";
import { getEventStartDate } from "@/lib/calendar-utils";
@@ -93,7 +93,7 @@ import {
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
type PendingScopeAction =
- | { type: "edit"; event: CalendarEvent; updates: Partial; sendScheduling?: boolean }
+ | { type: "edit"; event: CalendarEvent; updates: Partial; editor?: EventEditorState; sendScheduling?: boolean }
| { type: "delete"; event: CalendarEvent; sendScheduling?: boolean }
| { type: "rsvp"; event: CalendarEvent; participantId: string; status: CalendarParticipant["participationStatus"] };
@@ -163,6 +163,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
),
[participantIdentities, identities, accountEmails]
);
+ const resolveContactName = useContactNameResolver();
const [showEventModal, setShowEventModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
@@ -872,7 +873,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
jumpTo(eventDate);
}, [jumpTo]);
- const handleSaveEvent = useCallback(async (data: Partial, requestedScheduling?: boolean) => {
+ const handleSaveEvent = useCallback(async (data: Partial, requestedScheduling?: boolean, editor?: EventEditorState) => {
if (!client) { toast.error(t("notifications.event_error")); return; }
const save = async (sendSchedulingMessages: boolean | undefined): Promise => {
try {
@@ -882,6 +883,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
type: "edit",
event: editEvent,
updates: data,
+ editor,
sendScheduling: sendSchedulingMessages,
});
setShowEventModal(false);
@@ -966,28 +968,6 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
}
}, [client, deleteEvent, events, editEvent, t]);
- const truncateRecurrenceAtEvent = useCallback(async (event: CalendarEvent): Promise<{
- master: CalendarEvent;
- originalRules: CalendarEvent["recurrenceRules"];
- } | null> => {
- const master = await findMasterEvent(event);
- if (!master) return null;
- const originalRules = master.recurrenceRules
- ? JSON.parse(JSON.stringify(master.recurrenceRules))
- : null;
- const occurrenceDate = event.recurrenceId || event.start;
- const untilDate = new Date(occurrenceDate);
- untilDate.setSeconds(untilDate.getSeconds() - 1);
- const until = format(untilDate, "yyyy-MM-dd'T'HH:mm:ss");
- const truncatedRules = (master.recurrenceRules || []).map(rule => ({
- ...rule,
- until,
- count: null,
- }));
- await updateEvent(client!, master.id, { recurrenceRules: truncatedRules });
- return { master, originalRules };
- }, [client, findMasterEvent, updateEvent]);
-
const submitRsvp = useCallback(async (
eventId: string,
participantId: string,
@@ -1013,84 +993,22 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
}
const { type, event, sendScheduling } = pendingScopeAction;
const updates = type === "edit" ? pendingScopeAction.updates : undefined;
+ const editor = type === "edit" ? pendingScopeAction.editor : undefined;
setPendingScopeAction(null);
try {
if (type === "edit" && updates) {
- switch (scope) {
- case "this": {
- if (isServerRecurrenceInstance(event) || isBrowserExpandedOccurrence(event)) {
- // The store writes one occurrence through its own (synthetic)
- // id, or as a recurrence override on the base event it was
- // expanded from.
- await updateEvent(client, event.id, updates, sendScheduling);
- break;
- }
- // An override outside an expanded series: override it on the master.
- const master = await findMasterEvent(event);
- const occurrence = master && overrideContextOf(event, master);
- const overridePatch = occurrence
- && buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates, { seriesKeys: true }));
- if (master && overridePatch) {
- await updateEvent(client, master.id, overridePatch, sendScheduling);
- } else {
- await updateEvent(client, event.id, updates, sendScheduling);
- }
- break;
- }
- case "this_and_future": {
- const result = await truncateRecurrenceAtEvent(event);
- if (!result) {
- toast.error(t("notifications.event_error"));
- return;
- }
- const { master, originalRules } = result;
- const occurrenceStart = event.recurrenceId || event.start;
- const newEventData: Partial = {
- title: master.title,
- description: master.description,
- duration: master.duration,
- timeZone: master.timeZone,
- calendarIds: { ...master.calendarIds },
- status: master.status,
- freeBusyStatus: master.freeBusyStatus,
- privacy: master.privacy,
- showWithoutTime: master.showWithoutTime,
- recurrenceRules: originalRules,
- ...updates,
- start: updates.start || occurrenceStart,
- };
- delete (newEventData as Record).id;
- delete (newEventData as Record).uid;
- delete (newEventData as Record).recurrenceId;
- try {
- await createEvent(client, newEventData, sendScheduling);
- } catch (createError) {
- debug.error("Failed to create new series, rolling back master truncation:", createError);
- try {
- await updateEvent(client, master.id, { recurrenceRules: originalRules });
- } catch (rollbackError) {
- debug.error("Rollback of master event also failed:", rollbackError);
- }
- throw createError;
- }
- break;
- }
- case "all": {
- const master = await findMasterEvent(event);
- if (!master) {
- toast.error(t("notifications.event_error"));
- return;
- }
- const allUpdates = { ...updates };
- delete (allUpdates as Record).recurrenceId;
- await updateEvent(client, master.id, allUpdates, sendScheduling);
- break;
- }
- default: {
- const _exhaustive: never = scope;
- throw new Error(`Unhandled scope: ${_exhaustive}`);
- }
+ const saved = await saveOccurrenceEdit(scope, {
+ event,
+ updates,
+ editor,
+ sendScheduling,
+ userEmails: currentUserEmails,
+ resolveName: resolveContactName,
+ }, { client, updateEvent, createEvent, findMasterEvent });
+ if (!saved) {
+ toast.error(t("notifications.event_error"));
+ return;
}
toast.success(t("notifications.event_updated"));
} else {
@@ -1113,7 +1031,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
break;
}
case "this_and_future": {
- const result = await truncateRecurrenceAtEvent(event);
+ const result = await truncateSeriesAt(event, { client, updateEvent, findMasterEvent });
if (!result) {
toast.error(t("notifications.event_error"));
return;
@@ -1144,7 +1062,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
} catch (error) {
toast.error(describeError(error, t("notifications.event_error")));
}
- }, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, truncateRecurrenceAtEvent, refetchCurrentRange, submitRsvp, describeError, t]);
+ }, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, currentUserEmails, resolveContactName, refetchCurrentRange, submitRsvp, describeError, t]);
const handleRsvp = useCallback(async (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => {
if (!client) return;
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx
index 7f98e7ab1..175fefc97 100644
--- a/components/calendar/event-modal.tsx
+++ b/components/calendar/event-modal.tsx
@@ -25,6 +25,7 @@ import {
} from "@/lib/calendar-participants";
import { canUserRsvp, getEventEditability, canCreateEventsIn } from "@/lib/calendar-editability";
import { withOwnAlertIds } from "@/lib/calendar-alerts";
+import { changesWhen, type EditorAttendee, type EventEditorState } from "@/lib/recurrence-scope-edit";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store";
import { generateUUID } from "@/lib/utils";
@@ -49,7 +50,11 @@ interface EventModalProps {
defaultEndDate?: Date;
defaultAllDay?: boolean;
defaultCalendarId?: string;
- onSave: (data: Partial, sendSchedulingMessages?: boolean) => void | Promise;
+ /**
+ * `editor` is what the editor had besides `data`, for a save that covers
+ * more than the event it was opened on (every occurrence of a series).
+ */
+ onSave: (data: Partial, sendSchedulingMessages?: boolean, editor?: EventEditorState) => void | Promise;
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
onDuplicate?: (data: Partial) => void;
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
@@ -89,27 +94,6 @@ export function savedStatusFields(
return { status: "confirmed", freeBusyStatus: "busy", privacy: "public" };
}
-/**
- * Whether the editor's dates change when `event` happens: it turns all-day
- * or timed, or its start or end moves - for an all-day event its first or
- * last day. The form's dates and the event's are display dates in the
- * user's zone, compared to the minute the form shows: saving the event in
- * the user's zone instead of its own is no change. Pure, for testing.
- */
-export function changesWhen(
- event: CalendarEvent,
- next: { allDay: boolean; start: Date; end: Date },
-): boolean {
- if (!!event.showWithoutTime !== next.allDay) return true;
- if (next.allDay) {
- return !isSameDay(getEventStartDate(event), next.start)
- || !isSameDay(getEventDisplayEndDate(event), next.end);
- }
- const minute = (date: Date) => Math.floor(date.getTime() / 60_000);
- return minute(getEventStartDate(event)) !== minute(next.start)
- || minute(getEventEndDate(event)) !== minute(next.end);
-}
-
function formatDateInput(d: Date): string {
return format(d, "yyyy-MM-dd");
}
@@ -509,7 +493,9 @@ export function EventModal({
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isSaving, setIsSaving] = useState(false);
- const [attendees, setAttendees] = useState<{ name: string; email: string }[]>(() => {
+ // The attendees the editor opened with: a save for every occurrence of a
+ // series applies only those the user added or removed.
+ const [initialAttendees] = useState(() => {
if (!event?.participants) return [];
// Never seed the invite list with the organizer or with a repeated address:
// handleSave writes the organizer separately, so either would round-trip
@@ -528,6 +514,7 @@ export function EventModal({
return acc;
}, []);
});
+ const [attendees, setAttendees] = useState(initialAttendees);
const [sendInvitations, setSendInvitations] = useState(true);
const participantInputRef = useRef(null);
@@ -708,11 +695,15 @@ export function EventModal({
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
setIsSaving(true);
try {
- await onSave(data, shouldSendScheduling);
+ await onSave(data, shouldSendScheduling, {
+ initialAttendees,
+ attendees: effectiveAttendees,
+ when: { allDay, start, end },
+ });
} finally {
setIsSaving(false);
}
- }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, resolveContactName, event, onSave, isSaving]);
+ }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, recurrence, customRule, alertRows, attendees, initialAttendees, sendInvitations, currentUserEmails, resolveContactName, event, onSave, isSaving]);
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
if (!event || !userParticipantId || !onRsvp) return;
diff --git a/lib/__tests__/event-modal-reschedule.test.ts b/lib/__tests__/event-modal-reschedule.test.ts
index a4466e83b..7669302da 100644
--- a/lib/__tests__/event-modal-reschedule.test.ts
+++ b/lib/__tests__/event-modal-reschedule.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { changesWhen } from '@/components/calendar/event-modal';
+import { changesWhen } from '@/lib/recurrence-scope-edit';
import type { CalendarEvent } from '@/lib/jmap/types';
// Whether the event editor's dates change when an event happens - what makes
diff --git a/lib/__tests__/recurrence-scope-edit.test.ts b/lib/__tests__/recurrence-scope-edit.test.ts
new file mode 100644
index 000000000..c1389750b
--- /dev/null
+++ b/lib/__tests__/recurrence-scope-edit.test.ts
@@ -0,0 +1,277 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { addDays, addHours, addMinutes, startOfDay } from 'date-fns';
+import {
+ saveOccurrenceEdit,
+ seriesUpdatesFromOccurrence,
+ type EventEditorState,
+ type OccurrenceEditActions,
+} from '@/lib/recurrence-scope-edit';
+import { expandRecurringEvents } from '@/lib/recurrence-expansion';
+import { getEventEndDate, getEventStartDate } from '@/lib/calendar-utils';
+import { AUTO_TIME_ZONE } from '@/lib/timezone';
+import { useSettingsStore } from '@/stores/settings-store';
+import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+
+// "All occurrences" from the editor opened on one occurrence: the editor's
+// dates and participants are that occurrence's. Written to the series as
+// they were, a rename from the third occurrence moved the series' start to
+// it (the first two occurrences gone) and gave every occurrence the third
+// one's answers - here userb's "declined" for that day only.
+
+type Version = '0.16' | '1.0';
+const ORGANIZER = 'userc@example.org';
+const ATTENDEE = 'userb@example.org';
+
+// Participants as each version stores them: 0.16.23 drops an attendee's
+// roles, 1.0 keeps them.
+function participant(version: Version, address: string, owner: boolean, status: string): CalendarParticipant {
+ return {
+ '@type': 'Participant', calendarAddress: `mailto:${address}`, email: address,
+ ...(owner ? { roles: { owner: true } } : version === '1.0' ? { roles: { attendee: true } } : {}),
+ participationStatus: status, scheduleAgent: 'server', expectReply: !owner, kind: 'individual',
+ } as unknown as CalendarParticipant;
+}
+
+const statusOf = (participants: CalendarEvent['participants'] | undefined, address: string) =>
+ Object.values(participants ?? {}).find((p) => p.calendarAddress === `mailto:${address}`)?.participationStatus;
+
+// Weekly on Mondays from 5 October, 10:00 in Berlin, five times. userb
+// accepted the series and declined 19 October alone: 1.0 returns that
+// override as a PatchObject, 0.16 as the whole participants map.
+function series(version: Version): CalendarEvent {
+ const participants = {
+ org: participant(version, ORGANIZER, true, 'accepted'),
+ b: participant(version, ATTENDEE, false, 'accepted'),
+ };
+ return {
+ id: 'b', uid: 'weekly@example.org', '@type': 'Event', title: 'Planning', description: '',
+ start: '2026-10-05T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false,
+ utcStart: '2026-10-05T08:00:00Z', utcEnd: '2026-10-05T09:00:00Z',
+ calendarIds: { cal: true }, organizerCalendarAddress: `mailto:${ORGANIZER}`, participants,
+ recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: 5 }],
+ recurrenceOverrides: version === '1.0'
+ ? { '2026-10-19T10:00:00': { 'participants/b/participationStatus': 'declined' } }
+ : { '2026-10-19T10:00:00': { participants: { ...participants, b: participant(version, ATTENDEE, false, 'declined') } } },
+ excludedRecurrenceRules: null, alerts: null,
+ } as unknown as CalendarEvent;
+}
+
+// 19 October as the server expands it (0.16.20 on, and 1.0): a synthetic id
+// naming its base event, hydrated from it.
+function serverOccurrence(version: Version): CalendarEvent {
+ const base = series(version);
+ return {
+ ...base,
+ id: 'maaaaac', baseEventId: 'b',
+ start: '2026-10-19T10:00:00', utcStart: '2026-10-19T08:00:00Z', utcEnd: '2026-10-19T09:00:00Z',
+ recurrenceId: '2026-10-19T10:00:00', recurrenceIdTimeZone: 'Europe/Berlin',
+ participants: { ...base.participants, b: participant(version, ATTENDEE, false, 'declined') },
+ } as CalendarEvent;
+}
+
+// 19 October as the browser expands it (0.16 before 0.16.20).
+function browserOccurrence(): CalendarEvent {
+ const occurrences = expandRecurringEvents([series('0.16')], '2026-10-01T00:00:00', '2026-11-30T00:00:00');
+ return occurrences.find((o) => o.recurrenceId === '2026-10-19T10:00:00')!;
+}
+
+const occurrences: Array<[string, Version, () => CalendarEvent]> = [
+ ['1.0', '1.0', () => serverOccurrence('1.0')],
+ ['0.16.20+', '0.16', () => serverOccurrence('0.16')],
+ ['0.16 before 0.16.20', '0.16', browserOccurrence],
+];
+
+/** What the editor had, opened on `occurrence` and saved as it came unless changed. */
+function editorOn(occurrence: CalendarEvent, change: Partial = {}): EventEditorState {
+ const attendees = [{ name: '', email: ATTENDEE }];
+ return {
+ initialAttendees: attendees,
+ attendees,
+ when: { allDay: !!occurrence.showWithoutTime, start: getEventStartDate(occurrence), end: getEventEndDate(occurrence) },
+ ...change,
+ };
+}
+
+/** What the editor writes for `occurrence` (event-modal handleSave) with `change`. */
+function editorUpdates(occurrence: CalendarEvent, change: Partial = {}): Partial {
+ return {
+ title: occurrence.title, description: '', start: occurrence.start, duration: occurrence.duration,
+ timeZone: occurrence.timeZone, showWithoutTime: false, calendarIds: { cal: true },
+ recurrenceRules: occurrence.recurrenceRules, recurrenceId: occurrence.recurrenceId,
+ // The occurrence's participants, rebuilt with its answers.
+ participants: occurrence.participants,
+ ...change,
+ };
+}
+
+const context = { userEmail: ORGANIZER, userEmails: [ORGANIZER] };
+
+beforeEach(() => useSettingsStore.setState({ timeZone: 'Europe/Berlin' }));
+afterEach(() => useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE }));
+
+describe.each(occurrences)('changing every occurrence from one of them on Stalwart %s', (_label, version, make) => {
+ it('renames the series and keeps when it happens and its answers', () => {
+ const occurrence = make();
+ const updates = seriesUpdatesFromOccurrence(
+ occurrence, series(version), editorUpdates(occurrence, { title: 'Planning, room 2' }), editorOn(occurrence), context,
+ );
+
+ expect(updates.title).toBe('Planning, room 2');
+ for (const key of ['start', 'duration', 'timeZone', 'showWithoutTime', 'recurrenceId', 'participants', 'organizerCalendarAddress']) {
+ expect(updates).not.toHaveProperty(key);
+ }
+ });
+
+ it('moves the series from its own start by as much as the occurrence moved, and asks again', () => {
+ const occurrence = make();
+ const start = getEventStartDate(occurrence);
+ const updates = seriesUpdatesFromOccurrence(
+ occurrence, series(version),
+ editorUpdates(occurrence, { start: '2026-10-19T11:30:00' }),
+ editorOn(occurrence, { when: { allDay: false, start: addMinutes(start, 90), end: addMinutes(getEventEndDate(occurrence), 90) } }),
+ context,
+ );
+
+ expect(updates.start).toBe('2026-10-05T11:30:00');
+ expect(updates).not.toHaveProperty('duration');
+ expect(updates).not.toHaveProperty('timeZone');
+ // Asked again as the series' organizer: the series' answer, not the
+ // occurrence's "declined", goes back to needs-action.
+ expect(statusOf(updates.participants, ORGANIZER)).toBe('accepted');
+ expect(statusOf(updates.participants, ATTENDEE)).toBe('needs-action');
+ });
+
+ it('moves the series to another weekday by the days the occurrence moved', () => {
+ const occurrence = make();
+ const updates = seriesUpdatesFromOccurrence(
+ occurrence, series(version), editorUpdates(occurrence),
+ editorOn(occurrence, { when: { allDay: false, start: addDays(getEventStartDate(occurrence), 1), end: addDays(getEventEndDate(occurrence), 1) } }),
+ context,
+ );
+
+ expect(updates.start).toBe('2026-10-06T10:00:00');
+ });
+
+ it('changes only the length when only the end moved', () => {
+ const occurrence = make();
+ const updates = seriesUpdatesFromOccurrence(
+ occurrence, series(version), editorUpdates(occurrence, { duration: 'PT1H30M' }),
+ editorOn(occurrence, { when: { allDay: false, start: getEventStartDate(occurrence), end: addMinutes(getEventEndDate(occurrence), 30) } }),
+ context,
+ );
+
+ expect(updates.duration).toBe('PT1H30M');
+ expect(updates).not.toHaveProperty('start');
+ expect(statusOf(updates.participants, ATTENDEE)).toBe('needs-action');
+ });
+
+ it('turns the series all-day on its own first day', () => {
+ const occurrence = make();
+ const day = startOfDay(getEventStartDate(occurrence));
+ const updates = seriesUpdatesFromOccurrence(
+ occurrence, series(version),
+ editorUpdates(occurrence, { start: '2026-10-19T00:00:00', duration: 'P1D', timeZone: null, showWithoutTime: true }),
+ editorOn(occurrence, { when: { allDay: true, start: day, end: day } }),
+ context,
+ );
+
+ expect(updates).toMatchObject({ start: '2026-10-05T00:00:00', duration: 'P1D', timeZone: null, showWithoutTime: true });
+ });
+
+ it('adds the attendee the user added to the series\' own participants', () => {
+ const occurrence = make();
+ const attendees = [{ name: '', email: ATTENDEE }, { name: 'Anna', email: 'usera@example.org' }];
+ const updates = seriesUpdatesFromOccurrence(
+ occurrence, series(version), editorUpdates(occurrence), editorOn(occurrence, { attendees }), context,
+ );
+
+ const participants = updates.participants as Record;
+ // The series' ids and answers: userb accepted the series.
+ expect(participants.org.calendarAddress).toBe(`mailto:${ORGANIZER}`);
+ expect(participants.b.participationStatus).toBe('accepted');
+ expect(statusOf(participants, 'usera@example.org')).toBe('needs-action');
+ expect(Object.keys(participants)).toHaveLength(3);
+ expect(updates).not.toHaveProperty('organizerCalendarAddress');
+ });
+
+ it('removes the attendee the user removed, and the participants with the last one', () => {
+ const occurrence = make();
+ const updates = seriesUpdatesFromOccurrence(
+ occurrence, series(version), editorUpdates(occurrence, { participants: null }), editorOn(occurrence, { attendees: [] }), context,
+ );
+
+ expect(updates.participants).toBeNull();
+ expect(updates.organizerCalendarAddress).toBeNull();
+ });
+});
+
+describe('changing every occurrence of a series in another time zone', () => {
+ it('moves the series on its own wall clock, whatever zone the user sees it in', () => {
+ useSettingsStore.setState({ timeZone: 'Asia/Tokyo' });
+ // 08:00 in New York is 21:00 in Tokyo; the user moves it to 22:00.
+ const base = {
+ ...series('1.0'), start: '2026-10-05T08:00:00', timeZone: 'America/New_York',
+ utcStart: '2026-10-05T12:00:00Z', utcEnd: '2026-10-05T13:00:00Z',
+ } as CalendarEvent;
+ const occurrence = {
+ ...base, id: 'maaaaac', baseEventId: 'b', start: '2026-10-19T08:00:00',
+ utcStart: '2026-10-19T12:00:00Z', utcEnd: '2026-10-19T13:00:00Z', recurrenceId: '2026-10-19T08:00:00',
+ } as CalendarEvent;
+ const start = getEventStartDate(occurrence);
+
+ const updates = seriesUpdatesFromOccurrence(
+ occurrence, base,
+ editorUpdates(occurrence, { start: '2026-10-19T22:00:00', timeZone: 'Asia/Tokyo' }),
+ editorOn(occurrence, { when: { allDay: false, start: addHours(start, 1), end: addHours(getEventEndDate(occurrence), 1) } }),
+ context,
+ );
+
+ expect(updates.start).toBe('2026-10-05T09:00:00');
+ expect(updates).not.toHaveProperty('timeZone');
+ });
+});
+
+describe('saveOccurrenceEdit for every occurrence', () => {
+ function actions(master: CalendarEvent | null) {
+ return {
+ client: {} as IJMAPClient,
+ updateEvent: vi.fn().mockResolvedValue(undefined),
+ createEvent: vi.fn().mockResolvedValue(null),
+ findMasterEvent: vi.fn().mockResolvedValue(master),
+ } satisfies OccurrenceEditActions;
+ }
+
+ it('writes the series\' own change to its base event on Stalwart 1.0', async () => {
+ const occurrence = serverOccurrence('1.0');
+ const deps = actions(series('1.0'));
+
+ const saved = await saveOccurrenceEdit('all', {
+ event: occurrence,
+ updates: editorUpdates(occurrence, { title: 'Planning, room 2' }),
+ editor: editorOn(occurrence),
+ sendScheduling: true,
+ userEmails: [ORGANIZER],
+ }, deps);
+
+ expect(saved).toBe(true);
+ expect(deps.findMasterEvent).toHaveBeenCalledWith(occurrence);
+ expect(deps.updateEvent).toHaveBeenCalledWith(
+ deps.client, 'b',
+ { title: 'Planning, room 2', description: '', calendarIds: { cal: true }, recurrenceRules: occurrence.recurrenceRules },
+ true,
+ );
+ });
+
+ it('reports a series it cannot find', async () => {
+ const occurrence = serverOccurrence('1.0');
+ const deps = actions(null);
+
+ const saved = await saveOccurrenceEdit('all', {
+ event: occurrence, updates: editorUpdates(occurrence), editor: editorOn(occurrence), userEmails: [ORGANIZER],
+ }, deps);
+
+ expect(saved).toBe(false);
+ expect(deps.updateEvent).not.toHaveBeenCalled();
+ });
+});
diff --git a/lib/recurrence-scope-edit.ts b/lib/recurrence-scope-edit.ts
new file mode 100644
index 000000000..9e229963a
--- /dev/null
+++ b/lib/recurrence-scope-edit.ts
@@ -0,0 +1,340 @@
+/**
+ * Saving an event editor change made on one occurrence of a series for the
+ * part of the series the user picks in the recurrence scope dialog: that
+ * occurrence, it and the ones after it, or all of them.
+ */
+
+import { differenceInCalendarDays, format, isSameDay } from 'date-fns';
+import type { RecurrenceEditScope } from '@/components/calendar/recurrence-scope-dialog';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import type { CalendarEvent } from '@/lib/jmap/types';
+import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from '@/lib/calendar-utils';
+import { parseDurationSeconds } from '@/lib/calendar-event-normalization';
+import { getParticipantList, participantFieldsToSave, type ParticipantSaveContext } from '@/lib/calendar-participants';
+import {
+ buildFallbackOverridePatch,
+ isBrowserExpandedOccurrence,
+ isServerRecurrenceInstance,
+ overrideContextOf,
+ withNewOverrideDetails,
+} from '@/lib/recurrence-instances';
+import { getEffectiveTimeZone, moveLocalDateTime } from '@/lib/timezone';
+import { debug } from '@/lib/debug';
+
+export interface EditorAttendee {
+ name: string;
+ email: string;
+}
+
+/** When an event happens in the editor: display dates, the last day of an all-day event. */
+export interface EventWhen {
+ allDay: boolean;
+ start: Date;
+ end: Date;
+}
+
+/** What the event editor had when it saved, besides the event data it wrote. */
+export interface EventEditorState {
+ /** The attendees when the editor opened, and when it saved; the organizer is in neither. */
+ initialAttendees: EditorAttendee[];
+ attendees: EditorAttendee[];
+ when: EventWhen;
+}
+
+/**
+ * Whether the editor's dates change when `event` happens: it turns all-day
+ * or timed, or its start or end moves - for an all-day event its first or
+ * last day. The form's dates and the event's are display dates in the
+ * user's zone, compared to the minute the form shows: saving the event in
+ * the user's zone instead of its own is no change. Pure, for testing.
+ */
+export function changesWhen(event: CalendarEvent, next: EventWhen): boolean {
+ if (!!event.showWithoutTime !== next.allDay) return true;
+ if (next.allDay) {
+ return !isSameDay(getEventStartDate(event), next.start)
+ || !isSameDay(getEventDisplayEndDate(event), next.end);
+ }
+ const minute = (date: Date) => Math.floor(date.getTime() / 60_000);
+ return minute(getEventStartDate(event)) !== minute(next.start)
+ || minute(getEventEndDate(event)) !== minute(next.end);
+}
+
+const WHEN_KEYS = ['start', 'duration', 'timeZone', 'showWithoutTime'] as const;
+type SeriesWhen = Partial>;
+
+/** A display date's wall clock to the minute (the form shows no seconds), as milliseconds. */
+function wallClockMinutesMs(date: Date): number {
+ return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), date.getMinutes());
+}
+
+/** A LocalDateTime ("YYYY-MM-DDTHH:mm:ss") moved by `ms` on its own wall clock. */
+function shiftLocalDateTime(value: string, ms: number): string {
+ const match = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(value);
+ if (!match) return value;
+ const [year, month, day, hour, minute, second] = match.slice(1).map((part) => Number(part ?? 0));
+ const shifted = new Date(0);
+ shifted.setUTCFullYear(year, month - 1, day);
+ shifted.setUTCHours(hour, minute, second, 0);
+ return new Date(shifted.getTime() + ms).toISOString().slice(0, 19);
+}
+
+/**
+ * When the series happens after the editor moved one of its occurrences:
+ * its own start moved by as much as the occurrence was, on its own date and
+ * in its own zone; the length only when the editor changed it. Nothing when
+ * the occurrence did not move.
+ */
+function seriesWhen(
+ occurrence: CalendarEvent,
+ master: CalendarEvent,
+ updates: Partial,
+ when: EventWhen,
+): SeriesWhen {
+ if (!changesWhen(occurrence, when)) return {};
+ const result: SeriesWhen = {};
+ const allDayChanged = !!master.showWithoutTime !== when.allDay;
+ const from = getEventStartDate(occurrence);
+
+ if (when.allDay) {
+ // The series' first day as the user sees it, moved by the days the
+ // occurrence moved.
+ const first = !master.showWithoutTime && master.timeZone
+ ? moveLocalDateTime(master.start, master.timeZone, getEffectiveTimeZone())
+ : master.start;
+ const days = differenceInCalendarDays(when.start, from);
+ if (days !== 0 || allDayChanged) {
+ result.start = `${shiftLocalDateTime(`${first.slice(0, 10)}T00:00:00`, days * 86_400_000).slice(0, 10)}T00:00:00`;
+ }
+ } else {
+ const delta = wallClockMinutesMs(when.start) - wallClockMinutesMs(from);
+ if (delta !== 0 || allDayChanged) result.start = shiftLocalDateTime(master.start, delta);
+ }
+
+ const seconds = parseDurationSeconds(occurrence.duration ?? master.duration);
+ if (allDayChanged || parseDurationSeconds(updates.duration) !== seconds) {
+ result.duration = updates.duration;
+ }
+ if (allDayChanged) {
+ result.showWithoutTime = when.allDay;
+ result.timeZone = updates.timeZone ?? null;
+ }
+ return result;
+}
+
+/** An event's attendees as the editor lists them: each address once, without the organizer. */
+function attendeesOf(event: CalendarEvent): EditorAttendee[] {
+ return getParticipantList(event)
+ .filter((p) => !p.isOrganizer && p.email.trim())
+ .map((p) => ({ name: p.name, email: p.email.trim() }));
+}
+
+/**
+ * The series' participants after the editor changed one occurrence's: the
+ * series' own, with the attendees the user added in the editor or removed
+ * from it. Nothing when neither happened and the series does not move -
+ * the series keeps its participants and their answers as they are.
+ */
+function seriesParticipants(
+ master: CalendarEvent,
+ editor: EventEditorState,
+ context: ParticipantSaveContext,
+): Partial> {
+ const key = (email: string) => email.trim().toLowerCase();
+ const before = new Set(editor.initialAttendees.map((a) => key(a.email)));
+ const after = new Set(editor.attendees.map((a) => key(a.email)));
+ const added = editor.attendees.filter((a) => !before.has(key(a.email)));
+ const removed = new Set([...before].filter((address) => !after.has(address)));
+ if (added.length === 0 && removed.size === 0 && !context.rescheduled) return {};
+ const attendees = [...attendeesOf(master).filter((a) => !removed.has(key(a.email))), ...added];
+ return participantFieldsToSave(master, attendees, context);
+}
+
+/**
+ * The editor's `updates`, made on `occurrence`, as a change to its whole
+ * series `master`.
+ *
+ * The editor shows one occurrence, so the dates and participants it writes
+ * are that occurrence's. Written to the series as they are, the series
+ * started on the occurrence's day - the occurrences before it gone, a
+ * counted series running longer - and every occurrence got that one
+ * occurrence's attendees and answers. The series keeps its own instead:
+ * - when it happens, unless the occurrence moved; then the series moves by
+ * as much from its own start (seriesWhen);
+ * - its participants and their answers, with the attendees the user added
+ * or removed; when the series moves, the user as its organizer asks the
+ * attendees again (participantFieldsToSave).
+ */
+export function seriesUpdatesFromOccurrence(
+ occurrence: CalendarEvent,
+ master: CalendarEvent,
+ updates: Partial,
+ editor: EventEditorState,
+ context: Omit,
+): Partial {
+ const series: Partial = { ...updates };
+ for (const key of [...WHEN_KEYS, 'recurrenceId', 'participants', 'organizerCalendarAddress', 'replyTo'] as const) {
+ delete series[key];
+ }
+ const when = seriesWhen(occurrence, master, updates, editor.when);
+ Object.assign(series, when);
+ Object.assign(series, seriesParticipants(master, editor, {
+ ...context,
+ rescheduled: Object.keys(when).length > 0,
+ }));
+ return series;
+}
+
+/** Options of the calendar store's updateEvent. */
+export interface EventUpdateOptions {
+ /**
+ * The user organizes the event. A change to one occurrence that gives it
+ * its own override then reaches the attendees who have answered too
+ * (see the calendar store's updateEvent).
+ */
+ asOrganizer?: boolean;
+}
+
+/** What saving an occurrence edit works with: the calendar store's actions and the app's series lookup. */
+export interface OccurrenceEditActions {
+ client: IJMAPClient;
+ updateEvent: (
+ client: IJMAPClient,
+ id: string,
+ updates: Partial,
+ sendSchedulingMessages?: boolean,
+ options?: EventUpdateOptions,
+ ) => Promise;
+ createEvent: (client: IJMAPClient, event: Partial, sendSchedulingMessages?: boolean) => Promise;
+ /** The base event of a series, from one of its occurrences. */
+ findMasterEvent: (occurrence: CalendarEvent) => Promise;
+}
+
+export interface OccurrenceEdit {
+ /** The occurrence the editor was opened on. */
+ event: CalendarEvent;
+ /** What the editor wrote for it. */
+ updates: Partial;
+ /** What the editor had. Without it the updates go to the series as they are. */
+ editor?: EventEditorState;
+ sendScheduling?: boolean;
+ /** The user's calendar addresses; the first organizes new invitations. */
+ userEmails: string[];
+ /** A name for an address the event data carries none for. */
+ resolveName?: (email: string) => string | undefined;
+}
+
+/**
+ * End `occurrence`'s series just before it. Resolves to the series and its
+ * rules as they were, for a rollback; null when the series is not found.
+ */
+export async function truncateSeriesAt(
+ occurrence: CalendarEvent,
+ actions: Pick,
+): Promise<{ master: CalendarEvent; originalRules: CalendarEvent['recurrenceRules'] } | null> {
+ const master = await actions.findMasterEvent(occurrence);
+ if (!master) return null;
+ const originalRules = master.recurrenceRules
+ ? JSON.parse(JSON.stringify(master.recurrenceRules))
+ : null;
+ const occurrenceDate = occurrence.recurrenceId || occurrence.start;
+ const untilDate = new Date(occurrenceDate);
+ untilDate.setSeconds(untilDate.getSeconds() - 1);
+ const until = format(untilDate, "yyyy-MM-dd'T'HH:mm:ss");
+ const truncatedRules = (master.recurrenceRules || []).map(rule => ({
+ ...rule,
+ until,
+ count: null,
+ }));
+ await actions.updateEvent(actions.client, master.id, { recurrenceRules: truncatedRules });
+ return { master, originalRules };
+}
+
+/**
+ * Save the editor's change to one occurrence for `scope`. Resolves to false
+ * when the series the occurrence belongs to cannot be found.
+ */
+export async function saveOccurrenceEdit(
+ scope: RecurrenceEditScope,
+ edit: OccurrenceEdit,
+ actions: OccurrenceEditActions,
+): Promise {
+ const { client, updateEvent, createEvent, findMasterEvent } = actions;
+ const { event, updates, sendScheduling } = edit;
+ switch (scope) {
+ case 'this': {
+ if (isServerRecurrenceInstance(event) || isBrowserExpandedOccurrence(event)) {
+ // The store writes one occurrence through its own (synthetic) id, or
+ // as a recurrence override on the base event it was expanded from.
+ await updateEvent(client, event.id, updates, sendScheduling);
+ return true;
+ }
+ // An override outside an expanded series: override it on the master.
+ const master = await findMasterEvent(event);
+ const occurrence = master && overrideContextOf(event, master);
+ const overridePatch = occurrence
+ && buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates, { seriesKeys: true }));
+ if (master && overridePatch) {
+ await updateEvent(client, master.id, overridePatch, sendScheduling);
+ } else {
+ await updateEvent(client, event.id, updates, sendScheduling);
+ }
+ return true;
+ }
+ case 'this_and_future': {
+ const result = await truncateSeriesAt(event, actions);
+ if (!result) return false;
+ const { master, originalRules } = result;
+ const occurrenceStart = event.recurrenceId || event.start;
+ const newEventData: Partial = {
+ title: master.title,
+ description: master.description,
+ duration: master.duration,
+ timeZone: master.timeZone,
+ calendarIds: { ...master.calendarIds },
+ status: master.status,
+ freeBusyStatus: master.freeBusyStatus,
+ privacy: master.privacy,
+ showWithoutTime: master.showWithoutTime,
+ recurrenceRules: originalRules,
+ ...updates,
+ start: updates.start || occurrenceStart,
+ };
+ delete (newEventData as Record).id;
+ delete (newEventData as Record).uid;
+ delete (newEventData as Record).recurrenceId;
+ try {
+ await createEvent(client, newEventData, sendScheduling);
+ } catch (createError) {
+ debug.error('Failed to create new series, rolling back master truncation:', createError);
+ try {
+ await updateEvent(client, master.id, { recurrenceRules: originalRules });
+ } catch (rollbackError) {
+ debug.error('Rollback of master event also failed:', rollbackError);
+ }
+ throw createError;
+ }
+ return true;
+ }
+ case 'all': {
+ const master = await findMasterEvent(event);
+ if (!master) return false;
+ let seriesUpdates: Partial;
+ if (edit.editor) {
+ seriesUpdates = seriesUpdatesFromOccurrence(event, master, updates, edit.editor, {
+ userEmail: edit.userEmails[0],
+ userEmails: edit.userEmails,
+ resolveName: edit.resolveName,
+ });
+ } else {
+ seriesUpdates = { ...updates };
+ delete seriesUpdates.recurrenceId;
+ }
+ await updateEvent(client, master.id, seriesUpdates, sendScheduling);
+ return true;
+ }
+ default: {
+ const exhaustive: never = scope;
+ throw new Error(`Unhandled scope: ${exhaustive}`);
+ }
+ }
+}