diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 95fd12b3e..6068b6d85 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -52,14 +52,8 @@ import { useIsFocusedProTab } from "@/hooks/use-pane-context"; import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization"; -import { - baseEventStoreId, - buildFallbackExcludePatch, - isBrowserExpandedOccurrence, - isServerRecurrenceInstance, - overrideContextOf, -} from "@/lib/recurrence-instances"; -import { saveOccurrenceEdit, truncateSeriesAt, type EventEditorState } from "@/lib/recurrence-scope-edit"; +import { baseEventStoreId, isServerRecurrenceInstance } from "@/lib/recurrence-instances"; +import { deleteOccurrence, saveOccurrenceEdit, 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"; @@ -1012,48 +1006,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = } toast.success(t("notifications.event_updated")); } else { - switch (scope) { - case "this": { - if (isServerRecurrenceInstance(event) || isBrowserExpandedOccurrence(event)) { - // The store destroys a server occurrence, or excludes a - // browser-expanded one on its base event. - await deleteEvent(client, event.id, sendScheduling); - break; - } - // An override outside an expanded series: exclude it on the master. - const delMaster = await findMasterEvent(event); - const excludePatch = delMaster && buildFallbackExcludePatch(overrideContextOf(event, delMaster)); - if (delMaster && excludePatch) { - await updateEvent(client, delMaster.id, excludePatch, sendScheduling); - } else { - await deleteEvent(client, event.id, sendScheduling); - } - break; - } - case "this_and_future": { - const result = await truncateSeriesAt(event, { client, updateEvent, findMasterEvent }, { - sendScheduling, - userEmails: currentUserEmails, - }); - if (!result) { - toast.error(t("notifications.event_error")); - return; - } - break; - } - case "all": { - const master = await findMasterEvent(event); - if (!master) { - toast.error(t("notifications.event_error")); - return; - } - await deleteEvent(client, master.id, sendScheduling); - break; - } - default: { - const _exhaustive: never = scope; - throw new Error(`Unhandled scope: ${_exhaustive}`); - } + const deleted = await deleteOccurrence(scope, { + event, + sendScheduling, + userEmails: currentUserEmails, + }, { client, updateEvent, deleteEvent, findMasterEvent }); + if (!deleted) { + toast.error(t("notifications.event_error")); + return; } toast.success(t("notifications.event_deleted")); } diff --git a/lib/__tests__/recurrence-scope-edit.test.ts b/lib/__tests__/recurrence-scope-edit.test.ts index dd56167c8..291a70b6d 100644 --- a/lib/__tests__/recurrence-scope-edit.test.ts +++ b/lib/__tests__/recurrence-scope-edit.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { addDays, addHours, addMinutes, startOfDay } from 'date-fns'; import { + deleteOccurrence, followingSeriesData, saveOccurrenceEdit, seriesUpdatesFromOccurrence, @@ -391,3 +392,149 @@ describe.each(occurrences)('saving one occurrence on Stalwart %s', (_label, vers expect(deps.updateEvent).toHaveBeenCalledWith(deps.client, occurrence.id, updates, false, { asOrganizer: false }); }); }); + + +// A series the calendar has not expanded - one just created, one opened from +// a link - shows as the series itself, once, at its first occurrence. "This +// occurrence" of it went to the series: the editor's data rewrote every +// occurrence (live on 1.0 and 0.16.23, the request an update of the series' +// id with its title, start and rule), and deleting it deleted the series. +describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('the series itself standing for its first occurrence on Stalwart %s', (_label, version) => { + // As CalendarEvent/get returns the base event: on 1.0 (and 0.16.20 on) it + // names itself as its base event. + const base = (overrides: Partial = {}) => ({ + ...series(version), ...(version === '1.0' ? { baseEventId: 'b' } : {}), recurrenceOverrides: null, ...overrides, + }) as CalendarEvent; + function seriesActions(master: CalendarEvent | null) { + return { ...actions(master), deleteEvent: vi.fn().mockResolvedValue(undefined) }; + } + + it('changes only the first occurrence for "this occurrence"', async () => { + const master = base(); + const deps = seriesActions(master); + + const saved = await saveOccurrenceEdit('this', { + event: master, updates: editorUpdates(master, { title: 'Kick-off', recurrenceId: undefined }), sendScheduling: true, userEmails: [ORGANIZER], + }, deps); + + expect(saved).toBe(true); + const [, id, patch, send] = deps.updateEvent.mock.calls[0]; + expect([id, send]).toEqual(['b', true]); + // The whole map, as the series has no overrides yet; nothing of the series changes. + expect(Object.keys(patch)).toEqual(['recurrenceOverrides']); + expect(patch.recurrenceOverrides['2026-10-05T10:00:00']).toMatchObject({ start: '2026-10-05T10:00:00', title: 'Kick-off' }); + }); + + it('adds the override to those the series has', async () => { + const master = base({ recurrenceOverrides: series(version).recurrenceOverrides }); + const deps = seriesActions(master); + + await saveOccurrenceEdit('this', { + event: master, updates: editorUpdates(master, { title: 'Kick-off', recurrenceId: undefined }), sendScheduling: true, userEmails: [ORGANIZER], + }, deps); + + expect(Object.keys(deps.updateEvent.mock.calls[0][2])).toEqual(['recurrenceOverrides/2026-10-05T10:00:00']); + }); + + it('changes the whole series for "this and following" from its first occurrence', async () => { + const master = base(); + const deps = seriesActions(master); + + await saveOccurrenceEdit('this_and_future', { + event: master, updates: editorUpdates(master, { title: 'Kick-off', recurrenceId: undefined }), editor: editorOn(master), + sendScheduling: true, userEmails: [ORGANIZER], + }, deps); + + // Not ended before its first occurrence, no new series: the series itself changes. + expect(deps.createEvent).not.toHaveBeenCalled(); + expect(deps.updateEvent).toHaveBeenCalledTimes(1); + const [, id, patch] = deps.updateEvent.mock.calls[0]; + expect(id).toBe('b'); + expect(patch).toMatchObject({ title: 'Kick-off', recurrenceRules: master.recurrenceRules }); + }); + + it('excludes the first occurrence for "delete this occurrence"', async () => { + const master = base(); + const deps = seriesActions(master); + + const deleted = await deleteOccurrence('this', { event: master, sendScheduling: true, userEmails: [ORGANIZER] }, deps); + + expect(deleted).toBe(true); + expect(deps.deleteEvent).not.toHaveBeenCalled(); + expect(deps.updateEvent).toHaveBeenCalledWith(deps.client, 'b', { + recurrenceOverrides: { '2026-10-05T10:00:00': { excluded: true } }, + }, true); + }); + + it('deletes the whole series for "delete this and following" from its first occurrence', async () => { + const master = base(); + const deps = seriesActions(master); + + await deleteOccurrence('this_and_future', { event: master, sendScheduling: true, userEmails: [ORGANIZER] }, deps); + + expect(deps.deleteEvent).toHaveBeenCalledWith(deps.client, 'b', true); + expect(deps.updateEvent).not.toHaveBeenCalled(); + }); + + it('refuses rather than touch a series it cannot find', async () => { + const deps = seriesActions(null); + + expect(await saveOccurrenceEdit('this', { event: base(), updates: { title: 'x' }, userEmails: [ORGANIZER] }, deps)).toBe(false); + expect(await deleteOccurrence('this', { event: base(), userEmails: [ORGANIZER] }, deps)).toBe(false); + expect(deps.updateEvent).not.toHaveBeenCalled(); + expect(deps.deleteEvent).not.toHaveBeenCalled(); + }); +}); + +describe.each(occurrences)('deleting part of a series from one occurrence on Stalwart %s', (_label, version, make) => { + function deleteActions() { + return { ...actions(series(version)), deleteEvent: vi.fn().mockResolvedValue(undefined) }; + } + + it('deletes that occurrence alone through the store', async () => { + const occurrence = make(); + const deps = deleteActions(); + + await deleteOccurrence('this', { event: occurrence, sendScheduling: true, userEmails: [ORGANIZER] }, deps); + + expect(deps.deleteEvent).toHaveBeenCalledWith(deps.client, occurrence.id, true); + }); + + it('ends the series before it, telling the attendees', async () => { + const occurrence = make(); + const deps = deleteActions(); + + await deleteOccurrence('this_and_future', { event: occurrence, sendScheduling: true, userEmails: [ORGANIZER] }, deps); + + expect(deps.updateEvent).toHaveBeenCalledWith(deps.client, 'b', { + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: null, until: '2026-10-19T09:59:59' }], + }, true); + expect(deps.deleteEvent).not.toHaveBeenCalled(); + }); + + it('deletes the series for every occurrence', async () => { + const deps = deleteActions(); + + await deleteOccurrence('all', { event: make(), sendScheduling: true, userEmails: [ORGANIZER] }, deps); + + expect(deps.deleteEvent).toHaveBeenCalledWith(deps.client, 'b', true); + }); +}); + +// An invitation to one occurrence alone: the server keeps it as an event of +// its own, with a recurrence id and no series to find. +describe('one occurrence the server keeps as its own event', () => { + const lone = () => ({ + ...serverOccurrence('1.0'), id: 'lone', baseEventId: undefined, recurrenceRules: null, recurrenceOverrides: null, + }) as unknown as CalendarEvent; + + it('is itself what changes, and what is deleted', async () => { + const deps = { ...actions(null), deleteEvent: vi.fn().mockResolvedValue(undefined) }; + + await saveOccurrenceEdit('this', { event: lone(), updates: { title: 'Mine' }, userEmails: [ATTENDEE] }, deps); + await deleteOccurrence('this', { event: lone(), userEmails: [ATTENDEE] }, deps); + + expect(deps.updateEvent).toHaveBeenCalledWith(deps.client, 'lone', { title: 'Mine' }, undefined); + expect(deps.deleteEvent).toHaveBeenCalledWith(deps.client, 'lone', undefined); + }); +}); diff --git a/lib/recurrence-scope-edit.ts b/lib/recurrence-scope-edit.ts index 6ee341090..3e423adac 100644 --- a/lib/recurrence-scope-edit.ts +++ b/lib/recurrence-scope-edit.ts @@ -4,7 +4,7 @@ * occurrence, it and the ones after it, or all of them. */ -import { differenceInCalendarDays, format, isSameDay } from 'date-fns'; +import { differenceInCalendarDays, format, isSameDay, parseISO } 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'; @@ -18,6 +18,7 @@ import { type ParticipantSaveContext, } from '@/lib/calendar-participants'; import { + buildFallbackExcludePatch, buildFallbackOverridePatch, isBrowserExpandedOccurrence, isServerRecurrenceInstance, @@ -234,21 +235,45 @@ export interface OccurrenceEdit { resolveName?: (email: string) => string | undefined; } +/** A series itself, not one of its occurrences: it has rules and no recurrence id. */ +function isSeries(event: CalendarEvent): boolean { + return (event.recurrenceRules?.length ?? 0) > 0 && !event.recurrenceId; +} + /** - * End `occurrence`'s series just before it. With `sendScheduling`, when the - * user organizes the series, the attendees are told: Stalwart sends them - * the shortened series, and their copies end there too (they kept every - * occurrence before). Resolves to the series, its rules as they were and - * whether the attendees were told, for a rollback; null when the series is - * not found. + * `event` as an occurrence of `master`, to override or exclude on it: itself + * when it is one, the series' first occurrence when it is the series itself. + * The calendar shows a series it has not expanded - one just created, one + * opened from a link - as the series, once, at its first occurrence; a + * change to "this occurrence" of it went to the series and rewrote every + * occurrence, and deleting it deleted them all. */ -export async function truncateSeriesAt( +function asOccurrenceOf(event: CalendarEvent, master: CalendarEvent): CalendarEvent { + const occurrence = event.recurrenceId + ? event + : { ...event, recurrenceId: master.start, recurrenceIdTimeZone: master.timeZone ?? null }; + return overrideContextOf(occurrence, master); +} + +/** Whether `event` is `master`'s first occurrence, or the series itself standing for it. */ +function isFirstOccurrence(event: CalendarEvent, master: CalendarEvent): boolean { + if (!event.recurrenceId) return true; + return parseISO(event.recurrenceId).getTime() === parseISO(master.start).getTime(); +} + +/** + * End the series `master` just before `occurrence`. With `sendScheduling`, + * when the user organizes the series, the attendees are told: Stalwart + * sends them the shortened series, and their copies end there too (they + * kept every occurrence before). Resolves to the series' rules as they were + * and whether the attendees were told, for a rollback. + */ +async function truncateSeries( + master: CalendarEvent, occurrence: CalendarEvent, - actions: Pick, + actions: Pick, scheduling: { sendScheduling?: boolean; userEmails?: string[] } = {}, -): Promise<{ master: CalendarEvent; originalRules: CalendarEvent['recurrenceRules']; announced: true | undefined } | null> { - const master = await actions.findMasterEvent(occurrence); - if (!master) return null; +): Promise<{ originalRules: CalendarEvent['recurrenceRules']; announced: true | undefined }> { const originalRules = master.recurrenceRules ? JSON.parse(JSON.stringify(master.recurrenceRules)) : null; @@ -263,7 +288,7 @@ export async function truncateSeriesAt( })); const announced = scheduling.sendScheduling && userOrganizes(master, scheduling.userEmails ?? []) ? true : undefined; await actions.updateEvent(actions.client, master.id, { recurrenceRules: truncatedRules }, announced); - return { master, originalRules, announced }; + return { originalRules, announced }; } /** @@ -329,22 +354,30 @@ export async function saveOccurrenceEdit( await updateEvent(client, event.id, updates, sendScheduling, { asOrganizer }); return true; } - // An override outside an expanded series: override it on the master. + // An override outside an expanded series, or the series itself standing + // for its first occurrence: override that occurrence on the series, + // never write it to the series itself. const master = await findMasterEvent(event); - const occurrence = master && overrideContextOf(event, master); - const overridePatch = occurrence - && buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates, { seriesKeys: true })); - if (master && overridePatch) { + if (master) { + const occurrence = asOccurrenceOf(event, master); + const overridePatch = buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates, { seriesKeys: true })); + if (!overridePatch) return false; await updateEvent(client, master.id, overridePatch, sendScheduling); - } else { - await updateEvent(client, event.id, updates, sendScheduling); + return true; } + // One occurrence the server keeps as an event of its own (an + // invitation to that occurrence alone) is itself what changes. + if (isSeries(event)) return false; + await updateEvent(client, event.id, updates, sendScheduling); return true; } case 'this_and_future': { - const result = await truncateSeriesAt(event, actions, { sendScheduling, userEmails: edit.userEmails }); - if (!result) return false; - const { master, originalRules, announced } = result; + const master = await findMasterEvent(event); + if (!master) return false; + // From the first occurrence on is the whole series; ended before its + // first occurrence, the series would be left with none. + if (isFirstOccurrence(event, master)) return saveOccurrenceEdit('all', edit, actions); + const { originalRules, announced } = await truncateSeries(master, event, actions, { sendScheduling, userEmails: edit.userEmails }); const newEventData = followingSeriesData(event, master, updates, originalRules); try { await createEvent(client, newEventData, sendScheduling); @@ -382,3 +415,64 @@ export async function saveOccurrenceEdit( } } } + +/** What deleting part of a series works with: the calendar store's actions and the app's series lookup. */ +export interface OccurrenceDeleteActions extends Pick { + deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise; +} + +/** + * Delete the part of a series `scope` names, from `event`, the occurrence + * the user picked it on. Resolves to false when the series cannot be found. + */ +export async function deleteOccurrence( + scope: RecurrenceEditScope, + deletion: { event: CalendarEvent; sendScheduling?: boolean; userEmails: string[] }, + actions: OccurrenceDeleteActions, +): Promise { + const { client, updateEvent, deleteEvent, findMasterEvent } = actions; + const { event, sendScheduling } = deletion; + switch (scope) { + case 'this': { + if (isServerRecurrenceInstance(event) || isBrowserExpandedOccurrence(event)) { + // The store destroys a server occurrence, or excludes one the + // browser expanded on its base event. + await deleteEvent(client, event.id, sendScheduling); + return true; + } + // An override outside an expanded series, or the series itself standing + // for its first occurrence: exclude that occurrence on the series, + // never delete the series. + const master = await findMasterEvent(event); + if (master) { + const excludePatch = buildFallbackExcludePatch(asOccurrenceOf(event, master)); + if (!excludePatch) return false; + await updateEvent(client, master.id, excludePatch, sendScheduling); + return true; + } + if (isSeries(event)) return false; + await deleteEvent(client, event.id, sendScheduling); + return true; + } + case 'this_and_future': { + const master = await findMasterEvent(event); + if (!master) return false; + if (isFirstOccurrence(event, master)) { + await deleteEvent(client, master.id, sendScheduling); + return true; + } + await truncateSeries(master, event, actions, { sendScheduling, userEmails: deletion.userEmails }); + return true; + } + case 'all': { + const master = await findMasterEvent(event); + if (!master) return false; + await deleteEvent(client, master.id, sendScheduling); + return true; + } + default: { + const exhaustive: never = scope; + throw new Error(`Unhandled scope: ${exhaustive}`); + } + } +} diff --git a/stores/__tests__/calendar-store-synthetic-ids.test.ts b/stores/__tests__/calendar-store-synthetic-ids.test.ts index 3630c0095..ffa4247a1 100644 --- a/stores/__tests__/calendar-store-synthetic-ids.test.ts +++ b/stores/__tests__/calendar-store-synthetic-ids.test.ts @@ -941,3 +941,48 @@ describe('a first change to one occurrence of a meeting the user organizes', () expect(client.updateCalendarEvent).toHaveBeenCalledTimes(2); }); }); + + +// The grid shows a series as the occurrences the range load expands - on the +// server, or in the browser. A series just created was the series itself, +// once, at its first occurrence, until the page was reloaded (live on 1.0 +// and 0.16.23): the later ones did not appear, and "this occurrence" of the +// first went to the series. +describe('a series created or changed', () => { + const series = (overrides: Partial = {}) => occurrence({ + id: 'b', originalId: 'b', baseEventId: 'b', recurrenceId: null, recurrenceOverrides: null, ...overrides, + }); + + it('reloads the visible range after a series is created', async () => { + const client = fakeClient({ createCalendarEvent: vi.fn().mockResolvedValue(series()) }); + await useCalendarStore.getState().fetchEvents(client, RANGE[0], RANGE[1]); + client.queryAllCalendarEvents.mockResolvedValue([occurrence(), occurrence({ id: 'maaaaac', originalId: 'maaaaac', recurrenceId: '2026-09-09T10:00:00' })]); + + await useCalendarStore.getState().createEvent(client, { title: 'Daily standup', recurrenceRules: series().recurrenceRules }); + + expect(client.queryAllCalendarEvents).toHaveBeenCalledTimes(2); + expect(useCalendarStore.getState().events.map((e) => e.id)).toEqual(['maaaaab', 'maaaaac']); + }); + + it('does not reload after a single event is created', async () => { + const client = fakeClient({ createCalendarEvent: vi.fn().mockResolvedValue(series({ recurrenceRules: null })) }); + await useCalendarStore.getState().fetchEvents(client, RANGE[0], RANGE[1]); + + await useCalendarStore.getState().createEvent(client, { title: 'Once' }); + + expect(client.queryAllCalendarEvents).toHaveBeenCalledTimes(1); + }); + + it('reloads after a change to a series the grid shows as itself, or one that makes an event a series', async () => { + const client = fakeClient(); + await useCalendarStore.getState().fetchEvents(client, RANGE[0], RANGE[1]); + useCalendarStore.setState({ events: [series(), series({ id: 'x', originalId: 'x', baseEventId: 'x', recurrenceRules: null })] }); + + await useCalendarStore.getState().updateEvent(client, 'b', { title: 'Renamed' }); + expect(client.queryAllCalendarEvents).toHaveBeenCalledTimes(2); + + useCalendarStore.setState({ events: [series({ id: 'x', originalId: 'x', baseEventId: 'x', recurrenceRules: null })] }); + await useCalendarStore.getState().updateEvent(client, 'x', { recurrenceRules: series().recurrenceRules }); + expect(client.queryAllCalendarEvents).toHaveBeenCalledTimes(3); + }); +}); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 54f4fd556..e90520a4a 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -973,6 +973,12 @@ export const useCalendarStore = create()( } set((state) => ({ events: [...state.events, mappedCreated] })); + // A series shows as its occurrences, which only the range load + // expands (on the server, or in the browser): until then the grid + // had the series itself, once, at its first occurrence. + if ((mappedCreated.recurrenceRules?.length ?? 0) > 0) { + await refetchAfterOccurrenceMutation(); + } // Invitation emails are sent by the server: `sendSchedulingMessages` // on CalendarEvent/set makes Stalwart queue the iTIP REQUEST itself. // Sending a client-side iMIP copy here produced duplicate emails. @@ -1068,7 +1074,10 @@ export const useCalendarStore = create()( return merged; }), })); - if (target.isOccurrence || target.isBrowserOccurrence || hasExpandedOccurrencesOf(get().events, realId, target)) { + // A change to a series - or one that makes an event a series or a + // single event again - changes which occurrences the grid shows. + const changesSeries = 'recurrenceRules' in cleanUpdates || (storeEvent?.recurrenceRules?.length ?? 0) > 0; + if (target.isOccurrence || target.isBrowserOccurrence || changesSeries || hasExpandedOccurrencesOf(get().events, realId, target)) { await refetchAfterOccurrenceMutation(); } // Update emails (iTIP REQUEST/REPLY) are sent by the server via the