Compare commits

...
Author SHA1 Message Date
overseerandClaude Opus 5.5 0adaed553d Probe /api/v1/admin/ping and refuse the shell on any non-OK outcome
The initial authorization probe called the nonexistent /api/v1/children
and treated every non-403 outcome as success, so a 404 rendered the
admin shell. Probe the admin plane's GET /api/v1/admin/ping instead and
enter the shell only on a 2xx: 403 shows the refusal, 401 keeps the
login flow, and any other status or network failure shows a new
probe-error screen with Retry and Sign out.

Task: f50338c8-8b48-4e3d-9064-e20d9b32fe34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 00:00:02 -04:00
dev_agent 487bc00994 Task e57facc2: Undo and live SSE updates (#210) 2026-09-23 23:54:09 -04:00
dev_agent 75b2ac888a Task e5133d0f: CSV export of the filtered range (#209) 2026-09-23 23:33:37 -04:00
dev_agent bc3e64b4f8 Task 9b9188b8: History tab - filters, rows, stats and footer (#208) 2026-09-23 23:26:03 -04:00
dev_agent cc970bb646 Task 4ae0c879: Presence override with live consequence count (#207) 2026-09-23 23:16:04 -04:00
dev_agent 7b5177d055 Task d96d8925: Presence schedule month grid (#206) 2026-09-23 22:51:16 -04:00
dev_agent a22f990bd6 Task b50b048f: Lucide icon picker (shipped subset) (#205) 2026-09-23 22:39:25 -04:00
dev_agent 4b56e2f68a Task 85b2f166: Recurrence occurrence preview (#204) 2026-09-23 22:30:04 -04:00
overseer d0c236b569 Merge dev into feature/24ce7ebf (Feature 23: admin API completion) 2026-09-23 22:17:08 -04:00
dev_agent 33d8e0d2ac Task 2e96585c: Definitions list and edit sheet (#198) 2026-09-23 21:26:27 -04:00
overseer dbe873eb2a Merge dev into feature/24ce7ebf (Feature 22 follow-up: editable assignees) 2026-09-23 21:11:21 -04:00
overseer 706a7954d8 Merge dev into feature/24ce7ebf (Feature 22: definitions read + skip_on_away) 2026-09-23 20:52:48 -04:00
dev_agent be4fd04754 Task e0a2b862: Today tab (#192) 2026-09-23 20:13:52 -04:00
overseer 4ff40f00a2 Merge dev into feature/24ce7ebf (bring the Feature 21 admin snapshot route) 2026-09-23 20:03:05 -04:00
dev_agent f476821e8e Task 96ab3c0b: PWA manifest and service worker (#189) 2026-09-23 19:42:27 -04:00
dev_agent 75ad9892b8 Task 630c3472: Authentik OIDC PKCE login and refusal handling (#188) 2026-09-23 18:30:07 -04:00
dev_agent 48d0c4ef86 Task 0df715b5: React scaffold, build and serve (#187) 2026-09-23 16:39:36 -04:00
66 changed files with 14775 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# NestQuest Admin PWA — runtime configuration (Vite env).
# Copy to .env (or .env.local) and fill in real values. NEVER commit .env.
# Authentik OIDC provider. Defaults to the issuer below when unset.
VITE_AUTHENTIK_ISSUER=https://auth.cubecraftlabs.com/application/o/nestquest/
# REQUIRED. OAuth2/OIDC client id of the PUBLIC (PKCE) Authentik application.
# No default: the app shows a configuration error screen when this is missing.
# Never set a client_secret — this is a browser SPA using PKCE only.
VITE_AUTHENTIK_CLIENT_ID=
# NestQuest API base URL.
VITE_API_BASE_URL=https://nestquest.cubecraftlabs.com
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#3b3ab8" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>NestQuest Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3034
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "nestquest-admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"lucide-react": "^1.47.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"jsdom": "^30.1.1",
"typescript": "^5.9.2",
"vite": "^7.1.5",
"vitest": "^3.2.4"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

+14
View File
@@ -0,0 +1,14 @@
{
"name": "NestQuest Admin",
"short_name": "NestQuest",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#f4f4f6",
"theme_color": "#3b3ab8",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+88
View File
@@ -0,0 +1,88 @@
/* NestQuest Admin service worker — hand-written, served from the site root.
Precaches the app shell, serves static assets cache-first, and falls back
to the cached shell for navigations when offline. Only same-origin GET
requests are handled; cross-origin requests (Authentik, the API host) are
never intercepted or cached.
The build (vite-sw-plugin.ts) replaces PRECACHE_ASSETS with the hashed
bundles referenced by index.html and the revision marker with a content
hash, so each release installs a new worker with fresh caches. */
const REVISION = "__NQ_BUILD_REVISION__";
const CACHE_PREFIX = "nestquest-admin-";
const SHELL_CACHE = `${CACHE_PREFIX}shell-${REVISION}`;
const RUNTIME_CACHE = `${CACHE_PREFIX}runtime-${REVISION}`;
const CURRENT_CACHES = [SHELL_CACHE, RUNTIME_CACHE];
const PRECACHE_ASSETS = [];
const SHELL_URLS = [
...new Set([
"/",
"/index.html",
"/manifest.webmanifest",
"/icons/icon-192.png",
"/icons/icon-512.png",
"/icons/icon-maskable-512.png",
...PRECACHE_ASSETS,
]),
];
// Request destinations treated as static assets (cache-first). Anything else
// same-origin (e.g. API calls) goes straight to the network.
const STATIC_DESTINATIONS = ["script", "style", "image", "font", "manifest"];
self.addEventListener("install", (event) => {
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;
}
+80
View File
@@ -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}`);
}
+123
View File
@@ -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);
}
+47
View File
@@ -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<Response>(() => {})));
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("renders the title and all four tab labels", () => {
render(<App />);
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(<App />);
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(<App />);
fireEvent.click(screen.getByRole("tab", { name: "Tasks" }));
expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy();
expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder");
});
});
+63
View File
@@ -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<TabId>("today");
return (
<TransitionsProvider>
<div className="admin-shell">
{/* 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. */}
<header className="admin-header visually-hidden">
<h1 className="admin-title">NestQuest Admin</h1>
<p className="admin-subline">Admin console</p>
</header>
<main
className="admin-panel admin-panel--screen"
role="tabpanel"
aria-labelledby={`tab-${active}`}
>
{active === "today" ? (
<TodayTab />
) : active === "tasks" ? (
<DefinitionsTab />
) : active === "schedule" ? (
<ScheduleTab />
) : (
<HistoryTab />
)}
</main>
<nav className="admin-tabbar" aria-label="Admin sections">
{TABS.map((tab) => (
<button
key={tab.id}
id={`tab-${tab.id}`}
type="button"
role="tab"
aria-selected={tab.id === active}
className={tab.id === active ? "admin-tab active" : "admin-tab"}
onClick={() => setActive(tab.id)}
>
{tab.label}
</button>
))}
</nav>
</div>
</TransitionsProvider>
);
}
+193
View File
@@ -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(<AppRoutes />);
expect(screen.getByTestId("login-screen")).toBeTruthy();
expect(
screen.getByRole("button", { name: /sign in with authentik/i }),
).toBeTruthy();
});
function probeCalls(fetchMock: ReturnType<typeof vi.fn>): 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(<AppRoutes />);
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(<AppRoutes />);
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(<AppRoutes />);
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(<AppRoutes />);
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(<AppRoutes />);
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(<AppRoutes />);
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(<AppRoutes />);
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(<AppRoutes />);
expect(screen.getByTestId("config-error-screen")).toBeTruthy();
} finally {
(config as { clientId: string | undefined }).clientId = original;
}
});
});
+211
View File
@@ -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 (
<div className="admin-login" data-testid="login-screen">
<h1 className="admin-title">NestQuest Admin</h1>
<p className="admin-subline">Sign in to continue</p>
<button type="button" className="admin-login-button" onClick={onLogin}>
Sign in with Authentik
</button>
</div>
);
}
export default function AppRoutes() {
const [screen, setScreen] = useState<Screen>({ 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 (
<div className="admin-loading" role="status" data-testid="loading-screen">
Loading…
</div>
);
case "login":
return <LoginScreen onLogin={startLogin} />;
case "config-error":
return (
<div className="admin-error" role="alert" data-testid="config-error-screen">
<h1>Configuration error</h1>
<p>{screen.message}</p>
</div>
);
case "auth-error":
return (
<div className="admin-error" role="alert" data-testid="auth-error-screen">
<h1>Sign-in failed</h1>
<p>{screen.message}</p>
<button
type="button"
onClick={() => {
clearAuth();
window.history.replaceState({}, "", "/");
setScreen({ kind: "loading" });
}}
>
Try again
</button>
</div>
);
case "refusal":
return (
<div className="admin-refusal" role="alert" data-testid="refusal-screen">
<h1>Access refused</h1>
<p>{screen.message}</p>
<button type="button" onClick={() => { clearAuth(); window.history.replaceState({}, "", "/"); setScreen({ kind: "login" }); }}>
Sign out
</button>
</div>
);
case "probe-error":
return (
<div className="admin-error" role="alert" data-testid="probe-error-screen">
<h1>Admin console unavailable</h1>
<p>{screen.message}</p>
<button
type="button"
onClick={() => {
setScreen({ kind: "loading" });
void probeApi(setScreen);
}}
>
Retry
</button>
<button type="button" onClick={() => { clearAuth(); window.history.replaceState({}, "", "/"); setScreen({ kind: "login" }); }}>
Sign out
</button>
</div>
);
case "app":
return <AdminShell />;
}
}
+94
View File
@@ -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);
});
});
+93
View File
@@ -0,0 +1,93 @@
/**
* Minimal fetch wrapper for the NestQuest API.
*
* - Attaches `Authorization: Bearer <access_token>` 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<RequestInit, "headers"> {
headers?: Record<string, string>;
}
/**
* Redirect the browser to Authentik to start a new login.
* Exported for testing injection.
*/
export async function triggerLogin(app: AppConfig = config): Promise<void> {
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<Response> {
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;
}
+174
View File
@@ -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<DefinitionCreateBody>;
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<T>(response: Response): Promise<T> {
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<QuestDefinition[]> {
const body = await readJson<{ definitions: QuestDefinition[] }>(
await apiFetch(DEFINITIONS_PATH),
);
return body.definitions;
}
export async function fetchChildren(): Promise<AdminChild[]> {
const body = await readJson<{ children: AdminChild[] }>(await apiFetch(CHILDREN_PATH));
return body.children;
}
function sendJson(path: string, method: "POST" | "PATCH", body: unknown): Promise<Response> {
return apiFetch(path, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
export async function createDefinition(body: DefinitionCreateBody): Promise<QuestDefinition> {
return readJson<QuestDefinition>(await sendJson(DEFINITIONS_PATH, "POST", body));
}
export async function updateDefinition(
id: number,
body: DefinitionEditBody,
): Promise<QuestDefinition> {
return readJson<QuestDefinition>(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<string[]> {
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;
}
+162
View File
@@ -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<typeof vi.fn>;
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);
});
});
+196
View File
@@ -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<void> {
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<void> {
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();
}
+85
View File
@@ -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<HistoryResponse> {
const params = new URLSearchParams({ start, end, filter });
return readJson<HistoryResponse>(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<HistoryCsv> {
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<never>(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`,
};
}
+23
View File
@@ -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<UncompleteResult> {
return readJson<UncompleteResult>(await apiFetch(uncompletePath(instanceId), { method: "POST" }));
}
+126
View File
@@ -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<string, number[]>;
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<PresenceSchedule | null> {
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<PresenceSchedule> {
return readJson<PresenceSchedule>(
await apiFetch(presenceSchedulePath(childId), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
}
export async function fetchPresenceOverrides(
filter: PresenceOverrideFilter = {},
): Promise<PresenceOverride[]> {
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<Response> {
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<number> {
const body = await readJson<{ removed: number }>(
await postJson(PRESENCE_OVERRIDE_CONSEQUENCE_PATH, range),
);
return body.removed;
}
export async function createPresenceOverride(body: PresenceOverrideBody): Promise<PresenceOverride> {
return readJson<PresenceOverride>(await postJson(PRESENCE_OVERRIDES_PATH, body));
}
export async function deletePresenceOverride(overrideId: number): Promise<void> {
await readJson<{ status: string }>(
await apiFetch(`${PRESENCE_OVERRIDES_PATH}/${overrideId}`, { method: "DELETE" }),
);
}
+55
View File
@@ -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<AdminSnapshot> {
const response = await apiFetch(SNAPSHOT_PATH);
if (!response.ok) {
throw new Error(`Snapshot request failed (HTTP ${response.status}).`);
}
return (await response.json()) as AdminSnapshot;
}
+39
View File
@@ -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<Uint8Array>;
let cancelled = false;
const body = new ReadableStream<Uint8Array>({
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`;
}
+61
View File
@@ -0,0 +1,61 @@
/**
* One live transition stream per signed-in shell, fanned out to the screens.
*
* <TransitionsProvider> 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<TransitionHub | null>(null);
export function TransitionsProvider({ children }: { children: ReactNode }) {
const [hub] = useState(() => {
const listeners = new Set<Listener>();
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 <TransitionsContext.Provider value={hub}>{children}</TransitionsContext.Provider>;
}
/** 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]);
}
+173
View File
@@ -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`,
);
});
});
+225
View File
@@ -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<string> {
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<void> {
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<boolean> {
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<void> {
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()}`);
}
+41
View File
@@ -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());
});
});
+55
View File
@@ -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<string> {
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))
);
}
+215
View File
@@ -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<string, string>;
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);
});
});
+221
View File
@@ -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/<slug>/, 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<string, string | undefined> = 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<DiscoveredEndpoints> {
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<string, Promise<DiscoveredEndpoints>>();
export function resolveEndpointsFromDiscovery(
issuer: string,
fetchImpl: typeof fetch = fetch,
): Promise<DiscoveredEndpoints> {
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<ResolvedAppConfig> {
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,
};
}
+585
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+968
View File
@@ -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<DefinitionRule["rule_type"], Repeats> = {
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<WindowName, string>;
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<WindowName, string> {
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<T>(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 (
<div className="defs-chips" role="group" aria-label="Filter by child">
{chips.map((chip) => (
<button
key={chip.key}
type="button"
className={chip.key === filter ? "defs-chip defs-chip--active" : "defs-chip"}
aria-pressed={chip.key === filter}
onClick={() => onChange(chip.key)}
>
{chip.label}
</button>
))}
</div>
);
}
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 (
<li>
<button
type="button"
className={className}
data-testid={`definition-${definition.id}`}
aria-expanded={editing}
onClick={onOpen}
>
<span className="defs-row-glyph">
<DefinitionIcon name={definition.icon} />
</span>
<span className="defs-row-text">
<span className="defs-row-title">{definition.title}</span>
<span className="defs-row-meta">{editing ? "Editing" : meta}</span>
</span>
<span className="defs-row-chevron">
{editing ? <ChevronDownGlyph /> : <ChevronRightGlyph />}
</span>
</button>
</li>
);
}
/* ── 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<PreviewState>({ 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 (
<p className="defs-hint defs-preview defs-preview--error" data-testid="occurrence-preview">
{preview.message}
</p>
);
}
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 (
<p className="defs-hint defs-preview" data-testid="occurrence-preview" aria-live="polite">
{text}
</p>
);
}
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
return (
<div className="defs-field" role="group" aria-labelledby={id}>
<span className="defs-label" id={id}>
{label}
</span>
{children}
</div>
);
}
function EditSheet({
target,
activeChildren,
onCancel,
onSaved,
}: {
target: "new" | QuestDefinition;
activeChildren: AdminChild[];
onCancel: () => void;
onSaved: () => void;
}) {
const [draft, setDraft] = useState<Draft>(() =>
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<string | null>(null);
// A ref, not state: two taps in one frame must not both see `saving === false`.
const inFlight = useRef(false);
const sheetRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null);
const confirmRef = useRef<HTMLDivElement>(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<HTMLDivElement>) {
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<HTMLElement>(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<Draft>) => 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 (
<div className="defs-sheet-layer">
<div
className="defs-scrim"
data-testid="sheet-scrim"
onClick={() => {
if (!inFlight.current) onCancel();
}}
/>
<div
ref={sheetRef}
className="defs-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="defs-sheet-title"
onKeyDown={onKeyDown}
>
<div className="defs-grabber" aria-hidden="true" />
<h3 className="defs-sheet-title" id="defs-sheet-title" ref={titleRef} tabIndex={-1}>
{target === "new" ? "New task" : "Edit task"}
</h3>
<div className="defs-fields">
<Field label="Title" id="defs-label-title">
<input
className="defs-input"
aria-labelledby="defs-label-title"
value={draft.title}
onChange={(e) => update({ title: e.target.value })}
/>
</Field>
<Field label="Icon" id="defs-label-icon">
<div className="defs-icon-grid" role="radiogroup" aria-labelledby="defs-label-icon">
<button
type="button"
role="radio"
aria-checked={draft.icon === null}
aria-label="No icon"
className={draft.icon === null ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: null })}
>
<TaskGlyph size={18} />
</button>
{DEFINITION_ICONS.map(({ name, label, Icon }) => (
<button
key={name}
type="button"
role="radio"
aria-checked={draft.icon === name}
aria-label={label}
data-icon-option={name}
className={draft.icon === name ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: name })}
>
<Icon size={18} aria-hidden="true" focusable="false" />
</button>
))}
</div>
</Field>
<Field label="Assigned children" id="defs-label-children">
<button
type="button"
className="defs-select-row"
aria-expanded={pickerOpen}
aria-labelledby="defs-label-children defs-children-summary"
onClick={() => setPickerOpen((open) => !open)}
>
<span id="defs-children-summary">
{selectedAssignees.length > 0
? assigneeSummary(selectedAssignees, activeChildren)
: "Choose children"}
</span>
{pickerOpen ? <ChevronDownGlyph /> : <ChevronRightGlyph />}
</button>
{pickerOpen ? (
<ul className="defs-picker" aria-label="Children">
{activeChildren.map((child) => {
const checked = draft.assigneeIds.includes(child.id);
return (
<li key={child.id}>
<button
type="button"
role="checkbox"
aria-checked={checked}
className="defs-picker-option"
onClick={() => update({ assigneeIds: toggle(draft.assigneeIds, child.id) })}
>
<span>{child.display_name}</span>
<span className="defs-picker-check">{checked ? <CheckGlyph /> : null}</span>
</button>
</li>
);
})}
{inactiveAssignees.map((assignee) => (
<li
key={assignee.id}
className="defs-picker-option defs-picker-option--locked"
aria-disabled="true"
data-testid={`inactive-assignee-${assignee.id}`}
>
<span>{assignee.display_name}</span>
<span className="defs-picker-note">Inactive</span>
</li>
))}
</ul>
) : null}
{inactiveAssignees.length > 0 ? (
<p className="defs-hint">
Also assigned (inactive): {inactiveAssignees.map((a) => a.display_name).join(", ")}.
Kept unless you change the selection.
</p>
) : null}
</Field>
<Field label="Repeats" id="defs-label-repeats">
<div className="defs-repeats">
<select
className="defs-input"
aria-label="Repeats"
value={draft.repeats}
onChange={(e) =>
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 key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<label className="defs-interval">
<span>Every</span>
<input
className="defs-input"
type="number"
min={1}
aria-label="Interval"
value={draft.interval}
onChange={(e) => update({ interval: e.target.value })}
/>
</label>
</div>
{draft.repeats === "weekly" || draft.repeats === "custom" ? (
<div className="defs-weekdays" role="group" aria-label="Days">
{WEEKDAY_SHORT.map((label, day) => (
<button
key={label}
type="button"
aria-pressed={draft.weekdays.includes(day)}
aria-label={WEEKDAY_LONG[day]}
className={
draft.weekdays.includes(day) ? "defs-day defs-day--on" : "defs-day"
}
onClick={() => update({ weekdays: toggle(draft.weekdays, day) })}
>
{label.charAt(0)}
</button>
))}
</div>
) : null}
{draft.repeats === "monthly" && draft.monthlyWeekday ? (
<p className="defs-hint">
{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,
})}
</p>
) : null}
{draft.repeats === "yearly" ? (
<select
className="defs-input"
aria-label="Month"
value={draft.month}
onChange={(e) => update({ month: e.target.value })}
>
{MONTH_SHORT.map((label, index) => (
<option key={label} value={String(index + 1)}>
{label}
</option>
))}
</select>
) : null}
{(draft.repeats === "monthly" && !draft.monthlyWeekday) || draft.repeats === "yearly" ? (
<input
className="defs-input"
type="number"
min={1}
max={31}
aria-label="Day of month"
value={draft.dayOfMonth}
onChange={(e) => update({ dayOfMonth: e.target.value })}
/>
) : null}
<OccurrencePreview ruleKey={previewKey} />
</Field>
<Field label="Due time" id="defs-label-due">
{orderedWindows.length === 0 ? (
<p className="defs-hint">Pick a window to set its due time.</p>
) : (
<div className="defs-due-times">
{orderedWindows.map((w) => (
<label key={w} className="defs-due">
<span>{WINDOW_LABELS[w]}</span>
<input
className="defs-input"
type="time"
aria-label={`${WINDOW_LABELS[w]} due time`}
value={draft.dueTimes[w]}
onChange={(e) =>
update({ dueTimes: { ...draft.dueTimes, [w]: e.target.value } })
}
/>
</label>
))}
</div>
)}
</Field>
<Field label="Window" id="defs-label-window">
<div className="defs-segmented">
{WINDOW_ORDER.map((w) => (
<button
key={w}
type="button"
aria-pressed={draft.windows.includes(w)}
className={
draft.windows.includes(w) ? "defs-segment defs-segment--on" : "defs-segment"
}
onClick={() => update({ windows: toggle(draft.windows, w) })}
>
{WINDOW_LABELS[w]}
</button>
))}
</div>
</Field>
<div className="defs-toggle-row">
<div>
<div className="defs-toggle-title" id="defs-label-skip">
Skip on away days
</div>
<div className="defs-toggle-sub">No instance when the child is absent</div>
</div>
<button
type="button"
role="switch"
aria-checked={draft.skipOnAway}
aria-labelledby="defs-label-skip"
className={draft.skipOnAway ? "defs-switch defs-switch--on" : "defs-switch"}
onClick={() => update({ skipOnAway: !draft.skipOnAway })}
>
<span className="defs-switch-knob" />
</button>
</div>
</div>
{error ? (
<p className="defs-sheet-error" role="alert" data-testid="sheet-error">
{error}
</p>
) : null}
{confirming ? (
<div
className="defs-confirm"
role="group"
aria-labelledby="defs-confirm-text"
data-testid="unassign-confirm"
ref={confirmRef}
tabIndex={-1}
>
<p id="defs-confirm-text">
Saving will unassign{" "}
{inactiveAssignees.map((a) => a.display_name).join(", ")}. Inactive children can't be
assigned again until they are reactivated.
</p>
<div className="defs-sheet-footer">
<button type="button" className="defs-cancel" onClick={() => setConfirming(false)}>
Go back
</button>
<button type="button" className="defs-save" onClick={() => save(true)}>
Unassign and save
</button>
</div>
</div>
) : (
<div className="defs-sheet-footer">
<button type="button" className="defs-cancel" onClick={onCancel} disabled={saving}>
Cancel
</button>
<button type="button" className="defs-save" onClick={() => save()} disabled={saving}>
{saving ? "Saving…" : "Save changes"}
</button>
</div>
)}
</div>
</div>
);
}
/* ── Screen ─────────────────────────────────────────────────────────── */
export default function DefinitionsTab() {
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
const [filter, setFilter] = useState<number | "all">("all");
const [sheet, setSheet] = useState<SheetTarget>(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 = (
<div className="defs-status" role="status" data-testid="definitions-loading">
Loading tasks…
</div>
);
} else if (state.kind === "error") {
body = (
<div className="defs-status defs-status--error" role="alert" data-testid="definitions-error">
<p>{state.message}</p>
<button type="button" className="defs-retry" onClick={() => setAttempt((n) => n + 1)}>
Try again
</button>
</div>
);
} 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 = (
<>
<FilterChips options={activeChildren} filter={filter} onChange={setFilter} />
{shown.length > 0 ? (
<ul className="defs-card defs-list" aria-label="Task definitions">
{shown.map((definition) => (
<DefinitionRow
key={definition.id}
definition={definition}
activeChildren={activeChildren}
editing={definition.id === editingId}
onOpen={() => setSheet(definition)}
/>
))}
</ul>
) : (
<div className="defs-card defs-empty">No tasks yet.</div>
)}
</>
);
}
return (
<div className="defs-screen">
<header className="defs-header">
<div>
<h2 className="defs-title">Tasks</h2>
<p className="defs-sub">{sub}</p>
</div>
<button
type="button"
className="defs-new"
disabled={state.kind !== "ready"}
onClick={() => setSheet("new")}
>
<PlusGlyph />
New
</button>
</header>
<div
className="defs-scroll"
data-testid="definitions-scroll"
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
>
{body}
</div>
{sheet !== null && state.kind === "ready" ? (
<EditSheet
key={sheet === "new" ? "new" : sheet.id}
target={sheet}
activeChildren={activeChildren}
onCancel={() => setSheet(null)}
onSaved={() => {
setSheet(null);
setAttempt((n) => n + 1);
}}
/>
) : null}
</div>
);
}
+103
View File
@@ -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 <TaskGlyph size={size} />;
return <entry.Icon size={size} aria-hidden="true" focusable="false" data-icon={entry.name} />;
}
+111
View File
@@ -0,0 +1,111 @@
/**
* List-row summaries for the Definitions tab (ADMIN-SPEC §3.1):
* `<assignees> · <recurrence> · <windows>`.
*/
import type {
AdminChild,
DefinitionAssignee,
DefinitionRule,
DefinitionWindow,
WindowName,
} from "../api/definitions";
export const WINDOW_ORDER: WindowName[] = ["morning", "afternoon", "evening"];
export const WINDOW_LABELS: Record<WindowName, string> = {
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<DefinitionAssignee, "id" | "display_name">[],
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<DefinitionWindow, "window">[]): 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]}`;
}
+67
View File
@@ -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 (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
);
}
export function TaskGlyph({ size = 17 }: { size?: number }) {
return (
<Glyph size={size}>
<rect x="8" y="2" width="8" height="4" rx="1" />
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<path d="m9 14 2 2 4-4" />
</Glyph>
);
}
export function PlusGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="M12 5v14M5 12h14" />
</Glyph>
);
}
export function ChevronRightGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="m9 18 6-6-6-6" />
</Glyph>
);
}
export function ChevronDownGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="m6 9 6 6 6-6" />
</Glyph>
);
}
export function CheckGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="M20 6 9 17l-5-5" />
</Glyph>
);
}
+296
View File
@@ -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;
}
+631
View File
@@ -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>): 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<HistoryFilter, HistoryRow[]> = {
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<typeof vi.fn>;
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(<HistoryTab today={TODAY} />);
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<typeof vi.fn>;
let revokeObjectURL: ReturnType<typeof vi.fn>;
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<string, unknown>;
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<string, string>).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<Response>((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(<HistoryTab today={TODAY} />);
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<typeof vi.fn>;
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(
<TransitionsProvider>
<HistoryTab today={TODAY} />
</TransitionsProvider>,
);
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(
<TransitionsProvider>
<HistoryTab today={TODAY} />
</TransitionsProvider>,
);
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<void>((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(
<TransitionsProvider>
<HistoryTab today={TODAY} />
</TransitionsProvider>,
);
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);
});
});
+300
View File
@@ -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<Loaded> {
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 (
<header className="history-header">
<div>
<h2 className="history-title">History</h2>
<p className="history-sub">Append-only event log · last {RANGE_DAYS} days</p>
</div>
<button
type="button"
className="history-csv"
data-testid="history-csv"
disabled={exporting}
aria-busy={exporting}
onClick={onExport}
>
<DownloadGlyph />
CSV
</button>
</header>
);
}
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 (
<div className="history-stats">
{items.map((item) => (
<div key={item.key} className="history-stat" data-testid={`stat-${item.key}`}>
<div className={`history-stat-value history-stat-value--${item.key}`}>{item.value}</div>
<div className="history-stat-label">{item.label}</div>
</div>
))}
</div>
);
}
function EventRow({ row }: { row: HistoryRow }) {
return (
<li className="history-event" data-testid="history-row">
<span
className={`history-dot history-dot--${row.event_type}`}
data-testid="history-dot"
aria-hidden="true"
/>
<div className="history-event-text">
<div className="history-event-title">
{row.quest_title} · {row.child_name}
</div>
<div className="history-event-meta" data-testid="history-meta">
{rowMeta(row)}
</div>
</div>
<span className="history-event-time">{rowTime(row)}</span>
</li>
);
}
function FooterNote() {
return (
<div className="history-note" data-testid="history-footer">
<span className="history-note-glyph">
<LockGlyph />
</span>
<span>{FOOTER_NOTE}</span>
</div>
);
}
export default function HistoryTab({ today = localTodayIso() }: { today?: string }) {
const [filter, setFilter] = useState<HistoryFilter>("all");
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(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 = (
<div className="history-status" role="status" data-testid="history-loading">
Loading history…
</div>
);
} else if (shown.kind === "error") {
body = (
<div className="history-status history-status--error" role="alert" data-testid="history-error">
<p>{shown.message}</p>
<button type="button" className="history-retry" onClick={() => setAttempt((n) => n + 1)}>
Try again
</button>
</div>
);
} else {
const groups = groupByDay(shown.data.rows);
body =
groups.length > 0 ? (
groups.map((group) => (
<section key={group.day} className="history-day" data-testid={`day-${group.day}`}>
<h3 className="history-section-label">{dayLabel(group.day, today)}</h3>
<ul className="history-card history-list">
{group.rows.map((row, index) => (
<EventRow key={`${row.instance_id}-${row.event_type}-${row.occurred_at ?? ""}-${index}`} row={row} />
))}
</ul>
</section>
))
) : (
<div className="history-card history-empty">No events in the last {RANGE_DAYS} days.</div>
);
}
return (
<div className="history-screen">
<ScreenHeader exporting={exporting} onExport={() => void exportCsv()} />
<div
className="history-scroll"
data-testid="history-scroll"
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
>
{exportError !== null ? (
<div className="history-export-error" role="alert" data-testid="history-export-error">
<span>{exportError}</span>
<button type="button" className="history-export-dismiss" onClick={() => setExportError(null)}>
Dismiss
</button>
</div>
) : null}
{shown.kind === "ready" ? <StatRow stats={shown.data.stats} /> : null}
<div className="history-chips" role="group" aria-label="Filter events">
{FILTERS.map((item) => (
<button
key={item.id}
type="button"
className={item.id === filter ? "history-chip history-chip--active" : "history-chip"}
aria-pressed={item.id === filter}
onClick={() => setFilter(item.id)}
>
{item.label}
</button>
))}
</div>
{body}
<FooterNote />
</div>
</div>
);
}
+94
View File
@@ -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<string, HistoryRow[]>();
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";
}
/** `<event type> · <actor>` 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) : "";
}
+41
View File
@@ -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 (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
);
}
export function DownloadGlyph({ size = 15 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />
</Glyph>
);
}
export function LockGlyph({ size = 15 }: { size?: number }) {
return (
<Glyph size={size}>
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</Glyph>
);
}
+76
View File
@@ -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;
}
+13
View File
@@ -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(
<StrictMode>
<AppRoutes />
</StrictMode>,
);
void registerServiceWorker();
+396
View File
@@ -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('<link rel="manifest" href="/manifest.webmanifest" />');
expect(html).toContain(`<meta name="theme-color" content="${manifest.theme_color}" />`);
expect(html).toContain('<link rel="apple-touch-icon" href="/icons/icon-192.png" />');
});
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<FakeResponse>,
source: string = swSource,
) {
const listeners: Record<string, (event: any) => void> = {};
const stores = new Map<string, Map<string, FakeResponse>>();
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<unknown> | undefined;
const event = {
...init,
waitUntil: (p: Promise<unknown>) => (pending = p),
respondWith: (p: Promise<unknown>) => (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) => `<!doctype html>
<html lang="en">
<head>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<script type="module" crossorigin src="/assets/index-${hash}.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-${hash}.js">
<link rel="stylesheet" crossorigin href="/assets/index-${hash}.css">
<link rel="preconnect" href="https://auth.example.com" />
</head>
<body><div id="root"></div></body>
</html>`;
// 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<typeof vi.fn>) =>
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();
});
});
+13
View File
@@ -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<void> {
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);
}
}
+220
View File
@@ -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;
}
+533
View File
@@ -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<string, unknown>) => Response | Promise<Response>;
let createResponse: (body: Record<string, unknown>) => Response;
let fetchMock: ReturnType<typeof vi.fn>;
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<Response> {
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<Response>((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(<ScheduleTab today={TODAY} />);
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(<ScheduleTab today={TODAY} />);
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(<ScheduleTab today={TODAY} />);
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(<App />);
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);
});
});
+348
View File
@@ -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
* <body>), 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<Consequence>({ kind: "counting" });
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Every form change bumps the sequence; only the latest request may render.
const requestSeq = useRef(0);
const screenRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(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<HTMLDivElement>) {
if (event.key === "Escape") {
event.preventDefault();
if (!saving) onCancel();
return;
}
if (event.key !== "Tab" || !screenRef.current) return;
const focusable = Array.from(screenRef.current.querySelectorAll<HTMLElement>(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 (
<div
ref={screenRef}
className="override-screen"
role="dialog"
aria-modal="true"
aria-labelledby="override-title"
onKeyDown={onKeyDown}
>
<header className="override-header">
<button type="button" className="override-back" aria-label="Back" onClick={onCancel}>
<ChevronLeftGlyph size={20} />
</button>
<div>
<h2 className="override-title" id="override-title" ref={titleRef} tabIndex={-1}>
Presence override
</h2>
<p className="override-sub">Beats the custody pattern for these dates</p>
</div>
</header>
<div className="override-scroll">
<section className="override-card" aria-label="Override details">
<div className="override-field">
<span className="override-label" id="override-label-child">
Child
</span>
<div className="override-segments override-segments--3" role="group" aria-labelledby="override-label-child">
{childOptions.map((c) => (
<button
key={c.id}
type="button"
className="override-segment override-segment--gradient"
aria-pressed={c.id === childId}
onClick={() => setChildId(c.id)}
>
{c.display_name}
</button>
))}
</div>
</div>
<div className="override-field">
<span className="override-label" id="override-label-status">
Status for these dates
</span>
<div className="override-segments override-segments--2" role="group" aria-labelledby="override-label-status">
<button
type="button"
className="override-segment override-segment--ink"
aria-pressed={!isPresent}
onClick={() => setIsPresent(false)}
>
Away
</button>
<button
type="button"
className="override-segment override-segment--ink"
aria-pressed={isPresent}
onClick={() => setIsPresent(true)}
>
Home
</button>
</div>
</div>
<div className="override-dates">
<label className="override-field">
<span className="override-label">From</span>
<input
className="override-input override-date"
type="date"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
/>
</label>
<label className="override-field">
<span className="override-label">Through</span>
<input
className="override-input override-date"
type="date"
value={endDate}
min={isIsoDate(startDate) ? startDate : undefined}
onChange={(event) => setEndDate(event.target.value)}
/>
</label>
</div>
<label className="override-field">
<span className="override-label">Reason (optional)</span>
<input
className="override-input"
type="text"
value={note}
onChange={(event) => setNote(event.target.value)}
/>
</label>
</section>
<section
className="override-warning"
aria-label="Consequence"
aria-live="polite"
data-testid="override-consequence"
data-state={consequence.kind}
>
<span className="override-warning-glyph">
<AlertCircleGlyph />
</span>
<div>
<p className="override-warning-headline" data-testid="override-consequence-headline">
{headline}
</p>
<p className="override-warning-body">{detail}</p>
</div>
</section>
{error ? (
<p className="override-error" role="alert" data-testid="override-error">
{error}
</p>
) : null}
<div className="override-footer">
<button type="button" className="override-cancel" onClick={onCancel} disabled={saving}>
Cancel
</button>
<button type="button" className="override-save" onClick={save} disabled={!canSave}>
{saving ? "Saving…" : "Save override"}
</button>
</div>
</div>
</div>
);
}
+416
View File
@@ -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;
}
+276
View File
@@ -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<typeof vi.fn>;
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<HTMLElement>(`[data-date="${date}"]`);
if (!cell) throw new Error(`no cell for ${date}`);
return cell;
}
async function renderReady() {
render(<ScheduleTab today={TODAY} />);
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<string, string> = {
"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(<ScheduleTab today={TODAY} />);
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();
});
});
+557
View File
@@ -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<DayState, string> = {
home: "Home",
away: "Away",
"override-away": "Override · away",
"override-home": "Override · home",
};
interface Loaded {
children: AdminChild[];
schedules: Record<number, PresenceSchedule | null>;
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<Loaded> {
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 (
<header className="schedule-header">
<h2 className="schedule-title">Schedule</h2>
<p className="schedule-sub">{sub}</p>
</header>
);
}
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 (
<>
<div className="schedule-month-head">
<span className="schedule-month-label" data-testid="month-label">
{monthLabel(month)}
</span>
<div className="schedule-chevrons">
<button
type="button"
className="schedule-chevron"
aria-label="Previous month"
onClick={() => onMonth(-1)}
>
<ChevronLeftGlyph />
</button>
<button
type="button"
className="schedule-chevron"
aria-label="Next month"
onClick={() => onMonth(1)}
>
<ChevronRightGlyph />
</button>
</div>
</div>
<div className="schedule-grid" role="group" aria-label="Month grid">
{WEEKDAY_INITIALS.map((initial, i) => (
<span key={`wd-${i}`} className="schedule-weekday" aria-hidden="true">
{initial}
</span>
))}
{Array.from({ length: leading }, (_, i) => (
<span key={`pad-${i}`} aria-hidden="true" />
))}
{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 (
<button
key={date}
type="button"
className={className}
data-date={date}
data-state={state}
aria-pressed={patternIncludes(schedule, date)}
aria-label={`${shortDate(date)}${isToday ? " (today)" : ""}: ${STATE_LABELS[state]}`}
onClick={() => onToggle(date)}
>
{Number(date.slice(8))}
</button>
);
})}
</div>
<ul className="schedule-legend" aria-label="Legend">
{(["home", "away", "override-away", "override-home"] as DayState[]).map((state) => (
<li key={state} className="schedule-legend-item">
<span className={`schedule-swatch schedule-day--${state}`} aria-hidden="true" />
{STATE_LABELS[state]}
</li>
))}
<li className="schedule-legend-item">
<span className="schedule-swatch schedule-day--today" aria-hidden="true" />
Today
</li>
</ul>
</>
);
}
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 (
<li className="schedule-row" data-testid={`override-${override.id}`}>
<span className="schedule-row-glyph">
{override.is_present ? <CalendarCheckGlyph /> : <CalendarXGlyph />}
</span>
<div className="schedule-row-text">
<div className="schedule-row-title">
{childName} {override.is_present ? "home" : "away"} ·{" "}
{dateRange(override.start_date, override.end_date)}
</div>
<div className="schedule-row-meta">{override.note || "No reason given"}</div>
</div>
{mine ? (
<div className="schedule-confirm" role="group" aria-label="Confirm delete">
<span className="schedule-confirm-text">Delete override?</span>
<button
type="button"
className="schedule-confirm-keep"
onClick={onKeep}
disabled={deletion.kind === "deleting"}
>
Keep
</button>
<button
type="button"
className="schedule-confirm-delete"
onClick={onDelete}
disabled={deletion.kind === "deleting"}
>
{deletion.kind === "deleting" ? "Deleting…" : "Delete"}
</button>
</div>
) : (
<button
type="button"
className="schedule-icon-action"
aria-label="Delete override"
onClick={onAskDelete}
>
<Trash2Glyph />
</button>
)}
</li>
);
}
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<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [month, setMonth] = useState(() => monthStart(today));
const [drafts, setDrafts] = useState<Record<number, PresenceScheduleBody>>({});
const [save, setSave] = useState<SaveState>({ kind: "idle" });
const [editorOpen, setEditorOpen] = useState(false);
const [deletion, setDeletion] = useState<DeleteState>({ kind: "idle" });
const addRef = useRef<HTMLButtonElement>(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 (
<OverrideEditor
childOptions={children}
initialChildId={children.find((c) => 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 = (
<div className="schedule-status" role="status" data-testid="schedule-loading">
Loading schedule…
</div>
);
} else if (state.kind === "error") {
body = (
<div className="schedule-status schedule-status--error" role="alert" data-testid="schedule-error">
<p>{state.message}</p>
<button type="button" className="schedule-retry" onClick={() => setAttempt((n) => n + 1)}>
Try again
</button>
</div>
);
} else if (state.data.children.length === 0) {
body = <div className="schedule-card schedule-empty">No children yet.</div>;
} 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 = (
<>
<div className="schedule-children" role="group" aria-label="Child">
{data.children.map((c) => (
<button
key={c.id}
type="button"
className="schedule-child-chip"
aria-pressed={c.id === child.id}
onClick={() => {
setSelectedId(c.id);
setSave({ kind: "idle" });
}}
>
{c.display_name}
</button>
))}
</div>
<section className="schedule-card schedule-month" aria-label="Month grid card">
<MonthGrid
month={month}
today={today}
schedule={schedule}
effective={effective}
overrides={childOverrides}
onMonth={(delta) => setMonth((m) => monthStart(m, delta))}
onToggle={toggle}
/>
<div className="schedule-controls">
<label className="schedule-field">
<span>Anchor date</span>
<input
type="date"
value={schedule.anchor_date}
onChange={(event) => {
if (event.target.value) edit({ ...schedule, anchor_date: event.target.value });
}}
/>
</label>
<label className="schedule-field">
<span>Cycle length</span>
<select
value={schedule.cycle_length_weeks}
onChange={(event) => {
const weeks = Number(event.target.value);
edit({
...schedule,
cycle_length_weeks: weeks,
pattern: resizePattern(schedule.pattern, weeks),
});
}}
>
{CYCLE_LENGTH_CHOICES.map((weeks) => (
<option key={weeks} value={weeks}>
{weeks} {weeks === 1 ? "week" : "weeks"}
</option>
))}
</select>
</label>
<button
type="button"
className="schedule-save"
disabled={!draft || save.kind === "saving"}
onClick={submit}
>
{save.kind === "saving" ? "Saving…" : "Save pattern"}
</button>
</div>
{save.kind === "error" ? (
<p className="schedule-save-status schedule-save-status--error" role="alert">
{save.message}
</p>
) : save.kind === "saved" ? (
<p className="schedule-save-status" role="status">
Pattern saved.
</p>
) : null}
</section>
<h3 className="schedule-section-label">Presence pattern</h3>
<ul className="schedule-card schedule-list" aria-label="Presence pattern">
{data.children.map((c) => (
<li key={c.id} className="schedule-row" data-testid={`pattern-${c.id}`}>
<div className="schedule-row-text">
<div className="schedule-row-title">{c.display_name}</div>
</div>
<span className="schedule-rule">{presenceRule(data.schedules[c.id] ?? null)}</span>
</li>
))}
</ul>
<div className="schedule-section-head">
<h3 className="schedule-section-label">Overrides</h3>
<button
ref={addRef}
type="button"
className="schedule-text-action"
onClick={() => setEditorOpen(true)}
>
Add
</button>
</div>
{childOverrides.length > 0 ? (
<ul className="schedule-card schedule-list" aria-label="Overrides">
{childOverrides.map((override) => (
<OverrideRow
key={override.id}
override={override}
childName={child.display_name}
deletion={deletion}
onAskDelete={() => setDeletion({ kind: "confirming", id: override.id })}
onKeep={() => setDeletion({ kind: "idle" })}
onDelete={() => confirmDelete(override.id)}
/>
))}
</ul>
) : (
<div className="schedule-card schedule-empty">No overrides for {child.display_name}.</div>
)}
{deletion.kind === "error" ? (
<p className="schedule-save-status schedule-save-status--error" role="alert">
{deletion.message}
</p>
) : null}
</>
);
}
return (
<div className="schedule-screen">
<ScreenHeader sub={sub} />
<div
className="schedule-scroll"
data-testid="schedule-scroll"
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
>
{body}
</div>
</div>
);
}
+115
View File
@@ -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<string, number> = {};
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");
});
});
+142
View File
@@ -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`;
}
+37
View File
@@ -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)}`;
}
+85
View File
@@ -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 (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
);
}
export function ChevronLeftGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="m15 18-6-6 6-6" />
</Glyph>
);
}
export function ChevronRightGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="m9 18 6-6-6-6" />
</Glyph>
);
}
function CalendarFrame() {
return (
<>
<path d="M8 2v4M16 2v4" />
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M3 10h18" />
</>
);
}
export function CalendarXGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<CalendarFrame />
<path d="m14 14-4 4M10 14l4 4" />
</Glyph>
);
}
export function CalendarCheckGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<CalendarFrame />
<path d="m9 16 2 2 4-4" />
</Glyph>
);
}
export function Trash2Glyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6" />
</Glyph>
);
}
export function AlertCircleGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</Glyph>
);
}
+331
View File
@@ -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;
}
+408
View File
@@ -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>): 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<typeof vi.fn>;
async function renderReady() {
fetchMock.mockResolvedValue(jsonResponse(SNAPSHOT));
render(<TodayTab />);
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<Response>(() => {}));
render(<TodayTab />);
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(<TodayTab />);
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(<TodayTab />);
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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}
describe("TodayTab Undo and live updates", () => {
let fetchMock: ReturnType<typeof vi.fn>;
let snapshots: AdminSnapshot[];
let uncomplete: () => Promise<Response>;
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(<TodayTab />);
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<Response>();
uncomplete = () => pending.promise;
render(<TodayTab />);
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(<TodayTab />);
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(<TodayTab />);
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(
<TransitionsProvider>
<TodayTab />
</TransitionsProvider>,
);
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));
});
});
+347
View File
@@ -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 (
<header className="today-header">
<div>
<h2 className="today-title">Today</h2>
<p className="today-sub">{sub}</p>
</div>
{/* Settings screen is a later task; the control is not wired yet. */}
<button type="button" className="today-icon-button" aria-label="Settings">
<SettingsGlyph />
</button>
</header>
);
}
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 (
<div className="today-stats">
{stats.map((stat) => (
<div key={stat.key} className="today-stat" data-testid={`stat-${stat.key}`}>
<div className={`today-stat-value today-stat-value--${stat.key}`}>
{stat.value}
</div>
<div className="today-stat-label">{stat.label}</div>
</div>
))}
</div>
);
}
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 (
<li
className={child.present ? "today-child" : "today-child today-child--away"}
data-testid={`child-${child.child_id}`}
>
<span className="today-avatar" data-testid="child-avatar" aria-hidden="true">
{child.child_name.charAt(0).toUpperCase()}
</span>
<div className="today-child-text">
<div className="today-child-name">{child.child_name}</div>
<div className={metaClass}>{meta}</div>
</div>
<div
className="today-progress"
role="progressbar"
aria-label={`${child.child_name} progress`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
>
<div className="today-progress-fill" style={{ width: `${pct}%` }} />
</div>
</li>
);
}
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 (
<li className="today-event" data-testid={`attention-${instance.id}`}>
<span
className={overdue ? "today-event-glyph today-event-glyph--overdue" : "today-event-glyph"}
>
{overdue ? <AlertCircleGlyph /> : <CheckCircleGlyph />}
</span>
<div className="today-event-text">
<div className="today-event-title">
{instance.title} · {childName}
</div>
{overdue ? (
<div className="today-event-meta today-event-meta--overdue">
{instance.due_time
? `Overdue since ${formatDueTime(instance.due_time)}`
: "Overdue"}
</div>
) : (
// The snapshot carries no completion actor, so "from panel" is not printed.
<div className="today-event-meta">
Completed {formatClockTime(instance.completed_at as string)}
</div>
)}
</div>
{overdue ? (
<button type="button" className="today-action today-action--done">
Mark done
</button>
) : (
<button
type="button"
className="today-action today-action--undo"
disabled={undo.pendingId !== null}
aria-busy={undoing}
onClick={() => undo.onUndo(instance.id)}
>
{undoing ? "Undoing…" : "Undo"}
</button>
)}
</li>
);
}
function PermissionNote() {
return (
<div className="today-note" data-testid="permission-note">
<span className="today-note-glyph">
<InfoGlyph />
</span>
<span>{PERMISSION_NOTE}</span>
</div>
);
}
export default function TodayTab() {
const [state, setState] = useState<LoadState>({ 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<number | null>(null);
const undoingRef = useRef(false);
const [undoError, setUndoError] = useState<string | null>(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 = (
<div className="today-status" role="status" data-testid="today-loading">
Loading today…
</div>
);
} else if (state.kind === "error") {
body = (
<div className="today-status today-status--error" role="alert" data-testid="today-error">
<p>{state.message}</p>
<button type="button" className="today-retry" onClick={() => setAttempt((n) => n + 1)}>
Try again
</button>
</div>
);
} else {
const { snapshot } = state;
sub = `${formatLongDate(snapshot.today_iso)} · cycle day ${snapshot.cycle_day} of ${CYCLE_LENGTH_DAYS}`;
const attention = attentionItems(snapshot);
body = (
<>
<StatRow snapshot={snapshot} />
<ul className="today-card today-list" aria-label="Children">
{snapshot.children.map((child) => (
<ChildRow key={child.child_id} child={child} />
))}
</ul>
<h3 className="today-section-label">Needs attention</h3>
{attention.length > 0 ? (
<ul className="today-card today-list" aria-label="Needs attention">
{attention.map((item) => (
<AttentionRow
key={item.instance.id}
item={item}
undo={{ pendingId: undoingId, onUndo: (id) => void undo(id) }}
/>
))}
</ul>
) : (
<div className="today-card today-empty">Nothing needs attention.</div>
)}
<PermissionNote />
</>
);
}
return (
<div className="today-screen">
<ScreenHeader sub={sub} />
<div
className="today-scroll"
data-testid="today-scroll"
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
>
{undoError !== null ? (
<div className="today-action-error" role="alert" data-testid="today-undo-error">
<span>{undoError}</span>
<button type="button" className="today-action-dismiss" onClick={() => setUndoError(null)}>
Dismiss
</button>
</div>
) : null}
{body}
</div>
</div>
);
}
+50
View File
@@ -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" });
}
+61
View File
@@ -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 (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
);
}
export function SettingsGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1 7 17M17 7l2.1-2.1" />
</Glyph>
);
}
export function InfoGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" />
</Glyph>
);
}
export function AlertCircleGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</Glyph>
);
}
export function CheckCircleGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</Glyph>
);
}
+16
View File
@@ -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"]
}
+93
View File
@@ -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 <script src> or <link href>,
// normalised to an absolute path, in document order and without duplicates.
export function extractShellAssets(indexHtml: string): string[] {
const tags = indexHtml.match(/<(?:script|link)\b[^>]*>/gi) ?? [];
const urls = tags.flatMap((tag) => {
const attr = /\b(?:src|href)\s*=\s*["']([^"']+)["']/i.exec(tag);
if (!attr) return [];
const url = new URL(attr[1], "https://same-origin.invalid/");
return url.origin === "https://same-origin.invalid" ? [url.pathname] : [];
});
return [...new Set(urls)];
}
// Hash of every precached file, sorted by path; each entry is length-prefixed
// so no two different file sets hash the same concatenation.
export function computeRevision(files: PrecacheFile[]): string {
const hash = createHash("sha256");
for (const { path, bytes } of [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
const data = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : bytes;
hash.update(`${path}\0${data.byteLength}\0`).update(data);
}
return hash.digest("hex").slice(0, 12);
}
// Returns swSource with the precache list and the build revision filled in.
// The revision covers index.html (and so the hashed bundles it names), the
// sw.js source and the bytes of every unhashed static file it precaches.
export function injectServiceWorker(
indexHtml: string,
swSource: string,
staticFiles: PrecacheFile[],
): string {
if (!swSource.includes(PRECACHE_PLACEHOLDER) || !swSource.includes(REVISION_MARKER)) {
throw new Error("sw.js is missing the PRECACHE_ASSETS placeholder or revision marker");
}
const assets = extractShellAssets(indexHtml);
const revision = computeRevision([
{ path: "index.html", bytes: indexHtml },
{ path: "sw.js", bytes: swSource },
...staticFiles,
]);
return swSource
.replace(PRECACHE_PLACEHOLDER, `const PRECACHE_ASSETS = ${JSON.stringify(assets)};`)
.replaceAll(REVISION_MARKER, revision);
}
export function serviceWorkerInjectPlugin(): Plugin {
let outDir = "";
return {
name: "nestquest-sw-inject",
apply: "build",
configResolved(config) {
outDir = resolve(config.root, config.build.outDir);
},
writeBundle() {
const swPath = resolve(outDir, "sw.js");
const indexHtml = readFileSync(resolve(outDir, "index.html"), "utf8");
const staticFiles = STATIC_PRECACHE_FILES.map((path) => ({
path,
bytes: readFileSync(resolve(outDir, path)),
}));
writeFileSync(
swPath,
injectServiceWorker(indexHtml, readFileSync(swPath, "utf8"), staticFiles),
);
},
};
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { serviceWorkerInjectPlugin } from "./vite-sw-plugin";
export default defineConfig({
plugins: [react(), serviceWorkerInjectPlugin()],
test: {
environment: "jsdom",
},
});