Preview a definition's next occurrences in the edit sheet

The edit sheet now shows the next five dates the backend would materialize
for the draft rule ("Next: Mon 5 Oct · Thu 8 Oct · …"), fetched from
POST /api/v1/admin/quest-definitions/occurrences-preview. Requests are
debounced 300 ms, fire only when the built rule changes, skip incomplete
drafts, drop superseded responses, and a failure shows a non-blocking note.

Task 85b2f166-d9da-45b5-928e-3ff4d2cc6d89

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 22:19:29 -04:00
co-authored by Claude Opus 5.5
parent d0c236b569
commit a105b2b160
5 changed files with 315 additions and 5 deletions
+188 -3
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import DefinitionsTab from "./DefinitionsTab";
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
import { storeTokens } from "../auth/oidc";
@@ -97,19 +97,27 @@ interface Call {
body: unknown;
}
const PREVIEW_PATH = "/api/v1/admin/quest-definitions/occurrences-preview";
/** Answers an occurrence-preview request; the default serves no dates. */
type PreviewHandler = (body: { rule: DefinitionRule; count?: number }) => Promise<Response>;
/**
* A stub of the admin API: serves the list and children, records writes, and
* lets a test queue a response for the next write.
* lets a test queue a response for the next write. Occurrence previews are
* reads (recorded, never counted as writes) answered by `setPreview`.
*/
function fakeApi(initial: QuestDefinition[]) {
let definitions = [...initial];
const calls: Call[] = [];
let nextWrite: Response | null = null;
let preview: PreviewHandler = async () => jsonResponse({ dates: [] });
const fetchMock = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
const path = new URL(String(input), "http://localhost").pathname;
const method = init.method ?? "GET";
const body = typeof init.body === "string" ? JSON.parse(init.body) : null;
calls.push({ method, path, body });
if (method === "POST" && path === PREVIEW_PATH) return preview(body);
if (method === "GET" && path === "/api/v1/admin/quest-definitions") {
return jsonResponse({ definitions });
}
@@ -170,7 +178,11 @@ function fakeApi(initial: QuestDefinition[]) {
return {
fetchMock,
calls,
writes: () => calls.filter((c) => c.method !== "GET"),
writes: () => calls.filter((c) => c.method !== "GET" && c.path !== PREVIEW_PATH),
previews: () => calls.filter((c) => c.path === PREVIEW_PATH),
setPreview: (handler: PreviewHandler) => {
preview = handler;
},
listFetches: () =>
calls.filter((c) => c.method === "GET" && c.path === "/api/v1/admin/quest-definitions")
.length,
@@ -642,3 +654,176 @@ describe("DefinitionsTab", () => {
expect(screen.getByTestId("definitions-scroll").style.paddingBottom).toBe("92px");
});
});
/* ── Occurrence preview ─────────────────────────────────────────────── */
/** Past the sheet's 300 ms preview debounce. */
function settleDebounce(): Promise<void> {
return act(() => new Promise<void>((resolve) => setTimeout(resolve, 400)));
}
function previewText(): string | null {
return screen.queryByTestId("occurrence-preview")?.textContent ?? null;
}
/** BINS' rule (every 2 weeks on Mon, Thu) from 2026-10-05. */
const BINS_DATES = ["2026-10-05", "2026-10-08", "2026-10-19", "2026-10-22", "2026-11-02"];
/** The same rule on Tuesdays only. */
const TUESDAY_DATES = ["2026-10-06", "2026-10-20", "2026-11-03", "2026-11-17", "2026-12-01"];
function deferred() {
let resolve!: (response: Response) => void;
const promise = new Promise<Response>((r) => {
resolve = r;
});
return { promise, resolve };
}
describe("DefinitionsTab occurrence preview", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("previews a weekly rule's next dates in plain language from the API", async () => {
await renderReady();
api.setPreview(async () => jsonResponse({ dates: BINS_DATES }));
fireEvent.click(screen.getByTestId("definition-11"));
await waitFor(() =>
expect(previewText()).toBe(
"Next: Mon 5 Oct · Thu 8 Oct · Mon 19 Oct · Thu 22 Oct · Mon 2 Nov · …",
),
);
expect(api.previews()).toEqual([
{
method: "POST",
path: "/api/v1/admin/quest-definitions/occurrences-preview",
body: { rule: BINS.rule, count: 5 },
},
]);
});
it("re-requests once per rule change, debounced, and not for non-rule edits", async () => {
await renderReady();
api.setPreview(async ({ rule }) =>
jsonResponse({ dates: rule.weekday_set?.includes(0) ? BINS_DATES : TUESDAY_DATES }),
);
fireEvent.click(screen.getByTestId("definition-11"));
const s = sheet();
await waitFor(() => expect(previewText()).toContain("Mon 5 Oct"));
// A title edit leaves the rule alone: no new request.
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Bins" },
});
// Three rule edits in quick succession collapse into one request.
const days = within(s).getByRole("group", { name: "Days" });
fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
fireEvent.click(within(days).getByRole("button", { name: "Thursday" }));
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
await waitFor(() =>
expect(previewText()).toBe(
"Next: Tue 6 Oct · Tue 20 Oct · Tue 3 Nov · Tue 17 Nov · Tue 1 Dec · …",
),
);
await settleDebounce();
expect(api.previews()).toHaveLength(2);
expect(api.previews()[1].body).toEqual({
rule: { ...BINS.rule, weekday_set: [1] },
count: 5,
});
// An interval change is a rule change too.
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
target: { value: "1" },
});
await waitFor(() => expect(api.previews()).toHaveLength(3));
expect((api.previews()[2].body as { rule: DefinitionRule }).rule.interval).toBe(1);
});
it("a stale response resolving after a newer one does not overwrite it", async () => {
await renderReady();
const pending: ReturnType<typeof deferred>[] = [];
api.setPreview(() => {
const next = deferred();
pending.push(next);
return next.promise;
});
fireEvent.click(screen.getByTestId("definition-11"));
await waitFor(() => expect(pending).toHaveLength(1));
const days = within(sheet()).getByRole("group", { name: "Days" });
fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
fireEvent.click(within(days).getByRole("button", { name: "Thursday" }));
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
await waitFor(() => expect(pending).toHaveLength(2));
await act(async () => {
pending[1].resolve(jsonResponse({ dates: TUESDAY_DATES }));
});
await waitFor(() => expect(previewText()).toContain("Tue 6 Oct"));
await act(async () => {
pending[0].resolve(jsonResponse({ dates: BINS_DATES }));
});
await settleDebounce();
expect(previewText()).toBe(
"Next: Tue 6 Oct · Tue 20 Oct · Tue 3 Nov · Tue 17 Nov · Tue 1 Dec · …",
);
});
it("a failed preview shows a non-blocking message and the sheet still saves", async () => {
await renderReady();
api.setPreview(async () => jsonResponse({ detail: "rule rejected" }, 422));
fireEvent.click(screen.getByTestId("definition-10"));
await waitFor(() => expect(previewText()).toBe("Upcoming dates unavailable: rule rejected"));
expect(screen.queryByTestId("sheet-error")).toBeNull();
await act(async () => {
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toHaveLength(1);
expect(api.writes()[0].method).toBe("PATCH");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("an incomplete draft does not call the preview endpoint", async () => {
await renderReady();
api.setPreview(async () => jsonResponse({ dates: TUESDAY_DATES }));
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), {
target: { value: "weekly" },
});
// Clear the default weekday: a weekly rule with no days is incomplete.
const days = within(s).getByRole("group", { name: "Days" });
for (const day of within(days).getAllByRole("button")) {
if (day.getAttribute("aria-pressed") === "true") fireEvent.click(day);
}
await settleDebounce();
expect(api.previews()).toHaveLength(0);
expect(previewText()).toBeNull();
// An unparseable interval is incomplete too.
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
target: { value: "" },
});
await settleDebounce();
expect(api.previews()).toHaveLength(0);
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
target: { value: "2" },
});
await waitFor(() => expect(previewText()).toContain("Tue 6 Oct"));
expect(api.previews()).toHaveLength(1);
});
});