diff --git a/admin/src/api/presence.ts b/admin/src/api/presence.ts index 2789198..a04461e 100644 --- a/admin/src/api/presence.ts +++ b/admin/src/api/presence.ts @@ -80,3 +80,47 @@ export async function fetchPresenceOverrides( ); return body.overrides; } + +export interface PresenceOverrideRange { + child_id: number; + /** "YYYY-MM-DD", inclusive. */ + start_date: string; + /** "YYYY-MM-DD", inclusive; never before start_date. */ + end_date: string; + is_present: boolean; +} + +export interface PresenceOverrideBody extends PresenceOverrideRange { + note?: string; +} + +export const PRESENCE_OVERRIDE_CONSEQUENCE_PATH = `${PRESENCE_OVERRIDES_PATH}/consequence`; + +function postJson(path: string, body: unknown): Promise { + return apiFetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** + * How many open upcoming tasks saving this override would remove. Computed + * by the API before anything is saved; completed instances never count. + */ +export async function previewOverrideConsequence(range: PresenceOverrideRange): Promise { + const body = await readJson<{ removed: number }>( + await postJson(PRESENCE_OVERRIDE_CONSEQUENCE_PATH, range), + ); + return body.removed; +} + +export async function createPresenceOverride(body: PresenceOverrideBody): Promise { + return readJson(await postJson(PRESENCE_OVERRIDES_PATH, body)); +} + +export async function deletePresenceOverride(overrideId: number): Promise { + await readJson<{ status: string }>( + await apiFetch(`${PRESENCE_OVERRIDES_PATH}/${overrideId}`, { method: "DELETE" }), + ); +} diff --git a/admin/src/index.css b/admin/src/index.css index bfdc5d7..9965f55 100644 --- a/admin/src/index.css +++ b/admin/src/index.css @@ -24,6 +24,7 @@ --nq-a-danger-strong: #b91c1c; --nq-a-danger-bg: #fee2e2; --nq-a-warning-ink: #92400e; + --nq-a-warning-ink-2: #78350f; --nq-a-warning-bg: #fef3c7; --nq-a-warning-border: rgba(217, 119, 6, 0.35); --nq-a-info-bg: #dbeafe; diff --git a/admin/src/schedule/OverrideEditor.css b/admin/src/schedule/OverrideEditor.css new file mode 100644 index 0000000..e95c6e4 --- /dev/null +++ b/admin/src/schedule/OverrideEditor.css @@ -0,0 +1,220 @@ +/* Presence override editor — roomy density pushed screen + (design/ADMIN-SPEC.md §6). Covers the tab bar like a pushed route. */ + +.override-screen { + position: fixed; + inset: 0; + z-index: 30; + overflow-y: auto; + background: var(--nq-a-page); +} + +.override-header { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 16px; + background: var(--nq-a-surface); + border-bottom: 1px solid var(--nq-a-border); +} + +.override-back { + flex: none; + display: grid; + place-items: center; + width: var(--nq-a-tap-min); + height: var(--nq-a-tap-min); + margin: -6px 0 -6px -12px; + padding: 0; + border: 0; + background: none; + color: var(--nq-a-ink); + cursor: pointer; +} + +.override-title { + margin: 0; + font-size: var(--nq-a-size-screen); + font-weight: 800; + letter-spacing: -0.02em; +} + +.override-sub { + margin: 2px 0 0; + font-size: 12px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.override-scroll { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; + max-width: 640px; + margin: 0 auto; + padding: 16px 16px 24px; +} + +/* Form card: roomy density, 18px padding, 16px gaps. */ +.override-card { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px; + background: var(--nq-a-surface); + border: 1px solid var(--nq-a-border); + border-radius: 12px; +} + +.override-field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.override-label { + font-size: var(--nq-a-size-label); + font-weight: 700; + text-transform: uppercase; + letter-spacing: var(--nq-a-track-label); + color: var(--nq-a-ink-secondary); +} + +.override-segments { + display: grid; + gap: 6px; +} + +.override-segments--3 { + grid-template-columns: repeat(3, 1fr); +} + +.override-segments--2 { + grid-template-columns: repeat(2, 1fr); +} + +.override-segment { + min-height: var(--nq-a-tap-min); + padding: 0 8px; + border: 1px solid var(--nq-a-border); + border-radius: 10px; + background: var(--nq-a-surface); + color: var(--nq-a-ink-secondary); + font-family: inherit; + font-size: 13.5px; + font-weight: 700; + cursor: pointer; +} + +.override-segment--gradient[aria-pressed="true"] { + border-color: transparent; + background: var(--nq-brand-gradient); + color: #fff; +} + +.override-segment--ink[aria-pressed="true"] { + border-color: #18181d; + background: #18181d; + color: #fff; +} + +.override-dates { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.override-input { + width: 100%; + min-height: var(--nq-a-tap-min); + padding: 13px; + border: 1px solid var(--nq-a-border); + border-radius: 10px; + background: var(--nq-a-surface); + color: var(--nq-a-ink); + font-family: inherit; + font-size: 14px; + font-weight: 600; +} + +.override-date { + font-weight: 700; +} + +/* Consequence warning */ +.override-warning { + display: flex; + gap: 10px; + padding: 14px; + border: 1px solid var(--nq-a-warning-border); + border-radius: 12px; + background: var(--nq-a-warning-bg); +} + +.override-warning-glyph { + flex: none; + display: flex; + color: var(--nq-a-warning-ink); +} + +.override-warning-headline { + margin: 0; + font-size: 13.5px; + font-weight: 700; + color: var(--nq-a-warning-ink); +} + +.override-warning-body { + margin: 3px 0 0; + font-size: 12px; + font-weight: 600; + color: var(--nq-a-warning-ink-2); +} + +.override-error { + margin: 0; + padding: 10px 12px; + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-danger-bg); + color: var(--nq-a-danger-strong); + font-size: 12.5px; + font-weight: 700; +} + +/* Footer: Cancel (flex 1) + Save override (flex 2), 15px vertical padding. */ +.override-footer { + display: flex; + gap: 10px; +} + +.override-cancel, +.override-save { + padding: 15px 12px; + border-radius: 12px; + font-family: inherit; + font-size: 14px; + font-weight: 700; + cursor: pointer; +} + +.override-cancel { + flex: 1; + border: 1px solid var(--nq-a-border); + background: var(--nq-a-surface); + color: var(--nq-a-ink); +} + +.override-save { + flex: 2; + border: 0; + background: var(--nq-brand-gradient); + color: #fff; +} + +.override-cancel:disabled, +.override-save:disabled { + opacity: 0.45; + cursor: default; +} diff --git a/admin/src/schedule/OverrideEditor.test.tsx b/admin/src/schedule/OverrideEditor.test.tsx new file mode 100644 index 0000000..0a2c4bb --- /dev/null +++ b/admin/src/schedule/OverrideEditor.test.tsx @@ -0,0 +1,533 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import App from "../App"; +import ScheduleTab from "./ScheduleTab"; +import { CONSEQUENCE_DEBOUNCE_MS, isIsoDate, isValidRange } from "./OverrideEditor"; +import type { AdminChild } from "../api/definitions"; +import type { PresenceOverride, PresenceSchedule } from "../api/presence"; +import { storeTokens } from "../auth/oidc"; + +function child(id: number, display_name: string, sort_order: number): AdminChild { + return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active: true }; +} + +const CHILDREN = [child(1, "Declan", 1), child(2, "Maeve", 2), child(3, "Rory", 3)]; + +const DECLAN: PresenceSchedule = { + child_id: 1, + cycle_length_weeks: 2, + anchor_date: "2026-09-07", + pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] }, +}; + +const INITIAL_OVERRIDES: PresenceOverride[] = [ + { id: 41, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: "Dentist trip" }, +]; + +const TODAY = "2026-09-23"; +const OVERRIDES_PATH = "/api/v1/admin/presence-overrides"; +const CONSEQUENCE_PATH = `${OVERRIDES_PATH}/consequence`; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +interface Call { + method: string; + path: string; + body: unknown; +} + +let overrides: PresenceOverride[]; +let consequence: (body: Record) => Response | Promise; +let createResponse: (body: Record) => Response; +let fetchMock: ReturnType; + +function calls(): Call[] { + return fetchMock.mock.calls.map(([url, init]: [string, RequestInit | undefined]) => ({ + method: init?.method ?? "GET", + path: new URL(String(url), "http://x").pathname, + body: init?.body ? JSON.parse(String(init.body)) : undefined, + })); +} + +function consequenceBodies(): unknown[] { + return calls() + .filter((c) => c.path === CONSEQUENCE_PATH) + .map((c) => c.body); +} + +async function routeFetch(url: string, init: RequestInit = {}): Promise { + const path = new URL(url, "http://x").pathname; + const method = init.method ?? "GET"; + const body = init.body ? JSON.parse(String(init.body)) : undefined; + if (path === CONSEQUENCE_PATH && method === "POST") return consequence(body); + if (path === OVERRIDES_PATH && method === "POST") return createResponse(body); + const deleteMatch = /\/presence-overrides\/(\d+)$/.exec(path); + if (deleteMatch && method === "DELETE") { + overrides = overrides.filter((o) => o.id !== Number(deleteMatch[1])); + return jsonResponse({ status: "ok" }); + } + if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN }); + const scheduleMatch = /\/children\/(\d+)\/presence-schedule$/.exec(path); + if (scheduleMatch) { + return jsonResponse({ schedule: Number(scheduleMatch[1]) === 1 ? DECLAN : null }); + } + if (path === OVERRIDES_PATH) return jsonResponse({ overrides }); + return jsonResponse({ detail: "unexpected" }, 500); +} + +function deferred() { + let resolve!: (response: Response) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +const pastDebounce = () => new Promise((r) => setTimeout(r, CONSEQUENCE_DEBOUNCE_MS + 150)); + +function headline(): string { + return screen.getByTestId("override-consequence-headline").textContent ?? ""; +} + +function warningBody(): string { + return screen.getByTestId("override-consequence").querySelector(".override-warning-body")?.textContent ?? ""; +} + +function saveButton(): HTMLButtonElement { + return screen.getByRole("button", { name: "Save override" }) as HTMLButtonElement; +} + +function setDate(label: "From" | "Through", value: string) { + fireEvent.change(screen.getByLabelText(label), { target: { value } }); +} + +async function openEditor() { + render(); + await screen.findByTestId("pattern-1"); + fireEvent.click(screen.getByRole("button", { name: "Add" })); + await screen.findByRole("heading", { name: "Presence override" }); +} + +describe("OverrideEditor", () => { + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + overrides = [...INITIAL_OVERRIDES]; + consequence = () => jsonResponse({ removed: 4 }); + createResponse = (body) => { + const created: PresenceOverride = { + id: 50, + child_id: body.child_id as number, + start_date: body.start_date as string, + end_date: body.end_date as string, + is_present: body.is_present as boolean, + note: (body.note as string | undefined) ?? null, + }; + overrides = [...overrides, created]; + return jsonResponse(created, 201); + }; + fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init)); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("renders the pushed screen per ADMIN-SPEC §6", async () => { + await openEditor(); + expect(screen.getByText("Beats the custody pattern for these dates")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Back" })).toBeTruthy(); + const kids = within(screen.getByRole("group", { name: "Child" })).getAllByRole("button"); + expect(kids.map((b) => [b.textContent, b.getAttribute("aria-pressed")])).toEqual([ + ["Declan", "true"], + ["Maeve", "false"], + ["Rory", "false"], + ]); + const status = within(screen.getByRole("group", { name: "Status for these dates" })); + expect(status.getByRole("button", { name: "Away" }).getAttribute("aria-pressed")).toBe("true"); + expect(status.getByRole("button", { name: "Home" }).getAttribute("aria-pressed")).toBe("false"); + expect((screen.getByLabelText("From") as HTMLInputElement).value).toBe(TODAY); + expect((screen.getByLabelText("Through") as HTMLInputElement).value).toBe(TODAY); + expect(screen.getByLabelText("Reason (optional)")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy(); + }); + + it("shows the live consequence count from the API and recounts on every change", async () => { + await openEditor(); + // Save is off until the count is on screen: the warning precedes saving. + expect(saveButton().disabled).toBe(true); + await waitFor(() => expect(headline()).toBe("This removes 4 upcoming tasks")); + expect(consequenceBodies()).toEqual([ + { child_id: 1, start_date: TODAY, end_date: TODAY, is_present: false }, + ]); + expect(warningBody()).toBe( + "Declan's open tasks Sep 23 are removed while away. Completed days are untouched.", + ); + expect(saveButton().disabled).toBe(false); + + consequence = () => jsonResponse({ removed: 9 }); + setDate("Through", "2026-09-27"); + await waitFor(() => expect(headline()).toBe("This removes 9 upcoming tasks")); + expect(consequenceBodies()[1]).toEqual({ + child_id: 1, + start_date: TODAY, + end_date: "2026-09-27", + is_present: false, + }); + expect(warningBody()).toBe( + "Declan's open tasks Sep 23 – 27 are removed while away. Completed days are untouched.", + ); + + consequence = () => jsonResponse({ removed: 2 }); + fireEvent.click(screen.getByRole("button", { name: "Maeve" })); + await waitFor(() => expect(headline()).toBe("This removes 2 upcoming tasks")); + expect(consequenceBodies()[2]).toEqual({ + child_id: 2, + start_date: TODAY, + end_date: "2026-09-27", + is_present: false, + }); + expect(warningBody()).toBe( + "Maeve's open tasks Sep 23 – 27 are removed while away. Completed days are untouched.", + ); + + // A Home override removes nothing by definition: no request, count 0. + fireEvent.click(screen.getByRole("button", { name: "Home" })); + expect(headline()).toBe("This removes 0 upcoming tasks"); + expect(warningBody()).toBe( + "Maeve is home Sep 23 – 27, so no tasks are removed. Completed days are untouched.", + ); + await pastDebounce(); + expect(consequenceBodies()).toHaveLength(3); + }); + + it("renders the API's count verbatim — completed instances are excluded server-side", async () => { + // The range holds 3 open instances and 1 already completed. The API + // excludes the completed one (D-005; proven by + // tests/test_api_admin_presence_consequence.py::test_completed_instance_in_range_is_not_counted + // and ::test_count_matches_a_direct_materialise_comparison), so it answers 3, + // and the editor shows exactly that — it never counts days on its own. + consequence = () => jsonResponse({ removed: 3 }); + await openEditor(); + setDate("Through", "2026-09-25"); + await waitFor(() => expect(headline()).toBe("This removes 3 upcoming tasks")); + expect(warningBody()).toBe( + "Declan's open tasks Sep 23 – 25 are removed while away. Completed days are untouched.", + ); + }); + + it("a stale consequence response never overwrites a newer one", async () => { + const first = deferred(); + const second = deferred(); + const pending = [first, second]; + consequence = () => pending.shift()!.promise; + await openEditor(); + await waitFor(() => expect(consequenceBodies()).toHaveLength(1)); + + setDate("Through", "2026-09-26"); + await waitFor(() => expect(consequenceBodies()).toHaveLength(2)); + + second.resolve(jsonResponse({ removed: 5 })); + await waitFor(() => expect(headline()).toBe("This removes 5 upcoming tasks")); + first.resolve(jsonResponse({ removed: 99 })); + await pastDebounce(); + expect(headline()).toBe("This removes 5 upcoming tasks"); + }); + + it("debounces rapid edits into one request for the final range", async () => { + await openEditor(); + await waitFor(() => expect(consequenceBodies()).toHaveLength(1)); + setDate("Through", "2026-09-24"); + setDate("Through", "2026-09-25"); + setDate("Through", "2026-09-26"); + await pastDebounce(); + expect(consequenceBodies().slice(1)).toEqual([ + { child_id: 1, start_date: TODAY, end_date: "2026-09-26", is_present: false }, + ]); + }); + + it("an incomplete or reversed range sends no consequence request and cannot be saved", async () => { + await openEditor(); + await waitFor(() => expect(consequenceBodies()).toHaveLength(1)); + + setDate("Through", ""); + expect(headline()).toBe("Choose a valid date range"); + expect(saveButton().disabled).toBe(true); + await pastDebounce(); + expect(consequenceBodies()).toHaveLength(1); + + setDate("Through", "2026-09-20"); // before From + expect(headline()).toBe("Choose a valid date range"); + await pastDebounce(); + expect(consequenceBodies()).toHaveLength(1); + fireEvent.click(saveButton()); + expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false); + }); + + it("a malformed From date sends no consequence request and cannot be saved", async () => { + await openEditor(); + await waitFor(() => expect(consequenceBodies()).toHaveLength(1)); + + setDate("From", "2026-02-30"); + expect(headline()).toBe("Choose a valid date range"); + expect(saveButton().disabled).toBe(true); + await pastDebounce(); + expect(consequenceBodies()).toHaveLength(1); + fireEvent.click(saveButton()); + expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false); + }); + + it("Save sends the exact POST body, closes the editor and shows the new row", async () => { + await openEditor(); + setDate("From", "2026-09-24"); + setDate("Through", "2026-09-26"); + fireEvent.change(screen.getByLabelText("Reason (optional)"), { + target: { value: " Trip with grandparents " }, + }); + await waitFor(() => expect(saveButton().disabled).toBe(false)); + const before = calls().length; + const schedulesBefore = calls().filter((c) => c.path.endsWith("/presence-schedule")).length; + + fireEvent.click(saveButton()); + const row = await screen.findByTestId("override-50"); + expect(row.textContent).toContain("Declan away · Sep 24 – 26"); + expect(row.textContent).toContain("Trip with grandparents"); + expect(screen.queryByRole("heading", { name: "Presence override" })).toBeNull(); + + const after = calls().slice(before); + const writes = after.filter((c) => c.method !== "GET"); + // D-005: saving sends ONLY the create request — never a delete of an + // instance or an override. The API's regenerate path keeps completed + // instances (tests/test_materialize.py:: + // test_regenerate_for_definition_rebuilds_horizon_and_preserves_completed). + expect(writes).toEqual([ + { + method: "POST", + path: OVERRIDES_PATH, + body: { + child_id: 1, + start_date: "2026-09-24", + end_date: "2026-09-26", + is_present: false, + note: "Trip with grandparents", + }, + }, + ]); + expect(after.some((c) => c.method === "DELETE")).toBe(false); + // The overrides and the presence schedules are refetched for the grid. + expect(after.some((c) => c.method === "GET" && c.path === OVERRIDES_PATH)).toBe(true); + expect(calls().filter((c) => c.path.endsWith("/presence-schedule")).length).toBeGreaterThan( + schedulesBefore, + ); + // The grid reflects the new override. + expect(document.querySelector('[data-date="2026-09-25"]')?.getAttribute("data-state")).toBe( + "override-away", + ); + }); + + it("omits an empty reason and saves a Home override", async () => { + await openEditor(); + fireEvent.click(screen.getByRole("button", { name: "Home" })); + await waitFor(() => expect(saveButton().disabled).toBe(false)); + fireEvent.click(saveButton()); + await screen.findByTestId("override-50"); + const post = calls().find((c) => c.method === "POST" && c.path === OVERRIDES_PATH); + expect(post?.body).toEqual({ + child_id: 1, + start_date: TODAY, + end_date: TODAY, + is_present: true, + }); + }); + + it("a 422 shows the API detail and keeps the form", async () => { + createResponse = () => jsonResponse({ detail: "overlaps an existing override" }, 422); + await openEditor(); + setDate("Through", "2026-09-25"); + fireEvent.change(screen.getByLabelText("Reason (optional)"), { target: { value: "Camp" } }); + await waitFor(() => expect(saveButton().disabled).toBe(false)); + + fireEvent.click(saveButton()); + const alert = await screen.findByTestId("override-error"); + expect(alert.textContent).toBe("overlaps an existing override"); + expect(screen.getByRole("heading", { name: "Presence override" })).toBeTruthy(); + expect((screen.getByLabelText("Through") as HTMLInputElement).value).toBe("2026-09-25"); + expect((screen.getByLabelText("Reason (optional)") as HTMLInputElement).value).toBe("Camp"); + expect(saveButton().disabled).toBe(false); + }); + + it("Cancel and Back close the editor without writing", async () => { + await openEditor(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await screen.findByTestId("pattern-1"); + fireEvent.click(screen.getByRole("button", { name: "Add" })); + fireEvent.click(await screen.findByRole("button", { name: "Back" })); + await screen.findByTestId("pattern-1"); + expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false); + }); + + it("Add opens the editor for the selected child", async () => { + render(); + await screen.findByTestId("pattern-1"); + fireEvent.click(screen.getByRole("button", { name: "Maeve" })); + fireEvent.click(screen.getByRole("button", { name: "Add" })); + const kids = within(await screen.findByRole("group", { name: "Child" })); + expect(kids.getByRole("button", { name: "Maeve" }).getAttribute("aria-pressed")).toBe("true"); + await waitFor(() => + expect(consequenceBodies()).toEqual([ + { child_id: 2, start_date: TODAY, end_date: TODAY, is_present: false }, + ]), + ); + }); + + it("the trash button deletes the override only after confirm", async () => { + render(); + const row = await screen.findByTestId("override-41"); + + fireEvent.click(within(row).getByRole("button", { name: "Delete override" })); + fireEvent.click(within(row).getByRole("button", { name: "Keep" })); + expect(calls().some((c) => c.method === "DELETE")).toBe(false); + + fireEvent.click(within(row).getByRole("button", { name: "Delete override" })); + const confirm = within(row).getByRole("group", { name: "Confirm delete" }); + fireEvent.click(within(confirm).getByRole("button", { name: "Delete" })); + await waitFor(() => expect(screen.queryByTestId("override-41")).toBeNull()); + expect(calls().filter((c) => c.method === "DELETE")).toEqual([ + { method: "DELETE", path: `${OVERRIDES_PATH}/41`, body: undefined }, + ]); + expect(screen.getByText("No overrides for Declan.")).toBeTruthy(); + }); +}); + +describe("OverrideEditor focus", () => { + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + overrides = [...INITIAL_OVERRIDES]; + consequence = () => jsonResponse({ removed: 4 }); + createResponse = (body) => { + const created = { id: 50, note: null, ...body } as PresenceOverride; + overrides = [...overrides, created]; + return jsonResponse(created, 201); + }; + fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init)); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + function dialog(): HTMLElement { + return screen.getByRole("dialog", { name: "Presence override" }); + } + + function addButton(): HTMLElement { + return screen.getByRole("button", { name: "Add" }); + } + + it("moves focus into the editor on open", async () => { + await openEditor(); + expect(document.activeElement).toBe(screen.getByRole("heading", { name: "Presence override" })); + expect(dialog().contains(document.activeElement)).toBe(true); + }); + + it("keeps Tab and Shift+Tab inside the editor", async () => { + await openEditor(); + await waitFor(() => expect(saveButton().disabled).toBe(false)); + const back = screen.getByRole("button", { name: "Back" }); + + saveButton().focus(); + fireEvent.keyDown(saveButton(), { key: "Tab" }); + expect(document.activeElement).toBe(back); + + fireEvent.keyDown(back, { key: "Tab", shiftKey: true }); + expect(document.activeElement).toBe(saveButton()); + + // From the focused heading (not in the tab order) Shift+Tab wraps to the end. + const title = screen.getByRole("heading", { name: "Presence override" }); + title.focus(); + fireEvent.keyDown(title, { key: "Tab", shiftKey: true }); + expect(document.activeElement).toBe(saveButton()); + }); + + it("makes the tab bar inert while open and restores it on close", async () => { + render(); + fireEvent.click(screen.getByRole("tab", { name: "Schedule" })); + await screen.findByTestId("pattern-1"); + fireEvent.click(addButton()); + await screen.findByRole("heading", { name: "Presence override" }); + + const tabbar = document.querySelector(".admin-tabbar") as HTMLElement; + expect(tabbar.hasAttribute("inert")).toBe(true); + expect(tabbar.getAttribute("aria-hidden")).toBe("true"); + expect(dialog().closest("[inert]")).toBeNull(); + + fireEvent.keyDown(dialog(), { key: "Escape" }); + await screen.findByTestId("pattern-1"); + expect(tabbar.hasAttribute("inert")).toBe(false); + expect(tabbar.hasAttribute("aria-hidden")).toBe(false); + }); + + it("Escape cancels without writing and returns focus to Add", async () => { + await openEditor(); + fireEvent.keyDown(document.activeElement as HTMLElement, { key: "Escape" }); + await screen.findByTestId("pattern-1"); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(document.activeElement).toBe(addButton()); + expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false); + }); + + it("Escape does nothing while saving; focus returns to Add after the save", async () => { + const gate = deferred(); + fetchMock.mockImplementation(async (url: string, init?: RequestInit) => { + const path = new URL(url, "http://x").pathname; + if (path === OVERRIDES_PATH && init?.method === "POST") await gate.promise; + return routeFetch(url, init); + }); + await openEditor(); + await waitFor(() => expect(saveButton().disabled).toBe(false)); + fireEvent.click(saveButton()); + expect(screen.getByRole("button", { name: "Saving…" })).toBeTruthy(); + + fireEvent.keyDown(dialog(), { key: "Escape" }); + expect(screen.getByRole("dialog")).toBeTruthy(); + + gate.resolve(new Response(null)); + await screen.findByTestId("override-50"); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(document.activeElement).toBe(addButton()); + }); + + it("Back and Cancel return focus to Add", async () => { + await openEditor(); + fireEvent.click(screen.getByRole("button", { name: "Back" })); + await screen.findByTestId("pattern-1"); + expect(document.activeElement).toBe(addButton()); + + fireEvent.click(addButton()); + fireEvent.click(await screen.findByRole("button", { name: "Cancel" })); + await screen.findByTestId("pattern-1"); + expect(document.activeElement).toBe(addButton()); + }); +}); + +describe("range validation", () => { + it("accepts only strict ISO calendar dates in order", () => { + expect(isIsoDate("2026-09-23")).toBe(true); + expect(isIsoDate("2026-02-30")).toBe(false); + expect(isIsoDate("2026-9-3")).toBe(false); + expect(isIsoDate("")).toBe(false); + expect(isValidRange("2026-09-23", "2026-09-23")).toBe(true); + expect(isValidRange("2026-09-23", "2026-09-22")).toBe(false); + expect(isValidRange("2026-09-23", "")).toBe(false); + }); +}); diff --git a/admin/src/schedule/OverrideEditor.tsx b/admin/src/schedule/OverrideEditor.tsx new file mode 100644 index 0000000..964c6ad --- /dev/null +++ b/admin/src/schedule/OverrideEditor.tsx @@ -0,0 +1,348 @@ +/** + * Presence override editor — the pushed screen of design/ADMIN-SPEC.md §6. + * + * The consequence warning is counted by the API (POST + * /presence-overrides/consequence) before anything is saved; Save stays off + * until that count is on screen. Saving only ever sends the create request: + * the API never removes an instance that already has a completion event (D-005). + */ +import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import { ApiForbiddenError } from "../api/client"; +import { ApiRequestError, type AdminChild } from "../api/definitions"; +import { + createPresenceOverride, + previewOverrideConsequence, + type PresenceOverride, + type PresenceOverrideBody, +} from "../api/presence"; +import { dayNumber, isoFromDayNumber } from "./cycle"; +import { dateRange } from "./format"; +import { AlertCircleGlyph, ChevronLeftGlyph } from "./glyphs"; +import "./OverrideEditor.css"; + +/** How long the form must sit still before the consequence is recounted. */ +export const CONSEQUENCE_DEBOUNCE_MS = 300; + +/** Strict "YYYY-MM-DD" naming a real calendar day (no 2026-02-30). */ +export function isIsoDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + return isoFromDayNumber(dayNumber(value)) === value; +} + +export function isValidRange(start: string, end: string): boolean { + return isIsoDate(start) && isIsoDate(end) && start <= end; +} + +type Consequence = + | { kind: "invalid" } + | { kind: "counting" } + | { kind: "ready"; removed: number } + | { kind: "error"; message: string }; + +function errorMessage(error: unknown, fallback: string): string { + if (error instanceof ApiForbiddenError) return error.message; + if (error instanceof ApiRequestError && error.detail) return error.detail; + return fallback; +} + +const FOCUSABLE = + 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'; + +/** + * Make everything outside `root` inert (siblings of each ancestor up to + * ), so the tab bar behind the pushed screen cannot take focus. + * Returns the undo. + */ +function inertOutside(root: HTMLElement): () => void { + const made: HTMLElement[] = []; + for (let node: HTMLElement = root; node.parentElement && node !== document.body; node = node.parentElement) { + for (const sibling of Array.from(node.parentElement.children)) { + if (sibling === node || !(sibling instanceof HTMLElement) || sibling.hasAttribute("inert")) continue; + sibling.setAttribute("inert", ""); + sibling.setAttribute("aria-hidden", "true"); + made.push(sibling); + } + } + return () => { + for (const el of made) { + el.removeAttribute("inert"); + el.removeAttribute("aria-hidden"); + } + }; +} + +function tasksPhrase(count: number): string { + return `${count} upcoming ${count === 1 ? "task" : "tasks"}`; +} + +export interface OverrideEditorProps { + /** Active children, in display order. */ + childOptions: AdminChild[]; + initialChildId: number; + /** "YYYY-MM-DD"; the date fields start here. */ + today: string; + onCancel: () => void; + onSaved: (override: PresenceOverride) => void; +} + +export default function OverrideEditor({ + childOptions, + initialChildId, + today, + onCancel, + onSaved, +}: OverrideEditorProps) { + const [childId, setChildId] = useState(initialChildId); + const [isPresent, setIsPresent] = useState(false); + const [startDate, setStartDate] = useState(today); + const [endDate, setEndDate] = useState(today); + const [note, setNote] = useState(""); + const [consequence, setConsequence] = useState({ kind: "counting" }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + // Every form change bumps the sequence; only the latest request may render. + const requestSeq = useRef(0); + const screenRef = useRef(null); + const titleRef = useRef(null); + + // Modal focus: move in on open and keep the background out of reach. The + // opener hands focus back to its Add control once this screen closes. + useEffect(() => { + titleRef.current?.focus(); + return screenRef.current ? inertOutside(screenRef.current) : undefined; + }, []); + + function onKeyDown(event: ReactKeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + if (!saving) onCancel(); + return; + } + if (event.key !== "Tab" || !screenRef.current) return; + const focusable = Array.from(screenRef.current.querySelectorAll(FOCUSABLE)); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const current = document.activeElement; + const outside = !focusable.includes(current as HTMLElement); + if (event.shiftKey && (current === first || outside)) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && (current === last || outside)) { + event.preventDefault(); + first.focus(); + } + } + + const child = childOptions.find((c) => c.id === childId) ?? childOptions[0]; + const rangeValid = isValidRange(startDate, endDate); + + useEffect(() => { + const seq = ++requestSeq.current; + if (!rangeValid) { + setConsequence({ kind: "invalid" }); + return; + } + if (isPresent) { + // Keeping a child home removes nothing by definition. + setConsequence({ kind: "ready", removed: 0 }); + return; + } + setConsequence({ kind: "counting" }); + const timer = setTimeout(() => { + previewOverrideConsequence({ + child_id: childId, + start_date: startDate, + end_date: endDate, + is_present: false, + }) + .then((removed) => { + if (seq === requestSeq.current) setConsequence({ kind: "ready", removed }); + }) + .catch((err: unknown) => { + if (seq !== requestSeq.current) return; + setConsequence({ + kind: "error", + message: errorMessage(err, "The upcoming tasks could not be counted. Try again."), + }); + }); + }, CONSEQUENCE_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [childId, startDate, endDate, isPresent, rangeValid]); + + const canSave = rangeValid && consequence.kind === "ready" && !saving; + + function save() { + if (!canSave) return; + const body: PresenceOverrideBody = { + child_id: childId, + start_date: startDate, + end_date: endDate, + is_present: isPresent, + }; + const reason = note.trim(); + if (reason) body.note = reason; + setSaving(true); + setError(null); + createPresenceOverride(body) + .then(onSaved) + .catch((err: unknown) => { + setError(errorMessage(err, "The override could not be saved. Check your connection and try again.")); + setSaving(false); + }); + } + + let headline: string; + let detail: string; + const range = rangeValid ? dateRange(startDate, endDate) : ""; + if (consequence.kind === "invalid") { + headline = "Choose a valid date range"; + detail = "Through must be on or after From."; + } else if (consequence.kind === "counting") { + headline = "Counting upcoming tasks…"; + detail = `${child.display_name} away ${range}. Completed days are untouched.`; + } else if (consequence.kind === "error") { + headline = "Upcoming tasks not counted"; + detail = consequence.message; + } else { + headline = `This removes ${tasksPhrase(consequence.removed)}`; + detail = isPresent + ? `${child.display_name} is home ${range}, so no tasks are removed. Completed days are untouched.` + : `${child.display_name}'s open tasks ${range} are removed while away. Completed days are untouched.`; + } + + return ( +
+
+ +
+

+ Presence override +

+

Beats the custody pattern for these dates

+
+
+ +
+
+
+ + Child + +
+ {childOptions.map((c) => ( + + ))} +
+
+ +
+ + Status for these dates + +
+ + +
+
+ +
+ + +
+ + +
+ +
+ + + +
+

+ {headline} +

+

{detail}

+
+
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+ + +
+
+
+ ); +} diff --git a/admin/src/schedule/ScheduleTab.css b/admin/src/schedule/ScheduleTab.css index 34aa999..60540b5 100644 --- a/admin/src/schedule/ScheduleTab.css +++ b/admin/src/schedule/ScheduleTab.css @@ -377,3 +377,40 @@ font-weight: 700; cursor: pointer; } + +/* Inline delete confirmation in an override row */ +.schedule-confirm { + flex: none; + display: flex; + align-items: center; + gap: 6px; +} + +.schedule-confirm-text { + font-size: 12px; + font-weight: 700; + color: var(--nq-a-ink-secondary); +} + +.schedule-confirm-keep, +.schedule-confirm-delete { + min-height: 32px; + padding: 0 10px; + border-radius: 6px; + font-family: inherit; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.schedule-confirm-keep { + border: 1px solid var(--nq-a-border); + background: var(--nq-a-surface); + color: var(--nq-a-ink); +} + +.schedule-confirm-delete { + border: 0; + background: var(--nq-a-danger); + color: #fff; +} diff --git a/admin/src/schedule/ScheduleTab.tsx b/admin/src/schedule/ScheduleTab.tsx index 1a76862..6bd5eb4 100644 --- a/admin/src/schedule/ScheduleTab.tsx +++ b/admin/src/schedule/ScheduleTab.tsx @@ -4,10 +4,11 @@ * Every day's cycle position comes from the anchor date (./cycle.ts), never * from ISO week numbers or week parity (D-004). */ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ApiForbiddenError } from "../api/client"; import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions"; import { + deletePresenceOverride, fetchPresenceOverrides, fetchPresenceSchedule, savePresenceSchedule, @@ -37,6 +38,7 @@ import { ChevronRightGlyph, Trash2Glyph, } from "./glyphs"; +import OverrideEditor from "./OverrideEditor"; import "./ScheduleTab.css"; /** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */ @@ -64,6 +66,12 @@ type LoadState = | { kind: "error"; message: string } | { kind: "ready"; data: Loaded }; +type DeleteState = + | { kind: "idle" } + | { kind: "confirming"; id: number } + | { kind: "deleting"; id: number } + | { kind: "error"; id: number; message: string }; + type SaveState = | { kind: "idle" } | { kind: "saving" } @@ -85,10 +93,19 @@ async function loadSchedule(): Promise { }; } -function saveErrorMessage(error: unknown): string { +function loadErrorMessage(error: unknown): string { + return error instanceof ApiForbiddenError + ? error.message + : "The schedule could not be loaded. Check your connection and try again."; +} + +function saveErrorMessage( + error: unknown, + fallback = "The pattern could not be saved. Check your connection and try again.", +): string { if (error instanceof ApiForbiddenError) return error.message; if (error instanceof ApiRequestError && error.detail) return error.detail; - return "The pattern could not be saved. Check your connection and try again."; + return fallback; } function ScreenHeader({ sub }: { sub: string }) { @@ -185,7 +202,18 @@ function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onTo ); } -function OverrideRow({ override, childName }: { override: PresenceOverride; childName: string }) { +interface OverrideRowProps { + override: PresenceOverride; + childName: string; + deletion: DeleteState; + onAskDelete: () => void; + onKeep: () => void; + onDelete: () => void; +} + +function OverrideRow({ override, childName, deletion, onAskDelete, onKeep, onDelete }: OverrideRowProps) { + const mine = + (deletion.kind === "confirming" || deletion.kind === "deleting") && deletion.id === override.id; return (
  • @@ -198,10 +226,36 @@ function OverrideRow({ override, childName }: { override: PresenceOverride; chil
    {override.note || "No reason given"}
    - {/* TODO(4ae0c879): the override editor wires delete; inert until then. */} - + {mine ? ( +
    + Delete override? + + +
    + ) : ( + + )}
  • ); } @@ -218,6 +272,23 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp const [month, setMonth] = useState(() => monthStart(today)); const [drafts, setDrafts] = useState>({}); const [save, setSave] = useState({ kind: "idle" }); + const [editorOpen, setEditorOpen] = useState(false); + const [deletion, setDeletion] = useState({ kind: "idle" }); + const addRef = useRef(null); + const editorWasOpen = useRef(false); + + // The editor replaces this screen, so Add is remounted when it closes; + // hand focus back to it then. + useEffect(() => { + if (editorWasOpen.current && !editorOpen) addRef.current?.focus(); + editorWasOpen.current = editorOpen; + }, [editorOpen]); + + /** Reload children, schedules and overrides in place (no loading flash). */ + const refresh = () => + loadSchedule() + .then((data) => setState({ kind: "ready", data })) + .catch((error: unknown) => setState({ kind: "error", message: loadErrorMessage(error) })); useEffect(() => { let cancelled = false; @@ -230,17 +301,30 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp }) .catch((error: unknown) => { if (cancelled) return; - const message = - error instanceof ApiForbiddenError - ? error.message - : "The schedule could not be loaded. Check your connection and try again."; - setState({ kind: "error", message }); + setState({ kind: "error", message: loadErrorMessage(error) }); }); return () => { cancelled = true; }; }, [attempt]); + if (editorOpen && state.kind === "ready" && state.data.children.length > 0) { + const { children } = state.data; + return ( + c.id === selectedId)?.id ?? children[0].id} + today={today} + onCancel={() => setEditorOpen(false)} + onSaved={(override) => { + setEditorOpen(false); + setSelectedId(override.child_id); + void refresh(); + }} + /> + ); + } + let sub = ""; let body; if (state.kind === "loading") { @@ -285,6 +369,22 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp const week = cycleWeekIndex(schedule.anchor_date, date, schedule.cycle_length_weeks); edit({ ...schedule, pattern: togglePatternDay(schedule.pattern, week, weekdayOf(date)) }); }; + const confirmDelete = (id: number) => { + setDeletion({ kind: "deleting", id }); + deletePresenceOverride(id) + .then(() => refresh()) + .then(() => setDeletion({ kind: "idle" })) + .catch((error: unknown) => + setDeletion({ + kind: "error", + id, + message: saveErrorMessage( + error, + "The override could not be deleted. Check your connection and try again.", + ), + }), + ); + }; const submit = () => { if (!draft) return; const childId = child.id; @@ -407,20 +507,37 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp

    Overrides

    - {/* TODO(4ae0c879): the override editor wires Add; inert until then. */} -
    {childOverrides.length > 0 ? (
      {childOverrides.map((override) => ( - + setDeletion({ kind: "confirming", id: override.id })} + onKeep={() => setDeletion({ kind: "idle" })} + onDelete={() => confirmDelete(override.id)} + /> ))}
    ) : (
    No overrides for {child.display_name}.
    )} + {deletion.kind === "error" ? ( +

    + {deletion.message} +

    + ) : null} ); } diff --git a/admin/src/schedule/glyphs.tsx b/admin/src/schedule/glyphs.tsx index d6163d4..7419b42 100644 --- a/admin/src/schedule/glyphs.tsx +++ b/admin/src/schedule/glyphs.tsx @@ -74,3 +74,12 @@ export function Trash2Glyph({ size = 16 }: { size?: number }) { ); } + +export function AlertCircleGlyph({ size = 18 }: { size?: number }) { + return ( + + + + + ); +}