diff --git a/docs/acceptance/recurring-events.md b/docs/acceptance/recurring-events.md
index a0847e8974..a60cd4aa52 100644
--- a/docs/acceptance/recurring-events.md
+++ b/docs/acceptance/recurring-events.md
@@ -8,8 +8,7 @@ Use this guide to validate:
- creating weekly recurring events
- creating daily recurring events with an end date
-- editing a recurring event with each of the three scopes: This Event, This and Following Events, All Events
-- deleting a recurring event with each of the three scopes
+- editing and deleting an occurrence, then optionally promoting that exact change to a wider scope
- duplicating a recurring series (Cmd+D)
Do not use this guide to validate:
@@ -26,8 +25,9 @@ Do not use this guide to validate:
Helpful notes:
-- The "Apply Changes To" scope dialog appears any time you edit or delete an instance or base event of a recurring series.
-- If you are changing the recurrence rule itself (for example removing recurrence entirely), the "This Event" option is removed from the scope dialog — only "This and Following Events" and "All Events" are available.
+- Editing or deleting an occurrence applies to **This Event** immediately. A live toast offers **1 This and Following** and **2 All**; both options are clickable.
+- The scope keys work only while that toast is visible and never while typing in a form field.
+- Changing the recurrence rule itself (for example removing recurrence entirely) remains an explicit scope choice. "This Event" is not offered for that structural change.
- Recurring events synced with Google Calendar will push scope changes back to Google automatically.
---
@@ -86,7 +86,7 @@ Setting an end date on a recurring event stops the series on that date. No insta
### UX
-Editing a single instance of a recurring series and selecting "This Event" updates only that instance. All other instances in the series remain unchanged.
+Editing a single instance of a recurring series updates that instance immediately. All other instances remain unchanged unless the toast is promoted.
### Steps
@@ -94,12 +94,10 @@ Editing a single instance of a recurring series and selecting "This Event" updat
2. Right-click one instance (for example, next Monday's) and select Edit.
3. Change the title to something distinct (for example, "Team Sync — Special").
4. Submit the form.
-5. In the "Apply Changes To" dialog, select This Event.
-6. Confirm.
### Expected Results
-- The dialog presents three options: This Event, This and Following Events, All Events.
+- A toast offers This and Following (`1`) and All (`2`) without blocking the calendar.
- Only the selected instance updates to the new title.
- All other instances in the series retain the original title.
- The change persists after a page reload.
@@ -119,8 +117,7 @@ Selecting "This and Following Events" splits the series. The selected instance a
3. Right-click the instance and select Edit.
4. Change the title.
5. Submit the form.
-6. In the scope dialog, select This and Following Events.
-7. Confirm.
+6. While the toast is visible, press `1` or select **This and Following**.
### Expected Results
@@ -142,8 +139,7 @@ Selecting "All Events" updates the base event, which propagates the change to ev
2. Right-click any instance and select Edit.
3. Change the title.
4. Submit the form.
-5. In the scope dialog, select All Events.
-6. Confirm.
+5. While the toast is visible, press `2` or select **All**.
### Expected Results
@@ -185,8 +181,6 @@ Deleting a single instance removes only that occurrence. The rest of the series
1. Create a weekly recurring event.
2. Right-click one instance and select Delete.
-3. In the scope dialog, select This Event.
-4. Confirm.
### Expected Results
@@ -206,8 +200,7 @@ Deleting "This and Following Events" truncates the series. The selected instance
1. Create a weekly recurring event with at least four future instances.
2. Right-click the second instance and select Delete.
-3. In the scope dialog, select This and Following Events.
-4. Confirm.
+3. While the toast is visible, press `1` or select **This and Following**.
### Expected Results
@@ -227,8 +220,7 @@ Deleting "All Events" removes the entire recurring series from the calendar.
1. Create a weekly recurring event.
2. Right-click any instance and select Delete.
-3. In the scope dialog, select All Events.
-4. Confirm.
+3. While the toast is visible, press `2` or select **All**.
### Expected Results
@@ -264,9 +256,9 @@ If time is limited, run these checks before shipping recurring event changes:
1. Creating a weekly recurring event with specific days shows instances on only those days.
2. A daily recurring event with an end date shows no instances after that date.
-3. "This Event" scope updates only the selected instance.
-4. "This and Following Events" scope splits the series at the selected instance.
-5. "All Events" scope propagates the change to every instance.
+3. The immediate change updates only the selected instance.
+4. `1` splits the series at the selected instance.
+5. `2` propagates the change to every instance.
6. Disabling the Repeat toggle removes the "This Event" option from the scope dialog.
7. Deleting "This Event" removes only that instance; the rest of the series persists.
8. Deleting "This and Following Events" truncates the series at the selected instance.
diff --git a/packages/web/src/__tests__/utils/state/reset-stores.ts b/packages/web/src/__tests__/utils/state/reset-stores.ts
index d42859bb92..8acb531a34 100644
--- a/packages/web/src/__tests__/utils/state/reset-stores.ts
+++ b/packages/web/src/__tests__/utils/state/reset-stores.ts
@@ -13,6 +13,7 @@ import { resetCalendarVisibilityStoreForTests } from "@web/calendars/calendar-vi
import { useFeedbackStore } from "@web/components/Feedback/feedback.store";
import { useReleaseNotesPromptStore } from "@web/components/ReleaseNotesPrompt/release-notes-prompt.store";
import { useWelcomeGuideStore } from "@web/components/WelcomeModal/welcome.guide.store";
+import { recurrenceScopeOpportunityActions } from "@web/events/recurrence/recurrence-scope-opportunity.store";
import { resetEventRepositorySourceForTests } from "@web/events/repositories/event.repository.source.store";
import {
initialDraftState,
@@ -38,6 +39,7 @@ const storeResets: StoreReset[] = [
() => useUserMetadataStore.setState(initialUserMetadataState, true),
() => useDraftStore.setState(initialDraftState, true),
() => useUndoHistoryStore.setState(initialUndoHistoryState, true),
+ recurrenceScopeOpportunityActions.clear,
// Order matters for this pair: the availability flag must be cleared
// BEFORE the source store recomputes, or a test that tripped
// markBackendUnavailable() leaves every later file's repository source
diff --git a/packages/web/src/common/utils/toast/recurrence-scope.toast.test.tsx b/packages/web/src/common/utils/toast/recurrence-scope.toast.test.tsx
new file mode 100644
index 0000000000..3ca2c85ce0
--- /dev/null
+++ b/packages/web/src/common/utils/toast/recurrence-scope.toast.test.tsx
@@ -0,0 +1,102 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { type ReactNode } from "react";
+import { type EventId } from "@core/types/domain-primitives";
+import { createTestToastPort } from "@web/__tests__/helpers/web-test-seams";
+import { createMockEvent } from "@web/__tests__/utils/factories/event.factory";
+import { EVENT_DELETED_TOAST_ID } from "@web/common/constants/toast.constants";
+import { registerToastPort } from "@web/common/utils/toast/toast.port";
+import {
+ recurrenceScopeOpportunityActions,
+ useRecurrenceScopeOpportunityStore,
+} from "@web/events/recurrence/recurrence-scope-opportunity.store";
+import {
+ dismissRecurrenceScopeToast,
+ showRecurrenceScopeToast,
+} from "./recurrence-scope.toast";
+import { beforeEach, describe, expect, it } from "bun:test";
+
+describe("showRecurrenceScopeToast", () => {
+ const { port, mocks } = createTestToastPort();
+
+ beforeEach(() => {
+ recurrenceScopeOpportunityActions.clear();
+ mocks.toast.mockClear();
+ mocks.update.mockClear();
+ mocks.dismiss.mockClear();
+ registerToastPort(port);
+ });
+
+ it("offers accessible clickable promotion actions", async () => {
+ const original = createMockEvent({
+ recurrence: {
+ kind: "occurrence",
+ seriesId: "0123456789abcdef11111111" as EventId,
+ },
+ });
+ const id = recurrenceScopeOpportunityActions.begin({
+ kind: "delete",
+ original,
+ source: "local",
+ });
+ const opportunity =
+ useRecurrenceScopeOpportunityStore.getState().opportunity;
+ if (!opportunity) throw new Error("Expected opportunity");
+
+ showRecurrenceScopeToast(opportunity);
+ expect(mocks.update).toHaveBeenCalledWith(
+ EVENT_DELETED_TOAST_ID,
+ expect.objectContaining({
+ closeButton: false,
+ closeOnClick: false,
+ onClose: expect.any(Function),
+ }),
+ );
+ const [content] = mocks.toast.mock.calls.at(0) as unknown as [ReactNode];
+ render(content);
+
+ await userEvent.click(
+ screen.getByRole("button", { name: /this & following/i }),
+ );
+
+ expect(
+ useRecurrenceScopeOpportunityStore.getState().opportunity,
+ ).toMatchObject({
+ id,
+ status: "requested",
+ requestedScope: "thisAndFollowing",
+ });
+ expect(screen.getByRole("button", { name: /all/i })).toBeVisible();
+ });
+
+ it("dismisses only the matching live scope offer", () => {
+ const original = createMockEvent({
+ recurrence: {
+ kind: "occurrence",
+ seriesId: "0123456789abcdef11111111" as EventId,
+ },
+ });
+ const staleId = recurrenceScopeOpportunityActions.begin({
+ kind: "replace",
+ original,
+ input: {
+ content: { kind: "details", title: "Original", description: "" },
+ schedule: original.schedule,
+ recurrence: { kind: "preserve" },
+ scope: "this",
+ },
+ source: "local",
+ });
+ const activeId = recurrenceScopeOpportunityActions.begin({
+ kind: "delete",
+ original,
+ source: "local",
+ });
+
+ dismissRecurrenceScopeToast(staleId);
+ expect(mocks.dismiss).not.toHaveBeenCalled();
+
+ dismissRecurrenceScopeToast(activeId);
+ expect(mocks.dismiss).toHaveBeenCalledWith(EVENT_DELETED_TOAST_ID);
+ });
+});
diff --git a/packages/web/src/common/utils/toast/recurrence-scope.toast.tsx b/packages/web/src/common/utils/toast/recurrence-scope.toast.tsx
new file mode 100644
index 0000000000..83082a9e12
--- /dev/null
+++ b/packages/web/src/common/utils/toast/recurrence-scope.toast.tsx
@@ -0,0 +1,108 @@
+import { type ReactNode } from "react";
+import { type Id } from "react-toastify";
+import {
+ EVENT_DELETED_TOAST_ID,
+ getToastDefaultOptions,
+} from "@web/common/constants/toast.constants";
+import { getToast } from "@web/common/utils/toast/toast.port";
+import {
+ type RecurrenceScopeOpportunity,
+ recurrenceScopeOpportunityActions,
+ useRecurrenceScopeOpportunityStore,
+} from "@web/events/recurrence/recurrence-scope-opportunity.store";
+
+export const RECURRENCE_SCOPE_TOAST_ID = "recurrence-scope-opportunity";
+
+const actionClassName =
+ "rounded px-1.5 py-0.5 text-sm font-medium text-text hover:bg-surface-overlay focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent";
+
+function ScopeToastContent({
+ opportunity,
+}: {
+ opportunity: RecurrenceScopeOpportunity;
+}) {
+ const verb = opportunity.kind === "delete" ? "Deleted" : "Changed";
+
+ return (
+
+ {verb} this event
+
+ recurrenceScopeOpportunityActions.requestPromotion(
+ opportunity.id,
+ "thisAndFollowing",
+ )
+ }
+ >
+ 1 This & following
+
+
+ recurrenceScopeOpportunityActions.requestPromotion(
+ opportunity.id,
+ "all",
+ )
+ }
+ >
+ 2 All
+
+
+ );
+}
+
+const toastIdFor = (opportunity: RecurrenceScopeOpportunity): Id =>
+ opportunity.kind === "delete"
+ ? EVENT_DELETED_TOAST_ID
+ : RECURRENCE_SCOPE_TOAST_ID;
+
+const show = (content: ReactNode, toastId: Id, onClose?: () => void) => {
+ const toast = getToast();
+ toast(content, {
+ ...getToastDefaultOptions(),
+ toastId,
+ closeButton: false,
+ closeOnClick: false,
+ onClose,
+ });
+ toast.update(toastId, {
+ render: content,
+ autoClose: getToastDefaultOptions().autoClose,
+ closeButton: false,
+ closeOnClick: false,
+ onClose,
+ });
+};
+
+export function showRecurrenceScopeToast(
+ opportunity: RecurrenceScopeOpportunity,
+): void {
+ show(
+ ,
+ toastIdFor(opportunity),
+ () => {
+ recurrenceScopeOpportunityActions.dismiss(opportunity.id);
+ },
+ );
+}
+
+export function showRecurrenceScopePromotionToast(
+ opportunity: RecurrenceScopeOpportunity,
+): void {
+ show("Applying change to the series…", toastIdFor(opportunity));
+}
+
+export function dismissRecurrenceScopeToast(opportunityId?: number): void {
+ const opportunity = useRecurrenceScopeOpportunityStore.getState().opportunity;
+ if (
+ !opportunity ||
+ (opportunityId !== undefined && opportunity.id !== opportunityId)
+ ) {
+ return;
+ }
+
+ getToast().dismiss(toastIdFor(opportunity));
+}
diff --git a/packages/web/src/components/CompassProvider/CompassProvider.tsx b/packages/web/src/components/CompassProvider/CompassProvider.tsx
index acbb133141..2b235f1419 100644
--- a/packages/web/src/components/CompassProvider/CompassProvider.tsx
+++ b/packages/web/src/components/CompassProvider/CompassProvider.tsx
@@ -13,6 +13,7 @@ import { DeleteAccountConfirmationProvider } from "@web/components/DeleteAccount
import { FeedbackDialogHost } from "@web/components/Feedback/FeedbackDialogHost";
import { IconProvider } from "@web/components/IconProvider/IconProvider";
import { LogoutConfirmationProvider } from "@web/components/LogoutConfirmation/LogoutConfirmationProvider";
+import { RecurrenceScopeOpportunityHost } from "@web/events/recurrence/RecurrenceScopeOpportunityHost";
import { selectTheme, useThemeStore } from "@web/settings/theme/theme.store";
import { useUndoRedoShortcuts } from "@web/views/Week/hooks/shortcuts/useUndoRedoShortcuts";
@@ -22,7 +23,7 @@ import { useUndoRedoShortcuts } from "@web/views/Week/hooks/shortcuts/useUndoRed
*/
export function GlobalShortcutsHost() {
useUndoRedoShortcuts();
- return null;
+ return ;
}
function ThemeAwareToastContainer() {
diff --git a/packages/web/src/components/Sidebar/EventDetails/SidebarEventDetails.test.tsx b/packages/web/src/components/Sidebar/EventDetails/SidebarEventDetails.test.tsx
index c2ddc595ea..4400e651d5 100644
--- a/packages/web/src/components/Sidebar/EventDetails/SidebarEventDetails.test.tsx
+++ b/packages/web/src/components/Sidebar/EventDetails/SidebarEventDetails.test.tsx
@@ -149,7 +149,7 @@ describe("SidebarEventDetails", () => {
);
});
- it("asks for a recurrence scope before deleting a recurring day event", async () => {
+ it("deletes a recurring day event immediately", async () => {
const recurringEvent = createMockEvent({
id: EventIdSchema.parse(RECURRING_EVENT_ID),
content: {
@@ -186,14 +186,21 @@ describe("SidebarEventDetails", () => {
act(() => titleField.blur());
fireEvent.keyDown(screen.getByRole("form"), { key: "Delete" });
+ await waitFor(() =>
+ expect(
+ queryClient
+ .getMutationCache()
+ .getAll()
+ .some(
+ (mutation) =>
+ mutation.options.mutationKey?.[2] === "delete" &&
+ (mutation.state.variables as { id?: string }).id ===
+ RECURRING_EVENT_ID,
+ ),
+ ).toBe(true),
+ );
expect(
- await screen.findByRole("radiogroup", { name: "Delete events" }),
- ).toBeInTheDocument();
- expect(
- queryClient
- .getMutationCache()
- .getAll()
- .some((mutation) => mutation.options.mutationKey?.[2] === "delete"),
- ).toBe(false);
+ screen.queryByRole("radiogroup", { name: "Delete events" }),
+ ).toBeNull();
});
});
diff --git a/packages/web/src/events/mutations/useEventMutations.test.tsx b/packages/web/src/events/mutations/useEventMutations.test.tsx
index 364e4162b0..01bc5c94b7 100644
--- a/packages/web/src/events/mutations/useEventMutations.test.tsx
+++ b/packages/web/src/events/mutations/useEventMutations.test.tsx
@@ -7,9 +7,16 @@ import {
type CreateEventInput,
type ReplaceEventInput,
} from "@core/types/event-command.contracts";
+import { createTestToastPort } from "@web/__tests__/helpers/web-test-seams";
import { createMockEvent } from "@web/__tests__/utils/factories/event.factory";
+import { RECURRENCE_SCOPE_TOAST_ID } from "@web/common/utils/toast/recurrence-scope.toast";
+import { registerToastPort } from "@web/common/utils/toast/toast.port";
import { eventQueryKeys } from "@web/events/queries/event.query.keys";
import { type NormalizedEventQueryData } from "@web/events/queries/event.query.types";
+import {
+ recurrenceScopeOpportunityActions,
+ useRecurrenceScopeOpportunityStore,
+} from "@web/events/recurrence/recurrence-scope-opportunity.store";
import { type EventRepository } from "@web/events/repositories/event.repository.types";
import {
runHistoryRestore,
@@ -150,6 +157,124 @@ const replacePayload = (
});
describe("useEventMutations", () => {
+ afterEach(() => {
+ recurrenceScopeOpportunityActions.clear();
+ });
+
+ test("promotes a deleted occurrence even after the narrow optimistic delete removes it from cache", async () => {
+ const context = setup();
+ const seriesId = event().id;
+ const first = occurrence(seriesId);
+ const second = occurrence(seriesId, {
+ schedule: timedSchedule(
+ "2026-07-03T16:00:00.000Z",
+ "2026-07-03T17:00:00.000Z",
+ ),
+ });
+ context.queryClient.setQueryData(calendarKey, normalized(first, second));
+
+ act(() =>
+ context.hook.result.current.mutations.delete({
+ id: first.id,
+ scope: "this",
+ }),
+ );
+ const opportunity =
+ useRecurrenceScopeOpportunityStore.getState().opportunity;
+ expect(opportunity).toMatchObject({ kind: "delete", original: first });
+ if (!opportunity) throw new Error("Expected a recurrence opportunity");
+
+ act(() =>
+ context.hook.result.current.mutations.promoteRecurring(
+ opportunity,
+ "all",
+ ),
+ );
+
+ await waitFor(() => {
+ expect(
+ context.queryClient.getQueryData(calendarKey)
+ ?.ids,
+ ).toEqual([]);
+ });
+ await waitFor(() => {
+ expect(context.calls).toContainEqual({
+ method: "delete",
+ value: { id: first.id, scope: "this" },
+ });
+ });
+
+ act(() => context.pending.resolveNext());
+ await waitFor(() => {
+ expect(context.calls).toContainEqual({
+ method: "delete",
+ value: { id: first.id, scope: "all" },
+ });
+ });
+ context.pending.resolve();
+ });
+
+ test("does not coalesce a promoted series edit behind a later narrow edit", async () => {
+ const context = setup();
+ const seriesId = event().id;
+ const first = occurrence(seriesId);
+ const second = occurrence(seriesId, {
+ schedule: timedSchedule(
+ "2026-07-03T16:00:00.000Z",
+ "2026-07-03T17:00:00.000Z",
+ ),
+ });
+ context.queryClient.setQueryData(calendarKey, normalized(first, second));
+
+ act(() =>
+ context.hook.result.current.mutations.replace(
+ replacePayload(first.id, {
+ content: {
+ kind: "details",
+ title: "First narrow edit",
+ description: "",
+ },
+ }),
+ ),
+ );
+ const opportunity =
+ useRecurrenceScopeOpportunityStore.getState().opportunity;
+ if (!opportunity || opportunity.kind !== "replace") {
+ throw new Error("Expected a recurrence edit opportunity");
+ }
+
+ act(() =>
+ context.hook.result.current.mutations.promoteRecurring(
+ opportunity,
+ "all",
+ ),
+ );
+ act(() =>
+ context.hook.result.current.mutations.replace(
+ replacePayload(first.id, {
+ content: {
+ kind: "details",
+ title: "Later narrow edit",
+ description: "",
+ },
+ }),
+ ),
+ );
+
+ await waitFor(() => expect(context.calls).toHaveLength(1));
+ act(() => context.pending.resolveNext());
+ await waitFor(() => {
+ expect(context.calls).toContainEqual({
+ method: "replace",
+ value: expect.objectContaining({
+ id: first.id,
+ input: expect.objectContaining({ scope: "all" }),
+ }),
+ });
+ });
+ context.pending.resolve();
+ });
+
test("optimistically patches recurring instances across day and week caches", async () => {
const context = setup();
const seriesId = event().id;
@@ -622,6 +747,34 @@ describe("useEventMutations", () => {
});
});
+ test("withdraws the scope offer when a default occurrence change fails", async () => {
+ const { port, mocks } = createTestToastPort();
+ registerToastPort(port);
+ const context = setup();
+ const item = occurrence(event().id);
+ context.queryClient.setQueryData(calendarKey, normalized(item));
+
+ act(() =>
+ context.hook.result.current.mutations.replace(
+ replacePayload(item.id, {
+ content: { kind: "details", title: "Changed", description: "" },
+ }),
+ ),
+ );
+ await waitFor(() =>
+ expect(
+ context.calls.filter(({ method }) => method === "replace"),
+ ).toHaveLength(1),
+ );
+
+ context.pending.reject(new Error("write failed"));
+
+ await waitFor(() => {
+ expect(context.errors[0]?.message).toBe("write failed");
+ expect(mocks.dismiss).toHaveBeenCalledWith(RECURRENCE_SCOPE_TOAST_ID);
+ });
+ });
+
test("keeps a newer edit's optimistic value when an older edit for the same event fails", async () => {
const context = setup();
const original = event();
diff --git a/packages/web/src/events/mutations/useEventMutations.ts b/packages/web/src/events/mutations/useEventMutations.ts
index f71482c52b..95d8be5d0c 100644
--- a/packages/web/src/events/mutations/useEventMutations.ts
+++ b/packages/web/src/events/mutations/useEventMutations.ts
@@ -19,6 +19,7 @@ import {
} from "@web/calendars/useCalendarLookup";
import { handleError } from "@web/common/utils/event/event.util";
import { createObjectIdString } from "@web/common/utils/id/object-id.util";
+import { dismissRecurrenceScopeToast } from "@web/common/utils/toast/recurrence-scope.toast";
import {
applyEventProjectionAcrossQueries,
eventBelongsToEntry,
@@ -43,11 +44,18 @@ import {
projectSeriesMaterialization,
projectSeriesRulesChange,
} from "@web/events/recurrence/projectRecurringEdit";
+import {
+ type RecurrenceScopeOpportunity,
+ recurrenceScopeOpportunityActions,
+} from "@web/events/recurrence/recurrence-scope-opportunity.store";
import { type EventRepositorySource } from "@web/events/repositories/event.repository.factory";
import { useEventRepositorySource } from "@web/events/repositories/event.repository.source.store";
import { type EventRepository } from "@web/events/repositories/event.repository.types";
import { getEventRepositoryBySource } from "@web/events/repositories/event.repository.util";
-import { isRestoringHistory } from "@web/events/stores/undo.store";
+import {
+ isRestoringHistory,
+ undoHistoryActions,
+} from "@web/events/stores/undo.store";
import {
type EventMutationOperation,
eventMutationKeys,
@@ -115,7 +123,7 @@ const nowDateTime = () => DateTimeSchema.parse(new Date().toISOString());
// An occurrence's series is its own recurrence pointer; the series base's
// series is itself. Shared by the replace and delete optimistic callbacks,
// which both need the series id to gather every cached instance.
-function seriesIdOf(event: Event | null): EventId | null {
+function seriesIdOf(event: Event | null | undefined): EventId | null {
if (event?.recurrence.kind === "occurrence") return event.recurrence.seriesId;
if (event?.recurrence.kind === "series") return event.id;
return null;
@@ -175,18 +183,26 @@ type ReplaceVariables = {
id: EventId;
input: ReplaceEventInput;
writeKey: EventId;
+ originalOverride?: Event;
+ opportunityId?: number;
};
type DeleteVariables = {
id: EventId;
scope: RecurrenceScope;
writeKey: EventId;
skipRepository: boolean;
+ originalOverride?: Event;
+ opportunityId?: number;
};
export type EventMutations = {
create: (input: CreateEventInput) => void;
replace: (payload: { id: EventId; input: ReplaceEventInput }) => void;
delete: (payload: { id: EventId; scope: RecurrenceScope }) => void;
+ promoteRecurring: (
+ opportunity: RecurrenceScopeOpportunity,
+ scope: "thisAndFollowing" | "all",
+ ) => void;
};
export type EventMutationDependencies = {
@@ -301,6 +317,12 @@ export function useEventMutations(
context: EventMutationContext | undefined,
) => {
reportError(error);
+ const opportunityId = (variables as { opportunityId?: number })
+ .opportunityId;
+ if (opportunityId) {
+ dismissRecurrenceScopeToast(opportunityId);
+ recurrenceScopeOpportunityActions.complete(opportunityId);
+ }
if (!context) return;
// TanStack still counts *this* mutation as pending while onError runs
// (it dispatches status "error" only afterwards), so isMutating === 1
@@ -409,14 +431,19 @@ export function useEventMutations(
variables,
precedingCreateOk,
() => repository.replace(variables.id, variables.input),
- { coalesce: true },
+ // A promotion replays the saved occurrence mutation at a broader
+ // scope. It must reach the repository even if a later narrow edit
+ // shares its occurrence write key.
+ { coalesce: !variables.originalOverride },
);
},
- ({ id, input }) => {
- const existing = findEventInCache(queryClient, id, source);
+ ({ id, input, originalOverride }) => {
+ const existing =
+ findEventInCache(queryClient, id, source) ?? originalOverride;
if (!existing) return;
const edited = mergeReplaceInput(existing, input);
- const seriesId = seriesIdOf(existing);
+ const original = originalOverride ?? existing;
+ const seriesId = seriesIdOf(original);
// (Re)defining recurrence rules changes which instances exist, so
// shifting cached instances isn't enough: expand the new rules into
@@ -444,7 +471,7 @@ export function useEventMutations(
projectSeriesRulesChange({
scope: input.scope,
edited,
- original: existing,
+ original,
seriesId,
seriesEvents: seriesId
? findSeriesEventsInCache(queryClient, seriesId, source)
@@ -463,7 +490,7 @@ export function useEventMutations(
projectRecurringEdit({
scope: input.scope,
edited,
- original: existing,
+ original,
seriesEvents: findSeriesEventsInCache(
queryClient,
seriesId,
@@ -499,16 +526,17 @@ export function useEventMutations(
() => repository.delete(variables.id, variables.scope),
);
},
- ({ id, scope }) => {
- const existing = findEventInCache(queryClient, id, source);
- const seriesId = seriesIdOf(existing);
+ ({ id, scope, originalOverride }) => {
+ const existing =
+ findEventInCache(queryClient, id, source) ?? originalOverride;
+ const seriesId = seriesIdOf(originalOverride ?? existing);
if (existing && seriesId && scope !== "this") {
applyEventProjectionAcrossQueries(
queryClient,
projectRecurringDelete({
scope,
- target: existing,
+ target: originalOverride ?? existing,
seriesId,
seriesEvents: findSeriesEventsInCache(
queryClient,
@@ -552,6 +580,19 @@ export function useEventMutations(
payload.input.scope,
payload.id,
);
+ const opportunityId =
+ original &&
+ original.recurrence.kind === "occurrence" &&
+ payload.input.scope === "this" &&
+ payload.input.recurrence.kind === "preserve" &&
+ !isRestoringHistory()
+ ? recurrenceScopeOpportunityActions.begin({
+ kind: "replace",
+ original,
+ input: payload.input,
+ source,
+ })
+ : undefined;
if (original) {
recordEventEditHistory({
id: payload.id,
@@ -561,7 +602,7 @@ export function useEventMutations(
source,
});
}
- replaceMutation.mutate({ ...payload, writeKey });
+ replaceMutation.mutate({ ...payload, writeKey, opportunityId });
},
delete: (payload: { id: EventId; scope: RecurrenceScope }) => {
if (
@@ -573,6 +614,18 @@ export function useEventMutations(
);
return;
}
+ const original = findEventInCache(queryClient, payload.id, source);
+ const opportunityId =
+ original &&
+ original.recurrence.kind === "occurrence" &&
+ payload.scope === "this" &&
+ !isRestoringHistory()
+ ? recurrenceScopeOpportunityActions.begin({
+ kind: "delete",
+ original,
+ source,
+ })
+ : undefined;
const existing = recordEventDeleteHistory({
id: payload.id,
scope: payload.scope,
@@ -583,8 +636,54 @@ export function useEventMutations(
...payload,
writeKey: payload.id,
skipRepository: !existing,
+ opportunityId,
});
},
+ promoteRecurring: (
+ opportunity: RecurrenceScopeOpportunity,
+ scope: "thisAndFollowing" | "all",
+ ) => {
+ if (opportunity.source !== source) {
+ dismissRecurrenceScopeToast(opportunity.id);
+ recurrenceScopeOpportunityActions.complete(opportunity.id);
+ return;
+ }
+ // Broader recurring operations rewrite/split this series, so its
+ // narrow client snapshots are no longer safe to replay. Keep history
+ // for unrelated events the user changed while this toast was live.
+ const seriesId = seriesIdOf(opportunity.original);
+ if (seriesId) undoHistoryActions.discardSeries(seriesId);
+
+ const onSettled = () => {
+ recurrenceScopeOpportunityActions.complete(opportunity.id);
+ };
+ if (opportunity.kind === "replace") {
+ replaceMutation.mutate(
+ {
+ id: opportunity.original.id as EventId,
+ input: { ...opportunity.input, scope },
+ // Serialize behind the narrow write for this occurrence. The
+ // optimistic projection still uses originalOverride so a
+ // deleted/overridden cache entry cannot lose the promotion.
+ writeKey: opportunity.original.id as EventId,
+ originalOverride: opportunity.original,
+ },
+ { onSettled },
+ );
+ return;
+ }
+
+ deleteMutation.mutate(
+ {
+ id: opportunity.original.id as EventId,
+ scope,
+ writeKey: opportunity.original.id as EventId,
+ skipRepository: false,
+ originalOverride: opportunity.original,
+ },
+ { onSettled },
+ );
+ },
}),
[
queryClient,
diff --git a/packages/web/src/events/mutations/useUndoRedo.test.tsx b/packages/web/src/events/mutations/useUndoRedo.test.tsx
index 1f44c087ba..d1cb5c9917 100644
--- a/packages/web/src/events/mutations/useUndoRedo.test.tsx
+++ b/packages/web/src/events/mutations/useUndoRedo.test.tsx
@@ -7,9 +7,13 @@ import {
type CreateEventInput,
type ReplaceEventInput,
} from "@core/types/event-command.contracts";
+import { createTestToastPort } from "@web/__tests__/helpers/web-test-seams";
import { createMockEvent } from "@web/__tests__/utils/factories/event.factory";
+import { EVENT_DELETED_TOAST_ID } from "@web/common/constants/toast.constants";
+import { registerToastPort } from "@web/common/utils/toast/toast.port";
import { eventQueryKeys } from "@web/events/queries/event.query.keys";
import { type NormalizedEventQueryData } from "@web/events/queries/event.query.types";
+import { useRecurrenceScopeOpportunityStore } from "@web/events/recurrence/recurrence-scope-opportunity.store";
import { type EventRepository } from "@web/events/repositories/event.repository.types";
import { useUndoHistoryStore } from "@web/events/stores/undo.store";
import { useEventMutations } from "./useEventMutations";
@@ -250,6 +254,8 @@ describe("useUndoRedo", () => {
event({ recurrence: { kind: "occurrence", seriesId }, ...overrides });
test("undoes a recurring occurrence delete by replaying it back (un-cancel), not recreating", async () => {
+ const { port, mocks } = createTestToastPort();
+ registerToastPort(port);
const context = setup();
const seriesId = event().id;
const instance = occurrence(seriesId);
@@ -270,6 +276,8 @@ describe("useUndoRedo", () => {
act(() => context.hook.result.current.undoRedo.undo());
+ expect(mocks.dismiss).toHaveBeenCalledWith(EVENT_DELETED_TOAST_ID);
+
await waitFor(() => {
expect(
context.queryClient.getQueryData(calendarKey)
@@ -343,6 +351,60 @@ describe("useUndoRedo", () => {
).toBe(true);
});
+ test("keeps an unrelated live recurrence offer when undoing a later change", async () => {
+ const context = setup();
+ const seriesId = event().id;
+ const instance = occurrence(seriesId);
+ const other = event({
+ content: { kind: "details", title: "Other", description: "" },
+ });
+ context.queryClient.setQueryData(calendarKey, normalized(instance, other));
+
+ act(() =>
+ context.hook.result.current.mutations.replace({
+ id: instance.id,
+ input: {
+ content: { kind: "details", title: "Changed", description: "" },
+ schedule: instance.schedule as never,
+ recurrence: { kind: "preserve" },
+ scope: "this",
+ },
+ }),
+ );
+ await waitFor(() =>
+ expect(
+ useRecurrenceScopeOpportunityStore.getState().opportunity,
+ ).toMatchObject({
+ original: { id: instance.id },
+ status: "ready",
+ }),
+ );
+
+ act(() =>
+ context.hook.result.current.mutations.replace({
+ id: other.id,
+ input: {
+ content: { kind: "details", title: "Later", description: "" },
+ schedule: other.schedule as never,
+ recurrence: { kind: "preserve" },
+ scope: "this",
+ },
+ }),
+ );
+ await waitFor(() => {
+ expect(context.hook.result.current.undoRedo.canUndo).toBe(true);
+ });
+
+ act(() => context.hook.result.current.undoRedo.undo());
+
+ expect(
+ useRecurrenceScopeOpportunityStore.getState().opportunity,
+ ).toMatchObject({
+ original: { id: instance.id },
+ status: "ready",
+ });
+ });
+
test("declines an edit undo when the event changed since it was recorded (stale)", async () => {
const context = setup();
const original = event();
diff --git a/packages/web/src/events/mutations/useUndoRedo.ts b/packages/web/src/events/mutations/useUndoRedo.ts
index 1f348b35bf..42624b8141 100644
--- a/packages/web/src/events/mutations/useUndoRedo.ts
+++ b/packages/web/src/events/mutations/useUndoRedo.ts
@@ -5,6 +5,7 @@ import { type Event } from "@core/types/event.contracts";
import { UNDO_DECLINED_TOAST_ID } from "@web/common/constants/toast.constants";
import { DATA_EVENT_ELEMENT_ID } from "@web/common/constants/web.constants";
import { showRestoredToast } from "@web/common/utils/toast/deleted-toast.util";
+import { dismissRecurrenceScopeToast } from "@web/common/utils/toast/recurrence-scope.toast";
import { showStatusToast } from "@web/common/utils/toast/status-toast.util";
import {
type EventMutationDependencies,
@@ -15,6 +16,10 @@ import {
findEventInCache,
upsertEventAcrossQueries,
} from "@web/events/queries/event.query.cache";
+import {
+ recurrenceScopeOpportunityActions,
+ useRecurrenceScopeOpportunityStore,
+} from "@web/events/recurrence/recurrence-scope-opportunity.store";
import { useEventRepositorySource } from "@web/events/repositories/event.repository.source.store";
import {
runHistoryRestore,
@@ -182,6 +187,18 @@ export function useUndoRedo(dependencies: EventMutationDependencies = {}) {
const entry = undoHistoryActions.peekUndo();
if (!entry) return;
+ // Remove a promotion affordance only when undoing its own narrow action.
+ // Undoing a later, unrelated event must leave the earlier opportunity live.
+ const opportunity =
+ useRecurrenceScopeOpportunityStore.getState().opportunity;
+ if (
+ opportunity?.source === source &&
+ opportunity.original.id === entryEventId(entry)
+ ) {
+ dismissRecurrenceScopeToast(opportunity.id);
+ recurrenceScopeOpportunityActions.clear();
+ }
+
if (entry.kind === "edit") {
const current = findEventInCache(queryClient, entry.id, source);
// No cached copy to compare against (outside the loaded range) — fail
diff --git a/packages/web/src/events/recurrence/RecurrenceScopeOpportunityHost.test.tsx b/packages/web/src/events/recurrence/RecurrenceScopeOpportunityHost.test.tsx
new file mode 100644
index 0000000000..b6c3c75161
--- /dev/null
+++ b/packages/web/src/events/recurrence/RecurrenceScopeOpportunityHost.test.tsx
@@ -0,0 +1,50 @@
+import { HotkeyManager } from "@tanstack/react-hotkeys";
+import { type EventId } from "@core/types/domain-primitives";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ waitFor,
+} from "@web/__tests__/__mocks__/mock.render";
+import { createMockEvent } from "@web/__tests__/utils/factories/event.factory";
+import { recurrenceScopeOpportunityActions } from "@web/events/recurrence/recurrence-scope-opportunity.store";
+import { RecurrenceScopeOpportunityHost } from "./RecurrenceScopeOpportunityHost";
+import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
+
+describe("RecurrenceScopeOpportunityHost", () => {
+ beforeEach(() => {
+ HotkeyManager.resetInstance();
+ document.body.removeAttribute("data-app-locked");
+ recurrenceScopeOpportunityActions.clear();
+ });
+
+ afterEach(() => {
+ cleanup();
+ recurrenceScopeOpportunityActions.clear();
+ });
+
+ it("promotes the live opportunity with 2", async () => {
+ const id = recurrenceScopeOpportunityActions.begin({
+ kind: "delete",
+ original: createMockEvent({
+ recurrence: {
+ kind: "occurrence",
+ seriesId: "0123456789abcdef11111111" as EventId,
+ },
+ }),
+ source: "local",
+ });
+ const requestPromotion = spyOn(
+ recurrenceScopeOpportunityActions,
+ "requestPromotion",
+ );
+
+ render( );
+ fireEvent.keyDown(document, { key: "2" });
+
+ await waitFor(() => {
+ expect(requestPromotion).toHaveBeenCalledWith(id, "all");
+ });
+ requestPromotion.mockRestore();
+ });
+});
diff --git a/packages/web/src/events/recurrence/RecurrenceScopeOpportunityHost.tsx b/packages/web/src/events/recurrence/RecurrenceScopeOpportunityHost.tsx
new file mode 100644
index 0000000000..565dbe351e
--- /dev/null
+++ b/packages/web/src/events/recurrence/RecurrenceScopeOpportunityHost.tsx
@@ -0,0 +1,72 @@
+import { useEffect } from "react";
+import {
+ showRecurrenceScopePromotionToast,
+ showRecurrenceScopeToast,
+} from "@web/common/utils/toast/recurrence-scope.toast";
+import { useEventMutations } from "@web/events/mutations/useEventMutations";
+import {
+ recurrenceScopeOpportunityActions,
+ selectRecurrenceScopeOpportunity,
+ useRecurrenceScopeOpportunityStore,
+} from "@web/events/recurrence/recurrence-scope-opportunity.store";
+import { useAppShortcut } from "@web/shortcuts/useAppShortcut";
+
+const canHandleShortcut = (event: KeyboardEvent) =>
+ !event.isComposing &&
+ !event.metaKey &&
+ !event.ctrlKey &&
+ !event.altKey &&
+ !event.shiftKey;
+
+export function RecurrenceScopeOpportunityHost() {
+ const opportunity = useRecurrenceScopeOpportunityStore(
+ selectRecurrenceScopeOpportunity,
+ );
+ const { promoteRecurring } = useEventMutations();
+ const isReady = opportunity?.status === "ready";
+
+ useAppShortcut(
+ "1",
+ (event) => {
+ if (!opportunity || !canHandleShortcut(event)) return;
+ recurrenceScopeOpportunityActions.requestPromotion(
+ opportunity.id,
+ "thisAndFollowing",
+ );
+ },
+ {
+ enabled: isReady,
+ ignoreInputs: true,
+ preventDefault: true,
+ stopPropagation: true,
+ },
+ );
+ useAppShortcut(
+ "2",
+ (event) => {
+ if (!opportunity || !canHandleShortcut(event)) return;
+ recurrenceScopeOpportunityActions.requestPromotion(opportunity.id, "all");
+ },
+ {
+ enabled: isReady,
+ ignoreInputs: true,
+ preventDefault: true,
+ stopPropagation: true,
+ },
+ );
+
+ useEffect(() => {
+ if (opportunity?.status !== "ready") return;
+ showRecurrenceScopeToast(opportunity);
+ }, [opportunity]);
+
+ useEffect(() => {
+ if (opportunity?.status !== "requested") return;
+ const claimed = recurrenceScopeOpportunityActions.claimPromotion();
+ if (!claimed?.requestedScope) return;
+ showRecurrenceScopePromotionToast(claimed);
+ promoteRecurring(claimed, claimed.requestedScope);
+ }, [opportunity, promoteRecurring]);
+
+ return null;
+}
diff --git a/packages/web/src/events/recurrence/recurrence-scope-decision.test.ts b/packages/web/src/events/recurrence/recurrence-scope-decision.test.ts
index ad2083d89e..74779e99ee 100644
--- a/packages/web/src/events/recurrence/recurrence-scope-decision.test.ts
+++ b/packages/web/src/events/recurrence/recurrence-scope-decision.test.ts
@@ -121,13 +121,16 @@ describe("getScopeDecisionRecurrenceRule", () => {
describe("resolveRecurrenceScopeDecision", () => {
describe("delete", () => {
- it("prompts before deleting recurring events", () => {
+ it("deletes recurring events as this-event changes", () => {
expect(
resolveRecurrenceScopeDecision({
action: "delete",
isRecurring: true,
}),
- ).toEqual({ kind: "prompt" });
+ ).toEqual({
+ kind: "apply",
+ scope: RecurringEventUpdateScope.THIS_EVENT,
+ });
});
it("deletes non-recurring events immediately", () => {
@@ -163,7 +166,7 @@ describe("resolveRecurrenceScopeDecision", () => {
});
});
- it("prompts for existing multi-occurrence recurring instances", () => {
+ it("applies THIS_EVENT for existing recurring instances", () => {
const baseEventId = new ObjectId().toString();
const baseEvent = createMockEvent({
id: EventIdSchema.parse(baseEventId),
@@ -185,7 +188,10 @@ describe("resolveRecurrenceScopeDecision", () => {
isInstance: true,
isRecurring: true,
}),
- ).toEqual({ kind: "prompt" });
+ ).toEqual({
+ kind: "apply",
+ scope: RecurringEventUpdateScope.THIS_EVENT,
+ });
});
it("applies THIS_EVENT when a standalone draft is made recurring", () => {
@@ -207,7 +213,7 @@ describe("resolveRecurrenceScopeDecision", () => {
});
});
- it("applies ALL_EVENTS for a single-occurrence recurring instance", () => {
+ it("applies THIS_EVENT for a single-occurrence recurring instance", () => {
const baseEventId = new ObjectId().toString();
const baseEvent = createMockEvent({
id: EventIdSchema.parse(baseEventId),
@@ -231,7 +237,7 @@ describe("resolveRecurrenceScopeDecision", () => {
}),
).toEqual({
kind: "apply",
- scope: RecurringEventUpdateScope.ALL_EVENTS,
+ scope: RecurringEventUpdateScope.THIS_EVENT,
});
});
diff --git a/packages/web/src/events/recurrence/recurrence-scope-decision.ts b/packages/web/src/events/recurrence/recurrence-scope-decision.ts
index 3fd6a9c03c..fea44d85a3 100644
--- a/packages/web/src/events/recurrence/recurrence-scope-decision.ts
+++ b/packages/web/src/events/recurrence/recurrence-scope-decision.ts
@@ -87,10 +87,6 @@ export const resolveRecurrenceScopeDecision = (
input: ResolveRecurrenceScopeDecisionInput,
): RecurrenceScopeDecision => {
if (input.action === "delete") {
- if (input.isRecurring) {
- return { kind: "prompt" };
- }
-
return { kind: "apply", scope: RecurringEventUpdateScope.THIS_EVENT };
}
@@ -106,14 +102,20 @@ export const resolveRecurrenceScopeDecision = (
return { kind: "apply", scope: RecurringEventUpdateScope.THIS_EVENT };
}
- if (confirmAllRecurringEdits) {
- if (isRecurring) {
- return { kind: "prompt" };
- }
-
+ // Ordinary occurrence changes stay in flow: apply to this instance now and
+ // let the live toast promote the exact mutation to following/all. An
+ // explicit recurrence edit remains structural, so it keeps the existing
+ // scope chooser below — "this" is not a valid rule-change operation.
+ if (
+ isRecurring &&
+ isInstance &&
+ draft.values.recurrence.kind === "preserve"
+ ) {
return { kind: "apply", scope: RecurringEventUpdateScope.THIS_EVENT };
}
+ if (confirmAllRecurringEdits && isRecurring) return { kind: "prompt" };
+
const rule = getScopeDecisionRecurrenceRule(draft, baseEvent);
const toStandAlone = isInstance && rule === null;
const hasMultipleOccurrences = hasMultipleRecurrenceOccurrences(
diff --git a/packages/web/src/events/recurrence/recurrence-scope-opportunity.store.test.ts b/packages/web/src/events/recurrence/recurrence-scope-opportunity.store.test.ts
new file mode 100644
index 0000000000..620d50c740
--- /dev/null
+++ b/packages/web/src/events/recurrence/recurrence-scope-opportunity.store.test.ts
@@ -0,0 +1,65 @@
+import { type EventId } from "@core/types/domain-primitives";
+import { createMockEvent } from "@web/__tests__/utils/factories/event.factory";
+import {
+ recurrenceScopeOpportunityActions,
+ useRecurrenceScopeOpportunityStore,
+} from "./recurrence-scope-opportunity.store";
+import { describe, expect, it } from "bun:test";
+
+const original = createMockEvent({
+ recurrence: {
+ kind: "occurrence",
+ seriesId: "0123456789abcdef11111111" as EventId,
+ },
+});
+
+describe("recurrenceScopeOpportunityActions", () => {
+ it("only promotes the currently-live opportunity once", () => {
+ recurrenceScopeOpportunityActions.clear();
+ const id = recurrenceScopeOpportunityActions.begin({
+ kind: "delete",
+ original,
+ source: "local",
+ });
+
+ recurrenceScopeOpportunityActions.requestPromotion(id, "all");
+ const claimed = recurrenceScopeOpportunityActions.claimPromotion();
+
+ expect(claimed).toMatchObject({
+ id,
+ kind: "delete",
+ requestedScope: "all",
+ status: "requested",
+ });
+ expect(recurrenceScopeOpportunityActions.claimPromotion()).toBeNull();
+ expect(
+ useRecurrenceScopeOpportunityStore.getState().opportunity,
+ ).toMatchObject({
+ id,
+ status: "submitting",
+ });
+ });
+
+ it("new opportunities supersede an older toast without letting it promote", () => {
+ recurrenceScopeOpportunityActions.clear();
+ const older = recurrenceScopeOpportunityActions.begin({
+ kind: "delete",
+ original,
+ source: "local",
+ });
+ const newer = recurrenceScopeOpportunityActions.begin({
+ kind: "delete",
+ original,
+ source: "local",
+ });
+
+ recurrenceScopeOpportunityActions.requestPromotion(older, "all");
+
+ expect(
+ useRecurrenceScopeOpportunityStore.getState().opportunity,
+ ).toMatchObject({
+ id: newer,
+ status: "ready",
+ });
+ });
+});
diff --git a/packages/web/src/events/recurrence/recurrence-scope-opportunity.store.ts b/packages/web/src/events/recurrence/recurrence-scope-opportunity.store.ts
new file mode 100644
index 0000000000..16a1a51286
--- /dev/null
+++ b/packages/web/src/events/recurrence/recurrence-scope-opportunity.store.ts
@@ -0,0 +1,92 @@
+import { create } from "zustand";
+import { type Event } from "@core/types/event.contracts";
+import { type ReplaceEventInput } from "@core/types/event-command.contracts";
+import { type EventRepositorySource } from "@web/events/repositories/event.repository.factory";
+
+export type RecurrenceScopeOpportunity =
+ | {
+ id: number;
+ kind: "replace";
+ original: Event;
+ input: ReplaceEventInput;
+ source: EventRepositorySource;
+ status: "ready" | "requested" | "submitting";
+ requestedScope?: "thisAndFollowing" | "all";
+ }
+ | {
+ id: number;
+ kind: "delete";
+ original: Event;
+ source: EventRepositorySource;
+ status: "ready" | "requested" | "submitting";
+ requestedScope?: "thisAndFollowing" | "all";
+ };
+
+type NewRecurrenceScopeOpportunity =
+ | Omit<
+ Extract,
+ "id" | "status"
+ >
+ | Omit<
+ Extract,
+ "id" | "status"
+ >;
+
+type RecurrenceScopeOpportunityState = {
+ opportunity: RecurrenceScopeOpportunity | null;
+};
+
+let nextOpportunityId = 1;
+
+export const useRecurrenceScopeOpportunityStore =
+ create()(() => ({ opportunity: null }));
+
+const setOpportunity = (opportunity: RecurrenceScopeOpportunity | null) =>
+ useRecurrenceScopeOpportunityStore.setState({ opportunity });
+
+export const recurrenceScopeOpportunityActions = {
+ begin: (opportunity: NewRecurrenceScopeOpportunity): number => {
+ const id = nextOpportunityId++;
+ setOpportunity({ ...opportunity, id, status: "ready" });
+ return id;
+ },
+
+ dismiss: (id?: number): void => {
+ const current = useRecurrenceScopeOpportunityStore.getState().opportunity;
+ if (!current || (id !== undefined && current.id !== id)) return;
+ if (current.status !== "ready") return;
+ setOpportunity(null);
+ },
+
+ requestPromotion: (id: number, scope: "thisAndFollowing" | "all"): void => {
+ useRecurrenceScopeOpportunityStore.setState((state) => {
+ const current = state.opportunity;
+ if (!current || current.id !== id || current.status !== "ready") {
+ return state;
+ }
+ return {
+ opportunity: { ...current, status: "requested", requestedScope: scope },
+ };
+ });
+ },
+
+ claimPromotion: (): RecurrenceScopeOpportunity | null => {
+ const current = useRecurrenceScopeOpportunityStore.getState().opportunity;
+ if (!current || current.status !== "requested" || !current.requestedScope) {
+ return null;
+ }
+ setOpportunity({ ...current, status: "submitting" });
+ return current;
+ },
+
+ complete: (id: number): void => {
+ const current = useRecurrenceScopeOpportunityStore.getState().opportunity;
+ if (current?.id === id) setOpportunity(null);
+ },
+
+ clear: (): void => setOpportunity(null),
+};
+
+export const selectRecurrenceScopeOpportunity = (
+ state: RecurrenceScopeOpportunityState,
+) => state.opportunity;
diff --git a/packages/web/src/events/stores/undo.store.test.ts b/packages/web/src/events/stores/undo.store.test.ts
index 0207ab4606..fa6e92a194 100644
--- a/packages/web/src/events/stores/undo.store.test.ts
+++ b/packages/web/src/events/stores/undo.store.test.ts
@@ -15,6 +15,12 @@ const event = (id: string) =>
content: { kind: "details", title: id, description: "" },
});
+const occurrence = (id: string, seriesId: string) =>
+ createMockEvent({
+ id: id as EventId,
+ recurrence: { kind: "occurrence", seriesId: seriesId as EventId },
+ });
+
const editEntry = (id: string, title = `${id}-moved`): UndoHistoryEntry => ({
kind: "edit",
id,
@@ -162,6 +168,19 @@ describe("undoHistoryActions", () => {
expect(useUndoHistoryStore.getState()).toEqual({ past: [], future: [] });
});
+ it("discards only history for a promoted series", () => {
+ const seriesId = "series";
+ const seriesOccurrence = occurrence("occurrence", seriesId);
+ undoHistoryActions.record({ kind: "delete", event: seriesOccurrence });
+ undoHistoryActions.record(editEntry("unrelated"));
+
+ undoHistoryActions.discardSeries(seriesId);
+
+ expect(useUndoHistoryStore.getState().past).toEqual([
+ editEntry("unrelated"),
+ ]);
+ });
+
it("commitRedo caps past at 30, dropping the oldest on a long redo run", () => {
for (let i = 0; i < 30; i++) {
undoHistoryActions.record(editEntry(`e${i}`));
diff --git a/packages/web/src/events/stores/undo.store.ts b/packages/web/src/events/stores/undo.store.ts
index 5b140eaaa1..c02f09ae52 100644
--- a/packages/web/src/events/stores/undo.store.ts
+++ b/packages/web/src/events/stores/undo.store.ts
@@ -71,6 +71,17 @@ export const runHistoryRestore = (fn: () => void) => {
const coalesceTargetId = (entry: UndoHistoryEntry): string | null =>
entry.kind === "edit" ? entry.id : null;
+const entryEvent = (entry: UndoHistoryEntry): Event =>
+ entry.kind === "edit" ? entry.before : entry.event;
+
+const entrySeriesId = (entry: UndoHistoryEntry): string | null => {
+ const event = entryEvent(entry);
+ if (event.recurrence.kind === "occurrence") {
+ return event.recurrence.seriesId;
+ }
+ return event.recurrence.kind === "series" ? event.id : null;
+};
+
export const undoHistoryActions = {
// Consecutive edits to the SAME event within COALESCE_WINDOW_MS merge into
// one history entry (keep the run's first `before`, take its last
@@ -174,6 +185,20 @@ export const undoHistoryActions = {
{ type: "dropTopRedo" },
),
+ // A scope-all/following operation rewrites this series, invalidating only
+ // its narrow snapshots. Keep independent event history intact.
+ discardSeries: (seriesId: string): void =>
+ useUndoHistoryStore.setState(
+ (state) => ({
+ past: state.past.filter((entry) => entrySeriesId(entry) !== seriesId),
+ future: state.future.filter(
+ (entry) => entrySeriesId(entry) !== seriesId,
+ ),
+ }),
+ false,
+ { type: "discardSeries" },
+ ),
+
clear: (): void =>
useUndoHistoryStore.setState(initialUndoHistoryState, false, {
type: "clear",