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..1b9f425 --- /dev/null +++ b/admin/src/definitions/DefinitionsTab.css @@ -0,0 +1,552 @@ +/* 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-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; +} + +.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-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; + 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..1ae0d64 --- /dev/null +++ b/admin/src/definitions/DefinitionsTab.test.tsx @@ -0,0 +1,644 @@ +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, + // 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, + ); + 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 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" }), { + 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" })); + + // 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: "Unassign and save" })); + }); + + 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("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")); + 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..2bb80e6 --- /dev/null +++ b/admin/src/definitions/DefinitionsTab.tsx @@ -0,0 +1,853 @@ +/** + * 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 KeyboardEvent as ReactKeyboardEvent, + type ReactNode, +} from "react"; +import { ApiForbiddenError } from "../api/client"; +import { + ApiRequestError, + createDefinition, + fetchChildren, + fetchDefinitions, + updateDefinition, + type AdminChild, + type DefinitionCreateBody, + type DefinitionEditBody, + 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 }; + } + } +} + +/** + * `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 && !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; + 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]; +} + +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({ + 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), + ); + // 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 })); + + 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(confirmed = false) { + if (inFlight.current) return; + 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 { + const edit: DefinitionEditBody = { ...body }; + if (keepsAssignees) delete edit.assignee_child_ids; + await updateDefinition(target.id, edit); + } + 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; } * {