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