From c1d9e99f3666ede408b1a4c2a5d2e829ce470a60 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 23 Sep 2026 22:56:42 -0400 Subject: [PATCH] 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 ( + + + + + ); +}