From c1d9e99f3666ede408b1a4c2a5d2e829ce470a60 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 23 Sep 2026 22:56:42 -0400 Subject: [PATCH 1/2] Add presence override editor with live consequence count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the Schedule tab's Overrides card: Add opens the ADMIN-SPEC §6 pushed editor for the selected child, and the trash button confirms before calling DELETE. The editor counts the upcoming tasks an Away override removes from POST /presence-overrides/consequence (debounced, stale responses dropped, never for an invalid range) and keeps Save off until that count is shown. Saving sends only the create request, then refetches the overrides and schedules; a 422 keeps the form and shows the API detail. Task 4ae0c879-2bc3-4aee-a084-5e48b41c2085 Co-Authored-By: Claude Opus 5.5 --- admin/src/api/presence.ts | 44 +++ admin/src/index.css | 1 + admin/src/schedule/OverrideEditor.css | 220 ++++++++++++ admin/src/schedule/OverrideEditor.test.tsx | 396 +++++++++++++++++++++ admin/src/schedule/OverrideEditor.tsx | 284 +++++++++++++++ admin/src/schedule/ScheduleTab.css | 37 ++ admin/src/schedule/ScheduleTab.tsx | 133 ++++++- admin/src/schedule/glyphs.tsx | 9 + 8 files changed, 1109 insertions(+), 15 deletions(-) create mode 100644 admin/src/schedule/OverrideEditor.css create mode 100644 admin/src/schedule/OverrideEditor.test.tsx create mode 100644 admin/src/schedule/OverrideEditor.tsx 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..0dc4176 --- /dev/null +++ b/admin/src/schedule/OverrideEditor.test.tsx @@ -0,0 +1,396 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +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 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 }, + ]); + const warning = screen.getByTestId("override-consequence"); + expect(warning.textContent).toContain("Declan"); + expect(warning.textContent).toContain("Sep 23"); + expect(warning.textContent).toContain("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(warning.textContent).toContain("Sep 23 – 27"); + + 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(warning.textContent).toContain("Maeve"); + + // 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(warning.textContent).toContain("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(screen.getByTestId("override-consequence").textContent).toContain( + "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("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("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..44a1cc5 --- /dev/null +++ b/admin/src/schedule/OverrideEditor.tsx @@ -0,0 +1,284 @@ +/** + * 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 } 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; +} + +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 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..f367d63 100644 --- a/admin/src/schedule/ScheduleTab.tsx +++ b/admin/src/schedule/ScheduleTab.tsx @@ -8,6 +8,7 @@ import { useEffect, 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,14 @@ 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" }); + + /** 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 +292,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 +360,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 +498,32 @@ 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 ( + + + + + ); +} -- 2.55.0 From 8528200e32eaa613b2b77ff6161d013788fc5dde Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 23 Sep 2026 23:11:18 -0400 Subject: [PATCH 2/2] Trap focus in the presence override editor and assert exact warning copy Move focus to the editor heading on open, keep Tab/Shift+Tab inside, make the background (tab bar included) inert and aria-hidden while open, cancel on Escape unless saving, and return focus to Add on close. Tests assert the full consequence warning text and that a malformed From date sends no consequence request. Co-Authored-By: Claude Opus 5.5 --- admin/src/schedule/OverrideEditor.test.tsx | 155 +++++++++++++++++++-- admin/src/schedule/OverrideEditor.tsx | 70 +++++++++- admin/src/schedule/ScheduleTab.tsx | 18 ++- 3 files changed, 229 insertions(+), 14 deletions(-) diff --git a/admin/src/schedule/OverrideEditor.test.tsx b/admin/src/schedule/OverrideEditor.test.tsx index 0dc4176..0a2c4bb 100644 --- a/admin/src/schedule/OverrideEditor.test.tsx +++ b/admin/src/schedule/OverrideEditor.test.tsx @@ -1,5 +1,6 @@ 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"; @@ -93,6 +94,10 @@ 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; } @@ -162,10 +167,9 @@ describe("OverrideEditor", () => { expect(consequenceBodies()).toEqual([ { child_id: 1, start_date: TODAY, end_date: TODAY, is_present: false }, ]); - const warning = screen.getByTestId("override-consequence"); - expect(warning.textContent).toContain("Declan"); - expect(warning.textContent).toContain("Sep 23"); - expect(warning.textContent).toContain("Completed days are untouched."); + 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 }); @@ -177,7 +181,9 @@ describe("OverrideEditor", () => { end_date: "2026-09-27", is_present: false, }); - expect(warning.textContent).toContain("Sep 23 – 27"); + 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" })); @@ -188,12 +194,16 @@ describe("OverrideEditor", () => { end_date: "2026-09-27", is_present: false, }); - expect(warning.textContent).toContain("Maeve"); + 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(warning.textContent).toContain("Completed days are untouched."); + expect(warningBody()).toBe( + "Maeve is home Sep 23 – 27, so no tasks are removed. Completed days are untouched.", + ); await pastDebounce(); expect(consequenceBodies()).toHaveLength(3); }); @@ -208,8 +218,8 @@ describe("OverrideEditor", () => { await openEditor(); setDate("Through", "2026-09-25"); await waitFor(() => expect(headline()).toBe("This removes 3 upcoming tasks")); - expect(screen.getByTestId("override-consequence").textContent).toContain( - "Completed days are untouched.", + expect(warningBody()).toBe( + "Declan's open tasks Sep 23 – 25 are removed while away. Completed days are untouched.", ); }); @@ -261,6 +271,19 @@ describe("OverrideEditor", () => { 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"); @@ -383,6 +406,120 @@ describe("OverrideEditor", () => { }); }); +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); diff --git a/admin/src/schedule/OverrideEditor.tsx b/admin/src/schedule/OverrideEditor.tsx index 44a1cc5..964c6ad 100644 --- a/admin/src/schedule/OverrideEditor.tsx +++ b/admin/src/schedule/OverrideEditor.tsx @@ -6,7 +6,7 @@ * 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 } from "react"; +import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { ApiForbiddenError } from "../api/client"; import { ApiRequestError, type AdminChild } from "../api/definitions"; import { @@ -45,6 +45,32 @@ function errorMessage(error: unknown, fallback: string): string { 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"}`; } @@ -76,6 +102,37 @@ export default function OverrideEditor({ 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); @@ -155,13 +212,20 @@ export default function OverrideEditor({ } return ( -
    +
    -

    +

    Presence override

    Beats the custody pattern for these dates

    diff --git a/admin/src/schedule/ScheduleTab.tsx b/admin/src/schedule/ScheduleTab.tsx index f367d63..6bd5eb4 100644 --- a/admin/src/schedule/ScheduleTab.tsx +++ b/admin/src/schedule/ScheduleTab.tsx @@ -4,7 +4,7 @@ * 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 { @@ -274,6 +274,15 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp 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 = () => @@ -498,7 +507,12 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp

    Overrides

    -
    -- 2.55.0