From 5a28fddfe285a96cdab9ae253c9393bfeec77f02 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 23 Sep 2026 21:16:16 -0400 Subject: [PATCH 1/2] Add admin Definitions list and edit sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tasks tab lists quest definitions (assignees · recurrence · windows, inactive rows, per-child filter chips) and a bottom edit sheet with multi-select assignees, repeats + interval, per-window due times, multi-select windows and a skip-on-away toggle. Saving POSTs or PATCHes the admin quest-definition routes and re-fetches the list; 403 and 422 responses are shown in the sheet. Task: 2e96585c-ca63-413d-bf99-7e203c39ddde Co-Authored-By: Claude Opus 5.5 --- admin/src/App.test.tsx | 9 + admin/src/App.tsx | 5 +- admin/src/api/definitions.ts | 148 ++++ admin/src/definitions/DefinitionsTab.css | 524 +++++++++++++ admin/src/definitions/DefinitionsTab.test.tsx | 510 ++++++++++++ admin/src/definitions/DefinitionsTab.tsx | 731 ++++++++++++++++++ admin/src/definitions/format.ts | 97 +++ admin/src/definitions/glyphs.tsx | 67 ++ admin/src/index.css | 11 + 9 files changed, 2101 insertions(+), 1 deletion(-) create mode 100644 admin/src/api/definitions.ts create mode 100644 admin/src/definitions/DefinitionsTab.css create mode 100644 admin/src/definitions/DefinitionsTab.test.tsx create mode 100644 admin/src/definitions/DefinitionsTab.tsx create mode 100644 admin/src/definitions/format.ts create mode 100644 admin/src/definitions/glyphs.tsx diff --git a/admin/src/App.test.tsx b/admin/src/App.test.tsx index 61233c1..f4a6c05 100644 --- a/admin/src/App.test.tsx +++ b/admin/src/App.test.tsx @@ -36,4 +36,13 @@ describe("NestQuest Admin shell", () => { "true", ); }); + + it("the Tasks tab renders the definitions screen", () => { + render(); + + fireEvent.click(screen.getByRole("tab", { name: "Tasks" })); + + expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy(); + expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder"); + }); }); \ No newline at end of file diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 10c120d..299e4c0 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import "./App.css"; +import DefinitionsTab from "./definitions/DefinitionsTab"; import TodayTab from "./today/TodayTab"; const TABS = [ @@ -15,7 +16,7 @@ export default function App() { const [active, setActive] = useState("today"); // Built screens own their header (ADMIN-SPEC §1); the generic shell header // stays for placeholder tabs and is visually hidden otherwise. - const hasScreen = active === "today"; + const hasScreen = active === "today" || active === "tasks"; return (
@@ -30,6 +31,8 @@ export default function App() { > {active === "today" ? ( + ) : active === "tasks" ? ( + ) : (

{TABS.find((tab) => tab.id === active)?.label} placeholder — screens diff --git a/admin/src/api/definitions.ts b/admin/src/api/definitions.ts new file mode 100644 index 0000000..cf8671d --- /dev/null +++ b/admin/src/api/definitions.ts @@ -0,0 +1,148 @@ +/** + * Typed client for the admin quest-definition routes and the children list + * the assignee picker reads (/api/v1/admin/quest-definitions, /children). + */ +import { apiFetch } from "./client"; + +export type WindowName = "morning" | "afternoon" | "evening"; + +/** Model rule names, as the admin plane serializes ScheduleRule.to_dict. */ +export type RuleType = + | "daily" + | "weekly" + | "monthly_day" + | "monthly_weekday" + | "yearly" + | "custom_days"; + +export interface DefinitionRule { + rule_type: RuleType; + interval: number; + /** 0 = Monday .. 6 = Sunday. */ + weekday_set: number[] | null; + day_of_month: number | null; + nth_weekday: number | null; + nth_weekday_weekday: number | null; + month: number | null; + start_date: string; + end_date: string | null; +} + +export interface DefinitionAssignee { + id: number; + display_name: string; +} + +export interface DefinitionWindow { + window: WindowName; + /** Local wall-clock "HH:MM", or null. */ + due_time: string | null; +} + +export interface QuestDefinition { + id: number; + title: string; + description: string | null; + icon: string | null; + is_active: boolean; + skip_on_away: boolean; + rule: DefinitionRule; + assignees: DefinitionAssignee[]; + windows: DefinitionWindow[]; +} + +export interface AdminChild { + id: number; + display_name: string; + colour: string | null; + avatar_ref: string | null; + sort_order: number; + is_active: boolean; +} + +/** A window declaration as the create/edit bodies accept it. */ +export type WindowEntry = WindowName | [WindowName, string | null]; + +export interface DefinitionCreateBody { + title: string; + rule: DefinitionRule; + assignee_child_ids: number[]; + windows: WindowEntry[]; + description?: string | null; + icon?: string | null; + skip_on_away?: boolean; +} + +export type DefinitionEditBody = Partial; + +export const DEFINITIONS_PATH = "/api/v1/admin/quest-definitions"; +export const CHILDREN_PATH = "/api/v1/admin/children"; + +/** A non-2xx save response; `detail` is the API's message when it sent one. */ +export class ApiRequestError extends Error { + constructor( + readonly status: number, + readonly detail: string | null, + ) { + super(detail ?? `Request failed (HTTP ${status}).`); + this.name = "ApiRequestError"; + } +} + +/** FastAPI sends `detail` as a string (core errors) or a list of {msg} (body validation). */ +function readDetail(body: unknown): string | null { + if (!body || typeof body !== "object" || !("detail" in body)) return null; + const { detail } = body as { detail: unknown }; + if (typeof detail === "string") return detail; + if (Array.isArray(detail)) { + const messages = detail + .map((item) => (item && typeof item === "object" ? (item as { msg?: unknown }).msg : null)) + .filter((msg): msg is string => typeof msg === "string"); + return messages.length > 0 ? messages.join("; ") : null; + } + return null; +} + +async function readJson(response: Response): Promise { + if (!response.ok) { + let body: unknown = null; + try { + body = await response.json(); + } catch { + // Non-JSON error body: report the status alone. + } + throw new ApiRequestError(response.status, readDetail(body)); + } + return (await response.json()) as T; +} + +export async function fetchDefinitions(): Promise { + const body = await readJson<{ definitions: QuestDefinition[] }>( + await apiFetch(DEFINITIONS_PATH), + ); + return body.definitions; +} + +export async function fetchChildren(): Promise { + const body = await readJson<{ children: AdminChild[] }>(await apiFetch(CHILDREN_PATH)); + return body.children; +} + +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 createDefinition(body: DefinitionCreateBody): Promise { + return readJson(await sendJson(DEFINITIONS_PATH, "POST", body)); +} + +export async function updateDefinition( + id: number, + body: DefinitionEditBody, +): Promise { + return readJson(await sendJson(`${DEFINITIONS_PATH}/${id}`, "PATCH", body)); +} diff --git a/admin/src/definitions/DefinitionsTab.css b/admin/src/definitions/DefinitionsTab.css new file mode 100644 index 0000000..0c115ba --- /dev/null +++ b/admin/src/definitions/DefinitionsTab.css @@ -0,0 +1,524 @@ +/* Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2). + Mobile-first at the 390x844 reference; the column is capped and centred wider. */ + +.defs-screen { + width: 100%; + max-width: 640px; + margin: 0 auto; +} + +/* §3.1 header */ +.defs-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + background: var(--nq-a-surface); + border-bottom: 1px solid var(--nq-a-border); + padding: 14px 16px; +} + +.defs-title { + margin: 0; + font-size: var(--nq-a-size-screen); + font-weight: 800; + letter-spacing: -0.02em; +} + +.defs-sub { + margin: 2px 0 0; + min-height: 1.4em; + font-size: 12px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.defs-new { + flex: none; + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 36px; + padding: 0 14px; + border: 0; + border-radius: var(--nq-a-density-radius); + background: var(--nq-brand-gradient); + color: #fff; + font-family: inherit; + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.defs-new:disabled { + opacity: 0.5; + cursor: default; +} + +/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */ +.defs-scroll { + display: flex; + flex-direction: column; + gap: var(--nq-a-density-gap); + padding: 14px 16px; +} + +/* Filter chips */ +.defs-chips { + display: flex; + gap: 6px; + overflow-x: auto; +} + +.defs-chip { + flex: none; + min-height: 32px; + padding: 0 12px; + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-radius-pill); + background: var(--nq-a-surface); + color: var(--nq-a-ink); + font-family: inherit; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.defs-chip--active { + border-color: #18181d; + background: #18181d; + color: #fff; +} + +/* Rows */ +.defs-card { + background: var(--nq-a-surface); + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); + overflow: hidden; +} + +.defs-list { + margin: 0; + padding: 0; + list-style: none; +} + +.defs-list > li + li { + border-top: 1px solid var(--nq-a-divider); +} + +.defs-row { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: var(--nq-a-tap-min); + padding: var(--nq-a-density-row-pad); + border: 0; + background: none; + color: var(--nq-a-ink); + font-family: inherit; + text-align: left; + cursor: pointer; +} + +.defs-row-glyph { + flex: none; + display: flex; + color: var(--nq-a-ink-secondary); +} + +.defs-row-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.defs-row-title { + font-size: var(--nq-a-size-row); + font-weight: 700; +} + +.defs-row-meta { + font-size: var(--nq-a-size-meta); + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.defs-row-chevron { + flex: none; + display: flex; + color: var(--nq-a-ink-tertiary); +} + +/* An inactive definition renders the whole row tertiary (never hard-deleted). */ +.defs-row--inactive, +.defs-row--inactive .defs-row-glyph, +.defs-row--inactive .defs-row-meta { + color: var(--nq-a-ink-tertiary); +} + +.defs-row--editing { + background: var(--nq-a-selected); +} + +.defs-row--editing .defs-row-glyph { + color: var(--nq-brand-blue); +} + +.defs-empty { + padding: var(--nq-a-density-card-pad); + font-size: 13px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +/* Loading / error */ +.defs-status { + padding: 24px 13px; + background: var(--nq-a-surface); + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); + text-align: center; + font-size: 13.5px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.defs-status--error { + color: var(--nq-a-danger-strong); +} + +.defs-status--error p { + margin: 0 0 10px; +} + +.defs-retry { + padding: 8px 16px; + 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: 13px; + font-weight: 700; + cursor: pointer; +} + +/* §3.2 edit sheet */ +.defs-sheet-layer { + position: fixed; + inset: 0; + z-index: 20; + display: flex; + flex-direction: column; + justify-content: flex-end; +} + +.defs-scrim { + position: absolute; + inset: 0; + background: rgba(9, 9, 11, 0.4); + animation: defs-fade var(--nq-dur-modal) var(--nq-ease-out); +} + +.defs-sheet { + position: relative; + width: 100%; + max-width: 640px; + max-height: 92%; + margin: 0 auto; + overflow-y: auto; + padding: 14px 18px 24px; + border-radius: var(--nq-a-radius-sheet); + background: var(--nq-a-surface); + box-shadow: var(--nq-a-sheet-shadow); + animation: defs-rise var(--nq-dur-modal) var(--nq-ease-out); +} + +@keyframes defs-fade { + from { + opacity: 0; + } +} + +@keyframes defs-rise { + from { + transform: translateY(100%); + } +} + +@media (prefers-reduced-motion: reduce) { + .defs-scrim, + .defs-sheet { + animation: none; + } +} + +.defs-grabber { + width: 38px; + height: 4px; + margin: 0 auto 12px; + border-radius: var(--nq-a-radius-pill); + background: var(--nq-a-border); +} + +.defs-sheet-title { + margin: 0 0 12px; + font-size: 19px; + font-weight: 800; +} + +.defs-fields { + display: flex; + flex-direction: column; + gap: 11px; +} + +.defs-field { + display: flex; + flex-direction: column; + gap: 5px; +} + +.defs-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); +} + +.defs-input { + width: 100%; + min-height: var(--nq-a-tap-min); + padding: 0 11px; + 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: 14px; + font-weight: 600; +} + +.defs-select-row { + display: flex; + align-items: center; + justify-content: space-between; + min-height: var(--nq-a-tap-min); + padding: 0 11px; + 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: 14px; + font-weight: 700; + cursor: pointer; +} + +.defs-picker { + margin: 0; + padding: 0; + list-style: none; + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); +} + +.defs-picker > li + li { + border-top: 1px solid var(--nq-a-divider); +} + +.defs-picker-option { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-height: var(--nq-a-tap-min); + padding: 0 11px; + border: 0; + background: none; + color: var(--nq-a-ink); + font-family: inherit; + font-size: 13.5px; + font-weight: 700; + cursor: pointer; +} + +.defs-picker-check { + display: flex; + color: var(--nq-brand-blue); +} + +.defs-repeats { + display: flex; + gap: 8px; +} + +.defs-interval { + flex: none; + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 700; + color: var(--nq-a-ink-secondary); +} + +.defs-interval .defs-input { + width: 64px; +} + +.defs-weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; +} + +.defs-day, +.defs-segment { + min-height: var(--nq-a-tap-min); + 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: 13px; + font-weight: 700; + cursor: pointer; +} + +.defs-day--on, +.defs-segment--on { + border-color: transparent; + background: var(--nq-brand-gradient); + color: #fff; +} + +.defs-segmented { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; +} + +.defs-due-times { + display: flex; + flex-direction: column; + gap: 6px; +} + +.defs-due { + display: grid; + grid-template-columns: 84px 1fr; + align-items: center; + gap: 8px; + font-size: 12.5px; + font-weight: 700; +} + +.defs-hint { + margin: 0; + font-size: 12px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.defs-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.defs-toggle-title { + font-size: 13.5px; + font-weight: 700; +} + +.defs-toggle-sub { + font-size: var(--nq-a-size-meta); + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.defs-switch { + flex: none; + position: relative; + width: 44px; + height: 26px; + padding: 0; + border: 0; + border-radius: var(--nq-a-radius-pill); + background: var(--nq-a-border); + cursor: pointer; + transition: background var(--nq-dur-base) var(--nq-ease-out); +} + +.defs-switch--on { + background: var(--nq-brand-gradient); +} + +.defs-switch-knob { + position: absolute; + top: 3px; + left: 3px; + width: 20px; + height: 20px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgba(9, 9, 11, 0.25); + transition: transform var(--nq-dur-base) var(--nq-ease-out); +} + +.defs-switch--on .defs-switch-knob { + transform: translateX(18px); +} + +.defs-sheet-error { + margin: 12px 0 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; +} + +.defs-sheet-footer { + display: flex; + gap: 8px; + margin-top: 16px; +} + +.defs-cancel, +.defs-save { + padding: 13px 0; + border-radius: var(--nq-a-density-radius); + font-family: inherit; + font-size: 14px; + font-weight: 700; + cursor: pointer; +} + +.defs-cancel { + flex: 1; + border: 1px solid var(--nq-a-border); + background: var(--nq-a-surface); + color: var(--nq-a-ink); +} + +.defs-save { + flex: 2; + border: 0; + background: var(--nq-brand-gradient); + color: #fff; +} + +.defs-cancel:disabled, +.defs-save:disabled { + opacity: 0.6; + cursor: default; +} diff --git a/admin/src/definitions/DefinitionsTab.test.tsx b/admin/src/definitions/DefinitionsTab.test.tsx new file mode 100644 index 0000000..96c0654 --- /dev/null +++ b/admin/src/definitions/DefinitionsTab.test.tsx @@ -0,0 +1,510 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import DefinitionsTab from "./DefinitionsTab"; +import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions"; +import { storeTokens } from "../auth/oidc"; + +function child(id: number, name: string, overrides: Partial = {}): AdminChild { + return { + id, + display_name: name, + colour: null, + avatar_ref: null, + sort_order: id, + is_active: true, + ...overrides, + }; +} + +const CHILDREN: AdminChild[] = [ + child(1, "Declan"), + child(2, "Maeve"), + child(3, "Rory"), + child(4, "Old", { is_active: false }), +]; + +function rule(overrides: Partial = {}): DefinitionRule { + return { + rule_type: "daily", + interval: 1, + weekday_set: null, + day_of_month: null, + nth_weekday: null, + nth_weekday_weekday: null, + month: null, + start_date: "2026-01-01", + end_date: null, + ...overrides, + }; +} + +const BRUSH: QuestDefinition = { + id: 10, + title: "Brush teeth", + description: null, + icon: "smile", + is_active: true, + skip_on_away: true, + rule: rule(), + assignees: [ + { id: 1, display_name: "Declan" }, + { id: 2, display_name: "Maeve" }, + { id: 3, display_name: "Rory" }, + ], + windows: [ + { window: "morning", due_time: "07:30" }, + { window: "evening", due_time: null }, + ], +}; + +const BINS: QuestDefinition = { + id: 11, + title: "Take out bins", + description: "Green bin", + icon: null, + is_active: true, + skip_on_away: false, + rule: rule({ rule_type: "weekly", interval: 2, weekday_set: [0, 3] }), + assignees: [ + { id: 1, display_name: "Declan" }, + { id: 4, display_name: "Old" }, + ], + windows: [{ window: "afternoon", due_time: "16:00" }], +}; + +const PIANO: QuestDefinition = { + id: 12, + title: "Piano practice", + description: null, + icon: null, + is_active: false, + skip_on_away: true, + rule: rule({ rule_type: "monthly_day", day_of_month: 15 }), + assignees: [{ id: 2, display_name: "Maeve" }], + windows: [{ window: "afternoon", due_time: null }], +}; + +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; +} + +/** + * A stub of the admin API: serves the list and children, records writes, and + * lets a test queue a response for the next write. + */ +function fakeApi(initial: QuestDefinition[]) { + let definitions = [...initial]; + const calls: Call[] = []; + let nextWrite: Response | null = null; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => { + const path = new URL(String(input), "http://localhost").pathname; + const method = init.method ?? "GET"; + const body = typeof init.body === "string" ? JSON.parse(init.body) : null; + calls.push({ method, path, body }); + if (method === "GET" && path === "/api/v1/admin/quest-definitions") { + return jsonResponse({ definitions }); + } + if (method === "GET" && path === "/api/v1/admin/children") { + return jsonResponse({ children: CHILDREN }); + } + if (nextWrite) { + const response = nextWrite; + nextWrite = null; + return response; + } + if (method === "POST" && path === "/api/v1/admin/quest-definitions") { + const created: QuestDefinition = { + id: 99, + title: body.title, + description: null, + icon: null, + is_active: true, + skip_on_away: body.skip_on_away, + rule: body.rule, + assignees: CHILDREN.filter((c) => body.assignee_child_ids.includes(c.id)).map((c) => ({ + id: c.id, + display_name: c.display_name, + })), + windows: body.windows.map(([window, due_time]: [string, string | null]) => ({ + window, + due_time, + })), + }; + definitions = [...definitions, created]; + return jsonResponse(created, 201); + } + const match = /^\/api\/v1\/admin\/quest-definitions\/(\d+)$/.exec(path); + if (method === "PATCH" && match) { + const id = Number(match[1]); + definitions = definitions.map((d) => + d.id === id + ? { + ...d, + title: body.title ?? d.title, + skip_on_away: body.skip_on_away ?? d.skip_on_away, + rule: body.rule ?? d.rule, + } + : d, + ); + return jsonResponse(definitions.find((d) => d.id === id)); + } + return jsonResponse({ detail: "unexpected" }, 500); + }); + return { + fetchMock, + calls, + writes: () => calls.filter((c) => c.method !== "GET"), + listFetches: () => + calls.filter((c) => c.method === "GET" && c.path === "/api/v1/admin/quest-definitions") + .length, + failNextWrite: (response: Response) => { + nextWrite = response; + }, + }; +} + +let api: ReturnType; + +async function renderReady(initial: QuestDefinition[] = [BRUSH, BINS, PIANO]) { + api = fakeApi(initial); + vi.stubGlobal("fetch", api.fetchMock); + render(); + await screen.findByText("Brush teeth"); +} + +function meta(row: HTMLElement): string { + return (row.querySelector(".defs-row-meta") as HTMLElement).textContent ?? ""; +} + +function sheet(): HTMLElement { + return screen.getByRole("dialog"); +} + +describe("DefinitionsTab", () => { + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("header counts definitions and active children", async () => { + await renderReady(); + expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy(); + expect(screen.getByText("3 definitions · 3 children")).toBeTruthy(); + const [url, init] = api.fetchMock.mock.calls[0]; + expect(String(url)).toMatch(/\/api\/v1\/admin\/quest-definitions$/); + expect((init as RequestInit & { headers: Record }).headers.Authorization).toBe( + "Bearer at-123", + ); + }); + + it("rows show ` · · ` meta", async () => { + await renderReady(); + const brush = screen.getByTestId("definition-10"); + expect(within(brush).getByText("Brush teeth")).toBeTruthy(); + expect(meta(brush)).toBe("All three · Daily · Morning, Evening"); + expect(meta(screen.getByTestId("definition-11"))).toBe( + "Declan, Old · Every 2 weeks on Mon, Thu · Afternoon", + ); + expect(brush.className).not.toContain("defs-row--inactive"); + }); + + it("an inactive definition is styled inactive with `· inactive`", async () => { + await renderReady(); + const piano = screen.getByTestId("definition-12"); + expect(meta(piano)).toBe("Maeve · Monthly on day 15 · Afternoon · inactive"); + expect(piano.className).toContain("defs-row--inactive"); + }); + + it("filter chips filter by child", async () => { + await renderReady(); + const chips = screen.getByRole("group", { name: "Filter by child" }); + expect( + within(chips) + .getAllByRole("button") + .map((b) => b.textContent), + ).toEqual(["All", "Declan", "Maeve", "Rory"]); + expect(within(chips).getByRole("button", { name: "All" }).className).toContain( + "defs-chip--active", + ); + + fireEvent.click(within(chips).getByRole("button", { name: "Maeve" })); + expect(within(chips).getByRole("button", { name: "Maeve" }).getAttribute("aria-pressed")).toBe( + "true", + ); + expect(screen.queryByTestId("definition-10")).toBeTruthy(); + expect(screen.queryByTestId("definition-11")).toBeNull(); + expect(screen.queryByTestId("definition-12")).toBeTruthy(); + + fireEvent.click(within(chips).getByRole("button", { name: "Rory" })); + expect(screen.queryByTestId("definition-10")).toBeTruthy(); + expect(screen.queryByTestId("definition-12")).toBeNull(); + + fireEvent.click(within(chips).getByRole("button", { name: "All" })); + expect(screen.getAllByRole("listitem")).toHaveLength(3); + }); + + it("New opens the sheet and saving POSTs the exact body, then re-fetches", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(2026, 8, 23, 9, 0)); // Wed 23 Sep 2026, local + await renderReady(); + expect(api.listFetches()).toBe(1); + + fireEvent.click(screen.getByRole("button", { name: "New" })); + const s = sheet(); + expect(within(s).getByRole("heading", { name: "New task" })).toBeTruthy(); + + fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), { + target: { value: " Feed the cat " }, + }); + fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ })); + fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" })); + fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" })); + expect(within(s).getByRole("button", { name: /Assigned children/ }).textContent).toContain( + "Declan, Rory", + ); + + fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), { + target: { value: "weekly" }, + }); + fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), { + target: { value: "2" }, + }); + // Today (Wednesday) is preselected; add Saturday. + expect(within(s).getByRole("button", { name: "Wednesday" }).getAttribute("aria-pressed")).toBe( + "true", + ); + fireEvent.click(within(s).getByRole("button", { name: "Saturday" })); + + fireEvent.click(within(s).getByRole("button", { name: "Evening" })); + fireEvent.click(within(s).getByRole("button", { name: "Morning" })); + fireEvent.change(within(s).getByLabelText("Morning due time"), { + target: { value: "07:15" }, + }); + + const toggle = within(s).getByRole("switch", { name: "Skip on away days" }); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + + expect(api.writes()).toEqual([ + { + method: "POST", + path: "/api/v1/admin/quest-definitions", + body: { + title: "Feed the cat", + rule: { + rule_type: "weekly", + interval: 2, + weekday_set: [2, 5], + day_of_month: null, + nth_weekday: null, + nth_weekday_weekday: null, + month: null, + start_date: "2026-09-23", + end_date: null, + }, + assignee_child_ids: [1, 3], + windows: [ + ["morning", "07:15"], + ["evening", null], + ], + skip_on_away: false, + }, + }, + ]); + + const created = await screen.findByTestId("definition-99"); + expect(meta(created)).toBe("Declan, Rory · Every 2 weeks on Wed, Sat · Morning, Evening"); + expect(api.listFetches()).toBe(2); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(screen.getByText("4 definitions · 3 children")).toBeTruthy(); + }); + + it("tapping a row opens the sheet in editing state and saving PATCHes", async () => { + await renderReady(); + const row = screen.getByTestId("definition-11"); + fireEvent.click(row); + + // §3.1 editing state on the row. + expect(row.className).toContain("defs-row--editing"); + expect(meta(row)).toBe("Editing"); + expect(row.getAttribute("aria-expanded")).toBe("true"); + + const s = sheet(); + expect(within(s).getByRole("heading", { name: "Edit task" })).toBeTruthy(); + expect((within(s).getByRole("textbox", { name: "Title" }) as HTMLInputElement).value).toBe( + "Take out bins", + ); + expect((within(s).getByRole("combobox", { name: "Repeats" }) as HTMLSelectElement).value).toBe( + "weekly", + ); + expect((within(s).getByLabelText("Afternoon due time") as HTMLInputElement).value).toBe( + "16:00", + ); + // The inactive child "Old" is not offered and not submitted. + fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ })); + expect(within(s).queryByRole("checkbox", { name: "Old" })).toBeNull(); + fireEvent.click(within(s).getByRole("checkbox", { name: "Maeve" })); + + fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), { + target: { value: "Take out the bins" }, + }); + fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), { + target: { value: "monthly" }, + }); + fireEvent.change(within(s).getByRole("spinbutton", { name: "Day of month" }), { + target: { value: "3" }, + }); + fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), { + target: { value: "1" }, + }); + fireEvent.change(within(s).getByLabelText("Afternoon due time"), { + target: { value: "" }, + }); + fireEvent.click(within(s).getByRole("switch", { name: "Skip on away days" })); + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + + expect(api.writes()).toEqual([ + { + method: "PATCH", + path: "/api/v1/admin/quest-definitions/11", + body: { + title: "Take out the bins", + rule: { + rule_type: "monthly_day", + interval: 1, + weekday_set: null, + day_of_month: 3, + nth_weekday: null, + nth_weekday_weekday: null, + month: null, + start_date: "2026-01-01", + end_date: null, + }, + assignee_child_ids: [1, 2], + windows: [["afternoon", null]], + skip_on_away: true, + }, + }, + ]); + // The icon is never sent, so it is carried through unchanged. + expect(api.writes()[0].body).not.toHaveProperty("icon"); + + await screen.findByText("Take out the bins"); + expect(api.listFetches()).toBe(2); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("the skip-on-away toggle reflects and persists a false value", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-11")); + const s = sheet(); + const toggle = within(s).getByRole("switch", { name: "Skip on away days" }); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + expect(toggle.className).not.toContain("defs-switch--on"); + expect(within(s).getByText("No instance when the child is absent")).toBeTruthy(); + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + expect(api.writes()).toHaveLength(1); + expect((api.writes()[0].body as { skip_on_away: boolean }).skip_on_away).toBe(false); + }); + + it("a 422 from save shows the API's detail and keeps the sheet open", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-10")); + api.failNextWrite(jsonResponse({ detail: "interval must be >= 1, got 0" }, 422)); + + await act(async () => { + fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" })); + }); + + expect(screen.getByTestId("sheet-error").textContent).toBe("interval must be >= 1, got 0"); + expect(screen.getByRole("dialog")).toBeTruthy(); + expect(api.listFetches()).toBe(1); + // Save is usable again after the failure. + expect( + (within(sheet()).getByRole("button", { name: "Save changes" }) as HTMLButtonElement).disabled, + ).toBe(false); + }); + + it("a 403 from save shows the admin-group refusal", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-10")); + api.failNextWrite(jsonResponse({ detail: "Forbidden" }, 403)); + + await act(async () => { + fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" })); + }); + expect(screen.getByTestId("sheet-error").textContent).toBe( + "Your account is not in the nestquest-admins group.", + ); + }); + + it("guards against a double submit", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-10")); + const save = within(sheet()).getByRole("button", { name: "Save changes" }); + await act(async () => { + fireEvent.click(save); + fireEvent.click(save); + }); + expect(api.writes()).toHaveLength(1); + }); + + it("an incomplete new task is not submitted", async () => { + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "New" })); + fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" })); + expect(screen.getByTestId("sheet-error").textContent).toBe("Give the task a title."); + expect(api.writes()).toHaveLength(0); + }); + + it("Cancel closes the sheet without writing", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-10")); + fireEvent.click(within(sheet()).getByRole("button", { name: "Cancel" })); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(meta(screen.getByTestId("definition-10"))).toBe("All three · Daily · Morning, Evening"); + expect(api.writes()).toHaveLength(0); + }); + + it("a 403 on load renders the refusal", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse({ detail: "Forbidden" }, 403)), + ); + render(); + expect((await screen.findByTestId("definitions-error")).textContent).toContain( + "Your account is not in the nestquest-admins group.", + ); + }); + + it("scroll column carries the 92px bottom padding", async () => { + await renderReady(); + expect(screen.getByTestId("definitions-scroll").style.paddingBottom).toBe("92px"); + }); +}); diff --git a/admin/src/definitions/DefinitionsTab.tsx b/admin/src/definitions/DefinitionsTab.tsx new file mode 100644 index 0000000..b136446 --- /dev/null +++ b/admin/src/definitions/DefinitionsTab.tsx @@ -0,0 +1,731 @@ +/** + * Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2). + * + * The icon picker (§3.3) and the occurrence preview are separate tasks: an + * existing definition's icon is carried through unchanged by never sending it. + */ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { ApiForbiddenError } from "../api/client"; +import { + ApiRequestError, + createDefinition, + fetchChildren, + fetchDefinitions, + updateDefinition, + type AdminChild, + type DefinitionCreateBody, + type DefinitionRule, + type QuestDefinition, + type WindowEntry, + type WindowName, +} from "../api/definitions"; +import { + MONTH_SHORT, + WEEKDAY_SHORT, + WINDOW_LABELS, + WINDOW_ORDER, + assigneeSummary, + recurrenceSummary, + windowSummary, +} from "./format"; +import { + CheckGlyph, + ChevronDownGlyph, + ChevronRightGlyph, + PlusGlyph, + TaskGlyph, +} from "./glyphs"; +import "./DefinitionsTab.css"; + +/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */ +export const SCROLL_BOTTOM_PADDING = "92px"; + +type Repeats = "daily" | "weekly" | "monthly" | "yearly" | "custom"; + +const REPEATS_OPTIONS: { value: Repeats; label: string }[] = [ + { value: "daily", label: "Daily" }, + { value: "weekly", label: "Weekly" }, + { value: "monthly", label: "Monthly" }, + { value: "yearly", label: "Yearly" }, + { value: "custom", label: "Custom" }, +]; + +const REPEATS_BY_RULE: Record = { + daily: "daily", + weekly: "weekly", + monthly_day: "monthly", + monthly_weekday: "monthly", + yearly: "yearly", + custom_days: "custom", +}; + +const WEEKDAY_LONG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; + +interface Draft { + title: string; + assigneeIds: number[]; + repeats: Repeats; + interval: string; + weekdays: number[]; + dayOfMonth: string; + month: string; + /** An existing "nth weekday" monthly rule, kept while Repeats stays Monthly. */ + monthlyWeekday: { nth: number; weekday: number } | null; + windows: WindowName[]; + dueTimes: Record; + skipOnAway: boolean; + startDate: string; + endDate: string | null; +} + +type LoadState = + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "ready"; definitions: QuestDefinition[]; children: AdminChild[] }; + +/** `null` = closed, `"new"` = creating, a definition = editing it. */ +type SheetTarget = null | "new" | QuestDefinition; + +function pad2(n: number): string { + return String(n).padStart(2, "0"); +} + +function localIsoDate(date: Date): string { + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; +} + +function emptyDueTimes(): Record { + return { morning: "", afternoon: "", evening: "" }; +} + +function newDraft(activeChildren: AdminChild[]): Draft { + const today = new Date(); + return { + title: "", + assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [], + repeats: "daily", + interval: "1", + weekdays: [(today.getDay() + 6) % 7], + dayOfMonth: String(today.getDate()), + month: String(today.getMonth() + 1), + monthlyWeekday: null, + windows: [], + dueTimes: emptyDueTimes(), + // Mirrors the API's create default: no instance on an away day. + skipOnAway: true, + startDate: localIsoDate(today), + endDate: null, + }; +} + +function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): Draft { + const { rule } = definition; + const today = new Date(); + const active = new Set(activeChildren.map((child) => child.id)); + const dueTimes = emptyDueTimes(); + for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? ""; + return { + title: definition.title, + // Inactive children cannot be submitted (the API rejects them with 422). + assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)), + repeats: REPEATS_BY_RULE[rule.rule_type], + interval: String(rule.interval), + weekdays: + rule.rule_type === "weekly" || rule.rule_type === "custom_days" + ? [...(rule.weekday_set ?? [])] + : [(today.getDay() + 6) % 7], + dayOfMonth: String(rule.day_of_month ?? today.getDate()), + month: String(rule.month ?? today.getMonth() + 1), + monthlyWeekday: + rule.rule_type === "monthly_weekday" + ? { nth: rule.nth_weekday ?? 1, weekday: rule.nth_weekday_weekday ?? 0 } + : null, + windows: WINDOW_ORDER.filter((w) => definition.windows.some((d) => d.window === w)), + dueTimes, + skipOnAway: definition.skip_on_away, + startDate: rule.start_date, + endDate: rule.end_date, + }; +} + +function parseWhole(value: string, min: number, max: number): number | null { + if (!/^\d+$/.test(value.trim())) return null; + const n = Number(value); + return n >= min && n <= max ? n : null; +} + +/** Build the request rule from the draft, or return a message naming what to fix. */ +function buildRule(draft: Draft): DefinitionRule | string { + const interval = parseWhole(draft.interval, 1, 999); + if (interval === null) return "Interval must be a whole number of at least 1."; + const rule: DefinitionRule = { + rule_type: "daily", + interval, + weekday_set: null, + day_of_month: null, + nth_weekday: null, + nth_weekday_weekday: null, + month: null, + start_date: draft.startDate, + end_date: draft.endDate, + }; + switch (draft.repeats) { + case "daily": + return rule; + case "weekly": + case "custom": + if (draft.weekdays.length === 0) return "Pick at least one day."; + return { + ...rule, + rule_type: draft.repeats === "weekly" ? "weekly" : "custom_days", + weekday_set: [...draft.weekdays].sort((a, b) => a - b), + }; + case "monthly": { + if (draft.monthlyWeekday) { + return { + ...rule, + rule_type: "monthly_weekday", + nth_weekday: draft.monthlyWeekday.nth, + nth_weekday_weekday: draft.monthlyWeekday.weekday, + }; + } + const day = parseWhole(draft.dayOfMonth, 1, 31); + if (day === null) return "Day of month must be 1 to 31."; + return { ...rule, rule_type: "monthly_day", day_of_month: day }; + } + case "yearly": { + const month = parseWhole(draft.month, 1, 12); + const day = parseWhole(draft.dayOfMonth, 1, 31); + if (month === null || day === null) return "Pick a month and a day (1 to 31)."; + return { ...rule, rule_type: "yearly", month, day_of_month: day }; + } + } +} + +function buildBody(draft: Draft): DefinitionCreateBody | string { + const title = draft.title.trim(); + if (!title) return "Give the task a title."; + if (draft.assigneeIds.length === 0) return "Assign at least one child."; + if (draft.windows.length === 0) return "Pick at least one window."; + const rule = buildRule(draft); + if (typeof rule === "string") return rule; + const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map( + (w) => [w, draft.dueTimes[w] || null], + ); + return { + title, + rule, + assignee_child_ids: [...draft.assigneeIds], + windows, + skip_on_away: draft.skipOnAway, + }; +} + +function saveErrorMessage(error: unknown): string { + if (error instanceof ApiForbiddenError) return error.message; + if (error instanceof ApiRequestError && error.detail) return error.detail; + return "The task could not be saved. Check your connection and try again."; +} + +function toggle(list: T[], value: T): T[] { + return list.includes(value) ? list.filter((item) => item !== value) : [...list, value]; +} + +/* ── List ───────────────────────────────────────────────────────────── */ + +function FilterChips({ + options, + filter, + onChange, +}: { + options: AdminChild[]; + filter: number | "all"; + onChange: (filter: number | "all") => void; +}) { + const chips: { key: number | "all"; label: string }[] = [ + { key: "all", label: "All" }, + ...options.map((child) => ({ key: child.id, label: child.display_name })), + ]; + return ( +

+ {chips.map((chip) => ( + + ))} +
+ ); +} + +function DefinitionRow({ + definition, + activeChildren, + editing, + onOpen, +}: { + definition: QuestDefinition; + activeChildren: AdminChild[]; + editing: boolean; + onOpen: () => void; +}) { + let meta = [ + assigneeSummary(definition.assignees, activeChildren), + recurrenceSummary(definition.rule), + windowSummary(definition.windows), + ].join(" · "); + if (!definition.is_active) meta += " · inactive"; + let className = "defs-row"; + if (!definition.is_active) className += " defs-row--inactive"; + if (editing) className += " defs-row--editing"; + return ( +
  • + +
  • + ); +} + +/* ── Edit sheet ─────────────────────────────────────────────────────── */ + +function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) { + return ( +
    + + {label} + + {children} +
    + ); +} + +function EditSheet({ + target, + activeChildren, + onCancel, + onSaved, +}: { + target: "new" | QuestDefinition; + activeChildren: AdminChild[]; + onCancel: () => void; + onSaved: () => void; +}) { + const [draft, setDraft] = useState(() => + target === "new" ? newDraft(activeChildren) : draftFrom(target, activeChildren), + ); + const [pickerOpen, setPickerOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + // A ref, not state: two taps in one frame must not both see `saving === false`. + const inFlight = useRef(false); + + const update = (patch: Partial) => setDraft((d) => ({ ...d, ...patch })); + + const selectedAssignees = activeChildren + .filter((child) => draft.assigneeIds.includes(child.id)) + .map((child) => ({ id: child.id, display_name: child.display_name })); + const orderedWindows = WINDOW_ORDER.filter((w) => draft.windows.includes(w)); + + async function save() { + if (inFlight.current) return; + const body = buildBody(draft); + if (typeof body === "string") { + setError(body); + return; + } + inFlight.current = true; + setSaving(true); + setError(null); + try { + if (target === "new") await createDefinition(body); + else await updateDefinition(target.id, body); + onSaved(); + } catch (err) { + setError(saveErrorMessage(err)); + inFlight.current = false; + setSaving(false); + } + } + + return ( +
    +
    { + if (!inFlight.current) onCancel(); + }} + /> +
    + +
    + ); +} + +/* ── Screen ─────────────────────────────────────────────────────────── */ + +export default function DefinitionsTab() { + const [state, setState] = useState({ kind: "loading" }); + const [attempt, setAttempt] = useState(0); + const [filter, setFilter] = useState("all"); + const [sheet, setSheet] = useState(null); + + useEffect(() => { + let cancelled = false; + // Keep the list on screen during a post-save re-fetch; spin only on first load. + setState((current) => (current.kind === "ready" ? current : { kind: "loading" })); + Promise.all([fetchDefinitions(), fetchChildren()]) + .then(([definitions, children]) => { + if (!cancelled) setState({ kind: "ready", definitions, children }); + }) + .catch((error: unknown) => { + if (cancelled) return; + const message = + error instanceof ApiForbiddenError + ? error.message + : "Tasks could not be loaded. Check your connection and try again."; + setState({ kind: "error", message }); + }); + return () => { + cancelled = true; + }; + }, [attempt]); + + const activeChildren = + state.kind === "ready" + ? [...state.children] + .filter((child) => child.is_active) + .sort((a, b) => a.sort_order - b.sort_order) + : []; + + let sub = ""; + let body; + if (state.kind === "loading") { + body = ( +
    + Loading tasks… +
    + ); + } else if (state.kind === "error") { + body = ( +
    +

    {state.message}

    + +
    + ); + } else { + const { definitions } = state; + sub = `${definitions.length} definitions · ${activeChildren.length} children`; + const shown = + filter === "all" + ? definitions + : definitions.filter((d) => d.assignees.some((a) => a.id === filter)); + const editingId = sheet !== null && sheet !== "new" ? sheet.id : null; + body = ( + <> + + {shown.length > 0 ? ( +
      + {shown.map((definition) => ( + setSheet(definition)} + /> + ))} +
    + ) : ( +
    No tasks yet.
    + )} + + ); + } + + return ( +
    +
    +
    +

    Tasks

    +

    {sub}

    +
    + +
    +
    + {body} +
    + {sheet !== null && state.kind === "ready" ? ( + setSheet(null)} + onSaved={() => { + setSheet(null); + setAttempt((n) => n + 1); + }} + /> + ) : null} +
    + ); +} diff --git a/admin/src/definitions/format.ts b/admin/src/definitions/format.ts new file mode 100644 index 0000000..aa48f81 --- /dev/null +++ b/admin/src/definitions/format.ts @@ -0,0 +1,97 @@ +/** + * List-row summaries for the Definitions tab (ADMIN-SPEC §3.1): + * ` · · `. + */ +import type { + AdminChild, + DefinitionAssignee, + DefinitionRule, + DefinitionWindow, + WindowName, +} from "../api/definitions"; + +export const WINDOW_ORDER: WindowName[] = ["morning", "afternoon", "evening"]; + +export const WINDOW_LABELS: Record = { + morning: "Morning", + afternoon: "Afternoon", + evening: "Evening", +}; + +/** Index 0 = Monday .. 6 = Sunday, the core's weekday numbering. */ +export const WEEKDAY_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +export const MONTH_SHORT = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +const COUNT_WORDS = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"]; + +/** + * `Declan`, `Declan, Maeve`, or `All three` when every active child (two or + * more) is assigned. + */ +export function assigneeSummary( + assignees: Pick[], + activeChildren: AdminChild[], +): string { + if (assignees.length === 0) return "Unassigned"; + const assigned = new Set(assignees.map((a) => a.id)); + const everyone = + activeChildren.length > 1 && + assigned.size === activeChildren.length && + activeChildren.every((child) => assigned.has(child.id)); + if (everyone) { + const word = COUNT_WORDS[activeChildren.length] ?? String(activeChildren.length); + return `All ${word}`; + } + return assignees.map((a) => a.display_name).join(", "); +} + +function weekdayList(days: number[] | null): string { + return [...(days ?? [])] + .sort((a, b) => a - b) + .map((day) => WEEKDAY_SHORT[day]) + .join(", "); +} + +function ordinal(n: number): string { + if (n === -1) return "last"; + const suffix = n === 1 ? "st" : n === 2 ? "nd" : n === 3 ? "rd" : "th"; + return `${n}${suffix}`; +} + +function every(interval: number, one: string, unit: string): string { + return interval === 1 ? one : `Every ${interval} ${unit}`; +} + +export function recurrenceSummary(rule: DefinitionRule): string { + const n = rule.interval; + switch (rule.rule_type) { + case "daily": + return every(n, "Daily", "days"); + case "weekly": + return `${every(n, "Weekly", "weeks")} on ${weekdayList(rule.weekday_set)}`; + case "monthly_day": + return `${every(n, "Monthly", "months")} on day ${rule.day_of_month}`; + case "monthly_weekday": + return `${every(n, "Monthly", "months")} on the ${ordinal(rule.nth_weekday ?? 1)} ${ + WEEKDAY_SHORT[rule.nth_weekday_weekday ?? 0] + }`; + case "yearly": { + const month = MONTH_SHORT[(rule.month ?? 1) - 1]; + const when = rule.day_of_month ? `on ${month} ${rule.day_of_month}` : `in ${month}`; + return `${every(n, "Yearly", "years")} ${when}`; + } + case "custom_days": + return `Custom${n === 1 ? "" : ` every ${n} weeks`} (${weekdayList(rule.weekday_set)})`; + } +} + +export function windowSummary(windows: Pick[]): string { + const names = new Set(windows.map((w) => w.window)); + return WINDOW_ORDER.filter((w) => names.has(w)) + .map((w) => WINDOW_LABELS[w]) + .join(", "); +} diff --git a/admin/src/definitions/glyphs.tsx b/admin/src/definitions/glyphs.tsx new file mode 100644 index 0000000..3913d4c --- /dev/null +++ b/admin/src/definitions/glyphs.tsx @@ -0,0 +1,67 @@ +/** + * Inline stroke glyphs for the Definitions tab (Lucide-style 24px grid), + * CDN-free like the Today tab's. Definition icons arrive with the icon + * picker task; until then every row carries the generic task glyph. + */ +import type { ReactNode } from "react"; + +function Glyph({ size, children }: { size: number; children: ReactNode }) { + return ( + + ); +} + +export function TaskGlyph({ size = 17 }: { size?: number }) { + return ( + + + + + + ); +} + +export function PlusGlyph({ size = 16 }: { size?: number }) { + return ( + + + + ); +} + +export function ChevronRightGlyph({ size = 16 }: { size?: number }) { + return ( + + + + ); +} + +export function ChevronDownGlyph({ size = 16 }: { size?: number }) { + return ( + + + + ); +} + +export function CheckGlyph({ size = 16 }: { size?: number }) { + return ( + + + + ); +} diff --git a/admin/src/index.css b/admin/src/index.css index f1512aa..f725495 100644 --- a/admin/src/index.css +++ b/admin/src/index.css @@ -11,6 +11,7 @@ --nq-a-page: #f4f4f6; --nq-a-surface: #ffffff; + --nq-a-selected: #ebebf8; --nq-a-border: #e4e4ea; --nq-a-divider: #ededf1; @@ -21,6 +22,7 @@ --nq-a-success: #15803d; --nq-a-danger: #dc2626; --nq-a-danger-strong: #b91c1c; + --nq-a-danger-bg: #fee2e2; --nq-a-info-bg: #dbeafe; --nq-a-info-ink: #1e3a8a; --nq-a-info-icon: #1d4ed8; @@ -28,7 +30,10 @@ --nq-a-font: Nunito, system-ui, sans-serif; --nq-a-size-screen: 24px; --nq-a-size-stat: 22px; + --nq-a-size-row: 13.5px; --nq-a-size-meta: 11px; + --nq-a-size-label: 10px; + --nq-a-track-label: 0.1em; --nq-a-density-gap: 10px; --nq-a-density-card-pad: 13px; @@ -38,7 +43,13 @@ --nq-a-tabbar-height: 70px; --nq-a-tap-min: 44px; --nq-a-radius-pill: 9999px; + --nq-a-radius-sheet: 20px 20px 0 0; + --nq-a-sheet-shadow: 0 -8px 28px rgba(9, 9, 11, 0.18); --nq-a-progress-height: 6px; + + --nq-ease-out: cubic-bezier(0, 0, 0.2, 1); + --nq-dur-base: 200ms; + --nq-dur-modal: 350ms; } * { -- 2.55.0 From bf00b63215b4db062caafc1fb66e9e666d868d75 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 23 Sep 2026 21:22:25 -0400 Subject: [PATCH 2/2] Preserve inactive assignees on edit and manage edit-sheet focus NQ-198-001: a PATCH omits assignee_child_ids while the active selection is unchanged, so inactive assignees survive unrelated edits. Changing the selection when inactive children would be dropped asks for confirmation first. Inactive assignees are shown locked in the picker with a hint. NQ-198-002: the edit sheet moves focus in on open, wraps Tab/Shift+Tab, restores focus to the trigger on close, and cancels on Escape when not saving. Co-Authored-By: Claude Opus 5.5 --- admin/src/definitions/DefinitionsTab.css | 28 ++++ admin/src/definitions/DefinitionsTab.test.tsx | 138 +++++++++++++++- admin/src/definitions/DefinitionsTab.tsx | 154 ++++++++++++++++-- 3 files changed, 302 insertions(+), 18 deletions(-) diff --git a/admin/src/definitions/DefinitionsTab.css b/admin/src/definitions/DefinitionsTab.css index 0c115ba..1b9f425 100644 --- a/admin/src/definitions/DefinitionsTab.css +++ b/admin/src/definitions/DefinitionsTab.css @@ -353,6 +353,16 @@ color: var(--nq-brand-blue); } +.defs-picker-option--locked { + color: var(--nq-a-ink-secondary); + cursor: default; +} + +.defs-picker-note { + font-size: 12px; + font-weight: 600; +} + .defs-repeats { display: flex; gap: 8px; @@ -493,6 +503,24 @@ margin-top: 16px; } +.defs-confirm { + margin-top: 12px; + 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; +} + +.defs-confirm p { + margin: 0; +} + +.defs-confirm .defs-sheet-footer { + margin-top: 10px; +} + .defs-cancel, .defs-save { padding: 13px 0; diff --git a/admin/src/definitions/DefinitionsTab.test.tsx b/admin/src/definitions/DefinitionsTab.test.tsx index 96c0654..1ae0d64 100644 --- a/admin/src/definitions/DefinitionsTab.test.tsx +++ b/admin/src/definitions/DefinitionsTab.test.tsx @@ -152,6 +152,14 @@ function fakeApi(initial: QuestDefinition[]) { title: body.title ?? d.title, skip_on_away: body.skip_on_away ?? d.skip_on_away, rule: body.rule ?? d.rule, + // Like the API: a sent list replaces the whole set; an absent one keeps it. + assignees: + "assignee_child_ids" in body + ? CHILDREN.filter((c) => body.assignee_child_ids.includes(c.id)).map((c) => ({ + id: c.id, + display_name: c.display_name, + })) + : d.assignees, } : d, ); @@ -360,9 +368,13 @@ describe("DefinitionsTab", () => { expect((within(s).getByLabelText("Afternoon due time") as HTMLInputElement).value).toBe( "16:00", ); - // The inactive child "Old" is not offered and not submitted. + // The inactive child "Old" is shown locked, not offered as a checkbox. fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ })); expect(within(s).queryByRole("checkbox", { name: "Old" })).toBeNull(); + const locked = within(s).getByTestId("inactive-assignee-4"); + expect(locked.textContent).toContain("Old"); + expect(locked.textContent).toContain("Inactive"); + expect(locked.getAttribute("aria-disabled")).toBe("true"); fireEvent.click(within(s).getByRole("checkbox", { name: "Maeve" })); fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), { @@ -382,8 +394,11 @@ describe("DefinitionsTab", () => { }); fireEvent.click(within(s).getByRole("switch", { name: "Skip on away days" })); + // Changing the selection would drop "Old": confirm before sending. + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + expect(api.writes()).toHaveLength(0); await act(async () => { - fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + fireEvent.click(within(s).getByRole("button", { name: "Unassign and save" })); }); expect(api.writes()).toEqual([ @@ -417,6 +432,125 @@ describe("DefinitionsTab", () => { expect(screen.queryByRole("dialog")).toBeNull(); }); + it("editing only the title omits assignee_child_ids and keeps an inactive assignee", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-11")); + const s = sheet(); + expect(within(s).getByText(/Also assigned \(inactive\): Old\./)).toBeTruthy(); + fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), { + target: { value: "Bins out" }, + }); + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + + expect(screen.queryByTestId("unassign-confirm")).toBeNull(); + expect(api.writes()).toHaveLength(1); + const body = api.writes()[0].body as Record; + expect(api.writes()[0].method).toBe("PATCH"); + expect(body.title).toBe("Bins out"); + expect(body).not.toHaveProperty("assignee_child_ids"); + + await screen.findByText("Bins out"); + expect(meta(screen.getByTestId("definition-11"))).toBe( + "Declan, Old · Every 2 weeks on Mon, Thu · Afternoon", + ); + }); + + it("changing the active selection with an inactive assignee asks first", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-11")); + const s = sheet(); + fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ })); + fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" })); + + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + const confirm = within(s).getByTestId("unassign-confirm"); + expect(confirm.textContent).toContain("Saving will unassign Old."); + expect(document.activeElement).toBe(confirm); + expect(api.writes()).toHaveLength(0); + + // Going back sends nothing and restores the normal footer. + fireEvent.click(within(confirm).getByRole("button", { name: "Go back" })); + expect(within(s).queryByTestId("unassign-confirm")).toBeNull(); + expect(api.writes()).toHaveLength(0); + + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Unassign and save" })); + }); + expect(api.writes()).toHaveLength(1); + expect((api.writes()[0].body as { assignee_child_ids: number[] }).assignee_child_ids).toEqual([ + 1, 3, + ]); + }); + + it("reverting the selection to the original omits assignee_child_ids without asking", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-11")); + const s = sheet(); + fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ })); + fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" })); + fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" })); + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + expect(screen.queryByTestId("unassign-confirm")).toBeNull(); + expect(api.writes()).toHaveLength(1); + expect(api.writes()[0].body).not.toHaveProperty("assignee_child_ids"); + }); + + it("focus moves into the sheet on open and returns to the row on Escape", async () => { + await renderReady(); + const row = screen.getByTestId("definition-10"); + row.focus(); + fireEvent.click(row); + const s = sheet(); + expect(document.activeElement).toBe(within(s).getByRole("heading", { name: "Edit task" })); + + fireEvent.keyDown(s, { key: "Escape" }); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(document.activeElement).toBe(row); + expect(api.writes()).toHaveLength(0); + }); + + it("Tab and Shift+Tab wrap within the sheet", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-10")); + const s = sheet(); + const title = within(s).getByRole("textbox", { name: "Title" }); + const save = within(s).getByRole("button", { name: "Save changes" }); + + save.focus(); + fireEvent.keyDown(s, { key: "Tab" }); + expect(document.activeElement).toBe(title); + + fireEvent.keyDown(s, { key: "Tab", shiftKey: true }); + expect(document.activeElement).toBe(save); + }); + + it("Escape does not close the sheet while saving", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-10")); + let release: (response: Response) => void = () => {}; + api.fetchMock.mockImplementationOnce( + () => new Promise((resolve) => (release = resolve)), + ); + const s = sheet(); + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + fireEvent.keyDown(s, { key: "Escape" }); + expect(screen.getByRole("dialog")).toBeTruthy(); + + await act(async () => { + release(jsonResponse(BRUSH)); + }); + await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + }); + it("the skip-on-away toggle reflects and persists a false value", async () => { await renderReady(); fireEvent.click(screen.getByTestId("definition-11")); diff --git a/admin/src/definitions/DefinitionsTab.tsx b/admin/src/definitions/DefinitionsTab.tsx index b136446..2bb80e6 100644 --- a/admin/src/definitions/DefinitionsTab.tsx +++ b/admin/src/definitions/DefinitionsTab.tsx @@ -4,7 +4,13 @@ * The icon picker (§3.3) and the occurrence preview are separate tasks: an * existing definition's icon is carried through unchanged by never sending it. */ -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { + useEffect, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, +} from "react"; import { ApiForbiddenError } from "../api/client"; import { ApiRequestError, @@ -14,6 +20,7 @@ import { updateDefinition, type AdminChild, type DefinitionCreateBody, + type DefinitionEditBody, type DefinitionRule, type QuestDefinition, type WindowEntry, @@ -202,10 +209,14 @@ function buildRule(draft: Draft): DefinitionRule | string { } } -function buildBody(draft: Draft): DefinitionCreateBody | string { +/** + * `keepsAssignees`: an edit whose active selection is unchanged sends no + * assignee list, so an empty selection is fine when inactive children remain. + */ +function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody | string { const title = draft.title.trim(); if (!title) return "Give the task a title."; - if (draft.assigneeIds.length === 0) return "Assign at least one child."; + if (draft.assigneeIds.length === 0 && !keepsAssignees) return "Assign at least one child."; if (draft.windows.length === 0) return "Pick at least one window."; const rule = buildRule(draft); if (typeof rule === "string") return rule; @@ -231,6 +242,13 @@ function toggle(list: T[], value: T): T[] { return list.includes(value) ? list.filter((item) => item !== value) : [...list, value]; } +function sameIds(a: number[], b: number[]): boolean { + return a.length === b.length && a.every((id) => b.includes(id)); +} + +const FOCUSABLE = + 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'; + /* ── List ───────────────────────────────────────────────────────────── */ function FilterChips({ @@ -334,11 +352,58 @@ function EditSheet({ const [draft, setDraft] = useState(() => target === "new" ? newDraft(activeChildren) : draftFrom(target, activeChildren), ); + // The active selection as loaded: while it is unchanged, the PATCH omits + // assignee_child_ids, because the API replaces the whole set and inactive + // children cannot be sent back. + const [originalActiveIds] = useState(() => draft.assigneeIds); + const inactiveAssignees = + target === "new" + ? [] + : target.assignees.filter((a) => !activeChildren.some((child) => child.id === a.id)); const [pickerOpen, setPickerOpen] = useState(false); const [saving, setSaving] = useState(false); + const [confirming, setConfirming] = useState(false); const [error, setError] = useState(null); // A ref, not state: two taps in one frame must not both see `saving === false`. const inFlight = useRef(false); + const sheetRef = useRef(null); + const titleRef = useRef(null); + const confirmRef = useRef(null); + + // Modal focus: move in on open, hand back to the trigger on close. + useEffect(() => { + const trigger = document.activeElement as HTMLElement | null; + titleRef.current?.focus(); + return () => { + if (trigger && trigger.isConnected) trigger.focus(); + }; + }, []); + + useEffect(() => { + if (confirming) confirmRef.current?.focus(); + }, [confirming]); + + function onKeyDown(event: ReactKeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + if (!inFlight.current) onCancel(); + return; + } + if (event.key !== "Tab" || !sheetRef.current) return; + const focusable = Array.from(sheetRef.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 update = (patch: Partial) => setDraft((d) => ({ ...d, ...patch })); @@ -347,19 +412,32 @@ function EditSheet({ .map((child) => ({ id: child.id, display_name: child.display_name })); const orderedWindows = WINDOW_ORDER.filter((w) => draft.windows.includes(w)); - async function save() { + async function save(confirmed = false) { if (inFlight.current) return; - const body = buildBody(draft); + const keepsAssignees = target !== "new" && sameIds(draft.assigneeIds, originalActiveIds); + const body = buildBody(draft, keepsAssignees); if (typeof body === "string") { setError(body); + setConfirming(false); return; } + if (target !== "new" && !keepsAssignees && inactiveAssignees.length > 0 && !confirmed) { + setError(null); + setConfirming(true); + return; + } + setConfirming(false); inFlight.current = true; setSaving(true); setError(null); try { - if (target === "new") await createDefinition(body); - else await updateDefinition(target.id, body); + if (target === "new") { + await createDefinition(body); + } else { + const edit: DefinitionEditBody = { ...body }; + if (keepsAssignees) delete edit.assignee_child_ids; + await updateDefinition(target.id, edit); + } onSaved(); } catch (err) { setError(saveErrorMessage(err)); @@ -378,13 +456,15 @@ function EditSheet({ }} />
    ); -- 2.55.0