Compare commits

..
Author SHA1 Message Date
dev_agent eec8665610 Task 951bd4e0: Manage children screen (add/edit/active/reorder) (#222) 2026-09-24 14:55:54 -04:00
dev_agent ee6c1d6bc4 Feature 24: Admin PWA Hosting (#221)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-24 12:45:17 -04:00
orchestrator-agent 87e3f12f4c Merge pull request 'Reconcile dev from main (release 0.7.0)' (#219) from main into dev 2026-09-24 12:16:30 -04:00
overseer 9d741e75d0 Merge pull request 'Release: Admin PWA and admin API (0.7.0) (2026-09-24)' (#213) from release/2026-09-24-admin-pwa into main
Reviewed-on: #213
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-24 11:48:22 -04:00
overseer 7887d16f11 release: include Feature 17 (API deployment) in the 0.7.0 notes 2026-09-24 10:02:16 -04:00
overseer 03ee131c6e Merge dev into release/2026-09-24-admin-pwa (bring Feature 17 deployment) 2026-09-24 09:59:10 -04:00
dev_agent 2c6234be48 Feature 17: API Deployment and Identity (#217)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-24 09:57:07 -04:00
overseerandClaude Opus 5.5 7969c164c4 release: make the 0.7.0 rollback instructions API-host-aware
The API service owns and migrates the database at NESTQUEST_DB_PATH on the
API host; back up and restore it there, and scope the HACS/manual steps to
the Home Assistant integration component only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 00:14:42 -04:00
overseer 4b80fffe67 release: advance version to 0.7.0 and add the admin PWA release notes 2026-09-24 00:11:34 -04:00
dev_agent fe44740450 Feature 19: Admin PWA (#187-#210) (#211)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-24 00:08:58 -04:00
60 changed files with 8151 additions and 95 deletions
+27
View File
@@ -0,0 +1,27 @@
# Keep the image build context minimal: only api/ and the bundled
# custom_components/nestquest/core/ are copied (see the Dockerfile);
# everything else — VCS state, virtualenvs, tests, the frontend, and
# any local secrets or database files — never enters the build.
.git
.gitignore
.venv
**/__pycache__
**/*.py[cod]
.pytest_cache
**/*.db
**/*.db-wal
**/*.db-shm
# Local configuration — never baked into an image.
.env
**/.env
deploy
design
docs
frontend
orchestration_tests
scripts
templates
tests
uv.lock
hacs.json
*.md
+13
View File
@@ -5,3 +5,16 @@ __pycache__/
.pytest_cache/
*.metadata.json
frontend/node_modules/
# Feature 17 (API deployment): local secret config and runtime data
# are never committed — compose reads deploy/.env; SQLite/WAL sidecar
# files are container/volume artifacts.
.env
**/.env
*.db
*.db-wal
*.db-shm
# Dedicated Litestream SFTP backup keypair — lives only on the API box.
deploy/*key*
deploy/litestream_key*
+51
View File
@@ -0,0 +1,51 @@
# Container image for the NestQuest API service (Feature 17).
#
# Build context is the REPOSITORY ROOT (compose sets it), because the
# API loads the bundled domain layer from
# custom_components/nestquest/core/ by file location
# (api/nestquest_core.py): the image must carry both api/ and the
# bundled core at their relative positions. Everything else in the
# repository is excluded via .dockerignore.
#
# Multi-stage so no pip/build toolchain survives into the runtime:
# the builder installs the deployable's own manifest
# (api/requirements.txt) into a staging prefix and the runtime copies
# only the installed packages. The service runs as a non-root user;
# the SQLite database lives on the mounted volume at
# /var/lib/nestquest (compose supplies NESTQUEST_DB_PATH pointing
# there — configuration is environment-supplied, never baked in).
#
# Both stages use the same base image pinned by digest, and
# api/requirements.txt pins exact versions, so a rebuild reproduces
# the deployed image instead of floating to whatever is newest.
FROM python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9 AS builder
COPY api/requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir --prefix=/install -r /tmp/requirements.txt
FROM python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9
# The staged dependency install from the builder (no pip, no wheels).
COPY --from=builder /install /usr/local
# /app is the repository-root equivalent: api/ resolves imports as
# ``api.*`` and nestquest_core.py finds the bundled core one level up.
WORKDIR /app
COPY api/ ./api/
COPY custom_components/nestquest/core/ ./custom_components/nestquest/core/
# Non-root runtime user; owns the database volume mount point.
RUN useradd --system --create-home --home-dir /home/nestquest nestquest \
&& mkdir -p /var/lib/nestquest \
&& chown nestquest:nestquest /var/lib/nestquest
USER nestquest
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
# The database path and the service token / OIDC settings are supplied
# by the environment (compose env_file), never committed into the image.
ENV NESTQUEST_DB_PATH=/var/lib/nestquest/nestquest.db
VOLUME /var/lib/nestquest
EXPOSE 8080
CMD ["uvicorn", "--factory", "api.app:create_app", "--host", "0.0.0.0", "--port", "8080"]
+131
View File
@@ -1,5 +1,136 @@
# NestQuest Release Notes
## Version 0.7.0 — 2026-09-24
### Scope
Seventh release of NestQuest, promoting the re-imagined architecture's admin
surface from `dev` onto 0.6.0: the API deployment and identity layer, the
admin-plane API work, and the standalone admin PWA that uses them.
- **Feature 17 — API Deployment and Identity:** the NestQuest API is packaged as
a container (`Dockerfile`, `deploy/compose.yaml`, pinned base image and exact
dependencies) and deployed behind a TLS reverse proxy (the shared Traefik, ACME
via the Cloudflare DNS-01 challenge); the Authentik application, OIDC provider
and `nestquest-admins` group are wired and the API validates the provider's
issuer/audience/JWKS (RS256); the panel plane is restricted to the Home
Assistant box while the admin plane is internet-facing behind Authentik;
continuous backup runs as a contained Litestream sidecar over SFTP to a NAS and
a restore is proven; a deployment runbook (`deploy/RUNBOOK.md`) and the
host-firewall unit (`deploy/systemd/nestquest-api-firewall.service`) ship.
(PRs #183, #214, #215, #216, #218.)
- **Feature 19 — Admin PWA:** the parent-facing admin surface is now a
standalone mobile-first React progressive web app (`admin/`), implementing
`design/ADMIN-SPEC.md`: Authentik OIDC authorization-code + PKCE login with
an explicit refusal for a user outside `nestquest-admins`; an installable
PWA (manifest + service worker); and the Today, Definitions, Schedule and
History screens. Every mutating action goes through the API admin routes;
the API remains the security boundary (D-003/D-012). Today shows the
household snapshot; Definitions supports create/edit with multi-select
assignees, a recurrence rule with interval and per-window due times, a
skip-on-away toggle, a shipped Lucide icon subset and a backend-computed
occurrence preview; Schedule has a presence month grid (cycle position from
the anchor date, never ISO parity, D-004) and an override editor with a live
"removes N upcoming tasks" consequence count; History has All/Reversals/
Missed filters, day-grouped rows, per-period stats, CSV export and the
append-only footer. Undo reverses a completion, and live updates arrive over
the admin SSE stream without a manual reload. (PRs #187–#210, plus the
feature-review fix #212.)
- **Feature 21 — Admin Today Snapshot API:** `GET /api/v1/admin/snapshot`
serves the Today view's household snapshot on the admin plane (reusing the
core snapshot builder with missed instances included, D-009). (PRs #190/#191.)
- **Feature 22 — Admin Definitions Read and skip_on_away:**
`GET /api/v1/admin/quest-definitions` lists all definitions (active and
inactive), and a persisted per-definition `skip_on_away` boolean is accepted
by create/update and honoured by materialization. Adds an optional
`assignee_child_ids` to the edit route so the PWA can reassign children.
(PRs #193, #194, #196, #197.)
- **Feature 23 — Admin API Completion (preview, presence, live):** a bounded
rule occurrence-preview route; presence-schedule and presence-override
reads; a live override consequence count computed from the same core
generator the materializer uses; and an admin-plane transition SSE stream
(`GET /api/v1/admin/events`) off the one in-process publisher. (PRs
#199–#203.)
### Requirements
- Minimum Home Assistant version: **2024.6.0**.
- A running NestQuest API service, reachable at the base URL and panel service
token configured in the integration options. The deployable ships in this
release: `Dockerfile`, `deploy/compose.yaml` (API + a contained Litestream
backup sidecar), `api/requirements.txt`, the host-firewall unit and
`deploy/RUNBOOK.md`. Deploying it needs a host, the DNS/TLS path and the
Authentik/Synology services described in the runbook.
- The admin PWA is built to run at `https://nestquest.cubecraftlabs.com` behind
Authentik. Serving the built `admin/` static bundle at that hostname is part of
the deployment described in the runbook.
### Breaking changes
- **Schema version advances 7 → 8.** Migration 8 adds the additive
`skip_on_away` column to `quest_definitions` (default 1, so every existing
definition keeps today's skip-when-absent behaviour). The migration runs on
the first start of this version.
- **Once migrated, a plain downgrade to 0.6.0 is impossible** — older builds
refuse a newer schema version. **Back up `nestquest.db` (plus `-wal`/`-shm`)
before upgrading**; see Rollback.
- No Home Assistant entity, service, event or automation surface changes in
this release. The API's new admin routes are additive.
### Known Limitations
- **The API and its identity/backup layer are deployed by the owner's
infrastructure, not by this release alone.** The deployment artifacts and
runbook ship here, but a running service still requires the host, the shared
reverse proxy, the Authentik application/provider/`nestquest-admins` group, the
backup target and the panel/admin network separation to be in place as the
runbook describes.
- **Serving the admin PWA is still outstanding.** `admin/` builds and is
unit/integration tested and the API it talks to is live, but the built static
bundle must still be hosted at `https://nestquest.cubecraftlabs.com` behind
Authentik; until then the PWA is not reachable end-to-end.
- **Feature 12's TouchHub/DAKOS runbook task remains physically unverified**
(needs the wall panel).
- Moving the household database from Home Assistant to the API box is a manual
file copy (the schema is unchanged by that move beyond the v8 migration).
- Single household; single API service.
### Rollback
**Schema version advances 7 → 8.** Migration 8 is additive, but a migrated
database **cannot** be read by 0.6.0 or earlier (they refuse a newer schema
version). The database is owned and migrated by the **NestQuest API service**,
not by Home Assistant: it is the file at the API's configured
`NESTQUEST_DB_PATH` on the API host, which need not be anywhere near the Home
Assistant config directory. To return to 0.6.0 you must restore a backup of
that file taken before the upgrade; a plain downgrade is impossible once
migrated.
**API host (database):**
1. Before upgrading, stop the NestQuest API service and back up the database
file at its configured `NESTQUEST_DB_PATH`, plus its `-wal` and `-shm`
sidecars (`<NESTQUEST_DB_PATH>-wal`, `<NESTQUEST_DB_PATH>-shm`) if present.
Only then deploy 0.7.0 and start the API service, which applies migration 8.
2. To roll back: stop the API service, restore the backed-up database and
sidecars to `NESTQUEST_DB_PATH`, redeploy the API service at 0.6.0, and roll
back the Home Assistant integration to 0.6.0 (below). Then restart the API
service and Home Assistant.
**Home Assistant integration — HACS installs** (integration component only;
the database lives with the API, see above):
1. To roll back: HACS > Integrations > NestQuest > Redownload > 0.6.0, then
restart Home Assistant.
**Home Assistant integration — Manual installs** (integration component only;
the database lives with the API, see above):
1. To roll back: replace `custom_components/nestquest/` with the 0.6.0 tree,
then restart Home Assistant.
**Repository operators:** git-revert the 0.7.0 release merge on `main`.
## Version 0.6.0 — 2026-09-23
### Scope
+10
View File
@@ -8,6 +8,7 @@
"name": "nestquest-admin",
"version": "0.0.0",
"dependencies": {
"lucide-react": "^1.47.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
@@ -2257,6 +2258,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.47.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.47.0.tgz",
"integrity": "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+2 -1
View File
@@ -10,6 +10,7 @@
"test": "vitest run"
},
"dependencies": {
"lucide-react": "^1.47.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
@@ -24,4 +25,4 @@
"vite": "^7.1.5",
"vitest": "^3.2.4"
}
}
}
-11
View File
@@ -48,17 +48,6 @@
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;
+2 -3
View File
@@ -29,9 +29,8 @@ describe("NestQuest Admin shell", () => {
fireEvent.click(screen.getByRole("tab", { name: "History" }));
expect(screen.getByRole("tabpanel").textContent).toContain(
"History placeholder",
);
expect(screen.getByRole("heading", { name: "History" })).toBeTruthy();
expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder");
expect(screen.getByRole("tab", { name: "History" }).getAttribute("aria-selected")).toBe(
"true",
);
+43 -40
View File
@@ -1,6 +1,9 @@
import { useState } from "react";
import "./App.css";
import { TransitionsProvider } from "./api/useTransitions";
import DefinitionsTab from "./definitions/DefinitionsTab";
import HistoryTab from "./history/HistoryTab";
import ScheduleTab from "./schedule/ScheduleTab";
import TodayTab from "./today/TodayTab";
const TABS = [
@@ -14,47 +17,47 @@ 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>
<TransitionsProvider>
<div className="admin-shell">
{/* Every tab is a built screen that owns its header (ADMIN-SPEC §1); the
generic shell header stays only as a visually hidden page heading. */}
<header className="admin-header visually-hidden">
<h1 className="admin-title">NestQuest Admin</h1>
<p className="admin-subline">Admin console</p>
</header>
<main
className="admin-panel admin-panel--screen"
role="tabpanel"
aria-labelledby={`tab-${active}`}
>
{active === "today" ? (
<TodayTab />
) : active === "tasks" ? (
<DefinitionsTab />
) : active === "schedule" ? (
<ScheduleTab />
) : (
<HistoryTab />
)}
</main>
<nav className="admin-tabbar" aria-label="Admin sections">
{TABS.map((tab) => (
<button
key={tab.id}
id={`tab-${tab.id}`}
type="button"
role="tab"
aria-selected={tab.id === active}
className={tab.id === active ? "admin-tab active" : "admin-tab"}
onClick={() => setActive(tab.id)}
>
{tab.label}
</button>
))}
</nav>
</div>
</TransitionsProvider>
);
}
+121 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import AppRoutes from "./AppRoutes";
import { storeTokens } from "./auth/oidc";
import { config } from "./config";
@@ -32,6 +32,32 @@ describe("AppRoutes auth gating", () => {
).toBeTruthy();
});
function probeCalls(fetchMock: ReturnType<typeof vi.fn>): string[] {
return fetchMock.mock.calls
.map(([url]) => String(url))
.filter((url) => url.includes("/api/"));
}
it("probes exactly GET /api/v1/admin/ping and no non-admin path", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 500 }));
vi.stubGlobal("fetch", fetchMock);
render(<AppRoutes />);
await waitFor(() => {
expect(screen.getByTestId("probe-error-screen")).toBeTruthy();
});
const calls = probeCalls(fetchMock);
expect(calls).toEqual([`${config.apiBaseUrl}/api/v1/admin/ping`]);
const [, init] = fetchMock.mock.calls[0];
expect(init.method ?? "GET").toBe("GET");
expect(init.headers.Authorization).toBe("Bearer at-123");
for (const url of calls) {
expect(new URL(url, "http://localhost").pathname.startsWith("/api/v1/admin/")).toBe(true);
}
});
it("renders the four-tab shell after a successful authenticated probe", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
@@ -45,6 +71,9 @@ describe("AppRoutes auth gating", () => {
for (const label of ["Today", "Tasks", "Schedule", "History"]) {
expect(screen.getByRole("tab", { name: label })).toBeTruthy();
}
expect(String(fetchMock.mock.calls[0][0])).toBe(
`${config.apiBaseUrl}/api/v1/admin/ping`,
);
});
it("renders the explicit refusal when the API answers 403", async () => {
@@ -58,6 +87,97 @@ describe("AppRoutes auth gating", () => {
expect(screen.getByTestId("refusal-screen")).toBeTruthy();
});
expect(screen.getByText(/not in the nestquest-admins group/i)).toBeTruthy();
expect(screen.queryByRole("tab", { name: "Today" })).toBeNull();
});
it("keeps the login flow when the probe answers 401", async () => {
storeTokens({ access_token: "stale", expires_in: 600 });
const assign = vi.fn();
vi.stubGlobal("location", { ...window.location, assign });
const fetchMock = vi.fn().mockImplementation(async (url: string) =>
String(url).includes("/api/v1/admin/ping")
? new Response("", { status: 401 })
: jsonResponse({
issuer: config.issuer,
authorization_endpoint: "https://auth.example.com/authorize/",
token_endpoint: "https://auth.example.com/token/",
userinfo_endpoint: "https://auth.example.com/userinfo/",
end_session_endpoint: "https://auth.example.com/end-session/",
jwks_uri: "https://auth.example.com/jwks/",
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<AppRoutes />);
await waitFor(() => {
expect(screen.getByTestId("login-screen")).toBeTruthy();
});
expect(assign).toHaveBeenCalledTimes(1);
const redirect = new URL(assign.mock.calls[0][0] as string);
expect(redirect.origin + redirect.pathname).toBe("https://auth.example.com/authorize/");
expect(probeCalls(fetchMock)).toEqual([`${config.apiBaseUrl}/api/v1/admin/ping`]);
expect(screen.queryByRole("tab", { name: "Today" })).toBeNull();
expect(screen.queryByTestId("probe-error-screen")).toBeNull();
});
for (const status of [404, 500]) {
it(`shows the error screen, not the shell, when the probe answers ${status}`, async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status }));
vi.stubGlobal("fetch", fetchMock);
render(<AppRoutes />);
await waitFor(() => {
expect(screen.getByTestId("probe-error-screen")).toBeTruthy();
});
expect(screen.getByText(new RegExp(`answered ${status}`))).toBeTruthy();
expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Sign out" })).toBeTruthy();
expect(screen.queryByRole("tab", { name: "Today" })).toBeNull();
expect(screen.queryByTestId("refusal-screen")).toBeNull();
});
}
it("shows the error screen, not the shell, when the probe request fails", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockRejectedValue(new TypeError("Failed to fetch"));
vi.stubGlobal("fetch", fetchMock);
render(<AppRoutes />);
await waitFor(() => {
expect(screen.getByTestId("probe-error-screen")).toBeTruthy();
});
expect(screen.getByText(/could not be reached/i)).toBeTruthy();
expect(screen.queryByRole("tab", { name: "Today" })).toBeNull();
});
it("re-probes on retry and enters the shell once the probe succeeds", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
// After the retry the ping succeeds; the shell's own tab loads stay pending.
let pings = 0;
const fetchMock = vi.fn().mockImplementation((url: string) => {
if (!String(url).endsWith("/api/v1/admin/ping")) return new Promise(() => {});
pings += 1;
return Promise.resolve(new Response("", { status: pings === 1 ? 500 : 200 }));
});
vi.stubGlobal("fetch", fetchMock);
render(<AppRoutes />);
await waitFor(() => {
expect(screen.getByTestId("probe-error-screen")).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(screen.getByRole("tab", { name: "Today" })).toBeTruthy();
});
expect(String(fetchMock.mock.calls[1][0])).toBe(
`${config.apiBaseUrl}/api/v1/admin/ping`,
);
});
it("shows the configuration error screen when the client id is missing", () => {
+43 -8
View File
@@ -12,7 +12,7 @@ import {
handleCallback,
isAuthenticated,
} from "./auth/oidc";
import { ApiForbiddenError, apiFetch } from "./api/client";
import { ApiForbiddenError, ApiUnauthorizedError, apiFetch } from "./api/client";
import AdminShell from "./App";
type Screen =
@@ -21,18 +21,28 @@ type Screen =
| { kind: "config-error"; message: string }
| { kind: "auth-error"; message: string }
| { kind: "refusal"; message: string }
| { kind: "probe-error"; message: string }
| { kind: "app" };
function currentPath(): string {
return window.location.pathname.replace(/\/+$/, "") || "/";
}
const ADMIN_PROBE_PATH = "/api/v1/admin/ping";
async function probeApi(setScreen: (screen: Screen) => void) {
// A cheap authenticated probe so a 403 surfaces as an explicit refusal
// right after login instead of a silent empty shell.
// The admin plane's authorization probe: only a 2xx enters the shell, so a
// non-admin (403) sees the explicit refusal and any other failure an error.
try {
await apiFetch("/api/v1/children", {}, config);
setScreen({ kind: "app" });
const response = await apiFetch(ADMIN_PROBE_PATH, {}, config);
if (response.ok) {
setScreen({ kind: "app" });
return;
}
setScreen({
kind: "probe-error",
message: `The admin API answered ${response.status}. The admin console cannot load.`,
});
} catch (error) {
if (error instanceof ConfigError) {
setScreen({ kind: "config-error", message: error.message });
@@ -42,9 +52,15 @@ async function probeApi(setScreen: (screen: Screen) => void) {
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" });
if (error instanceof ApiUnauthorizedError) {
// apiFetch has already started the login redirect.
setScreen({ kind: "login" });
return;
}
setScreen({
kind: "probe-error",
message: "The admin API could not be reached. Check your connection and try again.",
});
}
}
@@ -170,6 +186,25 @@ export default function AppRoutes() {
</button>
</div>
);
case "probe-error":
return (
<div className="admin-error" role="alert" data-testid="probe-error-screen">
<h1>Admin console unavailable</h1>
<p>{screen.message}</p>
<button
type="button"
onClick={() => {
setScreen({ kind: "loading" });
void probeApi(setScreen);
}}
>
Retry
</button>
<button type="button" onClick={() => { clearAuth(); window.history.replaceState({}, "", "/"); setScreen({ kind: "login" }); }}>
Sign out
</button>
</div>
);
case "app":
return <AdminShell />;
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Typed client for the admin child-profile write routes
* (/api/v1/admin/children). The list read is `fetchChildren` in ./definitions.
*/
import { apiFetch } from "./client";
import { CHILDREN_PATH, readJson, type AdminChild } from "./definitions";
export interface ChildCreateBody {
display_name: string;
colour?: string;
avatar_ref?: string;
sort_order?: number;
}
/**
* Omitted fields stay unchanged. The API rejects an explicit null, so a set
* colour or avatar cannot be cleared through this route.
*/
export type ChildEditBody = Partial<ChildCreateBody>;
export const CHILDREN_REORDER_PATH = `${CHILDREN_PATH}/reorder`;
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 createChild(body: ChildCreateBody): Promise<AdminChild> {
return readJson<AdminChild>(await sendJson(CHILDREN_PATH, "POST", body));
}
export async function updateChild(id: number, body: ChildEditBody): Promise<AdminChild> {
return readJson<AdminChild>(await sendJson(`${CHILDREN_PATH}/${id}`, "PATCH", body));
}
export async function setChildActive(id: number, isActive: boolean): Promise<AdminChild> {
return readJson<AdminChild>(
await sendJson(`${CHILDREN_PATH}/${id}/active`, "PATCH", { is_active: isActive }),
);
}
/** `orderedIds` must be every child id in the household, in the new order. */
export async function reorderChildren(orderedIds: number[]): Promise<void> {
await readJson<{ status: string }>(
await sendJson(CHILDREN_REORDER_PATH, "POST", { ordered_ids: orderedIds }),
);
}
+1 -1
View File
@@ -103,7 +103,7 @@ function readDetail(body: unknown): string | null {
return null;
}
async function readJson<T>(response: Response): Promise<T> {
export async function readJson<T>(response: Response): Promise<T> {
if (!response.ok) {
let body: unknown = null;
try {
+162
View File
@@ -0,0 +1,162 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { waitFor } from "@testing-library/react";
import { EVENTS_PATH, SseParser, openTransitionStream, type TransitionEvent } from "./events";
import { frame, testStream, type TestStream } from "./testStream";
import { storeTokens } from "../auth/oidc";
const COMPLETED = {
child_id: 1,
child_name: "Declan",
instance_id: 12,
quest_title: "Make bed",
window: "morning",
due_date: "2026-09-23",
due_time: null,
occurred_at: "2026-09-23T11:04:00Z",
};
const DAY_COMPLETE = {
child_id: 2,
child_name: "Maeve",
quests_due: 4,
quests_completed: 4,
occurred_at: "2026-09-23T12:00:00Z",
};
describe("SseParser", () => {
it("yields every frame from one chunk", () => {
const parser = new SseParser();
const events = parser.push(
frame("nestquest_quest_completed", COMPLETED) +
frame("nestquest_child_day_complete", DAY_COMPLETE),
);
expect(events).toEqual([
{ type: "nestquest_quest_completed", payload: COMPLETED },
{ type: "nestquest_child_day_complete", payload: DAY_COMPLETE },
]);
});
it("buffers a frame split across two chunks", () => {
const parser = new SseParser();
const text = frame("nestquest_quest_uncompleted", COMPLETED);
const cut = text.indexOf("Make");
expect(parser.push(text.slice(0, cut))).toEqual([]);
expect(parser.push(text.slice(cut))).toEqual([
{ type: "nestquest_quest_uncompleted", payload: COMPLETED },
]);
});
it("buffers a split inside the event name and the blank-line terminator", () => {
const parser = new SseParser();
const text = frame("nestquest_quest_missed", COMPLETED);
expect(parser.push(text.slice(0, 12))).toEqual([]);
expect(parser.push(text.slice(12, -1))).toEqual([]);
expect(parser.push(text.slice(-1))).toEqual([
{ type: "nestquest_quest_missed", payload: COMPLETED },
]);
});
it("treats a CRLF split across chunks as one line break", () => {
const parser = new SseParser();
const text = `event: nestquest_quest_completed\r\ndata: ${JSON.stringify(COMPLETED)}\r\n\r\n`;
const cut = text.indexOf("\n"); // chunk ends on "\r"
expect(parser.push(text.slice(0, cut))).toEqual([]);
expect(parser.push(text.slice(cut))).toEqual([
{ type: "nestquest_quest_completed", payload: COMPLETED },
]);
});
it("drops malformed, unknown and comment frames without throwing", () => {
const parser = new SseParser();
const events = parser.push(
"event: nestquest_quest_completed\ndata: not-json\n\n" +
"event: nestquest_quest_completed\ndata: [1,2]\n\n" +
"event: something_else\ndata: {}\n\n" +
": keep-alive\n\n" +
"data: {}\n\n" +
frame("nestquest_quest_completed", COMPLETED),
);
expect(events).toEqual([{ type: "nestquest_quest_completed", payload: COMPLETED }]);
});
});
describe("openTransitionStream", () => {
let fetchMock: ReturnType<typeof vi.fn>;
let streams: TestStream[];
let stop: (() => void) | null;
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
streams = [];
stop = null;
fetchMock = vi.fn(() => {
const stream = testStream();
streams.push(stream);
return Promise.resolve(stream.response);
});
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
stop?.();
vi.unstubAllGlobals();
});
it("sends the token in the Authorization header, never in the URL", async () => {
stop = openTransitionStream({ onTransition: () => {} });
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toMatch(new RegExp(`${EVENTS_PATH}$`));
expect(String(url)).not.toContain("at-123");
expect(init.headers.Authorization).toBe("Bearer at-123");
expect(init.headers.Accept).toBe("text/event-stream");
});
it("delivers transitions from frames split across chunks", async () => {
const seen: TransitionEvent[] = [];
stop = openTransitionStream({ onTransition: (event) => seen.push(event) });
await waitFor(() => expect(streams).toHaveLength(1));
const text = frame("nestquest_quest_completed", COMPLETED);
streams[0].push(text.slice(0, 20));
streams[0].push(text.slice(20));
await waitFor(() =>
expect(seen).toEqual([{ type: "nestquest_quest_completed", payload: COMPLETED }]),
);
});
it("stop aborts the request and cancels the body", async () => {
stop = openTransitionStream({ onTransition: () => {} });
await waitFor(() => expect(streams).toHaveLength(1));
const signal = fetchMock.mock.calls[0][1].signal as AbortSignal;
stop();
expect(signal.aborted).toBe(true);
await waitFor(() => expect(streams[0].cancelled()).toBe(true));
});
it("reconnects after the stream drops", async () => {
const seen: TransitionEvent[] = [];
stop = openTransitionStream({ onTransition: (event) => seen.push(event), minDelayMs: 5 });
await waitFor(() => expect(streams).toHaveLength(1));
streams[0].close();
await waitFor(() => expect(streams).toHaveLength(2));
streams[1].push(frame("nestquest_quest_missed", COMPLETED));
await waitFor(() => expect(seen).toHaveLength(1));
});
it("does not reconnect after stop, even with a reconnect pending", async () => {
stop = openTransitionStream({ onTransition: () => {}, minDelayMs: 20 });
await waitFor(() => expect(streams).toHaveLength(1));
streams[0].close();
stop();
await new Promise((resolve) => setTimeout(resolve, 60));
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("gives up on a 403 refusal instead of retrying", async () => {
fetchMock.mockImplementation(() => Promise.resolve(new Response("", { status: 403 })));
stop = openTransitionStream({ onTransition: () => {}, minDelayMs: 5 });
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
await new Promise((resolve) => setTimeout(resolve, 40));
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
+196
View File
@@ -0,0 +1,196 @@
/**
* Live transition stream: GET /api/v1/admin/events (text/event-stream).
*
* The admin JWT travels in the Authorization header (via apiFetch), so the
* stream is read with fetch + a ReadableStream reader — EventSource cannot
* set headers, and the token must never go into a URL.
*/
import { ApiForbiddenError, ApiUnauthorizedError, apiFetch } from "./client";
export const EVENTS_PATH = "/api/v1/admin/events";
/** The four documented transitions, as the API names them on the wire. */
export const TRANSITION_TYPES = [
"nestquest_quest_completed",
"nestquest_quest_uncompleted",
"nestquest_quest_missed",
"nestquest_child_day_complete",
] as const;
export type TransitionType = (typeof TRANSITION_TYPES)[number];
/** Quest transitions carry the instance; a day-complete carries the child's counts. */
export interface QuestTransitionPayload {
child_id: number;
child_name: string | null;
instance_id: number;
quest_title: string | null;
window: string;
due_date: string;
due_time: string | null;
occurred_at: string;
}
export interface ChildDayCompletePayload {
child_id: number;
child_name: string | null;
quests_due: number;
quests_completed: number;
occurred_at: string;
}
export type TransitionEvent =
| {
type:
| "nestquest_quest_completed"
| "nestquest_quest_uncompleted"
| "nestquest_quest_missed";
payload: QuestTransitionPayload;
}
| { type: "nestquest_child_day_complete"; payload: ChildDayCompletePayload };
function isTransitionType(value: string): value is TransitionType {
return (TRANSITION_TYPES as readonly string[]).includes(value);
}
/**
* Incremental SSE frame parser. Feed it decoded text in whatever chunks the
* network delivers; it buffers a partial line or frame until it completes.
* Comments (keep-alives), unknown event types and frames whose data is not a
* JSON object are dropped, never thrown.
*/
export class SseParser {
private buffer = "";
private eventType = "";
private data: string[] = [];
/** A chunk ended on "\r": a leading "\n" in the next chunk is the same line break. */
private pendingCr = false;
push(text: string): TransitionEvent[] {
if (this.pendingCr && text.startsWith("\n")) text = text.slice(1);
this.pendingCr = text.endsWith("\r");
this.buffer += text;
const lines = this.buffer.split(/\r\n|\r|\n/);
this.buffer = lines.pop() ?? "";
const events: TransitionEvent[] = [];
for (const line of lines) {
const event = this.line(line);
if (event) events.push(event);
}
return events;
}
private line(line: string): TransitionEvent | null {
if (line === "") return this.dispatch();
if (line.startsWith(":")) return null;
const colon = line.indexOf(":");
const field = colon === -1 ? line : line.slice(0, colon);
let value = colon === -1 ? "" : line.slice(colon + 1);
if (value.startsWith(" ")) value = value.slice(1);
if (field === "event") this.eventType = value;
else if (field === "data") this.data.push(value);
return null;
}
private dispatch(): TransitionEvent | null {
const type = this.eventType;
const data = this.data.join("\n");
this.eventType = "";
this.data = [];
if (!isTransitionType(type)) return null;
let payload: unknown;
try {
payload = JSON.parse(data);
} catch {
return null;
}
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
return { type, payload } as TransitionEvent;
}
}
export interface TransitionStreamOptions {
onTransition: (event: TransitionEvent) => void;
/** Reconnect delay after a drop: starts at `minDelayMs`, doubles up to `maxDelayMs`. */
minDelayMs?: number;
maxDelayMs?: number;
}
function wait(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(done, ms);
signal.addEventListener("abort", done, { once: true });
function done() {
clearTimeout(timer);
signal.removeEventListener("abort", done);
resolve();
}
});
}
/** Read one connection until the body ends (or the signal aborts). */
async function readOnce(
signal: AbortSignal,
onTransition: (event: TransitionEvent) => void,
): Promise<void> {
const response = await apiFetch(EVENTS_PATH, {
signal,
headers: { Accept: "text/event-stream" },
});
if (!response.ok || !response.body) {
throw new Error(`Event stream request failed (HTTP ${response.status}).`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
const parser = new SseParser();
const cancel = () => void reader.cancel().catch(() => {});
signal.addEventListener("abort", cancel, { once: true });
try {
for (;;) {
const { value, done } = await reader.read();
if (done || signal.aborted) break;
for (const event of parser.push(decoder.decode(value, { stream: true }))) {
if (signal.aborted) break;
onTransition(event);
}
}
} finally {
signal.removeEventListener("abort", cancel);
reader.releaseLock();
}
}
/**
* Subscribe to the admin transition stream, reconnecting with a bounded
* backoff after a drop. Returns a stop function that aborts the open request
* and any pending reconnect. A 401/403 stops the stream for good: apiFetch
* has already started a login (401) or the account is refused (403).
*/
export function openTransitionStream({
onTransition,
minDelayMs = 1_000,
maxDelayMs = 30_000,
}: TransitionStreamOptions): () => void {
const controller = new AbortController();
const { signal } = controller;
void (async () => {
let delay = minDelayMs;
while (!signal.aborted) {
try {
await readOnce(signal, onTransition);
// A connection that was accepted and then ended starts the backoff over.
delay = minDelayMs;
} catch (error: unknown) {
if (signal.aborted) return;
if (error instanceof ApiUnauthorizedError || error instanceof ApiForbiddenError) return;
// Dropped or refused connection: fall through to the backoff.
}
if (signal.aborted) return;
await wait(delay, signal);
delay = Math.min(delay * 2, maxDelayMs);
}
})();
return () => controller.abort();
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Typed client for the admin history route (/api/v1/admin/history): the
* append-only event log over a closed due-date range for one filter.
*/
import { apiFetch } from "./client";
import { ApiRequestError, readJson } from "./definitions";
export type HistoryFilter = "all" | "reversals" | "missed";
export type HistoryEventType = "completed" | "uncompleted" | "missed";
export interface HistoryRow {
/** UTC ISO stamp; null for a derived missed row (there is no event). */
occurred_at: string | null;
event_type: HistoryEventType;
child_name: string;
child_id: number;
quest_title: string;
instance_id: number;
window: string;
/** "YYYY-MM-DD", the day the task was owed. */
due_date: string;
/** Local wall-clock "HH:MM", or null. */
due_time: string | null;
/** e.g. "panel (Declan)", "joshua (admin)", "nightly sweep". */
actor: string;
was_on_time: boolean | null;
}
export interface HistoryResponse {
filter: HistoryFilter;
start: string;
end: string;
count: number;
rows: HistoryRow[];
}
export const HISTORY_PATH = "/api/v1/admin/history";
/** `start`/`end` are inclusive "YYYY-MM-DD" due dates. */
export async function fetchHistory(
filter: HistoryFilter,
start: string,
end: string,
): Promise<HistoryResponse> {
const params = new URLSearchParams({ start, end, filter });
return readJson<HistoryResponse>(await apiFetch(`${HISTORY_PATH}?${params.toString()}`));
}
export const HISTORY_CSV_PATH = "/api/v1/admin/history.csv";
export interface HistoryCsv {
blob: Blob;
filename: string;
}
/** The `filename` from a Content-Disposition header, or null. */
export function csvFilename(disposition: string | null): string | null {
const match = disposition?.match(/filename="?([^";]+)"?/i);
return match ? match[1].trim() : null;
}
/**
* The CSV export of the same range and filter as `fetchHistory`. Goes through
* apiFetch so the admin token travels in the Authorization header.
*/
export async function exportHistoryCsv(
filter: HistoryFilter,
start: string,
end: string,
): Promise<HistoryCsv> {
const params = new URLSearchParams({ start, end, filter });
const response = await apiFetch(`${HISTORY_CSV_PATH}?${params.toString()}`);
if (!response.ok) {
// readJson raises ApiRequestError with the API's `detail` for a non-OK response.
await readJson<never>(response);
throw new ApiRequestError(response.status, null);
}
return {
blob: await response.blob(),
filename:
csvFilename(response.headers.get("Content-Disposition")) ??
`nestquest-history-${start}-to-${end}.csv`,
};
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Typed client for the admin instance mutation route
* (POST /api/v1/admin/instances/{id}/uncomplete).
*/
import { apiFetch } from "./client";
import { readJson } from "./definitions";
export interface UncompleteResult {
instance_id: number;
/** The derived state after the reversal: "open", or "missed" when past due. */
state: string;
/** False when the instance was already open (a no-op). */
appended: boolean;
}
export function uncompletePath(instanceId: number): string {
return `/api/v1/admin/instances/${instanceId}/uncomplete`;
}
/** Reverse one completion; raises ApiRequestError with the API's detail on failure. */
export async function uncompleteInstance(instanceId: number): Promise<UncompleteResult> {
return readJson<UncompleteResult>(await apiFetch(uncompletePath(instanceId), { method: "POST" }));
}
+126
View File
@@ -0,0 +1,126 @@
/**
* Typed client for the admin presence routes: a child's repeating schedule
* (/api/v1/admin/children/{id}/presence-schedule) and the household's
* date-range overrides (/api/v1/admin/presence-overrides).
*/
import { apiFetch } from "./client";
import { CHILDREN_PATH, readJson } from "./definitions";
/**
* Cycle week index ("0".."n-1", JSON object keys) -> present weekdays
* (0 = Monday .. 6 = Sunday). An empty list is away that whole week.
*/
export type PresencePattern = Record<string, number[]>;
export interface PresenceScheduleBody {
cycle_length_weeks: number;
/** "YYYY-MM-DD"; pins cycle week 0. */
anchor_date: string;
pattern: PresencePattern;
}
export interface PresenceSchedule extends PresenceScheduleBody {
child_id: number;
}
export interface PresenceOverride {
id: number;
child_id: number;
start_date: string;
end_date: string;
is_present: boolean;
note: string | null;
}
export interface PresenceOverrideFilter {
childId?: number;
/** "YYYY-MM-DD"; keeps overrides overlapping [start, end]. */
start?: string;
end?: string;
}
export const PRESENCE_OVERRIDES_PATH = "/api/v1/admin/presence-overrides";
export function presenceSchedulePath(childId: number): string {
return `${CHILDREN_PATH}/${childId}/presence-schedule`;
}
/** The child's schedule, or null when it has none (present every day). */
export async function fetchPresenceSchedule(childId: number): Promise<PresenceSchedule | null> {
const body = await readJson<{ schedule: PresenceSchedule | null }>(
await apiFetch(presenceSchedulePath(childId)),
);
return body.schedule;
}
/** Set (upsert) the child's schedule; resolves to the schedule as stored. */
export async function savePresenceSchedule(
childId: number,
body: PresenceScheduleBody,
): Promise<PresenceSchedule> {
return readJson<PresenceSchedule>(
await apiFetch(presenceSchedulePath(childId), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
}
export async function fetchPresenceOverrides(
filter: PresenceOverrideFilter = {},
): Promise<PresenceOverride[]> {
const params = new URLSearchParams();
if (filter.childId !== undefined) params.set("child_id", String(filter.childId));
if (filter.start !== undefined) params.set("start", filter.start);
if (filter.end !== undefined) params.set("end", filter.end);
const query = params.toString();
const body = await readJson<{ overrides: PresenceOverride[] }>(
await apiFetch(query ? `${PRESENCE_OVERRIDES_PATH}?${query}` : PRESENCE_OVERRIDES_PATH),
);
return body.overrides;
}
export interface PresenceOverrideRange {
child_id: number;
/** "YYYY-MM-DD", inclusive. */
start_date: string;
/** "YYYY-MM-DD", inclusive; never before start_date. */
end_date: string;
is_present: boolean;
}
export interface PresenceOverrideBody extends PresenceOverrideRange {
note?: string;
}
export const PRESENCE_OVERRIDE_CONSEQUENCE_PATH = `${PRESENCE_OVERRIDES_PATH}/consequence`;
function postJson(path: string, body: unknown): Promise<Response> {
return apiFetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
/**
* How many open upcoming tasks saving this override would remove. Computed
* by the API before anything is saved; completed instances never count.
*/
export async function previewOverrideConsequence(range: PresenceOverrideRange): Promise<number> {
const body = await readJson<{ removed: number }>(
await postJson(PRESENCE_OVERRIDE_CONSEQUENCE_PATH, range),
);
return body.removed;
}
export async function createPresenceOverride(body: PresenceOverrideBody): Promise<PresenceOverride> {
return readJson<PresenceOverride>(await postJson(PRESENCE_OVERRIDES_PATH, body));
}
export async function deletePresenceOverride(overrideId: number): Promise<void> {
await readJson<{ status: string }>(
await apiFetch(`${PRESENCE_OVERRIDES_PATH}/${overrideId}`, { method: "DELETE" }),
);
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Test helper: a controllable text/event-stream Response whose body the test
* feeds chunk by chunk (so a frame can be split across chunk boundaries).
*/
export interface TestStream {
response: Response;
push(text: string): void;
close(): void;
/** True once the reader cancelled the body (the stream was torn down). */
cancelled(): boolean;
}
export function testStream(): TestStream {
const encoder = new TextEncoder();
let controller!: ReadableStreamDefaultController<Uint8Array>;
let cancelled = false;
const body = new ReadableStream<Uint8Array>({
start(c) {
controller = c;
},
cancel() {
cancelled = true;
},
});
return {
response: new Response(body, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
}),
push: (text) => controller.enqueue(encoder.encode(text)),
close: () => controller.close(),
cancelled: () => cancelled,
};
}
/** One SSE frame exactly as api/sse.py writes it. */
export function frame(type: string, payload: unknown): string {
return `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`;
}
+61
View File
@@ -0,0 +1,61 @@
/**
* One live transition stream per signed-in shell, fanned out to the screens.
*
* <TransitionsProvider> opens the stream on mount and aborts it on unmount;
* useTransitions(handler) registers a screen's handler for as long as the
* screen is mounted. Without a provider the hook does nothing, so a screen
* rendered on its own (e.g. in a test) never opens a stream.
*/
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
import { openTransitionStream, type TransitionEvent } from "./events";
type Listener = (event: TransitionEvent) => void;
interface TransitionHub {
subscribe(listener: Listener): () => void;
}
const TransitionsContext = createContext<TransitionHub | null>(null);
export function TransitionsProvider({ children }: { children: ReactNode }) {
const [hub] = useState(() => {
const listeners = new Set<Listener>();
return {
listeners,
subscribe(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
});
useEffect(
() =>
openTransitionStream({
onTransition: (event) => {
for (const listener of [...hub.listeners]) {
try {
listener(event);
} catch {
// One screen's handler must not tear down the shared stream.
}
}
},
}),
[hub],
);
return <TransitionsContext.Provider value={hub}>{children}</TransitionsContext.Provider>;
}
/** Call `handler` for every transition while the calling component is mounted. */
export function useTransitions(handler: Listener): void {
const hub = useContext(TransitionsContext);
const handlerRef = useRef(handler);
useEffect(() => {
handlerRef.current = handler;
});
useEffect(() => hub?.subscribe((event) => handlerRef.current(event)), [hub]);
}
+24
View File
@@ -414,6 +414,30 @@
gap: 6px;
}
.defs-icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--nq-a-tap-min), 1fr));
gap: 6px;
}
.defs-icon {
display: flex;
align-items: center;
justify-content: center;
min-height: var(--nq-a-tap-min);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
cursor: pointer;
}
.defs-icon--on {
border-color: transparent;
background: var(--nq-brand-gradient);
color: #fff;
}
.defs-due-times {
display: flex;
flex-direction: column;
+171 -4
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import DefinitionsTab from "./DefinitionsTab";
import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons";
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
import { storeTokens } from "../auth/oidc";
@@ -134,7 +135,7 @@ function fakeApi(initial: QuestDefinition[]) {
id: 99,
title: body.title,
description: null,
icon: null,
icon: body.icon ?? null,
is_active: true,
skip_on_away: body.skip_on_away,
rule: body.rule,
@@ -158,6 +159,7 @@ function fakeApi(initial: QuestDefinition[]) {
? {
...d,
title: body.title ?? d.title,
icon: "icon" in body ? body.icon : d.icon,
skip_on_away: body.skip_on_away ?? d.skip_on_away,
rule: body.rule ?? d.rule,
// Like the API: a sent list replaces the whole set; an absent one keeps it.
@@ -331,6 +333,7 @@ describe("DefinitionsTab", () => {
path: "/api/v1/admin/quest-definitions",
body: {
title: "Feed the cat",
icon: null,
rule: {
rule_type: "weekly",
interval: 2,
@@ -419,6 +422,7 @@ describe("DefinitionsTab", () => {
path: "/api/v1/admin/quest-definitions/11",
body: {
title: "Take out the bins",
icon: null,
rule: {
rule_type: "monthly_day",
interval: 1,
@@ -436,9 +440,6 @@ describe("DefinitionsTab", () => {
},
},
]);
// 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();
@@ -918,3 +919,169 @@ describe("DefinitionsTab occurrence preview", () => {
expect(api.previews()).toHaveLength(1);
});
});
describe("DefinitionsTab icon picker", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
/** The Lucide name a row renders, or null for the generic fallback glyph. */
function rowIcon(row: HTMLElement): string | null {
const svg = row.querySelector(".defs-row-glyph svg");
expect(svg).not.toBeNull();
return svg!.getAttribute("data-icon");
}
function iconRadio(s: HTMLElement, label: string): HTMLElement {
return within(within(s).getByRole("radiogroup", { name: "Icon" })).getByRole("radio", {
name: label,
});
}
it("ships the curated subset, and every entry renders an SVG", () => {
expect(DEFINITION_ICONS.map((entry) => entry.name)).toEqual([
"bed",
"bath",
"shower-head",
"toilet",
"brush",
"smile",
"shirt",
"washing-machine",
"utensils",
"cooking-pot",
"apple",
"brush-cleaning",
"spray-can",
"trash-2",
"recycle",
"sofa",
"house",
"backpack",
"book-open",
"pencil",
"music",
"dog",
"cat",
"fish",
"sprout",
"droplets",
"bike",
"volleyball",
"gamepad-2",
"star",
"heart",
"sun",
"moon",
"clock",
"calendar",
"list-checks",
]);
expect(DEFINITION_ICONS.length).toBeGreaterThanOrEqual(24);
expect(DEFINITION_ICONS.length).toBeLessThanOrEqual(40);
for (const { name } of DEFINITION_ICONS) {
const { container, unmount } = render(<DefinitionIcon name={name} />);
const svg = container.querySelector("svg");
expect(svg, name).not.toBeNull();
expect(svg!.getAttribute("data-icon")).toBe(name);
expect(svg!.querySelectorAll("path, circle, rect, line, polyline, ellipse").length).toBeGreaterThan(0);
unmount();
}
});
it("a chosen icon is POSTed for a new task and renders on its row", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("true");
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Make the bed" },
});
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
fireEvent.click(iconRadio(s, "Bed"));
expect(iconRadio(s, "Bed").getAttribute("aria-checked")).toBe("true");
expect(iconRadio(s, "Bed").className).toContain("defs-icon--on");
expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("false");
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toHaveLength(1);
expect(api.writes()[0].method).toBe("POST");
expect((api.writes()[0].body as { icon: string }).icon).toBe("bed");
expect(rowIcon(await screen.findByTestId("definition-99"))).toBe("bed");
});
it("a chosen icon is PATCHed for an existing task without breaking the assignee omit", async () => {
await renderReady();
expect(rowIcon(screen.getByTestId("definition-11"))).toBeNull();
fireEvent.click(screen.getByTestId("definition-11"));
const s = sheet();
expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("true");
fireEvent.click(iconRadio(s, "Bins"));
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(screen.queryByTestId("unassign-confirm")).toBeNull();
expect(api.writes()).toHaveLength(1);
const { method, path, body } = api.writes()[0];
expect(method).toBe("PATCH");
expect(path).toBe("/api/v1/admin/quest-definitions/11");
expect((body as { icon: string }).icon).toBe("trash-2");
// Inactive "Old" stays assigned: the unchanged selection is still omitted.
expect(body).not.toHaveProperty("assignee_child_ids");
await waitFor(() => expect(rowIcon(screen.getByTestId("definition-11"))).toBe("trash-2"));
});
it("an unchanged icon is still sent on edit and shows as selected", async () => {
await renderReady();
expect(rowIcon(screen.getByTestId("definition-10"))).toBe("smile");
fireEvent.click(screen.getByTestId("definition-10"));
const s = sheet();
expect(iconRadio(s, "Smile").getAttribute("aria-checked")).toBe("true");
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Brush teeth well" },
});
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect((api.writes()[0].body as { icon: string }).icon).toBe("smile");
});
it("an unknown legacy icon falls back to the generic glyph and is kept on save", async () => {
const legacy: QuestDefinition = { ...BRUSH, id: 20, icon: "rocket-ship-9000" };
await renderReady([legacy]);
const row = screen.getByTestId("definition-20");
expect(rowIcon(row)).toBeNull();
fireEvent.click(row);
const s = sheet();
const group = within(s).getByRole("radiogroup", { name: "Icon" });
for (const radio of within(group).getAllByRole("radio")) {
expect(radio.getAttribute("aria-checked")).toBe("false");
}
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect((api.writes()[0].body as { icon: string }).icon).toBe("rocket-ship-9000");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(rowIcon(screen.getByTestId("definition-20"))).toBeNull();
});
});
+38 -5
View File
@@ -1,8 +1,6 @@
/**
* Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2).
*
* The icon picker (§3.3) is a separate task: an existing definition's icon is
* carried through unchanged by never sending it.
* Definitions ("Tasks") tab — list + edit sheet with icon picker
* (design/ADMIN-SPEC.md §3.1–3.3).
*/
import {
useEffect,
@@ -44,6 +42,7 @@ import {
PlusGlyph,
TaskGlyph,
} from "./glyphs";
import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons";
import "./DefinitionsTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
@@ -78,6 +77,8 @@ const WEEKDAY_LONG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "S
interface Draft {
title: string;
/** A Lucide name; a legacy value outside the subset is kept until replaced. */
icon: string | null;
assigneeIds: number[];
repeats: Repeats;
interval: string;
@@ -117,6 +118,7 @@ function newDraft(activeChildren: AdminChild[]): Draft {
const today = new Date();
return {
title: "",
icon: null,
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
repeats: "daily",
interval: "1",
@@ -141,6 +143,7 @@ function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): D
for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? "";
return {
title: definition.title,
icon: definition.icon,
// Inactive children cannot be submitted (the API rejects them with 422).
assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)),
repeats: REPEATS_BY_RULE[rule.rule_type],
@@ -233,6 +236,7 @@ function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody |
);
return {
title,
icon: draft.icon,
rule,
assignee_child_ids: [...draft.assigneeIds],
windows,
@@ -319,7 +323,7 @@ function DefinitionRow({
onClick={onOpen}
>
<span className="defs-row-glyph">
<TaskGlyph />
<DefinitionIcon name={definition.icon} />
</span>
<span className="defs-row-text">
<span className="defs-row-title">{definition.title}</span>
@@ -558,6 +562,35 @@ function EditSheet({
/>
</Field>
<Field label="Icon" id="defs-label-icon">
<div className="defs-icon-grid" role="radiogroup" aria-labelledby="defs-label-icon">
<button
type="button"
role="radio"
aria-checked={draft.icon === null}
aria-label="No icon"
className={draft.icon === null ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: null })}
>
<TaskGlyph size={18} />
</button>
{DEFINITION_ICONS.map(({ name, label, Icon }) => (
<button
key={name}
type="button"
role="radio"
aria-checked={draft.icon === name}
aria-label={label}
data-icon-option={name}
className={draft.icon === name ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: name })}
>
<Icon size={18} aria-hidden="true" focusable="false" />
</button>
))}
</div>
</Field>
<Field label="Assigned children" id="defs-label-children">
<button
type="button"
+103
View File
@@ -0,0 +1,103 @@
/**
* The shipped Lucide subset a definition's `icon` may name (ADMIN-SPEC §3.3).
* Only these named imports are bundled — no CDN, no runtime fetch. The stored
* value is the Lucide kebab-case name, so it stays meaningful to any surface.
*/
import {
Apple,
Backpack,
Bath,
Bed,
Bike,
BookOpen,
Brush,
BrushCleaning,
Calendar,
Cat,
Clock,
CookingPot,
Dog,
Droplets,
Fish,
Gamepad2,
Heart,
House,
ListChecks,
Moon,
Music,
Pencil,
Recycle,
Shirt,
ShowerHead,
Smile,
Sofa,
SprayCan,
Sprout,
Star,
Sun,
Toilet,
Trash2,
Utensils,
Volleyball,
WashingMachine,
type LucideIcon,
} from "lucide-react";
import { TaskGlyph } from "./glyphs";
export interface DefinitionIconEntry {
name: string;
label: string;
Icon: LucideIcon;
}
export const DEFINITION_ICONS: readonly DefinitionIconEntry[] = [
{ name: "bed", label: "Bed", Icon: Bed },
{ name: "bath", label: "Bath", Icon: Bath },
{ name: "shower-head", label: "Shower", Icon: ShowerHead },
{ name: "toilet", label: "Toilet", Icon: Toilet },
{ name: "brush", label: "Brush", Icon: Brush },
{ name: "smile", label: "Smile", Icon: Smile },
{ name: "shirt", label: "Clothes", Icon: Shirt },
{ name: "washing-machine", label: "Laundry", Icon: WashingMachine },
{ name: "utensils", label: "Meal", Icon: Utensils },
{ name: "cooking-pot", label: "Cooking", Icon: CookingPot },
{ name: "apple", label: "Snack", Icon: Apple },
{ name: "brush-cleaning", label: "Sweep", Icon: BrushCleaning },
{ name: "spray-can", label: "Clean", Icon: SprayCan },
{ name: "trash-2", label: "Bins", Icon: Trash2 },
{ name: "recycle", label: "Recycling", Icon: Recycle },
{ name: "sofa", label: "Tidy", Icon: Sofa },
{ name: "house", label: "Home", Icon: House },
{ name: "backpack", label: "School bag", Icon: Backpack },
{ name: "book-open", label: "Reading", Icon: BookOpen },
{ name: "pencil", label: "Homework", Icon: Pencil },
{ name: "music", label: "Music", Icon: Music },
{ name: "dog", label: "Dog", Icon: Dog },
{ name: "cat", label: "Cat", Icon: Cat },
{ name: "fish", label: "Fish", Icon: Fish },
{ name: "sprout", label: "Plants", Icon: Sprout },
{ name: "droplets", label: "Water", Icon: Droplets },
{ name: "bike", label: "Bike", Icon: Bike },
{ name: "volleyball", label: "Sport", Icon: Volleyball },
{ name: "gamepad-2", label: "Games", Icon: Gamepad2 },
{ name: "star", label: "Star", Icon: Star },
{ name: "heart", label: "Heart", Icon: Heart },
{ name: "sun", label: "Morning", Icon: Sun },
{ name: "moon", label: "Bedtime", Icon: Moon },
{ name: "clock", label: "Clock", Icon: Clock },
{ name: "calendar", label: "Calendar", Icon: Calendar },
{ name: "list-checks", label: "Checklist", Icon: ListChecks },
];
const BY_NAME = new Map(DEFINITION_ICONS.map((entry) => [entry.name, entry]));
export function findDefinitionIcon(name: string | null): DefinitionIconEntry | null {
return name === null ? null : (BY_NAME.get(name) ?? null);
}
/** A definition's glyph; no icon, or one outside the subset, gets the generic task glyph. */
export function DefinitionIcon({ name, size = 17 }: { name: string | null; size?: number }) {
const entry = findDefinitionIcon(name);
if (!entry) return <TaskGlyph size={size} />;
return <entry.Icon size={size} aria-hidden="true" focusable="false" data-icon={entry.name} />;
}
+296
View File
@@ -0,0 +1,296 @@
/* History tab — compact density (design/ADMIN-SPEC.md §5). Mobile-first at
the 390x844 reference; on wider viewports the column is capped and centred. */
.history-screen {
width: 100%;
max-width: 640px;
margin: 0 auto;
}
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--nq-a-surface);
border-bottom: 1px solid var(--nq-a-border);
padding: 14px 16px;
}
.history-title {
margin: 0;
font-size: var(--nq-a-size-screen);
font-weight: 800;
letter-spacing: -0.02em;
}
.history-sub {
margin: 2px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.history-csv {
flex: none;
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 36px;
padding: 0 12px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
}
.history-csv:disabled {
cursor: progress;
opacity: 0.6;
}
/* Non-blocking CSV export failure; the tab stays usable. */
.history-export-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: var(--nq-a-density-row-pad);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-danger-bg);
color: var(--nq-a-danger-strong);
font-size: 13px;
font-weight: 600;
}
.history-export-dismiss {
flex: none;
padding: 4px 8px;
border: none;
background: none;
color: inherit;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
}
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
.history-scroll {
display: flex;
flex-direction: column;
gap: var(--nq-a-density-gap);
padding: 14px 16px;
}
.history-card {
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
}
/* Stat row */
.history-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.history-stat {
padding: 12px;
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
}
.history-stat-value {
font-size: 20px;
font-weight: 800;
line-height: 1.1;
}
.history-stat-value--on-time {
color: var(--nq-brand-blue);
}
.history-stat-value--missed {
color: var(--nq-a-danger);
}
.history-stat-label {
margin-top: 2px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--nq-a-ink-secondary);
}
/* Filter chips */
.history-chips {
display: flex;
gap: 6px;
overflow-x: auto;
}
.history-chip {
flex: none;
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-radius-pill);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.history-chip--active {
border-color: #18181d;
background: #18181d;
color: #fff;
}
/* Day-grouped event rows */
.history-day {
display: flex;
flex-direction: column;
gap: 6px;
}
.history-section-label {
margin: 4px 0 0;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--nq-a-ink-secondary);
}
.history-list {
margin: 0;
padding: 0;
list-style: none;
}
.history-list > li + li {
border-top: 1px solid var(--nq-a-divider);
}
.history-event {
display: flex;
align-items: center;
gap: 10px;
min-height: var(--nq-a-tap-min);
padding: var(--nq-a-density-row-pad);
}
.history-dot {
flex: none;
width: 7px;
height: 7px;
border-radius: 50%;
}
.history-dot--completed {
background: var(--nq-a-success);
}
.history-dot--uncompleted {
background: var(--nq-a-reversal);
}
.history-dot--missed {
background: var(--nq-a-danger);
}
.history-event-text {
flex: 1;
min-width: 0;
}
.history-event-title {
font-size: 12.5px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-event-meta {
margin-top: 1px;
font-size: 10.5px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.history-event-time {
flex: none;
font-size: 11px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.history-empty {
padding: var(--nq-a-density-card-pad);
font-size: 13px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
/* Append-only footer strip */
.history-note {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-page);
color: var(--nq-a-ink-secondary);
font-size: 11.5px;
font-weight: 600;
}
.history-note-glyph {
flex: none;
display: flex;
}
/* Loading / error */
.history-status {
padding: 24px 13px;
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
text-align: center;
font-size: 13.5px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.history-status--error {
color: var(--nq-a-danger-strong);
}
.history-status--error p {
margin: 0 0 10px;
}
.history-retry {
padding: 8px 16px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
+631
View File
@@ -0,0 +1,631 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import HistoryTab, { FOOTER_NOTE, computeStats } from "./HistoryTab";
import type { HistoryFilter, HistoryRow } from "../api/history";
import { storeTokens } from "../auth/oidc";
import { TransitionsProvider } from "../api/useTransitions";
import { frame, testStream, type TestStream } from "../api/testStream";
const TODAY = "2026-09-23";
function row(overrides: Partial<HistoryRow>): HistoryRow {
return {
occurred_at: null,
event_type: "completed",
child_name: "Declan",
child_id: 1,
quest_title: "Brush teeth",
instance_id: 1,
window: "morning",
due_date: TODAY,
due_time: null,
actor: "panel (Declan)",
was_on_time: true,
...overrides,
};
}
// America/Los_Angeles is UTC-7 in September.
const BRUSH = row({
instance_id: 1,
occurred_at: "2026-09-23T14:04:00Z", // 7:04 AM
due_time: "07:30",
});
const BED_DONE = row({
instance_id: 2,
quest_title: "Make bed",
child_name: "Maeve",
child_id: 2,
occurred_at: "2026-09-23T14:50:00Z", // 7:50 AM
actor: "panel (Maeve)",
});
const BED_UNDONE = row({
instance_id: 2,
event_type: "uncompleted",
quest_title: "Make bed",
child_name: "Maeve",
child_id: 2,
occurred_at: "2026-09-23T15:10:00Z", // 8:10 AM
actor: "joshua (admin)",
was_on_time: null,
});
const CAT_LATE = row({
instance_id: 3,
quest_title: "Feed cat",
due_date: "2026-09-22",
due_time: "07:00",
occurred_at: "2026-09-22T14:22:00Z", // 7:22 AM, 22 min after due
was_on_time: false,
});
// 7:30 PM local on Sep 22 — already Sep 23 in UTC.
const HOMEWORK_LATE = row({
instance_id: 4,
quest_title: "Homework",
child_name: "Rory",
child_id: 3,
due_date: "2026-09-22",
due_time: null,
occurred_at: "2026-09-23T02:30:00Z",
actor: "panel (Rory)",
was_on_time: false,
});
const PLANTS_MISSED = row({
instance_id: 5,
event_type: "missed",
quest_title: "Water plants",
child_name: "Rory",
child_id: 3,
due_date: "2026-09-21",
due_time: "18:00",
actor: "nightly sweep",
was_on_time: null,
});
// The API's order: due_date, then instance, then event id.
const ROWS: Record<HistoryFilter, HistoryRow[]> = {
all: [CAT_LATE, HOMEWORK_LATE, BRUSH, BED_DONE, BED_UNDONE],
reversals: [BED_UNDONE],
missed: [PLANTS_MISSED],
};
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
const CSV_BODY = "occurred_at,event_type\n2026-09-23T14:04:00Z,completed\n";
function csvResponse(filter: string, start: string, end: string): Response {
return new Response(`${filter}\n${CSV_BODY}`, {
status: 200,
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="nestquest-history-${start}-to-${end}.csv"`,
},
});
}
function routeFetch(url: string): Response {
const parsed = new URL(url, "http://x");
if (parsed.pathname.endsWith("/api/v1/admin/history.csv")) {
return csvResponse(
parsed.searchParams.get("filter") ?? "",
parsed.searchParams.get("start") ?? "",
parsed.searchParams.get("end") ?? "",
);
}
if (!parsed.pathname.endsWith("/api/v1/admin/history")) {
return jsonResponse({ detail: "unexpected" }, 500);
}
const filter = parsed.searchParams.get("filter") as HistoryFilter;
const rows = ROWS[filter];
return jsonResponse({
filter,
start: parsed.searchParams.get("start"),
end: parsed.searchParams.get("end"),
count: rows.length,
rows,
});
}
let fetchMock: ReturnType<typeof vi.fn>;
function historyCalls(): URL[] {
return fetchMock.mock.calls.map(([url]) => new URL(String(url), "http://x"));
}
function metas(section: HTMLElement): string[] {
return within(section)
.getAllByTestId("history-meta")
.map((el) => el.textContent ?? "");
}
async function renderReady() {
render(<HistoryTab today={TODAY} />);
await screen.findByTestId("stat-on-time");
}
describe("HistoryTab", () => {
beforeAll(() => {
vi.stubEnv("TZ", "America/Los_Angeles");
});
afterAll(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
fetchMock = vi.fn((url: string) => Promise.resolve(routeFetch(url)));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("renders the header and queries the last 14 days with the Bearer token", async () => {
await renderReady();
expect(screen.getByRole("heading", { name: "History" })).toBeTruthy();
expect(screen.getByText("Append-only event log · last 14 days")).toBeTruthy();
const calls = historyCalls();
expect(calls.length).toBeGreaterThan(0);
for (const url of calls) {
expect(url.pathname).toBe("/api/v1/admin/history");
expect(url.searchParams.get("start")).toBe("2026-09-10");
expect(url.searchParams.get("end")).toBe(TODAY);
}
expect(calls.map((url) => url.searchParams.get("filter")).sort()).toEqual(["all", "missed"]);
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe("Bearer at-123");
});
it("stat row shows On time %, Completed and Missed for the period", async () => {
await renderReady();
// 4 completions (2 on time) in `all`; 1 derived missed row.
expect(screen.getByTestId("stat-on-time").textContent).toBe("50%On time");
expect(screen.getByTestId("stat-completed").textContent).toBe("4Completed");
expect(screen.getByTestId("stat-missed").textContent).toBe("1Missed");
});
it("On time shows — when the period has no completions", () => {
expect(computeStats([BED_UNDONE], [])).toEqual({ onTimePct: null, completed: 0, missed: 0 });
});
it("groups rows by local day, newest first, with event type, actor and on-time meta", async () => {
await renderReady();
const today = screen.getByTestId(`day-${TODAY}`);
expect(within(today).getByRole("heading").textContent).toBe("Today");
expect(metas(today)).toEqual([
"uncompleted · joshua (admin)",
"completed · panel (Maeve) · on time",
"completed · panel (Declan) · on time",
]);
expect(within(today).getAllByText("Make bed · Maeve")).toHaveLength(2);
expect(within(today).getByText("8:10 AM")).toBeTruthy();
// The 7:30 PM completion is Sep 23 in UTC but Sep 22 locally.
const yesterday = screen.getByTestId("day-2026-09-22");
expect(within(yesterday).getByRole("heading").textContent).toBe("Yesterday");
expect(metas(yesterday)).toEqual([
"completed · panel (Rory) · late",
"completed · panel (Declan) · late 22 min",
]);
expect(within(yesterday).getByText("7:30 PM")).toBeTruthy();
const dots = screen.getAllByTestId("history-dot").map((el) => el.className);
expect(dots[0]).toContain("history-dot--uncompleted");
expect(dots[1]).toContain("history-dot--completed");
});
it("each filter chip re-queries the API with its filter and re-renders", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
await waitFor(() =>
expect(historyCalls().some((url) => url.searchParams.get("filter") === "reversals")).toBe(true),
);
await waitFor(() => expect(screen.getAllByTestId("history-row")).toHaveLength(1));
expect(screen.getByTestId("history-meta").textContent).toBe("uncompleted · joshua (admin)");
expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
fireEvent.click(screen.getByRole("button", { name: "Missed" }));
const missedDay = await screen.findByTestId("day-2026-09-21");
expect(within(missedDay).getByRole("heading").textContent).toBe("Mon, Sep 21");
expect(metas(missedDay)).toEqual(["missed · nightly sweep"]);
expect(within(missedDay).getByTestId("history-dot").className).toContain("history-dot--missed");
expect(within(missedDay).getByText("6:00 PM")).toBeTruthy();
// Stats stay the period's numbers whichever chip is selected.
expect(screen.getByTestId("stat-completed").textContent).toBe("4Completed");
fetchMock.mockClear();
fireEvent.click(screen.getByRole("button", { name: "All events" }));
await screen.findByTestId(`day-${TODAY}`);
expect(historyCalls().map((url) => url.searchParams.get("filter"))).toContain("all");
expect(screen.getAllByTestId("history-row")).toHaveLength(5);
});
it("shows the append-only footer", async () => {
await renderReady();
expect(FOOTER_NOTE).toBe("Events are never edited or deleted. A reversal is its own row.");
expect(screen.getByTestId("history-footer").textContent).toBe(FOOTER_NOTE);
});
describe("CSV export", () => {
let createObjectURL: ReturnType<typeof vi.fn>;
let revokeObjectURL: ReturnType<typeof vi.fn>;
let clicked: { href: string; download: string; attached: boolean }[];
beforeEach(() => {
createObjectURL = vi.fn(() => "blob:nestquest/csv-1");
revokeObjectURL = vi.fn();
// jsdom has no object URLs; install stubs and remove them afterwards.
Object.assign(URL, { createObjectURL, revokeObjectURL });
clicked = [];
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) {
clicked.push({
href: this.getAttribute("href") ?? "",
download: this.download,
attached: document.body.contains(this),
});
});
});
afterEach(() => {
vi.restoreAllMocks();
const stubbed = URL as unknown as Record<string, unknown>;
delete stubbed.createObjectURL;
delete stubbed.revokeObjectURL;
});
function csvCalls(): [URL, RequestInit][] {
return fetchMock.mock.calls
.map(([url, init]) => [new URL(String(url), "http://x"), init] as [URL, RequestInit])
.filter(([url]) => url.pathname === "/api/v1/admin/history.csv");
}
it("requests the CSV for the active filter and displayed range with the Bearer token", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "CSV" }));
await waitFor(() => expect(clicked).toHaveLength(1));
const calls = csvCalls();
expect(calls).toHaveLength(1);
const [url, init] = calls[0];
expect(url.searchParams.get("filter")).toBe("all");
expect(url.searchParams.get("start")).toBe("2026-09-10");
expect(url.searchParams.get("end")).toBe(TODAY);
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer at-123");
// The token never travels in the URL.
expect(url.toString()).not.toContain("at-123");
});
it("offers the returned CSV for download, then revokes the URL and removes the anchor", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "CSV" }));
await waitFor(() => expect(clicked).toHaveLength(1));
expect(createObjectURL).toHaveBeenCalledTimes(1);
const blob = createObjectURL.mock.calls[0][0] as Blob;
expect(await blob.text()).toBe(`all\n${CSV_BODY}`);
expect(clicked[0]).toEqual({
href: "blob:nestquest/csv-1",
download: "nestquest-history-2026-09-10-to-2026-09-23.csv",
attached: true,
});
expect(revokeObjectURL).toHaveBeenCalledWith("blob:nestquest/csv-1");
expect(document.querySelector("a[download]")).toBeNull();
expect(screen.queryByTestId("history-export-error")).toBeNull();
});
it("falls back to the range filename without a Content-Disposition header", async () => {
fetchMock.mockImplementation((url: string) =>
Promise.resolve(
new URL(url, "http://x").pathname.endsWith(".csv")
? new Response(CSV_BODY, { status: 200, headers: { "Content-Type": "text/csv" } })
: routeFetch(url),
),
);
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "CSV" }));
await waitFor(() => expect(clicked).toHaveLength(1));
expect(clicked[0].download).toBe("nestquest-history-2026-09-10-to-2026-09-23.csv");
});
it("switching the filter chip then exporting uses the new filter", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
await waitFor(() => expect(screen.getAllByTestId("history-row")).toHaveLength(1));
fireEvent.click(screen.getByRole("button", { name: "CSV" }));
await waitFor(() => expect(clicked).toHaveLength(1));
const calls = csvCalls();
expect(calls).toHaveLength(1);
expect(calls[0][0].searchParams.get("filter")).toBe("reversals");
expect(calls[0][0].searchParams.get("start")).toBe("2026-09-10");
expect(calls[0][0].searchParams.get("end")).toBe(TODAY);
});
it("a failed export shows a non-blocking error and does not download", async () => {
fetchMock.mockImplementation((url: string) =>
Promise.resolve(
new URL(url, "http://x").pathname.endsWith(".csv")
? jsonResponse({ detail: "boom" }, 500)
: routeFetch(url),
),
);
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "CSV" }));
const error = await screen.findByTestId("history-export-error");
expect(error.textContent).toContain("The CSV export failed");
expect(createObjectURL).not.toHaveBeenCalled();
expect(clicked).toHaveLength(0);
// The tab stays usable: rows remain, the button is re-enabled, chips still work.
expect(screen.getAllByTestId("history-row")).toHaveLength(5);
expect((screen.getByRole("button", { name: "CSV" }) as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
await waitFor(() => expect(screen.getAllByTestId("history-row")).toHaveLength(1));
fireEvent.click(within(error).getByRole("button", { name: "Dismiss" }));
expect(screen.queryByTestId("history-export-error")).toBeNull();
});
it("the button is disabled while the request is in flight (no double-submit)", async () => {
let release: (response: Response) => void = () => {};
fetchMock.mockImplementation((url: string) =>
new URL(url, "http://x").pathname.endsWith(".csv")
? new Promise<Response>((resolve) => {
release = resolve;
})
: Promise.resolve(routeFetch(url)),
);
await renderReady();
const csv = screen.getByRole("button", { name: "CSV" }) as HTMLButtonElement;
fireEvent.click(csv);
await waitFor(() => expect(csv.disabled).toBe(true));
fireEvent.click(csv);
expect(csvCalls()).toHaveLength(1);
release(csvResponse("all", "2026-09-10", TODAY));
await waitFor(() => expect(clicked).toHaveLength(1));
await waitFor(() => expect(csv.disabled).toBe(false));
expect(csvCalls()).toHaveLength(1);
});
});
it("rows are read-only: no row carries a control", async () => {
await renderReady();
for (const el of screen.getAllByTestId("history-row")) {
expect(within(el).queryByRole("button")).toBeNull();
}
});
it("a failed fetch shows the error state and Try again reloads", async () => {
fetchMock.mockImplementation(() => Promise.resolve(jsonResponse({ detail: "boom" }, 500)));
render(<HistoryTab today={TODAY} />);
const error = await screen.findByTestId("history-error");
expect(error.textContent).toContain("The history could not be loaded");
expect(screen.getByTestId("history-footer")).toBeTruthy();
fetchMock.mockImplementation((url: string) => Promise.resolve(routeFetch(url)));
fireEvent.click(within(error).getByRole("button", { name: "Try again" }));
await screen.findByTestId("stat-on-time");
expect(screen.queryByTestId("history-error")).toBeNull();
});
it("the scroll column ends with 92px bottom padding", async () => {
await renderReady();
expect(screen.getByTestId("history-scroll").style.paddingBottom).toBe("92px");
});
});
describe("HistoryTab live updates", () => {
let fetchMock: ReturnType<typeof vi.fn>;
let streams: TestStream[];
function historyRequests(): URL[] {
return fetchMock.mock.calls
.map(([url]) => new URL(String(url), "http://x"))
.filter((url) => url.pathname === "/api/v1/admin/history");
}
const PAYLOAD = {
child_id: 2,
child_name: "Maeve",
instance_id: 2,
quest_title: "Make bed",
window: "morning",
due_date: TODAY,
due_time: null,
occurred_at: "2026-09-23T15:10:00Z",
};
beforeAll(() => {
vi.stubEnv("TZ", "America/Los_Angeles");
});
afterAll(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
streams = [];
fetchMock = vi.fn((url: string) => {
if (new URL(url, "http://x").pathname.endsWith("/api/v1/admin/events")) {
const stream = testStream();
streams.push(stream);
return Promise.resolve(stream.response);
}
return Promise.resolve(routeFetch(url));
});
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("a quest transition refetches the active filter; a day-complete does not", async () => {
render(
<TransitionsProvider>
<HistoryTab today={TODAY} />
</TransitionsProvider>,
);
await screen.findByTestId("stat-on-time");
fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
await waitFor(() =>
expect(historyRequests().some((u) => u.searchParams.get("filter") === "reversals")).toBe(true),
);
await screen.findByTestId("stat-on-time");
await waitFor(() => expect(streams).toHaveLength(1));
const before = historyRequests().length;
streams[0].push(
frame("nestquest_child_day_complete", {
child_id: 2,
child_name: "Maeve",
quests_due: 4,
quests_completed: 4,
occurred_at: "2026-09-23T15:00:00Z",
}),
);
await new Promise((resolve) => setTimeout(resolve, 30));
expect(historyRequests().length).toBe(before);
streams[0].push(frame("nestquest_quest_uncompleted", PAYLOAD));
await waitFor(() => expect(historyRequests().length).toBe(before + 3));
const refetched = historyRequests().slice(before);
// One refetch (reversals + the two stats queries), same range and filter.
expect(refetched.map((u) => u.searchParams.get("filter")).sort()).toEqual([
"all",
"missed",
"reversals",
]);
for (const url of refetched) {
expect(url.searchParams.get("start")).toBe("2026-09-10");
expect(url.searchParams.get("end")).toBe(TODAY);
}
await new Promise((resolve) => setTimeout(resolve, 30));
expect(historyRequests().length).toBe(before + 3);
expect(screen.queryByTestId("history-loading")).toBeNull();
expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
// Rows stay read-only (D-005): no row carries a control.
for (const row of screen.getAllByTestId("history-row")) {
expect(within(row).queryByRole("button")).toBeNull();
}
});
describe("a filter change interleaved with a quest transition", () => {
// Both land before the load effect runs, so one load serves both.
async function interleave(order: "sse-first" | "chip-first") {
await act(async () => {
const click = () => fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
if (order === "chip-first") click();
streams[0].push(frame("nestquest_quest_uncompleted", PAYLOAD));
await new Promise((resolve) => setTimeout(resolve, 30));
if (order === "sse-first") click();
});
}
async function renderLive() {
render(
<TransitionsProvider>
<HistoryTab today={TODAY} />
</TransitionsProvider>,
);
await screen.findByTestId("stat-on-time");
await waitFor(() => expect(streams).toHaveLength(1));
expect(screen.getAllByTestId("history-row")).toHaveLength(ROWS.all.length);
}
function titles(): string[] {
return screen.getAllByTestId("history-row").map((el) => el.textContent ?? "");
}
for (const order of ["sse-first", "chip-first"] as const) {
it(`${order}: shows loading, then only the active filter's rows`, async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const route = fetchMock.getMockImplementation()!;
await renderLive();
fetchMock.mockImplementation((url: string) => {
const parsed = new URL(url, "http://x");
if (parsed.searchParams.get("filter") === "reversals") {
return gate.then(() => route(url));
}
return route(url);
});
await interleave(order);
expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
// The previous filter's rows never show under the new chip.
expect(screen.getByTestId("history-loading")).toBeTruthy();
expect(screen.queryAllByTestId("history-row")).toHaveLength(0);
release();
await screen.findByTestId("stat-on-time");
expect(titles()).toHaveLength(1);
expect(titles()[0]).toContain("Make bed · Maeve");
expect(screen.getAllByTestId("history-dot")[0].className).toContain("history-dot--uncompleted");
});
it(`${order}: a failed filter-change load shows the error state, not stale rows`, async () => {
const route = fetchMock.getMockImplementation()!;
await renderLive();
fetchMock.mockImplementation((url: string) => {
const parsed = new URL(url, "http://x");
if (parsed.pathname === "/api/v1/admin/history") {
return Promise.resolve(jsonResponse({ detail: "boom" }, 500));
}
return route(url);
});
await interleave(order);
const error = await screen.findByTestId("history-error");
expect(error.textContent).toContain("The history could not be loaded");
expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
expect(screen.queryAllByTestId("history-row")).toHaveLength(0);
expect(screen.queryByTestId("stat-on-time")).toBeNull();
});
}
});
it("a failed transition refresh keeps the active filter's rows on screen", async () => {
render(
<TransitionsProvider>
<HistoryTab today={TODAY} />
</TransitionsProvider>,
);
await screen.findByTestId("stat-on-time");
await waitFor(() => expect(streams).toHaveLength(1));
const route = fetchMock.getMockImplementation()!;
fetchMock.mockImplementation((url: string) => {
if (new URL(url, "http://x").pathname === "/api/v1/admin/history") {
return Promise.resolve(jsonResponse({ detail: "boom" }, 500));
}
return route(url);
});
const before = historyRequests().length;
streams[0].push(frame("nestquest_quest_uncompleted", PAYLOAD));
// The "all" filter plus the missed stats query.
await waitFor(() => expect(historyRequests().length).toBe(before + 2));
await new Promise((resolve) => setTimeout(resolve, 30));
expect(screen.queryByTestId("history-error")).toBeNull();
expect(screen.queryByTestId("history-loading")).toBeNull();
expect(screen.getAllByTestId("history-row")).toHaveLength(ROWS.all.length);
});
});
+300
View File
@@ -0,0 +1,300 @@
/**
* History tab — the append-only event log (design/ADMIN-SPEC.md §5).
*
* Rows are read-only: events are never edited or deleted, and a reversal is
* its own row (D-005).
*/
import { useEffect, useRef, useState } from "react";
import { ApiForbiddenError } from "../api/client";
import type { TransitionEvent } from "../api/events";
import { useTransitions } from "../api/useTransitions";
import { exportHistoryCsv, fetchHistory, type HistoryFilter, type HistoryRow } from "../api/history";
import { localTodayIso } from "../schedule/cycle";
import { dayLabel, groupByDay, rowMeta, rowTime, shiftIso } from "./format";
import { DownloadGlyph, LockGlyph } from "./glyphs";
import "./HistoryTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
export const SCROLL_BOTTOM_PADDING = "92px";
export const RANGE_DAYS = 14;
export const FOOTER_NOTE = "Events are never edited or deleted. A reversal is its own row.";
export const FILTERS: { id: HistoryFilter; label: string }[] = [
{ id: "all", label: "All events" },
{ id: "reversals", label: "Reversals" },
{ id: "missed", label: "Missed" },
];
export interface HistoryStats {
/** Whole percent, or null when the period has no completions. */
onTimePct: number | null;
completed: number;
missed: number;
}
/** Per-period stats from the `all` rows (completions) and the `missed` rows. */
export function computeStats(allRows: HistoryRow[], missedRows: HistoryRow[]): HistoryStats {
const completions = allRows.filter((row) => row.event_type === "completed");
const onTime = completions.filter((row) => row.was_on_time === true).length;
return {
onTimePct: completions.length > 0 ? Math.round((onTime / completions.length) * 100) : null,
completed: completions.length,
missed: missedRows.filter((row) => row.event_type === "missed").length,
};
}
interface Loaded {
rows: HistoryRow[];
stats: HistoryStats;
}
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
/** `key` is the (filter, start, end) the rows were loaded for. */
| { kind: "ready"; data: Loaded; key: string };
/**
* The selected filter's rows plus the period stats. Stats always come from
* the `all` and `missed` queries, whichever chip is selected, so switching
* chips never changes the period's numbers.
*/
async function loadHistory(filter: HistoryFilter, start: string, end: string): Promise<Loaded> {
const [selected, all, missed] = await Promise.all([
fetchHistory(filter, start, end),
filter === "all" ? null : fetchHistory("all", start, end),
filter === "missed" ? null : fetchHistory("missed", start, end),
]);
return {
rows: selected.rows,
stats: computeStats((all ?? selected).rows, (missed ?? selected).rows),
};
}
/** Offer a Blob as a file download without navigating the page. */
export function offerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.style.display = "none";
document.body.appendChild(anchor);
try {
anchor.click();
} finally {
anchor.remove();
URL.revokeObjectURL(url);
}
}
function ScreenHeader({ exporting, onExport }: { exporting: boolean; onExport: () => void }) {
return (
<header className="history-header">
<div>
<h2 className="history-title">History</h2>
<p className="history-sub">Append-only event log · last {RANGE_DAYS} days</p>
</div>
<button
type="button"
className="history-csv"
data-testid="history-csv"
disabled={exporting}
aria-busy={exporting}
onClick={onExport}
>
<DownloadGlyph />
CSV
</button>
</header>
);
}
function StatRow({ stats }: { stats: HistoryStats }) {
const items = [
{ key: "on-time", label: "On time", value: stats.onTimePct === null ? "—" : `${stats.onTimePct}%` },
{ key: "completed", label: "Completed", value: String(stats.completed) },
{ key: "missed", label: "Missed", value: String(stats.missed) },
];
return (
<div className="history-stats">
{items.map((item) => (
<div key={item.key} className="history-stat" data-testid={`stat-${item.key}`}>
<div className={`history-stat-value history-stat-value--${item.key}`}>{item.value}</div>
<div className="history-stat-label">{item.label}</div>
</div>
))}
</div>
);
}
function EventRow({ row }: { row: HistoryRow }) {
return (
<li className="history-event" data-testid="history-row">
<span
className={`history-dot history-dot--${row.event_type}`}
data-testid="history-dot"
aria-hidden="true"
/>
<div className="history-event-text">
<div className="history-event-title">
{row.quest_title} · {row.child_name}
</div>
<div className="history-event-meta" data-testid="history-meta">
{rowMeta(row)}
</div>
</div>
<span className="history-event-time">{rowTime(row)}</span>
</li>
);
}
function FooterNote() {
return (
<div className="history-note" data-testid="history-footer">
<span className="history-note-glyph">
<LockGlyph />
</span>
<span>{FOOTER_NOTE}</span>
</div>
);
}
export default function HistoryTab({ today = localTodayIso() }: { today?: string }) {
const [filter, setFilter] = useState<HistoryFilter>("all");
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const exportingRef = useRef(false);
const start = shiftIso(today, -(RANGE_DAYS - 1));
const key = `${filter}|${start}|${today}`;
// A quest transition is a new history row (or changes a derived missed row);
// a day-complete adds none. The refetch keeps the active filter and range.
useTransitions((event: TransitionEvent) => {
if (event.type === "nestquest_child_day_complete") return;
setAttempt((n) => n + 1);
});
/** Download the CSV for the active filter over the displayed range. */
async function exportCsv() {
if (exportingRef.current) return;
exportingRef.current = true;
setExporting(true);
setExportError(null);
try {
const { blob, filename } = await exportHistoryCsv(filter, start, today);
offerDownload(blob, filename);
} catch (error: unknown) {
setExportError(
error instanceof ApiForbiddenError
? error.message
: "The CSV export failed. Check your connection and try again.",
);
} finally {
exportingRef.current = false;
setExporting(false);
}
}
useEffect(() => {
let cancelled = false;
// Rows already loaded for this (filter, range) stay on screen while they
// refresh; any other rows give way to the loading state.
const isCurrent = (prev: LoadState) => prev.kind === "ready" && prev.key === key;
setState((prev) => (isCurrent(prev) ? prev : { kind: "loading" }));
loadHistory(filter, start, today)
.then((data) => {
if (!cancelled) setState({ kind: "ready", data, key });
})
.catch((error: unknown) => {
if (cancelled) return;
const message =
error instanceof ApiForbiddenError
? error.message
: "The history could not be loaded. Check your connection and try again.";
// A failed refresh keeps the rows already on screen.
setState((prev) => (isCurrent(prev) ? prev : { kind: "error", message }));
});
return () => {
cancelled = true;
};
}, [filter, start, today, key, attempt]);
// Never render one filter's rows under another's chip, even for the render
// before the load effect runs.
const shown: LoadState = state.kind === "ready" && state.key !== key ? { kind: "loading" } : state;
let body;
if (shown.kind === "loading") {
body = (
<div className="history-status" role="status" data-testid="history-loading">
Loading history…
</div>
);
} else if (shown.kind === "error") {
body = (
<div className="history-status history-status--error" role="alert" data-testid="history-error">
<p>{shown.message}</p>
<button type="button" className="history-retry" onClick={() => setAttempt((n) => n + 1)}>
Try again
</button>
</div>
);
} else {
const groups = groupByDay(shown.data.rows);
body =
groups.length > 0 ? (
groups.map((group) => (
<section key={group.day} className="history-day" data-testid={`day-${group.day}`}>
<h3 className="history-section-label">{dayLabel(group.day, today)}</h3>
<ul className="history-card history-list">
{group.rows.map((row, index) => (
<EventRow key={`${row.instance_id}-${row.event_type}-${row.occurred_at ?? ""}-${index}`} row={row} />
))}
</ul>
</section>
))
) : (
<div className="history-card history-empty">No events in the last {RANGE_DAYS} days.</div>
);
}
return (
<div className="history-screen">
<ScreenHeader exporting={exporting} onExport={() => void exportCsv()} />
<div
className="history-scroll"
data-testid="history-scroll"
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
>
{exportError !== null ? (
<div className="history-export-error" role="alert" data-testid="history-export-error">
<span>{exportError}</span>
<button type="button" className="history-export-dismiss" onClick={() => setExportError(null)}>
Dismiss
</button>
</div>
) : null}
{shown.kind === "ready" ? <StatRow stats={shown.data.stats} /> : null}
<div className="history-chips" role="group" aria-label="Filter events">
{FILTERS.map((item) => (
<button
key={item.id}
type="button"
className={item.id === filter ? "history-chip history-chip--active" : "history-chip"}
aria-pressed={item.id === filter}
onClick={() => setFilter(item.id)}
>
{item.label}
</button>
))}
</div>
{body}
<FooterNote />
</div>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Day grouping and row wording for the History tab.
*
* Calendar dates ("YYYY-MM-DD") are handled by their components (never
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
* day in negative-offset zones); event stamps are UTC and shown in the
* viewer's local time.
*/
import type { HistoryRow } from "../api/history";
import { dayNumber, isoFromDayNumber, localTodayIso } from "../schedule/cycle";
import { shortDate } from "../schedule/format";
import { formatClockTime, formatDueTime } from "../today/format";
const MS_PER_MINUTE = 60_000;
/** `iso` shifted by `days` calendar days. */
export function shiftIso(iso: string, days: number): string {
return isoFromDayNumber(dayNumber(iso) + days);
}
/** The local calendar day an event belongs to (due_date for missed rows). */
export function rowDay(row: HistoryRow): string {
return row.occurred_at ? localTodayIso(new Date(row.occurred_at)) : row.due_date;
}
/** The row's due moment as local wall-clock time, or null without a due time. */
function dueMoment(row: HistoryRow): Date | null {
const match = /^(\d{2}):(\d{2})/.exec(row.due_time ?? "");
if (!match) return null;
const [year, month, day] = row.due_date.split("-").map(Number);
return new Date(year, month - 1, day, Number(match[1]), Number(match[2]));
}
/** Newest first; a missed row sorts at its due moment (or the end of its day). */
function sortKey(row: HistoryRow): number {
if (row.occurred_at) return Date.parse(row.occurred_at);
const due = dueMoment(row);
if (due) return due.getTime();
const [year, month, day] = row.due_date.split("-").map(Number);
return new Date(year, month - 1, day, 23, 59).getTime();
}
export interface DayGroup {
day: string;
rows: HistoryRow[];
}
/** Rows grouped by day, newest day first and newest row first within a day. */
export function groupByDay(rows: HistoryRow[]): DayGroup[] {
const groups = new Map<string, HistoryRow[]>();
for (const row of rows) {
const day = rowDay(row);
groups.set(day, [...(groups.get(day) ?? []), row]);
}
return [...groups.entries()]
.sort(([a], [b]) => b.localeCompare(a))
.map(([day, dayRows]) => ({
day,
// Stable sort: rows at the same moment keep the API's append order.
rows: [...dayRows].sort((a, b) => sortKey(b) - sortKey(a)),
}));
}
/** "Today", "Yesterday", else "Mon, Sep 21" (the CSS uppercases it). */
export function dayLabel(day: string, today: string): string {
if (day === today) return "Today";
if (day === shiftIso(today, -1)) return "Yesterday";
const weekday = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" }).format(
new Date(dayNumber(day) * 86_400_000),
);
return `${weekday}, ${shortDate(day)}`;
}
/** "on time", "late 22 min", or "late" when the lateness is not derivable. */
function onTimeFlag(row: HistoryRow): string | null {
if (row.event_type !== "completed" || row.was_on_time === null) return null;
if (row.was_on_time) return "on time";
const due = dueMoment(row);
if (!due || !row.occurred_at) return "late";
const minutes = Math.round((Date.parse(row.occurred_at) - due.getTime()) / MS_PER_MINUTE);
return minutes > 0 ? `late ${minutes} min` : "late";
}
/** `<event type> · <actor>` plus the on-time flag for completions. */
export function rowMeta(row: HistoryRow): string {
const flag = onTimeFlag(row);
return [row.event_type, row.actor, flag].filter(Boolean).join(" · ");
}
/** The event's local clock time; a missed row shows its due time, if any. */
export function rowTime(row: HistoryRow): string {
if (row.occurred_at) return formatClockTime(row.occurred_at);
return row.due_time ? formatDueTime(row.due_time) : "";
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Inline stroke glyphs for the History tab (Lucide-style 24px grid),
* CDN-free like the other tabs' glyphs.
*/
import type { ReactNode } from "react";
function Glyph({ size, children }: { size: number; children: ReactNode }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
);
}
export function DownloadGlyph({ size = 15 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />
</Glyph>
);
}
export function LockGlyph({ size = 15 }: { size?: number }) {
return (
<Glyph size={size}>
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</Glyph>
);
}
+5
View File
@@ -23,9 +23,14 @@
--nq-a-danger: #dc2626;
--nq-a-danger-strong: #b91c1c;
--nq-a-danger-bg: #fee2e2;
--nq-a-warning-ink: #92400e;
--nq-a-warning-ink-2: #78350f;
--nq-a-warning-bg: #fef3c7;
--nq-a-warning-border: rgba(217, 119, 6, 0.35);
--nq-a-info-bg: #dbeafe;
--nq-a-info-ink: #1e3a8a;
--nq-a-info-icon: #1d4ed8;
--nq-a-reversal: #7d28a0;
--nq-a-font: Nunito, system-ui, sans-serif;
--nq-a-size-screen: 24px;
+220
View File
@@ -0,0 +1,220 @@
/* Presence override editor — roomy density pushed screen
(design/ADMIN-SPEC.md §6). Covers the tab bar like a pushed route. */
.override-screen {
position: fixed;
inset: 0;
z-index: 30;
overflow-y: auto;
background: var(--nq-a-page);
}
.override-header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px;
background: var(--nq-a-surface);
border-bottom: 1px solid var(--nq-a-border);
}
.override-back {
flex: none;
display: grid;
place-items: center;
width: var(--nq-a-tap-min);
height: var(--nq-a-tap-min);
margin: -6px 0 -6px -12px;
padding: 0;
border: 0;
background: none;
color: var(--nq-a-ink);
cursor: pointer;
}
.override-title {
margin: 0;
font-size: var(--nq-a-size-screen);
font-weight: 800;
letter-spacing: -0.02em;
}
.override-sub {
margin: 2px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.override-scroll {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
max-width: 640px;
margin: 0 auto;
padding: 16px 16px 24px;
}
/* Form card: roomy density, 18px padding, 16px gaps. */
.override-card {
display: flex;
flex-direction: column;
gap: 16px;
padding: 18px;
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: 12px;
}
.override-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.override-label {
font-size: var(--nq-a-size-label);
font-weight: 700;
text-transform: uppercase;
letter-spacing: var(--nq-a-track-label);
color: var(--nq-a-ink-secondary);
}
.override-segments {
display: grid;
gap: 6px;
}
.override-segments--3 {
grid-template-columns: repeat(3, 1fr);
}
.override-segments--2 {
grid-template-columns: repeat(2, 1fr);
}
.override-segment {
min-height: var(--nq-a-tap-min);
padding: 0 8px;
border: 1px solid var(--nq-a-border);
border-radius: 10px;
background: var(--nq-a-surface);
color: var(--nq-a-ink-secondary);
font-family: inherit;
font-size: 13.5px;
font-weight: 700;
cursor: pointer;
}
.override-segment--gradient[aria-pressed="true"] {
border-color: transparent;
background: var(--nq-brand-gradient);
color: #fff;
}
.override-segment--ink[aria-pressed="true"] {
border-color: #18181d;
background: #18181d;
color: #fff;
}
.override-dates {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.override-input {
width: 100%;
min-height: var(--nq-a-tap-min);
padding: 13px;
border: 1px solid var(--nq-a-border);
border-radius: 10px;
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 14px;
font-weight: 600;
}
.override-date {
font-weight: 700;
}
/* Consequence warning */
.override-warning {
display: flex;
gap: 10px;
padding: 14px;
border: 1px solid var(--nq-a-warning-border);
border-radius: 12px;
background: var(--nq-a-warning-bg);
}
.override-warning-glyph {
flex: none;
display: flex;
color: var(--nq-a-warning-ink);
}
.override-warning-headline {
margin: 0;
font-size: 13.5px;
font-weight: 700;
color: var(--nq-a-warning-ink);
}
.override-warning-body {
margin: 3px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-warning-ink-2);
}
.override-error {
margin: 0;
padding: 10px 12px;
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-danger-bg);
color: var(--nq-a-danger-strong);
font-size: 12.5px;
font-weight: 700;
}
/* Footer: Cancel (flex 1) + Save override (flex 2), 15px vertical padding. */
.override-footer {
display: flex;
gap: 10px;
}
.override-cancel,
.override-save {
padding: 15px 12px;
border-radius: 12px;
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.override-cancel {
flex: 1;
border: 1px solid var(--nq-a-border);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
}
.override-save {
flex: 2;
border: 0;
background: var(--nq-brand-gradient);
color: #fff;
}
.override-cancel:disabled,
.override-save:disabled {
opacity: 0.45;
cursor: default;
}
+533
View File
@@ -0,0 +1,533 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import App from "../App";
import ScheduleTab from "./ScheduleTab";
import { CONSEQUENCE_DEBOUNCE_MS, isIsoDate, isValidRange } from "./OverrideEditor";
import type { AdminChild } from "../api/definitions";
import type { PresenceOverride, PresenceSchedule } from "../api/presence";
import { storeTokens } from "../auth/oidc";
function child(id: number, display_name: string, sort_order: number): AdminChild {
return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active: true };
}
const CHILDREN = [child(1, "Declan", 1), child(2, "Maeve", 2), child(3, "Rory", 3)];
const DECLAN: PresenceSchedule = {
child_id: 1,
cycle_length_weeks: 2,
anchor_date: "2026-09-07",
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] },
};
const INITIAL_OVERRIDES: PresenceOverride[] = [
{ id: 41, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: "Dentist trip" },
];
const TODAY = "2026-09-23";
const OVERRIDES_PATH = "/api/v1/admin/presence-overrides";
const CONSEQUENCE_PATH = `${OVERRIDES_PATH}/consequence`;
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
interface Call {
method: string;
path: string;
body: unknown;
}
let overrides: PresenceOverride[];
let consequence: (body: Record<string, unknown>) => Response | Promise<Response>;
let createResponse: (body: Record<string, unknown>) => Response;
let fetchMock: ReturnType<typeof vi.fn>;
function calls(): Call[] {
return fetchMock.mock.calls.map(([url, init]: [string, RequestInit | undefined]) => ({
method: init?.method ?? "GET",
path: new URL(String(url), "http://x").pathname,
body: init?.body ? JSON.parse(String(init.body)) : undefined,
}));
}
function consequenceBodies(): unknown[] {
return calls()
.filter((c) => c.path === CONSEQUENCE_PATH)
.map((c) => c.body);
}
async function routeFetch(url: string, init: RequestInit = {}): Promise<Response> {
const path = new URL(url, "http://x").pathname;
const method = init.method ?? "GET";
const body = init.body ? JSON.parse(String(init.body)) : undefined;
if (path === CONSEQUENCE_PATH && method === "POST") return consequence(body);
if (path === OVERRIDES_PATH && method === "POST") return createResponse(body);
const deleteMatch = /\/presence-overrides\/(\d+)$/.exec(path);
if (deleteMatch && method === "DELETE") {
overrides = overrides.filter((o) => o.id !== Number(deleteMatch[1]));
return jsonResponse({ status: "ok" });
}
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
const scheduleMatch = /\/children\/(\d+)\/presence-schedule$/.exec(path);
if (scheduleMatch) {
return jsonResponse({ schedule: Number(scheduleMatch[1]) === 1 ? DECLAN : null });
}
if (path === OVERRIDES_PATH) return jsonResponse({ overrides });
return jsonResponse({ detail: "unexpected" }, 500);
}
function deferred() {
let resolve!: (response: Response) => void;
const promise = new Promise<Response>((r) => {
resolve = r;
});
return { promise, resolve };
}
const pastDebounce = () => new Promise((r) => setTimeout(r, CONSEQUENCE_DEBOUNCE_MS + 150));
function headline(): string {
return screen.getByTestId("override-consequence-headline").textContent ?? "";
}
function warningBody(): string {
return screen.getByTestId("override-consequence").querySelector(".override-warning-body")?.textContent ?? "";
}
function saveButton(): HTMLButtonElement {
return screen.getByRole("button", { name: "Save override" }) as HTMLButtonElement;
}
function setDate(label: "From" | "Through", value: string) {
fireEvent.change(screen.getByLabelText(label), { target: { value } });
}
async function openEditor() {
render(<ScheduleTab today={TODAY} />);
await screen.findByTestId("pattern-1");
fireEvent.click(screen.getByRole("button", { name: "Add" }));
await screen.findByRole("heading", { name: "Presence override" });
}
describe("OverrideEditor", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
overrides = [...INITIAL_OVERRIDES];
consequence = () => jsonResponse({ removed: 4 });
createResponse = (body) => {
const created: PresenceOverride = {
id: 50,
child_id: body.child_id as number,
start_date: body.start_date as string,
end_date: body.end_date as string,
is_present: body.is_present as boolean,
note: (body.note as string | undefined) ?? null,
};
overrides = [...overrides, created];
return jsonResponse(created, 201);
};
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("renders the pushed screen per ADMIN-SPEC §6", async () => {
await openEditor();
expect(screen.getByText("Beats the custody pattern for these dates")).toBeTruthy();
expect(screen.getByRole("button", { name: "Back" })).toBeTruthy();
const kids = within(screen.getByRole("group", { name: "Child" })).getAllByRole("button");
expect(kids.map((b) => [b.textContent, b.getAttribute("aria-pressed")])).toEqual([
["Declan", "true"],
["Maeve", "false"],
["Rory", "false"],
]);
const status = within(screen.getByRole("group", { name: "Status for these dates" }));
expect(status.getByRole("button", { name: "Away" }).getAttribute("aria-pressed")).toBe("true");
expect(status.getByRole("button", { name: "Home" }).getAttribute("aria-pressed")).toBe("false");
expect((screen.getByLabelText("From") as HTMLInputElement).value).toBe(TODAY);
expect((screen.getByLabelText("Through") as HTMLInputElement).value).toBe(TODAY);
expect(screen.getByLabelText("Reason (optional)")).toBeTruthy();
expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
});
it("shows the live consequence count from the API and recounts on every change", async () => {
await openEditor();
// Save is off until the count is on screen: the warning precedes saving.
expect(saveButton().disabled).toBe(true);
await waitFor(() => expect(headline()).toBe("This removes 4 upcoming tasks"));
expect(consequenceBodies()).toEqual([
{ child_id: 1, start_date: TODAY, end_date: TODAY, is_present: false },
]);
expect(warningBody()).toBe(
"Declan's open tasks Sep 23 are removed while away. Completed days are untouched.",
);
expect(saveButton().disabled).toBe(false);
consequence = () => jsonResponse({ removed: 9 });
setDate("Through", "2026-09-27");
await waitFor(() => expect(headline()).toBe("This removes 9 upcoming tasks"));
expect(consequenceBodies()[1]).toEqual({
child_id: 1,
start_date: TODAY,
end_date: "2026-09-27",
is_present: false,
});
expect(warningBody()).toBe(
"Declan's open tasks Sep 23 – 27 are removed while away. Completed days are untouched.",
);
consequence = () => jsonResponse({ removed: 2 });
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
await waitFor(() => expect(headline()).toBe("This removes 2 upcoming tasks"));
expect(consequenceBodies()[2]).toEqual({
child_id: 2,
start_date: TODAY,
end_date: "2026-09-27",
is_present: false,
});
expect(warningBody()).toBe(
"Maeve's open tasks Sep 23 – 27 are removed while away. Completed days are untouched.",
);
// A Home override removes nothing by definition: no request, count 0.
fireEvent.click(screen.getByRole("button", { name: "Home" }));
expect(headline()).toBe("This removes 0 upcoming tasks");
expect(warningBody()).toBe(
"Maeve is home Sep 23 – 27, so no tasks are removed. Completed days are untouched.",
);
await pastDebounce();
expect(consequenceBodies()).toHaveLength(3);
});
it("renders the API's count verbatim — completed instances are excluded server-side", async () => {
// The range holds 3 open instances and 1 already completed. The API
// excludes the completed one (D-005; proven by
// tests/test_api_admin_presence_consequence.py::test_completed_instance_in_range_is_not_counted
// and ::test_count_matches_a_direct_materialise_comparison), so it answers 3,
// and the editor shows exactly that — it never counts days on its own.
consequence = () => jsonResponse({ removed: 3 });
await openEditor();
setDate("Through", "2026-09-25");
await waitFor(() => expect(headline()).toBe("This removes 3 upcoming tasks"));
expect(warningBody()).toBe(
"Declan's open tasks Sep 23 – 25 are removed while away. Completed days are untouched.",
);
});
it("a stale consequence response never overwrites a newer one", async () => {
const first = deferred();
const second = deferred();
const pending = [first, second];
consequence = () => pending.shift()!.promise;
await openEditor();
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
setDate("Through", "2026-09-26");
await waitFor(() => expect(consequenceBodies()).toHaveLength(2));
second.resolve(jsonResponse({ removed: 5 }));
await waitFor(() => expect(headline()).toBe("This removes 5 upcoming tasks"));
first.resolve(jsonResponse({ removed: 99 }));
await pastDebounce();
expect(headline()).toBe("This removes 5 upcoming tasks");
});
it("debounces rapid edits into one request for the final range", async () => {
await openEditor();
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
setDate("Through", "2026-09-24");
setDate("Through", "2026-09-25");
setDate("Through", "2026-09-26");
await pastDebounce();
expect(consequenceBodies().slice(1)).toEqual([
{ child_id: 1, start_date: TODAY, end_date: "2026-09-26", is_present: false },
]);
});
it("an incomplete or reversed range sends no consequence request and cannot be saved", async () => {
await openEditor();
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
setDate("Through", "");
expect(headline()).toBe("Choose a valid date range");
expect(saveButton().disabled).toBe(true);
await pastDebounce();
expect(consequenceBodies()).toHaveLength(1);
setDate("Through", "2026-09-20"); // before From
expect(headline()).toBe("Choose a valid date range");
await pastDebounce();
expect(consequenceBodies()).toHaveLength(1);
fireEvent.click(saveButton());
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
});
it("a malformed From date sends no consequence request and cannot be saved", async () => {
await openEditor();
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
setDate("From", "2026-02-30");
expect(headline()).toBe("Choose a valid date range");
expect(saveButton().disabled).toBe(true);
await pastDebounce();
expect(consequenceBodies()).toHaveLength(1);
fireEvent.click(saveButton());
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
});
it("Save sends the exact POST body, closes the editor and shows the new row", async () => {
await openEditor();
setDate("From", "2026-09-24");
setDate("Through", "2026-09-26");
fireEvent.change(screen.getByLabelText("Reason (optional)"), {
target: { value: " Trip with grandparents " },
});
await waitFor(() => expect(saveButton().disabled).toBe(false));
const before = calls().length;
const schedulesBefore = calls().filter((c) => c.path.endsWith("/presence-schedule")).length;
fireEvent.click(saveButton());
const row = await screen.findByTestId("override-50");
expect(row.textContent).toContain("Declan away · Sep 24 – 26");
expect(row.textContent).toContain("Trip with grandparents");
expect(screen.queryByRole("heading", { name: "Presence override" })).toBeNull();
const after = calls().slice(before);
const writes = after.filter((c) => c.method !== "GET");
// D-005: saving sends ONLY the create request — never a delete of an
// instance or an override. The API's regenerate path keeps completed
// instances (tests/test_materialize.py::
// test_regenerate_for_definition_rebuilds_horizon_and_preserves_completed).
expect(writes).toEqual([
{
method: "POST",
path: OVERRIDES_PATH,
body: {
child_id: 1,
start_date: "2026-09-24",
end_date: "2026-09-26",
is_present: false,
note: "Trip with grandparents",
},
},
]);
expect(after.some((c) => c.method === "DELETE")).toBe(false);
// The overrides and the presence schedules are refetched for the grid.
expect(after.some((c) => c.method === "GET" && c.path === OVERRIDES_PATH)).toBe(true);
expect(calls().filter((c) => c.path.endsWith("/presence-schedule")).length).toBeGreaterThan(
schedulesBefore,
);
// The grid reflects the new override.
expect(document.querySelector('[data-date="2026-09-25"]')?.getAttribute("data-state")).toBe(
"override-away",
);
});
it("omits an empty reason and saves a Home override", async () => {
await openEditor();
fireEvent.click(screen.getByRole("button", { name: "Home" }));
await waitFor(() => expect(saveButton().disabled).toBe(false));
fireEvent.click(saveButton());
await screen.findByTestId("override-50");
const post = calls().find((c) => c.method === "POST" && c.path === OVERRIDES_PATH);
expect(post?.body).toEqual({
child_id: 1,
start_date: TODAY,
end_date: TODAY,
is_present: true,
});
});
it("a 422 shows the API detail and keeps the form", async () => {
createResponse = () => jsonResponse({ detail: "overlaps an existing override" }, 422);
await openEditor();
setDate("Through", "2026-09-25");
fireEvent.change(screen.getByLabelText("Reason (optional)"), { target: { value: "Camp" } });
await waitFor(() => expect(saveButton().disabled).toBe(false));
fireEvent.click(saveButton());
const alert = await screen.findByTestId("override-error");
expect(alert.textContent).toBe("overlaps an existing override");
expect(screen.getByRole("heading", { name: "Presence override" })).toBeTruthy();
expect((screen.getByLabelText("Through") as HTMLInputElement).value).toBe("2026-09-25");
expect((screen.getByLabelText("Reason (optional)") as HTMLInputElement).value).toBe("Camp");
expect(saveButton().disabled).toBe(false);
});
it("Cancel and Back close the editor without writing", async () => {
await openEditor();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await screen.findByTestId("pattern-1");
fireEvent.click(screen.getByRole("button", { name: "Add" }));
fireEvent.click(await screen.findByRole("button", { name: "Back" }));
await screen.findByTestId("pattern-1");
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
});
it("Add opens the editor for the selected child", async () => {
render(<ScheduleTab today={TODAY} />);
await screen.findByTestId("pattern-1");
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
fireEvent.click(screen.getByRole("button", { name: "Add" }));
const kids = within(await screen.findByRole("group", { name: "Child" }));
expect(kids.getByRole("button", { name: "Maeve" }).getAttribute("aria-pressed")).toBe("true");
await waitFor(() =>
expect(consequenceBodies()).toEqual([
{ child_id: 2, start_date: TODAY, end_date: TODAY, is_present: false },
]),
);
});
it("the trash button deletes the override only after confirm", async () => {
render(<ScheduleTab today={TODAY} />);
const row = await screen.findByTestId("override-41");
fireEvent.click(within(row).getByRole("button", { name: "Delete override" }));
fireEvent.click(within(row).getByRole("button", { name: "Keep" }));
expect(calls().some((c) => c.method === "DELETE")).toBe(false);
fireEvent.click(within(row).getByRole("button", { name: "Delete override" }));
const confirm = within(row).getByRole("group", { name: "Confirm delete" });
fireEvent.click(within(confirm).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(screen.queryByTestId("override-41")).toBeNull());
expect(calls().filter((c) => c.method === "DELETE")).toEqual([
{ method: "DELETE", path: `${OVERRIDES_PATH}/41`, body: undefined },
]);
expect(screen.getByText("No overrides for Declan.")).toBeTruthy();
});
});
describe("OverrideEditor focus", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
overrides = [...INITIAL_OVERRIDES];
consequence = () => jsonResponse({ removed: 4 });
createResponse = (body) => {
const created = { id: 50, note: null, ...body } as PresenceOverride;
overrides = [...overrides, created];
return jsonResponse(created, 201);
};
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function dialog(): HTMLElement {
return screen.getByRole("dialog", { name: "Presence override" });
}
function addButton(): HTMLElement {
return screen.getByRole("button", { name: "Add" });
}
it("moves focus into the editor on open", async () => {
await openEditor();
expect(document.activeElement).toBe(screen.getByRole("heading", { name: "Presence override" }));
expect(dialog().contains(document.activeElement)).toBe(true);
});
it("keeps Tab and Shift+Tab inside the editor", async () => {
await openEditor();
await waitFor(() => expect(saveButton().disabled).toBe(false));
const back = screen.getByRole("button", { name: "Back" });
saveButton().focus();
fireEvent.keyDown(saveButton(), { key: "Tab" });
expect(document.activeElement).toBe(back);
fireEvent.keyDown(back, { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(saveButton());
// From the focused heading (not in the tab order) Shift+Tab wraps to the end.
const title = screen.getByRole("heading", { name: "Presence override" });
title.focus();
fireEvent.keyDown(title, { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(saveButton());
});
it("makes the tab bar inert while open and restores it on close", async () => {
render(<App />);
fireEvent.click(screen.getByRole("tab", { name: "Schedule" }));
await screen.findByTestId("pattern-1");
fireEvent.click(addButton());
await screen.findByRole("heading", { name: "Presence override" });
const tabbar = document.querySelector(".admin-tabbar") as HTMLElement;
expect(tabbar.hasAttribute("inert")).toBe(true);
expect(tabbar.getAttribute("aria-hidden")).toBe("true");
expect(dialog().closest("[inert]")).toBeNull();
fireEvent.keyDown(dialog(), { key: "Escape" });
await screen.findByTestId("pattern-1");
expect(tabbar.hasAttribute("inert")).toBe(false);
expect(tabbar.hasAttribute("aria-hidden")).toBe(false);
});
it("Escape cancels without writing and returns focus to Add", async () => {
await openEditor();
fireEvent.keyDown(document.activeElement as HTMLElement, { key: "Escape" });
await screen.findByTestId("pattern-1");
expect(screen.queryByRole("dialog")).toBeNull();
expect(document.activeElement).toBe(addButton());
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
});
it("Escape does nothing while saving; focus returns to Add after the save", async () => {
const gate = deferred();
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
const path = new URL(url, "http://x").pathname;
if (path === OVERRIDES_PATH && init?.method === "POST") await gate.promise;
return routeFetch(url, init);
});
await openEditor();
await waitFor(() => expect(saveButton().disabled).toBe(false));
fireEvent.click(saveButton());
expect(screen.getByRole("button", { name: "Saving…" })).toBeTruthy();
fireEvent.keyDown(dialog(), { key: "Escape" });
expect(screen.getByRole("dialog")).toBeTruthy();
gate.resolve(new Response(null));
await screen.findByTestId("override-50");
expect(screen.queryByRole("dialog")).toBeNull();
expect(document.activeElement).toBe(addButton());
});
it("Back and Cancel return focus to Add", async () => {
await openEditor();
fireEvent.click(screen.getByRole("button", { name: "Back" }));
await screen.findByTestId("pattern-1");
expect(document.activeElement).toBe(addButton());
fireEvent.click(addButton());
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
await screen.findByTestId("pattern-1");
expect(document.activeElement).toBe(addButton());
});
});
describe("range validation", () => {
it("accepts only strict ISO calendar dates in order", () => {
expect(isIsoDate("2026-09-23")).toBe(true);
expect(isIsoDate("2026-02-30")).toBe(false);
expect(isIsoDate("2026-9-3")).toBe(false);
expect(isIsoDate("")).toBe(false);
expect(isValidRange("2026-09-23", "2026-09-23")).toBe(true);
expect(isValidRange("2026-09-23", "2026-09-22")).toBe(false);
expect(isValidRange("2026-09-23", "")).toBe(false);
});
});
+348
View File
@@ -0,0 +1,348 @@
/**
* Presence override editor — the pushed screen of design/ADMIN-SPEC.md §6.
*
* The consequence warning is counted by the API (POST
* /presence-overrides/consequence) before anything is saved; Save stays off
* until that count is on screen. Saving only ever sends the create request:
* the API never removes an instance that already has a completion event (D-005).
*/
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { ApiForbiddenError } from "../api/client";
import { ApiRequestError, type AdminChild } from "../api/definitions";
import {
createPresenceOverride,
previewOverrideConsequence,
type PresenceOverride,
type PresenceOverrideBody,
} from "../api/presence";
import { dayNumber, isoFromDayNumber } from "./cycle";
import { dateRange } from "./format";
import { AlertCircleGlyph, ChevronLeftGlyph } from "./glyphs";
import "./OverrideEditor.css";
/** How long the form must sit still before the consequence is recounted. */
export const CONSEQUENCE_DEBOUNCE_MS = 300;
/** Strict "YYYY-MM-DD" naming a real calendar day (no 2026-02-30). */
export function isIsoDate(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
return isoFromDayNumber(dayNumber(value)) === value;
}
export function isValidRange(start: string, end: string): boolean {
return isIsoDate(start) && isIsoDate(end) && start <= end;
}
type Consequence =
| { kind: "invalid" }
| { kind: "counting" }
| { kind: "ready"; removed: number }
| { kind: "error"; message: string };
function errorMessage(error: unknown, fallback: string): string {
if (error instanceof ApiForbiddenError) return error.message;
if (error instanceof ApiRequestError && error.detail) return error.detail;
return fallback;
}
export const FOCUSABLE =
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])';
/**
* Make everything outside `root` inert (siblings of each ancestor up to
* <body>), so the tab bar behind the pushed screen cannot take focus.
* Returns the undo.
*/
export function inertOutside(root: HTMLElement): () => void {
const made: HTMLElement[] = [];
for (let node: HTMLElement = root; node.parentElement && node !== document.body; node = node.parentElement) {
for (const sibling of Array.from(node.parentElement.children)) {
if (sibling === node || !(sibling instanceof HTMLElement) || sibling.hasAttribute("inert")) continue;
sibling.setAttribute("inert", "");
sibling.setAttribute("aria-hidden", "true");
made.push(sibling);
}
}
return () => {
for (const el of made) {
el.removeAttribute("inert");
el.removeAttribute("aria-hidden");
}
};
}
function tasksPhrase(count: number): string {
return `${count} upcoming ${count === 1 ? "task" : "tasks"}`;
}
export interface OverrideEditorProps {
/** Active children, in display order. */
childOptions: AdminChild[];
initialChildId: number;
/** "YYYY-MM-DD"; the date fields start here. */
today: string;
onCancel: () => void;
onSaved: (override: PresenceOverride) => void;
}
export default function OverrideEditor({
childOptions,
initialChildId,
today,
onCancel,
onSaved,
}: OverrideEditorProps) {
const [childId, setChildId] = useState(initialChildId);
const [isPresent, setIsPresent] = useState(false);
const [startDate, setStartDate] = useState(today);
const [endDate, setEndDate] = useState(today);
const [note, setNote] = useState("");
const [consequence, setConsequence] = useState<Consequence>({ kind: "counting" });
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Every form change bumps the sequence; only the latest request may render.
const requestSeq = useRef(0);
const screenRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null);
// Modal focus: move in on open and keep the background out of reach. The
// opener hands focus back to its Add control once this screen closes.
useEffect(() => {
titleRef.current?.focus();
return screenRef.current ? inertOutside(screenRef.current) : undefined;
}, []);
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape") {
event.preventDefault();
if (!saving) onCancel();
return;
}
if (event.key !== "Tab" || !screenRef.current) return;
const focusable = Array.from(screenRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const current = document.activeElement;
const outside = !focusable.includes(current as HTMLElement);
if (event.shiftKey && (current === first || outside)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (current === last || outside)) {
event.preventDefault();
first.focus();
}
}
const child = childOptions.find((c) => c.id === childId) ?? childOptions[0];
const rangeValid = isValidRange(startDate, endDate);
useEffect(() => {
const seq = ++requestSeq.current;
if (!rangeValid) {
setConsequence({ kind: "invalid" });
return;
}
if (isPresent) {
// Keeping a child home removes nothing by definition.
setConsequence({ kind: "ready", removed: 0 });
return;
}
setConsequence({ kind: "counting" });
const timer = setTimeout(() => {
previewOverrideConsequence({
child_id: childId,
start_date: startDate,
end_date: endDate,
is_present: false,
})
.then((removed) => {
if (seq === requestSeq.current) setConsequence({ kind: "ready", removed });
})
.catch((err: unknown) => {
if (seq !== requestSeq.current) return;
setConsequence({
kind: "error",
message: errorMessage(err, "The upcoming tasks could not be counted. Try again."),
});
});
}, CONSEQUENCE_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [childId, startDate, endDate, isPresent, rangeValid]);
const canSave = rangeValid && consequence.kind === "ready" && !saving;
function save() {
if (!canSave) return;
const body: PresenceOverrideBody = {
child_id: childId,
start_date: startDate,
end_date: endDate,
is_present: isPresent,
};
const reason = note.trim();
if (reason) body.note = reason;
setSaving(true);
setError(null);
createPresenceOverride(body)
.then(onSaved)
.catch((err: unknown) => {
setError(errorMessage(err, "The override could not be saved. Check your connection and try again."));
setSaving(false);
});
}
let headline: string;
let detail: string;
const range = rangeValid ? dateRange(startDate, endDate) : "";
if (consequence.kind === "invalid") {
headline = "Choose a valid date range";
detail = "Through must be on or after From.";
} else if (consequence.kind === "counting") {
headline = "Counting upcoming tasks…";
detail = `${child.display_name} away ${range}. Completed days are untouched.`;
} else if (consequence.kind === "error") {
headline = "Upcoming tasks not counted";
detail = consequence.message;
} else {
headline = `This removes ${tasksPhrase(consequence.removed)}`;
detail = isPresent
? `${child.display_name} is home ${range}, so no tasks are removed. Completed days are untouched.`
: `${child.display_name}'s open tasks ${range} are removed while away. Completed days are untouched.`;
}
return (
<div
ref={screenRef}
className="override-screen"
role="dialog"
aria-modal="true"
aria-labelledby="override-title"
onKeyDown={onKeyDown}
>
<header className="override-header">
<button type="button" className="override-back" aria-label="Back" onClick={onCancel}>
<ChevronLeftGlyph size={20} />
</button>
<div>
<h2 className="override-title" id="override-title" ref={titleRef} tabIndex={-1}>
Presence override
</h2>
<p className="override-sub">Beats the custody pattern for these dates</p>
</div>
</header>
<div className="override-scroll">
<section className="override-card" aria-label="Override details">
<div className="override-field">
<span className="override-label" id="override-label-child">
Child
</span>
<div className="override-segments override-segments--3" role="group" aria-labelledby="override-label-child">
{childOptions.map((c) => (
<button
key={c.id}
type="button"
className="override-segment override-segment--gradient"
aria-pressed={c.id === childId}
onClick={() => setChildId(c.id)}
>
{c.display_name}
</button>
))}
</div>
</div>
<div className="override-field">
<span className="override-label" id="override-label-status">
Status for these dates
</span>
<div className="override-segments override-segments--2" role="group" aria-labelledby="override-label-status">
<button
type="button"
className="override-segment override-segment--ink"
aria-pressed={!isPresent}
onClick={() => setIsPresent(false)}
>
Away
</button>
<button
type="button"
className="override-segment override-segment--ink"
aria-pressed={isPresent}
onClick={() => setIsPresent(true)}
>
Home
</button>
</div>
</div>
<div className="override-dates">
<label className="override-field">
<span className="override-label">From</span>
<input
className="override-input override-date"
type="date"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
/>
</label>
<label className="override-field">
<span className="override-label">Through</span>
<input
className="override-input override-date"
type="date"
value={endDate}
min={isIsoDate(startDate) ? startDate : undefined}
onChange={(event) => setEndDate(event.target.value)}
/>
</label>
</div>
<label className="override-field">
<span className="override-label">Reason (optional)</span>
<input
className="override-input"
type="text"
value={note}
onChange={(event) => setNote(event.target.value)}
/>
</label>
</section>
<section
className="override-warning"
aria-label="Consequence"
aria-live="polite"
data-testid="override-consequence"
data-state={consequence.kind}
>
<span className="override-warning-glyph">
<AlertCircleGlyph />
</span>
<div>
<p className="override-warning-headline" data-testid="override-consequence-headline">
{headline}
</p>
<p className="override-warning-body">{detail}</p>
</div>
</section>
{error ? (
<p className="override-error" role="alert" data-testid="override-error">
{error}
</p>
) : null}
<div className="override-footer">
<button type="button" className="override-cancel" onClick={onCancel} disabled={saving}>
Cancel
</button>
<button type="button" className="override-save" onClick={save} disabled={!canSave}>
{saving ? "Saving…" : "Save override"}
</button>
</div>
</div>
</div>
);
}
+416
View File
@@ -0,0 +1,416 @@
/* Schedule tab — compact density (design/ADMIN-SPEC.md §4). Mobile-first at
the 390x844 reference; on wider viewports the column is capped and centred. */
.schedule-screen {
width: 100%;
max-width: 640px;
margin: 0 auto;
}
.schedule-header {
background: var(--nq-a-surface);
border-bottom: 1px solid var(--nq-a-border);
padding: 14px 16px;
}
.schedule-title {
margin: 0;
font-size: var(--nq-a-size-screen);
font-weight: 800;
letter-spacing: -0.02em;
}
.schedule-sub {
margin: 2px 0 0;
min-height: 1.4em;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
.schedule-scroll {
display: flex;
flex-direction: column;
gap: var(--nq-a-density-gap);
padding: 14px 16px;
}
.schedule-card {
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
}
/* Child selector */
.schedule-children {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.schedule-child-chip {
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-radius-pill);
background: var(--nq-a-surface);
color: var(--nq-a-ink-secondary);
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
}
.schedule-child-chip[aria-pressed="true"] {
border-color: var(--nq-brand-blue);
background: var(--nq-a-selected);
color: var(--nq-brand-blue);
}
/* Month grid card */
.schedule-month {
padding: var(--nq-a-density-card-pad);
}
.schedule-month-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.schedule-month-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.schedule-chevrons {
display: flex;
gap: 4px;
}
.schedule-chevron {
display: grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--nq-a-border);
border-radius: 6px;
background: var(--nq-a-surface);
color: var(--nq-a-ink-secondary);
cursor: pointer;
}
.schedule-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.schedule-weekday {
text-align: center;
font-size: 10px;
font-weight: 700;
color: var(--nq-a-ink-tertiary);
}
.schedule-day {
aspect-ratio: 1;
padding: 0;
border: 1px solid transparent;
border-radius: 6px;
font-family: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.schedule-day--home {
background: var(--nq-a-selected);
color: #201f7a;
}
.schedule-day--away {
background: var(--nq-a-page);
color: var(--nq-a-ink-secondary);
}
.schedule-day--override-away,
.schedule-day--override-home {
background: var(--nq-a-warning-bg);
color: var(--nq-a-warning-ink);
border-color: var(--nq-a-warning-border);
}
/* An override that keeps the child home reads with a dashed border. */
.schedule-day--override-home {
border-style: dashed;
}
.schedule-day--today {
background: var(--nq-brand-gradient);
border-color: transparent;
color: #fff;
font-weight: 800;
}
.schedule-legend {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
margin: 10px 0 0;
padding: 0;
list-style: none;
}
.schedule-legend-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 10.5px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.schedule-swatch {
display: inline-block;
width: 12px;
height: 12px;
border: 1px solid transparent;
border-radius: 3px;
}
/* Pattern editing controls */
.schedule-controls {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--nq-a-divider);
}
.schedule-field {
display: flex;
flex-direction: column;
gap: 3px;
font-size: var(--nq-a-size-label);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--nq-a-ink-secondary);
}
.schedule-field input,
.schedule-field select {
min-height: 36px;
padding: 0 8px;
border: 1px solid var(--nq-a-border);
border-radius: 6px;
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 13px;
font-weight: 600;
text-transform: none;
letter-spacing: normal;
}
.schedule-save {
min-height: 36px;
margin-left: auto;
padding: 0 14px;
border: 0;
border-radius: var(--nq-a-density-radius);
background: var(--nq-brand-gradient);
color: #fff;
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.schedule-save:disabled {
opacity: 0.45;
cursor: default;
}
.schedule-save-status {
margin: 8px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-success);
}
.schedule-save-status--error {
color: var(--nq-a-danger-strong);
}
/* Presence pattern + overrides cards */
.schedule-section-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.schedule-section-label {
margin: 6px 0 -2px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--nq-a-ink-secondary);
}
.schedule-text-action {
padding: 4px 0 0;
border: 0;
background: none;
color: var(--nq-brand-blue);
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
}
.schedule-list {
margin: 0;
padding: 0;
list-style: none;
}
.schedule-list > li + li {
border-top: 1px solid var(--nq-a-divider);
}
.schedule-row {
display: flex;
align-items: center;
gap: 10px;
min-height: var(--nq-a-tap-min);
padding: var(--nq-a-density-row-pad);
}
.schedule-row-glyph {
flex: none;
display: flex;
color: var(--nq-a-warning-ink);
}
.schedule-row-text {
flex: 1;
min-width: 0;
}
.schedule-row-title {
font-size: 13px;
font-weight: 700;
}
.schedule-row-meta {
font-size: var(--nq-a-size-meta);
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.schedule-rule {
flex: none;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.schedule-icon-action {
flex: none;
display: grid;
place-items: center;
width: var(--nq-a-tap-min);
height: var(--nq-a-tap-min);
margin: -8px -10px -8px 0;
padding: 0;
border: 0;
background: none;
color: var(--nq-a-ink-tertiary);
cursor: pointer;
}
.schedule-empty {
padding: var(--nq-a-density-card-pad);
font-size: 13px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
/* Loading / error */
.schedule-status {
padding: 24px 13px;
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
text-align: center;
font-size: 13.5px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.schedule-status--error {
color: var(--nq-a-danger-strong);
}
.schedule-status--error p {
margin: 0 0 10px;
}
.schedule-retry {
padding: 8px 16px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
/* Inline delete confirmation in an override row */
.schedule-confirm {
flex: none;
display: flex;
align-items: center;
gap: 6px;
}
.schedule-confirm-text {
font-size: 12px;
font-weight: 700;
color: var(--nq-a-ink-secondary);
}
.schedule-confirm-keep,
.schedule-confirm-delete {
min-height: 32px;
padding: 0 10px;
border-radius: 6px;
font-family: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.schedule-confirm-keep {
border: 1px solid var(--nq-a-border);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
}
.schedule-confirm-delete {
border: 0;
background: var(--nq-a-danger);
color: #fff;
}
+276
View File
@@ -0,0 +1,276 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import ScheduleTab from "./ScheduleTab";
import type { AdminChild } from "../api/definitions";
import type { PresenceOverride, PresenceSchedule } from "../api/presence";
import { storeTokens } from "../auth/oidc";
function child(id: number, display_name: string, sort_order: number, is_active = true): AdminChild {
return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active };
}
const CHILDREN = [
child(3, "Rory", 3),
child(1, "Declan", 1),
child(2, "Maeve", 2),
child(9, "Former", 4, false),
];
// Declan: anchor Monday 2026-09-07; cycle week 0 home every day, week 1 Sat/Sun.
// Keys arrive as JSON strings, exactly as the API serializes them.
const DECLAN: PresenceSchedule = {
child_id: 1,
cycle_length_weeks: 2,
anchor_date: "2026-09-07",
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] },
};
const MAEVE: PresenceSchedule = {
child_id: 2,
cycle_length_weeks: 4,
anchor_date: "2026-09-07",
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [], "2": [0, 1, 2, 3, 4, 5, 6], "3": [] },
};
const OVERRIDES: PresenceOverride[] = [
{ id: 41, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: "Dentist trip" },
{ id: 42, child_id: 1, start_date: "2026-09-16", end_date: "2026-09-16", is_present: true, note: null },
{ id: 43, child_id: 2, start_date: "2026-09-28", end_date: "2026-10-02", is_present: false, note: "Camp" },
];
const TODAY = "2026-09-23";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
let fetchMock: ReturnType<typeof vi.fn>;
let putResponse: (body: unknown) => Response;
function routeFetch(url: string, init: RequestInit = {}): Response {
const path = new URL(url, "http://x").pathname;
if (init.method === "PUT") return putResponse(JSON.parse(String(init.body)));
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
const match = /\/children\/(\d+)\/presence-schedule$/.exec(path);
if (match) {
const id = Number(match[1]);
return jsonResponse({ schedule: id === 1 ? DECLAN : id === 2 ? MAEVE : null });
}
if (path.endsWith("/presence-overrides")) return jsonResponse({ overrides: OVERRIDES });
return jsonResponse({ detail: "unexpected" }, 500);
}
function day(date: string): HTMLElement {
const cell = document.querySelector<HTMLElement>(`[data-date="${date}"]`);
if (!cell) throw new Error(`no cell for ${date}`);
return cell;
}
async function renderReady() {
render(<ScheduleTab today={TODAY} />);
await screen.findByTestId("pattern-1");
}
describe("ScheduleTab", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
putResponse = (body) => jsonResponse({ child_id: 1, ...(body as object) });
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("loads active children, their schedules and the overrides", async () => {
await renderReady();
const urls = fetchMock.mock.calls.map(([url]) => new URL(String(url), "http://x").pathname);
expect(urls).toContain("/api/v1/admin/children");
expect(urls).toContain("/api/v1/admin/children/1/presence-schedule");
expect(urls).toContain("/api/v1/admin/children/2/presence-schedule");
expect(urls).toContain("/api/v1/admin/children/3/presence-schedule");
expect(urls).not.toContain("/api/v1/admin/children/9/presence-schedule");
expect(urls).toContain("/api/v1/admin/presence-overrides");
const chips = within(screen.getByRole("group", { name: "Child" })).getAllByRole("button");
expect(chips.map((chip) => chip.textContent)).toEqual(["Declan", "Maeve", "Rory"]);
expect(chips[0].getAttribute("aria-pressed")).toBe("true");
});
it("header shows the cycle length and anchor", async () => {
await renderReady();
expect(screen.getByRole("heading", { name: "Schedule" })).toBeTruthy();
expect(screen.getByText("2-week cycle · anchor Monday, Sep 7")).toBeTruthy();
});
it("month grid renders home, away, override and today states from the anchor", async () => {
await renderReady();
expect(screen.getByTestId("month-label").textContent).toBe("September 2026");
const states: Record<string, string> = {
"2026-09-01": "away", // before the anchor: cycle week 1, Tuesday
"2026-09-05": "home", // before the anchor: cycle week 1, Saturday
"2026-09-07": "home", // anchor: week 0
"2026-09-11": "override-away",
"2026-09-15": "away", // week 1, Tuesday
"2026-09-16": "override-home",
"2026-09-19": "home", // week 1, Saturday
"2026-09-22": "home", // wrap back to week 0
"2026-09-29": "away", // week 1 again
};
for (const [date, state] of Object.entries(states)) {
expect(day(date).dataset.state, date).toBe(state);
expect(day(date).className).toContain(`schedule-day--${state}`);
expect(day(date).className).not.toContain("schedule-day--today");
}
const today = day(TODAY);
expect(today.className).toContain("schedule-day--today");
expect(today.dataset.state).toBe("home");
expect(today.getAttribute("aria-label")).toBe("Sep 23 (today): Home");
// September 2026 starts on a Tuesday: one leading pad after the initials.
const grid = screen.getByRole("group", { name: "Month grid" });
expect(grid.children[7].tagName).toBe("SPAN");
expect(grid.children[8].getAttribute("data-date")).toBe("2026-09-01");
const legend = screen.getByRole("list", { name: "Legend" });
expect(within(legend).getAllByRole("listitem").map((li) => li.textContent)).toEqual([
"Home",
"Away",
"Override · away",
"Override · home",
"Today",
]);
});
it("navigates months with the chevrons", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Next month" }));
expect(screen.getByTestId("month-label").textContent).toBe("October 2026");
// Oct 1 is day 24 from the anchor: cycle week 1, a Thursday — away.
expect(day("2026-10-01").dataset.state).toBe("away");
expect(day("2026-10-03").dataset.state).toBe("home"); // week 1, Saturday
expect(day("2026-10-05").dataset.state).toBe("home"); // week 0 again
fireEvent.click(screen.getByRole("button", { name: "Previous month" }));
expect(screen.getByTestId("month-label").textContent).toBe("September 2026");
});
it("presence pattern card shows a rule per child", async () => {
await renderReady();
expect(within(screen.getByTestId("pattern-1")).getByText("Weeks 1 & 2 of cycle")).toBeTruthy();
expect(within(screen.getByTestId("pattern-2")).getByText("Weeks 1 & 3 of cycle")).toBeTruthy();
expect(within(screen.getByTestId("pattern-3")).getByText("Every day")).toBeTruthy();
});
it("overrides card lists the selected child's overrides with inert actions", async () => {
await renderReady();
const list = screen.getByRole("list", { name: "Overrides" });
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
const away = within(list).getByTestId("override-41");
expect(within(away).getByText("Declan away · Sep 10 – 12")).toBeTruthy();
expect(within(away).getByText("Dentist trip")).toBeTruthy();
expect(within(away).getByRole("button", { name: "Delete override" })).toBeTruthy();
expect(within(list).getByText("Declan home · Sep 16")).toBeTruthy();
expect(screen.getByRole("button", { name: "Add" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
expect(screen.getByText("Maeve away · Sep 28 – Oct 2")).toBeTruthy();
expect(screen.getByText("4-week cycle · anchor Monday, Sep 7")).toBeTruthy();
});
it("a child with no schedule is home every day and defaults to a 2-week cycle", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Rory" }));
expect(screen.getByText("2-week cycle · anchor Tuesday, Sep 1")).toBeTruthy();
expect(day("2026-09-15").dataset.state).toBe("home");
expect(screen.getByText("No overrides for Rory.")).toBeTruthy();
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
true,
);
});
it("editing a day and saving PUTs the pattern; the grid reflects the saved schedule", async () => {
await renderReady();
expect(day("2026-09-01").dataset.state).toBe("away");
fireEvent.click(day("2026-09-01")); // cycle week 1 (pre-anchor), Tuesday
// The draft already shows the edit, and every other week-1 Tuesday follows.
expect(day("2026-09-01").dataset.state).toBe("home");
expect(day("2026-09-15").dataset.state).toBe("home");
expect(day("2026-09-08").dataset.state).toBe("home");
fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
expect(await screen.findByText("Pattern saved.")).toBeTruthy();
const put = fetchMock.mock.calls.find(([, init]) => init?.method === "PUT");
expect(put).toBeTruthy();
const [url, init] = put!;
expect(new URL(String(url), "http://x").pathname).toBe("/api/v1/admin/children/1/presence-schedule");
expect(init.headers.Authorization).toBe("Bearer at-123");
expect(JSON.parse(init.body)).toEqual({
cycle_length_weeks: 2,
anchor_date: "2026-09-07",
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [1, 5, 6] },
});
expect(day("2026-09-15").dataset.state).toBe("home");
expect(day("2026-09-29").dataset.state).toBe("home");
expect(day("2026-09-30").dataset.state).toBe("away");
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
true,
);
});
it("saves an edited anchor date and cycle length", async () => {
await renderReady();
fireEvent.change(screen.getByLabelText("Anchor date"), { target: { value: "2026-09-14" } });
fireEvent.change(screen.getByLabelText("Cycle length"), { target: { value: "3" } });
expect(screen.getByText("3-week cycle · anchor Monday, Sep 14")).toBeTruthy();
// Anchor moved a week: Sep 7-13 is now the last week of the cycle (full, added).
fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
await screen.findByText("Pattern saved.");
const [, init] = fetchMock.mock.calls.find(([, i]) => i?.method === "PUT")!;
expect(JSON.parse(init.body)).toEqual({
cycle_length_weeks: 3,
anchor_date: "2026-09-14",
pattern: {
"0": [0, 1, 2, 3, 4, 5, 6],
"1": [5, 6],
"2": [0, 1, 2, 3, 4, 5, 6],
},
});
});
it("a 422 shows the API detail and keeps the draft", async () => {
putResponse = () => jsonResponse({ detail: "anchor_date must be a Monday" }, 422);
await renderReady();
fireEvent.click(day("2026-09-01"));
fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("anchor_date must be a Monday");
expect(day("2026-09-01").dataset.state).toBe("home");
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
false,
);
// The saved rule is unchanged.
expect(within(screen.getByTestId("pattern-1")).getByText("Weeks 1 & 2 of cycle")).toBeTruthy();
});
it("scroll column ends with 92px bottom padding", async () => {
await renderReady();
expect(screen.getByTestId("schedule-scroll").style.paddingBottom).toBe("92px");
});
it("shows an error state on a failed load and retries", async () => {
fetchMock.mockResolvedValueOnce(new Response("boom", { status: 500 }));
render(<ScheduleTab today={TODAY} />);
const error = await screen.findByTestId("schedule-error");
expect(error.textContent).toContain("could not be loaded");
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(await screen.findByTestId("pattern-1")).toBeTruthy();
});
});
+557
View File
@@ -0,0 +1,557 @@
/**
* Schedule tab — the presence month grid (design/ADMIN-SPEC.md §4).
*
* Every day's cycle position comes from the anchor date (./cycle.ts), never
* from ISO week numbers or week parity (D-004).
*/
import { useEffect, useRef, useState } from "react";
import { ApiForbiddenError } from "../api/client";
import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions";
import {
deletePresenceOverride,
fetchPresenceOverrides,
fetchPresenceSchedule,
savePresenceSchedule,
type PresenceOverride,
type PresenceSchedule,
type PresenceScheduleBody,
} from "../api/presence";
import {
cycleWeekIndex,
dayState,
fullPattern,
localTodayIso,
monthDates,
monthStart,
patternIncludes,
presenceRule,
resizePattern,
togglePatternDay,
weekdayOf,
type DayState,
} from "./cycle";
import { anchorLabel, dateRange, monthLabel, shortDate } from "./format";
import {
CalendarCheckGlyph,
CalendarXGlyph,
ChevronLeftGlyph,
ChevronRightGlyph,
Trash2Glyph,
} from "./glyphs";
import OverrideEditor from "./OverrideEditor";
import "./ScheduleTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
export const SCROLL_BOTTOM_PADDING = "92px";
export const DEFAULT_CYCLE_LENGTH_WEEKS = 2;
const CYCLE_LENGTH_CHOICES = [1, 2, 3, 4];
const WEEKDAY_INITIALS = ["M", "T", "W", "T", "F", "S", "S"];
export const STATE_LABELS: Record<DayState, string> = {
home: "Home",
away: "Away",
"override-away": "Override · away",
"override-home": "Override · home",
};
interface Loaded {
children: AdminChild[];
schedules: Record<number, PresenceSchedule | null>;
overrides: PresenceOverride[];
}
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; data: Loaded };
type DeleteState =
| { kind: "idle" }
| { kind: "confirming"; id: number }
| { kind: "deleting"; id: number }
| { kind: "error"; id: number; message: string };
type SaveState =
| { kind: "idle" }
| { kind: "saving" }
| { kind: "saved" }
| { kind: "error"; message: string };
async function loadSchedule(): Promise<Loaded> {
const children = (await fetchChildren())
.filter((child) => child.is_active)
.sort((a, b) => a.sort_order - b.sort_order);
const [schedules, overrides] = await Promise.all([
Promise.all(children.map((child) => fetchPresenceSchedule(child.id))),
fetchPresenceOverrides(),
]);
return {
children,
schedules: Object.fromEntries(children.map((child, i) => [child.id, schedules[i]])),
overrides,
};
}
function loadErrorMessage(error: unknown): string {
return error instanceof ApiForbiddenError
? error.message
: "The schedule could not be loaded. Check your connection and try again.";
}
function saveErrorMessage(
error: unknown,
fallback = "The pattern could not be saved. Check your connection and try again.",
): string {
if (error instanceof ApiForbiddenError) return error.message;
if (error instanceof ApiRequestError && error.detail) return error.detail;
return fallback;
}
function ScreenHeader({ sub }: { sub: string }) {
return (
<header className="schedule-header">
<h2 className="schedule-title">Schedule</h2>
<p className="schedule-sub">{sub}</p>
</header>
);
}
interface MonthGridProps {
month: string;
today: string;
schedule: PresenceScheduleBody;
/** Null when the child has no saved schedule and no edits: home every day. */
effective: PresenceScheduleBody | null;
overrides: PresenceOverride[];
onMonth: (delta: number) => void;
onToggle: (date: string) => void;
}
function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onToggle }: MonthGridProps) {
const dates = monthDates(month);
const leading = weekdayOf(month);
return (
<>
<div className="schedule-month-head">
<span className="schedule-month-label" data-testid="month-label">
{monthLabel(month)}
</span>
<div className="schedule-chevrons">
<button
type="button"
className="schedule-chevron"
aria-label="Previous month"
onClick={() => onMonth(-1)}
>
<ChevronLeftGlyph />
</button>
<button
type="button"
className="schedule-chevron"
aria-label="Next month"
onClick={() => onMonth(1)}
>
<ChevronRightGlyph />
</button>
</div>
</div>
<div className="schedule-grid" role="group" aria-label="Month grid">
{WEEKDAY_INITIALS.map((initial, i) => (
<span key={`wd-${i}`} className="schedule-weekday" aria-hidden="true">
{initial}
</span>
))}
{Array.from({ length: leading }, (_, i) => (
<span key={`pad-${i}`} aria-hidden="true" />
))}
{dates.map((date) => {
const state = dayState(effective, overrides, date);
const isToday = date === today;
let className = `schedule-day schedule-day--${state}`;
if (isToday) className += " schedule-day--today";
return (
<button
key={date}
type="button"
className={className}
data-date={date}
data-state={state}
aria-pressed={patternIncludes(schedule, date)}
aria-label={`${shortDate(date)}${isToday ? " (today)" : ""}: ${STATE_LABELS[state]}`}
onClick={() => onToggle(date)}
>
{Number(date.slice(8))}
</button>
);
})}
</div>
<ul className="schedule-legend" aria-label="Legend">
{(["home", "away", "override-away", "override-home"] as DayState[]).map((state) => (
<li key={state} className="schedule-legend-item">
<span className={`schedule-swatch schedule-day--${state}`} aria-hidden="true" />
{STATE_LABELS[state]}
</li>
))}
<li className="schedule-legend-item">
<span className="schedule-swatch schedule-day--today" aria-hidden="true" />
Today
</li>
</ul>
</>
);
}
interface OverrideRowProps {
override: PresenceOverride;
childName: string;
deletion: DeleteState;
onAskDelete: () => void;
onKeep: () => void;
onDelete: () => void;
}
function OverrideRow({ override, childName, deletion, onAskDelete, onKeep, onDelete }: OverrideRowProps) {
const mine =
(deletion.kind === "confirming" || deletion.kind === "deleting") && deletion.id === override.id;
return (
<li className="schedule-row" data-testid={`override-${override.id}`}>
<span className="schedule-row-glyph">
{override.is_present ? <CalendarCheckGlyph /> : <CalendarXGlyph />}
</span>
<div className="schedule-row-text">
<div className="schedule-row-title">
{childName} {override.is_present ? "home" : "away"} ·{" "}
{dateRange(override.start_date, override.end_date)}
</div>
<div className="schedule-row-meta">{override.note || "No reason given"}</div>
</div>
{mine ? (
<div className="schedule-confirm" role="group" aria-label="Confirm delete">
<span className="schedule-confirm-text">Delete override?</span>
<button
type="button"
className="schedule-confirm-keep"
onClick={onKeep}
disabled={deletion.kind === "deleting"}
>
Keep
</button>
<button
type="button"
className="schedule-confirm-delete"
onClick={onDelete}
disabled={deletion.kind === "deleting"}
>
{deletion.kind === "deleting" ? "Deleting…" : "Delete"}
</button>
</div>
) : (
<button
type="button"
className="schedule-icon-action"
aria-label="Delete override"
onClick={onAskDelete}
>
<Trash2Glyph />
</button>
)}
</li>
);
}
export interface ScheduleTabProps {
/** "YYYY-MM-DD"; defaults to the viewer's local date. */
today?: string;
}
export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProps) {
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [month, setMonth] = useState(() => monthStart(today));
const [drafts, setDrafts] = useState<Record<number, PresenceScheduleBody>>({});
const [save, setSave] = useState<SaveState>({ kind: "idle" });
const [editorOpen, setEditorOpen] = useState(false);
const [deletion, setDeletion] = useState<DeleteState>({ kind: "idle" });
const addRef = useRef<HTMLButtonElement>(null);
const editorWasOpen = useRef(false);
// The editor replaces this screen, so Add is remounted when it closes;
// hand focus back to it then.
useEffect(() => {
if (editorWasOpen.current && !editorOpen) addRef.current?.focus();
editorWasOpen.current = editorOpen;
}, [editorOpen]);
/** Reload children, schedules and overrides in place (no loading flash). */
const refresh = () =>
loadSchedule()
.then((data) => setState({ kind: "ready", data }))
.catch((error: unknown) => setState({ kind: "error", message: loadErrorMessage(error) }));
useEffect(() => {
let cancelled = false;
setState({ kind: "loading" });
loadSchedule()
.then((data) => {
if (cancelled) return;
setState({ kind: "ready", data });
setSelectedId((current) => current ?? data.children[0]?.id ?? null);
})
.catch((error: unknown) => {
if (cancelled) return;
setState({ kind: "error", message: loadErrorMessage(error) });
});
return () => {
cancelled = true;
};
}, [attempt]);
if (editorOpen && state.kind === "ready" && state.data.children.length > 0) {
const { children } = state.data;
return (
<OverrideEditor
childOptions={children}
initialChildId={children.find((c) => c.id === selectedId)?.id ?? children[0].id}
today={today}
onCancel={() => setEditorOpen(false)}
onSaved={(override) => {
setEditorOpen(false);
setSelectedId(override.child_id);
void refresh();
}}
/>
);
}
let sub = "";
let body;
if (state.kind === "loading") {
body = (
<div className="schedule-status" role="status" data-testid="schedule-loading">
Loading schedule…
</div>
);
} else if (state.kind === "error") {
body = (
<div className="schedule-status schedule-status--error" role="alert" data-testid="schedule-error">
<p>{state.message}</p>
<button type="button" className="schedule-retry" onClick={() => setAttempt((n) => n + 1)}>
Try again
</button>
</div>
);
} else if (state.data.children.length === 0) {
body = <div className="schedule-card schedule-empty">No children yet.</div>;
} else {
const { data } = state;
const child = data.children.find((c) => c.id === selectedId) ?? data.children[0];
const saved = data.schedules[child.id] ?? null;
const draft = drafts[child.id];
// Edits start from the saved schedule, or — with none — from "home every
// day" on a 2-week cycle anchored at the displayed month's 1st.
const schedule: PresenceScheduleBody = draft ??
saved ?? {
cycle_length_weeks: DEFAULT_CYCLE_LENGTH_WEEKS,
anchor_date: month,
pattern: fullPattern(DEFAULT_CYCLE_LENGTH_WEEKS),
};
const effective = draft ?? saved;
const childOverrides = data.overrides.filter((o) => o.child_id === child.id);
sub = `${schedule.cycle_length_weeks}-week cycle · anchor ${anchorLabel(schedule.anchor_date)}`;
const edit = (next: PresenceScheduleBody) => {
setDrafts((all) => ({ ...all, [child.id]: next }));
setSave({ kind: "idle" });
};
const toggle = (date: string) => {
const week = cycleWeekIndex(schedule.anchor_date, date, schedule.cycle_length_weeks);
edit({ ...schedule, pattern: togglePatternDay(schedule.pattern, week, weekdayOf(date)) });
};
const confirmDelete = (id: number) => {
setDeletion({ kind: "deleting", id });
deletePresenceOverride(id)
.then(() => refresh())
.then(() => setDeletion({ kind: "idle" }))
.catch((error: unknown) =>
setDeletion({
kind: "error",
id,
message: saveErrorMessage(
error,
"The override could not be deleted. Check your connection and try again.",
),
}),
);
};
const submit = () => {
if (!draft) return;
const childId = child.id;
setSave({ kind: "saving" });
const { cycle_length_weeks, anchor_date, pattern } = draft;
savePresenceSchedule(childId, { cycle_length_weeks, anchor_date, pattern })
.then((stored) => {
setState((current) =>
current.kind === "ready"
? {
kind: "ready",
data: {
...current.data,
schedules: { ...current.data.schedules, [childId]: stored },
},
}
: current,
);
setDrafts((all) => {
const rest = { ...all };
delete rest[childId];
return rest;
});
setSave({ kind: "saved" });
})
.catch((error: unknown) => setSave({ kind: "error", message: saveErrorMessage(error) }));
};
body = (
<>
<div className="schedule-children" role="group" aria-label="Child">
{data.children.map((c) => (
<button
key={c.id}
type="button"
className="schedule-child-chip"
aria-pressed={c.id === child.id}
onClick={() => {
setSelectedId(c.id);
setSave({ kind: "idle" });
}}
>
{c.display_name}
</button>
))}
</div>
<section className="schedule-card schedule-month" aria-label="Month grid card">
<MonthGrid
month={month}
today={today}
schedule={schedule}
effective={effective}
overrides={childOverrides}
onMonth={(delta) => setMonth((m) => monthStart(m, delta))}
onToggle={toggle}
/>
<div className="schedule-controls">
<label className="schedule-field">
<span>Anchor date</span>
<input
type="date"
value={schedule.anchor_date}
onChange={(event) => {
if (event.target.value) edit({ ...schedule, anchor_date: event.target.value });
}}
/>
</label>
<label className="schedule-field">
<span>Cycle length</span>
<select
value={schedule.cycle_length_weeks}
onChange={(event) => {
const weeks = Number(event.target.value);
edit({
...schedule,
cycle_length_weeks: weeks,
pattern: resizePattern(schedule.pattern, weeks),
});
}}
>
{CYCLE_LENGTH_CHOICES.map((weeks) => (
<option key={weeks} value={weeks}>
{weeks} {weeks === 1 ? "week" : "weeks"}
</option>
))}
</select>
</label>
<button
type="button"
className="schedule-save"
disabled={!draft || save.kind === "saving"}
onClick={submit}
>
{save.kind === "saving" ? "Saving…" : "Save pattern"}
</button>
</div>
{save.kind === "error" ? (
<p className="schedule-save-status schedule-save-status--error" role="alert">
{save.message}
</p>
) : save.kind === "saved" ? (
<p className="schedule-save-status" role="status">
Pattern saved.
</p>
) : null}
</section>
<h3 className="schedule-section-label">Presence pattern</h3>
<ul className="schedule-card schedule-list" aria-label="Presence pattern">
{data.children.map((c) => (
<li key={c.id} className="schedule-row" data-testid={`pattern-${c.id}`}>
<div className="schedule-row-text">
<div className="schedule-row-title">{c.display_name}</div>
</div>
<span className="schedule-rule">{presenceRule(data.schedules[c.id] ?? null)}</span>
</li>
))}
</ul>
<div className="schedule-section-head">
<h3 className="schedule-section-label">Overrides</h3>
<button
ref={addRef}
type="button"
className="schedule-text-action"
onClick={() => setEditorOpen(true)}
>
Add
</button>
</div>
{childOverrides.length > 0 ? (
<ul className="schedule-card schedule-list" aria-label="Overrides">
{childOverrides.map((override) => (
<OverrideRow
key={override.id}
override={override}
childName={child.display_name}
deletion={deletion}
onAskDelete={() => setDeletion({ kind: "confirming", id: override.id })}
onKeep={() => setDeletion({ kind: "idle" })}
onDelete={() => confirmDelete(override.id)}
/>
))}
</ul>
) : (
<div className="schedule-card schedule-empty">No overrides for {child.display_name}.</div>
)}
{deletion.kind === "error" ? (
<p className="schedule-save-status schedule-save-status--error" role="alert">
{deletion.message}
</p>
) : null}
</>
);
}
return (
<div className="schedule-screen">
<ScreenHeader sub={sub} />
<div
className="schedule-scroll"
data-testid="schedule-scroll"
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
>
{body}
</div>
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import {
cycleWeekIndex,
dayState,
monthDates,
monthStart,
presenceRule,
resizePattern,
togglePatternDay,
weekdayOf,
} from "./cycle";
import type { PresenceOverride, PresenceScheduleBody } from "../api/presence";
// Anchor Monday 2026-09-07; week 0 home every day, week 1 home Sat/Sun only.
const SCHEDULE: PresenceScheduleBody = {
cycle_length_weeks: 2,
anchor_date: "2026-09-07",
pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [5, 6] },
};
describe("cycleWeekIndex", () => {
it("follows the anchor across a month, wrapping every 2 weeks", () => {
const expected: Record<string, number> = {};
for (const date of monthDates("2026-09-01")) {
const day = Number(date.slice(8));
// Sep 7-13 week 0, 14-20 week 1, 21-27 week 0 (wrap), 28-30 week 1;
// Sep 1-6 lie before the anchor and walk back into week 1.
expected[date] = day < 7 ? 1 : Math.floor((day - 7) / 7) % 2;
}
for (const [date, week] of Object.entries(expected)) {
expect(cycleWeekIndex(SCHEDULE.anchor_date, date, 2), date).toBe(week);
}
expect(cycleWeekIndex("2026-09-07", "2026-09-21", 2)).toBe(0);
expect(cycleWeekIndex("2026-09-07", "2026-09-28", 2)).toBe(1);
});
it("walks the cycle backwards before the anchor (floor division)", () => {
expect(cycleWeekIndex("2026-09-07", "2026-09-06", 2)).toBe(1); // day -1
expect(cycleWeekIndex("2026-09-07", "2026-08-31", 2)).toBe(1); // day -7
expect(cycleWeekIndex("2026-09-07", "2026-08-30", 2)).toBe(0); // day -8
expect(cycleWeekIndex("2026-09-07", "2026-08-24", 2)).toBe(0); // day -14
expect(cycleWeekIndex("2026-09-07", "2026-08-23", 2)).toBe(1); // day -15
expect(cycleWeekIndex("2026-09-07", "2026-09-06", 3)).toBe(2); // last week of a 3-week cycle
});
it("ignores ISO week parity across a 53-week year", () => {
// 2026-12-28 is ISO week 53 and 2027-01-04 ISO week 1 — both odd, so
// parity would call them the same week. From the anchor they alternate.
expect(cycleWeekIndex("2026-09-07", "2026-12-28", 2)).toBe(0);
expect(cycleWeekIndex("2026-09-07", "2027-01-04", 2)).toBe(1);
});
});
describe("calendar helpers", () => {
it("weekdayOf is Monday=0", () => {
expect(weekdayOf("2026-09-07")).toBe(0);
expect(weekdayOf("2026-09-01")).toBe(1);
expect(weekdayOf("2026-09-06")).toBe(6);
});
it("monthStart shifts across year ends", () => {
expect(monthStart("2026-12-15", 1)).toBe("2027-01-01");
expect(monthStart("2026-01-31", -1)).toBe("2025-12-01");
expect(monthDates("2026-02-01")).toHaveLength(28);
});
});
describe("dayState", () => {
const overrides: PresenceOverride[] = [
{ id: 1, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: null },
{ id: 2, child_id: 1, start_date: "2026-09-16", end_date: "2026-09-16", is_present: true, note: null },
];
it("applies overrides first, then the anchor-based pattern", () => {
expect(dayState(SCHEDULE, overrides, "2026-09-08")).toBe("home");
expect(dayState(SCHEDULE, overrides, "2026-09-11")).toBe("override-away");
expect(dayState(SCHEDULE, overrides, "2026-09-15")).toBe("away");
expect(dayState(SCHEDULE, overrides, "2026-09-16")).toBe("override-home");
expect(dayState(SCHEDULE, overrides, "2026-09-19")).toBe("home");
expect(dayState(SCHEDULE, overrides, "2026-09-01")).toBe("away");
expect(dayState(SCHEDULE, overrides, "2026-09-05")).toBe("home");
});
it("treats a child with no schedule as home every day", () => {
expect(dayState(null, [], "2026-09-15")).toBe("home");
});
});
describe("pattern editing and rule text", () => {
it("toggles one weekday of one cycle week", () => {
expect(togglePatternDay(SCHEDULE.pattern, 1, 1)).toEqual({
0: [0, 1, 2, 3, 4, 5, 6],
1: [1, 5, 6],
});
expect(togglePatternDay(SCHEDULE.pattern, 1, 5)[1]).toEqual([6]);
});
it("resizes the pattern to the cycle length", () => {
expect(resizePattern(SCHEDULE.pattern, 1)).toEqual({ 0: [0, 1, 2, 3, 4, 5, 6] });
expect(resizePattern(SCHEDULE.pattern, 3)[2]).toEqual([0, 1, 2, 3, 4, 5, 6]);
});
it("describes the presence rule in plain language", () => {
expect(presenceRule(null)).toBe("Every day");
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [0, 1, 2, 3, 4, 5, 6] } })).toBe(
"Every day",
);
expect(presenceRule(SCHEDULE)).toBe("Weeks 1 & 2 of cycle");
expect(
presenceRule({ cycle_length_weeks: 4, anchor_date: "2026-09-07", pattern: { 0: [0], 1: [], 2: [3], 3: [] } }),
).toBe("Weeks 1 & 3 of cycle");
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [1], 1: [] } })).toBe("Week 1 of cycle");
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [], 1: [] } })).toBe("Never home");
});
});
+142
View File
@@ -0,0 +1,142 @@
/**
* Pure presence-cycle math for the Schedule tab.
*
* Cycle position comes from the anchor date ONLY, mirroring
* core/presence.py `cycle_week_index`:
* `((date - anchor).days // 7) % cycle_length_weeks` with floor division,
* so dates before the anchor walk the cycle backwards. Never ISO week
* numbers or week parity (D-004).
*
* Calendar dates are "YYYY-MM-DD" strings handled as whole UTC days, so no
* viewer offset or DST change can shift a day.
*/
import type { PresenceOverride, PresencePattern, PresenceScheduleBody } from "../api/presence";
const MS_PER_DAY = 86_400_000;
export const ALL_WEEKDAYS = [0, 1, 2, 3, 4, 5, 6];
/** Days since 1970-01-01 for a "YYYY-MM-DD" calendar date. */
export function dayNumber(iso: string): number {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
if (!match) throw new Error(`Invalid calendar date: ${iso}`);
const [, year, month, day] = match;
return Date.UTC(Number(year), Number(month) - 1, Number(day)) / MS_PER_DAY;
}
export function isoFromDayNumber(days: number): string {
return new Date(days * MS_PER_DAY).toISOString().slice(0, 10);
}
/** Floor modulo: always in 0..divisor-1, like Python's `%`. */
function mod(value: number, divisor: number): number {
return ((value % divisor) + divisor) % divisor;
}
/** 0 = Monday .. 6 = Sunday (1970-01-01 was a Thursday). */
export function weekdayOf(iso: string): number {
return mod(dayNumber(iso) + 3, 7);
}
/** `((date - anchor).days // 7) % cycleLengthWeeks`, Python semantics. */
export function cycleWeekIndex(anchorDate: string, date: string, cycleLengthWeeks: number): number {
const days = dayNumber(date) - dayNumber(anchorDate);
return mod(Math.floor(days / 7), cycleLengthWeeks);
}
/** The viewer's local calendar date. */
export function localTodayIso(now: Date = new Date()): string {
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${now.getFullYear()}-${month}-${day}`;
}
/** "YYYY-MM-01" of the month containing `iso`, shifted by `delta` months. */
export function monthStart(iso: string, delta = 0): string {
const [year, month] = iso.split("-").map(Number);
const date = new Date(Date.UTC(year, month - 1 + delta, 1));
return date.toISOString().slice(0, 10);
}
/** Every date of the month starting at `firstIso`. */
export function monthDates(firstIso: string): string[] {
const start = dayNumber(firstIso);
const end = dayNumber(monthStart(firstIso, 1));
const dates: string[] = [];
for (let day = start; day < end; day += 1) dates.push(isoFromDayNumber(day));
return dates;
}
/** Present every day for every week of the cycle (the no-schedule case). */
export function fullPattern(cycleLengthWeeks: number): PresencePattern {
const pattern: PresencePattern = {};
for (let week = 0; week < cycleLengthWeeks; week += 1) pattern[week] = [...ALL_WEEKDAYS];
return pattern;
}
export type DayState = "home" | "away" | "override-home" | "override-away";
export function patternIncludes(
schedule: PresenceScheduleBody,
date: string,
): boolean {
const week = cycleWeekIndex(schedule.anchor_date, date, schedule.cycle_length_weeks);
return (schedule.pattern[week] ?? []).includes(weekdayOf(date));
}
/**
* A date's state for one child: an override covering the date wins;
* otherwise the schedule's pattern decides (no schedule = home every day).
*/
export function dayState(
schedule: PresenceScheduleBody | null,
overrides: PresenceOverride[],
date: string,
): DayState {
const override = overrides.find((o) => o.start_date <= date && date <= o.end_date);
if (override) return override.is_present ? "override-home" : "override-away";
if (!schedule) return "home";
return patternIncludes(schedule, date) ? "home" : "away";
}
/** Flip `weekday` in `week` of the pattern; returns a new pattern. */
export function togglePatternDay(
pattern: PresencePattern,
week: number,
weekday: number,
): PresencePattern {
const days = pattern[week] ?? [];
const next = days.includes(weekday)
? days.filter((d) => d !== weekday)
: [...days, weekday].sort((a, b) => a - b);
return { ...pattern, [week]: next };
}
/** Fit the pattern to a new cycle length; added weeks start present every day. */
export function resizePattern(pattern: PresencePattern, cycleLengthWeeks: number): PresencePattern {
const next: PresencePattern = {};
for (let week = 0; week < cycleLengthWeeks; week += 1) {
next[week] = pattern[week] ? [...pattern[week]] : [...ALL_WEEKDAYS];
}
return next;
}
function joinWeeks(weeks: number[]): string {
if (weeks.length === 1) return String(weeks[0]);
return `${weeks.slice(0, -1).join(", ")} & ${weeks[weeks.length - 1]}`;
}
/** Plain-language rule: `Every day`, `Weeks 1 & 3 of cycle`, `Never home`. */
export function presenceRule(schedule: PresenceScheduleBody | null): string {
if (!schedule) return "Every day";
const weeks: number[] = [];
let full = true;
for (let week = 0; week < schedule.cycle_length_weeks; week += 1) {
const days = schedule.pattern[week] ?? [];
if (days.length > 0) weeks.push(week + 1);
if (days.length < 7) full = false;
}
if (full) return "Every day";
if (weeks.length === 0) return "Never home";
return `${weeks.length === 1 ? "Week" : "Weeks"} ${joinWeeks(weeks)} of cycle`;
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Date labels for the Schedule tab. Calendar dates are formatted in UTC from
* their components, so the printed day never shifts with the viewer's offset.
*/
import { dayNumber } from "./cycle";
const LOCALE = "en-US";
function utcDate(iso: string): Date {
return new Date(dayNumber(iso) * 86_400_000);
}
function format(iso: string, options: Intl.DateTimeFormatOptions): string {
return new Intl.DateTimeFormat(LOCALE, { ...options, timeZone: "UTC" }).format(utcDate(iso));
}
/** "Tuesday, Sep 1" */
export function anchorLabel(iso: string): string {
return `${format(iso, { weekday: "long" })}, ${format(iso, { month: "short", day: "numeric" })}`;
}
/** "September 2026" */
export function monthLabel(iso: string): string {
return format(iso, { month: "long", year: "numeric" });
}
/** "Sep 4" */
export function shortDate(iso: string): string {
return format(iso, { month: "short", day: "numeric" });
}
/** "Sep 4 – 7", "Sep 28 – Oct 2", or "Sep 4" for a single day. */
export function dateRange(start: string, end: string): string {
if (start === end) return shortDate(start);
const sameMonth = start.slice(0, 7) === end.slice(0, 7);
return `${shortDate(start)} – ${sameMonth ? Number(end.slice(8)) : shortDate(end)}`;
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Inline stroke glyphs for the Schedule tab (Lucide-style 24px grid),
* CDN-free like the Today and Tasks tabs' glyphs.
*/
import type { ReactNode } from "react";
function Glyph({ size, children }: { size: number; children: ReactNode }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
);
}
export function ChevronLeftGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="m15 18-6-6 6-6" />
</Glyph>
);
}
export function ChevronRightGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="m9 18 6-6-6-6" />
</Glyph>
);
}
function CalendarFrame() {
return (
<>
<path d="M8 2v4M16 2v4" />
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M3 10h18" />
</>
);
}
export function CalendarXGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<CalendarFrame />
<path d="m14 14-4 4M10 14l4 4" />
</Glyph>
);
}
export function CalendarCheckGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<CalendarFrame />
<path d="m9 16 2 2 4-4" />
</Glyph>
);
}
export function Trash2Glyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6" />
</Glyph>
);
}
export function AlertCircleGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</Glyph>
);
}
+331
View File
@@ -0,0 +1,331 @@
/* Settings — pushed screen over the Today tab (and the tab bar), like the
presence override editor. Compact density list, roomy form card. */
.settings-screen {
position: fixed;
inset: 0;
z-index: 30;
overflow-y: auto;
background: var(--nq-a-page);
}
.settings-header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px;
background: var(--nq-a-surface);
border-bottom: 1px solid var(--nq-a-border);
}
.settings-back {
flex: none;
display: grid;
place-items: center;
width: var(--nq-a-tap-min);
height: var(--nq-a-tap-min);
margin: -6px 0 -6px -12px;
padding: 0;
border: 0;
background: none;
color: var(--nq-a-ink);
cursor: pointer;
}
.settings-title {
margin: 0;
font-size: var(--nq-a-size-screen);
font-weight: 800;
letter-spacing: -0.02em;
}
.settings-sub {
margin: 2px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
.settings-scroll {
display: flex;
flex-direction: column;
gap: var(--nq-a-density-gap);
width: 100%;
max-width: 640px;
margin: 0 auto;
padding: 14px 16px;
}
.settings-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.settings-section-label {
margin: 0;
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);
}
.settings-add {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 36px;
padding: 0 12px;
border: 0;
border-radius: var(--nq-a-radius-pill);
background: var(--nq-brand-gradient);
color: #fff;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
}
.settings-card {
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
}
.settings-list {
margin: 0;
padding: 0;
list-style: none;
}
.settings-list > li + li {
border-top: 1px solid var(--nq-a-divider);
}
.settings-child {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: var(--nq-a-density-row-pad);
}
.settings-child--inactive .settings-child-name {
color: var(--nq-a-ink-tertiary);
}
.settings-swatch {
flex: none;
width: 22px;
height: 22px;
border-radius: var(--nq-a-radius-pill);
border: 1px solid rgba(9, 9, 11, 0.12);
}
.settings-child-text {
flex: 1;
min-width: 0;
}
.settings-child-name {
font-size: var(--nq-a-size-row);
font-weight: 700;
}
.settings-child-meta {
margin-top: 1px;
font-size: var(--nq-a-size-meta);
font-weight: 600;
color: var(--nq-a-ink-tertiary);
}
.settings-pill {
flex: none;
padding: 2px 8px;
border-radius: var(--nq-a-radius-pill);
background: var(--nq-a-divider);
color: var(--nq-a-ink-secondary);
font-size: var(--nq-a-size-meta);
font-weight: 700;
}
.settings-pill--active {
background: #dcfce7;
color: var(--nq-a-success);
}
.settings-child-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
width: 100%;
}
.settings-icon-button,
.settings-action {
min-height: 36px;
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: 12px;
font-weight: 700;
cursor: pointer;
}
.settings-icon-button {
display: grid;
place-items: center;
width: 36px;
padding: 0;
}
.settings-action {
padding: 0 12px;
}
.settings-icon-button:disabled,
.settings-action:disabled,
.settings-add:disabled,
.settings-back:disabled,
.settings-cancel:disabled,
.settings-save:disabled {
opacity: 0.45;
cursor: default;
}
.settings-empty,
.settings-status {
padding: 16px;
font-size: 13px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
text-align: center;
}
.settings-status--error {
color: var(--nq-a-danger-strong);
}
.settings-status p {
margin: 0 0 10px;
}
.settings-action-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
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;
}
/* Add / edit form: roomy density, 18px padding, 16px gaps. */
.settings-form {
display: flex;
flex-direction: column;
gap: 16px;
padding: 18px;
}
.settings-form-title {
margin: 0;
font-size: 17px;
font-weight: 800;
}
.settings-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.settings-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);
}
.settings-input {
width: 100%;
min-height: var(--nq-a-tap-min);
padding: 13px;
border: 1px solid var(--nq-a-border);
border-radius: 10px;
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 14px;
font-weight: 600;
}
.settings-swatches {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.settings-swatch-option {
width: 32px;
height: 32px;
padding: 0;
border: 2px solid transparent;
border-radius: var(--nq-a-radius-pill);
cursor: pointer;
}
.settings-swatch-option[aria-pressed="true"] {
border-color: var(--nq-a-ink);
box-shadow: inset 0 0 0 2px var(--nq-a-surface);
}
.settings-error {
margin: 0;
padding: 10px 12px;
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-danger-bg);
color: var(--nq-a-danger-strong);
font-size: 12.5px;
font-weight: 700;
}
.settings-form-footer {
display: flex;
gap: 10px;
}
.settings-cancel,
.settings-save {
padding: 15px 12px;
border-radius: 12px;
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.settings-cancel {
flex: 1;
border: 1px solid var(--nq-a-border);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
}
.settings-save {
flex: 2;
border: 0;
background: var(--nq-brand-gradient);
color: #fff;
}
+318
View File
@@ -0,0 +1,318 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import App from "../App";
import SettingsScreen, { SCROLL_BOTTOM_PADDING } from "./SettingsScreen";
import type { AdminChild } from "../api/definitions";
import { storeTokens } from "../auth/oidc";
const CHILDREN_PATH = "/api/v1/admin/children";
const REORDER_PATH = `${CHILDREN_PATH}/reorder`;
function child(
id: number,
display_name: string,
sort_order: number,
extra: Partial<AdminChild> = {},
): AdminChild {
return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active: true, ...extra };
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
interface Call {
method: string;
path: string;
body: unknown;
}
let children: AdminChild[];
/** Overrides the next write's response (the store is left untouched). */
let writeResponse: (() => Response) | null;
let fetchMock: ReturnType<typeof vi.fn>;
function calls(): Call[] {
return fetchMock.mock.calls.map((args: unknown[]) => {
const [url, init] = args as [string, RequestInit | undefined];
return {
method: init?.method ?? "GET",
path: new URL(String(url), "http://x").pathname,
body: init?.body ? JSON.parse(String(init.body)) : undefined,
};
});
}
function writes(): Call[] {
return calls().filter((c) => c.method !== "GET");
}
function childGets(): number {
return calls().filter((c) => c.method === "GET" && c.path === CHILDREN_PATH).length;
}
async function routeFetch(url: string, init: RequestInit = {}): Promise<Response> {
const path = new URL(url, "http://x").pathname;
const method = init.method ?? "GET";
const body = init.body ? JSON.parse(String(init.body)) : undefined;
if (method !== "GET" && writeResponse) {
const respond = writeResponse;
writeResponse = null;
return respond();
}
if (path === CHILDREN_PATH && method === "GET") return jsonResponse({ children });
if (path === CHILDREN_PATH && method === "POST") {
const created = child(9, body.display_name, body.sort_order ?? 0, {
colour: body.colour ?? null,
avatar_ref: body.avatar_ref ?? null,
});
children = [...children, created];
return jsonResponse(created, 201);
}
if (path === REORDER_PATH && method === "POST") {
const ids = body.ordered_ids as number[];
children = children.map((c) => ({ ...c, sort_order: ids.indexOf(c.id) + 1 }));
return jsonResponse({ status: "ok" });
}
const activeMatch = /\/children\/(\d+)\/active$/.exec(path);
if (activeMatch && method === "PATCH") {
const id = Number(activeMatch[1]);
children = children.map((c) => (c.id === id ? { ...c, is_active: body.is_active } : c));
return jsonResponse(children.find((c) => c.id === id));
}
const editMatch = /\/children\/(\d+)$/.exec(path);
if (editMatch && method === "PATCH") {
const id = Number(editMatch[1]);
children = children.map((c) => (c.id === id ? { ...c, ...body } : c));
return jsonResponse(children.find((c) => c.id === id));
}
if (path.endsWith("/admin/snapshot")) {
return jsonResponse({ today_iso: "2026-09-23", cycle_day: 5, children: [] });
}
return jsonResponse({ detail: "unexpected" }, 500);
}
function row(id: number) {
return within(screen.getByTestId(`settings-child-${id}`));
}
function listNames(): string[] {
return within(screen.getByRole("list", { name: "Children" }))
.getAllByRole("listitem")
.map((li) => li.querySelector(".settings-child-name")?.textContent ?? "");
}
function field(form: HTMLElement, label: string): HTMLInputElement {
return within(form).getByLabelText(label) as HTMLInputElement;
}
async function renderScreen(onClose = vi.fn()) {
render(<SettingsScreen onClose={onClose} />);
await screen.findByTestId("settings-child-1");
return onClose;
}
describe("SettingsScreen — children", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
children = [
child(2, "Maeve", 2, { colour: "#16a34a" }),
child(1, "Declan", 1, { colour: "#0ea5e9", avatar_ref: "fox" }),
child(3, "Rory", 3, { is_active: false }),
];
writeResponse = null;
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("lists each child with name, colour swatch, active state and order", async () => {
await renderScreen();
expect(listNames()).toEqual(["Declan", "Maeve", "Rory"]);
expect(row(1).getByText("Order 1 · #0ea5e9")).toBeTruthy();
expect(screen.getByTestId("settings-swatch-1").style.background).toBe("rgb(14, 165, 233)");
expect(screen.getByTestId("settings-active-1").textContent).toBe("Active");
expect(row(3).getByText("Order 3 · no colour")).toBeTruthy();
expect(screen.getByTestId("settings-swatch-3").style.background).toBe("var(--nq-a-border)");
expect(screen.getByTestId("settings-active-3").textContent).toBe("Inactive");
expect(screen.getByTestId("settings-scroll").style.paddingBottom).toBe(SCROLL_BOTTOM_PADDING);
});
it("Add posts exactly the entered values, then shows the new child", async () => {
await renderScreen();
fireEvent.click(screen.getByRole("button", { name: "Add child" }));
const form = screen.getByRole("form", { name: "Add child" });
expect(field(form, "Order").value).toBe("4");
fireEvent.change(field(form, "Display name"), { target: { value: " Nora " } });
fireEvent.click(within(form).getByRole("button", { name: "Colour #7c3aed" }));
fireEvent.change(field(form, "Avatar ref (optional)"), { target: { value: "owl" } });
fireEvent.change(field(form, "Order"), { target: { value: "5" } });
const gets = childGets();
fireEvent.click(within(form).getByRole("button", { name: "Add child" }));
await screen.findByTestId("settings-child-9");
expect(writes()).toEqual([
{
method: "POST",
path: CHILDREN_PATH,
body: { display_name: "Nora", colour: "#7c3aed", avatar_ref: "owl", sort_order: 5 },
},
]);
expect(childGets()).toBe(gets + 1);
expect(listNames()).toEqual(["Declan", "Maeve", "Rory", "Nora"]);
expect(screen.queryByRole("form", { name: "Add child" })).toBeNull();
});
it("Add omits optional fields left blank and rejects a missing name", async () => {
await renderScreen();
fireEvent.click(screen.getByRole("button", { name: "Add child" }));
const form = screen.getByRole("form", { name: "Add child" });
fireEvent.change(field(form, "Order"), { target: { value: "" } });
fireEvent.click(within(form).getByRole("button", { name: "Add child" }));
expect(screen.getByTestId("child-form-error").textContent).toBe("Enter a display name.");
expect(writes()).toEqual([]);
fireEvent.change(field(form, "Display name"), { target: { value: "Nora" } });
fireEvent.click(within(form).getByRole("button", { name: "Add child" }));
await screen.findByTestId("settings-child-9");
expect(writes()[0].body).toEqual({ display_name: "Nora" });
});
it("Edit patches only the changed fields", async () => {
await renderScreen();
fireEvent.click(row(1).getByRole("button", { name: "Edit Declan" }));
const form = screen.getByRole("form", { name: "Edit Declan" });
expect(field(form, "Display name").value).toBe("Declan");
expect(field(form, "Colour").value).toBe("#0ea5e9");
fireEvent.change(field(form, "Display name"), { target: { value: "Dec" } });
fireEvent.change(field(form, "Colour"), { target: { value: "#FF8800" } });
fireEvent.click(within(form).getByRole("button", { name: "Save child" }));
await waitFor(() => expect(row(1).getByText("Dec")).toBeTruthy());
expect(writes()).toEqual([
{ method: "PATCH", path: `${CHILDREN_PATH}/1`, body: { display_name: "Dec", colour: "#FF8800" } },
]);
expect(row(1).getByText("Order 1 · #FF8800")).toBeTruthy();
});
it("Edit refuses to clear a set colour instead of sending null", async () => {
await renderScreen();
fireEvent.click(row(1).getByRole("button", { name: "Edit Declan" }));
const form = screen.getByRole("form", { name: "Edit Declan" });
fireEvent.change(field(form, "Colour"), { target: { value: "" } });
fireEvent.click(within(form).getByRole("button", { name: "Save child" }));
expect(screen.getByTestId("child-form-error").textContent).toMatch(/cannot be removed/);
expect(writes()).toEqual([]);
});
it("the active toggle patches /children/{id}/active with the new value", async () => {
await renderScreen();
fireEvent.click(row(1).getByRole("button", { name: "Deactivate Declan" }));
await waitFor(() => expect(screen.getByTestId("settings-active-1").textContent).toBe("Inactive"));
fireEvent.click(row(3).getByRole("button", { name: "Reactivate Rory" }));
await waitFor(() => expect(screen.getByTestId("settings-active-3").textContent).toBe("Active"));
expect(writes()).toEqual([
{ method: "PATCH", path: `${CHILDREN_PATH}/1/active`, body: { is_active: false } },
{ method: "PATCH", path: `${CHILDREN_PATH}/3/active`, body: { is_active: true } },
]);
});
it("reorder posts the complete ordered id list, then shows the new order", async () => {
await renderScreen();
expect((row(1).getByRole("button", { name: "Move Declan up" }) as HTMLButtonElement).disabled).toBe(true);
expect((row(3).getByRole("button", { name: "Move Rory down" }) as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(row(3).getByRole("button", { name: "Move Rory up" }));
await waitFor(() => expect(listNames()).toEqual(["Declan", "Rory", "Maeve"]));
expect(writes()).toEqual([{ method: "POST", path: REORDER_PATH, body: { ordered_ids: [1, 3, 2] } }]);
});
it("disables the write controls while a request is in flight (no double submit)", async () => {
await renderScreen();
let release!: () => void;
writeResponse = () => jsonResponse({ status: "ok" });
const gate = new Promise<void>((r) => {
release = r;
});
fetchMock.mockImplementationOnce(async (url: string, init?: RequestInit) => {
await gate;
return routeFetch(url, init);
});
const up = row(2).getByRole("button", { name: "Move Maeve up" }) as HTMLButtonElement;
fireEvent.click(up);
fireEvent.click(up);
await waitFor(() => expect(up.disabled).toBe(true));
expect((row(1).getByRole("button", { name: "Deactivate Declan" }) as HTMLButtonElement).disabled).toBe(true);
expect(writes()).toHaveLength(1);
release();
await waitFor(() => expect((row(1).getByRole("button", { name: "Edit Declan" }) as HTMLButtonElement).disabled).toBe(false));
expect(writes()).toHaveLength(1);
});
it("a 422 shows the API detail and keeps the form and the screen open", async () => {
const onClose = await renderScreen();
fireEvent.click(screen.getByRole("button", { name: "Add child" }));
const form = screen.getByRole("form", { name: "Add child" });
fireEvent.change(field(form, "Display name"), { target: { value: "Nora" } });
writeResponse = () => jsonResponse({ detail: "display_name is required" }, 422);
fireEvent.click(within(form).getByRole("button", { name: "Add child" }));
const error = await screen.findByTestId("child-form-error");
expect(error.textContent).toBe("The child could not be added: display_name is required");
expect(screen.getByRole("form", { name: "Add child" })).toBeTruthy();
expect(field(form, "Display name").value).toBe("Nora");
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole("dialog", { name: "Settings" })).toBeTruthy();
});
it("a 403 on a row action shows the refusal message and keeps the screen open", async () => {
const onClose = await renderScreen();
writeResponse = () => jsonResponse({ detail: "forbidden" }, 403);
fireEvent.click(row(1).getByRole("button", { name: "Deactivate Declan" }));
const error = await screen.findByTestId("settings-action-error");
expect(error.textContent).toContain("Your account is not in the nestquest-admins group.");
expect(screen.getByTestId("settings-active-1").textContent).toBe("Active");
expect(onClose).not.toHaveBeenCalled();
});
it("a 403 on load shows the refusal message", async () => {
fetchMock.mockImplementation(async () => jsonResponse({ detail: "forbidden" }, 403));
render(<SettingsScreen onClose={vi.fn()} />);
const error = await screen.findByTestId("settings-error");
expect(error.textContent).toContain("Your account is not in the nestquest-admins group.");
});
it("Escape and Back close the screen", async () => {
const onClose = await renderScreen();
fireEvent.keyDown(screen.getByRole("dialog", { name: "Settings" }), { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Back" }));
expect(onClose).toHaveBeenCalledTimes(2);
});
it("the Today Settings button opens the screen; closing restores focus and the background", async () => {
render(<App />);
const settings = await screen.findByRole("button", { name: "Settings" });
settings.focus();
fireEvent.click(settings);
const dialog = await screen.findByRole("dialog", { name: "Settings" });
await screen.findByTestId("settings-child-1");
expect(document.activeElement).toBe(screen.getByRole("heading", { name: "Settings" }));
const tabbar = document.querySelector("nav.admin-tabbar") as HTMLElement;
expect(tabbar.hasAttribute("inert")).toBe(true);
expect(dialog.closest("[inert]")).toBeNull();
fireEvent.keyDown(dialog, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Settings" })).toBeNull());
expect(tabbar.hasAttribute("inert")).toBe(false);
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Settings" }));
});
});
+560
View File
@@ -0,0 +1,560 @@
/**
* Settings — a pushed screen opened from the Today header's Settings button.
*
* Its one section so far is Children: list, add, edit, deactivate/reactivate
* and reorder the household's child profiles. Every write refetches the list.
*
* TODO: the other household settings (horizon days, day rollover time,
* notification config) are a separate later task.
*/
import {
useEffect,
useRef,
useState,
type FormEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { ApiForbiddenError } from "../api/client";
import {
createChild,
reorderChildren,
setChildActive,
updateChild,
type ChildCreateBody,
type ChildEditBody,
} from "../api/children";
import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions";
import { FOCUSABLE, inertOutside } from "../schedule/OverrideEditor";
import { ArrowDownGlyph, ArrowUpGlyph, ChevronLeftGlyph, PlusGlyph } from "./glyphs";
import "./SettingsScreen.css";
/** The swatch picker's presets; any other "#RRGGBB" can be typed. */
export const COLOUR_PRESETS = ["#e11d48", "#f97316", "#eab308", "#16a34a", "#0ea5e9", "#7c3aed"];
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
export const SCROLL_BOTTOM_PADDING = "92px";
const HEX_COLOUR = /^#[0-9a-fA-F]{6}$/;
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; children: AdminChild[] };
type FormTarget = null | "new" | AdminChild;
interface Draft {
name: string;
colour: string;
avatar: string;
order: string;
}
/** Display order: sort_order, ties broken by id (the API's own order). */
function sortChildren(children: AdminChild[]): AdminChild[] {
return [...children].sort((a, b) => a.sort_order - b.sort_order || a.id - b.id);
}
function errorMessage(error: unknown, action: string): string {
if (error instanceof ApiForbiddenError) return error.message;
if (error instanceof ApiRequestError && error.detail) return `${action}: ${error.detail}`;
return `${action}. Check your connection and try again.`;
}
function draftFor(target: "new" | AdminChild, siblings: AdminChild[]): Draft {
if (target === "new") {
const next = siblings.reduce((max, c) => Math.max(max, c.sort_order), 0) + 1;
return { name: "", colour: "", avatar: "", order: String(next) };
}
return {
name: target.display_name,
colour: target.colour ?? "",
avatar: target.avatar_ref ?? "",
order: String(target.sort_order),
};
}
/** The request body for the draft, or a validation message. */
function buildBody(
draft: Draft,
target: "new" | AdminChild,
): ChildCreateBody | ChildEditBody | string {
const name = draft.name.trim();
const colour = draft.colour.trim();
const avatar = draft.avatar.trim();
const order = draft.order.trim();
if (!name) return "Enter a display name.";
if (colour && !HEX_COLOUR.test(colour)) return "Colour must be a hex value like #0ea5e9.";
if (order && !/^-?\d+$/.test(order)) return "Order must be a whole number.";
if (target === "new") {
const body: ChildCreateBody = { display_name: name };
if (colour) body.colour = colour;
if (avatar) body.avatar_ref = avatar;
if (order) body.sort_order = Number(order);
return body;
}
// The API rejects null, so a set colour, avatar or order cannot be cleared.
if (!colour && target.colour !== null) return "A colour, once set, cannot be removed. Pick another.";
if (!avatar && target.avatar_ref !== null) return "An avatar ref, once set, cannot be removed.";
if (!order) return "Enter an order.";
const body: ChildEditBody = {};
if (name !== target.display_name) body.display_name = name;
if (colour && colour !== target.colour) body.colour = colour;
if (avatar && avatar !== target.avatar_ref) body.avatar_ref = avatar;
if (Number(order) !== target.sort_order) body.sort_order = Number(order);
if (Object.keys(body).length === 0) return "No changes to save.";
return body;
}
function ChildForm({
target,
siblings,
saving,
error,
onCancel,
onSubmit,
}: {
target: "new" | AdminChild;
/** The current list; a new child's order defaults to after the last. */
siblings: AdminChild[];
saving: boolean;
error: string | null;
onCancel: () => void;
onSubmit: (body: ChildCreateBody | ChildEditBody | string) => void;
}) {
const [draft, setDraft] = useState<Draft>(() => draftFor(target, siblings));
const nameRef = useRef<HTMLInputElement>(null);
const update = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
const isNew = target === "new";
const title = isNew ? "Add child" : `Edit ${target.display_name}`;
useEffect(() => {
nameRef.current?.focus();
}, []);
function submit(event: FormEvent) {
event.preventDefault();
onSubmit(buildBody(draft, target));
}
return (
<form className="settings-card settings-form" aria-label={title} onSubmit={submit} noValidate>
<h4 className="settings-form-title">{title}</h4>
<label className="settings-field">
<span className="settings-label">Display name</span>
<input
ref={nameRef}
className="settings-input"
type="text"
required
value={draft.name}
disabled={saving}
onChange={(event) => update({ name: event.target.value })}
/>
</label>
<div className="settings-field">
<label className="settings-field">
<span className="settings-label">Colour</span>
<input
className="settings-input"
type="text"
placeholder="#0ea5e9"
value={draft.colour}
disabled={saving}
onChange={(event) => update({ colour: event.target.value })}
/>
</label>
<div className="settings-swatches" role="group" aria-label="Colour presets">
{COLOUR_PRESETS.map((hex) => (
<button
key={hex}
type="button"
className="settings-swatch-option"
style={{ background: hex }}
aria-label={`Colour ${hex}`}
aria-pressed={draft.colour.toLowerCase() === hex}
disabled={saving}
onClick={() => update({ colour: hex })}
/>
))}
</div>
</div>
<label className="settings-field">
<span className="settings-label">Avatar ref (optional)</span>
<input
className="settings-input"
type="text"
value={draft.avatar}
disabled={saving}
onChange={(event) => update({ avatar: event.target.value })}
/>
</label>
<label className="settings-field">
<span className="settings-label">Order</span>
<input
className="settings-input"
type="text"
inputMode="numeric"
value={draft.order}
disabled={saving}
onChange={(event) => update({ order: event.target.value })}
/>
</label>
{error ? (
<p className="settings-error" role="alert" data-testid="child-form-error">
{error}
</p>
) : null}
<div className="settings-form-footer">
<button type="button" className="settings-cancel" disabled={saving} onClick={onCancel}>
Cancel
</button>
<button type="submit" className="settings-save" disabled={saving} aria-busy={saving}>
{saving ? "Saving…" : isNew ? "Add child" : "Save child"}
</button>
</div>
</form>
);
}
function ChildRow({
child,
index,
count,
busy,
onEdit,
onMove,
onToggleActive,
}: {
child: AdminChild;
index: number;
count: number;
/** The in-flight action key, or null. Every write control waits on it. */
busy: string | null;
onEdit: () => void;
onMove: (delta: -1 | 1) => void;
onToggleActive: () => void;
}) {
const locked = busy !== null;
const toggling = busy === `active-${child.id}`;
return (
<li
className={child.is_active ? "settings-child" : "settings-child settings-child--inactive"}
data-testid={`settings-child-${child.id}`}
>
<span
className="settings-swatch"
data-testid={`settings-swatch-${child.id}`}
style={{ background: child.colour ?? "var(--nq-a-border)" }}
aria-hidden="true"
/>
<div className="settings-child-text">
<div className="settings-child-name">{child.display_name}</div>
<div className="settings-child-meta">
Order {child.sort_order} · {child.colour ?? "no colour"}
</div>
</div>
<span
className={child.is_active ? "settings-pill settings-pill--active" : "settings-pill"}
data-testid={`settings-active-${child.id}`}
>
{child.is_active ? "Active" : "Inactive"}
</span>
<div className="settings-child-actions">
<button
type="button"
className="settings-icon-button"
aria-label={`Move ${child.display_name} up`}
aria-busy={busy === `move-${child.id}`}
disabled={locked || index === 0}
onClick={() => onMove(-1)}
>
<ArrowUpGlyph />
</button>
<button
type="button"
className="settings-icon-button"
aria-label={`Move ${child.display_name} down`}
aria-busy={busy === `move-${child.id}`}
disabled={locked || index === count - 1}
onClick={() => onMove(1)}
>
<ArrowDownGlyph />
</button>
<button
type="button"
className="settings-action"
aria-label={`Edit ${child.display_name}`}
disabled={locked}
onClick={onEdit}
>
Edit
</button>
<button
type="button"
className="settings-action"
aria-label={`${child.is_active ? "Deactivate" : "Reactivate"} ${child.display_name}`}
aria-busy={toggling}
disabled={locked}
onClick={onToggleActive}
>
{toggling ? "Saving…" : child.is_active ? "Deactivate" : "Reactivate"}
</button>
</div>
</li>
);
}
export default function SettingsScreen({ onClose }: { onClose: () => void }) {
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
const [form, setForm] = useState<FormTarget>(null);
const [formError, setFormError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
// A ref, not state: two taps in one frame must not both see `busy === null`.
const inFlight = useRef(false);
const screenRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null);
const addRef = useRef<HTMLButtonElement>(null);
const formWasOpen = useRef(false);
// Modal focus: move in on open, keep the background out of reach, and hand
// focus back to the opener (the Settings button) on close.
useEffect(() => {
const trigger = document.activeElement as HTMLElement | null;
titleRef.current?.focus();
const restoreBackground = screenRef.current ? inertOutside(screenRef.current) : undefined;
return () => {
restoreBackground?.();
if (trigger && trigger.isConnected) trigger.focus();
};
}, []);
// The form's controls unmount when it closes; hand focus to Add child then
// (after the render that re-enables it).
useEffect(() => {
if (formWasOpen.current && form === null) addRef.current?.focus();
formWasOpen.current = form !== null;
}, [form]);
useEffect(() => {
let cancelled = false;
setState((current) => (current.kind === "ready" ? current : { kind: "loading" }));
fetchChildren()
.then((children) => {
if (!cancelled) setState({ kind: "ready", children: sortChildren(children) });
})
.catch((error: unknown) => {
if (cancelled) return;
setState({ kind: "error", message: errorMessage(error, "Children could not be loaded") });
});
return () => {
cancelled = true;
};
}, [attempt]);
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape") {
event.preventDefault();
if (!inFlight.current) onClose();
return;
}
if (event.key !== "Tab" || !screenRef.current) return;
const focusable = Array.from(screenRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const current = document.activeElement;
const outside = !focusable.includes(current as HTMLElement);
if (event.shiftKey && (current === first || outside)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (current === last || outside)) {
event.preventDefault();
first.focus();
}
}
/**
* Run one write, then refetch the list. One write at a time; a failure
* keeps the screen (and any open form) as it was and says why.
*/
async function run(key: string, write: () => Promise<unknown>, action: string): Promise<boolean> {
if (inFlight.current) return false;
inFlight.current = true;
setBusy(key);
setActionError(null);
setFormError(null);
try {
await write();
} catch (error) {
const message = errorMessage(error, action);
if (key === "form") setFormError(message);
else setActionError(message);
inFlight.current = false;
setBusy(null);
return false;
}
try {
const children = await fetchChildren();
setState({ kind: "ready", children: sortChildren(children) });
} catch (error) {
setActionError(errorMessage(error, "Saved, but the children list could not be refreshed"));
}
inFlight.current = false;
setBusy(null);
return true;
}
function closeForm() {
setForm(null);
setFormError(null);
}
async function submitForm(body: ChildCreateBody | ChildEditBody | string) {
if (form === null) return;
if (typeof body === "string") {
setFormError(body);
return;
}
const saved =
form === "new"
? await run("form", () => createChild(body as ChildCreateBody), "The child could not be added")
: await run("form", () => updateChild(form.id, body), "The child could not be saved");
if (saved) closeForm();
}
function move(children: AdminChild[], index: number, delta: -1 | 1) {
const ids = children.map((c) => c.id);
const target = index + delta;
[ids[index], ids[target]] = [ids[target], ids[index]];
void run(`move-${children[index].id}`, () => reorderChildren(ids), "The order could not be saved");
}
function toggleActive(child: AdminChild) {
void run(
`active-${child.id}`,
() => setChildActive(child.id, !child.is_active),
child.is_active ? "The child could not be deactivated" : "The child could not be reactivated",
);
}
let body;
if (state.kind === "loading") {
body = (
<div className="settings-status" role="status" data-testid="settings-loading">
Loading children…
</div>
);
} else if (state.kind === "error") {
body = (
<div className="settings-status settings-status--error" role="alert" data-testid="settings-error">
<p>{state.message}</p>
<button type="button" className="settings-action" onClick={() => setAttempt((n) => n + 1)}>
Try again
</button>
</div>
);
} else {
const { children } = state;
body = (
<>
{children.length > 0 ? (
<ul className="settings-card settings-list" aria-label="Children">
{children.map((child, index) => (
<ChildRow
key={child.id}
child={child}
index={index}
count={children.length}
busy={busy}
onEdit={() => {
setFormError(null);
setForm(child);
}}
onMove={(delta) => move(children, index, delta)}
onToggleActive={() => toggleActive(child)}
/>
))}
</ul>
) : (
<div className="settings-card settings-empty">No children yet.</div>
)}
{form !== null ? (
<ChildForm
key={form === "new" ? "new" : form.id}
target={form}
siblings={children}
saving={busy === "form"}
error={formError}
onCancel={closeForm}
onSubmit={(b) => void submitForm(b)}
/>
) : null}
</>
);
}
return (
<div
ref={screenRef}
className="settings-screen"
role="dialog"
aria-modal="true"
aria-labelledby="settings-title"
onKeyDown={onKeyDown}
>
<header className="settings-header">
<button
type="button"
className="settings-back"
aria-label="Back"
disabled={busy !== null}
onClick={onClose}
>
<ChevronLeftGlyph size={20} />
</button>
<div>
<h2 className="settings-title" id="settings-title" ref={titleRef} tabIndex={-1}>
Settings
</h2>
<p className="settings-sub">Household setup</p>
</div>
</header>
<div
className="settings-scroll"
data-testid="settings-scroll"
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
>
<div className="settings-section-head">
<h3 className="settings-section-label">Children</h3>
<button
ref={addRef}
type="button"
className="settings-add"
disabled={state.kind !== "ready" || busy !== null || form === "new"}
onClick={() => {
setFormError(null);
setForm("new");
}}
>
<PlusGlyph />
Add child
</button>
</div>
{actionError !== null ? (
<div className="settings-action-error" role="alert" data-testid="settings-action-error">
<span>{actionError}</span>
<button type="button" className="settings-action" onClick={() => setActionError(null)}>
Dismiss
</button>
</div>
) : null}
{body}
</div>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Inline stroke glyphs for the Settings screen (Lucide-style 24px grid),
* CDN-free like the tabs' glyphs.
*/
import type { ReactNode } from "react";
function Glyph({ size, children }: { size: number; children: ReactNode }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
);
}
export function ChevronLeftGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="m15 18-6-6 6-6" />
</Glyph>
);
}
export function ArrowUpGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="m5 12 7-7 7 7M12 19V5" />
</Glyph>
);
}
export function ArrowDownGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="M12 5v14M19 12l-7 7-7-7" />
</Glyph>
);
}
export function PlusGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="M12 5v14M5 12h14" />
</Glyph>
);
}
+31
View File
@@ -241,6 +241,37 @@
color: var(--nq-brand-purple);
}
.today-action:disabled {
cursor: default;
opacity: 0.5;
}
/* Non-blocking Undo failure message. */
.today-action-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: var(--nq-a-density-row-pad);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-danger-bg);
color: var(--nq-a-danger-strong);
font-size: 13px;
font-weight: 600;
}
.today-action-dismiss {
flex: none;
padding: 4px 8px;
border: none;
background: none;
color: inherit;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
}
.today-empty {
padding: var(--nq-a-density-card-pad);
font-size: 13px;
+186 -1
View File
@@ -1,7 +1,9 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import TodayTab, { PERMISSION_NOTE } from "./TodayTab";
import type { AdminSnapshot, SnapshotInstance } from "../api/snapshot";
import { TransitionsProvider } from "../api/useTransitions";
import { frame, testStream, type TestStream } from "../api/testStream";
import { storeTokens } from "../auth/oidc";
function instance(overrides: Partial<SnapshotInstance>): SnapshotInstance {
@@ -221,3 +223,186 @@ describe("TodayTab", () => {
expect(error.textContent).toContain("nestquest-admins");
});
});
/** SNAPSHOT after instance 12 ("Make bed") is reversed back to open. */
const AFTER_UNDO: AdminSnapshot = {
...SNAPSHOT,
children: SNAPSHOT.children.map((child) =>
child.child_id !== 1
? child
: {
...child,
completed_today: 2,
remaining_today: 5,
completion_pct: 29,
instances: child.instances.map((i) =>
i.id === 12 ? { ...i, state: "open" as const, completed_at: null, on_time: null } : i,
),
},
),
};
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}
describe("TodayTab Undo and live updates", () => {
let fetchMock: ReturnType<typeof vi.fn>;
let snapshots: AdminSnapshot[];
let uncomplete: () => Promise<Response>;
let streams: TestStream[];
function calls(pathSuffix: string, method = "GET") {
return fetchMock.mock.calls.filter(
([url, init]) =>
new URL(String(url), "http://x").pathname.endsWith(pathSuffix) &&
(init?.method ?? "GET") === method,
);
}
beforeAll(() => {
vi.stubEnv("TZ", "America/Los_Angeles");
});
afterAll(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
snapshots = [SNAPSHOT, AFTER_UNDO];
uncomplete = () =>
Promise.resolve(jsonResponse({ instance_id: 12, state: "open", appended: true }));
streams = [];
fetchMock = vi.fn((url: string) => {
const path = new URL(url, "http://x").pathname;
if (path.endsWith("/api/v1/admin/snapshot")) {
const next = snapshots.length > 1 ? snapshots.shift()! : snapshots[0];
return Promise.resolve(jsonResponse(next));
}
if (path.endsWith("/api/v1/admin/instances/12/uncomplete")) return uncomplete();
if (path.endsWith("/api/v1/admin/events")) {
const stream = testStream();
streams.push(stream);
return Promise.resolve(stream.response);
}
return Promise.resolve(jsonResponse({ detail: "unexpected" }, 500));
});
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("Undo POSTs uncomplete for the completed instance and refetches the snapshot", async () => {
render(<TodayTab />);
const row = await screen.findByTestId("attention-12");
fireEvent.click(within(row).getByRole("button", { name: "Undo" }));
await waitFor(() => expect(screen.queryByTestId("attention-12")).toBeNull());
const posts = calls("/api/v1/admin/instances/12/uncomplete", "POST");
expect(posts).toHaveLength(1);
expect(posts[0][1].headers.Authorization).toBe("Bearer at-123");
expect(calls("/api/v1/admin/snapshot")).toHaveLength(2);
// The reversed instance is open again: one fewer done, one more remaining.
expect(screen.getByTestId("stat-done").textContent).toBe("6Done");
expect(screen.getByTestId("stat-remaining").textContent).toBe("5Remaining");
expect(screen.queryByTestId("today-undo-error")).toBeNull();
});
it("disables Undo while the request is in flight (no double submit)", async () => {
const pending = deferred<Response>();
uncomplete = () => pending.promise;
render(<TodayTab />);
const row = await screen.findByTestId("attention-12");
const button = within(row).getByRole("button", { name: "Undo" }) as HTMLButtonElement;
fireEvent.click(button);
await waitFor(() => expect(button.disabled).toBe(true));
expect(button.textContent).toBe("Undoing…");
// Every Undo is disabled while one reversal is in flight.
const other = within(screen.getByTestId("attention-14")).getByRole("button", {
name: "Undo",
}) as HTMLButtonElement;
expect(other.disabled).toBe(true);
fireEvent.click(button);
fireEvent.click(other);
expect(calls("/api/v1/admin/instances/12/uncomplete", "POST")).toHaveLength(1);
expect(calls("/uncomplete", "POST")).toHaveLength(1);
pending.resolve(jsonResponse({ instance_id: 12, state: "open", appended: true }));
await waitFor(() => expect(screen.queryByTestId("attention-12")).toBeNull());
expect(other.disabled).toBe(false);
});
it("a failed Undo shows a message and leaves the view intact", async () => {
uncomplete = () => Promise.resolve(jsonResponse({ detail: "instance 12 not found" }, 404));
render(<TodayTab />);
const row = await screen.findByTestId("attention-12");
fireEvent.click(within(row).getByRole("button", { name: "Undo" }));
const alert = await screen.findByTestId("today-undo-error");
expect(alert.textContent).toContain("The completion could not be undone: instance 12 not found");
// Still the original snapshot: nothing refetched, the row and its Undo remain usable.
expect(calls("/api/v1/admin/snapshot")).toHaveLength(1);
const button = within(screen.getByTestId("attention-12")).getByRole("button", {
name: "Undo",
}) as HTMLButtonElement;
expect(button.disabled).toBe(false);
expect(screen.getByTestId("stat-done").textContent).toBe("7Done");
fireEvent.click(within(alert).getByRole("button", { name: "Dismiss" }));
expect(screen.queryByTestId("today-undo-error")).toBeNull();
});
it("Mark done stays inert", async () => {
render(<TodayTab />);
const row = await screen.findByTestId("attention-11");
fireEvent.click(within(row).getByRole("button", { name: "Mark done" }));
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(0);
});
it("a transition on the live stream refetches the snapshot without a reload", async () => {
const { unmount } = render(
<TransitionsProvider>
<TodayTab />
</TransitionsProvider>,
);
await screen.findByTestId("attention-12");
await waitFor(() => expect(streams).toHaveLength(1));
const [, init] = calls("/api/v1/admin/events")[0];
expect(init.headers.Authorization).toBe("Bearer at-123");
// An uncompletion made elsewhere, its frame split across two chunks.
const text = frame("nestquest_quest_uncompleted", {
child_id: 1,
child_name: "Declan",
instance_id: 12,
quest_title: "Make bed",
window: "morning",
due_date: "2026-09-23",
due_time: null,
occurred_at: "2026-09-23T12:00:00Z",
});
streams[0].push(text.slice(0, 25));
streams[0].push(text.slice(25));
await waitFor(() => expect(calls("/api/v1/admin/snapshot")).toHaveLength(2));
await waitFor(() => expect(screen.queryByTestId("attention-12")).toBeNull());
// Refreshed in place: the loading state never replaced the view.
expect(screen.queryByTestId("today-loading")).toBeNull();
const signal = init.signal as AbortSignal;
expect(signal.aborted).toBe(false);
unmount();
expect(signal.aborted).toBe(true);
await waitFor(() => expect(streams[0].cancelled()).toBe(true));
});
});
+105 -17
View File
@@ -1,7 +1,7 @@
/**
* Today tab — compact density (design/ADMIN-SPEC.md §2.1–2.3, §2.5–2.6).
*/
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
fetchSnapshot,
type AdminSnapshot,
@@ -9,6 +9,10 @@ import {
type SnapshotInstance,
} from "../api/snapshot";
import { ApiForbiddenError } from "../api/client";
import { ApiRequestError } from "../api/definitions";
import { uncompleteInstance } from "../api/instances";
import { useTransitions } from "../api/useTransitions";
import SettingsScreen from "../settings/SettingsScreen";
import {
formatClockTime,
formatDueTime,
@@ -40,15 +44,22 @@ function isOverdueOpen(instance: SnapshotInstance): boolean {
return instance.overdue && instance.state === "open";
}
function ScreenHeader({ sub }: { sub: string }) {
function ScreenHeader({ sub, onSettings }: { sub: string; onSettings: () => void }) {
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">
{/* Opens the Settings screen. Only its Children section exists so far.
TODO: horizon days, day rollover time and notification config are a
separate later task. */}
<button
type="button"
className="today-icon-button"
aria-label="Settings"
onClick={onSettings}
>
<SettingsGlyph />
</button>
</header>
@@ -144,11 +155,18 @@ function attentionItems(snapshot: AdminSnapshot): AttentionItem[] {
return [...overdue, ...completed];
}
function AttentionRow({ item }: { item: AttentionItem }) {
interface UndoControl {
/** The instance whose Undo is in flight, or null. */
pendingId: number | null;
onUndo: (instanceId: number) => void;
}
function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }) {
const { instance, childName } = item;
const overdue = isOverdueOpen(instance);
// TODO(e57facc2): wire "Mark done" / "Undo" mutations and live updates.
// The admin plane has no complete route yet, so both actions are inert here.
const undoing = undo.pendingId === instance.id;
// TODO(e57facc2): wire "Mark done". The admin plane has no complete route
// yet, so that action is inert here.
return (
<li className="today-event" data-testid={`attention-${instance.id}`}>
<span
@@ -173,12 +191,21 @@ function AttentionRow({ item }: { item: AttentionItem }) {
</div>
)}
</div>
<button
type="button"
className={overdue ? "today-action today-action--done" : "today-action today-action--undo"}
>
{overdue ? "Mark done" : "Undo"}
</button>
{overdue ? (
<button type="button" className="today-action today-action--done">
Mark done
</button>
) : (
<button
type="button"
className="today-action today-action--undo"
disabled={undo.pendingId !== null}
aria-busy={undoing}
onClick={() => undo.onUndo(instance.id)}
>
{undoing ? "Undoing…" : "Undo"}
</button>
)}
</li>
);
}
@@ -197,10 +224,26 @@ function PermissionNote() {
export default function TodayTab() {
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
/** The next load is a refresh in place: keep the current view, no loading state. */
const silentNext = useRef(false);
const [undoingId, setUndoingId] = useState<number | null>(null);
const undoingRef = useRef(false);
const [undoError, setUndoError] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
function refresh() {
silentNext.current = true;
setAttempt((n) => n + 1);
}
// Any transition (completed, uncompleted, missed, day complete) changes the snapshot.
useTransitions(refresh);
useEffect(() => {
let cancelled = false;
setState({ kind: "loading" });
const silent = silentNext.current;
silentNext.current = false;
if (!silent) setState({ kind: "loading" });
fetchSnapshot()
.then((snapshot) => {
if (!cancelled) setState({ kind: "ready", snapshot });
@@ -211,13 +254,37 @@ export default function TodayTab() {
error instanceof ApiForbiddenError
? error.message
: "Today's snapshot could not be loaded. Check your connection and try again.";
setState({ kind: "error", message });
// A failed refresh keeps the snapshot already on screen.
setState((prev) => (silent && prev.kind === "ready" ? prev : { kind: "error", message }));
});
return () => {
cancelled = true;
};
}, [attempt]);
/** Reverse one completion, then refetch the snapshot. One request at a time. */
async function undo(instanceId: number) {
if (undoingRef.current) return;
undoingRef.current = true;
setUndoingId(instanceId);
setUndoError(null);
try {
await uncompleteInstance(instanceId);
refresh();
} catch (error: unknown) {
setUndoError(
error instanceof ApiForbiddenError
? error.message
: error instanceof ApiRequestError && error.detail
? `The completion could not be undone: ${error.detail}`
: "The completion could not be undone. Check your connection and try again.",
);
} finally {
undoingRef.current = false;
setUndoingId(null);
}
}
let sub = "";
let body;
if (state.kind === "loading") {
@@ -251,7 +318,11 @@ export default function TodayTab() {
{attention.length > 0 ? (
<ul className="today-card today-list" aria-label="Needs attention">
{attention.map((item) => (
<AttentionRow key={item.instance.id} item={item} />
<AttentionRow
key={item.instance.id}
item={item}
undo={{ pendingId: undoingId, onUndo: (id) => void undo(id) }}
/>
))}
</ul>
) : (
@@ -264,14 +335,31 @@ export default function TodayTab() {
return (
<div className="today-screen">
<ScreenHeader sub={sub} />
<ScreenHeader sub={sub} onSettings={() => setSettingsOpen(true)} />
<div
className="today-scroll"
data-testid="today-scroll"
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
>
{undoError !== null ? (
<div className="today-action-error" role="alert" data-testid="today-undo-error">
<span>{undoError}</span>
<button type="button" className="today-action-dismiss" onClick={() => setUndoError(null)}>
Dismiss
</button>
</div>
) : null}
{body}
</div>
{settingsOpen ? (
<SettingsScreen
onClose={() => {
setSettingsOpen(false);
// Child names and order feed the snapshot.
refresh();
}}
/>
) : null}
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
# NestQuest API
Standalone FastAPI service owning the NestQuest database (Feature 16).
Served with ``uvicorn --factory api.app:create_app``; configuration is
environment-supplied (``api/config.py``) and every variable is required.
## Runtime dependencies
``api/requirements.txt`` is the API deployable's OWN dependency
manifest (fastapi, uvicorn, pyjwt[crypto], httpx) — deliberately kept
out of the integration's ``pyproject.toml`` dev group, which exists only
so the monorepo test venv can run the API tests.
## Container deployment (scaffold)
The image is built from the repository ROOT context (the API loads the
bundled domain core from ``custom_components/nestquest/core/`` by file
location, so both trees must be in the build context):
docker build -t nestquest-api:latest .
Run it with compose — the SQLite file lives on the named volume
``nestquest-db`` so recreating the container keeps household data:
cp deploy/.env.example deploy/.env # then fill in real values
docker compose -f deploy/compose.yaml up -d --build
``deploy/.env`` is git-ignored and supplies the secrets (panel service
token, Authentik OIDC settings); ``NESTQUEST_DB_PATH`` is fixed to
``/var/lib/nestquest/nestquest.db`` by compose.
The published port binds ``NESTQUEST_BIND_ADDRESS`` from
``deploy/.env`` and defaults to ``127.0.0.1`` (localhost only). In
production TLS is terminated by the shared Traefik reverse proxy,
which serves ``https://nestquest.cubecraftlabs.com`` (Let's Encrypt,
auto-renewed, HTTP redirected to HTTPS) and forwards to the API box
on port 8080 — so on that box set ``NESTQUEST_BIND_ADDRESS`` to its
LAN address (``10.60.1.14``). Traefik only allows
``/api/v1/panel`` from the Home Assistant host. The full deployment,
restore and verification procedure is in ``deploy/RUNBOOK.md``.
## Continuous backup (Litestream)
Compose also runs a ``litestream`` sidecar
(``litestream/litestream:0.5.17``, config ``deploy/litestream.yml``).
It mounts the same ``nestquest-db`` volume and continuously replicates
``/var/lib/nestquest/nestquest.db`` over SFTP to the Synology NAS
``CubeCraft-SRVR1`` (``joshua@10.60.1.50``), directory
``/volume1/homes/Joshua/nestquest-backups`` (``/homes/Joshua/...`` in
the NAS's chrooted SFTP view) — so the backup is outside the Docker
host and its volume. A snapshot is taken daily and a week of
snapshots plus incremental changes is retained; the NAS host key is
pinned in the config.
The SFTP login uses a dedicated keypair that lives only on the API
box and is git-ignored. Create it once, before ``up``:
ssh-keygen -t ed25519 -N "" -C nestquest-litestream -f deploy/litestream_key
then append ``restrict `` + the contents of ``deploy/litestream_key.pub``
to ``joshua@10.60.1.50:~/.ssh/authorized_keys``. Compose mounts the
private key read-only at ``/etc/litestream/sftp_key``. The sidecar runs
as uid/gid 999 (the API's ``nestquest`` user), so give it the key:
sudo chown 999:999 deploy/litestream_key && chmod 600 deploy/litestream_key
The sidecar needs WRITE access to the database volume: Litestream adds
its own ``_litestream_seq``/``_litestream_lock`` tables to the database,
writes the ``-wal``/``-shm`` files and checkpoints the WAL, and fails
with "unable to open database file" on a read-only mount. That access
is contained rather than removed — the sidecar runs as the non-root
volume owner with a read-only root filesystem, ``no-new-privileges``
and all Linux capabilities dropped.
Check replication with
``docker compose -f deploy/compose.yaml logs litestream`` or
``docker compose -f deploy/compose.yaml exec litestream litestream ltx /var/lib/nestquest/nestquest.db``.
To restore (high level): stop the ``api`` service, run
``litestream restore -config /etc/litestream.yml -o <new path>
/var/lib/nestquest/nestquest.db`` from a litestream container with the
same mounts, verify the restored file, move it into place on the
volume (removing any stale ``-wal``/``-shm`` files), and start ``api``
again.
+38
View File
@@ -0,0 +1,38 @@
# Runtime dependency manifest for the NestQuest API deployable.
#
# The monorepo's pyproject.toml carries fastapi/uvicorn/pyjwt/httpx in
# the INTEGRATION's dev group only (so the test venv can exercise the
# API); they are deliberately NOT integration runtime dependencies.
# Per that manifest's own comment, the API deployable declares its own
# runtime dependencies HERE — this file is what the container image
# installs (see the Dockerfile at the repository root).
#
# EXACT pins: the versions installed in the deployed image
# (nestquest-api:latest on the API box, read with importlib.metadata),
# so a rebuild resolves the same packages. The four direct
# dependencies come first; the rest is their resolved transitive
# closure, pinned too so nothing floats. The dev group's floors in
# pyproject.toml are unchanged. Bump deliberately, together with the
# Dockerfile's digest-pinned base image.
fastapi==0.141.1
uvicorn==0.53.0
pyjwt[crypto]==2.15.0
httpx==0.28.1
# Transitive dependencies of the above.
annotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.15.1
certifi==2026.7.22
cffi==2.1.1
click==8.5.0
cryptography==50.0.1
h11==0.16.0
httpcore==1.0.9
idna==3.20
pycparser==3.0
pydantic==2.13.5
pydantic_core==2.46.5
starlette==1.7.0
typing-inspection==0.4.4
typing_extensions==4.16.0
+1 -1
View File
@@ -15,5 +15,5 @@
"iot_class": "local_polling",
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
"requirements": [],
"version": "0.6.0"
"version": "0.7.0"
}
+34
View File
@@ -0,0 +1,34 @@
# Example environment for the NestQuest API container deployment.
#
# Copy to ``deploy/.env`` (git-ignored) and fill in REAL values there;
# this example carries placeholder names only and is never a source of
# secrets. Compose loads ``deploy/.env`` into the api service; the
# API fails fast on startup when any of these is unset or empty
# (api/config.py — there are no silent defaults).
# The static panel-plane service token the HA integration presents as
# a Bearer credential on /api/v1/panel/* routes. Generate one, e.g.:
# python -c "import secrets; print(secrets.token_urlsafe(32))"
NESTQUEST_PANEL_TOKEN=
# The Authentik OIDC settings the admin plane verifies JWTs against.
NESTQUEST_OIDC_ISSUER=
NESTQUEST_OIDC_AUDIENCE=
NESTQUEST_OIDC_JWKS_URL=
# The host interface the API's port 8080 is published on (compose
# interpolation, not read by the API). Defaults to 127.0.0.1 when
# unset; on the API box set the LAN address the reverse proxy
# forwards to, e.g. 10.60.1.14.
NESTQUEST_BIND_ADDRESS=
# Admin PWA build args (compose interpolation into the pwa service's
# VITE_* build args; baked into the bundle, so rebuild after changing).
# PUBLIC values, not secrets: the Authentik application is a PKCE client
# with no client secret. All three are required — the pwa image build
# fails when any is empty.
# NESTQUEST_AUTHENTIK_ISSUER=https://auth.cubecraftlabs.com/application/o/nestquest/
# NESTQUEST_API_BASE_URL=https://nestquest.cubecraftlabs.com
NESTQUEST_AUTHENTIK_CLIENT_ID=
NESTQUEST_AUTHENTIK_ISSUER=
NESTQUEST_API_BASE_URL=
+553
View File
@@ -0,0 +1,553 @@
# NestQuest API — deployment and restore runbook
Operator procedure to rebuild the production NestQuest API deployment end
to end from this repository, restore its database from backup, and verify
it. Every value below is the real, non-secret production value. **No
secret appears in this file** — secrets are named and their location is
given, never their content.
| Piece | Where | Real value |
| --- | --- | --- |
| API box (Docker host) | this repository's `deploy/` | `10.60.1.14`, port `8080` (API), `8081` (admin PWA, section 10) |
| Reverse proxy / TLS | shared Traefik on `nukapi-03` | `10.60.1.33` |
| Public hostname | Cloudflare DNS | `nestquest.cubecraftlabs.com` |
| Identity | Authentik | `https://auth.cubecraftlabs.com` |
| Home Assistant box (panel client) | LAN | `10.60.1.80` |
| Local DNS | Pi-hole | `10.60.1.101` |
| Backup target | Synology NAS `CubeCraft-SRVR1`, SFTP | `10.60.1.50:/homes/Joshua/nestquest-backups` |
All `docker compose` commands below are run **on the API box, from the
repository root**. The shorthand `dc` means:
alias dc='docker compose -f deploy/compose.yaml'
Compose v2.24 or newer is required (`env_file` uses the `path`/`required`
form).
---
## 1. Image build
The image is defined by the root `Dockerfile` and is built from the
**repository root** as the build context: the API (`api/`) loads the
bundled domain core from `custom_components/nestquest/core/` by file
location, so both trees must be in the context. `.dockerignore` keeps
everything else (including `deploy/`, `.env` files and `*.db`) out of the
build.
- Multi-stage on `python:3.12-slim`: the builder installs
`api/requirements.txt`, the runtime copies only the installed packages
plus `api/` and `custom_components/nestquest/core/` into `/app`.
- Runs as the non-root system user `nestquest` (uid/gid `999`), which owns
`/var/lib/nestquest`.
- Command: `uvicorn --factory api.app:create_app --host 0.0.0.0 --port 8080`.
Get the code and build:
git clone ssh://code.cubecraftlabs.com:2288/Maniacs_Mansion/NestQuest.git
cd NestQuest
git checkout <release branch or tag being deployed>
dc build api # equivalent: docker build -t nestquest-api:latest .
Confirm the runtime user (the Litestream sidecar and the key ownership in
section 5 depend on uid 999):
docker run --rm --entrypoint id nestquest-api:latest
# uid=999(nestquest) gid=999(nestquest) groups=999(nestquest)
## 2. Compose
`deploy/compose.yaml` defines three services and one named volume:
| Service | Image | Role |
| --- | --- | --- |
| `api` | `nestquest-api:latest` (built, section 1) | FastAPI service on `8080`, healthcheck on `/health` |
| `litestream` | `litestream/litestream:0.5.17` | Continuous SFTP backup of the database (section 7); starts only once `api` is healthy |
| `pwa` | `nestquest-pwa:latest` (built from `deploy/pwa`, section 10) | Admin PWA served by nginx on `8081`; independent of the other two |
- **Volume `nestquest-db`** is mounted at `/var/lib/nestquest` in both
services. The database is `NESTQUEST_DB_PATH=/var/lib/nestquest/nestquest.db`
(fixed by compose, not a secret). `docker compose down` keeps named
volumes; **never run `down -v`** on the API box. Docker prefixes the
volume with the compose project name — with the default project
(`deploy`, the compose file's directory) it is `deploy_nestquest-db`;
confirm with `docker volume ls | grep nestquest-db`.
- **Published port**: `${NESTQUEST_BIND_ADDRESS:-127.0.0.1}:8080:8080`.
The default binds localhost only, so an unconfigured host is never
exposed. On the API box `deploy/.env` sets
`NESTQUEST_BIND_ADDRESS=10.60.1.14` so Traefik on nukapi-03 can reach
`http://10.60.1.14:8080`.
- **Secrets** come from `deploy/.env` (git-ignored, section 5) via
`env_file`.
Bring the stack up (after section 5's secrets exist):
dc up -d --build
dc ps # api "healthy", litestream "Up"
curl -s http://10.60.1.14:8080/health # {"status":"ok"}
## 3. TLS and reverse proxy (Traefik on nukapi-03)
TLS is terminated by the **shared** Traefik instance on `nukapi-03`
(`10.60.1.33`), not by this repository. Its NestQuest routing is the
owner-authored dynamic file-provider config
**`config/dynamic/nestquest-ccl.yml`** in the Traefik deployment on
nukapi-03 — that live file is authoritative; it is not stored in this
repository. What it must contain:
| Router | Rule | Middleware | Service |
| --- | --- | --- | --- |
| `nestquest-panel` (priority 200) | ``Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)`` | `nestquest-panel-lan`: `ipAllowList`, `sourceRange: ["10.60.1.80/32"]` (HA box only) | `nestquest-api` → `http://10.60.1.14:8080` |
| `nestquest-admin` (priority 100: `/api/v1/admin`, `/health`, `/openapi.json`, `/docs`) | ``Host(`nestquest.cubecraftlabs.com`) && (PathPrefix(`/api/v1/admin`) \|\| Path(`/health`) \|\| Path(`/openapi.json`) \|\| PathPrefix(`/docs`))`` | `nestquest-headers` — no source restriction; the API itself enforces Authentik JWTs (section 4) | `nestquest-api` → `http://10.60.1.14:8080` |
| `nestquest-app` (priority 50: everything else — the admin PWA) | see section 10 | none at Traefik; nginx allow-lists the proxy | `nestquest-pwa` → `http://10.60.1.14:8081` |
All routers use the HTTPS entrypoint `websecure` with `tls.certResolver: myresolver`
(HTTP is redirected to HTTPS by the shared Traefik). Priorities are set
explicitly so the order never depends on rule length: panel (200) above
admin (100), and the PWA's `nestquest-app` router (50) below both. The
admin router is scoped to `/api/v1/admin`, `/health`, `/openapi.json`
and `/docs` — a bare ``Host(...)`` rule at a higher priority would
swallow `/` and every PWA route. Any other `/api/*` path matches no
router (the app router excludes `/api`), so Traefik answers `404`.
Reference shape, to rebuild the file if it is ever lost (entrypoint names
must match the shared Traefik's static config on nukapi-03):
```yaml
http:
routers:
nestquest-panel:
rule: "Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)"
priority: 200
entryPoints: [websecure]
middlewares: [nestquest-panel-lan]
service: nestquest-api
tls:
certResolver: myresolver
nestquest-admin:
rule: "Host(`nestquest.cubecraftlabs.com`) && (PathPrefix(`/api/v1/admin`) || Path(`/health`) || Path(`/openapi.json`) || PathPrefix(`/docs`))"
priority: 100
entryPoints: [websecure]
middlewares: [nestquest-headers]
service: nestquest-api
tls:
certResolver: myresolver
nestquest-app:
rule: "Host(`nestquest.cubecraftlabs.com`) && !PathPrefix(`/api`) && !Path(`/health`) && !Path(`/openapi.json`) && !PathPrefix(`/docs`)"
priority: 50
entryPoints: [websecure]
service: nestquest-pwa
tls:
certResolver: myresolver
middlewares:
nestquest-panel-lan:
ipAllowList:
sourceRange: ["10.60.1.80/32"]
nestquest-headers:
# headers middleware; copy its definition from the live file
services:
nestquest-api:
loadBalancer:
servers:
- url: "http://10.60.1.14:8080"
nestquest-pwa:
loadBalancer:
servers:
- url: "http://10.60.1.14:8081"
```
**Certificates.** `myresolver` is the shared Traefik's ACME (Let's
Encrypt) resolver using the **Cloudflare DNS-01 challenge**; the
Cloudflare API token is held by the Traefik deployment on
nukapi-03, never in this repository. Because DNS-01 proves control
through a DNS TXT record, issuance and renewal need no inbound HTTP and
work regardless of the Cloudflare proxy setting. Certificates renew
automatically.
**Cloudflare proxy note.** Public traffic for
`nestquest.cubecraftlabs.com` resolves through Cloudflare, so a request
from the internet reaches Traefik from a Cloudflare edge address, never
from `10.60.1.80` — which is exactly why the panel allowlist refuses it
(403). The HA box does not go through Cloudflare at all: the local DNS
override in section 6 sends it straight to Traefik on the LAN, so Traefik
sees its real address. Do **not** add Cloudflare ranges to the panel
allowlist or make the allowlist trust `X-Forwarded-For`; that would let
any internet client through to the panel plane.
## 4. Authentik (admin identity)
Admin-plane requests (`/api/v1/admin/*`) carry an Authentik OIDC access
token (JWT). The API verifies issuer, audience, RS256 signature against
the JWKS, and expiry, then requires the `groups` claim to contain
`nestquest-admins` (`api/auth.py`). Configure in Authentik
(`https://auth.cubecraftlabs.com`, admin interface):
1. **Provider** — *OAuth2/OpenID Provider*:
- Client type: **Public** (no client secret; the admin PWA is a browser
SPA).
- PKCE: the PWA always uses the authorization-code flow with PKCE,
`code_challenge_method=S256`, and never sends a client secret.
- Redirect URI (strict): `https://nestquest.cubecraftlabs.com/auth/callback`.
- Signing key: an **RSA** certificate (the API only accepts `RS256`).
- Scopes: the default `openid`, `email`, `profile` mappings (the PWA
requests `openid email profile`; Authentik's default `profile`
mapping emits the `groups` claim the API checks).
- Note the generated **Client ID** — it is the audience below and the
PWA's `VITE_AUTHENTIK_CLIENT_ID`.
2. **Application** — slug **`nestquest`**, bound to the provider above.
The slug fixes the issuer URL.
3. **Group** — **`nestquest-admins`**. Add each parent's Authentik user.
Membership is the single source of truth for "who is an admin"; the
API refuses a signed-in non-member with 403.
API settings (in `deploy/.env`, section 5):
| Variable | Value |
| --- | --- |
| `NESTQUEST_OIDC_ISSUER` | `https://auth.cubecraftlabs.com/application/o/nestquest/` |
| `NESTQUEST_OIDC_AUDIENCE` | the provider's Client ID |
| `NESTQUEST_OIDC_JWKS_URL` | `https://auth.cubecraftlabs.com/application/o/nestquest/jwks/` |
Check them against Authentik's discovery document (the issuer must match
byte for byte, including the trailing slash):
curl -s https://auth.cubecraftlabs.com/application/o/nestquest/.well-known/openid-configuration \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["issuer"]); print(d["jwks_uri"])'
## 5. Secrets
Nothing below is ever committed; `.gitignore` excludes `.env`,
`**/.env`, `deploy/*key*` and `deploy/litestream_key*`.
**`deploy/.env`** — created on the API box from the committed example:
cp deploy/.env.example deploy/.env
chmod 600 deploy/.env
# edit deploy/.env and fill in:
# NESTQUEST_PANEL_TOKEN = a new random token, e.g.
# python3 -c "import secrets; print(secrets.token_urlsafe(32))"
# (the same value is configured in the HA integration)
# NESTQUEST_OIDC_ISSUER = section 4
# NESTQUEST_OIDC_AUDIENCE = section 4 (Authentik Client ID)
# NESTQUEST_OIDC_JWKS_URL = section 4
# NESTQUEST_BIND_ADDRESS = 10.60.1.14
The API fails fast at startup if any of the four API
variables is unset or empty (`api/config.py`); `dc logs api` names the
missing one. `NESTQUEST_BIND_ADDRESS` is read by compose only.
**Litestream SFTP key `deploy/litestream_key`** — a dedicated keypair that
lives only on the API box. Create it once, before the first `up`:
ssh-keygen -t ed25519 -N "" -C nestquest-litestream -f deploy/litestream_key
sudo chown 999:999 deploy/litestream_key
chmod 600 deploy/litestream_key
Then append `restrict ` followed by the contents of
`deploy/litestream_key.pub` as one line to
`joshua@10.60.1.50:~/.ssh/authorized_keys` on the NAS. Compose mounts the
private key read-only at `/etc/litestream/sftp_key` in the sidecar, which
runs as uid 999 — hence the ownership. The NAS host key is pinned in the
committed `deploy/litestream.yml` (`host-key`, the NAS's ECDSA key), so no
`known_hosts` is involved.
| Secret | Supplied by | Consumed by |
| --- | --- | --- |
| `NESTQUEST_PANEL_TOKEN` | `deploy/.env` → `env_file` | `api` (panel plane); also set in the HA integration |
| `NESTQUEST_OIDC_*` (not secret, but environment-supplied) | `deploy/.env` → `env_file` | `api` (admin plane) |
| SFTP private key | `deploy/litestream_key` → read-only bind mount | `litestream` |
| Cloudflare DNS API token | Traefik deployment on nukapi-03 | Traefik ACME `myresolver` |
## 6. Network separation and firewall
- **Panel plane** (`/api/v1/panel/*`, static service token): reachable
only from the HA box. Traefik's `ipAllowList` on the panel router allows
`10.60.1.80/32` and returns **403** to every other source, before the
request reaches the API. The API additionally requires the panel token
(401 without it).
- **Admin plane** (`/api/v1/admin/*`) and `/health`, `/docs`: public
through Cloudflare and Traefik; admin routes are protected by the API's
Authentik JWT check (401 without a valid token, 403 without
`nestquest-admins` or when a panel token is presented).
- **Local DNS override** so the HA box reaches Traefik directly, keeping
its real source address: in Pi-hole (`10.60.1.101`) local DNS records,
add `nestquest.cubecraftlabs.com` → `10.60.1.33`. The HA box
must use Pi-hole as its resolver. Check from the HA box:
nslookup nestquest.cubecraftlabs.com 10.60.1.101 # Address: 10.60.1.33
- **Direct `:8080` access is firewalled.** The API box publishes `8080`
on `10.60.1.14`, which alone would let any LAN host bypass Traefik and
the panel `ipAllowList`. This is enforced on the API box in Docker's
`DOCKER-USER` chain (which Docker evaluates before its own forwarding
rules for published ports), in this order:
-A DOCKER-USER -s 10.60.1.33/32 -p tcp --dport 8080 -j RETURN # Traefik on nukapi-03
-A DOCKER-USER -s 10.60.1.14/32 -p tcp --dport 8080 -j RETURN # the API box itself
-A DOCKER-USER -p tcp --dport 8080 -j DROP # every other source
The rules are persisted by the systemd oneshot unit
**`nestquest-api-firewall.service`**, committed as
`deploy/systemd/nestquest-api-firewall.service` (ordered after
`docker.service`, it idempotently applies the three rules at every
boot). Install and enable it on the API box, from the repository
checkout, **before** exposing the API:
sudo cp deploy/systemd/nestquest-api-firewall.service /etc/systemd/system/ \
&& sudo systemctl daemon-reload \
&& sudo systemctl enable --now nestquest-api-firewall.service
The HA box and every other client must use the Traefik hostname,
never `:8080` directly. Check it:
sudo systemctl is-enabled nestquest-api-firewall.service # enabled
sudo systemctl is-active nestquest-api-firewall.service # active
sudo iptables -S DOCKER-USER # the three rules above, in that order
# on nukapi-03 (10.60.1.33) — allowed:
curl -s -o /dev/null -w '%{http_code}\n' http://10.60.1.14:8080/health # 200
# on any other LAN host, e.g. 10.60.1.101 — dropped:
curl -s -m 5 -o /dev/null -w '%{http_code}\n' http://10.60.1.14:8080/health # 000, timeout
# public path still works:
curl -s -o /dev/null -w '%{http_code}\n' https://nestquest.cubecraftlabs.com/health # 200
On a rebuilt API box, install the unit with the commands above before
exposing the API. The committed file is the authoritative copy; the
deployed unit must match it:
diff deploy/systemd/nestquest-api-firewall.service \
/etc/systemd/system/nestquest-api-firewall.service # no output
## 7. Backup (Litestream to the Synology NAS)
The `litestream` sidecar (config `deploy/litestream.yml`, mounted at
`/etc/litestream.yml`) replicates `/var/lib/nestquest/nestquest.db` to:
- SFTP `joshua@10.60.1.50`, path `/homes/Joshua/nestquest-backups` (the
NAS's SFTP view is chrooted to the shared folders; over SSH the same
directory is `/volume1/homes/Joshua/nestquest-backups`).
- Every committed change is shipped as an LTX file within about a second.
Litestream 0.5.17 compacts them into coarser levels on its built-in
schedule (level 1 every 30s, level 2 every 5m, level 3 every 1h).
- **Snapshots**: `snapshot.interval: 24h` (a full snapshot daily);
**retention**: `snapshot.retention: 168h` — a week of snapshots plus the
changes between them, i.e. a 7-day point-in-time restore window.
The sidecar runs as uid 999 with a read-only root filesystem,
`no-new-privileges` and all capabilities dropped. It needs the database
volume read-write (it keeps its `_litestream_*` tables and writes the
WAL); its local state lives beside the database in
`/var/lib/nestquest/.nestquest.db-litestream/`.
Check it:
dc ps litestream # Up
dc logs --tail 50 litestream # no "error" lines
dc exec litestream litestream status # status "ok", local txid advancing
dc exec litestream litestream ltx /var/lib/nestquest/nestquest.db | tail # newest LTX files and timestamps
A change made in the app should appear as a new level-0 LTX file within
seconds. On the NAS, the files are under
`/volume1/homes/Joshua/nestquest-backups/ltx/`.
## 8. Restore
Restores the database from the SFTP replica into the `nestquest-db`
volume at `NESTQUEST_DB_PATH` (`/var/lib/nestquest/nestquest.db`). The
same procedure serves both a damaged database on the existing box and a
rebuilt box. The restore runs in one-off `litestream` containers so it
has the same user (999), volume, config and SFTP key as the sidecar.
**0. Fresh box only.** Complete sections 1 and 5, then start **only the
API** once so the volume exists and is owned by uid 999:
dc up -d api
Do not start `litestream` yet — it would begin replicating the new,
empty database the API just created. The swap in step 4 moves that empty
file aside, and step 5 creates and starts the sidecar with
`dc up -d litestream` once the API is healthy.
**1. Stop writers** — both the API and the sidecar (Litestream must not
replicate while the file is being replaced):
dc stop api litestream
**2. Restore beside the live database** (never over it), with a full
integrity check:
dc run --rm --no-deps litestream restore \
-config /etc/litestream.yml \
-integrity-check full \
-o /var/lib/nestquest/nestquest.db.restored \
/var/lib/nestquest/nestquest.db
Expect `post-restore integrity check passed` and exit code 0. For a
point in time within the retention window, add
`-timestamp 2026-09-24T12:00:00Z` (UTC). To preview without writing, add
`-dry-run`.
**3. Inspect the restored file** before switching to it — confirm known
children and definitions are present:
dc run --rm --no-deps --entrypoint sqlite3 litestream \
/var/lib/nestquest/nestquest.db.restored \
'PRAGMA integrity_check;' \
'SELECT id, display_name FROM children;' \
'SELECT id, title FROM quest_definitions;'
Expect `ok`, then the household's children and quest definitions.
**4. Swap it into place**, keeping the old files (database, its
`-wal`/`-shm`, and Litestream's local state) in a timestamped directory
on the volume:
dc run --rm --no-deps --entrypoint sh litestream -c '
set -e
cd /var/lib/nestquest
ts=$(date -u +%Y%m%dT%H%M%SZ)
mkdir -p "pre-restore-$ts"
for f in nestquest.db nestquest.db-wal nestquest.db-shm .nestquest.db-litestream; do
if [ -e "$f" ]; then mv "$f" "pre-restore-$ts/"; fi
done
mv nestquest.db.restored nestquest.db
ls -la'
**5. Start the API, then the sidecar:**
dc start api
dc ps api # wait for "healthy"
dc up -d litestream
dc ps litestream # Up
dc logs --tail 20 litestream
Use `dc up -d litestream`, not `dc start`: on a fresh box step 0 never
created the sidecar container, and `start` cannot start a container that
does not exist. `up -d` creates it if needed (or starts the existing one),
so continuous replication is back online on both paths.
The sidecar resumes replication from the restored position (it logs
`detected database behind replica` / `fetched latest L0 file from
replica`, then continues normally).
**6. Verify through the API** — the panel snapshot lists the restored
children. On the API box against `10.60.1.14:8080` directly (other LAN
hosts, including the HA box, are dropped on `:8080` by the section 6
firewall; from the HA box use the Traefik hostname instead):
PANEL_TOKEN=$(grep '^NESTQUEST_PANEL_TOKEN=' deploy/.env | cut -d= -f2-)
curl -s -H "Authorization: Bearer $PANEL_TOKEN" \
http://10.60.1.14:8080/api/v1/panel/snapshot
Expect HTTP 200 and `children` naming the known child(ren). Then run
section 9.
Once satisfied, remove the old copy:
`dc run --rm --no-deps --entrypoint rm litestream -rf /var/lib/nestquest/pre-restore-<ts>`.
This exact sequence (stop, restore with integrity check, inspect, swap,
start, snapshot shows the restored child, replication resumes) was
rehearsed against a local file replica with the committed compose file;
the live proof against the SFTP replica is recorded separately by the
controller.
## 9. Verification checklist
Run after every deploy and every restore. `$PANEL_TOKEN` is the value from
`deploy/.env`; never paste it into shared logs.
| # | Check | Command | Expect |
| --- | --- | --- | --- |
| 1 | Health over TLS | `curl -s -o /dev/null -w '%{http_code}\n' https://nestquest.cubecraftlabs.com/health` | `200` |
| 2 | Valid certificate | `curl -sv https://nestquest.cubecraftlabs.com/health 2>&1 \| grep -E 'issuer\|expire date'` | Let's Encrypt issuer, future expiry (no `-k` needed) |
| 3 | Docs public | `curl -s -o /dev/null -w '%{http_code}\n' https://nestquest.cubecraftlabs.com/docs` | `200` |
| 4 | Panel refused externally | from any host other than `10.60.1.80` (e.g. a phone off Wi-Fi, or a LAN machine): `curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $PANEL_TOKEN" https://nestquest.cubecraftlabs.com/api/v1/panel/snapshot` | `403` (Traefik allowlist) |
| 5 | Panel allowed from HA box | on `10.60.1.80`: same command | `200`, snapshot JSON |
| 6 | Panel needs the token | on `10.60.1.80`, without the header | `401` |
| 7 | Admin JWT accepted | `curl -s -w '\n%{http_code}\n' -H "Authorization: Bearer $ADMIN_JWT" https://nestquest.cubecraftlabs.com/api/v1/admin/ping` with a token of a `nestquest-admins` member | `200`, `{"status":"ok","sub":...}` |
| 8 | Non-admin refused | same, with a token of a user **not** in `nestquest-admins` | `403`, "Admin access requires membership in the nestquest-admins group" |
| 9 | No/invalid token refused | same, without a header or with garbage | `401` |
| 10 | Panel token refused on admin | same, with `$PANEL_TOKEN` | `403` |
| 11 | Backup running | `dc exec litestream litestream status` | status `ok` |
To obtain `$ADMIN_JWT`: sign in to the admin PWA as the user, open the
browser developer tools, and copy the session-storage value
`nestquest.access_token`. Tokens are short-lived and are credentials —
do not store them.
## 10. Admin PWA hosting
The admin PWA (`admin/`, a Vite + React single-page app) is served from
the API box by the compose service **`pwa`**: nginx on container port
`80`, published on `${NESTQUEST_BIND_ADDRESS:-127.0.0.1}:8081` (on the API
box `10.60.1.14:8081`). Traefik sends every path of
`nestquest.cubecraftlabs.com` that is not the API to it.
**Image** — `deploy/pwa/Dockerfile`, built from the **repository root**
(it needs `admin/`); `deploy/pwa/Dockerfile.dockerignore` limits the
context to `admin/` (without `node_modules`, `dist` or `.env` files) and
`deploy/pwa/nginx.conf`.
- Stage 1, `node:22-alpine` (pinned by digest): `npm ci`, then
`npm run build` with the three `VITE_*` build args below. Vite bakes
them into the bundle, so **changing any of them needs a rebuild**. The
build fails if any is empty.
- Stage 2, `nginx:alpine` pinned to
`sha256:1ed1b0e1d7652937d6cbdaf4018c7b6fc009a7dd6c3047351e2eddda745de43f`
(nginx 1.31.6): the built `dist/` in `/usr/share/nginx/html` and
`deploy/pwa/nginx.conf` as `conf.d/default.conf`.
**Build args** — compose reads them from `deploy/.env` (documented in
`deploy/.env.example`). They are **public values, not secrets**: the
Authentik application is a PKCE client with no client secret.
| `deploy/.env` variable | Build arg | Production value |
| --- | --- | --- |
| `NESTQUEST_AUTHENTIK_CLIENT_ID` | `VITE_AUTHENTIK_CLIENT_ID` | client id of the Authentik NestQuest application (Authentik admin → Applications → Providers) |
| `NESTQUEST_AUTHENTIK_ISSUER` | `VITE_AUTHENTIK_ISSUER` | `https://auth.cubecraftlabs.com/application/o/nestquest/` |
| `NESTQUEST_API_BASE_URL` | `VITE_API_BASE_URL` | `https://nestquest.cubecraftlabs.com` |
The Authentik application's redirect URI must include
`https://nestquest.cubecraftlabs.com/auth/callback`.
**nginx** (`deploy/pwa/nginx.conf`):
- `allow 10.60.1.33; allow 10.60.1.14; deny all;` — only Traefik on
nukapi-03 and the API box itself get content; any other source gets
`403`, even with the port reachable on the LAN. Checks run on the API
box must use its LAN address (`10.60.1.14:8081`), not `127.0.0.1`.
- SPA fallback: unknown paths (e.g. `/auth/callback`) serve `index.html`;
`/assets/*` (content-hashed, cached for a year) returns a real `404`
when missing.
- `/sw.js` and `/manifest.webmanifest` (`application/manifest+json`) are
served from the site root with `Cache-Control: no-cache`, as is
`index.html`, so a new release's service worker is picked up.
Build and start (the `api` and `litestream` services are unaffected):
dc build pwa
dc up -d pwa
dc ps pwa # "Up"
**Traefik router** — in `config/dynamic/nestquest-ccl.yml` on nukapi-03
(reference shape in section 3):
| Router | Rule | Priority | Service |
| --- | --- | --- | --- |
| `nestquest-app` | ``Host(`nestquest.cubecraftlabs.com`) && !PathPrefix(`/api`) && !Path(`/health`) && !Path(`/openapi.json`) && !PathPrefix(`/docs`)`` | `50` — below `nestquest-admin` (100) and `nestquest-panel` (200) | `nestquest-pwa` → `http://10.60.1.14:8081` |
Same `websecure` entrypoint and `myresolver` certificate as the API
routers; no Traefik middleware (nginx enforces the source allow-list).
The file provider reloads the dynamic file on save.
**Verification** (in addition to section 9):
| # | Check | Command | Expect |
| --- | --- | --- | --- |
| 1 | PWA root over TLS | `curl -s -w '\n%{http_code}\n' https://nestquest.cubecraftlabs.com/ \| tail -3` | `200`, the `NestQuest Admin` shell |
| 2 | Client route fallback | `curl -s -o /dev/null -w '%{http_code} %{content_type}\n' https://nestquest.cubecraftlabs.com/auth/callback` | `200 text/html` |
| 3 | Manifest and worker | `curl -s -o /dev/null -w '%{http_code} %{content_type}\n' https://nestquest.cubecraftlabs.com/manifest.webmanifest` and the same for `/sw.js` | `200 application/manifest+json`, `200 application/javascript` |
| 4 | API still routed to the API | `curl -s -o /dev/null -w '%{http_code}\n' https://nestquest.cubecraftlabs.com/health` | `200` (`{"status":"ok"}`, not HTML) |
| 5 | Panel refused externally | section 9 check 4 | `403` |
| 6 | Non-Traefik source refused | from a LAN machine other than `10.60.1.33`/`10.60.1.14`: `curl -s -o /dev/null -w '%{http_code}\n' http://10.60.1.14:8081/` | `403` (nginx `deny all`) |
| 7 | Proxy source allowed | on `10.60.1.14`: `curl -s -o /dev/null -w '%{http_code}\n' http://10.60.1.14:8081/` | `200` |
+98
View File
@@ -0,0 +1,98 @@
# Compose deployment scaffold for the NestQuest API service (Feature
# 17). The SQLite database lives on the NAMED VOLUME ``nestquest-db``
# mounted at /var/lib/nestquest — recreating the container keeps the
# household data (``docker compose down`` leaves named volumes alone).
#
# Configuration is supplied by the environment: the fixed database
# path here, and the secret values (panel service token, Authentik
# OIDC settings) from ``deploy/.env`` — a git-ignored file you create
# from ``deploy/.env.example`` (placeholder names only; never commit
# real values).
#
# TLS is terminated by the shared Traefik reverse proxy on another
# host, which forwards to this service on port 8080. The published
# port binds NESTQUEST_BIND_ADDRESS (read from deploy/.env, the
# project directory's env file): it defaults to 127.0.0.1 so an
# unconfigured deployment is never exposed; set it to the host's LAN
# address on the API box so the proxy can reach it.
services:
api:
build:
context: ..
dockerfile: Dockerfile
image: nestquest-api:latest
init: true
restart: unless-stopped
ports:
- "${NESTQUEST_BIND_ADDRESS:-127.0.0.1}:8080:8080"
environment:
# The SQLite file inside the named volume; fixed, not a secret.
NESTQUEST_DB_PATH: /var/lib/nestquest/nestquest.db
env_file:
# Secret values (NESTQUEST_PANEL_TOKEN, NESTQUEST_OIDC_*); absent
# until you create deploy/.env from the example.
- path: .env
required: false
volumes:
- nestquest-db:/var/lib/nestquest
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health')"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
# Admin PWA (Feature 24): the Vite bundle from admin/ served by nginx
# (deploy/pwa/). Traefik routes every non-API path of the public host
# here on port 8081; nginx itself only accepts the proxy (10.60.1.33)
# and this host (10.60.1.14). The three build args are PUBLIC values
# (the OIDC client is a PKCE client, no secret) read from deploy/.env
# by compose interpolation; the image build fails if any is empty.
pwa:
build:
context: ..
dockerfile: deploy/pwa/Dockerfile
args:
VITE_AUTHENTIK_CLIENT_ID: ${NESTQUEST_AUTHENTIK_CLIENT_ID:-}
VITE_AUTHENTIK_ISSUER: ${NESTQUEST_AUTHENTIK_ISSUER:-}
VITE_API_BASE_URL: ${NESTQUEST_API_BASE_URL:-}
image: nestquest-pwa:latest
restart: unless-stopped
ports:
- "${NESTQUEST_BIND_ADDRESS:-127.0.0.1}:8081:80"
# Continuous backup: streams the SQLite database on the shared named
# volume to the Synology NAS over SFTP (config: deploy/litestream.yml).
# The SFTP private key deploy/litestream_key is git-ignored and must be
# created on the API box before starting (see api/README.md).
#
# The database volume is mounted READ-WRITE on purpose: Litestream
# cannot run against a read-only mount ("unable to open database file
# (14)", even with its metadata moved elsewhere) because it writes its
# _litestream_seq/_litestream_lock tables into the database, opens the
# -wal/-shm files for writing, and checkpoints the WAL. The write
# access is contained instead: it runs as the API's own non-root user
# (uid/gid 999, owner of the volume) with a read-only root filesystem,
# no privilege escalation and no Linux capabilities.
litestream:
image: litestream/litestream:0.5.17@sha256:4b02b9859a6b6b4087d8b8944e15f7e984bd7957cba322bbeee38b0e27b9656a
restart: unless-stopped
command: ["replicate", "-config", "/etc/litestream.yml"]
user: "999:999"
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
depends_on:
api:
condition: service_healthy
volumes:
- nestquest-db:/var/lib/nestquest
- ./litestream.yml:/etc/litestream.yml:ro
- ./litestream_key:/etc/litestream/sftp_key:ro
volumes:
nestquest-db:
+32
View File
@@ -0,0 +1,32 @@
# Litestream continuous backup of the NestQuest SQLite database (Feature
# 17). The ``litestream`` compose service mounts the same named volume
# as the API and streams every committed change to the Synology NAS
# over SFTP, so the backup lives OUTSIDE the container's volume.
#
# Nothing here is secret. The SFTP private key is a dedicated,
# git-ignored keypair (deploy/litestream_key) mounted read-only by
# compose at /etc/litestream/sftp_key; its public half is in
# joshua@10.60.1.50:~/.ssh/authorized_keys. ``host-key`` pins the
# NAS's host key so the connection is verified; it must be the ECDSA
# key, because that is the algorithm Litestream's SSH client negotiates
# with this NAS (pinning the ed25519 key fails with "host key mismatch").
#
# The Synology SFTP server is chrooted to the shared folders, so the
# directory seen over SSH as /volume1/homes/Joshua/nestquest-backups is
# /homes/Joshua/nestquest-backups over SFTP.
# Take a full snapshot daily and keep a week of snapshots plus the
# incremental changes between them (point-in-time restore window).
snapshot:
interval: 24h
retention: 168h
dbs:
- path: /var/lib/nestquest/nestquest.db
replicas:
- type: sftp
host: 10.60.1.50
user: joshua
path: /homes/Joshua/nestquest-backups
key-path: /etc/litestream/sftp_key
host-key: ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNujay9nYEiXuRQf4wDRDU1gp+CDvpeMdhuhC2XypIihzKGEtB654kwrUGfA2kDWAoSPGlmojeS3OIRtHPkj6GY=
+29
View File
@@ -0,0 +1,29 @@
# Admin PWA image (Feature 24): builds the Vite bundle from admin/ and
# serves it with nginx. Build context is the REPOSITORY ROOT (it needs
# admin/); deploy/pwa/Dockerfile.dockerignore limits that context to
# admin/ and this directory's nginx.conf.
#
# docker compose -f deploy/compose.yaml build pwa
#
# The VITE_* values are baked into the bundle at build time. None is a
# secret (the OIDC client is a public PKCE client), and all three are
# required: the build fails fast rather than relying on app defaults.
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS build
ARG VITE_AUTHENTIK_CLIENT_ID
ARG VITE_AUTHENTIK_ISSUER
ARG VITE_API_BASE_URL
WORKDIR /src/admin
COPY admin/package.json admin/package-lock.json ./
RUN npm ci
COPY admin/ ./
RUN test -n "$VITE_AUTHENTIK_CLIENT_ID" && test -n "$VITE_AUTHENTIK_ISSUER" && test -n "$VITE_API_BASE_URL" \
|| { echo "VITE_AUTHENTIK_CLIENT_ID, VITE_AUTHENTIK_ISSUER and VITE_API_BASE_URL build args are required" >&2; exit 1; }
RUN npm run build
FROM nginx:alpine@sha256:1ed1b0e1d7652937d6cbdaf4018c7b6fc009a7dd6c3047351e2eddda745de43f
COPY deploy/pwa/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/admin/dist/ /usr/share/nginx/html/
+11
View File
@@ -0,0 +1,11 @@
# Build-context filter for deploy/pwa/Dockerfile (BuildKit reads
# <Dockerfile>.dockerignore in place of the root .dockerignore, which
# excludes deploy/). Only admin/ sources and nginx.conf enter the
# build; local dependencies, build output and .env files never do.
*
!admin/
!deploy/pwa/nginx.conf
admin/node_modules
admin/dist
**/.env
**/.env.*
+53
View File
@@ -0,0 +1,53 @@
# nginx server for the NestQuest admin PWA (Feature 24). Replaces the
# image's /etc/nginx/conf.d/default.conf. The shared Traefik on
# nukapi-03 (10.60.1.33) terminates TLS for nestquest.cubecraftlabs.com
# and forwards every path that is not the API (/api, /health,
# /openapi.json, /docs) here; see deploy/RUNBOOK.md section 10.
server {
listen 80;
server_name _;
# Only the reverse proxy and the API box itself may connect; every
# other source is refused (403) even if the port is reachable.
allow 10.60.1.33;
allow 10.60.1.14;
deny all;
root /usr/share/nginx/html;
index index.html;
server_tokens off;
# Content-hashed bundle files: cache forever, and a missing asset is
# a real 404 rather than the SPA shell.
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# The service worker must be revalidated on every load so a new
# release's worker (fresh revision, see admin/vite-sw-plugin.ts) is
# picked up.
location = /sw.js {
try_files $uri =404;
add_header Cache-Control "no-cache";
}
# nginx's stock mime.types has no .webmanifest entry.
location = /manifest.webmanifest {
types { }
default_type application/manifest+json;
try_files $uri =404;
add_header Cache-Control "no-cache";
}
# SPA fallback: client routes such as /auth/callback resolve to the
# app shell, which is never cached so it always names the current
# hashed assets.
location / {
try_files $uri $uri/ /index.html;
}
location = /index.html {
add_header Cache-Control "no-cache";
}
}
@@ -0,0 +1,12 @@
[Unit]
Description=Restrict NestQuest API port 8080 to Traefik (DOCKER-USER)
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c "iptables -C DOCKER-USER -s 10.60.1.33/32 -p tcp --dport 8080 -j RETURN 2>/dev/null || iptables -I DOCKER-USER 1 -s 10.60.1.33 -p tcp --dport 8080 -j RETURN; iptables -C DOCKER-USER -s 10.60.1.14/32 -p tcp --dport 8080 -j RETURN 2>/dev/null || iptables -I DOCKER-USER 2 -s 10.60.1.14 -p tcp --dport 8080 -j RETURN; iptables -C DOCKER-USER -p tcp --dport 8080 -j DROP 2>/dev/null || iptables -A DOCKER-USER -p tcp --dport 8080 -j DROP"
[Install]
WantedBy=multi-user.target
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nestquest"
version = "0.6.0"
version = "0.7.0"
description = "NestQuest Home Assistant Integration"
readme = "README.md"
requires-python = ">=3.11"
Generated
+1 -1
View File
@@ -373,7 +373,7 @@ wheels = [
[[package]]
name = "nestquest"
version = "0.6.0"
version = "0.7.0"
source = { virtual = "." }
[package.dev-dependencies]