From e8ba9e35692f74dfebb21b912e20e21fa50159f8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:21:57 +0200 Subject: [PATCH] fix: keep a series' dates and answers when editing all occurrences Saving "All events" from the editor opened on one occurrence wrote that occurrence's data to the whole series. Renaming a weekly series from its third occurrence moved the series' start to that day: the first two occurrences were gone, a counted series ran two weeks longer, and overrides before the new start were orphaned. The occurrence's participants went to every occurrence as well, so an attendee who had declined only that day now declined the series, and Stalwart sent them no more updates (live on 1.0 and 0.16.23). The check for a move compared the form with the occurrence, so the rename that moved the series did not ask anyone again. The series now keeps its own: - when it happens, unless the occurrence was moved; then the series moves by as much, from its own start and in its own time zone, and the length changes only when the editor changed it - its participants and their answers, with the attendees the user added or removed in the editor; when the series moves, its organizer asks the attendees again The scope handling moved from calendar-app.tsx to `lib/recurrence-scope-edit.ts` so it can be tested. The editor now hands its attendees (as opened and as saved) and its dates to the save. --- .../__tests__/event-modal-organizer.test.tsx | 26 ++ components/calendar/calendar-app.tsx | 122 ++----- components/calendar/event-modal.tsx | 41 +-- lib/__tests__/event-modal-reschedule.test.ts | 2 +- lib/__tests__/recurrence-scope-edit.test.ts | 277 ++++++++++++++ lib/recurrence-scope-edit.ts | 340 ++++++++++++++++++ 6 files changed, 680 insertions(+), 128 deletions(-) create mode 100644 lib/__tests__/recurrence-scope-edit.test.ts create mode 100644 lib/recurrence-scope-edit.ts 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}`); + } + } +}