@@ -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();
+ }}
+ />
+
+
+
+ {target === "new" ? "New task" : "Edit task"}
+
+
+
+ update({ title: e.target.value })}
+ />
+
+
+
+
+ {pickerOpen ? (
+
+ {activeChildren.map((child) => {
+ const checked = draft.assigneeIds.includes(child.id);
+ return (
+ -
+
+
+ );
+ })}
+
+ ) : null}
+
+
+
+
+
+
+
+ {draft.repeats === "weekly" || draft.repeats === "custom" ? (
+
+ {WEEKDAY_SHORT.map((label, day) => (
+
+ ))}
+
+ ) : null}
+ {draft.repeats === "monthly" && draft.monthlyWeekday ? (
+
+ {recurrenceSummary({
+ rule_type: "monthly_weekday",
+ interval: parseWhole(draft.interval, 1, 999) ?? 1,
+ weekday_set: null,
+ day_of_month: null,
+ nth_weekday: draft.monthlyWeekday.nth,
+ nth_weekday_weekday: draft.monthlyWeekday.weekday,
+ month: null,
+ start_date: draft.startDate,
+ end_date: draft.endDate,
+ })}
+
+ ) : null}
+ {draft.repeats === "yearly" ? (
+
+ ) : null}
+ {(draft.repeats === "monthly" && !draft.monthlyWeekday) || draft.repeats === "yearly" ? (
+ update({ dayOfMonth: e.target.value })}
+ />
+ ) : null}
+
+
+
+ {orderedWindows.length === 0 ? (
+ Pick a window to set its due time.
+ ) : (
+
+ {orderedWindows.map((w) => (
+
+ ))}
+
+ )}
+
+
+
+
+ {WINDOW_ORDER.map((w) => (
+
+ ))}
+
+
+
+
+
+
+ Skip on away days
+
+
No instance when the child is absent
+
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
+
+
+
+ );
+}
+
+/* ── 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 (
+
+
+
+
+
+
+ {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;
}
* {