fix: show a new series' occurrences and keep single edits off the series

A live Lite run on 1.0 and 0.16.23 found two faults with a series just
created:

- Its later occurrences did not appear until the page was reloaded. The
  store added the series itself to the grid and did not load the range
  again, and only the range load expands a series (on the server, or in
  the browser). The store now reloads the visible range after creating
  a series, and after any change to a series or one that makes an event
  a series or a single event again.
- That one item was the series itself, and "This event only" on it
  wrote the editor's data to the series: `CalendarEvent/set` on the
  series' id with its title, start and rule, so every occurrence
  changed. "Delete this event only" deleted the series. The series
  itself now stands for its first occurrence: a change becomes an
  override of that occurrence, and a delete excludes it. "This and
  following events" from the first occurrence changes, or deletes, the
  whole series instead of ending it before its first occurrence. No
  single-occurrence path falls back to the series' own id any more; only
  an occurrence the server keeps as an event of its own (an invitation
  to that occurrence alone) is changed or deleted as itself, and a
  series that cannot be found is refused with the usual error.

The delete scopes moved from calendar-app.tsx to
`lib/recurrence-scope-edit.ts`, next to the edit scopes, so they are
tested.
This commit is contained in:
Linus Rath
2026-09-26 00:27:20 +02:00
parent 6d4b695489
commit a6e6a5e201
5 changed files with 329 additions and 74 deletions
+10 -50
View File
@@ -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"));
}
+147
View File
@@ -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<CalendarEvent> = {}) => ({
...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);
});
});
+117 -23
View File
@@ -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<OccurrenceEditActions, 'client' | 'updateEvent' | 'findMasterEvent'>,
actions: Pick<OccurrenceEditActions, 'client' | 'updateEvent'>,
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<OccurrenceEditActions, 'client' | 'updateEvent' | 'findMasterEvent'> {
deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
}
/**
* 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<boolean> {
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}`);
}
}
}
@@ -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<CalendarEvent> = {}) => 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);
});
});
+10 -1
View File
@@ -973,6 +973,12 @@ export const useCalendarStore = create<CalendarStore>()(
}
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<CalendarStore>()(
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