Task 85b2f166: Recurrence occurrence preview (#204)
This commit was merged in pull request #204.
This commit is contained in:
@@ -146,3 +146,29 @@ export async function updateDefinition(
|
|||||||
): Promise<QuestDefinition> {
|
): Promise<QuestDefinition> {
|
||||||
return readJson<QuestDefinition>(await sendJson(`${DEFINITIONS_PATH}/${id}`, "PATCH", body));
|
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;
|
opacity: 0.6;
|
||||||
cursor: default;
|
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 { 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 DefinitionsTab from "./DefinitionsTab";
|
||||||
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
|
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
|
||||||
import { storeTokens } from "../auth/oidc";
|
import { storeTokens } from "../auth/oidc";
|
||||||
@@ -97,19 +97,27 @@ interface Call {
|
|||||||
body: unknown;
|
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
|
* 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[]) {
|
function fakeApi(initial: QuestDefinition[]) {
|
||||||
let definitions = [...initial];
|
let definitions = [...initial];
|
||||||
const calls: Call[] = [];
|
const calls: Call[] = [];
|
||||||
let nextWrite: Response | null = null;
|
let nextWrite: Response | null = null;
|
||||||
|
let preview: PreviewHandler = async () => jsonResponse({ dates: [] });
|
||||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
const fetchMock = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||||
const path = new URL(String(input), "http://localhost").pathname;
|
const path = new URL(String(input), "http://localhost").pathname;
|
||||||
const method = init.method ?? "GET";
|
const method = init.method ?? "GET";
|
||||||
const body = typeof init.body === "string" ? JSON.parse(init.body) : null;
|
const body = typeof init.body === "string" ? JSON.parse(init.body) : null;
|
||||||
calls.push({ method, path, body });
|
calls.push({ method, path, body });
|
||||||
|
if (method === "POST" && path === PREVIEW_PATH) return preview(body);
|
||||||
if (method === "GET" && path === "/api/v1/admin/quest-definitions") {
|
if (method === "GET" && path === "/api/v1/admin/quest-definitions") {
|
||||||
return jsonResponse({ definitions });
|
return jsonResponse({ definitions });
|
||||||
}
|
}
|
||||||
@@ -170,7 +178,11 @@ function fakeApi(initial: QuestDefinition[]) {
|
|||||||
return {
|
return {
|
||||||
fetchMock,
|
fetchMock,
|
||||||
calls,
|
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: () =>
|
listFetches: () =>
|
||||||
calls.filter((c) => c.method === "GET" && c.path === "/api/v1/admin/quest-definitions")
|
calls.filter((c) => c.method === "GET" && c.path === "/api/v1/admin/quest-definitions")
|
||||||
.length,
|
.length,
|
||||||
@@ -642,3 +654,267 @@ describe("DefinitionsTab", () => {
|
|||||||
expect(screen.getByTestId("definitions-scroll").style.paddingBottom).toBe("92px");
|
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("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<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));
|
||||||
|
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<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));
|
||||||
|
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<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).
|
* 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
|
* The icon picker (§3.3) is a separate task: an existing definition's icon is
|
||||||
* existing definition's icon is carried through unchanged by never sending it.
|
* carried through unchanged by never sending it.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
createDefinition,
|
createDefinition,
|
||||||
fetchChildren,
|
fetchChildren,
|
||||||
fetchDefinitions,
|
fetchDefinitions,
|
||||||
|
previewOccurrences,
|
||||||
updateDefinition,
|
updateDefinition,
|
||||||
type AdminChild,
|
type AdminChild,
|
||||||
type DefinitionCreateBody,
|
type DefinitionCreateBody,
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
WINDOW_LABELS,
|
WINDOW_LABELS,
|
||||||
WINDOW_ORDER,
|
WINDOW_ORDER,
|
||||||
assigneeSummary,
|
assigneeSummary,
|
||||||
|
occurrenceLabel,
|
||||||
recurrenceSummary,
|
recurrenceSummary,
|
||||||
windowSummary,
|
windowSummary,
|
||||||
} from "./format";
|
} from "./format";
|
||||||
@@ -66,6 +68,12 @@ const REPEATS_BY_RULE: Record<DefinitionRule["rule_type"], Repeats> = {
|
|||||||
custom_days: "custom",
|
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"];
|
const WEEKDAY_LONG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
||||||
|
|
||||||
interface Draft {
|
interface Draft {
|
||||||
@@ -327,6 +335,77 @@ function DefinitionRow({
|
|||||||
|
|
||||||
/* ── Edit sheet ─────────────────────────────────────────────────────── */
|
/* ── 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<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", 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 (
|
||||||
|
<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 }) {
|
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="defs-field" role="group" aria-labelledby={id}>
|
<div className="defs-field" role="group" aria-labelledby={id}>
|
||||||
@@ -411,6 +490,8 @@ function EditSheet({
|
|||||||
.filter((child) => draft.assigneeIds.includes(child.id))
|
.filter((child) => draft.assigneeIds.includes(child.id))
|
||||||
.map((child) => ({ id: child.id, display_name: child.display_name }));
|
.map((child) => ({ id: child.id, display_name: child.display_name }));
|
||||||
const orderedWindows = WINDOW_ORDER.filter((w) => draft.windows.includes(w));
|
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) {
|
async function save(confirmed = false) {
|
||||||
if (inFlight.current) return;
|
if (inFlight.current) return;
|
||||||
@@ -622,6 +703,7 @@ function EditSheet({
|
|||||||
onChange={(e) => update({ dayOfMonth: e.target.value })}
|
onChange={(e) => update({ dayOfMonth: e.target.value })}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
<OccurrencePreview ruleKey={previewKey} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Due time" id="defs-label-due">
|
<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])
|
.map((w) => WINDOW_LABELS[w])
|
||||||
.join(", ");
|
.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