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}`); + } + } +}