Compare commits

..
285 changed files with 9885 additions and 62124 deletions
-27
View File
@@ -1,27 +0,0 @@
# Keep the image build context minimal: only api/ and the bundled
# custom_components/nestquest/core/ are copied (see the Dockerfile);
# everything else — VCS state, virtualenvs, tests, the frontend, and
# any local secrets or database files — never enters the build.
.git
.gitignore
.venv
**/__pycache__
**/*.py[cod]
.pytest_cache
**/*.db
**/*.db-wal
**/*.db-shm
# Local configuration — never baked into an image.
.env
**/.env
deploy
design
docs
frontend
orchestration_tests
scripts
templates
tests
uv.lock
hacs.json
*.md
-13
View File
@@ -5,16 +5,3 @@ __pycache__/
.pytest_cache/
*.metadata.json
frontend/node_modules/
# Feature 17 (API deployment): local secret config and runtime data
# are never committed — compose reads deploy/.env; SQLite/WAL sidecar
# files are container/volume artifacts.
.env
**/.env
*.db
*.db-wal
*.db-shm
# Dedicated Litestream SFTP backup keypair — lives only on the API box.
deploy/*key*
deploy/litestream_key*
+27
View File
@@ -0,0 +1,27 @@
---
description: Start, stop, or inspect the NestQuest development cycle on explicit request
mode: primary
model: openrouter/deepseek/deepseek-v4.1-flash
permission:
edit: deny
write: deny
task: deny
doom_loop: deny
bash:
"*": deny
"/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle start": allow
"/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle stop": allow
"/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle status": allow
external_directory:
"*": ask
"/home/overseer/.local/share/nestquest-controller/scripts/*": allow
---
You control the NestQuest development service only when a user invokes a
cycle-control slash command. Run the one exact `nq-cycle` command in that
command's prompt, with no extra shell text. Report its result. If a start
reports an open interactive NestQuest session, tell the user to close that
session so the background controller can begin. Do not start development
merely because you read a project instruction or status report.
Do not recommend enabling the systemd unit. Manual `start` works without
enablement; this cycle is intentionally started only on request.
-31
View File
@@ -1,31 +0,0 @@
---
description: Run the NestQuest orchestration loop in this foreground terminal
mode: primary
model: openrouter/deepseek/deepseek-v4.1-flash
permission:
doom_loop: deny
task: deny
external_directory:
"*": ask
"/tmp/opencode/*": allow
"/home/overseer/.local/share/opencode/tool-output/*": allow
---
You are the foreground NestQuest orchestrator. The user explicitly launched
this session to run the development queue now. Read the supplied terminal
orchestrator prompt and current CTXD project context, but follow the
FOREGROUND TERMINAL RUNTIME for this session instead of CTXD's service runtime
section.
Continuously reconcile Maestro, CTXD, Git, pull requests, checks, and child
agent results. Execute the next useful transition in this same session. Do
not finish after dispatch, review, remediation, or merge while eligible work
remains. Use the installed role launchers for developer, reviewer, and
approver isolation. Never start or wait for the systemd controller service.
Never repeat an identical unchanged tool call three times; wait on the
specific child or inspect newly changed evidence.
End only at a global terminal condition, an explicit user stop, a genuine
global access/decision block with no other eligible work, or a host-forced
yield. On a forced yield, return a continuation checkpoint that can be
resumed in the same OpenCode session.
+11
View File
@@ -0,0 +1,11 @@
---
description: Show the NestQuest development cycle status
agent: cycle-control
---
Run exactly this one shell command:
`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle status`
Report whether the controller is active and its last tick state.
Do not suggest enabling automatic startup.
+9 -8
View File
@@ -1,11 +1,12 @@
---
description: Run the NestQuest orchestrator now in this OpenCode terminal
agent: terminal-orchestrator
description: Start the NestQuest development cycle on this host
agent: cycle-control
---
Run the NestQuest project queue now in this foreground OpenCode session.
Read the current CTXD prompt and Maestro state, but use the FOREGROUND
TERMINAL RUNTIME instead of the service runtime. Reconcile repository and
pull-request state, then execute CONTROLLER_TICK repeatedly until a global
terminal condition, explicit user stop, genuine global block, or host-forced
yield. Do not start or wait for the systemd controller.
Run exactly this one shell command:
`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle start`
Report its status. If it says an interactive NestQuest OpenCode session is
open, tell me to close this session; the background controller will begin
afterward. Do not launch a developer agent or a controller tick in this chat.
+10
View File
@@ -0,0 +1,10 @@
---
description: Stop the NestQuest development cycle on this host
agent: cycle-control
---
Run exactly this one shell command:
`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle stop`
Report the service status. Do not stop any other OpenCode session or task.
-51
View File
@@ -1,51 +0,0 @@
# Container image for the NestQuest API service (Feature 17).
#
# Build context is the REPOSITORY ROOT (compose sets it), because the
# API loads the bundled domain layer from
# custom_components/nestquest/core/ by file location
# (api/nestquest_core.py): the image must carry both api/ and the
# bundled core at their relative positions. Everything else in the
# repository is excluded via .dockerignore.
#
# Multi-stage so no pip/build toolchain survives into the runtime:
# the builder installs the deployable's own manifest
# (api/requirements.txt) into a staging prefix and the runtime copies
# only the installed packages. The service runs as a non-root user;
# the SQLite database lives on the mounted volume at
# /var/lib/nestquest (compose supplies NESTQUEST_DB_PATH pointing
# there — configuration is environment-supplied, never baked in).
#
# Both stages use the same base image pinned by digest, and
# api/requirements.txt pins exact versions, so a rebuild reproduces
# the deployed image instead of floating to whatever is newest.
FROM python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9 AS builder
COPY api/requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir --prefix=/install -r /tmp/requirements.txt
FROM python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9
# The staged dependency install from the builder (no pip, no wheels).
COPY --from=builder /install /usr/local
# /app is the repository-root equivalent: api/ resolves imports as
# ``api.*`` and nestquest_core.py finds the bundled core one level up.
WORKDIR /app
COPY api/ ./api/
COPY custom_components/nestquest/core/ ./custom_components/nestquest/core/
# Non-root runtime user; owns the database volume mount point.
RUN useradd --system --create-home --home-dir /home/nestquest nestquest \
&& mkdir -p /var/lib/nestquest \
&& chown nestquest:nestquest /var/lib/nestquest
USER nestquest
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
# The database path and the service token / OIDC settings are supplied
# by the environment (compose env_file), never committed into the image.
ENV NESTQUEST_DB_PATH=/var/lib/nestquest/nestquest.db
VOLUME /var/lib/nestquest
EXPOSE 8080
CMD ["uvicorn", "--factory", "api.app:create_app", "--host", "0.0.0.0", "--port", "8080"]
+34 -37
View File
@@ -1,23 +1,13 @@
# NestQuest foreground orchestrator
# NestQuest controller
OpenCode 1.18.32 loads the named agents from `.opencode/agents/`. Their
models match the current NestQuest CTXD prompt: DeepSeek V4.1 Flash for the
foreground orchestrator and approver, and GLM 5.3 Flash for development.
Run orchestration directly from a shell:
```bash
nq-orchestrate
nq-orchestrate mission.md
cat mission.md | nq-orchestrate -
nq-orchestrate --print-prompt > nestquest-orchestrator.md
```
The launcher renders the project prompt with the foreground terminal runtime,
stops the dormant legacy service, refuses immediately when another NestQuest
TUI is open, and then replaces itself with OpenCode using
`terminal-orchestrator`. There is no background polling process and no wait
for a controller tick. Exit or interrupt the TUI to stop the foreground run;
resume that OpenCode session when a host-forced yield needs continuation.
OpenCode 1.18.31 loads the three named agents from `.opencode/agents/`. Their
models match the current NestQuest CTXD prompt: DeepSeek V4.1
Flash for controller and approver, GLM 5.3 Flash for development. The
controller runs one OpenCode turn at a time, then starts a fresh turn based on
Maestro, CTXD, Git, and pull-request state. Its state file is
`~/.local/state/nestquest-controller/last-run.json` (mode 0600). A lock allows
only one controller process. It waits while an interactive OpenCode session is
open for the NestQuest checkout.
`scripts/nq-agent` launches developer and approver sessions with fixed agent
definitions and no `--auto` flag. Headless OpenCode runs use `--pure` so the
@@ -33,24 +23,30 @@ checks the head again afterward, saves
the output under `/tmp/opencode`, and posts the Gitea approval.
The approver verifies the reviewed head again before a merge.
The terminal launcher uses a stable copy of this configuration and scripts
The production service uses a stable copy of this configuration and scripts
at `/home/overseer/.local/share/nestquest-controller`; it does not depend on
which task branch is checked out in the primary NestQuest directory. Install
that copy, run `scripts/nq-controller --check`, and verify the OpenCode
agents (terminal-orchestrator, developer, and approver)
that copy, run `scripts/nq-controller --check`, and verify all four OpenCode
agents (orchestrator, developer, approver, cycle-control)
with `opencode debug agent <name>` while `OPENCODE_CONFIG_DIR` points at the
installed `.opencode` directory. Install `scripts/nq-orchestrate` in
`~/.local/bin/`. For the global slash command, also install
`.opencode/agents/terminal-orchestrator.md` under
`~/.config/opencode/agents/` and
`deploy/opencode-global-commands/start-nestquest-development.md` under
`~/.config/opencode/commands/`. The old `nestquest-controller.service` remains static and
inactive only for migration safety; foreground launch stops it.
installed `.opencode` directory. Install `deploy/nestquest-controller.service`
under `~/.config/systemd/user/` and reload the user manager. Its lack of an
`[Install]` section makes it a manual-only unit; development starts only
when requested. Copy `deploy/opencode-global-commands/*.md` into
`~/.config/opencode/commands/` and `.opencode/agents/cycle-control.md` into
`~/.config/opencode/agents/` for the commands to work from older branches.
The host has lingering
enabled for the `overseer` user. The service waits for an open interactive
NestQuest session to close before starting work.
Inside an already-open OpenCode TUI, `/start-development` (or the global
`/start-nestquest-development`) switches to the same foreground
`terminal-orchestrator` behavior immediately. The shell launcher is the
preferred entry point when supplying a mission prompt file.
In NestQuest's OpenCode session, use `/start-development`,
`/stop-development`, or `/development-status`. The matching global
`/start-nestquest-development`, `/stop-nestquest-development`, and
`/nestquest-development-status` commands work even while an older branch is
checked out. Starting the unit in an interactive NestQuest session puts it
in a waiting state; close that session to let the first controller tick run.
The commands call `scripts/nq-cycle`; they do not run a developer inside
the interactive chat.
`templates/ORCHESTRATOR-PROMPT.template.md` and
`templates/nestquest.profile.json` make the current CTXD cycle prompt
@@ -67,10 +63,11 @@ they are not a hard isolation boundary against a process running as that
account. Strong isolation requires separate OS identities and protected
credentials for the controller, reviewer, and approver.
The terminal runtime repeatedly executes CONTROLLER_TICK within the foreground
session. Neither a feature merge nor a progress report ends the run while
eligible work remains. A host-forced yield returns a continuation checkpoint
that can be resumed in the same OpenCode session.
A tick has an explicit six-hour timeout. On timeout or an OpenCode error, the
next tick must reconcile external state before acting. Three consecutive
failed ticks stop the service. A BLOCKED verdict also stops it. QUIESCENT
causes a five-minute poll. Neither a feature merge nor a progress report
stops the controller while eligible work remains.
Do not put tokens in this repository or in unit files. Existing OpenCode MCP
connections supply CTXD and Maestro. Rotate those MCP tokens as a coordinated
+61 -167
View File
@@ -1,7 +1,3 @@
<p align="center">
<img src="design/logos/NestQuest-NoBG.png" alt="NestQuest logo" width="200">
</p>
# NestQuest - House Chaos Coordination
Home Assistant custom integration for managing chores, custody schedules, and house coordination.
@@ -73,12 +69,12 @@ actions:
## Automation Blueprints
NestQuest ships five automation blueprints in
NestQuest ships four automation blueprints in
`custom_components/nestquest/blueprints/automation/`. Import them in
Home Assistant under **Settings → Automations & Scenes → Blueprints**
(or place the files in your `config/blueprints/automation/` folder),
then create automations from the blueprints, filling the inputs with your
household's choices. The UniFi blueprint needs one automation per child.
then create one automation per blueprint, filling the inputs with your
household's choices.
### Wiring to the integration's notification settings
@@ -100,7 +96,7 @@ The end-of-day report deliberately runs BEFORE the midnight missed
sweep and reads the same day's data — do not schedule it at the
day-rollover time.
### The five blueprints
### The four blueprints
1. **Morning summary** (`morning_summary.yaml`) — at the configured
time, lists every child whose `binary_sensor.nestquest_<child>_present_today`
@@ -128,36 +124,10 @@ day-rollover time.
not re-fire, but a second child clearing the same day still
celebrates.
5. **UniFi quest access** (`unifi_quest_access.yaml`) — create one
automation per child. Select that child's quests-remaining-today
sensor, present-today sensor, and all of their UniFi client access
switches. At 18:25 local
time Monday-Friday or 14:00 Saturday-Sunday, clients with open
quests lose all network access; access returns as soon as the count
reaches zero. When the child is **On travels** (`present today` is off),
access stays on regardless of open quests. An unavailable presence sensor
also leaves access on; verify that sensor before relying on enforcement.
Returning after the cutoff reapplies the rule immediately. At midnight
the old day's quests stop affecting access.
The automation also reconciles after Home Assistant starts and every
five minutes. If the quest sensor is unavailable after the cutoff,
it keeps the child blocked until a valid zero is observed.
First configure the [Home Assistant UniFi Network integration](https://www.home-assistant.io/integrations/unifi/)
with a local account that can manage clients. Under its **Network
access controlled clients** option, add each child's devices by MAC
address, then verify the resulting client access switches manually.
Those switches are **on = allowed** and **off = blocked**. Do not
select gateway, access-point, Home Assistant, or adult-device
switches. To override in an emergency, disable that child's
automation and turn their client switches on; re-enable the
automation to resume the quest rule. Device MAC randomization or
cellular data can bypass a MAC-based Wi-Fi block.
### Example package
```yaml
# Create automations from blueprints — Settings → Automations & Scenes →
# One automation per blueprint — Settings → Automations & Scenes →
# Create → Blueprint, then pick each NestQuest blueprint.
#
# morning_summary: morning_time 08:00, notify_target notify.mobile_app_your_phone
@@ -189,50 +159,9 @@ 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. 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.
is simply a Home Assistant client.
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
1. **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`,
@@ -245,101 +174,66 @@ and no hand-built views are involved.
Assistant user it may also reach other permitted Home Assistant entities
and services.
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.
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):
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.
- **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.
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.
+22 -892
View File
@@ -1,897 +1,27 @@
# NestQuest Release Notes
## Version 0.10.3 — 2026-10-06
### Scope
Patch release carrying the admin PWA desktop-icon update onto `main` (PR #301,
task 55e1e687).
### Behaviour
- The admin PWA's installable icons (192, 512, and 512 maskable) are now
generated from `design/logos/NestQuest_Logo.png` instead of placeholder
graphics, so a home-screen install of the admin app shows the NestQuest logo.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None. Schema unchanged at version 9; no migration, no database backup
required.
### Rollback
- No schema change: rolling back to 0.10.2 requires no database restore.
## Version 0.10.2 — 2026-10-06
### Scope
Patch release carrying the panel SSE idle-timeout fix onto `main` (PR #298, task
6c4b0fa4).
### Behaviour
- The panel event-stream request is no longer subject to aiohttp's default
~300 s total timeout; it now uses an unbounded total with a bounded connect,
so a healthy stream stays open instead of reconnecting every ~5 minutes.
Because the API does not replay transitions onto the Home Assistant event bus,
this also shrinks the windows in which a transition could be missed.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None. Schema unchanged at version 9; no migration, no database backup
required.
### Rollback
- No schema change: rolling back to 0.10.1 requires no database restore.
## Version 0.10.1 — 2026-10-06
### Scope
Patch release carrying the panel SSE stream fix onto `main` (PR #295, task
4cbf2e96).
### Behaviour
- The Home Assistant integration now reads the panel event stream from the
response body (`response.content`), fixing the logged failure
`'async for' requires an object with __aiter__ method, got HassClientResponse`
that logged ~935 times and prevented live panel events (completions,
un-completions, day-complete, missed) from re-firing from the API's SSE
stream. SSE line-buffering and JSON-frame parsing are unchanged.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None. Schema unchanged at version 9; no migration, no database backup
required.
### Known limitations
- Intermittent `GET /api/v1/panel/events failed with HTTP 502` during API
restarts/redeploys is expected (the integration reconnects with backoff).
- The event stream still reconnects periodically because Home Assistant's
shared aiohttp session applies a default total timeout — tracked as a
follow-up; it does not affect correctness.
### Rollback
- No schema change: rolling back to 0.10.0 requires no database restore.
## Version 0.10.0 — 2026-09-30
### Scope
Feature release: choose task icons by typing any Font Awesome Free **solid**
icon name, not just the curated subset. The shared icon registry now embeds the
full Font Awesome Free solid set offline (no CDN — the panel may be offline),
the admin icon picker is searchable with free-text entry and validation, and the
kids' panel renders any Free Solid icon. (Feature `6c681b17`; PRs #289–#292.)
### Behaviour
- **Full Free Solid set**: the panel and admin bundles now carry ~1,422 Font
Awesome Free solid glyphs as inline SVG path data, in addition to the existing
curated quick-pick lists (Lucide and Font Awesome). `fa:<name>` resolves any
Free Solid icon; unknown names still render the fallback glyph.
- **Searchable picker**: the admin definition icon picker's Font Awesome tab has
a search input. Empty shows the curated quick picks; typing filters the full
set into a bounded (≤40 per page) result grid, with Previous / Show more.
- **Free-text entry**: typing an exact Free Solid name selects and stores
`fa:<name>`. A partial, misspelled, Pro, or Brands name is rejected with an
inline message and nothing is saved.
- Stored format is unchanged: `lucide:<name>`, `fa:<name>`, `emoji:<grapheme>`,
or a legacy bare name.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None to the database: schema unchanged at version 9, no migration and no
database backup required.
- Font Awesome **Pro** and **Brands/Regular** icons are NOT included — only the
Free Solid set. A Pro/Brands name is rejected in the picker (and an unknown
`fa:` name falls back on the panel).
### Known limitations
- **Bundle size**: the embedded icon set increases the committed panel bundle to
~1.06 MB raw (~276 kB gzip) and the admin PWA bundle similarly. This is
expected for the full Free Solid set.
- Alias names (e.g. Font Awesome's `fa:home` alias of `fa:house`) are
de-duplicated to the canonical name, so `fa:house` resolves but `fa:home`
falls back.
- The backend still validates only the `fa:<kebab-name>` format; unknown names
are caught in the picker and render as the fallback on the panel.
### Rollback
- No schema change: rolling back to 0.9.8 requires no database restore.
## Version 0.9.8 — 2026-09-30
### Scope
Owner-directed change: the application's timezone is now **manual-only and
admin-authoritative**. Nothing sets it automatically any more; an admin sets it
from the admin app, and the saved value is the single source of local time for
the whole application. This reverses the automatic Home Assistant zone adoption
shipped in 0.9.6 and the admin app's browser-zone auto-set, and supersedes
decision D-018 with D-019. (PRs #282, #283, #285, #286; feature
`a3cd8e27` "Manual application timezone control".)
### Behaviour
- **No automatic timezone setting.** Removed: the API's adoption of Home
Assistant's reported zone (the `X-NestQuest-Timezone` request header and the
`adopt_reported_timezone` path), the integration's reporting of
`hass.config.time_zone`, the admin app's browser-zone auto-detect and
automatic save, and the `timezone_configured` marker.
- **The admin sets it manually**, in the admin app's Preferences: a curated IANA
zone picker plus a free "Other IANA zone" entry, with an explicit choice for
empty ("API host local time"). Nothing is auto-filled or auto-saved.
- **The saved value is authoritative across the whole application**: the panel
snapshot's overdue/today derivation, both panel cards' dates and clocks,
completion timestamps, the missed-sweep scheduler, and the admin reads all use
it. The API publishes the effective zone (`api/host_zone.effective_timezone`:
the saved value, else the API host's own zone) on the panel snapshot and the
admin settings response, so the panel and admin app render in the same zone.
- **The kids' panel now follows the application zone, not the browser.** This
supersedes the panel-local overdue behaviour from 0.9.7; a quest is overdue
when the application clock says so, not when the panel device's clock does.
- **An admin save of the timezone or the day-rollover time re-plans the running
missed-sweep scheduler.**
- The integration tolerates an API snapshot without the timezone field, so the
integration and the API may be upgraded in either order.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None to the database. The schema is unchanged at version 9: no migration and
no database backup is required for this release.
- Behavioural reversal: if a timezone had been auto-set by 0.9.6–0.9.7, the
stored value persists after upgrading (it is simply no longer changed
automatically), so no action is needed unless a different zone is wanted.
- With no timezone saved, the API host's local zone applies (UTC on the standard
deployment). Set the timezone in the admin Preferences for correct
household-local behaviour. A previously auto-set value counts as saved and
will be kept.
### Known limitations
- If no timezone has been saved, the API host zone applies; on the standard
UTC-host deployment, overdue and day boundaries will be wrong until an admin
sets the zone.
- The admin app's timezone picker replaces any zone outside its curated list via
the "Other IANA zone" entry; the value is validated as an IANA name.
### Rollback
- No schema change: rolling back to 0.9.7 requires no database restore.
## Version 0.9.7 — 2026-09-29
### Scope
Patch release carrying the panel-local overdue correction onto `main` (PR #279,
task fd1093b5 "Tasks are still showing over due when the local time has not
passed").
### Behaviour
- The kids' Quest Log card now decides whether an open quest is OVERDUE from
the panel's OWN local clock (the wall time where the panel physically is)
compared with the quest's due time, instead of trusting the API snapshot's
`overdue` flag. A quest therefore cannot read OVERDUE before its due time has
passed on the panel, regardless of the API host's timezone, Home Assistant's
configured `time_zone`, or whether the 0.9.6 timezone changes have been
deployed. A quest is overdue at the due minute and after (`>=`); a quest with
no due time is never overdue.
- The API's `overdue` field is unchanged; it still serves the admin plane and
automations. This is a panel display correction only.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None. The database schema is unchanged at version 9: no migration and no
database backup is required for this release.
### Known limitations
- The comparison uses only wall-clock time. If the API is unreachable across
local midnight and the coordinator serves its last-good snapshot, a quest
from the previous day with a later due time can briefly read as not overdue
until that time passes again. On the normal path the API rolls the household
day over and the missed sweep removes the previous day's open quests.
- The Quest Log header clock still formats in Home Assistant's configured
`time_zone`; if HA is set to a zone other than the household's, that header
remains wrong. Set Home Assistant's own `time_zone` correctly — the admin/API
timezone setting does not affect the header clock.
### Rollback
- No schema change: rolling back to 0.9.6 requires no database restore.
## Version 0.9.6 — 2026-09-28
### Scope
Patch release carrying the household time-zone fix onto `main` (PR #275, task
6351b36d "Time zone errors"), plus its release-review correction (PR #277, task
45243478).
### Behaviour
- The Home Assistant integration now reports `hass.config.time_zone` to the API
in an `X-NestQuest-Timezone` header on every panel request.
- While the API's `timezone` setting has never been explicitly chosen
(`timezone_configured` false), the API adopts and persists the reported zone
(under the settings lock), so the panel's overdue/today calculation, the
admin plane and the missed-sweep scheduler are all household-local even when
the API host runs UTC. No manual setup is required.
- The missed-sweep scheduler re-plans immediately when the adopted zone changes
while it is sleeping, and re-reads the stored zone before sweeping, so it no
longer fires at the old (UTC) rollover.
- An admin-chosen zone — including an explicit choice of empty (API-host local)
— still wins over the reported zone. A timezone-only admin update is recorded
as an explicit choice (`timezone_configured` true); an explicitly supplied
`timezone_configured` value in the same update is honored as given. An invalid
reported zone is logged and ignored; the panel keeps serving on the stored
settings.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None. The database schema is unchanged at version 9: no migration and no
database backup is required for this release.
- Deployment note: this fix needs BOTH the API service and the HA integration
upgraded. The integration supplies the zone and the API adopts it; upgrading
only one side leaves the previous behaviour. An install that sets or clears
its zone on 0.9.6 or later keeps that choice; see Known limitations for a zone
stored by an older release.
### Known limitations
- A zone stored by a release before 0.9.6 without the explicit-choice marker
(for example through the previously documented
`PATCH /api/v1/admin/settings {"timezone": ...}`) is not distinguishable from
an automatically adopted value, so 0.9.6 adopts Home Assistant's reported zone
over it once. If a zone different from Home Assistant's is intended, re-save
it in the admin Preferences, which now records the explicit choice.
- If `timezone` is empty and `timezone_configured` is true (an explicit
"host local" choice), that choice is respected and the API host's zone (UTC
on the standard deployment) still applies until an admin selects a zone in
the admin Preferences.
- A change to Home Assistant's own `time_zone` takes effect after the
integration reloads or Home Assistant restarts.
### Rollback
- No schema change: rolling back to 0.9.5 requires no database restore. The API
and the integration may be rolled back independently; rolling back only the
integration restores the previous stored-setting-only behaviour.
## Version 0.9.5 — 2026-09-27
### Scope
Patch release carrying the kids' panel Return button onto `main`.
- **Return button:** the Quest Log header now shows an explicit Return control —
a left chevron plus "The Party", 72px high and sized to its label, sitting left
of the crest — so a child
can go straight back to the main Party board instead of waiting out the 40 s
idle return. It calls the same navigation the idle return uses
(`board_path` + `location-changed`), so there is exactly one navigation path.
It is hidden when no board path is configured, appears on every header-bearing
Quest Log screen (normal, away, and "no quests today"), and does not appear on
the Quest-complete screen, which keeps its 12 s auto-return. The control meets
the panel's hard rules (72px tap target, 26px label, `:active` feedback only,
clear of the window columns and the dock), and the 40 s idle return and
crest-tap navigation are unchanged. (PR #272.)
### Upgrade note
The database schema is unchanged at version **9**; no migration or backup step is
needed. No runtime dependency is added — only the bundled panel card changed.
### Requirements
Home Assistant 2024.6.0 or newer. Install through HACS from the
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
mirror).
## Version 0.9.4 — 2026-09-26
### Scope
Patch release fixing the admin timezone control that shipped in 0.9.3.
- **Clearing the timezone now persists:** in 0.9.3 the Settings screen
re-detected the browser zone whenever the timezone field was empty, so an admin
could not leave it empty ("use the API host's local time") — closing and
reopening the screen set it again. A new persisted, API-only
`timezone_configured` marker (default `false`) now distinguishes "never set"
from "admin chose empty": the one-time auto-set runs only when the timezone is
empty AND the marker is false, and any saved timezone change — including
clearing it — sets the marker, so an explicit empty choice survives. (PRs #266,
#268.)
- Nothing else changes: the `timezone` setting, its auto-set from the browser zone
on a fresh install, the Add/Save flow, and the rest of the Settings screen are
as in 0.9.3.
### Upgrade note
The database schema is unchanged at version **9**; no migration or backup step is
needed. Settings stored before this release have no `timezone_configured` value
and read it as `false`, so an install whose timezone is empty will auto-set the
browser zone once on the next open — after which the choice persists.
### Requirements
Home Assistant 2024.6.0 or newer. Install through HACS from the
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
mirror).
## Version 0.9.3 — 2026-09-26
### Scope
Patch release carrying the admin timezone control onto `main`.
- **Admins set the system timezone from the PWA:** the Preferences screen gains a
Timezone field (a text input with a local datalist of common IANA zones;
`UTC` or `Area/Location`). On a fresh install the value is set AUTOMATICALLY
from the browser's IANA zone when the setting is empty on first load — no
geolocation prompt and no network lookup — and an admin can change or clear it
at any time. Only a changed value is sent, and the API's 422 for an unknown zone
is shown by the form. (PR #266.)
- This makes the household-local clock (overdue, daily rollover) configurable
from the UI, replacing the manual
`PATCH /api/v1/admin/settings {"timezone": ...}` step noted in 0.9.1. The
setting still defaults to the API host's local time when left empty.
### Upgrade note
The database schema is unchanged at version **9**; no migration or backup step is
needed. No runtime dependency is added.
### Requirements
Home Assistant 2024.6.0 or newer. Install through HACS from the
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
mirror).
## Version 0.9.2 — 2026-09-26
### Scope
Patch release carrying the admin "Delete task" capability onto `main`.
- **Delete a task in the admin PWA:** the definition edit sheet gains an
admin-only Delete action with hybrid semantics (D-017, owner decision "delete
if there's no history, retire/deactivate if there is"). If the definition has
**no completion history**, it is hard-deleted together with its instances,
assignees, windows and its schedule rule (when unreferenced), all in one
transaction under the connection lock. If it has **any completion history** (a
completion or an un-completion), nothing is deleted: it is retired by
deactivation instead, preserving the definition, rule, assignees, windows and
all history, with future uncompleted instances cleared as with any
deactivation. (PR #262.)
- **API:** `DELETE /api/v1/admin/quest-definitions/{id}` lives on the admin plane
(`require_admin`; a non-admin is refused and an unknown id is 404). The response
reports `deleted`/`retired` and carries the retired definition (`null` when
deleted). The kids' panel gains no control, and `QuestDefinitionsDao` now has
exactly one, history-guarded, delete method.
### Upgrade note
The database schema is unchanged at version **9**; no migration or backup step is
needed beyond the 0.8.0 note. No runtime dependency is added.
### Requirements
Home Assistant 2024.6.0 or newer. Install through HACS from the
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
mirror).
## Version 0.9.1 — 2026-09-26
### Scope
Patch release carrying the owner-reported UI-bug fixes for the kids' panel, plus a
household-timezone correction, onto `main`.
- **Household-local time:** a new `timezone` setting (an IANA name such as
`America/New_York`; empty keeps the API host's local time) is threaded into the
API's clock reads, so overdue and the daily rollover use the household's day
rather than a UTC host's. **Set it once after upgrading** (see the Upgrade
note); the admin UI has no field for it yet. (PR #254.)
- **Overdue and rollover correctness:** quests are no longer marked overdue
before their due time, and the missed-sweep scheduler fires at the configured
rollover across DST transitions — its delay is now a true elapsed-time
difference, with documented handling of nonexistent/ambiguous local times.
(PRs #254, #257.)
- **Complete works on the panel:** a TypeScript ES2022 `useDefineForClassFields`
default emitted native class fields that shadowed Lit's reactive accessors, so
the confirm dialog never re-rendered and a tap appeared to do nothing; the
panel now opens the confirm and completes. (PR #254.)
- **Panel polish:** the party-board hint line no longer hides behind the dock
(#244); the Quest Log header crest renders with its shield classes (#252); the
party-board wordmark is the new compass-rose monogram plus Cinzel Decorative
text, replacing the d20 dice (#253); and the quest meta line keeps its `meta`
class when not late (#255).
### Upgrade note
The database schema is unchanged at version **9**. After upgrading, set the
household timezone once so date, overdue and rollover behaviour match the house
(otherwise it falls back to the API host's local time, which may be UTC):
```
PATCH /api/v1/admin/settings {"timezone": "America/New_York"}
```
No runtime dependency is added.
### Requirements
Home Assistant 2024.6.0 or newer. Install through HACS from the
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
mirror).
## Version 0.9.0 — 2026-09-26
### Scope
Minor release carrying expanded, customisable task icons — Lucide, Font Awesome
Free and emoji — onto `main`.
- **Namespaced icon keys:** a quest definition's `icon` now accepts
`lucide:<kebab-name>`, `fa:<kebab-name>` and `emoji:<grapheme>`. A legacy bare
name (e.g. `dog`) is read as `lucide:dog`, so existing rows keep working with
no stored-data migration. The strict write validator rejects an unknown
namespace, a malformed name, an over-cap or markup-bearing emoji payload, and
`javascript:`. (PRs #241, #249.)
- **Shared icon registry:** one canonical set — the curated Lucide set plus a
curated Font Awesome Free solid subset — generates identical,
framework-agnostic registry modules for the admin and panel bundles, guarded by
a drift test. There is no CDN and no runtime fetch, and only the curated Font
Awesome glyphs are shipped. (PR #242.)
- **Admin PWA picker:** the definition edit sheet offers the Lucide set, the
Font Awesome subset and an emoji input; a selection stores the namespaced key
and an existing namespaced or legacy icon pre-fills correctly. The definition
list renders all three forms with a fallback glyph. Editing a definition whose
stored icon is a legacy value no longer resubmits the unchanged icon.
(PRs #243, #250.)
- **Kids' panel:** open quest cards render the definition's icon (Lucide stroked,
Font Awesome filled, emoji as text) with the existing star fallback; the sealed
card keeps its star/seal glyph. (PR #245.)
- **Docs and attribution:** ADMIN-SPEC and PANEL-SPEC describe the namespaced
model; `THIRD-PARTY-NOTICES.md` records Font Awesome Free (icons CC BY 4.0,
fonts SIL OFL 1.1, code MIT) and Lucide (ISC). (PR #246.)
### Upgrade note
The database schema is unchanged at version **9**; no migration or backup step
is needed beyond the 0.8.0 note. This release adds no runtime dependency — the
icon path data is embedded in the shipped bundles at build time.
### Requirements
Home Assistant 2024.6.0 or newer. Install through HACS from the
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
mirror).
## Version 0.8.0 — 2026-09-25
### 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`.
## Unreleased
- **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.
## Version 0.5.0 — 2026-09-18
-31
View File
@@ -1,31 +0,0 @@
# Third-party notices
NestQuest bundles glyph data from the icon sets below. The curated Lucide names and the
Font Awesome quick-pick 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: the full Free **solid** icon set, embedded as SVG path data (a curated
subset doubles as the admin picker's quick picks). 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
@@ -1,13 +0,0 @@
# 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
@@ -1,2 +0,0 @@
node_modules/
dist/
-15
View File
@@ -1,15 +0,0 @@
<!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
@@ -1,29 +0,0 @@
{
"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.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

-14
View File
@@ -1,14 +0,0 @@
{
"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
@@ -1,88 +0,0 @@
/* 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;
}
-170
View File
@@ -1,170 +0,0 @@
// Generates the committed PWA icons under public/icons/ from the brand logo
// (design/logos/NestQuest_Logo.png), box-filter downscaled.
// Pure Node (zlib only) — no network, no image dependencies.
// Run: node scripts/generate-icons.mjs
import { readFileSync, writeFileSync } from "node:fs";
import { deflateSync, inflateSync } from "node:zlib";
import { fileURLToPath } from "node:url";
const SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
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([
SIG,
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw, { level: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
// Minimal decoder for the source logo: 8-bit RGB, non-interlaced only.
// Anything else fails loudly rather than producing a wrong icon.
function decodePng(buf) {
if (!buf.subarray(0, 8).equals(SIG)) throw new Error("not a PNG");
let width, height;
const idat = [];
for (let off = 8; off < buf.length; ) {
const len = buf.readUInt32BE(off);
const type = buf.toString("ascii", off + 4, off + 8);
const data = buf.subarray(off + 8, off + 8 + len);
if (type === "IHDR") {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
const [depth, colour, , , interlace] = data.subarray(8);
if (depth !== 8 || colour !== 2 || interlace !== 0) {
throw new Error(`unsupported PNG: depth ${depth}, colour type ${colour}, interlace ${interlace}`);
}
} else if (type === "IDAT") idat.push(data);
else if (type === "IEND") break;
off += 12 + len;
}
const raw = inflateSync(Buffer.concat(idat));
const stride = width * 3;
const px = Buffer.alloc(stride * height);
for (let y = 0; y < height; y++) {
const filter = raw[y * (stride + 1)];
const src = y * (stride + 1) + 1;
const dst = y * stride;
for (let i = 0; i < stride; i++) {
const a = i >= 3 ? px[dst + i - 3] : 0;
const b = y > 0 ? px[dst - stride + i] : 0;
const c = i >= 3 && y > 0 ? px[dst - stride + i - 3] : 0;
let p;
if (filter === 0) p = 0;
else if (filter === 1) p = a;
else if (filter === 2) p = b;
else if (filter === 3) p = (a + b) >> 1;
else if (filter === 4) {
const pa = Math.abs(b - c), pb = Math.abs(a - c), pc = Math.abs(a + b - 2 * c);
p = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
} else throw new Error(`bad PNG filter ${filter} on row ${y}`);
px[dst + i] = (raw[src + i] + p) & 0xff;
}
}
return { width, height, px };
}
// Box-filter (area-average) resize along one axis: each output sample is the
// coverage-weighted mean of the source samples it spans.
function boxWeights(srcLen, dstLen) {
const scale = srcLen / dstLen;
return Array.from({ length: dstLen }, (_, o) => {
const start = o * scale, end = start + scale;
const taps = [];
for (let s = Math.floor(start); s < Math.min(Math.ceil(end), srcLen); s++) {
taps.push([s, (Math.min(end, s + 1) - Math.max(start, s)) / scale]);
}
return taps;
});
}
function resize({ width, height, px }, size) {
const wx = boxWeights(width, size);
const wy = boxWeights(height, size);
const tmp = new Float64Array(size * height * 3); // horizontal pass
for (let y = 0; y < height; y++) {
for (let x = 0; x < size; x++) {
for (const [s, w] of wx[x]) {
for (let k = 0; k < 3; k++) tmp[(y * size + x) * 3 + k] += px[(y * width + s) * 3 + k] * w;
}
}
}
const out = new Uint8Array(size * size * 3); // vertical pass
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
for (let k = 0; k < 3; k++) {
let v = 0;
for (const [s, w] of wy[y]) v += tmp[(s * size + x) * 3 + k] * w;
out[(y * size + x) * 3 + k] = Math.min(255, Math.round(v));
}
}
}
return (x, y) => out.subarray((y * size + x) * 3, (y * size + x) * 3 + 3);
}
// Mean of the four corner pixels — the logo's solid background colour.
function cornerColour({ width, height, px }) {
const corners = [[0, 0], [width - 1, 0], [0, height - 1], [width - 1, height - 1]];
return [0, 1, 2].map((k) =>
Math.round(corners.reduce((sum, [x, y]) => sum + px[(y * width + x) * 3 + k], 0) / 4),
);
}
// `scale` < 1 shrinks the logo onto a background-coloured canvas so a
// maskable icon keeps the whole emblem inside the safe zone (inner 80% circle).
function logoIcon(logo, size, scale) {
if (scale === 1) return resize(logo, size);
const inner = Math.round(size * scale);
const offset = Math.floor((size - inner) / 2);
const pixel = resize(logo, inner);
const bg = cornerColour(logo);
return (x, y) => {
const ix = x - offset, iy = y - offset;
return ix >= 0 && iy >= 0 && ix < inner && iy < inner ? pixel(ix, iy) : bg;
};
}
const logo = decodePng(readFileSync(new URL("../../design/logos/NestQuest_Logo.png", import.meta.url)));
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.8],
];
for (const [name, size, scale] of icons) {
const path = fileURLToPath(new URL(name, outDir));
writeFileSync(path, encodePng(size, logoIcon(logo, size, scale)));
console.log(`wrote ${path}`);
}
-123
View File
@@ -1,123 +0,0 @@
/* 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);
}
-90
View File
@@ -1,90 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import App from "./App";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
let settingsResponse: () => Promise<Response>;
describe("NestQuest Admin shell", () => {
beforeEach(() => {
settingsResponse = () =>
Promise.resolve(jsonResponse({ timezone: "", effective_timezone: "Europe/London" }));
// The shell loads the stored application timezone; every tab's own
// requests stay pending so the shell tests remain offline.
vi.stubGlobal(
"fetch",
vi.fn((url: string) =>
String(url).endsWith("/api/v1/admin/settings")
? settingsResponse()
: 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", async () => {
render(<App />);
expect(await screen.findByRole("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", async () => {
render(<App />);
fireEvent.click(screen.getByRole("tab", { name: "Tasks" }));
expect(await screen.findByRole("heading", { name: "Tasks" })).toBeTruthy();
expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder");
});
it("renders no screen until the stored application timezone is known", async () => {
settingsResponse = () => new Promise<Response>(() => {});
render(<App />);
expect(screen.getByTestId("timezone-loading")).toBeTruthy();
expect(screen.queryByRole("heading", { name: "Today" })).toBeNull();
});
it("a failed timezone load says so and retries", async () => {
settingsResponse = () => Promise.resolve(jsonResponse({ detail: "boom" }, 500));
render(<App />);
await screen.findByTestId("timezone-error");
expect(screen.queryByRole("heading", { name: "Today" })).toBeNull();
settingsResponse = () =>
Promise.resolve(
jsonResponse({ timezone: "Pacific/Auckland", effective_timezone: "Pacific/Auckland" }),
);
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(await screen.findByRole("heading", { name: "Today" })).toBeTruthy();
});
});
-67
View File
@@ -1,67 +0,0 @@
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 { AppTimezoneProvider } from "./time/AppTimezone";
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}`}
>
{/* Every screen's local time is the stored application timezone. */}
<AppTimezoneProvider>
{active === "today" ? (
<TodayTab />
) : active === "tasks" ? (
<DefinitionsTab />
) : active === "schedule" ? (
<ScheduleTab />
) : (
<HistoryTab />
)}
</AppTimezoneProvider>
</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
@@ -1,193 +0,0 @@
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
@@ -1,211 +0,0 @@
/**
* 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
@@ -1,50 +0,0 @@
/**
* 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
@@ -1,94 +0,0 @@
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
@@ -1,93 +0,0 @@
/**
* 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;
}
-200
View File
@@ -1,200 +0,0 @@
/**
* 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));
}
/** `DELETE /quest-definitions/{id}`: exactly one of `deleted` / `retired` is true. */
export interface DefinitionDeleteResult {
definition_id: number;
deleted: boolean;
retired: boolean;
/** The retired (now inactive) definition; null when it was deleted. */
definition: QuestDefinition | null;
}
/**
* Delete a task if it has no completion history; otherwise the API retires
* (deactivates) it. Retirement preserves the definition row, schedule rule,
* assignees, windows and ALL durable completion history. Like any
* deactivation it regenerates, so future UNCOMPLETED instances are cleared
* (rolling, regenerable rows) and not recreated while inactive; durable
* history lives only in the append-only `completion_events`.
*/
export async function deleteDefinition(id: number): Promise<DefinitionDeleteResult> {
return readJson<DefinitionDeleteResult>(
await apiFetch(`${DEFINITIONS_PATH}/${id}`, { method: "DELETE" }),
);
}
export const OCCURRENCES_PREVIEW_PATH = `${DEFINITIONS_PATH}/occurrences-preview`;
export interface OccurrencesPreviewOptions {
/**
* "YYYY-MM-DD"; the API defaults to the household-local today in the
* configured `timezone` (the API host's local time when that is empty).
*/
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
@@ -1,162 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { waitFor } from "@testing-library/react";
import { EVENTS_PATH, SseParser, openTransitionStream, type TransitionEvent } from "./events";
import { frame, testStream, type TestStream } from "./testStream";
import { storeTokens } from "../auth/oidc";
const COMPLETED = {
child_id: 1,
child_name: "Declan",
instance_id: 12,
quest_title: "Make bed",
window: "morning",
due_date: "2026-09-23",
due_time: null,
occurred_at: "2026-09-23T11:04:00Z",
};
const DAY_COMPLETE = {
child_id: 2,
child_name: "Maeve",
quests_due: 4,
quests_completed: 4,
occurred_at: "2026-09-23T12:00:00Z",
};
describe("SseParser", () => {
it("yields every frame from one chunk", () => {
const parser = new SseParser();
const events = parser.push(
frame("nestquest_quest_completed", COMPLETED) +
frame("nestquest_child_day_complete", DAY_COMPLETE),
);
expect(events).toEqual([
{ type: "nestquest_quest_completed", payload: COMPLETED },
{ type: "nestquest_child_day_complete", payload: DAY_COMPLETE },
]);
});
it("buffers a frame split across two chunks", () => {
const parser = new SseParser();
const text = frame("nestquest_quest_uncompleted", COMPLETED);
const cut = text.indexOf("Make");
expect(parser.push(text.slice(0, cut))).toEqual([]);
expect(parser.push(text.slice(cut))).toEqual([
{ type: "nestquest_quest_uncompleted", payload: COMPLETED },
]);
});
it("buffers a split inside the event name and the blank-line terminator", () => {
const parser = new SseParser();
const text = frame("nestquest_quest_missed", COMPLETED);
expect(parser.push(text.slice(0, 12))).toEqual([]);
expect(parser.push(text.slice(12, -1))).toEqual([]);
expect(parser.push(text.slice(-1))).toEqual([
{ type: "nestquest_quest_missed", payload: COMPLETED },
]);
});
it("treats a CRLF split across chunks as one line break", () => {
const parser = new SseParser();
const text = `event: nestquest_quest_completed\r\ndata: ${JSON.stringify(COMPLETED)}\r\n\r\n`;
const cut = text.indexOf("\n"); // chunk ends on "\r"
expect(parser.push(text.slice(0, cut))).toEqual([]);
expect(parser.push(text.slice(cut))).toEqual([
{ type: "nestquest_quest_completed", payload: COMPLETED },
]);
});
it("drops malformed, unknown and comment frames without throwing", () => {
const parser = new SseParser();
const events = parser.push(
"event: nestquest_quest_completed\ndata: not-json\n\n" +
"event: nestquest_quest_completed\ndata: [1,2]\n\n" +
"event: something_else\ndata: {}\n\n" +
": keep-alive\n\n" +
"data: {}\n\n" +
frame("nestquest_quest_completed", COMPLETED),
);
expect(events).toEqual([{ type: "nestquest_quest_completed", payload: COMPLETED }]);
});
});
describe("openTransitionStream", () => {
let fetchMock: ReturnType<typeof vi.fn>;
let streams: TestStream[];
let stop: (() => void) | null;
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
streams = [];
stop = null;
fetchMock = vi.fn(() => {
const stream = testStream();
streams.push(stream);
return Promise.resolve(stream.response);
});
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
stop?.();
vi.unstubAllGlobals();
});
it("sends the token in the Authorization header, never in the URL", async () => {
stop = openTransitionStream({ onTransition: () => {} });
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toMatch(new RegExp(`${EVENTS_PATH}$`));
expect(String(url)).not.toContain("at-123");
expect(init.headers.Authorization).toBe("Bearer at-123");
expect(init.headers.Accept).toBe("text/event-stream");
});
it("delivers transitions from frames split across chunks", async () => {
const seen: TransitionEvent[] = [];
stop = openTransitionStream({ onTransition: (event) => seen.push(event) });
await waitFor(() => expect(streams).toHaveLength(1));
const text = frame("nestquest_quest_completed", COMPLETED);
streams[0].push(text.slice(0, 20));
streams[0].push(text.slice(20));
await waitFor(() =>
expect(seen).toEqual([{ type: "nestquest_quest_completed", payload: COMPLETED }]),
);
});
it("stop aborts the request and cancels the body", async () => {
stop = openTransitionStream({ onTransition: () => {} });
await waitFor(() => expect(streams).toHaveLength(1));
const signal = fetchMock.mock.calls[0][1].signal as AbortSignal;
stop();
expect(signal.aborted).toBe(true);
await waitFor(() => expect(streams[0].cancelled()).toBe(true));
});
it("reconnects after the stream drops", async () => {
const seen: TransitionEvent[] = [];
stop = openTransitionStream({ onTransition: (event) => seen.push(event), minDelayMs: 5 });
await waitFor(() => expect(streams).toHaveLength(1));
streams[0].close();
await waitFor(() => expect(streams).toHaveLength(2));
streams[1].push(frame("nestquest_quest_missed", COMPLETED));
await waitFor(() => expect(seen).toHaveLength(1));
});
it("does not reconnect after stop, even with a reconnect pending", async () => {
stop = openTransitionStream({ onTransition: () => {}, minDelayMs: 20 });
await waitFor(() => expect(streams).toHaveLength(1));
streams[0].close();
stop();
await new Promise((resolve) => setTimeout(resolve, 60));
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("gives up on a 403 refusal instead of retrying", async () => {
fetchMock.mockImplementation(() => Promise.resolve(new Response("", { status: 403 })));
stop = openTransitionStream({ onTransition: () => {}, minDelayMs: 5 });
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
await new Promise((resolve) => setTimeout(resolve, 40));
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
-196
View File
@@ -1,196 +0,0 @@
/**
* Live transition stream: GET /api/v1/admin/events (text/event-stream).
*
* The admin JWT travels in the Authorization header (via apiFetch), so the
* stream is read with fetch + a ReadableStream reader — EventSource cannot
* set headers, and the token must never go into a URL.
*/
import { ApiForbiddenError, ApiUnauthorizedError, apiFetch } from "./client";
export const EVENTS_PATH = "/api/v1/admin/events";
/** The four documented transitions, as the API names them on the wire. */
export const TRANSITION_TYPES = [
"nestquest_quest_completed",
"nestquest_quest_uncompleted",
"nestquest_quest_missed",
"nestquest_child_day_complete",
] as const;
export type TransitionType = (typeof TRANSITION_TYPES)[number];
/** Quest transitions carry the instance; a day-complete carries the child's counts. */
export interface QuestTransitionPayload {
child_id: number;
child_name: string | null;
instance_id: number;
quest_title: string | null;
window: string;
due_date: string;
due_time: string | null;
occurred_at: string;
}
export interface ChildDayCompletePayload {
child_id: number;
child_name: string | null;
quests_due: number;
quests_completed: number;
occurred_at: string;
}
export type TransitionEvent =
| {
type:
| "nestquest_quest_completed"
| "nestquest_quest_uncompleted"
| "nestquest_quest_missed";
payload: QuestTransitionPayload;
}
| { type: "nestquest_child_day_complete"; payload: ChildDayCompletePayload };
function isTransitionType(value: string): value is TransitionType {
return (TRANSITION_TYPES as readonly string[]).includes(value);
}
/**
* Incremental SSE frame parser. Feed it decoded text in whatever chunks the
* network delivers; it buffers a partial line or frame until it completes.
* Comments (keep-alives), unknown event types and frames whose data is not a
* JSON object are dropped, never thrown.
*/
export class SseParser {
private buffer = "";
private eventType = "";
private data: string[] = [];
/** A chunk ended on "\r": a leading "\n" in the next chunk is the same line break. */
private pendingCr = false;
push(text: string): TransitionEvent[] {
if (this.pendingCr && text.startsWith("\n")) text = text.slice(1);
this.pendingCr = text.endsWith("\r");
this.buffer += text;
const lines = this.buffer.split(/\r\n|\r|\n/);
this.buffer = lines.pop() ?? "";
const events: TransitionEvent[] = [];
for (const line of lines) {
const event = this.line(line);
if (event) events.push(event);
}
return events;
}
private line(line: string): TransitionEvent | null {
if (line === "") return this.dispatch();
if (line.startsWith(":")) return null;
const colon = line.indexOf(":");
const field = colon === -1 ? line : line.slice(0, colon);
let value = colon === -1 ? "" : line.slice(colon + 1);
if (value.startsWith(" ")) value = value.slice(1);
if (field === "event") this.eventType = value;
else if (field === "data") this.data.push(value);
return null;
}
private dispatch(): TransitionEvent | null {
const type = this.eventType;
const data = this.data.join("\n");
this.eventType = "";
this.data = [];
if (!isTransitionType(type)) return null;
let payload: unknown;
try {
payload = JSON.parse(data);
} catch {
return null;
}
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
return { type, payload } as TransitionEvent;
}
}
export interface TransitionStreamOptions {
onTransition: (event: TransitionEvent) => void;
/** Reconnect delay after a drop: starts at `minDelayMs`, doubles up to `maxDelayMs`. */
minDelayMs?: number;
maxDelayMs?: number;
}
function wait(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(done, ms);
signal.addEventListener("abort", done, { once: true });
function done() {
clearTimeout(timer);
signal.removeEventListener("abort", done);
resolve();
}
});
}
/** Read one connection until the body ends (or the signal aborts). */
async function readOnce(
signal: AbortSignal,
onTransition: (event: TransitionEvent) => void,
): Promise<void> {
const response = await apiFetch(EVENTS_PATH, {
signal,
headers: { Accept: "text/event-stream" },
});
if (!response.ok || !response.body) {
throw new Error(`Event stream request failed (HTTP ${response.status}).`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
const parser = new SseParser();
const cancel = () => void reader.cancel().catch(() => {});
signal.addEventListener("abort", cancel, { once: true });
try {
for (;;) {
const { value, done } = await reader.read();
if (done || signal.aborted) break;
for (const event of parser.push(decoder.decode(value, { stream: true }))) {
if (signal.aborted) break;
onTransition(event);
}
}
} finally {
signal.removeEventListener("abort", cancel);
reader.releaseLock();
}
}
/**
* Subscribe to the admin transition stream, reconnecting with a bounded
* backoff after a drop. Returns a stop function that aborts the open request
* and any pending reconnect. A 401/403 stops the stream for good: apiFetch
* has already started a login (401) or the account is refused (403).
*/
export function openTransitionStream({
onTransition,
minDelayMs = 1_000,
maxDelayMs = 30_000,
}: TransitionStreamOptions): () => void {
const controller = new AbortController();
const { signal } = controller;
void (async () => {
let delay = minDelayMs;
while (!signal.aborted) {
try {
await readOnce(signal, onTransition);
// A connection that was accepted and then ended starts the backoff over.
delay = minDelayMs;
} catch (error: unknown) {
if (signal.aborted) return;
if (error instanceof ApiUnauthorizedError || error instanceof ApiForbiddenError) return;
// Dropped or refused connection: fall through to the backoff.
}
if (signal.aborted) return;
await wait(delay, signal);
delay = Math.min(delay * 2, maxDelayMs);
}
})();
return () => controller.abort();
}
-85
View File
@@ -1,85 +0,0 @@
/**
* Typed client for the admin history route (/api/v1/admin/history): the
* append-only event log over a closed due-date range for one filter.
*/
import { apiFetch } from "./client";
import { ApiRequestError, readJson } from "./definitions";
export type HistoryFilter = "all" | "reversals" | "missed";
export type HistoryEventType = "completed" | "uncompleted" | "missed";
export interface HistoryRow {
/** UTC ISO stamp; null for a derived missed row (there is no event). */
occurred_at: string | null;
event_type: HistoryEventType;
child_name: string;
child_id: number;
quest_title: string;
instance_id: number;
window: string;
/** "YYYY-MM-DD", the day the task was owed. */
due_date: string;
/** Local wall-clock "HH:MM", or null. */
due_time: string | null;
/** e.g. "panel (Declan)", "joshua (admin)", "nightly sweep". */
actor: string;
was_on_time: boolean | null;
}
export interface HistoryResponse {
filter: HistoryFilter;
start: string;
end: string;
count: number;
rows: HistoryRow[];
}
export const HISTORY_PATH = "/api/v1/admin/history";
/** `start`/`end` are inclusive "YYYY-MM-DD" due dates. */
export async function fetchHistory(
filter: HistoryFilter,
start: string,
end: string,
): Promise<HistoryResponse> {
const params = new URLSearchParams({ start, end, filter });
return readJson<HistoryResponse>(await apiFetch(`${HISTORY_PATH}?${params.toString()}`));
}
export const HISTORY_CSV_PATH = "/api/v1/admin/history.csv";
export interface HistoryCsv {
blob: Blob;
filename: string;
}
/** The `filename` from a Content-Disposition header, or null. */
export function csvFilename(disposition: string | null): string | null {
const match = disposition?.match(/filename="?([^";]+)"?/i);
return match ? match[1].trim() : null;
}
/**
* The CSV export of the same range and filter as `fetchHistory`. Goes through
* apiFetch so the admin token travels in the Authorization header.
*/
export async function exportHistoryCsv(
filter: HistoryFilter,
start: string,
end: string,
): Promise<HistoryCsv> {
const params = new URLSearchParams({ start, end, filter });
const response = await apiFetch(`${HISTORY_CSV_PATH}?${params.toString()}`);
if (!response.ok) {
// readJson raises ApiRequestError with the API's `detail` for a non-OK response.
await readJson<never>(response);
throw new ApiRequestError(response.status, null);
}
return {
blob: await response.blob(),
filename:
csvFilename(response.headers.get("Content-Disposition")) ??
`nestquest-history-${start}-to-${end}.csv`,
};
}
-23
View File
@@ -1,23 +0,0 @@
/**
* Typed client for the admin instance mutation route
* (POST /api/v1/admin/instances/{id}/uncomplete).
*/
import { apiFetch } from "./client";
import { readJson } from "./definitions";
export interface UncompleteResult {
instance_id: number;
/** The derived state after the reversal: "open", or "missed" when past due. */
state: string;
/** False when the instance was already open (a no-op). */
appended: boolean;
}
export function uncompletePath(instanceId: number): string {
return `/api/v1/admin/instances/${instanceId}/uncomplete`;
}
/** Reverse one completion; raises ApiRequestError with the API's detail on failure. */
export async function uncompleteInstance(instanceId: number): Promise<UncompleteResult> {
return readJson<UncompleteResult>(await apiFetch(uncompletePath(instanceId), { method: "POST" }));
}
-146
View File
@@ -1,146 +0,0 @@
/**
* 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" }),
);
}
-46
View File
@@ -1,46 +0,0 @@
/**
* 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;
/** IANA zone name (e.g. "America/New_York"); "" means the API host's local time. */
timezone: string;
/**
* Read-only: the zone the API reads its clock in — `timezone`, or the API
* host's own zone when that is "" ("" only when the host's zone has no name).
*/
effective_timezone: string;
}
/** Only the supplied fields are updated; the API answers with all of them. */
export type SettingsChanges = Partial<Omit<HouseholdSettings, "effective_timezone">>;
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
@@ -1,55 +0,0 @@
/**
* 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
@@ -1,39 +0,0 @@
/**
* Test helper: a controllable text/event-stream Response whose body the test
* feeds chunk by chunk (so a frame can be split across chunk boundaries).
*/
export interface TestStream {
response: Response;
push(text: string): void;
close(): void;
/** True once the reader cancelled the body (the stream was torn down). */
cancelled(): boolean;
}
export function testStream(): TestStream {
const encoder = new TextEncoder();
let controller!: ReadableStreamDefaultController<Uint8Array>;
let cancelled = false;
const body = new ReadableStream<Uint8Array>({
start(c) {
controller = c;
},
cancel() {
cancelled = true;
},
});
return {
response: new Response(body, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
}),
push: (text) => controller.enqueue(encoder.encode(text)),
close: () => controller.close(),
cancelled: () => cancelled,
};
}
/** One SSE frame exactly as api/sse.py writes it. */
export function frame(type: string, payload: unknown): string {
return `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`;
}
-61
View File
@@ -1,61 +0,0 @@
/**
* One live transition stream per signed-in shell, fanned out to the screens.
*
* <TransitionsProvider> opens the stream on mount and aborts it on unmount;
* useTransitions(handler) registers a screen's handler for as long as the
* screen is mounted. Without a provider the hook does nothing, so a screen
* rendered on its own (e.g. in a test) never opens a stream.
*/
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
import { openTransitionStream, type TransitionEvent } from "./events";
type Listener = (event: TransitionEvent) => void;
interface TransitionHub {
subscribe(listener: Listener): () => void;
}
const TransitionsContext = createContext<TransitionHub | null>(null);
export function TransitionsProvider({ children }: { children: ReactNode }) {
const [hub] = useState(() => {
const listeners = new Set<Listener>();
return {
listeners,
subscribe(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
});
useEffect(
() =>
openTransitionStream({
onTransition: (event) => {
for (const listener of [...hub.listeners]) {
try {
listener(event);
} catch {
// One screen's handler must not tear down the shared stream.
}
}
},
}),
[hub],
);
return <TransitionsContext.Provider value={hub}>{children}</TransitionsContext.Provider>;
}
/** Call `handler` for every transition while the calling component is mounted. */
export function useTransitions(handler: Listener): void {
const hub = useContext(TransitionsContext);
const handlerRef = useRef(handler);
useEffect(() => {
handlerRef.current = handler;
});
useEffect(() => hub?.subscribe((event) => handlerRef.current(event)), [hub]);
}
-173
View File
@@ -1,173 +0,0 @@
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
@@ -1,225 +0,0 @@
/**
* 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
@@ -1,41 +0,0 @@
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
@@ -1,55 +0,0 @@
/**
* 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
@@ -1,215 +0,0 @@
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
@@ -1,221 +0,0 @@
/**
* 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,
};
}
-647
View File
@@ -1,647 +0,0 @@
/* 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-save--danger {
background: var(--nq-a-danger);
}
.defs-delete {
display: block;
width: 100%;
margin-top: 10px;
padding: 11px 0;
border: 1px solid var(--nq-a-danger-bg);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-danger-strong);
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.defs-delete:disabled,
.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;
}
/* Font Awesome search: glyph + label tiles, inline validation, paging */
.defs-icon-grid--results {
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
}
.defs-icon-result {
flex-direction: column;
gap: 4px;
padding: 6px 4px;
font-family: inherit;
}
.defs-icon-label {
max-width: 100%;
overflow: hidden;
font-size: 11px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.defs-hint--error {
color: var(--nq-a-danger-strong);
}
.defs-show-more {
align-self: flex-start;
display: flex;
gap: 8px;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-135
View File
@@ -1,135 +0,0 @@
/**
* The icons a definition's `icon` may name (ADMIN-SPEC §3.3): the curated
* Lucide set, any icon in the full Font Awesome Free Solid set, or an emoji.
* The Font Awesome quick picks (`FA_DEFINITION_ICONS`) are only a curated
* shortcut list; `fa:<name>` resolves against the full set
* (`FA_SOLID_ICONS`). The names, labels and path data come from the shared
* icon registry (`src/icons/registry.generated.ts`, generated by
* `tools/icons/generate.mjs` from `tools/icons/curated-icons.json` and the
* installed Free Solid package) — 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,
FA_SOLID_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 Font Awesome Free Solid icons whose name or label contains `query`
* (trimmed, case-insensitive): an exact name first, then name-prefix
* matches, then the rest, each in registry (name) order. An empty query
* gives no results — the curated quick picks cover that case.
*/
export function searchFaIcons(query: string): DefinitionIconEntry[] {
const q = query.trim().toLowerCase();
if (!q) return [];
const rank = (icon: DefinitionIconEntry) => (icon.name === q ? 0 : icon.name.startsWith(q) ? 1 : 2);
return FA_SOLID_ICONS.filter(
(icon) => icon.name.includes(q) || icon.label.toLowerCase().includes(q),
).sort((a, b) => rank(a) - rank(b));
}
/**
* The stored key for a typed Font Awesome name, e.g. `Dog` → `fa:dog`: the
* trimmed, lower-cased text must be the exact name of a Free Solid icon,
* else null (a misspelling, a partial search, or a Pro/Brands name).
*/
export function faTypedIconKey(text: string): string | null {
const key = faIconKey(text.trim().toLowerCase());
return resolveIcon(key).kind === "fa" ? key : null;
}
/**
* Why Font Awesome search text cannot be saved as it stands: it matches
* icons but names none of them, or it matches nothing in the Free Solid set.
*/
export function faTextProblem(text: string): string {
return searchFaIcons(text).length > 0
? "Select a Font Awesome icon from the results, or type its full name."
: `“${text.trim()}” is not a Font Awesome Free Solid icon.`;
}
/** 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 glyph an icon value resolves to via the shared parser: a curated
* Lucide entry or any Font Awesome Free Solid icon (quick pick or not);
* 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
@@ -1,114 +0,0 @@
/**
* 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
@@ -1,67 +0,0 @@
/**
* 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
@@ -1,296 +0,0 @@
/* History tab — compact density (design/ADMIN-SPEC.md §5). Mobile-first at
the 390x844 reference; on wider viewports the column is capped and centred. */
.history-screen {
width: 100%;
max-width: 640px;
margin: 0 auto;
}
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--nq-a-surface);
border-bottom: 1px solid var(--nq-a-border);
padding: 14px 16px;
}
.history-title {
margin: 0;
font-size: var(--nq-a-size-screen);
font-weight: 800;
letter-spacing: -0.02em;
}
.history-sub {
margin: 2px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.history-csv {
flex: none;
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 36px;
padding: 0 12px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
}
.history-csv:disabled {
cursor: progress;
opacity: 0.6;
}
/* Non-blocking CSV export failure; the tab stays usable. */
.history-export-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: var(--nq-a-density-row-pad);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-danger-bg);
color: var(--nq-a-danger-strong);
font-size: 13px;
font-weight: 600;
}
.history-export-dismiss {
flex: none;
padding: 4px 8px;
border: none;
background: none;
color: inherit;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
}
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
.history-scroll {
display: flex;
flex-direction: column;
gap: var(--nq-a-density-gap);
padding: 14px 16px;
}
.history-card {
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
}
/* Stat row */
.history-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.history-stat {
padding: 12px;
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
}
.history-stat-value {
font-size: 20px;
font-weight: 800;
line-height: 1.1;
}
.history-stat-value--on-time {
color: var(--nq-brand-blue);
}
.history-stat-value--missed {
color: var(--nq-a-danger);
}
.history-stat-label {
margin-top: 2px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--nq-a-ink-secondary);
}
/* Filter chips */
.history-chips {
display: flex;
gap: 6px;
overflow-x: auto;
}
.history-chip {
flex: none;
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-radius-pill);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.history-chip--active {
border-color: #18181d;
background: #18181d;
color: #fff;
}
/* Day-grouped event rows */
.history-day {
display: flex;
flex-direction: column;
gap: 6px;
}
.history-section-label {
margin: 4px 0 0;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--nq-a-ink-secondary);
}
.history-list {
margin: 0;
padding: 0;
list-style: none;
}
.history-list > li + li {
border-top: 1px solid var(--nq-a-divider);
}
.history-event {
display: flex;
align-items: center;
gap: 10px;
min-height: var(--nq-a-tap-min);
padding: var(--nq-a-density-row-pad);
}
.history-dot {
flex: none;
width: 7px;
height: 7px;
border-radius: 50%;
}
.history-dot--completed {
background: var(--nq-a-success);
}
.history-dot--uncompleted {
background: var(--nq-a-reversal);
}
.history-dot--missed {
background: var(--nq-a-danger);
}
.history-event-text {
flex: 1;
min-width: 0;
}
.history-event-title {
font-size: 12.5px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-event-meta {
margin-top: 1px;
font-size: 10.5px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.history-event-time {
flex: none;
font-size: 11px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.history-empty {
padding: var(--nq-a-density-card-pad);
font-size: 13px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
/* Append-only footer strip */
.history-note {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-page);
color: var(--nq-a-ink-secondary);
font-size: 11.5px;
font-weight: 600;
}
.history-note-glyph {
flex: none;
display: flex;
}
/* Loading / error */
.history-status {
padding: 24px 13px;
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
text-align: center;
font-size: 13.5px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.history-status--error {
color: var(--nq-a-danger-strong);
}
.history-status--error p {
margin: 0 0 10px;
}
.history-retry {
padding: 8px 16px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
-746
View File
@@ -1,746 +0,0 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import HistoryTab, { FOOTER_NOTE, computeStats } from "./HistoryTab";
import type { HistoryFilter, HistoryRow } from "../api/history";
import { storeTokens } from "../auth/oidc";
import { TransitionsProvider } from "../api/useTransitions";
import { frame, testStream, type TestStream } from "../api/testStream";
import { AppTimezoneProvider } from "../time/AppTimezone";
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);
});
});
describe("HistoryTab in the stored application timezone", () => {
// 13:00 UTC on Sep 23 is 01:00 on Sep 24 in Auckland (NZST, UTC+12).
const NOW = new Date("2026-09-23T13:00:00Z");
// Due 00:10 Auckland on Sep 24, done 00:30 Auckland (12:30 UTC on Sep 23).
const LATE_NZ = row({
instance_id: 7,
quest_title: "Feed cat",
due_date: "2026-09-24",
due_time: "00:10",
occurred_at: "2026-09-23T12:30:00Z",
was_on_time: false,
});
// 22:00 Auckland on Sep 23; 10:00 UTC.
const EVENING_NZ = row({ instance_id: 8, occurred_at: "2026-09-23T10:00:00Z" });
let fetchMock: ReturnType<typeof vi.fn>;
function historyRequests(): URL[] {
return fetchMock.mock.calls
.map(([url]) => new URL(String(url), "http://x"))
.filter((url) => url.pathname === "/api/v1/admin/history");
}
async function renderWithZone(timezone: string, effective_timezone = timezone) {
fetchMock = vi.fn((url: string) => {
const parsed = new URL(url, "http://x");
if (parsed.pathname.endsWith("/api/v1/admin/settings")) {
return Promise.resolve(jsonResponse({ timezone, effective_timezone }));
}
const filter = parsed.searchParams.get("filter");
const rows = filter === "all" ? [EVENING_NZ, LATE_NZ] : [];
return Promise.resolve(
jsonResponse({
filter,
start: parsed.searchParams.get("start"),
end: parsed.searchParams.get("end"),
count: rows.length,
rows,
}),
);
});
vi.stubGlobal("fetch", fetchMock);
render(
<AppTimezoneProvider>
<HistoryTab />
</AppTimezoneProvider>,
);
await screen.findByTestId("stat-on-time");
}
function times(section: HTMLElement): string[] {
return Array.from(section.querySelectorAll(".history-event-time")).map(
(el) => el.textContent ?? "",
);
}
beforeAll(() => {
vi.stubEnv("TZ", "UTC");
});
afterAll(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
vi.useFakeTimers({ toFake: ["Date"], now: NOW });
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});
it.each([
["the stored zone", "Pacific/Auckland"],
["the API host's published zone when none is stored", ""],
])("today, day grouping, clock and lateness follow %s", async (_label, stored) => {
await renderWithZone(stored, "Pacific/Auckland");
for (const url of historyRequests()) {
expect(url.searchParams.get("end")).toBe("2026-09-24");
expect(url.searchParams.get("start")).toBe("2026-09-11");
}
const today = screen.getByTestId("day-2026-09-24");
expect(within(today).getByText("Today")).toBeTruthy();
expect(times(today)).toEqual(["12:30 AM"]);
expect(metas(today)).toEqual(["completed · panel (Declan) · late 20 min"]);
const yesterday = screen.getByTestId("day-2026-09-23");
expect(within(yesterday).getByText("Yesterday")).toBeTruthy();
expect(times(yesterday)).toEqual(["10:00 PM"]);
});
it("only an API that publishes no zone leaves the browser's local day and clock", async () => {
await renderWithZone("", "");
for (const url of historyRequests()) {
expect(url.searchParams.get("end")).toBe("2026-09-23");
}
expect(screen.queryByTestId("day-2026-09-24")).toBeNull();
const today = screen.getByTestId("day-2026-09-23");
expect(within(today).getByText("Today")).toBeTruthy();
expect(times(today)).toEqual(["12:30 PM", "10:00 AM"]);
// Due 00:10 UTC on Sep 24 is after the 12:30 UTC completion: no minutes.
expect(metas(today)).toEqual([
"completed · panel (Declan) · late",
"completed · panel (Declan) · on time",
]);
});
});
-304
View File
@@ -1,304 +0,0 @@
/**
* History tab — the append-only event log (design/ADMIN-SPEC.md §5).
*
* Rows are read-only: events are never edited or deleted, and a reversal is
* its own row (D-005).
*/
import { useEffect, useRef, useState } from "react";
import { ApiForbiddenError } from "../api/client";
import type { TransitionEvent } from "../api/events";
import { useTransitions } from "../api/useTransitions";
import { exportHistoryCsv, fetchHistory, type HistoryFilter, type HistoryRow } from "../api/history";
import { dayLabel, groupByDay, rowMeta, rowTime, shiftIso } from "./format";
import { DownloadGlyph, LockGlyph } from "./glyphs";
import { useAppTimezone } from "../time/AppTimezone";
import { todayIn } from "../time/zone";
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, timeZone }: { row: HistoryRow; timeZone: string }) {
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, timeZone)}
</div>
</div>
<span className="history-event-time">{rowTime(row, timeZone)}</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>
);
}
/** `today` ("YYYY-MM-DD") defaults to today in the application timezone. */
export default function HistoryTab({ today: todayProp }: { today?: string }) {
const timeZone = useAppTimezone();
const today = todayProp ?? todayIn(timeZone);
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, timeZone);
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} timeZone={timeZone} />
))}
</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>
);
}
-91
View File
@@ -1,91 +0,0 @@
/**
* Day grouping and row wording for the History tab.
*
* Calendar dates ("YYYY-MM-DD") are handled by their components (never
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
* day in negative-offset zones); event stamps are UTC and shown in the
* application timezone (`timeZone`: the stored IANA zone, "" = browser).
*/
import type { HistoryRow } from "../api/history";
import { dayNumber, isoFromDayNumber } from "../schedule/cycle";
import { shortDate } from "../schedule/format";
import { formatClockTime, formatDueTime } from "../today/format";
import { isoDateIn, wallClockInstant } from "../time/zone";
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, timeZone: string): string {
return row.occurred_at ? isoDateIn(new Date(row.occurred_at), timeZone) : row.due_date;
}
/** The row's due moment (epoch ms) as local wall-clock time, or null without a due time. */
function dueMoment(row: HistoryRow, timeZone: string): number | null {
const match = /^(\d{2}):(\d{2})/.exec(row.due_time ?? "");
if (!match) return null;
return wallClockInstant(row.due_date, Number(match[1]), Number(match[2]), timeZone);
}
/** Newest first; a missed row sorts at its due moment (or the end of its day). */
function sortKey(row: HistoryRow, timeZone: string): number {
if (row.occurred_at) return Date.parse(row.occurred_at);
return dueMoment(row, timeZone) ?? wallClockInstant(row.due_date, 23, 59, timeZone);
}
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[], timeZone: string): DayGroup[] {
const groups = new Map<string, HistoryRow[]>();
for (const row of rows) {
const day = rowDay(row, timeZone);
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, timeZone) - sortKey(a, timeZone)),
}));
}
/** "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, timeZone: string): 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, timeZone);
if (due === null || !row.occurred_at) return "late";
const minutes = Math.round((Date.parse(row.occurred_at) - due) / 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, timeZone: string): string {
const flag = onTimeFlag(row, timeZone);
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, timeZone: string): string {
if (row.occurred_at) return formatClockTime(row.occurred_at, timeZone);
return row.due_time ? formatDueTime(row.due_time) : "";
}
-41
View File
@@ -1,41 +0,0 @@
/**
* Inline stroke glyphs for the History tab (Lucide-style 24px grid),
* CDN-free like the other tabs' glyphs.
*/
import type { ReactNode } from "react";
function Glyph({ size, children }: { size: number; children: ReactNode }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
);
}
export function DownloadGlyph({ size = 15 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />
</Glyph>
);
}
export function LockGlyph({ size = 15 }: { size?: number }) {
return (
<Glyph size={size}>
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</Glyph>
);
}
-29
View File
@@ -1,29 +0,0 @@
/**
* 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>
);
}
File diff suppressed because it is too large Load Diff
-170
View File
@@ -1,170 +0,0 @@
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,
FA_SOLID_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);
}
const solidNames = FA_SOLID_ICONS.map((icon) => icon.name);
expect(solidNames.length).toBeGreaterThanOrEqual(1400);
expect(solidNames).toEqual([...solidNames].sort());
expect(new Set(solidNames).size).toBe(solidNames.length);
for (const { name } of FA_ICONS) expect(solidNames, name).toContain(name);
for (const icon of FA_SOLID_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, ...FA_SOLID_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"));
// Any Free Solid name resolves, not just the curated quick-pick set.
const rocket = resolveIcon("fa:rocket");
expect(rocket).toBe(FA_SOLID_ICONS.find((icon) => icon.name === "rocket"));
expect(rocket).toMatchObject({ kind: "fa", key: "fa:rocket", label: "Rocket" });
expect(resolveIcon("fa:person-dress-burst")).toMatchObject({ kind: "fa", label: "Person Dress Burst" });
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 generated registry 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
@@ -1,76 +0,0 @@
/* 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
@@ -1,13 +0,0 @@
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
@@ -1,396 +0,0 @@
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
@@ -1,13 +0,0 @@
/**
* 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
@@ -1,229 +0,0 @@
/* 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;
}
-540
View File
@@ -1,540 +0,0 @@
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" });
}
// The shell loads the application timezone first ("" = none published).
if (path.endsWith("/admin/settings")) {
return jsonResponse({ timezone: "", effective_timezone: "" });
}
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
@@ -1,353 +0,0 @@
/**
* 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
@@ -1,255 +0,0 @@
/**
* 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
@@ -1,363 +0,0 @@
/* 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;
}
-710
View File
@@ -1,710 +0,0 @@
import { afterAll, afterEach, beforeAll, 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 { AppTimezoneProvider } from "../time/AppTimezone";
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();
});
});
describe("ScheduleTab today in the stored application timezone", () => {
async function renderWithZone(timezone: string, effective_timezone = timezone) {
fetchMock = vi.fn(async (url: string, init?: RequestInit) =>
String(url).endsWith("/api/v1/admin/settings")
? jsonResponse({ timezone, effective_timezone })
: routeFetch(url, init),
);
vi.stubGlobal("fetch", fetchMock);
render(
<AppTimezoneProvider>
<ScheduleTab />
</AppTimezoneProvider>,
);
await screen.findByTestId("pattern-1");
}
beforeAll(() => {
vi.stubEnv("TZ", "UTC");
});
afterAll(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
patterns = { 1: [DECLAN_HOME], 2: [MAEVE_THU_FRI, MAEVE_WEEKENDS], 3: [] };
writeFailure = null;
// 13:00 UTC on Sep 23 is already 01:00 on Sep 24 in Auckland.
vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-09-23T13:00:00Z") });
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("marks today in the stored zone", async () => {
await renderWithZone("Pacific/Auckland");
expect(day("2026-09-24").className).toContain("schedule-day--today");
expect(day("2026-09-23").className).not.toContain("schedule-day--today");
});
it("an empty stored zone marks today in the API host's published zone", async () => {
await renderWithZone("", "Pacific/Auckland");
expect(day("2026-09-24").className).toContain("schedule-day--today");
expect(day("2026-09-23").className).not.toContain("schedule-day--today");
});
});
-589
View File
@@ -1,589 +0,0 @@
/**
* 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,
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 { useAppTimezone } from "../time/AppTimezone";
import { todayIn } from "../time/zone";
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 today in the application timezone. */
today?: string;
}
export default function ScheduleTab({ today: todayProp }: ScheduleTabProps) {
const timeZone = useAppTimezone();
const today = todayProp ?? todayIn(timeZone);
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
@@ -1,191 +0,0 @@
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");
});
});
-156
View File
@@ -1,156 +0,0 @@
/**
* 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);
}
/** "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
@@ -1,37 +0,0 @@
/**
* 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
@@ -1,94 +0,0 @@
/**
* 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>
);
}
-373
View File
@@ -1,373 +0,0 @@
/* 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-hint {
margin: 0;
font-size: 12.5px;
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;
}
-843
View File
@@ -1,843 +0,0 @@
import { StrictMode } from "react";
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";
import { AppTimezoneProvider, useAppTimezone } from "../time/AppTimezone";
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,
timezone: "America/Chicago",
effective_timezone: "America/Chicago",
};
/** The API host's own zone: the effective zone while none is stored. */
const API_HOST_ZONE = "Europe/London";
/** The settings as the API answers: `effective_timezone` derived like the API's. */
function settingsBody(): HouseholdSettings {
return { ...settings, effective_timezone: settings.timezone || API_HOST_ZONE };
}
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(settingsBody());
if (path === SETTINGS_PATH && method === "PATCH") {
settings = { ...settings, ...body };
return jsonResponse(settingsBody());
}
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 eleven settings form 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, "Timezone").value).toBe("America/Chicago");
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 timezone picked from the list patches exactly the timezone", async () => {
const { form } = await renderPreferences();
fireEvent.change(field(form, "Timezone"), { target: { value: "America/New_York" } });
save(form);
await screen.findByText("Preferences saved.");
expect(writes()).toEqual([
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } },
]);
const reloaded = screen.getByRole("form", { name: "Preferences" });
expect(field(reloaded, "Timezone").value).toBe("America/New_York");
});
it.each(["New York", "America/New York", "EST5EDT?", "/Chicago"])(
"an invalid other timezone %j is rejected before any request",
async (value) => {
const { form } = await renderPreferences();
fireEvent.change(field(form, "Timezone"), { target: { value: "__other__" } });
fireEvent.change(field(form, "Other timezone"), { target: { value } });
save(form);
expect(screen.getByTestId("preferences-message").textContent).toBe(
"Timezone must be an IANA zone like America/New_York, or UTC.",
);
expect(field(form, "Other timezone").value).toBe(value);
expect(writes()).toEqual([]);
},
);
it("a 422 on an unknown timezone shows the API detail and keeps the edit", async () => {
const { form } = await renderPreferences();
fireEvent.change(field(form, "Timezone"), { target: { value: "__other__" } });
fireEvent.change(field(form, "Other timezone"), { target: { value: "Mars/Olympus_Mons" } });
writeResponse = () =>
jsonResponse({ detail: "timezone must be an IANA time zone name such as 'America/New_York'" }, 422);
save(form);
const message = await screen.findByTestId("preferences-message");
expect(message.textContent).toBe(
"The preferences could not be saved: timezone must be an IANA time zone name such as 'America/New_York'",
);
expect(field(form, "Other timezone").value).toBe("Mars/Olympus_Mons");
expect(field(form, "Other timezone").disabled).toBe(false);
expect(settings.timezone).toBe("America/Chicago");
});
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" });
});
});
describe("SettingsScreen — no timezone auto-set", () => {
let zoneSpy: ReturnType<typeof vi.spyOn> | null;
function mockBrowserZone(timeZone: string | undefined) {
const real = Intl.DateTimeFormat.prototype.resolvedOptions;
zoneSpy = vi
.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions")
.mockImplementation(function (this: Intl.DateTimeFormat) {
return { ...real.call(this), timeZone } as Intl.ResolvedDateTimeFormatOptions;
});
}
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
children = [child(1, "Declan", 1)];
// A fresh install: no zone stored.
settings = { ...DEFAULT_SETTINGS, timezone: "" };
writeResponse = null;
zoneSpy = null;
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
zoneSpy?.mockRestore();
vi.unstubAllGlobals();
});
async function renderForm() {
render(<SettingsScreen onClose={vi.fn()} />);
await screen.findByTestId("settings-child-1");
return screen.findByRole("form", { name: "Preferences" });
}
it("an empty timezone stays empty on load and nothing is written", async () => {
mockBrowserZone("America/New_York");
const form = await renderForm();
expect(field(form, "Timezone").value).toBe("");
expect(writes()).toEqual([]);
expect(settings.timezone).toBe("");
});
it("StrictMode's doubled effects write nothing either", async () => {
mockBrowserZone("America/New_York");
render(
<StrictMode>
<SettingsScreen onClose={vi.fn()} />
</StrictMode>,
);
const form = await screen.findByRole("form", { name: "Preferences" });
expect(field(form, "Timezone").value).toBe("");
expect(writes()).toEqual([]);
});
it("a stored timezone is shown as stored, never replaced by the browser zone", async () => {
mockBrowserZone("America/New_York");
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin" };
const form = await renderForm();
expect(field(form, "Timezone").value).toBe("Europe/Dublin");
expect(writes()).toEqual([]);
});
it("saving another field sends only that field, never a timezone", async () => {
mockBrowserZone("America/New_York");
const form = await renderForm();
fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } });
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
await screen.findByText("Preferences saved.");
expect(writes().map((w) => w.body)).toEqual([{ horizon_days: 21 }]);
expect(settings.timezone).toBe("");
});
it("an admin clearing the timezone saves exactly the empty zone", async () => {
mockBrowserZone("America/New_York");
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin" };
const form = await renderForm();
fireEvent.change(field(form, "Timezone"), { target: { value: "" } });
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
await screen.findByText("Preferences saved.");
expect(writes().map((w) => w.body)).toEqual([{ timezone: "" }]);
expect(field(screen.getByRole("form", { name: "Preferences" }), "Timezone").value).toBe("");
});
});
describe("SettingsScreen — manual timezone control", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
children = [child(1, "Declan", 1)];
settings = { ...DEFAULT_SETTINGS, timezone: "" };
writeResponse = null;
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
async function renderForm() {
render(<SettingsScreen onClose={vi.fn()} />);
await screen.findByTestId("settings-child-1");
return screen.findByRole("form", { name: "Preferences" });
}
function save(form: HTMLElement) {
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
}
/** The Timezone control, which must be a real select, not a free-text box. */
function picker(form: HTMLElement): HTMLSelectElement {
const control = within(form).getByRole("combobox", { name: "Timezone" });
expect(control).toBeInstanceOf(HTMLSelectElement);
return control as HTMLSelectElement;
}
it("is a picker of curated zones with host-local and other-zone choices", async () => {
const form = await renderForm();
const select = picker(form);
const values = Array.from(select.options).map((o) => o.value);
expect(values[0]).toBe("");
expect(select.options[0].textContent).toBe("API host local time (no zone set)");
for (const zone of [
"UTC",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Phoenix",
"America/Los_Angeles",
"America/Anchorage",
"Pacific/Honolulu",
]) {
expect(values).toContain(zone);
}
expect(select.options[select.options.length - 1].textContent).toBe("Other IANA zone…");
expect(select.value).toBe("");
expect(within(form).queryByLabelText("Other timezone")).toBeNull();
});
it("explains that it is the application timezone for every surface", async () => {
const form = await renderForm();
const help = document.getElementById(picker(form).getAttribute("aria-describedby") ?? "");
expect(help?.textContent).toMatch(/application timezone/);
expect(help?.textContent).toMatch(/panel, the scheduler and this admin app/);
expect(help?.textContent).toMatch(/API host's local time/);
});
it("a picked zone saves via PATCH and is shown after a reload", async () => {
const form = await renderForm();
expect(writes()).toEqual([]);
fireEvent.change(picker(form), { target: { value: "America/New_York" } });
save(form);
await screen.findByText("Preferences saved.");
expect(writes()).toEqual([
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } },
]);
cleanup();
const reloaded = await renderForm();
expect(picker(reloaded).value).toBe("America/New_York");
expect(writes()).toHaveLength(1);
});
it("an other zone typed by hand saves via PATCH and is shown after a reload", async () => {
const form = await renderForm();
fireEvent.change(picker(form), { target: { value: "__other__" } });
const entry = within(form).getByLabelText("Other timezone") as HTMLInputElement;
expect(entry.value).toBe("");
fireEvent.change(entry, { target: { value: " America/Boise " } });
save(form);
await screen.findByText("Preferences saved.");
expect(writes()).toEqual([
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/Boise" } },
]);
cleanup();
const reloaded = await renderForm();
expect(picker(reloaded).value).toBe("__other__");
expect((within(reloaded).getByLabelText("Other timezone") as HTMLInputElement).value).toBe(
"America/Boise",
);
});
it("a saved zone outside the list is shown in the other-zone entry on load", async () => {
settings = { ...DEFAULT_SETTINGS, timezone: "Asia/Kathmandu" };
const form = await renderForm();
expect(picker(form).value).toBe("__other__");
expect((within(form).getByLabelText("Other timezone") as HTMLInputElement).value).toBe(
"Asia/Kathmandu",
);
expect(writes()).toEqual([]);
});
it("choosing API host local time clears the zone and persists after a reload", async () => {
settings = { ...DEFAULT_SETTINGS, timezone: "America/Denver" };
const form = await renderForm();
expect(picker(form).value).toBe("America/Denver");
fireEvent.change(picker(form), { target: { value: "" } });
save(form);
await screen.findByText("Preferences saved.");
expect(writes().map((w) => w.body)).toEqual([{ timezone: "" }]);
cleanup();
const reloaded = await renderForm();
expect(picker(reloaded).value).toBe("");
});
it("picking a zone and then the saved one again sends nothing", async () => {
settings = { ...DEFAULT_SETTINGS, timezone: "UTC" };
const form = await renderForm();
fireEvent.change(picker(form), { target: { value: "Europe/Paris" } });
fireEvent.change(picker(form), { target: { value: "UTC" } });
save(form);
expect(screen.getByTestId("preferences-message").textContent).toBe("No changes to save.");
expect(writes()).toEqual([]);
});
it("never sends a timezone_configured field", async () => {
const form = await renderForm();
fireEvent.change(picker(form), { target: { value: "America/Los_Angeles" } });
save(form);
await screen.findByText("Preferences saved.");
const again = screen.getByRole("form", { name: "Preferences" });
fireEvent.change(picker(again), { target: { value: "" } });
save(again);
await waitFor(() => expect(writes()).toHaveLength(2));
for (const write of writes()) {
expect(Object.keys(write.body as object)).toEqual(["timezone"]);
expect(write.body).not.toHaveProperty("timezone_configured");
}
});
});
describe("SettingsScreen — the saved zone applies app-wide", () => {
function ZoneProbe() {
return <output data-testid="app-zone">{useAppTimezone() || "(browser)"}</output>;
}
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
children = [child(1, "Declan", 1)];
settings = { ...DEFAULT_SETTINGS, timezone: "" };
writeResponse = null;
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("a saved zone becomes the application timezone at once", async () => {
render(
<AppTimezoneProvider>
<ZoneProbe />
<SettingsScreen onClose={vi.fn()} />
</AppTimezoneProvider>,
);
const form = await screen.findByRole("form", { name: "Preferences" });
// No zone stored: the application runs in the API host's zone.
expect(screen.getByTestId("app-zone").textContent).toBe(API_HOST_ZONE);
fireEvent.change(within(form).getByRole("combobox", { name: "Timezone" }), {
target: { value: "America/New_York" },
});
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
await screen.findByText("Preferences saved.");
expect(screen.getByTestId("app-zone").textContent).toBe("America/New_York");
});
});
-911
View File
@@ -1,911 +0,0 @@
/**
* 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,
* timezone, notifications). Save patches only the changed fields. The
* timezone — the application's source of local time — is only ever set by
* the admin picking or typing a zone here and saving it; every zone loaded
* or saved here is published app-wide (time/AppTimezone.tsx).
*
* 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 { usePublishAppTimezone } from "../time/AppTimezone";
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$/;
/** "UTC" or an IANA-looking "Area/Location" (the API checks it resolves). */
const IANA_ZONE = /^(UTC|[A-Za-z]+(\/[A-Za-z0-9_+-]+)+)$/;
/** The Timezone picker's zones; any other IANA zone can be typed via "Other". */
export const COMMON_TIMEZONES = [
"UTC",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Phoenix",
"America/Los_Angeles",
"America/Anchorage",
"Pacific/Honolulu",
"America/Halifax",
"America/St_Johns",
"America/Toronto",
"America/Vancouver",
"America/Mexico_City",
"America/Sao_Paulo",
"Europe/London",
"Europe/Dublin",
"Europe/Paris",
"Europe/Berlin",
"Europe/Madrid",
"Europe/Rome",
"Europe/Athens",
"Africa/Johannesburg",
"Asia/Dubai",
"Asia/Kolkata",
"Asia/Singapore",
"Asia/Shanghai",
"Asia/Tokyo",
"Australia/Perth",
"Australia/Sydney",
"Pacific/Auckland",
];
/** The picker's "Other IANA zone…" option; never sent to the API. */
const OTHER_ZONE = "__other__";
/** 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 timezone = draft.timezone.trim();
if (timezone && !IANA_ZONE.test(timezone)) {
return "Timezone must be an IANA zone like America/New_York, or UTC.";
}
const next: HouseholdSettings = {
...draft,
horizon_days: horizonDays,
notify_target: draft.notify_target.trim(),
timezone,
};
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 }));
// A saved zone outside the curated list opens in the free-text entry.
const [otherZone, setOtherZone] = useState(
() => settings.timezone !== "" && !COMMON_TIMEZONES.includes(settings.timezone),
);
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")}
<div className="settings-field">
<label className="settings-label" htmlFor="settings-timezone">
Timezone
</label>
<select
id="settings-timezone"
className="settings-input"
aria-describedby="settings-timezone-help"
value={otherZone ? OTHER_ZONE : draft.timezone}
disabled={disabled}
onChange={(event) => {
const choice = event.target.value;
setOtherZone(choice === OTHER_ZONE);
if (choice !== OTHER_ZONE) update({ timezone: choice });
}}
>
<option value="">API host local time (no zone set)</option>
{COMMON_TIMEZONES.map((zone) => (
<option key={zone} value={zone}>
{zone}
</option>
))}
<option value={OTHER_ZONE}>Other IANA zone…</option>
</select>
{otherZone ? (
<input
className="settings-input"
type="text"
aria-label="Other timezone"
placeholder="Area/Location, e.g. America/Boise"
autoComplete="off"
spellCheck={false}
value={draft.timezone}
disabled={disabled}
onChange={(event) => update({ timezone: event.target.value })}
/>
) : null}
<p className="settings-hint" id="settings-timezone-help">
The application timezone: the source of local time for the panel, the scheduler and this
admin app. It changes only when you save it here; with no zone set, the API host&apos;s
local time is used.
</p>
</div>
<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);
const publishTimezone = usePublishAppTimezone();
// 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) return;
setPreferences({ kind: "ready", settings });
publishTimezone(settings.effective_timezone);
})
.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 });
publishTimezone(settings.effective_timezone);
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
@@ -1,56 +0,0 @@
/**
* 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>
);
}
-93
View File
@@ -1,93 +0,0 @@
/**
* The effective application timezone, loaded once per signed-in shell: the
* API's `effective_timezone` — the stored zone, or the API host's own zone
* when none is stored — so every screen's local time matches the API's.
*
* <AppTimezoneProvider> fetches the household settings and renders its
* screens only once the zone is known (a failed load says so and offers a
* retry), so no screen ever computes local time in a zone it will not keep.
* The Settings screen publishes every zone it loads or saves, so a change
* applies app-wide at once.
*
* Without a provider (a screen rendered on its own, e.g. in a test)
* useAppTimezone() is "" — the documented unset value, i.e. browser-local.
*/
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { ApiForbiddenError } from "../api/client";
import { fetchSettings } from "../api/settings";
interface ZoneHub {
timeZone: string;
publish(timeZone: string): void;
}
const AppTimezoneContext = createContext<ZoneHub | null>(null);
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; timeZone: string };
export function AppTimezoneProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
setState({ kind: "loading" });
fetchSettings()
.then((settings) => {
if (!cancelled) setState({ kind: "ready", timeZone: settings.effective_timezone });
})
.catch((error: unknown) => {
if (cancelled) return;
setState({
kind: "error",
message:
error instanceof ApiForbiddenError
? error.message
: "The application timezone could not be loaded. Check your connection and try again.",
});
});
return () => {
cancelled = true;
};
}, [attempt]);
if (state.kind === "loading") {
return (
<div className="admin-loading" role="status" data-testid="timezone-loading">
Loading…
</div>
);
}
if (state.kind === "error") {
return (
<div className="admin-error" role="alert" data-testid="timezone-error">
<p>{state.message}</p>
<button type="button" onClick={() => setAttempt((n) => n + 1)}>
Try again
</button>
</div>
);
}
const hub: ZoneHub = {
timeZone: state.timeZone,
publish: (timeZone) =>
setState((prev) =>
prev.kind === "ready" && prev.timeZone === timeZone ? prev : { kind: "ready", timeZone },
),
};
return <AppTimezoneContext.Provider value={hub}>{children}</AppTimezoneContext.Provider>;
}
/** The effective application timezone: an IANA name, or "" (browser-local). */
export function useAppTimezone(): string {
return useContext(AppTimezoneContext)?.timeZone ?? "";
}
/** Publish an effective zone just loaded or saved; a no-op without a provider. */
export function usePublishAppTimezone(): (timeZone: string) => void {
const hub = useContext(AppTimezoneContext);
return (timeZone) => hub?.publish(timeZone);
}
-60
View File
@@ -1,60 +0,0 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { isoDateIn, todayIn, wallClockInstant, zoneOption } from "./zone";
import { formatClockTime } from "../today/format";
// 13:00 UTC on Sep 23 is already 01:00 on Sep 24 in Auckland (NZST, UTC+12).
const NOW = new Date("2026-09-23T13:00:00Z");
describe("application timezone helpers (process in UTC)", () => {
beforeAll(() => {
vi.stubEnv("TZ", "UTC");
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("a stored zone decides the calendar date; empty means the browser's zone", () => {
expect(todayIn("Pacific/Auckland", NOW)).toBe("2026-09-24");
expect(isoDateIn(NOW, "Pacific/Auckland")).toBe("2026-09-24");
expect(todayIn("", NOW)).toBe("2026-09-23");
});
it("wall-clock time converts in the stored zone, across its DST change", () => {
// NZST (UTC+12) before 27 Sep 2026, NZDT (UTC+13) after.
expect(wallClockInstant("2026-09-24", 0, 10, "Pacific/Auckland")).toBe(
Date.parse("2026-09-23T12:10:00Z"),
);
expect(wallClockInstant("2026-09-28", 7, 30, "Pacific/Auckland")).toBe(
Date.parse("2026-09-27T18:30:00Z"),
);
expect(wallClockInstant("2026-09-24", 0, 10, "")).toBe(Date.parse("2026-09-24T00:10:00Z"));
});
it("the completion clock reads in the stored zone", () => {
expect(formatClockTime("2026-09-23T11:04:00Z", "Pacific/Auckland")).toBe("11:04 PM");
expect(formatClockTime("2026-09-23T11:04:00Z", "")).toBe("11:04 AM");
});
it("an invalid stored zone fails fast", () => {
expect(zoneOption("")).toBeUndefined();
expect(() => todayIn("Not/AZone", NOW)).toThrow(RangeError);
});
});
describe("empty zone keeps browser-local behavior", () => {
beforeAll(() => {
vi.stubEnv("TZ", "America/Los_Angeles");
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("matches the browser's own calendar and wall clock", () => {
// 13:00 UTC is 06:00 PDT on Sep 23.
expect(todayIn("", NOW)).toBe("2026-09-23");
expect(wallClockInstant("2026-09-23", 7, 30, "")).toBe(new Date(2026, 8, 23, 7, 30).getTime());
expect(formatClockTime("2026-09-23T14:04:00Z", "")).toBe("7:04 AM");
});
});
-81
View File
@@ -1,81 +0,0 @@
/**
* Local time in the application timezone.
*
* The admin-saved zone (settings `timezone`, an IANA name) is the single
* source of local-time truth for the whole application; the API publishes it
* as `effective_timezone`, naming its host's own zone when none is stored.
* "" means the API published no zone, and only then does the browser's own
* zone apply. Every helper takes
* that stored value as-is; an invalid zone name throws (Intl's RangeError).
*/
/** The `timeZone` option for Intl: the zone, or undefined (browser) for "". */
export function zoneOption(timeZone: string): string | undefined {
return timeZone === "" ? undefined : timeZone;
}
interface WallClock {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
}
function wallClock(instant: number, timeZone: string): WallClock {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: zoneOption(timeZone),
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).formatToParts(instant);
const part = (type: Intl.DateTimeFormatPartTypes) =>
Number(parts.find((p) => p.type === type)?.value);
return {
year: part("year"),
month: part("month"),
day: part("day"),
hour: part("hour"),
minute: part("minute"),
second: part("second"),
};
}
/** The "YYYY-MM-DD" calendar date of `instant` in the zone. */
export function isoDateIn(instant: Date, timeZone: string): string {
const { year, month, day } = wallClock(instant.getTime(), timeZone);
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
/** Today's calendar date in the zone. */
export function todayIn(timeZone: string, now: Date = new Date()): string {
return isoDateIn(now, timeZone);
}
/** The zone's UTC offset in ms at `instant` (wall clock minus UTC). */
function offsetAt(instant: number, timeZone: string): number {
const c = wallClock(instant, timeZone);
const wholeSeconds = Math.floor(instant / 1000) * 1000;
return Date.UTC(c.year, c.month - 1, c.day, c.hour, c.minute, c.second) - wholeSeconds;
}
/**
* Epoch ms at which the zone's wall clock reads `hour:minute` on the
* "YYYY-MM-DD" date. Two passes settle the offset across a DST change.
*/
export function wallClockInstant(
iso: string,
hour: number,
minute: number,
timeZone: string,
): number {
const [year, month, day] = iso.split("-").map(Number);
const asUtc = Date.UTC(year, month - 1, day, hour, minute);
const guess = asUtc - offsetAt(asUtc, timeZone);
return asUtc - offsetAt(guess, timeZone);
}
-331
View File
@@ -1,331 +0,0 @@
/* 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;
}
-468
View File
@@ -1,468 +0,0 @@
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";
import { AppTimezoneProvider } from "../time/AppTimezone";
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));
});
});
describe("TodayTab in the stored application timezone", () => {
function renderWithZone(timezone: string, effective_timezone = timezone) {
fetchMock.mockImplementation((url: string) =>
Promise.resolve(
String(url).endsWith("/api/v1/admin/settings")
? jsonResponse({ timezone, effective_timezone })
: jsonResponse(SNAPSHOT),
),
);
render(
<AppTimezoneProvider>
<TodayTab />
</AppTimezoneProvider>,
);
}
beforeAll(() => {
// The browser runs in UTC; the household lives in Auckland (UTC+12 on Sep 23).
vi.stubEnv("TZ", "UTC");
});
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("shows the completion clock in the stored zone, not the browser's", async () => {
renderWithZone("Pacific/Auckland");
const completed = await screen.findByTestId("attention-12");
// 11:04 UTC is 11:04 PM in Auckland.
expect(within(completed).getByText("Completed 11:04 PM")).toBeTruthy();
// The header's day is the API's household-local today_iso, as-is.
expect(screen.getByText("Wednesday, September 23 · cycle day 5 of 14")).toBeTruthy();
});
it("an empty stored zone shows the clock in the API host's published zone", async () => {
renderWithZone("", "Pacific/Auckland");
const completed = await screen.findByTestId("attention-12");
expect(within(completed).getByText("Completed 11:04 PM")).toBeTruthy();
});
it("only an API that publishes no zone leaves the browser's local clock", async () => {
renderWithZone("", "");
const completed = await screen.findByTestId("attention-12");
expect(within(completed).getByText("Completed 11:04 AM")).toBeTruthy();
});
});
-367
View File
@@ -1,367 +0,0 @@
/**
* 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 { useAppTimezone } from "../time/AppTimezone";
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;
const timeZone = useAppTimezone();
// 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, timeZone)}
</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>
);
}
-56
View File
@@ -1,56 +0,0 @@
/**
* 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. Instants are shown in the application
* timezone (time/zone.ts).
*/
import { zoneOption } from "../time/zone";
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 wall-clock time in `timeZone` ("" = browser), "7:04 AM". */
export function formatClockTime(isoTimestamp: string, timeZone: string): string {
return format(new Date(isoTimestamp), {
hour: "numeric",
minute: "2-digit",
timeZone: zoneOption(timeZone),
});
}
-61
View File
@@ -1,61 +0,0 @@
/**
* 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
@@ -1,16 +0,0 @@
{
"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
@@ -1,93 +0,0 @@
/* 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
@@ -1,10 +0,0 @@
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
@@ -1,83 +0,0 @@
# NestQuest API
Standalone FastAPI service owning the NestQuest database (Feature 16).
Served with ``uvicorn --factory api.app:create_app``; configuration is
environment-supplied (``api/config.py``) and every variable is required.
## Runtime dependencies
``api/requirements.txt`` is the API deployable's OWN dependency
manifest (fastapi, uvicorn, pyjwt[crypto], httpx) — deliberately kept
out of the integration's ``pyproject.toml`` dev group, which exists only
so the monorepo test venv can run the API tests.
## Container deployment (scaffold)
The image is built from the repository ROOT context (the API loads the
bundled domain core from ``custom_components/nestquest/core/`` by file
location, so both trees must be in the build context):
docker build -t nestquest-api:latest .
Run it with compose — the SQLite file lives on the named volume
``nestquest-db`` so recreating the container keeps household data:
cp deploy/.env.example deploy/.env # then fill in real values
docker compose -f deploy/compose.yaml up -d --build
``deploy/.env`` is git-ignored and supplies the secrets (panel service
token, Authentik OIDC settings); ``NESTQUEST_DB_PATH`` is fixed to
``/var/lib/nestquest/nestquest.db`` by compose.
The published port binds ``NESTQUEST_BIND_ADDRESS`` from
``deploy/.env`` and defaults to ``127.0.0.1`` (localhost only). In
production TLS is terminated by the shared Traefik reverse proxy,
which serves ``https://nestquest.cubecraftlabs.com`` (Let's Encrypt,
auto-renewed, HTTP redirected to HTTPS) and forwards to the API box
on port 8080 — so on that box set ``NESTQUEST_BIND_ADDRESS`` to its
LAN address (``10.60.1.14``). Traefik only allows
``/api/v1/panel`` from the Home Assistant host. The full deployment,
restore and verification procedure is in ``deploy/RUNBOOK.md``.
## Continuous backup (Litestream)
Compose also runs a ``litestream`` sidecar
(``litestream/litestream:0.5.17``, config ``deploy/litestream.yml``).
It mounts the same ``nestquest-db`` volume and continuously replicates
``/var/lib/nestquest/nestquest.db`` over SFTP to the Synology NAS
``CubeCraft-SRVR1`` (``joshua@10.60.1.50``), directory
``/volume1/homes/Joshua/nestquest-backups`` (``/homes/Joshua/...`` in
the NAS's chrooted SFTP view) — so the backup is outside the Docker
host and its volume. A snapshot is taken daily and a week of
snapshots plus incremental changes is retained; the NAS host key is
pinned in the config.
The SFTP login uses a dedicated keypair that lives only on the API
box and is git-ignored. Create it once, before ``up``:
ssh-keygen -t ed25519 -N "" -C nestquest-litestream -f deploy/litestream_key
then append ``restrict `` + the contents of ``deploy/litestream_key.pub``
to ``joshua@10.60.1.50:~/.ssh/authorized_keys``. Compose mounts the
private key read-only at ``/etc/litestream/sftp_key``. The sidecar runs
as uid/gid 999 (the API's ``nestquest`` user), so give it the key:
sudo chown 999:999 deploy/litestream_key && chmod 600 deploy/litestream_key
The sidecar needs WRITE access to the database volume: Litestream adds
its own ``_litestream_seq``/``_litestream_lock`` tables to the database,
writes the ``-wal``/``-shm`` files and checkpoints the WAL, and fails
with "unable to open database file" on a read-only mount. That access
is contained rather than removed — the sidecar runs as the non-root
volume owner with a read-only root filesystem, ``no-new-privileges``
and all Linux capabilities dropped.
Check replication with
``docker compose -f deploy/compose.yaml logs litestream`` or
``docker compose -f deploy/compose.yaml exec litestream litestream ltx /var/lib/nestquest/nestquest.db``.
To restore (high level): stop the ``api`` service, run
``litestream restore -config /etc/litestream.yml -o <new path>
/var/lib/nestquest/nestquest.db`` from a litestream container with the
same mounts, verify the restored file, move it into place on the
volume (removing any stale ``-wal``/``-shm`` files), and start ``api``
again.
-19
View File
@@ -1,19 +0,0 @@
"""NestQuest API service.
A standalone FastAPI deployable that owns the NestQuest database and
exposes it over HTTP to the Home Assistant integration (panel plane) and
the admin PWA (admin plane). See Feature 16 (task cd03735b) in the
project tracker for the full contract.
This package imports the NestQuest domain ``core`` WITHOUT executing
``custom_components/nestquest/__init__.py`` — that parent package's
``__init__.py`` imports ``homeassistant``, which is absent from the
API's runtime. The coupling is encapsulated in
:mod:`api.nestquest_core`, which loads ``core`` by file location with
:mod:`importlib.util` and registers it under the distinct top-level name
``nestquest_core`` (no ``sys.path`` mutation, no generic top-level
names published). It is a TRANSITIONAL coupling that Feature 18 will
revisit: once the integration becomes a thin API client and ``core``
moves out of the integration bundle, this helper goes away.
"""
from __future__ import annotations
-139
View File
@@ -1,139 +0,0 @@
"""The NestQuest FastAPI application.
This module builds the ASGI app for the NestQuest API service (Feature
16). The skeleton here wires the bundled ``core`` (Feature 15) and
exposes the two endpoints every later task depends on:
- ``GET /health`` — returns ``200`` with a JSON status body once the
app has imported core, opened the database, and applied migrations;
a cheap ``SELECT 1`` confirms the connection is live without leaking
the filesystem path on an unauthenticated route.
- ``GET /api/v1/panel/snapshot`` — the panel plane's today snapshot
(service-token protected; see :mod:`api.routes_panel`, whose router
carries the shared :func:`api.dependencies.require_panel_token`
check).
- ``GET /api/v1/admin/ping`` — the admin plane's auth probe, protected
by the Authentik OIDC JWT check (see :mod:`api.routes_admin`, whose
router carries the shared :func:`api.auth.require_admin_jwt`
dependency; the JWKS document it verifies against is cached on
``app.state.jwks_cache``).
- ``GET /docs`` — FastAPI's auto-served OpenAPI document (Swagger UI);
``/openapi.json`` is the raw schema. No extra wiring is needed;
FastAPI serves both by default.
The database is opened and migrated in a FastAPI lifespan context so a
startup failure (e.g. an unwritable path) surfaces as a startup error
rather than the first request. The connection is closed on shutdown.
This module exports ONLY the :func:`create_app` factory — there is no
module-level app instance, because building one at import time would
read ``NESTQUEST_DB_PATH`` at import (and fail-fast on a missing value)
before any worker is ready to report it. Serve with::
uvicorn --factory api.app:create_app
so the factory runs on each worker startup and reads the environment
then.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from fastapi import FastAPI
from api.auth import JwksCache
from api.config import ApiConfig
from api.database import DatabaseState, make_database
from api.events import TransitionPublisher
from api.routes_admin import router as admin_router
from api.routes_panel import router as panel_router
from api.scheduler import MissedSweepScheduler
def _build_lifespan(db_path: str):
"""Return a lifespan that opens+migrates then closes one database.
Captured as a closure so the app factory can hand the configured
path in without threading it through FastAPI's lifespan signature.
The lifespan also installs the app's ONE in-process transition
publisher on ``app.state.publisher`` (see :mod:`api.events`) so
every route and SSE subscriber shares that single instance, and
starts the daily missed-sweep scheduler
(:class:`api.scheduler.MissedSweepScheduler`) against the same
database and publisher — cancelled before the connection closes on
shutdown.
"""
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
state = make_database(db_path)
app.state.db = state
app.state.publisher = TransitionPublisher()
scheduler = MissedSweepScheduler(
state.database, publisher=app.state.publisher
)
app.state.sweep_scheduler = scheduler
try:
await state.open_and_migrate()
scheduler.start()
yield
finally:
await scheduler.stop()
await state.close()
return lifespan
def create_app(config: ApiConfig | None = None) -> FastAPI:
"""Build the NestQuest API FastAPI app.
``config`` defaults to one built from the process environment, so
``uvicorn --factory api.app:create_app`` picks up
``NESTQUEST_DB_PATH`` (and fails fast on startup if it is missing).
Tests pass an explicit config so each test gets a hermetic temp
SQLite file.
"""
cfg = config if config is not None else ApiConfig.from_env()
app = FastAPI(
title="NestQuest API",
description=(
"Standalone HTTP service that owns the NestQuest database and "
"exposes it to the Home Assistant integration (panel plane) and "
"the admin PWA (admin plane)."
),
version="0.1.0",
lifespan=_build_lifespan(cfg.db_path),
)
# The resolved config rides on app.state so the reusable route
# dependencies (the panel service-token check, the admin JWT check)
# read the configured values from the app rather than module state.
app.state.config = cfg
# The admin plane's ONE per-app JWKS cache: fetched from the
# configured provider on first use, then reused until its TTL
# lapses (see api.auth.JwksCache). Tests replace it with a
# stub-backed cache so no request ever leaves the process.
app.state.jwks_cache = JwksCache(cfg.oidc_jwks_url)
app.include_router(panel_router)
app.include_router(admin_router)
@app.get("/health", summary="Service health")
async def health() -> dict[str, str]:
"""Return service health.
Returns ``200`` with ``{"status": "ok"}`` once the lifespan has
opened and migrated the database. A cheap ``SELECT 1`` against
the live connection confirms the database is reachable WITHOUT
leaking the filesystem path on an unauthenticated route; if the
connection is dead the query raises and the probe fails.
"""
state: DatabaseState = app.state.db
await state.database.fetch_one("SELECT 1")
return {"status": "ok"}
return app
__all__ = ["create_app"]
-490
View File
@@ -1,490 +0,0 @@
"""Admin-plane authentication for the NestQuest API service.
The admin plane (``/api/v1/admin/*``, D-012) is authenticated by
Authentik-issued OIDC access tokens: the admin PWA presents its JWT as
a Bearer credential and :func:`require_admin_jwt` — the ONE
implementation of that check — verifies it before any admin route runs.
Verification enforces, without exception:
- the signature, against the RSA signing key published in the
provider's JWKS document (fetched from the configured
``NESTQUEST_OIDC_JWKS_URL`` and cached per app — never per request);
- the issuer (``iss`` must equal the configured
``NESTQUEST_OIDC_ISSUER``);
- the audience (``aud`` must carry the configured
``NESTQUEST_OIDC_AUDIENCE``);
- expiry (``exp`` is required and enforced, and ``nbf`` is enforced
when the token carries it).
Every rejection raises the SAME :class:`~fastapi.HTTPException` with
the same detail — nothing in the response distinguishes which check
failed. The check FAILS CLOSED: a missing app config, a malformed or
missing ``Authorization`` header, a non-RS256 algorithm, an unknown
``kid``, and any PyJWT verification error all yield the one 401.
The token's header (``alg``, ``kid``) is parsed and checked BEFORE the
JWKS cache is consulted, so structurally invalid tokens can never
amplify load on the provider. An unknown ``kid`` forces ONE
TTL-bypassing refetch (throttled, see :class:`JwksCache`) so a rotated
key is picked up without waiting out the TTL, and a JWKS entry that
matches the ``kid`` but carries corrupt key material maps to the same
401. A JWKS FETCH failure is deliberately NOT mapped to 401: the
provider being unreachable is a server-side fault (an HTTP 500
surfaces) and never poisons the cache, so the plane stays closed while
the real cause is visible in the server log instead of disguised as a
bad credential; a short backoff window after a failed fetch fails fast
(re-raising the recorded fault) rather than fetching once per request.
Beyond authentication, :func:`require_admin` is the ONE dependency
every admin route uses: it REJECTS the panel service token outright —
that credential authenticates the panel plane only, so treating it as
a JWT candidate would blur two credential types into one check — and
then requires the verified JWT's ``groups`` claim to name the
``nestquest-admins`` group (authorization, 403, distinct from the
uniform authentication 401 so the two failure classes stay apart).
"""
from __future__ import annotations
import asyncio
import binascii
import json
import secrets
import time
from collections.abc import Awaitable, Callable
from typing import Annotated
import httpx
import jwt
from fastapi import Header, HTTPException, Request
#: The authorization scheme the admin plane expects. Matched
#: case-insensitively, as RFC 9110 requires for scheme names.
_BEARER_SCHEME = "bearer"
#: The ONLY JWT signing algorithm accepted. Pinned to one asymmetric
#: algorithm (Authentik's RS256 default) so neither the header's ``alg``
#: nor any JWKS key can talk the verifier into a weaker or symmetric
#: scheme; PyJWT re-enforces it at decode time.
_ALLOWED_ALGORITHM = "RS256"
#: The 401 detail shared by every rejection — deliberately uniform so
#: the response never reveals which part of the check failed.
_REJECTION_DETAIL = "Missing or invalid admin credentials"
#: Challenge header returned with a 401, per the Bearer auth scheme.
_WWW_AUTHENTICATE = {"WWW-Authenticate": "Bearer"}
#: The authorization group a verified admin JWT must name in its
#: ``groups`` claim. A module constant (the value Authentik's
#: nestquest-admins group maps to) so it is clearly named in one place.
ADMIN_GROUP = "nestquest-admins"
#: Seconds a fetched JWKS document stays trusted before the next
#: fetch. Five minutes bounds how long a rotated-away key keeps
#: verifying while staying far above a per-request fetch.
_JWKS_TTL_SECONDS = 300.0
#: Seconds a FAILED JWKS fetch blocks further fetch attempts. Within
#: this window after a failure the cache fails fast — it re-raises the
#: recorded fault instead of touching the provider — so an outage is
#: never amplified into one fetch per request.
_JWKS_BACKOFF_SECONDS = 5.0
#: Seconds that must pass before ANOTHER forced (TTL-bypassing) refetch
#: may run. An unmatched ``kid`` forces one refetch to pick up rotated
#: keys; this interval bounds it, so a flood of unknown-``kid`` tokens
#: cannot turn each request into a provider fetch either.
_JWKS_KID_REFETCH_SECONDS = 5.0
#: Seconds before the real JWKS fetch gives up on the provider.
_JWKS_TIMEOUT_SECONDS = 5.0
#: An async callable that fetches the JWKS document from a URL. The
#: real one (:func:`_fetch_jwks`) uses httpx; tests inject a stub so
#: the suite stays hermetic (no network access).
JwksFetcher = Callable[[str], Awaitable[dict[str, object]]]
async def _fetch_jwks(url: str) -> dict[str, object]:
"""Fetch the provider's JWKS document over HTTP (the real fetch).
Uses httpx with a short timeout; a non-2xx response or a body that
is not a ``{"keys": [...]}`` object raises — the cache never stores
a failed or malformed fetch.
"""
async with httpx.AsyncClient(timeout=_JWKS_TIMEOUT_SECONDS) as client:
response = await client.get(url)
response.raise_for_status()
document = response.json()
if not isinstance(document, dict) or not isinstance(
document.get("keys"), list
):
raise ValueError("JWKS document must be an object with a keys list")
return document
class JwksCache:
"""One app's cache of the OIDC provider's JWKS document.
The document is fetched through ``fetcher`` (the real one uses
httpx against the configured JWKS URL) and held for ``ttl_seconds``
— the dependency NEVER fetches per request. A single fetch is
shared between concurrent requests via an :class:`asyncio.Lock`
(double-checked around it), and a failed fetch is never cached, so
the next request retries once the backoff window lapses rather than
being locked out until the TTL does.
Two bounds keep the provider out of an amplification loop:
- ``backoff_seconds``: after a FAILED fetch, :meth:`get` fails fast
for this long — re-raising the recorded fault WITHOUT touching
the fetcher or even the lock — instead of retrying per request.
- ``kid_refetch_seconds``: forced (TTL-bypassing) refetches — the
unmatched-``kid`` path — may run at most this often; a forced
refetch that is bounded out (or one inside the backoff window)
is served from the cached document as-is.
A successful fetch always clears the backoff; the document itself
is still trusted only for ``ttl_seconds`` — nothing is cached
indefinitely.
One instance lives on ``app.state.jwks_cache`` (installed by
:func:`api.app.create_app`); tests replace it with one built around
a stub fetcher, keeping the suite hermetic.
"""
def __init__(
self,
url: str,
*,
ttl_seconds: float = _JWKS_TTL_SECONDS,
backoff_seconds: float = _JWKS_BACKOFF_SECONDS,
kid_refetch_seconds: float = _JWKS_KID_REFETCH_SECONDS,
fetcher: JwksFetcher | None = None,
) -> None:
self._url = url
self._ttl_seconds = ttl_seconds
self._backoff_seconds = backoff_seconds
self._kid_refetch_seconds = kid_refetch_seconds
self._fetcher: JwksFetcher = (
fetcher if fetcher is not None else _fetch_jwks
)
self._document: dict[str, object] | None = None
self._fetched_at: float | None = None
self._failed_until: float | None = None
self._last_error: BaseException | None = None
self._forced_at: float | None = None
self._lock = asyncio.Lock()
async def get(self, *, force: bool = False) -> dict[str, object]:
"""Return the JWKS document, refetching it if the TTL lapsed.
With ``force=True`` the TTL is bypassed so an unmatched ``kid``
can pick up a rotated key without waiting it out; the forced
refetch is itself bounded (see the class docstring): inside the
backoff window or the ``kid_refetch_seconds`` interval the
cached document is served as-is instead. A failed fetch is
re-raised to the caller (a provider outage is a server fault,
never a 401) and starts the backoff window, during which every
caller — including forced ones with nothing cached — fails fast
on the recorded fault.
"""
if not force:
fresh = self._fresh_document()
if fresh is not None:
return fresh
error = self._backoff_error()
if error is not None:
# Fail fast OUTSIDE the lock: a request inside the backoff
# window must not queue behind an in-flight fetch.
if force:
document = self._document
if document is not None:
return document
raise error
async with self._lock:
if not force:
fresh = self._fresh_document()
if fresh is not None:
return fresh
error = self._backoff_error()
if error is not None:
if force:
document = self._document
if document is not None:
return document
raise error
if force and self._refetch_throttled():
document = self._document
if document is not None:
return document
return await self._fetch_locked(force=force)
def _fresh_document(self) -> dict[str, object] | None:
"""The cached document if one exists and is inside its TTL."""
document = self._document
fetched_at = self._fetched_at
if (
document is not None
and fetched_at is not None
and (time.monotonic() - fetched_at) < self._ttl_seconds
):
return document
return None
def _backoff_error(self) -> BaseException | None:
"""The recorded fetch fault while the backoff window is open."""
failed_until = self._failed_until
last_error = self._last_error
if (
failed_until is not None
and last_error is not None
and time.monotonic() < failed_until
):
return last_error
return None
def _refetch_throttled(self) -> bool:
"""Whether a forced refetch ran too recently to run another."""
forced_at = self._forced_at
return forced_at is not None and (
time.monotonic() - forced_at
) < self._kid_refetch_seconds
async def _fetch_locked(self, *, force: bool) -> dict[str, object]:
"""Run ONE fetch attempt (caller holds the lock) and record it.
A failed fetch starts the ``backoff_seconds`` window and is
re-raised; a successful one stores the document, restarts the
TTL and clears the backoff. A forced attempt stamps
``_forced_at`` so the ``kid_refetch_seconds`` interval bounds
how often forced refetches may run back-to-back.
"""
if force:
self._forced_at = time.monotonic()
try:
document = await self._fetcher(self._url)
except Exception as error:
self._failed_until = time.monotonic() + self._backoff_seconds
self._last_error = error
raise
self._document = document
self._fetched_at = time.monotonic()
self._failed_until = None
self._last_error = None
return document
#: 403 detail for the panel service token on an admin route. Deliberately
#: non-leaking: it names the credential TYPE, not its value or where it came
#: from. 403 (not 401) because this is a REFUSED credential type, not an
#: absent/invalid one — challenging re-authentication would suggest the same
#: credential could work with a retry, which it never can here.
_PANEL_TOKEN_REJECTION_DETAIL = "This credential type is not accepted for admin access"
#: 403 detail for a valid JWT that is not a member of the admin group.
#: Distinct from the uniform 401 on purpose: the token AUTHENTICATED, but
#: the caller is not AUTHORIZED, so the response may say so.
_FORBIDDEN_DETAIL = "Admin access requires membership in the nestquest-admins group"
def _reject() -> HTTPException:
"""Build the single 401 the dependency raises for every failure."""
return HTTPException(
status_code=401,
detail=_REJECTION_DETAIL,
headers=_WWW_AUTHENTICATE,
)
def _matching_key(jwks: dict[str, object], kid: str) -> object | None:
"""Return the RSA signing key named ``kid`` from the JWKS.
Only ``kty: RSA`` entries are considered (the pinned RS256 scheme
is asymmetric RSA); anything else — a missing ``keys`` list, no
entry with that ``kid`` — returns ``None`` rather than falling back
to "some" key. The key is built through PyJWT's JWKS decoder, and
an entry that MATCHES the ``kid`` but carries corrupt key material
(a malformed ``n``/``e`` raises ``ValueError`` — ``binascii.Error``
is its subclass — or ``TypeError`` instead of a ``PyJWTError``) is
mapped to the one 401 like every other rejection: the caller must
never see a half-built key.
"""
keys = jwks.get("keys")
if not isinstance(keys, list):
raise _reject()
for entry in keys:
if not isinstance(entry, dict):
continue
if entry.get("kid") != kid or entry.get("kty") != "RSA":
continue
try:
return jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(entry))
except (
jwt.PyJWTError,
binascii.Error,
ValueError,
TypeError,
KeyError,
):
raise _reject() from None
return None
async def verify_admin_token(
request: Request, token: str
) -> dict[str, object]:
"""Verify ONE admin bearer token and return its verified claims.
Reads the OIDC settings off ``app.state.config`` and the JWKS cache
off ``app.state.jwks_cache`` (both installed by
:func:`api.app.create_app`) so the check carries no module state.
A missing config or cache, an EMPTY configured issuer/audience/JWKS
URL, a non-RS256 ``alg`` header, an absent ``kid``, an unknown
``kid``, and any PyJWT verification failure (bad signature,
expired, wrong issuer, wrong audience, not-yet-valid, missing
``exp``) raise the SAME 401 — the failure mode is never leaked.
``exp`` is REQUIRED (a token that never expires is never accepted)
and PyJWT enforces ``nbf`` when the token carries it.
The token's HEADER (``alg``, ``kid``) is parsed and enforced BEFORE
the JWKS cache is consulted, so a structurally invalid token costs
no fetch, no lock, no provider load. When the cached document has
no entry for the ``kid``, ONE forced (TTL-bypassing, throttled)
refetch is made and the match retried once — a rotated key is
picked up without waiting out the TTL — before the 401.
A JWKS fetch failure propagates (see the module docstring): the
provider being down is a server fault, not a bad credential.
"""
config = getattr(request.app.state, "config", None)
if (
config is None
or not config.oidc_issuer
or not config.oidc_audience
or not config.oidc_jwks_url
):
raise _reject()
cache = getattr(request.app.state, "jwks_cache", None)
if cache is None:
raise _reject()
try:
header = jwt.get_unverified_header(token)
except jwt.PyJWTError:
raise _reject() from None
if header.get("alg") != _ALLOWED_ALGORITHM:
raise _reject()
kid = header.get("kid")
if not isinstance(kid, str) or not kid:
raise _reject()
jwks = await cache.get()
try:
key = _matching_key(jwks, kid)
if key is None:
# The kid is not in the cached document: force ONE
# TTL-bypassing refetch (bounded by the cache) and retry
# the match once before rejecting.
jwks = await cache.get(force=True)
key = _matching_key(jwks, kid)
if key is None:
raise _reject()
claims = jwt.decode(
token,
key=key,
algorithms=[_ALLOWED_ALGORITHM],
issuer=config.oidc_issuer,
audience=config.oidc_audience,
options={"require": ["exp"]},
)
except jwt.PyJWTError:
raise _reject() from None
return claims
async def require_admin_jwt(
request: Request,
authorization: Annotated[str | None, Header()] = None,
) -> dict[str, object]:
"""Reject the request with 401 unless it carries a valid admin JWT.
Extracts the Bearer credential — a missing or malformed
``Authorization`` header (wrong scheme, empty credential) raises
the one 401 — and delegates the verification to
:func:`verify_admin_token`, returning the verified claims for the
route to use (the probe route surfaces the token's ``sub``).
The nestquest-admins GROUP check lives in :func:`require_admin`,
which wraps this dependency; ``require_admin_jwt`` remains the plain
JWT-validation contract — the 401 path stays exactly this uniform.
"""
if authorization is None:
raise _reject()
scheme, _, credential = authorization.partition(" ")
if scheme.lower() != _BEARER_SCHEME or not credential.strip():
raise _reject()
return await verify_admin_token(request, credential.strip())
async def require_admin(
request: Request,
authorization: Annotated[str | None, Header()] = None,
) -> dict[str, object]:
"""The ONE admin-plane dependency: service-token refusal + group gate.
Order matters. FIRST the Bearer credential is compared against the
configured panel service token (``app.state.config.panel_token``):
a match is refused OUTRIGHT with 403 — BEFORE any JWT parsing — so
the panel credential is never even treated as a JWT candidate, let
alone a non-admin JWT. (403 rather than 401 because this is a
refused credential TYPE, not an invalid one: challenging
re-authentication would imply retrying could ever succeed; the
detail names the type only, never the value.) A config with an
EMPTY panel token never matches anything, so it cannot lock every
Bearer request out.
Otherwise the credential goes through the existing JWT path
(:func:`require_admin_jwt` → :func:`verify_admin_token`): a missing
or malformed header and any invalid JWT still yield the SAME
uniform 401 as before. A VALID JWT is then checked for
authorization: its ``groups`` claim must be a list containing
:data:`ADMIN_GROUP` — a missing, empty, or non-list ``groups`` or
one without the group raises 403 (distinct from the 401 on
purpose: the token authenticated, the caller did not authorize).
Returns the verified claims for the route.
"""
if authorization is not None:
scheme, _, credential = authorization.partition(" ")
if scheme.lower() == _BEARER_SCHEME and credential.strip():
config = getattr(request.app.state, "config", None)
if (
config is not None
and config.panel_token
# compare_digest on bytes (as require_panel_token does)
# so the refusal does not short-circuit on the first
# mismatching byte — a constant-time comparison keeps a
# timing side channel on the credential off the table.
and secrets.compare_digest(
credential.strip().encode("utf-8"),
config.panel_token.encode("utf-8"),
)
):
raise HTTPException(
status_code=403,
detail=_PANEL_TOKEN_REJECTION_DETAIL,
)
claims = await require_admin_jwt(request, authorization)
groups = claims.get("groups")
if not isinstance(groups, list) or ADMIN_GROUP not in groups:
raise HTTPException(status_code=403, detail=_FORBIDDEN_DETAIL)
return claims
__all__ = [
"ADMIN_GROUP",
"JwksCache",
"JwksFetcher",
"require_admin",
"require_admin_jwt",
"verify_admin_token",
]
-174
View File
@@ -1,174 +0,0 @@
"""Configuration for the NestQuest API service.
The API reads its configuration knobs from environment variables:
- ``NESTQUEST_DB_PATH`` — the SQLite database file. The API opens this
file on startup (creating it if absent) and applies migrations.
- ``NESTQUEST_PANEL_TOKEN`` — the static panel-plane service token
(D-012): the credential ``/api/v1/panel/*`` routes require via
:func:`api.dependencies.require_panel_token`. It is stored outside
the repo and handed to the service through the environment.
- ``NESTQUEST_OIDC_ISSUER`` — the Authentik OIDC issuer URL whose
``iss`` claim admin tokens must carry (D-012 admin plane).
- ``NESTQUEST_OIDC_AUDIENCE`` — the OIDC client id the admin tokens
must carry as their ``aud`` claim (the API service's client id).
- ``NESTQUEST_OIDC_JWKS_URL`` — the Authentik endpoint serving the
signing keys (the JWKS document) the admin tokens' signatures are
verified against. Cached per app (see :class:`api.auth.JwksCache`).
Unlike a dev convenience default, a missing or empty value is a
configuration ERROR, not a silent fallback: a default database path
would silently drop data at reboot, a default (or empty) service token
would either authenticate everyone or lock the panel plane out for
good, and a default (or empty) OIDC setting would silently leave the
admin plane trusting an issuer that was never configured. All five
variables are therefore REQUIRED — ``ApiConfig.from_env`` RAISES when
any of them is unset or empty. This mirrors the integration's
"explicit settings object" discipline (Feature 15's ``core.settings``):
the API never reads HA config; callers pass the resolved values into
the wiring.
Serving: the app is built with the factory ``api.app:create_app`` — run
it with ``uvicorn --factory api.app:create_app`` so the factory reads
the environment on each worker startup rather than at import time.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
#: Environment variable holding the SQLite file path. The API opens
#: this file on startup (creating it if absent) and applies migrations.
#: REQUIRED — unset or empty raises :class:`ConfigError` from
#: :meth:`ApiConfig.from_env`.
DB_PATH_ENV = "NESTQUEST_DB_PATH"
#: Environment variable holding the static panel-plane service token
#: (D-012), checked by :func:`api.dependencies.require_panel_token`.
#: REQUIRED — unset or empty raises :class:`ConfigError` from
#: :meth:`ApiConfig.from_env`; there is no silent default, because an
#: empty configured token would either authenticate every caller or
#: lock the panel plane out permanently.
PANEL_TOKEN_ENV = "NESTQUEST_PANEL_TOKEN"
#: Environment variable holding the Authentik OIDC issuer URL (D-012
#: admin plane): every admin JWT's ``iss`` claim must equal it. Checked
#: by :func:`api.auth.require_admin_jwt`. REQUIRED — unset or empty
#: raises :class:`ConfigError` from :meth:`ApiConfig.from_env`; a default
#: issuer would silently make the admin plane trust tokens from an
#: issuer nobody configured.
OIDC_ISSUER_ENV = "NESTQUEST_OIDC_ISSUER"
#: Environment variable holding the OIDC audience (the API service's
#: client id at Authentik): every admin JWT's ``aud`` claim must carry
#: it. Checked by :func:`api.auth.require_admin_jwt`. REQUIRED — unset
#: or empty raises :class:`ConfigError` from :meth:`ApiConfig.from_env`.
OIDC_AUDIENCE_ENV = "NESTQUEST_OIDC_AUDIENCE"
#: Environment variable holding the Authentik JWKS URL: the endpoint
#: serving the signing keys admin JWT signatures are verified against.
#: Fetched and cached by :class:`api.auth.JwksCache`. REQUIRED — unset
#: or empty raises :class:`ConfigError` from :meth:`ApiConfig.from_env`;
#: without the provider's keys no token can be verified, so a silent
#: default would only move the failure to request time.
OIDC_JWKS_URL_ENV = "NESTQUEST_OIDC_JWKS_URL"
class ConfigError(RuntimeError):
"""Raised when required API configuration is missing or invalid."""
@dataclass(frozen=True)
class ApiConfig:
"""Resolved API configuration.
Every field is required (no defaults): ``from_env`` rejects an
unset or empty value for each, and a caller building the dataclass
directly must pass explicit values — the token check and the JWT
checks themselves fail closed on empty configured values, but
expecting a caller to discover that at request time is a wiring
mistake, so every field is mandatory. Kept as a dataclass so later
tasks (SSE, settings persistence) can extend it without touching
the call sites that read ``db_path``.
"""
db_path: str
panel_token: str
#: The Authentik OIDC issuer URL admin tokens' ``iss`` must equal.
oidc_issuer: str
#: The OIDC audience (the API service's client id) admin tokens'
#: ``aud`` must carry.
oidc_audience: str
#: The Authentik JWKS URL the signing keys are fetched from.
oidc_jwks_url: str
@classmethod
def from_env(cls, env: dict[str, str] | None = None) -> "ApiConfig":
"""Build configuration from the process environment.
``env`` defaults to ``os.environ``; tests pass an explicit dict
so they are hermetic (no monkeypatch of the global environ).
Raises :class:`ConfigError` when ANY of the five variables is
unset or empty/whitespace-only — there is no silent default:
- ``NESTQUEST_DB_PATH``: a default in the temp directory would
silently lose data at reboot;
- ``NESTQUEST_PANEL_TOKEN``: a default token is never invented —
the panel plane is closed until a real one is configured;
- the three OIDC variables: an unconfigured issuer/audience/JWKS
URL must not silently leave the admin plane open (a default
JWKS URL or issuer would point at a provider nobody set up).
"""
source = env if env is not None else os.environ
def require(env_var: str, reason: str) -> str:
"""Return the stripped env value or raise ConfigError."""
value = source.get(env_var, "").strip()
if not value:
raise ConfigError(f"{env_var} {reason}")
return value
return cls(
db_path=require(
DB_PATH_ENV,
"must be set to a writable SQLite file path (it is unset "
"or empty — the API will not fall back to a temp directory "
"and silently lose data)",
),
panel_token=require(
PANEL_TOKEN_ENV,
"must be set to the panel service token (it is unset or "
"empty — the API will not start without one, and no "
"default token is invented)",
),
oidc_issuer=require(
OIDC_ISSUER_ENV,
"must be set to the Authentik OIDC issuer URL (it is "
"unset or empty — the admin plane will not trust tokens "
"from an issuer nobody configured)",
),
oidc_audience=require(
OIDC_AUDIENCE_ENV,
"must be set to the OIDC audience the admin tokens carry "
"(it is unset or empty — the admin plane will not accept "
"tokens without a configured audience)",
),
oidc_jwks_url=require(
OIDC_JWKS_URL_ENV,
"must be set to the Authentik JWKS URL (it is unset or "
"empty — admin token signatures cannot be verified "
"without the provider's signing keys)",
),
)
__all__ = [
"ApiConfig",
"ConfigError",
"DB_PATH_ENV",
"OIDC_AUDIENCE_ENV",
"OIDC_ISSUER_ENV",
"OIDC_JWKS_URL_ENV",
"PANEL_TOKEN_ENV",
]
-82
View File
@@ -1,82 +0,0 @@
"""Database wiring for the NestQuest API service.
The API owns the database (it is the writer of record per Feature 16's
contract). This module owns one application-lifetime
:class:`core.db.NestQuestDatabase`: it opens the SQLite file at the
configured path on startup, applies migrations (creating the schema on
a fresh file), and closes the connection on shutdown.
The executor callable passed into ``NestQuestDatabase`` mirrors the
contract of ``hass.async_add_executor_job``: schedule a synchronous
callable on a worker and await its result. HA's own version lives on
the event loop; the API is not HA, so it uses
:func:`asyncio.to_thread` for the same effect — sqlite3 calls run on a
worker thread and only plain data returns to the event loop.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from api.nestquest_core import core_db, core_migrations
LOGGER = logging.getLogger(__name__)
#: The async SQLite wrapper. Re-exported so route handlers and later
#: tasks reach it through the API package, not the bundled core.
NestQuestDatabase = core_db.NestQuestDatabase
#: The migration runner. Re-exported for the same reason.
apply_migrations = core_migrations.apply_migrations
async def _executor(fn: Any, *args: Any, **kwargs: Any) -> Any:
"""Run a synchronous callable on a worker thread and await its result.
Mirrors the contract of ``hass.async_add_executor_job`` so the
bundled ``NestQuestDatabase`` (written against that contract) works
unchanged in the API process: sqlite3 calls never block the event
loop, and only plain Python data (row tuples,
:class:`core.db.ExecutionResult`) crosses back.
"""
return await asyncio.to_thread(fn, *args, **kwargs)
class DatabaseState:
"""Holds the live database wrapper and its resolved path.
A small container so the lifespan and the route handlers share one
authoritative reference; FastAPI's lifespan stores one of these on
``app.state.db`` and the health route reads it back.
"""
__slots__ = ("database", "db_path")
def __init__(self, database: NestQuestDatabase, db_path: str) -> None:
self.database = database
self.db_path = db_path
async def open_and_migrate(self) -> None:
"""Open the connection and apply migrations to the latest version."""
LOGGER.info("Opening NestQuest database at %s", self.db_path)
await self.database.open(self.db_path)
version = await apply_migrations(self.database)
LOGGER.info("NestQuest schema at version %d", version)
async def close(self) -> None:
"""Close the connection; safe to call even if open failed."""
await self.database.close()
def make_database(db_path: str) -> DatabaseState:
"""Construct a :class:`DatabaseState` for the given path (not yet open)."""
return DatabaseState(NestQuestDatabase(_executor), db_path)
__all__ = [
"DatabaseState",
"NestQuestDatabase",
"apply_migrations",
"make_database",
]
-81
View File
@@ -1,81 +0,0 @@
"""Reusable FastAPI dependencies for the NestQuest API routes.
The panel plane (``/api/v1/panel/*``) is authenticated by the static
service token configured through ``NESTQUEST_PANEL_TOKEN`` (D-012): the
HA integration presents it as a Bearer credential on every panel
request. :func:`require_panel_token` is the ONE implementation of that
check — every panel route (this task's snapshot route and each later
panel route) declares it as a dependency rather than re-implementing
token handling, so the credential, the failure behaviour, and the
401 contract stay in exactly one place.
The dependency FAILS CLOSED: a missing ``Authorization`` header, a
malformed one (wrong scheme or empty credential), a token that does not
match the configured value, and an empty configured token all yield
HTTP 401. The comparison is constant-time (:func:`secrets.compare_digest`)
so the wall-clock difference between "wrong length" and "wrong value"
cannot be used to probe the token byte by byte, and every rejection
carries the same detail — nothing in the response distinguishes which
part of the check failed.
"""
from __future__ import annotations
import secrets
from typing import Annotated
from fastapi import Header, HTTPException, Request
#: The authorization scheme the panel plane expects. Matched
#: case-insensitively, as RFC 9110 requires for scheme names.
_BEARER_SCHEME = "bearer"
#: The 401 detail shared by every rejection — deliberately uniform so
#: the response never reveals which part of the check failed.
_REJECTION_DETAIL = "Missing or invalid panel service token"
#: Challenge header returned with a 401, per the Bearer auth scheme.
_WWW_AUTHENTICATE = {"WWW-Authenticate": "Bearer"}
def _reject() -> HTTPException:
"""Build the single 401 the dependency raises for every failure."""
return HTTPException(
status_code=401,
detail=_REJECTION_DETAIL,
headers=_WWW_AUTHENTICATE,
)
async def require_panel_token(
request: Request,
authorization: Annotated[str | None, Header()] = None,
) -> None:
"""Reject the request with 401 unless it carries the panel token.
Reads the configured token off ``app.state.config`` (set by
:func:`api.app.create_app`) so the dependency stays reusable across
routes and carries no module state of its own. A missing or
malformed ``Authorization`` header, a non-matching credential, and
an EMPTY configured token (a wiring mistake) all raise the same
``HTTPException(401)`` — the check is closed even when the service
is misconfigured.
Returns ``None`` on success; FastAPI discards a ``None`` return,
the route handler simply proceeds.
"""
config = getattr(request.app.state, "config", None)
expected = config.panel_token if config is not None else ""
if not expected or authorization is None:
raise _reject()
scheme, _, credential = authorization.partition(" ")
if scheme.lower() != _BEARER_SCHEME or not credential.strip():
raise _reject()
# compare_digest on bytes so a non-ASCII token raises nothing and a
# timing side channel on the str path is not available.
if not secrets.compare_digest(
credential.strip().encode("utf-8"), expected.encode("utf-8")
):
raise _reject()
__all__ = ["require_panel_token"]
-78
View File
@@ -1,78 +0,0 @@
"""The ONE in-process transition-event publisher for the API service.
Route handlers call :meth:`TransitionPublisher.publish` synchronously
(no ``await``, callable from any async handler); SSE subscribers stream
those transitions through an async generator. The scope of "ONE" is
deliberate: a single :class:`TransitionPublisher` instance is created
in the app lifespan and stored on ``app.state.publisher`` — per-app,
so tests build their own hermetic app without module-level state, and
both the panel complete route and the SSE endpoint resolve the SAME
instance off ``request.app.state`` (exactly one publisher exists per
running app; nothing else may create one).
Design (small and dependency-free, asyncio only):
- Each subscriber gets its own ``asyncio.Queue(maxsize=64)``.
- ``publish`` is synchronous and non-blocking: it puts the event on
every live queue with ``put_nowait``. A slow subscriber that lets
its queue fill gets the event DROPPED (oldest overflow) — never a
blocked publisher, never unbounded growth.
- A disconnected subscriber's queue is removed in the generator's
``finally`` block, so teardown is clean and a closed consumer cannot
leak its queue.
Fan-out is fire-and-forget by design: the SSE plane mirrors transition
events for the panel UI; a dropped event costs the panel one UI
refresh, and the documented recovery path is the next snapshot poll.
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
#: Per-subscriber queue capacity. Bounded so a stalled consumer can
#: never make the publisher or another subscriber wait; beyond this
#: the slow subscriber drops the event (see module docstring).
_QUEUE_MAXSIZE = 64
__all__ = ["TransitionPublisher"]
class TransitionPublisher:
"""A single in-process fan-out point for transition events."""
def __init__(self) -> None:
"""Create the publisher with no subscribers."""
self._subscribers: set[asyncio.Queue] = set()
def publish(self, event_type: str, payload: dict) -> None:
"""Fan one transition event out to every current subscriber.
Synchronous and non-blocking: each subscriber's bounded queue
is filled with ``put_nowait``, and a FULL queue silently drops
the event for that (slow) subscriber rather than blocking the
route handler or growing without bound.
"""
for queue in tuple(self._subscribers):
try:
queue.put_nowait((event_type, payload))
except asyncio.QueueFull:
# The subscriber is not keeping up; drop the event for
# it and move on (the panel recovers by polling).
pass
async def subscribe(self) -> AsyncIterator[tuple[str, dict]]:
"""Yield ``(event_type, payload)`` tuples until the consumer breaks.
Registers ONE bounded queue for the duration of the iteration
and removes it in the ``finally`` block, so a client disconnect
(or any break out of the loop) tears the subscription down
cleanly with no leaked queue.
"""
queue: asyncio.Queue = asyncio.Queue(maxsize=_QUEUE_MAXSIZE)
self._subscribers.add(queue)
try:
while True:
yield await queue.get()
finally:
self._subscribers.discard(queue)
-62
View File
@@ -1,62 +0,0 @@
"""The API's effective application timezone (task 7e173881).
The admin-stored ``timezone`` setting is the single source of local-time
truth; stored empty, the API reads its clock in the API host's local
time. :func:`effective_timezone` names the zone the API actually uses —
the stored zone, or else the host's own IANA zone — so the panel cards
and the admin app format local time in exactly the API's zone instead of
guessing with Home Assistant's or the browser's. ``""`` only when the
host's zone genuinely cannot be named.
"""
from __future__ import annotations
import os
from collections.abc import Mapping
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
def _valid_zone(name: str) -> str:
"""Return ``name`` if it is a loadable IANA zone key, else ``""``."""
name = name.strip().removeprefix(":")
if not name:
return ""
try:
ZoneInfo(name)
except (ZoneInfoNotFoundError, ValueError):
return ""
return name
def host_timezone(
environ: Mapping[str, str] = os.environ,
timezone_file: Path = Path("/etc/timezone"),
localtime: Path = Path("/etc/localtime"),
) -> str:
"""Name the API host's local zone as an IANA key, or ``""``.
In the order the C library honours them: the ``TZ`` environment
variable, then the ``/etc/localtime`` zoneinfo link (with Debian's
``/etc/timezone`` naming it when the link is a copy). A host with
none of them configured runs UTC. A setting that names no IANA
zone (a POSIX rule string, a copied file) is ``""``.
"""
configured = environ.get("TZ")
if configured is not None:
# An empty TZ is UTC to the C library.
return _valid_zone(configured) if configured.strip() else "UTC"
if localtime.is_symlink():
target = str(localtime.resolve())
marker = "/zoneinfo/"
if marker in target:
return _valid_zone(target.split(marker, 1)[1])
if timezone_file.is_file():
return _valid_zone(timezone_file.read_text(encoding="utf-8"))
if not localtime.exists():
return "UTC"
return ""
def effective_timezone(stored: str) -> str:
"""The zone the API reads its clock in: ``stored``, else the host's."""
return stored or host_timezone()

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