~6UYoyIVQ>mUDj+}j|U@PN4W
zQn&t>@D&S@71Z-3!*f2K<}*^;psD<31X9SB&!b#9MeNcRFa!Cx9Rk@20r0vn7ayj3
zqL&Bv0X}Y4$%bAtxF?ar&@+RRHPM`C;|g)-gDd*GRNa1($*oZZH8NBwAh0ko(>EqGh&tSbu>iunL35v&G$F#~fw#5TL_jUc-%SJr=wN*Tv<T6^)EoQAT=Y6W3;0V4sw1#U$6sxh}s^^P4G8zV1XF
zQpS^<_rL^(N#xPKI+E%!pgp)4!9wPFxa*W=jJ>_)FbgSVDj{VOp{b(Wu6c$L*iG^`
z{dAPB)Edih(1=h7p4s
zNKu5Xn3a+|dTm2qFaQO$J|KpLF8~M4miaM|-Iq8uLl_}vA9n=Sj>|>p{6flnauHl&
zq*MX?6Ql{if1F3%529@-Q?!`9Z7&T8UZ7+Y2A}
zWKw+tsErc2RX`}%OqR@~=dSL-h?zg3$JCnH@eDdkQhIMlDLH#cF9VBb7)KU*^hU8)
z52?t(gPao_nkV7B7mznWy$uSy3Q%HI*Q(tem&0>AAHfJ(BjI>Uj*}j?yrH&cd?^^uqE!&A!kjD^&3I#e1K&E0kybd+dRBVgoD`ZIL!w;ZV?8{2
zXh|9cquizu^H%B-B-;Tin6QCNGZAk$NOpJ-+NO+)Vsr>2h1fArdRh3z%r*9mCcw6KEyVogK^VCh?eBWjy%}PPt79fw
z@Vp?sLbszBh^q?Vi%*kn(l#jh-Sk#GCUPl4pE5xoJ
z!z}XfA3V&A^Kh=;zUu@G`(Roj!$oVTZmAR&7=BRh2EP}Jh8X`#N-;K`OG@_0^LDP!m)#feb;@ea38RQutrWCG26D(sCV
zC7@$I=%^bhjR+3UmgfL^6w(m%S-1x>GQ`qQ-C)UfP>53@dvIPbNxWJ&U0NL!{)yZO
z+d)UU5wy#Ikm)*??7EHWRMAr{rG|+N3jacG>d^HCvITxG!gS6Jlk|JLgUgh5Mu*x5
zk(y`4$jobC|HABd86ubu@bpE^6neY~5UHE7QlrGY+#e%5k0m&G(abVa+~*&^228SAgHV(+ePvXJ!Deq>wt2aII4=OkN=_5UZ?lNToa#9Hq|8Z=tE{EBRA5)&Z~s
z5JfIwHAset@u9k8$#=i*HK_=kIk*u^La>FaV|bN3AQ)V!0JcqVA*gC*<5}+|<+&E?
zIvQ|bn-DwZWcCR7CJ3`qwYiAfMuQXT3NS@k6i6>Y{t?uBsjAaSoHazZUdjy&-$DI(
zyU4ADM3L73PWeeaUy9g+czy_T9DJ$*x;{_F_5}b!!vOZs&*%`HPD&32r-=k(&!K~g
zIfAiAMia*CttPwebGfW>3q&WXIbW<@d*9{*Ty<#jG-8?q28(oZPzf0GxlPOoE58f8
zpM41wPnNW>{aJL!FtOrn##JGwCtylFHvr(t!nbMyMiz64q4{J4a9t*ln{8zEM6yNX
z6ST}}2j{yi#D;_>&!osPjE3)acxF@$b#m9<>@67=kWnlt0B|C&q0UzJ+%&NKKqx;<
zN+$8=u21UOmx4a=^)smMC=E*7r%M!TU)H%Of|S`YM@Bj|#gzPSg6HtNg;*3!!cmR!
zAXsr^IaeMe6i|B>%IToma528i*UOJOb>!PV$yX|>C!Rso0K5?^_mF8b1L{ALI}We^
zo7=Nty5s^a8|f@5uYr{854BGx)p@B4H0FM_Y8%vxGpY5VRRIV)<~%P3dKF!J8inIcK15HOK>8Y%e(1Msg)L7!w;fDHJL3R)Rp7NSvw!I2eIVX6%}A
zWEFhsNO&l%m}+G1D+svn?3%}c_&72Oi1(LFz;%kqufKWm!@xY`ze0c(Jm_cSJZ>Bb7mo8N5-#$u-@MqkO(FL-O_qZpx);Jt7Wp{}c+%WvSV6xr>J}Oa+~broGo
zY)5p5*G~q$xHSB*e0t1qF|7#4r0xmC`s0_&|M9xh_DgOd7l-pw_^dde(bPrQ&lRaR
z%ex&s3ru23YQU3qx;!yTO2)z%!Jwzdov(7arjq*~43;1{0|o%wSNlG>>+LTEcgth-
z{4|=&%3b7Ko#Xn#zONk%EVtV%5m=~W%=_v|YFcQ5eyQa19dQ8UVnr4wde3E)4cohy
ztJP@1&cp<5H&`*vmT@Y6GI>Pvs8nsmLfX5Z+aEN&waH_;easAlORP0hq!h!aKi8dO
zpv5;i5#UDH5jvY;;DSbIvl~^}FmAX2ds*qJuVGT+18XnzSn&-lc=ZMs-GSo41XgT1
zd(Cc`OFbz^?t9U0m`7Lx_)U|LrxGk|Id%DRxi7Ri5e?|<5mwsN;6l<*w2LCGw||=g
zkIl3l=hCV&^QTW@rPWUCK1}<_x)ad8fj@!yOb2+3f@T+OwnPWNi+lM8XNYH^e
zNpJaJhb*|`ly1qJ+GSs;8JyGINVIe~;U1b@4!$0x4A`_T^8M}bemmcS;m0r!y$#@|
zOUuHCOAuErE;NsW6pK*5JD`&X{J{AOS7Z7?7b9xGG;!t1{Xn{h@131x0Y+@DG8fS3
zxjutqA@s-rr8|P5&newwBJ^M@Xn#~n4%^Aq+uJtu#x<9jHl^gKT^o=Iy}Q6aE%DDH
zd#*-|{KnGktIhe@Kp(^^vlVqINxZhv*HRBmA%B8laU_*@>mgTCGu(S*(FV@}MQK*e
z?&`oWFEH7zO#2}n*xcvu$owZ>`H@%u7cR{s$}30pCtl2G?)g}`r(h&Qqo}(WcgKYa
z6~x0QVG7Dohj>D=-tiL)(HMNU{&|6QcTTK~0Kq4{bZVhE^Z+G)QE84V^HqG{A9SOq
z!}yE6JJ9&URW7}Epg~e|*LSL0@7aa9mSZV|Em`%~_bM#AV&I< {
+ event.waitUntil(
+ caches
+ .open(SHELL_CACHE)
+ .then((cache) => cache.addAll(SHELL_URLS))
+ .then(() => self.skipWaiting()),
+ );
+});
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ caches
+ .keys()
+ .then((keys) =>
+ Promise.all(
+ keys
+ .filter((key) => key.startsWith(CACHE_PREFIX) && !CURRENT_CACHES.includes(key))
+ .map((key) => caches.delete(key)),
+ ),
+ )
+ .then(() => self.clients.claim()),
+ );
+});
+
+self.addEventListener("fetch", (event) => {
+ const request = event.request;
+ if (request.method !== "GET") return;
+ if (new URL(request.url).origin !== self.location.origin) return;
+
+ if (request.mode === "navigate") {
+ event.respondWith(
+ fetch(request).catch(() =>
+ caches.match("/index.html", { cacheName: SHELL_CACHE }).then(
+ (cached) => cached || Response.error(),
+ ),
+ ),
+ );
+ return;
+ }
+
+ if (STATIC_DESTINATIONS.includes(request.destination)) {
+ event.respondWith(cacheFirst(request));
+ }
+});
+
+async function cacheFirst(request) {
+ const cached = await caches.match(request);
+ if (cached) return cached;
+ const response = await fetch(request);
+ if (response.ok && response.type === "basic") {
+ const cache = await caches.open(RUNTIME_CACHE);
+ await cache.put(request, response.clone());
+ }
+ return response;
+}
diff --git a/admin/scripts/generate-icons.mjs b/admin/scripts/generate-icons.mjs
new file mode 100644
index 0000000..89cd01f
--- /dev/null
+++ b/admin/scripts/generate-icons.mjs
@@ -0,0 +1,80 @@
+// Generates the committed PWA icons under public/icons/ as genuine PNGs.
+// Pure Node (zlib only) — no network, no image dependencies.
+// Run: node scripts/generate-icons.mjs
+import { writeFileSync } from "node:fs";
+import { deflateSync } from "node:zlib";
+import { fileURLToPath } from "node:url";
+
+// Brand tokens (design/tokens/nestquest-admin-tokens.css).
+const BLUE = [0x3b, 0x3a, 0xb8];
+const PURPLE = [0xa0, 0x35, 0xcc];
+const WHITE = [0xff, 0xff, 0xff];
+
+const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
+ let c = n;
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ return c >>> 0;
+});
+
+function crc32(buf) {
+ let c = 0xffffffff;
+ for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
+ return (c ^ 0xffffffff) >>> 0;
+}
+
+function chunk(type, data) {
+ const len = Buffer.alloc(4);
+ len.writeUInt32BE(data.length);
+ const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
+ const crc = Buffer.alloc(4);
+ crc.writeUInt32BE(crc32(body));
+ return Buffer.concat([len, body, crc]);
+}
+
+function encodePng(size, pixel) {
+ const ihdr = Buffer.alloc(13);
+ ihdr.writeUInt32BE(size, 0);
+ ihdr.writeUInt32BE(size, 4);
+ ihdr[8] = 8; // bit depth
+ ihdr[9] = 2; // colour type: RGB
+ const raw = Buffer.alloc(size * (size * 3 + 1));
+ for (let y = 0; y < size; y++) {
+ const row = y * (size * 3 + 1);
+ raw[row] = 0; // filter: none
+ for (let x = 0; x < size; x++) raw.set(pixel(x, y), row + 1 + x * 3);
+ }
+ return Buffer.concat([
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
+ chunk("IHDR", ihdr),
+ chunk("IDAT", deflateSync(raw, { level: 9 })),
+ chunk("IEND", Buffer.alloc(0)),
+ ]);
+}
+
+// Flat brand mark: diagonal blue→purple gradient with a white ring ("nest")
+// around a white dot ("quest"). `scale` shrinks the mark for the maskable
+// safe zone (inner 80% circle).
+function brandMark(size, scale) {
+ const c = size / 2;
+ const outer = size * 0.34 * scale;
+ const inner = size * 0.24 * scale;
+ const dot = size * 0.1 * scale;
+ return (x, y) => {
+ const d = Math.hypot(x + 0.5 - c, y + 0.5 - c);
+ if ((d <= outer && d >= inner) || d <= dot) return WHITE;
+ const t = (x + y) / (2 * (size - 1));
+ return BLUE.map((b, i) => Math.round(b + (PURPLE[i] - b) * t));
+ };
+}
+
+const outDir = new URL("../public/icons/", import.meta.url);
+const icons = [
+ ["icon-192.png", 192, 1],
+ ["icon-512.png", 512, 1],
+ ["icon-maskable-512.png", 512, 0.75],
+];
+for (const [name, size, scale] of icons) {
+ const path = fileURLToPath(new URL(name, outDir));
+ writeFileSync(path, encodePng(size, brandMark(size, scale)));
+ console.log(`wrote ${path}`);
+}
diff --git a/admin/src/App.css b/admin/src/App.css
new file mode 100644
index 0000000..4f8fbcc
--- /dev/null
+++ b/admin/src/App.css
@@ -0,0 +1,123 @@
+/* Shell layout — mobile-first (390x844 reference), plain CubeCraft styling. */
+
+.admin-shell {
+ display: flex;
+ flex-direction: column;
+ min-height: 100%;
+}
+
+.admin-header {
+ background: var(--nq-a-surface);
+ border-bottom: 1px solid var(--nq-a-border);
+ padding: 14px 16px;
+}
+
+.admin-title {
+ margin: 0;
+ font-size: 24px;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+}
+
+.admin-subline {
+ margin: 2px 0 0;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--nq-a-ink-secondary);
+}
+
+.admin-panel {
+ flex: 1;
+ padding: 14px 16px 92px; /* bottom padding clears the 70px tab bar */
+}
+
+/* A built screen supplies its own header and 92px-padded scroll column. */
+.admin-panel--screen {
+ padding: 0;
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.admin-tabbar {
+ position: fixed;
+ inset: auto 0 0;
+ display: flex;
+ min-height: calc(var(--nq-a-tabbar-height) + 12px);
+ padding: 8px 4px calc(12px + env(safe-area-inset-bottom));
+ background: var(--nq-a-surface);
+ border-top: 1px solid var(--nq-a-border);
+}
+
+.admin-tab {
+ flex: 1;
+ border: 0;
+ background: none;
+ font-family: inherit;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--nq-a-ink-secondary);
+ cursor: pointer;
+ padding: 6px 4px;
+}
+
+.admin-tab.active {
+ font-weight: 700;
+ color: var(--nq-brand-blue);
+}
+/* Auth screens (login, errors, refusal). */
+
+.admin-login,
+.admin-error,
+.admin-refusal,
+.admin-loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ min-height: 100%;
+ padding: 32px 16px;
+ text-align: center;
+}
+
+.admin-login-button {
+ margin-top: 8px;
+ padding: 12px 24px;
+ border: none;
+ border-radius: 8px;
+ background: var(--nq-a-accent, #2563eb);
+ color: #fff;
+ font-size: 15px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.admin-error,
+.admin-refusal {
+ color: var(--nq-a-ink, inherit);
+}
+
+.admin-refusal h1,
+.admin-error h1 {
+ margin: 0;
+ font-size: 20px;
+}
+
+.admin-refusal p,
+.admin-error p {
+ margin: 0;
+ max-width: 34em;
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--nq-a-ink-secondary);
+}
diff --git a/admin/src/App.test.tsx b/admin/src/App.test.tsx
new file mode 100644
index 0000000..7a38e6a
--- /dev/null
+++ b/admin/src/App.test.tsx
@@ -0,0 +1,47 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import App from "./App";
+
+describe("NestQuest Admin shell", () => {
+ beforeEach(() => {
+ // The Today tab fetches the admin snapshot; keep the shell tests offline.
+ vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {})));
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ });
+
+ it("renders the title and all four tab labels", () => {
+ render( );
+
+ expect(screen.getByRole("heading", { name: "NestQuest Admin" })).toBeTruthy();
+ for (const label of ["Today", "Tasks", "Schedule", "History"]) {
+ expect(screen.getByRole("tab", { name: label })).toBeTruthy();
+ }
+ });
+
+ it("starts on the Today screen and switches on tab click", () => {
+ render( );
+
+ expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy();
+
+ fireEvent.click(screen.getByRole("tab", { name: "History" }));
+
+ expect(screen.getByRole("heading", { name: "History" })).toBeTruthy();
+ expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder");
+ expect(screen.getByRole("tab", { name: "History" }).getAttribute("aria-selected")).toBe(
+ "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
new file mode 100644
index 0000000..5f44fad
--- /dev/null
+++ b/admin/src/App.tsx
@@ -0,0 +1,63 @@
+import { useState } from "react";
+import "./App.css";
+import { TransitionsProvider } from "./api/useTransitions";
+import DefinitionsTab from "./definitions/DefinitionsTab";
+import HistoryTab from "./history/HistoryTab";
+import ScheduleTab from "./schedule/ScheduleTab";
+import TodayTab from "./today/TodayTab";
+
+const TABS = [
+ { id: "today", label: "Today" },
+ { id: "tasks", label: "Tasks" },
+ { id: "schedule", label: "Schedule" },
+ { id: "history", label: "History" },
+] as const;
+
+type TabId = (typeof TABS)[number]["id"];
+
+export default function App() {
+ const [active, setActive] = useState("today");
+
+ return (
+
+
+ {/* Every tab is a built screen that owns its header (ADMIN-SPEC §1); the
+ generic shell header stays only as a visually hidden page heading. */}
+
+ NestQuest Admin
+ Admin console
+
+
+ {active === "today" ? (
+
+ ) : active === "tasks" ? (
+
+ ) : active === "schedule" ? (
+
+ ) : (
+
+ )}
+
+
+ {TABS.map((tab) => (
+ setActive(tab.id)}
+ >
+ {tab.label}
+
+ ))}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/admin/src/AppRoutes.test.tsx b/admin/src/AppRoutes.test.tsx
new file mode 100644
index 0000000..5ba0287
--- /dev/null
+++ b/admin/src/AppRoutes.test.tsx
@@ -0,0 +1,193 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import AppRoutes from "./AppRoutes";
+import { storeTokens } from "./auth/oidc";
+import { config } from "./config";
+
+// The bundled config reads real env at import time; seed the shared module's
+// clientId directly so AppRoutes sees a configured app in these tests.
+(config as { clientId: string | undefined }).clientId = "test-client-id";
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+describe("AppRoutes auth gating", () => {
+ afterEach(() => {
+ cleanup();
+ sessionStorage.clear();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ window.history.replaceState({}, "", "/");
+ });
+
+ it("renders the login screen when unauthenticated at /", () => {
+ render( );
+ expect(screen.getByTestId("login-screen")).toBeTruthy();
+ expect(
+ screen.getByRole("button", { name: /sign in with authentik/i }),
+ ).toBeTruthy();
+ });
+
+ function probeCalls(fetchMock: ReturnType): string[] {
+ return fetchMock.mock.calls
+ .map(([url]) => String(url))
+ .filter((url) => url.includes("/api/"));
+ }
+
+ it("probes exactly GET /api/v1/admin/ping and no non-admin path", async () => {
+ storeTokens({ access_token: "at-123", expires_in: 600 });
+ const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 500 }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("probe-error-screen")).toBeTruthy();
+ });
+ const calls = probeCalls(fetchMock);
+ expect(calls).toEqual([`${config.apiBaseUrl}/api/v1/admin/ping`]);
+ const [, init] = fetchMock.mock.calls[0];
+ expect(init.method ?? "GET").toBe("GET");
+ expect(init.headers.Authorization).toBe("Bearer at-123");
+ for (const url of calls) {
+ expect(new URL(url, "http://localhost").pathname.startsWith("/api/v1/admin/")).toBe(true);
+ }
+ });
+
+ it("renders the four-tab shell after a successful authenticated probe", async () => {
+ storeTokens({ access_token: "at-123", expires_in: 600 });
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
+ vi.stubGlobal("fetch", fetchMock);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: "NestQuest Admin" })).toBeTruthy();
+ });
+ for (const label of ["Today", "Tasks", "Schedule", "History"]) {
+ expect(screen.getByRole("tab", { name: label })).toBeTruthy();
+ }
+ expect(String(fetchMock.mock.calls[0][0])).toBe(
+ `${config.apiBaseUrl}/api/v1/admin/ping`,
+ );
+ });
+
+ it("renders the explicit refusal when the API answers 403", async () => {
+ storeTokens({ access_token: "at-123", expires_in: 600 });
+ const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("refusal-screen")).toBeTruthy();
+ });
+ expect(screen.getByText(/not in the nestquest-admins group/i)).toBeTruthy();
+ expect(screen.queryByRole("tab", { name: "Today" })).toBeNull();
+ });
+
+ it("keeps the login flow when the probe answers 401", async () => {
+ storeTokens({ access_token: "stale", expires_in: 600 });
+ const assign = vi.fn();
+ vi.stubGlobal("location", { ...window.location, assign });
+ const fetchMock = vi.fn().mockImplementation(async (url: string) =>
+ String(url).includes("/api/v1/admin/ping")
+ ? new Response("", { status: 401 })
+ : jsonResponse({
+ issuer: config.issuer,
+ authorization_endpoint: "https://auth.example.com/authorize/",
+ token_endpoint: "https://auth.example.com/token/",
+ userinfo_endpoint: "https://auth.example.com/userinfo/",
+ end_session_endpoint: "https://auth.example.com/end-session/",
+ jwks_uri: "https://auth.example.com/jwks/",
+ }),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("login-screen")).toBeTruthy();
+ });
+ expect(assign).toHaveBeenCalledTimes(1);
+ const redirect = new URL(assign.mock.calls[0][0] as string);
+ expect(redirect.origin + redirect.pathname).toBe("https://auth.example.com/authorize/");
+ expect(probeCalls(fetchMock)).toEqual([`${config.apiBaseUrl}/api/v1/admin/ping`]);
+ expect(screen.queryByRole("tab", { name: "Today" })).toBeNull();
+ expect(screen.queryByTestId("probe-error-screen")).toBeNull();
+ });
+
+ for (const status of [404, 500]) {
+ it(`shows the error screen, not the shell, when the probe answers ${status}`, async () => {
+ storeTokens({ access_token: "at-123", expires_in: 600 });
+ const fetchMock = vi.fn().mockResolvedValue(new Response("", { status }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("probe-error-screen")).toBeTruthy();
+ });
+ expect(screen.getByText(new RegExp(`answered ${status}`))).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Sign out" })).toBeTruthy();
+ expect(screen.queryByRole("tab", { name: "Today" })).toBeNull();
+ expect(screen.queryByTestId("refusal-screen")).toBeNull();
+ });
+ }
+
+ it("shows the error screen, not the shell, when the probe request fails", async () => {
+ storeTokens({ access_token: "at-123", expires_in: 600 });
+ const fetchMock = vi.fn().mockRejectedValue(new TypeError("Failed to fetch"));
+ vi.stubGlobal("fetch", fetchMock);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("probe-error-screen")).toBeTruthy();
+ });
+ expect(screen.getByText(/could not be reached/i)).toBeTruthy();
+ expect(screen.queryByRole("tab", { name: "Today" })).toBeNull();
+ });
+
+ it("re-probes on retry and enters the shell once the probe succeeds", async () => {
+ storeTokens({ access_token: "at-123", expires_in: 600 });
+ // After the retry the ping succeeds; the shell's own tab loads stay pending.
+ let pings = 0;
+ const fetchMock = vi.fn().mockImplementation((url: string) => {
+ if (!String(url).endsWith("/api/v1/admin/ping")) return new Promise(() => {});
+ pings += 1;
+ return Promise.resolve(new Response("", { status: pings === 1 ? 500 : 200 }));
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("probe-error-screen")).toBeTruthy();
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("tab", { name: "Today" })).toBeTruthy();
+ });
+ expect(String(fetchMock.mock.calls[1][0])).toBe(
+ `${config.apiBaseUrl}/api/v1/admin/ping`,
+ );
+ });
+
+ it("shows the configuration error screen when the client id is missing", () => {
+ const original = config.clientId;
+ (config as { clientId: string | undefined }).clientId = undefined;
+ try {
+ render( );
+ expect(screen.getByTestId("config-error-screen")).toBeTruthy();
+ } finally {
+ (config as { clientId: string | undefined }).clientId = original;
+ }
+ });
+});
diff --git a/admin/src/AppRoutes.tsx b/admin/src/AppRoutes.tsx
new file mode 100644
index 0000000..9959c19
--- /dev/null
+++ b/admin/src/AppRoutes.tsx
@@ -0,0 +1,211 @@
+/**
+ * Minimal path-based routing without react-router-dom: the app only needs two
+ * paths (`/` shell and `/auth/callback`), so a tiny window.location switch is
+ * enough and keeps the bundle small. Change screens listen to popstate.
+ */
+import { useEffect, useState } from "react";
+import { ConfigError, config } from "./config";
+import {
+ AuthError,
+ buildAuthorizeUrl,
+ clearAuth,
+ handleCallback,
+ isAuthenticated,
+} from "./auth/oidc";
+import { ApiForbiddenError, ApiUnauthorizedError, apiFetch } from "./api/client";
+import AdminShell from "./App";
+
+type Screen =
+ | { kind: "loading" }
+ | { kind: "login" }
+ | { kind: "config-error"; message: string }
+ | { kind: "auth-error"; message: string }
+ | { kind: "refusal"; message: string }
+ | { kind: "probe-error"; message: string }
+ | { kind: "app" };
+
+function currentPath(): string {
+ return window.location.pathname.replace(/\/+$/, "") || "/";
+}
+
+const ADMIN_PROBE_PATH = "/api/v1/admin/ping";
+
+async function probeApi(setScreen: (screen: Screen) => void) {
+ // The admin plane's authorization probe: only a 2xx enters the shell, so a
+ // non-admin (403) sees the explicit refusal and any other failure an error.
+ try {
+ const response = await apiFetch(ADMIN_PROBE_PATH, {}, config);
+ if (response.ok) {
+ setScreen({ kind: "app" });
+ return;
+ }
+ setScreen({
+ kind: "probe-error",
+ message: `The admin API answered ${response.status}. The admin console cannot load.`,
+ });
+ } catch (error) {
+ if (error instanceof ConfigError) {
+ setScreen({ kind: "config-error", message: error.message });
+ return;
+ }
+ if (error instanceof ApiForbiddenError) {
+ setScreen({ kind: "refusal", message: error.message });
+ return;
+ }
+ if (error instanceof ApiUnauthorizedError) {
+ // apiFetch has already started the login redirect.
+ setScreen({ kind: "login" });
+ return;
+ }
+ setScreen({
+ kind: "probe-error",
+ message: "The admin API could not be reached. Check your connection and try again.",
+ });
+ }
+}
+
+function LoginScreen({ onLogin }: { onLogin: () => void }) {
+ return (
+
+
NestQuest Admin
+
Sign in to continue
+
+ Sign in with Authentik
+
+
+ );
+}
+
+export default function AppRoutes() {
+ const [screen, setScreen] = useState({ kind: "loading" });
+
+ useEffect(() => {
+ // Re-evaluate on navigation (popstate) — routing is path based.
+ const onPop = () => setScreen({ kind: "loading" });
+ window.addEventListener("popstate", onPop);
+ return () => window.removeEventListener("popstate", onPop);
+ }, []);
+
+ useEffect(() => {
+ let cancelled = false;
+ const path = currentPath();
+
+ if (!config.clientId) {
+ setScreen({
+ kind: "config-error",
+ message:
+ "Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
+ });
+ return;
+ }
+
+ if (path === "/auth/callback") {
+ (async () => {
+ try {
+ await handleCallback(window.location.search, config);
+ if (cancelled) return;
+ window.history.replaceState({}, "", "/");
+ await probeApi((next) => !cancelled && setScreen(next));
+ } catch (error) {
+ if (cancelled) return;
+ const message =
+ error instanceof AuthError || error instanceof Error
+ ? error.message
+ : "Authentication failed.";
+ setScreen({ kind: "auth-error", message });
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }
+
+ if (isAuthenticated()) {
+ void probeApi((next) => !cancelled && setScreen(next));
+ } else {
+ setScreen({ kind: "login" });
+ }
+ return () => {
+ cancelled = true;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const startLogin = async () => {
+ try {
+ const url = await buildAuthorizeUrl(config);
+ window.location.assign(url);
+ } catch (error) {
+ setScreen({
+ kind: "config-error",
+ message: error instanceof Error ? error.message : "Login failed.",
+ });
+ }
+ };
+
+ switch (screen.kind) {
+ case "loading":
+ return (
+
+ Loading…
+
+ );
+ case "login":
+ return ;
+ case "config-error":
+ return (
+
+
Configuration error
+
{screen.message}
+
+ );
+ case "auth-error":
+ return (
+
+
Sign-in failed
+
{screen.message}
+
{
+ clearAuth();
+ window.history.replaceState({}, "", "/");
+ setScreen({ kind: "loading" });
+ }}
+ >
+ Try again
+
+
+ );
+ case "refusal":
+ return (
+
+
Access refused
+
{screen.message}
+
{ clearAuth(); window.history.replaceState({}, "", "/"); setScreen({ kind: "login" }); }}>
+ Sign out
+
+
+ );
+ case "probe-error":
+ return (
+
+
Admin console unavailable
+
{screen.message}
+
{
+ setScreen({ kind: "loading" });
+ void probeApi(setScreen);
+ }}
+ >
+ Retry
+
+
{ clearAuth(); window.history.replaceState({}, "", "/"); setScreen({ kind: "login" }); }}>
+ Sign out
+
+
+ );
+ case "app":
+ return ;
+ }
+}
diff --git a/admin/src/api/client.test.ts b/admin/src/api/client.test.ts
new file mode 100644
index 0000000..9cd828a
--- /dev/null
+++ b/admin/src/api/client.test.ts
@@ -0,0 +1,94 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ ApiForbiddenError,
+ ApiUnauthorizedError,
+ apiFetch,
+} from "./client";
+import { storeTokens } from "../auth/oidc";
+import type { AppConfig } from "../config";
+
+const app: AppConfig = {
+ issuer: "https://auth.example.com/application/o/nestquest",
+ clientId: "test-client-id",
+ apiBaseUrl: "https://api.example.com",
+ authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
+ tokenEndpoint: "https://auth.example.com/application/o/token/",
+ userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
+ endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
+ redirectUri: "http://localhost:5173/auth/callback",
+};
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+describe("API client", () => {
+ beforeEach(() => {
+ sessionStorage.clear();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it("attaches the Bearer token to API calls", async () => {
+ storeTokens({ access_token: "at-123", expires_in: 600 });
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
+
+ await apiFetch("/api/v1/children", {}, app, fetchMock as any);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe("https://api.example.com/api/v1/children");
+ expect(init.headers.Authorization).toBe("Bearer at-123");
+ });
+
+ it("throws ApiForbiddenError on 403 (authenticated but refused)", async () => {
+ storeTokens({ access_token: "at-123", expires_in: 600 });
+ const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 }));
+
+ await expect(
+ apiFetch("/api/v1/children", {}, app, fetchMock as any),
+ ).rejects.toBeInstanceOf(ApiForbiddenError);
+ });
+
+ it("triggers a login redirect on 401 with no refresh token", async () => {
+ storeTokens({ access_token: "stale", expires_in: 600 });
+ const assign = vi.fn();
+ vi.stubGlobal("location", { ...window.location, assign });
+ const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 401 }));
+
+ await expect(
+ apiFetch("/api/v1/children", {}, app, fetchMock as any),
+ ).rejects.toBeInstanceOf(ApiUnauthorizedError);
+ expect(assign).toHaveBeenCalledTimes(1);
+ const url = new URL(assign.mock.calls[0][0] as string);
+ expect(url.origin + url.pathname).toBe(
+ "https://auth.example.com/application/o/authorize/",
+ );
+ expect(url.searchParams.get("code_challenge_method")).toBe("S256");
+ });
+
+ it("preserves non-401/403 statuses on the post-refresh retry (500 is not mislabeled)", async () => {
+ storeTokens({ access_token: "stale", refresh_token: "rt-1", expires_in: 600 });
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(new Response("", { status: 401 })) // initial API call
+ .mockResolvedValueOnce(
+ jsonResponse({ access_token: "fresh", expires_in: 600 }), // refresh grant
+ )
+ .mockResolvedValueOnce(new Response("", { status: 500 })); // retry
+
+ const response = await apiFetch("/api/v1/children", {}, app, fetchMock as any);
+
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ expect(fetchMock.mock.calls[1][0]).toBe(app.tokenEndpoint);
+ expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe("Bearer fresh");
+ // A server failure must surface as the real status, not ApiUnauthorizedError.
+ expect(response.status).toBe(500);
+ });
+});
diff --git a/admin/src/api/client.ts b/admin/src/api/client.ts
new file mode 100644
index 0000000..4ad468b
--- /dev/null
+++ b/admin/src/api/client.ts
@@ -0,0 +1,93 @@
+/**
+ * Minimal fetch wrapper for the NestQuest API.
+ *
+ * - Attaches `Authorization: Bearer ` when a token is stored.
+ * - 401: not authenticated (or token expired) -> trigger a new login.
+ * - 403: authenticated but refused -> throws ApiForbiddenError so the UI can
+ * render an explicit refusal message.
+ */
+import { config, type AppConfig } from "../config";
+import {
+ buildAuthorizeUrl,
+ getToken,
+ isExpired,
+ refreshAccessToken,
+} from "../auth/oidc";
+
+export class ApiForbiddenError extends Error {
+ constructor(message = "Your account is not in the nestquest-admins group.") {
+ super(message);
+ this.name = "ApiForbiddenError";
+ }
+}
+
+export class ApiUnauthorizedError extends Error {
+ constructor(message = "Not authenticated.") {
+ super(message);
+ this.name = "ApiUnauthorizedError";
+ }
+}
+
+export interface ApiRequestOptions extends Omit {
+ headers?: Record;
+}
+
+/**
+ * Redirect the browser to Authentik to start a new login.
+ * Exported for testing injection.
+ */
+export async function triggerLogin(app: AppConfig = config): Promise {
+ const url = await buildAuthorizeUrl(app);
+ window.location.assign(url);
+}
+
+export async function apiFetch(
+ path: string,
+ options: ApiRequestOptions = {},
+ app: AppConfig = config,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ if (isExpired() && (await refreshAccessToken(app, fetchImpl))) {
+ // token renewed; fall through and use the fresh one
+ }
+
+ const token = getToken();
+ const headers = {
+ ...(options.headers ?? {}),
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ };
+
+ const response = await fetchImpl(`${app.apiBaseUrl}${path}`, {
+ ...options,
+ headers,
+ });
+
+ if (response.status === 401) {
+ // Not authenticated: either no token or an unusable one.
+ if (await refreshAccessToken(app, fetchImpl)) {
+ const retryToken = getToken();
+ const retryResponse = await fetchImpl(`${app.apiBaseUrl}${path}`, {
+ ...options,
+ headers: {
+ ...(options.headers ?? {}),
+ ...(retryToken ? { Authorization: `Bearer ${retryToken}` } : {}),
+ },
+ });
+ if (retryResponse.status === 403) throw new ApiForbiddenError();
+ if (retryResponse.ok) return retryResponse;
+ // Only 401 means "unauthorized"; preserve every other status (e.g. a
+ // 500 from the API) so callers can react to the real response.
+ if (retryResponse.status === 401) throw new ApiUnauthorizedError();
+ return retryResponse;
+ }
+ await triggerLogin(app);
+ throw new ApiUnauthorizedError();
+ }
+
+ if (response.status === 403) {
+ // Authenticated, but Authentik's token does not belong to nestquest-admins.
+ throw new ApiForbiddenError();
+ }
+
+ return response;
+}
diff --git a/admin/src/api/definitions.ts b/admin/src/api/definitions.ts
new file mode 100644
index 0000000..b38f83b
--- /dev/null
+++ b/admin/src/api/definitions.ts
@@ -0,0 +1,174 @@
+/**
+ * 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;
+}
+
+export 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));
+}
+
+export const OCCURRENCES_PREVIEW_PATH = `${DEFINITIONS_PATH}/occurrences-preview`;
+
+export interface OccurrencesPreviewOptions {
+ /** "YYYY-MM-DD"; the API defaults to its host's today. */
+ startDate?: string;
+ /** 1..50; the API defaults to 10. */
+ count?: number;
+}
+
+/**
+ * The next occurrence dates ("YYYY-MM-DD", ascending) for `rule`, computed by
+ * the same backend recurrence engine the materializer uses.
+ */
+export async function previewOccurrences(
+ rule: DefinitionRule,
+ options: OccurrencesPreviewOptions = {},
+): Promise {
+ const body: { rule: DefinitionRule; start_date?: string; count?: number } = { rule };
+ if (options.startDate !== undefined) body.start_date = options.startDate;
+ if (options.count !== undefined) body.count = options.count;
+ const response = await readJson<{ dates: string[] }>(
+ await sendJson(OCCURRENCES_PREVIEW_PATH, "POST", body),
+ );
+ return response.dates;
+}
diff --git a/admin/src/api/events.test.ts b/admin/src/api/events.test.ts
new file mode 100644
index 0000000..33a24fd
--- /dev/null
+++ b/admin/src/api/events.test.ts
@@ -0,0 +1,162 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { waitFor } from "@testing-library/react";
+import { EVENTS_PATH, SseParser, openTransitionStream, type TransitionEvent } from "./events";
+import { frame, testStream, type TestStream } from "./testStream";
+import { storeTokens } from "../auth/oidc";
+
+const COMPLETED = {
+ child_id: 1,
+ child_name: "Declan",
+ instance_id: 12,
+ quest_title: "Make bed",
+ window: "morning",
+ due_date: "2026-09-23",
+ due_time: null,
+ occurred_at: "2026-09-23T11:04:00Z",
+};
+const DAY_COMPLETE = {
+ child_id: 2,
+ child_name: "Maeve",
+ quests_due: 4,
+ quests_completed: 4,
+ occurred_at: "2026-09-23T12:00:00Z",
+};
+
+describe("SseParser", () => {
+ it("yields every frame from one chunk", () => {
+ const parser = new SseParser();
+ const events = parser.push(
+ frame("nestquest_quest_completed", COMPLETED) +
+ frame("nestquest_child_day_complete", DAY_COMPLETE),
+ );
+ expect(events).toEqual([
+ { type: "nestquest_quest_completed", payload: COMPLETED },
+ { type: "nestquest_child_day_complete", payload: DAY_COMPLETE },
+ ]);
+ });
+
+ it("buffers a frame split across two chunks", () => {
+ const parser = new SseParser();
+ const text = frame("nestquest_quest_uncompleted", COMPLETED);
+ const cut = text.indexOf("Make");
+ expect(parser.push(text.slice(0, cut))).toEqual([]);
+ expect(parser.push(text.slice(cut))).toEqual([
+ { type: "nestquest_quest_uncompleted", payload: COMPLETED },
+ ]);
+ });
+
+ it("buffers a split inside the event name and the blank-line terminator", () => {
+ const parser = new SseParser();
+ const text = frame("nestquest_quest_missed", COMPLETED);
+ expect(parser.push(text.slice(0, 12))).toEqual([]);
+ expect(parser.push(text.slice(12, -1))).toEqual([]);
+ expect(parser.push(text.slice(-1))).toEqual([
+ { type: "nestquest_quest_missed", payload: COMPLETED },
+ ]);
+ });
+
+ it("treats a CRLF split across chunks as one line break", () => {
+ const parser = new SseParser();
+ const text = `event: nestquest_quest_completed\r\ndata: ${JSON.stringify(COMPLETED)}\r\n\r\n`;
+ const cut = text.indexOf("\n"); // chunk ends on "\r"
+ expect(parser.push(text.slice(0, cut))).toEqual([]);
+ expect(parser.push(text.slice(cut))).toEqual([
+ { type: "nestquest_quest_completed", payload: COMPLETED },
+ ]);
+ });
+
+ it("drops malformed, unknown and comment frames without throwing", () => {
+ const parser = new SseParser();
+ const events = parser.push(
+ "event: nestquest_quest_completed\ndata: not-json\n\n" +
+ "event: nestquest_quest_completed\ndata: [1,2]\n\n" +
+ "event: something_else\ndata: {}\n\n" +
+ ": keep-alive\n\n" +
+ "data: {}\n\n" +
+ frame("nestquest_quest_completed", COMPLETED),
+ );
+ expect(events).toEqual([{ type: "nestquest_quest_completed", payload: COMPLETED }]);
+ });
+});
+
+describe("openTransitionStream", () => {
+ let fetchMock: ReturnType;
+ let streams: TestStream[];
+ let stop: (() => void) | null;
+
+ beforeEach(() => {
+ sessionStorage.clear();
+ storeTokens({ access_token: "at-123", expires_in: 600 });
+ streams = [];
+ stop = null;
+ fetchMock = vi.fn(() => {
+ const stream = testStream();
+ streams.push(stream);
+ return Promise.resolve(stream.response);
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ afterEach(() => {
+ stop?.();
+ vi.unstubAllGlobals();
+ });
+
+ it("sends the token in the Authorization header, never in the URL", async () => {
+ stop = openTransitionStream({ onTransition: () => {} });
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(String(url)).toMatch(new RegExp(`${EVENTS_PATH}$`));
+ expect(String(url)).not.toContain("at-123");
+ expect(init.headers.Authorization).toBe("Bearer at-123");
+ expect(init.headers.Accept).toBe("text/event-stream");
+ });
+
+ it("delivers transitions from frames split across chunks", async () => {
+ const seen: TransitionEvent[] = [];
+ stop = openTransitionStream({ onTransition: (event) => seen.push(event) });
+ await waitFor(() => expect(streams).toHaveLength(1));
+ const text = frame("nestquest_quest_completed", COMPLETED);
+ streams[0].push(text.slice(0, 20));
+ streams[0].push(text.slice(20));
+ await waitFor(() =>
+ expect(seen).toEqual([{ type: "nestquest_quest_completed", payload: COMPLETED }]),
+ );
+ });
+
+ it("stop aborts the request and cancels the body", async () => {
+ stop = openTransitionStream({ onTransition: () => {} });
+ await waitFor(() => expect(streams).toHaveLength(1));
+ const signal = fetchMock.mock.calls[0][1].signal as AbortSignal;
+ stop();
+ expect(signal.aborted).toBe(true);
+ await waitFor(() => expect(streams[0].cancelled()).toBe(true));
+ });
+
+ it("reconnects after the stream drops", async () => {
+ const seen: TransitionEvent[] = [];
+ stop = openTransitionStream({ onTransition: (event) => seen.push(event), minDelayMs: 5 });
+ await waitFor(() => expect(streams).toHaveLength(1));
+ streams[0].close();
+ await waitFor(() => expect(streams).toHaveLength(2));
+ streams[1].push(frame("nestquest_quest_missed", COMPLETED));
+ await waitFor(() => expect(seen).toHaveLength(1));
+ });
+
+ it("does not reconnect after stop, even with a reconnect pending", async () => {
+ stop = openTransitionStream({ onTransition: () => {}, minDelayMs: 20 });
+ await waitFor(() => expect(streams).toHaveLength(1));
+ streams[0].close();
+ stop();
+ await new Promise((resolve) => setTimeout(resolve, 60));
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("gives up on a 403 refusal instead of retrying", async () => {
+ fetchMock.mockImplementation(() => Promise.resolve(new Response("", { status: 403 })));
+ stop = openTransitionStream({ onTransition: () => {}, minDelayMs: 5 });
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
+ await new Promise((resolve) => setTimeout(resolve, 40));
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/admin/src/api/events.ts b/admin/src/api/events.ts
new file mode 100644
index 0000000..bf6b572
--- /dev/null
+++ b/admin/src/api/events.ts
@@ -0,0 +1,196 @@
+/**
+ * Live transition stream: GET /api/v1/admin/events (text/event-stream).
+ *
+ * The admin JWT travels in the Authorization header (via apiFetch), so the
+ * stream is read with fetch + a ReadableStream reader — EventSource cannot
+ * set headers, and the token must never go into a URL.
+ */
+import { ApiForbiddenError, ApiUnauthorizedError, apiFetch } from "./client";
+
+export const EVENTS_PATH = "/api/v1/admin/events";
+
+/** The four documented transitions, as the API names them on the wire. */
+export const TRANSITION_TYPES = [
+ "nestquest_quest_completed",
+ "nestquest_quest_uncompleted",
+ "nestquest_quest_missed",
+ "nestquest_child_day_complete",
+] as const;
+
+export type TransitionType = (typeof TRANSITION_TYPES)[number];
+
+/** Quest transitions carry the instance; a day-complete carries the child's counts. */
+export interface QuestTransitionPayload {
+ child_id: number;
+ child_name: string | null;
+ instance_id: number;
+ quest_title: string | null;
+ window: string;
+ due_date: string;
+ due_time: string | null;
+ occurred_at: string;
+}
+
+export interface ChildDayCompletePayload {
+ child_id: number;
+ child_name: string | null;
+ quests_due: number;
+ quests_completed: number;
+ occurred_at: string;
+}
+
+export type TransitionEvent =
+ | {
+ type:
+ | "nestquest_quest_completed"
+ | "nestquest_quest_uncompleted"
+ | "nestquest_quest_missed";
+ payload: QuestTransitionPayload;
+ }
+ | { type: "nestquest_child_day_complete"; payload: ChildDayCompletePayload };
+
+function isTransitionType(value: string): value is TransitionType {
+ return (TRANSITION_TYPES as readonly string[]).includes(value);
+}
+
+/**
+ * Incremental SSE frame parser. Feed it decoded text in whatever chunks the
+ * network delivers; it buffers a partial line or frame until it completes.
+ * Comments (keep-alives), unknown event types and frames whose data is not a
+ * JSON object are dropped, never thrown.
+ */
+export class SseParser {
+ private buffer = "";
+ private eventType = "";
+ private data: string[] = [];
+ /** A chunk ended on "\r": a leading "\n" in the next chunk is the same line break. */
+ private pendingCr = false;
+
+ push(text: string): TransitionEvent[] {
+ if (this.pendingCr && text.startsWith("\n")) text = text.slice(1);
+ this.pendingCr = text.endsWith("\r");
+ this.buffer += text;
+ const lines = this.buffer.split(/\r\n|\r|\n/);
+ this.buffer = lines.pop() ?? "";
+ const events: TransitionEvent[] = [];
+ for (const line of lines) {
+ const event = this.line(line);
+ if (event) events.push(event);
+ }
+ return events;
+ }
+
+ private line(line: string): TransitionEvent | null {
+ if (line === "") return this.dispatch();
+ if (line.startsWith(":")) return null;
+ const colon = line.indexOf(":");
+ const field = colon === -1 ? line : line.slice(0, colon);
+ let value = colon === -1 ? "" : line.slice(colon + 1);
+ if (value.startsWith(" ")) value = value.slice(1);
+ if (field === "event") this.eventType = value;
+ else if (field === "data") this.data.push(value);
+ return null;
+ }
+
+ private dispatch(): TransitionEvent | null {
+ const type = this.eventType;
+ const data = this.data.join("\n");
+ this.eventType = "";
+ this.data = [];
+ if (!isTransitionType(type)) return null;
+ let payload: unknown;
+ try {
+ payload = JSON.parse(data);
+ } catch {
+ return null;
+ }
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
+ return { type, payload } as TransitionEvent;
+ }
+}
+
+export interface TransitionStreamOptions {
+ onTransition: (event: TransitionEvent) => void;
+ /** Reconnect delay after a drop: starts at `minDelayMs`, doubles up to `maxDelayMs`. */
+ minDelayMs?: number;
+ maxDelayMs?: number;
+}
+
+function wait(ms: number, signal: AbortSignal): Promise {
+ return new Promise((resolve) => {
+ const timer = setTimeout(done, ms);
+ signal.addEventListener("abort", done, { once: true });
+ function done() {
+ clearTimeout(timer);
+ signal.removeEventListener("abort", done);
+ resolve();
+ }
+ });
+}
+
+/** Read one connection until the body ends (or the signal aborts). */
+async function readOnce(
+ signal: AbortSignal,
+ onTransition: (event: TransitionEvent) => void,
+): Promise {
+ const response = await apiFetch(EVENTS_PATH, {
+ signal,
+ headers: { Accept: "text/event-stream" },
+ });
+ if (!response.ok || !response.body) {
+ throw new Error(`Event stream request failed (HTTP ${response.status}).`);
+ }
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ const parser = new SseParser();
+ const cancel = () => void reader.cancel().catch(() => {});
+ signal.addEventListener("abort", cancel, { once: true });
+ try {
+ for (;;) {
+ const { value, done } = await reader.read();
+ if (done || signal.aborted) break;
+ for (const event of parser.push(decoder.decode(value, { stream: true }))) {
+ if (signal.aborted) break;
+ onTransition(event);
+ }
+ }
+ } finally {
+ signal.removeEventListener("abort", cancel);
+ reader.releaseLock();
+ }
+}
+
+/**
+ * Subscribe to the admin transition stream, reconnecting with a bounded
+ * backoff after a drop. Returns a stop function that aborts the open request
+ * and any pending reconnect. A 401/403 stops the stream for good: apiFetch
+ * has already started a login (401) or the account is refused (403).
+ */
+export function openTransitionStream({
+ onTransition,
+ minDelayMs = 1_000,
+ maxDelayMs = 30_000,
+}: TransitionStreamOptions): () => void {
+ const controller = new AbortController();
+ const { signal } = controller;
+
+ void (async () => {
+ let delay = minDelayMs;
+ while (!signal.aborted) {
+ try {
+ await readOnce(signal, onTransition);
+ // A connection that was accepted and then ended starts the backoff over.
+ delay = minDelayMs;
+ } catch (error: unknown) {
+ if (signal.aborted) return;
+ if (error instanceof ApiUnauthorizedError || error instanceof ApiForbiddenError) return;
+ // Dropped or refused connection: fall through to the backoff.
+ }
+ if (signal.aborted) return;
+ await wait(delay, signal);
+ delay = Math.min(delay * 2, maxDelayMs);
+ }
+ })();
+
+ return () => controller.abort();
+}
diff --git a/admin/src/api/history.ts b/admin/src/api/history.ts
new file mode 100644
index 0000000..eeecda8
--- /dev/null
+++ b/admin/src/api/history.ts
@@ -0,0 +1,85 @@
+/**
+ * Typed client for the admin history route (/api/v1/admin/history): the
+ * append-only event log over a closed due-date range for one filter.
+ */
+import { apiFetch } from "./client";
+import { ApiRequestError, readJson } from "./definitions";
+
+export type HistoryFilter = "all" | "reversals" | "missed";
+
+export type HistoryEventType = "completed" | "uncompleted" | "missed";
+
+export interface HistoryRow {
+ /** UTC ISO stamp; null for a derived missed row (there is no event). */
+ occurred_at: string | null;
+ event_type: HistoryEventType;
+ child_name: string;
+ child_id: number;
+ quest_title: string;
+ instance_id: number;
+ window: string;
+ /** "YYYY-MM-DD", the day the task was owed. */
+ due_date: string;
+ /** Local wall-clock "HH:MM", or null. */
+ due_time: string | null;
+ /** e.g. "panel (Declan)", "joshua (admin)", "nightly sweep". */
+ actor: string;
+ was_on_time: boolean | null;
+}
+
+export interface HistoryResponse {
+ filter: HistoryFilter;
+ start: string;
+ end: string;
+ count: number;
+ rows: HistoryRow[];
+}
+
+export const HISTORY_PATH = "/api/v1/admin/history";
+
+/** `start`/`end` are inclusive "YYYY-MM-DD" due dates. */
+export async function fetchHistory(
+ filter: HistoryFilter,
+ start: string,
+ end: string,
+): Promise {
+ const params = new URLSearchParams({ start, end, filter });
+ return readJson(await apiFetch(`${HISTORY_PATH}?${params.toString()}`));
+}
+
+export const HISTORY_CSV_PATH = "/api/v1/admin/history.csv";
+
+export interface HistoryCsv {
+ blob: Blob;
+ filename: string;
+}
+
+/** The `filename` from a Content-Disposition header, or null. */
+export function csvFilename(disposition: string | null): string | null {
+ const match = disposition?.match(/filename="?([^";]+)"?/i);
+ return match ? match[1].trim() : null;
+}
+
+/**
+ * The CSV export of the same range and filter as `fetchHistory`. Goes through
+ * apiFetch so the admin token travels in the Authorization header.
+ */
+export async function exportHistoryCsv(
+ filter: HistoryFilter,
+ start: string,
+ end: string,
+): Promise {
+ const params = new URLSearchParams({ start, end, filter });
+ const response = await apiFetch(`${HISTORY_CSV_PATH}?${params.toString()}`);
+ if (!response.ok) {
+ // readJson raises ApiRequestError with the API's `detail` for a non-OK response.
+ await readJson(response);
+ throw new ApiRequestError(response.status, null);
+ }
+ return {
+ blob: await response.blob(),
+ filename:
+ csvFilename(response.headers.get("Content-Disposition")) ??
+ `nestquest-history-${start}-to-${end}.csv`,
+ };
+}
diff --git a/admin/src/api/instances.ts b/admin/src/api/instances.ts
new file mode 100644
index 0000000..bcc3c33
--- /dev/null
+++ b/admin/src/api/instances.ts
@@ -0,0 +1,23 @@
+/**
+ * Typed client for the admin instance mutation route
+ * (POST /api/v1/admin/instances/{id}/uncomplete).
+ */
+import { apiFetch } from "./client";
+import { readJson } from "./definitions";
+
+export interface UncompleteResult {
+ instance_id: number;
+ /** The derived state after the reversal: "open", or "missed" when past due. */
+ state: string;
+ /** False when the instance was already open (a no-op). */
+ appended: boolean;
+}
+
+export function uncompletePath(instanceId: number): string {
+ return `/api/v1/admin/instances/${instanceId}/uncomplete`;
+}
+
+/** Reverse one completion; raises ApiRequestError with the API's detail on failure. */
+export async function uncompleteInstance(instanceId: number): Promise {
+ return readJson(await apiFetch(uncompletePath(instanceId), { method: "POST" }));
+}
diff --git a/admin/src/api/presence.ts b/admin/src/api/presence.ts
new file mode 100644
index 0000000..a04461e
--- /dev/null
+++ b/admin/src/api/presence.ts
@@ -0,0 +1,126 @@
+/**
+ * Typed client for the admin presence routes: a child's repeating schedule
+ * (/api/v1/admin/children/{id}/presence-schedule) and the household's
+ * date-range overrides (/api/v1/admin/presence-overrides).
+ */
+import { apiFetch } from "./client";
+import { CHILDREN_PATH, readJson } from "./definitions";
+
+/**
+ * Cycle week index ("0".."n-1", JSON object keys) -> present weekdays
+ * (0 = Monday .. 6 = Sunday). An empty list is away that whole week.
+ */
+export type PresencePattern = Record;
+
+export interface PresenceScheduleBody {
+ cycle_length_weeks: number;
+ /** "YYYY-MM-DD"; pins cycle week 0. */
+ anchor_date: string;
+ pattern: PresencePattern;
+}
+
+export interface PresenceSchedule extends PresenceScheduleBody {
+ child_id: number;
+}
+
+export interface PresenceOverride {
+ id: number;
+ child_id: number;
+ start_date: string;
+ end_date: string;
+ is_present: boolean;
+ note: string | null;
+}
+
+export interface PresenceOverrideFilter {
+ childId?: number;
+ /** "YYYY-MM-DD"; keeps overrides overlapping [start, end]. */
+ start?: string;
+ end?: string;
+}
+
+export const PRESENCE_OVERRIDES_PATH = "/api/v1/admin/presence-overrides";
+
+export function presenceSchedulePath(childId: number): string {
+ return `${CHILDREN_PATH}/${childId}/presence-schedule`;
+}
+
+/** The child's schedule, or null when it has none (present every day). */
+export async function fetchPresenceSchedule(childId: number): Promise {
+ const body = await readJson<{ schedule: PresenceSchedule | null }>(
+ await apiFetch(presenceSchedulePath(childId)),
+ );
+ return body.schedule;
+}
+
+/** Set (upsert) the child's schedule; resolves to the schedule as stored. */
+export async function savePresenceSchedule(
+ childId: number,
+ body: PresenceScheduleBody,
+): Promise {
+ return readJson(
+ await apiFetch(presenceSchedulePath(childId), {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ }),
+ );
+}
+
+export async function fetchPresenceOverrides(
+ filter: PresenceOverrideFilter = {},
+): Promise {
+ const params = new URLSearchParams();
+ if (filter.childId !== undefined) params.set("child_id", String(filter.childId));
+ if (filter.start !== undefined) params.set("start", filter.start);
+ if (filter.end !== undefined) params.set("end", filter.end);
+ const query = params.toString();
+ const body = await readJson<{ overrides: PresenceOverride[] }>(
+ await apiFetch(query ? `${PRESENCE_OVERRIDES_PATH}?${query}` : PRESENCE_OVERRIDES_PATH),
+ );
+ return body.overrides;
+}
+
+export interface PresenceOverrideRange {
+ child_id: number;
+ /** "YYYY-MM-DD", inclusive. */
+ start_date: string;
+ /** "YYYY-MM-DD", inclusive; never before start_date. */
+ end_date: string;
+ is_present: boolean;
+}
+
+export interface PresenceOverrideBody extends PresenceOverrideRange {
+ note?: string;
+}
+
+export const PRESENCE_OVERRIDE_CONSEQUENCE_PATH = `${PRESENCE_OVERRIDES_PATH}/consequence`;
+
+function postJson(path: string, body: unknown): Promise {
+ return apiFetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+}
+
+/**
+ * How many open upcoming tasks saving this override would remove. Computed
+ * by the API before anything is saved; completed instances never count.
+ */
+export async function previewOverrideConsequence(range: PresenceOverrideRange): Promise {
+ const body = await readJson<{ removed: number }>(
+ await postJson(PRESENCE_OVERRIDE_CONSEQUENCE_PATH, range),
+ );
+ return body.removed;
+}
+
+export async function createPresenceOverride(body: PresenceOverrideBody): Promise {
+ return readJson(await postJson(PRESENCE_OVERRIDES_PATH, body));
+}
+
+export async function deletePresenceOverride(overrideId: number): Promise {
+ await readJson<{ status: string }>(
+ await apiFetch(`${PRESENCE_OVERRIDES_PATH}/${overrideId}`, { method: "DELETE" }),
+ );
+}
diff --git a/admin/src/api/snapshot.ts b/admin/src/api/snapshot.ts
new file mode 100644
index 0000000..9fa0186
--- /dev/null
+++ b/admin/src/api/snapshot.ts
@@ -0,0 +1,55 @@
+/**
+ * Typed client for GET /api/v1/admin/snapshot — today's household snapshot
+ * that drives the admin Today tab.
+ */
+import { apiFetch } from "./client";
+
+export type InstanceWindow = "morning" | "afternoon" | "evening";
+export type InstanceState = "open" | "completed" | "missed";
+
+export interface SnapshotInstance {
+ id: number;
+ definition_id: number;
+ child_id: number;
+ title: string;
+ icon: string | null;
+ window: InstanceWindow;
+ /** Local wall-clock "HH:MM", or null when the definition has no due time. */
+ due_time: string | null;
+ state: InstanceState;
+ overdue: boolean;
+ /** UTC ISO timestamp of the completion event, when completed. */
+ completed_at: string | null;
+ on_time: boolean | null;
+}
+
+export interface SnapshotChild {
+ child_id: number;
+ child_name: string;
+ present: boolean;
+ /** "YYYY-MM-DD" of the next present day, or null. */
+ next_present: string | null;
+ due_today: number;
+ completed_today: number;
+ remaining_today: number;
+ completion_pct: number;
+ instances: SnapshotInstance[];
+}
+
+export interface AdminSnapshot {
+ /** "YYYY-MM-DD" in the household's local zone. */
+ today_iso: string;
+ /** 1-based day in the custody cycle. */
+ cycle_day: number;
+ children: SnapshotChild[];
+}
+
+export const SNAPSHOT_PATH = "/api/v1/admin/snapshot";
+
+export async function fetchSnapshot(): Promise {
+ const response = await apiFetch(SNAPSHOT_PATH);
+ if (!response.ok) {
+ throw new Error(`Snapshot request failed (HTTP ${response.status}).`);
+ }
+ return (await response.json()) as AdminSnapshot;
+}
diff --git a/admin/src/api/testStream.ts b/admin/src/api/testStream.ts
new file mode 100644
index 0000000..18c469e
--- /dev/null
+++ b/admin/src/api/testStream.ts
@@ -0,0 +1,39 @@
+/**
+ * Test helper: a controllable text/event-stream Response whose body the test
+ * feeds chunk by chunk (so a frame can be split across chunk boundaries).
+ */
+export interface TestStream {
+ response: Response;
+ push(text: string): void;
+ close(): void;
+ /** True once the reader cancelled the body (the stream was torn down). */
+ cancelled(): boolean;
+}
+
+export function testStream(): TestStream {
+ const encoder = new TextEncoder();
+ let controller!: ReadableStreamDefaultController;
+ let cancelled = false;
+ const body = new ReadableStream({
+ start(c) {
+ controller = c;
+ },
+ cancel() {
+ cancelled = true;
+ },
+ });
+ return {
+ response: new Response(body, {
+ status: 200,
+ headers: { "Content-Type": "text/event-stream" },
+ }),
+ push: (text) => controller.enqueue(encoder.encode(text)),
+ close: () => controller.close(),
+ cancelled: () => cancelled,
+ };
+}
+
+/** One SSE frame exactly as api/sse.py writes it. */
+export function frame(type: string, payload: unknown): string {
+ return `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`;
+}
diff --git a/admin/src/api/useTransitions.tsx b/admin/src/api/useTransitions.tsx
new file mode 100644
index 0000000..e021d79
--- /dev/null
+++ b/admin/src/api/useTransitions.tsx
@@ -0,0 +1,61 @@
+/**
+ * One live transition stream per signed-in shell, fanned out to the screens.
+ *
+ * opens the stream on mount and aborts it on unmount;
+ * useTransitions(handler) registers a screen's handler for as long as the
+ * screen is mounted. Without a provider the hook does nothing, so a screen
+ * rendered on its own (e.g. in a test) never opens a stream.
+ */
+import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
+import { openTransitionStream, type TransitionEvent } from "./events";
+
+type Listener = (event: TransitionEvent) => void;
+
+interface TransitionHub {
+ subscribe(listener: Listener): () => void;
+}
+
+const TransitionsContext = createContext(null);
+
+export function TransitionsProvider({ children }: { children: ReactNode }) {
+ const [hub] = useState(() => {
+ const listeners = new Set();
+ return {
+ listeners,
+ subscribe(listener: Listener) {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ };
+ });
+
+ useEffect(
+ () =>
+ openTransitionStream({
+ onTransition: (event) => {
+ for (const listener of [...hub.listeners]) {
+ try {
+ listener(event);
+ } catch {
+ // One screen's handler must not tear down the shared stream.
+ }
+ }
+ },
+ }),
+ [hub],
+ );
+
+ return {children} ;
+}
+
+/** Call `handler` for every transition while the calling component is mounted. */
+export function useTransitions(handler: Listener): void {
+ const hub = useContext(TransitionsContext);
+ const handlerRef = useRef(handler);
+ useEffect(() => {
+ handlerRef.current = handler;
+ });
+ useEffect(() => hub?.subscribe((event) => handlerRef.current(event)), [hub]);
+}
diff --git a/admin/src/auth/oidc.test.ts b/admin/src/auth/oidc.test.ts
new file mode 100644
index 0000000..cd63b02
--- /dev/null
+++ b/admin/src/auth/oidc.test.ts
@@ -0,0 +1,173 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ buildAuthorizeUrl,
+ clearAuth,
+ getToken,
+ handleCallback,
+ logout,
+ storeTokens,
+} from "./oidc";
+import type { AppConfig } from "../config";
+
+function testApp(): AppConfig {
+ return {
+ issuer: "https://auth.example.com/application/o/nestquest",
+ clientId: "test-client-id",
+ apiBaseUrl: "https://api.example.com",
+ authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
+ tokenEndpoint: "https://auth.example.com/application/o/token/",
+ userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
+ endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
+ redirectUri: "http://localhost:5173/auth/callback",
+ };
+}
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+describe("OIDC flow", () => {
+ beforeEach(() => {
+ sessionStorage.clear();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("buildAuthorizeUrl carries all required params and stores state/verifier", async () => {
+ const app = testApp();
+ const url = new URL(await buildAuthorizeUrl(app));
+
+ expect(url.origin + url.pathname).toBe(app.authorizeEndpoint);
+ expect(url.searchParams.get("response_type")).toBe("code");
+ expect(url.searchParams.get("client_id")).toBe("test-client-id");
+ expect(url.searchParams.get("redirect_uri")).toBe(app.redirectUri);
+ expect(url.searchParams.get("scope")).toBe("openid email profile");
+ expect(url.searchParams.get("code_challenge_method")).toBe("S256");
+ expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/);
+ expect(url.searchParams.get("state")).toMatch(/^[A-Za-z0-9_-]+$/);
+
+ expect(sessionStorage.getItem("nestquest.oidc_state")).toBe(
+ url.searchParams.get("state"),
+ );
+ expect(sessionStorage.getItem("nestquest.code_verifier")).toMatch(/^[A-Za-z0-9-._~]{43,128}$/);
+ });
+
+ it("handleCallback rejects a mismatched state", async () => {
+ await buildAuthorizeUrl(testApp());
+ sessionStorage.setItem("nestquest.oidc_state", "different-state");
+
+ const fetchMock = vi.fn();
+ await expect(
+ handleCallback("?code=abc&state=tampered", testApp(), fetchMock as any),
+ ).rejects.toThrow(/state mismatch/i);
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(getToken()).toBeUndefined();
+ });
+
+ it("handleCallback exchanges the code with the correct form body and stores the token", async () => {
+ await buildAuthorizeUrl(testApp());
+ const verifier = sessionStorage.getItem("nestquest.code_verifier")!;
+ const state = sessionStorage.getItem("nestquest.oidc_state")!;
+
+ const fetchMock = vi.fn().mockResolvedValue(
+ jsonResponse({
+ access_token: "at-123",
+ refresh_token: "rt-456",
+ id_token: "id-789",
+ expires_in: 300,
+ }),
+ );
+ await handleCallback(`?code=abc&state=${state}`, testApp(), fetchMock as any);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe("https://auth.example.com/application/o/token/");
+ expect(init.method).toBe("POST");
+ expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
+
+ const body = new URLSearchParams(init.body);
+ expect(body.get("grant_type")).toBe("authorization_code");
+ expect(body.get("code")).toBe("abc");
+ expect(body.get("redirect_uri")).toBe(testApp().redirectUri);
+ expect(body.get("client_id")).toBe("test-client-id");
+ expect(body.get("code_verifier")).toBe(verifier);
+ expect(body.has("client_secret")).toBe(false);
+
+ expect(getToken()).toBe("at-123");
+ // one-time values consumed
+ expect(sessionStorage.getItem("nestquest.oidc_state")).toBeNull();
+ expect(sessionStorage.getItem("nestquest.code_verifier")).toBeNull();
+ });
+
+ it("handleCallback surfaces provider errors", async () => {
+ await expect(
+ handleCallback("?error=access_denied&error_description=nope", testApp(), vi.fn() as any),
+ ).rejects.toThrow(/nope|access_denied/);
+ });
+
+ it("clearAuth removes all stored auth data", () => {
+ storeTokens({ access_token: "a", refresh_token: "r", expires_in: 60 });
+ clearAuth();
+ expect(getToken()).toBeUndefined();
+ expect(sessionStorage.length).toBe(0);
+ });
+
+ it("logout clears auth and redirects to the end-session endpoint", async () => {
+ storeTokens({ access_token: "a", id_token: "idt", expires_in: 60 });
+ const assign = vi.fn();
+ const original = window.location;
+ vi.stubGlobal("location", { ...original, assign, origin: "http://localhost:5173" });
+
+ await logout(testApp());
+
+ expect(assign).toHaveBeenCalledTimes(1);
+ const url = new URL(assign.mock.calls[0][0] as string);
+ expect(url.toString()).toBe(
+ "https://auth.example.com/application/o/nestquest/end-session/?id_token_hint=idt&client_id=test-client-id&post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A5173",
+ );
+ expect(getToken()).toBeUndefined();
+ vi.unstubAllGlobals();
+ });
+
+ it("buildAuthorizeUrl resolves the authorize endpoint via discovery for a non-default issuer", async () => {
+ const issuer = "https://sso.discovery.example/application/o/discovered";
+ const discoveryDoc = {
+ issuer,
+ authorization_endpoint: "https://sso.discovery.example/application/o/authorize/",
+ token_endpoint: "https://sso.discovery.example/application/o/token/",
+ userinfo_endpoint: "https://sso.discovery.example/application/o/userinfo/",
+ end_session_endpoint: `${issuer}/end-session/`,
+ jwks_uri: `${issuer}/jwks/`,
+ };
+ const fetchMock = vi.fn().mockImplementation((url: string) =>
+ Promise.resolve(
+ String(url).endsWith("/.well-known/openid-configuration")
+ ? jsonResponse(discoveryDoc)
+ : jsonResponse({}),
+ ),
+ );
+ const app: AppConfig = {
+ issuer,
+ clientId: "test-client-id",
+ apiBaseUrl: "https://api.example.com",
+ redirectUri: "http://localhost:5173/auth/callback",
+ };
+
+ const url = new URL(await buildAuthorizeUrl(app, fetchMock as any));
+
+ // The authorize endpoint comes from the discovery document — Authentik
+ // serves it outside the app-specific issuer path, so it is NOT
+ // `${issuer}/authorize/` and must never be a hardcoded production host.
+ expect(url.origin + url.pathname).toBe(discoveryDoc.authorization_endpoint);
+ expect(url.searchParams.get("client_id")).toBe("test-client-id");
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(String(fetchMock.mock.calls[0][0])).toBe(
+ `${issuer}/.well-known/openid-configuration`,
+ );
+ });
+});
diff --git a/admin/src/auth/oidc.ts b/admin/src/auth/oidc.ts
new file mode 100644
index 0000000..f2fe723
--- /dev/null
+++ b/admin/src/auth/oidc.ts
@@ -0,0 +1,225 @@
+/**
+ * OIDC authorization-code + PKCE flow for a PUBLIC client.
+ *
+ * Never sends a client_secret anywhere. Tokens are stored in sessionStorage
+ * (per-tab, cleared on tab close) — never localStorage.
+ */
+import { config, resolveEndpoints, type AppConfig, type ResolvedAppConfig } from "../config";
+import {
+ computeCodeChallenge,
+ generateCodeVerifier,
+ generateState,
+} from "./pkce";
+
+const STORAGE_KEYS = {
+ accessToken: "nestquest.access_token",
+ refreshToken: "nestquest.refresh_token",
+ expiresAt: "nestquest.expires_at",
+ idToken: "nestquest.id_token",
+ state: "nestquest.oidc_state",
+ codeVerifier: "nestquest.code_verifier",
+} as const;
+
+export class AuthError extends Error {}
+
+export interface TokenResponse {
+ access_token?: string;
+ refresh_token?: string;
+ id_token?: string;
+ expires_in?: number;
+ token_type?: string;
+}
+
+function makeScopes(): string {
+ return "openid email profile";
+}
+
+/**
+ * Build the Authentik authorization redirect URL, storing state + verifier in
+ * sessionStorage so the callback can validate them. Endpoints are resolved
+ * from the issuer's OIDC discovery document when not set on the config.
+ */
+export async function buildAuthorizeUrl(
+ app: AppConfig = config,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ if (!app.clientId) {
+ throw new AuthError(
+ "Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
+ );
+ }
+ const resolved = await resolveEndpoints(app, fetchImpl);
+ const state = generateState();
+ const codeVerifier = generateCodeVerifier();
+ const codeChallenge = await computeCodeChallenge(codeVerifier);
+
+ sessionStorage.setItem(STORAGE_KEYS.state, state);
+ sessionStorage.setItem(STORAGE_KEYS.codeVerifier, codeVerifier);
+
+ const url = new URL(resolved.authorizeEndpoint);
+ url.searchParams.set("response_type", "code");
+ url.searchParams.set("client_id", app.clientId);
+ url.searchParams.set("redirect_uri", app.redirectUri);
+ url.searchParams.set("scope", makeScopes());
+ url.searchParams.set("state", state);
+ url.searchParams.set("code_challenge", codeChallenge);
+ url.searchParams.set("code_challenge_method", "S256");
+ return url.toString();
+}
+
+/**
+ * Handle the OAuth callback: validate state, exchange the code at the token
+ * endpoint (PKCE, form-urlencoded, no client_secret), store tokens.
+ */
+export async function handleCallback(
+ query: URLSearchParams | string = window.location.search,
+ app: AppConfig = config,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ const params =
+ typeof query === "string" ? new URLSearchParams(query) : query;
+
+ const providerError = params.get("error");
+ if (providerError) {
+ throw new AuthError(
+ params.get("error_description") || `Provider returned error: ${providerError}`,
+ );
+ }
+
+ const code = params.get("code");
+ const state = params.get("state");
+ if (!code || !state) {
+ throw new AuthError("Callback is missing the code or state parameter.");
+ }
+
+ const storedState = sessionStorage.getItem(STORAGE_KEYS.state);
+ const codeVerifier = sessionStorage.getItem(STORAGE_KEYS.codeVerifier);
+ if (!storedState || storedState !== state || !codeVerifier) {
+ throw new AuthError("State mismatch — possible CSRF; refusing to log in.");
+ }
+
+ if (!app.clientId) {
+ throw new AuthError(
+ "Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
+ );
+ }
+
+ const resolved: ResolvedAppConfig = await resolveEndpoints(app, fetchImpl);
+
+ const body = new URLSearchParams({
+ grant_type: "authorization_code",
+ code,
+ redirect_uri: app.redirectUri,
+ client_id: app.clientId,
+ code_verifier: codeVerifier,
+ });
+
+ const response = await fetchImpl(resolved.tokenEndpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: body.toString(),
+ });
+ if (!response.ok) {
+ throw new AuthError(`Token exchange failed with HTTP ${response.status}`);
+ }
+ const tokens = (await response.json()) as TokenResponse;
+ if (!tokens.access_token) {
+ throw new AuthError("Token response did not include an access token.");
+ }
+
+ storeTokens(tokens);
+ // One-time use values are consumed on success.
+ sessionStorage.removeItem(STORAGE_KEYS.state);
+ sessionStorage.removeItem(STORAGE_KEYS.codeVerifier);
+}
+
+export function storeTokens(tokens: TokenResponse): void {
+ sessionStorage.setItem(STORAGE_KEYS.accessToken, tokens.access_token!);
+ if (tokens.refresh_token) {
+ sessionStorage.setItem(STORAGE_KEYS.refreshToken, tokens.refresh_token);
+ }
+ if (tokens.id_token) {
+ sessionStorage.setItem(STORAGE_KEYS.idToken, tokens.id_token);
+ }
+ if (typeof tokens.expires_in === "number") {
+ sessionStorage.setItem(
+ STORAGE_KEYS.expiresAt,
+ String(Date.now() + tokens.expires_in * 1000),
+ );
+ }
+}
+
+export function getToken(): string | undefined {
+ return sessionStorage.getItem(STORAGE_KEYS.accessToken) ?? undefined;
+}
+
+export function getRefreshToken(): string | undefined {
+ return sessionStorage.getItem(STORAGE_KEYS.refreshToken) ?? undefined;
+}
+
+export function getIdToken(): string | undefined {
+ return sessionStorage.getItem(STORAGE_KEYS.idToken) ?? undefined;
+}
+
+export function isAuthenticated(): boolean {
+ return Boolean(getToken());
+}
+
+/** True once the stored access token has passed its (approximate) expiry. */
+export function isExpired(): boolean {
+ const expiresAt = sessionStorage.getItem(STORAGE_KEYS.expiresAt);
+ if (!expiresAt) return false;
+ return Date.now() >= Number(expiresAt);
+}
+
+/**
+ * Refresh the access token with the refresh token grant. Returns false when
+ * no refresh token is stored or the grant fails (caller should re-login).
+ */
+export async function refreshAccessToken(
+ app: AppConfig = config,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ const refreshToken = getRefreshToken();
+ if (!refreshToken || !app.clientId) return false;
+
+ const resolved = await resolveEndpoints(app, fetchImpl);
+
+ const body = new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: refreshToken,
+ client_id: app.clientId,
+ });
+
+ const response = await fetchImpl(resolved.tokenEndpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: body.toString(),
+ });
+ if (!response.ok) return false;
+ const tokens = (await response.json()) as TokenResponse;
+ if (!tokens.access_token) return false;
+ storeTokens(tokens);
+ return true;
+}
+
+export function clearAuth(): void {
+ for (const key of Object.values(STORAGE_KEYS)) {
+ sessionStorage.removeItem(key);
+ }
+}
+
+/** Sign out: clear local auth, then send the browser to the end-session endpoint. */
+export async function logout(
+ app: AppConfig = config,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ const resolved = await resolveEndpoints(app, fetchImpl);
+ const idTokenHint = getIdToken();
+ clearAuth();
+ const params = new URLSearchParams();
+ if (idTokenHint) params.set("id_token_hint", idTokenHint);
+ if (app.clientId) params.set("client_id", app.clientId);
+ params.set("post_logout_redirect_uri", window.location.origin);
+ window.location.assign(`${resolved.endSessionEndpoint}?${params.toString()}`);
+}
diff --git a/admin/src/auth/pkce.test.ts b/admin/src/auth/pkce.test.ts
new file mode 100644
index 0000000..7b7f686
--- /dev/null
+++ b/admin/src/auth/pkce.test.ts
@@ -0,0 +1,41 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ base64UrlEncode,
+ computeCodeChallenge,
+ generateCodeVerifier,
+ generateState,
+ isLikelyValidVerifier,
+} from "./pkce";
+
+describe("PKCE helpers", () => {
+ it("base64UrlEncode has no padding and url-safe characters", () => {
+ // bytes chosen to force "+" and "/" and padding in plain base64
+ const bytes = new Uint8Array([0xfb, 0xff, 0xbf, 0xff]);
+ const encoded = base64UrlEncode(bytes);
+ expect(encoded).not.toMatch(/[+/=]/);
+ });
+
+ it("computes the RFC 7636 appendix-B S256 vector", async () => {
+ const verifier =
+ "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+ const challenge = await computeCodeChallenge(verifier);
+ expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
+ });
+
+ it("generates verifiers with valid charset and length", () => {
+ for (let i = 0; i < 20; i++) {
+ const verifier = generateCodeVerifier();
+ expect(isLikelyValidVerifier(verifier)).toBe(true);
+ }
+ });
+
+ it("rejects verifiers outside 43-128 chars or with reserved chars", () => {
+ expect(isLikelyValidVerifier("short")).toBe(false);
+ expect(isLikelyValidVerifier("a".repeat(129))).toBe(false);
+ expect(isLikelyValidVerifier("a".repeat(43) + "+")).toBe(false);
+ });
+
+ it("generates distinct random states", () => {
+ expect(generateState()).not.toBe(generateState());
+ });
+});
diff --git a/admin/src/auth/pkce.ts b/admin/src/auth/pkce.ts
new file mode 100644
index 0000000..de04c5f
--- /dev/null
+++ b/admin/src/auth/pkce.ts
@@ -0,0 +1,55 @@
+/**
+ * PKCE (RFC 7636) helpers for a public SPA client.
+ *
+ * Uses the Web Crypto API, which requires a SECURE CONTEXT: the app must be
+ * served over https or http://localhost, otherwise crypto.subtle is
+ * unavailable and login cannot work.
+ */
+
+const VERIFIER_CHARSET =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
+
+/** base64url-encode bytes with NO padding. */
+export function base64UrlEncode(bytes: Uint8Array): string {
+ let binary = "";
+ for (const byte of bytes) {
+ binary += String.fromCharCode(byte);
+ }
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+
+function randomBase64Url(byteLength: number): string {
+ const bytes = new Uint8Array(byteLength);
+ crypto.getRandomValues(bytes);
+ return base64UrlEncode(bytes);
+}
+
+/**
+ * Random code_verifier: 43-128 chars, unreserved charset only.
+ * 32 random bytes -> 43 base64url chars, which is the minimum allowed length.
+ */
+export function generateCodeVerifier(): string {
+ return randomBase64Url(32);
+}
+
+/** S256 code_challenge = base64url(SHA-256(verifier)), no padding. */
+export async function computeCodeChallenge(verifier: string): Promise {
+ const digest = await crypto.subtle.digest(
+ "SHA-256",
+ new TextEncoder().encode(verifier),
+ );
+ return base64UrlEncode(new Uint8Array(digest));
+}
+
+/** Random opaque state value for CSRF protection. */
+export function generateState(): string {
+ return randomBase64Url(16);
+}
+
+export function isLikelyValidVerifier(verifier: string): boolean {
+ return (
+ verifier.length >= 43 &&
+ verifier.length <= 128 &&
+ [...verifier].every((char) => VERIFIER_CHARSET.includes(char))
+ );
+}
diff --git a/admin/src/config.test.ts b/admin/src/config.test.ts
new file mode 100644
index 0000000..9fcf9bf
--- /dev/null
+++ b/admin/src/config.test.ts
@@ -0,0 +1,215 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ ConfigError,
+ discoveryUrlForIssuer,
+ readConfig,
+ resolveEndpoints,
+ resolveEndpointsFromDiscovery,
+ type AppConfig,
+} from "./config";
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+// Mirrors Authentik's layout: authorize/token/userinfo live OUTSIDE the
+// application-specific issuer path, end-session/jwks under it.
+function discoveryDocument(issuer: string) {
+ const normalized = issuer.replace(/\/+$/, "");
+ const origin = new URL(normalized).origin;
+ return {
+ issuer,
+ authorization_endpoint: `${origin}/application/o/authorize/`,
+ token_endpoint: `${origin}/application/o/token/`,
+ userinfo_endpoint: `${origin}/application/o/userinfo/`,
+ end_session_endpoint: `${normalized}/end-session/`,
+ jwks_uri: `${normalized}/jwks/`,
+ };
+}
+
+describe("config", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("readConfig keeps the production issuer as the default", () => {
+ const cfg = readConfig({});
+ expect(cfg.issuer).toBe("https://auth.cubecraftlabs.com/application/o/nestquest");
+ expect(cfg.clientId).toBeUndefined();
+ });
+
+ it("readConfig normalizes a trailing slash off a custom issuer", () => {
+ expect(
+ readConfig({ VITE_AUTHENTIK_ISSUER: "https://sso.custom.example/application/o/custom/" })
+ .issuer,
+ ).toBe("https://sso.custom.example/application/o/custom");
+ });
+
+ it("readConfig hardcodes no OIDC endpoints — they come from discovery", () => {
+ const cfg = readConfig({});
+ expect(cfg.authorizeEndpoint).toBeUndefined();
+ expect(cfg.tokenEndpoint).toBeUndefined();
+ expect(cfg.userinfoEndpoint).toBeUndefined();
+ expect(cfg.endSessionEndpoint).toBeUndefined();
+ expect(cfg.jwksUri).toBeUndefined();
+ });
+
+ it("builds the discovery URL for issuers with and without a trailing slash", () => {
+ expect(discoveryUrlForIssuer("https://sso.example.com/application/o/app/")).toBe(
+ "https://sso.example.com/application/o/app/.well-known/openid-configuration",
+ );
+ expect(discoveryUrlForIssuer("https://sso.example.com/application/o/app")).toBe(
+ "https://sso.example.com/application/o/app/.well-known/openid-configuration",
+ );
+ });
+
+ it("resolves every endpoint from the discovery document of a non-default issuer", async () => {
+ const issuer = "https://sso.other-host.example/application/o/other-app";
+ const doc = discoveryDocument(issuer);
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
+ const app: AppConfig = {
+ issuer,
+ clientId: "client-id",
+ apiBaseUrl: "https://api.example.com",
+ redirectUri: "http://localhost:5173/auth/callback",
+ };
+
+ const resolved = await resolveEndpoints(app, fetchMock as unknown as typeof fetch);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock).toHaveBeenCalledWith(
+ "https://sso.other-host.example/application/o/other-app/.well-known/openid-configuration",
+ );
+ // Every endpoint comes verbatim from the (non-default) discovery document.
+ expect(resolved.authorizeEndpoint).toBe(doc.authorization_endpoint);
+ expect(resolved.tokenEndpoint).toBe(doc.token_endpoint);
+ expect(resolved.userinfoEndpoint).toBe(doc.userinfo_endpoint);
+ expect(resolved.endSessionEndpoint).toBe(doc.end_session_endpoint);
+ expect(resolved.jwksUri).toBe(doc.jwks_uri);
+ // Proves no hardcoded production host leaked into the resolved endpoints.
+ for (const endpoint of [
+ resolved.authorizeEndpoint,
+ resolved.tokenEndpoint,
+ resolved.userinfoEndpoint,
+ resolved.endSessionEndpoint,
+ resolved.jwksUri,
+ ]) {
+ expect(endpoint).not.toMatch(/cubecraftlabs/);
+ }
+ });
+
+ it("resolves discovery for an issuer configured with a trailing slash", async () => {
+ const issuer = "https://sso.slashy.example/application/o/slashy/";
+ const doc = discoveryDocument(issuer);
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
+
+ const resolved = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ "https://sso.slashy.example/application/o/slashy/.well-known/openid-configuration",
+ );
+ expect(resolved.authorizeEndpoint).toBe(doc.authorization_endpoint);
+ expect(resolved.endSessionEndpoint).toBe(doc.end_session_endpoint);
+ });
+
+ it("caches discovery per issuer for the session", async () => {
+ const issuer = "https://sso.cached.example/application/o/cached";
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(discoveryDocument(issuer)));
+
+ const first = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
+ const second = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(second).toBe(first);
+ });
+
+ it("surfaces a clear configuration error when discovery returns an HTTP error", async () => {
+ const issuer = "https://sso.http-error.example/application/o/broken";
+ const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 500 }));
+
+ const error = await resolveEndpointsFromDiscovery(issuer, fetchMock as any).then(
+ () => null,
+ (error: unknown) => error,
+ );
+
+ expect(error).toBeInstanceOf(ConfigError);
+ expect((error as Error).message).toContain(issuer);
+ expect((error as Error).message).toContain("HTTP 500");
+ });
+
+ it("surfaces a clear configuration error when discovery is unreachable", async () => {
+ const issuer = "https://sso.unreachable.example/application/o/unreachable";
+ const fetchMock = vi.fn().mockRejectedValue(new TypeError("network down"));
+
+ await expect(
+ resolveEndpointsFromDiscovery(issuer, fetchMock as any),
+ ).rejects.toBeInstanceOf(ConfigError);
+ });
+
+ it("surfaces a clear configuration error when the discovery document is incomplete", async () => {
+ const issuer = "https://sso.incomplete.example/application/o/incomplete";
+ const doc = discoveryDocument(issuer) as Record;
+ delete doc.end_session_endpoint;
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
+
+ await expect(
+ resolveEndpointsFromDiscovery(issuer, fetchMock as any),
+ ).rejects.toThrow(/end_session_endpoint/);
+ });
+
+ it("rejects a discovery document advertising a different issuer", async () => {
+ const issuer = "https://sso.mismatch.example/application/o/mismatch";
+ const doc = {
+ ...discoveryDocument(issuer),
+ issuer: "https://sso.elsewhere.example/application/o/elsewhere",
+ };
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
+
+ await expect(
+ resolveEndpointsFromDiscovery(issuer, fetchMock as any),
+ ).rejects.toThrow(/issuer mismatch/i);
+ });
+
+ it("the bundled config resolves its endpoints from discovery at runtime", async () => {
+ const issuer = "https://sso.bundled.example/application/o/bundled";
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(discoveryDocument(issuer)));
+ const bundled = readConfig({ VITE_AUTHENTIK_ISSUER: `${issuer}/`, VITE_AUTHENTIK_CLIENT_ID: "bundled-client" });
+ expect(bundled.authorizeEndpoint).toBeUndefined();
+
+ const resolved = await resolveEndpoints(bundled, fetchMock as any);
+
+ expect(resolved.authorizeEndpoint).toBe(
+ "https://sso.bundled.example/application/o/authorize/",
+ );
+ expect(resolved.tokenEndpoint).toBe("https://sso.bundled.example/application/o/token/");
+ expect(resolved.userinfoEndpoint).toBe(
+ "https://sso.bundled.example/application/o/userinfo/",
+ );
+ expect(resolved.endSessionEndpoint).toBe(
+ "https://sso.bundled.example/application/o/bundled/end-session/",
+ );
+ expect(resolved.jwksUri).toBe("https://sso.bundled.example/application/o/bundled/jwks/");
+ });
+
+ it("does not re-fetch discovery when the config already carries endpoints", async () => {
+ const app: AppConfig = {
+ issuer: "https://auth.example.com/application/o/nestquest",
+ clientId: "client-id",
+ apiBaseUrl: "https://api.example.com",
+ authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
+ tokenEndpoint: "https://auth.example.com/application/o/token/",
+ userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
+ endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
+ redirectUri: "http://localhost:5173/auth/callback",
+ };
+ const fetchMock = vi.fn();
+
+ const resolved = await resolveEndpoints(app, fetchMock as any);
+
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(resolved).toBe(app);
+ });
+});
diff --git a/admin/src/config.ts b/admin/src/config.ts
new file mode 100644
index 0000000..9880be9
--- /dev/null
+++ b/admin/src/config.ts
@@ -0,0 +1,221 @@
+/**
+ * Runtime configuration, read from Vite env (import.meta.env).
+ * See .env.example for the variables and their defaults.
+ *
+ * OIDC endpoints are NOT hardcoded here. They are resolved lazily from the
+ * issuer's OIDC discovery document (see resolveEndpoints), because Authentik
+ * serves authorize/token/userinfo OUTSIDE the application-specific issuer
+ * path (issuer is /application/o//, authorize is /application/o/authorize/).
+ * Both hardcoding the production host and naively concatenating onto the
+ * issuer would break non-default issuers; discovery is the only correct way.
+ */
+
+export interface AppConfig {
+ issuer: string;
+ clientId: string | undefined;
+ apiBaseUrl: string;
+ /** Resolved from the issuer's OIDC discovery document — see resolveEndpoints(). */
+ authorizeEndpoint?: string;
+ tokenEndpoint?: string;
+ userinfoEndpoint?: string;
+ endSessionEndpoint?: string;
+ jwksUri?: string;
+ redirectUri: string;
+}
+
+/** AppConfig whose OIDC endpoints have been filled in by resolveEndpoints(). */
+export type ResolvedAppConfig = AppConfig &
+ Required<
+ Pick<
+ AppConfig,
+ "authorizeEndpoint" | "tokenEndpoint" | "userinfoEndpoint" | "endSessionEndpoint"
+ >
+ >;
+
+export interface DiscoveredEndpoints {
+ issuer: string;
+ authorizeEndpoint: string;
+ tokenEndpoint: string;
+ userinfoEndpoint: string;
+ endSessionEndpoint: string;
+ jwksUri: string;
+}
+
+/** Raised when OIDC discovery fails or the configuration is unusable. */
+export class ConfigError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "ConfigError";
+ }
+}
+
+const REQUIRED_ENDPOINT_NAMES = [
+ "authorizeEndpoint",
+ "tokenEndpoint",
+ "userinfoEndpoint",
+ "endSessionEndpoint",
+] as const;
+
+export function readConfig(env: Record = import.meta.env as any): AppConfig {
+ const issuer = (
+ env.VITE_AUTHENTIK_ISSUER || "https://auth.cubecraftlabs.com/application/o/nestquest/"
+ ).replace(/\/$/, "");
+ const clientId = env.VITE_AUTHENTIK_CLIENT_ID;
+ const apiBaseUrl = env.VITE_API_BASE_URL || "https://nestquest.cubecraftlabs.com";
+
+ return {
+ issuer,
+ // No default: a missing client id must surface as a configuration error
+ // screen, not a broken redirect to Authentik.
+ clientId: clientId || undefined,
+ apiBaseUrl,
+ // authorize/token/userinfo/end-session endpoints are resolved from the
+ // issuer's discovery document at runtime — never hardcoded here.
+ redirectUri: `${window.location.origin}/auth/callback`,
+ };
+}
+
+export const config: AppConfig = readConfig();
+
+interface DiscoveryDocument {
+ issuer?: unknown;
+ authorization_endpoint?: unknown;
+ token_endpoint?: unknown;
+ userinfo_endpoint?: unknown;
+ end_session_endpoint?: unknown;
+ jwks_uri?: unknown;
+}
+
+function normalizeIssuer(issuer: string): string {
+ return issuer.replace(/\/+$/, "");
+}
+
+function nonEmptyString(value: unknown): string | undefined {
+ return typeof value === "string" && value.length > 0 ? value : undefined;
+}
+
+/** Discovery document location for an issuer, tolerant of a trailing slash. */
+export function discoveryUrlForIssuer(issuer: string): string {
+ return `${normalizeIssuer(issuer)}/.well-known/openid-configuration`;
+}
+
+async function fetchDiscoveredEndpoints(
+ issuer: string,
+ fetchImpl: typeof fetch,
+): Promise {
+ const normalizedIssuer = normalizeIssuer(issuer);
+ const discoveryUrl = `${normalizedIssuer}/.well-known/openid-configuration`;
+
+ let response: Response;
+ try {
+ response = await fetchImpl(discoveryUrl);
+ } catch (error) {
+ const reason = error instanceof Error ? error.message : String(error);
+ throw new ConfigError(
+ `OIDC discovery request to ${discoveryUrl} failed (${reason}). ` +
+ "Check VITE_AUTHENTIK_ISSUER and network reachability.",
+ );
+ }
+ if (!response.ok) {
+ throw new ConfigError(
+ `OIDC discovery failed: ${discoveryUrl} returned HTTP ${response.status}. ` +
+ "Check VITE_AUTHENTIK_ISSUER.",
+ );
+ }
+
+ let doc: DiscoveryDocument;
+ try {
+ doc = (await response.json()) as DiscoveryDocument;
+ } catch {
+ throw new ConfigError(
+ `OIDC discovery document at ${discoveryUrl} is not valid JSON. Check VITE_AUTHENTIK_ISSUER.`,
+ );
+ }
+
+ const authorizationEndpoint = nonEmptyString(doc.authorization_endpoint);
+ const tokenEndpoint = nonEmptyString(doc.token_endpoint);
+ const userinfoEndpoint = nonEmptyString(doc.userinfo_endpoint);
+ const endSessionEndpoint = nonEmptyString(doc.end_session_endpoint);
+ const jwksUri = nonEmptyString(doc.jwks_uri);
+ const discoveredIssuer = nonEmptyString(doc.issuer);
+
+ const endpointFields: Array<[string, string | undefined]> = [
+ ["authorization_endpoint", authorizationEndpoint],
+ ["token_endpoint", tokenEndpoint],
+ ["userinfo_endpoint", userinfoEndpoint],
+ ["end_session_endpoint", endSessionEndpoint],
+ ["jwks_uri", jwksUri],
+ ];
+ const missing = endpointFields.filter(([, value]) => !value).map(([name]) => name);
+ if (missing.length > 0) {
+ throw new ConfigError(
+ `OIDC discovery document at ${discoveryUrl} is missing: ${missing.join(", ")}. ` +
+ "The issuer must point at an OIDC provider that publishes these endpoints.",
+ );
+ }
+
+ if (discoveredIssuer && normalizeIssuer(discoveredIssuer) !== normalizedIssuer) {
+ throw new ConfigError(
+ `OIDC discovery issuer mismatch: ${discoveryUrl} advertises "${discoveredIssuer}" ` +
+ `but VITE_AUTHENTIK_ISSUER is "${normalizedIssuer}".`,
+ );
+ }
+
+ return {
+ issuer: normalizedIssuer,
+ authorizeEndpoint: authorizationEndpoint!,
+ tokenEndpoint: tokenEndpoint!,
+ userinfoEndpoint: userinfoEndpoint!,
+ endSessionEndpoint: endSessionEndpoint!,
+ jwksUri: jwksUri!,
+ };
+}
+
+/**
+ * Session cache: one discovery fetch per issuer per SPA session. Failed
+ * lookups are evicted so a transient network error can be retried instead of
+ * poisoning the whole session.
+ */
+const discoveryCache = new Map>();
+
+export function resolveEndpointsFromDiscovery(
+ issuer: string,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ const normalizedIssuer = normalizeIssuer(issuer);
+ const cached = discoveryCache.get(normalizedIssuer);
+ if (cached) return cached;
+
+ const pending = fetchDiscoveredEndpoints(normalizedIssuer, fetchImpl);
+ discoveryCache.set(normalizedIssuer, pending);
+ pending.catch(() => {
+ discoveryCache.delete(normalizedIssuer);
+ });
+ return pending;
+}
+
+/**
+ * Return `app` with all OIDC endpoints filled in. When the config already
+ * carries them (pre-resolved config, tests) this is a no-op; otherwise the
+ * issuer's discovery document is fetched (cached for the session) and its
+ * authorization/token/userinfo/end-session/jwks endpoints are used.
+ */
+export async function resolveEndpoints(
+ app: AppConfig,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ const missing = REQUIRED_ENDPOINT_NAMES.filter((name) => !app[name]);
+ if (missing.length === 0) {
+ return app as ResolvedAppConfig;
+ }
+
+ const discovered = await resolveEndpointsFromDiscovery(app.issuer, fetchImpl);
+ return {
+ ...app,
+ authorizeEndpoint: discovered.authorizeEndpoint,
+ tokenEndpoint: discovered.tokenEndpoint,
+ userinfoEndpoint: discovered.userinfoEndpoint,
+ endSessionEndpoint: discovered.endSessionEndpoint,
+ jwksUri: discovered.jwksUri,
+ };
+}
diff --git a/admin/src/definitions/DefinitionsTab.css b/admin/src/definitions/DefinitionsTab.css
new file mode 100644
index 0000000..a857716
--- /dev/null
+++ b/admin/src/definitions/DefinitionsTab.css
@@ -0,0 +1,585 @@
+/* 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-icon-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(var(--nq-a-tap-min), 1fr));
+ gap: 6px;
+}
+
+.defs-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ 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);
+ cursor: pointer;
+}
+
+.defs-icon--on {
+ border-color: transparent;
+ background: var(--nq-brand-gradient);
+ color: #fff;
+}
+
+.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;
+}
+
+.defs-preview {
+ margin-top: 8px;
+}
+
+.defs-preview--error {
+ color: var(--nq-a-ink-secondary);
+ font-style: italic;
+}
diff --git a/admin/src/definitions/DefinitionsTab.test.tsx b/admin/src/definitions/DefinitionsTab.test.tsx
new file mode 100644
index 0000000..038429b
--- /dev/null
+++ b/admin/src/definitions/DefinitionsTab.test.tsx
@@ -0,0 +1,1087 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import DefinitionsTab from "./DefinitionsTab";
+import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons";
+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;
+}
+
+const PREVIEW_PATH = "/api/v1/admin/quest-definitions/occurrences-preview";
+
+/** Answers an occurrence-preview request; the default serves no dates. */
+type PreviewHandler = (body: { rule: DefinitionRule; count?: number }) => Promise;
+
+/**
+ * A stub of the admin API: serves the list and children, records writes, and
+ * lets a test queue a response for the next write. Occurrence previews are
+ * reads (recorded, never counted as writes) answered by `setPreview`.
+ */
+function fakeApi(initial: QuestDefinition[]) {
+ let definitions = [...initial];
+ const calls: Call[] = [];
+ let nextWrite: Response | null = null;
+ let preview: PreviewHandler = async () => jsonResponse({ dates: [] });
+ 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 === "POST" && path === PREVIEW_PATH) return preview(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: body.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,
+ icon: "icon" in body ? body.icon : d.icon,
+ 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" && c.path !== PREVIEW_PATH),
+ previews: () => calls.filter((c) => c.path === PREVIEW_PATH),
+ setPreview: (handler: PreviewHandler) => {
+ preview = handler;
+ },
+ 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",
+ icon: null,
+ 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",
+ icon: null,
+ 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,
+ },
+ },
+ ]);
+ 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");
+ });
+});
+
+/* ── Occurrence preview ─────────────────────────────────────────────── */
+
+/** Past the sheet's 300 ms preview debounce. */
+function settleDebounce(): Promise {
+ return act(() => new Promise((resolve) => setTimeout(resolve, 400)));
+}
+
+function previewText(): string | null {
+ return screen.queryByTestId("occurrence-preview")?.textContent ?? null;
+}
+
+/** BINS' rule (every 2 weeks on Mon, Thu) from 2026-10-05. */
+const BINS_DATES = ["2026-10-05", "2026-10-08", "2026-10-19", "2026-10-22", "2026-11-02"];
+/** The same rule on Tuesdays only. */
+const TUESDAY_DATES = ["2026-10-06", "2026-10-20", "2026-11-03", "2026-11-17", "2026-12-01"];
+
+function deferred() {
+ let resolve!: (response: Response) => void;
+ const promise = new Promise((r) => {
+ resolve = r;
+ });
+ return { promise, resolve };
+}
+
+describe("DefinitionsTab occurrence preview", () => {
+ beforeEach(() => {
+ sessionStorage.clear();
+ storeTokens({ access_token: "at-123", expires_in: 600 });
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+ });
+
+ it("previews a weekly rule's next dates in plain language from the API", async () => {
+ await renderReady();
+ api.setPreview(async () => jsonResponse({ dates: BINS_DATES }));
+ fireEvent.click(screen.getByTestId("definition-11"));
+
+ await waitFor(() =>
+ expect(previewText()).toBe(
+ "Next: Mon 5 Oct · Thu 8 Oct · Mon 19 Oct · Thu 22 Oct · Mon 2 Nov · …",
+ ),
+ );
+ expect(api.previews()).toEqual([
+ {
+ method: "POST",
+ path: "/api/v1/admin/quest-definitions/occurrences-preview",
+ body: { rule: BINS.rule, count: 5 },
+ },
+ ]);
+ });
+
+ it("re-requests once per rule change, debounced, and not for non-rule edits", async () => {
+ await renderReady();
+ api.setPreview(async ({ rule }) =>
+ jsonResponse({ dates: rule.weekday_set?.includes(0) ? BINS_DATES : TUESDAY_DATES }),
+ );
+ fireEvent.click(screen.getByTestId("definition-11"));
+ const s = sheet();
+ await waitFor(() => expect(previewText()).toContain("Mon 5 Oct"));
+
+ // A title edit leaves the rule alone: no new request.
+ fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
+ target: { value: "Bins" },
+ });
+ // Three rule edits in quick succession collapse into one request.
+ const days = within(s).getByRole("group", { name: "Days" });
+ fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
+ fireEvent.click(within(days).getByRole("button", { name: "Thursday" }));
+ fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
+
+ await waitFor(() =>
+ expect(previewText()).toBe(
+ "Next: Tue 6 Oct · Tue 20 Oct · Tue 3 Nov · Tue 17 Nov · Tue 1 Dec · …",
+ ),
+ );
+ await settleDebounce();
+ expect(api.previews()).toHaveLength(2);
+ expect(api.previews()[1].body).toEqual({
+ rule: { ...BINS.rule, weekday_set: [1] },
+ count: 5,
+ });
+
+ // An interval change is a rule change too.
+ fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
+ target: { value: "1" },
+ });
+ await waitFor(() => expect(api.previews()).toHaveLength(3));
+ expect((api.previews()[2].body as { rule: DefinitionRule }).rule.interval).toBe(1);
+ });
+
+ it("window and assignee edits never request, and a rule edit waits out the debounce", async () => {
+ await renderReady();
+ api.setPreview(async () => jsonResponse({ dates: BINS_DATES }));
+ fireEvent.click(screen.getByTestId("definition-11"));
+ const s = sheet();
+ await waitFor(() => expect(previewText()).toContain("Mon 5 Oct"));
+ expect(api.previews()).toHaveLength(1);
+
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+ // Windows and assignees are not part of the rule.
+ fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
+ fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
+ fireEvent.click(within(s).getByRole("checkbox", { name: "Maeve" }));
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(api.previews()).toHaveLength(1);
+ expect(previewText()).toContain("Mon 5 Oct");
+
+ // A rule edit is not requested until the 300 ms debounce elapses.
+ const days = within(s).getByRole("group", { name: "Days" });
+ fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
+ await act(async () => {
+ vi.advanceTimersByTime(299);
+ });
+ expect(api.previews()).toHaveLength(1);
+ await act(async () => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(api.previews()).toHaveLength(2);
+ expect((api.previews()[1].body as { rule: DefinitionRule }).rule.weekday_set).toEqual([
+ 0, 1, 3,
+ ]);
+ });
+
+ it("a rule edit hides the previous rule's dates until its own response arrives", async () => {
+ await renderReady();
+ const pending: ReturnType[] = [];
+ api.setPreview(() => {
+ const next = deferred();
+ pending.push(next);
+ return next.promise;
+ });
+ fireEvent.click(screen.getByTestId("definition-11"));
+ await waitFor(() => expect(pending).toHaveLength(1));
+ await act(async () => {
+ pending[0].resolve(jsonResponse({ dates: BINS_DATES }));
+ });
+ await waitFor(() => expect(previewText()).toContain("Mon 5 Oct"));
+
+ const days = within(sheet()).getByRole("group", { name: "Days" });
+ fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
+ // Within the debounce: the old dates are already gone.
+ expect(previewText()).toBeNull();
+ // Request in flight: still nothing shown for the new rule.
+ await waitFor(() => expect(pending).toHaveLength(2));
+ expect(previewText()).toBeNull();
+
+ await act(async () => {
+ pending[1].resolve(jsonResponse({ dates: ["2026-10-08", "2026-10-22"] }));
+ });
+ await waitFor(() => expect(previewText()).toBe("Next: Thu 8 Oct · Thu 22 Oct"));
+ });
+
+ it("a rule edit hides the previous rule's error until its own response arrives", async () => {
+ await renderReady();
+ const pending: ReturnType[] = [];
+ api.setPreview(() => {
+ const next = deferred();
+ pending.push(next);
+ return next.promise;
+ });
+ fireEvent.click(screen.getByTestId("definition-11"));
+ await waitFor(() => expect(pending).toHaveLength(1));
+ await act(async () => {
+ pending[0].resolve(jsonResponse({ detail: "rule rejected" }, 422));
+ });
+ await waitFor(() => expect(previewText()).toBe("Upcoming dates unavailable: rule rejected"));
+
+ const days = within(sheet()).getByRole("group", { name: "Days" });
+ fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
+ expect(previewText()).toBeNull();
+ await waitFor(() => expect(pending).toHaveLength(2));
+ expect(previewText()).toBeNull();
+
+ await act(async () => {
+ pending[1].resolve(jsonResponse({ dates: ["2026-10-08"] }));
+ });
+ await waitFor(() => expect(previewText()).toBe("Next: Thu 8 Oct"));
+ });
+
+ it("a stale response resolving after a newer one does not overwrite it", async () => {
+ await renderReady();
+ const pending: ReturnType[] = [];
+ api.setPreview(() => {
+ const next = deferred();
+ pending.push(next);
+ return next.promise;
+ });
+ fireEvent.click(screen.getByTestId("definition-11"));
+ await waitFor(() => expect(pending).toHaveLength(1));
+
+ const days = within(sheet()).getByRole("group", { name: "Days" });
+ fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
+ fireEvent.click(within(days).getByRole("button", { name: "Thursday" }));
+ fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
+ await waitFor(() => expect(pending).toHaveLength(2));
+
+ await act(async () => {
+ pending[1].resolve(jsonResponse({ dates: TUESDAY_DATES }));
+ });
+ await waitFor(() => expect(previewText()).toContain("Tue 6 Oct"));
+ await act(async () => {
+ pending[0].resolve(jsonResponse({ dates: BINS_DATES }));
+ });
+ await settleDebounce();
+ expect(previewText()).toBe(
+ "Next: Tue 6 Oct · Tue 20 Oct · Tue 3 Nov · Tue 17 Nov · Tue 1 Dec · …",
+ );
+ });
+
+ it("a failed preview shows a non-blocking message and the sheet still saves", async () => {
+ await renderReady();
+ api.setPreview(async () => jsonResponse({ detail: "rule rejected" }, 422));
+ fireEvent.click(screen.getByTestId("definition-10"));
+
+ await waitFor(() => expect(previewText()).toBe("Upcoming dates unavailable: rule rejected"));
+ expect(screen.queryByTestId("sheet-error")).toBeNull();
+
+ await act(async () => {
+ fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
+ });
+ expect(api.writes()).toHaveLength(1);
+ expect(api.writes()[0].method).toBe("PATCH");
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+ });
+
+ it("an incomplete draft does not call the preview endpoint", async () => {
+ await renderReady();
+ api.setPreview(async () => jsonResponse({ dates: TUESDAY_DATES }));
+ fireEvent.click(screen.getByRole("button", { name: "New" }));
+ const s = sheet();
+ fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), {
+ target: { value: "weekly" },
+ });
+ // Clear the default weekday: a weekly rule with no days is incomplete.
+ const days = within(s).getByRole("group", { name: "Days" });
+ for (const day of within(days).getAllByRole("button")) {
+ if (day.getAttribute("aria-pressed") === "true") fireEvent.click(day);
+ }
+ await settleDebounce();
+ expect(api.previews()).toHaveLength(0);
+ expect(previewText()).toBeNull();
+
+ // An unparseable interval is incomplete too.
+ fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
+ fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
+ target: { value: "" },
+ });
+ await settleDebounce();
+ expect(api.previews()).toHaveLength(0);
+
+ fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
+ target: { value: "2" },
+ });
+ await waitFor(() => expect(previewText()).toContain("Tue 6 Oct"));
+ expect(api.previews()).toHaveLength(1);
+ });
+});
+
+describe("DefinitionsTab icon picker", () => {
+ beforeEach(() => {
+ sessionStorage.clear();
+ storeTokens({ access_token: "at-123", expires_in: 600 });
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+ });
+
+ /** The Lucide name a row renders, or null for the generic fallback glyph. */
+ function rowIcon(row: HTMLElement): string | null {
+ const svg = row.querySelector(".defs-row-glyph svg");
+ expect(svg).not.toBeNull();
+ return svg!.getAttribute("data-icon");
+ }
+
+ function iconRadio(s: HTMLElement, label: string): HTMLElement {
+ return within(within(s).getByRole("radiogroup", { name: "Icon" })).getByRole("radio", {
+ name: label,
+ });
+ }
+
+ it("ships the curated subset, and every entry renders an SVG", () => {
+ expect(DEFINITION_ICONS.map((entry) => entry.name)).toEqual([
+ "bed",
+ "bath",
+ "shower-head",
+ "toilet",
+ "brush",
+ "smile",
+ "shirt",
+ "washing-machine",
+ "utensils",
+ "cooking-pot",
+ "apple",
+ "brush-cleaning",
+ "spray-can",
+ "trash-2",
+ "recycle",
+ "sofa",
+ "house",
+ "backpack",
+ "book-open",
+ "pencil",
+ "music",
+ "dog",
+ "cat",
+ "fish",
+ "sprout",
+ "droplets",
+ "bike",
+ "volleyball",
+ "gamepad-2",
+ "star",
+ "heart",
+ "sun",
+ "moon",
+ "clock",
+ "calendar",
+ "list-checks",
+ ]);
+ expect(DEFINITION_ICONS.length).toBeGreaterThanOrEqual(24);
+ expect(DEFINITION_ICONS.length).toBeLessThanOrEqual(40);
+ for (const { name } of DEFINITION_ICONS) {
+ const { container, unmount } = render( );
+ const svg = container.querySelector("svg");
+ expect(svg, name).not.toBeNull();
+ expect(svg!.getAttribute("data-icon")).toBe(name);
+ expect(svg!.querySelectorAll("path, circle, rect, line, polyline, ellipse").length).toBeGreaterThan(0);
+ unmount();
+ }
+ });
+
+ it("a chosen icon is POSTed for a new task and renders on its row", async () => {
+ await renderReady();
+ fireEvent.click(screen.getByRole("button", { name: "New" }));
+ const s = sheet();
+ expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("true");
+
+ fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
+ target: { value: "Make the bed" },
+ });
+ fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
+ fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
+ fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
+ fireEvent.click(iconRadio(s, "Bed"));
+ expect(iconRadio(s, "Bed").getAttribute("aria-checked")).toBe("true");
+ expect(iconRadio(s, "Bed").className).toContain("defs-icon--on");
+ expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("false");
+
+ await act(async () => {
+ fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
+ });
+
+ expect(api.writes()).toHaveLength(1);
+ expect(api.writes()[0].method).toBe("POST");
+ expect((api.writes()[0].body as { icon: string }).icon).toBe("bed");
+ expect(rowIcon(await screen.findByTestId("definition-99"))).toBe("bed");
+ });
+
+ it("a chosen icon is PATCHed for an existing task without breaking the assignee omit", async () => {
+ await renderReady();
+ expect(rowIcon(screen.getByTestId("definition-11"))).toBeNull();
+ fireEvent.click(screen.getByTestId("definition-11"));
+ const s = sheet();
+ expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("true");
+ fireEvent.click(iconRadio(s, "Bins"));
+
+ await act(async () => {
+ fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
+ });
+
+ expect(screen.queryByTestId("unassign-confirm")).toBeNull();
+ expect(api.writes()).toHaveLength(1);
+ const { method, path, body } = api.writes()[0];
+ expect(method).toBe("PATCH");
+ expect(path).toBe("/api/v1/admin/quest-definitions/11");
+ expect((body as { icon: string }).icon).toBe("trash-2");
+ // Inactive "Old" stays assigned: the unchanged selection is still omitted.
+ expect(body).not.toHaveProperty("assignee_child_ids");
+ await waitFor(() => expect(rowIcon(screen.getByTestId("definition-11"))).toBe("trash-2"));
+ });
+
+ it("an unchanged icon is still sent on edit and shows as selected", async () => {
+ await renderReady();
+ expect(rowIcon(screen.getByTestId("definition-10"))).toBe("smile");
+ fireEvent.click(screen.getByTestId("definition-10"));
+ const s = sheet();
+ expect(iconRadio(s, "Smile").getAttribute("aria-checked")).toBe("true");
+ fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
+ target: { value: "Brush teeth well" },
+ });
+
+ await act(async () => {
+ fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
+ });
+
+ expect((api.writes()[0].body as { icon: string }).icon).toBe("smile");
+ });
+
+ it("an unknown legacy icon falls back to the generic glyph and is kept on save", async () => {
+ const legacy: QuestDefinition = { ...BRUSH, id: 20, icon: "rocket-ship-9000" };
+ await renderReady([legacy]);
+ const row = screen.getByTestId("definition-20");
+ expect(rowIcon(row)).toBeNull();
+
+ fireEvent.click(row);
+ const s = sheet();
+ const group = within(s).getByRole("radiogroup", { name: "Icon" });
+ for (const radio of within(group).getAllByRole("radio")) {
+ expect(radio.getAttribute("aria-checked")).toBe("false");
+ }
+
+ await act(async () => {
+ fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
+ });
+
+ expect((api.writes()[0].body as { icon: string }).icon).toBe("rocket-ship-9000");
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+ expect(rowIcon(screen.getByTestId("definition-20"))).toBeNull();
+ });
+});
diff --git a/admin/src/definitions/DefinitionsTab.tsx b/admin/src/definitions/DefinitionsTab.tsx
new file mode 100644
index 0000000..b4803b5
--- /dev/null
+++ b/admin/src/definitions/DefinitionsTab.tsx
@@ -0,0 +1,968 @@
+/**
+ * Definitions ("Tasks") tab — list + edit sheet with icon picker
+ * (design/ADMIN-SPEC.md §3.1–3.3).
+ */
+import {
+ useEffect,
+ useRef,
+ useState,
+ type KeyboardEvent as ReactKeyboardEvent,
+ type ReactNode,
+} from "react";
+import { ApiForbiddenError } from "../api/client";
+import {
+ ApiRequestError,
+ createDefinition,
+ fetchChildren,
+ fetchDefinitions,
+ previewOccurrences,
+ 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,
+ occurrenceLabel,
+ recurrenceSummary,
+ windowSummary,
+} from "./format";
+import {
+ CheckGlyph,
+ ChevronDownGlyph,
+ ChevronRightGlyph,
+ PlusGlyph,
+ TaskGlyph,
+} from "./glyphs";
+import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons";
+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",
+};
+
+/** How many upcoming dates the edit sheet previews. */
+const PREVIEW_COUNT = 5;
+
+/** Quiet period after the last rule edit before the preview is requested. */
+const PREVIEW_DEBOUNCE_MS = 300;
+
+const WEEKDAY_LONG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
+
+interface Draft {
+ title: string;
+ /** A Lucide name; a legacy value outside the subset is kept until replaced. */
+ icon: string | null;
+ 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: "",
+ icon: null,
+ 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,
+ icon: definition.icon,
+ // 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,
+ icon: draft.icon,
+ 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) => (
+ onChange(chip.key)}
+ >
+ {chip.label}
+
+ ))}
+
+ );
+}
+
+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 (
+
+
+
+
+
+
+ {definition.title}
+ {editing ? "Editing" : meta}
+
+
+ {editing ? : }
+
+
+
+ );
+}
+
+/* ── Edit sheet ─────────────────────────────────────────────────────── */
+
+type PreviewState =
+ | { kind: "idle" }
+ | { kind: "ready"; ruleKey: string; dates: string[] }
+ | { kind: "error"; ruleKey: string; message: string };
+
+/**
+ * The next few dates the backend would materialize for the draft's rule.
+ * `ruleKey` is the built rule serialized (null while the draft is invalid), so
+ * the request only goes out when the rule itself changes — debounced, and a
+ * superseded request's late response is dropped by its effect's cleanup.
+ * A result is tagged with the rule it answers and only shown while that rule
+ * is still the draft's, so a changed rule never shows the previous dates.
+ */
+function OccurrencePreview({ ruleKey }: { ruleKey: string | null }) {
+ const [preview, setPreview] = useState({ kind: "idle" });
+
+ useEffect(() => {
+ if (ruleKey === null) {
+ setPreview({ kind: "idle" });
+ return;
+ }
+ let cancelled = false;
+ const timer = setTimeout(() => {
+ previewOccurrences(JSON.parse(ruleKey) as DefinitionRule, { count: PREVIEW_COUNT })
+ .then((dates) => {
+ if (!cancelled) setPreview({ kind: "ready", ruleKey, dates });
+ })
+ .catch((error: unknown) => {
+ if (cancelled) return;
+ const detail =
+ error instanceof ApiForbiddenError ||
+ (error instanceof ApiRequestError && error.detail)
+ ? `: ${error.message}`
+ : ".";
+ setPreview({
+ kind: "error",
+ ruleKey,
+ message: `Upcoming dates unavailable${detail}`,
+ });
+ });
+ }, PREVIEW_DEBOUNCE_MS);
+ return () => {
+ cancelled = true;
+ clearTimeout(timer);
+ };
+ }, [ruleKey]);
+
+ if (preview.kind === "idle" || preview.ruleKey !== ruleKey) return null;
+ if (preview.kind === "error") {
+ return (
+
+ {preview.message}
+
+ );
+ }
+ let text: string;
+ if (preview.dates.length === 0) {
+ text = "No upcoming dates in the next year.";
+ } else {
+ const labels = preview.dates.map(occurrenceLabel);
+ // A full page means the rule keeps going past the last shown date.
+ if (preview.dates.length >= PREVIEW_COUNT) labels.push("…");
+ text = `Next: ${labels.join(" · ")}`;
+ }
+ return (
+
+ {text}
+
+ );
+}
+
+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));
+ const previewRule = buildRule(draft);
+ const previewKey = typeof previewRule === "string" ? null : JSON.stringify(previewRule);
+
+ 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();
+ }}
+ />
+
+
+
+ {target === "new" ? "New task" : "Edit task"}
+
+
+
+ update({ title: e.target.value })}
+ />
+
+
+
+
+ update({ icon: null })}
+ >
+
+
+ {DEFINITION_ICONS.map(({ name, label, Icon }) => (
+ update({ icon: name })}
+ >
+
+
+ ))}
+
+
+
+
+ setPickerOpen((open) => !open)}
+ >
+
+ {selectedAssignees.length > 0
+ ? assigneeSummary(selectedAssignees, activeChildren)
+ : "Choose children"}
+
+ {pickerOpen ? : }
+
+ {pickerOpen ? (
+
+ {activeChildren.map((child) => {
+ const checked = draft.assigneeIds.includes(child.id);
+ return (
+
+ update({ assigneeIds: toggle(draft.assigneeIds, child.id) })}
+ >
+ {child.display_name}
+ {checked ? : null}
+
+
+ );
+ })}
+ {inactiveAssignees.map((assignee) => (
+
+ {assignee.display_name}
+ Inactive
+
+ ))}
+
+ ) : null}
+ {inactiveAssignees.length > 0 ? (
+
+ Also assigned (inactive): {inactiveAssignees.map((a) => a.display_name).join(", ")}.
+ Kept unless you change the selection.
+
+ ) : null}
+
+
+
+
+
+ update({
+ repeats: e.target.value as Repeats,
+ // Leaving Monthly drops a carried nth-weekday rule for good.
+ monthlyWeekday: e.target.value === "monthly" ? draft.monthlyWeekday : null,
+ })
+ }
+ >
+ {REPEATS_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ Every
+ update({ interval: e.target.value })}
+ />
+
+
+ {draft.repeats === "weekly" || draft.repeats === "custom" ? (
+
+ {WEEKDAY_SHORT.map((label, day) => (
+ update({ weekdays: toggle(draft.weekdays, day) })}
+ >
+ {label.charAt(0)}
+
+ ))}
+
+ ) : 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" ? (
+ update({ month: e.target.value })}
+ >
+ {MONTH_SHORT.map((label, index) => (
+
+ {label}
+
+ ))}
+
+ ) : 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_LABELS[w]}
+
+ update({ dueTimes: { ...draft.dueTimes, [w]: e.target.value } })
+ }
+ />
+
+ ))}
+
+ )}
+
+
+
+
+ {WINDOW_ORDER.map((w) => (
+ update({ windows: toggle(draft.windows, w) })}
+ >
+ {WINDOW_LABELS[w]}
+
+ ))}
+
+
+
+
+
+
+ Skip on away days
+
+
No instance when the child is absent
+
+
update({ skipOnAway: !draft.skipOnAway })}
+ >
+
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {confirming ? (
+
+
+ Saving will unassign{" "}
+ {inactiveAssignees.map((a) => a.display_name).join(", ")}. Inactive children can't be
+ assigned again until they are reactivated.
+
+
+ setConfirming(false)}>
+ Go back
+
+ save(true)}>
+ Unassign and save
+
+
+
+ ) : (
+
+
+ Cancel
+
+ save()} disabled={saving}>
+ {saving ? "Saving…" : "Save changes"}
+
+
+ )}
+
+
+ );
+}
+
+/* ── 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}
+
setAttempt((n) => n + 1)}>
+ Try again
+
+
+ );
+ } 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 (
+
+
+
+ setSheet("new")}
+ >
+
+ New
+
+
+
+ {body}
+
+ {sheet !== null && state.kind === "ready" ? (
+
setSheet(null)}
+ onSaved={() => {
+ setSheet(null);
+ setAttempt((n) => n + 1);
+ }}
+ />
+ ) : null}
+
+ );
+}
diff --git a/admin/src/definitions/definitionIcons.tsx b/admin/src/definitions/definitionIcons.tsx
new file mode 100644
index 0000000..dbf0a3d
--- /dev/null
+++ b/admin/src/definitions/definitionIcons.tsx
@@ -0,0 +1,103 @@
+/**
+ * The shipped Lucide subset a definition's `icon` may name (ADMIN-SPEC §3.3).
+ * Only these named imports are bundled — no CDN, no runtime fetch. The stored
+ * value is the Lucide kebab-case name, so it stays meaningful to any surface.
+ */
+import {
+ Apple,
+ Backpack,
+ Bath,
+ Bed,
+ Bike,
+ BookOpen,
+ Brush,
+ BrushCleaning,
+ Calendar,
+ Cat,
+ Clock,
+ CookingPot,
+ Dog,
+ Droplets,
+ Fish,
+ Gamepad2,
+ Heart,
+ House,
+ ListChecks,
+ Moon,
+ Music,
+ Pencil,
+ Recycle,
+ Shirt,
+ ShowerHead,
+ Smile,
+ Sofa,
+ SprayCan,
+ Sprout,
+ Star,
+ Sun,
+ Toilet,
+ Trash2,
+ Utensils,
+ Volleyball,
+ WashingMachine,
+ type LucideIcon,
+} from "lucide-react";
+import { TaskGlyph } from "./glyphs";
+
+export interface DefinitionIconEntry {
+ name: string;
+ label: string;
+ Icon: LucideIcon;
+}
+
+export const DEFINITION_ICONS: readonly DefinitionIconEntry[] = [
+ { name: "bed", label: "Bed", Icon: Bed },
+ { name: "bath", label: "Bath", Icon: Bath },
+ { name: "shower-head", label: "Shower", Icon: ShowerHead },
+ { name: "toilet", label: "Toilet", Icon: Toilet },
+ { name: "brush", label: "Brush", Icon: Brush },
+ { name: "smile", label: "Smile", Icon: Smile },
+ { name: "shirt", label: "Clothes", Icon: Shirt },
+ { name: "washing-machine", label: "Laundry", Icon: WashingMachine },
+ { name: "utensils", label: "Meal", Icon: Utensils },
+ { name: "cooking-pot", label: "Cooking", Icon: CookingPot },
+ { name: "apple", label: "Snack", Icon: Apple },
+ { name: "brush-cleaning", label: "Sweep", Icon: BrushCleaning },
+ { name: "spray-can", label: "Clean", Icon: SprayCan },
+ { name: "trash-2", label: "Bins", Icon: Trash2 },
+ { name: "recycle", label: "Recycling", Icon: Recycle },
+ { name: "sofa", label: "Tidy", Icon: Sofa },
+ { name: "house", label: "Home", Icon: House },
+ { name: "backpack", label: "School bag", Icon: Backpack },
+ { name: "book-open", label: "Reading", Icon: BookOpen },
+ { name: "pencil", label: "Homework", Icon: Pencil },
+ { name: "music", label: "Music", Icon: Music },
+ { name: "dog", label: "Dog", Icon: Dog },
+ { name: "cat", label: "Cat", Icon: Cat },
+ { name: "fish", label: "Fish", Icon: Fish },
+ { name: "sprout", label: "Plants", Icon: Sprout },
+ { name: "droplets", label: "Water", Icon: Droplets },
+ { name: "bike", label: "Bike", Icon: Bike },
+ { name: "volleyball", label: "Sport", Icon: Volleyball },
+ { name: "gamepad-2", label: "Games", Icon: Gamepad2 },
+ { name: "star", label: "Star", Icon: Star },
+ { name: "heart", label: "Heart", Icon: Heart },
+ { name: "sun", label: "Morning", Icon: Sun },
+ { name: "moon", label: "Bedtime", Icon: Moon },
+ { name: "clock", label: "Clock", Icon: Clock },
+ { name: "calendar", label: "Calendar", Icon: Calendar },
+ { name: "list-checks", label: "Checklist", Icon: ListChecks },
+];
+
+const BY_NAME = new Map(DEFINITION_ICONS.map((entry) => [entry.name, entry]));
+
+export function findDefinitionIcon(name: string | null): DefinitionIconEntry | null {
+ return name === null ? null : (BY_NAME.get(name) ?? null);
+}
+
+/** A definition's glyph; no icon, or one outside the subset, gets the generic task glyph. */
+export function DefinitionIcon({ name, size = 17 }: { name: string | null; size?: number }) {
+ const entry = findDefinitionIcon(name);
+ if (!entry) return ;
+ return ;
+}
diff --git a/admin/src/definitions/format.ts b/admin/src/definitions/format.ts
new file mode 100644
index 0000000..a2246d4
--- /dev/null
+++ b/admin/src/definitions/format.ts
@@ -0,0 +1,111 @@
+/**
+ * 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(", ");
+}
+
+/**
+ * "2026-10-06" -> "Tue 6 Oct". The calendar date is read by its components
+ * and its weekday taken in UTC (as the Today tab does), so the printed day
+ * never shifts with the viewer's offset — `new Date("YYYY-MM-DD")` would.
+ */
+export function occurrenceLabel(iso: string): string {
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
+ if (!match) throw new Error(`Invalid calendar date: ${iso}`);
+ const [, year, month, day] = match.map(Number);
+ const date = new Date(Date.UTC(year, month - 1, day));
+ // getUTCDay: 0 = Sunday; WEEKDAY_SHORT: 0 = Monday.
+ return `${WEEKDAY_SHORT[(date.getUTCDay() + 6) % 7]} ${day} ${MONTH_SHORT[month - 1]}`;
+}
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 (
+
+ {children}
+
+ );
+}
+
+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/history/HistoryTab.css b/admin/src/history/HistoryTab.css
new file mode 100644
index 0000000..d362b9b
--- /dev/null
+++ b/admin/src/history/HistoryTab.css
@@ -0,0 +1,296 @@
+/* History tab — compact density (design/ADMIN-SPEC.md §5). Mobile-first at
+ the 390x844 reference; on wider viewports the column is capped and centred. */
+
+.history-screen {
+ width: 100%;
+ max-width: 640px;
+ margin: 0 auto;
+}
+
+.history-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;
+}
+
+.history-title {
+ margin: 0;
+ font-size: var(--nq-a-size-screen);
+ font-weight: 800;
+ letter-spacing: -0.02em;
+}
+
+.history-sub {
+ margin: 2px 0 0;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--nq-a-ink-secondary);
+}
+
+.history-csv {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ min-height: 36px;
+ padding: 0 12px;
+ 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: 12.5px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.history-csv:disabled {
+ cursor: progress;
+ opacity: 0.6;
+}
+
+/* Non-blocking CSV export failure; the tab stays usable. */
+.history-export-error {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: var(--nq-a-density-row-pad);
+ border-radius: var(--nq-a-density-radius);
+ background: var(--nq-a-danger-bg);
+ color: var(--nq-a-danger-strong);
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.history-export-dismiss {
+ flex: none;
+ padding: 4px 8px;
+ border: none;
+ background: none;
+ color: inherit;
+ font-family: inherit;
+ font-size: 12.5px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
+.history-scroll {
+ display: flex;
+ flex-direction: column;
+ gap: var(--nq-a-density-gap);
+ padding: 14px 16px;
+}
+
+.history-card {
+ background: var(--nq-a-surface);
+ border: 1px solid var(--nq-a-border);
+ border-radius: var(--nq-a-density-radius);
+}
+
+/* Stat row */
+.history-stats {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+}
+
+.history-stat {
+ padding: 12px;
+ background: var(--nq-a-surface);
+ border: 1px solid var(--nq-a-border);
+ border-radius: var(--nq-a-density-radius);
+}
+
+.history-stat-value {
+ font-size: 20px;
+ font-weight: 800;
+ line-height: 1.1;
+}
+
+.history-stat-value--on-time {
+ color: var(--nq-brand-blue);
+}
+
+.history-stat-value--missed {
+ color: var(--nq-a-danger);
+}
+
+.history-stat-label {
+ margin-top: 2px;
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--nq-a-ink-secondary);
+}
+
+/* Filter chips */
+.history-chips {
+ display: flex;
+ gap: 6px;
+ overflow-x: auto;
+}
+
+.history-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;
+}
+
+.history-chip--active {
+ border-color: #18181d;
+ background: #18181d;
+ color: #fff;
+}
+
+/* Day-grouped event rows */
+.history-day {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.history-section-label {
+ margin: 4px 0 0;
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--nq-a-ink-secondary);
+}
+
+.history-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.history-list > li + li {
+ border-top: 1px solid var(--nq-a-divider);
+}
+
+.history-event {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-height: var(--nq-a-tap-min);
+ padding: var(--nq-a-density-row-pad);
+}
+
+.history-dot {
+ flex: none;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+}
+
+.history-dot--completed {
+ background: var(--nq-a-success);
+}
+
+.history-dot--uncompleted {
+ background: var(--nq-a-reversal);
+}
+
+.history-dot--missed {
+ background: var(--nq-a-danger);
+}
+
+.history-event-text {
+ flex: 1;
+ min-width: 0;
+}
+
+.history-event-title {
+ font-size: 12.5px;
+ font-weight: 700;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.history-event-meta {
+ margin-top: 1px;
+ font-size: 10.5px;
+ font-weight: 600;
+ color: var(--nq-a-ink-secondary);
+}
+
+.history-event-time {
+ flex: none;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--nq-a-ink-secondary);
+}
+
+.history-empty {
+ padding: var(--nq-a-density-card-pad);
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--nq-a-ink-secondary);
+}
+
+/* Append-only footer strip */
+.history-note {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 10px 12px;
+ border: 1px solid var(--nq-a-border);
+ border-radius: var(--nq-a-density-radius);
+ background: var(--nq-a-page);
+ color: var(--nq-a-ink-secondary);
+ font-size: 11.5px;
+ font-weight: 600;
+}
+
+.history-note-glyph {
+ flex: none;
+ display: flex;
+}
+
+/* Loading / error */
+.history-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);
+}
+
+.history-status--error {
+ color: var(--nq-a-danger-strong);
+}
+
+.history-status--error p {
+ margin: 0 0 10px;
+}
+
+.history-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;
+}
diff --git a/admin/src/history/HistoryTab.test.tsx b/admin/src/history/HistoryTab.test.tsx
new file mode 100644
index 0000000..506bd08
--- /dev/null
+++ b/admin/src/history/HistoryTab.test.tsx
@@ -0,0 +1,631 @@
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import HistoryTab, { FOOTER_NOTE, computeStats } from "./HistoryTab";
+import type { HistoryFilter, HistoryRow } from "../api/history";
+import { storeTokens } from "../auth/oidc";
+import { TransitionsProvider } from "../api/useTransitions";
+import { frame, testStream, type TestStream } from "../api/testStream";
+
+const TODAY = "2026-09-23";
+
+function row(overrides: Partial): HistoryRow {
+ return {
+ occurred_at: null,
+ event_type: "completed",
+ child_name: "Declan",
+ child_id: 1,
+ quest_title: "Brush teeth",
+ instance_id: 1,
+ window: "morning",
+ due_date: TODAY,
+ due_time: null,
+ actor: "panel (Declan)",
+ was_on_time: true,
+ ...overrides,
+ };
+}
+
+// America/Los_Angeles is UTC-7 in September.
+const BRUSH = row({
+ instance_id: 1,
+ occurred_at: "2026-09-23T14:04:00Z", // 7:04 AM
+ due_time: "07:30",
+});
+const BED_DONE = row({
+ instance_id: 2,
+ quest_title: "Make bed",
+ child_name: "Maeve",
+ child_id: 2,
+ occurred_at: "2026-09-23T14:50:00Z", // 7:50 AM
+ actor: "panel (Maeve)",
+});
+const BED_UNDONE = row({
+ instance_id: 2,
+ event_type: "uncompleted",
+ quest_title: "Make bed",
+ child_name: "Maeve",
+ child_id: 2,
+ occurred_at: "2026-09-23T15:10:00Z", // 8:10 AM
+ actor: "joshua (admin)",
+ was_on_time: null,
+});
+const CAT_LATE = row({
+ instance_id: 3,
+ quest_title: "Feed cat",
+ due_date: "2026-09-22",
+ due_time: "07:00",
+ occurred_at: "2026-09-22T14:22:00Z", // 7:22 AM, 22 min after due
+ was_on_time: false,
+});
+// 7:30 PM local on Sep 22 — already Sep 23 in UTC.
+const HOMEWORK_LATE = row({
+ instance_id: 4,
+ quest_title: "Homework",
+ child_name: "Rory",
+ child_id: 3,
+ due_date: "2026-09-22",
+ due_time: null,
+ occurred_at: "2026-09-23T02:30:00Z",
+ actor: "panel (Rory)",
+ was_on_time: false,
+});
+const PLANTS_MISSED = row({
+ instance_id: 5,
+ event_type: "missed",
+ quest_title: "Water plants",
+ child_name: "Rory",
+ child_id: 3,
+ due_date: "2026-09-21",
+ due_time: "18:00",
+ actor: "nightly sweep",
+ was_on_time: null,
+});
+
+// The API's order: due_date, then instance, then event id.
+const ROWS: Record = {
+ all: [CAT_LATE, HOMEWORK_LATE, BRUSH, BED_DONE, BED_UNDONE],
+ reversals: [BED_UNDONE],
+ missed: [PLANTS_MISSED],
+};
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+const CSV_BODY = "occurred_at,event_type\n2026-09-23T14:04:00Z,completed\n";
+
+function csvResponse(filter: string, start: string, end: string): Response {
+ return new Response(`${filter}\n${CSV_BODY}`, {
+ status: 200,
+ headers: {
+ "Content-Type": "text/csv; charset=utf-8",
+ "Content-Disposition": `attachment; filename="nestquest-history-${start}-to-${end}.csv"`,
+ },
+ });
+}
+
+function routeFetch(url: string): Response {
+ const parsed = new URL(url, "http://x");
+ if (parsed.pathname.endsWith("/api/v1/admin/history.csv")) {
+ return csvResponse(
+ parsed.searchParams.get("filter") ?? "",
+ parsed.searchParams.get("start") ?? "",
+ parsed.searchParams.get("end") ?? "",
+ );
+ }
+ if (!parsed.pathname.endsWith("/api/v1/admin/history")) {
+ return jsonResponse({ detail: "unexpected" }, 500);
+ }
+ const filter = parsed.searchParams.get("filter") as HistoryFilter;
+ const rows = ROWS[filter];
+ return jsonResponse({
+ filter,
+ start: parsed.searchParams.get("start"),
+ end: parsed.searchParams.get("end"),
+ count: rows.length,
+ rows,
+ });
+}
+
+let fetchMock: ReturnType;
+
+function historyCalls(): URL[] {
+ return fetchMock.mock.calls.map(([url]) => new URL(String(url), "http://x"));
+}
+
+function metas(section: HTMLElement): string[] {
+ return within(section)
+ .getAllByTestId("history-meta")
+ .map((el) => el.textContent ?? "");
+}
+
+async function renderReady() {
+ render( );
+ await screen.findByTestId("stat-on-time");
+}
+
+describe("HistoryTab", () => {
+ beforeAll(() => {
+ vi.stubEnv("TZ", "America/Los_Angeles");
+ });
+
+ afterAll(() => {
+ vi.unstubAllEnvs();
+ });
+
+ beforeEach(() => {
+ sessionStorage.clear();
+ storeTokens({ access_token: "at-123", expires_in: 600 });
+ fetchMock = vi.fn((url: string) => Promise.resolve(routeFetch(url)));
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ });
+
+ it("renders the header and queries the last 14 days with the Bearer token", async () => {
+ await renderReady();
+ expect(screen.getByRole("heading", { name: "History" })).toBeTruthy();
+ expect(screen.getByText("Append-only event log · last 14 days")).toBeTruthy();
+
+ const calls = historyCalls();
+ expect(calls.length).toBeGreaterThan(0);
+ for (const url of calls) {
+ expect(url.pathname).toBe("/api/v1/admin/history");
+ expect(url.searchParams.get("start")).toBe("2026-09-10");
+ expect(url.searchParams.get("end")).toBe(TODAY);
+ }
+ expect(calls.map((url) => url.searchParams.get("filter")).sort()).toEqual(["all", "missed"]);
+ expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe("Bearer at-123");
+ });
+
+ it("stat row shows On time %, Completed and Missed for the period", async () => {
+ await renderReady();
+ // 4 completions (2 on time) in `all`; 1 derived missed row.
+ expect(screen.getByTestId("stat-on-time").textContent).toBe("50%On time");
+ expect(screen.getByTestId("stat-completed").textContent).toBe("4Completed");
+ expect(screen.getByTestId("stat-missed").textContent).toBe("1Missed");
+ });
+
+ it("On time shows — when the period has no completions", () => {
+ expect(computeStats([BED_UNDONE], [])).toEqual({ onTimePct: null, completed: 0, missed: 0 });
+ });
+
+ it("groups rows by local day, newest first, with event type, actor and on-time meta", async () => {
+ await renderReady();
+
+ const today = screen.getByTestId(`day-${TODAY}`);
+ expect(within(today).getByRole("heading").textContent).toBe("Today");
+ expect(metas(today)).toEqual([
+ "uncompleted · joshua (admin)",
+ "completed · panel (Maeve) · on time",
+ "completed · panel (Declan) · on time",
+ ]);
+ expect(within(today).getAllByText("Make bed · Maeve")).toHaveLength(2);
+ expect(within(today).getByText("8:10 AM")).toBeTruthy();
+
+ // The 7:30 PM completion is Sep 23 in UTC but Sep 22 locally.
+ const yesterday = screen.getByTestId("day-2026-09-22");
+ expect(within(yesterday).getByRole("heading").textContent).toBe("Yesterday");
+ expect(metas(yesterday)).toEqual([
+ "completed · panel (Rory) · late",
+ "completed · panel (Declan) · late 22 min",
+ ]);
+ expect(within(yesterday).getByText("7:30 PM")).toBeTruthy();
+
+ const dots = screen.getAllByTestId("history-dot").map((el) => el.className);
+ expect(dots[0]).toContain("history-dot--uncompleted");
+ expect(dots[1]).toContain("history-dot--completed");
+ });
+
+ it("each filter chip re-queries the API with its filter and re-renders", async () => {
+ await renderReady();
+
+ fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
+ await waitFor(() =>
+ expect(historyCalls().some((url) => url.searchParams.get("filter") === "reversals")).toBe(true),
+ );
+ await waitFor(() => expect(screen.getAllByTestId("history-row")).toHaveLength(1));
+ expect(screen.getByTestId("history-meta").textContent).toBe("uncompleted · joshua (admin)");
+ expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
+
+ fireEvent.click(screen.getByRole("button", { name: "Missed" }));
+ const missedDay = await screen.findByTestId("day-2026-09-21");
+ expect(within(missedDay).getByRole("heading").textContent).toBe("Mon, Sep 21");
+ expect(metas(missedDay)).toEqual(["missed · nightly sweep"]);
+ expect(within(missedDay).getByTestId("history-dot").className).toContain("history-dot--missed");
+ expect(within(missedDay).getByText("6:00 PM")).toBeTruthy();
+ // Stats stay the period's numbers whichever chip is selected.
+ expect(screen.getByTestId("stat-completed").textContent).toBe("4Completed");
+
+ fetchMock.mockClear();
+ fireEvent.click(screen.getByRole("button", { name: "All events" }));
+ await screen.findByTestId(`day-${TODAY}`);
+ expect(historyCalls().map((url) => url.searchParams.get("filter"))).toContain("all");
+ expect(screen.getAllByTestId("history-row")).toHaveLength(5);
+ });
+
+ it("shows the append-only footer", async () => {
+ await renderReady();
+ expect(FOOTER_NOTE).toBe("Events are never edited or deleted. A reversal is its own row.");
+ expect(screen.getByTestId("history-footer").textContent).toBe(FOOTER_NOTE);
+ });
+
+ describe("CSV export", () => {
+ let createObjectURL: ReturnType;
+ let revokeObjectURL: ReturnType;
+ let clicked: { href: string; download: string; attached: boolean }[];
+
+ beforeEach(() => {
+ createObjectURL = vi.fn(() => "blob:nestquest/csv-1");
+ revokeObjectURL = vi.fn();
+ // jsdom has no object URLs; install stubs and remove them afterwards.
+ Object.assign(URL, { createObjectURL, revokeObjectURL });
+ clicked = [];
+ vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) {
+ clicked.push({
+ href: this.getAttribute("href") ?? "",
+ download: this.download,
+ attached: document.body.contains(this),
+ });
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ const stubbed = URL as unknown as Record;
+ delete stubbed.createObjectURL;
+ delete stubbed.revokeObjectURL;
+ });
+
+ function csvCalls(): [URL, RequestInit][] {
+ return fetchMock.mock.calls
+ .map(([url, init]) => [new URL(String(url), "http://x"), init] as [URL, RequestInit])
+ .filter(([url]) => url.pathname === "/api/v1/admin/history.csv");
+ }
+
+ it("requests the CSV for the active filter and displayed range with the Bearer token", async () => {
+ await renderReady();
+ fireEvent.click(screen.getByRole("button", { name: "CSV" }));
+ await waitFor(() => expect(clicked).toHaveLength(1));
+
+ const calls = csvCalls();
+ expect(calls).toHaveLength(1);
+ const [url, init] = calls[0];
+ expect(url.searchParams.get("filter")).toBe("all");
+ expect(url.searchParams.get("start")).toBe("2026-09-10");
+ expect(url.searchParams.get("end")).toBe(TODAY);
+ expect((init.headers as Record).Authorization).toBe("Bearer at-123");
+ // The token never travels in the URL.
+ expect(url.toString()).not.toContain("at-123");
+ });
+
+ it("offers the returned CSV for download, then revokes the URL and removes the anchor", async () => {
+ await renderReady();
+ fireEvent.click(screen.getByRole("button", { name: "CSV" }));
+ await waitFor(() => expect(clicked).toHaveLength(1));
+
+ expect(createObjectURL).toHaveBeenCalledTimes(1);
+ const blob = createObjectURL.mock.calls[0][0] as Blob;
+ expect(await blob.text()).toBe(`all\n${CSV_BODY}`);
+ expect(clicked[0]).toEqual({
+ href: "blob:nestquest/csv-1",
+ download: "nestquest-history-2026-09-10-to-2026-09-23.csv",
+ attached: true,
+ });
+ expect(revokeObjectURL).toHaveBeenCalledWith("blob:nestquest/csv-1");
+ expect(document.querySelector("a[download]")).toBeNull();
+ expect(screen.queryByTestId("history-export-error")).toBeNull();
+ });
+
+ it("falls back to the range filename without a Content-Disposition header", async () => {
+ fetchMock.mockImplementation((url: string) =>
+ Promise.resolve(
+ new URL(url, "http://x").pathname.endsWith(".csv")
+ ? new Response(CSV_BODY, { status: 200, headers: { "Content-Type": "text/csv" } })
+ : routeFetch(url),
+ ),
+ );
+ await renderReady();
+ fireEvent.click(screen.getByRole("button", { name: "CSV" }));
+ await waitFor(() => expect(clicked).toHaveLength(1));
+ expect(clicked[0].download).toBe("nestquest-history-2026-09-10-to-2026-09-23.csv");
+ });
+
+ it("switching the filter chip then exporting uses the new filter", async () => {
+ await renderReady();
+ fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
+ await waitFor(() => expect(screen.getAllByTestId("history-row")).toHaveLength(1));
+
+ fireEvent.click(screen.getByRole("button", { name: "CSV" }));
+ await waitFor(() => expect(clicked).toHaveLength(1));
+ const calls = csvCalls();
+ expect(calls).toHaveLength(1);
+ expect(calls[0][0].searchParams.get("filter")).toBe("reversals");
+ expect(calls[0][0].searchParams.get("start")).toBe("2026-09-10");
+ expect(calls[0][0].searchParams.get("end")).toBe(TODAY);
+ });
+
+ it("a failed export shows a non-blocking error and does not download", async () => {
+ fetchMock.mockImplementation((url: string) =>
+ Promise.resolve(
+ new URL(url, "http://x").pathname.endsWith(".csv")
+ ? jsonResponse({ detail: "boom" }, 500)
+ : routeFetch(url),
+ ),
+ );
+ await renderReady();
+ fireEvent.click(screen.getByRole("button", { name: "CSV" }));
+
+ const error = await screen.findByTestId("history-export-error");
+ expect(error.textContent).toContain("The CSV export failed");
+ expect(createObjectURL).not.toHaveBeenCalled();
+ expect(clicked).toHaveLength(0);
+ // The tab stays usable: rows remain, the button is re-enabled, chips still work.
+ expect(screen.getAllByTestId("history-row")).toHaveLength(5);
+ expect((screen.getByRole("button", { name: "CSV" }) as HTMLButtonElement).disabled).toBe(false);
+ fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
+ await waitFor(() => expect(screen.getAllByTestId("history-row")).toHaveLength(1));
+
+ fireEvent.click(within(error).getByRole("button", { name: "Dismiss" }));
+ expect(screen.queryByTestId("history-export-error")).toBeNull();
+ });
+
+ it("the button is disabled while the request is in flight (no double-submit)", async () => {
+ let release: (response: Response) => void = () => {};
+ fetchMock.mockImplementation((url: string) =>
+ new URL(url, "http://x").pathname.endsWith(".csv")
+ ? new Promise((resolve) => {
+ release = resolve;
+ })
+ : Promise.resolve(routeFetch(url)),
+ );
+ await renderReady();
+ const csv = screen.getByRole("button", { name: "CSV" }) as HTMLButtonElement;
+ fireEvent.click(csv);
+ await waitFor(() => expect(csv.disabled).toBe(true));
+ fireEvent.click(csv);
+ expect(csvCalls()).toHaveLength(1);
+
+ release(csvResponse("all", "2026-09-10", TODAY));
+ await waitFor(() => expect(clicked).toHaveLength(1));
+ await waitFor(() => expect(csv.disabled).toBe(false));
+ expect(csvCalls()).toHaveLength(1);
+ });
+ });
+
+ it("rows are read-only: no row carries a control", async () => {
+ await renderReady();
+ for (const el of screen.getAllByTestId("history-row")) {
+ expect(within(el).queryByRole("button")).toBeNull();
+ }
+ });
+
+ it("a failed fetch shows the error state and Try again reloads", async () => {
+ fetchMock.mockImplementation(() => Promise.resolve(jsonResponse({ detail: "boom" }, 500)));
+ render( );
+
+ const error = await screen.findByTestId("history-error");
+ expect(error.textContent).toContain("The history could not be loaded");
+ expect(screen.getByTestId("history-footer")).toBeTruthy();
+
+ fetchMock.mockImplementation((url: string) => Promise.resolve(routeFetch(url)));
+ fireEvent.click(within(error).getByRole("button", { name: "Try again" }));
+ await screen.findByTestId("stat-on-time");
+ expect(screen.queryByTestId("history-error")).toBeNull();
+ });
+
+ it("the scroll column ends with 92px bottom padding", async () => {
+ await renderReady();
+ expect(screen.getByTestId("history-scroll").style.paddingBottom).toBe("92px");
+ });
+});
+
+describe("HistoryTab live updates", () => {
+ let fetchMock: ReturnType;
+ let streams: TestStream[];
+
+ function historyRequests(): URL[] {
+ return fetchMock.mock.calls
+ .map(([url]) => new URL(String(url), "http://x"))
+ .filter((url) => url.pathname === "/api/v1/admin/history");
+ }
+
+ const PAYLOAD = {
+ child_id: 2,
+ child_name: "Maeve",
+ instance_id: 2,
+ quest_title: "Make bed",
+ window: "morning",
+ due_date: TODAY,
+ due_time: null,
+ occurred_at: "2026-09-23T15:10:00Z",
+ };
+
+ beforeAll(() => {
+ vi.stubEnv("TZ", "America/Los_Angeles");
+ });
+
+ afterAll(() => {
+ vi.unstubAllEnvs();
+ });
+
+ beforeEach(() => {
+ sessionStorage.clear();
+ storeTokens({ access_token: "at-123", expires_in: 600 });
+ streams = [];
+ fetchMock = vi.fn((url: string) => {
+ if (new URL(url, "http://x").pathname.endsWith("/api/v1/admin/events")) {
+ const stream = testStream();
+ streams.push(stream);
+ return Promise.resolve(stream.response);
+ }
+ return Promise.resolve(routeFetch(url));
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ });
+
+ it("a quest transition refetches the active filter; a day-complete does not", async () => {
+ render(
+
+
+ ,
+ );
+ await screen.findByTestId("stat-on-time");
+ fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
+ await waitFor(() =>
+ expect(historyRequests().some((u) => u.searchParams.get("filter") === "reversals")).toBe(true),
+ );
+ await screen.findByTestId("stat-on-time");
+ await waitFor(() => expect(streams).toHaveLength(1));
+ const before = historyRequests().length;
+
+ streams[0].push(
+ frame("nestquest_child_day_complete", {
+ child_id: 2,
+ child_name: "Maeve",
+ quests_due: 4,
+ quests_completed: 4,
+ occurred_at: "2026-09-23T15:00:00Z",
+ }),
+ );
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ expect(historyRequests().length).toBe(before);
+
+ streams[0].push(frame("nestquest_quest_uncompleted", PAYLOAD));
+
+ await waitFor(() => expect(historyRequests().length).toBe(before + 3));
+ const refetched = historyRequests().slice(before);
+ // One refetch (reversals + the two stats queries), same range and filter.
+ expect(refetched.map((u) => u.searchParams.get("filter")).sort()).toEqual([
+ "all",
+ "missed",
+ "reversals",
+ ]);
+ for (const url of refetched) {
+ expect(url.searchParams.get("start")).toBe("2026-09-10");
+ expect(url.searchParams.get("end")).toBe(TODAY);
+ }
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ expect(historyRequests().length).toBe(before + 3);
+ expect(screen.queryByTestId("history-loading")).toBeNull();
+ expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
+ // Rows stay read-only (D-005): no row carries a control.
+ for (const row of screen.getAllByTestId("history-row")) {
+ expect(within(row).queryByRole("button")).toBeNull();
+ }
+ });
+
+ describe("a filter change interleaved with a quest transition", () => {
+ // Both land before the load effect runs, so one load serves both.
+ async function interleave(order: "sse-first" | "chip-first") {
+ await act(async () => {
+ const click = () => fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
+ if (order === "chip-first") click();
+ streams[0].push(frame("nestquest_quest_uncompleted", PAYLOAD));
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ if (order === "sse-first") click();
+ });
+ }
+
+ async function renderLive() {
+ render(
+
+
+ ,
+ );
+ await screen.findByTestId("stat-on-time");
+ await waitFor(() => expect(streams).toHaveLength(1));
+ expect(screen.getAllByTestId("history-row")).toHaveLength(ROWS.all.length);
+ }
+
+ function titles(): string[] {
+ return screen.getAllByTestId("history-row").map((el) => el.textContent ?? "");
+ }
+
+ for (const order of ["sse-first", "chip-first"] as const) {
+ it(`${order}: shows loading, then only the active filter's rows`, async () => {
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const route = fetchMock.getMockImplementation()!;
+ await renderLive();
+ fetchMock.mockImplementation((url: string) => {
+ const parsed = new URL(url, "http://x");
+ if (parsed.searchParams.get("filter") === "reversals") {
+ return gate.then(() => route(url));
+ }
+ return route(url);
+ });
+
+ await interleave(order);
+ expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
+ // The previous filter's rows never show under the new chip.
+ expect(screen.getByTestId("history-loading")).toBeTruthy();
+ expect(screen.queryAllByTestId("history-row")).toHaveLength(0);
+
+ release();
+ await screen.findByTestId("stat-on-time");
+ expect(titles()).toHaveLength(1);
+ expect(titles()[0]).toContain("Make bed · Maeve");
+ expect(screen.getAllByTestId("history-dot")[0].className).toContain("history-dot--uncompleted");
+ });
+
+ it(`${order}: a failed filter-change load shows the error state, not stale rows`, async () => {
+ const route = fetchMock.getMockImplementation()!;
+ await renderLive();
+ fetchMock.mockImplementation((url: string) => {
+ const parsed = new URL(url, "http://x");
+ if (parsed.pathname === "/api/v1/admin/history") {
+ return Promise.resolve(jsonResponse({ detail: "boom" }, 500));
+ }
+ return route(url);
+ });
+
+ await interleave(order);
+ const error = await screen.findByTestId("history-error");
+ expect(error.textContent).toContain("The history could not be loaded");
+ expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
+ expect(screen.queryAllByTestId("history-row")).toHaveLength(0);
+ expect(screen.queryByTestId("stat-on-time")).toBeNull();
+ });
+ }
+ });
+
+ it("a failed transition refresh keeps the active filter's rows on screen", async () => {
+ render(
+
+
+ ,
+ );
+ await screen.findByTestId("stat-on-time");
+ await waitFor(() => expect(streams).toHaveLength(1));
+ const route = fetchMock.getMockImplementation()!;
+ fetchMock.mockImplementation((url: string) => {
+ if (new URL(url, "http://x").pathname === "/api/v1/admin/history") {
+ return Promise.resolve(jsonResponse({ detail: "boom" }, 500));
+ }
+ return route(url);
+ });
+ const before = historyRequests().length;
+
+ streams[0].push(frame("nestquest_quest_uncompleted", PAYLOAD));
+ // The "all" filter plus the missed stats query.
+ await waitFor(() => expect(historyRequests().length).toBe(before + 2));
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ expect(screen.queryByTestId("history-error")).toBeNull();
+ expect(screen.queryByTestId("history-loading")).toBeNull();
+ expect(screen.getAllByTestId("history-row")).toHaveLength(ROWS.all.length);
+ });
+});
diff --git a/admin/src/history/HistoryTab.tsx b/admin/src/history/HistoryTab.tsx
new file mode 100644
index 0000000..33ab53d
--- /dev/null
+++ b/admin/src/history/HistoryTab.tsx
@@ -0,0 +1,300 @@
+/**
+ * History tab — the append-only event log (design/ADMIN-SPEC.md §5).
+ *
+ * Rows are read-only: events are never edited or deleted, and a reversal is
+ * its own row (D-005).
+ */
+import { useEffect, useRef, useState } from "react";
+import { ApiForbiddenError } from "../api/client";
+import type { TransitionEvent } from "../api/events";
+import { useTransitions } from "../api/useTransitions";
+import { exportHistoryCsv, fetchHistory, type HistoryFilter, type HistoryRow } from "../api/history";
+import { localTodayIso } from "../schedule/cycle";
+import { dayLabel, groupByDay, rowMeta, rowTime, shiftIso } from "./format";
+import { DownloadGlyph, LockGlyph } from "./glyphs";
+import "./HistoryTab.css";
+
+/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
+export const SCROLL_BOTTOM_PADDING = "92px";
+
+export const RANGE_DAYS = 14;
+
+export const FOOTER_NOTE = "Events are never edited or deleted. A reversal is its own row.";
+
+export const FILTERS: { id: HistoryFilter; label: string }[] = [
+ { id: "all", label: "All events" },
+ { id: "reversals", label: "Reversals" },
+ { id: "missed", label: "Missed" },
+];
+
+export interface HistoryStats {
+ /** Whole percent, or null when the period has no completions. */
+ onTimePct: number | null;
+ completed: number;
+ missed: number;
+}
+
+/** Per-period stats from the `all` rows (completions) and the `missed` rows. */
+export function computeStats(allRows: HistoryRow[], missedRows: HistoryRow[]): HistoryStats {
+ const completions = allRows.filter((row) => row.event_type === "completed");
+ const onTime = completions.filter((row) => row.was_on_time === true).length;
+ return {
+ onTimePct: completions.length > 0 ? Math.round((onTime / completions.length) * 100) : null,
+ completed: completions.length,
+ missed: missedRows.filter((row) => row.event_type === "missed").length,
+ };
+}
+
+interface Loaded {
+ rows: HistoryRow[];
+ stats: HistoryStats;
+}
+
+type LoadState =
+ | { kind: "loading" }
+ | { kind: "error"; message: string }
+ /** `key` is the (filter, start, end) the rows were loaded for. */
+ | { kind: "ready"; data: Loaded; key: string };
+
+/**
+ * The selected filter's rows plus the period stats. Stats always come from
+ * the `all` and `missed` queries, whichever chip is selected, so switching
+ * chips never changes the period's numbers.
+ */
+async function loadHistory(filter: HistoryFilter, start: string, end: string): Promise {
+ const [selected, all, missed] = await Promise.all([
+ fetchHistory(filter, start, end),
+ filter === "all" ? null : fetchHistory("all", start, end),
+ filter === "missed" ? null : fetchHistory("missed", start, end),
+ ]);
+ return {
+ rows: selected.rows,
+ stats: computeStats((all ?? selected).rows, (missed ?? selected).rows),
+ };
+}
+
+/** Offer a Blob as a file download without navigating the page. */
+export function offerDownload(blob: Blob, filename: string): void {
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = filename;
+ anchor.style.display = "none";
+ document.body.appendChild(anchor);
+ try {
+ anchor.click();
+ } finally {
+ anchor.remove();
+ URL.revokeObjectURL(url);
+ }
+}
+
+function ScreenHeader({ exporting, onExport }: { exporting: boolean; onExport: () => void }) {
+ return (
+
+ );
+}
+
+function StatRow({ stats }: { stats: HistoryStats }) {
+ const items = [
+ { key: "on-time", label: "On time", value: stats.onTimePct === null ? "—" : `${stats.onTimePct}%` },
+ { key: "completed", label: "Completed", value: String(stats.completed) },
+ { key: "missed", label: "Missed", value: String(stats.missed) },
+ ];
+ return (
+
+ {items.map((item) => (
+
+
{item.value}
+
{item.label}
+
+ ))}
+
+ );
+}
+
+function EventRow({ row }: { row: HistoryRow }) {
+ return (
+
+
+
+
+ {row.quest_title} · {row.child_name}
+
+
+ {rowMeta(row)}
+
+
+ {rowTime(row)}
+
+ );
+}
+
+function FooterNote() {
+ return (
+
+
+
+
+ {FOOTER_NOTE}
+
+ );
+}
+
+export default function HistoryTab({ today = localTodayIso() }: { today?: string }) {
+ const [filter, setFilter] = useState("all");
+ const [state, setState] = useState({ kind: "loading" });
+ const [attempt, setAttempt] = useState(0);
+ const [exporting, setExporting] = useState(false);
+ const [exportError, setExportError] = useState(null);
+ const exportingRef = useRef(false);
+ const start = shiftIso(today, -(RANGE_DAYS - 1));
+ const key = `${filter}|${start}|${today}`;
+
+ // A quest transition is a new history row (or changes a derived missed row);
+ // a day-complete adds none. The refetch keeps the active filter and range.
+ useTransitions((event: TransitionEvent) => {
+ if (event.type === "nestquest_child_day_complete") return;
+ setAttempt((n) => n + 1);
+ });
+
+ /** Download the CSV for the active filter over the displayed range. */
+ async function exportCsv() {
+ if (exportingRef.current) return;
+ exportingRef.current = true;
+ setExporting(true);
+ setExportError(null);
+ try {
+ const { blob, filename } = await exportHistoryCsv(filter, start, today);
+ offerDownload(blob, filename);
+ } catch (error: unknown) {
+ setExportError(
+ error instanceof ApiForbiddenError
+ ? error.message
+ : "The CSV export failed. Check your connection and try again.",
+ );
+ } finally {
+ exportingRef.current = false;
+ setExporting(false);
+ }
+ }
+
+ useEffect(() => {
+ let cancelled = false;
+ // Rows already loaded for this (filter, range) stay on screen while they
+ // refresh; any other rows give way to the loading state.
+ const isCurrent = (prev: LoadState) => prev.kind === "ready" && prev.key === key;
+ setState((prev) => (isCurrent(prev) ? prev : { kind: "loading" }));
+ loadHistory(filter, start, today)
+ .then((data) => {
+ if (!cancelled) setState({ kind: "ready", data, key });
+ })
+ .catch((error: unknown) => {
+ if (cancelled) return;
+ const message =
+ error instanceof ApiForbiddenError
+ ? error.message
+ : "The history could not be loaded. Check your connection and try again.";
+ // A failed refresh keeps the rows already on screen.
+ setState((prev) => (isCurrent(prev) ? prev : { kind: "error", message }));
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [filter, start, today, key, attempt]);
+
+ // Never render one filter's rows under another's chip, even for the render
+ // before the load effect runs.
+ const shown: LoadState = state.kind === "ready" && state.key !== key ? { kind: "loading" } : state;
+
+ let body;
+ if (shown.kind === "loading") {
+ body = (
+
+ Loading history…
+
+ );
+ } else if (shown.kind === "error") {
+ body = (
+
+
{shown.message}
+
setAttempt((n) => n + 1)}>
+ Try again
+
+
+ );
+ } else {
+ const groups = groupByDay(shown.data.rows);
+ body =
+ groups.length > 0 ? (
+ groups.map((group) => (
+
+ {dayLabel(group.day, today)}
+
+ {group.rows.map((row, index) => (
+
+ ))}
+
+
+ ))
+ ) : (
+ No events in the last {RANGE_DAYS} days.
+ );
+ }
+
+ return (
+
+
void exportCsv()} />
+
+ {exportError !== null ? (
+
+ {exportError}
+ setExportError(null)}>
+ Dismiss
+
+
+ ) : null}
+ {shown.kind === "ready" ?
: null}
+
+ {FILTERS.map((item) => (
+ setFilter(item.id)}
+ >
+ {item.label}
+
+ ))}
+
+ {body}
+
+
+
+ );
+}
diff --git a/admin/src/history/format.ts b/admin/src/history/format.ts
new file mode 100644
index 0000000..7575563
--- /dev/null
+++ b/admin/src/history/format.ts
@@ -0,0 +1,94 @@
+/**
+ * Day grouping and row wording for the History tab.
+ *
+ * Calendar dates ("YYYY-MM-DD") are handled by their components (never
+ * `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
+ * day in negative-offset zones); event stamps are UTC and shown in the
+ * viewer's local time.
+ */
+import type { HistoryRow } from "../api/history";
+import { dayNumber, isoFromDayNumber, localTodayIso } from "../schedule/cycle";
+import { shortDate } from "../schedule/format";
+import { formatClockTime, formatDueTime } from "../today/format";
+
+const MS_PER_MINUTE = 60_000;
+
+/** `iso` shifted by `days` calendar days. */
+export function shiftIso(iso: string, days: number): string {
+ return isoFromDayNumber(dayNumber(iso) + days);
+}
+
+/** The local calendar day an event belongs to (due_date for missed rows). */
+export function rowDay(row: HistoryRow): string {
+ return row.occurred_at ? localTodayIso(new Date(row.occurred_at)) : row.due_date;
+}
+
+/** The row's due moment as local wall-clock time, or null without a due time. */
+function dueMoment(row: HistoryRow): Date | null {
+ const match = /^(\d{2}):(\d{2})/.exec(row.due_time ?? "");
+ if (!match) return null;
+ const [year, month, day] = row.due_date.split("-").map(Number);
+ return new Date(year, month - 1, day, Number(match[1]), Number(match[2]));
+}
+
+/** Newest first; a missed row sorts at its due moment (or the end of its day). */
+function sortKey(row: HistoryRow): number {
+ if (row.occurred_at) return Date.parse(row.occurred_at);
+ const due = dueMoment(row);
+ if (due) return due.getTime();
+ const [year, month, day] = row.due_date.split("-").map(Number);
+ return new Date(year, month - 1, day, 23, 59).getTime();
+}
+
+export interface DayGroup {
+ day: string;
+ rows: HistoryRow[];
+}
+
+/** Rows grouped by day, newest day first and newest row first within a day. */
+export function groupByDay(rows: HistoryRow[]): DayGroup[] {
+ const groups = new Map();
+ for (const row of rows) {
+ const day = rowDay(row);
+ groups.set(day, [...(groups.get(day) ?? []), row]);
+ }
+ return [...groups.entries()]
+ .sort(([a], [b]) => b.localeCompare(a))
+ .map(([day, dayRows]) => ({
+ day,
+ // Stable sort: rows at the same moment keep the API's append order.
+ rows: [...dayRows].sort((a, b) => sortKey(b) - sortKey(a)),
+ }));
+}
+
+/** "Today", "Yesterday", else "Mon, Sep 21" (the CSS uppercases it). */
+export function dayLabel(day: string, today: string): string {
+ if (day === today) return "Today";
+ if (day === shiftIso(today, -1)) return "Yesterday";
+ const weekday = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" }).format(
+ new Date(dayNumber(day) * 86_400_000),
+ );
+ return `${weekday}, ${shortDate(day)}`;
+}
+
+/** "on time", "late 22 min", or "late" when the lateness is not derivable. */
+function onTimeFlag(row: HistoryRow): string | null {
+ if (row.event_type !== "completed" || row.was_on_time === null) return null;
+ if (row.was_on_time) return "on time";
+ const due = dueMoment(row);
+ if (!due || !row.occurred_at) return "late";
+ const minutes = Math.round((Date.parse(row.occurred_at) - due.getTime()) / MS_PER_MINUTE);
+ return minutes > 0 ? `late ${minutes} min` : "late";
+}
+
+/** ` · ` plus the on-time flag for completions. */
+export function rowMeta(row: HistoryRow): string {
+ const flag = onTimeFlag(row);
+ return [row.event_type, row.actor, flag].filter(Boolean).join(" · ");
+}
+
+/** The event's local clock time; a missed row shows its due time, if any. */
+export function rowTime(row: HistoryRow): string {
+ if (row.occurred_at) return formatClockTime(row.occurred_at);
+ return row.due_time ? formatDueTime(row.due_time) : "";
+}
diff --git a/admin/src/history/glyphs.tsx b/admin/src/history/glyphs.tsx
new file mode 100644
index 0000000..364fe82
--- /dev/null
+++ b/admin/src/history/glyphs.tsx
@@ -0,0 +1,41 @@
+/**
+ * Inline stroke glyphs for the History tab (Lucide-style 24px grid),
+ * CDN-free like the other tabs' glyphs.
+ */
+import type { ReactNode } from "react";
+
+function Glyph({ size, children }: { size: number; children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function DownloadGlyph({ size = 15 }: { size?: number }) {
+ return (
+
+
+
+ );
+}
+
+export function LockGlyph({ size = 15 }: { size?: number }) {
+ return (
+
+
+
+
+ );
+}
diff --git a/admin/src/index.css b/admin/src/index.css
new file mode 100644
index 0000000..63b2f4c
--- /dev/null
+++ b/admin/src/index.css
@@ -0,0 +1,76 @@
+/* NestQuest Admin — global tokens + base.
+ Plain CubeCraft styling, mobile-first, 390x844 reference
+ (design/ADMIN-SPEC.md). Values mirror design/tokens/nestquest-admin-tokens.css,
+ re-scoped to :root for the standalone PWA. */
+
+:root {
+ --nq-brand-blue: #3b3ab8;
+ --nq-brand-purple: #a035cc;
+ --nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));
+ --nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple));
+
+ --nq-a-page: #f4f4f6;
+ --nq-a-surface: #ffffff;
+ --nq-a-selected: #ebebf8;
+ --nq-a-border: #e4e4ea;
+ --nq-a-divider: #ededf1;
+
+ --nq-a-ink: #18181d;
+ --nq-a-ink-secondary: #52525e;
+ --nq-a-ink-tertiary: #70707e;
+
+ --nq-a-success: #15803d;
+ --nq-a-danger: #dc2626;
+ --nq-a-danger-strong: #b91c1c;
+ --nq-a-danger-bg: #fee2e2;
+ --nq-a-warning-ink: #92400e;
+ --nq-a-warning-ink-2: #78350f;
+ --nq-a-warning-bg: #fef3c7;
+ --nq-a-warning-border: rgba(217, 119, 6, 0.35);
+ --nq-a-info-bg: #dbeafe;
+ --nq-a-info-ink: #1e3a8a;
+ --nq-a-info-icon: #1d4ed8;
+ --nq-a-reversal: #7d28a0;
+
+ --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;
+ --nq-a-density-row-pad: 11px 13px;
+ --nq-a-density-radius: 8px;
+
+ --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;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root {
+ margin: 0;
+ height: 100%;
+}
+
+body {
+ font-family: var(--nq-a-font);
+ background: var(--nq-a-page);
+ color: var(--nq-a-ink);
+ -webkit-font-smoothing: antialiased;
+}
\ No newline at end of file
diff --git a/admin/src/main.tsx b/admin/src/main.tsx
new file mode 100644
index 0000000..5f81c94
--- /dev/null
+++ b/admin/src/main.tsx
@@ -0,0 +1,13 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import AppRoutes from "./AppRoutes";
+import "./index.css";
+import { registerServiceWorker } from "./pwa/register";
+
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
+
+void registerServiceWorker();
diff --git a/admin/src/pwa/pwa.test.ts b/admin/src/pwa/pwa.test.ts
new file mode 100644
index 0000000..4089ab4
--- /dev/null
+++ b/admin/src/pwa/pwa.test.ts
@@ -0,0 +1,396 @@
+import { existsSync, readFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ REVISION_MARKER,
+ STATIC_PRECACHE_FILES,
+ injectServiceWorker,
+} from "../../vite-sw-plugin";
+import { registerServiceWorker } from "./register";
+
+const adminRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
+const adminPath = (rel: string) => resolve(adminRoot, rel);
+const publicPath = (urlPath: string) => adminPath(`public${urlPath}`);
+
+interface ManifestIcon {
+ src: string;
+ sizes: string;
+ type: string;
+ purpose?: string;
+}
+
+const manifest = JSON.parse(readFileSync(publicPath("/manifest.webmanifest"), "utf8"));
+const icons: ManifestIcon[] = manifest.icons;
+const swSource = readFileSync(publicPath("/sw.js"), "utf8");
+
+const sizeOf = (icon: ManifestIcon) => Number(icon.sizes.split("x")[0]);
+const purposes = (icon: ManifestIcon) => (icon.purpose ?? "any").split(/\s+/);
+
+describe("web app manifest", () => {
+ it("meets the installability criteria", () => {
+ expect(manifest.name).toBe("NestQuest Admin");
+ expect(manifest.short_name).toBe("NestQuest");
+ expect(manifest.start_url).toBe("/");
+ expect(manifest.scope).toBe("/");
+ expect(manifest.display).toBe("standalone");
+ expect(manifest.background_color).toMatch(/^#[0-9a-f]{6}$/i);
+ expect(manifest.theme_color).toMatch(/^#[0-9a-f]{6}$/i);
+
+ const any = icons.filter((i) => purposes(i).includes("any"));
+ expect(any.some((i) => sizeOf(i) >= 192)).toBe(true);
+ expect(any.some((i) => sizeOf(i) >= 512)).toBe(true);
+ expect(icons.some((i) => purposes(i).includes("maskable"))).toBe(true);
+ });
+
+ it("uses the admin design token colours", () => {
+ const css = readFileSync(adminPath("src/index.css"), "utf8");
+ expect(css).toContain(`--nq-brand-blue: ${manifest.theme_color};`);
+ expect(css).toContain(`--nq-a-page: ${manifest.background_color};`);
+ });
+
+ it.each(icons.map((i) => [i.src, i] as const))(
+ "icon %s is a real PNG of its declared size",
+ (_src, icon) => {
+ const path = publicPath(icon.src);
+ expect(existsSync(path)).toBe(true);
+ const png = readFileSync(path);
+ expect([...png.subarray(0, 8)]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+ expect(png.toString("ascii", 12, 16)).toBe("IHDR");
+ const [w, h] = icon.sizes.split("x").map(Number);
+ expect(png.readUInt32BE(16)).toBe(w);
+ expect(png.readUInt32BE(20)).toBe(h);
+ expect(icon.type).toBe("image/png");
+ },
+ );
+});
+
+describe("index.html and entry point", () => {
+ const html = readFileSync(adminPath("index.html"), "utf8");
+
+ it("links the manifest, theme colour and apple touch icon", () => {
+ expect(html).toContain(' ');
+ expect(html).toContain(` `);
+ expect(html).toContain(' ');
+ });
+
+ it("main.tsx registers the service worker", () => {
+ const main = readFileSync(adminPath("src/main.tsx"), "utf8");
+ expect(main).toMatch(/import \{ registerServiceWorker \} from "\.\/pwa\/register"/);
+ expect(main).toMatch(/registerServiceWorker\(\)/);
+ });
+});
+
+// Minimal service-worker global scope: runs public/sw.js against in-memory
+// caches and a stubbed network, so handler behaviour is checked without I/O.
+function loadServiceWorker(
+ network: (url: string) => Promise,
+ source: string = swSource,
+) {
+ const listeners: Record void> = {};
+ const stores = new Map>();
+ const openStore = (name: string) => {
+ if (!stores.has(name)) stores.set(name, new Map());
+ return stores.get(name)!;
+ };
+ const keyOf = (req: string | { url: string }) =>
+ new URL(typeof req === "string" ? req : req.url, "https://admin.test").href;
+ const fetchFn = vi.fn((req: string | { url: string }) => network(keyOf(req)));
+
+ const caches = {
+ open: async (name: string) => {
+ const store = openStore(name);
+ return {
+ addAll: async (urls: string[]) => {
+ for (const u of urls) store.set(keyOf(u), await network(keyOf(u)));
+ },
+ put: async (req: { url: string }, res: FakeResponse) => void store.set(keyOf(req), res),
+ };
+ },
+ keys: async () => [...stores.keys()],
+ delete: async (name: string) => stores.delete(name),
+ match: async (req: string | { url: string }, opts?: { cacheName?: string }) => {
+ const names = opts?.cacheName ? [opts.cacheName] : [...stores.keys()];
+ for (const n of names) {
+ const hit = stores.get(n)?.get(keyOf(req));
+ if (hit) return hit;
+ }
+ return undefined;
+ },
+ };
+ const self = {
+ location: new URL("https://admin.test/"),
+ addEventListener: (type: string, fn: (event: any) => void) => (listeners[type] = fn),
+ skipWaiting: vi.fn(async () => undefined),
+ clients: { claim: vi.fn(async () => undefined) },
+ };
+ const ResponseStub = { error: () => new FakeResponse("", false, "error") };
+ new Function("self", "caches", "fetch", "Response", source)(self, caches, fetchFn, ResponseStub);
+
+ const dispatch = async (type: string, init: object = {}) => {
+ let pending: Promise | undefined;
+ const event = {
+ ...init,
+ waitUntil: (p: Promise) => (pending = p),
+ respondWith: (p: Promise) => (pending = p),
+ };
+ listeners[type](event);
+ return { responded: pending !== undefined, result: await pending };
+ };
+ return { listeners, stores, self, fetchFn, dispatch, openStore };
+}
+
+class FakeResponse {
+ constructor(
+ readonly body: string,
+ readonly ok = true,
+ readonly type = "basic",
+ ) {}
+ clone() {
+ return new FakeResponse(this.body, this.ok, this.type);
+ }
+}
+
+const request = (url: string, extra: object = {}) => ({
+ url: new URL(url, "https://admin.test").href,
+ method: "GET",
+ mode: "cors",
+ destination: "",
+ ...extra,
+});
+
+describe("service worker (public/sw.js)", () => {
+ it("is a static asset served from the site root", () => {
+ expect(existsSync(publicPath("/sw.js"))).toBe(true);
+ expect(swSource).toMatch(/const SHELL_CACHE = /);
+ expect(swSource).toMatch(/addEventListener\("install"/);
+ expect(swSource).toMatch(/addEventListener\("activate"/);
+ expect(swSource).toMatch(/addEventListener\("fetch"/);
+ });
+
+ it("precaches the shell on install, prunes stale caches on activate", async () => {
+ const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
+ sw.openStore("nestquest-admin-shell-v0");
+
+ await sw.dispatch("install");
+ expect(sw.self.skipWaiting).toHaveBeenCalled();
+ const precached = [...sw.stores.values()].flatMap((s) => [...s.keys()]);
+ for (const path of ["/", "/index.html", "/manifest.webmanifest", ...icons.map((i) => i.src)]) {
+ expect(precached).toContain(`https://admin.test${path}`);
+ }
+
+ await sw.dispatch("activate");
+ expect(sw.stores.has("nestquest-admin-shell-v0")).toBe(false);
+ expect(sw.self.clients.claim).toHaveBeenCalled();
+ });
+
+ it("serves the cached shell for navigations when offline", async () => {
+ let online = true;
+ const sw = loadServiceWorker(async (url) => {
+ if (!online) throw new TypeError("Failed to fetch");
+ return new FakeResponse(`net:${url}`);
+ });
+ await sw.dispatch("install");
+ online = false;
+
+ const { result } = await sw.dispatch("fetch", {
+ request: request("/children/42", { mode: "navigate", destination: "document" }),
+ });
+ expect((result as FakeResponse).body).toBe("net:https://admin.test/index.html");
+ });
+
+ it("serves static assets cache-first and fills the runtime cache", async () => {
+ const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
+ const asset = request("/assets/index-abc123.js", { destination: "script" });
+
+ const first = await sw.dispatch("fetch", { request: asset });
+ expect((first.result as FakeResponse).body).toBe("net:https://admin.test/assets/index-abc123.js");
+ expect(sw.fetchFn).toHaveBeenCalledTimes(1);
+
+ const second = await sw.dispatch("fetch", { request: asset });
+ expect((second.result as FakeResponse).body).toBe("net:https://admin.test/assets/index-abc123.js");
+ expect(sw.fetchFn).toHaveBeenCalledTimes(1);
+ });
+
+ it("never handles cross-origin, non-GET or non-static requests", async () => {
+ const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
+ const cases = [
+ request("https://auth.example.com/logo.png", { destination: "image" }),
+ request("/assets/app.js", { destination: "script", method: "POST" }),
+ request("/api/admin/children"),
+ ];
+ for (const req of cases) {
+ const { responded } = await sw.dispatch("fetch", { request: req });
+ expect(responded).toBe(false);
+ }
+ expect(sw.fetchFn).not.toHaveBeenCalled();
+ });
+});
+
+// A Vite-style built index.html referencing hashed bundles.
+const builtIndexHtml = (hash: string) => `
+
+
+
+
+
+
+
+
+
+
+`;
+
+// Same-origin script/link URLs of an HTML document, parsed independently of
+// the build plugin so the regression check does not trust its own extractor.
+const referencedAssets = (html: string) =>
+ [...new DOMParser().parseFromString(html, "text/html").querySelectorAll("script[src], link[href]")]
+ .map((el) => new URL(el.getAttribute("src") ?? el.getAttribute("href")!, "https://admin.test/"))
+ .filter((url) => url.origin === "https://admin.test")
+ .map((url) => url.href);
+
+// The unhashed files the build hashes into the revision, read from public/.
+const staticFiles = STATIC_PRECACHE_FILES.map((path) => ({
+ path,
+ bytes: readFileSync(publicPath(`/${path}`)),
+}));
+const revisionOf = (src: string) => /const REVISION = "([0-9a-f]+)";/.exec(src)?.[1];
+
+describe("build-time service worker injection", () => {
+ it("precaches every asset the cached index.html references on install (PWA-001)", async () => {
+ const html = builtIndexHtml("Ab12Cd34");
+ const built = injectServiceWorker(html, swSource, staticFiles);
+ const sw = loadServiceWorker(async (url) =>
+ new FakeResponse(url.endsWith("/index.html") ? html : `net:${url}`),
+ built);
+
+ await sw.dispatch("install");
+ const shell = [...sw.stores.entries()].find(([name]) => name.startsWith("nestquest-admin-shell-"));
+ expect(shell).toBeDefined();
+ const [, shellStore] = shell!;
+
+ const cachedHtml = shellStore.get("https://admin.test/index.html")!.body;
+ const assets = referencedAssets(cachedHtml);
+ expect(assets).toEqual(
+ expect.arrayContaining([
+ "https://admin.test/assets/index-Ab12Cd34.js",
+ "https://admin.test/assets/vendor-Ab12Cd34.js",
+ "https://admin.test/assets/index-Ab12Cd34.css",
+ ]),
+ );
+ for (const asset of assets) expect([...shellStore.keys()]).toContain(asset);
+ expect([...shellStore.keys()]).not.toContain("https://auth.example.com/");
+ });
+
+ it("derives a new revision for different index.html content (PWA-002)", () => {
+ const a = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
+ const b = injectServiceWorker(builtIndexHtml("bbbb2222"), swSource, staticFiles);
+
+ expect(a).not.toContain(REVISION_MARKER);
+ expect(b).not.toContain(REVISION_MARKER);
+ expect(revisionOf(a)).toMatch(/^[0-9a-f]{12}$/);
+ expect(revisionOf(b)).toMatch(/^[0-9a-f]{12}$/);
+ expect(revisionOf(a)).not.toBe(revisionOf(b));
+ expect(injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles)).toBe(a);
+ });
+
+ it("derives a new revision when only a precached static file changes (PWA-002)", () => {
+ const html = builtIndexHtml("aaaa1111");
+ const base = revisionOf(injectServiceWorker(html, swSource, staticFiles));
+ const withChanged = (path: string) =>
+ staticFiles.map((f) =>
+ f.path === path ? { path, bytes: Buffer.concat([f.bytes, Buffer.from([0])]) } : f,
+ );
+
+ expect(revisionOf(injectServiceWorker(html, swSource, [...staticFiles].reverse()))).toBe(base);
+ for (const path of STATIC_PRECACHE_FILES) {
+ const changed = revisionOf(injectServiceWorker(html, swSource, withChanged(path)));
+ expect(changed, path).toMatch(/^[0-9a-f]{12}$/);
+ expect(changed, path).not.toBe(base);
+ }
+ });
+
+ it("hashes every unhashed URL sw.js precaches (PWA-002)", () => {
+ const shellUrls = [...swSource.matchAll(/^\s*"(\/[^"]*)",$/gm)].map((m) => m[1]);
+ const fixed = shellUrls.filter((url) => url !== "/" && url !== "/index.html");
+ expect(fixed.map((url) => url.slice(1)).sort()).toEqual([...STATIC_PRECACHE_FILES].sort());
+ });
+
+ it("names both caches after the build revision (PWA-002)", async () => {
+ const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
+ const revision = /const REVISION = "([0-9a-f]+)";/.exec(built)![1];
+ const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built);
+
+ await sw.dispatch("install");
+ await sw.dispatch("fetch", { request: request("/assets/late.js", { destination: "script" }) });
+ expect([...sw.stores.keys()].sort()).toEqual([
+ `nestquest-admin-runtime-${revision}`,
+ `nestquest-admin-shell-${revision}`,
+ ]);
+ });
+
+ it("fails fast when sw.js lacks the placeholders", () => {
+ expect(() => injectServiceWorker(builtIndexHtml("x"), "const SHELL_URLS = [];", staticFiles)).toThrow();
+ });
+
+ it("activation deletes only stale nestquest-admin caches (PWA-003)", async () => {
+ const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
+ const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built);
+ for (const name of [
+ "nestquest-admin-shell-old",
+ "nestquest-admin-runtime-old",
+ "some-other-app-cache",
+ ]) {
+ sw.openStore(name);
+ }
+
+ await sw.dispatch("install");
+ await sw.dispatch("activate");
+ const names = [...sw.stores.keys()];
+ expect(names).not.toContain("nestquest-admin-shell-old");
+ expect(names).not.toContain("nestquest-admin-runtime-old");
+ expect(names).toContain("some-other-app-cache");
+ expect(names.some((n) => n.startsWith("nestquest-admin-shell-") && n !== "nestquest-admin-shell-old")).toBe(true);
+ });
+});
+
+describe("registerServiceWorker", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ const stubServiceWorker = (register: ReturnType) =>
+ vi.stubGlobal("navigator", { ...navigator, serviceWorker: { register } });
+
+ it("does not register outside production builds", async () => {
+ const register = vi.fn();
+ stubServiceWorker(register);
+ expect(import.meta.env.PROD).toBe(false);
+ await registerServiceWorker();
+ expect(register).not.toHaveBeenCalled();
+ });
+
+ it("registers /sw.js in production", async () => {
+ vi.stubEnv("PROD", true);
+ const register = vi.fn().mockResolvedValue({});
+ stubServiceWorker(register);
+ await registerServiceWorker();
+ expect(register).toHaveBeenCalledWith("/sw.js", { scope: "/" });
+ });
+
+ it("logs instead of throwing when registration fails", async () => {
+ vi.stubEnv("PROD", true);
+ stubServiceWorker(vi.fn().mockRejectedValue(new Error("boom")));
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
+ await expect(registerServiceWorker()).resolves.toBeUndefined();
+ expect(error).toHaveBeenCalled();
+ });
+
+ it("does nothing when the browser lacks service workers", async () => {
+ vi.stubEnv("PROD", true);
+ vi.stubGlobal("navigator", {});
+ await expect(registerServiceWorker()).resolves.toBeUndefined();
+ });
+});
diff --git a/admin/src/pwa/register.ts b/admin/src/pwa/register.ts
new file mode 100644
index 0000000..14f00f6
--- /dev/null
+++ b/admin/src/pwa/register.ts
@@ -0,0 +1,13 @@
+/**
+ * Service worker registration for the installable admin PWA.
+ * Registers /sw.js only in production builds, so dev servers and tests never
+ * run a worker. Failures are logged, never thrown — the app works without it.
+ */
+export async function registerServiceWorker(): Promise {
+ if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
+ try {
+ await navigator.serviceWorker.register("/sw.js", { scope: "/" });
+ } catch (error) {
+ console.error("Service worker registration failed:", error);
+ }
+}
diff --git a/admin/src/schedule/OverrideEditor.css b/admin/src/schedule/OverrideEditor.css
new file mode 100644
index 0000000..e95c6e4
--- /dev/null
+++ b/admin/src/schedule/OverrideEditor.css
@@ -0,0 +1,220 @@
+/* Presence override editor — roomy density pushed screen
+ (design/ADMIN-SPEC.md §6). Covers the tab bar like a pushed route. */
+
+.override-screen {
+ position: fixed;
+ inset: 0;
+ z-index: 30;
+ overflow-y: auto;
+ background: var(--nq-a-page);
+}
+
+.override-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 14px 16px;
+ background: var(--nq-a-surface);
+ border-bottom: 1px solid var(--nq-a-border);
+}
+
+.override-back {
+ flex: none;
+ display: grid;
+ place-items: center;
+ width: var(--nq-a-tap-min);
+ height: var(--nq-a-tap-min);
+ margin: -6px 0 -6px -12px;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: var(--nq-a-ink);
+ cursor: pointer;
+}
+
+.override-title {
+ margin: 0;
+ font-size: var(--nq-a-size-screen);
+ font-weight: 800;
+ letter-spacing: -0.02em;
+}
+
+.override-sub {
+ margin: 2px 0 0;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--nq-a-ink-secondary);
+}
+
+.override-scroll {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ width: 100%;
+ max-width: 640px;
+ margin: 0 auto;
+ padding: 16px 16px 24px;
+}
+
+/* Form card: roomy density, 18px padding, 16px gaps. */
+.override-card {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 18px;
+ background: var(--nq-a-surface);
+ border: 1px solid var(--nq-a-border);
+ border-radius: 12px;
+}
+
+.override-field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-width: 0;
+}
+
+.override-label {
+ font-size: var(--nq-a-size-label);
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: var(--nq-a-track-label);
+ color: var(--nq-a-ink-secondary);
+}
+
+.override-segments {
+ display: grid;
+ gap: 6px;
+}
+
+.override-segments--3 {
+ grid-template-columns: repeat(3, 1fr);
+}
+
+.override-segments--2 {
+ grid-template-columns: repeat(2, 1fr);
+}
+
+.override-segment {
+ min-height: var(--nq-a-tap-min);
+ padding: 0 8px;
+ border: 1px solid var(--nq-a-border);
+ border-radius: 10px;
+ background: var(--nq-a-surface);
+ color: var(--nq-a-ink-secondary);
+ font-family: inherit;
+ font-size: 13.5px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.override-segment--gradient[aria-pressed="true"] {
+ border-color: transparent;
+ background: var(--nq-brand-gradient);
+ color: #fff;
+}
+
+.override-segment--ink[aria-pressed="true"] {
+ border-color: #18181d;
+ background: #18181d;
+ color: #fff;
+}
+
+.override-dates {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+}
+
+.override-input {
+ width: 100%;
+ min-height: var(--nq-a-tap-min);
+ padding: 13px;
+ border: 1px solid var(--nq-a-border);
+ border-radius: 10px;
+ background: var(--nq-a-surface);
+ color: var(--nq-a-ink);
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.override-date {
+ font-weight: 700;
+}
+
+/* Consequence warning */
+.override-warning {
+ display: flex;
+ gap: 10px;
+ padding: 14px;
+ border: 1px solid var(--nq-a-warning-border);
+ border-radius: 12px;
+ background: var(--nq-a-warning-bg);
+}
+
+.override-warning-glyph {
+ flex: none;
+ display: flex;
+ color: var(--nq-a-warning-ink);
+}
+
+.override-warning-headline {
+ margin: 0;
+ font-size: 13.5px;
+ font-weight: 700;
+ color: var(--nq-a-warning-ink);
+}
+
+.override-warning-body {
+ margin: 3px 0 0;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--nq-a-warning-ink-2);
+}
+
+.override-error {
+ margin: 0;
+ padding: 10px 12px;
+ border-radius: var(--nq-a-density-radius);
+ background: var(--nq-a-danger-bg);
+ color: var(--nq-a-danger-strong);
+ font-size: 12.5px;
+ font-weight: 700;
+}
+
+/* Footer: Cancel (flex 1) + Save override (flex 2), 15px vertical padding. */
+.override-footer {
+ display: flex;
+ gap: 10px;
+}
+
+.override-cancel,
+.override-save {
+ padding: 15px 12px;
+ border-radius: 12px;
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.override-cancel {
+ flex: 1;
+ border: 1px solid var(--nq-a-border);
+ background: var(--nq-a-surface);
+ color: var(--nq-a-ink);
+}
+
+.override-save {
+ flex: 2;
+ border: 0;
+ background: var(--nq-brand-gradient);
+ color: #fff;
+}
+
+.override-cancel:disabled,
+.override-save:disabled {
+ opacity: 0.45;
+ cursor: default;
+}
diff --git a/admin/src/schedule/OverrideEditor.test.tsx b/admin/src/schedule/OverrideEditor.test.tsx
new file mode 100644
index 0000000..0a2c4bb
--- /dev/null
+++ b/admin/src/schedule/OverrideEditor.test.tsx
@@ -0,0 +1,533 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import App from "../App";
+import ScheduleTab from "./ScheduleTab";
+import { CONSEQUENCE_DEBOUNCE_MS, isIsoDate, isValidRange } from "./OverrideEditor";
+import type { AdminChild } from "../api/definitions";
+import type { PresenceOverride, PresenceSchedule } from "../api/presence";
+import { storeTokens } from "../auth/oidc";
+
+function child(id: number, display_name: string, sort_order: number): AdminChild {
+ return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active: true };
+}
+
+const CHILDREN = [child(1, "Declan", 1), child(2, "Maeve", 2), child(3, "Rory", 3)];
+
+const DECLAN: PresenceSchedule = {
+ child_id: 1,
+ cycle_length_weeks: 2,
+ anchor_date: "2026-09-07",
+ pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] },
+};
+
+const INITIAL_OVERRIDES: PresenceOverride[] = [
+ { id: 41, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: "Dentist trip" },
+];
+
+const TODAY = "2026-09-23";
+const OVERRIDES_PATH = "/api/v1/admin/presence-overrides";
+const CONSEQUENCE_PATH = `${OVERRIDES_PATH}/consequence`;
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+interface Call {
+ method: string;
+ path: string;
+ body: unknown;
+}
+
+let overrides: PresenceOverride[];
+let consequence: (body: Record) => Response | Promise;
+let createResponse: (body: Record) => Response;
+let fetchMock: ReturnType;
+
+function calls(): Call[] {
+ return fetchMock.mock.calls.map(([url, init]: [string, RequestInit | undefined]) => ({
+ method: init?.method ?? "GET",
+ path: new URL(String(url), "http://x").pathname,
+ body: init?.body ? JSON.parse(String(init.body)) : undefined,
+ }));
+}
+
+function consequenceBodies(): unknown[] {
+ return calls()
+ .filter((c) => c.path === CONSEQUENCE_PATH)
+ .map((c) => c.body);
+}
+
+async function routeFetch(url: string, init: RequestInit = {}): Promise {
+ const path = new URL(url, "http://x").pathname;
+ const method = init.method ?? "GET";
+ const body = init.body ? JSON.parse(String(init.body)) : undefined;
+ if (path === CONSEQUENCE_PATH && method === "POST") return consequence(body);
+ if (path === OVERRIDES_PATH && method === "POST") return createResponse(body);
+ const deleteMatch = /\/presence-overrides\/(\d+)$/.exec(path);
+ if (deleteMatch && method === "DELETE") {
+ overrides = overrides.filter((o) => o.id !== Number(deleteMatch[1]));
+ return jsonResponse({ status: "ok" });
+ }
+ if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
+ const scheduleMatch = /\/children\/(\d+)\/presence-schedule$/.exec(path);
+ if (scheduleMatch) {
+ return jsonResponse({ schedule: Number(scheduleMatch[1]) === 1 ? DECLAN : null });
+ }
+ if (path === OVERRIDES_PATH) return jsonResponse({ overrides });
+ return jsonResponse({ detail: "unexpected" }, 500);
+}
+
+function deferred() {
+ let resolve!: (response: Response) => void;
+ const promise = new Promise((r) => {
+ resolve = r;
+ });
+ return { promise, resolve };
+}
+
+const pastDebounce = () => new Promise((r) => setTimeout(r, CONSEQUENCE_DEBOUNCE_MS + 150));
+
+function headline(): string {
+ return screen.getByTestId("override-consequence-headline").textContent ?? "";
+}
+
+function warningBody(): string {
+ return screen.getByTestId("override-consequence").querySelector(".override-warning-body")?.textContent ?? "";
+}
+
+function saveButton(): HTMLButtonElement {
+ return screen.getByRole("button", { name: "Save override" }) as HTMLButtonElement;
+}
+
+function setDate(label: "From" | "Through", value: string) {
+ fireEvent.change(screen.getByLabelText(label), { target: { value } });
+}
+
+async function openEditor() {
+ render( );
+ await screen.findByTestId("pattern-1");
+ fireEvent.click(screen.getByRole("button", { name: "Add" }));
+ await screen.findByRole("heading", { name: "Presence override" });
+}
+
+describe("OverrideEditor", () => {
+ beforeEach(() => {
+ sessionStorage.clear();
+ storeTokens({ access_token: "at-123", expires_in: 600 });
+ overrides = [...INITIAL_OVERRIDES];
+ consequence = () => jsonResponse({ removed: 4 });
+ createResponse = (body) => {
+ const created: PresenceOverride = {
+ id: 50,
+ child_id: body.child_id as number,
+ start_date: body.start_date as string,
+ end_date: body.end_date as string,
+ is_present: body.is_present as boolean,
+ note: (body.note as string | undefined) ?? null,
+ };
+ overrides = [...overrides, created];
+ return jsonResponse(created, 201);
+ };
+ fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ });
+
+ it("renders the pushed screen per ADMIN-SPEC §6", async () => {
+ await openEditor();
+ expect(screen.getByText("Beats the custody pattern for these dates")).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Back" })).toBeTruthy();
+ const kids = within(screen.getByRole("group", { name: "Child" })).getAllByRole("button");
+ expect(kids.map((b) => [b.textContent, b.getAttribute("aria-pressed")])).toEqual([
+ ["Declan", "true"],
+ ["Maeve", "false"],
+ ["Rory", "false"],
+ ]);
+ const status = within(screen.getByRole("group", { name: "Status for these dates" }));
+ expect(status.getByRole("button", { name: "Away" }).getAttribute("aria-pressed")).toBe("true");
+ expect(status.getByRole("button", { name: "Home" }).getAttribute("aria-pressed")).toBe("false");
+ expect((screen.getByLabelText("From") as HTMLInputElement).value).toBe(TODAY);
+ expect((screen.getByLabelText("Through") as HTMLInputElement).value).toBe(TODAY);
+ expect(screen.getByLabelText("Reason (optional)")).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
+ });
+
+ it("shows the live consequence count from the API and recounts on every change", async () => {
+ await openEditor();
+ // Save is off until the count is on screen: the warning precedes saving.
+ expect(saveButton().disabled).toBe(true);
+ await waitFor(() => expect(headline()).toBe("This removes 4 upcoming tasks"));
+ expect(consequenceBodies()).toEqual([
+ { child_id: 1, start_date: TODAY, end_date: TODAY, is_present: false },
+ ]);
+ expect(warningBody()).toBe(
+ "Declan's open tasks Sep 23 are removed while away. Completed days are untouched.",
+ );
+ expect(saveButton().disabled).toBe(false);
+
+ consequence = () => jsonResponse({ removed: 9 });
+ setDate("Through", "2026-09-27");
+ await waitFor(() => expect(headline()).toBe("This removes 9 upcoming tasks"));
+ expect(consequenceBodies()[1]).toEqual({
+ child_id: 1,
+ start_date: TODAY,
+ end_date: "2026-09-27",
+ is_present: false,
+ });
+ expect(warningBody()).toBe(
+ "Declan's open tasks Sep 23 – 27 are removed while away. Completed days are untouched.",
+ );
+
+ consequence = () => jsonResponse({ removed: 2 });
+ fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
+ await waitFor(() => expect(headline()).toBe("This removes 2 upcoming tasks"));
+ expect(consequenceBodies()[2]).toEqual({
+ child_id: 2,
+ start_date: TODAY,
+ end_date: "2026-09-27",
+ is_present: false,
+ });
+ expect(warningBody()).toBe(
+ "Maeve's open tasks Sep 23 – 27 are removed while away. Completed days are untouched.",
+ );
+
+ // A Home override removes nothing by definition: no request, count 0.
+ fireEvent.click(screen.getByRole("button", { name: "Home" }));
+ expect(headline()).toBe("This removes 0 upcoming tasks");
+ expect(warningBody()).toBe(
+ "Maeve is home Sep 23 – 27, so no tasks are removed. Completed days are untouched.",
+ );
+ await pastDebounce();
+ expect(consequenceBodies()).toHaveLength(3);
+ });
+
+ it("renders the API's count verbatim — completed instances are excluded server-side", async () => {
+ // The range holds 3 open instances and 1 already completed. The API
+ // excludes the completed one (D-005; proven by
+ // tests/test_api_admin_presence_consequence.py::test_completed_instance_in_range_is_not_counted
+ // and ::test_count_matches_a_direct_materialise_comparison), so it answers 3,
+ // and the editor shows exactly that — it never counts days on its own.
+ consequence = () => jsonResponse({ removed: 3 });
+ await openEditor();
+ setDate("Through", "2026-09-25");
+ await waitFor(() => expect(headline()).toBe("This removes 3 upcoming tasks"));
+ expect(warningBody()).toBe(
+ "Declan's open tasks Sep 23 – 25 are removed while away. Completed days are untouched.",
+ );
+ });
+
+ it("a stale consequence response never overwrites a newer one", async () => {
+ const first = deferred();
+ const second = deferred();
+ const pending = [first, second];
+ consequence = () => pending.shift()!.promise;
+ await openEditor();
+ await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
+
+ setDate("Through", "2026-09-26");
+ await waitFor(() => expect(consequenceBodies()).toHaveLength(2));
+
+ second.resolve(jsonResponse({ removed: 5 }));
+ await waitFor(() => expect(headline()).toBe("This removes 5 upcoming tasks"));
+ first.resolve(jsonResponse({ removed: 99 }));
+ await pastDebounce();
+ expect(headline()).toBe("This removes 5 upcoming tasks");
+ });
+
+ it("debounces rapid edits into one request for the final range", async () => {
+ await openEditor();
+ await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
+ setDate("Through", "2026-09-24");
+ setDate("Through", "2026-09-25");
+ setDate("Through", "2026-09-26");
+ await pastDebounce();
+ expect(consequenceBodies().slice(1)).toEqual([
+ { child_id: 1, start_date: TODAY, end_date: "2026-09-26", is_present: false },
+ ]);
+ });
+
+ it("an incomplete or reversed range sends no consequence request and cannot be saved", async () => {
+ await openEditor();
+ await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
+
+ setDate("Through", "");
+ expect(headline()).toBe("Choose a valid date range");
+ expect(saveButton().disabled).toBe(true);
+ await pastDebounce();
+ expect(consequenceBodies()).toHaveLength(1);
+
+ setDate("Through", "2026-09-20"); // before From
+ expect(headline()).toBe("Choose a valid date range");
+ await pastDebounce();
+ expect(consequenceBodies()).toHaveLength(1);
+ fireEvent.click(saveButton());
+ expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
+ });
+
+ it("a malformed From date sends no consequence request and cannot be saved", async () => {
+ await openEditor();
+ await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
+
+ setDate("From", "2026-02-30");
+ expect(headline()).toBe("Choose a valid date range");
+ expect(saveButton().disabled).toBe(true);
+ await pastDebounce();
+ expect(consequenceBodies()).toHaveLength(1);
+ fireEvent.click(saveButton());
+ expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
+ });
+
+ it("Save sends the exact POST body, closes the editor and shows the new row", async () => {
+ await openEditor();
+ setDate("From", "2026-09-24");
+ setDate("Through", "2026-09-26");
+ fireEvent.change(screen.getByLabelText("Reason (optional)"), {
+ target: { value: " Trip with grandparents " },
+ });
+ await waitFor(() => expect(saveButton().disabled).toBe(false));
+ const before = calls().length;
+ const schedulesBefore = calls().filter((c) => c.path.endsWith("/presence-schedule")).length;
+
+ fireEvent.click(saveButton());
+ const row = await screen.findByTestId("override-50");
+ expect(row.textContent).toContain("Declan away · Sep 24 – 26");
+ expect(row.textContent).toContain("Trip with grandparents");
+ expect(screen.queryByRole("heading", { name: "Presence override" })).toBeNull();
+
+ const after = calls().slice(before);
+ const writes = after.filter((c) => c.method !== "GET");
+ // D-005: saving sends ONLY the create request — never a delete of an
+ // instance or an override. The API's regenerate path keeps completed
+ // instances (tests/test_materialize.py::
+ // test_regenerate_for_definition_rebuilds_horizon_and_preserves_completed).
+ expect(writes).toEqual([
+ {
+ method: "POST",
+ path: OVERRIDES_PATH,
+ body: {
+ child_id: 1,
+ start_date: "2026-09-24",
+ end_date: "2026-09-26",
+ is_present: false,
+ note: "Trip with grandparents",
+ },
+ },
+ ]);
+ expect(after.some((c) => c.method === "DELETE")).toBe(false);
+ // The overrides and the presence schedules are refetched for the grid.
+ expect(after.some((c) => c.method === "GET" && c.path === OVERRIDES_PATH)).toBe(true);
+ expect(calls().filter((c) => c.path.endsWith("/presence-schedule")).length).toBeGreaterThan(
+ schedulesBefore,
+ );
+ // The grid reflects the new override.
+ expect(document.querySelector('[data-date="2026-09-25"]')?.getAttribute("data-state")).toBe(
+ "override-away",
+ );
+ });
+
+ it("omits an empty reason and saves a Home override", async () => {
+ await openEditor();
+ fireEvent.click(screen.getByRole("button", { name: "Home" }));
+ await waitFor(() => expect(saveButton().disabled).toBe(false));
+ fireEvent.click(saveButton());
+ await screen.findByTestId("override-50");
+ const post = calls().find((c) => c.method === "POST" && c.path === OVERRIDES_PATH);
+ expect(post?.body).toEqual({
+ child_id: 1,
+ start_date: TODAY,
+ end_date: TODAY,
+ is_present: true,
+ });
+ });
+
+ it("a 422 shows the API detail and keeps the form", async () => {
+ createResponse = () => jsonResponse({ detail: "overlaps an existing override" }, 422);
+ await openEditor();
+ setDate("Through", "2026-09-25");
+ fireEvent.change(screen.getByLabelText("Reason (optional)"), { target: { value: "Camp" } });
+ await waitFor(() => expect(saveButton().disabled).toBe(false));
+
+ fireEvent.click(saveButton());
+ const alert = await screen.findByTestId("override-error");
+ expect(alert.textContent).toBe("overlaps an existing override");
+ expect(screen.getByRole("heading", { name: "Presence override" })).toBeTruthy();
+ expect((screen.getByLabelText("Through") as HTMLInputElement).value).toBe("2026-09-25");
+ expect((screen.getByLabelText("Reason (optional)") as HTMLInputElement).value).toBe("Camp");
+ expect(saveButton().disabled).toBe(false);
+ });
+
+ it("Cancel and Back close the editor without writing", async () => {
+ await openEditor();
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ await screen.findByTestId("pattern-1");
+ fireEvent.click(screen.getByRole("button", { name: "Add" }));
+ fireEvent.click(await screen.findByRole("button", { name: "Back" }));
+ await screen.findByTestId("pattern-1");
+ expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
+ });
+
+ it("Add opens the editor for the selected child", async () => {
+ render( );
+ await screen.findByTestId("pattern-1");
+ fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
+ fireEvent.click(screen.getByRole("button", { name: "Add" }));
+ const kids = within(await screen.findByRole("group", { name: "Child" }));
+ expect(kids.getByRole("button", { name: "Maeve" }).getAttribute("aria-pressed")).toBe("true");
+ await waitFor(() =>
+ expect(consequenceBodies()).toEqual([
+ { child_id: 2, start_date: TODAY, end_date: TODAY, is_present: false },
+ ]),
+ );
+ });
+
+ it("the trash button deletes the override only after confirm", async () => {
+ render( );
+ const row = await screen.findByTestId("override-41");
+
+ fireEvent.click(within(row).getByRole("button", { name: "Delete override" }));
+ fireEvent.click(within(row).getByRole("button", { name: "Keep" }));
+ expect(calls().some((c) => c.method === "DELETE")).toBe(false);
+
+ fireEvent.click(within(row).getByRole("button", { name: "Delete override" }));
+ const confirm = within(row).getByRole("group", { name: "Confirm delete" });
+ fireEvent.click(within(confirm).getByRole("button", { name: "Delete" }));
+ await waitFor(() => expect(screen.queryByTestId("override-41")).toBeNull());
+ expect(calls().filter((c) => c.method === "DELETE")).toEqual([
+ { method: "DELETE", path: `${OVERRIDES_PATH}/41`, body: undefined },
+ ]);
+ expect(screen.getByText("No overrides for Declan.")).toBeTruthy();
+ });
+});
+
+describe("OverrideEditor focus", () => {
+ beforeEach(() => {
+ sessionStorage.clear();
+ storeTokens({ access_token: "at-123", expires_in: 600 });
+ overrides = [...INITIAL_OVERRIDES];
+ consequence = () => jsonResponse({ removed: 4 });
+ createResponse = (body) => {
+ const created = { id: 50, note: null, ...body } as PresenceOverride;
+ overrides = [...overrides, created];
+ return jsonResponse(created, 201);
+ };
+ fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ });
+
+ function dialog(): HTMLElement {
+ return screen.getByRole("dialog", { name: "Presence override" });
+ }
+
+ function addButton(): HTMLElement {
+ return screen.getByRole("button", { name: "Add" });
+ }
+
+ it("moves focus into the editor on open", async () => {
+ await openEditor();
+ expect(document.activeElement).toBe(screen.getByRole("heading", { name: "Presence override" }));
+ expect(dialog().contains(document.activeElement)).toBe(true);
+ });
+
+ it("keeps Tab and Shift+Tab inside the editor", async () => {
+ await openEditor();
+ await waitFor(() => expect(saveButton().disabled).toBe(false));
+ const back = screen.getByRole("button", { name: "Back" });
+
+ saveButton().focus();
+ fireEvent.keyDown(saveButton(), { key: "Tab" });
+ expect(document.activeElement).toBe(back);
+
+ fireEvent.keyDown(back, { key: "Tab", shiftKey: true });
+ expect(document.activeElement).toBe(saveButton());
+
+ // From the focused heading (not in the tab order) Shift+Tab wraps to the end.
+ const title = screen.getByRole("heading", { name: "Presence override" });
+ title.focus();
+ fireEvent.keyDown(title, { key: "Tab", shiftKey: true });
+ expect(document.activeElement).toBe(saveButton());
+ });
+
+ it("makes the tab bar inert while open and restores it on close", async () => {
+ render( );
+ fireEvent.click(screen.getByRole("tab", { name: "Schedule" }));
+ await screen.findByTestId("pattern-1");
+ fireEvent.click(addButton());
+ await screen.findByRole("heading", { name: "Presence override" });
+
+ const tabbar = document.querySelector(".admin-tabbar") as HTMLElement;
+ expect(tabbar.hasAttribute("inert")).toBe(true);
+ expect(tabbar.getAttribute("aria-hidden")).toBe("true");
+ expect(dialog().closest("[inert]")).toBeNull();
+
+ fireEvent.keyDown(dialog(), { key: "Escape" });
+ await screen.findByTestId("pattern-1");
+ expect(tabbar.hasAttribute("inert")).toBe(false);
+ expect(tabbar.hasAttribute("aria-hidden")).toBe(false);
+ });
+
+ it("Escape cancels without writing and returns focus to Add", async () => {
+ await openEditor();
+ fireEvent.keyDown(document.activeElement as HTMLElement, { key: "Escape" });
+ await screen.findByTestId("pattern-1");
+ expect(screen.queryByRole("dialog")).toBeNull();
+ expect(document.activeElement).toBe(addButton());
+ expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
+ });
+
+ it("Escape does nothing while saving; focus returns to Add after the save", async () => {
+ const gate = deferred();
+ fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
+ const path = new URL(url, "http://x").pathname;
+ if (path === OVERRIDES_PATH && init?.method === "POST") await gate.promise;
+ return routeFetch(url, init);
+ });
+ await openEditor();
+ await waitFor(() => expect(saveButton().disabled).toBe(false));
+ fireEvent.click(saveButton());
+ expect(screen.getByRole("button", { name: "Saving…" })).toBeTruthy();
+
+ fireEvent.keyDown(dialog(), { key: "Escape" });
+ expect(screen.getByRole("dialog")).toBeTruthy();
+
+ gate.resolve(new Response(null));
+ await screen.findByTestId("override-50");
+ expect(screen.queryByRole("dialog")).toBeNull();
+ expect(document.activeElement).toBe(addButton());
+ });
+
+ it("Back and Cancel return focus to Add", async () => {
+ await openEditor();
+ fireEvent.click(screen.getByRole("button", { name: "Back" }));
+ await screen.findByTestId("pattern-1");
+ expect(document.activeElement).toBe(addButton());
+
+ fireEvent.click(addButton());
+ fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
+ await screen.findByTestId("pattern-1");
+ expect(document.activeElement).toBe(addButton());
+ });
+});
+
+describe("range validation", () => {
+ it("accepts only strict ISO calendar dates in order", () => {
+ expect(isIsoDate("2026-09-23")).toBe(true);
+ expect(isIsoDate("2026-02-30")).toBe(false);
+ expect(isIsoDate("2026-9-3")).toBe(false);
+ expect(isIsoDate("")).toBe(false);
+ expect(isValidRange("2026-09-23", "2026-09-23")).toBe(true);
+ expect(isValidRange("2026-09-23", "2026-09-22")).toBe(false);
+ expect(isValidRange("2026-09-23", "")).toBe(false);
+ });
+});
diff --git a/admin/src/schedule/OverrideEditor.tsx b/admin/src/schedule/OverrideEditor.tsx
new file mode 100644
index 0000000..964c6ad
--- /dev/null
+++ b/admin/src/schedule/OverrideEditor.tsx
@@ -0,0 +1,348 @@
+/**
+ * Presence override editor — the pushed screen of design/ADMIN-SPEC.md §6.
+ *
+ * The consequence warning is counted by the API (POST
+ * /presence-overrides/consequence) before anything is saved; Save stays off
+ * until that count is on screen. Saving only ever sends the create request:
+ * the API never removes an instance that already has a completion event (D-005).
+ */
+import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
+import { ApiForbiddenError } from "../api/client";
+import { ApiRequestError, type AdminChild } from "../api/definitions";
+import {
+ createPresenceOverride,
+ previewOverrideConsequence,
+ type PresenceOverride,
+ type PresenceOverrideBody,
+} from "../api/presence";
+import { dayNumber, isoFromDayNumber } from "./cycle";
+import { dateRange } from "./format";
+import { AlertCircleGlyph, ChevronLeftGlyph } from "./glyphs";
+import "./OverrideEditor.css";
+
+/** How long the form must sit still before the consequence is recounted. */
+export const CONSEQUENCE_DEBOUNCE_MS = 300;
+
+/** Strict "YYYY-MM-DD" naming a real calendar day (no 2026-02-30). */
+export function isIsoDate(value: string): boolean {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
+ return isoFromDayNumber(dayNumber(value)) === value;
+}
+
+export function isValidRange(start: string, end: string): boolean {
+ return isIsoDate(start) && isIsoDate(end) && start <= end;
+}
+
+type Consequence =
+ | { kind: "invalid" }
+ | { kind: "counting" }
+ | { kind: "ready"; removed: number }
+ | { kind: "error"; message: string };
+
+function errorMessage(error: unknown, fallback: string): string {
+ if (error instanceof ApiForbiddenError) return error.message;
+ if (error instanceof ApiRequestError && error.detail) return error.detail;
+ return fallback;
+}
+
+const FOCUSABLE =
+ 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])';
+
+/**
+ * Make everything outside `root` inert (siblings of each ancestor up to
+ * ), so the tab bar behind the pushed screen cannot take focus.
+ * Returns the undo.
+ */
+function inertOutside(root: HTMLElement): () => void {
+ const made: HTMLElement[] = [];
+ for (let node: HTMLElement = root; node.parentElement && node !== document.body; node = node.parentElement) {
+ for (const sibling of Array.from(node.parentElement.children)) {
+ if (sibling === node || !(sibling instanceof HTMLElement) || sibling.hasAttribute("inert")) continue;
+ sibling.setAttribute("inert", "");
+ sibling.setAttribute("aria-hidden", "true");
+ made.push(sibling);
+ }
+ }
+ return () => {
+ for (const el of made) {
+ el.removeAttribute("inert");
+ el.removeAttribute("aria-hidden");
+ }
+ };
+}
+
+function tasksPhrase(count: number): string {
+ return `${count} upcoming ${count === 1 ? "task" : "tasks"}`;
+}
+
+export interface OverrideEditorProps {
+ /** Active children, in display order. */
+ childOptions: AdminChild[];
+ initialChildId: number;
+ /** "YYYY-MM-DD"; the date fields start here. */
+ today: string;
+ onCancel: () => void;
+ onSaved: (override: PresenceOverride) => void;
+}
+
+export default function OverrideEditor({
+ childOptions,
+ initialChildId,
+ today,
+ onCancel,
+ onSaved,
+}: OverrideEditorProps) {
+ const [childId, setChildId] = useState(initialChildId);
+ const [isPresent, setIsPresent] = useState(false);
+ const [startDate, setStartDate] = useState(today);
+ const [endDate, setEndDate] = useState(today);
+ const [note, setNote] = useState("");
+ const [consequence, setConsequence] = useState({ kind: "counting" });
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ // Every form change bumps the sequence; only the latest request may render.
+ const requestSeq = useRef(0);
+ const screenRef = useRef(null);
+ const titleRef = useRef(null);
+
+ // Modal focus: move in on open and keep the background out of reach. The
+ // opener hands focus back to its Add control once this screen closes.
+ useEffect(() => {
+ titleRef.current?.focus();
+ return screenRef.current ? inertOutside(screenRef.current) : undefined;
+ }, []);
+
+ function onKeyDown(event: ReactKeyboardEvent) {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ if (!saving) onCancel();
+ return;
+ }
+ if (event.key !== "Tab" || !screenRef.current) return;
+ const focusable = Array.from(screenRef.current.querySelectorAll(FOCUSABLE));
+ if (focusable.length === 0) return;
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ const current = document.activeElement;
+ const outside = !focusable.includes(current as HTMLElement);
+ if (event.shiftKey && (current === first || outside)) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && (current === last || outside)) {
+ event.preventDefault();
+ first.focus();
+ }
+ }
+
+ const child = childOptions.find((c) => c.id === childId) ?? childOptions[0];
+ const rangeValid = isValidRange(startDate, endDate);
+
+ useEffect(() => {
+ const seq = ++requestSeq.current;
+ if (!rangeValid) {
+ setConsequence({ kind: "invalid" });
+ return;
+ }
+ if (isPresent) {
+ // Keeping a child home removes nothing by definition.
+ setConsequence({ kind: "ready", removed: 0 });
+ return;
+ }
+ setConsequence({ kind: "counting" });
+ const timer = setTimeout(() => {
+ previewOverrideConsequence({
+ child_id: childId,
+ start_date: startDate,
+ end_date: endDate,
+ is_present: false,
+ })
+ .then((removed) => {
+ if (seq === requestSeq.current) setConsequence({ kind: "ready", removed });
+ })
+ .catch((err: unknown) => {
+ if (seq !== requestSeq.current) return;
+ setConsequence({
+ kind: "error",
+ message: errorMessage(err, "The upcoming tasks could not be counted. Try again."),
+ });
+ });
+ }, CONSEQUENCE_DEBOUNCE_MS);
+ return () => clearTimeout(timer);
+ }, [childId, startDate, endDate, isPresent, rangeValid]);
+
+ const canSave = rangeValid && consequence.kind === "ready" && !saving;
+
+ function save() {
+ if (!canSave) return;
+ const body: PresenceOverrideBody = {
+ child_id: childId,
+ start_date: startDate,
+ end_date: endDate,
+ is_present: isPresent,
+ };
+ const reason = note.trim();
+ if (reason) body.note = reason;
+ setSaving(true);
+ setError(null);
+ createPresenceOverride(body)
+ .then(onSaved)
+ .catch((err: unknown) => {
+ setError(errorMessage(err, "The override could not be saved. Check your connection and try again."));
+ setSaving(false);
+ });
+ }
+
+ let headline: string;
+ let detail: string;
+ const range = rangeValid ? dateRange(startDate, endDate) : "";
+ if (consequence.kind === "invalid") {
+ headline = "Choose a valid date range";
+ detail = "Through must be on or after From.";
+ } else if (consequence.kind === "counting") {
+ headline = "Counting upcoming tasks…";
+ detail = `${child.display_name} away ${range}. Completed days are untouched.`;
+ } else if (consequence.kind === "error") {
+ headline = "Upcoming tasks not counted";
+ detail = consequence.message;
+ } else {
+ headline = `This removes ${tasksPhrase(consequence.removed)}`;
+ detail = isPresent
+ ? `${child.display_name} is home ${range}, so no tasks are removed. Completed days are untouched.`
+ : `${child.display_name}'s open tasks ${range} are removed while away. Completed days are untouched.`;
+ }
+
+ return (
+
+
+
+
+
+
+
+ Child
+
+
+ {childOptions.map((c) => (
+ setChildId(c.id)}
+ >
+ {c.display_name}
+
+ ))}
+
+
+
+
+
+ Status for these dates
+
+
+ setIsPresent(false)}
+ >
+ Away
+
+ setIsPresent(true)}
+ >
+ Home
+
+
+
+
+
+
+ From
+ setStartDate(event.target.value)}
+ />
+
+
+ Through
+ setEndDate(event.target.value)}
+ />
+
+
+
+
+ Reason (optional)
+ setNote(event.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+ {headline}
+
+
{detail}
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+ Cancel
+
+
+ {saving ? "Saving…" : "Save override"}
+
+
+
+
+ );
+}
diff --git a/admin/src/schedule/ScheduleTab.css b/admin/src/schedule/ScheduleTab.css
new file mode 100644
index 0000000..60540b5
--- /dev/null
+++ b/admin/src/schedule/ScheduleTab.css
@@ -0,0 +1,416 @@
+/* Schedule tab — compact density (design/ADMIN-SPEC.md §4). Mobile-first at
+ the 390x844 reference; on wider viewports the column is capped and centred. */
+
+.schedule-screen {
+ width: 100%;
+ max-width: 640px;
+ margin: 0 auto;
+}
+
+.schedule-header {
+ background: var(--nq-a-surface);
+ border-bottom: 1px solid var(--nq-a-border);
+ padding: 14px 16px;
+}
+
+.schedule-title {
+ margin: 0;
+ font-size: var(--nq-a-size-screen);
+ font-weight: 800;
+ letter-spacing: -0.02em;
+}
+
+.schedule-sub {
+ margin: 2px 0 0;
+ min-height: 1.4em;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--nq-a-ink-secondary);
+}
+
+/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
+.schedule-scroll {
+ display: flex;
+ flex-direction: column;
+ gap: var(--nq-a-density-gap);
+ padding: 14px 16px;
+}
+
+.schedule-card {
+ background: var(--nq-a-surface);
+ border: 1px solid var(--nq-a-border);
+ border-radius: var(--nq-a-density-radius);
+}
+
+/* Child selector */
+.schedule-children {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.schedule-child-chip {
+ 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-secondary);
+ font-family: inherit;
+ font-size: 12.5px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.schedule-child-chip[aria-pressed="true"] {
+ border-color: var(--nq-brand-blue);
+ background: var(--nq-a-selected);
+ color: var(--nq-brand-blue);
+}
+
+/* Month grid card */
+.schedule-month {
+ padding: var(--nq-a-density-card-pad);
+}
+
+.schedule-month-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 8px;
+}
+
+.schedule-month-label {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.schedule-chevrons {
+ display: flex;
+ gap: 4px;
+}
+
+.schedule-chevron {
+ display: grid;
+ place-items: center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: 1px solid var(--nq-a-border);
+ border-radius: 6px;
+ background: var(--nq-a-surface);
+ color: var(--nq-a-ink-secondary);
+ cursor: pointer;
+}
+
+.schedule-grid {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ gap: 4px;
+}
+
+.schedule-weekday {
+ text-align: center;
+ font-size: 10px;
+ font-weight: 700;
+ color: var(--nq-a-ink-tertiary);
+}
+
+.schedule-day {
+ aspect-ratio: 1;
+ padding: 0;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ font-family: inherit;
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.schedule-day--home {
+ background: var(--nq-a-selected);
+ color: #201f7a;
+}
+
+.schedule-day--away {
+ background: var(--nq-a-page);
+ color: var(--nq-a-ink-secondary);
+}
+
+.schedule-day--override-away,
+.schedule-day--override-home {
+ background: var(--nq-a-warning-bg);
+ color: var(--nq-a-warning-ink);
+ border-color: var(--nq-a-warning-border);
+}
+
+/* An override that keeps the child home reads with a dashed border. */
+.schedule-day--override-home {
+ border-style: dashed;
+}
+
+.schedule-day--today {
+ background: var(--nq-brand-gradient);
+ border-color: transparent;
+ color: #fff;
+ font-weight: 800;
+}
+
+.schedule-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px 12px;
+ margin: 10px 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.schedule-legend-item {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 10.5px;
+ font-weight: 600;
+ color: var(--nq-a-ink-secondary);
+}
+
+.schedule-swatch {
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ border: 1px solid transparent;
+ border-radius: 3px;
+}
+
+/* Pattern editing controls */
+.schedule-controls {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 8px;
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--nq-a-divider);
+}
+
+.schedule-field {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ font-size: var(--nq-a-size-label);
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--nq-a-ink-secondary);
+}
+
+.schedule-field input,
+.schedule-field select {
+ min-height: 36px;
+ padding: 0 8px;
+ border: 1px solid var(--nq-a-border);
+ border-radius: 6px;
+ background: var(--nq-a-surface);
+ color: var(--nq-a-ink);
+ font-family: inherit;
+ font-size: 13px;
+ font-weight: 600;
+ text-transform: none;
+ letter-spacing: normal;
+}
+
+.schedule-save {
+ min-height: 36px;
+ margin-left: auto;
+ 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;
+}
+
+.schedule-save:disabled {
+ opacity: 0.45;
+ cursor: default;
+}
+
+.schedule-save-status {
+ margin: 8px 0 0;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--nq-a-success);
+}
+
+.schedule-save-status--error {
+ color: var(--nq-a-danger-strong);
+}
+
+/* Presence pattern + overrides cards */
+.schedule-section-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.schedule-section-label {
+ margin: 6px 0 -2px;
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--nq-a-ink-secondary);
+}
+
+.schedule-text-action {
+ padding: 4px 0 0;
+ border: 0;
+ background: none;
+ color: var(--nq-brand-blue);
+ font-family: inherit;
+ font-size: 12.5px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.schedule-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.schedule-list > li + li {
+ border-top: 1px solid var(--nq-a-divider);
+}
+
+.schedule-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-height: var(--nq-a-tap-min);
+ padding: var(--nq-a-density-row-pad);
+}
+
+.schedule-row-glyph {
+ flex: none;
+ display: flex;
+ color: var(--nq-a-warning-ink);
+}
+
+.schedule-row-text {
+ flex: 1;
+ min-width: 0;
+}
+
+.schedule-row-title {
+ font-size: 13px;
+ font-weight: 700;
+}
+
+.schedule-row-meta {
+ font-size: var(--nq-a-size-meta);
+ font-weight: 600;
+ color: var(--nq-a-ink-secondary);
+}
+
+.schedule-rule {
+ flex: none;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--nq-a-ink-secondary);
+}
+
+.schedule-icon-action {
+ flex: none;
+ display: grid;
+ place-items: center;
+ width: var(--nq-a-tap-min);
+ height: var(--nq-a-tap-min);
+ margin: -8px -10px -8px 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: var(--nq-a-ink-tertiary);
+ cursor: pointer;
+}
+
+.schedule-empty {
+ padding: var(--nq-a-density-card-pad);
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--nq-a-ink-secondary);
+}
+
+/* Loading / error */
+.schedule-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);
+}
+
+.schedule-status--error {
+ color: var(--nq-a-danger-strong);
+}
+
+.schedule-status--error p {
+ margin: 0 0 10px;
+}
+
+.schedule-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;
+}
+
+/* Inline delete confirmation in an override row */
+.schedule-confirm {
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.schedule-confirm-text {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--nq-a-ink-secondary);
+}
+
+.schedule-confirm-keep,
+.schedule-confirm-delete {
+ min-height: 32px;
+ padding: 0 10px;
+ border-radius: 6px;
+ font-family: inherit;
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.schedule-confirm-keep {
+ border: 1px solid var(--nq-a-border);
+ background: var(--nq-a-surface);
+ color: var(--nq-a-ink);
+}
+
+.schedule-confirm-delete {
+ border: 0;
+ background: var(--nq-a-danger);
+ color: #fff;
+}
diff --git a/admin/src/schedule/ScheduleTab.test.tsx b/admin/src/schedule/ScheduleTab.test.tsx
new file mode 100644
index 0000000..feae62c
--- /dev/null
+++ b/admin/src/schedule/ScheduleTab.test.tsx
@@ -0,0 +1,276 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
+import ScheduleTab from "./ScheduleTab";
+import type { AdminChild } from "../api/definitions";
+import type { PresenceOverride, PresenceSchedule } from "../api/presence";
+import { storeTokens } from "../auth/oidc";
+
+function child(id: number, display_name: string, sort_order: number, is_active = true): AdminChild {
+ return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active };
+}
+
+const CHILDREN = [
+ child(3, "Rory", 3),
+ child(1, "Declan", 1),
+ child(2, "Maeve", 2),
+ child(9, "Former", 4, false),
+];
+
+// Declan: anchor Monday 2026-09-07; cycle week 0 home every day, week 1 Sat/Sun.
+// Keys arrive as JSON strings, exactly as the API serializes them.
+const DECLAN: PresenceSchedule = {
+ child_id: 1,
+ cycle_length_weeks: 2,
+ anchor_date: "2026-09-07",
+ pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] },
+};
+
+const MAEVE: PresenceSchedule = {
+ child_id: 2,
+ cycle_length_weeks: 4,
+ anchor_date: "2026-09-07",
+ pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [], "2": [0, 1, 2, 3, 4, 5, 6], "3": [] },
+};
+
+const OVERRIDES: PresenceOverride[] = [
+ { id: 41, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: "Dentist trip" },
+ { id: 42, child_id: 1, start_date: "2026-09-16", end_date: "2026-09-16", is_present: true, note: null },
+ { id: 43, child_id: 2, start_date: "2026-09-28", end_date: "2026-10-02", is_present: false, note: "Camp" },
+];
+
+const TODAY = "2026-09-23";
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+let fetchMock: ReturnType;
+let putResponse: (body: unknown) => Response;
+
+function routeFetch(url: string, init: RequestInit = {}): Response {
+ const path = new URL(url, "http://x").pathname;
+ if (init.method === "PUT") return putResponse(JSON.parse(String(init.body)));
+ if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
+ const match = /\/children\/(\d+)\/presence-schedule$/.exec(path);
+ if (match) {
+ const id = Number(match[1]);
+ return jsonResponse({ schedule: id === 1 ? DECLAN : id === 2 ? MAEVE : null });
+ }
+ if (path.endsWith("/presence-overrides")) return jsonResponse({ overrides: OVERRIDES });
+ return jsonResponse({ detail: "unexpected" }, 500);
+}
+
+function day(date: string): HTMLElement {
+ const cell = document.querySelector(`[data-date="${date}"]`);
+ if (!cell) throw new Error(`no cell for ${date}`);
+ return cell;
+}
+
+async function renderReady() {
+ render( );
+ await screen.findByTestId("pattern-1");
+}
+
+describe("ScheduleTab", () => {
+ beforeEach(() => {
+ sessionStorage.clear();
+ storeTokens({ access_token: "at-123", expires_in: 600 });
+ putResponse = (body) => jsonResponse({ child_id: 1, ...(body as object) });
+ fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ });
+
+ it("loads active children, their schedules and the overrides", async () => {
+ await renderReady();
+ const urls = fetchMock.mock.calls.map(([url]) => new URL(String(url), "http://x").pathname);
+ expect(urls).toContain("/api/v1/admin/children");
+ expect(urls).toContain("/api/v1/admin/children/1/presence-schedule");
+ expect(urls).toContain("/api/v1/admin/children/2/presence-schedule");
+ expect(urls).toContain("/api/v1/admin/children/3/presence-schedule");
+ expect(urls).not.toContain("/api/v1/admin/children/9/presence-schedule");
+ expect(urls).toContain("/api/v1/admin/presence-overrides");
+ const chips = within(screen.getByRole("group", { name: "Child" })).getAllByRole("button");
+ expect(chips.map((chip) => chip.textContent)).toEqual(["Declan", "Maeve", "Rory"]);
+ expect(chips[0].getAttribute("aria-pressed")).toBe("true");
+ });
+
+ it("header shows the cycle length and anchor", async () => {
+ await renderReady();
+ expect(screen.getByRole("heading", { name: "Schedule" })).toBeTruthy();
+ expect(screen.getByText("2-week cycle · anchor Monday, Sep 7")).toBeTruthy();
+ });
+
+ it("month grid renders home, away, override and today states from the anchor", async () => {
+ await renderReady();
+ expect(screen.getByTestId("month-label").textContent).toBe("September 2026");
+ const states: Record = {
+ "2026-09-01": "away", // before the anchor: cycle week 1, Tuesday
+ "2026-09-05": "home", // before the anchor: cycle week 1, Saturday
+ "2026-09-07": "home", // anchor: week 0
+ "2026-09-11": "override-away",
+ "2026-09-15": "away", // week 1, Tuesday
+ "2026-09-16": "override-home",
+ "2026-09-19": "home", // week 1, Saturday
+ "2026-09-22": "home", // wrap back to week 0
+ "2026-09-29": "away", // week 1 again
+ };
+ for (const [date, state] of Object.entries(states)) {
+ expect(day(date).dataset.state, date).toBe(state);
+ expect(day(date).className).toContain(`schedule-day--${state}`);
+ expect(day(date).className).not.toContain("schedule-day--today");
+ }
+ const today = day(TODAY);
+ expect(today.className).toContain("schedule-day--today");
+ expect(today.dataset.state).toBe("home");
+ expect(today.getAttribute("aria-label")).toBe("Sep 23 (today): Home");
+ // September 2026 starts on a Tuesday: one leading pad after the initials.
+ const grid = screen.getByRole("group", { name: "Month grid" });
+ expect(grid.children[7].tagName).toBe("SPAN");
+ expect(grid.children[8].getAttribute("data-date")).toBe("2026-09-01");
+
+ const legend = screen.getByRole("list", { name: "Legend" });
+ expect(within(legend).getAllByRole("listitem").map((li) => li.textContent)).toEqual([
+ "Home",
+ "Away",
+ "Override · away",
+ "Override · home",
+ "Today",
+ ]);
+ });
+
+ it("navigates months with the chevrons", async () => {
+ await renderReady();
+ fireEvent.click(screen.getByRole("button", { name: "Next month" }));
+ expect(screen.getByTestId("month-label").textContent).toBe("October 2026");
+ // Oct 1 is day 24 from the anchor: cycle week 1, a Thursday — away.
+ expect(day("2026-10-01").dataset.state).toBe("away");
+ expect(day("2026-10-03").dataset.state).toBe("home"); // week 1, Saturday
+ expect(day("2026-10-05").dataset.state).toBe("home"); // week 0 again
+ fireEvent.click(screen.getByRole("button", { name: "Previous month" }));
+ expect(screen.getByTestId("month-label").textContent).toBe("September 2026");
+ });
+
+ it("presence pattern card shows a rule per child", async () => {
+ await renderReady();
+ expect(within(screen.getByTestId("pattern-1")).getByText("Weeks 1 & 2 of cycle")).toBeTruthy();
+ expect(within(screen.getByTestId("pattern-2")).getByText("Weeks 1 & 3 of cycle")).toBeTruthy();
+ expect(within(screen.getByTestId("pattern-3")).getByText("Every day")).toBeTruthy();
+ });
+
+ it("overrides card lists the selected child's overrides with inert actions", async () => {
+ await renderReady();
+ const list = screen.getByRole("list", { name: "Overrides" });
+ expect(within(list).getAllByRole("listitem")).toHaveLength(2);
+ const away = within(list).getByTestId("override-41");
+ expect(within(away).getByText("Declan away · Sep 10 – 12")).toBeTruthy();
+ expect(within(away).getByText("Dentist trip")).toBeTruthy();
+ expect(within(away).getByRole("button", { name: "Delete override" })).toBeTruthy();
+ expect(within(list).getByText("Declan home · Sep 16")).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Add" })).toBeTruthy();
+
+ fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
+ expect(screen.getByText("Maeve away · Sep 28 – Oct 2")).toBeTruthy();
+ expect(screen.getByText("4-week cycle · anchor Monday, Sep 7")).toBeTruthy();
+ });
+
+ it("a child with no schedule is home every day and defaults to a 2-week cycle", async () => {
+ await renderReady();
+ fireEvent.click(screen.getByRole("button", { name: "Rory" }));
+ expect(screen.getByText("2-week cycle · anchor Tuesday, Sep 1")).toBeTruthy();
+ expect(day("2026-09-15").dataset.state).toBe("home");
+ expect(screen.getByText("No overrides for Rory.")).toBeTruthy();
+ expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
+ true,
+ );
+ });
+
+ it("editing a day and saving PUTs the pattern; the grid reflects the saved schedule", async () => {
+ await renderReady();
+ expect(day("2026-09-01").dataset.state).toBe("away");
+ fireEvent.click(day("2026-09-01")); // cycle week 1 (pre-anchor), Tuesday
+
+ // The draft already shows the edit, and every other week-1 Tuesday follows.
+ expect(day("2026-09-01").dataset.state).toBe("home");
+ expect(day("2026-09-15").dataset.state).toBe("home");
+ expect(day("2026-09-08").dataset.state).toBe("home");
+
+ fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
+ expect(await screen.findByText("Pattern saved.")).toBeTruthy();
+
+ const put = fetchMock.mock.calls.find(([, init]) => init?.method === "PUT");
+ expect(put).toBeTruthy();
+ const [url, init] = put!;
+ expect(new URL(String(url), "http://x").pathname).toBe("/api/v1/admin/children/1/presence-schedule");
+ expect(init.headers.Authorization).toBe("Bearer at-123");
+ expect(JSON.parse(init.body)).toEqual({
+ cycle_length_weeks: 2,
+ anchor_date: "2026-09-07",
+ pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [1, 5, 6] },
+ });
+
+ expect(day("2026-09-15").dataset.state).toBe("home");
+ expect(day("2026-09-29").dataset.state).toBe("home");
+ expect(day("2026-09-30").dataset.state).toBe("away");
+ expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
+ true,
+ );
+ });
+
+ it("saves an edited anchor date and cycle length", async () => {
+ await renderReady();
+ fireEvent.change(screen.getByLabelText("Anchor date"), { target: { value: "2026-09-14" } });
+ fireEvent.change(screen.getByLabelText("Cycle length"), { target: { value: "3" } });
+ expect(screen.getByText("3-week cycle · anchor Monday, Sep 14")).toBeTruthy();
+ // Anchor moved a week: Sep 7-13 is now the last week of the cycle (full, added).
+ fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
+ await screen.findByText("Pattern saved.");
+ const [, init] = fetchMock.mock.calls.find(([, i]) => i?.method === "PUT")!;
+ expect(JSON.parse(init.body)).toEqual({
+ cycle_length_weeks: 3,
+ anchor_date: "2026-09-14",
+ pattern: {
+ "0": [0, 1, 2, 3, 4, 5, 6],
+ "1": [5, 6],
+ "2": [0, 1, 2, 3, 4, 5, 6],
+ },
+ });
+ });
+
+ it("a 422 shows the API detail and keeps the draft", async () => {
+ putResponse = () => jsonResponse({ detail: "anchor_date must be a Monday" }, 422);
+ await renderReady();
+ fireEvent.click(day("2026-09-01"));
+ fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
+
+ const alert = await screen.findByRole("alert");
+ expect(alert.textContent).toBe("anchor_date must be a Monday");
+ expect(day("2026-09-01").dataset.state).toBe("home");
+ expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
+ false,
+ );
+ // The saved rule is unchanged.
+ expect(within(screen.getByTestId("pattern-1")).getByText("Weeks 1 & 2 of cycle")).toBeTruthy();
+ });
+
+ it("scroll column ends with 92px bottom padding", async () => {
+ await renderReady();
+ expect(screen.getByTestId("schedule-scroll").style.paddingBottom).toBe("92px");
+ });
+
+ it("shows an error state on a failed load and retries", async () => {
+ fetchMock.mockResolvedValueOnce(new Response("boom", { status: 500 }));
+ render( );
+ const error = await screen.findByTestId("schedule-error");
+ expect(error.textContent).toContain("could not be loaded");
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
+ expect(await screen.findByTestId("pattern-1")).toBeTruthy();
+ });
+});
diff --git a/admin/src/schedule/ScheduleTab.tsx b/admin/src/schedule/ScheduleTab.tsx
new file mode 100644
index 0000000..6bd5eb4
--- /dev/null
+++ b/admin/src/schedule/ScheduleTab.tsx
@@ -0,0 +1,557 @@
+/**
+ * Schedule tab — the presence month grid (design/ADMIN-SPEC.md §4).
+ *
+ * Every day's cycle position comes from the anchor date (./cycle.ts), never
+ * from ISO week numbers or week parity (D-004).
+ */
+import { useEffect, useRef, useState } from "react";
+import { ApiForbiddenError } from "../api/client";
+import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions";
+import {
+ deletePresenceOverride,
+ fetchPresenceOverrides,
+ fetchPresenceSchedule,
+ savePresenceSchedule,
+ type PresenceOverride,
+ type PresenceSchedule,
+ type PresenceScheduleBody,
+} from "../api/presence";
+import {
+ cycleWeekIndex,
+ dayState,
+ fullPattern,
+ localTodayIso,
+ monthDates,
+ monthStart,
+ patternIncludes,
+ presenceRule,
+ resizePattern,
+ togglePatternDay,
+ weekdayOf,
+ type DayState,
+} from "./cycle";
+import { anchorLabel, dateRange, monthLabel, shortDate } from "./format";
+import {
+ CalendarCheckGlyph,
+ CalendarXGlyph,
+ ChevronLeftGlyph,
+ ChevronRightGlyph,
+ Trash2Glyph,
+} from "./glyphs";
+import OverrideEditor from "./OverrideEditor";
+import "./ScheduleTab.css";
+
+/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
+export const SCROLL_BOTTOM_PADDING = "92px";
+
+export const DEFAULT_CYCLE_LENGTH_WEEKS = 2;
+const CYCLE_LENGTH_CHOICES = [1, 2, 3, 4];
+const WEEKDAY_INITIALS = ["M", "T", "W", "T", "F", "S", "S"];
+
+export const STATE_LABELS: Record = {
+ home: "Home",
+ away: "Away",
+ "override-away": "Override · away",
+ "override-home": "Override · home",
+};
+
+interface Loaded {
+ children: AdminChild[];
+ schedules: Record;
+ overrides: PresenceOverride[];
+}
+
+type LoadState =
+ | { kind: "loading" }
+ | { kind: "error"; message: string }
+ | { kind: "ready"; data: Loaded };
+
+type DeleteState =
+ | { kind: "idle" }
+ | { kind: "confirming"; id: number }
+ | { kind: "deleting"; id: number }
+ | { kind: "error"; id: number; message: string };
+
+type SaveState =
+ | { kind: "idle" }
+ | { kind: "saving" }
+ | { kind: "saved" }
+ | { kind: "error"; message: string };
+
+async function loadSchedule(): Promise {
+ const children = (await fetchChildren())
+ .filter((child) => child.is_active)
+ .sort((a, b) => a.sort_order - b.sort_order);
+ const [schedules, overrides] = await Promise.all([
+ Promise.all(children.map((child) => fetchPresenceSchedule(child.id))),
+ fetchPresenceOverrides(),
+ ]);
+ return {
+ children,
+ schedules: Object.fromEntries(children.map((child, i) => [child.id, schedules[i]])),
+ overrides,
+ };
+}
+
+function loadErrorMessage(error: unknown): string {
+ return error instanceof ApiForbiddenError
+ ? error.message
+ : "The schedule could not be loaded. Check your connection and try again.";
+}
+
+function saveErrorMessage(
+ error: unknown,
+ fallback = "The pattern could not be saved. Check your connection and try again.",
+): string {
+ if (error instanceof ApiForbiddenError) return error.message;
+ if (error instanceof ApiRequestError && error.detail) return error.detail;
+ return fallback;
+}
+
+function ScreenHeader({ sub }: { sub: string }) {
+ return (
+
+ );
+}
+
+interface MonthGridProps {
+ month: string;
+ today: string;
+ schedule: PresenceScheduleBody;
+ /** Null when the child has no saved schedule and no edits: home every day. */
+ effective: PresenceScheduleBody | null;
+ overrides: PresenceOverride[];
+ onMonth: (delta: number) => void;
+ onToggle: (date: string) => void;
+}
+
+function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onToggle }: MonthGridProps) {
+ const dates = monthDates(month);
+ const leading = weekdayOf(month);
+ return (
+ <>
+
+
+ {monthLabel(month)}
+
+
+ onMonth(-1)}
+ >
+
+
+ onMonth(1)}
+ >
+
+
+
+
+
+ {WEEKDAY_INITIALS.map((initial, i) => (
+
+ {initial}
+
+ ))}
+ {Array.from({ length: leading }, (_, i) => (
+
+ ))}
+ {dates.map((date) => {
+ const state = dayState(effective, overrides, date);
+ const isToday = date === today;
+ let className = `schedule-day schedule-day--${state}`;
+ if (isToday) className += " schedule-day--today";
+ return (
+ onToggle(date)}
+ >
+ {Number(date.slice(8))}
+
+ );
+ })}
+
+
+ {(["home", "away", "override-away", "override-home"] as DayState[]).map((state) => (
+
+
+ {STATE_LABELS[state]}
+
+ ))}
+
+
+ Today
+
+
+ >
+ );
+}
+
+interface OverrideRowProps {
+ override: PresenceOverride;
+ childName: string;
+ deletion: DeleteState;
+ onAskDelete: () => void;
+ onKeep: () => void;
+ onDelete: () => void;
+}
+
+function OverrideRow({ override, childName, deletion, onAskDelete, onKeep, onDelete }: OverrideRowProps) {
+ const mine =
+ (deletion.kind === "confirming" || deletion.kind === "deleting") && deletion.id === override.id;
+ return (
+
+
+ {override.is_present ? : }
+
+
+
+ {childName} {override.is_present ? "home" : "away"} ·{" "}
+ {dateRange(override.start_date, override.end_date)}
+
+
{override.note || "No reason given"}
+
+ {mine ? (
+
+ Delete override?
+
+ Keep
+
+
+ {deletion.kind === "deleting" ? "Deleting…" : "Delete"}
+
+
+ ) : (
+
+
+
+ )}
+
+ );
+}
+
+export interface ScheduleTabProps {
+ /** "YYYY-MM-DD"; defaults to the viewer's local date. */
+ today?: string;
+}
+
+export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProps) {
+ const [state, setState] = useState({ kind: "loading" });
+ const [attempt, setAttempt] = useState(0);
+ const [selectedId, setSelectedId] = useState(null);
+ const [month, setMonth] = useState(() => monthStart(today));
+ const [drafts, setDrafts] = useState>({});
+ const [save, setSave] = useState({ kind: "idle" });
+ const [editorOpen, setEditorOpen] = useState(false);
+ const [deletion, setDeletion] = useState({ kind: "idle" });
+ const addRef = useRef(null);
+ const editorWasOpen = useRef(false);
+
+ // The editor replaces this screen, so Add is remounted when it closes;
+ // hand focus back to it then.
+ useEffect(() => {
+ if (editorWasOpen.current && !editorOpen) addRef.current?.focus();
+ editorWasOpen.current = editorOpen;
+ }, [editorOpen]);
+
+ /** Reload children, schedules and overrides in place (no loading flash). */
+ const refresh = () =>
+ loadSchedule()
+ .then((data) => setState({ kind: "ready", data }))
+ .catch((error: unknown) => setState({ kind: "error", message: loadErrorMessage(error) }));
+
+ useEffect(() => {
+ let cancelled = false;
+ setState({ kind: "loading" });
+ loadSchedule()
+ .then((data) => {
+ if (cancelled) return;
+ setState({ kind: "ready", data });
+ setSelectedId((current) => current ?? data.children[0]?.id ?? null);
+ })
+ .catch((error: unknown) => {
+ if (cancelled) return;
+ setState({ kind: "error", message: loadErrorMessage(error) });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [attempt]);
+
+ if (editorOpen && state.kind === "ready" && state.data.children.length > 0) {
+ const { children } = state.data;
+ return (
+ c.id === selectedId)?.id ?? children[0].id}
+ today={today}
+ onCancel={() => setEditorOpen(false)}
+ onSaved={(override) => {
+ setEditorOpen(false);
+ setSelectedId(override.child_id);
+ void refresh();
+ }}
+ />
+ );
+ }
+
+ let sub = "";
+ let body;
+ if (state.kind === "loading") {
+ body = (
+
+ Loading schedule…
+
+ );
+ } else if (state.kind === "error") {
+ body = (
+
+
{state.message}
+
setAttempt((n) => n + 1)}>
+ Try again
+
+
+ );
+ } else if (state.data.children.length === 0) {
+ body = No children yet.
;
+ } else {
+ const { data } = state;
+ const child = data.children.find((c) => c.id === selectedId) ?? data.children[0];
+ const saved = data.schedules[child.id] ?? null;
+ const draft = drafts[child.id];
+ // Edits start from the saved schedule, or — with none — from "home every
+ // day" on a 2-week cycle anchored at the displayed month's 1st.
+ const schedule: PresenceScheduleBody = draft ??
+ saved ?? {
+ cycle_length_weeks: DEFAULT_CYCLE_LENGTH_WEEKS,
+ anchor_date: month,
+ pattern: fullPattern(DEFAULT_CYCLE_LENGTH_WEEKS),
+ };
+ const effective = draft ?? saved;
+ const childOverrides = data.overrides.filter((o) => o.child_id === child.id);
+ sub = `${schedule.cycle_length_weeks}-week cycle · anchor ${anchorLabel(schedule.anchor_date)}`;
+
+ const edit = (next: PresenceScheduleBody) => {
+ setDrafts((all) => ({ ...all, [child.id]: next }));
+ setSave({ kind: "idle" });
+ };
+ const toggle = (date: string) => {
+ const week = cycleWeekIndex(schedule.anchor_date, date, schedule.cycle_length_weeks);
+ edit({ ...schedule, pattern: togglePatternDay(schedule.pattern, week, weekdayOf(date)) });
+ };
+ const confirmDelete = (id: number) => {
+ setDeletion({ kind: "deleting", id });
+ deletePresenceOverride(id)
+ .then(() => refresh())
+ .then(() => setDeletion({ kind: "idle" }))
+ .catch((error: unknown) =>
+ setDeletion({
+ kind: "error",
+ id,
+ message: saveErrorMessage(
+ error,
+ "The override could not be deleted. Check your connection and try again.",
+ ),
+ }),
+ );
+ };
+ const submit = () => {
+ if (!draft) return;
+ const childId = child.id;
+ setSave({ kind: "saving" });
+ const { cycle_length_weeks, anchor_date, pattern } = draft;
+ savePresenceSchedule(childId, { cycle_length_weeks, anchor_date, pattern })
+ .then((stored) => {
+ setState((current) =>
+ current.kind === "ready"
+ ? {
+ kind: "ready",
+ data: {
+ ...current.data,
+ schedules: { ...current.data.schedules, [childId]: stored },
+ },
+ }
+ : current,
+ );
+ setDrafts((all) => {
+ const rest = { ...all };
+ delete rest[childId];
+ return rest;
+ });
+ setSave({ kind: "saved" });
+ })
+ .catch((error: unknown) => setSave({ kind: "error", message: saveErrorMessage(error) }));
+ };
+
+ body = (
+ <>
+
+ {data.children.map((c) => (
+ {
+ setSelectedId(c.id);
+ setSave({ kind: "idle" });
+ }}
+ >
+ {c.display_name}
+
+ ))}
+
+
+
+ setMonth((m) => monthStart(m, delta))}
+ onToggle={toggle}
+ />
+
+
+ Anchor date
+ {
+ if (event.target.value) edit({ ...schedule, anchor_date: event.target.value });
+ }}
+ />
+
+
+ Cycle length
+ {
+ const weeks = Number(event.target.value);
+ edit({
+ ...schedule,
+ cycle_length_weeks: weeks,
+ pattern: resizePattern(schedule.pattern, weeks),
+ });
+ }}
+ >
+ {CYCLE_LENGTH_CHOICES.map((weeks) => (
+
+ {weeks} {weeks === 1 ? "week" : "weeks"}
+
+ ))}
+
+
+
+ {save.kind === "saving" ? "Saving…" : "Save pattern"}
+
+
+ {save.kind === "error" ? (
+
+ {save.message}
+
+ ) : save.kind === "saved" ? (
+
+ Pattern saved.
+
+ ) : null}
+
+
+ Presence pattern
+
+ {data.children.map((c) => (
+
+
+ {presenceRule(data.schedules[c.id] ?? null)}
+
+ ))}
+
+
+
+
Overrides
+ setEditorOpen(true)}
+ >
+ Add
+
+
+ {childOverrides.length > 0 ? (
+
+ {childOverrides.map((override) => (
+ setDeletion({ kind: "confirming", id: override.id })}
+ onKeep={() => setDeletion({ kind: "idle" })}
+ onDelete={() => confirmDelete(override.id)}
+ />
+ ))}
+
+ ) : (
+ No overrides for {child.display_name}.
+ )}
+ {deletion.kind === "error" ? (
+
+ {deletion.message}
+
+ ) : null}
+ >
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/admin/src/schedule/cycle.test.ts b/admin/src/schedule/cycle.test.ts
new file mode 100644
index 0000000..26217f5
--- /dev/null
+++ b/admin/src/schedule/cycle.test.ts
@@ -0,0 +1,115 @@
+import { describe, expect, it } from "vitest";
+import {
+ cycleWeekIndex,
+ dayState,
+ monthDates,
+ monthStart,
+ presenceRule,
+ resizePattern,
+ togglePatternDay,
+ weekdayOf,
+} from "./cycle";
+import type { PresenceOverride, PresenceScheduleBody } from "../api/presence";
+
+// Anchor Monday 2026-09-07; week 0 home every day, week 1 home Sat/Sun only.
+const SCHEDULE: PresenceScheduleBody = {
+ cycle_length_weeks: 2,
+ anchor_date: "2026-09-07",
+ pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [5, 6] },
+};
+
+describe("cycleWeekIndex", () => {
+ it("follows the anchor across a month, wrapping every 2 weeks", () => {
+ const expected: Record = {};
+ for (const date of monthDates("2026-09-01")) {
+ const day = Number(date.slice(8));
+ // Sep 7-13 week 0, 14-20 week 1, 21-27 week 0 (wrap), 28-30 week 1;
+ // Sep 1-6 lie before the anchor and walk back into week 1.
+ expected[date] = day < 7 ? 1 : Math.floor((day - 7) / 7) % 2;
+ }
+ for (const [date, week] of Object.entries(expected)) {
+ expect(cycleWeekIndex(SCHEDULE.anchor_date, date, 2), date).toBe(week);
+ }
+ expect(cycleWeekIndex("2026-09-07", "2026-09-21", 2)).toBe(0);
+ expect(cycleWeekIndex("2026-09-07", "2026-09-28", 2)).toBe(1);
+ });
+
+ it("walks the cycle backwards before the anchor (floor division)", () => {
+ expect(cycleWeekIndex("2026-09-07", "2026-09-06", 2)).toBe(1); // day -1
+ expect(cycleWeekIndex("2026-09-07", "2026-08-31", 2)).toBe(1); // day -7
+ expect(cycleWeekIndex("2026-09-07", "2026-08-30", 2)).toBe(0); // day -8
+ expect(cycleWeekIndex("2026-09-07", "2026-08-24", 2)).toBe(0); // day -14
+ expect(cycleWeekIndex("2026-09-07", "2026-08-23", 2)).toBe(1); // day -15
+ expect(cycleWeekIndex("2026-09-07", "2026-09-06", 3)).toBe(2); // last week of a 3-week cycle
+ });
+
+ it("ignores ISO week parity across a 53-week year", () => {
+ // 2026-12-28 is ISO week 53 and 2027-01-04 ISO week 1 — both odd, so
+ // parity would call them the same week. From the anchor they alternate.
+ expect(cycleWeekIndex("2026-09-07", "2026-12-28", 2)).toBe(0);
+ expect(cycleWeekIndex("2026-09-07", "2027-01-04", 2)).toBe(1);
+ });
+});
+
+describe("calendar helpers", () => {
+ it("weekdayOf is Monday=0", () => {
+ expect(weekdayOf("2026-09-07")).toBe(0);
+ expect(weekdayOf("2026-09-01")).toBe(1);
+ expect(weekdayOf("2026-09-06")).toBe(6);
+ });
+
+ it("monthStart shifts across year ends", () => {
+ expect(monthStart("2026-12-15", 1)).toBe("2027-01-01");
+ expect(monthStart("2026-01-31", -1)).toBe("2025-12-01");
+ expect(monthDates("2026-02-01")).toHaveLength(28);
+ });
+});
+
+describe("dayState", () => {
+ const overrides: PresenceOverride[] = [
+ { id: 1, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: null },
+ { id: 2, child_id: 1, start_date: "2026-09-16", end_date: "2026-09-16", is_present: true, note: null },
+ ];
+
+ it("applies overrides first, then the anchor-based pattern", () => {
+ expect(dayState(SCHEDULE, overrides, "2026-09-08")).toBe("home");
+ expect(dayState(SCHEDULE, overrides, "2026-09-11")).toBe("override-away");
+ expect(dayState(SCHEDULE, overrides, "2026-09-15")).toBe("away");
+ expect(dayState(SCHEDULE, overrides, "2026-09-16")).toBe("override-home");
+ expect(dayState(SCHEDULE, overrides, "2026-09-19")).toBe("home");
+ expect(dayState(SCHEDULE, overrides, "2026-09-01")).toBe("away");
+ expect(dayState(SCHEDULE, overrides, "2026-09-05")).toBe("home");
+ });
+
+ it("treats a child with no schedule as home every day", () => {
+ expect(dayState(null, [], "2026-09-15")).toBe("home");
+ });
+});
+
+describe("pattern editing and rule text", () => {
+ it("toggles one weekday of one cycle week", () => {
+ expect(togglePatternDay(SCHEDULE.pattern, 1, 1)).toEqual({
+ 0: [0, 1, 2, 3, 4, 5, 6],
+ 1: [1, 5, 6],
+ });
+ expect(togglePatternDay(SCHEDULE.pattern, 1, 5)[1]).toEqual([6]);
+ });
+
+ it("resizes the pattern to the cycle length", () => {
+ expect(resizePattern(SCHEDULE.pattern, 1)).toEqual({ 0: [0, 1, 2, 3, 4, 5, 6] });
+ expect(resizePattern(SCHEDULE.pattern, 3)[2]).toEqual([0, 1, 2, 3, 4, 5, 6]);
+ });
+
+ it("describes the presence rule in plain language", () => {
+ expect(presenceRule(null)).toBe("Every day");
+ expect(presenceRule({ ...SCHEDULE, pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [0, 1, 2, 3, 4, 5, 6] } })).toBe(
+ "Every day",
+ );
+ expect(presenceRule(SCHEDULE)).toBe("Weeks 1 & 2 of cycle");
+ expect(
+ presenceRule({ cycle_length_weeks: 4, anchor_date: "2026-09-07", pattern: { 0: [0], 1: [], 2: [3], 3: [] } }),
+ ).toBe("Weeks 1 & 3 of cycle");
+ expect(presenceRule({ ...SCHEDULE, pattern: { 0: [1], 1: [] } })).toBe("Week 1 of cycle");
+ expect(presenceRule({ ...SCHEDULE, pattern: { 0: [], 1: [] } })).toBe("Never home");
+ });
+});
diff --git a/admin/src/schedule/cycle.ts b/admin/src/schedule/cycle.ts
new file mode 100644
index 0000000..ac16e6a
--- /dev/null
+++ b/admin/src/schedule/cycle.ts
@@ -0,0 +1,142 @@
+/**
+ * Pure presence-cycle math for the Schedule tab.
+ *
+ * Cycle position comes from the anchor date ONLY, mirroring
+ * core/presence.py `cycle_week_index`:
+ * `((date - anchor).days // 7) % cycle_length_weeks` with floor division,
+ * so dates before the anchor walk the cycle backwards. Never ISO week
+ * numbers or week parity (D-004).
+ *
+ * Calendar dates are "YYYY-MM-DD" strings handled as whole UTC days, so no
+ * viewer offset or DST change can shift a day.
+ */
+import type { PresenceOverride, PresencePattern, PresenceScheduleBody } from "../api/presence";
+
+const MS_PER_DAY = 86_400_000;
+
+export const ALL_WEEKDAYS = [0, 1, 2, 3, 4, 5, 6];
+
+/** Days since 1970-01-01 for a "YYYY-MM-DD" calendar date. */
+export function dayNumber(iso: string): number {
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
+ if (!match) throw new Error(`Invalid calendar date: ${iso}`);
+ const [, year, month, day] = match;
+ return Date.UTC(Number(year), Number(month) - 1, Number(day)) / MS_PER_DAY;
+}
+
+export function isoFromDayNumber(days: number): string {
+ return new Date(days * MS_PER_DAY).toISOString().slice(0, 10);
+}
+
+/** Floor modulo: always in 0..divisor-1, like Python's `%`. */
+function mod(value: number, divisor: number): number {
+ return ((value % divisor) + divisor) % divisor;
+}
+
+/** 0 = Monday .. 6 = Sunday (1970-01-01 was a Thursday). */
+export function weekdayOf(iso: string): number {
+ return mod(dayNumber(iso) + 3, 7);
+}
+
+/** `((date - anchor).days // 7) % cycleLengthWeeks`, Python semantics. */
+export function cycleWeekIndex(anchorDate: string, date: string, cycleLengthWeeks: number): number {
+ const days = dayNumber(date) - dayNumber(anchorDate);
+ return mod(Math.floor(days / 7), cycleLengthWeeks);
+}
+
+/** The viewer's local calendar date. */
+export function localTodayIso(now: Date = new Date()): string {
+ const month = String(now.getMonth() + 1).padStart(2, "0");
+ const day = String(now.getDate()).padStart(2, "0");
+ return `${now.getFullYear()}-${month}-${day}`;
+}
+
+/** "YYYY-MM-01" of the month containing `iso`, shifted by `delta` months. */
+export function monthStart(iso: string, delta = 0): string {
+ const [year, month] = iso.split("-").map(Number);
+ const date = new Date(Date.UTC(year, month - 1 + delta, 1));
+ return date.toISOString().slice(0, 10);
+}
+
+/** Every date of the month starting at `firstIso`. */
+export function monthDates(firstIso: string): string[] {
+ const start = dayNumber(firstIso);
+ const end = dayNumber(monthStart(firstIso, 1));
+ const dates: string[] = [];
+ for (let day = start; day < end; day += 1) dates.push(isoFromDayNumber(day));
+ return dates;
+}
+
+/** Present every day for every week of the cycle (the no-schedule case). */
+export function fullPattern(cycleLengthWeeks: number): PresencePattern {
+ const pattern: PresencePattern = {};
+ for (let week = 0; week < cycleLengthWeeks; week += 1) pattern[week] = [...ALL_WEEKDAYS];
+ return pattern;
+}
+
+export type DayState = "home" | "away" | "override-home" | "override-away";
+
+export function patternIncludes(
+ schedule: PresenceScheduleBody,
+ date: string,
+): boolean {
+ const week = cycleWeekIndex(schedule.anchor_date, date, schedule.cycle_length_weeks);
+ return (schedule.pattern[week] ?? []).includes(weekdayOf(date));
+}
+
+/**
+ * A date's state for one child: an override covering the date wins;
+ * otherwise the schedule's pattern decides (no schedule = home every day).
+ */
+export function dayState(
+ schedule: PresenceScheduleBody | null,
+ overrides: PresenceOverride[],
+ date: string,
+): DayState {
+ const override = overrides.find((o) => o.start_date <= date && date <= o.end_date);
+ if (override) return override.is_present ? "override-home" : "override-away";
+ if (!schedule) return "home";
+ return patternIncludes(schedule, date) ? "home" : "away";
+}
+
+/** Flip `weekday` in `week` of the pattern; returns a new pattern. */
+export function togglePatternDay(
+ pattern: PresencePattern,
+ week: number,
+ weekday: number,
+): PresencePattern {
+ const days = pattern[week] ?? [];
+ const next = days.includes(weekday)
+ ? days.filter((d) => d !== weekday)
+ : [...days, weekday].sort((a, b) => a - b);
+ return { ...pattern, [week]: next };
+}
+
+/** Fit the pattern to a new cycle length; added weeks start present every day. */
+export function resizePattern(pattern: PresencePattern, cycleLengthWeeks: number): PresencePattern {
+ const next: PresencePattern = {};
+ for (let week = 0; week < cycleLengthWeeks; week += 1) {
+ next[week] = pattern[week] ? [...pattern[week]] : [...ALL_WEEKDAYS];
+ }
+ return next;
+}
+
+function joinWeeks(weeks: number[]): string {
+ if (weeks.length === 1) return String(weeks[0]);
+ return `${weeks.slice(0, -1).join(", ")} & ${weeks[weeks.length - 1]}`;
+}
+
+/** Plain-language rule: `Every day`, `Weeks 1 & 3 of cycle`, `Never home`. */
+export function presenceRule(schedule: PresenceScheduleBody | null): string {
+ if (!schedule) return "Every day";
+ const weeks: number[] = [];
+ let full = true;
+ for (let week = 0; week < schedule.cycle_length_weeks; week += 1) {
+ const days = schedule.pattern[week] ?? [];
+ if (days.length > 0) weeks.push(week + 1);
+ if (days.length < 7) full = false;
+ }
+ if (full) return "Every day";
+ if (weeks.length === 0) return "Never home";
+ return `${weeks.length === 1 ? "Week" : "Weeks"} ${joinWeeks(weeks)} of cycle`;
+}
diff --git a/admin/src/schedule/format.ts b/admin/src/schedule/format.ts
new file mode 100644
index 0000000..357964a
--- /dev/null
+++ b/admin/src/schedule/format.ts
@@ -0,0 +1,37 @@
+/**
+ * Date labels for the Schedule tab. Calendar dates are formatted in UTC from
+ * their components, so the printed day never shifts with the viewer's offset.
+ */
+import { dayNumber } from "./cycle";
+
+const LOCALE = "en-US";
+
+function utcDate(iso: string): Date {
+ return new Date(dayNumber(iso) * 86_400_000);
+}
+
+function format(iso: string, options: Intl.DateTimeFormatOptions): string {
+ return new Intl.DateTimeFormat(LOCALE, { ...options, timeZone: "UTC" }).format(utcDate(iso));
+}
+
+/** "Tuesday, Sep 1" */
+export function anchorLabel(iso: string): string {
+ return `${format(iso, { weekday: "long" })}, ${format(iso, { month: "short", day: "numeric" })}`;
+}
+
+/** "September 2026" */
+export function monthLabel(iso: string): string {
+ return format(iso, { month: "long", year: "numeric" });
+}
+
+/** "Sep 4" */
+export function shortDate(iso: string): string {
+ return format(iso, { month: "short", day: "numeric" });
+}
+
+/** "Sep 4 – 7", "Sep 28 – Oct 2", or "Sep 4" for a single day. */
+export function dateRange(start: string, end: string): string {
+ if (start === end) return shortDate(start);
+ const sameMonth = start.slice(0, 7) === end.slice(0, 7);
+ return `${shortDate(start)} – ${sameMonth ? Number(end.slice(8)) : shortDate(end)}`;
+}
diff --git a/admin/src/schedule/glyphs.tsx b/admin/src/schedule/glyphs.tsx
new file mode 100644
index 0000000..7419b42
--- /dev/null
+++ b/admin/src/schedule/glyphs.tsx
@@ -0,0 +1,85 @@
+/**
+ * Inline stroke glyphs for the Schedule tab (Lucide-style 24px grid),
+ * CDN-free like the Today and Tasks tabs' glyphs.
+ */
+import type { ReactNode } from "react";
+
+function Glyph({ size, children }: { size: number; children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function ChevronLeftGlyph({ size = 16 }: { size?: number }) {
+ return (
+
+
+
+ );
+}
+
+export function ChevronRightGlyph({ size = 16 }: { size?: number }) {
+ return (
+
+
+
+ );
+}
+
+function CalendarFrame() {
+ return (
+ <>
+
+
+
+ >
+ );
+}
+
+export function CalendarXGlyph({ size = 18 }: { size?: number }) {
+ return (
+
+
+
+
+ );
+}
+
+export function CalendarCheckGlyph({ size = 18 }: { size?: number }) {
+ return (
+
+
+
+
+ );
+}
+
+export function Trash2Glyph({ size = 16 }: { size?: number }) {
+ return (
+
+
+
+ );
+}
+
+export function AlertCircleGlyph({ size = 18 }: { size?: number }) {
+ return (
+
+
+
+
+ );
+}
diff --git a/admin/src/today/TodayTab.css b/admin/src/today/TodayTab.css
new file mode 100644
index 0000000..760ac92
--- /dev/null
+++ b/admin/src/today/TodayTab.css
@@ -0,0 +1,331 @@
+/* Today tab — compact density (design/ADMIN-SPEC.md §2). Mobile-first at the
+ 390x844 reference; on wider viewports the column is capped and centred. */
+
+.today-screen {
+ width: 100%;
+ max-width: 640px;
+ margin: 0 auto;
+}
+
+/* §2.1 header */
+.today-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;
+}
+
+.today-title {
+ margin: 0;
+ font-size: var(--nq-a-size-screen);
+ font-weight: 800;
+ letter-spacing: -0.02em;
+}
+
+.today-sub {
+ margin: 2px 0 0;
+ min-height: 1.4em;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--nq-a-ink-secondary);
+}
+
+.today-icon-button {
+ flex: none;
+ display: grid;
+ place-items: center;
+ width: 36px;
+ height: 36px;
+ padding: 0;
+ border: 1px solid var(--nq-a-border);
+ border-radius: var(--nq-a-density-radius);
+ background: var(--nq-a-surface);
+ color: var(--nq-a-ink-secondary);
+ cursor: pointer;
+}
+
+/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
+.today-scroll {
+ display: flex;
+ flex-direction: column;
+ gap: var(--nq-a-density-gap);
+ padding: 14px 16px;
+}
+
+.today-card {
+ background: var(--nq-a-surface);
+ border: 1px solid var(--nq-a-border);
+ border-radius: var(--nq-a-density-radius);
+}
+
+.today-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.today-list > li + li {
+ border-top: 1px solid var(--nq-a-divider);
+}
+
+/* §2.2 stat row */
+.today-stats {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+}
+
+.today-stat {
+ padding: 12px;
+ background: var(--nq-a-surface);
+ border: 1px solid var(--nq-a-border);
+ border-radius: var(--nq-a-density-radius);
+}
+
+.today-stat-value {
+ font-size: var(--nq-a-size-stat);
+ font-weight: 800;
+ line-height: 1.1;
+}
+
+.today-stat-value--done {
+ color: var(--nq-brand-blue);
+}
+
+.today-stat-value--remaining {
+ color: var(--nq-a-ink);
+}
+
+.today-stat-value--overdue {
+ color: var(--nq-a-danger);
+}
+
+.today-stat-label {
+ margin-top: 2px;
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--nq-a-ink-secondary);
+}
+
+/* §2.3 child rows */
+.today-child {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-height: var(--nq-a-tap-min);
+ padding: var(--nq-a-density-row-pad);
+}
+
+.today-avatar {
+ flex: none;
+ display: grid;
+ place-items: center;
+ width: 30px;
+ height: 30px;
+ border-radius: 7px;
+ background: var(--nq-brand-gradient);
+ color: #fff;
+ font-size: 13px;
+ font-weight: 800;
+}
+
+.today-child--away .today-avatar {
+ background: #9898a8;
+}
+
+.today-child-text {
+ flex: 1;
+ min-width: 0;
+}
+
+.today-child-name {
+ font-size: 14px;
+ font-weight: 700;
+}
+
+.today-child-meta {
+ font-size: var(--nq-a-size-meta);
+ font-weight: 600;
+ color: var(--nq-a-ink-secondary);
+}
+
+.today-child-meta--done {
+ color: var(--nq-a-success);
+}
+
+.today-progress {
+ flex: none;
+ width: 82px;
+ height: var(--nq-a-progress-height);
+ border-radius: var(--nq-a-radius-pill);
+ background: var(--nq-a-divider);
+ overflow: hidden;
+}
+
+.today-progress-fill {
+ height: 100%;
+ border-radius: inherit;
+ background: var(--nq-brand-gradient-h);
+}
+
+/* §2.5 needs attention */
+.today-section-label {
+ margin: 6px 0 -2px;
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--nq-a-ink-secondary);
+}
+
+.today-event {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-height: var(--nq-a-tap-min);
+ padding: var(--nq-a-density-row-pad);
+}
+
+.today-event-glyph {
+ flex: none;
+ display: flex;
+ color: var(--nq-a-success);
+}
+
+.today-event-glyph--overdue {
+ color: var(--nq-a-danger);
+}
+
+.today-event-text {
+ flex: 1;
+ min-width: 0;
+}
+
+.today-event-title {
+ font-size: 13px;
+ font-weight: 700;
+}
+
+.today-event-meta {
+ font-size: var(--nq-a-size-meta);
+ font-weight: 600;
+ color: var(--nq-a-ink-secondary);
+}
+
+.today-event-meta--overdue {
+ color: var(--nq-a-danger);
+}
+
+.today-action {
+ flex: none;
+ min-height: var(--nq-a-tap-min);
+ padding: 0 4px;
+ border: 0;
+ background: none;
+ font-family: inherit;
+ font-size: 12.5px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.today-action--done {
+ color: var(--nq-brand-blue);
+}
+
+.today-action--undo {
+ color: var(--nq-brand-purple);
+}
+
+.today-action:disabled {
+ cursor: default;
+ opacity: 0.5;
+}
+
+/* Non-blocking Undo failure message. */
+.today-action-error {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: var(--nq-a-density-row-pad);
+ border-radius: var(--nq-a-density-radius);
+ background: var(--nq-a-danger-bg);
+ color: var(--nq-a-danger-strong);
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.today-action-dismiss {
+ flex: none;
+ padding: 4px 8px;
+ border: none;
+ background: none;
+ color: inherit;
+ font-family: inherit;
+ font-size: 12.5px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.today-empty {
+ padding: var(--nq-a-density-card-pad);
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--nq-a-ink-secondary);
+}
+
+/* §2.6 permission note */
+.today-note {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 10px 12px;
+ border-radius: var(--nq-a-density-radius);
+ background: var(--nq-a-info-bg);
+ color: var(--nq-a-info-ink);
+ font-size: 11.5px;
+ font-weight: 600;
+}
+
+.today-note-glyph {
+ flex: none;
+ display: flex;
+ color: var(--nq-a-info-icon);
+}
+
+/* Loading / error */
+.today-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);
+}
+
+.today-status--error {
+ color: var(--nq-a-danger-strong);
+}
+
+.today-status--error p {
+ margin: 0 0 10px;
+}
+
+.today-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;
+}
diff --git a/admin/src/today/TodayTab.test.tsx b/admin/src/today/TodayTab.test.tsx
new file mode 100644
index 0000000..41a59b7
--- /dev/null
+++ b/admin/src/today/TodayTab.test.tsx
@@ -0,0 +1,408 @@
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import TodayTab, { PERMISSION_NOTE } from "./TodayTab";
+import type { AdminSnapshot, SnapshotInstance } from "../api/snapshot";
+import { TransitionsProvider } from "../api/useTransitions";
+import { frame, testStream, type TestStream } from "../api/testStream";
+import { storeTokens } from "../auth/oidc";
+
+function instance(overrides: Partial): SnapshotInstance {
+ return {
+ id: 0,
+ definition_id: 1,
+ child_id: 1,
+ title: "Quest",
+ icon: null,
+ window: "morning",
+ due_time: null,
+ state: "open",
+ overdue: false,
+ completed_at: null,
+ on_time: null,
+ ...overrides,
+ };
+}
+
+const SNAPSHOT: AdminSnapshot = {
+ today_iso: "2026-09-23",
+ cycle_day: 5,
+ children: [
+ {
+ child_id: 1,
+ child_name: "Declan",
+ present: true,
+ next_present: null,
+ due_today: 7,
+ completed_today: 3,
+ remaining_today: 4,
+ completion_pct: 43,
+ instances: [
+ instance({ id: 11, title: "Brush teeth", due_time: "07:30", overdue: true }),
+ instance({
+ id: 12,
+ title: "Make bed",
+ state: "completed",
+ completed_at: "2026-09-23T11:04:00Z",
+ on_time: true,
+ }),
+ instance({ id: 13, title: "Homework", window: "afternoon", due_time: "16:00" }),
+ // Overdue but already completed: not counted as overdue.
+ instance({
+ id: 14,
+ title: "Pack bag",
+ state: "completed",
+ overdue: true,
+ completed_at: "2026-09-23T10:00:00Z",
+ on_time: false,
+ }),
+ ],
+ },
+ {
+ child_id: 2,
+ child_name: "Maeve",
+ present: true,
+ next_present: null,
+ due_today: 4,
+ completed_today: 4,
+ remaining_today: 0,
+ completion_pct: 100,
+ instances: [],
+ },
+ {
+ child_id: 3,
+ child_name: "Rory",
+ present: false,
+ next_present: "2026-10-01",
+ due_today: 0,
+ completed_today: 0,
+ remaining_today: 0,
+ completion_pct: 0,
+ instances: [],
+ },
+ ],
+};
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+let fetchMock: ReturnType;
+
+async function renderReady() {
+ fetchMock.mockResolvedValue(jsonResponse(SNAPSHOT));
+ render( );
+ await screen.findByText("Declan");
+}
+
+describe("TodayTab", () => {
+ beforeAll(() => {
+ // A negative-offset zone: `new Date("2026-09-23")` would read as Sep 22 here.
+ vi.stubEnv("TZ", "America/Los_Angeles");
+ });
+
+ afterAll(() => {
+ vi.unstubAllEnvs();
+ });
+
+ beforeEach(() => {
+ sessionStorage.clear();
+ storeTokens({ access_token: "at-123", expires_in: 600 });
+ fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ });
+
+ it("fetches the admin snapshot with the Bearer token", async () => {
+ await renderReady();
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(String(url)).toMatch(/\/api\/v1\/admin\/snapshot$/);
+ expect(init.headers.Authorization).toBe("Bearer at-123");
+ });
+
+ it("header shows the weekday, date and cycle day", async () => {
+ await renderReady();
+ expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy();
+ expect(screen.getByText("Wednesday, September 23 · cycle day 5 of 14")).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Settings" })).toBeTruthy();
+ });
+
+ it("stat row shows done, remaining and overdue totals", async () => {
+ await renderReady();
+ expect(screen.getByTestId("stat-done").textContent).toBe("7Done");
+ expect(screen.getByTestId("stat-remaining").textContent).toBe("4Remaining");
+ expect(screen.getByTestId("stat-overdue").textContent).toBe("1Overdue");
+ });
+
+ it("present child row shows progress meta and fill", async () => {
+ await renderReady();
+ const row = screen.getByTestId("child-1");
+ expect(within(row).getByText("3 of 7 · 1 overdue")).toBeTruthy();
+ const bar = within(row).getByRole("progressbar");
+ expect(bar.getAttribute("aria-valuenow")).toBe("43");
+ expect((bar.firstElementChild as HTMLElement).style.width).toBe("43%");
+ expect(row.className).not.toContain("today-child--away");
+ });
+
+ it("all-done child renders the success state", async () => {
+ await renderReady();
+ const meta = within(screen.getByTestId("child-2")).getByText("4 of 4 · all done");
+ expect(meta.className).toContain("today-child-meta--done");
+ });
+
+ it("away child shows the return date and the flat avatar", async () => {
+ await renderReady();
+ const row = screen.getByTestId("child-3");
+ expect(within(row).getByText("Away · returns Oct 1")).toBeTruthy();
+ expect(row.className).toContain("today-child--away");
+ expect(within(row).getByTestId("child-avatar").textContent).toBe("R");
+ });
+
+ it("needs-attention lists overdue and completed rows with their actions", async () => {
+ await renderReady();
+ const list = screen.getByRole("list", { name: "Needs attention" });
+ const overdue = within(list).getByTestId("attention-11");
+ expect(within(overdue).getByText("Brush teeth · Declan")).toBeTruthy();
+ expect(within(overdue).getByText("Overdue since 7:30 AM")).toBeTruthy();
+ expect(within(overdue).getByRole("button", { name: "Mark done" })).toBeTruthy();
+
+ const completed = within(list).getByTestId("attention-12");
+ expect(within(completed).getByText("Make bed · Declan")).toBeTruthy();
+ // 11:04Z is 4:04 AM in Los Angeles (PDT).
+ expect(within(completed).getByText("Completed 4:04 AM")).toBeTruthy();
+ expect(within(completed).getByRole("button", { name: "Undo" })).toBeTruthy();
+
+ // Open, not-overdue instances do not need attention.
+ expect(within(list).queryByTestId("attention-13")).toBeNull();
+ // No actor in the payload, so no "from panel".
+ expect(list.textContent).not.toContain("from panel");
+ });
+
+ it("permission note carries the exact copy", async () => {
+ await renderReady();
+ expect(PERMISSION_NOTE).toBe(
+ "Undo is admin-only. A panel tap can never reverse a completion.",
+ );
+ expect(screen.getByTestId("permission-note").textContent).toBe(PERMISSION_NOTE);
+ });
+
+ it("scroll column ends with 92px bottom padding", async () => {
+ await renderReady();
+ expect(screen.getByTestId("today-scroll").style.paddingBottom).toBe("92px");
+ });
+
+ it("shows a loading state while the snapshot is in flight", () => {
+ fetchMock.mockReturnValue(new Promise(() => {}));
+ render( );
+ expect(screen.getByTestId("today-loading").textContent).toContain("Loading");
+ expect(screen.getByTestId("today-scroll").style.paddingBottom).toBe("92px");
+ });
+
+ it("shows an error state on a failed fetch and retries", async () => {
+ fetchMock.mockResolvedValueOnce(new Response("boom", { status: 500 }));
+ render( );
+ const error = await screen.findByTestId("today-error");
+ expect(error.getAttribute("role")).toBe("alert");
+ expect(error.textContent).toContain("could not be loaded");
+
+ fetchMock.mockResolvedValueOnce(jsonResponse(SNAPSHOT));
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
+ expect(await screen.findByText("Declan")).toBeTruthy();
+ });
+
+ it("shows the refusal message when the API answers 403", async () => {
+ fetchMock.mockResolvedValueOnce(new Response("", { status: 403 }));
+ render( );
+ const error = await screen.findByTestId("today-error");
+ expect(error.textContent).toContain("nestquest-admins");
+ });
+});
+
+/** SNAPSHOT after instance 12 ("Make bed") is reversed back to open. */
+const AFTER_UNDO: AdminSnapshot = {
+ ...SNAPSHOT,
+ children: SNAPSHOT.children.map((child) =>
+ child.child_id !== 1
+ ? child
+ : {
+ ...child,
+ completed_today: 2,
+ remaining_today: 5,
+ completion_pct: 29,
+ instances: child.instances.map((i) =>
+ i.id === 12 ? { ...i, state: "open" as const, completed_at: null, on_time: null } : i,
+ ),
+ },
+ ),
+};
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((r) => {
+ resolve = r;
+ });
+ return { promise, resolve };
+}
+
+describe("TodayTab Undo and live updates", () => {
+ let fetchMock: ReturnType;
+ let snapshots: AdminSnapshot[];
+ let uncomplete: () => Promise;
+ let streams: TestStream[];
+
+ function calls(pathSuffix: string, method = "GET") {
+ return fetchMock.mock.calls.filter(
+ ([url, init]) =>
+ new URL(String(url), "http://x").pathname.endsWith(pathSuffix) &&
+ (init?.method ?? "GET") === method,
+ );
+ }
+
+ beforeAll(() => {
+ vi.stubEnv("TZ", "America/Los_Angeles");
+ });
+
+ afterAll(() => {
+ vi.unstubAllEnvs();
+ });
+
+ beforeEach(() => {
+ sessionStorage.clear();
+ storeTokens({ access_token: "at-123", expires_in: 600 });
+ snapshots = [SNAPSHOT, AFTER_UNDO];
+ uncomplete = () =>
+ Promise.resolve(jsonResponse({ instance_id: 12, state: "open", appended: true }));
+ streams = [];
+ fetchMock = vi.fn((url: string) => {
+ const path = new URL(url, "http://x").pathname;
+ if (path.endsWith("/api/v1/admin/snapshot")) {
+ const next = snapshots.length > 1 ? snapshots.shift()! : snapshots[0];
+ return Promise.resolve(jsonResponse(next));
+ }
+ if (path.endsWith("/api/v1/admin/instances/12/uncomplete")) return uncomplete();
+ if (path.endsWith("/api/v1/admin/events")) {
+ const stream = testStream();
+ streams.push(stream);
+ return Promise.resolve(stream.response);
+ }
+ return Promise.resolve(jsonResponse({ detail: "unexpected" }, 500));
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ });
+
+ it("Undo POSTs uncomplete for the completed instance and refetches the snapshot", async () => {
+ render( );
+ const row = await screen.findByTestId("attention-12");
+ fireEvent.click(within(row).getByRole("button", { name: "Undo" }));
+
+ await waitFor(() => expect(screen.queryByTestId("attention-12")).toBeNull());
+ const posts = calls("/api/v1/admin/instances/12/uncomplete", "POST");
+ expect(posts).toHaveLength(1);
+ expect(posts[0][1].headers.Authorization).toBe("Bearer at-123");
+ expect(calls("/api/v1/admin/snapshot")).toHaveLength(2);
+ // The reversed instance is open again: one fewer done, one more remaining.
+ expect(screen.getByTestId("stat-done").textContent).toBe("6Done");
+ expect(screen.getByTestId("stat-remaining").textContent).toBe("5Remaining");
+ expect(screen.queryByTestId("today-undo-error")).toBeNull();
+ });
+
+ it("disables Undo while the request is in flight (no double submit)", async () => {
+ const pending = deferred();
+ uncomplete = () => pending.promise;
+ render( );
+ const row = await screen.findByTestId("attention-12");
+ const button = within(row).getByRole("button", { name: "Undo" }) as HTMLButtonElement;
+
+ fireEvent.click(button);
+ await waitFor(() => expect(button.disabled).toBe(true));
+ expect(button.textContent).toBe("Undoing…");
+ // Every Undo is disabled while one reversal is in flight.
+ const other = within(screen.getByTestId("attention-14")).getByRole("button", {
+ name: "Undo",
+ }) as HTMLButtonElement;
+ expect(other.disabled).toBe(true);
+ fireEvent.click(button);
+ fireEvent.click(other);
+ expect(calls("/api/v1/admin/instances/12/uncomplete", "POST")).toHaveLength(1);
+ expect(calls("/uncomplete", "POST")).toHaveLength(1);
+
+ pending.resolve(jsonResponse({ instance_id: 12, state: "open", appended: true }));
+ await waitFor(() => expect(screen.queryByTestId("attention-12")).toBeNull());
+ expect(other.disabled).toBe(false);
+ });
+
+ it("a failed Undo shows a message and leaves the view intact", async () => {
+ uncomplete = () => Promise.resolve(jsonResponse({ detail: "instance 12 not found" }, 404));
+ render( );
+ const row = await screen.findByTestId("attention-12");
+ fireEvent.click(within(row).getByRole("button", { name: "Undo" }));
+
+ const alert = await screen.findByTestId("today-undo-error");
+ expect(alert.textContent).toContain("The completion could not be undone: instance 12 not found");
+ // Still the original snapshot: nothing refetched, the row and its Undo remain usable.
+ expect(calls("/api/v1/admin/snapshot")).toHaveLength(1);
+ const button = within(screen.getByTestId("attention-12")).getByRole("button", {
+ name: "Undo",
+ }) as HTMLButtonElement;
+ expect(button.disabled).toBe(false);
+ expect(screen.getByTestId("stat-done").textContent).toBe("7Done");
+
+ fireEvent.click(within(alert).getByRole("button", { name: "Dismiss" }));
+ expect(screen.queryByTestId("today-undo-error")).toBeNull();
+ });
+
+ it("Mark done stays inert", async () => {
+ render( );
+ const row = await screen.findByTestId("attention-11");
+ fireEvent.click(within(row).getByRole("button", { name: "Mark done" }));
+ expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(0);
+ });
+
+ it("a transition on the live stream refetches the snapshot without a reload", async () => {
+ const { unmount } = render(
+
+
+ ,
+ );
+ await screen.findByTestId("attention-12");
+ await waitFor(() => expect(streams).toHaveLength(1));
+ const [, init] = calls("/api/v1/admin/events")[0];
+ expect(init.headers.Authorization).toBe("Bearer at-123");
+
+ // An uncompletion made elsewhere, its frame split across two chunks.
+ const text = frame("nestquest_quest_uncompleted", {
+ child_id: 1,
+ child_name: "Declan",
+ instance_id: 12,
+ quest_title: "Make bed",
+ window: "morning",
+ due_date: "2026-09-23",
+ due_time: null,
+ occurred_at: "2026-09-23T12:00:00Z",
+ });
+ streams[0].push(text.slice(0, 25));
+ streams[0].push(text.slice(25));
+
+ await waitFor(() => expect(calls("/api/v1/admin/snapshot")).toHaveLength(2));
+ await waitFor(() => expect(screen.queryByTestId("attention-12")).toBeNull());
+ // Refreshed in place: the loading state never replaced the view.
+ expect(screen.queryByTestId("today-loading")).toBeNull();
+
+ const signal = init.signal as AbortSignal;
+ expect(signal.aborted).toBe(false);
+ unmount();
+ expect(signal.aborted).toBe(true);
+ await waitFor(() => expect(streams[0].cancelled()).toBe(true));
+ });
+});
diff --git a/admin/src/today/TodayTab.tsx b/admin/src/today/TodayTab.tsx
new file mode 100644
index 0000000..9d4fedc
--- /dev/null
+++ b/admin/src/today/TodayTab.tsx
@@ -0,0 +1,347 @@
+/**
+ * Today tab — compact density (design/ADMIN-SPEC.md §2.1–2.3, §2.5–2.6).
+ */
+import { useEffect, useRef, useState } from "react";
+import {
+ fetchSnapshot,
+ type AdminSnapshot,
+ type SnapshotChild,
+ type SnapshotInstance,
+} from "../api/snapshot";
+import { ApiForbiddenError } from "../api/client";
+import { ApiRequestError } from "../api/definitions";
+import { uncompleteInstance } from "../api/instances";
+import { useTransitions } from "../api/useTransitions";
+import {
+ formatClockTime,
+ formatDueTime,
+ formatLongDate,
+ formatShortDate,
+} from "./format";
+import {
+ AlertCircleGlyph,
+ CheckCircleGlyph,
+ InfoGlyph,
+ SettingsGlyph,
+} from "./glyphs";
+import "./TodayTab.css";
+
+const CYCLE_LENGTH_DAYS = 14;
+
+/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
+export const SCROLL_BOTTOM_PADDING = "92px";
+
+export const PERMISSION_NOTE =
+ "Undo is admin-only. A panel tap can never reverse a completion.";
+
+type LoadState =
+ | { kind: "loading" }
+ | { kind: "error"; message: string }
+ | { kind: "ready"; snapshot: AdminSnapshot };
+
+function isOverdueOpen(instance: SnapshotInstance): boolean {
+ return instance.overdue && instance.state === "open";
+}
+
+function ScreenHeader({ sub }: { sub: string }) {
+ return (
+
+
+ {/* Settings screen is a later task; the control is not wired yet. */}
+
+
+
+
+ );
+}
+
+function StatRow({ snapshot }: { snapshot: AdminSnapshot }) {
+ const done = snapshot.children.reduce((sum, c) => sum + c.completed_today, 0);
+ const remaining = snapshot.children.reduce((sum, c) => sum + c.remaining_today, 0);
+ const overdue = snapshot.children.reduce(
+ (sum, c) => sum + c.instances.filter(isOverdueOpen).length,
+ 0,
+ );
+ const stats = [
+ { key: "done", label: "Done", value: done },
+ { key: "remaining", label: "Remaining", value: remaining },
+ { key: "overdue", label: "Overdue", value: overdue },
+ ];
+ return (
+
+ {stats.map((stat) => (
+
+
+ {stat.value}
+
+
{stat.label}
+
+ ))}
+
+ );
+}
+
+function ChildRow({ child }: { child: SnapshotChild }) {
+ const overdue = child.instances.filter(isOverdueOpen).length;
+ const allDone = child.due_today > 0 && child.completed_today === child.due_today;
+ let meta: string;
+ let metaClass = "today-child-meta";
+ if (!child.present) {
+ meta = child.next_present
+ ? `Away · returns ${formatShortDate(child.next_present)}`
+ : "Away";
+ } else if (allDone) {
+ meta = `${child.completed_today} of ${child.due_today} · all done`;
+ metaClass += " today-child-meta--done";
+ } else {
+ meta = `${child.completed_today} of ${child.due_today} · ${overdue} overdue`;
+ }
+ const pct = Math.max(0, Math.min(100, child.completion_pct));
+ // ADMIN-SPEC §2.3 also asks for a lowercase `override` pill when an absence
+ // comes from a presence override. The snapshot carries no field saying
+ // where an absence came from, so the pill is omitted rather than guessed.
+ return (
+
+
+ {child.child_name.charAt(0).toUpperCase()}
+
+
+
{child.child_name}
+
{meta}
+
+
+
+ );
+}
+
+interface AttentionItem {
+ instance: SnapshotInstance;
+ childName: string;
+}
+
+function attentionItems(snapshot: AdminSnapshot): AttentionItem[] {
+ const all = snapshot.children.flatMap((child) =>
+ child.instances.map((instance) => ({ instance, childName: child.child_name })),
+ );
+ const overdue = all.filter((item) => isOverdueOpen(item.instance));
+ const completed = all
+ .filter((item) => item.instance.state === "completed" && item.instance.completed_at)
+ .sort((a, b) =>
+ (b.instance.completed_at ?? "").localeCompare(a.instance.completed_at ?? ""),
+ );
+ return [...overdue, ...completed];
+}
+
+interface UndoControl {
+ /** The instance whose Undo is in flight, or null. */
+ pendingId: number | null;
+ onUndo: (instanceId: number) => void;
+}
+
+function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }) {
+ const { instance, childName } = item;
+ const overdue = isOverdueOpen(instance);
+ const undoing = undo.pendingId === instance.id;
+ // TODO(e57facc2): wire "Mark done". The admin plane has no complete route
+ // yet, so that action is inert here.
+ return (
+
+
+ {overdue ? : }
+
+
+
+ {instance.title} · {childName}
+
+ {overdue ? (
+
+ {instance.due_time
+ ? `Overdue since ${formatDueTime(instance.due_time)}`
+ : "Overdue"}
+
+ ) : (
+ // The snapshot carries no completion actor, so "from panel" is not printed.
+
+ Completed {formatClockTime(instance.completed_at as string)}
+
+ )}
+
+ {overdue ? (
+
+ Mark done
+
+ ) : (
+ undo.onUndo(instance.id)}
+ >
+ {undoing ? "Undoing…" : "Undo"}
+
+ )}
+
+ );
+}
+
+function PermissionNote() {
+ return (
+
+
+
+
+ {PERMISSION_NOTE}
+
+ );
+}
+
+export default function TodayTab() {
+ const [state, setState] = useState({ kind: "loading" });
+ const [attempt, setAttempt] = useState(0);
+ /** The next load is a refresh in place: keep the current view, no loading state. */
+ const silentNext = useRef(false);
+ const [undoingId, setUndoingId] = useState(null);
+ const undoingRef = useRef(false);
+ const [undoError, setUndoError] = useState(null);
+
+ function refresh() {
+ silentNext.current = true;
+ setAttempt((n) => n + 1);
+ }
+
+ // Any transition (completed, uncompleted, missed, day complete) changes the snapshot.
+ useTransitions(refresh);
+
+ useEffect(() => {
+ let cancelled = false;
+ const silent = silentNext.current;
+ silentNext.current = false;
+ if (!silent) setState({ kind: "loading" });
+ fetchSnapshot()
+ .then((snapshot) => {
+ if (!cancelled) setState({ kind: "ready", snapshot });
+ })
+ .catch((error: unknown) => {
+ if (cancelled) return;
+ const message =
+ error instanceof ApiForbiddenError
+ ? error.message
+ : "Today's snapshot could not be loaded. Check your connection and try again.";
+ // A failed refresh keeps the snapshot already on screen.
+ setState((prev) => (silent && prev.kind === "ready" ? prev : { kind: "error", message }));
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [attempt]);
+
+ /** Reverse one completion, then refetch the snapshot. One request at a time. */
+ async function undo(instanceId: number) {
+ if (undoingRef.current) return;
+ undoingRef.current = true;
+ setUndoingId(instanceId);
+ setUndoError(null);
+ try {
+ await uncompleteInstance(instanceId);
+ refresh();
+ } catch (error: unknown) {
+ setUndoError(
+ error instanceof ApiForbiddenError
+ ? error.message
+ : error instanceof ApiRequestError && error.detail
+ ? `The completion could not be undone: ${error.detail}`
+ : "The completion could not be undone. Check your connection and try again.",
+ );
+ } finally {
+ undoingRef.current = false;
+ setUndoingId(null);
+ }
+ }
+
+ let sub = "";
+ let body;
+ if (state.kind === "loading") {
+ body = (
+
+ Loading today…
+
+ );
+ } else if (state.kind === "error") {
+ body = (
+
+
{state.message}
+
setAttempt((n) => n + 1)}>
+ Try again
+
+
+ );
+ } else {
+ const { snapshot } = state;
+ sub = `${formatLongDate(snapshot.today_iso)} · cycle day ${snapshot.cycle_day} of ${CYCLE_LENGTH_DAYS}`;
+ const attention = attentionItems(snapshot);
+ body = (
+ <>
+
+
+ {snapshot.children.map((child) => (
+
+ ))}
+
+ Needs attention
+ {attention.length > 0 ? (
+
+ {attention.map((item) => (
+ void undo(id) }}
+ />
+ ))}
+
+ ) : (
+ Nothing needs attention.
+ )}
+
+ >
+ );
+ }
+
+ return (
+
+
+
+ {undoError !== null ? (
+
+ {undoError}
+ setUndoError(null)}>
+ Dismiss
+
+
+ ) : null}
+ {body}
+
+
+ );
+}
diff --git a/admin/src/today/format.ts b/admin/src/today/format.ts
new file mode 100644
index 0000000..80537c0
--- /dev/null
+++ b/admin/src/today/format.ts
@@ -0,0 +1,50 @@
+/**
+ * Date/time formatting for the Today tab.
+ *
+ * Calendar dates ("YYYY-MM-DD") are parsed by their components and formatted
+ * in UTC, so the printed day never shifts with the viewer's offset — unlike
+ * `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
+ * day in negative-offset zones.
+ */
+
+const LOCALE = "en-US";
+
+function calendarDate(iso: string): Date {
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
+ if (!match) throw new Error(`Invalid calendar date: ${iso}`);
+ const [, year, month, day] = match;
+ return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
+}
+
+function format(date: Date, options: Intl.DateTimeFormatOptions): string {
+ // Newer ICU puts a narrow no-break space before AM/PM; normalise it.
+ return new Intl.DateTimeFormat(LOCALE, options)
+ .format(date)
+ .replace(/ /g, " ");
+}
+
+/** "Wednesday, September 23" */
+export function formatLongDate(iso: string): string {
+ const date = calendarDate(iso);
+ const weekday = format(date, { weekday: "long", timeZone: "UTC" });
+ const monthDay = format(date, { month: "long", day: "numeric", timeZone: "UTC" });
+ return `${weekday}, ${monthDay}`;
+}
+
+/** "Sep 23" */
+export function formatShortDate(iso: string): string {
+ return format(calendarDate(iso), { month: "short", day: "numeric", timeZone: "UTC" });
+}
+
+/** "09:00" -> "9:00 AM" (a wall-clock time, no zone conversion). */
+export function formatDueTime(hhmm: string): string {
+ const match = /^(\d{2}):(\d{2})/.exec(hhmm);
+ if (!match) return hhmm;
+ const date = new Date(Date.UTC(2000, 0, 1, Number(match[1]), Number(match[2])));
+ return format(date, { hour: "numeric", minute: "2-digit", timeZone: "UTC" });
+}
+
+/** A UTC ISO timestamp shown as the viewer's local time, "7:04 AM". */
+export function formatClockTime(isoTimestamp: string): string {
+ return format(new Date(isoTimestamp), { hour: "numeric", minute: "2-digit" });
+}
diff --git a/admin/src/today/glyphs.tsx b/admin/src/today/glyphs.tsx
new file mode 100644
index 0000000..742eea5
--- /dev/null
+++ b/admin/src/today/glyphs.tsx
@@ -0,0 +1,61 @@
+/**
+ * Inline stroke glyphs for the Today tab (Lucide-style 24px grid). The
+ * shipped Lucide subset is task b50b048f; these keep this screen CDN-free
+ * until then.
+ */
+import type { ReactNode } from "react";
+
+function Glyph({ size, children }: { size: number; children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function SettingsGlyph({ size = 18 }: { size?: number }) {
+ return (
+
+
+
+
+ );
+}
+
+export function InfoGlyph({ size = 16 }: { size?: number }) {
+ return (
+
+
+
+
+ );
+}
+
+export function AlertCircleGlyph({ size = 18 }: { size?: number }) {
+ return (
+
+
+
+
+ );
+}
+
+export function CheckCircleGlyph({ size = 18 }: { size?: number }) {
+ return (
+
+
+
+
+ );
+}
diff --git a/admin/tsconfig.json b/admin/tsconfig.json
new file mode 100644
index 0000000..a63985a
--- /dev/null
+++ b/admin/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "jsx": "react-jsx",
+ "types": ["vite/client"],
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "isolatedModules": true,
+ "useDefineForClassFields": true
+ },
+ "include": ["src"]
+}
\ No newline at end of file
diff --git a/admin/vite-sw-plugin.ts b/admin/vite-sw-plugin.ts
new file mode 100644
index 0000000..4db9f9e
--- /dev/null
+++ b/admin/vite-sw-plugin.ts
@@ -0,0 +1,93 @@
+/* Build-time service worker injection. After Vite writes the bundle, the
+ hashed asset URLs referenced by dist/index.html and a content-derived
+ revision are written into dist/sw.js, so the offline shell is complete right
+ after install and every release ships a new worker with fresh caches.
+ Build tooling only — never imported by the app bundle. */
+import { createHash } from "node:crypto";
+import { readFileSync, writeFileSync } from "node:fs";
+import { resolve } from "node:path";
+import type { Plugin } from "vite";
+
+export const REVISION_MARKER = "__NQ_BUILD_REVISION__";
+const PRECACHE_PLACEHOLDER = "const PRECACHE_ASSETS = [];";
+// Unhashed files sw.js precaches by fixed URL, relative to the build output.
+export const STATIC_PRECACHE_FILES = [
+ "manifest.webmanifest",
+ "icons/icon-192.png",
+ "icons/icon-512.png",
+ "icons/icon-maskable-512.png",
+];
+
+export interface PrecacheFile {
+ path: string;
+ bytes: string | Uint8Array;
+}
+
+// Every same-origin URL an index.html loads via