Compare commits

..
Author SHA1 Message Date
overseerandClaude Opus 5.5 8d74efd495 Hide a previous rule's preview while the edited rule is fetched
Tag each preview result with the rule it answers and render it only while
that rule is still the draft's, so a rule edit clears the old dates or error
immediately. Cover window and assignee edits not requesting, and the 300 ms
debounce boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:25:23 -04:00
overseerandClaude Opus 5.5 a105b2b160 Preview a definition's next occurrences in the edit sheet
The edit sheet now shows the next five dates the backend would materialize
for the draft rule ("Next: Mon 5 Oct · Thu 8 Oct · …"), fetched from
POST /api/v1/admin/quest-definitions/occurrences-preview. Requests are
debounced 300 ms, fire only when the built rule changes, skip incomplete
drafts, drop superseded responses, and a failure shows a non-blocking note.

Task 85b2f166-d9da-45b5-928e-3ff4d2cc6d89

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:19:29 -04:00
overseer d0c236b569 Merge dev into feature/24ce7ebf (Feature 23: admin API completion) 2026-09-23 22:17:08 -04:00
dev_agent b8532bacde Feature 23: Admin API Completion - preview, presence, live (#199-#202) (#203)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 22:16:47 -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
dev_agent 4b39af4b67 Feature 22 follow-up: Editable definition assignees (#196) (#197)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 21:10:39 -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 42ea5710d7 Feature 22: Admin Definitions Read and skip_on_away (#193, #194) (#195)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 20:52:06 -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
58 changed files with 11979 additions and 105 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>
+3024
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "nestquest-admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"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}`);
}
+134
View File
@@ -0,0 +1,134 @@
/* 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-placeholder {
margin: 0;
padding: 13px;
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: 8px;
font-size: 13.5px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.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);
}
+48
View File
@@ -0,0 +1,48 @@
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("tabpanel").textContent).toContain(
"History 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");
});
});
+60
View File
@@ -0,0 +1,60 @@
import { useState } from "react";
import "./App.css";
import DefinitionsTab from "./definitions/DefinitionsTab";
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");
// Built screens own their header (ADMIN-SPEC §1); the generic shell header
// stays for placeholder tabs and is visually hidden otherwise.
const hasScreen = active === "today" || active === "tasks";
return (
<div className="admin-shell">
<header className={hasScreen ? "admin-header visually-hidden" : "admin-header"}>
<h1 className="admin-title">NestQuest Admin</h1>
<p className="admin-subline">Admin console</p>
</header>
<main
className={hasScreen ? "admin-panel admin-panel--screen" : "admin-panel"}
role="tabpanel"
aria-labelledby={`tab-${active}`}
>
{active === "today" ? (
<TodayTab />
) : active === "tasks" ? (
<DefinitionsTab />
) : (
<p className="admin-placeholder">
{TABS.find((tab) => tab.id === active)?.label} placeholder — screens
arrive in later tasks.
</p>
)}
</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>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, 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();
});
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();
}
});
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();
});
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;
}
});
});
+176
View File
@@ -0,0 +1,176 @@
/**
* 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, 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: "app" };
function currentPath(): string {
return window.location.pathname.replace(/\/+$/, "") || "/";
}
async function probeApi(setScreen: (screen: Screen) => void) {
// A cheap authenticated probe so a 403 surfaces as an explicit refusal
// right after login instead of a silent empty shell.
try {
await apiFetch("/api/v1/children", {}, config);
setScreen({ kind: "app" });
} 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;
}
// Network or other failures still allow the shell to render; API screens
// handle their own errors in later tasks.
setScreen({ kind: "app" });
}
}
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 "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;
}
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;
}
+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;
}
+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,
};
}
+561
View File
@@ -0,0 +1,561 @@
/* Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2).
Mobile-first at the 390x844 reference; the column is capped and centred wider. */
.defs-screen {
width: 100%;
max-width: 640px;
margin: 0 auto;
}
/* §3.1 header */
.defs-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--nq-a-surface);
border-bottom: 1px solid var(--nq-a-border);
padding: 14px 16px;
}
.defs-title {
margin: 0;
font-size: var(--nq-a-size-screen);
font-weight: 800;
letter-spacing: -0.02em;
}
.defs-sub {
margin: 2px 0 0;
min-height: 1.4em;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.defs-new {
flex: none;
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 36px;
padding: 0 14px;
border: 0;
border-radius: var(--nq-a-density-radius);
background: var(--nq-brand-gradient);
color: #fff;
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.defs-new:disabled {
opacity: 0.5;
cursor: default;
}
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
.defs-scroll {
display: flex;
flex-direction: column;
gap: var(--nq-a-density-gap);
padding: 14px 16px;
}
/* Filter chips */
.defs-chips {
display: flex;
gap: 6px;
overflow-x: auto;
}
.defs-chip {
flex: none;
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-radius-pill);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.defs-chip--active {
border-color: #18181d;
background: #18181d;
color: #fff;
}
/* Rows */
.defs-card {
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
overflow: hidden;
}
.defs-list {
margin: 0;
padding: 0;
list-style: none;
}
.defs-list > li + li {
border-top: 1px solid var(--nq-a-divider);
}
.defs-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: var(--nq-a-tap-min);
padding: var(--nq-a-density-row-pad);
border: 0;
background: none;
color: var(--nq-a-ink);
font-family: inherit;
text-align: left;
cursor: pointer;
}
.defs-row-glyph {
flex: none;
display: flex;
color: var(--nq-a-ink-secondary);
}
.defs-row-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.defs-row-title {
font-size: var(--nq-a-size-row);
font-weight: 700;
}
.defs-row-meta {
font-size: var(--nq-a-size-meta);
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.defs-row-chevron {
flex: none;
display: flex;
color: var(--nq-a-ink-tertiary);
}
/* An inactive definition renders the whole row tertiary (never hard-deleted). */
.defs-row--inactive,
.defs-row--inactive .defs-row-glyph,
.defs-row--inactive .defs-row-meta {
color: var(--nq-a-ink-tertiary);
}
.defs-row--editing {
background: var(--nq-a-selected);
}
.defs-row--editing .defs-row-glyph {
color: var(--nq-brand-blue);
}
.defs-empty {
padding: var(--nq-a-density-card-pad);
font-size: 13px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
/* Loading / error */
.defs-status {
padding: 24px 13px;
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
text-align: center;
font-size: 13.5px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.defs-status--error {
color: var(--nq-a-danger-strong);
}
.defs-status--error p {
margin: 0 0 10px;
}
.defs-retry {
padding: 8px 16px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
/* §3.2 edit sheet */
.defs-sheet-layer {
position: fixed;
inset: 0;
z-index: 20;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.defs-scrim {
position: absolute;
inset: 0;
background: rgba(9, 9, 11, 0.4);
animation: defs-fade var(--nq-dur-modal) var(--nq-ease-out);
}
.defs-sheet {
position: relative;
width: 100%;
max-width: 640px;
max-height: 92%;
margin: 0 auto;
overflow-y: auto;
padding: 14px 18px 24px;
border-radius: var(--nq-a-radius-sheet);
background: var(--nq-a-surface);
box-shadow: var(--nq-a-sheet-shadow);
animation: defs-rise var(--nq-dur-modal) var(--nq-ease-out);
}
@keyframes defs-fade {
from {
opacity: 0;
}
}
@keyframes defs-rise {
from {
transform: translateY(100%);
}
}
@media (prefers-reduced-motion: reduce) {
.defs-scrim,
.defs-sheet {
animation: none;
}
}
.defs-grabber {
width: 38px;
height: 4px;
margin: 0 auto 12px;
border-radius: var(--nq-a-radius-pill);
background: var(--nq-a-border);
}
.defs-sheet-title {
margin: 0 0 12px;
font-size: 19px;
font-weight: 800;
}
.defs-fields {
display: flex;
flex-direction: column;
gap: 11px;
}
.defs-field {
display: flex;
flex-direction: column;
gap: 5px;
}
.defs-label {
font-size: var(--nq-a-size-label);
font-weight: 700;
text-transform: uppercase;
letter-spacing: var(--nq-a-track-label);
color: var(--nq-a-ink-secondary);
}
.defs-input {
width: 100%;
min-height: var(--nq-a-tap-min);
padding: 0 11px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 14px;
font-weight: 600;
}
.defs-select-row {
display: flex;
align-items: center;
justify-content: space-between;
min-height: var(--nq-a-tap-min);
padding: 0 11px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.defs-picker {
margin: 0;
padding: 0;
list-style: none;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
}
.defs-picker > li + li {
border-top: 1px solid var(--nq-a-divider);
}
.defs-picker-option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: var(--nq-a-tap-min);
padding: 0 11px;
border: 0;
background: none;
color: var(--nq-a-ink);
font-family: inherit;
font-size: 13.5px;
font-weight: 700;
cursor: pointer;
}
.defs-picker-check {
display: flex;
color: var(--nq-brand-blue);
}
.defs-picker-option--locked {
color: var(--nq-a-ink-secondary);
cursor: default;
}
.defs-picker-note {
font-size: 12px;
font-weight: 600;
}
.defs-repeats {
display: flex;
gap: 8px;
}
.defs-interval {
flex: none;
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: var(--nq-a-ink-secondary);
}
.defs-interval .defs-input {
width: 64px;
}
.defs-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.defs-day,
.defs-segment {
min-height: var(--nq-a-tap-min);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.defs-day--on,
.defs-segment--on {
border-color: transparent;
background: var(--nq-brand-gradient);
color: #fff;
}
.defs-segmented {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.defs-due-times {
display: flex;
flex-direction: column;
gap: 6px;
}
.defs-due {
display: grid;
grid-template-columns: 84px 1fr;
align-items: center;
gap: 8px;
font-size: 12.5px;
font-weight: 700;
}
.defs-hint {
margin: 0;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.defs-toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.defs-toggle-title {
font-size: 13.5px;
font-weight: 700;
}
.defs-toggle-sub {
font-size: var(--nq-a-size-meta);
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.defs-switch {
flex: none;
position: relative;
width: 44px;
height: 26px;
padding: 0;
border: 0;
border-radius: var(--nq-a-radius-pill);
background: var(--nq-a-border);
cursor: pointer;
transition: background var(--nq-dur-base) var(--nq-ease-out);
}
.defs-switch--on {
background: var(--nq-brand-gradient);
}
.defs-switch-knob {
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 2px rgba(9, 9, 11, 0.25);
transition: transform var(--nq-dur-base) var(--nq-ease-out);
}
.defs-switch--on .defs-switch-knob {
transform: translateX(18px);
}
.defs-sheet-error {
margin: 12px 0 0;
padding: 10px 12px;
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-danger-bg);
color: var(--nq-a-danger-strong);
font-size: 12.5px;
font-weight: 700;
}
.defs-sheet-footer {
display: flex;
gap: 8px;
margin-top: 16px;
}
.defs-confirm {
margin-top: 12px;
padding: 10px 12px;
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-danger-bg);
color: var(--nq-a-danger-strong);
font-size: 12.5px;
font-weight: 700;
}
.defs-confirm p {
margin: 0;
}
.defs-confirm .defs-sheet-footer {
margin-top: 10px;
}
.defs-cancel,
.defs-save {
padding: 13px 0;
border-radius: var(--nq-a-density-radius);
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.defs-cancel {
flex: 1;
border: 1px solid var(--nq-a-border);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
}
.defs-save {
flex: 2;
border: 0;
background: var(--nq-brand-gradient);
color: #fff;
}
.defs-cancel:disabled,
.defs-save:disabled {
opacity: 0.6;
cursor: default;
}
.defs-preview {
margin-top: 8px;
}
.defs-preview--error {
color: var(--nq-a-ink-secondary);
font-style: italic;
}
@@ -0,0 +1,920 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import DefinitionsTab from "./DefinitionsTab";
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
import { storeTokens } from "../auth/oidc";
function child(id: number, name: string, overrides: Partial<AdminChild> = {}): AdminChild {
return {
id,
display_name: name,
colour: null,
avatar_ref: null,
sort_order: id,
is_active: true,
...overrides,
};
}
const CHILDREN: AdminChild[] = [
child(1, "Declan"),
child(2, "Maeve"),
child(3, "Rory"),
child(4, "Old", { is_active: false }),
];
function rule(overrides: Partial<DefinitionRule> = {}): DefinitionRule {
return {
rule_type: "daily",
interval: 1,
weekday_set: null,
day_of_month: null,
nth_weekday: null,
nth_weekday_weekday: null,
month: null,
start_date: "2026-01-01",
end_date: null,
...overrides,
};
}
const BRUSH: QuestDefinition = {
id: 10,
title: "Brush teeth",
description: null,
icon: "smile",
is_active: true,
skip_on_away: true,
rule: rule(),
assignees: [
{ id: 1, display_name: "Declan" },
{ id: 2, display_name: "Maeve" },
{ id: 3, display_name: "Rory" },
],
windows: [
{ window: "morning", due_time: "07:30" },
{ window: "evening", due_time: null },
],
};
const BINS: QuestDefinition = {
id: 11,
title: "Take out bins",
description: "Green bin",
icon: null,
is_active: true,
skip_on_away: false,
rule: rule({ rule_type: "weekly", interval: 2, weekday_set: [0, 3] }),
assignees: [
{ id: 1, display_name: "Declan" },
{ id: 4, display_name: "Old" },
],
windows: [{ window: "afternoon", due_time: "16:00" }],
};
const PIANO: QuestDefinition = {
id: 12,
title: "Piano practice",
description: null,
icon: null,
is_active: false,
skip_on_away: true,
rule: rule({ rule_type: "monthly_day", day_of_month: 15 }),
assignees: [{ id: 2, display_name: "Maeve" }],
windows: [{ window: "afternoon", due_time: null }],
};
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
interface Call {
method: string;
path: string;
body: unknown;
}
const PREVIEW_PATH = "/api/v1/admin/quest-definitions/occurrences-preview";
/** Answers an occurrence-preview request; the default serves no dates. */
type PreviewHandler = (body: { rule: DefinitionRule; count?: number }) => Promise<Response>;
/**
* A stub of the admin API: serves the list and children, records writes, and
* lets a test queue a response for the next write. Occurrence previews are
* reads (recorded, never counted as writes) answered by `setPreview`.
*/
function fakeApi(initial: QuestDefinition[]) {
let definitions = [...initial];
const calls: Call[] = [];
let nextWrite: Response | null = null;
let preview: PreviewHandler = async () => jsonResponse({ dates: [] });
const fetchMock = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
const path = new URL(String(input), "http://localhost").pathname;
const method = init.method ?? "GET";
const body = typeof init.body === "string" ? JSON.parse(init.body) : null;
calls.push({ method, path, body });
if (method === "POST" && path === PREVIEW_PATH) return preview(body);
if (method === "GET" && path === "/api/v1/admin/quest-definitions") {
return jsonResponse({ definitions });
}
if (method === "GET" && path === "/api/v1/admin/children") {
return jsonResponse({ children: CHILDREN });
}
if (nextWrite) {
const response = nextWrite;
nextWrite = null;
return response;
}
if (method === "POST" && path === "/api/v1/admin/quest-definitions") {
const created: QuestDefinition = {
id: 99,
title: body.title,
description: null,
icon: null,
is_active: true,
skip_on_away: body.skip_on_away,
rule: body.rule,
assignees: CHILDREN.filter((c) => body.assignee_child_ids.includes(c.id)).map((c) => ({
id: c.id,
display_name: c.display_name,
})),
windows: body.windows.map(([window, due_time]: [string, string | null]) => ({
window,
due_time,
})),
};
definitions = [...definitions, created];
return jsonResponse(created, 201);
}
const match = /^\/api\/v1\/admin\/quest-definitions\/(\d+)$/.exec(path);
if (method === "PATCH" && match) {
const id = Number(match[1]);
definitions = definitions.map((d) =>
d.id === id
? {
...d,
title: body.title ?? d.title,
skip_on_away: body.skip_on_away ?? d.skip_on_away,
rule: body.rule ?? d.rule,
// Like the API: a sent list replaces the whole set; an absent one keeps it.
assignees:
"assignee_child_ids" in body
? CHILDREN.filter((c) => body.assignee_child_ids.includes(c.id)).map((c) => ({
id: c.id,
display_name: c.display_name,
}))
: d.assignees,
}
: d,
);
return jsonResponse(definitions.find((d) => d.id === id));
}
return jsonResponse({ detail: "unexpected" }, 500);
});
return {
fetchMock,
calls,
writes: () => calls.filter((c) => c.method !== "GET" && c.path !== PREVIEW_PATH),
previews: () => calls.filter((c) => c.path === PREVIEW_PATH),
setPreview: (handler: PreviewHandler) => {
preview = handler;
},
listFetches: () =>
calls.filter((c) => c.method === "GET" && c.path === "/api/v1/admin/quest-definitions")
.length,
failNextWrite: (response: Response) => {
nextWrite = response;
},
};
}
let api: ReturnType<typeof fakeApi>;
async function renderReady(initial: QuestDefinition[] = [BRUSH, BINS, PIANO]) {
api = fakeApi(initial);
vi.stubGlobal("fetch", api.fetchMock);
render(<DefinitionsTab />);
await screen.findByText("Brush teeth");
}
function meta(row: HTMLElement): string {
return (row.querySelector(".defs-row-meta") as HTMLElement).textContent ?? "";
}
function sheet(): HTMLElement {
return screen.getByRole("dialog");
}
describe("DefinitionsTab", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("header counts definitions and active children", async () => {
await renderReady();
expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy();
expect(screen.getByText("3 definitions · 3 children")).toBeTruthy();
const [url, init] = api.fetchMock.mock.calls[0];
expect(String(url)).toMatch(/\/api\/v1\/admin\/quest-definitions$/);
expect((init as RequestInit & { headers: Record<string, string> }).headers.Authorization).toBe(
"Bearer at-123",
);
});
it("rows show `<assignees> · <recurrence> · <windows>` meta", async () => {
await renderReady();
const brush = screen.getByTestId("definition-10");
expect(within(brush).getByText("Brush teeth")).toBeTruthy();
expect(meta(brush)).toBe("All three · Daily · Morning, Evening");
expect(meta(screen.getByTestId("definition-11"))).toBe(
"Declan, Old · Every 2 weeks on Mon, Thu · Afternoon",
);
expect(brush.className).not.toContain("defs-row--inactive");
});
it("an inactive definition is styled inactive with `· inactive`", async () => {
await renderReady();
const piano = screen.getByTestId("definition-12");
expect(meta(piano)).toBe("Maeve · Monthly on day 15 · Afternoon · inactive");
expect(piano.className).toContain("defs-row--inactive");
});
it("filter chips filter by child", async () => {
await renderReady();
const chips = screen.getByRole("group", { name: "Filter by child" });
expect(
within(chips)
.getAllByRole("button")
.map((b) => b.textContent),
).toEqual(["All", "Declan", "Maeve", "Rory"]);
expect(within(chips).getByRole("button", { name: "All" }).className).toContain(
"defs-chip--active",
);
fireEvent.click(within(chips).getByRole("button", { name: "Maeve" }));
expect(within(chips).getByRole("button", { name: "Maeve" }).getAttribute("aria-pressed")).toBe(
"true",
);
expect(screen.queryByTestId("definition-10")).toBeTruthy();
expect(screen.queryByTestId("definition-11")).toBeNull();
expect(screen.queryByTestId("definition-12")).toBeTruthy();
fireEvent.click(within(chips).getByRole("button", { name: "Rory" }));
expect(screen.queryByTestId("definition-10")).toBeTruthy();
expect(screen.queryByTestId("definition-12")).toBeNull();
fireEvent.click(within(chips).getByRole("button", { name: "All" }));
expect(screen.getAllByRole("listitem")).toHaveLength(3);
});
it("New opens the sheet and saving POSTs the exact body, then re-fetches", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date(2026, 8, 23, 9, 0)); // Wed 23 Sep 2026, local
await renderReady();
expect(api.listFetches()).toBe(1);
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
expect(within(s).getByRole("heading", { name: "New task" })).toBeTruthy();
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: " Feed the cat " },
});
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
expect(within(s).getByRole("button", { name: /Assigned children/ }).textContent).toContain(
"Declan, Rory",
);
fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), {
target: { value: "weekly" },
});
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
target: { value: "2" },
});
// Today (Wednesday) is preselected; add Saturday.
expect(within(s).getByRole("button", { name: "Wednesday" }).getAttribute("aria-pressed")).toBe(
"true",
);
fireEvent.click(within(s).getByRole("button", { name: "Saturday" }));
fireEvent.click(within(s).getByRole("button", { name: "Evening" }));
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
fireEvent.change(within(s).getByLabelText("Morning due time"), {
target: { value: "07:15" },
});
const toggle = within(s).getByRole("switch", { name: "Skip on away days" });
expect(toggle.getAttribute("aria-checked")).toBe("true");
fireEvent.click(toggle);
expect(toggle.getAttribute("aria-checked")).toBe("false");
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toEqual([
{
method: "POST",
path: "/api/v1/admin/quest-definitions",
body: {
title: "Feed the cat",
rule: {
rule_type: "weekly",
interval: 2,
weekday_set: [2, 5],
day_of_month: null,
nth_weekday: null,
nth_weekday_weekday: null,
month: null,
start_date: "2026-09-23",
end_date: null,
},
assignee_child_ids: [1, 3],
windows: [
["morning", "07:15"],
["evening", null],
],
skip_on_away: false,
},
},
]);
const created = await screen.findByTestId("definition-99");
expect(meta(created)).toBe("Declan, Rory · Every 2 weeks on Wed, Sat · Morning, Evening");
expect(api.listFetches()).toBe(2);
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.getByText("4 definitions · 3 children")).toBeTruthy();
});
it("tapping a row opens the sheet in editing state and saving PATCHes", async () => {
await renderReady();
const row = screen.getByTestId("definition-11");
fireEvent.click(row);
// §3.1 editing state on the row.
expect(row.className).toContain("defs-row--editing");
expect(meta(row)).toBe("Editing");
expect(row.getAttribute("aria-expanded")).toBe("true");
const s = sheet();
expect(within(s).getByRole("heading", { name: "Edit task" })).toBeTruthy();
expect((within(s).getByRole("textbox", { name: "Title" }) as HTMLInputElement).value).toBe(
"Take out bins",
);
expect((within(s).getByRole("combobox", { name: "Repeats" }) as HTMLSelectElement).value).toBe(
"weekly",
);
expect((within(s).getByLabelText("Afternoon due time") as HTMLInputElement).value).toBe(
"16:00",
);
// The inactive child "Old" is shown locked, not offered as a checkbox.
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
expect(within(s).queryByRole("checkbox", { name: "Old" })).toBeNull();
const locked = within(s).getByTestId("inactive-assignee-4");
expect(locked.textContent).toContain("Old");
expect(locked.textContent).toContain("Inactive");
expect(locked.getAttribute("aria-disabled")).toBe("true");
fireEvent.click(within(s).getByRole("checkbox", { name: "Maeve" }));
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Take out the bins" },
});
fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), {
target: { value: "monthly" },
});
fireEvent.change(within(s).getByRole("spinbutton", { name: "Day of month" }), {
target: { value: "3" },
});
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
target: { value: "1" },
});
fireEvent.change(within(s).getByLabelText("Afternoon due time"), {
target: { value: "" },
});
fireEvent.click(within(s).getByRole("switch", { name: "Skip on away days" }));
// Changing the selection would drop "Old": confirm before sending.
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
expect(api.writes()).toHaveLength(0);
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Unassign and save" }));
});
expect(api.writes()).toEqual([
{
method: "PATCH",
path: "/api/v1/admin/quest-definitions/11",
body: {
title: "Take out the bins",
rule: {
rule_type: "monthly_day",
interval: 1,
weekday_set: null,
day_of_month: 3,
nth_weekday: null,
nth_weekday_weekday: null,
month: null,
start_date: "2026-01-01",
end_date: null,
},
assignee_child_ids: [1, 2],
windows: [["afternoon", null]],
skip_on_away: true,
},
},
]);
// The icon is never sent, so it is carried through unchanged.
expect(api.writes()[0].body).not.toHaveProperty("icon");
await screen.findByText("Take out the bins");
expect(api.listFetches()).toBe(2);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("editing only the title omits assignee_child_ids and keeps an inactive assignee", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-11"));
const s = sheet();
expect(within(s).getByText(/Also assigned \(inactive\): Old\./)).toBeTruthy();
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Bins out" },
});
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(screen.queryByTestId("unassign-confirm")).toBeNull();
expect(api.writes()).toHaveLength(1);
const body = api.writes()[0].body as Record<string, unknown>;
expect(api.writes()[0].method).toBe("PATCH");
expect(body.title).toBe("Bins out");
expect(body).not.toHaveProperty("assignee_child_ids");
await screen.findByText("Bins out");
expect(meta(screen.getByTestId("definition-11"))).toBe(
"Declan, Old · Every 2 weeks on Mon, Thu · Afternoon",
);
});
it("changing the active selection with an inactive assignee asks first", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-11"));
const s = sheet();
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
const confirm = within(s).getByTestId("unassign-confirm");
expect(confirm.textContent).toContain("Saving will unassign Old.");
expect(document.activeElement).toBe(confirm);
expect(api.writes()).toHaveLength(0);
// Going back sends nothing and restores the normal footer.
fireEvent.click(within(confirm).getByRole("button", { name: "Go back" }));
expect(within(s).queryByTestId("unassign-confirm")).toBeNull();
expect(api.writes()).toHaveLength(0);
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Unassign and save" }));
});
expect(api.writes()).toHaveLength(1);
expect((api.writes()[0].body as { assignee_child_ids: number[] }).assignee_child_ids).toEqual([
1, 3,
]);
});
it("reverting the selection to the original omits assignee_child_ids without asking", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-11"));
const s = sheet();
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(screen.queryByTestId("unassign-confirm")).toBeNull();
expect(api.writes()).toHaveLength(1);
expect(api.writes()[0].body).not.toHaveProperty("assignee_child_ids");
});
it("focus moves into the sheet on open and returns to the row on Escape", async () => {
await renderReady();
const row = screen.getByTestId("definition-10");
row.focus();
fireEvent.click(row);
const s = sheet();
expect(document.activeElement).toBe(within(s).getByRole("heading", { name: "Edit task" }));
fireEvent.keyDown(s, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
expect(document.activeElement).toBe(row);
expect(api.writes()).toHaveLength(0);
});
it("Tab and Shift+Tab wrap within the sheet", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
const s = sheet();
const title = within(s).getByRole("textbox", { name: "Title" });
const save = within(s).getByRole("button", { name: "Save changes" });
save.focus();
fireEvent.keyDown(s, { key: "Tab" });
expect(document.activeElement).toBe(title);
fireEvent.keyDown(s, { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(save);
});
it("Escape does not close the sheet while saving", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
let release: (response: Response) => void = () => {};
api.fetchMock.mockImplementationOnce(
() => new Promise<Response>((resolve) => (release = resolve)),
);
const s = sheet();
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
fireEvent.keyDown(s, { key: "Escape" });
expect(screen.getByRole("dialog")).toBeTruthy();
await act(async () => {
release(jsonResponse(BRUSH));
});
await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("the skip-on-away toggle reflects and persists a false value", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-11"));
const s = sheet();
const toggle = within(s).getByRole("switch", { name: "Skip on away days" });
expect(toggle.getAttribute("aria-checked")).toBe("false");
expect(toggle.className).not.toContain("defs-switch--on");
expect(within(s).getByText("No instance when the child is absent")).toBeTruthy();
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toHaveLength(1);
expect((api.writes()[0].body as { skip_on_away: boolean }).skip_on_away).toBe(false);
});
it("a 422 from save shows the API's detail and keeps the sheet open", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
api.failNextWrite(jsonResponse({ detail: "interval must be >= 1, got 0" }, 422));
await act(async () => {
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
});
expect(screen.getByTestId("sheet-error").textContent).toBe("interval must be >= 1, got 0");
expect(screen.getByRole("dialog")).toBeTruthy();
expect(api.listFetches()).toBe(1);
// Save is usable again after the failure.
expect(
(within(sheet()).getByRole("button", { name: "Save changes" }) as HTMLButtonElement).disabled,
).toBe(false);
});
it("a 403 from save shows the admin-group refusal", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
api.failNextWrite(jsonResponse({ detail: "Forbidden" }, 403));
await act(async () => {
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
});
expect(screen.getByTestId("sheet-error").textContent).toBe(
"Your account is not in the nestquest-admins group.",
);
});
it("guards against a double submit", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
const save = within(sheet()).getByRole("button", { name: "Save changes" });
await act(async () => {
fireEvent.click(save);
fireEvent.click(save);
});
expect(api.writes()).toHaveLength(1);
});
it("an incomplete new task is not submitted", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
expect(screen.getByTestId("sheet-error").textContent).toBe("Give the task a title.");
expect(api.writes()).toHaveLength(0);
});
it("Cancel closes the sheet without writing", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
fireEvent.click(within(sheet()).getByRole("button", { name: "Cancel" }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(meta(screen.getByTestId("definition-10"))).toBe("All three · Daily · Morning, Evening");
expect(api.writes()).toHaveLength(0);
});
it("a 403 on load renders the refusal", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => jsonResponse({ detail: "Forbidden" }, 403)),
);
render(<DefinitionsTab />);
expect((await screen.findByTestId("definitions-error")).textContent).toContain(
"Your account is not in the nestquest-admins group.",
);
});
it("scroll column carries the 92px bottom padding", async () => {
await renderReady();
expect(screen.getByTestId("definitions-scroll").style.paddingBottom).toBe("92px");
});
});
/* ── Occurrence preview ─────────────────────────────────────────────── */
/** Past the sheet's 300 ms preview debounce. */
function settleDebounce(): Promise<void> {
return act(() => new Promise<void>((resolve) => setTimeout(resolve, 400)));
}
function previewText(): string | null {
return screen.queryByTestId("occurrence-preview")?.textContent ?? null;
}
/** BINS' rule (every 2 weeks on Mon, Thu) from 2026-10-05. */
const BINS_DATES = ["2026-10-05", "2026-10-08", "2026-10-19", "2026-10-22", "2026-11-02"];
/** The same rule on Tuesdays only. */
const TUESDAY_DATES = ["2026-10-06", "2026-10-20", "2026-11-03", "2026-11-17", "2026-12-01"];
function deferred() {
let resolve!: (response: Response) => void;
const promise = new Promise<Response>((r) => {
resolve = r;
});
return { promise, resolve };
}
describe("DefinitionsTab occurrence preview", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("previews a weekly rule's next dates in plain language from the API", async () => {
await renderReady();
api.setPreview(async () => jsonResponse({ dates: BINS_DATES }));
fireEvent.click(screen.getByTestId("definition-11"));
await waitFor(() =>
expect(previewText()).toBe(
"Next: Mon 5 Oct · Thu 8 Oct · Mon 19 Oct · Thu 22 Oct · Mon 2 Nov · …",
),
);
expect(api.previews()).toEqual([
{
method: "POST",
path: "/api/v1/admin/quest-definitions/occurrences-preview",
body: { rule: BINS.rule, count: 5 },
},
]);
});
it("re-requests once per rule change, debounced, and not for non-rule edits", async () => {
await renderReady();
api.setPreview(async ({ rule }) =>
jsonResponse({ dates: rule.weekday_set?.includes(0) ? BINS_DATES : TUESDAY_DATES }),
);
fireEvent.click(screen.getByTestId("definition-11"));
const s = sheet();
await waitFor(() => expect(previewText()).toContain("Mon 5 Oct"));
// A title edit leaves the rule alone: no new request.
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Bins" },
});
// Three rule edits in quick succession collapse into one request.
const days = within(s).getByRole("group", { name: "Days" });
fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
fireEvent.click(within(days).getByRole("button", { name: "Thursday" }));
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
await waitFor(() =>
expect(previewText()).toBe(
"Next: Tue 6 Oct · Tue 20 Oct · Tue 3 Nov · Tue 17 Nov · Tue 1 Dec · …",
),
);
await settleDebounce();
expect(api.previews()).toHaveLength(2);
expect(api.previews()[1].body).toEqual({
rule: { ...BINS.rule, weekday_set: [1] },
count: 5,
});
// An interval change is a rule change too.
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
target: { value: "1" },
});
await waitFor(() => expect(api.previews()).toHaveLength(3));
expect((api.previews()[2].body as { rule: DefinitionRule }).rule.interval).toBe(1);
});
it("window and assignee edits never request, and a rule edit waits out the debounce", async () => {
await renderReady();
api.setPreview(async () => jsonResponse({ dates: BINS_DATES }));
fireEvent.click(screen.getByTestId("definition-11"));
const s = sheet();
await waitFor(() => expect(previewText()).toContain("Mon 5 Oct"));
expect(api.previews()).toHaveLength(1);
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
// Windows and assignees are not part of the rule.
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Maeve" }));
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(api.previews()).toHaveLength(1);
expect(previewText()).toContain("Mon 5 Oct");
// A rule edit is not requested until the 300 ms debounce elapses.
const days = within(s).getByRole("group", { name: "Days" });
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
await act(async () => {
vi.advanceTimersByTime(299);
});
expect(api.previews()).toHaveLength(1);
await act(async () => {
vi.advanceTimersByTime(1);
});
expect(api.previews()).toHaveLength(2);
expect((api.previews()[1].body as { rule: DefinitionRule }).rule.weekday_set).toEqual([
0, 1, 3,
]);
});
it("a rule edit hides the previous rule's dates until its own response arrives", async () => {
await renderReady();
const pending: ReturnType<typeof deferred>[] = [];
api.setPreview(() => {
const next = deferred();
pending.push(next);
return next.promise;
});
fireEvent.click(screen.getByTestId("definition-11"));
await waitFor(() => expect(pending).toHaveLength(1));
await act(async () => {
pending[0].resolve(jsonResponse({ dates: BINS_DATES }));
});
await waitFor(() => expect(previewText()).toContain("Mon 5 Oct"));
const days = within(sheet()).getByRole("group", { name: "Days" });
fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
// Within the debounce: the old dates are already gone.
expect(previewText()).toBeNull();
// Request in flight: still nothing shown for the new rule.
await waitFor(() => expect(pending).toHaveLength(2));
expect(previewText()).toBeNull();
await act(async () => {
pending[1].resolve(jsonResponse({ dates: ["2026-10-08", "2026-10-22"] }));
});
await waitFor(() => expect(previewText()).toBe("Next: Thu 8 Oct · Thu 22 Oct"));
});
it("a rule edit hides the previous rule's error until its own response arrives", async () => {
await renderReady();
const pending: ReturnType<typeof deferred>[] = [];
api.setPreview(() => {
const next = deferred();
pending.push(next);
return next.promise;
});
fireEvent.click(screen.getByTestId("definition-11"));
await waitFor(() => expect(pending).toHaveLength(1));
await act(async () => {
pending[0].resolve(jsonResponse({ detail: "rule rejected" }, 422));
});
await waitFor(() => expect(previewText()).toBe("Upcoming dates unavailable: rule rejected"));
const days = within(sheet()).getByRole("group", { name: "Days" });
fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
expect(previewText()).toBeNull();
await waitFor(() => expect(pending).toHaveLength(2));
expect(previewText()).toBeNull();
await act(async () => {
pending[1].resolve(jsonResponse({ dates: ["2026-10-08"] }));
});
await waitFor(() => expect(previewText()).toBe("Next: Thu 8 Oct"));
});
it("a stale response resolving after a newer one does not overwrite it", async () => {
await renderReady();
const pending: ReturnType<typeof deferred>[] = [];
api.setPreview(() => {
const next = deferred();
pending.push(next);
return next.promise;
});
fireEvent.click(screen.getByTestId("definition-11"));
await waitFor(() => expect(pending).toHaveLength(1));
const days = within(sheet()).getByRole("group", { name: "Days" });
fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
fireEvent.click(within(days).getByRole("button", { name: "Thursday" }));
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
await waitFor(() => expect(pending).toHaveLength(2));
await act(async () => {
pending[1].resolve(jsonResponse({ dates: TUESDAY_DATES }));
});
await waitFor(() => expect(previewText()).toContain("Tue 6 Oct"));
await act(async () => {
pending[0].resolve(jsonResponse({ dates: BINS_DATES }));
});
await settleDebounce();
expect(previewText()).toBe(
"Next: Tue 6 Oct · Tue 20 Oct · Tue 3 Nov · Tue 17 Nov · Tue 1 Dec · …",
);
});
it("a failed preview shows a non-blocking message and the sheet still saves", async () => {
await renderReady();
api.setPreview(async () => jsonResponse({ detail: "rule rejected" }, 422));
fireEvent.click(screen.getByTestId("definition-10"));
await waitFor(() => expect(previewText()).toBe("Upcoming dates unavailable: rule rejected"));
expect(screen.queryByTestId("sheet-error")).toBeNull();
await act(async () => {
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toHaveLength(1);
expect(api.writes()[0].method).toBe("PATCH");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("an incomplete draft does not call the preview endpoint", async () => {
await renderReady();
api.setPreview(async () => jsonResponse({ dates: TUESDAY_DATES }));
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), {
target: { value: "weekly" },
});
// Clear the default weekday: a weekly rule with no days is incomplete.
const days = within(s).getByRole("group", { name: "Days" });
for (const day of within(days).getAllByRole("button")) {
if (day.getAttribute("aria-pressed") === "true") fireEvent.click(day);
}
await settleDebounce();
expect(api.previews()).toHaveLength(0);
expect(previewText()).toBeNull();
// An unparseable interval is incomplete too.
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
target: { value: "" },
});
await settleDebounce();
expect(api.previews()).toHaveLength(0);
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
target: { value: "2" },
});
await waitFor(() => expect(previewText()).toContain("Tue 6 Oct"));
expect(api.previews()).toHaveLength(1);
});
});
+935
View File
@@ -0,0 +1,935 @@
/**
* Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2).
*
* The icon picker (§3.3) is a separate task: an existing definition's icon is
* carried through unchanged by never sending it.
*/
import {
useEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react";
import { ApiForbiddenError } from "../api/client";
import {
ApiRequestError,
createDefinition,
fetchChildren,
fetchDefinitions,
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 "./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;
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: "",
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
repeats: "daily",
interval: "1",
weekdays: [(today.getDay() + 6) % 7],
dayOfMonth: String(today.getDate()),
month: String(today.getMonth() + 1),
monthlyWeekday: null,
windows: [],
dueTimes: emptyDueTimes(),
// Mirrors the API's create default: no instance on an away day.
skipOnAway: true,
startDate: localIsoDate(today),
endDate: null,
};
}
function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): Draft {
const { rule } = definition;
const today = new Date();
const active = new Set(activeChildren.map((child) => child.id));
const dueTimes = emptyDueTimes();
for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? "";
return {
title: definition.title,
// Inactive children cannot be submitted (the API rejects them with 422).
assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)),
repeats: REPEATS_BY_RULE[rule.rule_type],
interval: String(rule.interval),
weekdays:
rule.rule_type === "weekly" || rule.rule_type === "custom_days"
? [...(rule.weekday_set ?? [])]
: [(today.getDay() + 6) % 7],
dayOfMonth: String(rule.day_of_month ?? today.getDate()),
month: String(rule.month ?? today.getMonth() + 1),
monthlyWeekday:
rule.rule_type === "monthly_weekday"
? { nth: rule.nth_weekday ?? 1, weekday: rule.nth_weekday_weekday ?? 0 }
: null,
windows: WINDOW_ORDER.filter((w) => definition.windows.some((d) => d.window === w)),
dueTimes,
skipOnAway: definition.skip_on_away,
startDate: rule.start_date,
endDate: rule.end_date,
};
}
function parseWhole(value: string, min: number, max: number): number | null {
if (!/^\d+$/.test(value.trim())) return null;
const n = Number(value);
return n >= min && n <= max ? n : null;
}
/** Build the request rule from the draft, or return a message naming what to fix. */
function buildRule(draft: Draft): DefinitionRule | string {
const interval = parseWhole(draft.interval, 1, 999);
if (interval === null) return "Interval must be a whole number of at least 1.";
const rule: DefinitionRule = {
rule_type: "daily",
interval,
weekday_set: null,
day_of_month: null,
nth_weekday: null,
nth_weekday_weekday: null,
month: null,
start_date: draft.startDate,
end_date: draft.endDate,
};
switch (draft.repeats) {
case "daily":
return rule;
case "weekly":
case "custom":
if (draft.weekdays.length === 0) return "Pick at least one day.";
return {
...rule,
rule_type: draft.repeats === "weekly" ? "weekly" : "custom_days",
weekday_set: [...draft.weekdays].sort((a, b) => a - b),
};
case "monthly": {
if (draft.monthlyWeekday) {
return {
...rule,
rule_type: "monthly_weekday",
nth_weekday: draft.monthlyWeekday.nth,
nth_weekday_weekday: draft.monthlyWeekday.weekday,
};
}
const day = parseWhole(draft.dayOfMonth, 1, 31);
if (day === null) return "Day of month must be 1 to 31.";
return { ...rule, rule_type: "monthly_day", day_of_month: day };
}
case "yearly": {
const month = parseWhole(draft.month, 1, 12);
const day = parseWhole(draft.dayOfMonth, 1, 31);
if (month === null || day === null) return "Pick a month and a day (1 to 31).";
return { ...rule, rule_type: "yearly", month, day_of_month: day };
}
}
}
/**
* `keepsAssignees`: an edit whose active selection is unchanged sends no
* assignee list, so an empty selection is fine when inactive children remain.
*/
function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody | string {
const title = draft.title.trim();
if (!title) return "Give the task a title.";
if (draft.assigneeIds.length === 0 && !keepsAssignees) return "Assign at least one child.";
if (draft.windows.length === 0) return "Pick at least one window.";
const rule = buildRule(draft);
if (typeof rule === "string") return rule;
const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map(
(w) => [w, draft.dueTimes[w] || null],
);
return {
title,
rule,
assignee_child_ids: [...draft.assigneeIds],
windows,
skip_on_away: draft.skipOnAway,
};
}
function saveErrorMessage(error: unknown): string {
if (error instanceof ApiForbiddenError) return error.message;
if (error instanceof ApiRequestError && error.detail) return error.detail;
return "The task could not be saved. Check your connection and try again.";
}
function toggle<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">
<TaskGlyph />
</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="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>
);
}
+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>
);
}
+71
View File
@@ -0,0 +1,71 @@
/* 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-info-bg: #dbeafe;
--nq-a-info-ink: #1e3a8a;
--nq-a-info-icon: #1d4ed8;
--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);
}
}
+300
View File
@@ -0,0 +1,300 @@
/* 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-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;
}
+223
View File
@@ -0,0 +1,223 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import TodayTab, { PERMISSION_NOTE } from "./TodayTab";
import type { AdminSnapshot, SnapshotInstance } from "../api/snapshot";
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");
});
});
+277
View File
@@ -0,0 +1,277 @@
/**
* Today tab — compact density (design/ADMIN-SPEC.md §2.1–2.3, §2.5–2.6).
*/
import { useEffect, useState } from "react";
import {
fetchSnapshot,
type AdminSnapshot,
type SnapshotChild,
type SnapshotInstance,
} from "../api/snapshot";
import { ApiForbiddenError } from "../api/client";
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];
}
function AttentionRow({ item }: { item: AttentionItem }) {
const { instance, childName } = item;
const overdue = isOverdueOpen(instance);
// TODO(e57facc2): wire "Mark done" / "Undo" mutations and live updates.
// The admin plane has no complete route yet, so both actions are 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>
<button
type="button"
className={overdue ? "today-action today-action--done" : "today-action today-action--undo"}
>
{overdue ? "Mark done" : "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);
useEffect(() => {
let cancelled = false;
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.";
setState({ kind: "error", message });
});
return () => {
cancelled = true;
};
}, [attempt]);
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} />
))}
</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 }}
>
{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",
},
});
+370 -17
View File
@@ -30,10 +30,10 @@ hand-rolled here), and the call goes to
``set_quest_definition_active``. The title policy, the window-name
and due-time policy, the assignee checks, the whole-set window
replacement and the never-hard-delete policy (deactivation is the only
removal path) all live in the core. Assignment is NOT settable
through the API: the core's edit path takes no assignees. The edit
route forwards only SUPPLIED fields (``exclude_unset``), like the
children edit.
removal path) all live in the core. The edit route forwards only
SUPPLIED fields (``exclude_unset``), like the children edit; a supplied
``assignee_child_ids`` list REPLACES the whole assignee set inside the
core edit's one transaction (and its one regeneration).
The presence routes follow the same pattern over
:mod:`nestquest_core.presence_management` — the ONE presence write
@@ -58,6 +58,24 @@ layer the HA services share, so the two planes cannot drift:
(children are never hard-deleted; overrides are deletable by
design) and regenerates that override's child. It answers
``{"status": "ok"}``.
- ``POST /presence-overrides/consequence`` previews an override
BEFORE it is saved: ONE ``preview_presence_override_consequence``
call answers ``{removed: N}``, the number of the child's open
upcoming instances the materialization walk would stop generating
(instances with a completion event are never counted, D-005).
Nothing is written; an unknown child is 404, a malformed or inverted
range 422.
- ``GET /children/{child_id}/presence-schedule`` reads the child's
schedule through ONE ``get_presence_schedule`` call, wrapped as
``{"schedule": ...}`` in the PUT response's shape — ``null`` when the
child has none (present every day), never a 404; an unknown child is
404.
- ``GET /presence-overrides`` lists the household's overrides through
ONE ``list_presence_overrides`` call, wrapped as
``{"overrides": [...]}`` in the POST response's shape, ordered by
start date, child id, id; the optional ``child_id``, ``start`` and
``end`` query filters (strict dates, ``end >= start``) are the
core's to validate.
The uncomplete and regenerate routes (task da0226b3) follow the same
pattern over :mod:`nestquest_core.completion` and
@@ -172,6 +190,33 @@ integration's rollover listener shares:
read-only-over-the-domain-tables guarantee all live in the core; the
response reports how many transitions were published.
The occurrence-preview route (task 5f843564) lets the admin PWA's
Definitions edit sheet show the next dates a rule fires on WITHOUT
reimplementing the recurrence rules client-side:
- ``POST /quest-definitions/occurrences-preview`` takes the SAME
``rule`` object create/edit take (:class:`AdminRuleRequest`, built
through the SAME :func:`_rule_from_request` / core ``from_dict``
validation, so a rejected rule is 422 exactly as on create), an
optional ``start_date`` (default: the API host's local ``today``,
ONE :func:`_local_now` clock read) and an optional ``count`` (1..50,
default 10). The dates come from ONE
:func:`nestquest_core.recurrence.occurrences_between` call — the
same engine the materializer walks — so the preview can only agree
with what would be generated. The scan is BOUNDED server-side: the
window is ``[start_date, start_date + 366 days]`` inclusive,
whatever the caller sends, so every rule shape (a yearly rule
included) shows at least its next occurrence, and a caller can never
ask for an unbounded walk. Any strict ``start_date`` is accepted
rather than clamped or range-limited — the fixed window already
bounds the work — EXCEPT one whose window would reach 9999-12-31,
which is 422 (the core's day walk cannot step past the calendar's
last date). ``start_date`` strictness
(``YYYY-MM-DD``) is the core's own date validator; a non-strict or
unparseable value is 422. A ``count`` outside 1..50 is the body
model's 422 before any handler code runs. Nothing is read or
written: the route touches no database.
Error mapping (every children, quest-definition and presence route,
deliberately narrow):
@@ -182,8 +227,9 @@ deliberately narrow):
set_active on an unknown id) is mapped to 404.
- A core ``ValueError`` reporting a NON-EXISTENT DEFINITION (edit or
set_active on an unknown id — the core prefixes that error with
``definition_id:``) is mapped to 404. Create's rejected-assignee
error names a child from the BODY, not a path id, so it maps to 422.
``definition_id:``) is mapped to 404. Create's and edit's
rejected-assignee error names a child from the BODY, not a path id,
so it maps to 422.
- Every OTHER core ``ValueError`` — an empty display name, an explicit
``null`` colour/avatar_ref (clearing is not supported), a reorder
list that is not a complete permutation of the children (missing,
@@ -244,6 +290,16 @@ an omitted field is never passed to
:func:`nestquest_core.children.edit_child` and stays unchanged — while
an explicit ``null`` IS passed and is rejected by the core rather than
silently ignored.
The EVENTS route (``GET /events``) is the admin plane's live
transition stream for the Admin PWA: the SAME four transitions
(quest-completed, quest-uncompleted, quest-missed, child-day-complete)
off the SAME in-process publisher as the panel plane, framed by the
shared :func:`api.sse.transition_event_stream` helper. It inherits the
router's ONE :func:`~api.auth.require_admin` check (the Authorization
header — the PWA reads it with a fetch-based reader because
``EventSource`` cannot set headers), so the panel service token is
refused here exactly as on every other admin route.
"""
from __future__ import annotations
@@ -251,7 +307,14 @@ import datetime
from typing import Annotated, Literal, NoReturn
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, StrictBool, field_validator, model_validator
from fastapi.responses import StreamingResponse
from pydantic import (
BaseModel,
StrictBool,
StrictInt,
field_validator,
model_validator,
)
from api import transitions as api_transitions
from api.auth import require_admin
@@ -273,6 +336,7 @@ from api.nestquest_core import (
core_snapshot,
core_sweep,
)
from api.sse import transition_event_stream
#: All admin-plane routes share this router; the ONE admin dependency
#: (service-token refusal + JWT verification + nestquest-admins group
@@ -445,6 +509,45 @@ class AdminRuleRequest(BaseModel):
end_date: str | None = None
#: Upper bound on ``count`` for the occurrence-preview route.
_PREVIEW_MAX_COUNT = 50
#: Days after ``start_date`` the occurrence-preview window spans
#: (inclusive), so a yearly rule always shows its next occurrence.
_PREVIEW_WINDOW_DAYS = 366
class AdminOccurrencesPreviewRequest(BaseModel):
"""The body of ``POST /api/v1/admin/quest-definitions/occurrences-preview``.
``rule`` is the SAME object create/edit take. ``start_date`` is an
optional strict ``YYYY-MM-DD`` (validated by the core; default the
API host's today). ``count`` is a strict JSON integer in
1..``_PREVIEW_MAX_COUNT`` — anything else is 422 before any handler
code runs.
"""
rule: AdminRuleRequest
start_date: str | None = None
count: StrictInt = 10
@field_validator("count")
@classmethod
def _count_in_range(cls, value: int) -> int:
"""Reject a count outside 1.._PREVIEW_MAX_COUNT."""
if not 1 <= value <= _PREVIEW_MAX_COUNT:
raise ValueError(
f"count must be between 1 and {_PREVIEW_MAX_COUNT}"
)
return value
class AdminOccurrencesPreviewResponse(BaseModel):
"""The next occurrence dates (``YYYY-MM-DD``, ascending)."""
dates: list[str]
class AdminQuestDefinitionCreateRequest(BaseModel):
"""The body of ``POST /api/v1/admin/quest-definitions``.
@@ -456,11 +559,13 @@ class AdminQuestDefinitionCreateRequest(BaseModel):
date the child is absent). ``windows`` entries are a window name (``morning``) or a two-item
``[window, due_time]`` pair whose due time is a strict 24-hour
HH:MM (or ``null`` for none) — both forms the core normalizes.
Assignee ids are strict JSON integers: a boolean, float or numeric
string is 422, never coerced to a child id.
"""
title: str
rule: AdminRuleRequest
assignee_child_ids: list[int]
assignee_child_ids: list[StrictInt]
windows: list[str | tuple[str, str | None]]
description: str | None = None
icon: str | None = None
@@ -476,11 +581,13 @@ class AdminQuestDefinitionEditRequest(BaseModel):
field stays unchanged. An explicit ``null`` ``description``/
``icon`` IS passed and CLEARS the stored value (the core supports
clearing here, unlike the children edit); an explicit ``null``
``rule`` or ``windows`` is passed and rejected by the core. A
supplied ``windows`` list REPLACES the whole window set, and
``assignees`` are deliberately absent — assignment is not settable
through this route. An omitted (or ``null``) ``skip_on_away``
keeps the stored value.
``rule``, ``windows`` or ``assignee_child_ids`` is passed and
rejected by the core. A supplied ``windows`` list REPLACES the
whole window set, and a supplied ``assignee_child_ids`` list
REPLACES the whole assignee set (non-empty, every id an existing
active child — create's contract; a rejection changes nothing). An
omitted (or ``null``) ``skip_on_away`` keeps the stored value.
Assignee ids are strict JSON integers, as on create.
"""
title: str | None = None
@@ -489,6 +596,7 @@ class AdminQuestDefinitionEditRequest(BaseModel):
rule: AdminRuleRequest | None = None
windows: list[str | tuple[str, str | None]] | None = None
skip_on_away: StrictBool | None = None
assignee_child_ids: list[StrictInt] | None = None
class AdminQuestDefinitionActiveRequest(BaseModel):
@@ -561,6 +669,50 @@ class AdminPresenceOverrideResponse(BaseModel):
note: str | None
class AdminPresenceScheduleReadResponse(BaseModel):
"""The ``GET /children/{child_id}/presence-schedule`` payload.
``schedule`` is ``None`` when the child has no schedule — the child
is present every day (Feature 05); absence is state, not an error.
"""
schedule: AdminPresenceScheduleResponse | None
class AdminPresenceOverrideListResponse(BaseModel):
"""The ``GET /presence-overrides`` payload: the matching overrides."""
overrides: list[AdminPresenceOverrideResponse]
class AdminPresenceOverrideConsequenceRequest(BaseModel):
"""The body of ``POST /api/v1/admin/presence-overrides/consequence``.
The create body's shape minus the note (which cannot change what is
generated): the strict dates, their order and the real
``is_present`` bool are enforced by the SAME
:class:`~nestquest_core.presence.PresenceOverride` constructor the
create route's core path builds; this model only guards the SHAPE.
Strict fields: a JSON ``true`` is not child id 1, nor ``1`` a bool.
"""
child_id: StrictInt
start_date: str
end_date: str
is_present: StrictBool
class AdminPresenceOverrideConsequenceResponse(BaseModel):
"""The consequence preview: ``removed`` upcoming instances.
The number of open (no completion event) upcoming quest instances
of the child that saving the override would remove — the "This
removes N upcoming tasks" warning. Always 0 for a present override.
"""
removed: int
class AdminUncompleteResponse(BaseModel):
"""The result of ``POST /api/v1/admin/instances/{id}/uncomplete``.
@@ -926,6 +1078,40 @@ def _raise_sweep_error(error: ValueError) -> NoReturn:
raise HTTPException(status_code=422, detail=str(error)) from error
def _raise_preview_error(error: ValueError) -> NoReturn:
"""Map a core recurrence ``ValueError`` from the preview route to 422.
The preview route has no path ids, so it has no 404 case: a
rejected rule (:class:`~nestquest_core.recurrence.RuleValidationError`)
or a non-strict ``start_date`` is 422 with the core's message as
the detail. Only ``ValueError`` is caught, so an unexpected
failure re-raises rather than becoming a 4xx.
"""
raise HTTPException(status_code=422, detail=str(error)) from error
def _preview_window_end(start_date: str) -> str:
"""Return the preview window's inclusive end for ``start_date``.
``start_date`` + :data:`_PREVIEW_WINDOW_DAYS`. Parsing here only
locates the window; the STRICT ``YYYY-MM-DD`` check stays the
core's — :func:`~nestquest_core.recurrence.occurrences_between`
re-validates the same string and rejects any lenient form this
parse accepted. Raises ``ValueError`` for an unparseable value and
for a start whose window would reach the calendar's last date (the
core's day walk steps one day PAST its end, which overflows there).
"""
start = datetime.date.fromisoformat(start_date)
if datetime.date.max - start <= datetime.timedelta(
days=_PREVIEW_WINDOW_DAYS
):
raise ValueError(
f"start_date {start_date!r} is too close to the end of the "
"calendar for a preview window"
)
return (start + datetime.timedelta(days=_PREVIEW_WINDOW_DAYS)).isoformat()
def _local_now() -> datetime.datetime:
"""Return the API host's local time as ONE timezone-aware clock read.
@@ -1247,6 +1433,42 @@ async def admin_create_quest_definition(
return _quest_definition_response(bundle)
@router.post(
"/quest-definitions/occurrences-preview",
summary="Preview the next occurrence dates of a schedule rule",
response_model=AdminOccurrencesPreviewResponse,
)
async def admin_preview_occurrences(
body: AdminOccurrencesPreviewRequest,
) -> AdminOccurrencesPreviewResponse:
"""Return the first ``count`` dates the supplied rule fires on.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: the ``rule``
becomes ONE :class:`~nestquest_core.recurrence.ScheduleRule`
(:func:`_rule_from_request`, the create/edit path), and the dates
come from ONE :func:`nestquest_core.recurrence.occurrences_between`
call over the bounded window ``[start_date, start_date + 366
days]`` — ``start_date`` defaulting to ONE :func:`_local_now` clock
read — truncated to ``count``. Every core ``ValueError`` is 422
(:func:`_raise_preview_error`). No database access.
"""
start_date = (
body.start_date
if body.start_date is not None
else _local_now().date().isoformat()
)
try:
rule = _rule_from_request(body.rule)
dates = core_recurrence.occurrences_between(
rule, start_date, _preview_window_end(start_date)
)
except ValueError as error:
_raise_preview_error(error)
return AdminOccurrencesPreviewResponse(dates=dates[: body.count])
@router.patch(
"/quest-definitions/{definition_id}",
summary="Edit a quest definition",
@@ -1266,10 +1488,12 @@ async def admin_edit_quest_definition(
field is never passed to
:func:`nestquest_core.quest_definitions.edit_quest_definition` and
stays unchanged; a supplied ``windows`` list replaces the WHOLE
window set in the core, and a supplied ``rule`` is rebuilt into a
ScheduleRule (:func:`_rule_from_request`, validated by the model).
An explicit ``null`` ``rule``/``windows`` reaches the core as None
and is rejected there (422), while an explicit ``null``
window set in the core, a supplied ``assignee_child_ids`` list
replaces the WHOLE assignee set in the same core transaction, and a
supplied ``rule`` is rebuilt into a ScheduleRule
(:func:`_rule_from_request`, validated by the model). An explicit
``null`` ``rule``/``windows``/``assignee_child_ids`` reaches the
core as None and is rejected there (422), while an explicit ``null``
``description``/``icon`` clears the stored value — the core edit
path supports clearing, unlike the children edit. An unknown
definition id is mapped to 404 and any other core ``ValueError``
@@ -1431,6 +1655,114 @@ async def admin_delete_presence_override(
return {"status": "ok"}
@router.post(
"/presence-overrides/consequence",
summary="Count the upcoming instances a presence override would remove",
response_model=AdminPresenceOverrideConsequenceResponse,
)
async def admin_presence_override_consequence(
body: AdminPresenceOverrideConsequenceRequest, request: Request
) -> AdminPresenceOverrideConsequenceResponse:
"""Return how many upcoming instances saving the override removes.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: ONE
:func:`nestquest_core.presence_management.preview_presence_override_consequence`
call, anchored on ONE :func:`_local_now` clock read, which compares
the materialization walk's output for the child with and without
the proposed override and excludes instances that already have a
completion event (D-005). Nothing is written.
Errors are mapped by :func:`_raise_child_error`: an unknown child
is 404; a malformed or inverted range is 422.
"""
state: DatabaseState = request.app.state.db
try:
removed = await (
core_presence_management.preview_presence_override_consequence(
state.database,
body.child_id,
body.start_date,
body.end_date,
body.is_present,
today=_local_now().date(),
)
)
except ValueError as error:
_raise_child_error(error)
return AdminPresenceOverrideConsequenceResponse(removed=removed)
@router.get(
"/children/{child_id}/presence-schedule",
summary="Read a child's repeating presence schedule",
response_model=AdminPresenceScheduleReadResponse,
)
async def admin_get_presence_schedule(
child_id: int, request: Request
) -> AdminPresenceScheduleReadResponse:
"""Return the child's presence schedule, or ``schedule: null``.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: one
:func:`nestquest_core.presence_management.get_presence_schedule`
call; a stored schedule is serialized exactly as the PUT response
is, and a child without one answers ``{"schedule": null}`` (present
every day). Errors are mapped by :func:`_raise_child_error`: an
unknown child is 404.
"""
state: DatabaseState = request.app.state.db
try:
schedule = await core_presence_management.get_presence_schedule(
state.database, child_id
)
except ValueError as error:
_raise_child_error(error)
return AdminPresenceScheduleReadResponse(
schedule=(
None if schedule is None else _presence_schedule_response(schedule)
)
)
@router.get(
"/presence-overrides",
summary="List presence overrides",
response_model=AdminPresenceOverrideListResponse,
)
async def admin_list_presence_overrides(
request: Request,
child_id: int | None = None,
start: str | None = None,
end: str | None = None,
) -> AdminPresenceOverrideListResponse:
"""Return the household's presence overrides, optionally filtered.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: the optional
``child_id``, ``start`` and ``end`` query parameters go straight to
:func:`nestquest_core.presence_management.list_presence_overrides`
— the date policy (strict ``YYYY-MM-DD``, ``end >= start``) and the
overlap rule live in the core — and each record is serialized
exactly as the POST response is, ordered by start date, child id,
id. Errors are mapped by :func:`_raise_child_error`: an unknown
``child_id`` is 404, a rejected date or range is 422.
"""
state: DatabaseState = request.app.state.db
try:
records = await core_presence_management.list_presence_overrides(
state.database, child_id=child_id, start=start, end=end
)
except ValueError as error:
_raise_child_error(error)
return AdminPresenceOverrideListResponse(
overrides=[_presence_override_response(record) for record in records]
)
@router.post(
"/instances/{instance_id}/uncomplete",
summary="Reverse one completion (append a reversal event)",
@@ -1885,4 +2217,25 @@ async def admin_snapshot(request: Request) -> AdminSnapshotResponse:
)
@router.get(
"/events",
summary="Stream admin transition events (SSE)",
)
async def admin_events(request: Request) -> StreamingResponse:
"""Stream the app's transition events to an admin client
(Server-Sent Events).
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own), so an unauthenticated or
panel-token request is refused before any stream is established.
Returns the shared :func:`api.sse.transition_event_stream` response
off the app's single in-process publisher (``app.state.publisher``)
— the same publisher, framing and teardown as the panel stream.
"""
return transition_event_stream(request)
__all__ = ["router"]
+7 -30
View File
@@ -53,9 +53,6 @@ from __future__ import annotations
import datetime
import importlib
import json
from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
@@ -70,6 +67,7 @@ from api.nestquest_core import (
core_snapshot,
)
from api import transitions as api_transitions
from api.sse import transition_event_stream
#: The instance DAO, imported on the app's core copy for the
#: child-mismatch guard on the complete route.
@@ -328,23 +326,6 @@ async def panel_complete_instance(
return {"status": str(result)}
async def _sse_stream(request: Request) -> AsyncIterator[str]:
"""Yield the SSE frames for every published transition event.
One subscriber of the app's single publisher (resolved off
``app.state.publisher``); each event becomes one
``event: <type>\\ndata: <json>\\n\\n`` frame. Breaking out of the
publisher's subscription (on client disconnect) tears the
subscription down cleanly — no queue is leaked.
"""
publisher = request.app.state.publisher
async for event_type, payload in publisher.subscribe():
yield (
f"event: {event_type}\n"
f"data: {json.dumps(payload)}\n\n"
)
@router.get(
"/events",
summary="Stream panel transition events (SSE)",
@@ -357,17 +338,13 @@ async def panel_events(request: Request) -> StreamingResponse:
:func:`~api.dependencies.require_panel_token` dependency — the ONE
token check; this handler performs NO auth of its own).
Returns a ``text/event-stream`` response whose body is produced by
:func:`_sse_stream` off the app's single in-process publisher
(``app.state.publisher``, installed in the lifespan). The stream
ends when the client disconnects, at which point the subscription
is torn down (its queue removed) with nothing leaked.
Returns the shared :func:`api.sse.transition_event_stream` response
off the app's single in-process publisher (``app.state.publisher``,
installed in the lifespan). The stream ends when the client
disconnects, at which point the subscription is torn down (its
queue removed) with nothing leaked.
"""
return StreamingResponse(
_sse_stream(request),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache"},
)
return transition_event_stream(request)
__all__ = ["router"]
+50
View File
@@ -0,0 +1,50 @@
"""The ONE Server-Sent Events response helper for the API service.
Both SSE planes — the panel plane (``/api/v1/panel/events``, panel
service token, LAN-only) and the admin plane (``/api/v1/admin/events``,
Authentik admin JWT) — stream the SAME transitions off the SAME
in-process publisher (``app.state.publisher``, see :mod:`api.events`)
with the SAME framing and teardown, so the stream lives here once and
each plane's route only supplies its own router-level auth.
"""
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from fastapi import Request
from fastapi.responses import StreamingResponse
__all__ = ["transition_event_stream"]
async def _sse_stream(request: Request) -> AsyncIterator[str]:
"""Yield the SSE frames for every published transition event.
One subscriber of the app's single publisher (resolved off
``app.state.publisher``); each event becomes one
``event: <type>\\ndata: <json>\\n\\n`` frame. Breaking out of the
publisher's subscription (on client disconnect) tears the
subscription down cleanly — no queue is leaked.
"""
publisher = request.app.state.publisher
async for event_type, payload in publisher.subscribe():
yield (
f"event: {event_type}\n"
f"data: {json.dumps(payload)}\n\n"
)
def transition_event_stream(request: Request) -> StreamingResponse:
"""A ``text/event-stream`` response over the app's transition events.
Performs NO auth of its own: the calling route's router-level
dependency is the ONE check for its plane. The stream ends when
the client disconnects, at which point the subscription is torn
down (its queue removed) with nothing leaked.
"""
return StreamingResponse(
_sse_stream(request),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache"},
)
@@ -270,6 +270,57 @@ class PresenceOverridesDao:
)
return [_override_from_row(row) for row in rows]
async def list_filtered(
self,
*,
child_id: int | None = None,
start: str | None = None,
end: str | None = None,
) -> list[PresenceOverrideRecord]:
"""Return the household's overrides, optionally narrowed.
Every filter is optional and they combine: ``child_id`` keeps
that child's overrides only (an unknown child raises ValueError
rather than reading as "no overrides"); ``start`` keeps
overrides ending on or after it and ``end`` keeps overrides
starting on or before it, so both together select the overrides
overlapping the closed range ``[start, end]`` (the
:meth:`list_by_child_and_range` boundary rule). Dates are
strict YYYY-MM-DD and ``end`` must not precede ``start``.
Ordered stably by start date, then child id, then id.
"""
if start is not None:
_validate_date(start, "start")
if end is not None:
_validate_date(end, "end")
if start is not None and end is not None and end < start:
raise ValueError(
f"end must be on or after start, got {end!r} < {start!r}"
)
clauses: list[str] = []
params: list[object] = []
if child_id is not None:
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
clauses.append("child_id = ?")
params.append(child_id)
if start is not None:
clauses.append("end_date >= ?")
params.append(start)
if end is not None:
clauses.append("start_date <= ?")
params.append(end)
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
rows = await self._database.fetch_all(
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides{where} "
"ORDER BY start_date, child_id, id",
tuple(params),
)
return [_override_from_row(row) for row in rows]
async def get(self, override_id: int) -> PresenceOverrideRecord | None:
"""Return one override by id, or None when it does not exist."""
row = await self._database.fetch_one(
+47 -5
View File
@@ -796,8 +796,9 @@ class QuestDefinitionsDao:
rule: ScheduleRuleStorage | object = _UNSET,
windows: list[tuple[str, str | None]] | object = _UNSET,
skip_on_away: bool | object = _UNSET,
assignee_child_ids: list[int] | object = _UNSET,
) -> QuestDefinitionSnapshot:
"""Edit a definition's metadata, rule and windows atomically.
"""Edit a definition's metadata, rule, windows and assignees atomically.
Mirrors :meth:`create_with_rule_and_windows`: everything runs
inside ONE transaction under the connection lock, and the
@@ -818,11 +819,15 @@ class QuestDefinitionsDao:
are deleted and the given set re-inserted, so missing windows
are added, changed due times updated and removed windows
dropped.
- ``assignee_child_ids`` REPLACES the whole assignee set: every
id is validated as existing-and-active BEFORE any write (the
same check :meth:`add_assignee` applies), then links for
children no longer listed are removed and links for newly
listed children inserted; unchanged links are left alone.
Assignment is deliberately not settable here (multi-assignee is
managed by :meth:`add_assignee`/:meth:`remove_assignee`), and
activation is untouched. Raises ValueError when the definition
does not exist. Edits change future instances only: no
Activation is untouched. Raises ValueError when the definition
does not exist or an assignee is rejected; either way the
transaction rolls back and nothing is written. Edits change future instances only: no
``quest_instances`` or ``completion_events`` row is touched.
"""
if skip_on_away is not _UNSET:
@@ -834,6 +839,9 @@ class QuestDefinitionsDao:
raise ValueError(
f"quest definition {definition_id} does not exist"
)
if assignee_child_ids is not _UNSET:
for child_id in assignee_child_ids:
await self._validate_assignable_child(child_id)
assignments: list[str] = []
parameters: list[object] = []
for column, value in (
@@ -882,6 +890,10 @@ class QuestDefinitionsDao:
"VALUES (?, ?, ?)",
(definition_id, window, due_time),
)
if assignee_child_ids is not _UNSET:
await self._replace_assignees(
definition_id, assignee_child_ids
)
updated = await self.get(definition_id)
assignees = await self.list_assignees(definition_id)
window_records = await self.list_windows(definition_id)
@@ -897,6 +909,36 @@ class QuestDefinitionsDao:
windows=window_records,
)
async def _replace_assignees(
self, definition_id: int, child_ids: list[int]
) -> None:
"""Make the definition's assignee links exactly ``child_ids``.
Removes the links for children not in ``child_ids`` and inserts
the missing ones. MUST be called inside the connection lock and
an open transaction, after the ids were validated as assignable.
"""
rows = await self._database.fetch_all(
"SELECT child_id FROM quest_definition_assignees "
"WHERE definition_id = ?",
(definition_id,),
)
current = {row[0] for row in rows}
wanted = set(child_ids)
for child_id in sorted(current - wanted):
await self._database.execute(
"DELETE FROM quest_definition_assignees "
"WHERE definition_id = ? AND child_id = ?",
(definition_id, child_id),
)
for child_id in child_ids:
if child_id not in current:
await self._database.execute(
"INSERT INTO quest_definition_assignees "
"(definition_id, child_id) VALUES (?, ?)",
(definition_id, child_id),
)
async def _validate_rule_exists(self, schedule_rule_id: int) -> None:
"""Raise ValueError unless the rule exists.
+184 -35
View File
@@ -64,9 +64,14 @@ the presence DAO (``dao_presence.py``) must stay free of business rules
from __future__ import annotations
import datetime
from typing import NamedTuple
from .const import DEFAULT_HORIZON_DAYS
from .dao_instances import QuestInstancesDao, _resolve_today
from .dao_instances import (
CompletionEventsDao,
QuestInstancesDao,
_resolve_today,
)
from .dao_rules import (
_validate_date,
load_materialization_input,
@@ -126,8 +131,15 @@ def _decode_rule(snapshot) -> ScheduleRule:
def _build_engine(
schedules_records,
overrides_records,
*,
extra_overrides: tuple[PresenceOverride, ...] = (),
) -> PresenceEngine:
"""Convert raw presence records into an immutable PresenceEngine."""
"""Convert raw presence records into an immutable PresenceEngine.
``extra_overrides`` layers already-validated, NOT-yet-stored
overrides on top of the stored ones — the consequence preview's
proposed override (:func:`count_removed_by_override`).
"""
schedules: dict[int, PresenceSchedule] = {}
for child_id, record in schedules_records.items():
schedules[child_id] = PresenceSchedule.decode(
@@ -145,9 +157,75 @@ def _build_engine(
)
for record in records
]
for override in extra_overrides:
overrides.setdefault(override.child_id, []).append(override)
return PresenceEngine(schedules, overrides)
class GeneratedTuple(NamedTuple):
"""One instance the walk WOULD write: its upsert key plus due time."""
definition_id: int
child_id: int
due_date: str
window: str
due_time: str | None
def _generation_preview(
snapshots,
engine: PresenceEngine,
start: datetime.date,
end: datetime.date,
*,
child_filter: set[int] | None = None,
) -> list[GeneratedTuple]:
"""Return the tuples the walk would write over ``[start, end]``.
The pure, read-only half of :func:`materialize`: for each date, each
definition whose decoded rule :func:`~.recurrence.occurs_on` it,
each assignee in ``child_filter`` (every assignee when None) who is
``is_present`` on it — or every assignee when the definition's
``skip_on_away`` is off — and each declared window, one tuple, in
the walk's write order. :func:`materialize` writes exactly these
tuples and :func:`count_removed_by_override` compares two of these
previews, so the write and the consequence preview can never
diverge. No database access.
"""
definitions = [
(snapshot.definition.id, _decode_rule(snapshot),
snapshot.assignees, snapshot.windows,
snapshot.definition.skip_on_away)
for snapshot in snapshots
]
generated: list[GeneratedTuple] = []
cursor = start
while cursor <= end:
iso = cursor.isoformat()
for (
definition_id, rule, assignees, windows, skip_on_away
) in definitions:
if not occurs_on(rule, cursor):
continue
for child in assignees:
if child_filter is not None and child.id not in child_filter:
continue
if skip_on_away and not engine.is_present(child.id, cursor):
continue
for window in windows:
generated.append(
GeneratedTuple(
definition_id,
child.id,
iso,
window.window,
window.due_time,
)
)
cursor += datetime.timedelta(days=1)
return generated
async def materialize(
database: NestQuestDatabase,
start_date: str,
@@ -207,13 +285,6 @@ async def materialize(
engine = _build_engine(schedules_records, overrides_records)
child_filter = None if child_ids is None else set(child_ids)
definitions = [
(snapshot.definition.id, _decode_rule(snapshot),
snapshot.assignees, snapshot.windows,
snapshot.definition.skip_on_away)
for snapshot in snapshots
]
generated_at = _now_stamp()
instances = QuestInstancesDao(database)
@@ -228,32 +299,20 @@ async def materialize(
start = max(start, today_date)
count = 0
cursor = start
while cursor <= end:
iso = cursor.isoformat()
for (
definition_id, rule, assignees, windows, skip_on_away
) in definitions:
if not occurs_on(rule, cursor):
continue
for child in assignees:
if child_filter is not None and child.id not in child_filter:
continue
if skip_on_away and not engine.is_present(child.id, cursor):
continue
for window in windows:
written = await instances.upsert_if_valid(
definition_id,
child.id,
iso,
generated_at,
window=window.window,
due_time=window.due_time,
today=today,
)
if written is not None:
count += 1
cursor += datetime.timedelta(days=1)
for generated in _generation_preview(
snapshots, engine, start, end, child_filter=child_filter
):
written = await instances.upsert_if_valid(
generated.definition_id,
generated.child_id,
generated.due_date,
generated_at,
window=generated.window,
due_time=generated.due_time,
today=today,
)
if written is not None:
count += 1
return count
@@ -379,3 +438,93 @@ async def regenerate_for_child(
return await materialize(
database, start.isoformat(), end_date, child_ids=[child_id], today=today
)
async def count_removed_by_override(
database: NestQuestDatabase,
override: PresenceOverride,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> int:
"""Count the upcoming instances saving ``override`` would remove.
Read-only: nothing is written. The window is the part of the
override's range inside the rolling horizon ``[today, today +
horizon_days]`` that :func:`regenerate_for_child` rebuilds after the
override is saved (outside the range the two presence answers agree,
so nothing there can change). The materialization input is read
ONCE (:func:`~.dao_rules.load_materialization_input`) and
:func:`_generation_preview` runs twice over it for the override's
child — with the stored presence, and with ``override`` layered on
top (:func:`_build_engine`'s ``extra_overrides``). The count is the
tuples the first preview generates and the second does not, MINUS
every (definition, date, window) whose stored instance already has a
completion event (D-005: regeneration never deletes those). A
definition with ``skip_on_away`` off generates regardless of
presence, so it contributes nothing; a present override only ever
ADDS tuples, so it counts 0.
An override overlapping a stored one (which the create path refuses)
resolves through the engine's latest-start-wins rule. ``today`` and
``horizon_days`` resolve exactly as :func:`regenerate_for_child`
resolves them; a malformed horizon raises ValueError.
"""
horizon = _resolve_horizon_days(horizon_days)
today_date = _resolve_today(today)
start = max(today_date, override.start_date)
end = min(
today_date + datetime.timedelta(days=horizon), override.end_date
)
if end < start:
return 0
start_date, end_date = start.isoformat(), end.isoformat()
child_id = override.child_id
snapshots, schedules_records, overrides_records = await (
load_materialization_input(database, start_date, end_date)
)
current = _generation_preview(
snapshots,
_build_engine(schedules_records, overrides_records),
start,
end,
child_filter={child_id},
)
proposed = set(
_generation_preview(
snapshots,
_build_engine(
schedules_records,
overrides_records,
extra_overrides=(override,),
),
start,
end,
child_filter={child_id},
)
)
removed = [generated for generated in current if generated not in proposed]
if not removed:
return 0
stored = await QuestInstancesDao(database).list_by_date_range(
child_id, start_date, end_date
)
touched = {
event.instance_id
for event in await CompletionEventsDao(
database
).list_by_child_and_date_range(child_id, start_date, end_date)
}
completed = {
(instance.definition_id, instance.due_date, instance.window)
for instance in stored
if instance.id in touched
}
return sum(
1
for generated in removed
if (generated.definition_id, generated.due_date, generated.window)
not in completed
)
@@ -22,18 +22,23 @@ the children layer owns that guardrail). Deleting a nonexistent
override raises ValueError prefixed ``override_id:`` so a caller can
distinguish "unknown id" from a rejected argument the same way the
quest-definitions layer prefixes ``definition_id:``.
The admin plane's presence READS go through here too
(:func:`get_presence_schedule`, :func:`list_presence_overrides`), so a
reader never touches the DAO directly either.
"""
from __future__ import annotations
import datetime
from .dao_children import ChildrenDao
from .dao_presence import (
PresenceOverrideRecord,
PresenceOverridesDao,
PresenceSchedulesDao,
)
from .db import NestQuestDatabase
from .materialize import regenerate_for_child
from .materialize import count_removed_by_override, regenerate_for_child
from .presence import PresenceOverride, PresenceSchedule
@@ -130,6 +135,78 @@ async def create_presence_override(
return record
async def preview_presence_override_consequence(
database: NestQuestDatabase,
child_id: int,
start_date: object,
end_date: object,
is_present: bool,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> int:
"""Return how many upcoming instances saving this override removes.
The admin override editor's live consequence warning, computed
BEFORE anything is saved: the arguments build the SAME
:class:`~.presence.PresenceOverride` :func:`create_presence_override`
builds (so a malformed or inverted range, or a non-bool
``is_present``, raises the same ValueError), then
:func:`~.materialize.count_removed_by_override` compares what the
materialization walk generates for the child with and without it.
Nothing is written. An unknown child reads ``child N does not
exist``; an INACTIVE child has no instances to remove (the walk's
write path never materializes one), so it counts 0.
``today``/``horizon_days`` resolve as the post-save regeneration
resolves them.
"""
override = PresenceOverride(child_id, start_date, end_date, is_present)
child = await ChildrenDao(database).get(override.child_id)
if child is None:
raise ValueError(f"child {override.child_id} does not exist")
if not child.is_active:
return 0
return await count_removed_by_override(
database, override, today=today, horizon_days=horizon_days
)
async def get_presence_schedule(
database: NestQuestDatabase, child_id: int
) -> PresenceSchedule | None:
"""Return the child's presence schedule decoded, or None when unset.
None means the child exists and has no schedule row — present
every day (Feature 05). An unknown child raises ValueError
(``child N does not exist``) rather than reading as present.
"""
record = await PresenceSchedulesDao(database).get_by_child(child_id)
if record is None:
return None
return PresenceSchedule.decode(
record.child_id, record.anchor_date, record.pattern
)
async def list_presence_overrides(
database: NestQuestDatabase,
*,
child_id: int | None = None,
start: str | None = None,
end: str | None = None,
) -> list[PresenceOverrideRecord]:
"""Return the household's presence overrides, optionally filtered.
``child_id`` narrows to one child (unknown: ``child N does not
exist``); ``start``/``end`` keep the overrides overlapping that
closed range (strict YYYY-MM-DD, ``end >= start``). Ordered by
start date, then child id, then id.
"""
return await PresenceOverridesDao(database).list_filtered(
child_id=child_id, start=start, end=end
)
async def delete_presence_override(
database: NestQuestDatabase,
override_id: int,
@@ -4,8 +4,9 @@ Sits between the Feature 09 service gate and the typed
:class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the
all-or-nothing create/edit here, and never touch the DAO SQL directly.
:func:`create_quest_definition` is the create path,
:func:`edit_quest_definition` the edit path, :func:`assign_child` and
:func:`unassign_child` the assignment paths, and
:func:`edit_quest_definition` the edit path (which may also replace the
whole assignee set), :func:`assign_child` and
:func:`unassign_child` the single-child assignment paths, and
:func:`set_quest_definition_active` the deactivate/reactivate path.
There is no permission check here (that is Feature 09).
@@ -286,10 +287,11 @@ async def edit_quest_definition(
rule: ScheduleRule | object = _UNSET,
windows: list[str | tuple[str, str | None]] | object = _UNSET,
skip_on_away: bool | None = None,
assignee_child_ids: list[int] | object = _UNSET,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> CreatedQuestDefinition:
"""Edit a quest definition's metadata, rule and windows atomically.
"""Edit a quest definition's metadata, rule, windows and assignees atomically.
Arguments default to the sentinel ``_UNSET`` meaning "leave this
field alone". A provided ``title`` is validated like create's
@@ -302,9 +304,14 @@ async def edit_quest_definition(
a ``const.QUEST_WINDOWS`` name, optionally with a strict HH:MM due
time), matching create's non-empty contract. A provided
``skip_on_away`` (a real bool) replaces the stored flag; ``None``
leaves it unchanged. Assignment is NOT
settable here. Raises ValueError when the definition does not
exist or any argument is rejected.
leaves it unchanged. A provided ``assignee_child_ids`` REPLACES the
whole assignee set under create's contract (a non-empty list of
distinct plain ints, each an existing ACTIVE child — the DAO checks
that inside the edit's transaction): newly listed children are
assigned and unlisted ones unassigned. Raises ValueError when the
definition does not exist or any argument is rejected; a rejection
writes nothing, so the stored assignees (and every other field)
stay unchanged.
Returns the updated definition together with its decoded rule,
assignees and windows — all read back inside the DAO's transaction,
@@ -348,6 +355,10 @@ async def edit_quest_definition(
if windows is not _UNSET:
window_specs = _normalize_windows(windows)
assignee_ids: list[int] | object = _UNSET
if assignee_child_ids is not _UNSET:
assignee_ids = _validate_assignee_ids(assignee_child_ids)
skip_on_away_value: bool | object = _UNSET
if skip_on_away is not None:
skip_on_away_value = _validate_skip_on_away(skip_on_away)
@@ -362,14 +373,17 @@ async def edit_quest_definition(
rule=rule_storage,
windows=window_specs,
skip_on_away=skip_on_away_value,
assignee_child_ids=assignee_ids,
)
except ValueError as error:
# The DAO's missing-definition error names only the id; re-raise
# so the public contract names the field the caller passed (matching
# assign/unassign/deactivate).
# The DAO's missing-definition and assignee errors name only the
# id; re-raise so the public contract names the field the caller
# passed (matching create/assign/unassign/deactivate).
message = str(error)
if message.startswith("quest definition "):
raise ValueError(f"definition_id: {message}") from error
if message.startswith("child "):
raise ValueError(f"assignee_child_ids: {message}") from error
raise
decoded_rule = schedule_rule_from_storage(
+431
View File
@@ -0,0 +1,431 @@
"""Admin-plane occurrence-preview route tests (task 5f843564).
These tests exercise ``POST /api/v1/admin/quest-definitions/occurrences-preview``
against the ASGI app with httpx, reusing the shared local-RSA-key/
stubbed-JWKS harness (:mod:`tests.admin_jwt_harness`) so an admin JWT
genuinely authenticates with NO network access.
- Authentication runs through the router's ONE ``require_admin``
dependency: no credential is 401, the panel service token is 403
(refused as a credential type), a JWT without the admin group is
403, an admin JWT is 200.
- The returned dates are asserted EXACTLY for a weekly rule with an
interval and a start_date, a daily rule with an interval, a rule
with an end_date (the list stops at it), a month-end-clamped monthly
rule, and the default start (the pinned host clock's today); one
case also asserts parity with the core's own ``occurrences_between``
over the documented window.
- A rejected rule (the same core ``from_dict`` validation create/edit
use), a non-strict ``start_date`` and a ``count`` outside 1..50 are
422.
"""
from __future__ import annotations
import datetime
import importlib
from types import SimpleNamespace
from zoneinfo import ZoneInfo
import pytest
from api import routes_admin
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
AdminRunner,
jwks_for,
make_token,
)
# The recurrence engine through the SAME core copy the app uses
# (nestquest_core.*, NOT the conftest-stubbed custom_components copy).
_recurrence = importlib.import_module("nestquest_core.recurrence")
PREVIEW_URL = "/api/v1/admin/quest-definitions/occurrences-preview"
#: The panel service token the harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
#: The 403 detail for the panel service token on an admin route.
PANEL_TOKEN_REJECTION_DETAIL = (
"This credential type is not accepted for admin access"
)
#: The 403 detail for a valid JWT without the admin group.
FORBIDDEN_DETAIL = (
"Admin access requires membership in the nestquest-admins group"
)
#: The pinned host instant: Wednesday 2026-09-23, noon UTC.
PINNED_NOW = datetime.datetime(2026, 9, 23, 12, 0, 0, tzinfo=ZoneInfo("UTC"))
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
def _weekly_rule() -> dict[str, object]:
"""Every OTHER week's Tuesday and Friday, anchored on Tue 2026-03-03."""
return {
"rule_type": "weekly",
"interval": 2,
"weekday_set": [1, 4],
"start_date": "2026-03-03",
}
@pytest.fixture
async def preview(temp_db_path: str, monkeypatch) -> SimpleNamespace:
"""An admin-plane app client with the host clock pinned (and counted)."""
clock_reads: list[datetime.datetime] = []
def _admin_now() -> datetime.datetime:
clock_reads.append(PINNED_NOW)
return PINNED_NOW
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(client=client, clock_reads=clock_reads)
async def _post(client: object, body: dict[str, object]) -> object:
"""POST a preview body with an admin JWT."""
return await client.post(PREVIEW_URL, json=body, headers=_admin_headers())
# --- authentication: the ONE require_admin dependency ----------------------
async def test_preview_without_credential_is_401(
preview: SimpleNamespace,
) -> None:
"""No Authorization header is the uniform 401."""
response = await preview.client.post(
PREVIEW_URL, json={"rule": _weekly_rule()}
)
assert response.status_code == 401
async def test_preview_refuses_panel_service_token(
preview: SimpleNamespace,
) -> None:
"""The panel service token is refused as a credential type (403)."""
response = await preview.client.post(
PREVIEW_URL,
json={"rule": _weekly_rule()},
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
)
assert response.status_code == 403
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
async def test_preview_refuses_non_admin_jwt(
preview: SimpleNamespace,
) -> None:
"""A valid JWT without the nestquest-admins group is 403."""
non_admin = make_token(groups=("some-other-group",))
response = await preview.client.post(
PREVIEW_URL,
json={"rule": _weekly_rule()},
headers={"Authorization": f"Bearer {non_admin}"},
)
assert response.status_code == 403
assert response.json()["detail"] == FORBIDDEN_DETAIL
async def test_preview_admin_jwt_is_200(preview: SimpleNamespace) -> None:
"""An admin JWT is 200 with the documented ``dates`` shape."""
response = await _post(
preview.client, {"rule": _weekly_rule(), "start_date": "2026-03-01"}
)
assert response.status_code == 200
assert set(response.json()) == {"dates"}
# --- the returned dates ----------------------------------------------------
async def test_weekly_interval_rule_from_before_its_start(
preview: SimpleNamespace,
) -> None:
"""Every other week's Tue/Fri, starting at the rule's own start_date.
The preview starts on Sun 2026-03-01, before the rule's anchor, so
the first date is the anchor itself; the off weeks (Mar 10-16,
Mar 24-30) are skipped.
"""
response = await _post(
preview.client,
{"rule": _weekly_rule(), "start_date": "2026-03-01", "count": 6},
)
assert response.status_code == 200
assert response.json()["dates"] == [
"2026-03-03",
"2026-03-06",
"2026-03-17",
"2026-03-20",
"2026-03-31",
"2026-04-03",
]
async def test_weekly_interval_rule_from_mid_cadence(
preview: SimpleNamespace,
) -> None:
"""A start inside an on-week keeps the rule's anchor cadence."""
response = await _post(
preview.client,
{"rule": _weekly_rule(), "start_date": "2026-03-18", "count": 4},
)
assert response.status_code == 200
assert response.json()["dates"] == [
"2026-03-20",
"2026-03-31",
"2026-04-03",
"2026-04-14",
]
async def test_daily_interval_rule(preview: SimpleNamespace) -> None:
"""Every third day from 2026-01-01, previewed from 2026-01-05."""
response = await _post(
preview.client,
{
"rule": {
"rule_type": "daily",
"interval": 3,
"start_date": "2026-01-01",
},
"start_date": "2026-01-05",
"count": 4,
},
)
assert response.status_code == 200
assert response.json()["dates"] == [
"2026-01-07",
"2026-01-10",
"2026-01-13",
"2026-01-16",
]
async def test_rule_with_end_date_stops_at_it(
preview: SimpleNamespace,
) -> None:
"""A daily rule ending 2026-05-04 yields only its four dates."""
response = await _post(
preview.client,
{
"rule": {
"rule_type": "daily",
"start_date": "2026-05-01",
"end_date": "2026-05-04",
},
"start_date": "2026-04-28",
"count": 10,
},
)
assert response.status_code == 200
assert response.json()["dates"] == [
"2026-05-01",
"2026-05-02",
"2026-05-03",
"2026-05-04",
]
async def test_monthly_day_rule_clamps_to_month_end(
preview: SimpleNamespace,
) -> None:
"""The core's month-end policy: the 31st fires on Feb 28th."""
response = await _post(
preview.client,
{
"rule": {
"rule_type": "monthly_day",
"day_of_month": 31,
"start_date": "2026-01-31",
},
"start_date": "2026-01-01",
"count": 3,
},
)
assert response.status_code == 200
assert response.json()["dates"] == [
"2026-01-31",
"2026-02-28",
"2026-03-31",
]
async def test_default_start_is_host_today_with_default_count(
preview: SimpleNamespace,
) -> None:
"""No start_date: ONE clock read's date; no count: ten dates."""
response = await _post(preview.client, {"rule": {"rule_type": "daily"}})
assert response.status_code == 200
expected = [
(datetime.date(2026, 9, 23) + datetime.timedelta(days=n)).isoformat()
for n in range(10)
]
assert response.json()["dates"] == expected
assert preview.clock_reads == [PINNED_NOW]
async def test_supplied_start_date_reads_no_clock(
preview: SimpleNamespace,
) -> None:
"""A supplied start_date is used as-is: the host clock is not read."""
response = await _post(
preview.client, {"rule": _weekly_rule(), "start_date": "2026-03-01"}
)
assert response.status_code == 200
assert preview.clock_reads == []
async def test_dates_match_core_occurrences_between_over_window(
preview: SimpleNamespace,
) -> None:
"""The route's dates ARE the core's walk over the 366-day window."""
rule_body = {
"rule_type": "monthly_weekday",
"nth_weekday": -1,
"nth_weekday_weekday": 4,
"start_date": "2026-01-01",
}
response = await _post(
preview.client,
{"rule": rule_body, "start_date": "2026-02-10", "count": 50},
)
assert response.status_code == 200
core_dates = _recurrence.occurrences_between(
_recurrence.ScheduleRule.from_dict(rule_body),
"2026-02-10",
"2027-02-11",
)
assert response.json()["dates"] == core_dates
assert len(core_dates) == 12
async def test_yearly_rule_shows_next_occurrence_within_window(
preview: SimpleNamespace,
) -> None:
"""The 366-day window always reaches a yearly rule's next date."""
response = await _post(
preview.client,
{
"rule": {
"rule_type": "yearly",
"month": 9,
"day_of_month": 22,
"start_date": "2020-09-22",
},
"start_date": "2026-09-23",
},
)
assert response.status_code == 200
assert response.json()["dates"] == ["2027-09-22"]
async def test_start_near_calendar_end_is_422(
preview: SimpleNamespace,
) -> None:
"""A window that would reach 9999-12-31 is rejected, not clamped."""
response = await _post(
preview.client,
{"rule": {"rule_type": "daily"}, "start_date": "9999-12-30"},
)
assert response.status_code == 422
assert "too close to the end of the calendar" in response.json()["detail"]
async def test_latest_accepted_start_date(preview: SimpleNamespace) -> None:
"""The last start whose window ends before 9999-12-31 is accepted."""
response = await _post(
preview.client,
{
"rule": {"rule_type": "daily"},
"start_date": "9998-12-29",
"count": 1,
},
)
assert response.status_code == 200
assert response.json()["dates"] == ["9998-12-29"]
# --- rejections: 422 -------------------------------------------------------
@pytest.mark.parametrize(
"rule",
[
{"rule_type": "weekly"}, # weekly without weekday_set
{"rule_type": "fortnightly"}, # unknown rule_type
{"rule_type": "daily", "interval": 0},
{"rule_type": "daily", "weekday_set": [1]}, # forbidden field
{"rule_type": "daily", "start_date": "2026-3-1"},
{
"rule_type": "daily",
"start_date": "2026-05-04",
"end_date": "2026-05-01",
},
],
)
async def test_malformed_rule_is_422(
preview: SimpleNamespace, rule: dict[str, object]
) -> None:
"""A rule the core's from_dict rejects is 422, as on create/edit."""
response = await _post(
preview.client, {"rule": rule, "start_date": "2026-03-01"}
)
assert response.status_code == 422
async def test_missing_rule_is_422(preview: SimpleNamespace) -> None:
"""The rule is required: the body model's 422."""
response = await _post(preview.client, {"start_date": "2026-03-01"})
assert response.status_code == 422
@pytest.mark.parametrize(
"start_date", ["2026-3-1", "20260301", "tomorrow", "2026-02-30", ""]
)
async def test_non_strict_start_date_is_422(
preview: SimpleNamespace, start_date: str
) -> None:
"""start_date must be a strict YYYY-MM-DD date (the core's check)."""
response = await _post(
preview.client, {"rule": _weekly_rule(), "start_date": start_date}
)
assert response.status_code == 422
@pytest.mark.parametrize("count", [0, -1, 51, 1000, True, "5", 2.5])
async def test_count_out_of_range_is_422(
preview: SimpleNamespace, count: object
) -> None:
"""count must be a strict JSON integer in 1..50."""
response = await _post(
preview.client,
{"rule": _weekly_rule(), "start_date": "2026-03-01", "count": count},
)
assert response.status_code == 422
@pytest.mark.parametrize("count", [1, 50])
async def test_count_bounds_are_inclusive(
preview: SimpleNamespace, count: int
) -> None:
"""count 1 and 50 are accepted and truncate the list exactly."""
response = await _post(
preview.client,
{
"rule": {"rule_type": "daily"},
"start_date": "2026-01-01",
"count": count,
},
)
assert response.status_code == 200
assert len(response.json()["dates"]) == count
@@ -0,0 +1,448 @@
"""Admin override consequence-count route tests (task 7616e3eb).
``POST /api/v1/admin/presence-overrides/consequence`` answers, BEFORE an
override is saved, how many of the child's upcoming open quest
instances saving it would remove (``{"removed": N}``) — the ADMIN-SPEC
§6 "This removes N upcoming tasks" warning. These tests exercise it
against the ASGI app with the shared stubbed-JWKS harness
(:mod:`tests.admin_jwt_harness`):
- a two-week custody pattern (present the whole first week of the
cycle, absent the whole second) where an absent override over a
known span removes an EXACT number of instances;
- ranges that remove nothing (already-absent days, a present
override) count 0;
- an instance with a completion event inside the range is NOT counted
(D-005 — regeneration never deletes it);
- a ``skip_on_away = false`` definition contributes nothing;
- the count MATCHES a direct materialise comparison: the override is
then actually created (which regenerates the child) and the removed
open future instances are counted from the database;
- the preview writes nothing;
- the four auth outcomes, an unknown child 404 and a malformed range
422.
The cycle is anchored on the REAL host "today" (the create route's
regeneration reads the host clock, so the comparison test cannot pin
it): days today..today+6 are cycle week 0 (present every day), days
today+7..today+13 week 1 (absent every day), today+14 week 0 again.
"""
from __future__ import annotations
import datetime
import importlib
from types import SimpleNamespace
import pytest
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
AdminRunner,
jwks_for,
make_token,
)
# The DAOs and materializer through the SAME core copy the app uses
# (nestquest_core.*, NOT the conftest-stubbed custom_components copy).
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
_dao_presence = importlib.import_module("nestquest_core.dao_presence")
_materialize = importlib.import_module("nestquest_core.materialize")
CONSEQUENCE_URL = "/api/v1/admin/presence-overrides/consequence"
OVERRIDES_URL = "/api/v1/admin/presence-overrides"
#: The panel service token the harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
#: The 403 detail for the panel service token on an admin route.
PANEL_TOKEN_REJECTION_DETAIL = (
"This credential type is not accepted for admin access"
)
#: The 403 detail for a valid JWT without the admin group.
FORBIDDEN_DETAIL = (
"Admin access requires membership in the nestquest-admins group"
)
#: The two-week custody pattern: week 0 every weekday, week 1 none.
CUSTODY_PATTERN = {"0": [0, 1, 2, 3, 4, 5, 6], "1": []}
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
def _day(offset: int) -> str:
"""The ISO date ``offset`` days after the host's today."""
return (datetime.date.today() + datetime.timedelta(days=offset)).isoformat()
def _body(
child_id: int, start: int, end: int, *, is_present: bool = False
) -> dict[str, object]:
"""A consequence body over ``[today+start, today+end]``."""
return {
"child_id": child_id,
"start_date": _day(start),
"end_date": _day(end),
"is_present": is_present,
}
async def _create_child(client: object, name: str = "Ada") -> int:
created = await client.post(
"/api/v1/admin/children",
json={"display_name": name},
headers=_admin_headers(),
)
assert created.status_code == 201
return created.json()["id"]
async def _create_daily_definition(
client: object,
child_id: int,
title: str,
windows: list[str],
*,
skip_on_away: bool = True,
) -> int:
created = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": title,
"rule": {"rule_type": "daily"},
"assignee_child_ids": [child_id],
"windows": windows,
"skip_on_away": skip_on_away,
},
headers=_admin_headers(),
)
assert created.status_code == 201
return created.json()["id"]
async def _set_custody_schedule(client: object, child_id: int) -> None:
"""Anchor the two-week custody cycle on today (week 0 starts today)."""
response = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json={
"cycle_length_weeks": 2,
"anchor_date": _day(0),
"pattern": CUSTODY_PATTERN,
},
headers=_admin_headers(),
)
assert response.status_code == 200
async def _materialize_horizon(database: object) -> None:
"""Materialize the household horizon so the stored state is current."""
today = datetime.date.today()
await _materialize.materialize(
database, _day(0), _day(14), today=today
)
async def _open_future_keys(database: object, child_id: int) -> set[tuple]:
"""The child's stored open (no completion event) future instance keys."""
instances = await _dao_instances.QuestInstancesDao(
database
).list_by_date_range(child_id, _day(0), _day(60))
events = _dao_instances.CompletionEventsDao(database)
keys = set()
for instance in instances:
if not await events.list_by_instance(instance.id):
keys.add(
(instance.definition_id, instance.due_date, instance.window)
)
return keys
async def _complete(
database: object, child_id: int, definition_id: int, due_date: str,
window: str,
) -> None:
"""Append a completion event to the stored instance."""
instance = await _dao_instances.QuestInstancesDao(database).get(
definition_id, child_id, due_date, window
)
assert instance is not None
await _dao_instances.CompletionEventsDao(database).append(
instance.id,
child_id,
"completed",
"user",
datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
),
True,
actor_user_id="admin-user-1",
)
async def _consequence(client: object, body: dict[str, object]) -> int:
response = await client.post(
CONSEQUENCE_URL, json=body, headers=_admin_headers()
)
assert response.status_code == 200, response.text
return response.json()["removed"]
@pytest.fixture
async def custody(temp_db_path: str) -> SimpleNamespace:
"""A child on the two-week custody cycle with a two-window chore.
Also assigns a ``skip_on_away = false`` daily chore (generated
regardless of presence) and materializes the horizon, so the stored
instances are the walk's current output.
"""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
database = runner.app.state.db.database
child_id = await _create_child(client)
chore_id = await _create_daily_definition(
client, child_id, "Brush teeth", ["morning", "evening"]
)
always_id = await _create_daily_definition(
client, child_id, "Feed the fish", ["afternoon"],
skip_on_away=False,
)
await _set_custody_schedule(client, child_id)
await _materialize_horizon(database)
yield SimpleNamespace(
client=client,
database=database,
child_id=child_id,
chore_id=chore_id,
always_id=always_id,
)
# --- the count ---------------------------------------------------------------
async def test_custody_override_removes_a_known_number(
custody: SimpleNamespace,
) -> None:
"""Absent over today+2..today+8 removes days +2..+6 x two windows.
Days +7 and +8 are already absent (cycle week 1), and the
skip_on_away=false chore is generated regardless of presence, so
exactly 5 present days x 2 windows = 10 instances would go.
"""
removed = await _consequence(
custody.client, _body(custody.child_id, 2, 8)
)
assert removed == 10
async def test_already_absent_range_removes_nothing(
custody: SimpleNamespace,
) -> None:
"""An absent override over cycle week 1 changes nothing: 0."""
assert await _consequence(
custody.client, _body(custody.child_id, 7, 13)
) == 0
async def test_present_override_removes_nothing(
custody: SimpleNamespace,
) -> None:
"""A present override only ever adds instances: 0."""
assert await _consequence(
custody.client, _body(custody.child_id, 2, 8, is_present=True)
) == 0
async def test_past_range_removes_nothing(custody: SimpleNamespace) -> None:
"""A range entirely before today touches no upcoming instance: 0."""
assert await _consequence(
custody.client, _body(custody.child_id, -20, -1)
) == 0
async def test_completed_instance_in_range_is_not_counted(
custody: SimpleNamespace,
) -> None:
"""D-005: an instance with a completion event is never removed."""
await _complete(
custody.database, custody.child_id, custody.chore_id, _day(3),
"morning",
)
removed = await _consequence(
custody.client, _body(custody.child_id, 2, 8)
)
assert removed == 9
async def test_skip_on_away_false_definition_contributes_nothing(
temp_db_path: str,
) -> None:
"""A child whose only chore ignores presence: absent removes 0."""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
database = runner.app.state.db.database
child_id = await _create_child(client)
await _create_daily_definition(
client, child_id, "Feed the fish", ["morning"],
skip_on_away=False,
)
await _set_custody_schedule(client, child_id)
await _materialize_horizon(database)
assert await _consequence(client, _body(child_id, 0, 14)) == 0
async def test_count_matches_a_direct_materialise_comparison(
custody: SimpleNamespace,
) -> None:
"""Saving the override removes EXACTLY the previewed instances.
With a completed instance inside the range, the preview is taken,
then the override is really created (its core path regenerates the
child) and the open future instances that disappeared are counted
from the database. The completed instance survives the regeneration.
"""
client, database = custody.client, custody.database
await _complete(
database, custody.child_id, custody.chore_id, _day(4), "evening"
)
body = _body(custody.child_id, 1, 9)
removed = await _consequence(client, body)
before = await _open_future_keys(database, custody.child_id)
created = await client.post(
OVERRIDES_URL, json=body, headers=_admin_headers()
)
assert created.status_code == 201
after = await _open_future_keys(database, custody.child_id)
assert after <= before
assert len(before - after) == removed
# Days +1..+6 present x two windows, minus the completed one.
assert removed == 11
completed = await _dao_instances.QuestInstancesDao(database).get(
custody.chore_id, custody.child_id, _day(4), "evening"
)
assert completed is not None
async def test_preview_writes_nothing(custody: SimpleNamespace) -> None:
"""No override is stored and no instance is removed by the preview."""
client, database = custody.client, custody.database
before = await _open_future_keys(database, custody.child_id)
assert await _consequence(client, _body(custody.child_id, 0, 14)) > 0
assert await _open_future_keys(database, custody.child_id) == before
overrides = await _dao_presence.PresenceOverridesDao(
database
).list_by_child_and_range(custody.child_id, "2000-01-01", "2100-01-01")
assert overrides == []
# --- errors ------------------------------------------------------------------
async def test_unknown_child_is_404(custody: SimpleNamespace) -> None:
response = await custody.client.post(
CONSEQUENCE_URL, json=_body(9999, 1, 2), headers=_admin_headers()
)
assert response.status_code == 404
@pytest.mark.parametrize(
"overrides",
[
{"start_date": "2026-02-30"},
{"end_date": "2026-1-5"},
{"start_date": "2026-03-10", "end_date": "2026-03-01"},
{"is_present": "sometimes"},
{"child_id": "one"},
],
)
async def test_malformed_range_or_status_is_422(
custody: SimpleNamespace, overrides: dict[str, object]
) -> None:
body = {**_body(custody.child_id, 1, 2), **overrides}
response = await custody.client.post(
CONSEQUENCE_URL, json=body, headers=_admin_headers()
)
assert response.status_code == 422
@pytest.mark.parametrize(
"overrides",
[
{"child_id": True},
{"child_id": False},
{"child_id": "1"},
{"child_id": 1.0},
{"is_present": 1},
{"is_present": 0},
{"is_present": "true"},
{"is_present": "false"},
],
)
async def test_coercible_child_id_or_status_is_422(
custody: SimpleNamespace, overrides: dict[str, object]
) -> None:
"""Strict fields: a bool id or a numeric/string status is rejected.
The fixture child's id is 1, so a coerced ``true`` would otherwise
target it and return 200. Nothing is stored and nothing removed.
"""
assert custody.child_id == 1
database = custody.database
before = await _open_future_keys(database, custody.child_id)
body = {**_body(custody.child_id, 0, 14), **overrides}
response = await custody.client.post(
CONSEQUENCE_URL, json=body, headers=_admin_headers()
)
assert response.status_code == 422
assert await _open_future_keys(database, custody.child_id) == before
overrides_stored = await _dao_presence.PresenceOverridesDao(
database
).list_by_child_and_range(custody.child_id, "2000-01-01", "2100-01-01")
assert overrides_stored == []
# --- authentication: the router's ONE require_admin --------------------------
async def test_no_credential_is_401(custody: SimpleNamespace) -> None:
response = await custody.client.post(
CONSEQUENCE_URL, json=_body(custody.child_id, 1, 2)
)
assert response.status_code == 401
async def test_panel_service_token_is_403(custody: SimpleNamespace) -> None:
response = await custody.client.post(
CONSEQUENCE_URL,
json=_body(custody.child_id, 1, 2),
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
)
assert response.status_code == 403
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
async def test_non_admin_jwt_is_403(custody: SimpleNamespace) -> None:
non_admin = make_token(groups=("some-other-group",))
response = await custody.client.post(
CONSEQUENCE_URL,
json=_body(custody.child_id, 1, 2),
headers={"Authorization": f"Bearer {non_admin}"},
)
assert response.status_code == 403
assert response.json()["detail"] == FORBIDDEN_DETAIL
async def test_admin_jwt_is_200(custody: SimpleNamespace) -> None:
response = await custody.client.post(
CONSEQUENCE_URL,
json=_body(custody.child_id, 1, 2),
headers=_admin_headers(),
)
assert response.status_code == 200
assert response.json() == {"removed": 4}
+345
View File
@@ -0,0 +1,345 @@
"""Admin-plane presence READ route tests (task 07fde892).
``GET /api/v1/admin/children/{child_id}/presence-schedule`` and
``GET /api/v1/admin/presence-overrides`` against the ASGI app with
httpx, through the shared stubbed-JWKS harness
(:mod:`tests.admin_jwt_harness`) — no network access.
- The schedule read answers ``{"schedule": {...}}`` in the PUT
response's shape for a seeded schedule, ``{"schedule": null}`` for a
child with none (present every day — NOT a 404), and 404 for an
unknown child.
- The overrides read answers ``{"overrides": [...]}`` in the POST
response's shape, ordered by start date, child id, id; ``child_id``
and the ``start``/``end`` range each narrow the list; a malformed or
inverted range is 422 and an unknown ``child_id`` is 404.
- Both routes inherit the router's ONE ``require_admin``: no
credential 401, the panel service token 403, a non-admin JWT 403, an
admin JWT 200.
Seeding goes through the admin write routes, so the reads are proven
against rows the real write path stored.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from tests.admin_jwt_harness import ADMIN_KEY, KID, AdminRunner, jwks_for, make_token
OVERRIDES_URL = "/api/v1/admin/presence-overrides"
#: The panel service token the harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
#: The 403 detail for the panel service token on an admin route.
PANEL_TOKEN_REJECTION_DETAIL = (
"This credential type is not accepted for admin access"
)
#: The 403 detail for a valid JWT without the admin group.
FORBIDDEN_DETAIL = (
"Admin access requires membership in the nestquest-admins group"
)
def _schedule_url(child_id: int) -> str:
return f"/api/v1/admin/children/{child_id}/presence-schedule"
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
async def _create_child(client: object, name: str) -> int:
"""Create one child through the admin children route; return the id."""
created = await client.post(
"/api/v1/admin/children",
json={"display_name": name},
headers=_admin_headers(),
)
assert created.status_code == 201
return created.json()["id"]
async def _create_override(
client: object,
child_id: int,
start_date: str,
end_date: str,
*,
is_present: bool = False,
note: str | None = None,
) -> dict[str, object]:
"""Create one override through the admin write route; return it."""
created = await client.post(
OVERRIDES_URL,
json={
"child_id": child_id,
"start_date": start_date,
"end_date": end_date,
"is_present": is_present,
"note": note,
},
headers=_admin_headers(),
)
assert created.status_code == 201, created.text
return created.json()
@pytest.fixture
async def reads(temp_db_path: str) -> SimpleNamespace:
"""An admin-plane client over a fresh database."""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(client=client)
@pytest.fixture
async def seeded(reads: SimpleNamespace) -> SimpleNamespace:
"""Two children with interleaved overrides (Ada has a schedule)."""
client = reads.client
ada = await _create_child(client, "Ada")
ben = await _create_child(client, "Ben")
put = await client.put(
_schedule_url(ada),
json={
"cycle_length_weeks": 2,
"anchor_date": "2026-01-05",
"pattern": {"0": [4, 0, 2], "1": []},
},
headers=_admin_headers(),
)
assert put.status_code == 200
ada_march = await _create_override(
client, ada, "2026-03-01", "2026-03-07", note="grandparents"
)
ben_january = await _create_override(
client, ben, "2026-01-10", "2026-01-12", is_present=True
)
ben_march = await _create_override(client, ben, "2026-03-01", "2026-03-01")
ada_may = await _create_override(client, ada, "2026-05-20", "2026-05-20")
return SimpleNamespace(
client=client,
ada=ada,
ben=ben,
ada_march=ada_march,
ben_january=ben_january,
ben_march=ben_march,
ada_may=ada_may,
)
# --- presence schedule read ------------------------------------------------
async def test_schedule_read_returns_the_stored_schedule(
seeded: SimpleNamespace,
) -> None:
"""A seeded schedule reads back in the PUT response's shape."""
response = await seeded.client.get(
_schedule_url(seeded.ada), headers=_admin_headers()
)
assert response.status_code == 200
assert response.json() == {
"schedule": {
"child_id": seeded.ada,
"cycle_length_weeks": 2,
"anchor_date": "2026-01-05",
"pattern": {"0": [0, 2, 4], "1": []},
}
}
async def test_schedule_read_without_schedule_is_null_not_404(
seeded: SimpleNamespace,
) -> None:
"""A child with no schedule (present every day) is schedule: null."""
response = await seeded.client.get(
_schedule_url(seeded.ben), headers=_admin_headers()
)
assert response.status_code == 200
assert response.json() == {"schedule": None}
async def test_schedule_read_unknown_child_is_404(
reads: SimpleNamespace,
) -> None:
"""An unknown child is 404 with the children routes' detail."""
response = await reads.client.get(
_schedule_url(999), headers=_admin_headers()
)
assert response.status_code == 404
assert response.json() == {"detail": "Child not found"}
# --- presence overrides read -----------------------------------------------
async def test_overrides_read_lists_all_in_stable_order(
seeded: SimpleNamespace,
) -> None:
"""Unfiltered: every override, by start date, then child id, then id."""
response = await seeded.client.get(
OVERRIDES_URL, headers=_admin_headers()
)
assert response.status_code == 200
assert response.json() == {
"overrides": [
seeded.ben_january,
seeded.ada_march,
seeded.ben_march,
seeded.ada_may,
]
}
assert seeded.ada_march == {
"id": seeded.ada_march["id"],
"child_id": seeded.ada,
"start_date": "2026-03-01",
"end_date": "2026-03-07",
"is_present": False,
"note": "grandparents",
}
async def test_overrides_read_empty_household(reads: SimpleNamespace) -> None:
"""No overrides at all is an empty list."""
response = await reads.client.get(OVERRIDES_URL, headers=_admin_headers())
assert response.status_code == 200
assert response.json() == {"overrides": []}
async def test_overrides_read_filters_by_child(
seeded: SimpleNamespace,
) -> None:
"""child_id keeps only that child's overrides."""
response = await seeded.client.get(
OVERRIDES_URL,
params={"child_id": seeded.ada},
headers=_admin_headers(),
)
assert response.status_code == 200
assert response.json() == {
"overrides": [seeded.ada_march, seeded.ada_may]
}
async def test_overrides_read_filters_by_overlapping_range(
seeded: SimpleNamespace,
) -> None:
"""start/end keep overrides overlapping the closed range (inclusive)."""
# 2026-03-07 is Ada's March override's LAST day: the boundary counts.
response = await seeded.client.get(
OVERRIDES_URL,
params={"start": "2026-03-07", "end": "2026-05-20"},
headers=_admin_headers(),
)
assert response.status_code == 200
assert response.json() == {
"overrides": [seeded.ada_march, seeded.ada_may]
}
async def test_overrides_read_open_ended_range_filters(
seeded: SimpleNamespace,
) -> None:
"""start alone keeps later-ending, end alone keeps earlier-starting."""
from_april = await seeded.client.get(
OVERRIDES_URL, params={"start": "2026-04-01"}, headers=_admin_headers()
)
assert from_april.json() == {"overrides": [seeded.ada_may]}
until_january = await seeded.client.get(
OVERRIDES_URL, params={"end": "2026-01-31"}, headers=_admin_headers()
)
assert until_january.json() == {"overrides": [seeded.ben_january]}
async def test_overrides_read_combines_child_and_range(
seeded: SimpleNamespace,
) -> None:
"""child_id and the range narrow together."""
response = await seeded.client.get(
OVERRIDES_URL,
params={"child_id": seeded.ben, "start": "2026-02-01", "end": "2026-12-31"},
headers=_admin_headers(),
)
assert response.status_code == 200
assert response.json() == {"overrides": [seeded.ben_march]}
@pytest.mark.parametrize(
"params",
[
{"start": "2026-03-10", "end": "2026-03-01"},
{"start": "2026-3-1"},
{"end": "not-a-date"},
{"start": "2026-02-30"},
],
)
async def test_overrides_read_invalid_range_is_422(
seeded: SimpleNamespace, params: dict[str, str]
) -> None:
"""A malformed date or an inverted range is the core's 422."""
response = await seeded.client.get(
OVERRIDES_URL, params=params, headers=_admin_headers()
)
assert response.status_code == 422
async def test_overrides_read_unknown_child_is_404(
reads: SimpleNamespace,
) -> None:
"""An unknown child_id filter is 404, never an empty list."""
response = await reads.client.get(
OVERRIDES_URL, params={"child_id": 999}, headers=_admin_headers()
)
assert response.status_code == 404
assert response.json() == {"detail": "Child not found"}
# --- authentication: the router's ONE require_admin --------------------------
@pytest.fixture(params=["schedule", "overrides"])
async def read_url(request: pytest.FixtureRequest, reads: SimpleNamespace) -> str:
"""Each read route's URL (the schedule route for a real child)."""
if request.param == "overrides":
return OVERRIDES_URL
return _schedule_url(await _create_child(reads.client, "Ada"))
async def test_read_without_credential_is_401(
reads: SimpleNamespace, read_url: str
) -> None:
response = await reads.client.get(read_url)
assert response.status_code == 401
async def test_read_refuses_panel_service_token(
reads: SimpleNamespace, read_url: str
) -> None:
response = await reads.client.get(
read_url, headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}
)
assert response.status_code == 403
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
async def test_read_refuses_non_admin_jwt(
reads: SimpleNamespace, read_url: str
) -> None:
non_admin = make_token(groups=("some-other-group",))
response = await reads.client.get(
read_url, headers={"Authorization": f"Bearer {non_admin}"}
)
assert response.status_code == 403
assert response.json()["detail"] == FORBIDDEN_DETAIL
async def test_read_admin_jwt_is_200(
reads: SimpleNamespace, read_url: str
) -> None:
response = await reads.client.get(read_url, headers=_admin_headers())
assert response.status_code == 200
+4 -3
View File
@@ -18,8 +18,9 @@ caveat in api/nestquest_core.py):
and windows exactly as posted.
- ``PATCH /api/v1/admin/quest-definitions/{id}``: an edit PERSISTS the
supplied fields and leaves omitted fields untouched (exclude_unset
semantics); a supplied ``windows`` list replaces the whole set;
assignment is not settable through the edit.
semantics); a supplied ``windows`` list replaces the whole set; an
omitted ``assignee_child_ids`` leaves the assignees untouched (the
supplied-list cases live in test_api_admin_questdef_assignees.py).
- ``PATCH /api/v1/admin/quest-definitions/{id}/active`` with
``is_active: false`` sets the flag FALSE and the definition STILL
EXISTS with its rule, assignees and windows (never hard-deleted);
@@ -397,7 +398,7 @@ async def test_edit_persists_supplied_fields_and_leaves_omitted_untouched(
)
assert stored.definition.icon == "toy-box"
assert stored.rule.to_dict() == _weekly_rule_dict()
# Assignment is not settable through the edit route: untouched.
# assignee_child_ids was omitted: the assignees are untouched.
assert sorted(child.id for child in stored.assignees) == [ada, bo]
# A second edit touching only the rule leaves the metadata and the
+431
View File
@@ -0,0 +1,431 @@
"""Admin-plane editable assignees on the definition edit route (task 1c8096f6).
``PATCH /api/v1/admin/quest-definitions/{definition_id}`` accepts an
optional ``assignee_child_ids`` list that REPLACES the definition's
assignee set inside the core edit's ONE transaction
(:func:`nestquest_core.quest_definitions.edit_quest_definition`):
- newly listed children are assigned and unlisted ones unassigned; the
response and the database both carry the resulting set;
- an empty list, a duplicate, an unknown child or an inactive child is
422 and NOTHING is written — the stored assignees (and any other
field supplied in the same body) are unchanged;
- a JSON boolean, float or numeric string id is 422 on create and edit
(never coerced to an int) and nothing is written;
- an omitted ``assignee_child_ids`` leaves the assignees unchanged;
- an assignment change regenerates the definition's future instances
through the existing core path;
- the route stays behind the router's ONE ``require_admin`` dependency
(absent credential 401, panel service token 403, non-admin JWT 403,
admin JWT 200).
Every case is proven against the DATABASE, read through the SAME core
copy the app uses (``nestquest_core.*``; see api/nestquest_core.py).
"""
from __future__ import annotations
import datetime
import importlib
from types import SimpleNamespace
import pytest
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
AdminRunner,
jwks_for,
make_token,
)
_dao_rules = importlib.import_module("nestquest_core.dao_rules")
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
_materialize = importlib.import_module("nestquest_core.materialize")
_quest_definitions = importlib.import_module(
"nestquest_core.quest_definitions"
)
#: The panel service token the harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
def _edit_path(definition_id: int) -> str:
"""The edit route for one definition."""
return f"/api/v1/admin/quest-definitions/{definition_id}"
@pytest.fixture
async def admin_assignees(temp_db_path: str) -> SimpleNamespace:
"""An admin-plane app client plus its live database."""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(
client=client,
database=runner.app.state.db.database,
)
async def _create_children(
client: object, names: tuple[str, ...]
) -> list[int]:
"""Create ACTIVE children through the admin children route."""
ids = []
for name in names:
created = await client.post(
"/api/v1/admin/children",
json={"display_name": name},
headers=_admin_headers(),
)
assert created.status_code == 201
ids.append(created.json()["id"])
return ids
async def _create_definition(client: object, assignee_ids: list[int]) -> int:
"""POST a daily morning definition for the given assignees."""
created = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Brush teeth",
"rule": {"rule_type": "daily"},
"assignee_child_ids": assignee_ids,
"windows": ["morning"],
},
headers=_admin_headers(),
)
assert created.status_code == 201
return created.json()["id"]
async def _stored_assignee_ids(
database: object, definition_id: int
) -> set[int]:
"""The definition's assignee ids as stored."""
dao = _dao_rules.QuestDefinitionsDao(database)
return {child.id for child in await dao.list_assignees(definition_id)}
def _response_assignee_ids(response: object) -> set[int]:
"""The assignee ids a definition payload reports."""
return {assignee["id"] for assignee in response.json()["assignees"]}
# --- add / remove / replace -------------------------------------------------
@pytest.mark.parametrize(
("initial", "requested"),
[
(("Ada",), ("Ada", "Bo")), # add a child
(("Ada", "Bo"), ("Ada",)), # remove a child
(("Ada", "Bo"), ("Cy",)), # replace the whole set
],
)
async def test_edit_replaces_the_assignee_set(
admin_assignees: SimpleNamespace,
initial: tuple[str, ...],
requested: tuple[str, ...],
) -> None:
"""A supplied list becomes exactly the stored and returned set."""
client = admin_assignees.client
names = ("Ada", "Bo", "Cy")
ids = dict(zip(names, await _create_children(client, names)))
definition_id = await _create_definition(
client, [ids[name] for name in initial]
)
wanted = {ids[name] for name in requested}
response = await client.patch(
_edit_path(definition_id),
json={"assignee_child_ids": [ids[name] for name in requested]},
headers=_admin_headers(),
)
assert response.status_code == 200
assert _response_assignee_ids(response) == wanted
assert {a["display_name"] for a in response.json()["assignees"]} == set(
requested
)
assert await _stored_assignee_ids(
admin_assignees.database, definition_id
) == wanted
# The rest of the definition is untouched by an assignee-only edit.
assert response.json()["title"] == "Brush teeth"
assert [w["window"] for w in response.json()["windows"]] == ["morning"]
async def test_edit_with_assignees_and_other_fields_applies_both(
admin_assignees: SimpleNamespace,
) -> None:
"""Assignees and metadata supplied together land in one edit."""
client = admin_assignees.client
ada, bo = await _create_children(client, ("Ada", "Bo"))
definition_id = await _create_definition(client, [ada])
response = await client.patch(
_edit_path(definition_id),
json={"title": "Floss", "assignee_child_ids": [bo]},
headers=_admin_headers(),
)
assert response.status_code == 200
assert response.json()["title"] == "Floss"
assert _response_assignee_ids(response) == {bo}
dao = _dao_rules.QuestDefinitionsDao(admin_assignees.database)
assert (await dao.get(definition_id)).title == "Floss"
assert await _stored_assignee_ids(
admin_assignees.database, definition_id
) == {bo}
# --- rejections leave the stored state unchanged ----------------------------
@pytest.mark.parametrize(
("case", "expected_detail"),
[
("empty", "assignee_child_ids must not be empty"),
("unknown", "assignee_child_ids: child 999 does not exist"),
("inactive", "assignee_child_ids: child {cy} is inactive"),
("duplicate", "assignee_child_ids must not contain duplicate"),
("null", "assignee_child_ids must be a list"),
("mixed_unknown", "assignee_child_ids: child 999 does not exist"),
("wrong_type", None), # the body model's own 422
],
)
async def test_rejected_assignee_list_is_422_and_changes_nothing(
admin_assignees: SimpleNamespace,
case: str,
expected_detail: str | None,
) -> None:
"""Empty/unknown/inactive/malformed lists are 422 with no mutation.
The same body also renames the definition, proving the whole edit
is all-or-nothing: neither the assignees nor the title change.
"""
client = admin_assignees.client
ada, bo, cy, di = await _create_children(
client, ("Ada", "Bo", "Cy", "Di")
)
definition_id = await _create_definition(client, [ada, bo])
deactivated = await client.patch(
f"/api/v1/admin/children/{cy}/active",
json={"is_active": False},
headers=_admin_headers(),
)
assert deactivated.status_code == 200
requested: object = {
"empty": [],
"unknown": [999],
"inactive": [ada, cy],
"duplicate": [ada, ada],
"null": None,
# A valid new child alongside an unknown one: di must NOT be
# assigned (all-or-nothing), and ada/bo must NOT be removed.
"mixed_unknown": [di, 999],
"wrong_type": ["Ada"],
}[case]
response = await client.patch(
_edit_path(definition_id),
json={"title": "Renamed", "assignee_child_ids": requested},
headers=_admin_headers(),
)
assert response.status_code == 422, case
if expected_detail is not None:
assert response.json()["detail"].startswith(
expected_detail.format(cy=cy)
), response.json()
assert await _stored_assignee_ids(
admin_assignees.database, definition_id
) == {ada, bo}
dao = _dao_rules.QuestDefinitionsDao(admin_assignees.database)
assert (await dao.get(definition_id)).title == "Brush teeth"
#: JSON ids the lax ``int`` parser would coerce (``true`` -> 1).
_COERCIBLE_IDS = [True, 1.0, "1"]
@pytest.mark.parametrize("coercible", _COERCIBLE_IDS)
async def test_edit_rejects_coercible_assignee_ids_and_changes_nothing(
admin_assignees: SimpleNamespace, coercible: object
) -> None:
"""A bool, float or numeric-string id edit is 422; nothing changes."""
client = admin_assignees.client
ada, bo = await _create_children(client, ("Ada", "Bo"))
# Assign bo only, so a coerced id 1 (ada) would be a visible change.
definition_id = await _create_definition(client, [bo])
assert ada == 1
response = await client.patch(
_edit_path(definition_id),
json={"title": "Renamed", "assignee_child_ids": [coercible]},
headers=_admin_headers(),
)
assert response.status_code == 422
assert await _stored_assignee_ids(
admin_assignees.database, definition_id
) == {bo}
dao = _dao_rules.QuestDefinitionsDao(admin_assignees.database)
assert (await dao.get(definition_id)).title == "Brush teeth"
@pytest.mark.parametrize("coercible", _COERCIBLE_IDS)
async def test_create_rejects_coercible_assignee_ids_and_persists_nothing(
admin_assignees: SimpleNamespace, coercible: object
) -> None:
"""A bool, float or numeric-string id create is 422; nothing is written."""
client = admin_assignees.client
ada, bo = await _create_children(client, ("Ada", "Bo"))
assert ada == 1
existing_id = await _create_definition(client, [bo])
response = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Tidy the den",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [coercible],
"windows": ["morning"],
},
headers=_admin_headers(),
)
assert response.status_code == 422
bundles = await _quest_definitions.list_active_definitions(
admin_assignees.database
)
assert [bundle.definition.id for bundle in bundles] == [existing_id]
assert await _stored_assignee_ids(
admin_assignees.database, existing_id
) == {bo}
async def test_rejected_assignees_on_unknown_definition_is_404(
admin_assignees: SimpleNamespace,
) -> None:
"""An unknown definition id is still 404 when assignees are supplied."""
client = admin_assignees.client
(ada,) = await _create_children(client, ("Ada",))
response = await client.patch(
_edit_path(999),
json={"assignee_child_ids": [ada]},
headers=_admin_headers(),
)
assert response.status_code == 404
assert response.json()["detail"] == "Quest definition not found"
# --- omitted keeps ----------------------------------------------------------
async def test_omitted_assignees_are_unchanged(
admin_assignees: SimpleNamespace,
) -> None:
"""An edit without assignee_child_ids never touches the assignees."""
client = admin_assignees.client
ada, bo = await _create_children(client, ("Ada", "Bo"))
definition_id = await _create_definition(client, [ada, bo])
response = await client.patch(
_edit_path(definition_id),
json={"title": "Floss"},
headers=_admin_headers(),
)
assert response.status_code == 200
assert _response_assignee_ids(response) == {ada, bo}
assert await _stored_assignee_ids(
admin_assignees.database, definition_id
) == {ada, bo}
# --- regeneration -----------------------------------------------------------
async def test_assignment_change_regenerates_future_instances(
admin_assignees: SimpleNamespace,
) -> None:
"""Swapping the assignee moves the future open instances with it.
The regeneration lives in the core edit path the route calls: the
definition's open instances at or after today are deleted and the
horizon re-materialized for the NEW assignee set.
"""
client = admin_assignees.client
database = admin_assignees.database
ada, bo = await _create_children(client, ("Ada", "Bo"))
definition_id = await _create_definition(client, [ada])
today = datetime.date.today()
start = today.isoformat()
end = (today + datetime.timedelta(days=7)).isoformat()
await _materialize.materialize(database, start, end, today=today)
instances = _dao_instances.QuestInstancesDao(database)
assert await instances.list_by_date_range(ada, start, end)
assert await instances.list_by_date_range(bo, start, end) == []
response = await client.patch(
_edit_path(definition_id),
json={"assignee_child_ids": [bo]},
headers=_admin_headers(),
)
assert response.status_code == 200
assert await instances.list_by_date_range(ada, start, end) == []
regenerated = await instances.list_by_date_range(bo, start, end)
assert regenerated
assert {i.definition_id for i in regenerated} == {definition_id}
# --- authentication ---------------------------------------------------------
async def test_assignee_edit_auth_outcomes(
admin_assignees: SimpleNamespace,
) -> None:
"""401 absent, 403 panel token, 403 non-admin, 200 admin."""
client = admin_assignees.client
ada, bo = await _create_children(client, ("Ada", "Bo"))
definition_id = await _create_definition(client, [ada])
body = {"assignee_child_ids": [bo]}
absent = await client.patch(_edit_path(definition_id), json=body)
assert absent.status_code == 401
panel = await client.patch(
_edit_path(definition_id),
json=body,
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
)
assert panel.status_code == 403
non_admin_token = make_token(groups=("some-other-group",))
non_admin = await client.patch(
_edit_path(definition_id),
json=body,
headers={"Authorization": f"Bearer {non_admin_token}"},
)
assert non_admin.status_code == 403
# None of the refused calls reached the handler.
assert await _stored_assignee_ids(
admin_assignees.database, definition_id
) == {ada}
admin = await client.patch(
_edit_path(definition_id), json=body, headers=_admin_headers()
)
assert admin.status_code == 200
assert await _stored_assignee_ids(
admin_assignees.database, definition_id
) == {bo}
+283
View File
@@ -0,0 +1,283 @@
"""Admin-plane SSE tests: the admin transition stream (task 92f75680).
These tests exercise ``GET /api/v1/admin/events`` — the admin plane's
Server-Sent Events stream for the Admin PWA — against a REAL server:
- The stream inherits the admin router's ONE ``require_admin``
dependency: an absent credential is 401, the panel service token and
a non-admin JWT are 403, all before any stream is established.
- An admin JWT subscribes; each of the four documented transitions
(quest-completed, quest-uncompleted, quest-missed, child-day-complete)
arrives as an ``event: <type>`` / ``data: <json>`` frame carrying its
documented payload fields (design/ENTITIES-AND-SERVICES.md §3).
- The admin and panel streams are fed by the SAME in-process publisher:
one transition arrives, identically, on both.
Streaming approach: :mod:`tests.test_api_sse`'s live-uvicorn reader
(httpx's ASGITransport buffers SSE bodies), on the admin-enabled
:class:`tests.test_api_admin_uncomplete._AdminServer` whose JWKS cache
is stubbed, so a real admin JWT verifies with NO network access.
"""
from __future__ import annotations
import datetime
from types import SimpleNamespace
from zoneinfo import ZoneInfo
import httpx
import pytest
from api import routes_admin, routes_panel
from tests.admin_jwt_harness import make_token
from tests.test_api_admin_uncomplete import _AdminServer
from tests.test_api_panel import PANEL_TOKEN, _seed_household
from tests.test_api_sse import (
EVENT_CHILD_DAY_COMPLETE,
EVENT_QUEST_COMPLETED,
EVENT_QUEST_MISSED,
EVENT_QUEST_UNCOMPLETED,
READ_TIMEOUT,
_stream,
)
EVENTS_PATH = "/api/v1/admin/events"
@pytest.fixture
def temp_db_path(tmp_path) -> str:
"""A fresh per-test SQLite path under pytest's tmp_path."""
return str(tmp_path / "nestquest.db")
@pytest.fixture
async def admin_live(temp_db_path: str) -> SimpleNamespace:
"""A seeded household served by a live admin-enabled uvicorn app.
Both planes' clocks are pinned to 12:00 UTC (past Ada's 10:00 due,
before Bo's 17:00) BEFORE the server starts — the app is created
inside uvicorn's factory thread.
"""
today = datetime.datetime.now().astimezone().date()
pinned_now = datetime.datetime(
today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC")
)
seed = await _seed_household(temp_db_path, today, pinned_now)
original_panel = routes_panel._local_now
original_admin = routes_admin._local_now
routes_panel._local_now = lambda: pinned_now
routes_admin._local_now = lambda: pinned_now
try:
with _AdminServer(temp_db_path) as server:
client = httpx.AsyncClient(
base_url=server.url(""), timeout=READ_TIMEOUT
)
async with client:
yield SimpleNamespace(client=client, seed=seed, server=server)
finally:
routes_panel._local_now = original_panel
routes_admin._local_now = original_admin
async def _panel_complete(live: SimpleNamespace, instance_id: int, child_id: int):
"""Complete ``instance_id`` through the panel route (a real transition)."""
response = await live.client.post(
f"/api/v1/panel/instances/{instance_id}/complete",
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
json={"actor_child_id": child_id},
)
assert response.status_code == 200
# --- auth: the ONE require_admin dependency, no stream on refusal ----------
async def test_admin_events_refuses_absent_panel_and_non_admin(
admin_live: SimpleNamespace,
) -> None:
"""No credential is 401; the panel token and a non-admin JWT are 403.
Each refusal is a complete (non-streaming) JSON error response, so
no stream is established for any of them.
"""
client = admin_live.client
absent = await client.get(EVENTS_PATH)
assert absent.status_code == 401
assert not absent.headers["content-type"].startswith("text/event-stream")
panel = await client.get(
EVENTS_PATH, headers={"Authorization": f"Bearer {PANEL_TOKEN}"}
)
assert panel.status_code == 403
assert panel.json()["detail"] == (
"This credential type is not accepted for admin access"
)
non_admin = await client.get(
EVENTS_PATH,
headers={
"Authorization": f"Bearer {make_token(groups=('other-group',))}"
},
)
assert non_admin.status_code == 403
assert non_admin.json()["detail"] == (
"Admin access requires membership in the nestquest-admins group"
)
async def test_admin_jwt_is_not_accepted_on_panel_events(
admin_live: SimpleNamespace,
) -> None:
"""The panel plane is unchanged: an admin JWT does not open it."""
response = await admin_live.client.get(
"/api/v1/panel/events",
headers={"Authorization": f"Bearer {make_token()}"},
)
assert response.status_code == 401
async def test_admin_events_sets_no_cache(admin_live: SimpleNamespace) -> None:
"""The admin stream is ``text/event-stream`` with ``no-cache``."""
async with admin_live.client.stream(
"GET",
EVENTS_PATH,
headers={"Authorization": f"Bearer {make_token()}"},
) as response:
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/event-stream")
assert response.headers["cache-control"] == "no-cache"
# --- the four documented transitions on the admin stream -------------------
async def test_admin_stream_carries_completed_and_day_complete(
admin_live: SimpleNamespace,
) -> None:
"""A panel completion streams quest-completed then child-day-complete.
Ada's only quest today is Pack bag, so completing it also clears her
day; both frames arrive on the ADMIN stream with their documented
payload fields.
"""
seed = admin_live.seed
pack_bag = seed.pack_bag_instance
stream = _stream(admin_live.server.url(EVENTS_PATH), make_token())
async with stream:
await _panel_complete(admin_live, pack_bag.id, seed.ada.id)
event_type, payload = await stream.next_event()
assert event_type == EVENT_QUEST_COMPLETED == "nestquest_quest_completed"
assert payload["child_id"] == seed.ada.id
assert payload["child_name"] == "Ada"
assert payload["instance_id"] == pack_bag.id
assert payload["quest_title"] == "Pack bag"
assert payload["window"] == "morning"
assert payload["due_date"] == pack_bag.due_date
assert payload["due_time"] == "10:00"
assert payload["occurred_at"]
assert payload["was_on_time"] is False # 10:00 due, 12:00 now
completed_at = payload["occurred_at"]
event_type, payload = await stream.next_event()
assert event_type == EVENT_CHILD_DAY_COMPLETE
assert event_type == "nestquest_child_day_complete"
assert payload["child_id"] == seed.ada.id
assert payload["child_name"] == "Ada"
assert payload["quests_due"] == 1
assert payload["quests_completed"] == 1
assert payload["occurred_at"] == completed_at
async def test_admin_stream_carries_uncompleted(
admin_live: SimpleNamespace,
) -> None:
"""The admin uncomplete route's reversal streams quest-uncompleted."""
seed = admin_live.seed
brush = seed.brush_teeth_instance
admin_headers = {"Authorization": f"Bearer {make_token()}"}
stream = _stream(admin_live.server.url(EVENTS_PATH), make_token())
async with stream:
response = await admin_live.client.post(
f"/api/v1/admin/instances/{brush.id}/uncomplete",
headers=admin_headers,
)
assert response.status_code == 200
assert response.json()["appended"] is True
event_type, payload = await stream.next_event()
assert event_type == EVENT_QUEST_UNCOMPLETED
assert event_type == "nestquest_quest_uncompleted"
assert payload["child_id"] == seed.bo.id
assert payload["child_name"] == "Bo"
assert payload["instance_id"] == brush.id
assert payload["quest_title"] == "Brush teeth"
assert payload["window"] == "morning"
assert payload["due_date"] == brush.due_date
assert payload["due_time"] == "17:00"
assert payload["occurred_at"]
assert "was_on_time" not in payload
async def test_admin_stream_carries_missed(admin_live: SimpleNamespace) -> None:
"""The missed-sweep publisher's transition streams to the admin client.
Drives the API-side helper the sweep calls, on the SERVER's loop,
against the app's own database and its ONE publisher.
"""
from api import transitions
seed = admin_live.seed
stale = seed.stale_instance
app = admin_live.server.app
stream = _stream(admin_live.server.url(EVENTS_PATH), make_token())
async with stream:
admin_live.server.run_on_loop(
transitions.publish_quest_missed(
app.state.db.database, stale.id, app.state.publisher
)
)
event_type, payload = await stream.next_event()
assert event_type == EVENT_QUEST_MISSED == "nestquest_quest_missed"
assert payload["child_id"] == seed.ada.id
assert payload["child_name"] == "Ada"
assert payload["instance_id"] == stale.id
assert payload["quest_title"] == "Stale chore"
assert payload["window"] == "morning"
assert payload["due_date"] == stale.due_date
assert payload["due_time"] == "09:00"
assert payload["occurred_at"]
# --- one publisher feeds both planes ---------------------------------------
async def test_admin_and_panel_streams_share_one_publisher(
admin_live: SimpleNamespace,
) -> None:
"""One transition arrives, identically, on the admin AND panel streams.
The route publishes once to ``app.state.publisher``; both planes'
subscribers receive the same frames (same ``occurred_at``), which a
second, admin-only publisher could never produce.
"""
seed = admin_live.seed
pack_bag = seed.pack_bag_instance
admin_stream = _stream(admin_live.server.url(EVENTS_PATH), make_token())
panel_stream = _stream(admin_live.server.url("/api/v1/panel/events"))
async with admin_stream, panel_stream:
await _panel_complete(admin_live, pack_bag.id, seed.ada.id)
admin_events = [
await admin_stream.next_event(),
await admin_stream.next_event(),
]
panel_events = [
await panel_stream.next_event(),
await panel_stream.next_event(),
]
assert [event_type for event_type, _ in admin_events] == [
EVENT_QUEST_COMPLETED,
EVENT_CHILD_DAY_COMPLETE,
]
assert admin_events == panel_events
+10 -5
View File
@@ -158,7 +158,8 @@ class _EventStream:
parsed frame with a timeout.
"""
def __init__(self) -> None:
def __init__(self, token: str = PANEL_TOKEN) -> None:
self.token = token
self.queue: asyncio.Queue = asyncio.Queue()
self._task: asyncio.Task | None = None
self._client: httpx.AsyncClient | None = None
@@ -170,7 +171,7 @@ class _EventStream:
self._stream_cm = self._client.stream(
"GET",
self.url,
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
headers={"Authorization": f"Bearer {self.token}"},
)
self._response = await self._stream_cm.__aenter__()
assert self._response.status_code == 200
@@ -263,9 +264,13 @@ async def seeded_panel(temp_db_path: str) -> SimpleNamespace:
routes_panel._local_now = original
def _stream(url: str) -> _EventStream:
"""A subscribed SSE reader for ``url``."""
return _EventStream()._set_url(url)
def _stream(url: str, token: str = PANEL_TOKEN) -> _EventStream:
"""A subscribed SSE reader for ``url``, sending ``token`` as Bearer.
Defaults to the panel service token; the admin-plane stream tests
pass an admin JWT instead.
"""
return _EventStream(token)._set_url(url)
# --- auth and content type ----------------------------------------------