Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 13 additions & 21 deletions docs/acceptance/recurring-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.

---
Expand Down Expand Up @@ -86,20 +86,18 @@ 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

1. Create a weekly recurring event (see Scenario 1).
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.
Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/__tests__/utils/state/reset-stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
102 changes: 102 additions & 0 deletions packages/web/src/common/utils/toast/recurrence-scope.toast.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
108 changes: 108 additions & 0 deletions packages/web/src/common/utils/toast/recurrence-scope.toast.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{verb} this event</span>
<button
type="button"
className={actionClassName}
onClick={() =>
recurrenceScopeOpportunityActions.requestPromotion(
opportunity.id,
"thisAndFollowing",
)
}
>
<kbd>1</kbd> This &amp; following
</button>
<button
type="button"
className={actionClassName}
onClick={() =>
recurrenceScopeOpportunityActions.requestPromotion(
opportunity.id,
"all",
)
}
>
<kbd>2</kbd> All
</button>
</div>
);
}

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(
<ScopeToastContent opportunity={opportunity} />,
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));
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -22,7 +23,7 @@ import { useUndoRedoShortcuts } from "@web/views/Week/hooks/shortcuts/useUndoRed
*/
export function GlobalShortcutsHost() {
useUndoRedoShortcuts();
return null;
return <RecurrenceScopeOpportunityHost />;
}

function ThemeAwareToastContainer() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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();
});
});
Loading