Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf00b63215 | ||
|
|
5a28fddfe2 | ||
|
|
dbe873eb2a | ||
|
|
706a7954d8 | ||
|
|
be4fd04754 | ||
|
|
4ff40f00a2 | ||
|
|
f476821e8e | ||
|
|
75ad9892b8 | ||
|
|
48d0c4ef86 |
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -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>
|
||||
Generated
+3024
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 />;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Typed client for the admin quest-definition routes and the children list
|
||||
* the assignee picker reads (/api/v1/admin/quest-definitions, /children).
|
||||
*/
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export type WindowName = "morning" | "afternoon" | "evening";
|
||||
|
||||
/** Model rule names, as the admin plane serializes ScheduleRule.to_dict. */
|
||||
export type RuleType =
|
||||
| "daily"
|
||||
| "weekly"
|
||||
| "monthly_day"
|
||||
| "monthly_weekday"
|
||||
| "yearly"
|
||||
| "custom_days";
|
||||
|
||||
export interface DefinitionRule {
|
||||
rule_type: RuleType;
|
||||
interval: number;
|
||||
/** 0 = Monday .. 6 = Sunday. */
|
||||
weekday_set: number[] | null;
|
||||
day_of_month: number | null;
|
||||
nth_weekday: number | null;
|
||||
nth_weekday_weekday: number | null;
|
||||
month: number | null;
|
||||
start_date: string;
|
||||
end_date: string | null;
|
||||
}
|
||||
|
||||
export interface DefinitionAssignee {
|
||||
id: number;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export interface DefinitionWindow {
|
||||
window: WindowName;
|
||||
/** Local wall-clock "HH:MM", or null. */
|
||||
due_time: string | null;
|
||||
}
|
||||
|
||||
export interface QuestDefinition {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
is_active: boolean;
|
||||
skip_on_away: boolean;
|
||||
rule: DefinitionRule;
|
||||
assignees: DefinitionAssignee[];
|
||||
windows: DefinitionWindow[];
|
||||
}
|
||||
|
||||
export interface AdminChild {
|
||||
id: number;
|
||||
display_name: string;
|
||||
colour: string | null;
|
||||
avatar_ref: string | null;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
/** A window declaration as the create/edit bodies accept it. */
|
||||
export type WindowEntry = WindowName | [WindowName, string | null];
|
||||
|
||||
export interface DefinitionCreateBody {
|
||||
title: string;
|
||||
rule: DefinitionRule;
|
||||
assignee_child_ids: number[];
|
||||
windows: WindowEntry[];
|
||||
description?: string | null;
|
||||
icon?: string | null;
|
||||
skip_on_away?: boolean;
|
||||
}
|
||||
|
||||
export type DefinitionEditBody = Partial<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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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()}`);
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
/* Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2).
|
||||
Mobile-first at the 390x844 reference; the column is capped and centred wider. */
|
||||
|
||||
.defs-screen {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* §3.1 header */
|
||||
.defs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: var(--nq-a-surface);
|
||||
border-bottom: 1px solid var(--nq-a-border);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.defs-title {
|
||||
margin: 0;
|
||||
font-size: var(--nq-a-size-screen);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.defs-sub {
|
||||
margin: 2px 0 0;
|
||||
min-height: 1.4em;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-new {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-brand-gradient);
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-new:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
|
||||
.defs-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nq-a-density-gap);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
/* Filter chips */
|
||||
.defs-chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.defs-chip {
|
||||
flex: none;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-radius-pill);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-chip--active {
|
||||
border-color: #18181d;
|
||||
background: #18181d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Rows */
|
||||
.defs-card {
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.defs-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.defs-list > li + li {
|
||||
border-top: 1px solid var(--nq-a-divider);
|
||||
}
|
||||
|
||||
.defs-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: var(--nq-a-density-row-pad);
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-row-glyph {
|
||||
flex: none;
|
||||
display: flex;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-row-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.defs-row-title {
|
||||
font-size: var(--nq-a-size-row);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.defs-row-meta {
|
||||
font-size: var(--nq-a-size-meta);
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-row-chevron {
|
||||
flex: none;
|
||||
display: flex;
|
||||
color: var(--nq-a-ink-tertiary);
|
||||
}
|
||||
|
||||
/* An inactive definition renders the whole row tertiary (never hard-deleted). */
|
||||
.defs-row--inactive,
|
||||
.defs-row--inactive .defs-row-glyph,
|
||||
.defs-row--inactive .defs-row-meta {
|
||||
color: var(--nq-a-ink-tertiary);
|
||||
}
|
||||
|
||||
.defs-row--editing {
|
||||
background: var(--nq-a-selected);
|
||||
}
|
||||
|
||||
.defs-row--editing .defs-row-glyph {
|
||||
color: var(--nq-brand-blue);
|
||||
}
|
||||
|
||||
.defs-empty {
|
||||
padding: var(--nq-a-density-card-pad);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
/* Loading / error */
|
||||
.defs-status {
|
||||
padding: 24px 13px;
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
text-align: center;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-status--error {
|
||||
color: var(--nq-a-danger-strong);
|
||||
}
|
||||
|
||||
.defs-status--error p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.defs-retry {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* §3.2 edit sheet */
|
||||
.defs-sheet-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.defs-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(9, 9, 11, 0.4);
|
||||
animation: defs-fade var(--nq-dur-modal) var(--nq-ease-out);
|
||||
}
|
||||
|
||||
.defs-sheet {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
max-height: 92%;
|
||||
margin: 0 auto;
|
||||
overflow-y: auto;
|
||||
padding: 14px 18px 24px;
|
||||
border-radius: var(--nq-a-radius-sheet);
|
||||
background: var(--nq-a-surface);
|
||||
box-shadow: var(--nq-a-sheet-shadow);
|
||||
animation: defs-rise var(--nq-dur-modal) var(--nq-ease-out);
|
||||
}
|
||||
|
||||
@keyframes defs-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes defs-rise {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.defs-scrim,
|
||||
.defs-sheet {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.defs-grabber {
|
||||
width: 38px;
|
||||
height: 4px;
|
||||
margin: 0 auto 12px;
|
||||
border-radius: var(--nq-a-radius-pill);
|
||||
background: var(--nq-a-border);
|
||||
}
|
||||
|
||||
.defs-sheet-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.defs-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.defs-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.defs-label {
|
||||
font-size: var(--nq-a-size-label);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--nq-a-track-label);
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-input {
|
||||
width: 100%;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: 0 11px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.defs-select-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: 0 11px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-picker {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
}
|
||||
|
||||
.defs-picker > li + li {
|
||||
border-top: 1px solid var(--nq-a-divider);
|
||||
}
|
||||
|
||||
.defs-picker-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: 0 11px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-picker-check {
|
||||
display: flex;
|
||||
color: var(--nq-brand-blue);
|
||||
}
|
||||
|
||||
.defs-picker-option--locked {
|
||||
color: var(--nq-a-ink-secondary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.defs-picker-note {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.defs-repeats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.defs-interval {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-interval .defs-input {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.defs-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.defs-day,
|
||||
.defs-segment {
|
||||
min-height: var(--nq-a-tap-min);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-day--on,
|
||||
.defs-segment--on {
|
||||
border-color: transparent;
|
||||
background: var(--nq-brand-gradient);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.defs-segmented {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.defs-due-times {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.defs-due {
|
||||
display: grid;
|
||||
grid-template-columns: 84px 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.defs-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.defs-toggle-title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.defs-toggle-sub {
|
||||
font-size: var(--nq-a-size-meta);
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-switch {
|
||||
flex: none;
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--nq-a-radius-pill);
|
||||
background: var(--nq-a-border);
|
||||
cursor: pointer;
|
||||
transition: background var(--nq-dur-base) var(--nq-ease-out);
|
||||
}
|
||||
|
||||
.defs-switch--on {
|
||||
background: var(--nq-brand-gradient);
|
||||
}
|
||||
|
||||
.defs-switch-knob {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(9, 9, 11, 0.25);
|
||||
transition: transform var(--nq-dur-base) var(--nq-ease-out);
|
||||
}
|
||||
|
||||
.defs-switch--on .defs-switch-knob {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.defs-sheet-error {
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-danger-bg);
|
||||
color: var(--nq-a-danger-strong);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.defs-sheet-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.defs-confirm {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-danger-bg);
|
||||
color: var(--nq-a-danger-strong);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.defs-confirm p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.defs-confirm .defs-sheet-footer {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.defs-cancel,
|
||||
.defs-save {
|
||||
padding: 13px 0;
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-cancel {
|
||||
flex: 1;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
}
|
||||
|
||||
.defs-save {
|
||||
flex: 2;
|
||||
border: 0;
|
||||
background: var(--nq-brand-gradient);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.defs-cancel:disabled,
|
||||
.defs-save:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import DefinitionsTab from "./DefinitionsTab";
|
||||
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
|
||||
function child(id: number, name: string, overrides: Partial<AdminChild> = {}): 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stub of the admin API: serves the list and children, records writes, and
|
||||
* lets a test queue a response for the next write.
|
||||
*/
|
||||
function fakeApi(initial: QuestDefinition[]) {
|
||||
let definitions = [...initial];
|
||||
const calls: Call[] = [];
|
||||
let nextWrite: Response | null = null;
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||
const path = new URL(String(input), "http://localhost").pathname;
|
||||
const method = init.method ?? "GET";
|
||||
const body = typeof init.body === "string" ? JSON.parse(init.body) : null;
|
||||
calls.push({ method, path, body });
|
||||
if (method === "GET" && path === "/api/v1/admin/quest-definitions") {
|
||||
return jsonResponse({ definitions });
|
||||
}
|
||||
if (method === "GET" && path === "/api/v1/admin/children") {
|
||||
return jsonResponse({ children: CHILDREN });
|
||||
}
|
||||
if (nextWrite) {
|
||||
const response = nextWrite;
|
||||
nextWrite = null;
|
||||
return response;
|
||||
}
|
||||
if (method === "POST" && path === "/api/v1/admin/quest-definitions") {
|
||||
const created: QuestDefinition = {
|
||||
id: 99,
|
||||
title: body.title,
|
||||
description: null,
|
||||
icon: null,
|
||||
is_active: true,
|
||||
skip_on_away: body.skip_on_away,
|
||||
rule: body.rule,
|
||||
assignees: CHILDREN.filter((c) => body.assignee_child_ids.includes(c.id)).map((c) => ({
|
||||
id: c.id,
|
||||
display_name: c.display_name,
|
||||
})),
|
||||
windows: body.windows.map(([window, due_time]: [string, string | null]) => ({
|
||||
window,
|
||||
due_time,
|
||||
})),
|
||||
};
|
||||
definitions = [...definitions, created];
|
||||
return jsonResponse(created, 201);
|
||||
}
|
||||
const match = /^\/api\/v1\/admin\/quest-definitions\/(\d+)$/.exec(path);
|
||||
if (method === "PATCH" && match) {
|
||||
const id = Number(match[1]);
|
||||
definitions = definitions.map((d) =>
|
||||
d.id === id
|
||||
? {
|
||||
...d,
|
||||
title: body.title ?? d.title,
|
||||
skip_on_away: body.skip_on_away ?? d.skip_on_away,
|
||||
rule: body.rule ?? d.rule,
|
||||
// Like the API: a sent list replaces the whole set; an absent one keeps it.
|
||||
assignees:
|
||||
"assignee_child_ids" in body
|
||||
? CHILDREN.filter((c) => body.assignee_child_ids.includes(c.id)).map((c) => ({
|
||||
id: c.id,
|
||||
display_name: c.display_name,
|
||||
}))
|
||||
: d.assignees,
|
||||
}
|
||||
: d,
|
||||
);
|
||||
return jsonResponse(definitions.find((d) => d.id === id));
|
||||
}
|
||||
return jsonResponse({ detail: "unexpected" }, 500);
|
||||
});
|
||||
return {
|
||||
fetchMock,
|
||||
calls,
|
||||
writes: () => calls.filter((c) => c.method !== "GET"),
|
||||
listFetches: () =>
|
||||
calls.filter((c) => c.method === "GET" && c.path === "/api/v1/admin/quest-definitions")
|
||||
.length,
|
||||
failNextWrite: (response: Response) => {
|
||||
nextWrite = response;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let api: ReturnType<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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,853 @@
|
||||
/**
|
||||
* Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2).
|
||||
*
|
||||
* The icon picker (§3.3) and the occurrence preview are separate tasks: an
|
||||
* existing definition's icon is carried through unchanged by never sending it.
|
||||
*/
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { ApiForbiddenError } from "../api/client";
|
||||
import {
|
||||
ApiRequestError,
|
||||
createDefinition,
|
||||
fetchChildren,
|
||||
fetchDefinitions,
|
||||
updateDefinition,
|
||||
type AdminChild,
|
||||
type DefinitionCreateBody,
|
||||
type DefinitionEditBody,
|
||||
type DefinitionRule,
|
||||
type QuestDefinition,
|
||||
type WindowEntry,
|
||||
type WindowName,
|
||||
} from "../api/definitions";
|
||||
import {
|
||||
MONTH_SHORT,
|
||||
WEEKDAY_SHORT,
|
||||
WINDOW_LABELS,
|
||||
WINDOW_ORDER,
|
||||
assigneeSummary,
|
||||
recurrenceSummary,
|
||||
windowSummary,
|
||||
} from "./format";
|
||||
import {
|
||||
CheckGlyph,
|
||||
ChevronDownGlyph,
|
||||
ChevronRightGlyph,
|
||||
PlusGlyph,
|
||||
TaskGlyph,
|
||||
} from "./glyphs";
|
||||
import "./DefinitionsTab.css";
|
||||
|
||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||
export const SCROLL_BOTTOM_PADDING = "92px";
|
||||
|
||||
type Repeats = "daily" | "weekly" | "monthly" | "yearly" | "custom";
|
||||
|
||||
const REPEATS_OPTIONS: { value: Repeats; label: string }[] = [
|
||||
{ value: "daily", label: "Daily" },
|
||||
{ value: "weekly", label: "Weekly" },
|
||||
{ value: "monthly", label: "Monthly" },
|
||||
{ value: "yearly", label: "Yearly" },
|
||||
{ value: "custom", label: "Custom" },
|
||||
];
|
||||
|
||||
const REPEATS_BY_RULE: Record<DefinitionRule["rule_type"], Repeats> = {
|
||||
daily: "daily",
|
||||
weekly: "weekly",
|
||||
monthly_day: "monthly",
|
||||
monthly_weekday: "monthly",
|
||||
yearly: "yearly",
|
||||
custom_days: "custom",
|
||||
};
|
||||
|
||||
const WEEKDAY_LONG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
||||
|
||||
interface Draft {
|
||||
title: string;
|
||||
assigneeIds: number[];
|
||||
repeats: Repeats;
|
||||
interval: string;
|
||||
weekdays: number[];
|
||||
dayOfMonth: string;
|
||||
month: string;
|
||||
/** An existing "nth weekday" monthly rule, kept while Repeats stays Monthly. */
|
||||
monthlyWeekday: { nth: number; weekday: number } | null;
|
||||
windows: WindowName[];
|
||||
dueTimes: Record<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 ─────────────────────────────────────────────────────── */
|
||||
|
||||
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));
|
||||
|
||||
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}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 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(", ");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user