Compare commits

...
Author SHA1 Message Date
overseerandClaude Opus 5.5 bce2792d8c Compute the missed-sweep delay as real elapsed time across DST
_delay_seconds subtracted two aware datetimes sharing the household
ZoneInfo, which Python does as wall-clock arithmetic, so the sweep fired
an hour late at spring-forward and an hour early at fall-back. Both
instants are now compared and subtracted in UTC.

A nonexistent rollover time resolves to the first valid wall minute
at/after it (the gap's end); an ambiguous one resolves to its first
occurrence (fold=0). Adds America/New_York spring-forward, fall-back,
nonexistent and ambiguous cases alongside the ordinary EDT case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 09:54:36 -04:00
overseerandClaude Opus 5.5 59e072602b Merge remote-tracking branch 'origin/dev' into feature/916210e7
Brings the expanded task icons feature (#248) into the UI-bugs branch.
Conflicts resolved:
- tests/test_panel_cards.py: kept both the UI-bugs tests (tap-to-complete,
  Lit accessor tsconfig, crest classes/geometry, meta class) and the icon
  tile tests (registry-backed open tile, star fallback, sealed star).
- frontend/tests/render-quest-log.mjs: merged doc comment to describe both
  the tiles and tap payload fields (code auto-merged).
- custom_components/nestquest/www/nestquest-cards.js: regenerated with
  npm run build from the merged source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 09:45:29 -04:00
dev_agent 87566689aa Fix quest-log meta line losing its class when not late (#255) 2026-09-26 09:42:28 -04:00
dev_agent 744d80e645 Quest bugs: household-local overdue clock and the panel Complete no-op (#254) 2026-09-26 09:37:01 -04:00
dev_agent 8f38946c04 Update NestQuest logo on the party board (new monogram wordmark) (#253) 2026-09-26 09:18:38 -04:00
dev_agent 9916d83138 Fix the Quest Log header crest/shield to match the design (resubmission of PR #247) (#252) 2026-09-26 09:08:59 -04:00
dev_agent bdf6d0aca1 Feature: expanded and custom task icons (Lucide + Font Awesome + emoji) (#248)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-26 09:02:07 -04:00
dev_agent c5b313a5e3 Fix hidden hint text behind the party-board dock (#244) 2026-09-26 08:09:51 -04:00
orchestrator-agent fb9907ae7f Merge pull request Reconcile dev with main after the 0.8.0 release 2026-09-25 14:53:18 -04:00
overseer dbf410ada3 Merge pull request 'Release: multi-pattern presence, Sunday-first week and admin fixes (0.8.0)' (#239) from release/2026-09-25-presence-patterns-and-start-date into main
Reviewed-on: #239
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-25 12:52:31 -04:00
overseer 2906da7ed1 release: clarify the 0.8.0 scope wording 2026-09-25 07:42:04 -04:00
overseer 4f609d7bb9 release: advance version to 0.8.0 and add the 0.8.0 release notes 2026-09-25 07:39:18 -04:00
dev_agent 39e0d715fc Feature: definition sheet start-date field (#238)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-25 07:38:14 -04:00
dev_agent d248b9cb32 Fix: create quest definition materializes its instances (#236)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-25 07:26:33 -04:00
dev_agent 64a56389ad Feature docs: multi-pattern presence, Sunday-first week and the migration-9 rollback (#234)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-24 20:41:06 -04:00
dev_agent c313e1c56f Feature: multiple presence patterns per child (schema 9) (#232)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-24 20:15:27 -04:00
dev_agent d3c52974f5 Feature 19 follow-up: calendar week starts on Sunday (#230)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-24 19:33:22 -04:00
orchestrator-agent 5650a1f5a2 Merge pull request Reconcile dev with main after the 0.7.1 release (#228)
Post-release reconciliation of dev from main (release 0.7.1, PR #227)
2026-09-24 17:39:46 -04:00
overseer 7acc6501d9 Merge pull request 'Release: Admin PWA follow-ups and hosting (0.7.1) (2026-09-24)' (#227) from release/2026-09-24-pwa-followups into main
Reviewed-on: #227
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-24 17:07:33 -04:00
overseer 345a5129b6 release: correct the 0.7.1 notes (manifest version metadata) 2026-09-24 16:53:00 -04:00
overseer 3d293e0431 release: advance version to 0.7.1 and add the PWA follow-ups release notes 2026-09-24 16:48:58 -04:00
dev_agent 9cdc69ea72 Feature 19 follow-up: Settings preferences section (#224) (#225)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-24 16:17:52 -04:00
dev_agent 1eb136fc67 Feature 19 follow-up: Manage children screen (#222) (#223)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-24 15:06:22 -04:00
dev_agent ee6c1d6bc4 Feature 24: Admin PWA Hosting (#221)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-24 12:45:17 -04:00
orchestrator-agent 87e3f12f4c Merge pull request 'Reconcile dev from main (release 0.7.0)' (#219) from main into dev 2026-09-24 12:16:30 -04:00
overseer 9d741e75d0 Merge pull request 'Release: Admin PWA and admin API (0.7.0) (2026-09-24)' (#213) from release/2026-09-24-admin-pwa into main
Reviewed-on: #213
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-24 11:48:22 -04:00
overseer 7887d16f11 release: include Feature 17 (API deployment) in the 0.7.0 notes 2026-09-24 10:02:16 -04:00
overseer 03ee131c6e Merge dev into release/2026-09-24-admin-pwa (bring Feature 17 deployment) 2026-09-24 09:59:10 -04:00
dev_agent 2c6234be48 Feature 17: API Deployment and Identity (#217)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-24 09:57:07 -04:00
overseerandClaude Opus 5.5 7969c164c4 release: make the 0.7.0 rollback instructions API-host-aware
The API service owns and migrates the database at NESTQUEST_DB_PATH on the
API host; back up and restore it there, and scope the HACS/manual steps to
the Home Assistant integration component only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 00:14:42 -04:00
overseer 4b80fffe67 release: advance version to 0.7.0 and add the admin PWA release notes 2026-09-24 00:11:34 -04:00
dev_agent fe44740450 Feature 19: Admin PWA (#187-#210) (#211)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-24 00:08:58 -04:00
dev_agent b8532bacde Feature 23: Admin API Completion - preview, presence, live (#199-#202) (#203)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 22:16:47 -04:00
dev_agent 4b39af4b67 Feature 22 follow-up: Editable definition assignees (#196) (#197)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 21:10:39 -04:00
dev_agent 42ea5710d7 Feature 22: Admin Definitions Read and skip_on_away (#193, #194) (#195)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 20:52:06 -04:00
dev_agent a808f89993 Feature 21: Admin Today Snapshot API (#190) (#191)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 20:01:25 -04:00
orchestrator-agent 22f1a6861d Merge pull request 'Reconcile dev from main (release 0.6.0 commits)' (#186) from main into dev 2026-09-23 16:16:23 -04:00
overseer 939556a38a Merge pull request 'Release: Re-imagined architecture - Features 15, 16, 18, 20 (2026-09-23)' (#185) from release/2026-09-23-reimagining into main
Reviewed-on: #185
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-23 14:55:38 -04:00
overseer 8676bdc6ad release: correct the 0.6.0 notes - no containerization artifacts ship in this release 2026-09-23 14:35:01 -04:00
overseer 0ed35ab85a release: advance version to 0.6.0 and add the re-imagined architecture release notes 2026-09-23 14:29:12 -04:00
overseer c57d950101 merge: origin/main into 0.6.0 release (bring the architecture doc; keep dev versions and notes) 2026-09-23 14:27:56 -04:00
dev_agent 70ee4ec111 Make the missed-sweep watermark claim safe across processes (#184)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 13:06:40 -04:00
dev_agent 415ababc8e Automatically prepare releases for completed features (#182)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 13:02:44 -04:00
dev_agent ee8ef6dcac Feature 20: Zero-Config Panel Dashboard (#180)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 12:27:35 -04:00
dev_agent 6059a67122 Feature 18: HA Integration as API Client (#173)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 09:19:48 -04:00
overseer 0972761994 Merge pull request 'Add the re-imagined architecture document' (#140) from docs/architecture-reimagining into main
Reviewed-on: #140
2026-09-21 16:09:26 -04:00
overseer 10f365ef9f Add the re-imagined architecture document 2026-09-21 13:10:57 -04:00
241 changed files with 40353 additions and 9624 deletions
+27
View File
@@ -0,0 +1,27 @@
# Keep the image build context minimal: only api/ and the bundled
# custom_components/nestquest/core/ are copied (see the Dockerfile);
# everything else — VCS state, virtualenvs, tests, the frontend, and
# any local secrets or database files — never enters the build.
.git
.gitignore
.venv
**/__pycache__
**/*.py[cod]
.pytest_cache
**/*.db
**/*.db-wal
**/*.db-shm
# Local configuration — never baked into an image.
.env
**/.env
deploy
design
docs
frontend
orchestration_tests
scripts
templates
tests
uv.lock
hacs.json
*.md
+13
View File
@@ -5,3 +5,16 @@ __pycache__/
.pytest_cache/
*.metadata.json
frontend/node_modules/
# Feature 17 (API deployment): local secret config and runtime data
# are never committed — compose reads deploy/.env; SQLite/WAL sidecar
# files are container/volume artifacts.
.env
**/.env
*.db
*.db-wal
*.db-shm
# Dedicated Litestream SFTP backup keypair — lives only on the API box.
deploy/*key*
deploy/litestream_key*
+51
View File
@@ -0,0 +1,51 @@
# Container image for the NestQuest API service (Feature 17).
#
# Build context is the REPOSITORY ROOT (compose sets it), because the
# API loads the bundled domain layer from
# custom_components/nestquest/core/ by file location
# (api/nestquest_core.py): the image must carry both api/ and the
# bundled core at their relative positions. Everything else in the
# repository is excluded via .dockerignore.
#
# Multi-stage so no pip/build toolchain survives into the runtime:
# the builder installs the deployable's own manifest
# (api/requirements.txt) into a staging prefix and the runtime copies
# only the installed packages. The service runs as a non-root user;
# the SQLite database lives on the mounted volume at
# /var/lib/nestquest (compose supplies NESTQUEST_DB_PATH pointing
# there — configuration is environment-supplied, never baked in).
#
# Both stages use the same base image pinned by digest, and
# api/requirements.txt pins exact versions, so a rebuild reproduces
# the deployed image instead of floating to whatever is newest.
FROM python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9 AS builder
COPY api/requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir --prefix=/install -r /tmp/requirements.txt
FROM python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9
# The staged dependency install from the builder (no pip, no wheels).
COPY --from=builder /install /usr/local
# /app is the repository-root equivalent: api/ resolves imports as
# ``api.*`` and nestquest_core.py finds the bundled core one level up.
WORKDIR /app
COPY api/ ./api/
COPY custom_components/nestquest/core/ ./custom_components/nestquest/core/
# Non-root runtime user; owns the database volume mount point.
RUN useradd --system --create-home --home-dir /home/nestquest nestquest \
&& mkdir -p /var/lib/nestquest \
&& chown nestquest:nestquest /var/lib/nestquest
USER nestquest
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
# The database path and the service token / OIDC settings are supplied
# by the environment (compose env_file), never committed into the image.
ENV NESTQUEST_DB_PATH=/var/lib/nestquest/nestquest.db
VOLUME /var/lib/nestquest
EXPOSE 8080
CMD ["uvicorn", "--factory", "api.app:create_app", "--host", "0.0.0.0", "--port", "8080"]
+132 -56
View File
@@ -159,9 +159,50 @@ Sensors the automations read: `sensor.nestquest_<child>_quests_due_today`
Run these steps in order to display the NestQuest panel on a TouchHub
wall device. The panel is an HA Lovelace dashboard, so the kiosk browser
is simply a Home Assistant client.
is simply a Home Assistant client. The path below is zero-config: the
integration registers its panel dashboard itself on setup, so no YAML
and no hand-built views are involved.
1. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
1. **Install the integration and add the config entry.** Install
NestQuest via HACS or manually (see Installation above) and restart
Home Assistant. Then, in *Settings → Devices & Services*, click
**Add Integration**, search for **NestQuest**, and submit the flow —
it has no fields; it just creates the single config entry. Then open
the entry, click **Configure**, and fill the panel-plane API
connection in the options flow:
- **NestQuest API base URL** — the full `http://host:port` URL of
your NestQuest API service (default `http://127.0.0.1:8000`).
- **Panel service token** — the token the panel presents to the API
service as its Bearer credential. It may stay empty until you have
one; a pasted token must be unpadded.
The form's remaining fields (panel idle timeout, coordinator update
interval, last-good snapshot staleness) have defaults that serve the
panel and need no change here.
2. **The panel dashboard appears automatically.** On setup the
integration registers one storage-mode Lovelace dashboard: a sidebar
entry titled **NestQuest** at the URL path `/nestquest`, whose stored
config is nothing but the **`custom:nestquest-party` strategy**
(`strategy: { type: custom:nestquest-party }`). The strategy
generates the views at render time from the household roster:
- **The Party** — the party-board view at `/nestquest/party`; the
dashboard root `/nestquest` shows it as the first view.
- one quest-log view per child at `/nestquest/<child-slug>`
("Ada's Quest Log", …), in roster order.
Nothing is hand-configured: no YAML, no manual views, no
`child_order`, no `quest_log_path`/`board_path` — the board and the
quest logs compute their navigation paths from the dashboard's own
URL — and every generated view is already in **Panel** layout (see
the two constraints below). If the Lovelace surfaces are unavailable
(for example when Lovelace runs in YAML mode), setup logs a warning
and skips the registration; create the dashboard manually with the
same strategy as described in the manual alternative below.
3. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
a new user for the wall device. Make it **non-admin**, and ensure it is
not in the NestQuest admin allowlist. The allowlist blocks the NestQuest
**admin** operations — `uncomplete_quest`, `create_quest_definition`,
@@ -174,66 +215,101 @@ is simply a Home Assistant client.
Assistant user it may also reach other permitted Home Assistant entities
and services.
2. **Build a dashboard with only the NestQuest panel cards.** Create a
Lovelace dashboard whose **URL slug is `nestquest`** (Settings →
Dashboards → Edit → URL slug). A functional panel needs **two** views
in that dashboard, each with its own card config from
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
paths (the path segment after the dashboard slug):
4. **Open the dashboard and point TouchHub at it.** Open
`http://<home-assistant>/nestquest` in a browser first and check the
party board renders with your children's crests. Then, in the
TouchHub launcher, add the **Home Assistant Lovelace** app to the
dock and paste the dashboard URL from step 2 — the dashboard root,
`/nestquest` (TouchHub loads that URL directly as the kiosk landing
page). Set TouchHub **Auto-Return** to a value GREATER than the
quest log's `idle_return_seconds` (default 40s) — recommend **60s or
higher**. If Auto-Return is at or below 40s, TouchHub returns to its
launcher before the quest log's idle return can navigate back to the
party board.
- **View 1 path `board`** (URL `/nestquest/board`) — party board
(`type: custom:nestquest-party-board-card`): the household quest
board. Set `weather_entity` (optional; the dock falls back to
date/time), `quest_log_path: /nestquest/log` (the view the crest tap
navigates to), and `child_order: [slug1, slug2, slug3]` listing each
child's slug in display order.
- **View 2 path `log`** (URL `/nestquest/log`) — quest log
(`type: custom:nestquest-quest-log-card`): the per-child quest screen
the party board hops to. Set `board_path: /nestquest/board` (the
party-board view to return to), `weather_entity`,
`idle_return_seconds` (must be shorter than TouchHub Auto-Return),
`confirm_timeout_seconds`, and `complete_screen_seconds`. The quest
log reads the selected child's slug from the view's URL path, so the
board → log hop is a plain Lovelace navigation.
Each view's layout MUST be set to **Panel** (Edit Dashboard → pencil on the
view → View type: Panel). Home Assistant's default Masonry layout constrains
the 1080px-tall NestQuest card to a narrow column; Panel (single-card)
layout is what renders the card full-screen at 1920×1080.
The dashboard contains only the NestQuest panel card for each view —
no other cards, no admin controls.
The `quest_log_path` and `board_path` values must match the dashboard
slug and view paths above: `/<slug>/<view>`. If you chose `nestquest`
as the dashboard slug with view paths `board` and `log`, the card
configs are already correct. If you pick different names, substitute
your actual paths in **both** card configs — e.g. a dashboard slug
`chores` with view paths `main` and `history` needs
`quest_log_path: /chores/history` and `board_path: /chores/main`.
Navigation between the two views fails (the crest tap or back button
lands on a 404) if the card config paths do not match the dashboard
slug and view paths exactly.
See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML.
3. **Point TouchHub at it.** In the TouchHub launcher, add the **Home
Assistant Lovelace** app to the dock and paste the dashboard URL from
step 2 — the party-board view URL, e.g. `/nestquest/board` (TouchHub
loads that URL directly as the kiosk landing page). Set TouchHub
**Auto-Return** to a value GREATER than the quest log's
`idle_return_seconds` (default 40s) — recommend **60s or higher**. If
Auto-Return is at or below 40s, TouchHub returns to its launcher before
the quest log's idle return can navigate back to the party board.
4. **Log the kiosk session in.** Sign in on the device as the dedicated
kiosk user from step 1, and leave that session logged in.
5. **Log the kiosk session in.** Sign in on the device as the dedicated
kiosk user from step 3, and leave that session logged in.
TouchHub loads the dashboard URL directly. The NestQuest cards are normal
Lovelace cards, not an iframe, so no Home Assistant framing setting is
needed.
### The two constraints
- **Panel layout.** Every view must be in **Panel** (single-card)
layout: Home Assistant's default Masonry layout constrains the
1080px-tall NestQuest card to a narrow column, while Panel renders the
card full-screen at 1920×1080. The zero-config path already satisfies
the requirement — the strategy generates every view as
`type: panel` — while the manual alternative below requires setting
the layout by hand on each view (Edit Dashboard → pencil on the view
→ View type: Panel).
- **TouchHub Auto-Return.** TouchHub's **Auto-Return** must be greater
than the quest log's `idle_return_seconds` (default 40s) — recommend
**60s or higher** — so the launcher does not reclaim the wall device
before the quest log's own idle return navigates back to the party
board.
### Manual alternative: build the dashboard by hand
If you prefer not to use the zero-config dashboard above — or its
registration was skipped — configure a Lovelace dashboard whose **URL
slug is `nestquest`** (Settings → Dashboards → Edit → URL slug)
yourself. Two options, in decreasing order of convenience:
- **Use the strategy.** Give the dashboard
```yaml
strategy:
type: custom:nestquest-party
```
and no views of your own — the strategy generates the party-board and
quest-log views (each already in Panel layout) from the household
roster at render time. Optional `url_path` and `weather_entity` keys
may sit beside `strategy:`.
- **Build the two views by hand.** A functional panel needs **two**
views in that dashboard, each with its own card config from
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
paths (the path segment after the dashboard slug):
- **View 1 path `board`** (URL `/nestquest/board`) — party board
(`type: custom:nestquest-party-board-card`): the household quest
board. Set `weather_entity` (optional; the dock falls back to
date/time), `quest_log_path: /nestquest/log` (the view the crest tap
navigates to), and `child_order: [slug1, slug2, slug3]` listing each
child's slug in display order.
- **View 2 path `log`** (URL `/nestquest/log`) — quest log
(`type: custom:nestquest-quest-log-card`): the per-child quest screen
the party board hops to. Set `board_path: /nestquest/board` (the
party-board view to return to), `weather_entity`,
`idle_return_seconds` (must be shorter than TouchHub Auto-Return —
see the two constraints above), `confirm_timeout_seconds`, and
`complete_screen_seconds`. The quest log reads the selected child's
slug from the view's URL path, so the board → log hop is a plain
Lovelace navigation.
The dashboard contains only the NestQuest panel card for each view —
no other cards, no admin controls. Each view's layout MUST be set to
**Panel** — see the two constraints above for why; the strategy
generates it for you, hand-built views do not get it for free.
The `quest_log_path` and `board_path` values must match the dashboard
slug and view paths above: `/<slug>/<view>`. If you chose `nestquest`
as the dashboard slug with view paths `board` and `log`, the card
configs are already correct. If you pick different names, substitute
your actual paths in **both** card configs — e.g. a dashboard slug
`chores` with view paths `main` and `history` needs
`quest_log_path: /chores/history` and `board_path: /chores/main`.
Navigation between the two views fails (the crest tap or back button
lands on a 404) if the card config paths do not match the dashboard
slug and view paths exactly.
See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML.
## Developer Setup and Testing
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
+364 -21
View File
@@ -1,27 +1,370 @@
# NestQuest Release Notes
## Unreleased
## Version 0.8.0 — 2026-09-25
- **Storage layer extraction (task d0b691d8):** the SQLite storage layer
(schema, migrations, db, store, const, recurrence, DAOs) is bundled
under `custom_components/nestquest/core/`, with the integration's
original module names preserved as re-export shims.
- **Logger namespace change (breaking for logging configuration):** the
migrations runner now logs under
`custom_components.nestquest.core.migrations` instead of
`custom_components.nestquest.migrations`. Users who tuned the old
logger name (e.g. in `logger.yaml` or `configuration.yaml`) must
update the entry to keep that level, as the old name no longer
receives migration log output.
- **Additional logger namespace moves:** the children registry and the
admin allowlist business layers now log under
`custom_components.nestquest.core.children` and
`custom_components.nestquest.core.admin_allowlist` respectively
(moved with the engines/business-layer extraction, task 510c1f78).
The parent `custom_components.nestquest` logger still catches both,
so users who tune the parent rather than the leaf names are
unaffected; only users who pinned a leaf logger name by its old
spelling need to update.
### Scope
Minor release carrying the multi-pattern presence model (schema 9), the
Sunday-first calendar week, and the create-materializes fix, plus the
definition start-date field, onto `main`.
- **Multiple presence patterns per child (schema 9):** a child now has zero or
more presence patterns instead of at most one. Each pattern has a name, a kind
(`home` or `away`), its own cycle length (1–4 weeks), its own anchor date and a
per-week weekday set. Evaluation is first-match-wins — overrides, then away,
then home, then absent when the child has at least one home pattern, else
present. Migration 9 copies each existing schedule into a `home` pattern named
"Home schedule" and drops the old `presence_schedules` table. (PRs #231, #232.)
- **Sunday-first calendar week:** the admin Schedule month grid and the
Definitions weekday picker now render weeks Sunday-first; the stored/API
weekday numbering is unchanged (Monday=0..Sunday=6). (PR #230.)
- **Fix — newly created tasks materialize immediately:** creating a quest
definition now regenerates its future instances over the rolling horizon, so a
new task reaches the panel without waiting for an unrelated write. (PRs #235,
#236.)
- **Definition start date:** the definition edit sheet gains a Start date field
so a task can be scheduled to begin on a future date. (PRs #237, #238.)
- **Docs:** the multi-pattern decision (D-016), the migration-9 rollback step in
the runbook, and the ADMIN-SPEC Schedule section. (PRs #233, #234.)
### Upgrade note
This release runs schema version **9**. Version 0.7.1 refuses a version-9
database, so take a copy of the SQLite file (or note the Litestream generation)
before the first start on 0.8.0 if you need a rollback path.
### Requirements
## Version 0.7.1 — 2026-09-24
### Scope
Patch release bringing the admin PWA follow-ups and its hosting deployment from
`dev` onto 0.7.0.
- **Feature 24 — Admin PWA Hosting:** the admin PWA's static hosting is now
reproducible from the repository — `deploy/pwa/Dockerfile` (multi-stage, pinned
`node`/`nginx` base images by digest, `VITE_AUTHENTIK_CLIENT_ID` /
`VITE_AUTHENTIK_ISSUER` / `VITE_API_BASE_URL` build args), `deploy/pwa/nginx.conf`
(SPA fallback plus a source allow-list for the reverse proxy and the host), a
`pwa` service in `deploy/compose.yaml` published on `:8081`, and a
`deploy/RUNBOOK.md` section describing the build/serve and the Traefik
`nestquest-app` router. (PRs #220, #221.)
- **Feature 19 follow-up — Manage children:** the Today header's Settings button
now opens a Settings screen. Its **Children** section lists the household's
children and lets a parent add (POST `/api/v1/admin/children`), edit
(PATCH `.../{id}`), activate/deactivate (PATCH `.../{id}/active`) and reorder
them (POST `.../reorder`). (PRs #222, #223.)
- **Feature 19 follow-up — Settings preferences:** the same screen has a
**Preferences** section for the planning horizon, day rollover time, notify
target, the morning/afternoon/end-of-day notification times and their toggles,
and celebration, read from `GET /api/v1/admin/settings` and saved with a partial
`PATCH` of only the changed fields. (PRs #224, #225, #226.)
### Requirements
- Minimum Home Assistant version: **2024.6.0**.
- A running NestQuest API service as in 0.7.0 (the Feature 17 deployment); the
`admin/` PWA is served from the owner's deployment at
`https://nestquest.cubecraftlabs.com`, not by HACS.
### Breaking changes
- **None.** The schema is unchanged at version 8 and no migration runs.
`custom_components/` is unchanged from 0.7.0 apart from the manifest
release-version metadata — no Home Assistant entity, service, event or
automation surface changes. This release adds admin PWA screens and deployment
artifacts only.
### Known Limitations
- **HACS does not ship the admin PWA or the deployment artifacts.** HACS copies
only `custom_components/nestquest`, so upgrading via HACS changes nothing
functional over 0.7.0; the PWA screens and hosting are deployed from `admin/`
and `deploy/` per the runbook.
- Serving the PWA and API still requires the Feature 17 deployment (host, reverse
proxy, Authentik, backup target) as documented in `deploy/RUNBOOK.md`.
- **Feature 12's TouchHub/DAKOS runbook task remains physically unverified**
(needs the wall panel).
- The Settings UI does not expose resetting a field to its default with `null`.
### Rollback
Schema is unchanged (8), so no migration is involved in either direction.
`custom_components/` is unchanged from 0.7.0 apart from the release-version
metadata; rolling back the integration is a plain swap, and the PWA/hosting can be
redeployed from the 0.7.0 tree.
**HACS installs:** HACS > Integrations > NestQuest > Redownload > 0.7.0, then
restart Home Assistant.
**Manual installs:** replace `custom_components/nestquest/` with the 0.7.0 tree,
then restart Home Assistant.
**Repository operators:** git-revert the 0.7.1 release merge on `main`.
## 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
Sixth release of NestQuest, promoting the re-imagined architecture
(Features 15, 16, 18 and 20) and the foreground development-orchestration
tooling from `dev` onto 0.5.0. This is a major architectural change: the
Home Assistant integration becomes a thin client of a standalone NestQuest
API service that owns the database.
- **Feature 15 — Domain Core Extraction:** all domain code (schema,
migrations, database access, the DAOs, the pure recurrence/presence
engines, materialization, completion, the SSE-free event payload
builders, the panel snapshot builder, the children/definitions/allowlist
business layers, and an explicit settings object) now lives in a
Home-Assistant-free package bundled inside the integration at
`custom_components/nestquest/core/`. The SQLite schema and migration
versions are unchanged, so an existing `nestquest.db` opens without
migration. Covered by a no-Home-Assistant end-to-end smoke test.
(Tasks d0b691d8, 510c1f78, 4dd8f870, 628473ad.)
- **Feature 16 — NestQuest API Service:** a standalone FastAPI service
(`api/`) that imports `core/` and is the only writer of the database.
Panel plane (`/api/v1/panel/*`) is authenticated by a static service
token: household snapshot and an idempotent complete route. Admin plane
(`/api/v1/admin/*`): children, quest definitions, presence
schedule/override, uncomplete, regenerate, history query + CSV export,
and settings — authenticated by an Authentik OIDC JWT (issuer, audience,
JWKS signature and expiry) plus membership in `nestquest-admins`, with
the panel service token rejected outright. A server-sent-events stream
publishes the quest-completed, quest-uncompleted, quest-missed and
child-day-complete transitions; the missed sweep runs in the API with a
cross-process-safe watermark. `/health` and the OpenAPI document are
served. (Tasks 042dfc5c, ae7e90ae, 1860ef76, 4d7bae00, 44c93cfd,
e70e6bfb, 0f2afb29, e4845baa, b586a2f1, e4a9d31b, da0226b3, 2d2dda53,
2b3de7e5, 9047a97b, 9a118da9, 058c7b69, a232c4f5.)
- **Feature 18 — HA Integration as API Client:** the integration no longer
owns a database. It polls `GET /api/v1/panel/snapshot` and builds the
existing sensors (entity ids and per-child instance payload shape
unchanged), subscribes to the SSE stream and re-fires the four HA bus
events, proxies `nestquest.complete_quest` to the panel complete route,
and caches the last-good snapshot so a brief API outage does not blank
the panel. The HA admin services and the service permission gate are
removed; only the panel completion path remains. (Tasks 37d5e806,
6e70d166, b1ed9c45, 47c7c90e, d25b875f, d04f5bf3, ad67fc44.)
- **Feature 20 — Zero-Config Panel Dashboard:** the panel installs with no
hand-written card YAML and no manual dashboard creation. The cards
auto-discover children from a household entity attribute (ordered by the
API's `sort_order`), a Lovelace dashboard strategy generates the
party-board view and one quest-log view per child at render time with
computed navigation paths, and the integration registers the dashboard
on first setup. Navigation and the panel transitions match the design
mockups. (Tasks 1b6d91d2, c859af23, c2d4dd8e, 107b8fbe, 50067c1f,
fc69c940, 67775be9.)
- **Development orchestration tooling:** durable OpenCode controller and
role launchers, the foreground terminal orchestrator, the independent
review/approval gates, and the automatic release-preparation policy.
### 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 API service
source ships in `api/`; there is no container image, compose file or
deployment runbook in this release, and Feature 17 — the deployment
(TLS reverse proxy, Authentik, network separation, backups) — is **not**
part of this release and must be completed before the integration is
functional. See `docs/ARCHITECTURE.md` for the target design.
### Breaking changes
- **The HA integration no longer owns a database.** It reads exclusively
from the NestQuest API. The local `nestquest.db` is no longer opened,
migrated or written by the integration (the file is left untouched on
disk). Without a reachable API the entities become unavailable after the
configured staleness threshold; the panel serves the last-good snapshot
in the meantime.
- **HA admin services are removed** (`uncomplete`, quest-definition
create/update/set-active, presence pattern/override, CSV export,
`manage_child`, `regenerate`) and the HA-side permission gate is gone.
Admin operations move to the API, and — once Feature 19 lands — the
admin PWA. Any automation that called an admin service must be
repointed at the API.
- **Logger namespaces moved** under `custom_components.nestquest.core.*`:
the migrations runner now logs under
`custom_components.nestquest.core.migrations` (previously
`custom_components.nestquest.migrations`), and the children registry and
admin allowlist log under `custom_components.nestquest.core.children` and
`custom_components.nestquest.core.admin_allowlist`. The parent
`custom_components.nestquest` logger still catches all three; only users
who pinned a leaf logger name by its old spelling must update.
- **The panel installs with no configuration.** `child_order`,
`quest_log_path` and `board_path` are no longer required (an explicit
value still wins). The integration registers a `custom:nestquest-party`
dashboard on setup; point TouchHub at it.
- **Schema is unchanged at version 7** — no migration runs on upgrade.
### Known Limitations
- **Feature 17 (API deployment and identity) is not in this release.** The
API service source ships in `api/`, but no container image, compose
file, TLS reverse proxy, Authentik application/provider/`nestquest-admins`
group, proven network separation of the panel routes, or backup/restore
is included. Feature 17's containerization groundwork was completed on
its own feature branch, which is **not** merged into `dev` and therefore
is not part of this release. Until that deployment is completed the
integration has nothing to talk to.
- **Feature 19 (admin PWA) is not in this release.** There is no admin UI
in 0.6.0; admin operations are reachable only through the API directly.
- Moving the household database from Home Assistant to the API box is a
manual file copy (the schema is unchanged).
- Single household; single API service.
### Rollback
Schema version is unchanged (7), so no migration is involved in either
direction. Nevertheless **back up `nestquest.db` (plus `-wal`/`-shm`)
before upgrading**: 0.6.0 never touches it, and a downgrade to 0.5.0
resumes using exactly that file.
**HACS installs:**
1. Downgrade via HACS (HACS > Integrations > NestQuest > Redownload > 0.5.0).
2. Restart Home Assistant.
**Manual installs:**
1. Replace `custom_components/nestquest/` with the 0.5.0 tree.
2. Restart Home Assistant.
**Repository operators:** git-revert the 0.6.0 release merge on `main`.
## Version 0.5.0 — 2026-09-18
+31
View File
@@ -0,0 +1,31 @@
# Third-party notices
NestQuest bundles glyph data from the icon sets below. The curated names are listed in
`tools/icons/curated-icons.json`; `tools/icons/generate.mjs` reads each glyph's SVG path
data from the locally installed packages and generates it into both bundles
(`admin/src/icons/registry.generated.ts` and `frontend/src/icons/registry.generated.ts`).
Nothing is loaded from a CDN or fetched at runtime. The package versions used are recorded
in the header of each generated file.
## Font Awesome Free
- Project: <https://fontawesome.com> · repository: <https://github.com/FortAwesome/Font-Awesome>
- Package: `@fortawesome/free-solid-svg-icons`
- Licence: <https://fontawesome.com/license/free>
- Icons: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
- Fonts: [SIL OFL 1.1](https://openfontlicense.org)
- Code: [MIT](https://opensource.org/licenses/MIT)
- Use in NestQuest: a curated subset of the Free **solid** icon set, embedded as SVG path
data. No fonts are bundled.
Font Awesome Free by @fontawesome — https://fontawesome.com — icons licensed under CC BY 4.0.
## Lucide
- Project: <https://lucide.dev> · repository: <https://github.com/lucide-icons/lucide>
- Package: `lucide-react`
- Licence: [ISC](https://github.com/lucide-icons/lucide/blob/main/LICENSE)
- Use in NestQuest: a curated subset of Lucide icons, embedded as SVG path data.
Lucide icons are Copyright (c) Lucide Contributors, licensed under the ISC License.
Portions derived from Feather are Copyright (c) Cole Bemis, licensed under the MIT License.
+13
View File
@@ -0,0 +1,13 @@
# NestQuest Admin PWA — runtime configuration (Vite env).
# Copy to .env (or .env.local) and fill in real values. NEVER commit .env.
# Authentik OIDC provider. Defaults to the issuer below when unset.
VITE_AUTHENTIK_ISSUER=https://auth.cubecraftlabs.com/application/o/nestquest/
# REQUIRED. OAuth2/OIDC client id of the PUBLIC (PKCE) Authentik application.
# No default: the app shows a configuration error screen when this is missing.
# Never set a client_secret — this is a browser SPA using PKCE only.
VITE_AUTHENTIK_CLIENT_ID=
# NestQuest API base URL.
VITE_API_BASE_URL=https://nestquest.cubecraftlabs.com
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#3b3ab8" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>NestQuest Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3059
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "nestquest-admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@fortawesome/free-solid-svg-icons": "7.3.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"jsdom": "^30.1.1",
"lucide-react": "1.47.0",
"typescript": "^5.9.2",
"vite": "^7.1.5",
"vitest": "^3.2.4"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

+14
View File
@@ -0,0 +1,14 @@
{
"name": "NestQuest Admin",
"short_name": "NestQuest",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#f4f4f6",
"theme_color": "#3b3ab8",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+88
View File
@@ -0,0 +1,88 @@
/* NestQuest Admin service worker — hand-written, served from the site root.
Precaches the app shell, serves static assets cache-first, and falls back
to the cached shell for navigations when offline. Only same-origin GET
requests are handled; cross-origin requests (Authentik, the API host) are
never intercepted or cached.
The build (vite-sw-plugin.ts) replaces PRECACHE_ASSETS with the hashed
bundles referenced by index.html and the revision marker with a content
hash, so each release installs a new worker with fresh caches. */
const REVISION = "__NQ_BUILD_REVISION__";
const CACHE_PREFIX = "nestquest-admin-";
const SHELL_CACHE = `${CACHE_PREFIX}shell-${REVISION}`;
const RUNTIME_CACHE = `${CACHE_PREFIX}runtime-${REVISION}`;
const CURRENT_CACHES = [SHELL_CACHE, RUNTIME_CACHE];
const PRECACHE_ASSETS = [];
const SHELL_URLS = [
...new Set([
"/",
"/index.html",
"/manifest.webmanifest",
"/icons/icon-192.png",
"/icons/icon-512.png",
"/icons/icon-maskable-512.png",
...PRECACHE_ASSETS,
]),
];
// Request destinations treated as static assets (cache-first). Anything else
// same-origin (e.g. API calls) goes straight to the network.
const STATIC_DESTINATIONS = ["script", "style", "image", "font", "manifest"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(SHELL_CACHE)
.then((cache) => cache.addAll(SHELL_URLS))
.then(() => self.skipWaiting()),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => key.startsWith(CACHE_PREFIX) && !CURRENT_CACHES.includes(key))
.map((key) => caches.delete(key)),
),
)
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return;
if (new URL(request.url).origin !== self.location.origin) return;
if (request.mode === "navigate") {
event.respondWith(
fetch(request).catch(() =>
caches.match("/index.html", { cacheName: SHELL_CACHE }).then(
(cached) => cached || Response.error(),
),
),
);
return;
}
if (STATIC_DESTINATIONS.includes(request.destination)) {
event.respondWith(cacheFirst(request));
}
});
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok && response.type === "basic") {
const cache = await caches.open(RUNTIME_CACHE);
await cache.put(request, response.clone());
}
return response;
}
+80
View File
@@ -0,0 +1,80 @@
// Generates the committed PWA icons under public/icons/ as genuine PNGs.
// Pure Node (zlib only) — no network, no image dependencies.
// Run: node scripts/generate-icons.mjs
import { writeFileSync } from "node:fs";
import { deflateSync } from "node:zlib";
import { fileURLToPath } from "node:url";
// Brand tokens (design/tokens/nestquest-admin-tokens.css).
const BLUE = [0x3b, 0x3a, 0xb8];
const PURPLE = [0xa0, 0x35, 0xcc];
const WHITE = [0xff, 0xff, 0xff];
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(buf) {
let c = 0xffffffff;
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([len, body, crc]);
}
function encodePng(size, pixel) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // colour type: RGB
const raw = Buffer.alloc(size * (size * 3 + 1));
for (let y = 0; y < size; y++) {
const row = y * (size * 3 + 1);
raw[row] = 0; // filter: none
for (let x = 0; x < size; x++) raw.set(pixel(x, y), row + 1 + x * 3);
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw, { level: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
// Flat brand mark: diagonal blue→purple gradient with a white ring ("nest")
// around a white dot ("quest"). `scale` shrinks the mark for the maskable
// safe zone (inner 80% circle).
function brandMark(size, scale) {
const c = size / 2;
const outer = size * 0.34 * scale;
const inner = size * 0.24 * scale;
const dot = size * 0.1 * scale;
return (x, y) => {
const d = Math.hypot(x + 0.5 - c, y + 0.5 - c);
if ((d <= outer && d >= inner) || d <= dot) return WHITE;
const t = (x + y) / (2 * (size - 1));
return BLUE.map((b, i) => Math.round(b + (PURPLE[i] - b) * t));
};
}
const outDir = new URL("../public/icons/", import.meta.url);
const icons = [
["icon-192.png", 192, 1],
["icon-512.png", 512, 1],
["icon-maskable-512.png", 512, 0.75],
];
for (const [name, size, scale] of icons) {
const path = fileURLToPath(new URL(name, outDir));
writeFileSync(path, encodePng(size, brandMark(size, scale)));
console.log(`wrote ${path}`);
}
+123
View File
@@ -0,0 +1,123 @@
/* Shell layout — mobile-first (390x844 reference), plain CubeCraft styling. */
.admin-shell {
display: flex;
flex-direction: column;
min-height: 100%;
}
.admin-header {
background: var(--nq-a-surface);
border-bottom: 1px solid var(--nq-a-border);
padding: 14px 16px;
}
.admin-title {
margin: 0;
font-size: 24px;
font-weight: 800;
letter-spacing: -0.02em;
}
.admin-subline {
margin: 2px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.admin-panel {
flex: 1;
padding: 14px 16px 92px; /* bottom padding clears the 70px tab bar */
}
/* A built screen supplies its own header and 92px-padded scroll column. */
.admin-panel--screen {
padding: 0;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.admin-tabbar {
position: fixed;
inset: auto 0 0;
display: flex;
min-height: calc(var(--nq-a-tabbar-height) + 12px);
padding: 8px 4px calc(12px + env(safe-area-inset-bottom));
background: var(--nq-a-surface);
border-top: 1px solid var(--nq-a-border);
}
.admin-tab {
flex: 1;
border: 0;
background: none;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
cursor: pointer;
padding: 6px 4px;
}
.admin-tab.active {
font-weight: 700;
color: var(--nq-brand-blue);
}
/* Auth screens (login, errors, refusal). */
.admin-login,
.admin-error,
.admin-refusal,
.admin-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 100%;
padding: 32px 16px;
text-align: center;
}
.admin-login-button {
margin-top: 8px;
padding: 12px 24px;
border: none;
border-radius: 8px;
background: var(--nq-a-accent, #2563eb);
color: #fff;
font-size: 15px;
font-weight: 700;
cursor: pointer;
}
.admin-error,
.admin-refusal {
color: var(--nq-a-ink, inherit);
}
.admin-refusal h1,
.admin-error h1 {
margin: 0;
font-size: 20px;
}
.admin-refusal p,
.admin-error p {
margin: 0;
max-width: 34em;
font-size: 14px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
+47
View File
@@ -0,0 +1,47 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import App from "./App";
describe("NestQuest Admin shell", () => {
beforeEach(() => {
// The Today tab fetches the admin snapshot; keep the shell tests offline.
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("renders the title and all four tab labels", () => {
render(<App />);
expect(screen.getByRole("heading", { name: "NestQuest Admin" })).toBeTruthy();
for (const label of ["Today", "Tasks", "Schedule", "History"]) {
expect(screen.getByRole("tab", { name: label })).toBeTruthy();
}
});
it("starts on the Today screen and switches on tab click", () => {
render(<App />);
expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy();
fireEvent.click(screen.getByRole("tab", { name: "History" }));
expect(screen.getByRole("heading", { name: "History" })).toBeTruthy();
expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder");
expect(screen.getByRole("tab", { name: "History" }).getAttribute("aria-selected")).toBe(
"true",
);
});
it("the Tasks tab renders the definitions screen", () => {
render(<App />);
fireEvent.click(screen.getByRole("tab", { name: "Tasks" }));
expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy();
expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder");
});
});
+63
View File
@@ -0,0 +1,63 @@
import { useState } from "react";
import "./App.css";
import { TransitionsProvider } from "./api/useTransitions";
import DefinitionsTab from "./definitions/DefinitionsTab";
import HistoryTab from "./history/HistoryTab";
import ScheduleTab from "./schedule/ScheduleTab";
import TodayTab from "./today/TodayTab";
const TABS = [
{ id: "today", label: "Today" },
{ id: "tasks", label: "Tasks" },
{ id: "schedule", label: "Schedule" },
{ id: "history", label: "History" },
] as const;
type TabId = (typeof TABS)[number]["id"];
export default function App() {
const [active, setActive] = useState<TabId>("today");
return (
<TransitionsProvider>
<div className="admin-shell">
{/* Every tab is a built screen that owns its header (ADMIN-SPEC §1); the
generic shell header stays only as a visually hidden page heading. */}
<header className="admin-header visually-hidden">
<h1 className="admin-title">NestQuest Admin</h1>
<p className="admin-subline">Admin console</p>
</header>
<main
className="admin-panel admin-panel--screen"
role="tabpanel"
aria-labelledby={`tab-${active}`}
>
{active === "today" ? (
<TodayTab />
) : active === "tasks" ? (
<DefinitionsTab />
) : active === "schedule" ? (
<ScheduleTab />
) : (
<HistoryTab />
)}
</main>
<nav className="admin-tabbar" aria-label="Admin sections">
{TABS.map((tab) => (
<button
key={tab.id}
id={`tab-${tab.id}`}
type="button"
role="tab"
aria-selected={tab.id === active}
className={tab.id === active ? "admin-tab active" : "admin-tab"}
onClick={() => setActive(tab.id)}
>
{tab.label}
</button>
))}
</nav>
</div>
</TransitionsProvider>
);
}
+193
View File
@@ -0,0 +1,193 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import AppRoutes from "./AppRoutes";
import { storeTokens } from "./auth/oidc";
import { config } from "./config";
// The bundled config reads real env at import time; seed the shared module's
// clientId directly so AppRoutes sees a configured app in these tests.
(config as { clientId: string | undefined }).clientId = "test-client-id";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("AppRoutes auth gating", () => {
afterEach(() => {
cleanup();
sessionStorage.clear();
vi.restoreAllMocks();
vi.unstubAllGlobals();
window.history.replaceState({}, "", "/");
});
it("renders the login screen when unauthenticated at /", () => {
render(<AppRoutes />);
expect(screen.getByTestId("login-screen")).toBeTruthy();
expect(
screen.getByRole("button", { name: /sign in with authentik/i }),
).toBeTruthy();
});
function probeCalls(fetchMock: ReturnType<typeof vi.fn>): string[] {
return fetchMock.mock.calls
.map(([url]) => String(url))
.filter((url) => url.includes("/api/"));
}
it("probes exactly GET /api/v1/admin/ping and no non-admin path", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 500 }));
vi.stubGlobal("fetch", fetchMock);
render(<AppRoutes />);
await waitFor(() => {
expect(screen.getByTestId("probe-error-screen")).toBeTruthy();
});
const calls = probeCalls(fetchMock);
expect(calls).toEqual([`${config.apiBaseUrl}/api/v1/admin/ping`]);
const [, init] = fetchMock.mock.calls[0];
expect(init.method ?? "GET").toBe("GET");
expect(init.headers.Authorization).toBe("Bearer at-123");
for (const url of calls) {
expect(new URL(url, "http://localhost").pathname.startsWith("/api/v1/admin/")).toBe(true);
}
});
it("renders the four-tab shell after a successful authenticated probe", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
vi.stubGlobal("fetch", fetchMock);
render(<AppRoutes />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "NestQuest Admin" })).toBeTruthy();
});
for (const label of ["Today", "Tasks", "Schedule", "History"]) {
expect(screen.getByRole("tab", { name: label })).toBeTruthy();
}
expect(String(fetchMock.mock.calls[0][0])).toBe(
`${config.apiBaseUrl}/api/v1/admin/ping`,
);
});
it("renders the explicit refusal when the API answers 403", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 }));
vi.stubGlobal("fetch", fetchMock);
render(<AppRoutes />);
await waitFor(() => {
expect(screen.getByTestId("refusal-screen")).toBeTruthy();
});
expect(screen.getByText(/not in the nestquest-admins group/i)).toBeTruthy();
expect(screen.queryByRole("tab", { name: "Today" })).toBeNull();
});
it("keeps the login flow when the probe answers 401", async () => {
storeTokens({ access_token: "stale", expires_in: 600 });
const assign = vi.fn();
vi.stubGlobal("location", { ...window.location, assign });
const fetchMock = vi.fn().mockImplementation(async (url: string) =>
String(url).includes("/api/v1/admin/ping")
? new Response("", { status: 401 })
: jsonResponse({
issuer: config.issuer,
authorization_endpoint: "https://auth.example.com/authorize/",
token_endpoint: "https://auth.example.com/token/",
userinfo_endpoint: "https://auth.example.com/userinfo/",
end_session_endpoint: "https://auth.example.com/end-session/",
jwks_uri: "https://auth.example.com/jwks/",
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<AppRoutes />);
await waitFor(() => {
expect(screen.getByTestId("login-screen")).toBeTruthy();
});
expect(assign).toHaveBeenCalledTimes(1);
const redirect = new URL(assign.mock.calls[0][0] as string);
expect(redirect.origin + redirect.pathname).toBe("https://auth.example.com/authorize/");
expect(probeCalls(fetchMock)).toEqual([`${config.apiBaseUrl}/api/v1/admin/ping`]);
expect(screen.queryByRole("tab", { name: "Today" })).toBeNull();
expect(screen.queryByTestId("probe-error-screen")).toBeNull();
});
for (const status of [404, 500]) {
it(`shows the error screen, not the shell, when the probe answers ${status}`, async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status }));
vi.stubGlobal("fetch", fetchMock);
render(<AppRoutes />);
await waitFor(() => {
expect(screen.getByTestId("probe-error-screen")).toBeTruthy();
});
expect(screen.getByText(new RegExp(`answered ${status}`))).toBeTruthy();
expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Sign out" })).toBeTruthy();
expect(screen.queryByRole("tab", { name: "Today" })).toBeNull();
expect(screen.queryByTestId("refusal-screen")).toBeNull();
});
}
it("shows the error screen, not the shell, when the probe request fails", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockRejectedValue(new TypeError("Failed to fetch"));
vi.stubGlobal("fetch", fetchMock);
render(<AppRoutes />);
await waitFor(() => {
expect(screen.getByTestId("probe-error-screen")).toBeTruthy();
});
expect(screen.getByText(/could not be reached/i)).toBeTruthy();
expect(screen.queryByRole("tab", { name: "Today" })).toBeNull();
});
it("re-probes on retry and enters the shell once the probe succeeds", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
// After the retry the ping succeeds; the shell's own tab loads stay pending.
let pings = 0;
const fetchMock = vi.fn().mockImplementation((url: string) => {
if (!String(url).endsWith("/api/v1/admin/ping")) return new Promise(() => {});
pings += 1;
return Promise.resolve(new Response("", { status: pings === 1 ? 500 : 200 }));
});
vi.stubGlobal("fetch", fetchMock);
render(<AppRoutes />);
await waitFor(() => {
expect(screen.getByTestId("probe-error-screen")).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(screen.getByRole("tab", { name: "Today" })).toBeTruthy();
});
expect(String(fetchMock.mock.calls[1][0])).toBe(
`${config.apiBaseUrl}/api/v1/admin/ping`,
);
});
it("shows the configuration error screen when the client id is missing", () => {
const original = config.clientId;
(config as { clientId: string | undefined }).clientId = undefined;
try {
render(<AppRoutes />);
expect(screen.getByTestId("config-error-screen")).toBeTruthy();
} finally {
(config as { clientId: string | undefined }).clientId = original;
}
});
});
+211
View File
@@ -0,0 +1,211 @@
/**
* Minimal path-based routing without react-router-dom: the app only needs two
* paths (`/` shell and `/auth/callback`), so a tiny window.location switch is
* enough and keeps the bundle small. Change screens listen to popstate.
*/
import { useEffect, useState } from "react";
import { ConfigError, config } from "./config";
import {
AuthError,
buildAuthorizeUrl,
clearAuth,
handleCallback,
isAuthenticated,
} from "./auth/oidc";
import { ApiForbiddenError, ApiUnauthorizedError, apiFetch } from "./api/client";
import AdminShell from "./App";
type Screen =
| { kind: "loading" }
| { kind: "login" }
| { kind: "config-error"; message: string }
| { kind: "auth-error"; message: string }
| { kind: "refusal"; message: string }
| { kind: "probe-error"; message: string }
| { kind: "app" };
function currentPath(): string {
return window.location.pathname.replace(/\/+$/, "") || "/";
}
const ADMIN_PROBE_PATH = "/api/v1/admin/ping";
async function probeApi(setScreen: (screen: Screen) => void) {
// The admin plane's authorization probe: only a 2xx enters the shell, so a
// non-admin (403) sees the explicit refusal and any other failure an error.
try {
const response = await apiFetch(ADMIN_PROBE_PATH, {}, config);
if (response.ok) {
setScreen({ kind: "app" });
return;
}
setScreen({
kind: "probe-error",
message: `The admin API answered ${response.status}. The admin console cannot load.`,
});
} catch (error) {
if (error instanceof ConfigError) {
setScreen({ kind: "config-error", message: error.message });
return;
}
if (error instanceof ApiForbiddenError) {
setScreen({ kind: "refusal", message: error.message });
return;
}
if (error instanceof ApiUnauthorizedError) {
// apiFetch has already started the login redirect.
setScreen({ kind: "login" });
return;
}
setScreen({
kind: "probe-error",
message: "The admin API could not be reached. Check your connection and try again.",
});
}
}
function LoginScreen({ onLogin }: { onLogin: () => void }) {
return (
<div className="admin-login" data-testid="login-screen">
<h1 className="admin-title">NestQuest Admin</h1>
<p className="admin-subline">Sign in to continue</p>
<button type="button" className="admin-login-button" onClick={onLogin}>
Sign in with Authentik
</button>
</div>
);
}
export default function AppRoutes() {
const [screen, setScreen] = useState<Screen>({ kind: "loading" });
useEffect(() => {
// Re-evaluate on navigation (popstate) — routing is path based.
const onPop = () => setScreen({ kind: "loading" });
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
useEffect(() => {
let cancelled = false;
const path = currentPath();
if (!config.clientId) {
setScreen({
kind: "config-error",
message:
"Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
});
return;
}
if (path === "/auth/callback") {
(async () => {
try {
await handleCallback(window.location.search, config);
if (cancelled) return;
window.history.replaceState({}, "", "/");
await probeApi((next) => !cancelled && setScreen(next));
} catch (error) {
if (cancelled) return;
const message =
error instanceof AuthError || error instanceof Error
? error.message
: "Authentication failed.";
setScreen({ kind: "auth-error", message });
}
})();
return () => {
cancelled = true;
};
}
if (isAuthenticated()) {
void probeApi((next) => !cancelled && setScreen(next));
} else {
setScreen({ kind: "login" });
}
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const startLogin = async () => {
try {
const url = await buildAuthorizeUrl(config);
window.location.assign(url);
} catch (error) {
setScreen({
kind: "config-error",
message: error instanceof Error ? error.message : "Login failed.",
});
}
};
switch (screen.kind) {
case "loading":
return (
<div className="admin-loading" role="status" data-testid="loading-screen">
Loading…
</div>
);
case "login":
return <LoginScreen onLogin={startLogin} />;
case "config-error":
return (
<div className="admin-error" role="alert" data-testid="config-error-screen">
<h1>Configuration error</h1>
<p>{screen.message}</p>
</div>
);
case "auth-error":
return (
<div className="admin-error" role="alert" data-testid="auth-error-screen">
<h1>Sign-in failed</h1>
<p>{screen.message}</p>
<button
type="button"
onClick={() => {
clearAuth();
window.history.replaceState({}, "", "/");
setScreen({ kind: "loading" });
}}
>
Try again
</button>
</div>
);
case "refusal":
return (
<div className="admin-refusal" role="alert" data-testid="refusal-screen">
<h1>Access refused</h1>
<p>{screen.message}</p>
<button type="button" onClick={() => { clearAuth(); window.history.replaceState({}, "", "/"); setScreen({ kind: "login" }); }}>
Sign out
</button>
</div>
);
case "probe-error":
return (
<div className="admin-error" role="alert" data-testid="probe-error-screen">
<h1>Admin console unavailable</h1>
<p>{screen.message}</p>
<button
type="button"
onClick={() => {
setScreen({ kind: "loading" });
void probeApi(setScreen);
}}
>
Retry
</button>
<button type="button" onClick={() => { clearAuth(); window.history.replaceState({}, "", "/"); setScreen({ kind: "login" }); }}>
Sign out
</button>
</div>
);
case "app":
return <AdminShell />;
}
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Typed client for the admin child-profile write routes
* (/api/v1/admin/children). The list read is `fetchChildren` in ./definitions.
*/
import { apiFetch } from "./client";
import { CHILDREN_PATH, readJson, type AdminChild } from "./definitions";
export interface ChildCreateBody {
display_name: string;
colour?: string;
avatar_ref?: string;
sort_order?: number;
}
/**
* Omitted fields stay unchanged. The API rejects an explicit null, so a set
* colour or avatar cannot be cleared through this route.
*/
export type ChildEditBody = Partial<ChildCreateBody>;
export const CHILDREN_REORDER_PATH = `${CHILDREN_PATH}/reorder`;
function sendJson(path: string, method: "POST" | "PATCH", body: unknown): Promise<Response> {
return apiFetch(path, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
export async function createChild(body: ChildCreateBody): Promise<AdminChild> {
return readJson<AdminChild>(await sendJson(CHILDREN_PATH, "POST", body));
}
export async function updateChild(id: number, body: ChildEditBody): Promise<AdminChild> {
return readJson<AdminChild>(await sendJson(`${CHILDREN_PATH}/${id}`, "PATCH", body));
}
export async function setChildActive(id: number, isActive: boolean): Promise<AdminChild> {
return readJson<AdminChild>(
await sendJson(`${CHILDREN_PATH}/${id}/active`, "PATCH", { is_active: isActive }),
);
}
/** `orderedIds` must be every child id in the household, in the new order. */
export async function reorderChildren(orderedIds: number[]): Promise<void> {
await readJson<{ status: string }>(
await sendJson(CHILDREN_REORDER_PATH, "POST", { ordered_ids: orderedIds }),
);
}
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ApiForbiddenError,
ApiUnauthorizedError,
apiFetch,
} from "./client";
import { storeTokens } from "../auth/oidc";
import type { AppConfig } from "../config";
const app: AppConfig = {
issuer: "https://auth.example.com/application/o/nestquest",
clientId: "test-client-id",
apiBaseUrl: "https://api.example.com",
authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
tokenEndpoint: "https://auth.example.com/application/o/token/",
userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
redirectUri: "http://localhost:5173/auth/callback",
};
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("API client", () => {
beforeEach(() => {
sessionStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("attaches the Bearer token to API calls", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
await apiFetch("/api/v1/children", {}, app, fetchMock as any);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://api.example.com/api/v1/children");
expect(init.headers.Authorization).toBe("Bearer at-123");
});
it("throws ApiForbiddenError on 403 (authenticated but refused)", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 }));
await expect(
apiFetch("/api/v1/children", {}, app, fetchMock as any),
).rejects.toBeInstanceOf(ApiForbiddenError);
});
it("triggers a login redirect on 401 with no refresh token", async () => {
storeTokens({ access_token: "stale", expires_in: 600 });
const assign = vi.fn();
vi.stubGlobal("location", { ...window.location, assign });
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 401 }));
await expect(
apiFetch("/api/v1/children", {}, app, fetchMock as any),
).rejects.toBeInstanceOf(ApiUnauthorizedError);
expect(assign).toHaveBeenCalledTimes(1);
const url = new URL(assign.mock.calls[0][0] as string);
expect(url.origin + url.pathname).toBe(
"https://auth.example.com/application/o/authorize/",
);
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
});
it("preserves non-401/403 statuses on the post-refresh retry (500 is not mislabeled)", async () => {
storeTokens({ access_token: "stale", refresh_token: "rt-1", expires_in: 600 });
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response("", { status: 401 })) // initial API call
.mockResolvedValueOnce(
jsonResponse({ access_token: "fresh", expires_in: 600 }), // refresh grant
)
.mockResolvedValueOnce(new Response("", { status: 500 })); // retry
const response = await apiFetch("/api/v1/children", {}, app, fetchMock as any);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock.mock.calls[1][0]).toBe(app.tokenEndpoint);
expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe("Bearer fresh");
// A server failure must surface as the real status, not ApiUnauthorizedError.
expect(response.status).toBe(500);
});
});
+93
View File
@@ -0,0 +1,93 @@
/**
* Minimal fetch wrapper for the NestQuest API.
*
* - Attaches `Authorization: Bearer <access_token>` when a token is stored.
* - 401: not authenticated (or token expired) -> trigger a new login.
* - 403: authenticated but refused -> throws ApiForbiddenError so the UI can
* render an explicit refusal message.
*/
import { config, type AppConfig } from "../config";
import {
buildAuthorizeUrl,
getToken,
isExpired,
refreshAccessToken,
} from "../auth/oidc";
export class ApiForbiddenError extends Error {
constructor(message = "Your account is not in the nestquest-admins group.") {
super(message);
this.name = "ApiForbiddenError";
}
}
export class ApiUnauthorizedError extends Error {
constructor(message = "Not authenticated.") {
super(message);
this.name = "ApiUnauthorizedError";
}
}
export interface ApiRequestOptions extends Omit<RequestInit, "headers"> {
headers?: Record<string, string>;
}
/**
* Redirect the browser to Authentik to start a new login.
* Exported for testing injection.
*/
export async function triggerLogin(app: AppConfig = config): Promise<void> {
const url = await buildAuthorizeUrl(app);
window.location.assign(url);
}
export async function apiFetch(
path: string,
options: ApiRequestOptions = {},
app: AppConfig = config,
fetchImpl: typeof fetch = fetch,
): Promise<Response> {
if (isExpired() && (await refreshAccessToken(app, fetchImpl))) {
// token renewed; fall through and use the fresh one
}
const token = getToken();
const headers = {
...(options.headers ?? {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
const response = await fetchImpl(`${app.apiBaseUrl}${path}`, {
...options,
headers,
});
if (response.status === 401) {
// Not authenticated: either no token or an unusable one.
if (await refreshAccessToken(app, fetchImpl)) {
const retryToken = getToken();
const retryResponse = await fetchImpl(`${app.apiBaseUrl}${path}`, {
...options,
headers: {
...(options.headers ?? {}),
...(retryToken ? { Authorization: `Bearer ${retryToken}` } : {}),
},
});
if (retryResponse.status === 403) throw new ApiForbiddenError();
if (retryResponse.ok) return retryResponse;
// Only 401 means "unauthorized"; preserve every other status (e.g. a
// 500 from the API) so callers can react to the real response.
if (retryResponse.status === 401) throw new ApiUnauthorizedError();
return retryResponse;
}
await triggerLogin(app);
throw new ApiUnauthorizedError();
}
if (response.status === 403) {
// Authenticated, but Authentik's token does not belong to nestquest-admins.
throw new ApiForbiddenError();
}
return response;
}
+174
View File
@@ -0,0 +1,174 @@
/**
* Typed client for the admin quest-definition routes and the children list
* the assignee picker reads (/api/v1/admin/quest-definitions, /children).
*/
import { apiFetch } from "./client";
export type WindowName = "morning" | "afternoon" | "evening";
/** Model rule names, as the admin plane serializes ScheduleRule.to_dict. */
export type RuleType =
| "daily"
| "weekly"
| "monthly_day"
| "monthly_weekday"
| "yearly"
| "custom_days";
export interface DefinitionRule {
rule_type: RuleType;
interval: number;
/** 0 = Monday .. 6 = Sunday. */
weekday_set: number[] | null;
day_of_month: number | null;
nth_weekday: number | null;
nth_weekday_weekday: number | null;
month: number | null;
start_date: string;
end_date: string | null;
}
export interface DefinitionAssignee {
id: number;
display_name: string;
}
export interface DefinitionWindow {
window: WindowName;
/** Local wall-clock "HH:MM", or null. */
due_time: string | null;
}
export interface QuestDefinition {
id: number;
title: string;
description: string | null;
icon: string | null;
is_active: boolean;
skip_on_away: boolean;
rule: DefinitionRule;
assignees: DefinitionAssignee[];
windows: DefinitionWindow[];
}
export interface AdminChild {
id: number;
display_name: string;
colour: string | null;
avatar_ref: string | null;
sort_order: number;
is_active: boolean;
}
/** A window declaration as the create/edit bodies accept it. */
export type WindowEntry = WindowName | [WindowName, string | null];
export interface DefinitionCreateBody {
title: string;
rule: DefinitionRule;
assignee_child_ids: number[];
windows: WindowEntry[];
description?: string | null;
icon?: string | null;
skip_on_away?: boolean;
}
export type DefinitionEditBody = Partial<DefinitionCreateBody>;
export const DEFINITIONS_PATH = "/api/v1/admin/quest-definitions";
export const CHILDREN_PATH = "/api/v1/admin/children";
/** A non-2xx save response; `detail` is the API's message when it sent one. */
export class ApiRequestError extends Error {
constructor(
readonly status: number,
readonly detail: string | null,
) {
super(detail ?? `Request failed (HTTP ${status}).`);
this.name = "ApiRequestError";
}
}
/** FastAPI sends `detail` as a string (core errors) or a list of {msg} (body validation). */
function readDetail(body: unknown): string | null {
if (!body || typeof body !== "object" || !("detail" in body)) return null;
const { detail } = body as { detail: unknown };
if (typeof detail === "string") return detail;
if (Array.isArray(detail)) {
const messages = detail
.map((item) => (item && typeof item === "object" ? (item as { msg?: unknown }).msg : null))
.filter((msg): msg is string => typeof msg === "string");
return messages.length > 0 ? messages.join("; ") : null;
}
return null;
}
export async function readJson<T>(response: Response): Promise<T> {
if (!response.ok) {
let body: unknown = null;
try {
body = await response.json();
} catch {
// Non-JSON error body: report the status alone.
}
throw new ApiRequestError(response.status, readDetail(body));
}
return (await response.json()) as T;
}
export async function fetchDefinitions(): Promise<QuestDefinition[]> {
const body = await readJson<{ definitions: QuestDefinition[] }>(
await apiFetch(DEFINITIONS_PATH),
);
return body.definitions;
}
export async function fetchChildren(): Promise<AdminChild[]> {
const body = await readJson<{ children: AdminChild[] }>(await apiFetch(CHILDREN_PATH));
return body.children;
}
function sendJson(path: string, method: "POST" | "PATCH", body: unknown): Promise<Response> {
return apiFetch(path, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
export async function createDefinition(body: DefinitionCreateBody): Promise<QuestDefinition> {
return readJson<QuestDefinition>(await sendJson(DEFINITIONS_PATH, "POST", body));
}
export async function updateDefinition(
id: number,
body: DefinitionEditBody,
): Promise<QuestDefinition> {
return readJson<QuestDefinition>(await sendJson(`${DEFINITIONS_PATH}/${id}`, "PATCH", body));
}
export const OCCURRENCES_PREVIEW_PATH = `${DEFINITIONS_PATH}/occurrences-preview`;
export interface OccurrencesPreviewOptions {
/** "YYYY-MM-DD"; the API defaults to its host's today. */
startDate?: string;
/** 1..50; the API defaults to 10. */
count?: number;
}
/**
* The next occurrence dates ("YYYY-MM-DD", ascending) for `rule`, computed by
* the same backend recurrence engine the materializer uses.
*/
export async function previewOccurrences(
rule: DefinitionRule,
options: OccurrencesPreviewOptions = {},
): Promise<string[]> {
const body: { rule: DefinitionRule; start_date?: string; count?: number } = { rule };
if (options.startDate !== undefined) body.start_date = options.startDate;
if (options.count !== undefined) body.count = options.count;
const response = await readJson<{ dates: string[] }>(
await sendJson(OCCURRENCES_PREVIEW_PATH, "POST", body),
);
return response.dates;
}
+162
View File
@@ -0,0 +1,162 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { waitFor } from "@testing-library/react";
import { EVENTS_PATH, SseParser, openTransitionStream, type TransitionEvent } from "./events";
import { frame, testStream, type TestStream } from "./testStream";
import { storeTokens } from "../auth/oidc";
const COMPLETED = {
child_id: 1,
child_name: "Declan",
instance_id: 12,
quest_title: "Make bed",
window: "morning",
due_date: "2026-09-23",
due_time: null,
occurred_at: "2026-09-23T11:04:00Z",
};
const DAY_COMPLETE = {
child_id: 2,
child_name: "Maeve",
quests_due: 4,
quests_completed: 4,
occurred_at: "2026-09-23T12:00:00Z",
};
describe("SseParser", () => {
it("yields every frame from one chunk", () => {
const parser = new SseParser();
const events = parser.push(
frame("nestquest_quest_completed", COMPLETED) +
frame("nestquest_child_day_complete", DAY_COMPLETE),
);
expect(events).toEqual([
{ type: "nestquest_quest_completed", payload: COMPLETED },
{ type: "nestquest_child_day_complete", payload: DAY_COMPLETE },
]);
});
it("buffers a frame split across two chunks", () => {
const parser = new SseParser();
const text = frame("nestquest_quest_uncompleted", COMPLETED);
const cut = text.indexOf("Make");
expect(parser.push(text.slice(0, cut))).toEqual([]);
expect(parser.push(text.slice(cut))).toEqual([
{ type: "nestquest_quest_uncompleted", payload: COMPLETED },
]);
});
it("buffers a split inside the event name and the blank-line terminator", () => {
const parser = new SseParser();
const text = frame("nestquest_quest_missed", COMPLETED);
expect(parser.push(text.slice(0, 12))).toEqual([]);
expect(parser.push(text.slice(12, -1))).toEqual([]);
expect(parser.push(text.slice(-1))).toEqual([
{ type: "nestquest_quest_missed", payload: COMPLETED },
]);
});
it("treats a CRLF split across chunks as one line break", () => {
const parser = new SseParser();
const text = `event: nestquest_quest_completed\r\ndata: ${JSON.stringify(COMPLETED)}\r\n\r\n`;
const cut = text.indexOf("\n"); // chunk ends on "\r"
expect(parser.push(text.slice(0, cut))).toEqual([]);
expect(parser.push(text.slice(cut))).toEqual([
{ type: "nestquest_quest_completed", payload: COMPLETED },
]);
});
it("drops malformed, unknown and comment frames without throwing", () => {
const parser = new SseParser();
const events = parser.push(
"event: nestquest_quest_completed\ndata: not-json\n\n" +
"event: nestquest_quest_completed\ndata: [1,2]\n\n" +
"event: something_else\ndata: {}\n\n" +
": keep-alive\n\n" +
"data: {}\n\n" +
frame("nestquest_quest_completed", COMPLETED),
);
expect(events).toEqual([{ type: "nestquest_quest_completed", payload: COMPLETED }]);
});
});
describe("openTransitionStream", () => {
let fetchMock: ReturnType<typeof vi.fn>;
let streams: TestStream[];
let stop: (() => void) | null;
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
streams = [];
stop = null;
fetchMock = vi.fn(() => {
const stream = testStream();
streams.push(stream);
return Promise.resolve(stream.response);
});
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
stop?.();
vi.unstubAllGlobals();
});
it("sends the token in the Authorization header, never in the URL", async () => {
stop = openTransitionStream({ onTransition: () => {} });
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toMatch(new RegExp(`${EVENTS_PATH}$`));
expect(String(url)).not.toContain("at-123");
expect(init.headers.Authorization).toBe("Bearer at-123");
expect(init.headers.Accept).toBe("text/event-stream");
});
it("delivers transitions from frames split across chunks", async () => {
const seen: TransitionEvent[] = [];
stop = openTransitionStream({ onTransition: (event) => seen.push(event) });
await waitFor(() => expect(streams).toHaveLength(1));
const text = frame("nestquest_quest_completed", COMPLETED);
streams[0].push(text.slice(0, 20));
streams[0].push(text.slice(20));
await waitFor(() =>
expect(seen).toEqual([{ type: "nestquest_quest_completed", payload: COMPLETED }]),
);
});
it("stop aborts the request and cancels the body", async () => {
stop = openTransitionStream({ onTransition: () => {} });
await waitFor(() => expect(streams).toHaveLength(1));
const signal = fetchMock.mock.calls[0][1].signal as AbortSignal;
stop();
expect(signal.aborted).toBe(true);
await waitFor(() => expect(streams[0].cancelled()).toBe(true));
});
it("reconnects after the stream drops", async () => {
const seen: TransitionEvent[] = [];
stop = openTransitionStream({ onTransition: (event) => seen.push(event), minDelayMs: 5 });
await waitFor(() => expect(streams).toHaveLength(1));
streams[0].close();
await waitFor(() => expect(streams).toHaveLength(2));
streams[1].push(frame("nestquest_quest_missed", COMPLETED));
await waitFor(() => expect(seen).toHaveLength(1));
});
it("does not reconnect after stop, even with a reconnect pending", async () => {
stop = openTransitionStream({ onTransition: () => {}, minDelayMs: 20 });
await waitFor(() => expect(streams).toHaveLength(1));
streams[0].close();
stop();
await new Promise((resolve) => setTimeout(resolve, 60));
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("gives up on a 403 refusal instead of retrying", async () => {
fetchMock.mockImplementation(() => Promise.resolve(new Response("", { status: 403 })));
stop = openTransitionStream({ onTransition: () => {}, minDelayMs: 5 });
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
await new Promise((resolve) => setTimeout(resolve, 40));
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
+196
View File
@@ -0,0 +1,196 @@
/**
* Live transition stream: GET /api/v1/admin/events (text/event-stream).
*
* The admin JWT travels in the Authorization header (via apiFetch), so the
* stream is read with fetch + a ReadableStream reader — EventSource cannot
* set headers, and the token must never go into a URL.
*/
import { ApiForbiddenError, ApiUnauthorizedError, apiFetch } from "./client";
export const EVENTS_PATH = "/api/v1/admin/events";
/** The four documented transitions, as the API names them on the wire. */
export const TRANSITION_TYPES = [
"nestquest_quest_completed",
"nestquest_quest_uncompleted",
"nestquest_quest_missed",
"nestquest_child_day_complete",
] as const;
export type TransitionType = (typeof TRANSITION_TYPES)[number];
/** Quest transitions carry the instance; a day-complete carries the child's counts. */
export interface QuestTransitionPayload {
child_id: number;
child_name: string | null;
instance_id: number;
quest_title: string | null;
window: string;
due_date: string;
due_time: string | null;
occurred_at: string;
}
export interface ChildDayCompletePayload {
child_id: number;
child_name: string | null;
quests_due: number;
quests_completed: number;
occurred_at: string;
}
export type TransitionEvent =
| {
type:
| "nestquest_quest_completed"
| "nestquest_quest_uncompleted"
| "nestquest_quest_missed";
payload: QuestTransitionPayload;
}
| { type: "nestquest_child_day_complete"; payload: ChildDayCompletePayload };
function isTransitionType(value: string): value is TransitionType {
return (TRANSITION_TYPES as readonly string[]).includes(value);
}
/**
* Incremental SSE frame parser. Feed it decoded text in whatever chunks the
* network delivers; it buffers a partial line or frame until it completes.
* Comments (keep-alives), unknown event types and frames whose data is not a
* JSON object are dropped, never thrown.
*/
export class SseParser {
private buffer = "";
private eventType = "";
private data: string[] = [];
/** A chunk ended on "\r": a leading "\n" in the next chunk is the same line break. */
private pendingCr = false;
push(text: string): TransitionEvent[] {
if (this.pendingCr && text.startsWith("\n")) text = text.slice(1);
this.pendingCr = text.endsWith("\r");
this.buffer += text;
const lines = this.buffer.split(/\r\n|\r|\n/);
this.buffer = lines.pop() ?? "";
const events: TransitionEvent[] = [];
for (const line of lines) {
const event = this.line(line);
if (event) events.push(event);
}
return events;
}
private line(line: string): TransitionEvent | null {
if (line === "") return this.dispatch();
if (line.startsWith(":")) return null;
const colon = line.indexOf(":");
const field = colon === -1 ? line : line.slice(0, colon);
let value = colon === -1 ? "" : line.slice(colon + 1);
if (value.startsWith(" ")) value = value.slice(1);
if (field === "event") this.eventType = value;
else if (field === "data") this.data.push(value);
return null;
}
private dispatch(): TransitionEvent | null {
const type = this.eventType;
const data = this.data.join("\n");
this.eventType = "";
this.data = [];
if (!isTransitionType(type)) return null;
let payload: unknown;
try {
payload = JSON.parse(data);
} catch {
return null;
}
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
return { type, payload } as TransitionEvent;
}
}
export interface TransitionStreamOptions {
onTransition: (event: TransitionEvent) => void;
/** Reconnect delay after a drop: starts at `minDelayMs`, doubles up to `maxDelayMs`. */
minDelayMs?: number;
maxDelayMs?: number;
}
function wait(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(done, ms);
signal.addEventListener("abort", done, { once: true });
function done() {
clearTimeout(timer);
signal.removeEventListener("abort", done);
resolve();
}
});
}
/** Read one connection until the body ends (or the signal aborts). */
async function readOnce(
signal: AbortSignal,
onTransition: (event: TransitionEvent) => void,
): Promise<void> {
const response = await apiFetch(EVENTS_PATH, {
signal,
headers: { Accept: "text/event-stream" },
});
if (!response.ok || !response.body) {
throw new Error(`Event stream request failed (HTTP ${response.status}).`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
const parser = new SseParser();
const cancel = () => void reader.cancel().catch(() => {});
signal.addEventListener("abort", cancel, { once: true });
try {
for (;;) {
const { value, done } = await reader.read();
if (done || signal.aborted) break;
for (const event of parser.push(decoder.decode(value, { stream: true }))) {
if (signal.aborted) break;
onTransition(event);
}
}
} finally {
signal.removeEventListener("abort", cancel);
reader.releaseLock();
}
}
/**
* Subscribe to the admin transition stream, reconnecting with a bounded
* backoff after a drop. Returns a stop function that aborts the open request
* and any pending reconnect. A 401/403 stops the stream for good: apiFetch
* has already started a login (401) or the account is refused (403).
*/
export function openTransitionStream({
onTransition,
minDelayMs = 1_000,
maxDelayMs = 30_000,
}: TransitionStreamOptions): () => void {
const controller = new AbortController();
const { signal } = controller;
void (async () => {
let delay = minDelayMs;
while (!signal.aborted) {
try {
await readOnce(signal, onTransition);
// A connection that was accepted and then ended starts the backoff over.
delay = minDelayMs;
} catch (error: unknown) {
if (signal.aborted) return;
if (error instanceof ApiUnauthorizedError || error instanceof ApiForbiddenError) return;
// Dropped or refused connection: fall through to the backoff.
}
if (signal.aborted) return;
await wait(delay, signal);
delay = Math.min(delay * 2, maxDelayMs);
}
})();
return () => controller.abort();
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Typed client for the admin history route (/api/v1/admin/history): the
* append-only event log over a closed due-date range for one filter.
*/
import { apiFetch } from "./client";
import { ApiRequestError, readJson } from "./definitions";
export type HistoryFilter = "all" | "reversals" | "missed";
export type HistoryEventType = "completed" | "uncompleted" | "missed";
export interface HistoryRow {
/** UTC ISO stamp; null for a derived missed row (there is no event). */
occurred_at: string | null;
event_type: HistoryEventType;
child_name: string;
child_id: number;
quest_title: string;
instance_id: number;
window: string;
/** "YYYY-MM-DD", the day the task was owed. */
due_date: string;
/** Local wall-clock "HH:MM", or null. */
due_time: string | null;
/** e.g. "panel (Declan)", "joshua (admin)", "nightly sweep". */
actor: string;
was_on_time: boolean | null;
}
export interface HistoryResponse {
filter: HistoryFilter;
start: string;
end: string;
count: number;
rows: HistoryRow[];
}
export const HISTORY_PATH = "/api/v1/admin/history";
/** `start`/`end` are inclusive "YYYY-MM-DD" due dates. */
export async function fetchHistory(
filter: HistoryFilter,
start: string,
end: string,
): Promise<HistoryResponse> {
const params = new URLSearchParams({ start, end, filter });
return readJson<HistoryResponse>(await apiFetch(`${HISTORY_PATH}?${params.toString()}`));
}
export const HISTORY_CSV_PATH = "/api/v1/admin/history.csv";
export interface HistoryCsv {
blob: Blob;
filename: string;
}
/** The `filename` from a Content-Disposition header, or null. */
export function csvFilename(disposition: string | null): string | null {
const match = disposition?.match(/filename="?([^";]+)"?/i);
return match ? match[1].trim() : null;
}
/**
* The CSV export of the same range and filter as `fetchHistory`. Goes through
* apiFetch so the admin token travels in the Authorization header.
*/
export async function exportHistoryCsv(
filter: HistoryFilter,
start: string,
end: string,
): Promise<HistoryCsv> {
const params = new URLSearchParams({ start, end, filter });
const response = await apiFetch(`${HISTORY_CSV_PATH}?${params.toString()}`);
if (!response.ok) {
// readJson raises ApiRequestError with the API's `detail` for a non-OK response.
await readJson<never>(response);
throw new ApiRequestError(response.status, null);
}
return {
blob: await response.blob(),
filename:
csvFilename(response.headers.get("Content-Disposition")) ??
`nestquest-history-${start}-to-${end}.csv`,
};
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Typed client for the admin instance mutation route
* (POST /api/v1/admin/instances/{id}/uncomplete).
*/
import { apiFetch } from "./client";
import { readJson } from "./definitions";
export interface UncompleteResult {
instance_id: number;
/** The derived state after the reversal: "open", or "missed" when past due. */
state: string;
/** False when the instance was already open (a no-op). */
appended: boolean;
}
export function uncompletePath(instanceId: number): string {
return `/api/v1/admin/instances/${instanceId}/uncomplete`;
}
/** Reverse one completion; raises ApiRequestError with the API's detail on failure. */
export async function uncompleteInstance(instanceId: number): Promise<UncompleteResult> {
return readJson<UncompleteResult>(await apiFetch(uncompletePath(instanceId), { method: "POST" }));
}
+146
View File
@@ -0,0 +1,146 @@
/**
* Typed client for the admin presence routes: a child's repeating patterns
* (/api/v1/admin/children/{id}/presence-patterns, /presence-patterns/{id})
* and the household's date-range overrides (/api/v1/admin/presence-overrides).
*/
import { apiFetch } from "./client";
import { CHILDREN_PATH, readJson } from "./definitions";
/** A pattern claims its covered days as the child being home or away. */
export type PatternKind = "home" | "away";
/**
* Cycle week index ("0".."n-1", JSON object keys) -> covered weekdays
* (0 = Monday .. 6 = Sunday). An empty list covers nothing that week.
*/
export type PatternWeeks = Record<string, number[]>;
/** The anchor-based cycle every pattern repeats on. */
export interface PatternCycle {
cycle_length_weeks: number;
/** "YYYY-MM-DD"; pins cycle week 0. */
anchor_date: string;
pattern: PatternWeeks;
}
export interface PresencePatternBody extends PatternCycle {
name: string;
kind: PatternKind;
}
export interface PresencePattern extends PresencePatternBody {
id: number;
child_id: number;
}
export interface PresenceOverride {
id: number;
child_id: number;
start_date: string;
end_date: string;
is_present: boolean;
note: string | null;
}
export interface PresenceOverrideFilter {
childId?: number;
/** "YYYY-MM-DD"; keeps overrides overlapping [start, end]. */
start?: string;
end?: string;
}
export const PRESENCE_OVERRIDES_PATH = "/api/v1/admin/presence-overrides";
export const PRESENCE_PATTERNS_PATH = "/api/v1/admin/presence-patterns";
export function childPatternsPath(childId: number): string {
return `${CHILDREN_PATH}/${childId}/presence-patterns`;
}
function sendJson(path: string, method: "POST" | "PATCH", body: unknown): Promise<Response> {
return apiFetch(path, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
/** The child's patterns in id order; empty when it has none (present every day). */
export async function fetchPresencePatterns(childId: number): Promise<PresencePattern[]> {
const body = await readJson<{ patterns: PresencePattern[] }>(
await apiFetch(childPatternsPath(childId)),
);
return body.patterns;
}
export async function createPresencePattern(
childId: number,
body: PresencePatternBody,
): Promise<PresencePattern> {
return readJson<PresencePattern>(await sendJson(childPatternsPath(childId), "POST", body));
}
/** Change only the supplied fields; resolves to the pattern as stored. */
export async function updatePresencePattern(
patternId: number,
body: Partial<PresencePatternBody>,
): Promise<PresencePattern> {
return readJson<PresencePattern>(
await sendJson(`${PRESENCE_PATTERNS_PATH}/${patternId}`, "PATCH", body),
);
}
export async function deletePresencePattern(patternId: number): Promise<void> {
await readJson<{ status: string }>(
await apiFetch(`${PRESENCE_PATTERNS_PATH}/${patternId}`, { method: "DELETE" }),
);
}
export async function fetchPresenceOverrides(
filter: PresenceOverrideFilter = {},
): Promise<PresenceOverride[]> {
const params = new URLSearchParams();
if (filter.childId !== undefined) params.set("child_id", String(filter.childId));
if (filter.start !== undefined) params.set("start", filter.start);
if (filter.end !== undefined) params.set("end", filter.end);
const query = params.toString();
const body = await readJson<{ overrides: PresenceOverride[] }>(
await apiFetch(query ? `${PRESENCE_OVERRIDES_PATH}?${query}` : PRESENCE_OVERRIDES_PATH),
);
return body.overrides;
}
export interface PresenceOverrideRange {
child_id: number;
/** "YYYY-MM-DD", inclusive. */
start_date: string;
/** "YYYY-MM-DD", inclusive; never before start_date. */
end_date: string;
is_present: boolean;
}
export interface PresenceOverrideBody extends PresenceOverrideRange {
note?: string;
}
export const PRESENCE_OVERRIDE_CONSEQUENCE_PATH = `${PRESENCE_OVERRIDES_PATH}/consequence`;
/**
* How many open upcoming tasks saving this override would remove. Computed
* by the API before anything is saved; completed instances never count.
*/
export async function previewOverrideConsequence(range: PresenceOverrideRange): Promise<number> {
const body = await readJson<{ removed: number }>(
await sendJson(PRESENCE_OVERRIDE_CONSEQUENCE_PATH, "POST", range),
);
return body.removed;
}
export async function createPresenceOverride(body: PresenceOverrideBody): Promise<PresenceOverride> {
return readJson<PresenceOverride>(await sendJson(PRESENCE_OVERRIDES_PATH, "POST", body));
}
export async function deletePresenceOverride(overrideId: number): Promise<void> {
await readJson<{ status: string }>(
await apiFetch(`${PRESENCE_OVERRIDES_PATH}/${overrideId}`, { method: "DELETE" }),
);
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Typed client for the admin household-settings routes
* (/api/v1/admin/settings).
*/
import { apiFetch } from "./client";
import { readJson } from "./definitions";
export interface HouseholdSettings {
horizon_days: number;
/** Local wall-clock "HH:MM", as are the other *_time fields. */
day_rollover_time: string;
notify_target: string;
morning_summary_time: string;
afternoon_reminder_time: string;
end_of_day_report_time: string;
morning_summary_enabled: boolean;
afternoon_reminder_enabled: boolean;
end_of_day_report_enabled: boolean;
celebration_enabled: boolean;
}
/** Only the supplied fields are updated; the API answers with all of them. */
export type SettingsChanges = Partial<HouseholdSettings>;
export const SETTINGS_PATH = "/api/v1/admin/settings";
export async function fetchSettings(): Promise<HouseholdSettings> {
return readJson<HouseholdSettings>(await apiFetch(SETTINGS_PATH));
}
export async function updateSettings(changes: SettingsChanges): Promise<HouseholdSettings> {
return readJson<HouseholdSettings>(
await apiFetch(SETTINGS_PATH, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changes),
}),
);
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Typed client for GET /api/v1/admin/snapshot — today's household snapshot
* that drives the admin Today tab.
*/
import { apiFetch } from "./client";
export type InstanceWindow = "morning" | "afternoon" | "evening";
export type InstanceState = "open" | "completed" | "missed";
export interface SnapshotInstance {
id: number;
definition_id: number;
child_id: number;
title: string;
icon: string | null;
window: InstanceWindow;
/** Local wall-clock "HH:MM", or null when the definition has no due time. */
due_time: string | null;
state: InstanceState;
overdue: boolean;
/** UTC ISO timestamp of the completion event, when completed. */
completed_at: string | null;
on_time: boolean | null;
}
export interface SnapshotChild {
child_id: number;
child_name: string;
present: boolean;
/** "YYYY-MM-DD" of the next present day, or null. */
next_present: string | null;
due_today: number;
completed_today: number;
remaining_today: number;
completion_pct: number;
instances: SnapshotInstance[];
}
export interface AdminSnapshot {
/** "YYYY-MM-DD" in the household's local zone. */
today_iso: string;
/** 1-based day in the custody cycle. */
cycle_day: number;
children: SnapshotChild[];
}
export const SNAPSHOT_PATH = "/api/v1/admin/snapshot";
export async function fetchSnapshot(): Promise<AdminSnapshot> {
const response = await apiFetch(SNAPSHOT_PATH);
if (!response.ok) {
throw new Error(`Snapshot request failed (HTTP ${response.status}).`);
}
return (await response.json()) as AdminSnapshot;
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Test helper: a controllable text/event-stream Response whose body the test
* feeds chunk by chunk (so a frame can be split across chunk boundaries).
*/
export interface TestStream {
response: Response;
push(text: string): void;
close(): void;
/** True once the reader cancelled the body (the stream was torn down). */
cancelled(): boolean;
}
export function testStream(): TestStream {
const encoder = new TextEncoder();
let controller!: ReadableStreamDefaultController<Uint8Array>;
let cancelled = false;
const body = new ReadableStream<Uint8Array>({
start(c) {
controller = c;
},
cancel() {
cancelled = true;
},
});
return {
response: new Response(body, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
}),
push: (text) => controller.enqueue(encoder.encode(text)),
close: () => controller.close(),
cancelled: () => cancelled,
};
}
/** One SSE frame exactly as api/sse.py writes it. */
export function frame(type: string, payload: unknown): string {
return `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`;
}
+61
View File
@@ -0,0 +1,61 @@
/**
* One live transition stream per signed-in shell, fanned out to the screens.
*
* <TransitionsProvider> opens the stream on mount and aborts it on unmount;
* useTransitions(handler) registers a screen's handler for as long as the
* screen is mounted. Without a provider the hook does nothing, so a screen
* rendered on its own (e.g. in a test) never opens a stream.
*/
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
import { openTransitionStream, type TransitionEvent } from "./events";
type Listener = (event: TransitionEvent) => void;
interface TransitionHub {
subscribe(listener: Listener): () => void;
}
const TransitionsContext = createContext<TransitionHub | null>(null);
export function TransitionsProvider({ children }: { children: ReactNode }) {
const [hub] = useState(() => {
const listeners = new Set<Listener>();
return {
listeners,
subscribe(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
});
useEffect(
() =>
openTransitionStream({
onTransition: (event) => {
for (const listener of [...hub.listeners]) {
try {
listener(event);
} catch {
// One screen's handler must not tear down the shared stream.
}
}
},
}),
[hub],
);
return <TransitionsContext.Provider value={hub}>{children}</TransitionsContext.Provider>;
}
/** Call `handler` for every transition while the calling component is mounted. */
export function useTransitions(handler: Listener): void {
const hub = useContext(TransitionsContext);
const handlerRef = useRef(handler);
useEffect(() => {
handlerRef.current = handler;
});
useEffect(() => hub?.subscribe((event) => handlerRef.current(event)), [hub]);
}
+173
View File
@@ -0,0 +1,173 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildAuthorizeUrl,
clearAuth,
getToken,
handleCallback,
logout,
storeTokens,
} from "./oidc";
import type { AppConfig } from "../config";
function testApp(): AppConfig {
return {
issuer: "https://auth.example.com/application/o/nestquest",
clientId: "test-client-id",
apiBaseUrl: "https://api.example.com",
authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
tokenEndpoint: "https://auth.example.com/application/o/token/",
userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
redirectUri: "http://localhost:5173/auth/callback",
};
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("OIDC flow", () => {
beforeEach(() => {
sessionStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("buildAuthorizeUrl carries all required params and stores state/verifier", async () => {
const app = testApp();
const url = new URL(await buildAuthorizeUrl(app));
expect(url.origin + url.pathname).toBe(app.authorizeEndpoint);
expect(url.searchParams.get("response_type")).toBe("code");
expect(url.searchParams.get("client_id")).toBe("test-client-id");
expect(url.searchParams.get("redirect_uri")).toBe(app.redirectUri);
expect(url.searchParams.get("scope")).toBe("openid email profile");
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(url.searchParams.get("state")).toMatch(/^[A-Za-z0-9_-]+$/);
expect(sessionStorage.getItem("nestquest.oidc_state")).toBe(
url.searchParams.get("state"),
);
expect(sessionStorage.getItem("nestquest.code_verifier")).toMatch(/^[A-Za-z0-9-._~]{43,128}$/);
});
it("handleCallback rejects a mismatched state", async () => {
await buildAuthorizeUrl(testApp());
sessionStorage.setItem("nestquest.oidc_state", "different-state");
const fetchMock = vi.fn();
await expect(
handleCallback("?code=abc&state=tampered", testApp(), fetchMock as any),
).rejects.toThrow(/state mismatch/i);
expect(fetchMock).not.toHaveBeenCalled();
expect(getToken()).toBeUndefined();
});
it("handleCallback exchanges the code with the correct form body and stores the token", async () => {
await buildAuthorizeUrl(testApp());
const verifier = sessionStorage.getItem("nestquest.code_verifier")!;
const state = sessionStorage.getItem("nestquest.oidc_state")!;
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
access_token: "at-123",
refresh_token: "rt-456",
id_token: "id-789",
expires_in: 300,
}),
);
await handleCallback(`?code=abc&state=${state}`, testApp(), fetchMock as any);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://auth.example.com/application/o/token/");
expect(init.method).toBe("POST");
expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
const body = new URLSearchParams(init.body);
expect(body.get("grant_type")).toBe("authorization_code");
expect(body.get("code")).toBe("abc");
expect(body.get("redirect_uri")).toBe(testApp().redirectUri);
expect(body.get("client_id")).toBe("test-client-id");
expect(body.get("code_verifier")).toBe(verifier);
expect(body.has("client_secret")).toBe(false);
expect(getToken()).toBe("at-123");
// one-time values consumed
expect(sessionStorage.getItem("nestquest.oidc_state")).toBeNull();
expect(sessionStorage.getItem("nestquest.code_verifier")).toBeNull();
});
it("handleCallback surfaces provider errors", async () => {
await expect(
handleCallback("?error=access_denied&error_description=nope", testApp(), vi.fn() as any),
).rejects.toThrow(/nope|access_denied/);
});
it("clearAuth removes all stored auth data", () => {
storeTokens({ access_token: "a", refresh_token: "r", expires_in: 60 });
clearAuth();
expect(getToken()).toBeUndefined();
expect(sessionStorage.length).toBe(0);
});
it("logout clears auth and redirects to the end-session endpoint", async () => {
storeTokens({ access_token: "a", id_token: "idt", expires_in: 60 });
const assign = vi.fn();
const original = window.location;
vi.stubGlobal("location", { ...original, assign, origin: "http://localhost:5173" });
await logout(testApp());
expect(assign).toHaveBeenCalledTimes(1);
const url = new URL(assign.mock.calls[0][0] as string);
expect(url.toString()).toBe(
"https://auth.example.com/application/o/nestquest/end-session/?id_token_hint=idt&client_id=test-client-id&post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A5173",
);
expect(getToken()).toBeUndefined();
vi.unstubAllGlobals();
});
it("buildAuthorizeUrl resolves the authorize endpoint via discovery for a non-default issuer", async () => {
const issuer = "https://sso.discovery.example/application/o/discovered";
const discoveryDoc = {
issuer,
authorization_endpoint: "https://sso.discovery.example/application/o/authorize/",
token_endpoint: "https://sso.discovery.example/application/o/token/",
userinfo_endpoint: "https://sso.discovery.example/application/o/userinfo/",
end_session_endpoint: `${issuer}/end-session/`,
jwks_uri: `${issuer}/jwks/`,
};
const fetchMock = vi.fn().mockImplementation((url: string) =>
Promise.resolve(
String(url).endsWith("/.well-known/openid-configuration")
? jsonResponse(discoveryDoc)
: jsonResponse({}),
),
);
const app: AppConfig = {
issuer,
clientId: "test-client-id",
apiBaseUrl: "https://api.example.com",
redirectUri: "http://localhost:5173/auth/callback",
};
const url = new URL(await buildAuthorizeUrl(app, fetchMock as any));
// The authorize endpoint comes from the discovery document — Authentik
// serves it outside the app-specific issuer path, so it is NOT
// `${issuer}/authorize/` and must never be a hardcoded production host.
expect(url.origin + url.pathname).toBe(discoveryDoc.authorization_endpoint);
expect(url.searchParams.get("client_id")).toBe("test-client-id");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(String(fetchMock.mock.calls[0][0])).toBe(
`${issuer}/.well-known/openid-configuration`,
);
});
});
+225
View File
@@ -0,0 +1,225 @@
/**
* OIDC authorization-code + PKCE flow for a PUBLIC client.
*
* Never sends a client_secret anywhere. Tokens are stored in sessionStorage
* (per-tab, cleared on tab close) — never localStorage.
*/
import { config, resolveEndpoints, type AppConfig, type ResolvedAppConfig } from "../config";
import {
computeCodeChallenge,
generateCodeVerifier,
generateState,
} from "./pkce";
const STORAGE_KEYS = {
accessToken: "nestquest.access_token",
refreshToken: "nestquest.refresh_token",
expiresAt: "nestquest.expires_at",
idToken: "nestquest.id_token",
state: "nestquest.oidc_state",
codeVerifier: "nestquest.code_verifier",
} as const;
export class AuthError extends Error {}
export interface TokenResponse {
access_token?: string;
refresh_token?: string;
id_token?: string;
expires_in?: number;
token_type?: string;
}
function makeScopes(): string {
return "openid email profile";
}
/**
* Build the Authentik authorization redirect URL, storing state + verifier in
* sessionStorage so the callback can validate them. Endpoints are resolved
* from the issuer's OIDC discovery document when not set on the config.
*/
export async function buildAuthorizeUrl(
app: AppConfig = config,
fetchImpl: typeof fetch = fetch,
): Promise<string> {
if (!app.clientId) {
throw new AuthError(
"Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
);
}
const resolved = await resolveEndpoints(app, fetchImpl);
const state = generateState();
const codeVerifier = generateCodeVerifier();
const codeChallenge = await computeCodeChallenge(codeVerifier);
sessionStorage.setItem(STORAGE_KEYS.state, state);
sessionStorage.setItem(STORAGE_KEYS.codeVerifier, codeVerifier);
const url = new URL(resolved.authorizeEndpoint);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", app.clientId);
url.searchParams.set("redirect_uri", app.redirectUri);
url.searchParams.set("scope", makeScopes());
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
return url.toString();
}
/**
* Handle the OAuth callback: validate state, exchange the code at the token
* endpoint (PKCE, form-urlencoded, no client_secret), store tokens.
*/
export async function handleCallback(
query: URLSearchParams | string = window.location.search,
app: AppConfig = config,
fetchImpl: typeof fetch = fetch,
): Promise<void> {
const params =
typeof query === "string" ? new URLSearchParams(query) : query;
const providerError = params.get("error");
if (providerError) {
throw new AuthError(
params.get("error_description") || `Provider returned error: ${providerError}`,
);
}
const code = params.get("code");
const state = params.get("state");
if (!code || !state) {
throw new AuthError("Callback is missing the code or state parameter.");
}
const storedState = sessionStorage.getItem(STORAGE_KEYS.state);
const codeVerifier = sessionStorage.getItem(STORAGE_KEYS.codeVerifier);
if (!storedState || storedState !== state || !codeVerifier) {
throw new AuthError("State mismatch — possible CSRF; refusing to log in.");
}
if (!app.clientId) {
throw new AuthError(
"Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
);
}
const resolved: ResolvedAppConfig = await resolveEndpoints(app, fetchImpl);
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: app.redirectUri,
client_id: app.clientId,
code_verifier: codeVerifier,
});
const response = await fetchImpl(resolved.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
if (!response.ok) {
throw new AuthError(`Token exchange failed with HTTP ${response.status}`);
}
const tokens = (await response.json()) as TokenResponse;
if (!tokens.access_token) {
throw new AuthError("Token response did not include an access token.");
}
storeTokens(tokens);
// One-time use values are consumed on success.
sessionStorage.removeItem(STORAGE_KEYS.state);
sessionStorage.removeItem(STORAGE_KEYS.codeVerifier);
}
export function storeTokens(tokens: TokenResponse): void {
sessionStorage.setItem(STORAGE_KEYS.accessToken, tokens.access_token!);
if (tokens.refresh_token) {
sessionStorage.setItem(STORAGE_KEYS.refreshToken, tokens.refresh_token);
}
if (tokens.id_token) {
sessionStorage.setItem(STORAGE_KEYS.idToken, tokens.id_token);
}
if (typeof tokens.expires_in === "number") {
sessionStorage.setItem(
STORAGE_KEYS.expiresAt,
String(Date.now() + tokens.expires_in * 1000),
);
}
}
export function getToken(): string | undefined {
return sessionStorage.getItem(STORAGE_KEYS.accessToken) ?? undefined;
}
export function getRefreshToken(): string | undefined {
return sessionStorage.getItem(STORAGE_KEYS.refreshToken) ?? undefined;
}
export function getIdToken(): string | undefined {
return sessionStorage.getItem(STORAGE_KEYS.idToken) ?? undefined;
}
export function isAuthenticated(): boolean {
return Boolean(getToken());
}
/** True once the stored access token has passed its (approximate) expiry. */
export function isExpired(): boolean {
const expiresAt = sessionStorage.getItem(STORAGE_KEYS.expiresAt);
if (!expiresAt) return false;
return Date.now() >= Number(expiresAt);
}
/**
* Refresh the access token with the refresh token grant. Returns false when
* no refresh token is stored or the grant fails (caller should re-login).
*/
export async function refreshAccessToken(
app: AppConfig = config,
fetchImpl: typeof fetch = fetch,
): Promise<boolean> {
const refreshToken = getRefreshToken();
if (!refreshToken || !app.clientId) return false;
const resolved = await resolveEndpoints(app, fetchImpl);
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: app.clientId,
});
const response = await fetchImpl(resolved.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
if (!response.ok) return false;
const tokens = (await response.json()) as TokenResponse;
if (!tokens.access_token) return false;
storeTokens(tokens);
return true;
}
export function clearAuth(): void {
for (const key of Object.values(STORAGE_KEYS)) {
sessionStorage.removeItem(key);
}
}
/** Sign out: clear local auth, then send the browser to the end-session endpoint. */
export async function logout(
app: AppConfig = config,
fetchImpl: typeof fetch = fetch,
): Promise<void> {
const resolved = await resolveEndpoints(app, fetchImpl);
const idTokenHint = getIdToken();
clearAuth();
const params = new URLSearchParams();
if (idTokenHint) params.set("id_token_hint", idTokenHint);
if (app.clientId) params.set("client_id", app.clientId);
params.set("post_logout_redirect_uri", window.location.origin);
window.location.assign(`${resolved.endSessionEndpoint}?${params.toString()}`);
}
+41
View File
@@ -0,0 +1,41 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
base64UrlEncode,
computeCodeChallenge,
generateCodeVerifier,
generateState,
isLikelyValidVerifier,
} from "./pkce";
describe("PKCE helpers", () => {
it("base64UrlEncode has no padding and url-safe characters", () => {
// bytes chosen to force "+" and "/" and padding in plain base64
const bytes = new Uint8Array([0xfb, 0xff, 0xbf, 0xff]);
const encoded = base64UrlEncode(bytes);
expect(encoded).not.toMatch(/[+/=]/);
});
it("computes the RFC 7636 appendix-B S256 vector", async () => {
const verifier =
"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const challenge = await computeCodeChallenge(verifier);
expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
});
it("generates verifiers with valid charset and length", () => {
for (let i = 0; i < 20; i++) {
const verifier = generateCodeVerifier();
expect(isLikelyValidVerifier(verifier)).toBe(true);
}
});
it("rejects verifiers outside 43-128 chars or with reserved chars", () => {
expect(isLikelyValidVerifier("short")).toBe(false);
expect(isLikelyValidVerifier("a".repeat(129))).toBe(false);
expect(isLikelyValidVerifier("a".repeat(43) + "+")).toBe(false);
});
it("generates distinct random states", () => {
expect(generateState()).not.toBe(generateState());
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* PKCE (RFC 7636) helpers for a public SPA client.
*
* Uses the Web Crypto API, which requires a SECURE CONTEXT: the app must be
* served over https or http://localhost, otherwise crypto.subtle is
* unavailable and login cannot work.
*/
const VERIFIER_CHARSET =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
/** base64url-encode bytes with NO padding. */
export function base64UrlEncode(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function randomBase64Url(byteLength: number): string {
const bytes = new Uint8Array(byteLength);
crypto.getRandomValues(bytes);
return base64UrlEncode(bytes);
}
/**
* Random code_verifier: 43-128 chars, unreserved charset only.
* 32 random bytes -> 43 base64url chars, which is the minimum allowed length.
*/
export function generateCodeVerifier(): string {
return randomBase64Url(32);
}
/** S256 code_challenge = base64url(SHA-256(verifier)), no padding. */
export async function computeCodeChallenge(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier),
);
return base64UrlEncode(new Uint8Array(digest));
}
/** Random opaque state value for CSRF protection. */
export function generateState(): string {
return randomBase64Url(16);
}
export function isLikelyValidVerifier(verifier: string): boolean {
return (
verifier.length >= 43 &&
verifier.length <= 128 &&
[...verifier].every((char) => VERIFIER_CHARSET.includes(char))
);
}
+215
View File
@@ -0,0 +1,215 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
ConfigError,
discoveryUrlForIssuer,
readConfig,
resolveEndpoints,
resolveEndpointsFromDiscovery,
type AppConfig,
} from "./config";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
// Mirrors Authentik's layout: authorize/token/userinfo live OUTSIDE the
// application-specific issuer path, end-session/jwks under it.
function discoveryDocument(issuer: string) {
const normalized = issuer.replace(/\/+$/, "");
const origin = new URL(normalized).origin;
return {
issuer,
authorization_endpoint: `${origin}/application/o/authorize/`,
token_endpoint: `${origin}/application/o/token/`,
userinfo_endpoint: `${origin}/application/o/userinfo/`,
end_session_endpoint: `${normalized}/end-session/`,
jwks_uri: `${normalized}/jwks/`,
};
}
describe("config", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("readConfig keeps the production issuer as the default", () => {
const cfg = readConfig({});
expect(cfg.issuer).toBe("https://auth.cubecraftlabs.com/application/o/nestquest");
expect(cfg.clientId).toBeUndefined();
});
it("readConfig normalizes a trailing slash off a custom issuer", () => {
expect(
readConfig({ VITE_AUTHENTIK_ISSUER: "https://sso.custom.example/application/o/custom/" })
.issuer,
).toBe("https://sso.custom.example/application/o/custom");
});
it("readConfig hardcodes no OIDC endpoints — they come from discovery", () => {
const cfg = readConfig({});
expect(cfg.authorizeEndpoint).toBeUndefined();
expect(cfg.tokenEndpoint).toBeUndefined();
expect(cfg.userinfoEndpoint).toBeUndefined();
expect(cfg.endSessionEndpoint).toBeUndefined();
expect(cfg.jwksUri).toBeUndefined();
});
it("builds the discovery URL for issuers with and without a trailing slash", () => {
expect(discoveryUrlForIssuer("https://sso.example.com/application/o/app/")).toBe(
"https://sso.example.com/application/o/app/.well-known/openid-configuration",
);
expect(discoveryUrlForIssuer("https://sso.example.com/application/o/app")).toBe(
"https://sso.example.com/application/o/app/.well-known/openid-configuration",
);
});
it("resolves every endpoint from the discovery document of a non-default issuer", async () => {
const issuer = "https://sso.other-host.example/application/o/other-app";
const doc = discoveryDocument(issuer);
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
const app: AppConfig = {
issuer,
clientId: "client-id",
apiBaseUrl: "https://api.example.com",
redirectUri: "http://localhost:5173/auth/callback",
};
const resolved = await resolveEndpoints(app, fetchMock as unknown as typeof fetch);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://sso.other-host.example/application/o/other-app/.well-known/openid-configuration",
);
// Every endpoint comes verbatim from the (non-default) discovery document.
expect(resolved.authorizeEndpoint).toBe(doc.authorization_endpoint);
expect(resolved.tokenEndpoint).toBe(doc.token_endpoint);
expect(resolved.userinfoEndpoint).toBe(doc.userinfo_endpoint);
expect(resolved.endSessionEndpoint).toBe(doc.end_session_endpoint);
expect(resolved.jwksUri).toBe(doc.jwks_uri);
// Proves no hardcoded production host leaked into the resolved endpoints.
for (const endpoint of [
resolved.authorizeEndpoint,
resolved.tokenEndpoint,
resolved.userinfoEndpoint,
resolved.endSessionEndpoint,
resolved.jwksUri,
]) {
expect(endpoint).not.toMatch(/cubecraftlabs/);
}
});
it("resolves discovery for an issuer configured with a trailing slash", async () => {
const issuer = "https://sso.slashy.example/application/o/slashy/";
const doc = discoveryDocument(issuer);
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
const resolved = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
expect(fetchMock).toHaveBeenCalledWith(
"https://sso.slashy.example/application/o/slashy/.well-known/openid-configuration",
);
expect(resolved.authorizeEndpoint).toBe(doc.authorization_endpoint);
expect(resolved.endSessionEndpoint).toBe(doc.end_session_endpoint);
});
it("caches discovery per issuer for the session", async () => {
const issuer = "https://sso.cached.example/application/o/cached";
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(discoveryDocument(issuer)));
const first = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
const second = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(second).toBe(first);
});
it("surfaces a clear configuration error when discovery returns an HTTP error", async () => {
const issuer = "https://sso.http-error.example/application/o/broken";
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 500 }));
const error = await resolveEndpointsFromDiscovery(issuer, fetchMock as any).then(
() => null,
(error: unknown) => error,
);
expect(error).toBeInstanceOf(ConfigError);
expect((error as Error).message).toContain(issuer);
expect((error as Error).message).toContain("HTTP 500");
});
it("surfaces a clear configuration error when discovery is unreachable", async () => {
const issuer = "https://sso.unreachable.example/application/o/unreachable";
const fetchMock = vi.fn().mockRejectedValue(new TypeError("network down"));
await expect(
resolveEndpointsFromDiscovery(issuer, fetchMock as any),
).rejects.toBeInstanceOf(ConfigError);
});
it("surfaces a clear configuration error when the discovery document is incomplete", async () => {
const issuer = "https://sso.incomplete.example/application/o/incomplete";
const doc = discoveryDocument(issuer) as Record<string, string>;
delete doc.end_session_endpoint;
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
await expect(
resolveEndpointsFromDiscovery(issuer, fetchMock as any),
).rejects.toThrow(/end_session_endpoint/);
});
it("rejects a discovery document advertising a different issuer", async () => {
const issuer = "https://sso.mismatch.example/application/o/mismatch";
const doc = {
...discoveryDocument(issuer),
issuer: "https://sso.elsewhere.example/application/o/elsewhere",
};
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
await expect(
resolveEndpointsFromDiscovery(issuer, fetchMock as any),
).rejects.toThrow(/issuer mismatch/i);
});
it("the bundled config resolves its endpoints from discovery at runtime", async () => {
const issuer = "https://sso.bundled.example/application/o/bundled";
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(discoveryDocument(issuer)));
const bundled = readConfig({ VITE_AUTHENTIK_ISSUER: `${issuer}/`, VITE_AUTHENTIK_CLIENT_ID: "bundled-client" });
expect(bundled.authorizeEndpoint).toBeUndefined();
const resolved = await resolveEndpoints(bundled, fetchMock as any);
expect(resolved.authorizeEndpoint).toBe(
"https://sso.bundled.example/application/o/authorize/",
);
expect(resolved.tokenEndpoint).toBe("https://sso.bundled.example/application/o/token/");
expect(resolved.userinfoEndpoint).toBe(
"https://sso.bundled.example/application/o/userinfo/",
);
expect(resolved.endSessionEndpoint).toBe(
"https://sso.bundled.example/application/o/bundled/end-session/",
);
expect(resolved.jwksUri).toBe("https://sso.bundled.example/application/o/bundled/jwks/");
});
it("does not re-fetch discovery when the config already carries endpoints", async () => {
const app: AppConfig = {
issuer: "https://auth.example.com/application/o/nestquest",
clientId: "client-id",
apiBaseUrl: "https://api.example.com",
authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
tokenEndpoint: "https://auth.example.com/application/o/token/",
userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
redirectUri: "http://localhost:5173/auth/callback",
};
const fetchMock = vi.fn();
const resolved = await resolveEndpoints(app, fetchMock as any);
expect(fetchMock).not.toHaveBeenCalled();
expect(resolved).toBe(app);
});
});
+221
View File
@@ -0,0 +1,221 @@
/**
* Runtime configuration, read from Vite env (import.meta.env).
* See .env.example for the variables and their defaults.
*
* OIDC endpoints are NOT hardcoded here. They are resolved lazily from the
* issuer's OIDC discovery document (see resolveEndpoints), because Authentik
* serves authorize/token/userinfo OUTSIDE the application-specific issuer
* path (issuer is /application/o/<slug>/, authorize is /application/o/authorize/).
* Both hardcoding the production host and naively concatenating onto the
* issuer would break non-default issuers; discovery is the only correct way.
*/
export interface AppConfig {
issuer: string;
clientId: string | undefined;
apiBaseUrl: string;
/** Resolved from the issuer's OIDC discovery document — see resolveEndpoints(). */
authorizeEndpoint?: string;
tokenEndpoint?: string;
userinfoEndpoint?: string;
endSessionEndpoint?: string;
jwksUri?: string;
redirectUri: string;
}
/** AppConfig whose OIDC endpoints have been filled in by resolveEndpoints(). */
export type ResolvedAppConfig = AppConfig &
Required<
Pick<
AppConfig,
"authorizeEndpoint" | "tokenEndpoint" | "userinfoEndpoint" | "endSessionEndpoint"
>
>;
export interface DiscoveredEndpoints {
issuer: string;
authorizeEndpoint: string;
tokenEndpoint: string;
userinfoEndpoint: string;
endSessionEndpoint: string;
jwksUri: string;
}
/** Raised when OIDC discovery fails or the configuration is unusable. */
export class ConfigError extends Error {
constructor(message: string) {
super(message);
this.name = "ConfigError";
}
}
const REQUIRED_ENDPOINT_NAMES = [
"authorizeEndpoint",
"tokenEndpoint",
"userinfoEndpoint",
"endSessionEndpoint",
] as const;
export function readConfig(env: Record<string, string | undefined> = import.meta.env as any): AppConfig {
const issuer = (
env.VITE_AUTHENTIK_ISSUER || "https://auth.cubecraftlabs.com/application/o/nestquest/"
).replace(/\/$/, "");
const clientId = env.VITE_AUTHENTIK_CLIENT_ID;
const apiBaseUrl = env.VITE_API_BASE_URL || "https://nestquest.cubecraftlabs.com";
return {
issuer,
// No default: a missing client id must surface as a configuration error
// screen, not a broken redirect to Authentik.
clientId: clientId || undefined,
apiBaseUrl,
// authorize/token/userinfo/end-session endpoints are resolved from the
// issuer's discovery document at runtime — never hardcoded here.
redirectUri: `${window.location.origin}/auth/callback`,
};
}
export const config: AppConfig = readConfig();
interface DiscoveryDocument {
issuer?: unknown;
authorization_endpoint?: unknown;
token_endpoint?: unknown;
userinfo_endpoint?: unknown;
end_session_endpoint?: unknown;
jwks_uri?: unknown;
}
function normalizeIssuer(issuer: string): string {
return issuer.replace(/\/+$/, "");
}
function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
/** Discovery document location for an issuer, tolerant of a trailing slash. */
export function discoveryUrlForIssuer(issuer: string): string {
return `${normalizeIssuer(issuer)}/.well-known/openid-configuration`;
}
async function fetchDiscoveredEndpoints(
issuer: string,
fetchImpl: typeof fetch,
): Promise<DiscoveredEndpoints> {
const normalizedIssuer = normalizeIssuer(issuer);
const discoveryUrl = `${normalizedIssuer}/.well-known/openid-configuration`;
let response: Response;
try {
response = await fetchImpl(discoveryUrl);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new ConfigError(
`OIDC discovery request to ${discoveryUrl} failed (${reason}). ` +
"Check VITE_AUTHENTIK_ISSUER and network reachability.",
);
}
if (!response.ok) {
throw new ConfigError(
`OIDC discovery failed: ${discoveryUrl} returned HTTP ${response.status}. ` +
"Check VITE_AUTHENTIK_ISSUER.",
);
}
let doc: DiscoveryDocument;
try {
doc = (await response.json()) as DiscoveryDocument;
} catch {
throw new ConfigError(
`OIDC discovery document at ${discoveryUrl} is not valid JSON. Check VITE_AUTHENTIK_ISSUER.`,
);
}
const authorizationEndpoint = nonEmptyString(doc.authorization_endpoint);
const tokenEndpoint = nonEmptyString(doc.token_endpoint);
const userinfoEndpoint = nonEmptyString(doc.userinfo_endpoint);
const endSessionEndpoint = nonEmptyString(doc.end_session_endpoint);
const jwksUri = nonEmptyString(doc.jwks_uri);
const discoveredIssuer = nonEmptyString(doc.issuer);
const endpointFields: Array<[string, string | undefined]> = [
["authorization_endpoint", authorizationEndpoint],
["token_endpoint", tokenEndpoint],
["userinfo_endpoint", userinfoEndpoint],
["end_session_endpoint", endSessionEndpoint],
["jwks_uri", jwksUri],
];
const missing = endpointFields.filter(([, value]) => !value).map(([name]) => name);
if (missing.length > 0) {
throw new ConfigError(
`OIDC discovery document at ${discoveryUrl} is missing: ${missing.join(", ")}. ` +
"The issuer must point at an OIDC provider that publishes these endpoints.",
);
}
if (discoveredIssuer && normalizeIssuer(discoveredIssuer) !== normalizedIssuer) {
throw new ConfigError(
`OIDC discovery issuer mismatch: ${discoveryUrl} advertises "${discoveredIssuer}" ` +
`but VITE_AUTHENTIK_ISSUER is "${normalizedIssuer}".`,
);
}
return {
issuer: normalizedIssuer,
authorizeEndpoint: authorizationEndpoint!,
tokenEndpoint: tokenEndpoint!,
userinfoEndpoint: userinfoEndpoint!,
endSessionEndpoint: endSessionEndpoint!,
jwksUri: jwksUri!,
};
}
/**
* Session cache: one discovery fetch per issuer per SPA session. Failed
* lookups are evicted so a transient network error can be retried instead of
* poisoning the whole session.
*/
const discoveryCache = new Map<string, Promise<DiscoveredEndpoints>>();
export function resolveEndpointsFromDiscovery(
issuer: string,
fetchImpl: typeof fetch = fetch,
): Promise<DiscoveredEndpoints> {
const normalizedIssuer = normalizeIssuer(issuer);
const cached = discoveryCache.get(normalizedIssuer);
if (cached) return cached;
const pending = fetchDiscoveredEndpoints(normalizedIssuer, fetchImpl);
discoveryCache.set(normalizedIssuer, pending);
pending.catch(() => {
discoveryCache.delete(normalizedIssuer);
});
return pending;
}
/**
* Return `app` with all OIDC endpoints filled in. When the config already
* carries them (pre-resolved config, tests) this is a no-op; otherwise the
* issuer's discovery document is fetched (cached for the session) and its
* authorization/token/userinfo/end-session/jwks endpoints are used.
*/
export async function resolveEndpoints(
app: AppConfig,
fetchImpl: typeof fetch = fetch,
): Promise<ResolvedAppConfig> {
const missing = REQUIRED_ENDPOINT_NAMES.filter((name) => !app[name]);
if (missing.length === 0) {
return app as ResolvedAppConfig;
}
const discovered = await resolveEndpointsFromDiscovery(app.issuer, fetchImpl);
return {
...app,
authorizeEndpoint: discovered.authorizeEndpoint,
tokenEndpoint: discovered.tokenEndpoint,
userinfoEndpoint: discovered.userinfoEndpoint,
endSessionEndpoint: discovered.endSessionEndpoint,
jwksUri: discovered.jwksUri,
};
}
+596
View File
@@ -0,0 +1,596 @@
/* Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2).
Mobile-first at the 390x844 reference; the column is capped and centred wider. */
.defs-screen {
width: 100%;
max-width: 640px;
margin: 0 auto;
}
/* §3.1 header */
.defs-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--nq-a-surface);
border-bottom: 1px solid var(--nq-a-border);
padding: 14px 16px;
}
.defs-title {
margin: 0;
font-size: var(--nq-a-size-screen);
font-weight: 800;
letter-spacing: -0.02em;
}
.defs-sub {
margin: 2px 0 0;
min-height: 1.4em;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.defs-new {
flex: none;
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 36px;
padding: 0 14px;
border: 0;
border-radius: var(--nq-a-density-radius);
background: var(--nq-brand-gradient);
color: #fff;
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.defs-new:disabled {
opacity: 0.5;
cursor: default;
}
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
.defs-scroll {
display: flex;
flex-direction: column;
gap: var(--nq-a-density-gap);
padding: 14px 16px;
}
/* Filter chips */
.defs-chips {
display: flex;
gap: 6px;
overflow-x: auto;
}
.defs-chip {
flex: none;
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-radius-pill);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.defs-chip--active {
border-color: #18181d;
background: #18181d;
color: #fff;
}
/* Rows */
.defs-card {
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
overflow: hidden;
}
.defs-list {
margin: 0;
padding: 0;
list-style: none;
}
.defs-list > li + li {
border-top: 1px solid var(--nq-a-divider);
}
.defs-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: var(--nq-a-tap-min);
padding: var(--nq-a-density-row-pad);
border: 0;
background: none;
color: var(--nq-a-ink);
font-family: inherit;
text-align: left;
cursor: pointer;
}
.defs-row-glyph {
flex: none;
display: flex;
color: var(--nq-a-ink-secondary);
}
.defs-row-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.defs-row-title {
font-size: var(--nq-a-size-row);
font-weight: 700;
}
.defs-row-meta {
font-size: var(--nq-a-size-meta);
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.defs-row-chevron {
flex: none;
display: flex;
color: var(--nq-a-ink-tertiary);
}
/* An inactive definition renders the whole row tertiary (never hard-deleted). */
.defs-row--inactive,
.defs-row--inactive .defs-row-glyph,
.defs-row--inactive .defs-row-meta {
color: var(--nq-a-ink-tertiary);
}
.defs-row--editing {
background: var(--nq-a-selected);
}
.defs-row--editing .defs-row-glyph {
color: var(--nq-brand-blue);
}
.defs-empty {
padding: var(--nq-a-density-card-pad);
font-size: 13px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
/* Loading / error */
.defs-status {
padding: 24px 13px;
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
text-align: center;
font-size: 13.5px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.defs-status--error {
color: var(--nq-a-danger-strong);
}
.defs-status--error p {
margin: 0 0 10px;
}
.defs-retry {
padding: 8px 16px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
/* §3.2 edit sheet */
.defs-sheet-layer {
position: fixed;
inset: 0;
z-index: 20;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.defs-scrim {
position: absolute;
inset: 0;
background: rgba(9, 9, 11, 0.4);
animation: defs-fade var(--nq-dur-modal) var(--nq-ease-out);
}
.defs-sheet {
position: relative;
width: 100%;
max-width: 640px;
max-height: 92%;
margin: 0 auto;
overflow-y: auto;
padding: 14px 18px 24px;
border-radius: var(--nq-a-radius-sheet);
background: var(--nq-a-surface);
box-shadow: var(--nq-a-sheet-shadow);
animation: defs-rise var(--nq-dur-modal) var(--nq-ease-out);
}
@keyframes defs-fade {
from {
opacity: 0;
}
}
@keyframes defs-rise {
from {
transform: translateY(100%);
}
}
@media (prefers-reduced-motion: reduce) {
.defs-scrim,
.defs-sheet {
animation: none;
}
}
.defs-grabber {
width: 38px;
height: 4px;
margin: 0 auto 12px;
border-radius: var(--nq-a-radius-pill);
background: var(--nq-a-border);
}
.defs-sheet-title {
margin: 0 0 12px;
font-size: 19px;
font-weight: 800;
}
.defs-fields {
display: flex;
flex-direction: column;
gap: 11px;
}
.defs-field {
display: flex;
flex-direction: column;
gap: 5px;
}
.defs-label {
font-size: var(--nq-a-size-label);
font-weight: 700;
text-transform: uppercase;
letter-spacing: var(--nq-a-track-label);
color: var(--nq-a-ink-secondary);
}
.defs-input {
width: 100%;
min-height: var(--nq-a-tap-min);
padding: 0 11px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 14px;
font-weight: 600;
}
.defs-select-row {
display: flex;
align-items: center;
justify-content: space-between;
min-height: var(--nq-a-tap-min);
padding: 0 11px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.defs-picker {
margin: 0;
padding: 0;
list-style: none;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
}
.defs-picker > li + li {
border-top: 1px solid var(--nq-a-divider);
}
.defs-picker-option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: var(--nq-a-tap-min);
padding: 0 11px;
border: 0;
background: none;
color: var(--nq-a-ink);
font-family: inherit;
font-size: 13.5px;
font-weight: 700;
cursor: pointer;
}
.defs-picker-check {
display: flex;
color: var(--nq-brand-blue);
}
.defs-picker-option--locked {
color: var(--nq-a-ink-secondary);
cursor: default;
}
.defs-picker-note {
font-size: 12px;
font-weight: 600;
}
.defs-repeats {
display: flex;
gap: 8px;
}
.defs-interval {
flex: none;
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: var(--nq-a-ink-secondary);
}
.defs-interval .defs-input {
width: 64px;
}
.defs-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.defs-day,
.defs-segment {
min-height: var(--nq-a-tap-min);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.defs-day--on,
.defs-segment--on {
border-color: transparent;
background: var(--nq-brand-gradient);
color: #fff;
}
.defs-segmented {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.defs-icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--nq-a-tap-min), 1fr));
gap: 6px;
}
.defs-icon {
display: flex;
align-items: center;
justify-content: center;
min-height: var(--nq-a-tap-min);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
cursor: pointer;
}
.defs-icon--on {
border-color: transparent;
background: var(--nq-brand-gradient);
color: #fff;
}
.defs-due-times {
display: flex;
flex-direction: column;
gap: 6px;
}
.defs-due {
display: grid;
grid-template-columns: 84px 1fr;
align-items: center;
gap: 8px;
font-size: 12.5px;
font-weight: 700;
}
.defs-hint {
margin: 0;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.defs-toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.defs-toggle-title {
font-size: 13.5px;
font-weight: 700;
}
.defs-toggle-sub {
font-size: var(--nq-a-size-meta);
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.defs-switch {
flex: none;
position: relative;
width: 44px;
height: 26px;
padding: 0;
border: 0;
border-radius: var(--nq-a-radius-pill);
background: var(--nq-a-border);
cursor: pointer;
transition: background var(--nq-dur-base) var(--nq-ease-out);
}
.defs-switch--on {
background: var(--nq-brand-gradient);
}
.defs-switch-knob {
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 2px rgba(9, 9, 11, 0.25);
transition: transform var(--nq-dur-base) var(--nq-ease-out);
}
.defs-switch--on .defs-switch-knob {
transform: translateX(18px);
}
.defs-sheet-error {
margin: 12px 0 0;
padding: 10px 12px;
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-danger-bg);
color: var(--nq-a-danger-strong);
font-size: 12.5px;
font-weight: 700;
}
.defs-sheet-footer {
display: flex;
gap: 8px;
margin-top: 16px;
}
.defs-confirm {
margin-top: 12px;
padding: 10px 12px;
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-danger-bg);
color: var(--nq-a-danger-strong);
font-size: 12.5px;
font-weight: 700;
}
.defs-confirm p {
margin: 0;
}
.defs-confirm .defs-sheet-footer {
margin-top: 10px;
}
.defs-cancel,
.defs-save {
padding: 13px 0;
border-radius: var(--nq-a-density-radius);
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.defs-cancel {
flex: 1;
border: 1px solid var(--nq-a-border);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
}
.defs-save {
flex: 2;
border: 0;
background: var(--nq-brand-gradient);
color: #fff;
}
.defs-cancel:disabled,
.defs-save:disabled {
opacity: 0.6;
cursor: default;
}
.defs-preview {
margin-top: 8px;
}
.defs-preview--error {
color: var(--nq-a-ink-secondary);
font-style: italic;
}
.defs-sublabel {
margin-top: 4px;
font-size: 12px;
font-weight: 700;
color: var(--nq-a-ink-secondary);
}
.defs-emoji {
line-height: 1;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
/**
* The icons a definition's `icon` may name (ADMIN-SPEC §3.3): the curated
* Lucide set, the curated Font Awesome Free solid subset, or an emoji. The
* names, labels and path data come from the shared icon registry
* (`src/icons/registry.generated.ts`, generated from
* `tools/icons/curated-icons.json`) — no CDN, no runtime fetch. The stored
* value is namespaced — `lucide:<name>`, `fa:<name>` or `emoji:<grapheme>` —
* and a legacy bare `<kebab-name>` still names the same Lucide entry.
*/
import { GlyphSvg } from "../icons/GlyphSvg";
import { FA_ICONS, LUCIDE_ICONS, resolveIcon, type GlyphIcon } from "../icons/registry.generated";
import { TaskGlyph } from "./glyphs";
export type DefinitionIconEntry = GlyphIcon;
export const DEFINITION_ICONS: readonly DefinitionIconEntry[] = LUCIDE_ICONS;
export const FA_DEFINITION_ICONS: readonly DefinitionIconEntry[] = FA_ICONS;
const LUCIDE_PREFIX = "lucide:";
const FA_PREFIX = "fa:";
const EMOJI_PREFIX = "emoji:";
/** The canonical stored key for a Lucide choice, e.g. `lucide:dog`. */
export function lucideIconKey(name: string): string {
return `${LUCIDE_PREFIX}${name}`;
}
/** The canonical stored key for a Font Awesome choice, e.g. `fa:dog`. */
export function faIconKey(name: string): string {
return `${FA_PREFIX}${name}`;
}
/**
* The stored key for typed emoji text, e.g. `emoji:🐶`. The raw text — not
* trimmed, as the backend rejects any whitespace — must pass the registry's
* emoji check (1-8 code points, no whitespace, control characters or `<`,
* `>`, `&`), else null. Only an empty field means "no icon".
*/
export function emojiIconKey(text: string): string | null {
const key = `${EMOJI_PREFIX}${text}`;
return resolveIcon(key).kind === "emoji" ? key : null;
}
/**
* The Lucide name an icon value refers to: `lucide:dog` and a legacy bare
* `dog` both give `dog`. Any other namespace (`fa:`, `emoji:`, unknown) and
* null give null — the admin has no Lucide entry for them.
*/
export function normalizeDefinitionIconName(value: string | null): string | null {
if (value === null) return null;
if (value.startsWith(LUCIDE_PREFIX)) return value.slice(LUCIDE_PREFIX.length);
return value.includes(":") ? null : value;
}
/** The Font Awesome name an `fa:<name>` value refers to; anything else gives null. */
export function faIconName(value: string | null): string | null {
return value?.startsWith(FA_PREFIX) ? value.slice(FA_PREFIX.length) : null;
}
/** The emoji text an `emoji:<grapheme>` value holds; anything else gives "". */
export function emojiIconText(value: string | null): string {
return value?.startsWith(EMOJI_PREFIX) ? value.slice(EMOJI_PREFIX.length) : "";
}
/**
* The curated glyph (Lucide or Font Awesome) an icon value resolves to via
* the shared parser; emoji, unknown and null values give null.
*/
export function findDefinitionIcon(value: string | null): DefinitionIconEntry | null {
const icon = resolveIcon(value);
return icon.kind === "lucide" || icon.kind === "fa" ? icon : null;
}
/**
* A definition's icon: a Lucide or Font Awesome glyph, an emoji drawn as
* text, or — for no icon or an unknown value — the generic task glyph.
*/
export function DefinitionIcon({ name, size = 17 }: { name: string | null; size?: number }) {
const icon = resolveIcon(name);
if (icon.kind === "fallback") return <TaskGlyph size={size} />;
if (icon.kind === "emoji") {
return (
<span className="defs-emoji" style={{ fontSize: size }} aria-hidden="true" data-emoji={icon.text}>
{icon.text}
</span>
);
}
return <GlyphSvg icon={icon} size={size} />;
}
+114
View File
@@ -0,0 +1,114 @@
/**
* List-row summaries for the Definitions tab (ADMIN-SPEC §3.1):
* `<assignees> · <recurrence> · <windows>`.
*/
import type {
AdminChild,
DefinitionAssignee,
DefinitionRule,
DefinitionWindow,
WindowName,
} from "../api/definitions";
export const WINDOW_ORDER: WindowName[] = ["morning", "afternoon", "evening"];
export const WINDOW_LABELS: Record<WindowName, string> = {
morning: "Morning",
afternoon: "Afternoon",
evening: "Evening",
};
/** Index 0 = Monday .. 6 = Sunday, the core's weekday numbering. */
export const WEEKDAY_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
/** Display order for a week: Sunday first. The stored indices stay Monday=0. */
export const WEEKDAY_DISPLAY_ORDER = [6, 0, 1, 2, 3, 4, 5];
export const MONTH_SHORT = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
const COUNT_WORDS = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"];
/**
* `Declan`, `Declan, Maeve`, or `All three` when every active child (two or
* more) is assigned.
*/
export function assigneeSummary(
assignees: Pick<DefinitionAssignee, "id" | "display_name">[],
activeChildren: AdminChild[],
): string {
if (assignees.length === 0) return "Unassigned";
const assigned = new Set(assignees.map((a) => a.id));
const everyone =
activeChildren.length > 1 &&
assigned.size === activeChildren.length &&
activeChildren.every((child) => assigned.has(child.id));
if (everyone) {
const word = COUNT_WORDS[activeChildren.length] ?? String(activeChildren.length);
return `All ${word}`;
}
return assignees.map((a) => a.display_name).join(", ");
}
function weekdayList(days: number[] | null): string {
return [...(days ?? [])]
.sort((a, b) => WEEKDAY_DISPLAY_ORDER.indexOf(a) - WEEKDAY_DISPLAY_ORDER.indexOf(b))
.map((day) => WEEKDAY_SHORT[day])
.join(", ");
}
function ordinal(n: number): string {
if (n === -1) return "last";
const suffix = n === 1 ? "st" : n === 2 ? "nd" : n === 3 ? "rd" : "th";
return `${n}${suffix}`;
}
function every(interval: number, one: string, unit: string): string {
return interval === 1 ? one : `Every ${interval} ${unit}`;
}
export function recurrenceSummary(rule: DefinitionRule): string {
const n = rule.interval;
switch (rule.rule_type) {
case "daily":
return every(n, "Daily", "days");
case "weekly":
return `${every(n, "Weekly", "weeks")} on ${weekdayList(rule.weekday_set)}`;
case "monthly_day":
return `${every(n, "Monthly", "months")} on day ${rule.day_of_month}`;
case "monthly_weekday":
return `${every(n, "Monthly", "months")} on the ${ordinal(rule.nth_weekday ?? 1)} ${
WEEKDAY_SHORT[rule.nth_weekday_weekday ?? 0]
}`;
case "yearly": {
const month = MONTH_SHORT[(rule.month ?? 1) - 1];
const when = rule.day_of_month ? `on ${month} ${rule.day_of_month}` : `in ${month}`;
return `${every(n, "Yearly", "years")} ${when}`;
}
case "custom_days":
return `Custom${n === 1 ? "" : ` every ${n} weeks`} (${weekdayList(rule.weekday_set)})`;
}
}
export function windowSummary(windows: Pick<DefinitionWindow, "window">[]): string {
const names = new Set(windows.map((w) => w.window));
return WINDOW_ORDER.filter((w) => names.has(w))
.map((w) => WINDOW_LABELS[w])
.join(", ");
}
/**
* "2026-10-06" -> "Tue 6 Oct". The calendar date is read by its components
* and its weekday taken in UTC (as the Today tab does), so the printed day
* never shifts with the viewer's offset — `new Date("YYYY-MM-DD")` would.
*/
export function occurrenceLabel(iso: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
if (!match) throw new Error(`Invalid calendar date: ${iso}`);
const [, year, month, day] = match.map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
// getUTCDay: 0 = Sunday; WEEKDAY_SHORT: 0 = Monday.
return `${WEEKDAY_SHORT[(date.getUTCDay() + 6) % 7]} ${day} ${MONTH_SHORT[month - 1]}`;
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Inline stroke glyphs for the Definitions tab (Lucide-style 24px grid),
* CDN-free like the Today tab's. Definition icons arrive with the icon
* picker task; until then every row carries the generic task glyph.
*/
import type { ReactNode } from "react";
function Glyph({ size, children }: { size: number; children: ReactNode }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
);
}
export function TaskGlyph({ size = 17 }: { size?: number }) {
return (
<Glyph size={size}>
<rect x="8" y="2" width="8" height="4" rx="1" />
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<path d="m9 14 2 2 4-4" />
</Glyph>
);
}
export function PlusGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="M12 5v14M5 12h14" />
</Glyph>
);
}
export function ChevronRightGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="m9 18 6-6-6-6" />
</Glyph>
);
}
export function ChevronDownGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="m6 9 6 6 6-6" />
</Glyph>
);
}
export function CheckGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="M20 6 9 17l-5-5" />
</Glyph>
);
}
+296
View File
@@ -0,0 +1,296 @@
/* History tab — compact density (design/ADMIN-SPEC.md §5). Mobile-first at
the 390x844 reference; on wider viewports the column is capped and centred. */
.history-screen {
width: 100%;
max-width: 640px;
margin: 0 auto;
}
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--nq-a-surface);
border-bottom: 1px solid var(--nq-a-border);
padding: 14px 16px;
}
.history-title {
margin: 0;
font-size: var(--nq-a-size-screen);
font-weight: 800;
letter-spacing: -0.02em;
}
.history-sub {
margin: 2px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.history-csv {
flex: none;
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 36px;
padding: 0 12px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
}
.history-csv:disabled {
cursor: progress;
opacity: 0.6;
}
/* Non-blocking CSV export failure; the tab stays usable. */
.history-export-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: var(--nq-a-density-row-pad);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-danger-bg);
color: var(--nq-a-danger-strong);
font-size: 13px;
font-weight: 600;
}
.history-export-dismiss {
flex: none;
padding: 4px 8px;
border: none;
background: none;
color: inherit;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
}
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
.history-scroll {
display: flex;
flex-direction: column;
gap: var(--nq-a-density-gap);
padding: 14px 16px;
}
.history-card {
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
}
/* Stat row */
.history-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.history-stat {
padding: 12px;
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
}
.history-stat-value {
font-size: 20px;
font-weight: 800;
line-height: 1.1;
}
.history-stat-value--on-time {
color: var(--nq-brand-blue);
}
.history-stat-value--missed {
color: var(--nq-a-danger);
}
.history-stat-label {
margin-top: 2px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--nq-a-ink-secondary);
}
/* Filter chips */
.history-chips {
display: flex;
gap: 6px;
overflow-x: auto;
}
.history-chip {
flex: none;
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-radius-pill);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.history-chip--active {
border-color: #18181d;
background: #18181d;
color: #fff;
}
/* Day-grouped event rows */
.history-day {
display: flex;
flex-direction: column;
gap: 6px;
}
.history-section-label {
margin: 4px 0 0;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--nq-a-ink-secondary);
}
.history-list {
margin: 0;
padding: 0;
list-style: none;
}
.history-list > li + li {
border-top: 1px solid var(--nq-a-divider);
}
.history-event {
display: flex;
align-items: center;
gap: 10px;
min-height: var(--nq-a-tap-min);
padding: var(--nq-a-density-row-pad);
}
.history-dot {
flex: none;
width: 7px;
height: 7px;
border-radius: 50%;
}
.history-dot--completed {
background: var(--nq-a-success);
}
.history-dot--uncompleted {
background: var(--nq-a-reversal);
}
.history-dot--missed {
background: var(--nq-a-danger);
}
.history-event-text {
flex: 1;
min-width: 0;
}
.history-event-title {
font-size: 12.5px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-event-meta {
margin-top: 1px;
font-size: 10.5px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.history-event-time {
flex: none;
font-size: 11px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.history-empty {
padding: var(--nq-a-density-card-pad);
font-size: 13px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
/* Append-only footer strip */
.history-note {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-page);
color: var(--nq-a-ink-secondary);
font-size: 11.5px;
font-weight: 600;
}
.history-note-glyph {
flex: none;
display: flex;
}
/* Loading / error */
.history-status {
padding: 24px 13px;
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
text-align: center;
font-size: 13.5px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.history-status--error {
color: var(--nq-a-danger-strong);
}
.history-status--error p {
margin: 0 0 10px;
}
.history-retry {
padding: 8px 16px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
+631
View File
@@ -0,0 +1,631 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import HistoryTab, { FOOTER_NOTE, computeStats } from "./HistoryTab";
import type { HistoryFilter, HistoryRow } from "../api/history";
import { storeTokens } from "../auth/oidc";
import { TransitionsProvider } from "../api/useTransitions";
import { frame, testStream, type TestStream } from "../api/testStream";
const TODAY = "2026-09-23";
function row(overrides: Partial<HistoryRow>): HistoryRow {
return {
occurred_at: null,
event_type: "completed",
child_name: "Declan",
child_id: 1,
quest_title: "Brush teeth",
instance_id: 1,
window: "morning",
due_date: TODAY,
due_time: null,
actor: "panel (Declan)",
was_on_time: true,
...overrides,
};
}
// America/Los_Angeles is UTC-7 in September.
const BRUSH = row({
instance_id: 1,
occurred_at: "2026-09-23T14:04:00Z", // 7:04 AM
due_time: "07:30",
});
const BED_DONE = row({
instance_id: 2,
quest_title: "Make bed",
child_name: "Maeve",
child_id: 2,
occurred_at: "2026-09-23T14:50:00Z", // 7:50 AM
actor: "panel (Maeve)",
});
const BED_UNDONE = row({
instance_id: 2,
event_type: "uncompleted",
quest_title: "Make bed",
child_name: "Maeve",
child_id: 2,
occurred_at: "2026-09-23T15:10:00Z", // 8:10 AM
actor: "joshua (admin)",
was_on_time: null,
});
const CAT_LATE = row({
instance_id: 3,
quest_title: "Feed cat",
due_date: "2026-09-22",
due_time: "07:00",
occurred_at: "2026-09-22T14:22:00Z", // 7:22 AM, 22 min after due
was_on_time: false,
});
// 7:30 PM local on Sep 22 — already Sep 23 in UTC.
const HOMEWORK_LATE = row({
instance_id: 4,
quest_title: "Homework",
child_name: "Rory",
child_id: 3,
due_date: "2026-09-22",
due_time: null,
occurred_at: "2026-09-23T02:30:00Z",
actor: "panel (Rory)",
was_on_time: false,
});
const PLANTS_MISSED = row({
instance_id: 5,
event_type: "missed",
quest_title: "Water plants",
child_name: "Rory",
child_id: 3,
due_date: "2026-09-21",
due_time: "18:00",
actor: "nightly sweep",
was_on_time: null,
});
// The API's order: due_date, then instance, then event id.
const ROWS: Record<HistoryFilter, HistoryRow[]> = {
all: [CAT_LATE, HOMEWORK_LATE, BRUSH, BED_DONE, BED_UNDONE],
reversals: [BED_UNDONE],
missed: [PLANTS_MISSED],
};
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
const CSV_BODY = "occurred_at,event_type\n2026-09-23T14:04:00Z,completed\n";
function csvResponse(filter: string, start: string, end: string): Response {
return new Response(`${filter}\n${CSV_BODY}`, {
status: 200,
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="nestquest-history-${start}-to-${end}.csv"`,
},
});
}
function routeFetch(url: string): Response {
const parsed = new URL(url, "http://x");
if (parsed.pathname.endsWith("/api/v1/admin/history.csv")) {
return csvResponse(
parsed.searchParams.get("filter") ?? "",
parsed.searchParams.get("start") ?? "",
parsed.searchParams.get("end") ?? "",
);
}
if (!parsed.pathname.endsWith("/api/v1/admin/history")) {
return jsonResponse({ detail: "unexpected" }, 500);
}
const filter = parsed.searchParams.get("filter") as HistoryFilter;
const rows = ROWS[filter];
return jsonResponse({
filter,
start: parsed.searchParams.get("start"),
end: parsed.searchParams.get("end"),
count: rows.length,
rows,
});
}
let fetchMock: ReturnType<typeof vi.fn>;
function historyCalls(): URL[] {
return fetchMock.mock.calls.map(([url]) => new URL(String(url), "http://x"));
}
function metas(section: HTMLElement): string[] {
return within(section)
.getAllByTestId("history-meta")
.map((el) => el.textContent ?? "");
}
async function renderReady() {
render(<HistoryTab today={TODAY} />);
await screen.findByTestId("stat-on-time");
}
describe("HistoryTab", () => {
beforeAll(() => {
vi.stubEnv("TZ", "America/Los_Angeles");
});
afterAll(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
fetchMock = vi.fn((url: string) => Promise.resolve(routeFetch(url)));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("renders the header and queries the last 14 days with the Bearer token", async () => {
await renderReady();
expect(screen.getByRole("heading", { name: "History" })).toBeTruthy();
expect(screen.getByText("Append-only event log · last 14 days")).toBeTruthy();
const calls = historyCalls();
expect(calls.length).toBeGreaterThan(0);
for (const url of calls) {
expect(url.pathname).toBe("/api/v1/admin/history");
expect(url.searchParams.get("start")).toBe("2026-09-10");
expect(url.searchParams.get("end")).toBe(TODAY);
}
expect(calls.map((url) => url.searchParams.get("filter")).sort()).toEqual(["all", "missed"]);
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe("Bearer at-123");
});
it("stat row shows On time %, Completed and Missed for the period", async () => {
await renderReady();
// 4 completions (2 on time) in `all`; 1 derived missed row.
expect(screen.getByTestId("stat-on-time").textContent).toBe("50%On time");
expect(screen.getByTestId("stat-completed").textContent).toBe("4Completed");
expect(screen.getByTestId("stat-missed").textContent).toBe("1Missed");
});
it("On time shows — when the period has no completions", () => {
expect(computeStats([BED_UNDONE], [])).toEqual({ onTimePct: null, completed: 0, missed: 0 });
});
it("groups rows by local day, newest first, with event type, actor and on-time meta", async () => {
await renderReady();
const today = screen.getByTestId(`day-${TODAY}`);
expect(within(today).getByRole("heading").textContent).toBe("Today");
expect(metas(today)).toEqual([
"uncompleted · joshua (admin)",
"completed · panel (Maeve) · on time",
"completed · panel (Declan) · on time",
]);
expect(within(today).getAllByText("Make bed · Maeve")).toHaveLength(2);
expect(within(today).getByText("8:10 AM")).toBeTruthy();
// The 7:30 PM completion is Sep 23 in UTC but Sep 22 locally.
const yesterday = screen.getByTestId("day-2026-09-22");
expect(within(yesterday).getByRole("heading").textContent).toBe("Yesterday");
expect(metas(yesterday)).toEqual([
"completed · panel (Rory) · late",
"completed · panel (Declan) · late 22 min",
]);
expect(within(yesterday).getByText("7:30 PM")).toBeTruthy();
const dots = screen.getAllByTestId("history-dot").map((el) => el.className);
expect(dots[0]).toContain("history-dot--uncompleted");
expect(dots[1]).toContain("history-dot--completed");
});
it("each filter chip re-queries the API with its filter and re-renders", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
await waitFor(() =>
expect(historyCalls().some((url) => url.searchParams.get("filter") === "reversals")).toBe(true),
);
await waitFor(() => expect(screen.getAllByTestId("history-row")).toHaveLength(1));
expect(screen.getByTestId("history-meta").textContent).toBe("uncompleted · joshua (admin)");
expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
fireEvent.click(screen.getByRole("button", { name: "Missed" }));
const missedDay = await screen.findByTestId("day-2026-09-21");
expect(within(missedDay).getByRole("heading").textContent).toBe("Mon, Sep 21");
expect(metas(missedDay)).toEqual(["missed · nightly sweep"]);
expect(within(missedDay).getByTestId("history-dot").className).toContain("history-dot--missed");
expect(within(missedDay).getByText("6:00 PM")).toBeTruthy();
// Stats stay the period's numbers whichever chip is selected.
expect(screen.getByTestId("stat-completed").textContent).toBe("4Completed");
fetchMock.mockClear();
fireEvent.click(screen.getByRole("button", { name: "All events" }));
await screen.findByTestId(`day-${TODAY}`);
expect(historyCalls().map((url) => url.searchParams.get("filter"))).toContain("all");
expect(screen.getAllByTestId("history-row")).toHaveLength(5);
});
it("shows the append-only footer", async () => {
await renderReady();
expect(FOOTER_NOTE).toBe("Events are never edited or deleted. A reversal is its own row.");
expect(screen.getByTestId("history-footer").textContent).toBe(FOOTER_NOTE);
});
describe("CSV export", () => {
let createObjectURL: ReturnType<typeof vi.fn>;
let revokeObjectURL: ReturnType<typeof vi.fn>;
let clicked: { href: string; download: string; attached: boolean }[];
beforeEach(() => {
createObjectURL = vi.fn(() => "blob:nestquest/csv-1");
revokeObjectURL = vi.fn();
// jsdom has no object URLs; install stubs and remove them afterwards.
Object.assign(URL, { createObjectURL, revokeObjectURL });
clicked = [];
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) {
clicked.push({
href: this.getAttribute("href") ?? "",
download: this.download,
attached: document.body.contains(this),
});
});
});
afterEach(() => {
vi.restoreAllMocks();
const stubbed = URL as unknown as Record<string, unknown>;
delete stubbed.createObjectURL;
delete stubbed.revokeObjectURL;
});
function csvCalls(): [URL, RequestInit][] {
return fetchMock.mock.calls
.map(([url, init]) => [new URL(String(url), "http://x"), init] as [URL, RequestInit])
.filter(([url]) => url.pathname === "/api/v1/admin/history.csv");
}
it("requests the CSV for the active filter and displayed range with the Bearer token", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "CSV" }));
await waitFor(() => expect(clicked).toHaveLength(1));
const calls = csvCalls();
expect(calls).toHaveLength(1);
const [url, init] = calls[0];
expect(url.searchParams.get("filter")).toBe("all");
expect(url.searchParams.get("start")).toBe("2026-09-10");
expect(url.searchParams.get("end")).toBe(TODAY);
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer at-123");
// The token never travels in the URL.
expect(url.toString()).not.toContain("at-123");
});
it("offers the returned CSV for download, then revokes the URL and removes the anchor", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "CSV" }));
await waitFor(() => expect(clicked).toHaveLength(1));
expect(createObjectURL).toHaveBeenCalledTimes(1);
const blob = createObjectURL.mock.calls[0][0] as Blob;
expect(await blob.text()).toBe(`all\n${CSV_BODY}`);
expect(clicked[0]).toEqual({
href: "blob:nestquest/csv-1",
download: "nestquest-history-2026-09-10-to-2026-09-23.csv",
attached: true,
});
expect(revokeObjectURL).toHaveBeenCalledWith("blob:nestquest/csv-1");
expect(document.querySelector("a[download]")).toBeNull();
expect(screen.queryByTestId("history-export-error")).toBeNull();
});
it("falls back to the range filename without a Content-Disposition header", async () => {
fetchMock.mockImplementation((url: string) =>
Promise.resolve(
new URL(url, "http://x").pathname.endsWith(".csv")
? new Response(CSV_BODY, { status: 200, headers: { "Content-Type": "text/csv" } })
: routeFetch(url),
),
);
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "CSV" }));
await waitFor(() => expect(clicked).toHaveLength(1));
expect(clicked[0].download).toBe("nestquest-history-2026-09-10-to-2026-09-23.csv");
});
it("switching the filter chip then exporting uses the new filter", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
await waitFor(() => expect(screen.getAllByTestId("history-row")).toHaveLength(1));
fireEvent.click(screen.getByRole("button", { name: "CSV" }));
await waitFor(() => expect(clicked).toHaveLength(1));
const calls = csvCalls();
expect(calls).toHaveLength(1);
expect(calls[0][0].searchParams.get("filter")).toBe("reversals");
expect(calls[0][0].searchParams.get("start")).toBe("2026-09-10");
expect(calls[0][0].searchParams.get("end")).toBe(TODAY);
});
it("a failed export shows a non-blocking error and does not download", async () => {
fetchMock.mockImplementation((url: string) =>
Promise.resolve(
new URL(url, "http://x").pathname.endsWith(".csv")
? jsonResponse({ detail: "boom" }, 500)
: routeFetch(url),
),
);
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "CSV" }));
const error = await screen.findByTestId("history-export-error");
expect(error.textContent).toContain("The CSV export failed");
expect(createObjectURL).not.toHaveBeenCalled();
expect(clicked).toHaveLength(0);
// The tab stays usable: rows remain, the button is re-enabled, chips still work.
expect(screen.getAllByTestId("history-row")).toHaveLength(5);
expect((screen.getByRole("button", { name: "CSV" }) as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
await waitFor(() => expect(screen.getAllByTestId("history-row")).toHaveLength(1));
fireEvent.click(within(error).getByRole("button", { name: "Dismiss" }));
expect(screen.queryByTestId("history-export-error")).toBeNull();
});
it("the button is disabled while the request is in flight (no double-submit)", async () => {
let release: (response: Response) => void = () => {};
fetchMock.mockImplementation((url: string) =>
new URL(url, "http://x").pathname.endsWith(".csv")
? new Promise<Response>((resolve) => {
release = resolve;
})
: Promise.resolve(routeFetch(url)),
);
await renderReady();
const csv = screen.getByRole("button", { name: "CSV" }) as HTMLButtonElement;
fireEvent.click(csv);
await waitFor(() => expect(csv.disabled).toBe(true));
fireEvent.click(csv);
expect(csvCalls()).toHaveLength(1);
release(csvResponse("all", "2026-09-10", TODAY));
await waitFor(() => expect(clicked).toHaveLength(1));
await waitFor(() => expect(csv.disabled).toBe(false));
expect(csvCalls()).toHaveLength(1);
});
});
it("rows are read-only: no row carries a control", async () => {
await renderReady();
for (const el of screen.getAllByTestId("history-row")) {
expect(within(el).queryByRole("button")).toBeNull();
}
});
it("a failed fetch shows the error state and Try again reloads", async () => {
fetchMock.mockImplementation(() => Promise.resolve(jsonResponse({ detail: "boom" }, 500)));
render(<HistoryTab today={TODAY} />);
const error = await screen.findByTestId("history-error");
expect(error.textContent).toContain("The history could not be loaded");
expect(screen.getByTestId("history-footer")).toBeTruthy();
fetchMock.mockImplementation((url: string) => Promise.resolve(routeFetch(url)));
fireEvent.click(within(error).getByRole("button", { name: "Try again" }));
await screen.findByTestId("stat-on-time");
expect(screen.queryByTestId("history-error")).toBeNull();
});
it("the scroll column ends with 92px bottom padding", async () => {
await renderReady();
expect(screen.getByTestId("history-scroll").style.paddingBottom).toBe("92px");
});
});
describe("HistoryTab live updates", () => {
let fetchMock: ReturnType<typeof vi.fn>;
let streams: TestStream[];
function historyRequests(): URL[] {
return fetchMock.mock.calls
.map(([url]) => new URL(String(url), "http://x"))
.filter((url) => url.pathname === "/api/v1/admin/history");
}
const PAYLOAD = {
child_id: 2,
child_name: "Maeve",
instance_id: 2,
quest_title: "Make bed",
window: "morning",
due_date: TODAY,
due_time: null,
occurred_at: "2026-09-23T15:10:00Z",
};
beforeAll(() => {
vi.stubEnv("TZ", "America/Los_Angeles");
});
afterAll(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
streams = [];
fetchMock = vi.fn((url: string) => {
if (new URL(url, "http://x").pathname.endsWith("/api/v1/admin/events")) {
const stream = testStream();
streams.push(stream);
return Promise.resolve(stream.response);
}
return Promise.resolve(routeFetch(url));
});
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("a quest transition refetches the active filter; a day-complete does not", async () => {
render(
<TransitionsProvider>
<HistoryTab today={TODAY} />
</TransitionsProvider>,
);
await screen.findByTestId("stat-on-time");
fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
await waitFor(() =>
expect(historyRequests().some((u) => u.searchParams.get("filter") === "reversals")).toBe(true),
);
await screen.findByTestId("stat-on-time");
await waitFor(() => expect(streams).toHaveLength(1));
const before = historyRequests().length;
streams[0].push(
frame("nestquest_child_day_complete", {
child_id: 2,
child_name: "Maeve",
quests_due: 4,
quests_completed: 4,
occurred_at: "2026-09-23T15:00:00Z",
}),
);
await new Promise((resolve) => setTimeout(resolve, 30));
expect(historyRequests().length).toBe(before);
streams[0].push(frame("nestquest_quest_uncompleted", PAYLOAD));
await waitFor(() => expect(historyRequests().length).toBe(before + 3));
const refetched = historyRequests().slice(before);
// One refetch (reversals + the two stats queries), same range and filter.
expect(refetched.map((u) => u.searchParams.get("filter")).sort()).toEqual([
"all",
"missed",
"reversals",
]);
for (const url of refetched) {
expect(url.searchParams.get("start")).toBe("2026-09-10");
expect(url.searchParams.get("end")).toBe(TODAY);
}
await new Promise((resolve) => setTimeout(resolve, 30));
expect(historyRequests().length).toBe(before + 3);
expect(screen.queryByTestId("history-loading")).toBeNull();
expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
// Rows stay read-only (D-005): no row carries a control.
for (const row of screen.getAllByTestId("history-row")) {
expect(within(row).queryByRole("button")).toBeNull();
}
});
describe("a filter change interleaved with a quest transition", () => {
// Both land before the load effect runs, so one load serves both.
async function interleave(order: "sse-first" | "chip-first") {
await act(async () => {
const click = () => fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
if (order === "chip-first") click();
streams[0].push(frame("nestquest_quest_uncompleted", PAYLOAD));
await new Promise((resolve) => setTimeout(resolve, 30));
if (order === "sse-first") click();
});
}
async function renderLive() {
render(
<TransitionsProvider>
<HistoryTab today={TODAY} />
</TransitionsProvider>,
);
await screen.findByTestId("stat-on-time");
await waitFor(() => expect(streams).toHaveLength(1));
expect(screen.getAllByTestId("history-row")).toHaveLength(ROWS.all.length);
}
function titles(): string[] {
return screen.getAllByTestId("history-row").map((el) => el.textContent ?? "");
}
for (const order of ["sse-first", "chip-first"] as const) {
it(`${order}: shows loading, then only the active filter's rows`, async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const route = fetchMock.getMockImplementation()!;
await renderLive();
fetchMock.mockImplementation((url: string) => {
const parsed = new URL(url, "http://x");
if (parsed.searchParams.get("filter") === "reversals") {
return gate.then(() => route(url));
}
return route(url);
});
await interleave(order);
expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
// The previous filter's rows never show under the new chip.
expect(screen.getByTestId("history-loading")).toBeTruthy();
expect(screen.queryAllByTestId("history-row")).toHaveLength(0);
release();
await screen.findByTestId("stat-on-time");
expect(titles()).toHaveLength(1);
expect(titles()[0]).toContain("Make bed · Maeve");
expect(screen.getAllByTestId("history-dot")[0].className).toContain("history-dot--uncompleted");
});
it(`${order}: a failed filter-change load shows the error state, not stale rows`, async () => {
const route = fetchMock.getMockImplementation()!;
await renderLive();
fetchMock.mockImplementation((url: string) => {
const parsed = new URL(url, "http://x");
if (parsed.pathname === "/api/v1/admin/history") {
return Promise.resolve(jsonResponse({ detail: "boom" }, 500));
}
return route(url);
});
await interleave(order);
const error = await screen.findByTestId("history-error");
expect(error.textContent).toContain("The history could not be loaded");
expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
expect(screen.queryAllByTestId("history-row")).toHaveLength(0);
expect(screen.queryByTestId("stat-on-time")).toBeNull();
});
}
});
it("a failed transition refresh keeps the active filter's rows on screen", async () => {
render(
<TransitionsProvider>
<HistoryTab today={TODAY} />
</TransitionsProvider>,
);
await screen.findByTestId("stat-on-time");
await waitFor(() => expect(streams).toHaveLength(1));
const route = fetchMock.getMockImplementation()!;
fetchMock.mockImplementation((url: string) => {
if (new URL(url, "http://x").pathname === "/api/v1/admin/history") {
return Promise.resolve(jsonResponse({ detail: "boom" }, 500));
}
return route(url);
});
const before = historyRequests().length;
streams[0].push(frame("nestquest_quest_uncompleted", PAYLOAD));
// The "all" filter plus the missed stats query.
await waitFor(() => expect(historyRequests().length).toBe(before + 2));
await new Promise((resolve) => setTimeout(resolve, 30));
expect(screen.queryByTestId("history-error")).toBeNull();
expect(screen.queryByTestId("history-loading")).toBeNull();
expect(screen.getAllByTestId("history-row")).toHaveLength(ROWS.all.length);
});
});
+300
View File
@@ -0,0 +1,300 @@
/**
* History tab — the append-only event log (design/ADMIN-SPEC.md §5).
*
* Rows are read-only: events are never edited or deleted, and a reversal is
* its own row (D-005).
*/
import { useEffect, useRef, useState } from "react";
import { ApiForbiddenError } from "../api/client";
import type { TransitionEvent } from "../api/events";
import { useTransitions } from "../api/useTransitions";
import { exportHistoryCsv, fetchHistory, type HistoryFilter, type HistoryRow } from "../api/history";
import { localTodayIso } from "../schedule/cycle";
import { dayLabel, groupByDay, rowMeta, rowTime, shiftIso } from "./format";
import { DownloadGlyph, LockGlyph } from "./glyphs";
import "./HistoryTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
export const SCROLL_BOTTOM_PADDING = "92px";
export const RANGE_DAYS = 14;
export const FOOTER_NOTE = "Events are never edited or deleted. A reversal is its own row.";
export const FILTERS: { id: HistoryFilter; label: string }[] = [
{ id: "all", label: "All events" },
{ id: "reversals", label: "Reversals" },
{ id: "missed", label: "Missed" },
];
export interface HistoryStats {
/** Whole percent, or null when the period has no completions. */
onTimePct: number | null;
completed: number;
missed: number;
}
/** Per-period stats from the `all` rows (completions) and the `missed` rows. */
export function computeStats(allRows: HistoryRow[], missedRows: HistoryRow[]): HistoryStats {
const completions = allRows.filter((row) => row.event_type === "completed");
const onTime = completions.filter((row) => row.was_on_time === true).length;
return {
onTimePct: completions.length > 0 ? Math.round((onTime / completions.length) * 100) : null,
completed: completions.length,
missed: missedRows.filter((row) => row.event_type === "missed").length,
};
}
interface Loaded {
rows: HistoryRow[];
stats: HistoryStats;
}
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
/** `key` is the (filter, start, end) the rows were loaded for. */
| { kind: "ready"; data: Loaded; key: string };
/**
* The selected filter's rows plus the period stats. Stats always come from
* the `all` and `missed` queries, whichever chip is selected, so switching
* chips never changes the period's numbers.
*/
async function loadHistory(filter: HistoryFilter, start: string, end: string): Promise<Loaded> {
const [selected, all, missed] = await Promise.all([
fetchHistory(filter, start, end),
filter === "all" ? null : fetchHistory("all", start, end),
filter === "missed" ? null : fetchHistory("missed", start, end),
]);
return {
rows: selected.rows,
stats: computeStats((all ?? selected).rows, (missed ?? selected).rows),
};
}
/** Offer a Blob as a file download without navigating the page. */
export function offerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.style.display = "none";
document.body.appendChild(anchor);
try {
anchor.click();
} finally {
anchor.remove();
URL.revokeObjectURL(url);
}
}
function ScreenHeader({ exporting, onExport }: { exporting: boolean; onExport: () => void }) {
return (
<header className="history-header">
<div>
<h2 className="history-title">History</h2>
<p className="history-sub">Append-only event log · last {RANGE_DAYS} days</p>
</div>
<button
type="button"
className="history-csv"
data-testid="history-csv"
disabled={exporting}
aria-busy={exporting}
onClick={onExport}
>
<DownloadGlyph />
CSV
</button>
</header>
);
}
function StatRow({ stats }: { stats: HistoryStats }) {
const items = [
{ key: "on-time", label: "On time", value: stats.onTimePct === null ? "—" : `${stats.onTimePct}%` },
{ key: "completed", label: "Completed", value: String(stats.completed) },
{ key: "missed", label: "Missed", value: String(stats.missed) },
];
return (
<div className="history-stats">
{items.map((item) => (
<div key={item.key} className="history-stat" data-testid={`stat-${item.key}`}>
<div className={`history-stat-value history-stat-value--${item.key}`}>{item.value}</div>
<div className="history-stat-label">{item.label}</div>
</div>
))}
</div>
);
}
function EventRow({ row }: { row: HistoryRow }) {
return (
<li className="history-event" data-testid="history-row">
<span
className={`history-dot history-dot--${row.event_type}`}
data-testid="history-dot"
aria-hidden="true"
/>
<div className="history-event-text">
<div className="history-event-title">
{row.quest_title} · {row.child_name}
</div>
<div className="history-event-meta" data-testid="history-meta">
{rowMeta(row)}
</div>
</div>
<span className="history-event-time">{rowTime(row)}</span>
</li>
);
}
function FooterNote() {
return (
<div className="history-note" data-testid="history-footer">
<span className="history-note-glyph">
<LockGlyph />
</span>
<span>{FOOTER_NOTE}</span>
</div>
);
}
export default function HistoryTab({ today = localTodayIso() }: { today?: string }) {
const [filter, setFilter] = useState<HistoryFilter>("all");
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const exportingRef = useRef(false);
const start = shiftIso(today, -(RANGE_DAYS - 1));
const key = `${filter}|${start}|${today}`;
// A quest transition is a new history row (or changes a derived missed row);
// a day-complete adds none. The refetch keeps the active filter and range.
useTransitions((event: TransitionEvent) => {
if (event.type === "nestquest_child_day_complete") return;
setAttempt((n) => n + 1);
});
/** Download the CSV for the active filter over the displayed range. */
async function exportCsv() {
if (exportingRef.current) return;
exportingRef.current = true;
setExporting(true);
setExportError(null);
try {
const { blob, filename } = await exportHistoryCsv(filter, start, today);
offerDownload(blob, filename);
} catch (error: unknown) {
setExportError(
error instanceof ApiForbiddenError
? error.message
: "The CSV export failed. Check your connection and try again.",
);
} finally {
exportingRef.current = false;
setExporting(false);
}
}
useEffect(() => {
let cancelled = false;
// Rows already loaded for this (filter, range) stay on screen while they
// refresh; any other rows give way to the loading state.
const isCurrent = (prev: LoadState) => prev.kind === "ready" && prev.key === key;
setState((prev) => (isCurrent(prev) ? prev : { kind: "loading" }));
loadHistory(filter, start, today)
.then((data) => {
if (!cancelled) setState({ kind: "ready", data, key });
})
.catch((error: unknown) => {
if (cancelled) return;
const message =
error instanceof ApiForbiddenError
? error.message
: "The history could not be loaded. Check your connection and try again.";
// A failed refresh keeps the rows already on screen.
setState((prev) => (isCurrent(prev) ? prev : { kind: "error", message }));
});
return () => {
cancelled = true;
};
}, [filter, start, today, key, attempt]);
// Never render one filter's rows under another's chip, even for the render
// before the load effect runs.
const shown: LoadState = state.kind === "ready" && state.key !== key ? { kind: "loading" } : state;
let body;
if (shown.kind === "loading") {
body = (
<div className="history-status" role="status" data-testid="history-loading">
Loading history…
</div>
);
} else if (shown.kind === "error") {
body = (
<div className="history-status history-status--error" role="alert" data-testid="history-error">
<p>{shown.message}</p>
<button type="button" className="history-retry" onClick={() => setAttempt((n) => n + 1)}>
Try again
</button>
</div>
);
} else {
const groups = groupByDay(shown.data.rows);
body =
groups.length > 0 ? (
groups.map((group) => (
<section key={group.day} className="history-day" data-testid={`day-${group.day}`}>
<h3 className="history-section-label">{dayLabel(group.day, today)}</h3>
<ul className="history-card history-list">
{group.rows.map((row, index) => (
<EventRow key={`${row.instance_id}-${row.event_type}-${row.occurred_at ?? ""}-${index}`} row={row} />
))}
</ul>
</section>
))
) : (
<div className="history-card history-empty">No events in the last {RANGE_DAYS} days.</div>
);
}
return (
<div className="history-screen">
<ScreenHeader exporting={exporting} onExport={() => void exportCsv()} />
<div
className="history-scroll"
data-testid="history-scroll"
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
>
{exportError !== null ? (
<div className="history-export-error" role="alert" data-testid="history-export-error">
<span>{exportError}</span>
<button type="button" className="history-export-dismiss" onClick={() => setExportError(null)}>
Dismiss
</button>
</div>
) : null}
{shown.kind === "ready" ? <StatRow stats={shown.data.stats} /> : null}
<div className="history-chips" role="group" aria-label="Filter events">
{FILTERS.map((item) => (
<button
key={item.id}
type="button"
className={item.id === filter ? "history-chip history-chip--active" : "history-chip"}
aria-pressed={item.id === filter}
onClick={() => setFilter(item.id)}
>
{item.label}
</button>
))}
</div>
{body}
<FooterNote />
</div>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Day grouping and row wording for the History tab.
*
* Calendar dates ("YYYY-MM-DD") are handled by their components (never
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
* day in negative-offset zones); event stamps are UTC and shown in the
* viewer's local time.
*/
import type { HistoryRow } from "../api/history";
import { dayNumber, isoFromDayNumber, localTodayIso } from "../schedule/cycle";
import { shortDate } from "../schedule/format";
import { formatClockTime, formatDueTime } from "../today/format";
const MS_PER_MINUTE = 60_000;
/** `iso` shifted by `days` calendar days. */
export function shiftIso(iso: string, days: number): string {
return isoFromDayNumber(dayNumber(iso) + days);
}
/** The local calendar day an event belongs to (due_date for missed rows). */
export function rowDay(row: HistoryRow): string {
return row.occurred_at ? localTodayIso(new Date(row.occurred_at)) : row.due_date;
}
/** The row's due moment as local wall-clock time, or null without a due time. */
function dueMoment(row: HistoryRow): Date | null {
const match = /^(\d{2}):(\d{2})/.exec(row.due_time ?? "");
if (!match) return null;
const [year, month, day] = row.due_date.split("-").map(Number);
return new Date(year, month - 1, day, Number(match[1]), Number(match[2]));
}
/** Newest first; a missed row sorts at its due moment (or the end of its day). */
function sortKey(row: HistoryRow): number {
if (row.occurred_at) return Date.parse(row.occurred_at);
const due = dueMoment(row);
if (due) return due.getTime();
const [year, month, day] = row.due_date.split("-").map(Number);
return new Date(year, month - 1, day, 23, 59).getTime();
}
export interface DayGroup {
day: string;
rows: HistoryRow[];
}
/** Rows grouped by day, newest day first and newest row first within a day. */
export function groupByDay(rows: HistoryRow[]): DayGroup[] {
const groups = new Map<string, HistoryRow[]>();
for (const row of rows) {
const day = rowDay(row);
groups.set(day, [...(groups.get(day) ?? []), row]);
}
return [...groups.entries()]
.sort(([a], [b]) => b.localeCompare(a))
.map(([day, dayRows]) => ({
day,
// Stable sort: rows at the same moment keep the API's append order.
rows: [...dayRows].sort((a, b) => sortKey(b) - sortKey(a)),
}));
}
/** "Today", "Yesterday", else "Mon, Sep 21" (the CSS uppercases it). */
export function dayLabel(day: string, today: string): string {
if (day === today) return "Today";
if (day === shiftIso(today, -1)) return "Yesterday";
const weekday = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" }).format(
new Date(dayNumber(day) * 86_400_000),
);
return `${weekday}, ${shortDate(day)}`;
}
/** "on time", "late 22 min", or "late" when the lateness is not derivable. */
function onTimeFlag(row: HistoryRow): string | null {
if (row.event_type !== "completed" || row.was_on_time === null) return null;
if (row.was_on_time) return "on time";
const due = dueMoment(row);
if (!due || !row.occurred_at) return "late";
const minutes = Math.round((Date.parse(row.occurred_at) - due.getTime()) / MS_PER_MINUTE);
return minutes > 0 ? `late ${minutes} min` : "late";
}
/** `<event type> · <actor>` plus the on-time flag for completions. */
export function rowMeta(row: HistoryRow): string {
const flag = onTimeFlag(row);
return [row.event_type, row.actor, flag].filter(Boolean).join(" · ");
}
/** The event's local clock time; a missed row shows its due time, if any. */
export function rowTime(row: HistoryRow): string {
if (row.occurred_at) return formatClockTime(row.occurred_at);
return row.due_time ? formatDueTime(row.due_time) : "";
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Inline stroke glyphs for the History tab (Lucide-style 24px grid),
* CDN-free like the other tabs' glyphs.
*/
import type { ReactNode } from "react";
function Glyph({ size, children }: { size: number; children: ReactNode }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
);
}
export function DownloadGlyph({ size = 15 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />
</Glyph>
);
}
export function LockGlyph({ size = 15 }: { size?: number }) {
return (
<Glyph size={size}>
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</Glyph>
);
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Draws a registry glyph (`registry.generated.ts`) as an inline SVG — the
* path data is bundled, so there is no CDN and no runtime fetch. Lucide
* glyphs are stroked on their 24px grid; Font Awesome glyphs are filled.
*/
import type { GlyphIcon } from "./registry.generated";
export function GlyphSvg({ icon, size }: { icon: GlyphIcon; size: number }) {
const stroked = icon.kind === "lucide";
return (
<svg
width={size}
height={size}
viewBox={icon.viewBox}
fill={stroked ? "none" : "currentColor"}
stroke={stroked ? "currentColor" : undefined}
strokeWidth={stroked ? 2 : undefined}
strokeLinecap={stroked ? "round" : undefined}
strokeLinejoin={stroked ? "round" : undefined}
aria-hidden="true"
focusable="false"
data-icon={icon.name}
>
{icon.paths.map((d, index) => (
<path key={index} d={d} />
))}
</svg>
);
}
+765
View File
@@ -0,0 +1,765 @@
// GENERATED by tools/icons/generate.mjs from tools/icons/curated-icons.json — do not edit.
// Regenerate with `node tools/icons/generate.mjs` (the drift tests fail if this
// file differs from a fresh generation).
//
// Glyph path data is embedded at generation time; nothing is fetched at runtime.
// Lucide glyphs: lucide-react 1.47.0 (ISC licence).
// Font Awesome glyphs: @fortawesome/free-solid-svg-icons 7.3.1
// (Font Awesome Free, icons under CC BY 4.0).
/** A vector glyph: draw each path in `paths` inside an <svg viewBox>. Lucide
* glyphs are stroked (fill none, stroke-width 2, round caps and joins);
* Font Awesome glyphs are filled. */
export interface GlyphIcon {
kind: "lucide" | "fa";
key: string;
name: string;
label: string;
viewBox: string;
paths: readonly string[];
}
/** An emoji icon; render `text` as text. */
export interface EmojiIcon {
kind: "emoji";
key: string;
text: string;
}
/** No usable icon: the caller draws its own default glyph. */
export interface FallbackIcon {
kind: "fallback";
key: null;
}
export type ResolvedIcon = GlyphIcon | EmojiIcon | FallbackIcon;
export const LUCIDE_ICONS: readonly GlyphIcon[] = [
{
"kind": "lucide",
"key": "lucide:bed",
"name": "bed",
"label": "Bed",
"viewBox": "0 0 24 24",
"paths": [
"M2 4v16",
"M2 8h18a2 2 0 0 1 2 2v10",
"M2 17h20",
"M6 8v9"
]
},
{
"kind": "lucide",
"key": "lucide:bath",
"name": "bath",
"label": "Bath",
"viewBox": "0 0 24 24",
"paths": [
"M10 4 8 6",
"M17 19v2",
"M2 12h20",
"M7 19v2",
"M9 5 7.621 3.621A2.121 2.121 0 0 0 4 5v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5"
]
},
{
"kind": "lucide",
"key": "lucide:shower-head",
"name": "shower-head",
"label": "Shower",
"viewBox": "0 0 24 24",
"paths": [
"m4 4 2.5 2.5",
"M13.5 6.5a4.95 4.95 0 0 0-7 7",
"M15 5 5 15",
"M14 17v.01",
"M10 16v.01",
"M13 13v.01",
"M16 10v.01",
"M11 20v.01",
"M17 14v.01",
"M20 11v.01"
]
},
{
"kind": "lucide",
"key": "lucide:toilet",
"name": "toilet",
"label": "Toilet",
"viewBox": "0 0 24 24",
"paths": [
"M7 12h13a1 1 0 0 1 1 1 5 5 0 0 1-5 5h-.598a.5.5 0 0 0-.424.765l1.544 2.47a.5.5 0 0 1-.424.765H5.402a.5.5 0 0 1-.424-.765L7 18",
"M8 18a5 5 0 0 1-5-5V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8"
]
},
{
"kind": "lucide",
"key": "lucide:brush",
"name": "brush",
"label": "Brush",
"viewBox": "0 0 24 24",
"paths": [
"m11 10 3 3",
"M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z",
"M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031"
]
},
{
"kind": "lucide",
"key": "lucide:smile",
"name": "smile",
"label": "Smile",
"viewBox": "0 0 24 24",
"paths": [
"M15 10V9",
"M16.472 15a6 6 0 01-8.943 0",
"M9 10V9",
"M2 12a10 10 0 1 0 20 0a10 10 0 1 0 -20 0"
]
},
{
"kind": "lucide",
"key": "lucide:shirt",
"name": "shirt",
"label": "Clothes",
"viewBox": "0 0 24 24",
"paths": [
"M20.38 3.46 16 2a4 4 0 0 1-8 0L3.62 3.46a2 2 0 0 0-1.34 2.23l.58 3.47a1 1 0 0 0 .99.84H6v10c0 1.1.9 2 2 2h8a2 2 0 0 0 2-2V10h2.15a1 1 0 0 0 .99-.84l.58-3.47a2 2 0 0 0-1.34-2.23z"
]
},
{
"kind": "lucide",
"key": "lucide:washing-machine",
"name": "washing-machine",
"label": "Laundry",
"viewBox": "0 0 24 24",
"paths": [
"M3 6h3",
"M17 6h.01",
"M5 2h14a2 2 0 0 1 2 2v16a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-16a2 2 0 0 1 2 -2z",
"M7 13a5 5 0 1 0 10 0a5 5 0 1 0 -10 0",
"M12 18a2.5 2.5 0 0 0 0-5 2.5 2.5 0 0 1 0-5"
]
},
{
"kind": "lucide",
"key": "lucide:utensils",
"name": "utensils",
"label": "Meal",
"viewBox": "0 0 24 24",
"paths": [
"M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2",
"M7 2v20",
"M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7"
]
},
{
"kind": "lucide",
"key": "lucide:cooking-pot",
"name": "cooking-pot",
"label": "Cooking",
"viewBox": "0 0 24 24",
"paths": [
"M2 12h20",
"M20 12v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-8",
"m4 8 16-4",
"m8.86 6.78-.45-1.81a2 2 0 0 1 1.45-2.43l1.94-.48a2 2 0 0 1 2.43 1.46l.45 1.8"
]
},
{
"kind": "lucide",
"key": "lucide:apple",
"name": "apple",
"label": "Snack",
"viewBox": "0 0 24 24",
"paths": [
"M12 6.528V3a1 1 0 0 1 1-1h0",
"M18.237 21A15 15 0 0 0 22 11a6 6 0 0 0-10-4.472A6 6 0 0 0 2 11a15.1 15.1 0 0 0 3.763 10 3 3 0 0 0 3.648.648 5.5 5.5 0 0 1 5.178 0A3 3 0 0 0 18.237 21"
]
},
{
"kind": "lucide",
"key": "lucide:brush-cleaning",
"name": "brush-cleaning",
"label": "Sweep",
"viewBox": "0 0 24 24",
"paths": [
"m16 22-1-4",
"M19 14a1 1 0 0 0 1-1v-1a2 2 0 0 0-2-2h-3a1 1 0 0 1-1-1V4a2 2 0 0 0-4 0v5a1 1 0 0 1-1 1H6a2 2 0 0 0-2 2v1a1 1 0 0 0 1 1",
"M19 14H5l-1.973 6.767A1 1 0 0 0 4 22h16a1 1 0 0 0 .973-1.233z",
"m8 22 1-4"
]
},
{
"kind": "lucide",
"key": "lucide:spray-can",
"name": "spray-can",
"label": "Clean",
"viewBox": "0 0 24 24",
"paths": [
"M3 3h.01",
"M7 5h.01",
"M11 7h.01",
"M3 7h.01",
"M7 9h.01",
"M3 11h.01",
"M15 5h4v4h-4z",
"m19 9 2 2v10c0 .6-.4 1-1 1h-6c-.6 0-1-.4-1-1V11l2-2",
"m13 14 8-2",
"m13 19 8-2"
]
},
{
"kind": "lucide",
"key": "lucide:trash-2",
"name": "trash-2",
"label": "Bins",
"viewBox": "0 0 24 24",
"paths": [
"M10 11v6",
"M14 11v6",
"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6",
"M3 6h18",
"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
]
},
{
"kind": "lucide",
"key": "lucide:recycle",
"name": "recycle",
"label": "Recycling",
"viewBox": "0 0 24 24",
"paths": [
"M7 19H4.815a1.83 1.83 0 0 1-1.57-.881 1.785 1.785 0 0 1-.004-1.784L7.196 9.5",
"M11 19h8.203a1.83 1.83 0 0 0 1.556-.89 1.784 1.784 0 0 0 0-1.775l-1.226-2.12",
"m14 16-3 3 3 3",
"M8.293 13.596 7.196 9.5 3.1 10.598",
"m9.344 5.811 1.093-1.892A1.83 1.83 0 0 1 11.985 3a1.784 1.784 0 0 1 1.546.888l3.943 6.843",
"m13.378 9.633 4.096 1.098 1.097-4.096"
]
},
{
"kind": "lucide",
"key": "lucide:sofa",
"name": "sofa",
"label": "Tidy",
"viewBox": "0 0 24 24",
"paths": [
"M20 9V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v3",
"M2 16a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5a2 2 0 0 0-4 0v1.5a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5V11a2 2 0 0 0-4 0z",
"M4 18v2",
"M20 18v2",
"M12 4v9"
]
},
{
"kind": "lucide",
"key": "lucide:house",
"name": "house",
"label": "Home",
"viewBox": "0 0 24 24",
"paths": [
"M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8",
"M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
]
},
{
"kind": "lucide",
"key": "lucide:backpack",
"name": "backpack",
"label": "School bag",
"viewBox": "0 0 24 24",
"paths": [
"M4 10a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z",
"M8 10h8",
"M8 18h8",
"M8 22v-6a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v6",
"M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"
]
},
{
"kind": "lucide",
"key": "lucide:book-open",
"name": "book-open",
"label": "Reading",
"viewBox": "0 0 24 24",
"paths": [
"M12 5v16",
"M20.001 19A2 2 0 0022 17V5a2 2 0 00-1.999-2L16 3.002A5 5 0 0012 5a5 5 0 00-4-2H4a2 2 0 00-2 2v12a2 2 0 001.999 2H8a5 5 0 014 2 5 5 0 014-2z"
]
},
{
"kind": "lucide",
"key": "lucide:pencil",
"name": "pencil",
"label": "Homework",
"viewBox": "0 0 24 24",
"paths": [
"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z",
"m15 5 4 4"
]
},
{
"kind": "lucide",
"key": "lucide:music",
"name": "music",
"label": "Music",
"viewBox": "0 0 24 24",
"paths": [
"M9 18V5l12-2v13",
"M3 18a3 3 0 1 0 6 0a3 3 0 1 0 -6 0",
"M15 16a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"
]
},
{
"kind": "lucide",
"key": "lucide:dog",
"name": "dog",
"label": "Dog",
"viewBox": "0 0 24 24",
"paths": [
"M11.25 16.25h1.5L12 17z",
"M16 14v.5",
"M4.42 11.247A13.152 13.152 0 0 0 4 14.556C4 18.728 7.582 21 12 21s8-2.272 8-6.444a11.702 11.702 0 0 0-.493-3.309",
"M8 14v.5",
"M8.5 8.5c-.384 1.05-1.083 2.028-2.344 2.5-1.931.722-3.576-.297-3.656-1-.113-.994 1.177-6.53 4-7 1.923-.321 3.651.845 3.651 2.235A7.497 7.497 0 0 1 14 5.277c0-1.39 1.844-2.598 3.767-2.277 2.823.47 4.113 6.006 4 7-.08.703-1.725 1.722-3.656 1-1.261-.472-1.855-1.45-2.239-2.5"
]
},
{
"kind": "lucide",
"key": "lucide:cat",
"name": "cat",
"label": "Cat",
"viewBox": "0 0 24 24",
"paths": [
"M12 5c.67 0 1.35.09 2 .26 1.78-2 5.03-2.84 6.42-2.26 1.4.58-.42 7-.42 7 .57 1.07 1 2.24 1 3.44C21 17.9 16.97 21 12 21s-9-3-9-7.56c0-1.25.5-2.4 1-3.44 0 0-1.89-6.42-.5-7 1.39-.58 4.72.23 6.5 2.23A9.04 9.04 0 0 1 12 5Z",
"M8 14v.5",
"M16 14v.5",
"M11.25 16.25h1.5L12 17l-.75-.75Z"
]
},
{
"kind": "lucide",
"key": "lucide:fish",
"name": "fish",
"label": "Fish",
"viewBox": "0 0 24 24",
"paths": [
"M6.5 12c.94-3.46 4.94-6 8.5-6 3.56 0 6.06 2.54 7 6-.94 3.47-3.44 6-7 6s-7.56-2.53-8.5-6Z",
"M18 12v.5",
"M16 17.93a9.77 9.77 0 0 1 0-11.86",
"M7 10.67C7 8 5.58 5.97 2.73 5.5c-1 1.5-1 5 .23 6.5-1.24 1.5-1.24 5-.23 6.5C5.58 18.03 7 16 7 13.33",
"M10.46 7.26C10.2 5.88 9.17 4.24 8 3h5.8a2 2 0 0 1 1.98 1.67l.23 1.4",
"m16.01 17.93-.23 1.4A2 2 0 0 1 13.8 21H9.5a5.96 5.96 0 0 0 1.49-3.98"
]
},
{
"kind": "lucide",
"key": "lucide:sprout",
"name": "sprout",
"label": "Plants",
"viewBox": "0 0 24 24",
"paths": [
"M14 9.536V7a4 4 0 0 1 4-4h1.5a.5.5 0 0 1 .5.5V5a4 4 0 0 1-4 4 4 4 0 0 0-4 4c0 2 1 3 1 5a5 5 0 0 1-1 3",
"M4 9a5 5 0 0 1 8 4 5 5 0 0 1-8-4",
"M5 21h14"
]
},
{
"kind": "lucide",
"key": "lucide:droplets",
"name": "droplets",
"label": "Water",
"viewBox": "0 0 24 24",
"paths": [
"M7 16.3c2.2 0 4-1.83 4-4.05 0-1.16-.57-2.26-1.71-3.19S7.29 6.75 7 5.3c-.29 1.45-1.14 2.84-2.29 3.76S3 11.1 3 12.25c0 2.22 1.8 4.05 4 4.05z",
"M12.56 6.6A10.97 10.97 0 0 0 14 3.02c.5 2.5 2 4.9 4 6.5s3 3.5 3 5.5a6.98 6.98 0 0 1-11.91 4.97"
]
},
{
"kind": "lucide",
"key": "lucide:bike",
"name": "bike",
"label": "Bike",
"viewBox": "0 0 24 24",
"paths": [
"M15 17.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0 -7 0",
"M2 17.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0 -7 0",
"M14 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0",
"M12 17.5V14l-3-3 4-3 2 3h2"
]
},
{
"kind": "lucide",
"key": "lucide:volleyball",
"name": "volleyball",
"label": "Sport",
"viewBox": "0 0 24 24",
"paths": [
"M11 7a16 16 20 0 1 10.98 4.362",
"M12 12a13 13 0 0 1-8.66 5",
"M16.83 13.634a16 16 0 0 1-9.267 7.328",
"M20.66 17A13 13 0 0 0 12 12a13 13 0 0 1 0-10",
"M8.17 15.366a16 16 0 0 1-1.713-11.69",
"M2 12a10 10 0 1 0 20 0a10 10 0 1 0 -20 0"
]
},
{
"kind": "lucide",
"key": "lucide:gamepad-2",
"name": "gamepad-2",
"label": "Games",
"viewBox": "0 0 24 24",
"paths": [
"M6 11L10 11",
"M8 9L8 13",
"M15 12L15.01 12",
"M18 10L18.01 10",
"M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z"
]
},
{
"kind": "lucide",
"key": "lucide:star",
"name": "star",
"label": "Star",
"viewBox": "0 0 24 24",
"paths": [
"M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"
]
},
{
"kind": "lucide",
"key": "lucide:heart",
"name": "heart",
"label": "Heart",
"viewBox": "0 0 24 24",
"paths": [
"M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5"
]
},
{
"kind": "lucide",
"key": "lucide:sun",
"name": "sun",
"label": "Morning",
"viewBox": "0 0 24 24",
"paths": [
"M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0",
"M12 2v2",
"M12 20v2",
"m4.93 4.93 1.41 1.41",
"m17.66 17.66 1.41 1.41",
"M2 12h2",
"M20 12h2",
"m6.34 17.66-1.41 1.41",
"m19.07 4.93-1.41 1.41"
]
},
{
"kind": "lucide",
"key": "lucide:moon",
"name": "moon",
"label": "Bedtime",
"viewBox": "0 0 24 24",
"paths": [
"M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"
]
},
{
"kind": "lucide",
"key": "lucide:clock",
"name": "clock",
"label": "Clock",
"viewBox": "0 0 24 24",
"paths": [
"M2 12a10 10 0 1 0 20 0a10 10 0 1 0 -20 0",
"M12 6v6l4 2"
]
},
{
"kind": "lucide",
"key": "lucide:calendar",
"name": "calendar",
"label": "Calendar",
"viewBox": "0 0 24 24",
"paths": [
"M8 2v3",
"M16 2v3",
"M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2z",
"M3 9h18"
]
},
{
"kind": "lucide",
"key": "lucide:list-checks",
"name": "list-checks",
"label": "Checklist",
"viewBox": "0 0 24 24",
"paths": [
"M13 5h8",
"M13 12h8",
"M13 19h8",
"m3 17 2 2 4-4",
"m3 7 2 2 4-4"
]
}
];
export const FA_ICONS: readonly GlyphIcon[] = [
{
"kind": "fa",
"key": "fa:broom",
"name": "broom",
"label": "Sweep",
"viewBox": "0 0 576 512",
"paths": [
"M566.6 54.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192-34.7-34.7c-4.2-4.2-10-6.6-16-6.6-12.5 0-22.6 10.1-22.6 22.6l0 29.1 108.3 108.3 29.1 0c12.5 0 22.6-10.1 22.6-22.6 0-6-2.4-11.8-6.6-16l-34.7-34.7 192-192zM341.1 353.4L222.6 234.9c-42.7-3.7-85.2 11.7-115.8 42.3l-8 8c-22.3 22.3-34.8 52.5-34.8 84 0 6.8 7.1 11.2 13.2 8.2l51.1-25.5c5-2.5 9.5 4.1 5.4 7.9L7.3 473.4C2.7 477.6 0 483.6 0 489.9 0 502.1 9.9 512 22.1 512l173.3 0c38.8 0 75.9-15.4 103.4-42.8 30.6-30.6 45.9-73.1 42.3-115.8z"
]
},
{
"kind": "fa",
"key": "fa:spray-can-sparkles",
"name": "spray-can-sparkles",
"label": "Clean",
"viewBox": "0 0 576 512",
"paths": [
"M160 32l0 80 128 0 0-80c0-17.7-14.3-32-32-32L192 0c-17.7 0-32 14.3-32 32zm0 128c-53 0-96 43-96 96l0 208c0 26.5 21.5 48 48 48l224 0c26.5 0 48-21.5 48-48l0-208c0-53-43-96-96-96l-128 0zm64 96a80 80 0 1 1 0 160 80 80 0 1 1 0-160zM448 48c0-1.4-1-3-2.2-3.6L416 32 403.6 2.2C403 1 401.4 0 400 0s-3 1-3.6 2.2L384 32 354.2 44.4c-1.2 .6-2.2 2.2-2.2 3.6 0 1.4 1 3 2.2 3.6L384 64 396.4 93.8C397 95 398.6 96 400 96s3-1 3.6-2.2L416 64 445.8 51.6C447 51 448 49.4 448 48zm76.4 45.8C525 95 526.6 96 528 96s3-1 3.6-2.2L544 64 573.8 51.6c1.2-.6 2.2-2.2 2.2-3.6 0-1.4-1-3-2.2-3.6L544 32 531.6 2.2C531 1 529.4 0 528 0s-3 1-3.6 2.2L512 32 482.2 44.4c-1.2 .6-2.2 2.2-2.2 3.6 0 1.4 1 3 2.2 3.6L512 64 524.4 93.8zm7.2 100.4c-.6-1.2-2.2-2.2-3.6-2.2s-3 1-3.6 2.2L512 224 482.2 236.4c-1.2 .6-2.2 2.2-2.2 3.6 0 1.4 1 3 2.2 3.6L512 256 524.4 285.8c.6 1.2 2.2 2.2 3.6 2.2s3-1 3.6-2.2L544 256 573.8 243.6c1.2-.6 2.2-2.2 2.2-3.6 0-1.4-1-3-2.2-3.6L544 224 531.6 194.2zM512 144c0-1.4-1-3-2.2-3.6L480 128 467.6 98.2C467 97 465.4 96 464 96s-3 1-3.6 2.2L448 128 418.2 140.4c-1.2 .6-2.2 2.2-2.2 3.6 0 1.4 1 3 2.2 3.6L448 160 460.4 189.8c.6 1.2 2.2 2.2 3.6 2.2s3-1 3.6-2.2L480 160 509.8 147.6c1.2-.6 2.2-2.2 2.2-3.6z"
]
},
{
"kind": "fa",
"key": "fa:bucket",
"name": "bucket",
"label": "Mop",
"viewBox": "0 0 448 512",
"paths": [
"M443.7 208c2.7 4.7 4.3 10.2 4.3 16 0 17.7-14.3 32-32 32l-5.1 0-22.4 213c-2.6 24.4-23.2 43-47.8 43l-233.6 0c-24.6 0-45.2-18.5-47.8-43L37.1 256 32 256c-17.7 0-32-14.3-32-32 0-5.8 1.6-11.3 4.3-16l439.4 0zM224-16c79.5 0 144 64.5 144 144l0 32-48 0 0-32c0-53-43-96-96-96s-96 43-96 96l0 32-48 0 0-32C80 48.5 144.5-16 224-16z"
]
},
{
"kind": "fa",
"key": "fa:soap",
"name": "soap",
"label": "Wash hands",
"viewBox": "0 0 512 512",
"paths": [
"M208-32a48 48 0 1 1 0 96 48 48 0 1 1 0-96zM320 96a64 64 0 1 1 0 128 64 64 0 1 1 0-128zM352 0a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zM96 160l112 0c0 23.8 7.4 45.9 20.1 64L160 224c-53 0-96 43-96 96s43 96 96 96l192 0c53 0 96-43 96-96 0-35.1-18.9-65.9-47-82.6 19-19.8 30.7-46.6 31-76.1 45.4 7.6 80 47.1 80 94.7l0 128c0 53-43 96-96 96L96 480c-53 0-96-43-96-96L0 256c0-53 43-96 96-96zm64 112l192 0c26.5 0 48 21.5 48 48s-21.5 48-48 48l-192 0c-26.5 0-48-21.5-48-48s21.5-48 48-48z"
]
},
{
"kind": "fa",
"key": "fa:sink",
"name": "sink",
"label": "Dishes",
"viewBox": "0 0 512 512",
"paths": [
"M288 96c0-17.7 14.3-32 32-32s32 14.3 32 32 14.3 32 32 32 32-14.3 32-32c0-53-43-96-96-96s-96 43-96 96l0 192-64 0 0-40c0-30.9-25.1-56-56-56l-48 0c-13.3 0-24 10.7-24 24s10.7 24 24 24l48 0c4.4 0 8 3.6 8 8l0 40-80 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l0 64c0 53 43 96 96 96l256 0c53 0 96-43 96-96l0-64c17.7 0 32-14.3 32-32s-14.3-32-32-32l-80 0 0-40c0-4.4 3.6-8 8-8l56 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-56 0c-30.9 0-56 25.1-56 56l0 40-64 0 0-192z"
]
},
{
"kind": "fa",
"key": "fa:jug-detergent",
"name": "jug-detergent",
"label": "Laundry",
"viewBox": "0 0 384 512",
"paths": [
"M96 24c0-13.3 10.7-24 24-24l80 0c13.3 0 24 10.7 24 24l0 24 8 0c13.3 0 24 10.7 24 24s-10.7 24-24 24L88 96C74.7 96 64 85.3 64 72S74.7 48 88 48l8 0 0-24zM0 256c0-70.7 57.3-128 128-128l128 0c70.7 0 128 57.3 128 128l0 192c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 256zm256 0l0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96c0-17.7-14.3-32-32-32s-32 14.3-32 32z"
]
},
{
"kind": "fa",
"key": "fa:socks",
"name": "socks",
"label": "Socks",
"viewBox": "0 0 512 512",
"paths": [
"M252.8 0L176 0c-26.5 0-48 21.5-48 48l0 16 112 0 0-16c0-17.5 4.7-33.9 12.8-48zM128 112l0 128c0 20.1-9.5 39.1-25.6 51.2l-64 48c-24.2 18.1-38.4 46.6-38.4 76.8 0 53 43 96 96 96 15.4 0 30.5-3.7 44-10.7-17.6-23.9-28-53.4-28-85.3 0-45.3 21.3-88 57.6-115.2l64-48c4-3 6.4-7.8 6.4-12.8l0-128-112 0zm160 0l0 128c0 20.1-9.5 39.1-25.6 51.2l-64 48c-24.2 18.1-38.4 46.6-38.4 76.8 0 53 43 96 96 96 20.8 0 41-6.7 57.6-19.2l115.2-86.4C461 382.2 480 344.3 480 304l0-192-192 0zM480 64l0-16c0-26.5-21.5-48-48-48L336 0c-26.5 0-48 21.5-48 48l0 16 192 0z"
]
},
{
"kind": "fa",
"key": "fa:shirt",
"name": "shirt",
"label": "Clothes",
"viewBox": "0 0 640 512",
"paths": [
"M320.2 112c44.2 0 80-35.8 80-80l53.5 0c17 0 33.3 6.7 45.3 18.7L617.6 169.4c12.5 12.5 12.5 32.8 0 45.3l-50.7 50.7c-12.5 12.5-32.8 12.5-45.3 0l-41.4-41.4 0 224c0 35.3-28.7 64-64 64l-192 0c-35.3 0-64-28.7-64-64l0-224-41.4 41.4c-12.5 12.5-32.8 12.5-45.3 0L22.9 214.6c-12.5-12.5-12.5-32.8 0-45.3L141.5 50.7c12-12 28.3-18.7 45.3-18.7l53.5 0c0 44.2 35.8 80 80 80z"
]
},
{
"kind": "fa",
"key": "fa:bed",
"name": "bed",
"label": "Bed",
"viewBox": "0 0 576 512",
"paths": [
"M32 32c17.7 0 32 14.3 32 32l0 224 224 0 0-128c0-17.7 14.3-32 32-32l160 0c53 0 96 43 96 96l0 224c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-64-448 0 0 64c0 17.7-14.3 32-32 32S0 465.7 0 448L0 64C0 46.3 14.3 32 32 32zm80 160a64 64 0 1 1 128 0 64 64 0 1 1 -128 0z"
]
},
{
"kind": "fa",
"key": "fa:couch",
"name": "couch",
"label": "Tidy",
"viewBox": "0 0 640 512",
"paths": [
"M144 272C144 224.7 109.8 185.4 64.8 177.5 72 113.6 126.2 64 192 64l256 0c65.8 0 120 49.6 127.2 113.5-45 8-79.2 47.2-79.2 94.5l0 32-352 0 0-32zM0 384L0 272c0-26.5 21.5-48 48-48s48 21.5 48 48l0 80 448 0 0-80c0-26.5 21.5-48 48-48s48 21.5 48 48l0 112c0 35.3-28.7 64-64 64L64 448c-35.3 0-64-28.7-64-64z"
]
},
{
"kind": "fa",
"key": "fa:bath",
"name": "bath",
"label": "Bath",
"viewBox": "0 0 512 512",
"paths": [
"M96 77.3c0-7.3 5.9-13.3 13.3-13.3 3.5 0 6.9 1.4 9.4 3.9l14.9 14.9c-3.6 9.1-5.5 18.9-5.5 29.2 0 19.9 7.2 38 19.2 52-5.3 9.2-4 21.1 3.8 29 9.4 9.4 24.6 9.4 33.9 0L289 89c9.4-9.4 9.4-24.6 0-33.9-7.8-7.9-19.8-9.1-29-3.8-14-12-32.1-19.2-52-19.2-10.3 0-20.2 2-29.2 5.5L163.9 22.6C149.4 8.1 129.7 0 109.3 0 66.6 0 32 34.6 32 77.3L32 256c-17.7 0-32 14.3-32 32s14.3 32 32 32l0 48c0 28.4 12.4 54 32 71.6L64 480c0 17.7 14.3 32 32 32s32-14.3 32-32l0-16 256 0 0 16c0 17.7 14.3 32 32 32s32-14.3 32-32l0-40.4c19.6-17.6 32-43.1 32-71.6l0-48c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 256 96 77.3z"
]
},
{
"kind": "fa",
"key": "fa:tooth",
"name": "tooth",
"label": "Teeth",
"viewBox": "0 0 448 512",
"paths": [
"M145 5.7L224 32 303 5.7C314.3 1.9 326 0 337.9 0 398.7 0 448 49.3 448 110.1l0 68.5c0 29.4-9.5 58.1-27.2 81.6l-1.1 1.5c-12.9 17.2-21.3 37.4-24.3 58.7L373.7 471.9c-3.3 23-23 40.1-46.2 40.1-22.8 0-42.3-16.5-46-39L261.3 351.6c-3-18.2-18.8-31.6-37.3-31.6s-34.2 13.4-37.3 31.6L166.5 473c-3.8 22.5-23.2 39-46 39-23.2 0-42.9-17.1-46.2-40.1L52.6 320.5c-3-21.3-11.4-41.5-24.3-58.7l-1.1-1.5C9.5 236.7 0 208.1 0 178.7l0-68.5C0 49.3 49.3 0 110.1 0 122 0 133.7 1.9 145 5.7z"
]
},
{
"kind": "fa",
"key": "fa:utensils",
"name": "utensils",
"label": "Meal",
"viewBox": "0 0 512 512",
"paths": [
"M63.9 14.4C63.1 6.2 56.2 0 48 0s-15.1 6.2-16 14.3L17.9 149.7c-1.3 6-1.9 12.1-1.9 18.2 0 45.9 35.1 83.6 80 87.7L96 480c0 17.7 14.3 32 32 32s32-14.3 32-32l0-224.4c44.9-4.1 80-41.8 80-87.7 0-6.1-.6-12.2-1.9-18.2L223.9 14.3C223.1 6.2 216.2 0 208 0s-15.1 6.2-15.9 14.4L178.5 149.9c-.6 5.7-5.4 10.1-11.1 10.1-5.8 0-10.6-4.4-11.2-10.2L143.9 14.6C143.2 6.3 136.3 0 128 0s-15.2 6.3-15.9 14.6L99.8 149.8c-.5 5.8-5.4 10.2-11.2 10.2-5.8 0-10.6-4.4-11.1-10.1L63.9 14.4zM448 0C432 0 320 32 320 176l0 112c0 35.3 28.7 64 64 64l32 0 0 128c0 17.7 14.3 32 32 32s32-14.3 32-32l0-448c0-17.7-14.3-32-32-32z"
]
},
{
"kind": "fa",
"key": "fa:bowl-food",
"name": "bowl-food",
"label": "Set the table",
"viewBox": "0 0 512 512",
"paths": [
"M0 176c0-35.3 28.7-64 64-64 .5 0 1.1 0 1.6 0 7.4-36.5 39.7-64 78.4-64 15 0 29 4.1 40.9 11.2 13.3-25.7 40.1-43.2 71.1-43.2s57.8 17.6 71.1 43.2c12-7.1 26-11.2 40.9-11.2 38.7 0 71 27.5 78.4 64 .5 0 1.1 0 1.6 0 35.3 0 64 28.7 64 64 0 11.7-3.1 22.6-8.6 32L8.6 208C3.1 198.6 0 187.7 0 176zM0 283.4C0 268.3 12.3 256 27.4 256l457.1 0c15.1 0 27.4 12.3 27.4 27.4 0 70.5-44.4 130.7-106.7 154.1L403.5 452c-2 16-15.6 28-31.8 28l-231.5 0c-16.1 0-29.8-12-31.8-28l-1.8-14.4C44.4 414.1 0 353.9 0 283.4z"
]
},
{
"kind": "fa",
"key": "fa:basket-shopping",
"name": "basket-shopping",
"label": "Groceries",
"viewBox": "0 0 576 512",
"paths": [
"M288 0c6.6 0 12.9 2.7 17.4 7.5l144 152 .5 .5 78.1 0c17.7 0 32 14.3 32 32 0 14.5-9.6 26.7-22.8 30.7L491.1 429.9c-6.5 29.3-32.5 50.1-62.5 50.1l-281.3 0c-30 0-56-20.8-62.5-50.1l-46-207.2c-13.2-3.9-22.8-16.2-22.8-30.7 0-17.7 14.3-32 32-32l78.1 0 .5-.5 144-152C275.1 2.7 281.4 0 288 0zm0 58.9L192.2 160 383.8 160 288 58.9zM208 264c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112zm80-24c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm128 24c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112z"
]
},
{
"kind": "fa",
"key": "fa:trash-can",
"name": "trash-can",
"label": "Bins",
"viewBox": "0 0 448 512",
"paths": [
"M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"
]
},
{
"kind": "fa",
"key": "fa:recycle",
"name": "recycle",
"label": "Recycling",
"viewBox": "0 0 512 512",
"paths": [
"M152.3 60C198.5-20 314-20 360.2 60l37.3 64.6 27.7-16c8.4-4.9 18.9-4.2 26.6 1.7s11.1 15.9 8.6 25.3L436.9 223c-3.4 12.8-16.6 20.4-29.4 17l-87.4-23.4c-9.4-2.5-16.3-10.4-17.6-20s3.4-19.1 11.8-23.9l27.7-16-37.3-64.6c-21.6-37.3-75.4-37.3-97 0l-5.3 9.1c-8.8 15.3-28.4 20.5-43.7 11.7S138.2 84.5 147 69.1l5.3-9.1zM449.7 279.1c15.3-8.8 34.9-3.6 43.7 11.7l5.3 9.1c46.2 80-11.5 180-103.9 180l-74.6 0 0 32c0 9.7-5.8 18.5-14.8 22.2s-19.3 1.7-26.2-5.2l-64-64c-9.4-9.4-9.4-24.6 0-33.9l64-64c6.9-6.9 17.2-8.9 26.2-5.2s14.8 12.5 14.8 22.2l0 32 74.6 0c43.1 0 70.1-46.7 48.5-84l-5.3-9.1c-8.8-15.3-3.6-34.9 11.7-43.7zM51 235.4l-27.7-16c-8.4-4.9-13.1-14.3-11.8-23.9s8.2-17.5 17.6-20L116.5 152c12.8-3.4 26 4.2 29.4 17l23.4 87.4c2.5 9.4-.9 19.3-8.6 25.3s-18.2 6.6-26.6 1.7l-27.7-16-37.3 64.6c-21.6 37.3 5.4 84 48.5 84l10.6 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-10.6 0C25.3 480-32.4 380 13.8 300L51 235.4z"
]
},
{
"kind": "fa",
"key": "fa:dog",
"name": "dog",
"label": "Dog",
"viewBox": "0 0 576 512",
"paths": [
"M32 112c16.6 0 30.2 12.6 31.8 28.7l.3 6.6C65.8 163.4 79.4 176 96 176l179.1 0 140.9 60.4 0 243.6c0 17.7-14.3 32-32 32l-32 0c-17.7 0-32-14.3-32-32l0-131.3C296 361 268.8 368 240 368s-56-7-80-19.3L160 480c0 17.7-14.3 32-32 32l-32 0c-17.7 0-32-14.3-32-32l0-245.6c-37.3-13.2-64-48.6-64-90.4 0-17.7 14.3-32 32-32zM355.8-32c7.7 0 14.9 3.6 19.6 9.8L392 0 444.1 0c12.7 0 24.9 5.1 33.9 14.1L496 32 552 32c13.3 0 24 10.7 24 24l0 24c0 44.2-35.8 80-80 80l-64 0-7 28-124.7-53.4 31.6-147.2C334.3-23.9 344.2-32 355.8-32zM448 44a20 20 0 1 0 0 40 20 20 0 1 0 0-40z"
]
},
{
"kind": "fa",
"key": "fa:cat",
"name": "cat",
"label": "Cat",
"viewBox": "0 0 576 512",
"paths": [
"M64 96c53 0 96 43 96 96l0 85.8c29.7-44.7 77.8-76.2 133.4-84 25.6 60 85.2 102.1 154.6 102.1 10.9 0 21.6-1.1 32-3.1L480 480c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-140.8-136 108.8 56 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-144 0c-53 0-96-43-96-96l0-224c0-16.6-12.6-30.2-28.7-31.8l-6.6-.3C44.6 158.2 32 144.6 32 128 32 110.3 46.3 96 64 96zM533.8 3.2C544.2-5.5 560 1.9 560 15.5L560 128c0 61.9-50.1 112-112 112S336 189.9 336 128l0-112.5c0-13.6 15.8-21 26.2-12.3L416 48 480 48 533.8 3.2zM400 108a20 20 0 1 0 0 40 20 20 0 1 0 0-40zm96 0a20 20 0 1 0 0 40 20 20 0 1 0 0-40z"
]
},
{
"kind": "fa",
"key": "fa:paw",
"name": "paw",
"label": "Pets",
"viewBox": "0 0 512 512",
"paths": [
"M234.5 92.9c14.3 42.9-.3 86.2-32.6 96.8s-70.1-15.6-84.4-58.5 .3-86.2 32.6-96.8 70.1 15.6 84.4 58.5zM100.4 198.6c18.9 32.4 14.3 70.1-10.2 84.1s-59.7-.9-78.5-33.3-14.3-70.1 10.2-84.1 59.7 .9 78.5 33.3zM69.2 401.2C121.6 259.9 214.7 224 256 224s134.4 35.9 186.8 177.2c3.6 9.7 5.2 20.1 5.2 30.5l0 1.6c0 25.8-20.9 46.7-46.7 46.7-11.5 0-22.9-1.4-34-4.2l-88-22c-15.3-3.8-31.3-3.8-46.6 0l-88 22c-11.1 2.8-22.5 4.2-34 4.2-25.8 0-46.7-20.9-46.7-46.7l0-1.6c0-10.4 1.6-20.8 5.2-30.5zM421.8 282.7c-24.5-14-29.1-51.7-10.2-84.1s54-47.3 78.5-33.3 29.1 51.7 10.2 84.1-54 47.3-78.5 33.3zM310.1 189.7c-32.3-10.6-46.9-53.9-32.6-96.8s52.1-69.1 84.4-58.5 46.9 53.9 32.6 96.8-52.1 69.1-84.4 58.5z"
]
},
{
"kind": "fa",
"key": "fa:seedling",
"name": "seedling",
"label": "Plants",
"viewBox": "0 0 512 512",
"paths": [
"M512 32C512 140.1 435.4 230.3 333.6 251.4 325.7 193.3 299.6 141 261.1 100.5 301.2 40 369.9 0 448 0l32 0c17.7 0 32 14.3 32 32zM0 96C0 78.3 14.3 64 32 64l32 0c123.7 0 224 100.3 224 224l0 192c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-160C100.3 320 0 219.7 0 96z"
]
},
{
"kind": "fa",
"key": "fa:book",
"name": "book",
"label": "Reading",
"viewBox": "0 0 448 512",
"paths": [
"M384 512L96 512c-53 0-96-43-96-96L0 96C0 43 43 0 96 0L400 0c26.5 0 48 21.5 48 48l0 288c0 20.9-13.4 38.7-32 45.3l0 66.7c17.7 0 32 14.3 32 32s-14.3 32-32 32l-32 0zM96 384c-17.7 0-32 14.3-32 32s14.3 32 32 32l256 0 0-64-256 0zm32-232c0 13.3 10.7 24 24 24l176 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-176 0c-13.3 0-24 10.7-24 24zm24 72c-13.3 0-24 10.7-24 24s10.7 24 24 24l176 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-176 0z"
]
}
];
export const FALLBACK_ICON: FallbackIcon = { kind: "fallback", key: null };
/** Mirrors core/icons.py EMOJI_MAX_CODE_POINTS. */
export const EMOJI_MAX_CODE_POINTS = 8;
const LUCIDE_BY_NAME: ReadonlyMap<string, GlyphIcon> = new Map(LUCIDE_ICONS.map((icon) => [icon.name, icon]));
const FA_BY_NAME: ReadonlyMap<string, GlyphIcon> = new Map(FA_ICONS.map((icon) => [icon.name, icon]));
function isEmojiPayload(text: string): boolean {
const codePoints = [...text];
if (codePoints.length === 0 || codePoints.length > EMOJI_MAX_CODE_POINTS) return false;
return !codePoints.some((char) => /[\s<>&]|\p{Cc}/u.test(char));
}
/**
* The renderable icon for a stored definition `icon` value.
*
* `lucide:<name>` and a legacy bare `<name>` resolve against the curated
* Lucide set, `fa:<name>` against the curated Font Awesome set, and
* `emoji:<grapheme>` to its text. Like the backend's
* normalize_icon_for_read, nothing here throws: null, empty, an unknown
* namespace or name, or a malformed emoji all resolve to FALLBACK_ICON.
*/
export function resolveIcon(value: string | null | undefined): ResolvedIcon {
if (!value || !value.trim()) return FALLBACK_ICON;
const colon = value.indexOf(":");
if (colon === -1) return LUCIDE_BY_NAME.get(value) ?? FALLBACK_ICON;
const namespace = value.slice(0, colon);
const name = value.slice(colon + 1);
if (namespace === "lucide") return LUCIDE_BY_NAME.get(name) ?? FALLBACK_ICON;
if (namespace === "fa") return FA_BY_NAME.get(name) ?? FALLBACK_ICON;
if (namespace === "emoji" && isEmojiPayload(name)) return { kind: "emoji", key: value, text: name };
return FALLBACK_ICON;
}
+146
View File
@@ -0,0 +1,146 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
// The offline generator is the only writer of both registry artefacts.
import { OUTPUT_PATHS, REPO_ROOT, SOURCE_PATH, renderRegistry } from "../../../tools/icons/generate.mjs";
import { EMOJI_MAX_CODE_POINTS, FALLBACK_ICON, FA_ICONS, LUCIDE_ICONS, resolveIcon } from "./registry.generated";
type Curated = { lucide: { name: string; label: string }[]; fa: { name: string; label: string }[] };
const curated = JSON.parse(readFileSync(SOURCE_PATH, "utf8")) as Curated;
type EmojiPayloadCase = { payload: string; valid: boolean; why: string };
const emojiPayloadCases = JSON.parse(
readFileSync(join(REPO_ROOT, "tools", "icons", "emoji-payload-cases.json"), "utf8"),
) as { cases: EmojiPayloadCase[] };
describe("shared icon registry — one source of truth", () => {
it("both committed artefacts are byte-identical to a fresh generation", async () => {
const fresh = await renderRegistry();
expect(OUTPUT_PATHS).toHaveLength(2);
for (const path of OUTPUT_PATHS) {
expect(readFileSync(path, "utf8"), `${path} drifted — run node tools/icons/generate.mjs`).toBe(fresh);
}
});
it("the generated sets carry exactly the curated names and labels, in order", () => {
expect(LUCIDE_ICONS.map(({ name, label }) => ({ name, label }))).toEqual(curated.lucide);
expect(FA_ICONS.map(({ name, label }) => ({ name, label }))).toEqual(curated.fa);
expect(curated.lucide).toHaveLength(36);
expect(FA_ICONS.length).toBeGreaterThanOrEqual(15);
expect(FA_ICONS.length).toBeLessThanOrEqual(25);
});
it("every glyph is plain data: a viewBox and at least one path", () => {
for (const icon of LUCIDE_ICONS) {
expect(icon.kind).toBe("lucide");
expect(icon.key).toBe(`lucide:${icon.name}`);
expect(icon.viewBox).toBe("0 0 24 24");
expect(icon.paths.length, icon.name).toBeGreaterThan(0);
}
for (const icon of FA_ICONS) {
expect(icon.kind).toBe("fa");
expect(icon.key).toBe(`fa:${icon.name}`);
expect(icon.viewBox).toMatch(/^0 0 \d+ 512$/);
expect(icon.paths.length, icon.name).toBeGreaterThan(0);
}
for (const icon of [...LUCIDE_ICONS, ...FA_ICONS]) {
for (const d of icon.paths) expect(d, icon.key).toMatch(/^[Mm][-0-9.,\sA-Za-z]+$/);
}
});
});
describe("resolveIcon", () => {
it("resolves lucide:, fa: and emoji: keys", () => {
const dog = resolveIcon("lucide:dog");
expect(dog.kind).toBe("lucide");
expect(dog).toBe(LUCIDE_ICONS.find((icon) => icon.name === "dog"));
const broom = resolveIcon("fa:broom");
expect(broom.kind).toBe("fa");
expect(broom).toBe(FA_ICONS.find((icon) => icon.name === "broom"));
expect(resolveIcon("emoji:🐶")).toEqual({ kind: "emoji", key: "emoji:🐶", text: "🐶" });
expect(resolveIcon("emoji:👨‍👩‍👧")).toEqual({ kind: "emoji", key: "emoji:👨‍👩‍👧", text: "👨‍👩‍👧" });
});
it("reads a legacy bare name as Lucide", () => {
expect(resolveIcon("dog")).toBe(resolveIcon("lucide:dog"));
expect(resolveIcon("trash-2")).toBe(LUCIDE_ICONS.find((icon) => icon.name === "trash-2"));
});
it("falls back — never throws — for empty or unknown values", () => {
for (const value of [
null,
undefined,
"",
" ",
"rocket-ship-9000",
"lucide:",
"lucide:rocket-ship-9000",
"fa:",
"fa:rocket-ship-9000",
"fa:Dog",
"mystery:dog",
"emoji:",
"emoji:<b>",
"emoji:a b",
"emoji:123456789",
]) {
expect(resolveIcon(value), String(value)).toBe(FALLBACK_ICON);
}
expect(FALLBACK_ICON).toEqual({ kind: "fallback", key: null });
});
// tests/test_core_icons.py asserts the same table against the backend's
// validate_icon, so the two emoji rules cannot drift apart silently.
it.each(emojiPayloadCases.cases)("emoji verdict matches the shared table: $why", ({ payload, valid }) => {
const value = `emoji:${payload}`;
expect(resolveIcon(value)).toEqual(valid ? { kind: "emoji", key: value, text: payload } : FALLBACK_ICON);
});
it("the shared emoji table covers the contract edges", () => {
const verdicts = new Map(emojiPayloadCases.cases.map(({ payload, valid }) => [payload, valid]));
expect(verdicts.get("\ufeff")).toBe(false);
expect(verdicts.get("👨\u200d👩\u200d👧\u200d👦")).toBe(true);
expect(verdicts.get("🐕 ")).toBe(false);
expect(verdicts.get("🐕\u0000")).toBe(false);
for (const markup of ["<b>", "a>b", "a&b"]) expect(verdicts.get(markup)).toBe(false);
expect(verdicts.get("⭐".repeat(EMOJI_MAX_CODE_POINTS))).toBe(true);
expect(verdicts.get("⭐".repeat(EMOJI_MAX_CODE_POINTS + 1))).toBe(false);
});
});
function filesUnder(dir: string): string[] {
return readdirSync(dir).flatMap((entry) => {
const path = join(dir, entry);
return statSync(path).isDirectory() ? filesUnder(path) : [path];
});
}
describe("offline bundling", () => {
const registryFiles = [SOURCE_PATH, join(REPO_ROOT, "tools", "icons", "generate.mjs"), ...OUTPUT_PATHS];
it("the registry has no remote URL, CDN reference or data-lucide runtime", () => {
for (const path of registryFiles) {
const text = readFileSync(path, "utf8");
expect(text, path).not.toMatch(/https?:\/\//i);
expect(text, path).not.toMatch(/\/\/[a-z0-9.-]+\.[a-z]{2,}\//i);
expect(text, path).not.toMatch(/\bcdn\b|jsdelivr|unpkg|cdnjs|kit\.fontawesome/i);
expect(text, path).not.toContain("data-lucide");
expect(text, path).not.toMatch(/\bfetch\s*\(/);
}
});
it("no bundled source imports an icon package — only the curated generated data ships", () => {
const sources = [join(REPO_ROOT, "admin", "src"), join(REPO_ROOT, "frontend", "src")]
.flatMap(filesUnder)
.filter((path) => /\.(ts|tsx)$/.test(path) && !path.endsWith(".test.ts") && !path.endsWith(".test.tsx"));
expect(sources.length).toBeGreaterThan(0);
for (const path of sources) {
const text = readFileSync(path, "utf8");
expect(text, path).not.toMatch(/from\s+["'](lucide|lucide-react|lucide-static|@fortawesome\/[^"']+)["']/);
}
});
});
+76
View File
@@ -0,0 +1,76 @@
/* NestQuest Admin — global tokens + base.
Plain CubeCraft styling, mobile-first, 390x844 reference
(design/ADMIN-SPEC.md). Values mirror design/tokens/nestquest-admin-tokens.css,
re-scoped to :root for the standalone PWA. */
:root {
--nq-brand-blue: #3b3ab8;
--nq-brand-purple: #a035cc;
--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));
--nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple));
--nq-a-page: #f4f4f6;
--nq-a-surface: #ffffff;
--nq-a-selected: #ebebf8;
--nq-a-border: #e4e4ea;
--nq-a-divider: #ededf1;
--nq-a-ink: #18181d;
--nq-a-ink-secondary: #52525e;
--nq-a-ink-tertiary: #70707e;
--nq-a-success: #15803d;
--nq-a-danger: #dc2626;
--nq-a-danger-strong: #b91c1c;
--nq-a-danger-bg: #fee2e2;
--nq-a-warning-ink: #92400e;
--nq-a-warning-ink-2: #78350f;
--nq-a-warning-bg: #fef3c7;
--nq-a-warning-border: rgba(217, 119, 6, 0.35);
--nq-a-info-bg: #dbeafe;
--nq-a-info-ink: #1e3a8a;
--nq-a-info-icon: #1d4ed8;
--nq-a-reversal: #7d28a0;
--nq-a-font: Nunito, system-ui, sans-serif;
--nq-a-size-screen: 24px;
--nq-a-size-stat: 22px;
--nq-a-size-row: 13.5px;
--nq-a-size-meta: 11px;
--nq-a-size-label: 10px;
--nq-a-track-label: 0.1em;
--nq-a-density-gap: 10px;
--nq-a-density-card-pad: 13px;
--nq-a-density-row-pad: 11px 13px;
--nq-a-density-radius: 8px;
--nq-a-tabbar-height: 70px;
--nq-a-tap-min: 44px;
--nq-a-radius-pill: 9999px;
--nq-a-radius-sheet: 20px 20px 0 0;
--nq-a-sheet-shadow: 0 -8px 28px rgba(9, 9, 11, 0.18);
--nq-a-progress-height: 6px;
--nq-ease-out: cubic-bezier(0, 0, 0.2, 1);
--nq-dur-base: 200ms;
--nq-dur-modal: 350ms;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
height: 100%;
}
body {
font-family: var(--nq-a-font);
background: var(--nq-a-page);
color: var(--nq-a-ink);
-webkit-font-smoothing: antialiased;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import AppRoutes from "./AppRoutes";
import "./index.css";
import { registerServiceWorker } from "./pwa/register";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<AppRoutes />
</StrictMode>,
);
void registerServiceWorker();
+396
View File
@@ -0,0 +1,396 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
REVISION_MARKER,
STATIC_PRECACHE_FILES,
injectServiceWorker,
} from "../../vite-sw-plugin";
import { registerServiceWorker } from "./register";
const adminRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const adminPath = (rel: string) => resolve(adminRoot, rel);
const publicPath = (urlPath: string) => adminPath(`public${urlPath}`);
interface ManifestIcon {
src: string;
sizes: string;
type: string;
purpose?: string;
}
const manifest = JSON.parse(readFileSync(publicPath("/manifest.webmanifest"), "utf8"));
const icons: ManifestIcon[] = manifest.icons;
const swSource = readFileSync(publicPath("/sw.js"), "utf8");
const sizeOf = (icon: ManifestIcon) => Number(icon.sizes.split("x")[0]);
const purposes = (icon: ManifestIcon) => (icon.purpose ?? "any").split(/\s+/);
describe("web app manifest", () => {
it("meets the installability criteria", () => {
expect(manifest.name).toBe("NestQuest Admin");
expect(manifest.short_name).toBe("NestQuest");
expect(manifest.start_url).toBe("/");
expect(manifest.scope).toBe("/");
expect(manifest.display).toBe("standalone");
expect(manifest.background_color).toMatch(/^#[0-9a-f]{6}$/i);
expect(manifest.theme_color).toMatch(/^#[0-9a-f]{6}$/i);
const any = icons.filter((i) => purposes(i).includes("any"));
expect(any.some((i) => sizeOf(i) >= 192)).toBe(true);
expect(any.some((i) => sizeOf(i) >= 512)).toBe(true);
expect(icons.some((i) => purposes(i).includes("maskable"))).toBe(true);
});
it("uses the admin design token colours", () => {
const css = readFileSync(adminPath("src/index.css"), "utf8");
expect(css).toContain(`--nq-brand-blue: ${manifest.theme_color};`);
expect(css).toContain(`--nq-a-page: ${manifest.background_color};`);
});
it.each(icons.map((i) => [i.src, i] as const))(
"icon %s is a real PNG of its declared size",
(_src, icon) => {
const path = publicPath(icon.src);
expect(existsSync(path)).toBe(true);
const png = readFileSync(path);
expect([...png.subarray(0, 8)]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
expect(png.toString("ascii", 12, 16)).toBe("IHDR");
const [w, h] = icon.sizes.split("x").map(Number);
expect(png.readUInt32BE(16)).toBe(w);
expect(png.readUInt32BE(20)).toBe(h);
expect(icon.type).toBe("image/png");
},
);
});
describe("index.html and entry point", () => {
const html = readFileSync(adminPath("index.html"), "utf8");
it("links the manifest, theme colour and apple touch icon", () => {
expect(html).toContain('<link rel="manifest" href="/manifest.webmanifest" />');
expect(html).toContain(`<meta name="theme-color" content="${manifest.theme_color}" />`);
expect(html).toContain('<link rel="apple-touch-icon" href="/icons/icon-192.png" />');
});
it("main.tsx registers the service worker", () => {
const main = readFileSync(adminPath("src/main.tsx"), "utf8");
expect(main).toMatch(/import \{ registerServiceWorker \} from "\.\/pwa\/register"/);
expect(main).toMatch(/registerServiceWorker\(\)/);
});
});
// Minimal service-worker global scope: runs public/sw.js against in-memory
// caches and a stubbed network, so handler behaviour is checked without I/O.
function loadServiceWorker(
network: (url: string) => Promise<FakeResponse>,
source: string = swSource,
) {
const listeners: Record<string, (event: any) => void> = {};
const stores = new Map<string, Map<string, FakeResponse>>();
const openStore = (name: string) => {
if (!stores.has(name)) stores.set(name, new Map());
return stores.get(name)!;
};
const keyOf = (req: string | { url: string }) =>
new URL(typeof req === "string" ? req : req.url, "https://admin.test").href;
const fetchFn = vi.fn((req: string | { url: string }) => network(keyOf(req)));
const caches = {
open: async (name: string) => {
const store = openStore(name);
return {
addAll: async (urls: string[]) => {
for (const u of urls) store.set(keyOf(u), await network(keyOf(u)));
},
put: async (req: { url: string }, res: FakeResponse) => void store.set(keyOf(req), res),
};
},
keys: async () => [...stores.keys()],
delete: async (name: string) => stores.delete(name),
match: async (req: string | { url: string }, opts?: { cacheName?: string }) => {
const names = opts?.cacheName ? [opts.cacheName] : [...stores.keys()];
for (const n of names) {
const hit = stores.get(n)?.get(keyOf(req));
if (hit) return hit;
}
return undefined;
},
};
const self = {
location: new URL("https://admin.test/"),
addEventListener: (type: string, fn: (event: any) => void) => (listeners[type] = fn),
skipWaiting: vi.fn(async () => undefined),
clients: { claim: vi.fn(async () => undefined) },
};
const ResponseStub = { error: () => new FakeResponse("", false, "error") };
new Function("self", "caches", "fetch", "Response", source)(self, caches, fetchFn, ResponseStub);
const dispatch = async (type: string, init: object = {}) => {
let pending: Promise<unknown> | undefined;
const event = {
...init,
waitUntil: (p: Promise<unknown>) => (pending = p),
respondWith: (p: Promise<unknown>) => (pending = p),
};
listeners[type](event);
return { responded: pending !== undefined, result: await pending };
};
return { listeners, stores, self, fetchFn, dispatch, openStore };
}
class FakeResponse {
constructor(
readonly body: string,
readonly ok = true,
readonly type = "basic",
) {}
clone() {
return new FakeResponse(this.body, this.ok, this.type);
}
}
const request = (url: string, extra: object = {}) => ({
url: new URL(url, "https://admin.test").href,
method: "GET",
mode: "cors",
destination: "",
...extra,
});
describe("service worker (public/sw.js)", () => {
it("is a static asset served from the site root", () => {
expect(existsSync(publicPath("/sw.js"))).toBe(true);
expect(swSource).toMatch(/const SHELL_CACHE = /);
expect(swSource).toMatch(/addEventListener\("install"/);
expect(swSource).toMatch(/addEventListener\("activate"/);
expect(swSource).toMatch(/addEventListener\("fetch"/);
});
it("precaches the shell on install, prunes stale caches on activate", async () => {
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
sw.openStore("nestquest-admin-shell-v0");
await sw.dispatch("install");
expect(sw.self.skipWaiting).toHaveBeenCalled();
const precached = [...sw.stores.values()].flatMap((s) => [...s.keys()]);
for (const path of ["/", "/index.html", "/manifest.webmanifest", ...icons.map((i) => i.src)]) {
expect(precached).toContain(`https://admin.test${path}`);
}
await sw.dispatch("activate");
expect(sw.stores.has("nestquest-admin-shell-v0")).toBe(false);
expect(sw.self.clients.claim).toHaveBeenCalled();
});
it("serves the cached shell for navigations when offline", async () => {
let online = true;
const sw = loadServiceWorker(async (url) => {
if (!online) throw new TypeError("Failed to fetch");
return new FakeResponse(`net:${url}`);
});
await sw.dispatch("install");
online = false;
const { result } = await sw.dispatch("fetch", {
request: request("/children/42", { mode: "navigate", destination: "document" }),
});
expect((result as FakeResponse).body).toBe("net:https://admin.test/index.html");
});
it("serves static assets cache-first and fills the runtime cache", async () => {
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
const asset = request("/assets/index-abc123.js", { destination: "script" });
const first = await sw.dispatch("fetch", { request: asset });
expect((first.result as FakeResponse).body).toBe("net:https://admin.test/assets/index-abc123.js");
expect(sw.fetchFn).toHaveBeenCalledTimes(1);
const second = await sw.dispatch("fetch", { request: asset });
expect((second.result as FakeResponse).body).toBe("net:https://admin.test/assets/index-abc123.js");
expect(sw.fetchFn).toHaveBeenCalledTimes(1);
});
it("never handles cross-origin, non-GET or non-static requests", async () => {
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
const cases = [
request("https://auth.example.com/logo.png", { destination: "image" }),
request("/assets/app.js", { destination: "script", method: "POST" }),
request("/api/admin/children"),
];
for (const req of cases) {
const { responded } = await sw.dispatch("fetch", { request: req });
expect(responded).toBe(false);
}
expect(sw.fetchFn).not.toHaveBeenCalled();
});
});
// A Vite-style built index.html referencing hashed bundles.
const builtIndexHtml = (hash: string) => `<!doctype html>
<html lang="en">
<head>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<script type="module" crossorigin src="/assets/index-${hash}.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-${hash}.js">
<link rel="stylesheet" crossorigin href="/assets/index-${hash}.css">
<link rel="preconnect" href="https://auth.example.com" />
</head>
<body><div id="root"></div></body>
</html>`;
// Same-origin script/link URLs of an HTML document, parsed independently of
// the build plugin so the regression check does not trust its own extractor.
const referencedAssets = (html: string) =>
[...new DOMParser().parseFromString(html, "text/html").querySelectorAll("script[src], link[href]")]
.map((el) => new URL(el.getAttribute("src") ?? el.getAttribute("href")!, "https://admin.test/"))
.filter((url) => url.origin === "https://admin.test")
.map((url) => url.href);
// The unhashed files the build hashes into the revision, read from public/.
const staticFiles = STATIC_PRECACHE_FILES.map((path) => ({
path,
bytes: readFileSync(publicPath(`/${path}`)),
}));
const revisionOf = (src: string) => /const REVISION = "([0-9a-f]+)";/.exec(src)?.[1];
describe("build-time service worker injection", () => {
it("precaches every asset the cached index.html references on install (PWA-001)", async () => {
const html = builtIndexHtml("Ab12Cd34");
const built = injectServiceWorker(html, swSource, staticFiles);
const sw = loadServiceWorker(async (url) =>
new FakeResponse(url.endsWith("/index.html") ? html : `net:${url}`),
built);
await sw.dispatch("install");
const shell = [...sw.stores.entries()].find(([name]) => name.startsWith("nestquest-admin-shell-"));
expect(shell).toBeDefined();
const [, shellStore] = shell!;
const cachedHtml = shellStore.get("https://admin.test/index.html")!.body;
const assets = referencedAssets(cachedHtml);
expect(assets).toEqual(
expect.arrayContaining([
"https://admin.test/assets/index-Ab12Cd34.js",
"https://admin.test/assets/vendor-Ab12Cd34.js",
"https://admin.test/assets/index-Ab12Cd34.css",
]),
);
for (const asset of assets) expect([...shellStore.keys()]).toContain(asset);
expect([...shellStore.keys()]).not.toContain("https://auth.example.com/");
});
it("derives a new revision for different index.html content (PWA-002)", () => {
const a = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
const b = injectServiceWorker(builtIndexHtml("bbbb2222"), swSource, staticFiles);
expect(a).not.toContain(REVISION_MARKER);
expect(b).not.toContain(REVISION_MARKER);
expect(revisionOf(a)).toMatch(/^[0-9a-f]{12}$/);
expect(revisionOf(b)).toMatch(/^[0-9a-f]{12}$/);
expect(revisionOf(a)).not.toBe(revisionOf(b));
expect(injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles)).toBe(a);
});
it("derives a new revision when only a precached static file changes (PWA-002)", () => {
const html = builtIndexHtml("aaaa1111");
const base = revisionOf(injectServiceWorker(html, swSource, staticFiles));
const withChanged = (path: string) =>
staticFiles.map((f) =>
f.path === path ? { path, bytes: Buffer.concat([f.bytes, Buffer.from([0])]) } : f,
);
expect(revisionOf(injectServiceWorker(html, swSource, [...staticFiles].reverse()))).toBe(base);
for (const path of STATIC_PRECACHE_FILES) {
const changed = revisionOf(injectServiceWorker(html, swSource, withChanged(path)));
expect(changed, path).toMatch(/^[0-9a-f]{12}$/);
expect(changed, path).not.toBe(base);
}
});
it("hashes every unhashed URL sw.js precaches (PWA-002)", () => {
const shellUrls = [...swSource.matchAll(/^\s*"(\/[^"]*)",$/gm)].map((m) => m[1]);
const fixed = shellUrls.filter((url) => url !== "/" && url !== "/index.html");
expect(fixed.map((url) => url.slice(1)).sort()).toEqual([...STATIC_PRECACHE_FILES].sort());
});
it("names both caches after the build revision (PWA-002)", async () => {
const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
const revision = /const REVISION = "([0-9a-f]+)";/.exec(built)![1];
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built);
await sw.dispatch("install");
await sw.dispatch("fetch", { request: request("/assets/late.js", { destination: "script" }) });
expect([...sw.stores.keys()].sort()).toEqual([
`nestquest-admin-runtime-${revision}`,
`nestquest-admin-shell-${revision}`,
]);
});
it("fails fast when sw.js lacks the placeholders", () => {
expect(() => injectServiceWorker(builtIndexHtml("x"), "const SHELL_URLS = [];", staticFiles)).toThrow();
});
it("activation deletes only stale nestquest-admin caches (PWA-003)", async () => {
const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built);
for (const name of [
"nestquest-admin-shell-old",
"nestquest-admin-runtime-old",
"some-other-app-cache",
]) {
sw.openStore(name);
}
await sw.dispatch("install");
await sw.dispatch("activate");
const names = [...sw.stores.keys()];
expect(names).not.toContain("nestquest-admin-shell-old");
expect(names).not.toContain("nestquest-admin-runtime-old");
expect(names).toContain("some-other-app-cache");
expect(names.some((n) => n.startsWith("nestquest-admin-shell-") && n !== "nestquest-admin-shell-old")).toBe(true);
});
});
describe("registerServiceWorker", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const stubServiceWorker = (register: ReturnType<typeof vi.fn>) =>
vi.stubGlobal("navigator", { ...navigator, serviceWorker: { register } });
it("does not register outside production builds", async () => {
const register = vi.fn();
stubServiceWorker(register);
expect(import.meta.env.PROD).toBe(false);
await registerServiceWorker();
expect(register).not.toHaveBeenCalled();
});
it("registers /sw.js in production", async () => {
vi.stubEnv("PROD", true);
const register = vi.fn().mockResolvedValue({});
stubServiceWorker(register);
await registerServiceWorker();
expect(register).toHaveBeenCalledWith("/sw.js", { scope: "/" });
});
it("logs instead of throwing when registration fails", async () => {
vi.stubEnv("PROD", true);
stubServiceWorker(vi.fn().mockRejectedValue(new Error("boom")));
const error = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(registerServiceWorker()).resolves.toBeUndefined();
expect(error).toHaveBeenCalled();
});
it("does nothing when the browser lacks service workers", async () => {
vi.stubEnv("PROD", true);
vi.stubGlobal("navigator", {});
await expect(registerServiceWorker()).resolves.toBeUndefined();
});
});
+13
View File
@@ -0,0 +1,13 @@
/**
* Service worker registration for the installable admin PWA.
* Registers /sw.js only in production builds, so dev servers and tests never
* run a worker. Failures are logged, never thrown — the app works without it.
*/
export async function registerServiceWorker(): Promise<void> {
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
try {
await navigator.serviceWorker.register("/sw.js", { scope: "/" });
} catch (error) {
console.error("Service worker registration failed:", error);
}
}
+229
View File
@@ -0,0 +1,229 @@
/* Presence override editor — roomy density pushed screen
(design/ADMIN-SPEC.md §6). Covers the tab bar like a pushed route. */
.override-screen {
position: fixed;
inset: 0;
z-index: 30;
overflow-y: auto;
background: var(--nq-a-page);
}
.override-header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px;
background: var(--nq-a-surface);
border-bottom: 1px solid var(--nq-a-border);
}
.override-back {
flex: none;
display: grid;
place-items: center;
width: var(--nq-a-tap-min);
height: var(--nq-a-tap-min);
margin: -6px 0 -6px -12px;
padding: 0;
border: 0;
background: none;
color: var(--nq-a-ink);
cursor: pointer;
}
.override-title {
margin: 0;
font-size: var(--nq-a-size-screen);
font-weight: 800;
letter-spacing: -0.02em;
}
.override-sub {
margin: 2px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.override-scroll {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
max-width: 640px;
margin: 0 auto;
padding: 16px 16px 24px;
}
/* Form card: roomy density, 18px padding, 16px gaps. */
.override-card {
display: flex;
flex-direction: column;
gap: 16px;
padding: 18px;
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: 12px;
}
.override-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.override-label {
font-size: var(--nq-a-size-label);
font-weight: 700;
text-transform: uppercase;
letter-spacing: var(--nq-a-track-label);
color: var(--nq-a-ink-secondary);
}
.override-segments {
display: grid;
gap: 6px;
}
.override-segments--3 {
grid-template-columns: repeat(3, 1fr);
}
.override-segments--2 {
grid-template-columns: repeat(2, 1fr);
}
.override-segments--7 {
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.override-segments--7 .override-segment {
padding: 0;
}
.override-segment {
min-height: var(--nq-a-tap-min);
padding: 0 8px;
border: 1px solid var(--nq-a-border);
border-radius: 10px;
background: var(--nq-a-surface);
color: var(--nq-a-ink-secondary);
font-family: inherit;
font-size: 13.5px;
font-weight: 700;
cursor: pointer;
}
.override-segment--gradient[aria-pressed="true"] {
border-color: transparent;
background: var(--nq-brand-gradient);
color: #fff;
}
.override-segment--ink[aria-pressed="true"] {
border-color: #18181d;
background: #18181d;
color: #fff;
}
.override-dates {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.override-input {
width: 100%;
min-height: var(--nq-a-tap-min);
padding: 13px;
border: 1px solid var(--nq-a-border);
border-radius: 10px;
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 14px;
font-weight: 600;
}
.override-date {
font-weight: 700;
}
/* Consequence warning */
.override-warning {
display: flex;
gap: 10px;
padding: 14px;
border: 1px solid var(--nq-a-warning-border);
border-radius: 12px;
background: var(--nq-a-warning-bg);
}
.override-warning-glyph {
flex: none;
display: flex;
color: var(--nq-a-warning-ink);
}
.override-warning-headline {
margin: 0;
font-size: 13.5px;
font-weight: 700;
color: var(--nq-a-warning-ink);
}
.override-warning-body {
margin: 3px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-warning-ink-2);
}
.override-error {
margin: 0;
padding: 10px 12px;
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-danger-bg);
color: var(--nq-a-danger-strong);
font-size: 12.5px;
font-weight: 700;
}
/* Footer: Cancel (flex 1) + Save override (flex 2), 15px vertical padding. */
.override-footer {
display: flex;
gap: 10px;
}
.override-cancel,
.override-save {
padding: 15px 12px;
border-radius: 12px;
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.override-cancel {
flex: 1;
border: 1px solid var(--nq-a-border);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
}
.override-save {
flex: 2;
border: 0;
background: var(--nq-brand-gradient);
color: #fff;
}
.override-cancel:disabled,
.override-save:disabled {
opacity: 0.45;
cursor: default;
}
+536
View File
@@ -0,0 +1,536 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import App from "../App";
import ScheduleTab from "./ScheduleTab";
import { CONSEQUENCE_DEBOUNCE_MS, isIsoDate, isValidRange } from "./OverrideEditor";
import type { AdminChild } from "../api/definitions";
import type { PresenceOverride, PresencePattern } from "../api/presence";
import { storeTokens } from "../auth/oidc";
function child(id: number, display_name: string, sort_order: number): AdminChild {
return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active: true };
}
const CHILDREN = [child(1, "Declan", 1), child(2, "Maeve", 2), child(3, "Rory", 3)];
const DECLAN: PresencePattern = {
id: 1,
child_id: 1,
name: "Home weeks",
kind: "home",
cycle_length_weeks: 2,
anchor_date: "2026-09-07",
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] },
};
const INITIAL_OVERRIDES: PresenceOverride[] = [
{ id: 41, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: "Dentist trip" },
];
const TODAY = "2026-09-23";
const OVERRIDES_PATH = "/api/v1/admin/presence-overrides";
const CONSEQUENCE_PATH = `${OVERRIDES_PATH}/consequence`;
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
interface Call {
method: string;
path: string;
body: unknown;
}
let overrides: PresenceOverride[];
let consequence: (body: Record<string, unknown>) => Response | Promise<Response>;
let createResponse: (body: Record<string, unknown>) => Response;
let fetchMock: ReturnType<typeof vi.fn>;
function calls(): Call[] {
return fetchMock.mock.calls.map(([url, init]: [string, RequestInit | undefined]) => ({
method: init?.method ?? "GET",
path: new URL(String(url), "http://x").pathname,
body: init?.body ? JSON.parse(String(init.body)) : undefined,
}));
}
function consequenceBodies(): unknown[] {
return calls()
.filter((c) => c.path === CONSEQUENCE_PATH)
.map((c) => c.body);
}
async function routeFetch(url: string, init: RequestInit = {}): Promise<Response> {
const path = new URL(url, "http://x").pathname;
const method = init.method ?? "GET";
const body = init.body ? JSON.parse(String(init.body)) : undefined;
if (path === CONSEQUENCE_PATH && method === "POST") return consequence(body);
if (path === OVERRIDES_PATH && method === "POST") return createResponse(body);
const deleteMatch = /\/presence-overrides\/(\d+)$/.exec(path);
if (deleteMatch && method === "DELETE") {
overrides = overrides.filter((o) => o.id !== Number(deleteMatch[1]));
return jsonResponse({ status: "ok" });
}
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
const patternsMatch = /\/children\/(\d+)\/presence-patterns$/.exec(path);
if (patternsMatch) {
return jsonResponse({ patterns: Number(patternsMatch[1]) === 1 ? [DECLAN] : [] });
}
if (path === OVERRIDES_PATH) return jsonResponse({ overrides });
return jsonResponse({ detail: "unexpected" }, 500);
}
function deferred() {
let resolve!: (response: Response) => void;
const promise = new Promise<Response>((r) => {
resolve = r;
});
return { promise, resolve };
}
const pastDebounce = () => new Promise((r) => setTimeout(r, CONSEQUENCE_DEBOUNCE_MS + 150));
function headline(): string {
return screen.getByTestId("override-consequence-headline").textContent ?? "";
}
function warningBody(): string {
return screen.getByTestId("override-consequence").querySelector(".override-warning-body")?.textContent ?? "";
}
function saveButton(): HTMLButtonElement {
return screen.getByRole("button", { name: "Save override" }) as HTMLButtonElement;
}
function setDate(label: "From" | "Through", value: string) {
fireEvent.change(screen.getByLabelText(label), { target: { value } });
}
async function openEditor() {
render(<ScheduleTab today={TODAY} />);
await screen.findByTestId("pattern-1");
fireEvent.click(screen.getByRole("button", { name: "Add" }));
await screen.findByRole("heading", { name: "Presence override" });
}
describe("OverrideEditor", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
overrides = [...INITIAL_OVERRIDES];
consequence = () => jsonResponse({ removed: 4 });
createResponse = (body) => {
const created: PresenceOverride = {
id: 50,
child_id: body.child_id as number,
start_date: body.start_date as string,
end_date: body.end_date as string,
is_present: body.is_present as boolean,
note: (body.note as string | undefined) ?? null,
};
overrides = [...overrides, created];
return jsonResponse(created, 201);
};
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("renders the pushed screen per ADMIN-SPEC §6", async () => {
await openEditor();
expect(screen.getByText("Beats the custody pattern for these dates")).toBeTruthy();
expect(screen.getByRole("button", { name: "Back" })).toBeTruthy();
const kids = within(screen.getByRole("group", { name: "Child" })).getAllByRole("button");
expect(kids.map((b) => [b.textContent, b.getAttribute("aria-pressed")])).toEqual([
["Declan", "true"],
["Maeve", "false"],
["Rory", "false"],
]);
const status = within(screen.getByRole("group", { name: "Status for these dates" }));
expect(status.getByRole("button", { name: "Away" }).getAttribute("aria-pressed")).toBe("true");
expect(status.getByRole("button", { name: "Home" }).getAttribute("aria-pressed")).toBe("false");
expect((screen.getByLabelText("From") as HTMLInputElement).value).toBe(TODAY);
expect((screen.getByLabelText("Through") as HTMLInputElement).value).toBe(TODAY);
expect(screen.getByLabelText("Reason (optional)")).toBeTruthy();
expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
});
it("shows the live consequence count from the API and recounts on every change", async () => {
await openEditor();
// Save is off until the count is on screen: the warning precedes saving.
expect(saveButton().disabled).toBe(true);
await waitFor(() => expect(headline()).toBe("This removes 4 upcoming tasks"));
expect(consequenceBodies()).toEqual([
{ child_id: 1, start_date: TODAY, end_date: TODAY, is_present: false },
]);
expect(warningBody()).toBe(
"Declan's open tasks Sep 23 are removed while away. Completed days are untouched.",
);
expect(saveButton().disabled).toBe(false);
consequence = () => jsonResponse({ removed: 9 });
setDate("Through", "2026-09-27");
await waitFor(() => expect(headline()).toBe("This removes 9 upcoming tasks"));
expect(consequenceBodies()[1]).toEqual({
child_id: 1,
start_date: TODAY,
end_date: "2026-09-27",
is_present: false,
});
expect(warningBody()).toBe(
"Declan's open tasks Sep 23 – 27 are removed while away. Completed days are untouched.",
);
consequence = () => jsonResponse({ removed: 2 });
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
await waitFor(() => expect(headline()).toBe("This removes 2 upcoming tasks"));
expect(consequenceBodies()[2]).toEqual({
child_id: 2,
start_date: TODAY,
end_date: "2026-09-27",
is_present: false,
});
expect(warningBody()).toBe(
"Maeve's open tasks Sep 23 – 27 are removed while away. Completed days are untouched.",
);
// A Home override removes nothing by definition: no request, count 0.
fireEvent.click(screen.getByRole("button", { name: "Home" }));
expect(headline()).toBe("This removes 0 upcoming tasks");
expect(warningBody()).toBe(
"Maeve is home Sep 23 – 27, so no tasks are removed. Completed days are untouched.",
);
await pastDebounce();
expect(consequenceBodies()).toHaveLength(3);
});
it("renders the API's count verbatim — completed instances are excluded server-side", async () => {
// The range holds 3 open instances and 1 already completed. The API
// excludes the completed one (D-005; proven by
// tests/test_api_admin_presence_consequence.py::test_completed_instance_in_range_is_not_counted
// and ::test_count_matches_a_direct_materialise_comparison), so it answers 3,
// and the editor shows exactly that — it never counts days on its own.
consequence = () => jsonResponse({ removed: 3 });
await openEditor();
setDate("Through", "2026-09-25");
await waitFor(() => expect(headline()).toBe("This removes 3 upcoming tasks"));
expect(warningBody()).toBe(
"Declan's open tasks Sep 23 – 25 are removed while away. Completed days are untouched.",
);
});
it("a stale consequence response never overwrites a newer one", async () => {
const first = deferred();
const second = deferred();
const pending = [first, second];
consequence = () => pending.shift()!.promise;
await openEditor();
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
setDate("Through", "2026-09-26");
await waitFor(() => expect(consequenceBodies()).toHaveLength(2));
second.resolve(jsonResponse({ removed: 5 }));
await waitFor(() => expect(headline()).toBe("This removes 5 upcoming tasks"));
first.resolve(jsonResponse({ removed: 99 }));
await pastDebounce();
expect(headline()).toBe("This removes 5 upcoming tasks");
});
it("debounces rapid edits into one request for the final range", async () => {
await openEditor();
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
setDate("Through", "2026-09-24");
setDate("Through", "2026-09-25");
setDate("Through", "2026-09-26");
await pastDebounce();
expect(consequenceBodies().slice(1)).toEqual([
{ child_id: 1, start_date: TODAY, end_date: "2026-09-26", is_present: false },
]);
});
it("an incomplete or reversed range sends no consequence request and cannot be saved", async () => {
await openEditor();
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
setDate("Through", "");
expect(headline()).toBe("Choose a valid date range");
expect(saveButton().disabled).toBe(true);
await pastDebounce();
expect(consequenceBodies()).toHaveLength(1);
setDate("Through", "2026-09-20"); // before From
expect(headline()).toBe("Choose a valid date range");
await pastDebounce();
expect(consequenceBodies()).toHaveLength(1);
fireEvent.click(saveButton());
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
});
it("a malformed From date sends no consequence request and cannot be saved", async () => {
await openEditor();
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
setDate("From", "2026-02-30");
expect(headline()).toBe("Choose a valid date range");
expect(saveButton().disabled).toBe(true);
await pastDebounce();
expect(consequenceBodies()).toHaveLength(1);
fireEvent.click(saveButton());
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
});
it("Save sends the exact POST body, closes the editor and shows the new row", async () => {
await openEditor();
setDate("From", "2026-09-24");
setDate("Through", "2026-09-26");
fireEvent.change(screen.getByLabelText("Reason (optional)"), {
target: { value: " Trip with grandparents " },
});
await waitFor(() => expect(saveButton().disabled).toBe(false));
const before = calls().length;
const patternsBefore = calls().filter((c) => c.path.endsWith("/presence-patterns")).length;
fireEvent.click(saveButton());
const row = await screen.findByTestId("override-50");
expect(row.textContent).toContain("Declan away · Sep 24 – 26");
expect(row.textContent).toContain("Trip with grandparents");
expect(screen.queryByRole("heading", { name: "Presence override" })).toBeNull();
const after = calls().slice(before);
const writes = after.filter((c) => c.method !== "GET");
// D-005: saving sends ONLY the create request — never a delete of an
// instance or an override. The API's regenerate path keeps completed
// instances (tests/test_materialize.py::
// test_regenerate_for_definition_rebuilds_horizon_and_preserves_completed).
expect(writes).toEqual([
{
method: "POST",
path: OVERRIDES_PATH,
body: {
child_id: 1,
start_date: "2026-09-24",
end_date: "2026-09-26",
is_present: false,
note: "Trip with grandparents",
},
},
]);
expect(after.some((c) => c.method === "DELETE")).toBe(false);
// The overrides and the presence patterns are refetched for the grid.
expect(after.some((c) => c.method === "GET" && c.path === OVERRIDES_PATH)).toBe(true);
expect(calls().filter((c) => c.path.endsWith("/presence-patterns")).length).toBeGreaterThan(
patternsBefore,
);
// The grid reflects the new override.
expect(document.querySelector('[data-date="2026-09-25"]')?.getAttribute("data-state")).toBe(
"override-away",
);
});
it("omits an empty reason and saves a Home override", async () => {
await openEditor();
fireEvent.click(screen.getByRole("button", { name: "Home" }));
await waitFor(() => expect(saveButton().disabled).toBe(false));
fireEvent.click(saveButton());
await screen.findByTestId("override-50");
const post = calls().find((c) => c.method === "POST" && c.path === OVERRIDES_PATH);
expect(post?.body).toEqual({
child_id: 1,
start_date: TODAY,
end_date: TODAY,
is_present: true,
});
});
it("a 422 shows the API detail and keeps the form", async () => {
createResponse = () => jsonResponse({ detail: "overlaps an existing override" }, 422);
await openEditor();
setDate("Through", "2026-09-25");
fireEvent.change(screen.getByLabelText("Reason (optional)"), { target: { value: "Camp" } });
await waitFor(() => expect(saveButton().disabled).toBe(false));
fireEvent.click(saveButton());
const alert = await screen.findByTestId("override-error");
expect(alert.textContent).toBe("overlaps an existing override");
expect(screen.getByRole("heading", { name: "Presence override" })).toBeTruthy();
expect((screen.getByLabelText("Through") as HTMLInputElement).value).toBe("2026-09-25");
expect((screen.getByLabelText("Reason (optional)") as HTMLInputElement).value).toBe("Camp");
expect(saveButton().disabled).toBe(false);
});
it("Cancel and Back close the editor without writing", async () => {
await openEditor();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await screen.findByTestId("pattern-1");
fireEvent.click(screen.getByRole("button", { name: "Add" }));
fireEvent.click(await screen.findByRole("button", { name: "Back" }));
await screen.findByTestId("pattern-1");
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
});
it("Add opens the editor for the selected child", async () => {
render(<ScheduleTab today={TODAY} />);
await screen.findByTestId("pattern-1");
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
fireEvent.click(screen.getByRole("button", { name: "Add" }));
const kids = within(await screen.findByRole("group", { name: "Child" }));
expect(kids.getByRole("button", { name: "Maeve" }).getAttribute("aria-pressed")).toBe("true");
await waitFor(() =>
expect(consequenceBodies()).toEqual([
{ child_id: 2, start_date: TODAY, end_date: TODAY, is_present: false },
]),
);
});
it("the trash button deletes the override only after confirm", async () => {
render(<ScheduleTab today={TODAY} />);
const row = await screen.findByTestId("override-41");
fireEvent.click(within(row).getByRole("button", { name: "Delete override" }));
fireEvent.click(within(row).getByRole("button", { name: "Keep" }));
expect(calls().some((c) => c.method === "DELETE")).toBe(false);
fireEvent.click(within(row).getByRole("button", { name: "Delete override" }));
const confirm = within(row).getByRole("group", { name: "Confirm delete" });
fireEvent.click(within(confirm).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(screen.queryByTestId("override-41")).toBeNull());
expect(calls().filter((c) => c.method === "DELETE")).toEqual([
{ method: "DELETE", path: `${OVERRIDES_PATH}/41`, body: undefined },
]);
expect(screen.getByText("No overrides for Declan.")).toBeTruthy();
});
});
describe("OverrideEditor focus", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
overrides = [...INITIAL_OVERRIDES];
consequence = () => jsonResponse({ removed: 4 });
createResponse = (body) => {
const created = { id: 50, note: null, ...body } as PresenceOverride;
overrides = [...overrides, created];
return jsonResponse(created, 201);
};
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function dialog(): HTMLElement {
return screen.getByRole("dialog", { name: "Presence override" });
}
function addButton(): HTMLElement {
return screen.getByRole("button", { name: "Add" });
}
it("moves focus into the editor on open", async () => {
await openEditor();
expect(document.activeElement).toBe(screen.getByRole("heading", { name: "Presence override" }));
expect(dialog().contains(document.activeElement)).toBe(true);
});
it("keeps Tab and Shift+Tab inside the editor", async () => {
await openEditor();
await waitFor(() => expect(saveButton().disabled).toBe(false));
const back = screen.getByRole("button", { name: "Back" });
saveButton().focus();
fireEvent.keyDown(saveButton(), { key: "Tab" });
expect(document.activeElement).toBe(back);
fireEvent.keyDown(back, { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(saveButton());
// From the focused heading (not in the tab order) Shift+Tab wraps to the end.
const title = screen.getByRole("heading", { name: "Presence override" });
title.focus();
fireEvent.keyDown(title, { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(saveButton());
});
it("makes the tab bar inert while open and restores it on close", async () => {
render(<App />);
fireEvent.click(screen.getByRole("tab", { name: "Schedule" }));
await screen.findByTestId("pattern-1");
fireEvent.click(addButton());
await screen.findByRole("heading", { name: "Presence override" });
const tabbar = document.querySelector(".admin-tabbar") as HTMLElement;
expect(tabbar.hasAttribute("inert")).toBe(true);
expect(tabbar.getAttribute("aria-hidden")).toBe("true");
expect(dialog().closest("[inert]")).toBeNull();
fireEvent.keyDown(dialog(), { key: "Escape" });
await screen.findByTestId("pattern-1");
expect(tabbar.hasAttribute("inert")).toBe(false);
expect(tabbar.hasAttribute("aria-hidden")).toBe(false);
});
it("Escape cancels without writing and returns focus to Add", async () => {
await openEditor();
fireEvent.keyDown(document.activeElement as HTMLElement, { key: "Escape" });
await screen.findByTestId("pattern-1");
expect(screen.queryByRole("dialog")).toBeNull();
expect(document.activeElement).toBe(addButton());
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
});
it("Escape does nothing while saving; focus returns to Add after the save", async () => {
const gate = deferred();
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
const path = new URL(url, "http://x").pathname;
if (path === OVERRIDES_PATH && init?.method === "POST") await gate.promise;
return routeFetch(url, init);
});
await openEditor();
await waitFor(() => expect(saveButton().disabled).toBe(false));
fireEvent.click(saveButton());
expect(screen.getByRole("button", { name: "Saving…" })).toBeTruthy();
fireEvent.keyDown(dialog(), { key: "Escape" });
expect(screen.getByRole("dialog")).toBeTruthy();
gate.resolve(new Response(null));
await screen.findByTestId("override-50");
expect(screen.queryByRole("dialog")).toBeNull();
expect(document.activeElement).toBe(addButton());
});
it("Back and Cancel return focus to Add", async () => {
await openEditor();
fireEvent.click(screen.getByRole("button", { name: "Back" }));
await screen.findByTestId("pattern-1");
expect(document.activeElement).toBe(addButton());
fireEvent.click(addButton());
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
await screen.findByTestId("pattern-1");
expect(document.activeElement).toBe(addButton());
});
});
describe("range validation", () => {
it("accepts only strict ISO calendar dates in order", () => {
expect(isIsoDate("2026-09-23")).toBe(true);
expect(isIsoDate("2026-02-30")).toBe(false);
expect(isIsoDate("2026-9-3")).toBe(false);
expect(isIsoDate("")).toBe(false);
expect(isValidRange("2026-09-23", "2026-09-23")).toBe(true);
expect(isValidRange("2026-09-23", "2026-09-22")).toBe(false);
expect(isValidRange("2026-09-23", "")).toBe(false);
});
});
+353
View File
@@ -0,0 +1,353 @@
/**
* Presence override editor — the pushed screen of design/ADMIN-SPEC.md §6.
*
* The consequence warning is counted by the API (POST
* /presence-overrides/consequence) before anything is saved; Save stays off
* until that count is on screen. Saving only ever sends the create request:
* the API never removes an instance that already has a completion event (D-005).
*/
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { ApiForbiddenError } from "../api/client";
import { ApiRequestError, type AdminChild } from "../api/definitions";
import {
createPresenceOverride,
previewOverrideConsequence,
type PresenceOverride,
type PresenceOverrideBody,
} from "../api/presence";
import { dayNumber, isoFromDayNumber } from "./cycle";
import { dateRange } from "./format";
import { AlertCircleGlyph, ChevronLeftGlyph } from "./glyphs";
import "./OverrideEditor.css";
/** How long the form must sit still before the consequence is recounted. */
export const CONSEQUENCE_DEBOUNCE_MS = 300;
/** Strict "YYYY-MM-DD" naming a real calendar day (no 2026-02-30). */
export function isIsoDate(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
return isoFromDayNumber(dayNumber(value)) === value;
}
export function isValidRange(start: string, end: string): boolean {
return isIsoDate(start) && isIsoDate(end) && start <= end;
}
type Consequence =
| { kind: "invalid" }
| { kind: "counting" }
| { kind: "ready"; removed: number }
| { kind: "error"; message: string };
export function errorMessage(error: unknown, fallback: string): string {
if (error instanceof ApiForbiddenError) return error.message;
if (error instanceof ApiRequestError && error.detail) return error.detail;
return fallback;
}
export const FOCUSABLE =
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])';
/**
* Make everything outside `root` inert (siblings of each ancestor up to
* <body>), so the tab bar behind the pushed screen cannot take focus.
* Returns the undo.
*/
export function inertOutside(root: HTMLElement): () => void {
const made: HTMLElement[] = [];
for (let node: HTMLElement = root; node.parentElement && node !== document.body; node = node.parentElement) {
for (const sibling of Array.from(node.parentElement.children)) {
if (sibling === node || !(sibling instanceof HTMLElement) || sibling.hasAttribute("inert")) continue;
sibling.setAttribute("inert", "");
sibling.setAttribute("aria-hidden", "true");
made.push(sibling);
}
}
return () => {
for (const el of made) {
el.removeAttribute("inert");
el.removeAttribute("aria-hidden");
}
};
}
/** Keep Tab / Shift+Tab cycling inside `root` (a pushed modal screen). */
export function trapTab(event: ReactKeyboardEvent<HTMLElement>, root: HTMLElement | null): void {
if (event.key !== "Tab" || !root) return;
const focusable = Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE));
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const current = document.activeElement;
const outside = !focusable.includes(current as HTMLElement);
if (event.shiftKey && (current === first || outside)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (current === last || outside)) {
event.preventDefault();
first.focus();
}
}
function tasksPhrase(count: number): string {
return `${count} upcoming ${count === 1 ? "task" : "tasks"}`;
}
export interface OverrideEditorProps {
/** Active children, in display order. */
childOptions: AdminChild[];
initialChildId: number;
/** "YYYY-MM-DD"; the date fields start here. */
today: string;
onCancel: () => void;
onSaved: (override: PresenceOverride) => void;
}
export default function OverrideEditor({
childOptions,
initialChildId,
today,
onCancel,
onSaved,
}: OverrideEditorProps) {
const [childId, setChildId] = useState(initialChildId);
const [isPresent, setIsPresent] = useState(false);
const [startDate, setStartDate] = useState(today);
const [endDate, setEndDate] = useState(today);
const [note, setNote] = useState("");
const [consequence, setConsequence] = useState<Consequence>({ kind: "counting" });
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Every form change bumps the sequence; only the latest request may render.
const requestSeq = useRef(0);
const screenRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null);
// Modal focus: move in on open and keep the background out of reach. The
// opener hands focus back to its Add control once this screen closes.
useEffect(() => {
titleRef.current?.focus();
return screenRef.current ? inertOutside(screenRef.current) : undefined;
}, []);
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape") {
event.preventDefault();
if (!saving) onCancel();
return;
}
trapTab(event, screenRef.current);
}
const child = childOptions.find((c) => c.id === childId) ?? childOptions[0];
const rangeValid = isValidRange(startDate, endDate);
useEffect(() => {
const seq = ++requestSeq.current;
if (!rangeValid) {
setConsequence({ kind: "invalid" });
return;
}
if (isPresent) {
// Keeping a child home removes nothing by definition.
setConsequence({ kind: "ready", removed: 0 });
return;
}
setConsequence({ kind: "counting" });
const timer = setTimeout(() => {
previewOverrideConsequence({
child_id: childId,
start_date: startDate,
end_date: endDate,
is_present: false,
})
.then((removed) => {
if (seq === requestSeq.current) setConsequence({ kind: "ready", removed });
})
.catch((err: unknown) => {
if (seq !== requestSeq.current) return;
setConsequence({
kind: "error",
message: errorMessage(err, "The upcoming tasks could not be counted. Try again."),
});
});
}, CONSEQUENCE_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [childId, startDate, endDate, isPresent, rangeValid]);
const canSave = rangeValid && consequence.kind === "ready" && !saving;
function save() {
if (!canSave) return;
const body: PresenceOverrideBody = {
child_id: childId,
start_date: startDate,
end_date: endDate,
is_present: isPresent,
};
const reason = note.trim();
if (reason) body.note = reason;
setSaving(true);
setError(null);
createPresenceOverride(body)
.then(onSaved)
.catch((err: unknown) => {
setError(errorMessage(err, "The override could not be saved. Check your connection and try again."));
setSaving(false);
});
}
let headline: string;
let detail: string;
const range = rangeValid ? dateRange(startDate, endDate) : "";
if (consequence.kind === "invalid") {
headline = "Choose a valid date range";
detail = "Through must be on or after From.";
} else if (consequence.kind === "counting") {
headline = "Counting upcoming tasks…";
detail = `${child.display_name} away ${range}. Completed days are untouched.`;
} else if (consequence.kind === "error") {
headline = "Upcoming tasks not counted";
detail = consequence.message;
} else {
headline = `This removes ${tasksPhrase(consequence.removed)}`;
detail = isPresent
? `${child.display_name} is home ${range}, so no tasks are removed. Completed days are untouched.`
: `${child.display_name}'s open tasks ${range} are removed while away. Completed days are untouched.`;
}
return (
<div
ref={screenRef}
className="override-screen"
role="dialog"
aria-modal="true"
aria-labelledby="override-title"
onKeyDown={onKeyDown}
>
<header className="override-header">
<button type="button" className="override-back" aria-label="Back" onClick={onCancel}>
<ChevronLeftGlyph size={20} />
</button>
<div>
<h2 className="override-title" id="override-title" ref={titleRef} tabIndex={-1}>
Presence override
</h2>
<p className="override-sub">Beats the custody pattern for these dates</p>
</div>
</header>
<div className="override-scroll">
<section className="override-card" aria-label="Override details">
<div className="override-field">
<span className="override-label" id="override-label-child">
Child
</span>
<div className="override-segments override-segments--3" role="group" aria-labelledby="override-label-child">
{childOptions.map((c) => (
<button
key={c.id}
type="button"
className="override-segment override-segment--gradient"
aria-pressed={c.id === childId}
onClick={() => setChildId(c.id)}
>
{c.display_name}
</button>
))}
</div>
</div>
<div className="override-field">
<span className="override-label" id="override-label-status">
Status for these dates
</span>
<div className="override-segments override-segments--2" role="group" aria-labelledby="override-label-status">
<button
type="button"
className="override-segment override-segment--ink"
aria-pressed={!isPresent}
onClick={() => setIsPresent(false)}
>
Away
</button>
<button
type="button"
className="override-segment override-segment--ink"
aria-pressed={isPresent}
onClick={() => setIsPresent(true)}
>
Home
</button>
</div>
</div>
<div className="override-dates">
<label className="override-field">
<span className="override-label">From</span>
<input
className="override-input override-date"
type="date"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
/>
</label>
<label className="override-field">
<span className="override-label">Through</span>
<input
className="override-input override-date"
type="date"
value={endDate}
min={isIsoDate(startDate) ? startDate : undefined}
onChange={(event) => setEndDate(event.target.value)}
/>
</label>
</div>
<label className="override-field">
<span className="override-label">Reason (optional)</span>
<input
className="override-input"
type="text"
value={note}
onChange={(event) => setNote(event.target.value)}
/>
</label>
</section>
<section
className="override-warning"
aria-label="Consequence"
aria-live="polite"
data-testid="override-consequence"
data-state={consequence.kind}
>
<span className="override-warning-glyph">
<AlertCircleGlyph />
</span>
<div>
<p className="override-warning-headline" data-testid="override-consequence-headline">
{headline}
</p>
<p className="override-warning-body">{detail}</p>
</div>
</section>
{error ? (
<p className="override-error" role="alert" data-testid="override-error">
{error}
</p>
) : null}
<div className="override-footer">
<button type="button" className="override-cancel" onClick={onCancel} disabled={saving}>
Cancel
</button>
<button type="button" className="override-save" onClick={save} disabled={!canSave}>
{saving ? "Saving…" : "Save override"}
</button>
</div>
</div>
</div>
);
}
+255
View File
@@ -0,0 +1,255 @@
/**
* Presence pattern editor — a pushed screen like the override editor.
*
* Adds a pattern to the child's collection (POST) or edits one in place
* (PATCH with only the changed fields). The weekday numbering sent to the
* API stays Monday=0 .. Sunday=6; only the display is Sunday-first.
*/
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import type { AdminChild } from "../api/definitions";
import {
createPresencePattern,
updatePresencePattern,
type PatternKind,
type PatternWeeks,
type PresencePattern,
type PresencePatternBody,
} from "../api/presence";
import {
DISPLAY_WEEKDAYS,
WEEKDAY_SHORT,
dayNumber,
emptyPattern,
isoFromDayNumber,
resizePattern,
togglePatternDay,
} from "./cycle";
import { anchorLabel, shortDate } from "./format";
import { ChevronLeftGlyph } from "./glyphs";
import { errorMessage, inertOutside, isIsoDate, trapTab } from "./OverrideEditor";
import "./OverrideEditor.css";
export const CYCLE_LENGTH_CHOICES = [1, 2, 3, 4];
const WEEKDAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
/** The fields of `next` that differ from the stored pattern. */
export function changedFields(
stored: PresencePatternBody,
next: PresencePatternBody,
): Partial<PresencePatternBody> {
const changes: Partial<PresencePatternBody> = {};
if (next.name !== stored.name) changes.name = next.name;
if (next.kind !== stored.kind) changes.kind = next.kind;
if (next.cycle_length_weeks !== stored.cycle_length_weeks) {
changes.cycle_length_weeks = next.cycle_length_weeks;
}
if (next.anchor_date !== stored.anchor_date) changes.anchor_date = next.anchor_date;
const samePattern = Array.from({ length: next.cycle_length_weeks }, (_, week) => week).every(
(week) =>
JSON.stringify([...(stored.pattern[week] ?? [])].sort()) ===
JSON.stringify([...(next.pattern[week] ?? [])].sort()),
);
if (!samePattern || changes.cycle_length_weeks !== undefined) changes.pattern = next.pattern;
return changes;
}
export interface PatternEditorProps {
child: AdminChild;
/** The pattern being edited, or null to add a new one. */
pattern: PresencePattern | null;
/** "YYYY-MM-DD"; a new pattern's anchor starts here. */
today: string;
onCancel: () => void;
onSaved: (pattern: PresencePattern) => void;
}
export default function PatternEditor({ child, pattern, today, onCancel, onSaved }: PatternEditorProps) {
const [name, setName] = useState(pattern?.name ?? "");
const [kind, setKind] = useState<PatternKind>(pattern?.kind ?? "away");
const [cycleLength, setCycleLength] = useState(pattern?.cycle_length_weeks ?? 1);
const [anchor, setAnchor] = useState(pattern?.anchor_date ?? today);
const [weeks, setWeeks] = useState<PatternWeeks>(pattern?.pattern ?? emptyPattern(1));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const screenRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
titleRef.current?.focus();
return screenRef.current ? inertOutside(screenRef.current) : undefined;
}, []);
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape") {
event.preventDefault();
if (!saving) onCancel();
return;
}
trapTab(event, screenRef.current);
}
const body: PresencePatternBody = {
name: name.trim(),
kind,
cycle_length_weeks: cycleLength,
anchor_date: anchor,
pattern: resizePattern(weeks, cycleLength),
};
const changes = pattern ? changedFields(pattern, body) : body;
// A pattern covering no day is valid (e.g. a migrated all-absent home schedule).
const canSave = body.name !== "" && isIsoDate(anchor) && Object.keys(changes).length > 0 && !saving;
function save() {
if (!canSave) return;
setSaving(true);
setError(null);
const request = pattern
? updatePresencePattern(pattern.id, changes)
: createPresencePattern(child.id, body);
request.then(onSaved).catch((err: unknown) => {
setError(errorMessage(err, "The pattern could not be saved. Check your connection and try again."));
setSaving(false);
});
}
const title = pattern ? "Edit pattern" : "New pattern";
return (
<div
ref={screenRef}
className="override-screen"
role="dialog"
aria-modal="true"
aria-labelledby="pattern-title"
onKeyDown={onKeyDown}
>
<header className="override-header">
<button type="button" className="override-back" aria-label="Back" onClick={onCancel}>
<ChevronLeftGlyph size={20} />
</button>
<div>
<h2 className="override-title" id="pattern-title" ref={titleRef} tabIndex={-1}>
{title}
</h2>
<p className="override-sub">Repeating presence for {child.display_name}</p>
</div>
</header>
<div className="override-scroll">
<section className="override-card" aria-label="Pattern details">
<label className="override-field">
<span className="override-label">Name</span>
<input
className="override-input"
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</label>
<div className="override-field">
<span className="override-label" id="pattern-label-kind">
Covered days are
</span>
<div className="override-segments override-segments--2" role="group" aria-labelledby="pattern-label-kind">
{(["away", "home"] as PatternKind[]).map((value) => (
<button
key={value}
type="button"
className="override-segment override-segment--ink"
aria-pressed={kind === value}
onClick={() => setKind(value)}
>
{value === "away" ? "Away" : "Home"}
</button>
))}
</div>
</div>
<div className="override-dates">
<label className="override-field">
<span className="override-label">Cycle length</span>
<select
className="override-input"
value={cycleLength}
onChange={(event) => {
const next = Number(event.target.value);
setCycleLength(next);
setWeeks((current) => resizePattern(current, next));
}}
>
{CYCLE_LENGTH_CHOICES.map((count) => (
<option key={count} value={count}>
{count} {count === 1 ? "week" : "weeks"}
</option>
))}
</select>
</label>
<label className="override-field">
<span className="override-label">Anchor date</span>
<input
className="override-input override-date"
type="date"
value={anchor}
onChange={(event) => setAnchor(event.target.value)}
/>
</label>
</div>
{Array.from({ length: cycleLength }, (_, week) => {
const days = body.pattern[week];
const from = isIsoDate(anchor)
? shortDate(isoFromDayNumber(dayNumber(anchor) + week * 7))
: null;
return (
<div key={week} className="override-field">
<span className="override-label" id={`pattern-label-week-${week}`}>
Week {week + 1}
{from ? ` · from ${from}` : ""}
</span>
<div
className="override-segments override-segments--7"
role="group"
aria-labelledby={`pattern-label-week-${week}`}
>
{DISPLAY_WEEKDAYS.map((weekday) => (
<button
key={weekday}
type="button"
className="override-segment override-segment--ink"
aria-pressed={days.includes(weekday)}
aria-label={WEEKDAY_NAMES[weekday]}
onClick={() => setWeeks((current) => togglePatternDay(current, week, weekday))}
>
{WEEKDAY_SHORT[weekday].slice(0, 2)}
</button>
))}
</div>
</div>
);
})}
<p className="override-sub">
{isIsoDate(anchor)
? `Week 1 starts ${anchorLabel(anchor)}; the cycle repeats from there.`
: "Choose an anchor date."}
</p>
</section>
{error ? (
<p className="override-error" role="alert" data-testid="pattern-error">
{error}
</p>
) : null}
<div className="override-footer">
<button type="button" className="override-cancel" onClick={onCancel} disabled={saving}>
Cancel
</button>
<button type="button" className="override-save" onClick={save} disabled={!canSave}>
{saving ? "Saving…" : "Save pattern"}
</button>
</div>
</div>
</div>
);
}
+363
View File
@@ -0,0 +1,363 @@
/* Schedule tab — compact density (design/ADMIN-SPEC.md §4). Mobile-first at
the 390x844 reference; on wider viewports the column is capped and centred. */
.schedule-screen {
width: 100%;
max-width: 640px;
margin: 0 auto;
}
.schedule-header {
background: var(--nq-a-surface);
border-bottom: 1px solid var(--nq-a-border);
padding: 14px 16px;
}
.schedule-title {
margin: 0;
font-size: var(--nq-a-size-screen);
font-weight: 800;
letter-spacing: -0.02em;
}
.schedule-sub {
margin: 2px 0 0;
min-height: 1.4em;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
.schedule-scroll {
display: flex;
flex-direction: column;
gap: var(--nq-a-density-gap);
padding: 14px 16px;
}
.schedule-card {
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
}
/* Child selector */
.schedule-children {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.schedule-child-chip {
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-radius-pill);
background: var(--nq-a-surface);
color: var(--nq-a-ink-secondary);
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
}
.schedule-child-chip[aria-pressed="true"] {
border-color: var(--nq-brand-blue);
background: var(--nq-a-selected);
color: var(--nq-brand-blue);
}
/* Month grid card */
.schedule-month {
padding: var(--nq-a-density-card-pad);
}
.schedule-month-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.schedule-month-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.schedule-chevrons {
display: flex;
gap: 4px;
}
.schedule-chevron {
display: grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--nq-a-border);
border-radius: 6px;
background: var(--nq-a-surface);
color: var(--nq-a-ink-secondary);
cursor: pointer;
}
.schedule-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.schedule-weekday {
text-align: center;
font-size: 10px;
font-weight: 700;
color: var(--nq-a-ink-tertiary);
}
.schedule-day {
display: grid;
place-items: center;
aspect-ratio: 1;
border: 1px solid transparent;
border-radius: 6px;
font-size: 12px;
font-weight: 700;
}
.schedule-day--home {
background: var(--nq-a-selected);
color: #201f7a;
}
.schedule-day--away {
background: var(--nq-a-page);
color: var(--nq-a-ink-secondary);
}
.schedule-day--override-away,
.schedule-day--override-home {
background: var(--nq-a-warning-bg);
color: var(--nq-a-warning-ink);
border-color: var(--nq-a-warning-border);
}
/* An override that keeps the child home reads with a dashed border. */
.schedule-day--override-home {
border-style: dashed;
}
.schedule-day--today {
background: var(--nq-brand-gradient);
border-color: transparent;
color: #fff;
font-weight: 800;
}
.schedule-legend {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
margin: 10px 0 0;
padding: 0;
list-style: none;
}
.schedule-legend-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 10.5px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.schedule-swatch {
display: inline-block;
width: 12px;
height: 12px;
border: 1px solid transparent;
border-radius: 3px;
}
.schedule-legend-note {
margin: 8px 0 0;
font-size: 10.5px;
font-weight: 600;
color: var(--nq-a-ink-tertiary);
}
.schedule-save-status {
margin: 8px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-success);
}
.schedule-save-status--error {
color: var(--nq-a-danger-strong);
}
/* Presence patterns + overrides cards */
.schedule-section-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.schedule-section-label {
margin: 6px 0 -2px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--nq-a-ink-secondary);
}
.schedule-text-action {
padding: 4px 0 0;
border: 0;
background: none;
color: var(--nq-brand-blue);
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
}
.schedule-list {
margin: 0;
padding: 0;
list-style: none;
}
.schedule-list > li + li {
border-top: 1px solid var(--nq-a-divider);
}
.schedule-row {
display: flex;
align-items: center;
gap: 10px;
min-height: var(--nq-a-tap-min);
padding: var(--nq-a-density-row-pad);
}
.schedule-row-glyph {
flex: none;
display: flex;
color: var(--nq-a-warning-ink);
}
.schedule-row-text {
flex: 1;
min-width: 0;
}
.schedule-row-title {
font-size: 13px;
font-weight: 700;
}
.schedule-row-meta {
font-size: var(--nq-a-size-meta);
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.schedule-icon-action {
flex: none;
display: grid;
place-items: center;
width: var(--nq-a-tap-min);
height: var(--nq-a-tap-min);
margin: -8px -10px -8px 0;
padding: 0;
border: 0;
background: none;
color: var(--nq-a-ink-tertiary);
cursor: pointer;
}
.schedule-icon-action--edit {
margin-right: -4px;
}
.schedule-empty {
padding: var(--nq-a-density-card-pad);
font-size: 13px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
/* Loading / error */
.schedule-status {
padding: 24px 13px;
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
text-align: center;
font-size: 13.5px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.schedule-status--error {
color: var(--nq-a-danger-strong);
}
.schedule-status--error p {
margin: 0 0 10px;
}
.schedule-retry {
padding: 8px 16px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
/* Inline delete confirmation in an override row */
.schedule-confirm {
flex: none;
display: flex;
align-items: center;
gap: 6px;
}
.schedule-confirm-text {
font-size: 12px;
font-weight: 700;
color: var(--nq-a-ink-secondary);
}
.schedule-confirm-keep,
.schedule-confirm-delete {
min-height: 32px;
padding: 0 10px;
border-radius: 6px;
font-family: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.schedule-confirm-keep {
border: 1px solid var(--nq-a-border);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
}
.schedule-confirm-delete {
border: 0;
background: var(--nq-a-danger);
color: #fff;
}
+657
View File
@@ -0,0 +1,657 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import ScheduleTab from "./ScheduleTab";
import type { AdminChild } from "../api/definitions";
import type { PresenceOverride, PresencePattern } from "../api/presence";
import { storeTokens } from "../auth/oidc";
import { cycleWeekIndex, monthDates, weekdayOf } from "./cycle";
function child(id: number, display_name: string, sort_order: number, is_active = true): AdminChild {
return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active };
}
const CHILDREN = [
child(3, "Rory", 3),
child(1, "Declan", 1),
child(2, "Maeve", 2),
child(9, "Former", 4, false),
];
// Declan: the migrated single schedule — one `home` pattern anchored Monday
// 2026-09-07; cycle week 0 home every day, week 1 Sat/Sun only. Keys arrive
// as JSON strings, exactly as the API serializes them.
const DECLAN_HOME: PresencePattern = {
id: 1,
child_id: 1,
name: "Home weeks",
kind: "home",
cycle_length_weeks: 2,
anchor_date: "2026-09-07",
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] },
};
// Maeve: the owner scenario — away Thursday+Friday every week, and away
// weekends every 2 weeks (the "on" week is cycle week 0 from Sep 7).
const MAEVE_THU_FRI: PresencePattern = {
id: 2,
child_id: 2,
name: "Thursday+Friday",
kind: "away",
cycle_length_weeks: 1,
anchor_date: "2026-09-07",
pattern: { "0": [3, 4] },
};
const MAEVE_WEEKENDS: PresencePattern = {
id: 3,
child_id: 2,
name: "Alternate weekends",
kind: "away",
cycle_length_weeks: 2,
anchor_date: "2026-09-07",
pattern: { "0": [5, 6], "1": [] },
};
const OVERRIDES: PresenceOverride[] = [
{ id: 41, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: "Dentist trip" },
{ id: 42, child_id: 1, start_date: "2026-09-16", end_date: "2026-09-16", is_present: true, note: null },
{ id: 43, child_id: 2, start_date: "2026-09-28", end_date: "2026-10-02", is_present: false, note: "Camp" },
];
const TODAY = "2026-09-23";
const PATTERNS_PATH = "/api/v1/admin/presence-patterns";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
interface Call {
method: string;
path: string;
body: unknown;
}
/** The server's pattern store; POST/PATCH/DELETE mutate it like the API. */
let patterns: Record<number, PresencePattern[]>;
let nextId: number;
let writeFailure: Response | null;
let fetchMock: ReturnType<typeof vi.fn>;
function calls(): Call[] {
return fetchMock.mock.calls.map((args: unknown[]) => {
const [url, init] = args as [string, RequestInit | undefined];
return {
method: init?.method ?? "GET",
path: new URL(String(url), "http://x").pathname,
body: init?.body ? JSON.parse(String(init.body)) : undefined,
};
});
}
function writes(): Call[] {
return calls().filter((c) => c.method !== "GET");
}
function findPattern(id: number): PresencePattern | undefined {
return Object.values(patterns)
.flat()
.find((p) => p.id === id);
}
function routeFetch(url: string, init: RequestInit = {}): Response {
const path = new URL(url, "http://x").pathname;
const method = init.method ?? "GET";
const body = init.body ? JSON.parse(String(init.body)) : undefined;
if (method !== "GET" && writeFailure) return writeFailure;
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
const collection = /\/children\/(\d+)\/presence-patterns$/.exec(path);
if (collection) {
const childId = Number(collection[1]);
if (method === "POST") {
const stored: PresencePattern = { id: nextId++, child_id: childId, ...body };
patterns[childId] = [...(patterns[childId] ?? []), stored];
return jsonResponse(stored, 201);
}
return jsonResponse({ patterns: patterns[childId] ?? [] });
}
const single = /\/presence-patterns\/(\d+)$/.exec(path);
if (single) {
const id = Number(single[1]);
const existing = findPattern(id);
if (!existing) return jsonResponse({ detail: "Presence pattern not found" }, 404);
if (method === "PATCH") {
const updated = { ...existing, ...body };
patterns[existing.child_id] = patterns[existing.child_id].map((p) => (p.id === id ? updated : p));
return jsonResponse(updated);
}
if (method === "DELETE") {
patterns[existing.child_id] = patterns[existing.child_id].filter((p) => p.id !== id);
return jsonResponse({ status: "ok" });
}
}
if (path.endsWith("/presence-overrides")) return jsonResponse({ overrides: OVERRIDES });
return jsonResponse({ detail: "unexpected" }, 500);
}
function day(date: string): HTMLElement {
const cell = document.querySelector<HTMLElement>(`[data-date="${date}"]`);
if (!cell) throw new Error(`no cell for ${date}`);
return cell;
}
function saveButton(): HTMLButtonElement {
return screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement;
}
function weekGroup(week: number): HTMLElement {
return screen.getByRole("group", { name: new RegExp(`^Week ${week}\\b`) });
}
async function renderReady() {
render(<ScheduleTab today={TODAY} />);
await screen.findByTestId("pattern-1");
}
async function selectMaeve() {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
await screen.findByTestId("pattern-3");
}
describe("ScheduleTab", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
patterns = { 1: [DECLAN_HOME], 2: [MAEVE_THU_FRI, MAEVE_WEEKENDS], 3: [] };
nextId = 50;
writeFailure = null;
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("loads active children, their patterns and the overrides", async () => {
await renderReady();
const urls = calls().map((c) => c.path);
expect(urls).toContain("/api/v1/admin/children");
expect(urls).toContain("/api/v1/admin/children/1/presence-patterns");
expect(urls).toContain("/api/v1/admin/children/2/presence-patterns");
expect(urls).toContain("/api/v1/admin/children/3/presence-patterns");
expect(urls).not.toContain("/api/v1/admin/children/9/presence-patterns");
expect(urls).toContain("/api/v1/admin/presence-overrides");
const chips = within(screen.getByRole("group", { name: "Child" })).getAllByRole("button");
expect(chips.map((chip) => chip.textContent)).toEqual(["Declan", "Maeve", "Rory"]);
expect(chips[0].getAttribute("aria-pressed")).toBe("true");
});
it("header names the child and how many patterns it has", async () => {
await renderReady();
expect(screen.getByRole("heading", { name: "Schedule" })).toBeTruthy();
expect(screen.getByText("Declan · 1 pattern")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
expect(screen.getByText("Maeve · 2 patterns")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Rory" }));
expect(screen.getByText("Rory · no patterns, home every day")).toBeTruthy();
});
it("a home-pattern-only child is away on days no home pattern covers", async () => {
await renderReady();
expect(screen.getByTestId("month-label").textContent).toBe("September 2026");
const states: Record<string, string> = {
"2026-09-01": "away", // before the anchor: cycle week 1, Tuesday
"2026-09-05": "home", // before the anchor: cycle week 1, Saturday
"2026-09-07": "home", // anchor: week 0
"2026-09-11": "override-away",
"2026-09-15": "away", // week 1, Tuesday
"2026-09-16": "override-home",
"2026-09-19": "home", // week 1, Saturday
"2026-09-22": "home", // wrap back to week 0
"2026-09-29": "away", // week 1 again
};
for (const [date, state] of Object.entries(states)) {
expect(day(date).dataset.state, date).toBe(state);
expect(day(date).className).toContain(`schedule-day--${state}`);
expect(day(date).className).not.toContain("schedule-day--today");
}
const today = day(TODAY);
expect(today.className).toContain("schedule-day--today");
expect(today.dataset.state).toBe("home");
expect(today.getAttribute("aria-label")).toBe("Sep 23 (today): Home");
// The grid is a read-out of the combined presence, not a set of toggles.
expect(today.tagName).toBe("SPAN");
// Sunday-first header; September 2026 starts on a Tuesday: two leading pads.
const grid = screen.getByRole("group", { name: "Month grid" });
expect(Array.from(grid.children, (c) => c.textContent).slice(0, 7)).toEqual([
"S",
"M",
"T",
"W",
"T",
"F",
"S",
]);
expect(grid.children[7].getAttribute("data-date")).toBeNull();
expect(grid.children[8].getAttribute("data-date")).toBeNull();
expect(grid.children[9].getAttribute("data-date")).toBe("2026-09-01");
const legend = screen.getByRole("list", { name: "Legend" });
expect(within(legend).getAllByRole("listitem").map((li) => li.textContent)).toEqual([
"Home",
"Away",
"Override · away",
"Override · home",
"Today",
]);
expect(screen.getByText(/Combined from every pattern/)).toBeTruthy();
});
it("the owner scenario: Thu/Fri away every week, Sat/Sun away only in the on week", async () => {
await selectMaeve();
for (const date of monthDates("2026-09-01")) {
if (date >= "2026-09-28") continue; // Camp override, checked below
const weekday = weekdayOf(date);
const onWeek = cycleWeekIndex("2026-09-07", date, 2) === 0;
const away = weekday === 3 || weekday === 4 || (weekday >= 5 && onWeek);
expect(day(date).dataset.state, date).toBe(away ? "away" : "home");
}
// Explicit spot checks of that rule.
expect(day("2026-09-03").dataset.state).toBe("away"); // Thu
expect(day("2026-09-04").dataset.state).toBe("away"); // Fri
expect(day("2026-09-05").dataset.state).toBe("home"); // Sat, off week (pre-anchor)
expect(day("2026-09-12").dataset.state).toBe("away"); // Sat, on week
expect(day("2026-09-13").dataset.state).toBe("away"); // Sun, on week
expect(day("2026-09-19").dataset.state).toBe("home"); // Sat, off week
expect(day("2026-09-20").dataset.state).toBe("home"); // Sun, off week
expect(day("2026-09-26").dataset.state).toBe("away"); // Sat, on week again
expect(day("2026-09-14").dataset.state).toBe("home"); // Mon
expect(day("2026-09-28").dataset.state).toBe("override-away");
});
it("navigates months with the chevrons", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Next month" }));
expect(screen.getByTestId("month-label").textContent).toBe("October 2026");
// Oct 1 is day 24 from the anchor: cycle week 1, a Thursday — away.
expect(day("2026-10-01").dataset.state).toBe("away");
expect(day("2026-10-03").dataset.state).toBe("home"); // week 1, Saturday
expect(day("2026-10-05").dataset.state).toBe("home"); // week 0 again
fireEvent.click(screen.getByRole("button", { name: "Previous month" }));
expect(screen.getByTestId("month-label").textContent).toBe("September 2026");
});
it("patterns list shows each pattern's name, kind, cycle and weekday set", async () => {
await selectMaeve();
const list = screen.getByRole("list", { name: "Presence patterns" });
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
const thuFri = within(list).getByTestId("pattern-2");
expect(within(thuFri).getByText("Thursday+Friday")).toBeTruthy();
expect(within(thuFri).getByText("Away · Every week")).toBeTruthy();
expect(within(thuFri).getByText("Thu, Fri")).toBeTruthy();
const weekends = within(list).getByTestId("pattern-3");
expect(within(weekends).getByText("Alternate weekends")).toBeTruthy();
expect(within(weekends).getByText("Away · Every 2 weeks")).toBeTruthy();
expect(within(weekends).getByText("Week 1: Sat, Sun · Week 2: No days")).toBeTruthy();
expect(within(weekends).getByRole("button", { name: "Edit Alternate weekends" })).toBeTruthy();
expect(within(weekends).getByRole("button", { name: "Delete Alternate weekends" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Declan" }));
const declan = screen.getByTestId("pattern-1");
expect(within(declan).getByText("Home · Every 2 weeks")).toBeTruthy();
expect(within(declan).getByText("Week 1: Every day · Week 2: Sat, Sun")).toBeTruthy();
});
it("a child with no patterns is home every day", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Rory" }));
expect(screen.getByTestId("patterns-empty").textContent).toBe("No patterns for Rory — home every day.");
for (const date of monthDates("2026-09-01")) expect(day(date).dataset.state, date).toBe("home");
expect(screen.getByText("No overrides for Rory.")).toBeTruthy();
});
it("overrides card lists the selected child's overrides", async () => {
await renderReady();
const list = screen.getByRole("list", { name: "Overrides" });
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
const away = within(list).getByTestId("override-41");
expect(within(away).getByText("Declan away · Sep 10 – 12")).toBeTruthy();
expect(within(away).getByText("Dentist trip")).toBeTruthy();
expect(within(away).getByRole("button", { name: "Delete override" })).toBeTruthy();
expect(within(list).getByText("Declan home · Sep 16")).toBeTruthy();
expect(screen.getByRole("button", { name: "Add" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
expect(screen.getByText("Maeve away · Sep 28 – Oct 2")).toBeTruthy();
});
it("Add POSTs a new pattern to the child's collection and refreshes the list from the API", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Rory" }));
fireEvent.click(screen.getByRole("button", { name: "Add pattern" }));
expect(await screen.findByRole("heading", { name: "New pattern" })).toBeTruthy();
// Nothing named yet.
expect(saveButton().disabled).toBe(true);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: " Dad's weekends " } });
fireEvent.change(screen.getByLabelText("Cycle length"), { target: { value: "2" } });
fireEvent.change(screen.getByLabelText("Anchor date"), { target: { value: "2026-09-07" } });
// Sunday-first buttons; the body keeps Monday=0 numbering.
const week1 = weekGroup(1);
expect(within(week1).getAllByRole("button").map((b) => b.textContent)).toEqual([
"Su",
"Mo",
"Tu",
"We",
"Th",
"Fr",
"Sa",
]);
fireEvent.click(within(week1).getByRole("button", { name: "Saturday" }));
fireEvent.click(within(week1).getByRole("button", { name: "Sunday" }));
expect(saveButton().disabled).toBe(false);
const before = calls().length;
fireEvent.click(saveButton());
const row = await screen.findByTestId("pattern-50");
expect(screen.queryByRole("heading", { name: "New pattern" })).toBeNull();
expect(writes()).toEqual([
{
method: "POST",
path: "/api/v1/admin/children/3/presence-patterns",
body: {
name: "Dad's weekends",
kind: "away",
cycle_length_weeks: 2,
anchor_date: "2026-09-07",
pattern: { "0": [5, 6], "1": [] },
},
},
]);
const after = calls().slice(before);
expect(after.some((c) => c.method === "GET" && c.path === "/api/v1/admin/children/3/presence-patterns")).toBe(
true,
);
expect(within(row).getByText("Dad's weekends")).toBeTruthy();
expect(within(row).getByText("Away · Every 2 weeks")).toBeTruthy();
expect(day("2026-09-12").dataset.state).toBe("away");
expect(day("2026-09-19").dataset.state).toBe("home");
// Focus returns to the Add control of the patterns section.
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Add pattern" }));
});
it("Add can save a Home pattern", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Rory" }));
fireEvent.click(screen.getByRole("button", { name: "Add pattern" }));
await screen.findByRole("heading", { name: "New pattern" });
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "School nights" } });
fireEvent.click(screen.getByRole("button", { name: "Home" }));
fireEvent.click(within(weekGroup(1)).getByRole("button", { name: "Monday" }));
fireEvent.click(saveButton());
await screen.findByTestId("pattern-50");
expect(writes()[0].body).toEqual({
name: "School nights",
kind: "home",
cycle_length_weeks: 1,
anchor_date: TODAY,
pattern: { "0": [0] },
});
// A home pattern: uncovered days are now away.
expect(day("2026-09-14").dataset.state).toBe("home"); // Monday
expect(day("2026-09-15").dataset.state).toBe("away"); // Tuesday
});
it("Save requires a name; covered days alone are not enough", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Rory" }));
fireEvent.click(screen.getByRole("button", { name: "Add pattern" }));
await screen.findByRole("heading", { name: "New pattern" });
fireEvent.click(within(weekGroup(1)).getByRole("button", { name: "Monday" }));
expect(saveButton().disabled).toBe(true);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: " " } });
expect(saveButton().disabled).toBe(true);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Mondays" } });
expect(saveButton().disabled).toBe(false);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "" } });
expect(saveButton().disabled).toBe(true);
fireEvent.click(saveButton());
expect(writes()).toEqual([]);
});
it("Add saves a pattern that covers no weekday and lists it", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Rory" }));
fireEvent.click(screen.getByRole("button", { name: "Add pattern" }));
await screen.findByRole("heading", { name: "New pattern" });
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Always away" } });
fireEvent.click(screen.getByRole("button", { name: "Home" }));
fireEvent.change(screen.getByLabelText("Cycle length"), { target: { value: "2" } });
expect(saveButton().disabled).toBe(false);
fireEvent.click(saveButton());
const row = await screen.findByTestId("pattern-50");
expect(writes()).toEqual([
{
method: "POST",
path: "/api/v1/admin/children/3/presence-patterns",
body: {
name: "Always away",
kind: "home",
cycle_length_weeks: 2,
anchor_date: TODAY,
pattern: { "0": [], "1": [] },
},
},
]);
expect(findPattern(50)?.pattern).toEqual({ "0": [], "1": [] });
expect(within(row).getByText("Always away")).toBeTruthy();
expect(within(row).getByText("Home · Every 2 weeks")).toBeTruthy();
expect(within(row).getByText("Week 1: No days · Week 2: No days")).toBeTruthy();
// A home pattern covering no day: the child is away every day.
for (const date of monthDates("2026-09-01")) expect(day(date).dataset.state, date).toBe("away");
});
it("Edit of a migrated all-absent home pattern saves without touching any weekday", async () => {
const migrated: PresencePattern = {
id: 7,
child_id: 3,
name: "Home weeks",
kind: "home",
cycle_length_weeks: 2,
anchor_date: "2026-09-07",
pattern: { "0": [], "1": [] },
};
patterns[3] = [migrated];
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Rory" }));
fireEvent.click(await screen.findByRole("button", { name: "Edit Home weeks" }));
await screen.findByRole("heading", { name: "Edit pattern" });
for (const week of [1, 2]) {
for (const button of within(weekGroup(week)).getAllByRole("button")) {
expect(button.getAttribute("aria-pressed")).toBe("false");
}
}
// Unchanged: nothing to save.
expect(saveButton().disabled).toBe(true);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Never home" } });
expect(saveButton().disabled).toBe(false);
fireEvent.click(saveButton());
await waitFor(() => expect(screen.queryByRole("heading", { name: "Edit pattern" })).toBeNull());
expect(writes()).toEqual([{ method: "PATCH", path: `${PATTERNS_PATH}/7`, body: { name: "Never home" } }]);
expect(findPattern(7)).toEqual({ ...migrated, name: "Never home" });
const row = await screen.findByTestId("pattern-7");
expect(within(row).getByText("Week 1: No days · Week 2: No days")).toBeTruthy();
});
it("Edit PATCHes only the changed fields to /presence-patterns/{id} and refreshes", async () => {
await selectMaeve();
fireEvent.click(screen.getByRole("button", { name: "Edit Alternate weekends" }));
expect(await screen.findByRole("heading", { name: "Edit pattern" })).toBeTruthy();
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("Alternate weekends");
expect((screen.getByLabelText("Cycle length") as HTMLSelectElement).value).toBe("2");
expect((screen.getByLabelText("Anchor date") as HTMLInputElement).value).toBe("2026-09-07");
expect(within(weekGroup(1)).getByRole("button", { name: "Saturday" }).getAttribute("aria-pressed")).toBe(
"true",
);
// Unchanged: nothing to save.
expect(saveButton().disabled).toBe(true);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Every other weekend" } });
fireEvent.click(within(weekGroup(1)).getByRole("button", { name: "Sunday" }));
const before = calls().length;
fireEvent.click(saveButton());
await waitFor(() => expect(screen.queryByRole("heading", { name: "Edit pattern" })).toBeNull());
expect(writes()).toEqual([
{
method: "PATCH",
path: `${PATTERNS_PATH}/3`,
body: { name: "Every other weekend", pattern: { "0": [5], "1": [] } },
},
]);
const after = calls().slice(before);
expect(after.some((c) => c.method === "GET" && c.path === "/api/v1/admin/children/2/presence-patterns")).toBe(
true,
);
const row = await screen.findByText("Every other weekend");
expect(within(row.closest("li")!).getByText("Week 1: Sat · Week 2: No days")).toBeTruthy();
expect(day("2026-09-12").dataset.state).toBe("away"); // Sat, on week
expect(day("2026-09-13").dataset.state).toBe("home"); // Sun no longer covered
});
it("Delete asks first, DELETEs /presence-patterns/{id} and refreshes the list", async () => {
await selectMaeve();
fireEvent.click(screen.getByRole("button", { name: "Delete Thursday+Friday" }));
const confirm = within(screen.getByTestId("pattern-2")).getByRole("group", { name: "Confirm delete" });
fireEvent.click(within(confirm).getByRole("button", { name: "Keep" }));
expect(writes()).toEqual([]);
fireEvent.click(screen.getByRole("button", { name: "Delete Thursday+Friday" }));
fireEvent.click(within(screen.getByTestId("pattern-2")).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(screen.queryByTestId("pattern-2")).toBeNull());
expect(writes()).toEqual([{ method: "DELETE", path: `${PATTERNS_PATH}/2`, body: undefined }]);
expect(screen.getByTestId("pattern-3")).toBeTruthy();
expect(day("2026-09-17").dataset.state).toBe("home"); // Thursday, no longer away
expect(day("2026-09-12").dataset.state).toBe("away"); // weekends pattern still applies
});
it("deleting the last pattern leaves the child with none: home every day", async () => {
await renderReady();
expect(day("2026-09-15").dataset.state).toBe("away");
fireEvent.click(screen.getByRole("button", { name: "Delete Home weeks" }));
fireEvent.click(within(screen.getByTestId("pattern-1")).getByRole("button", { name: "Delete" }));
expect(await screen.findByTestId("patterns-empty")).toBeTruthy();
expect(screen.queryByRole("list", { name: "Presence patterns" })).toBeNull();
expect(patterns[1]).toEqual([]);
expect(day("2026-09-15").dataset.state).toBe("home");
expect(day("2026-09-11").dataset.state).toBe("override-away"); // overrides still win
expect(screen.getByText("Declan · no patterns, home every day")).toBeTruthy();
});
it("a 422 on save shows the API detail naming the field and keeps the editor open", async () => {
writeFailure = jsonResponse({ detail: "name must not be empty" }, 422);
await selectMaeve();
fireEvent.click(screen.getByRole("button", { name: "Edit Thursday+Friday" }));
await screen.findByRole("heading", { name: "Edit pattern" });
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Thu/Fri" } });
fireEvent.click(saveButton());
const alert = await screen.findByTestId("pattern-error");
expect(alert.getAttribute("role")).toBe("alert");
expect(alert.textContent).toBe("name must not be empty");
expect(screen.getByRole("heading", { name: "Edit pattern" })).toBeTruthy();
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("Thu/Fri");
expect(saveButton().disabled).toBe(false);
});
it("a 403 on add is surfaced as the refusal, not swallowed", async () => {
writeFailure = new Response("", { status: 403 });
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Add pattern" }));
await screen.findByRole("heading", { name: "New pattern" });
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Fridays" } });
fireEvent.click(within(weekGroup(1)).getByRole("button", { name: "Friday" }));
fireEvent.click(saveButton());
const alert = await screen.findByTestId("pattern-error");
expect(alert.textContent).toBe("Your account is not in the nestquest-admins group.");
expect(patterns[1]).toEqual([DECLAN_HOME]);
});
it("a 403 on delete is surfaced and the pattern stays listed", async () => {
writeFailure = new Response("", { status: 403 });
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Delete Home weeks" }));
fireEvent.click(within(screen.getByTestId("pattern-1")).getByRole("button", { name: "Delete" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("Your account is not in the nestquest-admins group.");
expect(screen.getByTestId("pattern-1")).toBeTruthy();
});
it("a 422 on delete shows the API detail", async () => {
writeFailure = jsonResponse({ detail: "pattern_id must be an integer" }, 422);
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Delete Home weeks" }));
fireEvent.click(within(screen.getByTestId("pattern-1")).getByRole("button", { name: "Delete" }));
expect((await screen.findByRole("alert")).textContent).toBe("pattern_id must be an integer");
});
it("Cancel closes the editor without any request", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Add pattern" }));
await screen.findByRole("heading", { name: "New pattern" });
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Draft" } });
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByRole("heading", { name: "New pattern" })).toBeNull();
expect(writes()).toEqual([]);
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Add pattern" }));
});
it("never calls the removed one-per-child /presence-schedule route", async () => {
await selectMaeve();
// Add, edit and delete: every pattern path the screen can take.
fireEvent.click(screen.getByRole("button", { name: "Add pattern" }));
await screen.findByRole("heading", { name: "New pattern" });
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Mondays" } });
fireEvent.click(within(weekGroup(1)).getByRole("button", { name: "Monday" }));
fireEvent.click(saveButton());
await screen.findByTestId("pattern-50");
fireEvent.click(screen.getByRole("button", { name: "Edit Mondays" }));
await screen.findByRole("heading", { name: "Edit pattern" });
fireEvent.click(screen.getByRole("button", { name: "Home" }));
fireEvent.click(saveButton());
await waitFor(() => expect(screen.queryByRole("heading", { name: "Edit pattern" })).toBeNull());
fireEvent.click(await screen.findByRole("button", { name: "Delete Mondays" }));
fireEvent.click(within(screen.getByTestId("pattern-50")).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(screen.queryByTestId("pattern-50")).toBeNull());
expect(writes().map((c) => `${c.method} ${c.path}`)).toEqual([
"POST /api/v1/admin/children/2/presence-patterns",
`PATCH ${PATTERNS_PATH}/50`,
`DELETE ${PATTERNS_PATH}/50`,
]);
expect(calls().filter((c) => c.path.includes("presence-schedule"))).toEqual([]);
});
it("scroll column ends with 92px bottom padding", async () => {
await renderReady();
expect(screen.getByTestId("schedule-scroll").style.paddingBottom).toBe("92px");
});
it("shows an error state on a failed load and retries", async () => {
fetchMock.mockResolvedValueOnce(new Response("boom", { status: 500 }));
render(<ScheduleTab today={TODAY} />);
const error = await screen.findByTestId("schedule-error");
expect(error.textContent).toContain("could not be loaded");
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(await screen.findByTestId("pattern-1")).toBeTruthy();
});
});
+586
View File
@@ -0,0 +1,586 @@
/**
* Schedule tab — the presence month grid (design/ADMIN-SPEC.md §4) and the
* selected child's presence patterns and overrides.
*
* The grid shows the COMBINED presence of every pattern (./cycle.ts
* `dayState`); each pattern's cycle position comes from its own anchor date,
* never from ISO week numbers or week parity (D-004).
*/
import { useEffect, useRef, useState } from "react";
import { ApiForbiddenError } from "../api/client";
import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions";
import {
deletePresenceOverride,
deletePresencePattern,
fetchPresenceOverrides,
fetchPresencePatterns,
type PresenceOverride,
type PresencePattern,
} from "../api/presence";
import {
cycleLabel,
dayState,
localTodayIso,
monthDates,
monthStart,
patternDaysLabel,
weekdayOf,
type DayState,
} from "./cycle";
import { dateRange, monthLabel, shortDate } from "./format";
import {
CalendarCheckGlyph,
CalendarXGlyph,
ChevronLeftGlyph,
ChevronRightGlyph,
PencilGlyph,
Trash2Glyph,
} from "./glyphs";
import OverrideEditor from "./OverrideEditor";
import PatternEditor from "./PatternEditor";
import "./ScheduleTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
export const SCROLL_BOTTOM_PADDING = "92px";
/** Sunday-first header; `weekdayOf` stays Monday=0. */
const WEEKDAY_INITIALS = ["S", "M", "T", "W", "T", "F", "S"];
export const STATE_LABELS: Record<DayState, string> = {
home: "Home",
away: "Away",
"override-away": "Override · away",
"override-home": "Override · home",
};
interface Loaded {
children: AdminChild[];
patterns: Record<number, PresencePattern[]>;
overrides: PresenceOverride[];
}
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; data: Loaded };
type DeleteState =
| { kind: "idle" }
| { kind: "confirming"; id: number }
| { kind: "deleting"; id: number }
| { kind: "error"; id: number; message: string };
type EditorState =
| { kind: "closed" }
| { kind: "override" }
| { kind: "pattern"; pattern: PresencePattern | null };
async function loadSchedule(): Promise<Loaded> {
const children = (await fetchChildren())
.filter((child) => child.is_active)
.sort((a, b) => a.sort_order - b.sort_order);
const [patterns, overrides] = await Promise.all([
Promise.all(children.map((child) => fetchPresencePatterns(child.id))),
fetchPresenceOverrides(),
]);
return {
children,
patterns: Object.fromEntries(children.map((child, i) => [child.id, patterns[i]])),
overrides,
};
}
function loadErrorMessage(error: unknown): string {
return error instanceof ApiForbiddenError
? error.message
: "The schedule could not be loaded. Check your connection and try again.";
}
function saveErrorMessage(error: unknown, fallback: string): string {
if (error instanceof ApiForbiddenError) return error.message;
if (error instanceof ApiRequestError && error.detail) return error.detail;
return fallback;
}
function ScreenHeader({ sub }: { sub: string }) {
return (
<header className="schedule-header">
<h2 className="schedule-title">Schedule</h2>
<p className="schedule-sub">{sub}</p>
</header>
);
}
interface MonthGridProps {
month: string;
today: string;
/** Every pattern of the child; none means home every day. */
patterns: PresencePattern[];
overrides: PresenceOverride[];
onMonth: (delta: number) => void;
}
function MonthGrid({ month, today, patterns, overrides, onMonth }: MonthGridProps) {
const dates = monthDates(month);
const leading = (weekdayOf(month) + 1) % 7;
return (
<>
<div className="schedule-month-head">
<span className="schedule-month-label" data-testid="month-label">
{monthLabel(month)}
</span>
<div className="schedule-chevrons">
<button
type="button"
className="schedule-chevron"
aria-label="Previous month"
onClick={() => onMonth(-1)}
>
<ChevronLeftGlyph />
</button>
<button
type="button"
className="schedule-chevron"
aria-label="Next month"
onClick={() => onMonth(1)}
>
<ChevronRightGlyph />
</button>
</div>
</div>
<div className="schedule-grid" role="group" aria-label="Month grid">
{WEEKDAY_INITIALS.map((initial, i) => (
<span key={`wd-${i}`} className="schedule-weekday" aria-hidden="true">
{initial}
</span>
))}
{Array.from({ length: leading }, (_, i) => (
<span key={`pad-${i}`} aria-hidden="true" />
))}
{dates.map((date) => {
const state = dayState(patterns, overrides, date);
const isToday = date === today;
let className = `schedule-day schedule-day--${state}`;
if (isToday) className += " schedule-day--today";
return (
<span
key={date}
role="img"
className={className}
data-date={date}
data-state={state}
aria-label={`${shortDate(date)}${isToday ? " (today)" : ""}: ${STATE_LABELS[state]}`}
>
{Number(date.slice(8))}
</span>
);
})}
</div>
<ul className="schedule-legend" aria-label="Legend">
{(["home", "away", "override-away", "override-home"] as DayState[]).map((state) => (
<li key={state} className="schedule-legend-item">
<span className={`schedule-swatch schedule-day--${state}`} aria-hidden="true" />
{STATE_LABELS[state]}
</li>
))}
<li className="schedule-legend-item">
<span className="schedule-swatch schedule-day--today" aria-hidden="true" />
Today
</li>
</ul>
<p className="schedule-legend-note">
Combined from every pattern: away beats home; overrides beat both.
</p>
</>
);
}
interface PatternRowProps {
pattern: PresencePattern;
deletion: DeleteState;
onEdit: () => void;
onAskDelete: () => void;
onKeep: () => void;
onDelete: () => void;
}
function PatternRow({ pattern, deletion, onEdit, onAskDelete, onKeep, onDelete }: PatternRowProps) {
const mine =
(deletion.kind === "confirming" || deletion.kind === "deleting") && deletion.id === pattern.id;
return (
<li className="schedule-row" data-testid={`pattern-${pattern.id}`}>
<span className="schedule-row-glyph">
{pattern.kind === "home" ? <CalendarCheckGlyph /> : <CalendarXGlyph />}
</span>
<div className="schedule-row-text">
<div className="schedule-row-title">{pattern.name}</div>
<div className="schedule-row-meta">
{pattern.kind === "home" ? "Home" : "Away"} · {cycleLabel(pattern.cycle_length_weeks)}
</div>
<div className="schedule-row-meta" data-testid={`pattern-days-${pattern.id}`}>
{patternDaysLabel(pattern)}
</div>
</div>
{mine ? (
<div className="schedule-confirm" role="group" aria-label="Confirm delete">
<span className="schedule-confirm-text">Delete pattern?</span>
<button
type="button"
className="schedule-confirm-keep"
onClick={onKeep}
disabled={deletion.kind === "deleting"}
>
Keep
</button>
<button
type="button"
className="schedule-confirm-delete"
onClick={onDelete}
disabled={deletion.kind === "deleting"}
>
{deletion.kind === "deleting" ? "Deleting…" : "Delete"}
</button>
</div>
) : (
<>
<button
type="button"
className="schedule-icon-action schedule-icon-action--edit"
aria-label={`Edit ${pattern.name}`}
onClick={onEdit}
>
<PencilGlyph />
</button>
<button
type="button"
className="schedule-icon-action"
aria-label={`Delete ${pattern.name}`}
onClick={onAskDelete}
>
<Trash2Glyph />
</button>
</>
)}
</li>
);
}
interface OverrideRowProps {
override: PresenceOverride;
childName: string;
deletion: DeleteState;
onAskDelete: () => void;
onKeep: () => void;
onDelete: () => void;
}
function OverrideRow({ override, childName, deletion, onAskDelete, onKeep, onDelete }: OverrideRowProps) {
const mine =
(deletion.kind === "confirming" || deletion.kind === "deleting") && deletion.id === override.id;
return (
<li className="schedule-row" data-testid={`override-${override.id}`}>
<span className="schedule-row-glyph">
{override.is_present ? <CalendarCheckGlyph /> : <CalendarXGlyph />}
</span>
<div className="schedule-row-text">
<div className="schedule-row-title">
{childName} {override.is_present ? "home" : "away"} ·{" "}
{dateRange(override.start_date, override.end_date)}
</div>
<div className="schedule-row-meta">{override.note || "No reason given"}</div>
</div>
{mine ? (
<div className="schedule-confirm" role="group" aria-label="Confirm delete">
<span className="schedule-confirm-text">Delete override?</span>
<button
type="button"
className="schedule-confirm-keep"
onClick={onKeep}
disabled={deletion.kind === "deleting"}
>
Keep
</button>
<button
type="button"
className="schedule-confirm-delete"
onClick={onDelete}
disabled={deletion.kind === "deleting"}
>
{deletion.kind === "deleting" ? "Deleting…" : "Delete"}
</button>
</div>
) : (
<button
type="button"
className="schedule-icon-action"
aria-label="Delete override"
onClick={onAskDelete}
>
<Trash2Glyph />
</button>
)}
</li>
);
}
export interface ScheduleTabProps {
/** "YYYY-MM-DD"; defaults to the viewer's local date. */
today?: string;
}
export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProps) {
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [month, setMonth] = useState(() => monthStart(today));
const [editor, setEditor] = useState<EditorState>({ kind: "closed" });
const [deletion, setDeletion] = useState<DeleteState>({ kind: "idle" });
const [patternDeletion, setPatternDeletion] = useState<DeleteState>({ kind: "idle" });
const addRef = useRef<HTMLButtonElement>(null);
const addPatternRef = useRef<HTMLButtonElement>(null);
const openedEditor = useRef<EditorState["kind"]>("closed");
// An editor replaces this screen, so the Add controls are remounted when
// it closes; hand focus back to the one that belongs to that editor.
useEffect(() => {
if (editor.kind === "closed") {
if (openedEditor.current === "override") addRef.current?.focus();
if (openedEditor.current === "pattern") addPatternRef.current?.focus();
}
openedEditor.current = editor.kind;
}, [editor.kind]);
/** Reload children, patterns and overrides in place (no loading flash). */
const refresh = () =>
loadSchedule()
.then((data) => setState({ kind: "ready", data }))
.catch((error: unknown) => setState({ kind: "error", message: loadErrorMessage(error) }));
useEffect(() => {
let cancelled = false;
setState({ kind: "loading" });
loadSchedule()
.then((data) => {
if (cancelled) return;
setState({ kind: "ready", data });
setSelectedId((current) => current ?? data.children[0]?.id ?? null);
})
.catch((error: unknown) => {
if (cancelled) return;
setState({ kind: "error", message: loadErrorMessage(error) });
});
return () => {
cancelled = true;
};
}, [attempt]);
if (editor.kind !== "closed" && state.kind === "ready" && state.data.children.length > 0) {
const { children } = state.data;
const current = children.find((c) => c.id === selectedId) ?? children[0];
if (editor.kind === "pattern") {
return (
<PatternEditor
child={current}
pattern={editor.pattern}
today={today}
onCancel={() => setEditor({ kind: "closed" })}
onSaved={() => {
setEditor({ kind: "closed" });
void refresh();
}}
/>
);
}
return (
<OverrideEditor
childOptions={children}
initialChildId={current.id}
today={today}
onCancel={() => setEditor({ kind: "closed" })}
onSaved={(override) => {
setEditor({ kind: "closed" });
setSelectedId(override.child_id);
void refresh();
}}
/>
);
}
let sub = "";
let body;
if (state.kind === "loading") {
body = (
<div className="schedule-status" role="status" data-testid="schedule-loading">
Loading schedule…
</div>
);
} else if (state.kind === "error") {
body = (
<div className="schedule-status schedule-status--error" role="alert" data-testid="schedule-error">
<p>{state.message}</p>
<button type="button" className="schedule-retry" onClick={() => setAttempt((n) => n + 1)}>
Try again
</button>
</div>
);
} else if (state.data.children.length === 0) {
body = <div className="schedule-card schedule-empty">No children yet.</div>;
} else {
const { data } = state;
const child = data.children.find((c) => c.id === selectedId) ?? data.children[0];
const patterns = data.patterns[child.id] ?? [];
const childOverrides = data.overrides.filter((o) => o.child_id === child.id);
sub =
patterns.length === 0
? `${child.display_name} · no patterns, home every day`
: `${child.display_name} · ${patterns.length} ${patterns.length === 1 ? "pattern" : "patterns"}`;
const confirmDelete = (id: number) => {
setDeletion({ kind: "deleting", id });
deletePresenceOverride(id)
.then(() => refresh())
.then(() => setDeletion({ kind: "idle" }))
.catch((error: unknown) =>
setDeletion({
kind: "error",
id,
message: saveErrorMessage(
error,
"The override could not be deleted. Check your connection and try again.",
),
}),
);
};
const confirmPatternDelete = (id: number) => {
setPatternDeletion({ kind: "deleting", id });
deletePresencePattern(id)
.then(() => refresh())
.then(() => setPatternDeletion({ kind: "idle" }))
.catch((error: unknown) =>
setPatternDeletion({
kind: "error",
id,
message: saveErrorMessage(
error,
"The pattern could not be deleted. Check your connection and try again.",
),
}),
);
};
body = (
<>
<div className="schedule-children" role="group" aria-label="Child">
{data.children.map((c) => (
<button
key={c.id}
type="button"
className="schedule-child-chip"
aria-pressed={c.id === child.id}
onClick={() => {
setSelectedId(c.id);
setPatternDeletion({ kind: "idle" });
}}
>
{c.display_name}
</button>
))}
</div>
<section className="schedule-card schedule-month" aria-label="Month grid card">
<MonthGrid
month={month}
today={today}
patterns={patterns}
overrides={childOverrides}
onMonth={(delta) => setMonth((m) => monthStart(m, delta))}
/>
</section>
<div className="schedule-section-head">
<h3 className="schedule-section-label">Presence patterns</h3>
<button
ref={addPatternRef}
type="button"
className="schedule-text-action"
aria-label="Add pattern"
onClick={() => setEditor({ kind: "pattern", pattern: null })}
>
Add
</button>
</div>
{patterns.length > 0 ? (
<ul className="schedule-card schedule-list" aria-label="Presence patterns">
{patterns.map((pattern) => (
<PatternRow
key={pattern.id}
pattern={pattern}
deletion={patternDeletion}
onEdit={() => setEditor({ kind: "pattern", pattern })}
onAskDelete={() => setPatternDeletion({ kind: "confirming", id: pattern.id })}
onKeep={() => setPatternDeletion({ kind: "idle" })}
onDelete={() => confirmPatternDelete(pattern.id)}
/>
))}
</ul>
) : (
<div className="schedule-card schedule-empty" data-testid="patterns-empty">
No patterns for {child.display_name} — home every day.
</div>
)}
{patternDeletion.kind === "error" ? (
<p className="schedule-save-status schedule-save-status--error" role="alert">
{patternDeletion.message}
</p>
) : null}
<div className="schedule-section-head">
<h3 className="schedule-section-label">Overrides</h3>
<button
ref={addRef}
type="button"
className="schedule-text-action"
onClick={() => setEditor({ kind: "override" })}
>
Add
</button>
</div>
{childOverrides.length > 0 ? (
<ul className="schedule-card schedule-list" aria-label="Overrides">
{childOverrides.map((override) => (
<OverrideRow
key={override.id}
override={override}
childName={child.display_name}
deletion={deletion}
onAskDelete={() => setDeletion({ kind: "confirming", id: override.id })}
onKeep={() => setDeletion({ kind: "idle" })}
onDelete={() => confirmDelete(override.id)}
/>
))}
</ul>
) : (
<div className="schedule-card schedule-empty">No overrides for {child.display_name}.</div>
)}
{deletion.kind === "error" ? (
<p className="schedule-save-status schedule-save-status--error" role="alert">
{deletion.message}
</p>
) : null}
</>
);
}
return (
<div className="schedule-screen">
<ScreenHeader sub={sub} />
<div
className="schedule-scroll"
data-testid="schedule-scroll"
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
>
{body}
</div>
</div>
);
}
+191
View File
@@ -0,0 +1,191 @@
import { describe, expect, it } from "vitest";
import {
cycleLabel,
cycleWeekIndex,
dayState,
emptyPattern,
monthDates,
monthStart,
patternDaysLabel,
patternIncludes,
resizePattern,
togglePatternDay,
weekdayOf,
} from "./cycle";
import type { PatternCycle, PatternKind, PresenceOverride } from "../api/presence";
type KindedCycle = PatternCycle & { kind: PatternKind };
// The migrated single schedule: anchor Monday 2026-09-07; week 0 home every
// day, week 1 home Sat/Sun only.
const SCHEDULE: KindedCycle = {
kind: "home",
cycle_length_weeks: 2,
anchor_date: "2026-09-07",
pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [5, 6] },
};
// The owner scenario: away Thu+Fri every week, away weekends every 2 weeks.
const THU_FRI: KindedCycle = {
kind: "away",
cycle_length_weeks: 1,
anchor_date: "2026-09-07",
pattern: { 0: [3, 4] },
};
const ALTERNATE_WEEKENDS: KindedCycle = {
kind: "away",
cycle_length_weeks: 2,
anchor_date: "2026-09-07",
pattern: { 0: [5, 6], 1: [] },
};
describe("cycleWeekIndex", () => {
it("follows the anchor across a month, wrapping every 2 weeks", () => {
const expected: Record<string, number> = {};
for (const date of monthDates("2026-09-01")) {
const day = Number(date.slice(8));
// Sep 7-13 week 0, 14-20 week 1, 21-27 week 0 (wrap), 28-30 week 1;
// Sep 1-6 lie before the anchor and walk back into week 1.
expected[date] = day < 7 ? 1 : Math.floor((day - 7) / 7) % 2;
}
for (const [date, week] of Object.entries(expected)) {
expect(cycleWeekIndex(SCHEDULE.anchor_date, date, 2), date).toBe(week);
}
expect(cycleWeekIndex("2026-09-07", "2026-09-21", 2)).toBe(0);
expect(cycleWeekIndex("2026-09-07", "2026-09-28", 2)).toBe(1);
});
it("walks the cycle backwards before the anchor (floor division)", () => {
expect(cycleWeekIndex("2026-09-07", "2026-09-06", 2)).toBe(1); // day -1
expect(cycleWeekIndex("2026-09-07", "2026-08-31", 2)).toBe(1); // day -7
expect(cycleWeekIndex("2026-09-07", "2026-08-30", 2)).toBe(0); // day -8
expect(cycleWeekIndex("2026-09-07", "2026-08-24", 2)).toBe(0); // day -14
expect(cycleWeekIndex("2026-09-07", "2026-08-23", 2)).toBe(1); // day -15
expect(cycleWeekIndex("2026-09-07", "2026-09-06", 3)).toBe(2); // last week of a 3-week cycle
});
it("ignores ISO week parity across a 53-week year", () => {
// 2026-12-28 is ISO week 53 and 2027-01-04 ISO week 1 — both odd, so
// parity would call them the same week. From the anchor they alternate.
expect(cycleWeekIndex("2026-09-07", "2026-12-28", 2)).toBe(0);
expect(cycleWeekIndex("2026-09-07", "2027-01-04", 2)).toBe(1);
});
});
describe("calendar helpers", () => {
it("weekdayOf is Monday=0", () => {
expect(weekdayOf("2026-09-07")).toBe(0);
expect(weekdayOf("2026-09-01")).toBe(1);
expect(weekdayOf("2026-09-06")).toBe(6);
});
it("weekdayOf keeps the stored Monday=0 .. Sunday=6 numbering (display order is separate)", () => {
// A full known week plus dates across month and year ends.
expect(
[
"2026-09-07",
"2026-09-08",
"2026-09-09",
"2026-09-10",
"2026-09-11",
"2026-09-12",
"2026-09-13",
].map(weekdayOf),
).toEqual([0, 1, 2, 3, 4, 5, 6]);
expect(weekdayOf("2026-11-01")).toBe(6); // Sunday
expect(weekdayOf("2027-01-01")).toBe(4); // Friday
expect(weekdayOf("2024-02-29")).toBe(3); // Thursday
});
it("monthStart shifts across year ends", () => {
expect(monthStart("2026-12-15", 1)).toBe("2027-01-01");
expect(monthStart("2026-01-31", -1)).toBe("2025-12-01");
expect(monthDates("2026-02-01")).toHaveLength(28);
});
});
describe("dayState", () => {
const overrides: PresenceOverride[] = [
{ id: 1, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: null },
{ id: 2, child_id: 1, start_date: "2026-09-16", end_date: "2026-09-16", is_present: true, note: null },
];
it("a home-only child: overrides first, then absent on days no home pattern covers", () => {
const patterns = [SCHEDULE];
expect(dayState(patterns, overrides, "2026-09-08")).toBe("home");
expect(dayState(patterns, overrides, "2026-09-11")).toBe("override-away");
expect(dayState(patterns, overrides, "2026-09-15")).toBe("away");
expect(dayState(patterns, overrides, "2026-09-16")).toBe("override-home");
expect(dayState(patterns, overrides, "2026-09-19")).toBe("home");
expect(dayState(patterns, overrides, "2026-09-01")).toBe("away");
expect(dayState(patterns, overrides, "2026-09-05")).toBe("home");
});
it("combines the owner's two away patterns; every other day is home", () => {
const patterns = [THU_FRI, ALTERNATE_WEEKENDS];
const expected: Record<string, string> = {};
for (const date of monthDates("2026-09-01")) {
const weekday = weekdayOf(date);
const onWeek = cycleWeekIndex("2026-09-07", date, 2) === 0;
const away = weekday === 3 || weekday === 4 || (onWeek && weekday >= 5);
expected[date] = away ? "away" : "home";
}
for (const [date, state] of Object.entries(expected)) {
expect(dayState(patterns, [], date), date).toBe(state);
}
// Spot checks: Thu/Fri every week; weekends alternate from the anchor.
expect(dayState(patterns, [], "2026-09-10")).toBe("away"); // Thu, week 0
expect(dayState(patterns, [], "2026-09-18")).toBe("away"); // Fri, week 1
expect(dayState(patterns, [], "2026-09-12")).toBe("away"); // Sat, week 0
expect(dayState(patterns, [], "2026-09-20")).toBe("home"); // Sun, week 1
expect(dayState(patterns, [], "2026-09-27")).toBe("away"); // Sun, week 0
expect(dayState(patterns, [], "2026-09-15")).toBe("home"); // Tue
});
it("away beats home when both cover a date; a home pattern makes uncovered days away", () => {
const patterns = [SCHEDULE, THU_FRI];
expect(dayState(patterns, [], "2026-09-10")).toBe("away"); // home week 0 Thu, but away wins
expect(dayState(patterns, [], "2026-09-08")).toBe("home"); // home covers, away does not
expect(dayState(patterns, [], "2026-09-15")).toBe("away"); // no home pattern covers it
// An override still beats every pattern.
expect(
dayState(patterns, [{ ...overrides[1], start_date: "2026-09-10", end_date: "2026-09-10" }], "2026-09-10"),
).toBe("override-home");
});
it("treats a child with no patterns as home every day", () => {
expect(dayState([], [], "2026-09-15")).toBe("home");
});
});
describe("pattern editing and labels", () => {
it("patternIncludes reads the date's cycle week from the anchor", () => {
expect(patternIncludes(ALTERNATE_WEEKENDS, "2026-09-12")).toBe(true);
expect(patternIncludes(ALTERNATE_WEEKENDS, "2026-09-19")).toBe(false);
expect(patternIncludes(ALTERNATE_WEEKENDS, "2026-09-05")).toBe(false); // pre-anchor week 1
});
it("toggles one weekday of one cycle week", () => {
expect(togglePatternDay(SCHEDULE.pattern, 1, 1)).toEqual({
0: [0, 1, 2, 3, 4, 5, 6],
1: [1, 5, 6],
});
expect(togglePatternDay(SCHEDULE.pattern, 1, 5)[1]).toEqual([6]);
});
it("resizes the pattern to the cycle length; added weeks cover no day", () => {
expect(resizePattern(SCHEDULE.pattern, 1)).toEqual({ 0: [0, 1, 2, 3, 4, 5, 6] });
expect(resizePattern(SCHEDULE.pattern, 3)[2]).toEqual([]);
expect(emptyPattern(2)).toEqual({ 0: [], 1: [] });
});
it("describes the cycle and the weekday set in plain language", () => {
expect(cycleLabel(1)).toBe("Every week");
expect(cycleLabel(2)).toBe("Every 2 weeks");
expect(patternDaysLabel(THU_FRI)).toBe("Thu, Fri");
expect(patternDaysLabel(ALTERNATE_WEEKENDS)).toBe("Week 1: Sat, Sun · Week 2: No days");
expect(patternDaysLabel(SCHEDULE)).toBe("Week 1: Every day · Week 2: Sat, Sun");
// Listed Monday-first by weekday number, whatever order they were stored in.
expect(patternDaysLabel({ ...THU_FRI, pattern: { "0": [6, 0] } })).toBe("Mon, Sun");
});
});
+163
View File
@@ -0,0 +1,163 @@
/**
* Pure presence-cycle math for the Schedule tab.
*
* Cycle position comes from the anchor date ONLY, mirroring
* core/presence.py `cycle_week_index`:
* `((date - anchor).days // 7) % cycle_length_weeks` with floor division,
* so dates before the anchor walk the cycle backwards. Never ISO week
* numbers or week parity (D-004).
*
* Calendar dates are "YYYY-MM-DD" strings handled as whole UTC days, so no
* viewer offset or DST change can shift a day.
*/
import type {
PatternCycle,
PatternKind,
PatternWeeks,
PresenceOverride,
} from "../api/presence";
const MS_PER_DAY = 86_400_000;
export const ALL_WEEKDAYS = [0, 1, 2, 3, 4, 5, 6];
/** Monday=0 weekday numbers in Sunday-first display order. */
export const DISPLAY_WEEKDAYS = [6, 0, 1, 2, 3, 4, 5];
/** Short names indexed by Monday=0 weekday number. */
export const WEEKDAY_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
/** Days since 1970-01-01 for a "YYYY-MM-DD" calendar date. */
export function dayNumber(iso: string): number {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
if (!match) throw new Error(`Invalid calendar date: ${iso}`);
const [, year, month, day] = match;
return Date.UTC(Number(year), Number(month) - 1, Number(day)) / MS_PER_DAY;
}
export function isoFromDayNumber(days: number): string {
return new Date(days * MS_PER_DAY).toISOString().slice(0, 10);
}
/** Floor modulo: always in 0..divisor-1, like Python's `%`. */
function mod(value: number, divisor: number): number {
return ((value % divisor) + divisor) % divisor;
}
/** 0 = Monday .. 6 = Sunday (1970-01-01 was a Thursday). */
export function weekdayOf(iso: string): number {
return mod(dayNumber(iso) + 3, 7);
}
/** `((date - anchor).days // 7) % cycleLengthWeeks`, Python semantics. */
export function cycleWeekIndex(anchorDate: string, date: string, cycleLengthWeeks: number): number {
const days = dayNumber(date) - dayNumber(anchorDate);
return mod(Math.floor(days / 7), cycleLengthWeeks);
}
/** The viewer's local calendar date. */
export function localTodayIso(now: Date = new Date()): string {
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${now.getFullYear()}-${month}-${day}`;
}
/** "YYYY-MM-01" of the month containing `iso`, shifted by `delta` months. */
export function monthStart(iso: string, delta = 0): string {
const [year, month] = iso.split("-").map(Number);
const date = new Date(Date.UTC(year, month - 1 + delta, 1));
return date.toISOString().slice(0, 10);
}
/** Every date of the month starting at `firstIso`. */
export function monthDates(firstIso: string): string[] {
const start = dayNumber(firstIso);
const end = dayNumber(monthStart(firstIso, 1));
const dates: string[] = [];
for (let day = start; day < end; day += 1) dates.push(isoFromDayNumber(day));
return dates;
}
/** Every week of the cycle covers no day yet (a new pattern). */
export function emptyPattern(cycleLengthWeeks: number): PatternWeeks {
const pattern: PatternWeeks = {};
for (let week = 0; week < cycleLengthWeeks; week += 1) pattern[week] = [];
return pattern;
}
export type DayState = "home" | "away" | "override-home" | "override-away";
/** Does the pattern cover `date` (its cycle week lists the date's weekday)? */
export function patternIncludes(cycle: PatternCycle, date: string): boolean {
const week = cycleWeekIndex(cycle.anchor_date, date, cycle.cycle_length_weeks);
return (cycle.pattern[week] ?? []).includes(weekdayOf(date));
}
/**
* A date's combined state for one child, mirroring core/presence.py
* `is_present` — first match wins:
* 1. an override covering the date;
* 2. away when any `away` pattern covers it;
* 3. home when any `home` pattern covers it;
* 4. away when the child has at least one `home` pattern (a home pattern
* lists the days the child IS here), else home (no patterns, or only
* `away` ones).
*/
export function dayState(
patterns: (PatternCycle & { kind: PatternKind })[],
overrides: PresenceOverride[],
date: string,
): DayState {
const override = overrides.find((o) => o.start_date <= date && date <= o.end_date);
if (override) return override.is_present ? "override-home" : "override-away";
if (patterns.some((p) => p.kind === "away" && patternIncludes(p, date))) return "away";
const homes = patterns.filter((p) => p.kind === "home");
if (homes.some((p) => patternIncludes(p, date))) return "home";
return homes.length > 0 ? "away" : "home";
}
/** Flip `weekday` in `week` of the pattern; returns a new pattern. */
export function togglePatternDay(
pattern: PatternWeeks,
week: number,
weekday: number,
): PatternWeeks {
const days = pattern[week] ?? [];
const next = days.includes(weekday)
? days.filter((d) => d !== weekday)
: [...days, weekday].sort((a, b) => a - b);
return { ...pattern, [week]: next };
}
/** Fit the pattern to a new cycle length; added weeks start covering no day. */
export function resizePattern(pattern: PatternWeeks, cycleLengthWeeks: number): PatternWeeks {
const next: PatternWeeks = {};
for (let week = 0; week < cycleLengthWeeks; week += 1) {
next[week] = pattern[week] ? [...pattern[week]] : [];
}
return next;
}
function daysLabel(days: number[]): string {
if (days.length === 7) return "Every day";
if (days.length === 0) return "No days";
return [...days].sort((a, b) => a - b).map((d) => WEEKDAY_SHORT[d]).join(", ");
}
/**
* Plain-language weekday set: `Thu, Fri` for a 1-week cycle, otherwise one
* entry per cycle week, e.g. `Week 1: Sat, Sun · Week 2: No days`.
*/
export function patternDaysLabel(cycle: PatternCycle): string {
if (cycle.cycle_length_weeks === 1) return daysLabel(cycle.pattern[0] ?? []);
const weeks: string[] = [];
for (let week = 0; week < cycle.cycle_length_weeks; week += 1) {
weeks.push(`Week ${week + 1}: ${daysLabel(cycle.pattern[week] ?? [])}`);
}
return weeks.join(" · ");
}
/** `Every week`, `Every 2 weeks`. */
export function cycleLabel(cycleLengthWeeks: number): string {
return cycleLengthWeeks === 1 ? "Every week" : `Every ${cycleLengthWeeks} weeks`;
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Date labels for the Schedule tab. Calendar dates are formatted in UTC from
* their components, so the printed day never shifts with the viewer's offset.
*/
import { dayNumber } from "./cycle";
const LOCALE = "en-US";
function utcDate(iso: string): Date {
return new Date(dayNumber(iso) * 86_400_000);
}
function format(iso: string, options: Intl.DateTimeFormatOptions): string {
return new Intl.DateTimeFormat(LOCALE, { ...options, timeZone: "UTC" }).format(utcDate(iso));
}
/** "Tuesday, Sep 1" */
export function anchorLabel(iso: string): string {
return `${format(iso, { weekday: "long" })}, ${format(iso, { month: "short", day: "numeric" })}`;
}
/** "September 2026" */
export function monthLabel(iso: string): string {
return format(iso, { month: "long", year: "numeric" });
}
/** "Sep 4" */
export function shortDate(iso: string): string {
return format(iso, { month: "short", day: "numeric" });
}
/** "Sep 4 – 7", "Sep 28 – Oct 2", or "Sep 4" for a single day. */
export function dateRange(start: string, end: string): string {
if (start === end) return shortDate(start);
const sameMonth = start.slice(0, 7) === end.slice(0, 7);
return `${shortDate(start)} – ${sameMonth ? Number(end.slice(8)) : shortDate(end)}`;
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Inline stroke glyphs for the Schedule tab (Lucide-style 24px grid),
* CDN-free like the Today and Tasks tabs' glyphs.
*/
import type { ReactNode } from "react";
function Glyph({ size, children }: { size: number; children: ReactNode }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
);
}
export function ChevronLeftGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="m15 18-6-6 6-6" />
</Glyph>
);
}
export function ChevronRightGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="m9 18 6-6-6-6" />
</Glyph>
);
}
function CalendarFrame() {
return (
<>
<path d="M8 2v4M16 2v4" />
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M3 10h18" />
</>
);
}
export function CalendarXGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<CalendarFrame />
<path d="m14 14-4 4M10 14l4 4" />
</Glyph>
);
}
export function CalendarCheckGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<CalendarFrame />
<path d="m9 16 2 2 4-4" />
</Glyph>
);
}
export function PencilGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
<path d="m15 5 4 4" />
</Glyph>
);
}
export function Trash2Glyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6" />
</Glyph>
);
}
export function AlertCircleGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</Glyph>
);
}
+367
View File
@@ -0,0 +1,367 @@
/* Settings — pushed screen over the Today tab (and the tab bar), like the
presence override editor. Compact density list, roomy form card. */
.settings-screen {
position: fixed;
inset: 0;
z-index: 30;
overflow-y: auto;
background: var(--nq-a-page);
}
.settings-header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px;
background: var(--nq-a-surface);
border-bottom: 1px solid var(--nq-a-border);
}
.settings-back {
flex: none;
display: grid;
place-items: center;
width: var(--nq-a-tap-min);
height: var(--nq-a-tap-min);
margin: -6px 0 -6px -12px;
padding: 0;
border: 0;
background: none;
color: var(--nq-a-ink);
cursor: pointer;
}
.settings-title {
margin: 0;
font-size: var(--nq-a-size-screen);
font-weight: 800;
letter-spacing: -0.02em;
}
.settings-sub {
margin: 2px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
.settings-scroll {
display: flex;
flex-direction: column;
gap: var(--nq-a-density-gap);
width: 100%;
max-width: 640px;
margin: 0 auto;
padding: 14px 16px;
}
.settings-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.settings-section-label {
margin: 0;
font-size: var(--nq-a-size-label);
font-weight: 700;
text-transform: uppercase;
letter-spacing: var(--nq-a-track-label);
color: var(--nq-a-ink-secondary);
}
.settings-add {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 36px;
padding: 0 12px;
border: 0;
border-radius: var(--nq-a-radius-pill);
background: var(--nq-brand-gradient);
color: #fff;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
}
.settings-card {
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
}
.settings-list {
margin: 0;
padding: 0;
list-style: none;
}
.settings-list > li + li {
border-top: 1px solid var(--nq-a-divider);
}
.settings-child {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: var(--nq-a-density-row-pad);
}
.settings-child--inactive .settings-child-name {
color: var(--nq-a-ink-tertiary);
}
.settings-swatch {
flex: none;
width: 22px;
height: 22px;
border-radius: var(--nq-a-radius-pill);
border: 1px solid rgba(9, 9, 11, 0.12);
}
.settings-child-text {
flex: 1;
min-width: 0;
}
.settings-child-name {
font-size: var(--nq-a-size-row);
font-weight: 700;
}
.settings-child-meta {
margin-top: 1px;
font-size: var(--nq-a-size-meta);
font-weight: 600;
color: var(--nq-a-ink-tertiary);
}
.settings-pill {
flex: none;
padding: 2px 8px;
border-radius: var(--nq-a-radius-pill);
background: var(--nq-a-divider);
color: var(--nq-a-ink-secondary);
font-size: var(--nq-a-size-meta);
font-weight: 700;
}
.settings-pill--active {
background: #dcfce7;
color: var(--nq-a-success);
}
.settings-child-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
width: 100%;
}
.settings-icon-button,
.settings-action {
min-height: 36px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.settings-icon-button {
display: grid;
place-items: center;
width: 36px;
padding: 0;
}
.settings-action {
padding: 0 12px;
}
.settings-icon-button:disabled,
.settings-action:disabled,
.settings-add:disabled,
.settings-back:disabled,
.settings-cancel:disabled,
.settings-save:disabled {
opacity: 0.45;
cursor: default;
}
.settings-empty,
.settings-status {
padding: 16px;
font-size: 13px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
text-align: center;
}
.settings-status--error {
color: var(--nq-a-danger-strong);
}
.settings-status p {
margin: 0 0 10px;
}
.settings-action-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-danger-bg);
color: var(--nq-a-danger-strong);
font-size: 12.5px;
font-weight: 700;
}
/* Add / edit form: roomy density, 18px padding, 16px gaps. */
.settings-form {
display: flex;
flex-direction: column;
gap: 16px;
padding: 18px;
}
.settings-form-title {
margin: 0;
font-size: 17px;
font-weight: 800;
}
.settings-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.settings-label {
font-size: var(--nq-a-size-label);
font-weight: 700;
text-transform: uppercase;
letter-spacing: var(--nq-a-track-label);
color: var(--nq-a-ink-secondary);
}
.settings-input {
width: 100%;
min-height: var(--nq-a-tap-min);
padding: 13px;
border: 1px solid var(--nq-a-border);
border-radius: 10px;
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 14px;
font-weight: 600;
}
.settings-swatches {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.settings-swatch-option {
width: 32px;
height: 32px;
padding: 0;
border: 2px solid transparent;
border-radius: var(--nq-a-radius-pill);
cursor: pointer;
}
.settings-swatch-option[aria-pressed="true"] {
border-color: var(--nq-a-ink);
box-shadow: inset 0 0 0 2px var(--nq-a-surface);
}
.settings-error {
margin: 0;
padding: 10px 12px;
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-danger-bg);
color: var(--nq-a-danger-strong);
font-size: 12.5px;
font-weight: 700;
}
.settings-form-footer {
display: flex;
gap: 10px;
}
.settings-cancel,
.settings-save {
padding: 15px 12px;
border-radius: 12px;
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.settings-cancel {
flex: 1;
border: 1px solid var(--nq-a-border);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
}
.settings-save {
flex: 2;
border: 0;
background: var(--nq-brand-gradient);
color: #fff;
}
/* Preferences: one box per notification — its toggle, then its time. */
.settings-notification {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border: 1px solid var(--nq-a-divider);
border-radius: var(--nq-a-density-radius);
}
.settings-toggle {
display: flex;
align-items: center;
gap: 10px;
min-height: var(--nq-a-tap-min);
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.settings-toggle > input {
width: 20px;
height: 20px;
margin: 0;
}
.settings-notice {
margin: 0;
padding: 10px 12px;
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-divider);
color: var(--nq-a-ink-secondary);
font-size: 12.5px;
font-weight: 700;
}
+514
View File
@@ -0,0 +1,514 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import App from "../App";
import SettingsScreen, { SCROLL_BOTTOM_PADDING } from "./SettingsScreen";
import type { AdminChild } from "../api/definitions";
import type { HouseholdSettings } from "../api/settings";
import { storeTokens } from "../auth/oidc";
const CHILDREN_PATH = "/api/v1/admin/children";
const REORDER_PATH = `${CHILDREN_PATH}/reorder`;
const SETTINGS_PATH = "/api/v1/admin/settings";
const DEFAULT_SETTINGS: HouseholdSettings = {
horizon_days: 14,
day_rollover_time: "03:00",
notify_target: "notify.family",
morning_summary_time: "07:00",
afternoon_reminder_time: "15:30",
end_of_day_report_time: "20:00",
morning_summary_enabled: true,
afternoon_reminder_enabled: false,
end_of_day_report_enabled: true,
celebration_enabled: false,
};
function child(
id: number,
display_name: string,
sort_order: number,
extra: Partial<AdminChild> = {},
): AdminChild {
return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active: true, ...extra };
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
interface Call {
method: string;
path: string;
body: unknown;
}
let children: AdminChild[];
let settings: HouseholdSettings;
/** Overrides the next write's response (the store is left untouched). */
let writeResponse: (() => Response) | null;
let fetchMock: ReturnType<typeof vi.fn>;
function calls(): Call[] {
return fetchMock.mock.calls.map((args: unknown[]) => {
const [url, init] = args as [string, RequestInit | undefined];
return {
method: init?.method ?? "GET",
path: new URL(String(url), "http://x").pathname,
body: init?.body ? JSON.parse(String(init.body)) : undefined,
};
});
}
function writes(): Call[] {
return calls().filter((c) => c.method !== "GET");
}
function childGets(): number {
return calls().filter((c) => c.method === "GET" && c.path === CHILDREN_PATH).length;
}
async function routeFetch(url: string, init: RequestInit = {}): Promise<Response> {
const path = new URL(url, "http://x").pathname;
const method = init.method ?? "GET";
const body = init.body ? JSON.parse(String(init.body)) : undefined;
if (method !== "GET" && writeResponse) {
const respond = writeResponse;
writeResponse = null;
return respond();
}
if (path === CHILDREN_PATH && method === "GET") return jsonResponse({ children });
if (path === SETTINGS_PATH && method === "GET") return jsonResponse(settings);
if (path === SETTINGS_PATH && method === "PATCH") {
settings = { ...settings, ...body };
return jsonResponse(settings);
}
if (path === CHILDREN_PATH && method === "POST") {
const created = child(9, body.display_name, body.sort_order ?? 0, {
colour: body.colour ?? null,
avatar_ref: body.avatar_ref ?? null,
});
children = [...children, created];
return jsonResponse(created, 201);
}
if (path === REORDER_PATH && method === "POST") {
const ids = body.ordered_ids as number[];
children = children.map((c) => ({ ...c, sort_order: ids.indexOf(c.id) + 1 }));
return jsonResponse({ status: "ok" });
}
const activeMatch = /\/children\/(\d+)\/active$/.exec(path);
if (activeMatch && method === "PATCH") {
const id = Number(activeMatch[1]);
children = children.map((c) => (c.id === id ? { ...c, is_active: body.is_active } : c));
return jsonResponse(children.find((c) => c.id === id));
}
const editMatch = /\/children\/(\d+)$/.exec(path);
if (editMatch && method === "PATCH") {
const id = Number(editMatch[1]);
children = children.map((c) => (c.id === id ? { ...c, ...body } : c));
return jsonResponse(children.find((c) => c.id === id));
}
if (path.endsWith("/admin/snapshot")) {
return jsonResponse({ today_iso: "2026-09-23", cycle_day: 5, children: [] });
}
return jsonResponse({ detail: "unexpected" }, 500);
}
function row(id: number) {
return within(screen.getByTestId(`settings-child-${id}`));
}
function listNames(): string[] {
return within(screen.getByRole("list", { name: "Children" }))
.getAllByRole("listitem")
.map((li) => li.querySelector(".settings-child-name")?.textContent ?? "");
}
function field(form: HTMLElement, label: string): HTMLInputElement {
return within(form).getByLabelText(label) as HTMLInputElement;
}
async function renderScreen(onClose = vi.fn()) {
render(<SettingsScreen onClose={onClose} />);
await screen.findByTestId("settings-child-1");
return onClose;
}
describe("SettingsScreen — children", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
children = [
child(2, "Maeve", 2, { colour: "#16a34a" }),
child(1, "Declan", 1, { colour: "#0ea5e9", avatar_ref: "fox" }),
child(3, "Rory", 3, { is_active: false }),
];
settings = { ...DEFAULT_SETTINGS };
writeResponse = null;
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("lists each child with name, colour swatch, active state and order", async () => {
await renderScreen();
expect(listNames()).toEqual(["Declan", "Maeve", "Rory"]);
expect(row(1).getByText("Order 1 · #0ea5e9")).toBeTruthy();
expect(screen.getByTestId("settings-swatch-1").style.background).toBe("rgb(14, 165, 233)");
expect(screen.getByTestId("settings-active-1").textContent).toBe("Active");
expect(row(3).getByText("Order 3 · no colour")).toBeTruthy();
expect(screen.getByTestId("settings-swatch-3").style.background).toBe("var(--nq-a-border)");
expect(screen.getByTestId("settings-active-3").textContent).toBe("Inactive");
expect(screen.getByTestId("settings-scroll").style.paddingBottom).toBe(SCROLL_BOTTOM_PADDING);
});
it("Add posts exactly the entered values, then shows the new child", async () => {
await renderScreen();
fireEvent.click(screen.getByRole("button", { name: "Add child" }));
const form = screen.getByRole("form", { name: "Add child" });
expect(field(form, "Order").value).toBe("4");
fireEvent.change(field(form, "Display name"), { target: { value: " Nora " } });
fireEvent.click(within(form).getByRole("button", { name: "Colour #7c3aed" }));
fireEvent.change(field(form, "Avatar ref (optional)"), { target: { value: "owl" } });
fireEvent.change(field(form, "Order"), { target: { value: "5" } });
const gets = childGets();
fireEvent.click(within(form).getByRole("button", { name: "Add child" }));
await screen.findByTestId("settings-child-9");
expect(writes()).toEqual([
{
method: "POST",
path: CHILDREN_PATH,
body: { display_name: "Nora", colour: "#7c3aed", avatar_ref: "owl", sort_order: 5 },
},
]);
expect(childGets()).toBe(gets + 1);
expect(listNames()).toEqual(["Declan", "Maeve", "Rory", "Nora"]);
expect(screen.queryByRole("form", { name: "Add child" })).toBeNull();
});
it("Add omits optional fields left blank and rejects a missing name", async () => {
await renderScreen();
fireEvent.click(screen.getByRole("button", { name: "Add child" }));
const form = screen.getByRole("form", { name: "Add child" });
fireEvent.change(field(form, "Order"), { target: { value: "" } });
fireEvent.click(within(form).getByRole("button", { name: "Add child" }));
expect(screen.getByTestId("child-form-error").textContent).toBe("Enter a display name.");
expect(writes()).toEqual([]);
fireEvent.change(field(form, "Display name"), { target: { value: "Nora" } });
fireEvent.click(within(form).getByRole("button", { name: "Add child" }));
await screen.findByTestId("settings-child-9");
expect(writes()[0].body).toEqual({ display_name: "Nora" });
});
it("Edit patches only the changed fields", async () => {
await renderScreen();
fireEvent.click(row(1).getByRole("button", { name: "Edit Declan" }));
const form = screen.getByRole("form", { name: "Edit Declan" });
expect(field(form, "Display name").value).toBe("Declan");
expect(field(form, "Colour").value).toBe("#0ea5e9");
fireEvent.change(field(form, "Display name"), { target: { value: "Dec" } });
fireEvent.change(field(form, "Colour"), { target: { value: "#FF8800" } });
fireEvent.click(within(form).getByRole("button", { name: "Save child" }));
await waitFor(() => expect(row(1).getByText("Dec")).toBeTruthy());
expect(writes()).toEqual([
{ method: "PATCH", path: `${CHILDREN_PATH}/1`, body: { display_name: "Dec", colour: "#FF8800" } },
]);
expect(row(1).getByText("Order 1 · #FF8800")).toBeTruthy();
});
it("Edit refuses to clear a set colour instead of sending null", async () => {
await renderScreen();
fireEvent.click(row(1).getByRole("button", { name: "Edit Declan" }));
const form = screen.getByRole("form", { name: "Edit Declan" });
fireEvent.change(field(form, "Colour"), { target: { value: "" } });
fireEvent.click(within(form).getByRole("button", { name: "Save child" }));
expect(screen.getByTestId("child-form-error").textContent).toMatch(/cannot be removed/);
expect(writes()).toEqual([]);
});
it("the active toggle patches /children/{id}/active with the new value", async () => {
await renderScreen();
fireEvent.click(row(1).getByRole("button", { name: "Deactivate Declan" }));
await waitFor(() => expect(screen.getByTestId("settings-active-1").textContent).toBe("Inactive"));
fireEvent.click(row(3).getByRole("button", { name: "Reactivate Rory" }));
await waitFor(() => expect(screen.getByTestId("settings-active-3").textContent).toBe("Active"));
expect(writes()).toEqual([
{ method: "PATCH", path: `${CHILDREN_PATH}/1/active`, body: { is_active: false } },
{ method: "PATCH", path: `${CHILDREN_PATH}/3/active`, body: { is_active: true } },
]);
});
it("reorder posts the complete ordered id list, then shows the new order", async () => {
await renderScreen();
expect((row(1).getByRole("button", { name: "Move Declan up" }) as HTMLButtonElement).disabled).toBe(true);
expect((row(3).getByRole("button", { name: "Move Rory down" }) as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(row(3).getByRole("button", { name: "Move Rory up" }));
await waitFor(() => expect(listNames()).toEqual(["Declan", "Rory", "Maeve"]));
expect(writes()).toEqual([{ method: "POST", path: REORDER_PATH, body: { ordered_ids: [1, 3, 2] } }]);
});
it("disables the write controls while a request is in flight (no double submit)", async () => {
await renderScreen();
let release!: () => void;
writeResponse = () => jsonResponse({ status: "ok" });
const gate = new Promise<void>((r) => {
release = r;
});
fetchMock.mockImplementationOnce(async (url: string, init?: RequestInit) => {
await gate;
return routeFetch(url, init);
});
const up = row(2).getByRole("button", { name: "Move Maeve up" }) as HTMLButtonElement;
fireEvent.click(up);
fireEvent.click(up);
await waitFor(() => expect(up.disabled).toBe(true));
expect((row(1).getByRole("button", { name: "Deactivate Declan" }) as HTMLButtonElement).disabled).toBe(true);
expect(writes()).toHaveLength(1);
release();
await waitFor(() => expect((row(1).getByRole("button", { name: "Edit Declan" }) as HTMLButtonElement).disabled).toBe(false));
expect(writes()).toHaveLength(1);
});
it("a 422 shows the API detail and keeps the form and the screen open", async () => {
const onClose = await renderScreen();
fireEvent.click(screen.getByRole("button", { name: "Add child" }));
const form = screen.getByRole("form", { name: "Add child" });
fireEvent.change(field(form, "Display name"), { target: { value: "Nora" } });
writeResponse = () => jsonResponse({ detail: "display_name is required" }, 422);
fireEvent.click(within(form).getByRole("button", { name: "Add child" }));
const error = await screen.findByTestId("child-form-error");
expect(error.textContent).toBe("The child could not be added: display_name is required");
expect(screen.getByRole("form", { name: "Add child" })).toBeTruthy();
expect(field(form, "Display name").value).toBe("Nora");
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole("dialog", { name: "Settings" })).toBeTruthy();
});
it("a 403 on a row action shows the refusal message and keeps the screen open", async () => {
const onClose = await renderScreen();
writeResponse = () => jsonResponse({ detail: "forbidden" }, 403);
fireEvent.click(row(1).getByRole("button", { name: "Deactivate Declan" }));
const error = await screen.findByTestId("settings-action-error");
expect(error.textContent).toContain("Your account is not in the nestquest-admins group.");
expect(screen.getByTestId("settings-active-1").textContent).toBe("Active");
expect(onClose).not.toHaveBeenCalled();
});
it("a 403 on load shows the refusal message", async () => {
fetchMock.mockImplementation(async () => jsonResponse({ detail: "forbidden" }, 403));
render(<SettingsScreen onClose={vi.fn()} />);
const error = await screen.findByTestId("settings-error");
expect(error.textContent).toContain("Your account is not in the nestquest-admins group.");
});
it("Escape and Back close the screen", async () => {
const onClose = await renderScreen();
fireEvent.keyDown(screen.getByRole("dialog", { name: "Settings" }), { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Back" }));
expect(onClose).toHaveBeenCalledTimes(2);
});
it("the Today Settings button opens the screen; closing restores focus and the background", async () => {
render(<App />);
const settings = await screen.findByRole("button", { name: "Settings" });
settings.focus();
fireEvent.click(settings);
const dialog = await screen.findByRole("dialog", { name: "Settings" });
await screen.findByTestId("settings-child-1");
expect(document.activeElement).toBe(screen.getByRole("heading", { name: "Settings" }));
const tabbar = document.querySelector("nav.admin-tabbar") as HTMLElement;
expect(tabbar.hasAttribute("inert")).toBe(true);
expect(dialog.closest("[inert]")).toBeNull();
fireEvent.keyDown(dialog, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Settings" })).toBeNull());
expect(tabbar.hasAttribute("inert")).toBe(false);
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Settings" }));
});
});
describe("SettingsScreen — preferences", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
children = [child(1, "Declan", 1)];
settings = { ...DEFAULT_SETTINGS };
writeResponse = null;
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
async function renderPreferences(onClose = vi.fn()) {
render(<SettingsScreen onClose={onClose} />);
await screen.findByTestId("settings-child-1");
const form = await screen.findByRole("form", { name: "Preferences" });
return { form, onClose };
}
function checkbox(form: HTMLElement, label: string): HTMLInputElement {
return within(form).getByRole("checkbox", { name: label }) as HTMLInputElement;
}
function save(form: HTMLElement) {
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
}
it("renders the current value of all ten fields", async () => {
const { form } = await renderPreferences();
expect(field(form, "Planning horizon (days)").value).toBe("14");
expect(field(form, "Day rollover").value).toBe("03:00");
expect(field(form, "Notify target").value).toBe("notify.family");
expect(field(form, "Morning summary time").value).toBe("07:00");
expect(field(form, "Afternoon reminder time").value).toBe("15:30");
expect(field(form, "End-of-day report time").value).toBe("20:00");
expect(checkbox(form, "Morning summary").checked).toBe(true);
expect(checkbox(form, "Afternoon reminder").checked).toBe(false);
expect(checkbox(form, "End-of-day report").checked).toBe(true);
expect(checkbox(form, "Celebration").checked).toBe(false);
});
it("saving one changed field patches exactly that field", async () => {
const { form } = await renderPreferences();
fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } });
save(form);
await screen.findByText("Preferences saved.");
expect(writes()).toEqual([{ method: "PATCH", path: SETTINGS_PATH, body: { horizon_days: 21 } }]);
const reloaded = screen.getByRole("form", { name: "Preferences" });
expect(field(reloaded, "Planning horizon (days)").value).toBe("21");
});
it("saving several changed fields sends exactly those", async () => {
const { form } = await renderPreferences();
fireEvent.change(field(form, "Day rollover"), { target: { value: "04:15" } });
fireEvent.change(field(form, "Notify target"), { target: { value: " notify.parents " } });
fireEvent.change(field(form, "End-of-day report time"), { target: { value: "21:45" } });
fireEvent.click(checkbox(form, "Afternoon reminder"));
fireEvent.click(checkbox(form, "Celebration"));
save(form);
await screen.findByText("Preferences saved.");
expect(writes()).toEqual([
{
method: "PATCH",
path: SETTINGS_PATH,
body: {
day_rollover_time: "04:15",
notify_target: "notify.parents",
end_of_day_report_time: "21:45",
afternoon_reminder_enabled: true,
celebration_enabled: true,
},
},
]);
const reloaded = screen.getByRole("form", { name: "Preferences" });
expect(checkbox(reloaded, "Celebration").checked).toBe(true);
expect(field(reloaded, "Notify target").value).toBe("notify.parents");
});
it("with nothing changed it says so and sends no request", async () => {
const { form } = await renderPreferences();
fireEvent.click(checkbox(form, "Celebration"));
fireEvent.click(checkbox(form, "Celebration"));
save(form);
expect(screen.getByTestId("preferences-message").textContent).toBe("No changes to save.");
expect(writes()).toEqual([]);
});
it("a 422 shows the API detail naming the field and keeps the edit", async () => {
const { form, onClose } = await renderPreferences();
fireEvent.change(field(form, "Notify target"), { target: { value: "notify.nobody" } });
writeResponse = () => jsonResponse({ detail: "notify_target is not a notify service" }, 422);
save(form);
const message = await screen.findByTestId("preferences-message");
expect(message.textContent).toBe(
"The preferences could not be saved: notify_target is not a notify service",
);
expect(message.getAttribute("role")).toBe("alert");
expect(field(form, "Notify target").value).toBe("notify.nobody");
expect(writes()).toEqual([
{ method: "PATCH", path: SETTINGS_PATH, body: { notify_target: "notify.nobody" } },
]);
expect(onClose).not.toHaveBeenCalled();
});
it("a 403 on save shows the refusal message", async () => {
const { form } = await renderPreferences();
fireEvent.click(checkbox(form, "Celebration"));
writeResponse = () => jsonResponse({ detail: "forbidden" }, 403);
save(form);
const message = await screen.findByTestId("preferences-message");
expect(message.textContent).toContain("Your account is not in the nestquest-admins group.");
expect(checkbox(form, "Celebration").checked).toBe(true);
});
it.each([
["empty", ""],
["zero", "0"],
["oversized (Infinity would serialize as null and reset the field)", "9".repeat(400)],
["past the safe-integer range", "123456789012345678901"],
])("a %s horizon is rejected before any request", async (_case, value) => {
const { form } = await renderPreferences();
fireEvent.change(field(form, "Planning horizon (days)"), { target: { value } });
save(form);
expect(screen.getByTestId("preferences-message").textContent).toBe(
"Planning horizon must be a whole number of days, at least 1.",
);
expect(writes()).toEqual([]);
});
it("disables every write control while a save is in flight (no double submit)", async () => {
const { form } = await renderPreferences();
let release!: () => void;
const gate = new Promise<void>((r) => {
release = r;
});
fetchMock.mockImplementationOnce(async (url: string, init?: RequestInit) => {
await gate;
return routeFetch(url, init);
});
fireEvent.click(checkbox(form, "Celebration"));
const button = within(form).getByRole("button", { name: "Save preferences" }) as HTMLButtonElement;
fireEvent.click(button);
fireEvent.click(button);
await waitFor(() => expect(button.disabled).toBe(true));
expect(checkbox(form, "Celebration").disabled).toBe(true);
expect(field(form, "Planning horizon (days)").disabled).toBe(true);
expect((row(1).getByRole("button", { name: "Edit Declan" }) as HTMLButtonElement).disabled).toBe(true);
expect(writes()).toHaveLength(1);
release();
await screen.findByText("Preferences saved.");
expect(writes()).toHaveLength(1);
});
it("a failed load shows the reason and Try again refetches", async () => {
let fail = true;
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
if (fail && new URL(url, "http://x").pathname === SETTINGS_PATH) {
return jsonResponse({ detail: "boom" }, 500);
}
return routeFetch(url, init);
});
render(<SettingsScreen onClose={vi.fn()} />);
const error = await screen.findByTestId("preferences-error");
expect(error.textContent).toContain("Preferences could not be loaded: boom");
fail = false;
fireEvent.click(within(error).getByRole("button", { name: "Try again" }));
await screen.findByRole("form", { name: "Preferences" });
});
});
+811
View File
@@ -0,0 +1,811 @@
/**
* Settings — a pushed screen opened from the Today header's Settings button.
*
* Children: list, add, edit, deactivate/reactivate and reorder the
* household's child profiles. Every write refetches the list.
*
* Preferences: the household settings (planning horizon, day rollover,
* notifications). Save patches only the changed fields.
*
* One write at a time across both sections.
*/
import {
useEffect,
useRef,
useState,
type FormEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { ApiForbiddenError } from "../api/client";
import {
createChild,
reorderChildren,
setChildActive,
updateChild,
type ChildCreateBody,
type ChildEditBody,
} from "../api/children";
import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions";
import {
fetchSettings,
updateSettings,
type HouseholdSettings,
type SettingsChanges,
} from "../api/settings";
import { FOCUSABLE, inertOutside } from "../schedule/OverrideEditor";
import { ArrowDownGlyph, ArrowUpGlyph, ChevronLeftGlyph, PlusGlyph } from "./glyphs";
import "./SettingsScreen.css";
/** The swatch picker's presets; any other "#RRGGBB" can be typed. */
export const COLOUR_PRESETS = ["#e11d48", "#f97316", "#eab308", "#16a34a", "#0ea5e9", "#7c3aed"];
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
export const SCROLL_BOTTOM_PADDING = "92px";
const HEX_COLOUR = /^#[0-9a-fA-F]{6}$/;
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; children: AdminChild[] };
type FormTarget = null | "new" | AdminChild;
interface Draft {
name: string;
colour: string;
avatar: string;
order: string;
}
/** Display order: sort_order, ties broken by id (the API's own order). */
function sortChildren(children: AdminChild[]): AdminChild[] {
return [...children].sort((a, b) => a.sort_order - b.sort_order || a.id - b.id);
}
function errorMessage(error: unknown, action: string): string {
if (error instanceof ApiForbiddenError) return error.message;
if (error instanceof ApiRequestError && error.detail) return `${action}: ${error.detail}`;
return `${action}. Check your connection and try again.`;
}
function draftFor(target: "new" | AdminChild, siblings: AdminChild[]): Draft {
if (target === "new") {
const next = siblings.reduce((max, c) => Math.max(max, c.sort_order), 0) + 1;
return { name: "", colour: "", avatar: "", order: String(next) };
}
return {
name: target.display_name,
colour: target.colour ?? "",
avatar: target.avatar_ref ?? "",
order: String(target.sort_order),
};
}
/** The request body for the draft, or a validation message. */
function buildBody(
draft: Draft,
target: "new" | AdminChild,
): ChildCreateBody | ChildEditBody | string {
const name = draft.name.trim();
const colour = draft.colour.trim();
const avatar = draft.avatar.trim();
const order = draft.order.trim();
if (!name) return "Enter a display name.";
if (colour && !HEX_COLOUR.test(colour)) return "Colour must be a hex value like #0ea5e9.";
if (order && !/^-?\d+$/.test(order)) return "Order must be a whole number.";
if (target === "new") {
const body: ChildCreateBody = { display_name: name };
if (colour) body.colour = colour;
if (avatar) body.avatar_ref = avatar;
if (order) body.sort_order = Number(order);
return body;
}
// The API rejects null, so a set colour, avatar or order cannot be cleared.
if (!colour && target.colour !== null) return "A colour, once set, cannot be removed. Pick another.";
if (!avatar && target.avatar_ref !== null) return "An avatar ref, once set, cannot be removed.";
if (!order) return "Enter an order.";
const body: ChildEditBody = {};
if (name !== target.display_name) body.display_name = name;
if (colour && colour !== target.colour) body.colour = colour;
if (avatar && avatar !== target.avatar_ref) body.avatar_ref = avatar;
if (Number(order) !== target.sort_order) body.sort_order = Number(order);
if (Object.keys(body).length === 0) return "No changes to save.";
return body;
}
function ChildForm({
target,
siblings,
saving,
error,
onCancel,
onSubmit,
}: {
target: "new" | AdminChild;
/** The current list; a new child's order defaults to after the last. */
siblings: AdminChild[];
saving: boolean;
error: string | null;
onCancel: () => void;
onSubmit: (body: ChildCreateBody | ChildEditBody | string) => void;
}) {
const [draft, setDraft] = useState<Draft>(() => draftFor(target, siblings));
const nameRef = useRef<HTMLInputElement>(null);
const update = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
const isNew = target === "new";
const title = isNew ? "Add child" : `Edit ${target.display_name}`;
useEffect(() => {
nameRef.current?.focus();
}, []);
function submit(event: FormEvent) {
event.preventDefault();
onSubmit(buildBody(draft, target));
}
return (
<form className="settings-card settings-form" aria-label={title} onSubmit={submit} noValidate>
<h4 className="settings-form-title">{title}</h4>
<label className="settings-field">
<span className="settings-label">Display name</span>
<input
ref={nameRef}
className="settings-input"
type="text"
required
value={draft.name}
disabled={saving}
onChange={(event) => update({ name: event.target.value })}
/>
</label>
<div className="settings-field">
<label className="settings-field">
<span className="settings-label">Colour</span>
<input
className="settings-input"
type="text"
placeholder="#0ea5e9"
value={draft.colour}
disabled={saving}
onChange={(event) => update({ colour: event.target.value })}
/>
</label>
<div className="settings-swatches" role="group" aria-label="Colour presets">
{COLOUR_PRESETS.map((hex) => (
<button
key={hex}
type="button"
className="settings-swatch-option"
style={{ background: hex }}
aria-label={`Colour ${hex}`}
aria-pressed={draft.colour.toLowerCase() === hex}
disabled={saving}
onClick={() => update({ colour: hex })}
/>
))}
</div>
</div>
<label className="settings-field">
<span className="settings-label">Avatar ref (optional)</span>
<input
className="settings-input"
type="text"
value={draft.avatar}
disabled={saving}
onChange={(event) => update({ avatar: event.target.value })}
/>
</label>
<label className="settings-field">
<span className="settings-label">Order</span>
<input
className="settings-input"
type="text"
inputMode="numeric"
value={draft.order}
disabled={saving}
onChange={(event) => update({ order: event.target.value })}
/>
</label>
{error ? (
<p className="settings-error" role="alert" data-testid="child-form-error">
{error}
</p>
) : null}
<div className="settings-form-footer">
<button type="button" className="settings-cancel" disabled={saving} onClick={onCancel}>
Cancel
</button>
<button type="submit" className="settings-save" disabled={saving} aria-busy={saving}>
{saving ? "Saving…" : isNew ? "Add child" : "Save child"}
</button>
</div>
</form>
);
}
type PreferencesState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; settings: HouseholdSettings };
type TimeField =
| "day_rollover_time"
| "morning_summary_time"
| "afternoon_reminder_time"
| "end_of_day_report_time";
type ToggleField =
| "morning_summary_enabled"
| "afternoon_reminder_enabled"
| "end_of_day_report_enabled"
| "celebration_enabled";
const NOTIFICATIONS: { label: string; time: TimeField; toggle: ToggleField }[] = [
{ label: "Morning summary", time: "morning_summary_time", toggle: "morning_summary_enabled" },
{ label: "Afternoon reminder", time: "afternoon_reminder_time", toggle: "afternoon_reminder_enabled" },
{ label: "End-of-day report", time: "end_of_day_report_time", toggle: "end_of_day_report_enabled" },
];
const HH_MM = /^([01]\d|2[0-3]):[0-5]\d$/;
/** The form's working copy: the number is text so it can be mid-edit. */
type PreferencesDraft = Omit<HouseholdSettings, "horizon_days"> & { horizon_days: string };
function preferencesDraft(settings: HouseholdSettings): PreferencesDraft {
return { ...settings, horizon_days: String(settings.horizon_days) };
}
/** Only the fields that differ from `loaded`, or a validation message. */
function buildChanges(draft: PreferencesDraft, loaded: HouseholdSettings): SettingsChanges | string {
const horizon = draft.horizon_days.trim();
const horizonDays = Number(horizon);
// A digit string too long for a safe integer would become Infinity, which
// JSON serializes as null, and the API reads null as "reset to default".
if (!/^\d+$/.test(horizon) || !Number.isSafeInteger(horizonDays) || horizonDays < 1) {
return "Planning horizon must be a whole number of days, at least 1.";
}
const times: [TimeField, string][] = [
["day_rollover_time", "Day rollover"],
...NOTIFICATIONS.map((n): [TimeField, string] => [n.time, `${n.label} time`]),
];
for (const [key, label] of times) {
if (!HH_MM.test(draft[key])) return `${label} must be a time like 07:30.`;
}
const next: HouseholdSettings = {
...draft,
horizon_days: horizonDays,
notify_target: draft.notify_target.trim(),
};
const changes: SettingsChanges = {};
for (const key of Object.keys(loaded) as (keyof HouseholdSettings)[]) {
if (next[key] !== loaded[key]) (changes as Record<string, unknown>)[key] = next[key];
}
if (Object.keys(changes).length === 0) return "No changes to save.";
return changes;
}
function PreferencesForm({
settings,
saving,
locked,
message,
onSubmit,
}: {
settings: HouseholdSettings;
saving: boolean;
/** Another write is in flight. */
locked: boolean;
message: { kind: "error" | "info"; text: string } | null;
onSubmit: (changes: SettingsChanges | string) => void;
}) {
const [draft, setDraft] = useState<PreferencesDraft>(() => preferencesDraft(settings));
const update = (patch: Partial<PreferencesDraft>) => setDraft((d) => ({ ...d, ...patch }));
const disabled = saving || locked;
function submit(event: FormEvent) {
event.preventDefault();
onSubmit(buildChanges(draft, settings));
}
function timeInput(key: TimeField, label: string) {
return (
<label className="settings-field">
<span className="settings-label">{label}</span>
<input
className="settings-input"
type="time"
value={draft[key]}
disabled={disabled}
onChange={(event) => update({ [key]: event.target.value })}
/>
</label>
);
}
function toggle(key: ToggleField, label: string) {
return (
<label className="settings-toggle">
<input
type="checkbox"
checked={draft[key]}
disabled={disabled}
onChange={(event) => update({ [key]: event.target.checked })}
/>
<span>{label}</span>
</label>
);
}
return (
<form className="settings-card settings-form" aria-label="Preferences" onSubmit={submit} noValidate>
<label className="settings-field">
<span className="settings-label">Planning horizon (days)</span>
<input
className="settings-input"
type="number"
min={1}
step={1}
inputMode="numeric"
value={draft.horizon_days}
disabled={disabled}
onChange={(event) => update({ horizon_days: event.target.value })}
/>
</label>
{timeInput("day_rollover_time", "Day rollover")}
<label className="settings-field">
<span className="settings-label">Notify target</span>
<input
className="settings-input"
type="text"
value={draft.notify_target}
disabled={disabled}
onChange={(event) => update({ notify_target: event.target.value })}
/>
</label>
{NOTIFICATIONS.map((n) => (
<div key={n.toggle} className="settings-notification">
{toggle(n.toggle, n.label)}
{timeInput(n.time, `${n.label} time`)}
</div>
))}
{toggle("celebration_enabled", "Celebration")}
{message ? (
<p
className={message.kind === "error" ? "settings-error" : "settings-notice"}
role={message.kind === "error" ? "alert" : "status"}
data-testid="preferences-message"
>
{message.text}
</p>
) : null}
<div className="settings-form-footer">
<button type="submit" className="settings-save" disabled={disabled} aria-busy={saving}>
{saving ? "Saving…" : "Save preferences"}
</button>
</div>
</form>
);
}
function ChildRow({
child,
index,
count,
busy,
onEdit,
onMove,
onToggleActive,
}: {
child: AdminChild;
index: number;
count: number;
/** The in-flight action key, or null. Every write control waits on it. */
busy: string | null;
onEdit: () => void;
onMove: (delta: -1 | 1) => void;
onToggleActive: () => void;
}) {
const locked = busy !== null;
const toggling = busy === `active-${child.id}`;
return (
<li
className={child.is_active ? "settings-child" : "settings-child settings-child--inactive"}
data-testid={`settings-child-${child.id}`}
>
<span
className="settings-swatch"
data-testid={`settings-swatch-${child.id}`}
style={{ background: child.colour ?? "var(--nq-a-border)" }}
aria-hidden="true"
/>
<div className="settings-child-text">
<div className="settings-child-name">{child.display_name}</div>
<div className="settings-child-meta">
Order {child.sort_order} · {child.colour ?? "no colour"}
</div>
</div>
<span
className={child.is_active ? "settings-pill settings-pill--active" : "settings-pill"}
data-testid={`settings-active-${child.id}`}
>
{child.is_active ? "Active" : "Inactive"}
</span>
<div className="settings-child-actions">
<button
type="button"
className="settings-icon-button"
aria-label={`Move ${child.display_name} up`}
aria-busy={busy === `move-${child.id}`}
disabled={locked || index === 0}
onClick={() => onMove(-1)}
>
<ArrowUpGlyph />
</button>
<button
type="button"
className="settings-icon-button"
aria-label={`Move ${child.display_name} down`}
aria-busy={busy === `move-${child.id}`}
disabled={locked || index === count - 1}
onClick={() => onMove(1)}
>
<ArrowDownGlyph />
</button>
<button
type="button"
className="settings-action"
aria-label={`Edit ${child.display_name}`}
disabled={locked}
onClick={onEdit}
>
Edit
</button>
<button
type="button"
className="settings-action"
aria-label={`${child.is_active ? "Deactivate" : "Reactivate"} ${child.display_name}`}
aria-busy={toggling}
disabled={locked}
onClick={onToggleActive}
>
{toggling ? "Saving…" : child.is_active ? "Deactivate" : "Reactivate"}
</button>
</div>
</li>
);
}
export default function SettingsScreen({ onClose }: { onClose: () => void }) {
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
const [form, setForm] = useState<FormTarget>(null);
const [formError, setFormError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [preferences, setPreferences] = useState<PreferencesState>({ kind: "loading" });
const [preferencesAttempt, setPreferencesAttempt] = useState(0);
const [preferencesMessage, setPreferencesMessage] = useState<{
kind: "error" | "info";
text: string;
} | null>(null);
// A ref, not state: two taps in one frame must not both see `busy === null`.
const inFlight = useRef(false);
const screenRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null);
const addRef = useRef<HTMLButtonElement>(null);
const formWasOpen = useRef(false);
// Modal focus: move in on open, keep the background out of reach, and hand
// focus back to the opener (the Settings button) on close.
useEffect(() => {
const trigger = document.activeElement as HTMLElement | null;
titleRef.current?.focus();
const restoreBackground = screenRef.current ? inertOutside(screenRef.current) : undefined;
return () => {
restoreBackground?.();
if (trigger && trigger.isConnected) trigger.focus();
};
}, []);
// The form's controls unmount when it closes; hand focus to Add child then
// (after the render that re-enables it).
useEffect(() => {
if (formWasOpen.current && form === null) addRef.current?.focus();
formWasOpen.current = form !== null;
}, [form]);
useEffect(() => {
let cancelled = false;
setState((current) => (current.kind === "ready" ? current : { kind: "loading" }));
fetchChildren()
.then((children) => {
if (!cancelled) setState({ kind: "ready", children: sortChildren(children) });
})
.catch((error: unknown) => {
if (cancelled) return;
setState({ kind: "error", message: errorMessage(error, "Children could not be loaded") });
});
return () => {
cancelled = true;
};
}, [attempt]);
useEffect(() => {
let cancelled = false;
setPreferences({ kind: "loading" });
fetchSettings()
.then((settings) => {
if (!cancelled) setPreferences({ kind: "ready", settings });
})
.catch((error: unknown) => {
if (cancelled) return;
setPreferences({ kind: "error", message: errorMessage(error, "Preferences could not be loaded") });
});
return () => {
cancelled = true;
};
}, [preferencesAttempt]);
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape") {
event.preventDefault();
if (!inFlight.current) onClose();
return;
}
if (event.key !== "Tab" || !screenRef.current) return;
const focusable = Array.from(screenRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const current = document.activeElement;
const outside = !focusable.includes(current as HTMLElement);
if (event.shiftKey && (current === first || outside)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (current === last || outside)) {
event.preventDefault();
first.focus();
}
}
/**
* Run one write, then refetch the list. One write at a time; a failure
* keeps the screen (and any open form) as it was and says why.
*/
async function run(key: string, write: () => Promise<unknown>, action: string): Promise<boolean> {
if (inFlight.current) return false;
inFlight.current = true;
setBusy(key);
setActionError(null);
setFormError(null);
try {
await write();
} catch (error) {
const message = errorMessage(error, action);
if (key === "form") setFormError(message);
else setActionError(message);
inFlight.current = false;
setBusy(null);
return false;
}
try {
const children = await fetchChildren();
setState({ kind: "ready", children: sortChildren(children) });
} catch (error) {
setActionError(errorMessage(error, "Saved, but the children list could not be refreshed"));
}
inFlight.current = false;
setBusy(null);
return true;
}
function closeForm() {
setForm(null);
setFormError(null);
}
async function submitForm(body: ChildCreateBody | ChildEditBody | string) {
if (form === null) return;
if (typeof body === "string") {
setFormError(body);
return;
}
const saved =
form === "new"
? await run("form", () => createChild(body as ChildCreateBody), "The child could not be added")
: await run("form", () => updateChild(form.id, body), "The child could not be saved");
if (saved) closeForm();
}
async function savePreferences(changes: SettingsChanges | string) {
if (typeof changes === "string") {
setPreferencesMessage({ kind: changes === "No changes to save." ? "info" : "error", text: changes });
return;
}
if (inFlight.current) return;
inFlight.current = true;
setBusy("preferences");
setPreferencesMessage(null);
try {
const settings = await updateSettings(changes);
setPreferences({ kind: "ready", settings });
setPreferencesMessage({ kind: "info", text: "Preferences saved." });
} catch (error) {
setPreferencesMessage({
kind: "error",
text: errorMessage(error, "The preferences could not be saved"),
});
}
inFlight.current = false;
setBusy(null);
}
function move(children: AdminChild[], index: number, delta: -1 | 1) {
const ids = children.map((c) => c.id);
const target = index + delta;
[ids[index], ids[target]] = [ids[target], ids[index]];
void run(`move-${children[index].id}`, () => reorderChildren(ids), "The order could not be saved");
}
function toggleActive(child: AdminChild) {
void run(
`active-${child.id}`,
() => setChildActive(child.id, !child.is_active),
child.is_active ? "The child could not be deactivated" : "The child could not be reactivated",
);
}
let body;
if (state.kind === "loading") {
body = (
<div className="settings-status" role="status" data-testid="settings-loading">
Loading children…
</div>
);
} else if (state.kind === "error") {
body = (
<div className="settings-status settings-status--error" role="alert" data-testid="settings-error">
<p>{state.message}</p>
<button type="button" className="settings-action" onClick={() => setAttempt((n) => n + 1)}>
Try again
</button>
</div>
);
} else {
const { children } = state;
body = (
<>
{children.length > 0 ? (
<ul className="settings-card settings-list" aria-label="Children">
{children.map((child, index) => (
<ChildRow
key={child.id}
child={child}
index={index}
count={children.length}
busy={busy}
onEdit={() => {
setFormError(null);
setForm(child);
}}
onMove={(delta) => move(children, index, delta)}
onToggleActive={() => toggleActive(child)}
/>
))}
</ul>
) : (
<div className="settings-card settings-empty">No children yet.</div>
)}
{form !== null ? (
<ChildForm
key={form === "new" ? "new" : form.id}
target={form}
siblings={children}
saving={busy === "form"}
error={formError}
onCancel={closeForm}
onSubmit={(b) => void submitForm(b)}
/>
) : null}
</>
);
}
return (
<div
ref={screenRef}
className="settings-screen"
role="dialog"
aria-modal="true"
aria-labelledby="settings-title"
onKeyDown={onKeyDown}
>
<header className="settings-header">
<button
type="button"
className="settings-back"
aria-label="Back"
disabled={busy !== null}
onClick={onClose}
>
<ChevronLeftGlyph size={20} />
</button>
<div>
<h2 className="settings-title" id="settings-title" ref={titleRef} tabIndex={-1}>
Settings
</h2>
<p className="settings-sub">Household setup</p>
</div>
</header>
<div
className="settings-scroll"
data-testid="settings-scroll"
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
>
<div className="settings-section-head">
<h3 className="settings-section-label">Children</h3>
<button
ref={addRef}
type="button"
className="settings-add"
disabled={state.kind !== "ready" || busy !== null || form === "new"}
onClick={() => {
setFormError(null);
setForm("new");
}}
>
<PlusGlyph />
Add child
</button>
</div>
{actionError !== null ? (
<div className="settings-action-error" role="alert" data-testid="settings-action-error">
<span>{actionError}</span>
<button type="button" className="settings-action" onClick={() => setActionError(null)}>
Dismiss
</button>
</div>
) : null}
{body}
<div className="settings-section-head">
<h3 className="settings-section-label">Preferences</h3>
</div>
{preferences.kind === "loading" ? (
<div className="settings-status" role="status" data-testid="preferences-loading">
Loading preferences…
</div>
) : preferences.kind === "error" ? (
<div
className="settings-status settings-status--error"
role="alert"
data-testid="preferences-error"
>
<p>{preferences.message}</p>
<button
type="button"
className="settings-action"
onClick={() => setPreferencesAttempt((n) => n + 1)}
>
Try again
</button>
</div>
) : (
<PreferencesForm
// A saved response remounts the form so its draft matches the store.
key={JSON.stringify(preferences.settings)}
settings={preferences.settings}
saving={busy === "preferences"}
locked={busy !== null && busy !== "preferences"}
message={preferencesMessage}
onSubmit={(changes) => void savePreferences(changes)}
/>
)}
</div>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Inline stroke glyphs for the Settings screen (Lucide-style 24px grid),
* CDN-free like the tabs' glyphs.
*/
import type { ReactNode } from "react";
function Glyph({ size, children }: { size: number; children: ReactNode }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
);
}
export function ChevronLeftGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="m15 18-6-6 6-6" />
</Glyph>
);
}
export function ArrowUpGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="m5 12 7-7 7 7M12 19V5" />
</Glyph>
);
}
export function ArrowDownGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="M12 5v14M19 12l-7 7-7-7" />
</Glyph>
);
}
export function PlusGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="M12 5v14M5 12h14" />
</Glyph>
);
}
+331
View File
@@ -0,0 +1,331 @@
/* Today tab — compact density (design/ADMIN-SPEC.md §2). Mobile-first at the
390x844 reference; on wider viewports the column is capped and centred. */
.today-screen {
width: 100%;
max-width: 640px;
margin: 0 auto;
}
/* §2.1 header */
.today-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--nq-a-surface);
border-bottom: 1px solid var(--nq-a-border);
padding: 14px 16px;
}
.today-title {
margin: 0;
font-size: var(--nq-a-size-screen);
font-weight: 800;
letter-spacing: -0.02em;
}
.today-sub {
margin: 2px 0 0;
min-height: 1.4em;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.today-icon-button {
flex: none;
display: grid;
place-items: center;
width: 36px;
height: 36px;
padding: 0;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink-secondary);
cursor: pointer;
}
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
.today-scroll {
display: flex;
flex-direction: column;
gap: var(--nq-a-density-gap);
padding: 14px 16px;
}
.today-card {
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
}
.today-list {
margin: 0;
padding: 0;
list-style: none;
}
.today-list > li + li {
border-top: 1px solid var(--nq-a-divider);
}
/* §2.2 stat row */
.today-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.today-stat {
padding: 12px;
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
}
.today-stat-value {
font-size: var(--nq-a-size-stat);
font-weight: 800;
line-height: 1.1;
}
.today-stat-value--done {
color: var(--nq-brand-blue);
}
.today-stat-value--remaining {
color: var(--nq-a-ink);
}
.today-stat-value--overdue {
color: var(--nq-a-danger);
}
.today-stat-label {
margin-top: 2px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--nq-a-ink-secondary);
}
/* §2.3 child rows */
.today-child {
display: flex;
align-items: center;
gap: 10px;
min-height: var(--nq-a-tap-min);
padding: var(--nq-a-density-row-pad);
}
.today-avatar {
flex: none;
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 7px;
background: var(--nq-brand-gradient);
color: #fff;
font-size: 13px;
font-weight: 800;
}
.today-child--away .today-avatar {
background: #9898a8;
}
.today-child-text {
flex: 1;
min-width: 0;
}
.today-child-name {
font-size: 14px;
font-weight: 700;
}
.today-child-meta {
font-size: var(--nq-a-size-meta);
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.today-child-meta--done {
color: var(--nq-a-success);
}
.today-progress {
flex: none;
width: 82px;
height: var(--nq-a-progress-height);
border-radius: var(--nq-a-radius-pill);
background: var(--nq-a-divider);
overflow: hidden;
}
.today-progress-fill {
height: 100%;
border-radius: inherit;
background: var(--nq-brand-gradient-h);
}
/* §2.5 needs attention */
.today-section-label {
margin: 6px 0 -2px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--nq-a-ink-secondary);
}
.today-event {
display: flex;
align-items: center;
gap: 10px;
min-height: var(--nq-a-tap-min);
padding: var(--nq-a-density-row-pad);
}
.today-event-glyph {
flex: none;
display: flex;
color: var(--nq-a-success);
}
.today-event-glyph--overdue {
color: var(--nq-a-danger);
}
.today-event-text {
flex: 1;
min-width: 0;
}
.today-event-title {
font-size: 13px;
font-weight: 700;
}
.today-event-meta {
font-size: var(--nq-a-size-meta);
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.today-event-meta--overdue {
color: var(--nq-a-danger);
}
.today-action {
flex: none;
min-height: var(--nq-a-tap-min);
padding: 0 4px;
border: 0;
background: none;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
}
.today-action--done {
color: var(--nq-brand-blue);
}
.today-action--undo {
color: var(--nq-brand-purple);
}
.today-action:disabled {
cursor: default;
opacity: 0.5;
}
/* Non-blocking Undo failure message. */
.today-action-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: var(--nq-a-density-row-pad);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-danger-bg);
color: var(--nq-a-danger-strong);
font-size: 13px;
font-weight: 600;
}
.today-action-dismiss {
flex: none;
padding: 4px 8px;
border: none;
background: none;
color: inherit;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
}
.today-empty {
padding: var(--nq-a-density-card-pad);
font-size: 13px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
/* §2.6 permission note */
.today-note {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-info-bg);
color: var(--nq-a-info-ink);
font-size: 11.5px;
font-weight: 600;
}
.today-note-glyph {
flex: none;
display: flex;
color: var(--nq-a-info-icon);
}
/* Loading / error */
.today-status {
padding: 24px 13px;
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
text-align: center;
font-size: 13.5px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.today-status--error {
color: var(--nq-a-danger-strong);
}
.today-status--error p {
margin: 0 0 10px;
}
.today-retry {
padding: 8px 16px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
+408
View File
@@ -0,0 +1,408 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import TodayTab, { PERMISSION_NOTE } from "./TodayTab";
import type { AdminSnapshot, SnapshotInstance } from "../api/snapshot";
import { TransitionsProvider } from "../api/useTransitions";
import { frame, testStream, type TestStream } from "../api/testStream";
import { storeTokens } from "../auth/oidc";
function instance(overrides: Partial<SnapshotInstance>): SnapshotInstance {
return {
id: 0,
definition_id: 1,
child_id: 1,
title: "Quest",
icon: null,
window: "morning",
due_time: null,
state: "open",
overdue: false,
completed_at: null,
on_time: null,
...overrides,
};
}
const SNAPSHOT: AdminSnapshot = {
today_iso: "2026-09-23",
cycle_day: 5,
children: [
{
child_id: 1,
child_name: "Declan",
present: true,
next_present: null,
due_today: 7,
completed_today: 3,
remaining_today: 4,
completion_pct: 43,
instances: [
instance({ id: 11, title: "Brush teeth", due_time: "07:30", overdue: true }),
instance({
id: 12,
title: "Make bed",
state: "completed",
completed_at: "2026-09-23T11:04:00Z",
on_time: true,
}),
instance({ id: 13, title: "Homework", window: "afternoon", due_time: "16:00" }),
// Overdue but already completed: not counted as overdue.
instance({
id: 14,
title: "Pack bag",
state: "completed",
overdue: true,
completed_at: "2026-09-23T10:00:00Z",
on_time: false,
}),
],
},
{
child_id: 2,
child_name: "Maeve",
present: true,
next_present: null,
due_today: 4,
completed_today: 4,
remaining_today: 0,
completion_pct: 100,
instances: [],
},
{
child_id: 3,
child_name: "Rory",
present: false,
next_present: "2026-10-01",
due_today: 0,
completed_today: 0,
remaining_today: 0,
completion_pct: 0,
instances: [],
},
],
};
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
let fetchMock: ReturnType<typeof vi.fn>;
async function renderReady() {
fetchMock.mockResolvedValue(jsonResponse(SNAPSHOT));
render(<TodayTab />);
await screen.findByText("Declan");
}
describe("TodayTab", () => {
beforeAll(() => {
// A negative-offset zone: `new Date("2026-09-23")` would read as Sep 22 here.
vi.stubEnv("TZ", "America/Los_Angeles");
});
afterAll(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("fetches the admin snapshot with the Bearer token", async () => {
await renderReady();
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toMatch(/\/api\/v1\/admin\/snapshot$/);
expect(init.headers.Authorization).toBe("Bearer at-123");
});
it("header shows the weekday, date and cycle day", async () => {
await renderReady();
expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy();
expect(screen.getByText("Wednesday, September 23 · cycle day 5 of 14")).toBeTruthy();
expect(screen.getByRole("button", { name: "Settings" })).toBeTruthy();
});
it("stat row shows done, remaining and overdue totals", async () => {
await renderReady();
expect(screen.getByTestId("stat-done").textContent).toBe("7Done");
expect(screen.getByTestId("stat-remaining").textContent).toBe("4Remaining");
expect(screen.getByTestId("stat-overdue").textContent).toBe("1Overdue");
});
it("present child row shows progress meta and fill", async () => {
await renderReady();
const row = screen.getByTestId("child-1");
expect(within(row).getByText("3 of 7 · 1 overdue")).toBeTruthy();
const bar = within(row).getByRole("progressbar");
expect(bar.getAttribute("aria-valuenow")).toBe("43");
expect((bar.firstElementChild as HTMLElement).style.width).toBe("43%");
expect(row.className).not.toContain("today-child--away");
});
it("all-done child renders the success state", async () => {
await renderReady();
const meta = within(screen.getByTestId("child-2")).getByText("4 of 4 · all done");
expect(meta.className).toContain("today-child-meta--done");
});
it("away child shows the return date and the flat avatar", async () => {
await renderReady();
const row = screen.getByTestId("child-3");
expect(within(row).getByText("Away · returns Oct 1")).toBeTruthy();
expect(row.className).toContain("today-child--away");
expect(within(row).getByTestId("child-avatar").textContent).toBe("R");
});
it("needs-attention lists overdue and completed rows with their actions", async () => {
await renderReady();
const list = screen.getByRole("list", { name: "Needs attention" });
const overdue = within(list).getByTestId("attention-11");
expect(within(overdue).getByText("Brush teeth · Declan")).toBeTruthy();
expect(within(overdue).getByText("Overdue since 7:30 AM")).toBeTruthy();
expect(within(overdue).getByRole("button", { name: "Mark done" })).toBeTruthy();
const completed = within(list).getByTestId("attention-12");
expect(within(completed).getByText("Make bed · Declan")).toBeTruthy();
// 11:04Z is 4:04 AM in Los Angeles (PDT).
expect(within(completed).getByText("Completed 4:04 AM")).toBeTruthy();
expect(within(completed).getByRole("button", { name: "Undo" })).toBeTruthy();
// Open, not-overdue instances do not need attention.
expect(within(list).queryByTestId("attention-13")).toBeNull();
// No actor in the payload, so no "from panel".
expect(list.textContent).not.toContain("from panel");
});
it("permission note carries the exact copy", async () => {
await renderReady();
expect(PERMISSION_NOTE).toBe(
"Undo is admin-only. A panel tap can never reverse a completion.",
);
expect(screen.getByTestId("permission-note").textContent).toBe(PERMISSION_NOTE);
});
it("scroll column ends with 92px bottom padding", async () => {
await renderReady();
expect(screen.getByTestId("today-scroll").style.paddingBottom).toBe("92px");
});
it("shows a loading state while the snapshot is in flight", () => {
fetchMock.mockReturnValue(new Promise<Response>(() => {}));
render(<TodayTab />);
expect(screen.getByTestId("today-loading").textContent).toContain("Loading");
expect(screen.getByTestId("today-scroll").style.paddingBottom).toBe("92px");
});
it("shows an error state on a failed fetch and retries", async () => {
fetchMock.mockResolvedValueOnce(new Response("boom", { status: 500 }));
render(<TodayTab />);
const error = await screen.findByTestId("today-error");
expect(error.getAttribute("role")).toBe("alert");
expect(error.textContent).toContain("could not be loaded");
fetchMock.mockResolvedValueOnce(jsonResponse(SNAPSHOT));
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(await screen.findByText("Declan")).toBeTruthy();
});
it("shows the refusal message when the API answers 403", async () => {
fetchMock.mockResolvedValueOnce(new Response("", { status: 403 }));
render(<TodayTab />);
const error = await screen.findByTestId("today-error");
expect(error.textContent).toContain("nestquest-admins");
});
});
/** SNAPSHOT after instance 12 ("Make bed") is reversed back to open. */
const AFTER_UNDO: AdminSnapshot = {
...SNAPSHOT,
children: SNAPSHOT.children.map((child) =>
child.child_id !== 1
? child
: {
...child,
completed_today: 2,
remaining_today: 5,
completion_pct: 29,
instances: child.instances.map((i) =>
i.id === 12 ? { ...i, state: "open" as const, completed_at: null, on_time: null } : i,
),
},
),
};
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}
describe("TodayTab Undo and live updates", () => {
let fetchMock: ReturnType<typeof vi.fn>;
let snapshots: AdminSnapshot[];
let uncomplete: () => Promise<Response>;
let streams: TestStream[];
function calls(pathSuffix: string, method = "GET") {
return fetchMock.mock.calls.filter(
([url, init]) =>
new URL(String(url), "http://x").pathname.endsWith(pathSuffix) &&
(init?.method ?? "GET") === method,
);
}
beforeAll(() => {
vi.stubEnv("TZ", "America/Los_Angeles");
});
afterAll(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
snapshots = [SNAPSHOT, AFTER_UNDO];
uncomplete = () =>
Promise.resolve(jsonResponse({ instance_id: 12, state: "open", appended: true }));
streams = [];
fetchMock = vi.fn((url: string) => {
const path = new URL(url, "http://x").pathname;
if (path.endsWith("/api/v1/admin/snapshot")) {
const next = snapshots.length > 1 ? snapshots.shift()! : snapshots[0];
return Promise.resolve(jsonResponse(next));
}
if (path.endsWith("/api/v1/admin/instances/12/uncomplete")) return uncomplete();
if (path.endsWith("/api/v1/admin/events")) {
const stream = testStream();
streams.push(stream);
return Promise.resolve(stream.response);
}
return Promise.resolve(jsonResponse({ detail: "unexpected" }, 500));
});
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("Undo POSTs uncomplete for the completed instance and refetches the snapshot", async () => {
render(<TodayTab />);
const row = await screen.findByTestId("attention-12");
fireEvent.click(within(row).getByRole("button", { name: "Undo" }));
await waitFor(() => expect(screen.queryByTestId("attention-12")).toBeNull());
const posts = calls("/api/v1/admin/instances/12/uncomplete", "POST");
expect(posts).toHaveLength(1);
expect(posts[0][1].headers.Authorization).toBe("Bearer at-123");
expect(calls("/api/v1/admin/snapshot")).toHaveLength(2);
// The reversed instance is open again: one fewer done, one more remaining.
expect(screen.getByTestId("stat-done").textContent).toBe("6Done");
expect(screen.getByTestId("stat-remaining").textContent).toBe("5Remaining");
expect(screen.queryByTestId("today-undo-error")).toBeNull();
});
it("disables Undo while the request is in flight (no double submit)", async () => {
const pending = deferred<Response>();
uncomplete = () => pending.promise;
render(<TodayTab />);
const row = await screen.findByTestId("attention-12");
const button = within(row).getByRole("button", { name: "Undo" }) as HTMLButtonElement;
fireEvent.click(button);
await waitFor(() => expect(button.disabled).toBe(true));
expect(button.textContent).toBe("Undoing…");
// Every Undo is disabled while one reversal is in flight.
const other = within(screen.getByTestId("attention-14")).getByRole("button", {
name: "Undo",
}) as HTMLButtonElement;
expect(other.disabled).toBe(true);
fireEvent.click(button);
fireEvent.click(other);
expect(calls("/api/v1/admin/instances/12/uncomplete", "POST")).toHaveLength(1);
expect(calls("/uncomplete", "POST")).toHaveLength(1);
pending.resolve(jsonResponse({ instance_id: 12, state: "open", appended: true }));
await waitFor(() => expect(screen.queryByTestId("attention-12")).toBeNull());
expect(other.disabled).toBe(false);
});
it("a failed Undo shows a message and leaves the view intact", async () => {
uncomplete = () => Promise.resolve(jsonResponse({ detail: "instance 12 not found" }, 404));
render(<TodayTab />);
const row = await screen.findByTestId("attention-12");
fireEvent.click(within(row).getByRole("button", { name: "Undo" }));
const alert = await screen.findByTestId("today-undo-error");
expect(alert.textContent).toContain("The completion could not be undone: instance 12 not found");
// Still the original snapshot: nothing refetched, the row and its Undo remain usable.
expect(calls("/api/v1/admin/snapshot")).toHaveLength(1);
const button = within(screen.getByTestId("attention-12")).getByRole("button", {
name: "Undo",
}) as HTMLButtonElement;
expect(button.disabled).toBe(false);
expect(screen.getByTestId("stat-done").textContent).toBe("7Done");
fireEvent.click(within(alert).getByRole("button", { name: "Dismiss" }));
expect(screen.queryByTestId("today-undo-error")).toBeNull();
});
it("Mark done stays inert", async () => {
render(<TodayTab />);
const row = await screen.findByTestId("attention-11");
fireEvent.click(within(row).getByRole("button", { name: "Mark done" }));
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(0);
});
it("a transition on the live stream refetches the snapshot without a reload", async () => {
const { unmount } = render(
<TransitionsProvider>
<TodayTab />
</TransitionsProvider>,
);
await screen.findByTestId("attention-12");
await waitFor(() => expect(streams).toHaveLength(1));
const [, init] = calls("/api/v1/admin/events")[0];
expect(init.headers.Authorization).toBe("Bearer at-123");
// An uncompletion made elsewhere, its frame split across two chunks.
const text = frame("nestquest_quest_uncompleted", {
child_id: 1,
child_name: "Declan",
instance_id: 12,
quest_title: "Make bed",
window: "morning",
due_date: "2026-09-23",
due_time: null,
occurred_at: "2026-09-23T12:00:00Z",
});
streams[0].push(text.slice(0, 25));
streams[0].push(text.slice(25));
await waitFor(() => expect(calls("/api/v1/admin/snapshot")).toHaveLength(2));
await waitFor(() => expect(screen.queryByTestId("attention-12")).toBeNull());
// Refreshed in place: the loading state never replaced the view.
expect(screen.queryByTestId("today-loading")).toBeNull();
const signal = init.signal as AbortSignal;
expect(signal.aborted).toBe(false);
unmount();
expect(signal.aborted).toBe(true);
await waitFor(() => expect(streams[0].cancelled()).toBe(true));
});
});
+365
View File
@@ -0,0 +1,365 @@
/**
* Today tab — compact density (design/ADMIN-SPEC.md §2.1–2.3, §2.5–2.6).
*/
import { useEffect, useRef, useState } from "react";
import {
fetchSnapshot,
type AdminSnapshot,
type SnapshotChild,
type SnapshotInstance,
} from "../api/snapshot";
import { ApiForbiddenError } from "../api/client";
import { ApiRequestError } from "../api/definitions";
import { uncompleteInstance } from "../api/instances";
import { useTransitions } from "../api/useTransitions";
import SettingsScreen from "../settings/SettingsScreen";
import {
formatClockTime,
formatDueTime,
formatLongDate,
formatShortDate,
} from "./format";
import {
AlertCircleGlyph,
CheckCircleGlyph,
InfoGlyph,
SettingsGlyph,
} from "./glyphs";
import "./TodayTab.css";
const CYCLE_LENGTH_DAYS = 14;
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
export const SCROLL_BOTTOM_PADDING = "92px";
export const PERMISSION_NOTE =
"Undo is admin-only. A panel tap can never reverse a completion.";
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; snapshot: AdminSnapshot };
function isOverdueOpen(instance: SnapshotInstance): boolean {
return instance.overdue && instance.state === "open";
}
function ScreenHeader({ sub, onSettings }: { sub: string; onSettings: () => void }) {
return (
<header className="today-header">
<div>
<h2 className="today-title">Today</h2>
<p className="today-sub">{sub}</p>
</div>
{/* Opens the Settings screen. Only its Children section exists so far.
TODO: horizon days, day rollover time and notification config are a
separate later task. */}
<button
type="button"
className="today-icon-button"
aria-label="Settings"
onClick={onSettings}
>
<SettingsGlyph />
</button>
</header>
);
}
function StatRow({ snapshot }: { snapshot: AdminSnapshot }) {
const done = snapshot.children.reduce((sum, c) => sum + c.completed_today, 0);
const remaining = snapshot.children.reduce((sum, c) => sum + c.remaining_today, 0);
const overdue = snapshot.children.reduce(
(sum, c) => sum + c.instances.filter(isOverdueOpen).length,
0,
);
const stats = [
{ key: "done", label: "Done", value: done },
{ key: "remaining", label: "Remaining", value: remaining },
{ key: "overdue", label: "Overdue", value: overdue },
];
return (
<div className="today-stats">
{stats.map((stat) => (
<div key={stat.key} className="today-stat" data-testid={`stat-${stat.key}`}>
<div className={`today-stat-value today-stat-value--${stat.key}`}>
{stat.value}
</div>
<div className="today-stat-label">{stat.label}</div>
</div>
))}
</div>
);
}
function ChildRow({ child }: { child: SnapshotChild }) {
const overdue = child.instances.filter(isOverdueOpen).length;
const allDone = child.due_today > 0 && child.completed_today === child.due_today;
let meta: string;
let metaClass = "today-child-meta";
if (!child.present) {
meta = child.next_present
? `Away · returns ${formatShortDate(child.next_present)}`
: "Away";
} else if (allDone) {
meta = `${child.completed_today} of ${child.due_today} · all done`;
metaClass += " today-child-meta--done";
} else {
meta = `${child.completed_today} of ${child.due_today} · ${overdue} overdue`;
}
const pct = Math.max(0, Math.min(100, child.completion_pct));
// ADMIN-SPEC §2.3 also asks for a lowercase `override` pill when an absence
// comes from a presence override. The snapshot carries no field saying
// where an absence came from, so the pill is omitted rather than guessed.
return (
<li
className={child.present ? "today-child" : "today-child today-child--away"}
data-testid={`child-${child.child_id}`}
>
<span className="today-avatar" data-testid="child-avatar" aria-hidden="true">
{child.child_name.charAt(0).toUpperCase()}
</span>
<div className="today-child-text">
<div className="today-child-name">{child.child_name}</div>
<div className={metaClass}>{meta}</div>
</div>
<div
className="today-progress"
role="progressbar"
aria-label={`${child.child_name} progress`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
>
<div className="today-progress-fill" style={{ width: `${pct}%` }} />
</div>
</li>
);
}
interface AttentionItem {
instance: SnapshotInstance;
childName: string;
}
function attentionItems(snapshot: AdminSnapshot): AttentionItem[] {
const all = snapshot.children.flatMap((child) =>
child.instances.map((instance) => ({ instance, childName: child.child_name })),
);
const overdue = all.filter((item) => isOverdueOpen(item.instance));
const completed = all
.filter((item) => item.instance.state === "completed" && item.instance.completed_at)
.sort((a, b) =>
(b.instance.completed_at ?? "").localeCompare(a.instance.completed_at ?? ""),
);
return [...overdue, ...completed];
}
interface UndoControl {
/** The instance whose Undo is in flight, or null. */
pendingId: number | null;
onUndo: (instanceId: number) => void;
}
function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }) {
const { instance, childName } = item;
const overdue = isOverdueOpen(instance);
const undoing = undo.pendingId === instance.id;
// TODO(e57facc2): wire "Mark done". The admin plane has no complete route
// yet, so that action is inert here.
return (
<li className="today-event" data-testid={`attention-${instance.id}`}>
<span
className={overdue ? "today-event-glyph today-event-glyph--overdue" : "today-event-glyph"}
>
{overdue ? <AlertCircleGlyph /> : <CheckCircleGlyph />}
</span>
<div className="today-event-text">
<div className="today-event-title">
{instance.title} · {childName}
</div>
{overdue ? (
<div className="today-event-meta today-event-meta--overdue">
{instance.due_time
? `Overdue since ${formatDueTime(instance.due_time)}`
: "Overdue"}
</div>
) : (
// The snapshot carries no completion actor, so "from panel" is not printed.
<div className="today-event-meta">
Completed {formatClockTime(instance.completed_at as string)}
</div>
)}
</div>
{overdue ? (
<button type="button" className="today-action today-action--done">
Mark done
</button>
) : (
<button
type="button"
className="today-action today-action--undo"
disabled={undo.pendingId !== null}
aria-busy={undoing}
onClick={() => undo.onUndo(instance.id)}
>
{undoing ? "Undoing…" : "Undo"}
</button>
)}
</li>
);
}
function PermissionNote() {
return (
<div className="today-note" data-testid="permission-note">
<span className="today-note-glyph">
<InfoGlyph />
</span>
<span>{PERMISSION_NOTE}</span>
</div>
);
}
export default function TodayTab() {
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
/** The next load is a refresh in place: keep the current view, no loading state. */
const silentNext = useRef(false);
const [undoingId, setUndoingId] = useState<number | null>(null);
const undoingRef = useRef(false);
const [undoError, setUndoError] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
function refresh() {
silentNext.current = true;
setAttempt((n) => n + 1);
}
// Any transition (completed, uncompleted, missed, day complete) changes the snapshot.
useTransitions(refresh);
useEffect(() => {
let cancelled = false;
const silent = silentNext.current;
silentNext.current = false;
if (!silent) setState({ kind: "loading" });
fetchSnapshot()
.then((snapshot) => {
if (!cancelled) setState({ kind: "ready", snapshot });
})
.catch((error: unknown) => {
if (cancelled) return;
const message =
error instanceof ApiForbiddenError
? error.message
: "Today's snapshot could not be loaded. Check your connection and try again.";
// A failed refresh keeps the snapshot already on screen.
setState((prev) => (silent && prev.kind === "ready" ? prev : { kind: "error", message }));
});
return () => {
cancelled = true;
};
}, [attempt]);
/** Reverse one completion, then refetch the snapshot. One request at a time. */
async function undo(instanceId: number) {
if (undoingRef.current) return;
undoingRef.current = true;
setUndoingId(instanceId);
setUndoError(null);
try {
await uncompleteInstance(instanceId);
refresh();
} catch (error: unknown) {
setUndoError(
error instanceof ApiForbiddenError
? error.message
: error instanceof ApiRequestError && error.detail
? `The completion could not be undone: ${error.detail}`
: "The completion could not be undone. Check your connection and try again.",
);
} finally {
undoingRef.current = false;
setUndoingId(null);
}
}
let sub = "";
let body;
if (state.kind === "loading") {
body = (
<div className="today-status" role="status" data-testid="today-loading">
Loading today…
</div>
);
} else if (state.kind === "error") {
body = (
<div className="today-status today-status--error" role="alert" data-testid="today-error">
<p>{state.message}</p>
<button type="button" className="today-retry" onClick={() => setAttempt((n) => n + 1)}>
Try again
</button>
</div>
);
} else {
const { snapshot } = state;
sub = `${formatLongDate(snapshot.today_iso)} · cycle day ${snapshot.cycle_day} of ${CYCLE_LENGTH_DAYS}`;
const attention = attentionItems(snapshot);
body = (
<>
<StatRow snapshot={snapshot} />
<ul className="today-card today-list" aria-label="Children">
{snapshot.children.map((child) => (
<ChildRow key={child.child_id} child={child} />
))}
</ul>
<h3 className="today-section-label">Needs attention</h3>
{attention.length > 0 ? (
<ul className="today-card today-list" aria-label="Needs attention">
{attention.map((item) => (
<AttentionRow
key={item.instance.id}
item={item}
undo={{ pendingId: undoingId, onUndo: (id) => void undo(id) }}
/>
))}
</ul>
) : (
<div className="today-card today-empty">Nothing needs attention.</div>
)}
<PermissionNote />
</>
);
}
return (
<div className="today-screen">
<ScreenHeader sub={sub} onSettings={() => setSettingsOpen(true)} />
<div
className="today-scroll"
data-testid="today-scroll"
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
>
{undoError !== null ? (
<div className="today-action-error" role="alert" data-testid="today-undo-error">
<span>{undoError}</span>
<button type="button" className="today-action-dismiss" onClick={() => setUndoError(null)}>
Dismiss
</button>
</div>
) : null}
{body}
</div>
{settingsOpen ? (
<SettingsScreen
onClose={() => {
setSettingsOpen(false);
// Child names and order feed the snapshot.
refresh();
}}
/>
) : null}
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Date/time formatting for the Today tab.
*
* Calendar dates ("YYYY-MM-DD") are parsed by their components and formatted
* in UTC, so the printed day never shifts with the viewer's offset — unlike
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
* day in negative-offset zones.
*/
const LOCALE = "en-US";
function calendarDate(iso: string): Date {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
if (!match) throw new Error(`Invalid calendar date: ${iso}`);
const [, year, month, day] = match;
return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
}
function format(date: Date, options: Intl.DateTimeFormatOptions): string {
// Newer ICU puts a narrow no-break space before AM/PM; normalise it.
return new Intl.DateTimeFormat(LOCALE, options)
.format(date)
.replace(/ /g, " ");
}
/** "Wednesday, September 23" */
export function formatLongDate(iso: string): string {
const date = calendarDate(iso);
const weekday = format(date, { weekday: "long", timeZone: "UTC" });
const monthDay = format(date, { month: "long", day: "numeric", timeZone: "UTC" });
return `${weekday}, ${monthDay}`;
}
/** "Sep 23" */
export function formatShortDate(iso: string): string {
return format(calendarDate(iso), { month: "short", day: "numeric", timeZone: "UTC" });
}
/** "09:00" -> "9:00 AM" (a wall-clock time, no zone conversion). */
export function formatDueTime(hhmm: string): string {
const match = /^(\d{2}):(\d{2})/.exec(hhmm);
if (!match) return hhmm;
const date = new Date(Date.UTC(2000, 0, 1, Number(match[1]), Number(match[2])));
return format(date, { hour: "numeric", minute: "2-digit", timeZone: "UTC" });
}
/** A UTC ISO timestamp shown as the viewer's local time, "7:04 AM". */
export function formatClockTime(isoTimestamp: string): string {
return format(new Date(isoTimestamp), { hour: "numeric", minute: "2-digit" });
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Inline stroke glyphs for the Today tab (Lucide-style 24px grid). The
* shipped Lucide subset is task b50b048f; these keep this screen CDN-free
* until then.
*/
import type { ReactNode } from "react";
function Glyph({ size, children }: { size: number; children: ReactNode }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
);
}
export function SettingsGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1 7 17M17 7l2.1-2.1" />
</Glyph>
);
}
export function InfoGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" />
</Glyph>
);
}
export function AlertCircleGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</Glyph>
);
}
export function CheckCircleGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</Glyph>
);
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"useDefineForClassFields": true
},
"include": ["src"]
}
+93
View File
@@ -0,0 +1,93 @@
/* Build-time service worker injection. After Vite writes the bundle, the
hashed asset URLs referenced by dist/index.html and a content-derived
revision are written into dist/sw.js, so the offline shell is complete right
after install and every release ships a new worker with fresh caches.
Build tooling only — never imported by the app bundle. */
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import type { Plugin } from "vite";
export const REVISION_MARKER = "__NQ_BUILD_REVISION__";
const PRECACHE_PLACEHOLDER = "const PRECACHE_ASSETS = [];";
// Unhashed files sw.js precaches by fixed URL, relative to the build output.
export const STATIC_PRECACHE_FILES = [
"manifest.webmanifest",
"icons/icon-192.png",
"icons/icon-512.png",
"icons/icon-maskable-512.png",
];
export interface PrecacheFile {
path: string;
bytes: string | Uint8Array;
}
// Every same-origin URL an index.html loads via <script src> or <link href>,
// normalised to an absolute path, in document order and without duplicates.
export function extractShellAssets(indexHtml: string): string[] {
const tags = indexHtml.match(/<(?:script|link)\b[^>]*>/gi) ?? [];
const urls = tags.flatMap((tag) => {
const attr = /\b(?:src|href)\s*=\s*["']([^"']+)["']/i.exec(tag);
if (!attr) return [];
const url = new URL(attr[1], "https://same-origin.invalid/");
return url.origin === "https://same-origin.invalid" ? [url.pathname] : [];
});
return [...new Set(urls)];
}
// Hash of every precached file, sorted by path; each entry is length-prefixed
// so no two different file sets hash the same concatenation.
export function computeRevision(files: PrecacheFile[]): string {
const hash = createHash("sha256");
for (const { path, bytes } of [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
const data = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : bytes;
hash.update(`${path}\0${data.byteLength}\0`).update(data);
}
return hash.digest("hex").slice(0, 12);
}
// Returns swSource with the precache list and the build revision filled in.
// The revision covers index.html (and so the hashed bundles it names), the
// sw.js source and the bytes of every unhashed static file it precaches.
export function injectServiceWorker(
indexHtml: string,
swSource: string,
staticFiles: PrecacheFile[],
): string {
if (!swSource.includes(PRECACHE_PLACEHOLDER) || !swSource.includes(REVISION_MARKER)) {
throw new Error("sw.js is missing the PRECACHE_ASSETS placeholder or revision marker");
}
const assets = extractShellAssets(indexHtml);
const revision = computeRevision([
{ path: "index.html", bytes: indexHtml },
{ path: "sw.js", bytes: swSource },
...staticFiles,
]);
return swSource
.replace(PRECACHE_PLACEHOLDER, `const PRECACHE_ASSETS = ${JSON.stringify(assets)};`)
.replaceAll(REVISION_MARKER, revision);
}
export function serviceWorkerInjectPlugin(): Plugin {
let outDir = "";
return {
name: "nestquest-sw-inject",
apply: "build",
configResolved(config) {
outDir = resolve(config.root, config.build.outDir);
},
writeBundle() {
const swPath = resolve(outDir, "sw.js");
const indexHtml = readFileSync(resolve(outDir, "index.html"), "utf8");
const staticFiles = STATIC_PRECACHE_FILES.map((path) => ({
path,
bytes: readFileSync(resolve(outDir, path)),
}));
writeFileSync(
swPath,
injectServiceWorker(indexHtml, readFileSync(swPath, "utf8"), staticFiles),
);
},
};
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { serviceWorkerInjectPlugin } from "./vite-sw-plugin";
export default defineConfig({
plugins: [react(), serviceWorkerInjectPlugin()],
test: {
environment: "jsdom",
},
});
+83
View File
@@ -0,0 +1,83 @@
# NestQuest API
Standalone FastAPI service owning the NestQuest database (Feature 16).
Served with ``uvicorn --factory api.app:create_app``; configuration is
environment-supplied (``api/config.py``) and every variable is required.
## Runtime dependencies
``api/requirements.txt`` is the API deployable's OWN dependency
manifest (fastapi, uvicorn, pyjwt[crypto], httpx) — deliberately kept
out of the integration's ``pyproject.toml`` dev group, which exists only
so the monorepo test venv can run the API tests.
## Container deployment (scaffold)
The image is built from the repository ROOT context (the API loads the
bundled domain core from ``custom_components/nestquest/core/`` by file
location, so both trees must be in the build context):
docker build -t nestquest-api:latest .
Run it with compose — the SQLite file lives on the named volume
``nestquest-db`` so recreating the container keeps household data:
cp deploy/.env.example deploy/.env # then fill in real values
docker compose -f deploy/compose.yaml up -d --build
``deploy/.env`` is git-ignored and supplies the secrets (panel service
token, Authentik OIDC settings); ``NESTQUEST_DB_PATH`` is fixed to
``/var/lib/nestquest/nestquest.db`` by compose.
The published port binds ``NESTQUEST_BIND_ADDRESS`` from
``deploy/.env`` and defaults to ``127.0.0.1`` (localhost only). In
production TLS is terminated by the shared Traefik reverse proxy,
which serves ``https://nestquest.cubecraftlabs.com`` (Let's Encrypt,
auto-renewed, HTTP redirected to HTTPS) and forwards to the API box
on port 8080 — so on that box set ``NESTQUEST_BIND_ADDRESS`` to its
LAN address (``10.60.1.14``). Traefik only allows
``/api/v1/panel`` from the Home Assistant host. The full deployment,
restore and verification procedure is in ``deploy/RUNBOOK.md``.
## Continuous backup (Litestream)
Compose also runs a ``litestream`` sidecar
(``litestream/litestream:0.5.17``, config ``deploy/litestream.yml``).
It mounts the same ``nestquest-db`` volume and continuously replicates
``/var/lib/nestquest/nestquest.db`` over SFTP to the Synology NAS
``CubeCraft-SRVR1`` (``joshua@10.60.1.50``), directory
``/volume1/homes/Joshua/nestquest-backups`` (``/homes/Joshua/...`` in
the NAS's chrooted SFTP view) — so the backup is outside the Docker
host and its volume. A snapshot is taken daily and a week of
snapshots plus incremental changes is retained; the NAS host key is
pinned in the config.
The SFTP login uses a dedicated keypair that lives only on the API
box and is git-ignored. Create it once, before ``up``:
ssh-keygen -t ed25519 -N "" -C nestquest-litestream -f deploy/litestream_key
then append ``restrict `` + the contents of ``deploy/litestream_key.pub``
to ``joshua@10.60.1.50:~/.ssh/authorized_keys``. Compose mounts the
private key read-only at ``/etc/litestream/sftp_key``. The sidecar runs
as uid/gid 999 (the API's ``nestquest`` user), so give it the key:
sudo chown 999:999 deploy/litestream_key && chmod 600 deploy/litestream_key
The sidecar needs WRITE access to the database volume: Litestream adds
its own ``_litestream_seq``/``_litestream_lock`` tables to the database,
writes the ``-wal``/``-shm`` files and checkpoints the WAL, and fails
with "unable to open database file" on a read-only mount. That access
is contained rather than removed — the sidecar runs as the non-root
volume owner with a read-only root filesystem, ``no-new-privileges``
and all Linux capabilities dropped.
Check replication with
``docker compose -f deploy/compose.yaml logs litestream`` or
``docker compose -f deploy/compose.yaml exec litestream litestream ltx /var/lib/nestquest/nestquest.db``.
To restore (high level): stop the ``api`` service, run
``litestream restore -config /etc/litestream.yml -o <new path>
/var/lib/nestquest/nestquest.db`` from a litestream container with the
same mounts, verify the restored file, move it into place on the
volume (removing any stale ``-wal``/``-shm`` files), and start ``api``
again.
+8 -3
View File
@@ -152,6 +152,10 @@ core_quest_definitions = importlib.import_module(
f"{_CORE_MODULE_NAME}.quest_definitions"
)
#: The icon-key module (``core.icons.normalize_icon_for_read``) the
#: admin plane's quest-definition responses normalize ``icon`` through.
core_icons = importlib.import_module(f"{_CORE_MODULE_NAME}.icons")
#: The recurrence model (``core.recurrence.ScheduleRule`` and
#: ``RuleValidationError``) the quest-definition routes build request
#: rules with — the model validates at construction, the routes never
@@ -159,12 +163,12 @@ core_quest_definitions = importlib.import_module(
core_recurrence = importlib.import_module(f"{_CORE_MODULE_NAME}.recurrence")
#: The presence model (``core.presence.PresenceEngine``,
#: ``PresenceSchedule``, ``PresenceOverride``) the admin plane's
#: ``PresencePattern``, ``PresenceOverride``) the admin plane's
#: presence routes serialize and the tests resolve presence with.
core_presence = importlib.import_module(f"{_CORE_MODULE_NAME}.presence")
#: The presence-write business layer
#: (``core.presence_management.set_presence_schedule`` et al.) the
#: (``core.presence_management.create_presence_pattern`` et al.) the
#: admin plane's presence routes call — the ONE implementation the HA
#: services share.
core_presence_management = importlib.import_module(
@@ -172,7 +176,7 @@ core_presence_management = importlib.import_module(
)
#: The typed presence DAOs (``core.dao_presence``) the admin-plane tests
#: read stored schedules and overrides back through.
#: read stored patterns and overrides back through.
core_dao_presence = importlib.import_module(
f"{_CORE_MODULE_NAME}.dao_presence"
)
@@ -215,6 +219,7 @@ __all__ = [
"core_db",
"core_events",
"core_history",
"core_icons",
"core_materialize",
"core_migrations",
"core_presence",
+38
View File
@@ -0,0 +1,38 @@
# Runtime dependency manifest for the NestQuest API deployable.
#
# The monorepo's pyproject.toml carries fastapi/uvicorn/pyjwt/httpx in
# the INTEGRATION's dev group only (so the test venv can exercise the
# API); they are deliberately NOT integration runtime dependencies.
# Per that manifest's own comment, the API deployable declares its own
# runtime dependencies HERE — this file is what the container image
# installs (see the Dockerfile at the repository root).
#
# EXACT pins: the versions installed in the deployed image
# (nestquest-api:latest on the API box, read with importlib.metadata),
# so a rebuild resolves the same packages. The four direct
# dependencies come first; the rest is their resolved transitive
# closure, pinned too so nothing floats. The dev group's floors in
# pyproject.toml are unchanged. Bump deliberately, together with the
# Dockerfile's digest-pinned base image.
fastapi==0.141.1
uvicorn==0.53.0
pyjwt[crypto]==2.15.0
httpx==0.28.1
# Transitive dependencies of the above.
annotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.15.1
certifi==2026.7.22
cffi==2.1.1
click==8.5.0
cryptography==50.0.1
h11==0.16.0
httpcore==1.0.9
idna==3.20
pycparser==3.0
pydantic==2.13.5
pydantic_core==2.46.5
starlette==1.7.0
typing-inspection==0.4.4
typing_extensions==4.16.0
+747 -89
View File
File diff suppressed because it is too large Load Diff
+28 -36
View File
@@ -8,9 +8,9 @@ this router inherits the check).
The snapshot route is a thin adapter over the bundled core: it performs
NO business logic. It reads the live database off ``app.state.db``,
resolves ONE timezone-aware ``now`` (the API host's local time — the
API is the household's "local" host now that the domain no longer lives
inside Home Assistant), calls the pure
resolves ONE timezone-aware ``now`` (the host clock re-expressed in the
stored household ``timezone`` setting — the API host may run UTC while
the household does not), calls the pure
:func:`nestquest_core.snapshot.build_snapshot`, shapes each child's
instances through :func:`nestquest_core.snapshot.instance_payload` with
``include_missed=False`` (D-009: missed instances are OMITTED from the
@@ -53,9 +53,6 @@ from __future__ import annotations
import datetime
import importlib
import json
from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
@@ -66,10 +63,11 @@ from api.dependencies import require_panel_token
from api.nestquest_core import (
core_completion,
core_events,
core_settings,
core_settings_store,
core_snapshot,
)
from api import transitions as api_transitions
from api.sse import transition_event_stream
#: The instance DAO, imported on the app's core copy for the
#: child-mismatch guard on the complete route.
@@ -180,6 +178,20 @@ def _local_now() -> datetime.datetime:
return datetime.datetime.now().astimezone()
async def _household_clock(
database: object,
) -> tuple[object, datetime.datetime]:
"""Return the stored settings and ONE household-local clock read.
The API host may run UTC while the household does not, so the one
:func:`_local_now` read is re-expressed in the stored settings'
``timezone`` (:meth:`NestQuestSettings.household_now`); an unset
zone keeps the host's local time.
"""
settings = await core_settings_store.load_settings(database)
return settings, settings.household_now(_local_now())
@router.get(
"/snapshot",
summary="Today's household snapshot",
@@ -198,8 +210,9 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
:class:`PanelSnapshotResponse`.
"""
state: DatabaseState = request.app.state.db
settings, now = await _household_clock(state.database)
snapshot = await core_snapshot.build_snapshot(
state.database, core_settings.NestQuestSettings(), _local_now()
state.database, settings, now
)
return PanelSnapshotResponse(
today_iso=snapshot.today_iso,
@@ -284,7 +297,7 @@ async def panel_complete_instance(
detail="Quest instance not found for this child",
)
now = _local_now()
_settings, now = await _household_clock(database)
try:
result = await core_completion.complete_instance(
database,
@@ -328,23 +341,6 @@ async def panel_complete_instance(
return {"status": str(result)}
async def _sse_stream(request: Request) -> AsyncIterator[str]:
"""Yield the SSE frames for every published transition event.
One subscriber of the app's single publisher (resolved off
``app.state.publisher``); each event becomes one
``event: <type>\\ndata: <json>\\n\\n`` frame. Breaking out of the
publisher's subscription (on client disconnect) tears the
subscription down cleanly — no queue is leaked.
"""
publisher = request.app.state.publisher
async for event_type, payload in publisher.subscribe():
yield (
f"event: {event_type}\n"
f"data: {json.dumps(payload)}\n\n"
)
@router.get(
"/events",
summary="Stream panel transition events (SSE)",
@@ -357,17 +353,13 @@ async def panel_events(request: Request) -> StreamingResponse:
:func:`~api.dependencies.require_panel_token` dependency — the ONE
token check; this handler performs NO auth of its own).
Returns a ``text/event-stream`` response whose body is produced by
:func:`_sse_stream` off the app's single in-process publisher
(``app.state.publisher``, installed in the lifespan). The stream
ends when the client disconnects, at which point the subscription
is torn down (its queue removed) with nothing leaked.
Returns the shared :func:`api.sse.transition_event_stream` response
off the app's single in-process publisher (``app.state.publisher``,
installed in the lifespan). The stream ends when the client
disconnects, at which point the subscription is torn down (its
queue removed) with nothing leaked.
"""
return StreamingResponse(
_sse_stream(request),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache"},
)
return transition_event_stream(request)
__all__ = ["router"]
+57 -13
View File
@@ -8,7 +8,8 @@ configured ``day_rollover_time`` — read fresh from the settings store
at the top of EVERY cycle, so an admin's settings change applies on
the next scheduling decision without a restart — then runs the
HA-free core's :func:`nestquest_core.sweep.run_missed_sweep` for the
API host's local date and publishes each returned
household-local date (the stored ``timezone`` setting; the API host's
local date when unset) and publishes each returned
``nestquest_quest_missed`` event on the app's ONE transition publisher
(the same SSE stream every route publishes through).
@@ -43,19 +44,56 @@ LOGGER = logging.getLogger(__name__)
_RETRY_BACKOFF_SECONDS = 60.0
def _delay_seconds(target: datetime.time, now: datetime.datetime) -> float:
"""Seconds from ``now`` until the next occurrence of ``target``.
#: Upper bound on the minute-by-minute search past a nonexistent wall
#: time; real DST gaps are an hour, the largest ever skipped a day.
_MAX_GAP_MINUTES = 2 * 24 * 60
``target`` is a plain ``HH:MM`` time of day: the next wall-clock
occurrence strictly AFTER ``now`` (an equal time schedules for
tomorrow, so the just-run sweep is never instantly re-run).
def _resolve_wall_time(
day: datetime.date, target: datetime.time, zone: datetime.tzinfo
) -> datetime.datetime:
"""The aware instant ``target`` (``HH:MM``) names on ``day`` in ``zone``.
A nonexistent wall time (inside a spring-forward gap) resolves to
the first valid wall minute at/after it — the gap's end, e.g. 02:30
on a 02:00→03:00 day becomes 03:00. An ambiguous wall time (the
repeated fall-back hour) resolves to its FIRST occurrence
(``fold=0``).
"""
scheduled = now.replace(
hour=target.hour, minute=target.minute, second=0, microsecond=0
wall = datetime.datetime.combine(
day, datetime.time(target.hour, target.minute)
)
if scheduled <= now:
scheduled += datetime.timedelta(days=1)
return (scheduled - now).total_seconds()
for _ in range(_MAX_GAP_MINUTES):
candidate = wall.replace(tzinfo=zone)
round_trip = candidate.astimezone(datetime.timezone.utc).astimezone(
zone
)
if round_trip.replace(tzinfo=None) == wall:
return candidate
wall += datetime.timedelta(minutes=1)
raise ValueError(f"no valid wall time at/after {target} on {day}")
def _delay_seconds(target: datetime.time, now: datetime.datetime) -> float:
"""Real seconds from ``now`` until the next occurrence of ``target``.
``target`` is a plain ``HH:MM`` household time of day, resolved in
``now``'s zone by :func:`_resolve_wall_time` (nonexistent → first
valid instant after the gap; ambiguous → first occurrence). The
result is the next such instant strictly AFTER ``now`` (an equal
time schedules for tomorrow, so the just-run sweep is never
instantly re-run). Both instants are compared and subtracted in
UTC: same-``tzinfo`` aware arithmetic is wall-clock arithmetic and
would be an hour off across a DST transition.
"""
now_utc = now.astimezone(datetime.timezone.utc)
day = now.date()
while True:
scheduled = _resolve_wall_time(day, target, now.tzinfo)
scheduled_utc = scheduled.astimezone(datetime.timezone.utc)
if scheduled_utc > now_utc:
return (scheduled_utc - now_utc).total_seconds()
day += datetime.timedelta(days=1)
class MissedSweepScheduler:
@@ -130,7 +168,11 @@ class MissedSweepScheduler:
)
await self._sleep(_RETRY_BACKOFF_SECONDS)
continue
delay = _delay_seconds(rollover, self._clock())
# The host may run UTC: the rollover is a household wall-clock
# time, so the clock read is re-expressed in the stored zone.
delay = _delay_seconds(
rollover, settings.household_now(self._clock())
)
LOGGER.info(
"NestQuest missed sweep scheduled for %s (%.0fs from now)",
settings.day_rollover_time,
@@ -138,7 +180,9 @@ class MissedSweepScheduler:
)
await self._sleep(delay)
try:
await self._run_once(self._clock().date())
await self._run_once(
settings.household_now(self._clock()).date()
)
except Exception:
# Not silent either: the sweep error is logged and the
# loop backs off before the next cycle, whose watermark
+50
View File
@@ -0,0 +1,50 @@
"""The ONE Server-Sent Events response helper for the API service.
Both SSE planes — the panel plane (``/api/v1/panel/events``, panel
service token, LAN-only) and the admin plane (``/api/v1/admin/events``,
Authentik admin JWT) — stream the SAME transitions off the SAME
in-process publisher (``app.state.publisher``, see :mod:`api.events`)
with the SAME framing and teardown, so the stream lives here once and
each plane's route only supplies its own router-level auth.
"""
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from fastapi import Request
from fastapi.responses import StreamingResponse
__all__ = ["transition_event_stream"]
async def _sse_stream(request: Request) -> AsyncIterator[str]:
"""Yield the SSE frames for every published transition event.
One subscriber of the app's single publisher (resolved off
``app.state.publisher``); each event becomes one
``event: <type>\\ndata: <json>\\n\\n`` frame. Breaking out of the
publisher's subscription (on client disconnect) tears the
subscription down cleanly — no queue is leaked.
"""
publisher = request.app.state.publisher
async for event_type, payload in publisher.subscribe():
yield (
f"event: {event_type}\n"
f"data: {json.dumps(payload)}\n\n"
)
def transition_event_stream(request: Request) -> StreamingResponse:
"""A ``text/event-stream`` response over the app's transition events.
Performs NO auth of its own: the calling route's router-level
dependency is the ONE check for its plane. The stream ends when
the client disconnects, at which point the subscription is torn
down (its queue removed) with nothing leaked.
"""
return StreamingResponse(
_sse_stream(request),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache"},
)
+135 -482
View File
@@ -1,375 +1,78 @@
"""The NestQuest integration."""
"""The NestQuest integration (Feature 18: a thin client of the NestQuest API service).
The integration owns NO database: the API service (Feature 16) owns the
SQLite file, the migrations, and every business rule. Setup builds the
entry's ONE shared API client, the coordinator that polls the panel
snapshot through it, and the SSE subscription that re-fires the API's
transition frames on the HA bus, registers the single
``nestquest.complete_quest`` proxy service, registers the zero-config
panel dashboard (Feature 20), and forwards the sensor and
binary_sensor platforms.
"""
from __future__ import annotations
import asyncio
import datetime
import inspect
import sqlite3
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.event import async_track_time_change
from .const import (
CONF_ADMIN_USER_IDS,
CONF_SNAPSHOT_STALENESS,
CONF_UPDATE_INTERVAL,
DEFAULT_SNAPSHOT_STALENESS,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
LOGGER,
PLATFORMS,
)
from .core.settings import NestQuestSettings
from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
from .dashboard import async_register_dashboard
from .frontend import async_register_frontend
from .coordinator import NestQuestCoordinator
from .db import NestQuestDatabase, make_database
from .materialize import materialize as _materialize_run
from .migrations import apply_migrations
from .services import async_deregister_services, async_register_services
from .store import async_get_db_path
from .admin_allowlist import seed_setup_admin
from .sweep import run_missed_sweep
from .sse import NestQuestEventStream
_LOGGER = LOGGER
#: SQLite reports "file is not a database" for corruption in every
#: sqlite3 version this integration targets; the version-specific
#: "encrypted or malformed" spelling (3.41+) maps to the same failure.
_CORRUPTION_MARKERS = (
"file is not a database",
"database disk image is malformed",
"encrypted or malformed",
"is not a database",
)
#: Primary sqlite error codes that mean "the file is corrupt" (SQLite
#: result codes, https://www.sqlite.org/c3ref/c_abort.html). Extended
#: codes carry the primary code in their low byte, so the match is on
#: errcode & 0xff.
_CORRUPT_PRIMARY_CODES = frozenset({11, 26}) # SQLITE_CORRUPT, SQLITE_NOTADB
def _is_corruption_error(error: BaseException) -> bool:
"""Return True when ``error`` indicates a corrupt database file.
Classification order: sqlite_errorcode (Python 3.11+) is primary
and locale-independent — SQLITE_CORRUPT and SQLITE_NOTADB, matched
on the primary byte so extended codes (SQLITE_CORRUPT_VTAB etc.)
classify correctly too. Message matching is the fallback for
older Pythons: the corruption spellings are the documented English
texts; a non-English locale could evade them, but the code check
already covers every locale on supported Pythons.
"""
if not isinstance(error, sqlite3.DatabaseError):
return False
errorcode = getattr(error, "sqlite_errorcode", None)
if errorcode is not None and (errorcode & 0xFF) in _CORRUPT_PRIMARY_CODES:
return True
message = str(error).lower()
return any(marker in message for marker in _CORRUPTION_MARKERS)
async def _preflight_existing_file(
hass: HomeAssistant, db_path: Path
) -> None:
"""Detect corruption in an EXISTING file before it is opened read-write.
Opening read-write persists WAL mode — for a rollback-journal file
that rewrites the header byte 18/19 and can create -wal/-shm
sidecars — which would modify a corrupt file before detection and
break the leave-untouched guarantee. This preflight therefore
validates the WHOLE file with a READ-ONLY connection (mode=ro +
immutable=1 URI, PRAGMA integrity_check, run on the executor like
every other DB call): corruption anywhere fails there, before
anything is written. ``immutable=1`` is SQLite's documented
guarantee that the connection performs NO writes and creates NO
sidecars — even a WAL-header file gets its -wal/-shm reads served
without creating the shared-memory file. Missing/empty files skip
the preflight (they are created fresh by the real open). All
filesystem stat/URI work runs inside the executor job with the
probe so the event loop never touches disk.
"""
def _probe() -> None:
# The file-state check and URI construction live inside the
# executor job: exists/stat/resolve are disk I/O.
if not db_path.exists():
return
# A zero-byte file is neither missing nor a valid database: it
# is a corrupt (truncated-to-nothing) existing file, likely an
# interrupted first write. The leave-untouched contract applies
# to it too: refuse rather than silently initialize over it.
if db_path.stat().st_size == 0:
raise sqlite3.DatabaseError(
"existing database file is empty (zero bytes): file is "
"not a database"
)
# Properly escape the path: '#'/'?'/'%' in a directory or file
# name would otherwise be parsed as URI fragments/parameters/
# escapes. immutable=1: SQLite guarantees no writes and no
# sidecar creation from this connection.
uri = db_path.resolve().as_uri() + "?mode=ro&immutable=1"
conn = sqlite3.connect(uri, uri=True)
try:
row = conn.execute("PRAGMA integrity_check").fetchone()
finally:
conn.close()
verdict = row[0] if row else "missing"
if verdict != "ok":
# A non-ok integrity verdict IS corruption: raise with the
# verdict text (which matches the corruption markers) and
# the NOTADB code so classification is code-based.
raise sqlite3.DatabaseError(
f"integrity_check verdict {verdict!r} on read-only "
f"probe: file is not a database"
)
try:
# Every DB-touching call goes through the executor (the mock
# harness's async_add_executor_job is awaitable too).
await hass.async_add_executor_job(_probe)
except sqlite3.DatabaseError as err:
# Non-ok integrity verdicts are corruption, full stop: there is
# no benign reading of a failed whole-file integrity check, so
# ANY DatabaseError out of the probe that carries a corruption
# marker or code propagates as corruption. Only exotic probe
# failures that classify as non-corruption (e.g. a transient
# 'database is locked' on the read) are downgraded to a debug
# log, letting the real open path surface them.
if _is_corruption_error(err):
raise
raise sqlite3.DatabaseError(
f"read-only corruption probe of {db_path} failed: {err}"
) from err
async def _async_open_database(
hass: HomeAssistant, db_path: Path
) -> NestQuestDatabase:
"""Open the NestQuest database, mapping failure modes for setup.
Three startup cases (Feature 02 done-condition):
- file missing: SQLite creates it fresh on open — no special path,
migrations stamp version 1 on the next step.
- file present and valid: opens normally.
- file present but corrupt: log an ERROR with the path, close
anything opened, and raise ConfigEntryNotReady so HA retries —
the file is left byte-for-byte untouched so the owner can
restore a backup. No code path here deletes or overwrites an
existing database file.
Corruption can surface at EITHER stage: a truncated header fails
in the read-only preflight (before the file is ever opened
read-write), while damaged later pages fail during migrations —
the preflight guarantees no WAL sidecar or header write happens
on a corrupt file before it is detected. Both are classified the
same way.
"""
try:
await _preflight_existing_file(hass, db_path)
database = await make_database(hass).open(db_path)
except asyncio.CancelledError:
raise
except sqlite3.DatabaseError as err:
if _is_corruption_error(err):
_LOGGER.error(
"NestQuest database at %s is corrupt: %s. Refusing to "
"start with a corrupt database; the file was left "
"untouched so a backup can be restored. Home Assistant "
"will retry setup.",
db_path,
err,
)
raise ConfigEntryNotReady(
f"NestQuest database corrupt: {db_path}"
) from err
# open() failed for a non-corruption reason (locked file,
# permission problem): retryable, but nothing was opened.
_LOGGER.error(
"NestQuest database at %s failed to open: %s. Home "
"Assistant will retry setup.",
db_path,
err,
)
raise ConfigEntryNotReady(
f"NestQuest database unavailable: {db_path}"
) from err
try:
await apply_migrations(database)
except (sqlite3.DatabaseError, RuntimeError) as err:
await database.close()
if _is_corruption_error(err):
_LOGGER.error(
"NestQuest database at %s is corrupt: %s. Refusing to "
"start with a corrupt database; the file was left "
"untouched so a backup can be restored. Home Assistant "
"will retry setup.",
db_path,
err,
)
raise ConfigEntryNotReady(
f"NestQuest database corrupt: {db_path}"
) from err
# A non-corruption failure (e.g. a locked file, a future schema
# version) is also a retryable setup problem, but without the
# corruption framing.
_LOGGER.error(
"NestQuest database at %s failed to open: %s. Home "
"Assistant will retry setup.",
db_path,
err,
)
raise ConfigEntryNotReady(
f"NestQuest database unavailable: {db_path}"
) from err
except asyncio.CancelledError:
await database.close()
raise
return database
@dataclass
class NestQuestRuntimeData:
"""Runtime data stored on a NestQuest config entry."""
entry_id: str
settings: NestQuestSettings
database: NestQuestDatabase
remove_update_listener: Callable[[], Any]
remove_time_change_listener: Callable[[], Any]
coordinator: Any = None
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
"""Return every Home Assistant owner-account user id.
The last-resort admin seed: when no persisted allowlist copy and no
flow-context user survive to first setup, the HA owner accounts are
seeded so the household is never left with zero admins (an empty
allowlist fails closed, which would lock the household out of its
own integration). Owner accounts can already do everything in HA,
so granting NestQuest admin is not a privilege escalation.
"""
auth = getattr(hass, "auth", None)
if auth is None:
return []
users = await auth.async_get_users()
owners = [
user.id
for user in users
if getattr(user, "is_owner", False) and getattr(user, "id", None)
]
return owners
async def _run_horizon_materialization(
hass: HomeAssistant,
database: NestQuestDatabase,
settings: NestQuestSettings,
) -> None:
"""Materialize the rolling horizon ``[today, today + horizon_days]``.
"today" is computed in HA local time (``hass.config.time_zone``) rather
than the system clock, so the horizon tracks the household's own day.
The walk itself runs through :func:`~.materialize.materialize`, whose DB
access is executor-only.
This is the ONE generation path: the startup backfill, the daily rollover
listener and the ``regenerate`` service all funnel through it, so there is
no duplicated materialization logic. ``settings`` (REQUIRED — there is no
silent default; every caller already passes the entry's validated
:class:`NestQuestSettings`) carries the configured ``horizon_days``. The
window itself comes from :meth:`settings.horizon_window` so the helper is
real and shared (not recomputed inline here).
"""
time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
start, end = settings.horizon_window(today)
await _materialize_run(database, start.isoformat(), end.isoformat(), today=today)
def _register_day_rollover_listener(
hass: HomeAssistant,
database: NestQuestDatabase,
settings: NestQuestSettings,
) -> Callable[[], Any]:
"""Register the daily rollover listener and return its remover.
Fires once per day at the configured local ``day_rollover_time``:
first re-materializes the rolling horizon
``[today, today + horizon_days]`` through the shared
:func:`_run_horizon_materialization` path (the entry's configured,
validated ``horizon_days`` carried on ``settings``), then runs the
Feature 11 missed sweep — announcing yesterday's still-open quests
as missed AFTER today's instances exist, watermark-guarded so a
re-fire of the listener the same night announces nothing twice.
"""
hour, minute = settings.day_rollover_hour_minute
async def _run_materialization(_now: datetime.datetime) -> None:
await _run_horizon_materialization(hass, database, settings)
await run_missed_sweep(hass, database)
return async_track_time_change(
hass, _run_materialization, hour=hour, minute=minute
)
event_stream: NestQuestEventStream | None = None
def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
"""Return a live runtime record with a connected database, or None.
"""Return a live runtime record with a coordinator, or None.
The domain-global services (e.g. ``regenerate``) have no database of
their own; they resolve whichever config entry's runtime data is
currently live at call time, so none of them can hold a stale handle
to a closed connection after another entry unloads. The record also
carries that entry's validated ``settings`` (e.g. the configured
horizon) so service handlers read horizon days off it rather than
re-reading HA config.
The domain-global service (the panel completion proxy) has no
database of its own; it resolves whichever config entry's runtime
data is currently live at call time, so it cannot hold a stale
handle to an unloaded entry's coordinator.
"""
for runtime_data in hass.data.get(DOMAIN, {}).values():
database = getattr(runtime_data, "database", None)
if database is not None and database.connected:
if getattr(runtime_data, "coordinator", None) is not None:
return runtime_data
return None
def _make_regenerate_handler(hass: HomeAssistant):
"""Return the Feature 07 ``regenerate`` handler closed over ``hass``."""
async def _regenerate(_call: Any) -> None:
runtime_data = _find_live_runtime_data(hass)
if runtime_data is None:
_LOGGER.warning(
"NestQuest regenerate requested but no config entry has a "
"live database; ignoring"
)
return
await _run_horizon_materialization(
hass, runtime_data.database, runtime_data.settings
)
return _regenerate
def _register_services(hass: HomeAssistant) -> None:
"""Register domain-global NestQuest services once.
"""Register the domain-global NestQuest service once.
Services are DOMAIN-global — registered exactly once (guarded by
``has_service`` on regenerate) and NOT bound to any config entry —
so each handler resolves the currently-live database at call time
The service is DOMAIN-global — registered exactly once (guarded by
``has_service``) and NOT bound to any config entry — so the panel
completion proxy resolves the currently-live runtime at call time
and a name collision or duplicate setup is impossible. Removal is
handled by the unload path only once the LAST entry is removed
(see :func:`_deregister_services`).
"""
async_register_services(
hass,
find_runtime=_find_live_runtime_data,
regenerate_handler=_make_regenerate_handler(hass),
)
async_register_services(hass, find_runtime=_find_live_runtime_data)
def _deregister_services(hass: HomeAssistant) -> None:
@@ -386,48 +89,14 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up NestQuest from a config entry."""
await async_register_frontend(hass)
# The zero-config panel dashboard (Feature 20): register the
# storage-mode strategy dashboard if no dashboard under the stable
# url_path exists yet. Unavailability of the Lovelace dashboards
# collection (YAML mode, lovelace not loaded) logs a warning and
# NEVER fails setup; an existing dashboard — ours or the user's —
# is left untouched, so a reload cannot duplicate it.
await async_register_dashboard(hass)
hass.data.setdefault(DOMAIN, {})
existing = hass.data[DOMAIN].get(entry.entry_id)
if existing is not None:
db = getattr(existing, "database", None)
if db is None or not db.connected:
# Reopen through the SAME safety path as first setup: a
# record without a live connection (a previous setup's
# close-on-failure, or a hand-replaced file) must get the
# missing/valid/corrupt classification too, never a raw
# sqlite3 error and never skipped migrations — and the
# same allowlist seeding, so a reopened fresh file is
# never ownerless.
db = await _async_open_database(
hass, await async_get_db_path(hass)
)
try:
stored_admin_ids = (
entry.options.get(CONF_ADMIN_USER_IDS)
or entry.data.get(CONF_ADMIN_USER_IDS)
)
context = getattr(entry, "context", None)
context_user_id = (
context.get("user_id")
if isinstance(context, dict)
else None
)
await seed_setup_admin(
db,
stored_admin_ids=stored_admin_ids,
context_user_id=context_user_id,
owner_ids=await _async_owner_user_ids(hass),
)
except BaseException:
# A seeding failure must not leak the just-opened
# connection: close it before the error propagates, so
# HA's setup retry starts from a clean handle (the
# first-setup path closes symmetrically).
await db.close()
raise
existing.database = db
entry.runtime_data = existing
return True
async def _async_update_listener(
listener_hass: HomeAssistant, listener_entry: ConfigEntry
@@ -435,109 +104,94 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Reload the entry when its options change."""
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
database = await _async_open_database(hass, await async_get_db_path(hass))
remove_update_listener: Callable[[], Any] | None = None
remove_time_change_listener: Callable[[], Any] | None = None
coordinator: Any = None
event_stream: NestQuestEventStream | None = None
try:
# Seed the admin allowlist on first setup so the owner is never
# locked out: an empty database takes the persisted admin copy
# — the options flow's saved list first (it is the current
# one), else the config flow's user from entry data — and only
# then the HA user still present on the entry context. No-op
# on a non-empty list (fail-closed narrowing is deliberate).
stored_admin_ids = (
entry.options.get(CONF_ADMIN_USER_IDS)
or entry.data.get(CONF_ADMIN_USER_IDS)
)
context = getattr(entry, "context", None)
context_user_id = (
context.get("user_id")
if isinstance(context, dict)
else None
)
owner_ids = await _async_owner_user_ids(hass)
await seed_setup_admin(
database,
stored_admin_ids=stored_admin_ids,
context_user_id=context_user_id,
owner_ids=owner_ids,
)
# Build the explicit settings object from entry.options ONCE: the
# horizon materialization and the daily-rollover listener both
# consume it, so core never reads HA config itself. A malformed
# stored option falls back PER FIELD (via from_options_resilient)
# so a single bad sibling option cannot reset the user's
# horizon_days or day_rollover_time — the two fields core
# consumes — to their defaults.
settings = NestQuestSettings.from_options_resilient(entry.options)
remove_update_listener = entry.add_update_listener(_async_update_listener)
remove_time_change_listener = _register_day_rollover_listener(
hass, database, settings
)
# Backfill any days the daily listener missed while the integration
# was off (or freshly installed): run the one idempotent walk over
# [today, today + horizon_days] once at setup, using the entry's
# configured horizon. This runs through the SAME path as the daily
# listener and regenerate service.
await _run_horizon_materialization(hass, database, settings)
# The missed sweep runs at startup too (Feature 11): if HA was
# down at the rollover time, the previous night's still-open
# quests are announced as missed now instead of waiting for
# the next midnight. Watermark-guarded, so this is a no-op
# when the night's sweep already ran.
await run_missed_sweep(hass, database)
_register_services(hass)
# The shared Feature 10 coordinator: one refresh cycle every
# entity reads from (CONF_UPDATE_INTERVAL seconds, default
# five minutes). Created AFTER services so a failure below
# unwinds them through the except path's listener handling.
# ONE API client for the entry, shared by the coordinator's
# snapshot polling, the SSE subscription, and the completion
# proxy (the client is stateless — one session, one connection
# pool). An entry with no panel token yet gets the
# typed-error stub (unavailable entities, never a setup crash).
api_client = coordinator_client_from_entry(hass, entry)
# The shared coordinator: one refresh cycle every entity reads
# from (CONF_UPDATE_INTERVAL seconds, default five minutes).
# It polls the API service's panel snapshot; a failed poll
# keeps the entities rendering the last-good snapshot within
# CONF_SNAPSHOT_STALENESS, unavailable past it.
coordinator = NestQuestCoordinator(
hass,
entry_id=entry.entry_id,
database=database,
settings=settings,
api_client=api_client,
update_interval_seconds=entry.options.get(
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
),
staleness_seconds=entry.options.get(
CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS
),
)
await coordinator.async_config_entry_first_refresh()
# The panel event subscription (the SSE task): a background
# task consumes the API service's transition-event stream and
# re-fires the four documented HA bus events. It reuses the
# coordinator's client, so it only starts when that client
# actually offers a stream — an unconfigured-token stub (or a
# test snapshot client without ``stream_events``) leaves it
# unstarted, and the typed-error path already covers
# availability for those entries.
if callable(getattr(api_client, "stream_events", None)):
event_stream = NestQuestEventStream(hass, api_client)
event_stream.start()
runtime_data = NestQuestRuntimeData(
entry_id=entry.entry_id,
settings=settings,
database=database,
remove_update_listener=remove_update_listener,
remove_time_change_listener=remove_time_change_listener,
coordinator=coordinator,
event_stream=event_stream,
)
# runtime_data is assigned BEFORE the platforms forward so each
# platform's async_setup_entry reads its coordinator off the
# entry (the HA runtime-data contract); a forward failure below
# unwinds it through the except path.
# The runtime record is registered BEFORE the first refresh:
# the service proxy resolves the entry's runtime state through
# hass.data, and it must find the live coordinator there —
# never a half-set-up entry. The platforms forward below reads
# the coordinator off the entry (the HA runtime-data contract);
# a forward failure below unwinds it through the except path.
hass.data[DOMAIN][entry.entry_id] = runtime_data
entry.runtime_data = runtime_data
# The first refresh polls the API snapshot; an API failure
# (unconfigured token, API service down) does NOT crash setup:
# the coordinator reports last_update_success=False, entities
# come up unavailable, and HA's standard retry cycle applies
# once the API is reachable. HA's real
# ``async_config_entry_first_refresh`` wraps ANY failed update
# pass in ConfigEntryNotReady — the original error survives
# only as ``__cause__``, never as the raised exception — so the
# catch keys on ConfigEntryNotReady, NOT on the typed API
# error. Everything else (a platform-forward failure,
# cancellation) still unwinds through the except block below.
try:
await coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady:
pass
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
except BaseException:
# A failure anywhere after the database is open must not leak the
# listeners already registered against that (now-closed) database:
# unwind EVERY remover we acquired before the failure, keeping the
# original error as the one raised. Removal is best-effort — a
# failing remover must not mask the setup error that triggered it.
for remove_listener in (
remove_time_change_listener,
remove_update_listener,
):
if remove_listener is None:
continue
# A failure anywhere after the listener was registered must not
# leak it (or the SSE task): unwind EVERYTHING acquired before
# the failure, keeping the original error as the one raised.
# Removal is best-effort — a failing step must not mask the
# setup error that triggered it.
if event_stream is not None:
try:
result = remove_listener()
if inspect.isawaitable(result):
await result
await event_stream.stop()
except BaseException:
pass
if remove_update_listener is not None:
try:
remove_update_listener()
except BaseException:
pass
# A coordinator created before the failure holds entity
# listeners pointed at the database being closed below —
# shut it down FIRST so no scheduled refresh can ever run
# against a closed connection.
# listeners pointed at the never-registered runtime — shut it
# down so no scheduled refresh outlives the failed setup.
if coordinator is not None:
try:
await coordinator.async_shutdown()
@@ -545,13 +199,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
pass
# A platform-forward failure reached this unwind AFTER
# runtime_data was assigned: clear it so no later unload path
# closes the already-closed database through the stale record.
# re-invokes the already-consumed update-listener remover
# through the stale record.
if getattr(entry, "runtime_data", None) is not None:
entry.runtime_data = None
await database.close()
# The runtime record was ALSO registered in hass.data before
# the first refresh: remove it here on ANY failure, or a later
# unload picks up the stale, already-torn-down record
# (hass.data wins over entry.runtime_data there) and
# re-invokes the already-consumed update-listener remover.
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
raise
assert remove_update_listener is not None
assert remove_time_change_listener is not None
# The runtime record was already registered before the first
# refresh; re-assert the (unchanged) record so the return path
# stays readable as one registration site.
hass.data[DOMAIN][entry.entry_id] = runtime_data
return True
@@ -566,58 +227,50 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if runtime_data is None:
runtime_data = getattr(entry, "runtime_data", None)
# Platforms unload FIRST (mirroring HA conventions): entity
# listeners must not outlive the database and coordinator the
# teardown below closes.
# listeners must not outlive the coordinator the teardown below
# shuts down.
platforms_unloaded = await hass.config_entries.async_unload_platforms(
entry, PLATFORMS
)
if platforms_unloaded is False and runtime_data is not None:
# Home Assistant KEPT one or more platforms loaded — their
# entities still live and must keep their database. Restore
# entities still live and must keep their coordinator. Restore
# the runtime record popped above and report failure so HA
# retries the unload later instead of leaving live entities
# backed by a closed database.
# backed by a torn-down coordinator.
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime_data
return False
unload_error: BaseException | None = None
if runtime_data is not None:
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
remove_time_change_listener = getattr(
runtime_data, "remove_time_change_listener", None
# The SSE subscription is stopped FIRST: its task must be gone
# (no frame can fire) before anything else is torn down.
event_stream = getattr(runtime_data, "event_stream", None)
if event_stream is not None:
stop_stream = getattr(event_stream, "stop", None)
if stop_stream is not None:
await stop_stream()
remove_update_listener = getattr(
runtime_data, "remove_update_listener", None
)
try:
for remove_listener in (
remove_update_listener,
remove_time_change_listener,
):
if remove_listener is None:
continue
try:
result = remove_listener()
if inspect.isawaitable(result):
await result
except BaseException as err:
# Cancel EVERY listener even when an earlier remover
# raises: a skipped remover would leave a daily
# callback firing against a closed database after
# unload. The first error is retained and re-raised
# only after both removers (and the DB close) ran.
if unload_error is None:
unload_error = err
if remove_update_listener is not None:
result = remove_update_listener()
if inspect.isawaitable(result):
await result
except BaseException as err:
# A failing remover must not skip the coordinator shutdown
# below; the first error is retained and re-raised only
# after the teardown ran.
if unload_error is None:
unload_error = err
finally:
# The coordinator's entity listeners must stop BEFORE the
# database closes: a scheduled refresh starting during the
# close await would touch a cleared connection. (The
# platform unload above already removed the entities; this
# clears the coordinator's own remaining listeners.)
# The coordinator's entity listeners must stop here: a
# scheduled refresh must never outlive the unload.
coordinator = getattr(runtime_data, "coordinator", None)
if coordinator is not None:
shutdown = getattr(coordinator, "async_shutdown", None)
if shutdown is not None:
await shutdown()
database = getattr(runtime_data, "database", None)
if database is not None:
await database.close()
# Domain-global services are torn down only once the FINAL entry
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
# entry is still loaded.
@@ -1,11 +0,0 @@
"""Re-export of :mod:`custom_components.nestquest.core.admin_allowlist` for the integration package.
The admin allowlist business layer lives in
:mod:`custom_components.nestquest.core.admin_allowlist` (extracted in
task 510c1f78); this module re-exports it so existing
``from .admin_allowlist import ...`` references inside the integration
keep working.
"""
from __future__ import annotations
from .core.admin_allowlist import * # noqa: F401,F403
+414
View File
@@ -0,0 +1,414 @@
"""Panel-plane API client for the NestQuest integration (Feature 18).
The integration talks to the NestQuest API service (Feature 16) over
HTTP instead of reading the database: this module is the ONE place that
speaks the API's panel plane — ``GET /api/v1/panel/snapshot`` (today's
household snapshot) and ``POST /api/v1/panel/instances/{id}/complete``
(completion as the tapped child profile) — authenticated with the
static panel service token as a Bearer credential on every request.
Transport-injected by construction: the constructor takes the base URL,
the panel token, and the HTTP session to use. The session speaks the
aiohttp ``ClientSession`` surface (``request(method, url, headers=...,
json=...)`` returning an async context manager whose response carries
``status`` and ``await text()``), so tests stub the transport with NO
network and NO aiohttp installed; production builds the client through
:func:`client_from_entry`, which hands in Home Assistant's shared
aiohttp session (``homeassistant.helpers.aiohttp_client
.async_get_clientsession``). The module itself imports NEITHER
homeassistant NOR aiohttp at runtime (aiohttp appears only behind
``TYPE_CHECKING`` for annotations), so the client is usable anywhere —
the HA coupling lives in the one production factory.
Every failure is typed: a transport failure (connection refused, DNS
failure, timeout — the exchange never produced a response) and every
non-2xx HTTP response raise :class:`NestQuestApiError`. The error
carries the HTTP status where there is one (so 401 and 404 are
distinguishable at the call site) and ``None`` when there is not; the
original transport error is chained as ``__cause__`` — nothing is
swallowed. There are NO retries and the client logs nothing: refresh
cadence is the coordinator's concern (the next task), and the panel
token is a secret that must never reach a log line or an error
message — the error texts name the method, path, and status only.
The panel plane's SSE event stream (``GET /api/v1/panel/events``) is
exposed by :meth:`NestQuestApiClient.stream_events` — an async
generator yielding ``(event_type, payload)`` per Server-Sent Events
frame (``event: <type>`` / ``data: <json>`` lines separated by a blank
line). Opening the stream gets the same one-timeout, typed-error
contract as every other call; reading it has NO body-wide timeout (a
healthy stream is long-lived — the subscription manager owns
reconnection and backoff).
"""
from __future__ import annotations
import asyncio
import inspect
import json
from collections.abc import AsyncIterator
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from .const import (
CONF_API_BASE_URL,
CONF_PANEL_TOKEN,
DEFAULT_API_BASE_URL,
DEFAULT_PANEL_TOKEN,
)
if TYPE_CHECKING:
import aiohttp
__all__ = [
"COMPLETE_INSTANCE_PATH",
"DEFAULT_TIMEOUT_SECONDS",
"EVENTS_PATH",
"NestQuestApiError",
"NestQuestApiClient",
"SNAPSHOT_PATH",
"client_from_entry",
"is_valid_base_url",
"resolve_api_config",
]
#: The panel plane's snapshot route (Feature 16).
SNAPSHOT_PATH = "/api/v1/panel/snapshot"
#: The panel plane's SSE event-stream route (Feature 16): each
#: transition arrives as an ``event: <type>`` / ``data: <json>`` frame.
EVENTS_PATH = "/api/v1/panel/events"
#: The panel plane's completion route template (Feature 16); the
#: ``{instance_id}`` placeholder is filled per call.
COMPLETE_INSTANCE_PATH = "/api/v1/panel/instances/{instance_id}/complete"
#: Request timeout, in seconds. One attempt, one timeout: a hung API
#: call surfaces as a typed error for the coordinator to report, never
#: a retry loop.
DEFAULT_TIMEOUT_SECONDS = 10.0
class NestQuestApiError(Exception):
"""A failed NestQuest API exchange, raised for every failure mode.
``status`` carries the HTTP status when a response arrived (a
non-2xx response — 401 for a bad token, 404 for an unknown
instance) and is ``None`` when the exchange failed at the transport
level (connection refused, DNS failure, timeout) — so callers
distinguish the two with ``is_transport_error``. The original
transport error, where there is one, is chained as ``__cause__``.
Messages carry the method, path, and status — never the request
headers, so the panel token can never leak through an error.
"""
def __init__(self, message: str, *, status: int | None = None) -> None:
super().__init__(message)
self.status = status
@property
def is_transport_error(self) -> bool:
"""True when the exchange failed before any response arrived."""
return self.status is None
def is_valid_base_url(value: object) -> bool:
"""Return True when ``value`` is a usable API base URL.
A full ``http://`` or ``https://`` URL with a host — the exact
shape the options flow validates (form ergonomics) and the client
constructor enforces (fail fast, never build requests against a
scheme-less or host-less string).
"""
if not isinstance(value, str):
return False
candidate = value.strip()
if not candidate:
return False
parsed = urlparse(candidate)
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
def resolve_api_config(entry: Any) -> tuple[str, str]:
"""Resolve ``(base_url, panel_token)`` off a config entry.
Precedence mirrors the options flow's stored-values resolution:
``entry.options`` first (the live options-flow copy), then
``entry.data`` (the config-flow-era copy), then the const defaults.
The token is a secret: callers must never log the returned pair.
"""
options = getattr(entry, "options", None) or {}
data = getattr(entry, "data", None) or {}
def _pick(key: str, default: str) -> str:
if key in options:
return options[key]
if key in data:
return data[key]
return default
return (
_pick(CONF_API_BASE_URL, DEFAULT_API_BASE_URL),
_pick(CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN),
)
def client_from_entry(hass: Any, entry: Any) -> NestQuestApiClient:
"""Build the production client for a config entry.
Reads the configured base URL and panel token off the entry
(:func:`resolve_api_config`) and hands the client Home Assistant's
SHARED aiohttp session (``homeassistant.helpers.aiohttp_client
.async_get_clientsession``) so panel calls ride HA's one connection
pool. The helper import is deferred into this function on purpose:
the test harness is mock-only and the module must import (and the
client must be stub-transport testable) without homeassistant or
aiohttp installed.
"""
from homeassistant.helpers.aiohttp_client import async_get_clientsession
base_url, panel_token = resolve_api_config(entry)
return NestQuestApiClient(
base_url, panel_token, async_get_clientsession(hass)
)
class NestQuestApiClient:
"""Async client for the NestQuest API service's panel plane.
ONE instance per config entry (the coordinator task wires it into
the refresh cycle). The client is deliberately thin: it adds the
Bearer credential and the base URL, enforces one timeout per
request, and turns every failure into :class:`NestQuestApiError` —
no business logic, no retries, no caching.
"""
def __init__(
self,
base_url: str,
panel_token: str,
session: aiohttp.ClientSession,
*,
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
) -> None:
"""Store the connection settings; the session is injected.
``session`` is an aiohttp ``ClientSession`` in production and a
stub transport in tests. ``base_url`` must be a full http(s)
URL and ``panel_token`` must be non-empty — a client built
without either can only fail later at request time with a
less-explicable error, so construction fails fast instead.
"""
if not is_valid_base_url(base_url):
raise ValueError(
f"base_url must be a full http(s) URL, got {base_url!r}"
)
if not isinstance(panel_token, str) or not panel_token.strip():
raise ValueError("panel_token must be a non-empty string")
if (
not isinstance(timeout_seconds, (int, float))
or isinstance(timeout_seconds, bool)
or timeout_seconds <= 0
):
raise ValueError(
f"timeout_seconds must be a positive number, got {timeout_seconds!r}"
)
self._base_url = base_url.strip().rstrip("/")
self._panel_token = panel_token
self._session = session
self._timeout_seconds = float(timeout_seconds)
async def get_snapshot(self) -> dict[str, Any]:
"""Fetch and parse today's household snapshot.
Returns the JSON the snapshot route returns (``today_iso``,
``cycle_day``, and the per-child list — see
``api/routes_panel.py`` for the documented shape). Raises
:class:`NestQuestApiError` on transport failure, timeout, or a
non-2xx response.
"""
return await self._request_json("GET", SNAPSHOT_PATH)
async def complete_instance(
self, instance_id: int, actor_child_id: int
) -> dict[str, Any]:
"""Complete one quest instance as the tapped child profile.
POSTs ``{"actor_child_id": actor_child_id}`` to the complete
route and returns the parsed response body (the route answers
``{"status": ...}`` both for a first completion and for an
idempotent repeat, so a retried panel tap succeeds). Raises
:class:`NestQuestApiError` on transport failure, timeout, or a
non-2xx response — 401 for a wrong token, 404 for an unknown
instance.
"""
path = COMPLETE_INSTANCE_PATH.format(instance_id=instance_id)
return await self._request_json(
"POST", path, json_body={"actor_child_id": actor_child_id}
)
async def stream_events(
self,
) -> AsyncIterator[tuple[str, dict[str, Any]]]:
"""Yield ``(event_type, payload)`` for every panel SSE frame.
Opens ``GET /api/v1/panel/events`` with the Bearer credential
and parses the Server-Sent Events framing the API service
emits: ``event: <type>`` and ``data: <json>`` lines closed by a
blank line (one frame per transition event; a frame's ``data``
may span several ``data:`` lines, joined with newlines — the
standard SSE rule). Line splits across chunk boundaries are
buffered, so frames are parsed correctly whatever chunking the
transport produces.
Opening the stream carries the SAME one-timeout, typed-error
contract as every other call: a non-2xx response raises
:class:`NestQuestApiError` with its status and a transport
failure (connection refused, DNS, the open timing out) raises
one with ``status=None`` and the original error chained. The
reading loop itself has NO timeout — a healthy stream is
long-lived — but any failure raised from the transport
mid-stream raises typed as well, so the consumer can reconnect.
A frame whose ``data`` is not valid JSON is a protocol failure
and raises typed, carrying the response status it arrived
with. Cancellation is never converted: closing the generator
(``aclose``, i.e. the consumer's task being cancelled) stops
cleanly and closes the underlying response in every path.
"""
url = f"{self._base_url}{EVENTS_PATH}"
headers = {"Authorization": f"Bearer {self._panel_token}"}
# Opening the response gets ONE request timeout, exactly like
# ``_request_json``; the read loop below runs WITHOUT one — a
# body-wide timeout would kill a healthy long-lived stream.
# The response context manager is entered manually (not via
# ``async with``) because the block must span the yields below;
# the ``finally`` closes it on EVERY exit path.
try:
async with asyncio.timeout(self._timeout_seconds):
stream_cm = self._session.request(
"GET", url, headers=headers, json=None
)
response = await stream_cm.__aenter__()
except asyncio.CancelledError:
# Cancellation is never converted (see ``_request_json``).
raise
except NestQuestApiError:
raise
except Exception as err:
raise NestQuestApiError(
f"NestQuest API GET {EVENTS_PATH} transport failure: {err}",
status=None,
) from err
status = response.status
if not 200 <= status < 300:
await self._close_stream(stream_cm)
raise NestQuestApiError(
f"NestQuest API GET {EVENTS_PATH} failed with HTTP {status}",
status=status,
)
try:
event_type: str | None = None
data_lines: list[str] = []
buffer = ""
async for chunk in response:
if isinstance(chunk, (bytes, bytearray)):
chunk = chunk.decode("utf-8")
buffer += chunk
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
if line.startswith("event:"):
event_type = line[len("event:"):].strip()
elif line.startswith("data:"):
data_lines.append(line[len("data:"):].strip())
elif not line and event_type is not None:
# A blank line ends the frame. A trailing
# incomplete frame (stream ends without its
# closing blank line) is discarded, per the
# SSE spec's treatment of incomplete events.
try:
payload = json.loads("\n".join(data_lines))
except ValueError as err:
raise NestQuestApiError(
f"NestQuest API GET {EVENTS_PATH} "
f"delivered a malformed SSE frame: {err}",
status=status,
) from err
yield event_type, payload
event_type = None
data_lines = []
except asyncio.CancelledError:
raise
except NestQuestApiError:
raise
except Exception as err:
raise NestQuestApiError(
f"NestQuest API GET {EVENTS_PATH} transport failure: {err}",
status=None,
) from err
finally:
await self._close_stream(stream_cm)
@staticmethod
async def _close_stream(stream_cm: Any) -> None:
"""Close an opened streaming response context manager.
Works for aiohttp's response context manager (whose
``__aexit__`` is a coroutine) and for test stubs that return
either a coroutine or a plain value.
"""
result = stream_cm.__aexit__(None, None, None)
if inspect.isawaitable(result):
await result
async def _request_json(
self,
method: str,
path: str,
*,
json_body: dict[str, Any] | None = None,
) -> Any:
"""Run ONE request and return the parsed JSON body.
The Bearer token rides the Authorization header only. A
non-2xx response raises :class:`NestQuestApiError` with its
status; anything the transport raises (connection, timeout) is
wrapped as a transport-level :class:`NestQuestApiError` with
the original error chained — never swallowed, never retried.
A 2xx body that is not valid JSON is a protocol failure and
raises typed too, carrying the status it arrived with.
"""
url = f"{self._base_url}{path}"
headers = {"Authorization": f"Bearer {self._panel_token}"}
try:
async with asyncio.timeout(self._timeout_seconds):
async with self._session.request(
method, url, headers=headers, json=json_body
) as response:
status = response.status
if not 200 <= status < 300:
raise NestQuestApiError(
f"NestQuest API {method} {path} failed with "
f"HTTP {status}",
status=status,
)
body_text = await response.text()
except asyncio.CancelledError:
# Cancellation is never converted: a shutting-down event
# loop must stop, not read a synthesized API error.
raise
except NestQuestApiError:
raise
except Exception as err:
raise NestQuestApiError(
f"NestQuest API {method} {path} transport failure: {err}",
status=None,
) from err
try:
return json.loads(body_text)
except ValueError as err:
raise NestQuestApiError(
f"NestQuest API {method} {path} returned malformed "
f"JSON: {err}",
status=status,
) from err
-11
View File
@@ -1,11 +0,0 @@
"""Re-export of :mod:`custom_components.nestquest.core.children` for the integration package.
The children registry business layer lives in
:mod:`custom_components.nestquest.core.children` (extracted in
task 510c1f78); this module re-exports it so existing
``from .children import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.children import * # noqa: F401,F403
-11
View File
@@ -1,11 +0,0 @@
"""Re-export of :mod:`custom_components.nestquest.core.completion` for the integration package.
The completion business layer lives in
:mod:`custom_components.nestquest.core.completion` (extracted in
task 510c1f78); this module re-exports it so existing
``from .completion import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.completion import * # noqa: F401,F403
+7 -15
View File
@@ -7,7 +7,7 @@ from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from .const import CONF_ADMIN_USER_IDS, DOMAIN
from .const import DOMAIN
class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@@ -30,11 +30,11 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) -> FlowResult:
"""Handle the user step of the config flow.
The HA user completing the flow is persisted into the entry
data as the initial admin allowlist copy: real HA does not
guarantee the flow context survives to setup, and entry data is
the persisted record first setup seeds from — the owner is
never locked out.
The integration needs no configuration to start (the API
connection is set in the options flow), so the user input is
stored as-is. The former admin-allowlist copy is gone: with
the database removed the allowlist lives in the API service's
own store, not on the entry.
"""
await self.async_set_unique_id(DOMAIN, raise_on_progress=False)
if self._async_in_progress() or self._async_current_entries():
@@ -43,12 +43,4 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is None:
return self.async_show_form(step_id="user")
data = dict(user_input)
flow_user_id = (
self.context.get("user_id")
if isinstance(self.context, dict)
else None
)
if flow_user_id:
data[CONF_ADMIN_USER_IDS] = [flow_user_id]
return self.async_create_entry(title="NestQuest", data=data)
return self.async_create_entry(title="NestQuest", data=dict(user_input))
+302 -38
View File
@@ -1,4 +1,4 @@
"""The shared NestQuest DataUpdateCoordinator (Feature 10).
"""The shared NestQuest DataUpdateCoordinator (Feature 10, Feature 18).
ONE refresh pass builds the whole entity snapshot: active children,
today's instances with their derived states (open / done / missed via
@@ -7,54 +7,81 @@ per-child rollup counts. Every Feature 10 entity reads
``coordinator.data`` — no entity queries the database itself, so one
refresh updates the whole board coherently.
"Today" and "now" are HA-local (``hass.config.time_zone``), never the
host clock and never UTC, matching the materialization and completion
layers.
Since Feature 18 the snapshot no longer comes from the local database:
the coordinator polls the NestQuest API service's panel plane —
``GET /api/v1/panel/snapshot`` through
:class:`~.api_client.NestQuestApiClient` on its update interval — and
RECONSTRUCTS the same core dataclasses the local builder produced
(:class:`~.core.snapshot.NestQuestSnapshot`,
:class:`~.core.snapshot.ChildDaySnapshot`,
:class:`~.core.snapshot.QuestInstanceView`). ``sensor.py`` and
``binary_sensor.py`` read ``coordinator.data`` and
:func:`~.core.snapshot.instance_payload` exactly as before, so every
entity_id and attribute payload shape is unchanged: the panel cards and
the four blueprints keep working untouched.
The snapshot assembly itself lives in the Home-Assistant-free
:mod:`custom_components.nestquest.core.snapshot` module
(:func:`~core.snapshot.build_snapshot`); this coordinator is now a thin
HA wrapper that resolves the local timezone and reads the settings handed
into its constructor, then delegates to that builder, passing the SAME
HA-local ``now`` it resolved in one clock read. The API service's panel
snapshot route shares the same builder, so both consumers render ONE
snapshot shape.
One loss is deliberate and documented (D-009 revisited): the panel
payload OMITS missed instances, so a snapshot rebuilt from the API can
no longer carry them — ``admin_instances`` (``include_missed=True``)
now equals ``instances`` (they can hold only open and completed rows).
The admin surface for missed quests is the PWA, which reads the API
service directly; the integration's entities never needed missed rows
for their states or counts (the counts arrive precomputed).
Presence is resolved through the SAME engine the materializer uses
(:class:`~.presence.PresenceEngine`): schedules and overrides are read
with the public DAO methods and the engine's anchor-date arithmetic
answers ``is this child at this house today`` (D-004 — never ISO week
parity).
Failure handling: constructing the client fails fast (ValueError) when
the entry has no panel token configured, and a fetch failure raises the
typed :class:`~.api_client.NestQuestApiError`. Neither crashes
integration setup: the constructor logs a clear message and hands back
a client-shaped stub whose refresh raises the same typed error, and
``_async_update_data`` converts every typed error into
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed` — the
exception a HA ``DataUpdateCoordinator`` records as a failed pass
(``last_update_success = False``, ``data = None``), so the entities
come up unavailable. The typed error is never swallowed silently: it
is logged with its message and chained into the UpdateFailed. (The
conversion matters at setup: HA's real
``async_config_entry_first_refresh`` wraps any failed pass in
``ConfigEntryNotReady``, so a setup that keys on the original
exception type would never match it.)
``cycle_day`` reports where today falls in the custody cycle of the
first active child (display order) that HAS a schedule — 1-based day
``N`` of ``cycle_length_weeks * 7``, computed from that schedule's
anchor date. ``0`` means no active child has a schedule at all, so
the admin header degrades to a harmless zero instead of guessing.
Last-good cache and staleness: every successful pass refreshes a cache
of the rebuilt snapshot stamped with the :func:`time.monotonic`
instant of the success. A typed failure is checked against that stamp
BEFORE the pass is declared failed: within the configured staleness
threshold (``CONF_SNAPSHOT_STALENESS``, seconds, default three update
intervals) the pass SUCCEEDS with the cached snapshot — the panel
keeps rendering last-good data and the entities stay available — and
the outage is logged as a warning. Entities go unavailable only once
the cache is older than the threshold (or before the first success);
a later successful fetch refreshes the cache and the entities recover
on their own, without a config-entry reload.
"""
from __future__ import annotations
import asyncio
import datetime
from zoneinfo import ZoneInfo
import time
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
UpdateFailed,
)
from .api_client import NestQuestApiClient, NestQuestApiError, resolve_api_config
from .const import (
CONF_UPDATE_INTERVAL,
DEFAULT_SNAPSHOT_STALENESS,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
LOGGER,
MIN_UPDATE_INTERVAL,
)
from .core.db import NestQuestDatabase
from .core.settings import NestQuestSettings
from .core.snapshot import (
ChildDaySnapshot,
NestQuestSnapshot,
QuestInstanceView,
build_snapshot,
)
__all__ = [
@@ -65,9 +92,131 @@ __all__ = [
]
def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot:
"""Reconstruct the core snapshot dataclasses from the API's JSON.
The panel snapshot route (``api/routes_panel.py``) returns today's
household as plain JSON — ``today_iso``, ``cycle_day``, and the
per-child list with precomputed counts and the instances in the
documented payload shape. This function maps that shape BACK onto
the dataclasses ``sensor.py`` / ``binary_sensor.py`` read, field by
field:
- the API's ``state`` ``"completed"`` maps back to the core view's
``"done"`` (so :func:`~.core.snapshot.instance_payload` renders
``"completed"`` again — the round-trip is identity for open and
completed rows);
- ``overdue``, ``on_time`` (→ ``was_on_time``), ``completed_at``,
``window``, ``due_time``, ``icon`` and the per-child counts pass
through verbatim;
- ``present`` and ``next_present`` pass through verbatim (the API
resolved them through the same presence engine server-side);
- the instance list is rebuilt IN ORDER, so the panel payload the
sensors emit matches the route's order byte for byte.
Because the route omits missed instances (D-009), a snapshot built
here cannot contain any ``missed`` view — see the module docstring.
"""
children: list[ChildDaySnapshot] = []
for child in payload.get("children", []):
views = tuple(
QuestInstanceView(
instance_id=instance["id"],
definition_id=instance["definition_id"],
child_id=instance["child_id"],
title=instance["title"],
icon=instance.get("icon"),
window=instance["window"],
# The API payload does not carry due_date: every row
# is TODAY's instance (the route queries
# [today, today]), so the view's due_date is the
# snapshot's today_iso — the same value the local
# builder stamped on every today row.
due_date=payload["today_iso"],
due_time=instance.get("due_time"),
state=(
"done" if instance["state"] == "completed" else
instance["state"]
),
overdue=instance["overdue"],
completed_at=instance.get("completed_at"),
was_on_time=instance.get("on_time"),
)
for instance in child.get("instances", [])
)
children.append(
ChildDaySnapshot(
child_id=child["child_id"],
child_name=child["child_name"],
instances=views,
present=child["present"],
due_today=child["due_today"],
completed_today=child["completed_today"],
remaining_today=child["remaining_today"],
completion_pct=child["completion_pct"],
next_present=child.get("next_present"),
)
)
return NestQuestSnapshot(
today_iso=payload["today_iso"],
children=tuple(children),
cycle_day=payload["cycle_day"],
)
class _UnconfiguredApiClient:
"""Client-shaped stand-in used when the entry has no panel token.
Constructing a real :class:`~.api_client.NestQuestApiClient` with an
empty token raises ValueError by design (fail fast on a client that
could only ever 401). An entry that simply has not been configured
yet must not crash integration setup, so the coordinator swaps in
this stub: every refresh raises the SAME typed
:class:`~.api_client.NestQuestApiError` a transport failure would,
carrying a clear "API not configured" message;
:meth:`NestQuestCoordinator._async_update_data` converts it to
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, so
the coordinator marks the entities unavailable and the HA standard
retry cycle applies. The error is logged, never silent.
"""
__slots__ = ()
async def get_snapshot(self) -> dict[str, Any]:
"""Always raise: there is no configured API to fetch from."""
raise NestQuestApiError(
"NestQuest API not configured: set the panel token in the "
"integration options"
)
def _monotonic() -> float:
"""The monotonic clock the staleness math reads.
A one-line indirection over :func:`time.monotonic` so the staleness
tests can drive the clock deterministically (patch THIS function,
never the stdlib clock). Monotonic is deliberate: wall-clock jumps
(NTP corrections, suspends) must not shorten or extend the window.
"""
return time.monotonic()
class NestQuestCoordinator(DataUpdateCoordinator):
"""Shared refresh cycle for every NestQuest entity.
One refresh pass = one ``GET /api/v1/panel/snapshot`` through the
API client, reconstructed into the core snapshot dataclasses every
entity reads. A failed pass (unconfigured API, transport failure,
non-2xx response) raises the typed error out of the client;
``_async_update_data`` converts it to
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, the
exception HA's coordinator machinery records as a failed pass: the
coordinator's ``data`` keeps its last good value while
``last_update_success`` goes False — HA marks the entities
unavailable until a later pass succeeds. An unexpected
non-typed error still propagates out of ``_async_update_data``
and is logged (with its traceback) by HA's machinery, the same way.
Forced refreshes (the service paths pushing an immediate update
after a completion) are SERIALIZED per entry: overlapping
complete/uncomplete calls must not interleave their snapshot
@@ -75,6 +224,15 @@ class NestQuestCoordinator(DataUpdateCoordinator):
newer one and leave entities wrong until the next poll. Real
HA's coordinator already serializes internally; the lock keeps
the same guarantee on the stand-in and costs nothing upstream.
Last-good cache: a successful pass stamps the rebuilt snapshot
with the :func:`~.coordinator._monotonic` instant of the success;
a typed failure within the configured staleness threshold
(``staleness_seconds``, from ``CONF_SNAPSHOT_STALENESS``) serves
that cached snapshot instead of failing the pass, so a short API
outage keeps the board up. Past the threshold — or before the
first success — the pass fails as above, and a later successful
pass refreshes the cache and recovers the entities on its own.
"""
def __init__(
@@ -82,9 +240,9 @@ class NestQuestCoordinator(DataUpdateCoordinator):
hass: HomeAssistant,
*,
entry_id: str,
database: NestQuestDatabase,
settings: NestQuestSettings,
api_client: NestQuestApiClient,
update_interval_seconds: int | None = None,
staleness_seconds: int | None = None,
) -> None:
interval = update_interval_seconds
if (
@@ -93,6 +251,19 @@ class NestQuestCoordinator(DataUpdateCoordinator):
or interval < MIN_UPDATE_INTERVAL
):
interval = DEFAULT_UPDATE_INTERVAL
staleness = staleness_seconds
if (
not isinstance(staleness, int)
or isinstance(staleness, bool)
or staleness < interval
):
# A missing or nonsensical threshold falls back to the
# shipped default; a configured-but-too-short one (below
# even one poll interval) falls back to whichever of
# default/interval spans at least one failed poll, so the
# cache is never dead on arrival. (The options flow
# validates the same rule at the form.)
staleness = max(DEFAULT_SNAPSHOT_STALENESS, interval)
super().__init__(
hass,
LOGGER,
@@ -100,9 +271,16 @@ class NestQuestCoordinator(DataUpdateCoordinator):
update_interval=datetime.timedelta(seconds=interval),
)
self.entry_id = entry_id
self.database = database
self.settings = settings
self.api_client = api_client
self._refresh_lock = asyncio.Lock()
#: The configured last-good window, in SECONDS (const.py:
#: CONF_SNAPSHOT_STALENESS / DEFAULT_SNAPSHOT_STALENESS).
self._staleness_seconds = staleness
#: The last-good cache: the snapshot from the most recent
#: SUCCESSFUL fetch and the :func:`time.monotonic` instant it
#: landed. Both stay None until the first success.
self._last_good_snapshot: NestQuestSnapshot | None = None
self._last_good_monotonic: float | None = None
async def async_refresh(self) -> None:
"""One refresh at a time; a queued refresh runs AFTER the
@@ -111,12 +289,98 @@ class NestQuestCoordinator(DataUpdateCoordinator):
async with self._refresh_lock:
await super().async_refresh()
def _local_now(self) -> datetime.datetime:
time_zone = ZoneInfo(self.hass.config.time_zone)
return datetime.datetime.now(time_zone)
async def _async_update_data(self) -> NestQuestSnapshot:
now = self._local_now()
return await build_snapshot(
self.database, self.settings, now
"""One refresh pass: fetch the panel snapshot and rebuild it.
A successful pass rebuilds the snapshot and refreshes the
last-good cache (the snapshot plus the
:func:`time.monotonic` instant of the success).
A typed API failure (unconfigured token stub, transport
failure, non-2xx response) is first checked against that
stamp: WITHIN the configured staleness threshold (elapsed time
since the last successful fetch, inclusive of the threshold
itself) the pass SUCCEEDS with the cached snapshot — the panel
keeps rendering last-good data, the entities stay available —
and the outage is logged as a clear warning (never silent).
With no cache yet, or past the threshold, the typed error is
converted to
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`
— the exception HA's coordinator machinery records as a failed
pass (``last_update_success = False``, ``data`` unchanged) so
entities go unavailable and the setup-time first refresh
arrives as ``ConfigEntryNotReady`` — with the typed error
logged and chained. Anything else still propagates.
"""
try:
payload = await self.api_client.get_snapshot()
except NestQuestApiError as err:
now = _monotonic()
cached = self._last_good_snapshot
fetched_at = self._last_good_monotonic
if (
cached is not None
and fetched_at is not None
and now - fetched_at <= self._staleness_seconds
):
LOGGER.warning(
"NestQuest API refresh failed (%s); serving the "
"last-good snapshot fetched %.0f s ago, within the "
"%d s staleness threshold",
err,
now - fetched_at,
self._staleness_seconds,
)
return cached
LOGGER.error(
"NestQuest coordinator refresh failed: %s", err
)
raise UpdateFailed(str(err)) from err
snapshot = snapshot_from_api_payload(payload)
self._last_good_snapshot = snapshot
self._last_good_monotonic = _monotonic()
return snapshot
def coordinator_client_from_entry(
hass: HomeAssistant, entry: Any, *, session_factory: Any = None
) -> Any:
"""Build the refresh client for a config entry, tolerating no token.
Reads ``(base_url, panel_token)`` off the entry and constructs the
real client. An entry with no panel token configured (the shipped
default is the empty string) makes the constructor raise ValueError
by design; integration setup must not crash on an unconfigured
entry, so this factory swaps in the typed-error stub instead and
logs ONE clear message. The ValueError itself is never re-raised
past this point — it is converted, with the reason logged.
``session_factory`` overrides where the HTTP session comes from
(production: HA's shared aiohttp session via
:func:`~.api_client.client_from_entry`); it exists so tests can
inject a stub transport without importing aiohttp — the mock-only
test harness has no ``homeassistant.helpers.aiohttp_client``.
"""
base_url, panel_token = resolve_api_config(entry)
if not isinstance(panel_token, str) or not panel_token.strip():
LOGGER.warning(
"NestQuest API panel token is not configured for entry %s: "
"entities will stay unavailable until a token is set in the "
"integration options",
entry.entry_id,
)
return _UnconfiguredApiClient()
if session_factory is not None:
return NestQuestApiClient(
base_url, panel_token, session_factory(hass)
)
from custom_components.nestquest.api_client import client_from_entry
return client_from_entry(hass, entry)
#: Test seam: entry_id → client, consulted by the conftest's client
#: factory BEFORE the production path. Always empty in production
#: (nothing in the integration writes to it); tests register scripted
#: clients through it and clear it between tests.
_COORDINATOR_CLIENT_OVERRIDES: dict[str, Any] = {}
+70 -25
View File
@@ -20,44 +20,41 @@ TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The
# panel and admin cards listen for these; Feature 10 fires completed,
# uncompleted, and child_day_complete from the service paths, and
# Feature 11's nightly sweep fires quest_missed.
# panel and admin cards listen for these; since Feature 18 the
# completed/uncompleted/day-complete transitions are published by the
# API service on its SSE stream and re-fired on the bus by the
# integration's subscription, and Feature 11's nightly sweep fires
# quest_missed.
EVENT_QUEST_COMPLETED = "nestquest_quest_completed"
EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted"
EVENT_QUEST_MISSED = "nestquest_quest_missed"
EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete"
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
#: plus the existing Feature 07 regenerate service).
#: Canonical domain-global service name (design/ENTITIES-AND-SERVICES.md §2).
#: Since Feature 18 only the panel completion service remains: the admin
#: surface is the PWA and the API service's admin routes, so the former
#: HA admin services (uncomplete, quest-definition and presence
#: management, CSV export, child management, regenerate) are gone.
SERVICE_COMPLETE_QUEST = "complete_quest"
SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest"
SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition"
SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition"
SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active"
SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern"
SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override"
SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override"
SERVICE_EXPORT_HISTORY_CSV = "export_history_csv"
SERVICE_MANAGE_CHILD = "manage_child"
SERVICE_REGENERATE = "regenerate"
DOMAIN_SERVICES: tuple[str, ...] = (
SERVICE_COMPLETE_QUEST,
SERVICE_UNCOMPLETE_QUEST,
SERVICE_CREATE_QUEST_DEFINITION,
SERVICE_UPDATE_QUEST_DEFINITION,
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
SERVICE_SET_PRESENCE_PATTERN,
SERVICE_CREATE_PRESENCE_OVERRIDE,
SERVICE_DELETE_PRESENCE_OVERRIDE,
SERVICE_EXPORT_HISTORY_CSV,
SERVICE_MANAGE_CHILD,
SERVICE_REGENERATE,
)
SQLITE_DB_FILENAME = "nestquest.db"
# Zero-config panel dashboard (Feature 20): ONE storage-mode Lovelace
# dashboard registered on setup whose config carries the bundle's
# ``custom:nestquest-party`` strategy (frontend/src/strategy.ts) — the
# views are generated at render time from the household roster. The
# url_path IS the domain: the stable key idempotency is keyed on (a
# reload or a second entry never duplicates it, and a dashboard already
# registered under it — ours or the user's — is left untouched).
DASHBOARD_URL_PATH = DOMAIN
DASHBOARD_TITLE = "NestQuest"
DASHBOARD_ICON = "mdi:party-popper"
DASHBOARD_STRATEGY_TYPE = "custom:nestquest-party"
# Configuration keys and default values
CONF_HORIZON_DAYS = "horizon_days"
DEFAULT_HORIZON_DAYS = 14
@@ -81,6 +78,21 @@ CONF_UPDATE_INTERVAL = "update_interval"
DEFAULT_UPDATE_INTERVAL = 300
MIN_UPDATE_INTERVAL = 30
# Last-good snapshot staleness (Feature 18), in SECONDS: how long the
# coordinator keeps SERVING the last good snapshot after the API stops
# answering. A failed poll whose elapsed time since the last successful
# fetch is within the threshold returns the cached snapshot (the panel
# keeps rendering last-good data, entities stay available); past it the
# poll fails the HA way and the entities go unavailable. The default is
# 3x the default update interval (900 s = 15 minutes): three consecutive
# failed five-minute polls before the board is declared stale — enough
# to ride out an API service restart, short enough not to render a
# days-old board all day. Validation floor: the threshold must be at
# least the configured update interval, or the cache could never span
# even one failed poll.
CONF_SNAPSHOT_STALENESS = "snapshot_staleness"
DEFAULT_SNAPSHOT_STALENESS = 3 * DEFAULT_UPDATE_INTERVAL
# Notification automations (Feature 11). The notify target is a free-text
# Home Assistant service name (e.g. "notify.mobile_app_dads_phone") the
# household fills in — never a hard-coded personal target; the times are
@@ -100,6 +112,39 @@ CONF_AFTERNOON_REMINDER_ENABLED = "afternoon_reminder_enabled"
CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
CONF_CELEBRATION_ENABLED = "celebration_enabled"
DEFAULT_AUTOMATION_ENABLED = True
# The household's IANA time zone (e.g. ``America/New_York``) the API
# service reads its clock in. The empty string keeps the historical
# behaviour — the API host's own local time — so an existing install is
# unchanged until the owner sets it. The integration itself always uses
# Home Assistant's configured ``time_zone`` and ignores this field.
CONF_TIMEZONE = "timezone"
DEFAULT_TIMEZONE = ""
# Panel-plane API client (Feature 18): the integration reaches the
# NestQuest API service (Feature 16) over HTTP instead of the database.
# The base URL points at the API service host serving ``/api/v1/panel/*``
# (uvicorn's default host/port is the sensible starting point); the panel
# service token is the static Bearer credential the panel plane requires
# (``NESTQUEST_PANEL_TOKEN`` on the API side). Both are stored on the
# entry like every other setting — options first, then the entry-data
# copy, then defaults. The TOKEN IS A SECRET: it is never logged, never
# echoed into error messages, and never committed. The shipped default
# token IS the empty string (DEFAULT_PANEL_TOKEN) — an entry with no
# configured token is therefore NOT a working client: the options flow
# accepts an empty token as "not configured yet" and only rejects
# malformed (padded/whitespace-only/non-string) values — the actual
# non-emptiness guard is enforced at client construction, where
# NestQuestApiClient fails fast on an empty/whitespace token
# (api_client.py). So a caller building the client from a bare
# entry must either ensure a token is configured first or handle the
# construction ValueError and surface an "API not configured" state —
# an unconfigured entry never yields a client that can only 401.
# This mirrors the API service's fail-closed stance (api/config.py: no
# default token exists).
CONF_API_BASE_URL = "api_base_url"
DEFAULT_API_BASE_URL = "http://127.0.0.1:8000"
CONF_PANEL_TOKEN = "panel_token"
DEFAULT_PANEL_TOKEN = ""
# Quest windows (D-008): a definition may declare several day windows;
# each produces its own instance per day and groups the panel's Quest
+35 -1
View File
@@ -41,4 +41,38 @@ class MetaStateDao:
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
"ON CONFLICT (key) DO UPDATE SET value = excluded.value",
(key, value),
)
)
async def claim(self, key: str, value: str) -> bool:
"""Atomically set ``key`` to ``value`` unless it already is.
The single upsert statement carries a ``WHERE`` guard on the
conflict path (``t.value IS NOT excluded.value``), so the
read-compare-write is ONE SQLite statement — atomic at the
DATABASE level, not just within one process: two connections
(two processes sharing one SQLite file) cannot both observe
"not yet set" and both write, because the second writer's
statement re-evaluates the guard against the first writer's
committed value and matches zero rows.
Returns True only when THIS call changed the row: the first
claimant wins and every later claimant of the same value
returns False. A DIFFERENT value always wins (an advance, e.g.
the sweep watermark moving to a new date) and re-arms the
same-value loser check for the new value.
Runs under the shared connection lock and inside one
transaction, like :meth:`set`; ``cursor.rowcount`` (1 for a
matched write, 0 for a guarded no-op) is captured by the
wrapper on the executor thread, so no sqlite3 object crosses
back to the event loop.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
result = await self._database.execute(
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
"ON CONFLICT (key) DO UPDATE SET value = excluded.value "
f"WHERE {_TABLE}.value IS NOT excluded.value",
(key, value),
)
return result.rowcount == 1
+174 -74
View File
@@ -1,4 +1,4 @@
"""Typed DAO layer for the presence_schedules and presence_overrides tables.
"""Typed DAO layer for the presence_patterns and presence_overrides tables.
All SQL for these tables lives in this module per the feature
guardrails: callers get typed dataclasses back and never see raw rows
@@ -8,11 +8,12 @@ executor and the event loop never blocks.
Semantics per the done-condition and Feature 05:
- A child has AT MOST ONE presence schedule (schema UNIQUE); upserting
by child updates the existing row rather than raising.
- A child with NO schedule row is present every day — the absence of a
row is meaningful state, expressed by :meth:`PresenceSchedulesDao.get_by_child`
returning None, never by an empty pattern row.
- A child has ZERO OR MORE presence patterns (schema 9 — no UNIQUE on
``child_id``); each is created, updated and deleted by its own id.
- A child with NO pattern row is present every day — the absence of a
row is meaningful state, expressed by
:meth:`PresencePatternsDao.list_by_child` returning an empty list,
never by an empty pattern row.
- Overrides beat the pattern for their date range (that resolution is
the presence engine's job, Feature 05); the DAO only stores and
lists them. A single-day override stores the same date as both
@@ -32,11 +33,13 @@ from .db import NestQuestDatabase
@dataclass(frozen=True)
class PresenceScheduleRecord:
"""One row of ``presence_schedules``."""
class PresencePatternRecord:
"""One row of ``presence_patterns``."""
id: int
child_id: int
name: str
kind: str
cycle_length_weeks: int
anchor_date: str
pattern: str
@@ -54,21 +57,23 @@ class PresenceOverrideRecord:
note: str | None
_SCHEDULE_COLUMNS = (
"id, child_id, cycle_length_weeks, anchor_date, pattern"
_PATTERN_COLUMNS = (
"id, child_id, name, kind, cycle_length_weeks, anchor_date, pattern"
)
_OVERRIDE_COLUMNS = (
"id, child_id, start_date, end_date, is_present, note"
)
def _schedule_from_row(row: tuple) -> PresenceScheduleRecord:
return PresenceScheduleRecord(
def _pattern_from_row(row: tuple) -> PresencePatternRecord:
return PresencePatternRecord(
id=row[0],
child_id=row[1],
cycle_length_weeks=row[2],
anchor_date=row[3],
pattern=row[4],
name=row[2],
kind=row[3],
cycle_length_weeks=row[4],
anchor_date=row[5],
pattern=row[6],
)
@@ -83,29 +88,27 @@ def _override_from_row(row: tuple) -> PresenceOverrideRecord:
)
class PresenceSchedulesDao:
"""Typed async access to the ``presence_schedules`` table."""
class PresencePatternsDao:
"""Typed async access to the ``presence_patterns`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def upsert_by_child(
async def create(
self,
child_id: int,
name: str,
kind: str,
cycle_length_weeks: int,
anchor_date: str,
pattern: str,
) -> PresenceScheduleRecord:
"""Set the child's presence schedule, replacing any existing one.
) -> PresencePatternRecord:
"""Insert one pattern for the child and return it as stored.
Upsert semantics are the done-condition: a second schedule for
the same child updates the existing row rather than raising.
Runs under the connection lock so a concurrent upsert/delete
for the same child serializes instead of colliding on the
schema's UNIQUE(child_id). Pattern validity (segment shape,
segment count = cycle_length_weeks) is enforced by the schema
CHECKs; the child must exist (FK) — validated atomically here
so a deleted child cannot pass a stale check.
The child must exist (FK) — validated atomically under the
connection lock so a deleted child cannot pass a stale check.
``kind``, the cycle length and the pattern shape (segment count
= ``cycle_length_weeks``) are enforced by the schema CHECKs.
"""
_validate_date(anchor_date, "anchor_date")
async with _connection_lock(self._database):
@@ -115,53 +118,98 @@ class PresenceSchedulesDao:
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
await self._database.execute(
"INSERT INTO presence_schedules (child_id, "
result = await self._database.execute(
"INSERT INTO presence_patterns (child_id, name, kind, "
"cycle_length_weeks, anchor_date, pattern) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT (child_id) DO UPDATE SET "
"cycle_length_weeks = excluded.cycle_length_weeks, "
"anchor_date = excluded.anchor_date, "
"pattern = excluded.pattern",
(child_id, cycle_length_weeks, anchor_date, pattern),
"VALUES (?, ?, ?, ?, ?, ?)",
(
child_id,
name,
kind,
cycle_length_weeks,
anchor_date,
pattern,
),
)
schedule = await self.get_by_child(child_id)
assert schedule is not None
return schedule
record = await self.get(result.lastrowid)
assert record is not None
return record
async def get_by_child(
self, child_id: int
) -> PresenceScheduleRecord | None:
"""Return the child's schedule, or None when the child has none.
async def list_by_child(self, child_id: int) -> list[PresencePatternRecord]:
"""Return the child's patterns ordered by id; empty when none.
None is meaningful and UNAMBIGUOUS here: the child exists and
has no schedule row, i.e. the child is present every day
(Feature 05 guardrail). An unknown child id raises ValueError
instead of returning None, because "nonexistent child" must
never be silently read as "present every day".
An empty list is meaningful and UNAMBIGUOUS here: the child
exists and has no pattern row, i.e. the child is present every
day (Feature 05 guardrail). An unknown child id raises
ValueError instead of returning an empty list, because
"nonexistent child" must never be silently read as "present
every day".
"""
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
row = await self._database.fetch_one(
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
"WHERE child_id = ?",
rows = await self._database.fetch_all(
f"SELECT {_PATTERN_COLUMNS} FROM presence_patterns "
"WHERE child_id = ? ORDER BY id",
(child_id,),
)
return _schedule_from_row(row) if row is not None else None
return [_pattern_from_row(row) for row in rows]
async def delete(self, child_id: int) -> bool:
"""Remove the child's schedule; True if a row was removed.
async def get(self, pattern_id: int) -> PresencePatternRecord | None:
"""Return one pattern by id, or None when it does not exist."""
row = await self._database.fetch_one(
f"SELECT {_PATTERN_COLUMNS} FROM presence_patterns WHERE id = ?",
(pattern_id,),
)
return _pattern_from_row(row) if row is not None else None
After deletion the child is present every day (no row means
no restriction). Deleting a schedule the child never had
returns False.
async def update(
self,
pattern_id: int,
name: str,
kind: str,
cycle_length_weeks: int,
anchor_date: str,
pattern: str,
) -> PresencePatternRecord | None:
"""Replace one pattern's fields; None when the id does not exist.
Every column but ``id`` and ``child_id`` is rewritten — a
pattern never moves to another child. The schema CHECKs apply
exactly as on :meth:`create`.
"""
_validate_date(anchor_date, "anchor_date")
async with _connection_lock(self._database):
async with self._database.transaction():
result = await self._database.execute(
"UPDATE presence_patterns SET name = ?, kind = ?, "
"cycle_length_weeks = ?, anchor_date = ?, pattern = ? "
"WHERE id = ?",
(
name,
kind,
cycle_length_weeks,
anchor_date,
pattern,
pattern_id,
),
)
if result.rowcount == 0:
return None
record = await self.get(pattern_id)
return record
async def delete(self, pattern_id: int) -> bool:
"""Remove one pattern by id; True if a row was removed.
Deleting a child's last pattern leaves it present every day (no
row means no restriction).
"""
result = await self._database.execute(
"DELETE FROM presence_schedules WHERE child_id = ?",
(child_id,),
"DELETE FROM presence_patterns WHERE id = ?",
(pattern_id,),
)
return result.rowcount > 0
@@ -270,6 +318,57 @@ class PresenceOverridesDao:
)
return [_override_from_row(row) for row in rows]
async def list_filtered(
self,
*,
child_id: int | None = None,
start: str | None = None,
end: str | None = None,
) -> list[PresenceOverrideRecord]:
"""Return the household's overrides, optionally narrowed.
Every filter is optional and they combine: ``child_id`` keeps
that child's overrides only (an unknown child raises ValueError
rather than reading as "no overrides"); ``start`` keeps
overrides ending on or after it and ``end`` keeps overrides
starting on or before it, so both together select the overrides
overlapping the closed range ``[start, end]`` (the
:meth:`list_by_child_and_range` boundary rule). Dates are
strict YYYY-MM-DD and ``end`` must not precede ``start``.
Ordered stably by start date, then child id, then id.
"""
if start is not None:
_validate_date(start, "start")
if end is not None:
_validate_date(end, "end")
if start is not None and end is not None and end < start:
raise ValueError(
f"end must be on or after start, got {end!r} < {start!r}"
)
clauses: list[str] = []
params: list[object] = []
if child_id is not None:
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
clauses.append("child_id = ?")
params.append(child_id)
if start is not None:
clauses.append("end_date >= ?")
params.append(start)
if end is not None:
clauses.append("start_date <= ?")
params.append(end)
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
rows = await self._database.fetch_all(
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides{where} "
"ORDER BY start_date, child_id, id",
tuple(params),
)
return [_override_from_row(row) for row in rows]
async def get(self, override_id: int) -> PresenceOverrideRecord | None:
"""Return one override by id, or None when it does not exist."""
row = await self._database.fetch_one(
@@ -294,22 +393,23 @@ async def read_snapshot_unlocked(
range_start: str,
range_end: str,
) -> tuple[
dict[int, PresenceScheduleRecord],
dict[int, list[PresencePatternRecord]],
dict[int, list[PresenceOverrideRecord]],
]:
"""Read schedules and overrides for ``child_ids`` WITHOUT locking.
"""Read patterns and overrides for ``child_ids`` WITHOUT locking.
MUST be called inside the connection lock and an open transaction, so
the schedules and overrides returned here stay coherent with every
the patterns and overrides returned here stay coherent with every
other table the caller reads inside that same transaction (the
materialization walk's single input snapshot does exactly that). The
date range comes already validated by the caller.
Returns ``(schedules, overrides)``:
Returns ``(patterns, overrides)``:
- ``schedules`` maps ``child_id`` to its :class:`PresenceScheduleRecord`
for every child that HAS a schedule row. A child absent from the
mapping has no schedule, i.e. is present every day (Feature 05).
- ``patterns`` maps ``child_id`` to its :class:`PresencePatternRecord`
list (ordered by id) for every child that HAS a pattern row. A
child absent from the mapping has no pattern, i.e. is present
every day (Feature 05).
- ``overrides`` maps ``child_id`` to that child's overrides overlapping
``[range_start, range_end]`` (inclusive boundaries), ordered by start
date; a child with no matching override is absent from the mapping.
@@ -321,9 +421,9 @@ async def read_snapshot_unlocked(
if not children:
return {}, {}
placeholders = ",".join("?" for _ in children)
schedule_rows = await database.fetch_all(
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
f"WHERE child_id IN ({placeholders})",
pattern_rows = await database.fetch_all(
f"SELECT {_PATTERN_COLUMNS} FROM presence_patterns "
f"WHERE child_id IN ({placeholders}) ORDER BY id",
tuple(children),
)
override_rows = await database.fetch_all(
@@ -332,12 +432,12 @@ async def read_snapshot_unlocked(
"AND start_date <= ? ORDER BY start_date",
tuple(children) + (range_start, range_end),
)
schedules = {
record.child_id: record
for record in map(_schedule_from_row, schedule_rows)
}
patterns: dict[int, list[PresencePatternRecord]] = {}
for row in pattern_rows:
record = _pattern_from_row(row)
patterns.setdefault(record.child_id, []).append(record)
overrides: dict[int, list[PresenceOverrideRecord]] = {}
for row in override_rows:
record = _override_from_row(row)
overrides.setdefault(record.child_id, []).append(record)
return schedules, overrides
return patterns, overrides
+123 -18
View File
@@ -105,6 +105,19 @@ def _validate_window(window: str) -> None:
)
def _validate_skip_on_away(value: object) -> int:
"""Return ``value`` as 0/1, raising ValueError for anything else.
The fresh-schema CHECK constraint enforces this in SQLite, but a
migrated database gained the column through ``ADD COLUMN`` without
it, so every DAO write path validates here instead. Accepts a bool
or the ints 0/1; floats, strings and other ints are rejected.
"""
if not isinstance(value, int) or value not in (0, 1):
raise ValueError(f"skip_on_away must be 0 or 1, got {value!r}")
return int(value)
@dataclass(frozen=True)
class ScheduleRuleRecord:
"""One row of ``schedule_rules``."""
@@ -152,6 +165,7 @@ class QuestDefinitionRecord:
due_time: str | None
is_active: bool
created_at: str
skip_on_away: bool
@dataclass(frozen=True)
@@ -187,7 +201,7 @@ _RULE_COLUMNS = (
)
_DEFINITION_COLUMNS = (
"id, title, description, icon, schedule_rule_id, due_time, "
"is_active, created_at"
"is_active, created_at, skip_on_away"
)
_WINDOW_COLUMNS = "definition_id, window, due_time"
@@ -216,6 +230,7 @@ def _definition_from_row(row: tuple) -> QuestDefinitionRecord:
due_time=row[5],
is_active=bool(row[6]),
created_at=row[7],
skip_on_away=bool(row[8]),
)
@@ -632,6 +647,7 @@ class QuestDefinitionsDao:
due_time: str | None = None,
is_active: bool = True,
assignee_child_ids: list[int] | None = None,
skip_on_away: bool = True,
) -> QuestDefinitionRecord:
"""Insert one definition and return the record as stored.
@@ -644,6 +660,7 @@ class QuestDefinitionsDao:
empty list or None creates an unassigned definition, which
callers may fill via :meth:`add_assignee`.
"""
skip_on_away_value = _validate_skip_on_away(skip_on_away)
async with _connection_lock(self._database):
async with self._database.transaction():
await self._validate_rule_exists(schedule_rule_id)
@@ -652,7 +669,8 @@ class QuestDefinitionsDao:
result = await self._database.execute(
"INSERT INTO quest_definitions (title, description, "
"icon, schedule_rule_id, due_time, is_active, "
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
"created_at, skip_on_away) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
title,
description,
@@ -661,6 +679,7 @@ class QuestDefinitionsDao:
due_time,
int(is_active),
created_at,
skip_on_away_value,
),
)
definition_id = result.lastrowid
@@ -684,6 +703,7 @@ class QuestDefinitionsDao:
*,
description: str | None = None,
icon: str | None = None,
skip_on_away: bool = True,
) -> QuestDefinitionSnapshot:
"""Insert a rule, definition, assignees and windows atomically.
@@ -713,6 +733,7 @@ class QuestDefinitionsDao:
_validate_date(schedule_rule.start_date, "start_date")
if schedule_rule.end_date is not None:
_validate_date(schedule_rule.end_date, "end_date")
skip_on_away_value = _validate_skip_on_away(skip_on_away)
async with _connection_lock(self._database):
async with self._database.transaction():
for child_id in assignee_child_ids:
@@ -721,8 +742,18 @@ class QuestDefinitionsDao:
result = await self._database.execute(
"INSERT INTO quest_definitions (title, description, "
"icon, schedule_rule_id, due_time, is_active, "
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(title, description, icon, rule_id, None, 1, created_at),
"created_at, skip_on_away) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
title,
description,
icon,
rule_id,
None,
1,
created_at,
skip_on_away_value,
),
)
definition_id = result.lastrowid
for child_id in assignee_child_ids:
@@ -764,8 +795,10 @@ class QuestDefinitionsDao:
icon: str | None | object = _UNSET,
rule: ScheduleRuleStorage | object = _UNSET,
windows: list[tuple[str, str | None]] | object = _UNSET,
skip_on_away: bool | object = _UNSET,
assignee_child_ids: list[int] | object = _UNSET,
) -> QuestDefinitionSnapshot:
"""Edit a definition's metadata, rule and windows atomically.
"""Edit a definition's metadata, rule, windows and assignees atomically.
Mirrors :meth:`create_with_rule_and_windows`: everything runs
inside ONE transaction under the connection lock, and the
@@ -774,9 +807,10 @@ class QuestDefinitionsDao:
``_UNSET`` meaning "leave this field alone"; ``None`` writes SQL
NULL so optional metadata (description, icon) can be cleared.
- ``title``/``description``/``icon`` update the definition row
in place; the definition-level ``due_time`` column is never
written (per-window due times supersede it, D-008).
- ``title``/``description``/``icon``/``skip_on_away`` update the
definition row in place; the definition-level ``due_time``
column is never written (per-window due times supersede it,
D-008).
- ``rule`` (a pre-validated :class:`ScheduleRuleStorage`)
overwrites the referenced ``schedule_rules`` row in place: the
definition keeps its ``schedule_rule_id``, so no new or
@@ -785,13 +819,19 @@ class QuestDefinitionsDao:
are deleted and the given set re-inserted, so missing windows
are added, changed due times updated and removed windows
dropped.
- ``assignee_child_ids`` REPLACES the whole assignee set: every
id is validated as existing-and-active BEFORE any write (the
same check :meth:`add_assignee` applies), then links for
children no longer listed are removed and links for newly
listed children inserted; unchanged links are left alone.
Assignment is deliberately not settable here (multi-assignee is
managed by :meth:`add_assignee`/:meth:`remove_assignee`), and
activation is untouched. Raises ValueError when the definition
does not exist. Edits change future instances only: no
Activation is untouched. Raises ValueError when the definition
does not exist or an assignee is rejected; either way the
transaction rolls back and nothing is written. Edits change future instances only: no
``quest_instances`` or ``completion_events`` row is touched.
"""
if skip_on_away is not _UNSET:
skip_on_away = _validate_skip_on_away(skip_on_away)
async with _connection_lock(self._database):
async with self._database.transaction():
definition = await self.get(definition_id)
@@ -799,6 +839,9 @@ class QuestDefinitionsDao:
raise ValueError(
f"quest definition {definition_id} does not exist"
)
if assignee_child_ids is not _UNSET:
for child_id in assignee_child_ids:
await self._validate_assignable_child(child_id)
assignments: list[str] = []
parameters: list[object] = []
for column, value in (
@@ -809,6 +852,9 @@ class QuestDefinitionsDao:
if value is not _UNSET:
assignments.append(f"{column} = ?")
parameters.append(value)
if skip_on_away is not _UNSET:
assignments.append("skip_on_away = ?")
parameters.append(skip_on_away)
if assignments:
parameters.append(definition_id)
await self._database.execute(
@@ -844,6 +890,10 @@ class QuestDefinitionsDao:
"VALUES (?, ?, ?)",
(definition_id, window, due_time),
)
if assignee_child_ids is not _UNSET:
await self._replace_assignees(
definition_id, assignee_child_ids
)
updated = await self.get(definition_id)
assignees = await self.list_assignees(definition_id)
window_records = await self.list_windows(definition_id)
@@ -859,6 +909,36 @@ class QuestDefinitionsDao:
windows=window_records,
)
async def _replace_assignees(
self, definition_id: int, child_ids: list[int]
) -> None:
"""Make the definition's assignee links exactly ``child_ids``.
Removes the links for children not in ``child_ids`` and inserts
the missing ones. MUST be called inside the connection lock and
an open transaction, after the ids were validated as assignable.
"""
rows = await self._database.fetch_all(
"SELECT child_id FROM quest_definition_assignees "
"WHERE definition_id = ?",
(definition_id,),
)
current = {row[0] for row in rows}
wanted = set(child_ids)
for child_id in sorted(current - wanted):
await self._database.execute(
"DELETE FROM quest_definition_assignees "
"WHERE definition_id = ? AND child_id = ?",
(definition_id, child_id),
)
for child_id in child_ids:
if child_id not in current:
await self._database.execute(
"INSERT INTO quest_definition_assignees "
"(definition_id, child_id) VALUES (?, ?)",
(definition_id, child_id),
)
async def _validate_rule_exists(self, schedule_rule_id: int) -> None:
"""Raise ValueError unless the rule exists.
@@ -907,6 +987,13 @@ class QuestDefinitionsDao:
)
return [_definition_from_row(row) for row in rows]
async def list_all(self) -> list[QuestDefinitionRecord]:
"""Return every definition, active AND inactive, oldest first."""
rows = await self._database.fetch_all(
f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions ORDER BY id"
)
return [_definition_from_row(row) for row in rows]
async def _snapshots_for(
self, definitions: list[QuestDefinitionRecord]
) -> list[QuestDefinitionSnapshot]:
@@ -951,6 +1038,18 @@ class QuestDefinitionsDao:
definitions = await self.list_active()
return await self._snapshots_for(definitions)
async def list_snapshots_all(self) -> list[QuestDefinitionSnapshot]:
"""Return every definition (active AND inactive) with its rule,
assignees and windows, all read inside one locked transaction.
Mirrors :meth:`list_snapshots_active` over :meth:`list_all`
(oldest first), with the same coherence guarantee.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
definitions = await self.list_all()
return await self._snapshots_for(definitions)
async def read_snapshots_active(self) -> list[QuestDefinitionSnapshot]:
"""Return every active definition's snapshot WITHOUT locking.
@@ -990,13 +1089,16 @@ class QuestDefinitionsDao:
description: str | None | object = _UNSET,
icon: str | None | object = _UNSET,
due_time: str | None | object = _UNSET,
skip_on_away: bool | None = None,
) -> int:
"""Update the given fields; returns rows updated (0 if absent).
Arguments default to the module sentinel ``_UNSET`` meaning
"leave this column alone"; passing ``None`` explicitly writes
SQL NULL, so callers can remove optional metadata
(description, icon, due_time). Assignment and activation are
(description, icon, due_time). ``skip_on_away`` is NOT NULL,
so its ``None`` default leaves it unchanged; a supplied value
must be 0/1 (a bool). Assignment and activation are
deliberately NOT settable here: they have their own explicit
operations (:meth:`add_assignee`, :meth:`remove_assignee`,
:meth:`set_active`) so they stay separately loggable and
@@ -1013,6 +1115,9 @@ class QuestDefinitionsDao:
if value is not _UNSET:
assignments.append(f"{column} = ?")
parameters.append(value)
if skip_on_away is not None:
assignments.append("skip_on_away = ?")
parameters.append(_validate_skip_on_away(skip_on_away))
if not assignments:
return 0
parameters.append(definition_id)
@@ -1214,16 +1319,16 @@ async def load_materialization_input(
):
"""Read every materialization input in ONE locked transaction.
Returns ``(snapshots, schedules, overrides)``:
Returns ``(snapshots, patterns, overrides)``:
- ``snapshots``: every active definition with its rule, assignees and
windows (the :class:`QuestDefinitionSnapshot` shape).
- ``schedules`` / ``overrides``: the presence schedules and
- ``patterns`` / ``overrides``: the presence patterns and
range-scoped overrides of every assignee child, in
:mod:`.dao_presence`'s record shapes.
The whole read — definitions, rules, assignees, windows, and the
assignees' presence schedules and overrides — runs inside a single
assignees' presence patterns and overrides — runs inside a single
BEGIN..COMMIT span under the connection lock, so a concurrent edit
(definition, assignment, window, or presence) cannot interleave
between the parts and the walk never sees a mixed-time view. Each
@@ -1244,7 +1349,7 @@ async def load_materialization_input(
)
from . import dao_presence # lazy: dao_presence imports us
schedules, overrides = await dao_presence.read_snapshot_unlocked(
patterns, overrides = await dao_presence.read_snapshot_unlocked(
database, child_ids, range_start, range_end
)
return snapshots, schedules, overrides
return snapshots, patterns, overrides

Some files were not shown because too many files have changed in this diff Show More