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:
@@ -146,3 +146,29 @@ export async function updateDefinition(
|
||||
): Promise<QuestDefinition> {
|
||||
return readJson<QuestDefinition>(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<string[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<DefinitionRule["rule_type"], Repeats> = {
|
||||
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,71 @@ function DefinitionRow({
|
||||
|
||||
/* ── Edit sheet ─────────────────────────────────────────────────────── */
|
||||
|
||||
type PreviewState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "ready"; dates: string[] }
|
||||
| { kind: "error"; 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.
|
||||
*/
|
||||
function OccurrencePreview({ ruleKey }: { ruleKey: string | null }) {
|
||||
const [preview, setPreview] = useState<PreviewState>({ 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", dates });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
const detail =
|
||||
error instanceof ApiForbiddenError ||
|
||||
(error instanceof ApiRequestError && error.detail)
|
||||
? `: ${error.message}`
|
||||
: ".";
|
||||
setPreview({ kind: "error", message: `Upcoming dates unavailable${detail}` });
|
||||
});
|
||||
}, PREVIEW_DEBOUNCE_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [ruleKey]);
|
||||
|
||||
if (ruleKey === null || preview.kind === "idle") return null;
|
||||
if (preview.kind === "error") {
|
||||
return (
|
||||
<p className="defs-hint defs-preview defs-preview--error" data-testid="occurrence-preview">
|
||||
{preview.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<p className="defs-hint defs-preview" data-testid="occurrence-preview" aria-live="polite">
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="defs-field" role="group" aria-labelledby={id}>
|
||||
@@ -411,6 +484,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 +697,7 @@ function EditSheet({
|
||||
onChange={(e) => update({ dayOfMonth: e.target.value })}
|
||||
/>
|
||||
) : null}
|
||||
<OccurrencePreview ruleKey={previewKey} />
|
||||
</Field>
|
||||
|
||||
<Field label="Due time" id="defs-label-due">
|
||||
|
||||
@@ -95,3 +95,17 @@ export function windowSummary(windows: Pick<DefinitionWindow, "window">[]): 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]}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user