Compare commits

..
Author SHA1 Message Date
overseerandClaude Opus 5.5 8528200e32 Trap focus in the presence override editor and assert exact warning copy
Move focus to the editor heading on open, keep Tab/Shift+Tab inside,
make the background (tab bar included) inert and aria-hidden while open,
cancel on Escape unless saving, and return focus to Add on close.
Tests assert the full consequence warning text and that a malformed
From date sends no consequence request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 23:11:18 -04:00
overseerandClaude Opus 5.5 c1d9e99f36 Add presence override editor with live consequence count
Wire the Schedule tab's Overrides card: Add opens the ADMIN-SPEC §6
pushed editor for the selected child, and the trash button confirms
before calling DELETE. The editor counts the upcoming tasks an Away
override removes from POST /presence-overrides/consequence (debounced,
stale responses dropped, never for an invalid range) and keeps Save off
until that count is shown. Saving sends only the create request, then
refetches the overrides and schedules; a 422 keeps the form and shows
the API detail.

Task 4ae0c879-2bc3-4aee-a084-5e48b41c2085

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:56:42 -04:00
dev_agent 7b5177d055 Task d96d8925: Presence schedule month grid (#206) 2026-09-23 22:51:16 -04:00
dev_agent a22f990bd6 Task b50b048f: Lucide icon picker (shipped subset) (#205) 2026-09-23 22:39:25 -04:00
dev_agent 4b56e2f68a Task 85b2f166: Recurrence occurrence preview (#204) 2026-09-23 22:30:04 -04:00
overseer d0c236b569 Merge dev into feature/24ce7ebf (Feature 23: admin API completion) 2026-09-23 22:17:08 -04:00
dev_agent 33d8e0d2ac Task 2e96585c: Definitions list and edit sheet (#198) 2026-09-23 21:26:27 -04:00
overseer dbe873eb2a Merge dev into feature/24ce7ebf (Feature 22 follow-up: editable assignees) 2026-09-23 21:11:21 -04:00
overseer 706a7954d8 Merge dev into feature/24ce7ebf (Feature 22: definitions read + skip_on_away) 2026-09-23 20:52:48 -04:00
dev_agent be4fd04754 Task e0a2b862: Today tab (#192) 2026-09-23 20:13:52 -04:00
overseer 4ff40f00a2 Merge dev into feature/24ce7ebf (bring the Feature 21 admin snapshot route) 2026-09-23 20:03:05 -04:00
dev_agent f476821e8e Task 96ab3c0b: PWA manifest and service worker (#189) 2026-09-23 19:42:27 -04:00
dev_agent 75ad9892b8 Task 630c3472: Authentik OIDC PKCE login and refusal handling (#188) 2026-09-23 18:30:07 -04:00
dev_agent 48d0c4ef86 Task 0df715b5: React scaffold, build and serve (#187) 2026-09-23 16:39:36 -04:00
34 changed files with 82 additions and 3384 deletions
-27
View File
@@ -1,27 +0,0 @@
# 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,16 +5,3 @@ __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
@@ -1,51 +0,0 @@
# 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,136 +1,5 @@
# 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
+11
View File
@@ -48,6 +48,17 @@
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;
+3 -2
View File
@@ -29,8 +29,9 @@ describe("NestQuest Admin shell", () => {
fireEvent.click(screen.getByRole("tab", { name: "History" }));
expect(screen.getByRole("heading", { name: "History" })).toBeTruthy();
expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder");
expect(screen.getByRole("tabpanel").textContent).toContain(
"History placeholder",
);
expect(screen.getByRole("tab", { name: "History" }).getAttribute("aria-selected")).toBe(
"true",
);
+42 -42
View File
@@ -1,8 +1,6 @@
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";
@@ -17,47 +15,49 @@ 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" || active === "schedule";
return (
<TransitionsProvider>
<div className="admin-shell">
{/* Every tab is a built screen that owns its header (ADMIN-SPEC §1); the
generic shell header stays only as a visually hidden page heading. */}
<header className="admin-header visually-hidden">
<h1 className="admin-title">NestQuest Admin</h1>
<p className="admin-subline">Admin console</p>
</header>
<main
className="admin-panel admin-panel--screen"
role="tabpanel"
aria-labelledby={`tab-${active}`}
>
{active === "today" ? (
<TodayTab />
) : active === "tasks" ? (
<DefinitionsTab />
) : active === "schedule" ? (
<ScheduleTab />
) : (
<HistoryTab />
)}
</main>
<nav className="admin-tabbar" aria-label="Admin sections">
{TABS.map((tab) => (
<button
key={tab.id}
id={`tab-${tab.id}`}
type="button"
role="tab"
aria-selected={tab.id === active}
className={tab.id === active ? "admin-tab active" : "admin-tab"}
onClick={() => setActive(tab.id)}
>
{tab.label}
</button>
))}
</nav>
</div>
</TransitionsProvider>
<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 />
) : active === "schedule" ? (
<ScheduleTab />
) : (
<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>
);
}
+1 -121
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import AppRoutes from "./AppRoutes";
import { storeTokens } from "./auth/oidc";
import { config } from "./config";
@@ -32,32 +32,6 @@ 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([]));
@@ -71,9 +45,6 @@ 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 () => {
@@ -87,97 +58,6 @@ 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", () => {
+8 -43
View File
@@ -12,7 +12,7 @@ import {
handleCallback,
isAuthenticated,
} from "./auth/oidc";
import { ApiForbiddenError, ApiUnauthorizedError, apiFetch } from "./api/client";
import { ApiForbiddenError, apiFetch } from "./api/client";
import AdminShell from "./App";
type Screen =
@@ -21,28 +21,18 @@ 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) {
// 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.
// A cheap authenticated probe so a 403 surfaces as an explicit refusal
// right after login instead of a silent empty shell.
try {
const response = await apiFetch(ADMIN_PROBE_PATH, {}, config);
if (response.ok) {
setScreen({ kind: "app" });
return;
}
setScreen({
kind: "probe-error",
message: `The admin API answered ${response.status}. The admin console cannot load.`,
});
await apiFetch("/api/v1/children", {}, config);
setScreen({ kind: "app" });
} catch (error) {
if (error instanceof ConfigError) {
setScreen({ kind: "config-error", message: error.message });
@@ -52,15 +42,9 @@ async function probeApi(setScreen: (screen: Screen) => void) {
setScreen({ kind: "refusal", message: error.message });
return;
}
if (error instanceof ApiUnauthorizedError) {
// apiFetch has already started the login redirect.
setScreen({ kind: "login" });
return;
}
setScreen({
kind: "probe-error",
message: "The admin API could not be reached. Check your connection and try again.",
});
// Network or other failures still allow the shell to render; API screens
// handle their own errors in later tasks.
setScreen({ kind: "app" });
}
}
@@ -186,25 +170,6 @@ 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 />;
}
-162
View File
@@ -1,162 +0,0 @@
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
@@ -1,196 +0,0 @@
/**
* 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
@@ -1,85 +0,0 @@
/**
* 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
@@ -1,23 +0,0 @@
/**
* 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" }));
}
-39
View File
@@ -1,39 +0,0 @@
/**
* 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
@@ -1,61 +0,0 @@
/**
* 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]);
}
-296
View File
@@ -1,296 +0,0 @@
/* 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
@@ -1,631 +0,0 @@
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
@@ -1,300 +0,0 @@
/**
* 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
@@ -1,94 +0,0 @@
/**
* 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
@@ -1,41 +0,0 @@
/**
* 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>
);
}
-1
View File
@@ -30,7 +30,6 @@
--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;
-31
View File
@@ -241,37 +241,6 @@
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;
+1 -186
View File
@@ -1,9 +1,7 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, 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 {
@@ -223,186 +221,3 @@ 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));
});
});
+13 -83
View File
@@ -1,7 +1,7 @@
/**
* Today tab — compact density (design/ADMIN-SPEC.md §2.1–2.3, §2.5–2.6).
*/
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import {
fetchSnapshot,
type AdminSnapshot,
@@ -9,9 +9,6 @@ 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 {
formatClockTime,
formatDueTime,
@@ -147,18 +144,11 @@ function attentionItems(snapshot: AdminSnapshot): AttentionItem[] {
return [...overdue, ...completed];
}
interface UndoControl {
/** The instance whose Undo is in flight, or null. */
pendingId: number | null;
onUndo: (instanceId: number) => void;
}
function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }) {
function AttentionRow({ item }: { item: AttentionItem }) {
const { instance, childName } = item;
const overdue = isOverdueOpen(instance);
const undoing = undo.pendingId === instance.id;
// TODO(e57facc2): wire "Mark done". The admin plane has no complete route
// yet, so that action is inert here.
// TODO(e57facc2): wire "Mark done" / "Undo" mutations and live updates.
// The admin plane has no complete route yet, so both actions are inert here.
return (
<li className="today-event" data-testid={`attention-${instance.id}`}>
<span
@@ -183,21 +173,12 @@ function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }
</div>
)}
</div>
{overdue ? (
<button type="button" className="today-action today-action--done">
Mark done
</button>
) : (
<button
type="button"
className="today-action today-action--undo"
disabled={undo.pendingId !== null}
aria-busy={undoing}
onClick={() => undo.onUndo(instance.id)}
>
{undoing ? "Undoing…" : "Undo"}
</button>
)}
<button
type="button"
className={overdue ? "today-action today-action--done" : "today-action today-action--undo"}
>
{overdue ? "Mark done" : "Undo"}
</button>
</li>
);
}
@@ -216,25 +197,10 @@ 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);
function refresh() {
silentNext.current = true;
setAttempt((n) => n + 1);
}
// Any transition (completed, uncompleted, missed, day complete) changes the snapshot.
useTransitions(refresh);
useEffect(() => {
let cancelled = false;
const silent = silentNext.current;
silentNext.current = false;
if (!silent) setState({ kind: "loading" });
setState({ kind: "loading" });
fetchSnapshot()
.then((snapshot) => {
if (!cancelled) setState({ kind: "ready", snapshot });
@@ -245,37 +211,13 @@ export default function TodayTab() {
error instanceof ApiForbiddenError
? error.message
: "Today's snapshot could not be loaded. Check your connection and try again.";
// A failed refresh keeps the snapshot already on screen.
setState((prev) => (silent && prev.kind === "ready" ? prev : { kind: "error", message }));
setState({ 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") {
@@ -309,11 +251,7 @@ 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}
undo={{ pendingId: undoingId, onUndo: (id) => void undo(id) }}
/>
<AttentionRow key={item.instance.id} item={item} />
))}
</ul>
) : (
@@ -332,14 +270,6 @@ export default function TodayTab() {
data-testid="today-scroll"
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
>
{undoError !== null ? (
<div className="today-action-error" role="alert" data-testid="today-undo-error">
<span>{undoError}</span>
<button type="button" className="today-action-dismiss" onClick={() => setUndoError(null)}>
Dismiss
</button>
</div>
) : null}
{body}
</div>
</div>
-83
View File
@@ -1,83 +0,0 @@
# 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
@@ -1,38 +0,0 @@
# 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.7.0"
"version": "0.6.0"
}
-23
View File
@@ -1,23 +0,0 @@
# 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=
-455
View File
@@ -1,455 +0,0 @@
# 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` |
| 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 two 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 |
- **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 |
| --- | --- | --- | --- |
| panel | ``Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)`` | `ipAllowList`, `sourceRange: ["10.60.1.80/32"]` (HA box only) | `http://10.60.1.14:8080` |
| admin (everything else: `/health`, `/docs`, `/openapi.json`, `/api/v1/admin/*`) | ``Host(`nestquest.cubecraftlabs.com`)`` | none — public; the API itself enforces Authentik JWTs (section 4) | `http://10.60.1.14:8080` |
Both routers use the HTTPS entrypoint `websecure` with `tls.certResolver: myresolver`
(HTTP is redirected to HTTPS by the shared Traefik). The panel router's
rule is longer, so Traefik's default rule-length priority evaluates it
before the catch-all admin router; if you set explicit priorities, keep
panel higher.
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`)"
entryPoints: [websecure]
middlewares: [nestquest-panel-allowlist]
service: nestquest-api
tls:
certResolver: myresolver
nestquest-admin:
rule: "Host(`nestquest.cubecraftlabs.com`)"
entryPoints: [websecure]
service: nestquest-api
tls:
certResolver: myresolver
middlewares:
nestquest-panel-allowlist:
ipAllowList:
sourceRange: ["10.60.1.80/32"]
services:
nestquest-api:
loadBalancer:
servers:
- url: "http://10.60.1.14:8080"
```
**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.
-79
View File
@@ -1,79 +0,0 @@
# 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
# 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
@@ -1,32 +0,0 @@
# 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=
@@ -1,12 +0,0 @@
[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.7.0"
version = "0.6.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.7.0"
version = "0.6.0"
source = { virtual = "." }
[package.dev-dependencies]