From 49d8922ed8c22b4de37f7e946f825b837c332dbe Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 24 Sep 2026 14:40:40 -0400 Subject: [PATCH] Add Settings screen to manage children from the Admin PWA The Today header's Settings button now opens a pushed Settings screen whose Children section lists each child (name, colour swatch, active pill, order) and lets an admin add, edit, deactivate/reactivate and reorder them. Reorder sends the complete ordered id list. Every write refetches the list; 403/422 errors are shown in place and the in-flight controls are disabled. Focus moves in, is trapped, restores on close, and the background is inert, matching the presence override editor. Other settings (horizon days, day rollover, notifications) remain a separate later task. Maestro task: 951bd4e0-787d-4f8d-baf5-7d37ea455c7b Co-Authored-By: Claude Opus 5.5 --- admin/src/api/children.ts | 50 ++ admin/src/schedule/OverrideEditor.tsx | 4 +- admin/src/settings/SettingsScreen.css | 331 ++++++++++++ admin/src/settings/SettingsScreen.test.tsx | 318 ++++++++++++ admin/src/settings/SettingsScreen.tsx | 560 +++++++++++++++++++++ admin/src/settings/glyphs.tsx | 56 +++ admin/src/today/TodayTab.tsx | 26 +- 7 files changed, 1339 insertions(+), 6 deletions(-) create mode 100644 admin/src/api/children.ts create mode 100644 admin/src/settings/SettingsScreen.css create mode 100644 admin/src/settings/SettingsScreen.test.tsx create mode 100644 admin/src/settings/SettingsScreen.tsx create mode 100644 admin/src/settings/glyphs.tsx diff --git a/admin/src/api/children.ts b/admin/src/api/children.ts new file mode 100644 index 0000000..90333ff --- /dev/null +++ b/admin/src/api/children.ts @@ -0,0 +1,50 @@ +/** + * Typed client for the admin child-profile write routes + * (/api/v1/admin/children). The list read is `fetchChildren` in ./definitions. + */ +import { apiFetch } from "./client"; +import { CHILDREN_PATH, readJson, type AdminChild } from "./definitions"; + +export interface ChildCreateBody { + display_name: string; + colour?: string; + avatar_ref?: string; + sort_order?: number; +} + +/** + * Omitted fields stay unchanged. The API rejects an explicit null, so a set + * colour or avatar cannot be cleared through this route. + */ +export type ChildEditBody = Partial; + +export const CHILDREN_REORDER_PATH = `${CHILDREN_PATH}/reorder`; + +function sendJson(path: string, method: "POST" | "PATCH", body: unknown): Promise { + return apiFetch(path, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +export async function createChild(body: ChildCreateBody): Promise { + return readJson(await sendJson(CHILDREN_PATH, "POST", body)); +} + +export async function updateChild(id: number, body: ChildEditBody): Promise { + return readJson(await sendJson(`${CHILDREN_PATH}/${id}`, "PATCH", body)); +} + +export async function setChildActive(id: number, isActive: boolean): Promise { + return readJson( + await sendJson(`${CHILDREN_PATH}/${id}/active`, "PATCH", { is_active: isActive }), + ); +} + +/** `orderedIds` must be every child id in the household, in the new order. */ +export async function reorderChildren(orderedIds: number[]): Promise { + await readJson<{ status: string }>( + await sendJson(CHILDREN_REORDER_PATH, "POST", { ordered_ids: orderedIds }), + ); +} diff --git a/admin/src/schedule/OverrideEditor.tsx b/admin/src/schedule/OverrideEditor.tsx index 964c6ad..3a6ca4a 100644 --- a/admin/src/schedule/OverrideEditor.tsx +++ b/admin/src/schedule/OverrideEditor.tsx @@ -45,7 +45,7 @@ function errorMessage(error: unknown, fallback: string): string { return fallback; } -const FOCUSABLE = +export const FOCUSABLE = 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'; /** @@ -53,7 +53,7 @@ const FOCUSABLE = * ), so the tab bar behind the pushed screen cannot take focus. * Returns the undo. */ -function inertOutside(root: HTMLElement): () => void { +export 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)) { diff --git a/admin/src/settings/SettingsScreen.css b/admin/src/settings/SettingsScreen.css new file mode 100644 index 0000000..d751ce9 --- /dev/null +++ b/admin/src/settings/SettingsScreen.css @@ -0,0 +1,331 @@ +/* Settings — pushed screen over the Today tab (and the tab bar), like the + presence override editor. Compact density list, roomy form card. */ + +.settings-screen { + position: fixed; + inset: 0; + z-index: 30; + overflow-y: auto; + background: var(--nq-a-page); +} + +.settings-header { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 16px; + background: var(--nq-a-surface); + border-bottom: 1px solid var(--nq-a-border); +} + +.settings-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; +} + +.settings-title { + margin: 0; + font-size: var(--nq-a-size-screen); + font-weight: 800; + letter-spacing: -0.02em; +} + +.settings-sub { + margin: 2px 0 0; + font-size: 12px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */ +.settings-scroll { + display: flex; + flex-direction: column; + gap: var(--nq-a-density-gap); + width: 100%; + max-width: 640px; + margin: 0 auto; + padding: 14px 16px; +} + +.settings-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.settings-section-label { + margin: 0; + 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); +} + +.settings-add { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 36px; + padding: 0 12px; + border: 0; + border-radius: var(--nq-a-radius-pill); + background: var(--nq-brand-gradient); + color: #fff; + font-family: inherit; + font-size: 12.5px; + font-weight: 700; + cursor: pointer; +} + +.settings-card { + background: var(--nq-a-surface); + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); +} + +.settings-list { + margin: 0; + padding: 0; + list-style: none; +} + +.settings-list > li + li { + border-top: 1px solid var(--nq-a-divider); +} + +.settings-child { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: var(--nq-a-density-row-pad); +} + +.settings-child--inactive .settings-child-name { + color: var(--nq-a-ink-tertiary); +} + +.settings-swatch { + flex: none; + width: 22px; + height: 22px; + border-radius: var(--nq-a-radius-pill); + border: 1px solid rgba(9, 9, 11, 0.12); +} + +.settings-child-text { + flex: 1; + min-width: 0; +} + +.settings-child-name { + font-size: var(--nq-a-size-row); + font-weight: 700; +} + +.settings-child-meta { + margin-top: 1px; + font-size: var(--nq-a-size-meta); + font-weight: 600; + color: var(--nq-a-ink-tertiary); +} + +.settings-pill { + flex: none; + padding: 2px 8px; + border-radius: var(--nq-a-radius-pill); + background: var(--nq-a-divider); + color: var(--nq-a-ink-secondary); + font-size: var(--nq-a-size-meta); + font-weight: 700; +} + +.settings-pill--active { + background: #dcfce7; + color: var(--nq-a-success); +} + +.settings-child-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; + width: 100%; +} + +.settings-icon-button, +.settings-action { + min-height: 36px; + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-surface); + color: var(--nq-a-ink); + font-family: inherit; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.settings-icon-button { + display: grid; + place-items: center; + width: 36px; + padding: 0; +} + +.settings-action { + padding: 0 12px; +} + +.settings-icon-button:disabled, +.settings-action:disabled, +.settings-add:disabled, +.settings-back:disabled, +.settings-cancel:disabled, +.settings-save:disabled { + opacity: 0.45; + cursor: default; +} + +.settings-empty, +.settings-status { + padding: 16px; + font-size: 13px; + font-weight: 600; + color: var(--nq-a-ink-secondary); + text-align: center; +} + +.settings-status--error { + color: var(--nq-a-danger-strong); +} + +.settings-status p { + margin: 0 0 10px; +} + +.settings-action-error { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + 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; +} + +/* Add / edit form: roomy density, 18px padding, 16px gaps. */ +.settings-form { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px; +} + +.settings-form-title { + margin: 0; + font-size: 17px; + font-weight: 800; +} + +.settings-field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.settings-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); +} + +.settings-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; +} + +.settings-swatches { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.settings-swatch-option { + width: 32px; + height: 32px; + padding: 0; + border: 2px solid transparent; + border-radius: var(--nq-a-radius-pill); + cursor: pointer; +} + +.settings-swatch-option[aria-pressed="true"] { + border-color: var(--nq-a-ink); + box-shadow: inset 0 0 0 2px var(--nq-a-surface); +} + +.settings-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; +} + +.settings-form-footer { + display: flex; + gap: 10px; +} + +.settings-cancel, +.settings-save { + padding: 15px 12px; + border-radius: 12px; + font-family: inherit; + font-size: 14px; + font-weight: 700; + cursor: pointer; +} + +.settings-cancel { + flex: 1; + border: 1px solid var(--nq-a-border); + background: var(--nq-a-surface); + color: var(--nq-a-ink); +} + +.settings-save { + flex: 2; + border: 0; + background: var(--nq-brand-gradient); + color: #fff; +} diff --git a/admin/src/settings/SettingsScreen.test.tsx b/admin/src/settings/SettingsScreen.test.tsx new file mode 100644 index 0000000..fdd9916 --- /dev/null +++ b/admin/src/settings/SettingsScreen.test.tsx @@ -0,0 +1,318 @@ +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 SettingsScreen, { SCROLL_BOTTOM_PADDING } from "./SettingsScreen"; +import type { AdminChild } from "../api/definitions"; +import { storeTokens } from "../auth/oidc"; + +const CHILDREN_PATH = "/api/v1/admin/children"; +const REORDER_PATH = `${CHILDREN_PATH}/reorder`; + +function child( + id: number, + display_name: string, + sort_order: number, + extra: Partial = {}, +): AdminChild { + return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active: true, ...extra }; +} + +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 children: AdminChild[]; +/** Overrides the next write's response (the store is left untouched). */ +let writeResponse: (() => Response) | null; +let fetchMock: ReturnType; + +function calls(): Call[] { + return fetchMock.mock.calls.map((args: unknown[]) => { + const [url, init] = args as [string, RequestInit | undefined]; + return { + method: init?.method ?? "GET", + path: new URL(String(url), "http://x").pathname, + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }; + }); +} + +function writes(): Call[] { + return calls().filter((c) => c.method !== "GET"); +} + +function childGets(): number { + return calls().filter((c) => c.method === "GET" && c.path === CHILDREN_PATH).length; +} + +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 (method !== "GET" && writeResponse) { + const respond = writeResponse; + writeResponse = null; + return respond(); + } + if (path === CHILDREN_PATH && method === "GET") return jsonResponse({ children }); + if (path === CHILDREN_PATH && method === "POST") { + const created = child(9, body.display_name, body.sort_order ?? 0, { + colour: body.colour ?? null, + avatar_ref: body.avatar_ref ?? null, + }); + children = [...children, created]; + return jsonResponse(created, 201); + } + if (path === REORDER_PATH && method === "POST") { + const ids = body.ordered_ids as number[]; + children = children.map((c) => ({ ...c, sort_order: ids.indexOf(c.id) + 1 })); + return jsonResponse({ status: "ok" }); + } + const activeMatch = /\/children\/(\d+)\/active$/.exec(path); + if (activeMatch && method === "PATCH") { + const id = Number(activeMatch[1]); + children = children.map((c) => (c.id === id ? { ...c, is_active: body.is_active } : c)); + return jsonResponse(children.find((c) => c.id === id)); + } + const editMatch = /\/children\/(\d+)$/.exec(path); + if (editMatch && method === "PATCH") { + const id = Number(editMatch[1]); + children = children.map((c) => (c.id === id ? { ...c, ...body } : c)); + return jsonResponse(children.find((c) => c.id === id)); + } + if (path.endsWith("/admin/snapshot")) { + return jsonResponse({ today_iso: "2026-09-23", cycle_day: 5, children: [] }); + } + return jsonResponse({ detail: "unexpected" }, 500); +} + +function row(id: number) { + return within(screen.getByTestId(`settings-child-${id}`)); +} + +function listNames(): string[] { + return within(screen.getByRole("list", { name: "Children" })) + .getAllByRole("listitem") + .map((li) => li.querySelector(".settings-child-name")?.textContent ?? ""); +} + +function field(form: HTMLElement, label: string): HTMLInputElement { + return within(form).getByLabelText(label) as HTMLInputElement; +} + +async function renderScreen(onClose = vi.fn()) { + render(); + await screen.findByTestId("settings-child-1"); + return onClose; +} + +describe("SettingsScreen — children", () => { + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + children = [ + child(2, "Maeve", 2, { colour: "#16a34a" }), + child(1, "Declan", 1, { colour: "#0ea5e9", avatar_ref: "fox" }), + child(3, "Rory", 3, { is_active: false }), + ]; + writeResponse = null; + fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init)); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("lists each child with name, colour swatch, active state and order", async () => { + await renderScreen(); + expect(listNames()).toEqual(["Declan", "Maeve", "Rory"]); + expect(row(1).getByText("Order 1 · #0ea5e9")).toBeTruthy(); + expect(screen.getByTestId("settings-swatch-1").style.background).toBe("rgb(14, 165, 233)"); + expect(screen.getByTestId("settings-active-1").textContent).toBe("Active"); + expect(row(3).getByText("Order 3 · no colour")).toBeTruthy(); + expect(screen.getByTestId("settings-swatch-3").style.background).toBe("var(--nq-a-border)"); + expect(screen.getByTestId("settings-active-3").textContent).toBe("Inactive"); + expect(screen.getByTestId("settings-scroll").style.paddingBottom).toBe(SCROLL_BOTTOM_PADDING); + }); + + it("Add posts exactly the entered values, then shows the new child", async () => { + await renderScreen(); + fireEvent.click(screen.getByRole("button", { name: "Add child" })); + const form = screen.getByRole("form", { name: "Add child" }); + expect(field(form, "Order").value).toBe("4"); + fireEvent.change(field(form, "Display name"), { target: { value: " Nora " } }); + fireEvent.click(within(form).getByRole("button", { name: "Colour #7c3aed" })); + fireEvent.change(field(form, "Avatar ref (optional)"), { target: { value: "owl" } }); + fireEvent.change(field(form, "Order"), { target: { value: "5" } }); + const gets = childGets(); + fireEvent.click(within(form).getByRole("button", { name: "Add child" })); + + await screen.findByTestId("settings-child-9"); + expect(writes()).toEqual([ + { + method: "POST", + path: CHILDREN_PATH, + body: { display_name: "Nora", colour: "#7c3aed", avatar_ref: "owl", sort_order: 5 }, + }, + ]); + expect(childGets()).toBe(gets + 1); + expect(listNames()).toEqual(["Declan", "Maeve", "Rory", "Nora"]); + expect(screen.queryByRole("form", { name: "Add child" })).toBeNull(); + }); + + it("Add omits optional fields left blank and rejects a missing name", async () => { + await renderScreen(); + fireEvent.click(screen.getByRole("button", { name: "Add child" })); + const form = screen.getByRole("form", { name: "Add child" }); + fireEvent.change(field(form, "Order"), { target: { value: "" } }); + fireEvent.click(within(form).getByRole("button", { name: "Add child" })); + expect(screen.getByTestId("child-form-error").textContent).toBe("Enter a display name."); + expect(writes()).toEqual([]); + + fireEvent.change(field(form, "Display name"), { target: { value: "Nora" } }); + fireEvent.click(within(form).getByRole("button", { name: "Add child" })); + await screen.findByTestId("settings-child-9"); + expect(writes()[0].body).toEqual({ display_name: "Nora" }); + }); + + it("Edit patches only the changed fields", async () => { + await renderScreen(); + fireEvent.click(row(1).getByRole("button", { name: "Edit Declan" })); + const form = screen.getByRole("form", { name: "Edit Declan" }); + expect(field(form, "Display name").value).toBe("Declan"); + expect(field(form, "Colour").value).toBe("#0ea5e9"); + fireEvent.change(field(form, "Display name"), { target: { value: "Dec" } }); + fireEvent.change(field(form, "Colour"), { target: { value: "#FF8800" } }); + fireEvent.click(within(form).getByRole("button", { name: "Save child" })); + + await waitFor(() => expect(row(1).getByText("Dec")).toBeTruthy()); + expect(writes()).toEqual([ + { method: "PATCH", path: `${CHILDREN_PATH}/1`, body: { display_name: "Dec", colour: "#FF8800" } }, + ]); + expect(row(1).getByText("Order 1 · #FF8800")).toBeTruthy(); + }); + + it("Edit refuses to clear a set colour instead of sending null", async () => { + await renderScreen(); + fireEvent.click(row(1).getByRole("button", { name: "Edit Declan" })); + const form = screen.getByRole("form", { name: "Edit Declan" }); + fireEvent.change(field(form, "Colour"), { target: { value: "" } }); + fireEvent.click(within(form).getByRole("button", { name: "Save child" })); + expect(screen.getByTestId("child-form-error").textContent).toMatch(/cannot be removed/); + expect(writes()).toEqual([]); + }); + + it("the active toggle patches /children/{id}/active with the new value", async () => { + await renderScreen(); + fireEvent.click(row(1).getByRole("button", { name: "Deactivate Declan" })); + await waitFor(() => expect(screen.getByTestId("settings-active-1").textContent).toBe("Inactive")); + fireEvent.click(row(3).getByRole("button", { name: "Reactivate Rory" })); + await waitFor(() => expect(screen.getByTestId("settings-active-3").textContent).toBe("Active")); + expect(writes()).toEqual([ + { method: "PATCH", path: `${CHILDREN_PATH}/1/active`, body: { is_active: false } }, + { method: "PATCH", path: `${CHILDREN_PATH}/3/active`, body: { is_active: true } }, + ]); + }); + + it("reorder posts the complete ordered id list, then shows the new order", async () => { + await renderScreen(); + expect((row(1).getByRole("button", { name: "Move Declan up" }) as HTMLButtonElement).disabled).toBe(true); + expect((row(3).getByRole("button", { name: "Move Rory down" }) as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(row(3).getByRole("button", { name: "Move Rory up" })); + await waitFor(() => expect(listNames()).toEqual(["Declan", "Rory", "Maeve"])); + expect(writes()).toEqual([{ method: "POST", path: REORDER_PATH, body: { ordered_ids: [1, 3, 2] } }]); + }); + + it("disables the write controls while a request is in flight (no double submit)", async () => { + await renderScreen(); + let release!: () => void; + writeResponse = () => jsonResponse({ status: "ok" }); + const gate = new Promise((r) => { + release = r; + }); + fetchMock.mockImplementationOnce(async (url: string, init?: RequestInit) => { + await gate; + return routeFetch(url, init); + }); + const up = row(2).getByRole("button", { name: "Move Maeve up" }) as HTMLButtonElement; + fireEvent.click(up); + fireEvent.click(up); + await waitFor(() => expect(up.disabled).toBe(true)); + expect((row(1).getByRole("button", { name: "Deactivate Declan" }) as HTMLButtonElement).disabled).toBe(true); + expect(writes()).toHaveLength(1); + release(); + await waitFor(() => expect((row(1).getByRole("button", { name: "Edit Declan" }) as HTMLButtonElement).disabled).toBe(false)); + expect(writes()).toHaveLength(1); + }); + + it("a 422 shows the API detail and keeps the form and the screen open", async () => { + const onClose = await renderScreen(); + fireEvent.click(screen.getByRole("button", { name: "Add child" })); + const form = screen.getByRole("form", { name: "Add child" }); + fireEvent.change(field(form, "Display name"), { target: { value: "Nora" } }); + writeResponse = () => jsonResponse({ detail: "display_name is required" }, 422); + fireEvent.click(within(form).getByRole("button", { name: "Add child" })); + + const error = await screen.findByTestId("child-form-error"); + expect(error.textContent).toBe("The child could not be added: display_name is required"); + expect(screen.getByRole("form", { name: "Add child" })).toBeTruthy(); + expect(field(form, "Display name").value).toBe("Nora"); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog", { name: "Settings" })).toBeTruthy(); + }); + + it("a 403 on a row action shows the refusal message and keeps the screen open", async () => { + const onClose = await renderScreen(); + writeResponse = () => jsonResponse({ detail: "forbidden" }, 403); + fireEvent.click(row(1).getByRole("button", { name: "Deactivate Declan" })); + const error = await screen.findByTestId("settings-action-error"); + expect(error.textContent).toContain("Your account is not in the nestquest-admins group."); + expect(screen.getByTestId("settings-active-1").textContent).toBe("Active"); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("a 403 on load shows the refusal message", async () => { + fetchMock.mockImplementation(async () => jsonResponse({ detail: "forbidden" }, 403)); + render(); + const error = await screen.findByTestId("settings-error"); + expect(error.textContent).toContain("Your account is not in the nestquest-admins group."); + }); + + it("Escape and Back close the screen", async () => { + const onClose = await renderScreen(); + fireEvent.keyDown(screen.getByRole("dialog", { name: "Settings" }), { key: "Escape" }); + expect(onClose).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "Back" })); + expect(onClose).toHaveBeenCalledTimes(2); + }); + + it("the Today Settings button opens the screen; closing restores focus and the background", async () => { + render(); + const settings = await screen.findByRole("button", { name: "Settings" }); + settings.focus(); + fireEvent.click(settings); + + const dialog = await screen.findByRole("dialog", { name: "Settings" }); + await screen.findByTestId("settings-child-1"); + expect(document.activeElement).toBe(screen.getByRole("heading", { name: "Settings" })); + const tabbar = document.querySelector("nav.admin-tabbar") as HTMLElement; + expect(tabbar.hasAttribute("inert")).toBe(true); + expect(dialog.closest("[inert]")).toBeNull(); + + fireEvent.keyDown(dialog, { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("dialog", { name: "Settings" })).toBeNull()); + expect(tabbar.hasAttribute("inert")).toBe(false); + expect(document.activeElement).toBe(screen.getByRole("button", { name: "Settings" })); + }); +}); diff --git a/admin/src/settings/SettingsScreen.tsx b/admin/src/settings/SettingsScreen.tsx new file mode 100644 index 0000000..66c1602 --- /dev/null +++ b/admin/src/settings/SettingsScreen.tsx @@ -0,0 +1,560 @@ +/** + * Settings — a pushed screen opened from the Today header's Settings button. + * + * Its one section so far is Children: list, add, edit, deactivate/reactivate + * and reorder the household's child profiles. Every write refetches the list. + * + * TODO: the other household settings (horizon days, day rollover time, + * notification config) are a separate later task. + */ +import { + useEffect, + useRef, + useState, + type FormEvent, + type KeyboardEvent as ReactKeyboardEvent, +} from "react"; +import { ApiForbiddenError } from "../api/client"; +import { + createChild, + reorderChildren, + setChildActive, + updateChild, + type ChildCreateBody, + type ChildEditBody, +} from "../api/children"; +import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions"; +import { FOCUSABLE, inertOutside } from "../schedule/OverrideEditor"; +import { ArrowDownGlyph, ArrowUpGlyph, ChevronLeftGlyph, PlusGlyph } from "./glyphs"; +import "./SettingsScreen.css"; + +/** The swatch picker's presets; any other "#RRGGBB" can be typed. */ +export const COLOUR_PRESETS = ["#e11d48", "#f97316", "#eab308", "#16a34a", "#0ea5e9", "#7c3aed"]; + +/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */ +export const SCROLL_BOTTOM_PADDING = "92px"; + +const HEX_COLOUR = /^#[0-9a-fA-F]{6}$/; + +type LoadState = + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "ready"; children: AdminChild[] }; + +type FormTarget = null | "new" | AdminChild; + +interface Draft { + name: string; + colour: string; + avatar: string; + order: string; +} + +/** Display order: sort_order, ties broken by id (the API's own order). */ +function sortChildren(children: AdminChild[]): AdminChild[] { + return [...children].sort((a, b) => a.sort_order - b.sort_order || a.id - b.id); +} + +function errorMessage(error: unknown, action: string): string { + if (error instanceof ApiForbiddenError) return error.message; + if (error instanceof ApiRequestError && error.detail) return `${action}: ${error.detail}`; + return `${action}. Check your connection and try again.`; +} + +function draftFor(target: "new" | AdminChild, siblings: AdminChild[]): Draft { + if (target === "new") { + const next = siblings.reduce((max, c) => Math.max(max, c.sort_order), 0) + 1; + return { name: "", colour: "", avatar: "", order: String(next) }; + } + return { + name: target.display_name, + colour: target.colour ?? "", + avatar: target.avatar_ref ?? "", + order: String(target.sort_order), + }; +} + +/** The request body for the draft, or a validation message. */ +function buildBody( + draft: Draft, + target: "new" | AdminChild, +): ChildCreateBody | ChildEditBody | string { + const name = draft.name.trim(); + const colour = draft.colour.trim(); + const avatar = draft.avatar.trim(); + const order = draft.order.trim(); + if (!name) return "Enter a display name."; + if (colour && !HEX_COLOUR.test(colour)) return "Colour must be a hex value like #0ea5e9."; + if (order && !/^-?\d+$/.test(order)) return "Order must be a whole number."; + + if (target === "new") { + const body: ChildCreateBody = { display_name: name }; + if (colour) body.colour = colour; + if (avatar) body.avatar_ref = avatar; + if (order) body.sort_order = Number(order); + return body; + } + + // The API rejects null, so a set colour, avatar or order cannot be cleared. + if (!colour && target.colour !== null) return "A colour, once set, cannot be removed. Pick another."; + if (!avatar && target.avatar_ref !== null) return "An avatar ref, once set, cannot be removed."; + if (!order) return "Enter an order."; + const body: ChildEditBody = {}; + if (name !== target.display_name) body.display_name = name; + if (colour && colour !== target.colour) body.colour = colour; + if (avatar && avatar !== target.avatar_ref) body.avatar_ref = avatar; + if (Number(order) !== target.sort_order) body.sort_order = Number(order); + if (Object.keys(body).length === 0) return "No changes to save."; + return body; +} + +function ChildForm({ + target, + siblings, + saving, + error, + onCancel, + onSubmit, +}: { + target: "new" | AdminChild; + /** The current list; a new child's order defaults to after the last. */ + siblings: AdminChild[]; + saving: boolean; + error: string | null; + onCancel: () => void; + onSubmit: (body: ChildCreateBody | ChildEditBody | string) => void; +}) { + const [draft, setDraft] = useState(() => draftFor(target, siblings)); + const nameRef = useRef(null); + const update = (patch: Partial) => setDraft((d) => ({ ...d, ...patch })); + const isNew = target === "new"; + const title = isNew ? "Add child" : `Edit ${target.display_name}`; + + useEffect(() => { + nameRef.current?.focus(); + }, []); + + function submit(event: FormEvent) { + event.preventDefault(); + onSubmit(buildBody(draft, target)); + } + + return ( +
+

{title}

+ +
+ +
+ {COLOUR_PRESETS.map((hex) => ( +
+
+ + + {error ? ( +

+ {error} +

+ ) : null} +
+ + +
+
+ ); +} + +function ChildRow({ + child, + index, + count, + busy, + onEdit, + onMove, + onToggleActive, +}: { + child: AdminChild; + index: number; + count: number; + /** The in-flight action key, or null. Every write control waits on it. */ + busy: string | null; + onEdit: () => void; + onMove: (delta: -1 | 1) => void; + onToggleActive: () => void; +}) { + const locked = busy !== null; + const toggling = busy === `active-${child.id}`; + return ( +
  • +
  • + ); +} + +export default function SettingsScreen({ onClose }: { onClose: () => void }) { + const [state, setState] = useState({ kind: "loading" }); + const [attempt, setAttempt] = useState(0); + const [form, setForm] = useState(null); + const [formError, setFormError] = useState(null); + const [actionError, setActionError] = useState(null); + const [busy, setBusy] = useState(null); + // A ref, not state: two taps in one frame must not both see `busy === null`. + const inFlight = useRef(false); + const screenRef = useRef(null); + const titleRef = useRef(null); + const addRef = useRef(null); + const formWasOpen = useRef(false); + + // Modal focus: move in on open, keep the background out of reach, and hand + // focus back to the opener (the Settings button) on close. + useEffect(() => { + const trigger = document.activeElement as HTMLElement | null; + titleRef.current?.focus(); + const restoreBackground = screenRef.current ? inertOutside(screenRef.current) : undefined; + return () => { + restoreBackground?.(); + if (trigger && trigger.isConnected) trigger.focus(); + }; + }, []); + + // The form's controls unmount when it closes; hand focus to Add child then + // (after the render that re-enables it). + useEffect(() => { + if (formWasOpen.current && form === null) addRef.current?.focus(); + formWasOpen.current = form !== null; + }, [form]); + + useEffect(() => { + let cancelled = false; + setState((current) => (current.kind === "ready" ? current : { kind: "loading" })); + fetchChildren() + .then((children) => { + if (!cancelled) setState({ kind: "ready", children: sortChildren(children) }); + }) + .catch((error: unknown) => { + if (cancelled) return; + setState({ kind: "error", message: errorMessage(error, "Children could not be loaded") }); + }); + return () => { + cancelled = true; + }; + }, [attempt]); + + function onKeyDown(event: ReactKeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + if (!inFlight.current) onClose(); + 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(); + } + } + + /** + * Run one write, then refetch the list. One write at a time; a failure + * keeps the screen (and any open form) as it was and says why. + */ + async function run(key: string, write: () => Promise, action: string): Promise { + if (inFlight.current) return false; + inFlight.current = true; + setBusy(key); + setActionError(null); + setFormError(null); + try { + await write(); + } catch (error) { + const message = errorMessage(error, action); + if (key === "form") setFormError(message); + else setActionError(message); + inFlight.current = false; + setBusy(null); + return false; + } + try { + const children = await fetchChildren(); + setState({ kind: "ready", children: sortChildren(children) }); + } catch (error) { + setActionError(errorMessage(error, "Saved, but the children list could not be refreshed")); + } + inFlight.current = false; + setBusy(null); + return true; + } + + function closeForm() { + setForm(null); + setFormError(null); + } + + async function submitForm(body: ChildCreateBody | ChildEditBody | string) { + if (form === null) return; + if (typeof body === "string") { + setFormError(body); + return; + } + const saved = + form === "new" + ? await run("form", () => createChild(body as ChildCreateBody), "The child could not be added") + : await run("form", () => updateChild(form.id, body), "The child could not be saved"); + if (saved) closeForm(); + } + + function move(children: AdminChild[], index: number, delta: -1 | 1) { + const ids = children.map((c) => c.id); + const target = index + delta; + [ids[index], ids[target]] = [ids[target], ids[index]]; + void run(`move-${children[index].id}`, () => reorderChildren(ids), "The order could not be saved"); + } + + function toggleActive(child: AdminChild) { + void run( + `active-${child.id}`, + () => setChildActive(child.id, !child.is_active), + child.is_active ? "The child could not be deactivated" : "The child could not be reactivated", + ); + } + + let body; + if (state.kind === "loading") { + body = ( +
    + Loading children… +
    + ); + } else if (state.kind === "error") { + body = ( +
    +

    {state.message}

    + +
    + ); + } else { + const { children } = state; + body = ( + <> + {children.length > 0 ? ( +
      + {children.map((child, index) => ( + { + setFormError(null); + setForm(child); + }} + onMove={(delta) => move(children, index, delta)} + onToggleActive={() => toggleActive(child)} + /> + ))} +
    + ) : ( +
    No children yet.
    + )} + {form !== null ? ( + void submitForm(b)} + /> + ) : null} + + ); + } + + return ( +
    +
    + +
    +

    + Settings +

    +

    Household setup

    +
    +
    +
    +
    +

    Children

    + +
    + {actionError !== null ? ( +
    + {actionError} + +
    + ) : null} + {body} +
    +
    + ); +} diff --git a/admin/src/settings/glyphs.tsx b/admin/src/settings/glyphs.tsx new file mode 100644 index 0000000..48dcedb --- /dev/null +++ b/admin/src/settings/glyphs.tsx @@ -0,0 +1,56 @@ +/** + * Inline stroke glyphs for the Settings screen (Lucide-style 24px grid), + * CDN-free like the tabs' glyphs. + */ +import type { ReactNode } from "react"; + +function Glyph({ size, children }: { size: number; children: ReactNode }) { + return ( + + ); +} + +export function ChevronLeftGlyph({ size = 16 }: { size?: number }) { + return ( + + + + ); +} + +export function ArrowUpGlyph({ size = 16 }: { size?: number }) { + return ( + + + + ); +} + +export function ArrowDownGlyph({ size = 16 }: { size?: number }) { + return ( + + + + ); +} + +export function PlusGlyph({ size = 16 }: { size?: number }) { + return ( + + + + ); +} diff --git a/admin/src/today/TodayTab.tsx b/admin/src/today/TodayTab.tsx index 9d4fedc..7646b87 100644 --- a/admin/src/today/TodayTab.tsx +++ b/admin/src/today/TodayTab.tsx @@ -12,6 +12,7 @@ import { ApiForbiddenError } from "../api/client"; import { ApiRequestError } from "../api/definitions"; import { uncompleteInstance } from "../api/instances"; import { useTransitions } from "../api/useTransitions"; +import SettingsScreen from "../settings/SettingsScreen"; import { formatClockTime, formatDueTime, @@ -43,15 +44,22 @@ function isOverdueOpen(instance: SnapshotInstance): boolean { return instance.overdue && instance.state === "open"; } -function ScreenHeader({ sub }: { sub: string }) { +function ScreenHeader({ sub, onSettings }: { sub: string; onSettings: () => void }) { return (

    Today

    {sub}

    - {/* Settings screen is a later task; the control is not wired yet. */} -
    @@ -221,6 +229,7 @@ export default function TodayTab() { const [undoingId, setUndoingId] = useState(null); const undoingRef = useRef(false); const [undoError, setUndoError] = useState(null); + const [settingsOpen, setSettingsOpen] = useState(false); function refresh() { silentNext.current = true; @@ -326,7 +335,7 @@ export default function TodayTab() { return (
    - + setSettingsOpen(true)} />
    + {settingsOpen ? ( + { + setSettingsOpen(false); + // Child names and order feed the snapshot. + refresh(); + }} + /> + ) : null}
    ); }