diff --git a/admin/src/api/definitions.ts b/admin/src/api/definitions.ts index cf8671d..d17582b 100644 --- a/admin/src/api/definitions.ts +++ b/admin/src/api/definitions.ts @@ -146,3 +146,29 @@ export async function updateDefinition( ): Promise { return readJson(await sendJson(`${DEFINITIONS_PATH}/${id}`, "PATCH", body)); } + +export const OCCURRENCES_PREVIEW_PATH = `${DEFINITIONS_PATH}/occurrences-preview`; + +export interface OccurrencesPreviewOptions { + /** "YYYY-MM-DD"; the API defaults to its host's today. */ + startDate?: string; + /** 1..50; the API defaults to 10. */ + count?: number; +} + +/** + * The next occurrence dates ("YYYY-MM-DD", ascending) for `rule`, computed by + * the same backend recurrence engine the materializer uses. + */ +export async function previewOccurrences( + rule: DefinitionRule, + options: OccurrencesPreviewOptions = {}, +): Promise { + const body: { rule: DefinitionRule; start_date?: string; count?: number } = { rule }; + if (options.startDate !== undefined) body.start_date = options.startDate; + if (options.count !== undefined) body.count = options.count; + const response = await readJson<{ dates: string[] }>( + await sendJson(OCCURRENCES_PREVIEW_PATH, "POST", body), + ); + return response.dates; +} diff --git a/admin/src/definitions/DefinitionsTab.css b/admin/src/definitions/DefinitionsTab.css index 1b9f425..80c6fe7 100644 --- a/admin/src/definitions/DefinitionsTab.css +++ b/admin/src/definitions/DefinitionsTab.css @@ -550,3 +550,12 @@ opacity: 0.6; cursor: default; } + +.defs-preview { + margin-top: 8px; +} + +.defs-preview--error { + color: var(--nq-a-ink-secondary); + font-style: italic; +} diff --git a/admin/src/definitions/DefinitionsTab.test.tsx b/admin/src/definitions/DefinitionsTab.test.tsx index 1ae0d64..238f769 100644 --- a/admin/src/definitions/DefinitionsTab.test.tsx +++ b/admin/src/definitions/DefinitionsTab.test.tsx @@ -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; + /** * 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,267 @@ describe("DefinitionsTab", () => { expect(screen.getByTestId("definitions-scroll").style.paddingBottom).toBe("92px"); }); }); + +/* ── Occurrence preview ─────────────────────────────────────────────── */ + +/** Past the sheet's 300 ms preview debounce. */ +function settleDebounce(): Promise { + return act(() => new Promise((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((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("window and assignee edits never request, and a rule edit waits out the debounce", async () => { + await renderReady(); + api.setPreview(async () => jsonResponse({ dates: BINS_DATES })); + fireEvent.click(screen.getByTestId("definition-11")); + const s = sheet(); + await waitFor(() => expect(previewText()).toContain("Mon 5 Oct")); + expect(api.previews()).toHaveLength(1); + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + // Windows and assignees are not part of the rule. + fireEvent.click(within(s).getByRole("button", { name: "Morning" })); + fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ })); + fireEvent.click(within(s).getByRole("checkbox", { name: "Maeve" })); + await act(async () => { + vi.advanceTimersByTime(1000); + }); + expect(api.previews()).toHaveLength(1); + expect(previewText()).toContain("Mon 5 Oct"); + + // A rule edit is not requested until the 300 ms debounce elapses. + const days = within(s).getByRole("group", { name: "Days" }); + fireEvent.click(within(days).getByRole("button", { name: "Tuesday" })); + await act(async () => { + vi.advanceTimersByTime(299); + }); + expect(api.previews()).toHaveLength(1); + await act(async () => { + vi.advanceTimersByTime(1); + }); + expect(api.previews()).toHaveLength(2); + expect((api.previews()[1].body as { rule: DefinitionRule }).rule.weekday_set).toEqual([ + 0, 1, 3, + ]); + }); + + it("a rule edit hides the previous rule's dates until its own response arrives", async () => { + await renderReady(); + const pending: ReturnType[] = []; + api.setPreview(() => { + const next = deferred(); + pending.push(next); + return next.promise; + }); + fireEvent.click(screen.getByTestId("definition-11")); + await waitFor(() => expect(pending).toHaveLength(1)); + await act(async () => { + pending[0].resolve(jsonResponse({ dates: BINS_DATES })); + }); + await waitFor(() => expect(previewText()).toContain("Mon 5 Oct")); + + const days = within(sheet()).getByRole("group", { name: "Days" }); + fireEvent.click(within(days).getByRole("button", { name: "Monday" })); + // Within the debounce: the old dates are already gone. + expect(previewText()).toBeNull(); + // Request in flight: still nothing shown for the new rule. + await waitFor(() => expect(pending).toHaveLength(2)); + expect(previewText()).toBeNull(); + + await act(async () => { + pending[1].resolve(jsonResponse({ dates: ["2026-10-08", "2026-10-22"] })); + }); + await waitFor(() => expect(previewText()).toBe("Next: Thu 8 Oct · Thu 22 Oct")); + }); + + it("a rule edit hides the previous rule's error until its own response arrives", async () => { + await renderReady(); + const pending: ReturnType[] = []; + api.setPreview(() => { + const next = deferred(); + pending.push(next); + return next.promise; + }); + fireEvent.click(screen.getByTestId("definition-11")); + await waitFor(() => expect(pending).toHaveLength(1)); + await act(async () => { + pending[0].resolve(jsonResponse({ detail: "rule rejected" }, 422)); + }); + await waitFor(() => expect(previewText()).toBe("Upcoming dates unavailable: rule rejected")); + + const days = within(sheet()).getByRole("group", { name: "Days" }); + fireEvent.click(within(days).getByRole("button", { name: "Monday" })); + expect(previewText()).toBeNull(); + await waitFor(() => expect(pending).toHaveLength(2)); + expect(previewText()).toBeNull(); + + await act(async () => { + pending[1].resolve(jsonResponse({ dates: ["2026-10-08"] })); + }); + await waitFor(() => expect(previewText()).toBe("Next: Thu 8 Oct")); + }); + + it("a stale response resolving after a newer one does not overwrite it", async () => { + await renderReady(); + const pending: ReturnType[] = []; + 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); + }); +}); diff --git a/admin/src/definitions/DefinitionsTab.tsx b/admin/src/definitions/DefinitionsTab.tsx index 2bb80e6..05cc19a 100644 --- a/admin/src/definitions/DefinitionsTab.tsx +++ b/admin/src/definitions/DefinitionsTab.tsx @@ -1,8 +1,8 @@ /** * Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2). * - * The icon picker (§3.3) and the occurrence preview are separate tasks: an - * existing definition's icon is carried through unchanged by never sending it. + * The icon picker (§3.3) is a separate task: an existing definition's icon is + * carried through unchanged by never sending it. */ import { useEffect, @@ -17,6 +17,7 @@ import { createDefinition, fetchChildren, fetchDefinitions, + previewOccurrences, updateDefinition, type AdminChild, type DefinitionCreateBody, @@ -32,6 +33,7 @@ import { WINDOW_LABELS, WINDOW_ORDER, assigneeSummary, + occurrenceLabel, recurrenceSummary, windowSummary, } from "./format"; @@ -66,6 +68,12 @@ const REPEATS_BY_RULE: Record = { custom_days: "custom", }; +/** How many upcoming dates the edit sheet previews. */ +const PREVIEW_COUNT = 5; + +/** Quiet period after the last rule edit before the preview is requested. */ +const PREVIEW_DEBOUNCE_MS = 300; + const WEEKDAY_LONG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; interface Draft { @@ -327,6 +335,77 @@ function DefinitionRow({ /* ── Edit sheet ─────────────────────────────────────────────────────── */ +type PreviewState = + | { kind: "idle" } + | { kind: "ready"; ruleKey: string; dates: string[] } + | { kind: "error"; ruleKey: string; message: string }; + +/** + * The next few dates the backend would materialize for the draft's rule. + * `ruleKey` is the built rule serialized (null while the draft is invalid), so + * the request only goes out when the rule itself changes — debounced, and a + * superseded request's late response is dropped by its effect's cleanup. + * A result is tagged with the rule it answers and only shown while that rule + * is still the draft's, so a changed rule never shows the previous dates. + */ +function OccurrencePreview({ ruleKey }: { ruleKey: string | null }) { + const [preview, setPreview] = useState({ kind: "idle" }); + + useEffect(() => { + if (ruleKey === null) { + setPreview({ kind: "idle" }); + return; + } + let cancelled = false; + const timer = setTimeout(() => { + previewOccurrences(JSON.parse(ruleKey) as DefinitionRule, { count: PREVIEW_COUNT }) + .then((dates) => { + if (!cancelled) setPreview({ kind: "ready", ruleKey, dates }); + }) + .catch((error: unknown) => { + if (cancelled) return; + const detail = + error instanceof ApiForbiddenError || + (error instanceof ApiRequestError && error.detail) + ? `: ${error.message}` + : "."; + setPreview({ + kind: "error", + ruleKey, + message: `Upcoming dates unavailable${detail}`, + }); + }); + }, PREVIEW_DEBOUNCE_MS); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [ruleKey]); + + if (preview.kind === "idle" || preview.ruleKey !== ruleKey) return null; + if (preview.kind === "error") { + return ( +

+ {preview.message} +

+ ); + } + let text: string; + if (preview.dates.length === 0) { + text = "No upcoming dates in the next year."; + } else { + const labels = preview.dates.map(occurrenceLabel); + // A full page means the rule keeps going past the last shown date. + if (preview.dates.length >= PREVIEW_COUNT) labels.push("…"); + text = `Next: ${labels.join(" · ")}`; + } + return ( +

+ {text} +

+ ); +} + function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) { return (
@@ -411,6 +490,8 @@ function EditSheet({ .filter((child) => draft.assigneeIds.includes(child.id)) .map((child) => ({ id: child.id, display_name: child.display_name })); const orderedWindows = WINDOW_ORDER.filter((w) => draft.windows.includes(w)); + const previewRule = buildRule(draft); + const previewKey = typeof previewRule === "string" ? null : JSON.stringify(previewRule); async function save(confirmed = false) { if (inFlight.current) return; @@ -622,6 +703,7 @@ function EditSheet({ onChange={(e) => update({ dayOfMonth: e.target.value })} /> ) : null} + diff --git a/admin/src/definitions/format.ts b/admin/src/definitions/format.ts index aa48f81..a2246d4 100644 --- a/admin/src/definitions/format.ts +++ b/admin/src/definitions/format.ts @@ -95,3 +95,17 @@ export function windowSummary(windows: Pick[]): stri .map((w) => WINDOW_LABELS[w]) .join(", "); } + +/** + * "2026-10-06" -> "Tue 6 Oct". The calendar date is read by its components + * and its weekday taken in UTC (as the Today tab does), so the printed day + * never shifts with the viewer's offset — `new Date("YYYY-MM-DD")` would. + */ +export function occurrenceLabel(iso: string): string { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); + if (!match) throw new Error(`Invalid calendar date: ${iso}`); + const [, year, month, day] = match.map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + // getUTCDay: 0 = Sunday; WEEKDAY_SHORT: 0 = Monday. + return `${WEEKDAY_SHORT[(date.getUTCDay() + 6) % 7]} ${day} ${MONTH_SHORT[month - 1]}`; +}