Compare commits

..
Author SHA1 Message Date
overseer 80ac786a54 Admin auth: harden JWKS cache against outage amplification (P2) and corrupt/unknown-key handling (P3)
- P2: verify_admin_token parses and enforces the JWT header (alg ==
  RS256, kid present) BEFORE consulting the JWKS cache, so a
  structurally invalid token costs no fetch, no lock, no provider load.
- P2: JwksCache gains a short negative-cache backoff window (5s,
  time.monotonic-recorded) after a failed fetch: within it, get()
  re-raises the recorded fault without touching the fetcher — and
  without holding or queueing on the lock — so an outage costs one
  fetch per window instead of one per request. A successful fetch
  clears the backoff; nothing is cached indefinitely (TTL unchanged).
- P3: _matching_key widens the from_jwk handling to
  (PyJWTError, binascii.Error, ValueError, TypeError, KeyError) so a
  JWKS entry matching the kid but carrying corrupt n/e maps to the
  SAME uniform 401 instead of surfacing a 500.
- P3: an unmatched kid now forces ONE TTL-bypassing refetch and
  retries the match once, so rotated keys verify without waiting out
  the TTL; the forced refetch is itself bounded (inside the backoff
  window or the 5s kid-refetch interval the cached document is served
  as-is), so unknown-kid floods cannot force a fetch per request.
- Tests: no-fetch proofs for garbage/non-RS256 tokens, corrupt-entry
  401, outage fail-fast (sequential + concurrent burst), backoff
  lapse/recovery, outage-as-server-fault, and the unknown-kid
  refetch: one forced, throttled, resume-after-interval, rotation
  picked up, failed forced refetch propagates then fails fast.
  Existing tests unchanged (uniform 401 contract intact).
2026-09-23 00:30:04 -04:00
overseer 3cdedb37c8 Admin plane: validate Authentik OIDC JWTs on /api/v1/admin/*
The admin plane had no authentication: any caller could reach its
routes once they existed. This adds the plane's ONE reusable FastAPI
dependency (require_admin_jwt in api/auth.py) that verifies an
Authentik OIDC JWT — Bearer extraction, RS256-only signature check
against the provider's JWKS, issuer, audience, required exp, and
enforced nbf — and raises the SAME 401 for every failure so the
response never leaks which check failed.

The JWKS is fetched over httpx once per app (JwksCache, 5-minute TTL,
asyncio-lock-guarded, failed fetches never cached) and tests stub the
fetcher, so the suite proves signature/expiry/audience enforcement
with locally generated RSA key pairs and zero network access.

ApiConfig grows the three required OIDC settings
(NESTQUEST_OIDC_ISSUER/AUDIENCE/JWKS_URL) under the existing fail-fast
discipline: an unconfigured issuer must not silently leave the admin
plane open, so from_env raises rather than inventing a default.

This is authentication only — the verified claims are the dependency's
return value, so the nestquest-admins group check is the next task's
addition, not a rewrite. PyJWT[crypto] joins the dev dependency group
(the API deployable's runtime manifest lands in Feature 17).

Maestro task: e70e6bfb-085a-4781-a3f4-276355bbbd19
2026-09-22 23:55:02 -04:00
dev_agent d4a55b6b40 SSE: publish uncompleted, child-day-complete and missed (#155) 2026-09-22 23:25:06 -04:00
dev_agent a07ce1b2f2 SSE endpoint and the quest-completed transition (#154) 2026-09-22 23:08:03 -04:00
dev_agent 550a8b3e1e Panel plane: complete route (idempotent) (#153) 2026-09-22 22:38:48 -04:00
dev_agent 4da762173c Panel plane: service-token auth and snapshot route (#152) 2026-09-22 22:23:16 -04:00
dev_agent ca477125d6 Extract the panel snapshot builder into core (task 9047a97b)
Extract the panel snapshot builder into core (task 9047a97b)
2026-09-21 17:43:51 -04:00
dev_agent 7fec3c76ef FastAPI skeleton: health, OpenAPI, core wiring (task 042dfc5c)
FastAPI skeleton: health, OpenAPI, core wiring (task 042dfc5c)
2026-09-21 16:13:41 -04:00
197 changed files with 8056 additions and 31339 deletions
-32
View File
@@ -1,32 +0,0 @@
---
description: Independently verify NestQuest merge gates and merge approved heads
mode: primary
model: openrouter/deepseek/deepseek-v4.1-flash
permission:
edit: deny
write: deny
task: deny
bash:
"*": deny
"/home/overseer/.local/share/nestquest-controller/scripts/nq-approve-merge *": allow
external_directory:
"*": ask
"/tmp/opencode/*": allow
"/home/overseer/.local/share/nestquest-controller/*": allow
---
You are the NestQuest feature-to-dev approver, separate from the controller and
developer sessions. Verify the current PR head equals the independently
reviewed and approved commit,
required checks pass, blocking findings are resolved, branch protection is
satisfied, and Maestro has accurate links. Use only the approver Gitea identity
for the merge. Do not change code or approve a stale head. Use only the
installed nq-approve-merge launcher for Gitea inspection and merging. Merge
only into dev; never merge into main. If any gate is
missing, refuse the merge and give the exact missing evidence. Report the PR,
reviewed head, resulting merge commit, and target branch.
Broker syntax: `nq-approve-merge inspect PR_NUMBER` or
`nq-approve-merge merge PR_NUMBER REVIEWED_HEAD REVIEW_FILE`. Run one broker
command per Bash call. The broker validates the current PR and review again
before merging; do not substitute an ad hoc Gitea command.
-21
View File
@@ -1,21 +0,0 @@
---
description: Implement one assigned NestQuest task in an isolated worktree
mode: primary
model: openrouter/z-ai/glm-5.3-flash
permission:
task: deny
external_directory:
"*": ask
"/tmp/opencode/*": allow
---
You are the NestQuest development agent. Implement only the assigned Maestro
task in the supplied worktree. Follow the task's acceptance criteria and the
repository's instructions. Run relevant tests, commit on the task branch, and
report the commit and test results. Keep changes inside the assigned worktree.
Do not approve your own pull request. You may merge your task PR into the
feature branch only after the independent reviewer approved its exact current
head and the controller verified every task merge gate. Never merge a feature
into dev or a release into main. Do not start another task, change role, or
use another model. Do not read or print credential files except when the
approved task merge requires the existing Gitea development credential.
-60
View File
@@ -1,60 +0,0 @@
---
description: Reconcile NestQuest work and dispatch the next verified action
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 NestQuest controller. Work in the NestQuest repository. Maestro is
the source of truth for feature and task state; CTXD is the source of truth for
project instructions. At the start of every invocation, read the current
NestQuest context in CTXD, including OPENCODE-ORCHESTRATOR-PROMPT.MD, then
reconcile it with Maestro, Git, pull requests, and checks before dispatching.
Follow the review, identity, and branch rules in that prompt.
If the CTXD project or prompt is unavailable, do not dispatch or merge work;
report the missing source and end with ORCHESTRATOR_STATE: BLOCKED.
One invocation is a controller tick, not an entire feature lifetime. Prefer a
state-changing action over another identical status query. Never repeat an
unchanged tool call three times. If work is asynchronous, wait or inspect a
specific changed result. Do not claim that you are continuing while ending
the turn without naming the next action.
The NESTQUEST_CONTROL_HOME environment variable names the installed launchers
(normally /home/overseer/.local/share/nestquest-controller).
For implementation, run "$NESTQUEST_CONTROL_HOME/scripts/nq-agent" with role
developer, a task worktree, title, and task prompt file. For independent
review, run "$NESTQUEST_CONTROL_HOME/scripts/nq-review" with the worktree,
feature base ref, exact head ref, review prompt file, PR number, and an output
file under /tmp/opencode. It publishes an exact-head Gitea approval only after
Claude approves. The developer may merge
a task PR into its feature branch only after independent approval of the exact
head and verification of every task gate. For feature-to-dev approval and
merge, run "$NESTQUEST_CONTROL_HOME/scripts/nq-agent" with role approver in
a separate session after the independent review. Save the Claude review output
in a file under /tmp/opencode and give that file and its reviewed head to the
approver. Never substitute a different model or reviewer silently.
When a feature is integrated into dev, reconcile Maestro, then immediately
select and dispatch the next eligible feature. Feature completion is a
transition, not the end of the controller's job. A release awaiting Joshua's
main merge does not prevent work on independent eligible features.
End each invocation with exactly one final line using one of these values:
ORCHESTRATOR_STATE: CONTINUE
ORCHESTRATOR_STATE: QUIESCENT
ORCHESTRATOR_STATE: BLOCKED
Use CONTINUE when there is any eligible or in-flight work; include the feature,
task, and next concrete action immediately above the final line. Use QUIESCENT
only after verifying no eligible or in-flight work remains in Maestro and the
repository. Use BLOCKED only when a specific decision or access is required;
include evidence and the needed human action. If you cannot establish the
state, use BLOCKED. Never emit a success claim based only on a child's summary.
-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
@@ -1,11 +0,0 @@
---
description: Run the NestQuest orchestrator now in this OpenCode terminal
agent: terminal-orchestrator
---
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.
-77
View File
@@ -1,77 +0,0 @@
# NestQuest foreground orchestrator
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.
`scripts/nq-agent` launches developer and approver sessions with fixed agent
definitions and no `--auto` flag. Headless OpenCode runs use `--pure` so the
global idle-notification plugin does not notify on every controller tick.
The approver's shell permission permits only the installed
`nq-approve-merge` broker for Gitea. That broker requires exact-head
Claude approval evidence, an exact-head Gitea approval by `review_agent`,
the `dev` target, a mergeable PR, and passing configured checks before it
requests a squash merge with Gitea's atomic `head_commit_id` guard.
`scripts/nq-review` launches the independent
Claude review in a noninteractive read-only session in the task worktree,
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
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)
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.
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.
`templates/ORCHESTRATOR-PROMPT.template.md` and
`templates/nestquest.profile.json` make the current CTXD cycle prompt
adaptable to another project. See `templates/README.md` before publishing a
rendered prompt for a new project.
The CTXD preflight reads the configured HTTP/MCP server's NestQuest prompt.
It deliberately does not inspect `/home/overseer/.ctx/ctxd.db`, which belongs
to a different local store and lacks this project.
All four agent credentials are currently readable by the same Unix account.
The Gitea review and merge gates enforce normal workflow sequencing, but
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.
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
operation across every consumer before revoking the old values.
+56 -132
View File
@@ -159,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`,
@@ -215,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.
+21 -129
View File
@@ -1,135 +1,27 @@
# NestQuest Release Notes
## Version 0.6.0 — 2026-09-23
## Unreleased
### 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`.
- **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
-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>
-3024
View File
File diff suppressed because it is too large Load Diff
-27
View File
@@ -1,27 +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": {
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"jsdom": "^30.1.1",
"typescript": "^5.9.2",
"vite": "^7.1.5",
"vitest": "^3.2.4"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 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;
}
-80
View File
@@ -1,80 +0,0 @@
// Generates the committed PWA icons under public/icons/ as genuine PNGs.
// Pure Node (zlib only) — no network, no image dependencies.
// Run: node scripts/generate-icons.mjs
import { writeFileSync } from "node:fs";
import { deflateSync } from "node:zlib";
import { fileURLToPath } from "node:url";
// Brand tokens (design/tokens/nestquest-admin-tokens.css).
const BLUE = [0x3b, 0x3a, 0xb8];
const PURPLE = [0xa0, 0x35, 0xcc];
const WHITE = [0xff, 0xff, 0xff];
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(buf) {
let c = 0xffffffff;
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([len, body, crc]);
}
function encodePng(size, pixel) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // colour type: RGB
const raw = Buffer.alloc(size * (size * 3 + 1));
for (let y = 0; y < size; y++) {
const row = y * (size * 3 + 1);
raw[row] = 0; // filter: none
for (let x = 0; x < size; x++) raw.set(pixel(x, y), row + 1 + x * 3);
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw, { level: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
// Flat brand mark: diagonal blue→purple gradient with a white ring ("nest")
// around a white dot ("quest"). `scale` shrinks the mark for the maskable
// safe zone (inner 80% circle).
function brandMark(size, scale) {
const c = size / 2;
const outer = size * 0.34 * scale;
const inner = size * 0.24 * scale;
const dot = size * 0.1 * scale;
return (x, y) => {
const d = Math.hypot(x + 0.5 - c, y + 0.5 - c);
if ((d <= outer && d >= inner) || d <= dot) return WHITE;
const t = (x + y) / (2 * (size - 1));
return BLUE.map((b, i) => Math.round(b + (PURPLE[i] - b) * t));
};
}
const outDir = new URL("../public/icons/", import.meta.url);
const icons = [
["icon-192.png", 192, 1],
["icon-512.png", 512, 1],
["icon-maskable-512.png", 512, 0.75],
];
for (const [name, size, scale] of icons) {
const path = fileURLToPath(new URL(name, outDir));
writeFileSync(path, encodePng(size, brandMark(size, scale)));
console.log(`wrote ${path}`);
}
-134
View File
@@ -1,134 +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-placeholder {
margin: 0;
padding: 13px;
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: 8px;
font-size: 13.5px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.admin-tabbar {
position: fixed;
inset: auto 0 0;
display: flex;
min-height: calc(var(--nq-a-tabbar-height) + 12px);
padding: 8px 4px calc(12px + env(safe-area-inset-bottom));
background: var(--nq-a-surface);
border-top: 1px solid var(--nq-a-border);
}
.admin-tab {
flex: 1;
border: 0;
background: none;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
cursor: pointer;
padding: 6px 4px;
}
.admin-tab.active {
font-weight: 700;
color: var(--nq-brand-blue);
}
/* Auth screens (login, errors, refusal). */
.admin-login,
.admin-error,
.admin-refusal,
.admin-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 100%;
padding: 32px 16px;
text-align: center;
}
.admin-login-button {
margin-top: 8px;
padding: 12px 24px;
border: none;
border-radius: 8px;
background: var(--nq-a-accent, #2563eb);
color: #fff;
font-size: 15px;
font-weight: 700;
cursor: pointer;
}
.admin-error,
.admin-refusal {
color: var(--nq-a-ink, inherit);
}
.admin-refusal h1,
.admin-error h1 {
margin: 0;
font-size: 20px;
}
.admin-refusal p,
.admin-error p {
margin: 0;
max-width: 34em;
font-size: 14px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
-48
View File
@@ -1,48 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import App from "./App";
describe("NestQuest Admin shell", () => {
beforeEach(() => {
// The Today tab fetches the admin snapshot; keep the shell tests offline.
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("renders the title and all four tab labels", () => {
render(<App />);
expect(screen.getByRole("heading", { name: "NestQuest Admin" })).toBeTruthy();
for (const label of ["Today", "Tasks", "Schedule", "History"]) {
expect(screen.getByRole("tab", { name: label })).toBeTruthy();
}
});
it("starts on the Today screen and switches on tab click", () => {
render(<App />);
expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy();
fireEvent.click(screen.getByRole("tab", { name: "History" }));
expect(screen.getByRole("tabpanel").textContent).toContain(
"History placeholder",
);
expect(screen.getByRole("tab", { name: "History" }).getAttribute("aria-selected")).toBe(
"true",
);
});
it("the Tasks tab renders the definitions screen", () => {
render(<App />);
fireEvent.click(screen.getByRole("tab", { name: "Tasks" }));
expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy();
expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder");
});
});
-60
View File
@@ -1,60 +0,0 @@
import { useState } from "react";
import "./App.css";
import DefinitionsTab from "./definitions/DefinitionsTab";
import TodayTab from "./today/TodayTab";
const TABS = [
{ id: "today", label: "Today" },
{ id: "tasks", label: "Tasks" },
{ id: "schedule", label: "Schedule" },
{ id: "history", label: "History" },
] as const;
type TabId = (typeof TABS)[number]["id"];
export default function App() {
const [active, setActive] = useState<TabId>("today");
// Built screens own their header (ADMIN-SPEC §1); the generic shell header
// stays for placeholder tabs and is visually hidden otherwise.
const hasScreen = active === "today" || active === "tasks";
return (
<div className="admin-shell">
<header className={hasScreen ? "admin-header visually-hidden" : "admin-header"}>
<h1 className="admin-title">NestQuest Admin</h1>
<p className="admin-subline">Admin console</p>
</header>
<main
className={hasScreen ? "admin-panel admin-panel--screen" : "admin-panel"}
role="tabpanel"
aria-labelledby={`tab-${active}`}
>
{active === "today" ? (
<TodayTab />
) : active === "tasks" ? (
<DefinitionsTab />
) : (
<p className="admin-placeholder">
{TABS.find((tab) => tab.id === active)?.label} placeholder — screens
arrive in later tasks.
</p>
)}
</main>
<nav className="admin-tabbar" aria-label="Admin sections">
{TABS.map((tab) => (
<button
key={tab.id}
id={`tab-${tab.id}`}
type="button"
role="tab"
aria-selected={tab.id === active}
className={tab.id === active ? "admin-tab active" : "admin-tab"}
onClick={() => setActive(tab.id)}
>
{tab.label}
</button>
))}
</nav>
</div>
);
}
-73
View File
@@ -1,73 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import AppRoutes from "./AppRoutes";
import { storeTokens } from "./auth/oidc";
import { config } from "./config";
// The bundled config reads real env at import time; seed the shared module's
// clientId directly so AppRoutes sees a configured app in these tests.
(config as { clientId: string | undefined }).clientId = "test-client-id";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("AppRoutes auth gating", () => {
afterEach(() => {
cleanup();
sessionStorage.clear();
vi.restoreAllMocks();
vi.unstubAllGlobals();
window.history.replaceState({}, "", "/");
});
it("renders the login screen when unauthenticated at /", () => {
render(<AppRoutes />);
expect(screen.getByTestId("login-screen")).toBeTruthy();
expect(
screen.getByRole("button", { name: /sign in with authentik/i }),
).toBeTruthy();
});
it("renders the four-tab shell after a successful authenticated probe", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
vi.stubGlobal("fetch", fetchMock);
render(<AppRoutes />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "NestQuest Admin" })).toBeTruthy();
});
for (const label of ["Today", "Tasks", "Schedule", "History"]) {
expect(screen.getByRole("tab", { name: label })).toBeTruthy();
}
});
it("renders the explicit refusal when the API answers 403", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 }));
vi.stubGlobal("fetch", fetchMock);
render(<AppRoutes />);
await waitFor(() => {
expect(screen.getByTestId("refusal-screen")).toBeTruthy();
});
expect(screen.getByText(/not in the nestquest-admins group/i)).toBeTruthy();
});
it("shows the configuration error screen when the client id is missing", () => {
const original = config.clientId;
(config as { clientId: string | undefined }).clientId = undefined;
try {
render(<AppRoutes />);
expect(screen.getByTestId("config-error-screen")).toBeTruthy();
} finally {
(config as { clientId: string | undefined }).clientId = original;
}
});
});
-176
View File
@@ -1,176 +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, apiFetch } from "./api/client";
import AdminShell from "./App";
type Screen =
| { kind: "loading" }
| { kind: "login" }
| { kind: "config-error"; message: string }
| { kind: "auth-error"; message: string }
| { kind: "refusal"; message: string }
| { kind: "app" };
function currentPath(): string {
return window.location.pathname.replace(/\/+$/, "") || "/";
}
async function probeApi(setScreen: (screen: Screen) => void) {
// A cheap authenticated probe so a 403 surfaces as an explicit refusal
// right after login instead of a silent empty shell.
try {
await apiFetch("/api/v1/children", {}, config);
setScreen({ kind: "app" });
} catch (error) {
if (error instanceof ConfigError) {
setScreen({ kind: "config-error", message: error.message });
return;
}
if (error instanceof ApiForbiddenError) {
setScreen({ kind: "refusal", message: error.message });
return;
}
// Network or other failures still allow the shell to render; API screens
// handle their own errors in later tasks.
setScreen({ kind: "app" });
}
}
function LoginScreen({ onLogin }: { onLogin: () => void }) {
return (
<div className="admin-login" data-testid="login-screen">
<h1 className="admin-title">NestQuest Admin</h1>
<p className="admin-subline">Sign in to continue</p>
<button type="button" className="admin-login-button" onClick={onLogin}>
Sign in with Authentik
</button>
</div>
);
}
export default function AppRoutes() {
const [screen, setScreen] = useState<Screen>({ kind: "loading" });
useEffect(() => {
// Re-evaluate on navigation (popstate) — routing is path based.
const onPop = () => setScreen({ kind: "loading" });
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
useEffect(() => {
let cancelled = false;
const path = currentPath();
if (!config.clientId) {
setScreen({
kind: "config-error",
message:
"Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
});
return;
}
if (path === "/auth/callback") {
(async () => {
try {
await handleCallback(window.location.search, config);
if (cancelled) return;
window.history.replaceState({}, "", "/");
await probeApi((next) => !cancelled && setScreen(next));
} catch (error) {
if (cancelled) return;
const message =
error instanceof AuthError || error instanceof Error
? error.message
: "Authentication failed.";
setScreen({ kind: "auth-error", message });
}
})();
return () => {
cancelled = true;
};
}
if (isAuthenticated()) {
void probeApi((next) => !cancelled && setScreen(next));
} else {
setScreen({ kind: "login" });
}
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const startLogin = async () => {
try {
const url = await buildAuthorizeUrl(config);
window.location.assign(url);
} catch (error) {
setScreen({
kind: "config-error",
message: error instanceof Error ? error.message : "Login failed.",
});
}
};
switch (screen.kind) {
case "loading":
return (
<div className="admin-loading" role="status" data-testid="loading-screen">
Loading…
</div>
);
case "login":
return <LoginScreen onLogin={startLogin} />;
case "config-error":
return (
<div className="admin-error" role="alert" data-testid="config-error-screen">
<h1>Configuration error</h1>
<p>{screen.message}</p>
</div>
);
case "auth-error":
return (
<div className="admin-error" role="alert" data-testid="auth-error-screen">
<h1>Sign-in failed</h1>
<p>{screen.message}</p>
<button
type="button"
onClick={() => {
clearAuth();
window.history.replaceState({}, "", "/");
setScreen({ kind: "loading" });
}}
>
Try again
</button>
</div>
);
case "refusal":
return (
<div className="admin-refusal" role="alert" data-testid="refusal-screen">
<h1>Access refused</h1>
<p>{screen.message}</p>
<button type="button" onClick={() => { clearAuth(); window.history.replaceState({}, "", "/"); setScreen({ kind: "login" }); }}>
Sign out
</button>
</div>
);
case "app":
return <AdminShell />;
}
}
-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;
}
-174
View File
@@ -1,174 +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;
}
async function readJson<T>(response: Response): Promise<T> {
if (!response.ok) {
let body: unknown = null;
try {
body = await response.json();
} catch {
// Non-JSON error body: report the status alone.
}
throw new ApiRequestError(response.status, readDetail(body));
}
return (await response.json()) as T;
}
export async function fetchDefinitions(): Promise<QuestDefinition[]> {
const body = await readJson<{ definitions: QuestDefinition[] }>(
await apiFetch(DEFINITIONS_PATH),
);
return body.definitions;
}
export async function fetchChildren(): Promise<AdminChild[]> {
const body = await readJson<{ children: AdminChild[] }>(await apiFetch(CHILDREN_PATH));
return body.children;
}
function sendJson(path: string, method: "POST" | "PATCH", body: unknown): Promise<Response> {
return apiFetch(path, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
export async function createDefinition(body: DefinitionCreateBody): Promise<QuestDefinition> {
return readJson<QuestDefinition>(await sendJson(DEFINITIONS_PATH, "POST", body));
}
export async function updateDefinition(
id: number,
body: DefinitionEditBody,
): Promise<QuestDefinition> {
return readJson<QuestDefinition>(await sendJson(`${DEFINITIONS_PATH}/${id}`, "PATCH", body));
}
export const OCCURRENCES_PREVIEW_PATH = `${DEFINITIONS_PATH}/occurrences-preview`;
export interface OccurrencesPreviewOptions {
/** "YYYY-MM-DD"; the API defaults to its host's today. */
startDate?: string;
/** 1..50; the API defaults to 10. */
count?: number;
}
/**
* The next occurrence dates ("YYYY-MM-DD", ascending) for `rule`, computed by
* the same backend recurrence engine the materializer uses.
*/
export async function previewOccurrences(
rule: DefinitionRule,
options: OccurrencesPreviewOptions = {},
): Promise<string[]> {
const body: { rule: DefinitionRule; start_date?: string; count?: number } = { rule };
if (options.startDate !== undefined) body.start_date = options.startDate;
if (options.count !== undefined) body.count = options.count;
const response = await readJson<{ dates: string[] }>(
await sendJson(OCCURRENCES_PREVIEW_PATH, "POST", body),
);
return response.dates;
}
-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;
}
-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,
};
}
-561
View File
@@ -1,561 +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-due-times {
display: flex;
flex-direction: column;
gap: 6px;
}
.defs-due {
display: grid;
grid-template-columns: 84px 1fr;
align-items: center;
gap: 8px;
font-size: 12.5px;
font-weight: 700;
}
.defs-hint {
margin: 0;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.defs-toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.defs-toggle-title {
font-size: 13.5px;
font-weight: 700;
}
.defs-toggle-sub {
font-size: var(--nq-a-size-meta);
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.defs-switch {
flex: none;
position: relative;
width: 44px;
height: 26px;
padding: 0;
border: 0;
border-radius: var(--nq-a-radius-pill);
background: var(--nq-a-border);
cursor: pointer;
transition: background var(--nq-dur-base) var(--nq-ease-out);
}
.defs-switch--on {
background: var(--nq-brand-gradient);
}
.defs-switch-knob {
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 2px rgba(9, 9, 11, 0.25);
transition: transform var(--nq-dur-base) var(--nq-ease-out);
}
.defs-switch--on .defs-switch-knob {
transform: translateX(18px);
}
.defs-sheet-error {
margin: 12px 0 0;
padding: 10px 12px;
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-danger-bg);
color: var(--nq-a-danger-strong);
font-size: 12.5px;
font-weight: 700;
}
.defs-sheet-footer {
display: flex;
gap: 8px;
margin-top: 16px;
}
.defs-confirm {
margin-top: 12px;
padding: 10px 12px;
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-danger-bg);
color: var(--nq-a-danger-strong);
font-size: 12.5px;
font-weight: 700;
}
.defs-confirm p {
margin: 0;
}
.defs-confirm .defs-sheet-footer {
margin-top: 10px;
}
.defs-cancel,
.defs-save {
padding: 13px 0;
border-radius: var(--nq-a-density-radius);
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.defs-cancel {
flex: 1;
border: 1px solid var(--nq-a-border);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
}
.defs-save {
flex: 2;
border: 0;
background: var(--nq-brand-gradient);
color: #fff;
}
.defs-cancel:disabled,
.defs-save:disabled {
opacity: 0.6;
cursor: default;
}
.defs-preview {
margin-top: 8px;
}
.defs-preview--error {
color: var(--nq-a-ink-secondary);
font-style: italic;
}
@@ -1,920 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import DefinitionsTab from "./DefinitionsTab";
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
import { storeTokens } from "../auth/oidc";
function child(id: number, name: string, overrides: Partial<AdminChild> = {}): AdminChild {
return {
id,
display_name: name,
colour: null,
avatar_ref: null,
sort_order: id,
is_active: true,
...overrides,
};
}
const CHILDREN: AdminChild[] = [
child(1, "Declan"),
child(2, "Maeve"),
child(3, "Rory"),
child(4, "Old", { is_active: false }),
];
function rule(overrides: Partial<DefinitionRule> = {}): DefinitionRule {
return {
rule_type: "daily",
interval: 1,
weekday_set: null,
day_of_month: null,
nth_weekday: null,
nth_weekday_weekday: null,
month: null,
start_date: "2026-01-01",
end_date: null,
...overrides,
};
}
const BRUSH: QuestDefinition = {
id: 10,
title: "Brush teeth",
description: null,
icon: "smile",
is_active: true,
skip_on_away: true,
rule: rule(),
assignees: [
{ id: 1, display_name: "Declan" },
{ id: 2, display_name: "Maeve" },
{ id: 3, display_name: "Rory" },
],
windows: [
{ window: "morning", due_time: "07:30" },
{ window: "evening", due_time: null },
],
};
const BINS: QuestDefinition = {
id: 11,
title: "Take out bins",
description: "Green bin",
icon: null,
is_active: true,
skip_on_away: false,
rule: rule({ rule_type: "weekly", interval: 2, weekday_set: [0, 3] }),
assignees: [
{ id: 1, display_name: "Declan" },
{ id: 4, display_name: "Old" },
],
windows: [{ window: "afternoon", due_time: "16:00" }],
};
const PIANO: QuestDefinition = {
id: 12,
title: "Piano practice",
description: null,
icon: null,
is_active: false,
skip_on_away: true,
rule: rule({ rule_type: "monthly_day", day_of_month: 15 }),
assignees: [{ id: 2, display_name: "Maeve" }],
windows: [{ window: "afternoon", due_time: null }],
};
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
interface Call {
method: string;
path: string;
body: unknown;
}
const PREVIEW_PATH = "/api/v1/admin/quest-definitions/occurrences-preview";
/** Answers an occurrence-preview request; the default serves no dates. */
type PreviewHandler = (body: { rule: DefinitionRule; count?: number }) => Promise<Response>;
/**
* A stub of the admin API: serves the list and children, records writes, and
* lets a test queue a response for the next write. Occurrence previews are
* reads (recorded, never counted as writes) answered by `setPreview`.
*/
function fakeApi(initial: QuestDefinition[]) {
let definitions = [...initial];
const calls: Call[] = [];
let nextWrite: Response | null = null;
let preview: PreviewHandler = async () => jsonResponse({ dates: [] });
const fetchMock = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
const path = new URL(String(input), "http://localhost").pathname;
const method = init.method ?? "GET";
const body = typeof init.body === "string" ? JSON.parse(init.body) : null;
calls.push({ method, path, body });
if (method === "POST" && path === PREVIEW_PATH) return preview(body);
if (method === "GET" && path === "/api/v1/admin/quest-definitions") {
return jsonResponse({ definitions });
}
if (method === "GET" && path === "/api/v1/admin/children") {
return jsonResponse({ children: CHILDREN });
}
if (nextWrite) {
const response = nextWrite;
nextWrite = null;
return response;
}
if (method === "POST" && path === "/api/v1/admin/quest-definitions") {
const created: QuestDefinition = {
id: 99,
title: body.title,
description: null,
icon: null,
is_active: true,
skip_on_away: body.skip_on_away,
rule: body.rule,
assignees: CHILDREN.filter((c) => body.assignee_child_ids.includes(c.id)).map((c) => ({
id: c.id,
display_name: c.display_name,
})),
windows: body.windows.map(([window, due_time]: [string, string | null]) => ({
window,
due_time,
})),
};
definitions = [...definitions, created];
return jsonResponse(created, 201);
}
const match = /^\/api\/v1\/admin\/quest-definitions\/(\d+)$/.exec(path);
if (method === "PATCH" && match) {
const id = Number(match[1]);
definitions = definitions.map((d) =>
d.id === id
? {
...d,
title: body.title ?? d.title,
skip_on_away: body.skip_on_away ?? d.skip_on_away,
rule: body.rule ?? d.rule,
// Like the API: a sent list replaces the whole set; an absent one keeps it.
assignees:
"assignee_child_ids" in body
? CHILDREN.filter((c) => body.assignee_child_ids.includes(c.id)).map((c) => ({
id: c.id,
display_name: c.display_name,
}))
: d.assignees,
}
: d,
);
return jsonResponse(definitions.find((d) => d.id === id));
}
return jsonResponse({ detail: "unexpected" }, 500);
});
return {
fetchMock,
calls,
writes: () => calls.filter((c) => c.method !== "GET" && c.path !== PREVIEW_PATH),
previews: () => calls.filter((c) => c.path === PREVIEW_PATH),
setPreview: (handler: PreviewHandler) => {
preview = handler;
},
listFetches: () =>
calls.filter((c) => c.method === "GET" && c.path === "/api/v1/admin/quest-definitions")
.length,
failNextWrite: (response: Response) => {
nextWrite = response;
},
};
}
let api: ReturnType<typeof fakeApi>;
async function renderReady(initial: QuestDefinition[] = [BRUSH, BINS, PIANO]) {
api = fakeApi(initial);
vi.stubGlobal("fetch", api.fetchMock);
render(<DefinitionsTab />);
await screen.findByText("Brush teeth");
}
function meta(row: HTMLElement): string {
return (row.querySelector(".defs-row-meta") as HTMLElement).textContent ?? "";
}
function sheet(): HTMLElement {
return screen.getByRole("dialog");
}
describe("DefinitionsTab", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("header counts definitions and active children", async () => {
await renderReady();
expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy();
expect(screen.getByText("3 definitions · 3 children")).toBeTruthy();
const [url, init] = api.fetchMock.mock.calls[0];
expect(String(url)).toMatch(/\/api\/v1\/admin\/quest-definitions$/);
expect((init as RequestInit & { headers: Record<string, string> }).headers.Authorization).toBe(
"Bearer at-123",
);
});
it("rows show `<assignees> · <recurrence> · <windows>` meta", async () => {
await renderReady();
const brush = screen.getByTestId("definition-10");
expect(within(brush).getByText("Brush teeth")).toBeTruthy();
expect(meta(brush)).toBe("All three · Daily · Morning, Evening");
expect(meta(screen.getByTestId("definition-11"))).toBe(
"Declan, Old · Every 2 weeks on Mon, Thu · Afternoon",
);
expect(brush.className).not.toContain("defs-row--inactive");
});
it("an inactive definition is styled inactive with `· inactive`", async () => {
await renderReady();
const piano = screen.getByTestId("definition-12");
expect(meta(piano)).toBe("Maeve · Monthly on day 15 · Afternoon · inactive");
expect(piano.className).toContain("defs-row--inactive");
});
it("filter chips filter by child", async () => {
await renderReady();
const chips = screen.getByRole("group", { name: "Filter by child" });
expect(
within(chips)
.getAllByRole("button")
.map((b) => b.textContent),
).toEqual(["All", "Declan", "Maeve", "Rory"]);
expect(within(chips).getByRole("button", { name: "All" }).className).toContain(
"defs-chip--active",
);
fireEvent.click(within(chips).getByRole("button", { name: "Maeve" }));
expect(within(chips).getByRole("button", { name: "Maeve" }).getAttribute("aria-pressed")).toBe(
"true",
);
expect(screen.queryByTestId("definition-10")).toBeTruthy();
expect(screen.queryByTestId("definition-11")).toBeNull();
expect(screen.queryByTestId("definition-12")).toBeTruthy();
fireEvent.click(within(chips).getByRole("button", { name: "Rory" }));
expect(screen.queryByTestId("definition-10")).toBeTruthy();
expect(screen.queryByTestId("definition-12")).toBeNull();
fireEvent.click(within(chips).getByRole("button", { name: "All" }));
expect(screen.getAllByRole("listitem")).toHaveLength(3);
});
it("New opens the sheet and saving POSTs the exact body, then re-fetches", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date(2026, 8, 23, 9, 0)); // Wed 23 Sep 2026, local
await renderReady();
expect(api.listFetches()).toBe(1);
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
expect(within(s).getByRole("heading", { name: "New task" })).toBeTruthy();
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: " Feed the cat " },
});
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
expect(within(s).getByRole("button", { name: /Assigned children/ }).textContent).toContain(
"Declan, Rory",
);
fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), {
target: { value: "weekly" },
});
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
target: { value: "2" },
});
// Today (Wednesday) is preselected; add Saturday.
expect(within(s).getByRole("button", { name: "Wednesday" }).getAttribute("aria-pressed")).toBe(
"true",
);
fireEvent.click(within(s).getByRole("button", { name: "Saturday" }));
fireEvent.click(within(s).getByRole("button", { name: "Evening" }));
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
fireEvent.change(within(s).getByLabelText("Morning due time"), {
target: { value: "07:15" },
});
const toggle = within(s).getByRole("switch", { name: "Skip on away days" });
expect(toggle.getAttribute("aria-checked")).toBe("true");
fireEvent.click(toggle);
expect(toggle.getAttribute("aria-checked")).toBe("false");
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toEqual([
{
method: "POST",
path: "/api/v1/admin/quest-definitions",
body: {
title: "Feed the cat",
rule: {
rule_type: "weekly",
interval: 2,
weekday_set: [2, 5],
day_of_month: null,
nth_weekday: null,
nth_weekday_weekday: null,
month: null,
start_date: "2026-09-23",
end_date: null,
},
assignee_child_ids: [1, 3],
windows: [
["morning", "07:15"],
["evening", null],
],
skip_on_away: false,
},
},
]);
const created = await screen.findByTestId("definition-99");
expect(meta(created)).toBe("Declan, Rory · Every 2 weeks on Wed, Sat · Morning, Evening");
expect(api.listFetches()).toBe(2);
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.getByText("4 definitions · 3 children")).toBeTruthy();
});
it("tapping a row opens the sheet in editing state and saving PATCHes", async () => {
await renderReady();
const row = screen.getByTestId("definition-11");
fireEvent.click(row);
// §3.1 editing state on the row.
expect(row.className).toContain("defs-row--editing");
expect(meta(row)).toBe("Editing");
expect(row.getAttribute("aria-expanded")).toBe("true");
const s = sheet();
expect(within(s).getByRole("heading", { name: "Edit task" })).toBeTruthy();
expect((within(s).getByRole("textbox", { name: "Title" }) as HTMLInputElement).value).toBe(
"Take out bins",
);
expect((within(s).getByRole("combobox", { name: "Repeats" }) as HTMLSelectElement).value).toBe(
"weekly",
);
expect((within(s).getByLabelText("Afternoon due time") as HTMLInputElement).value).toBe(
"16:00",
);
// The inactive child "Old" is shown locked, not offered as a checkbox.
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
expect(within(s).queryByRole("checkbox", { name: "Old" })).toBeNull();
const locked = within(s).getByTestId("inactive-assignee-4");
expect(locked.textContent).toContain("Old");
expect(locked.textContent).toContain("Inactive");
expect(locked.getAttribute("aria-disabled")).toBe("true");
fireEvent.click(within(s).getByRole("checkbox", { name: "Maeve" }));
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Take out the bins" },
});
fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), {
target: { value: "monthly" },
});
fireEvent.change(within(s).getByRole("spinbutton", { name: "Day of month" }), {
target: { value: "3" },
});
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
target: { value: "1" },
});
fireEvent.change(within(s).getByLabelText("Afternoon due time"), {
target: { value: "" },
});
fireEvent.click(within(s).getByRole("switch", { name: "Skip on away days" }));
// Changing the selection would drop "Old": confirm before sending.
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
expect(api.writes()).toHaveLength(0);
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Unassign and save" }));
});
expect(api.writes()).toEqual([
{
method: "PATCH",
path: "/api/v1/admin/quest-definitions/11",
body: {
title: "Take out the bins",
rule: {
rule_type: "monthly_day",
interval: 1,
weekday_set: null,
day_of_month: 3,
nth_weekday: null,
nth_weekday_weekday: null,
month: null,
start_date: "2026-01-01",
end_date: null,
},
assignee_child_ids: [1, 2],
windows: [["afternoon", null]],
skip_on_away: true,
},
},
]);
// The icon is never sent, so it is carried through unchanged.
expect(api.writes()[0].body).not.toHaveProperty("icon");
await screen.findByText("Take out the bins");
expect(api.listFetches()).toBe(2);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("editing only the title omits assignee_child_ids and keeps an inactive assignee", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-11"));
const s = sheet();
expect(within(s).getByText(/Also assigned \(inactive\): Old\./)).toBeTruthy();
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Bins out" },
});
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(screen.queryByTestId("unassign-confirm")).toBeNull();
expect(api.writes()).toHaveLength(1);
const body = api.writes()[0].body as Record<string, unknown>;
expect(api.writes()[0].method).toBe("PATCH");
expect(body.title).toBe("Bins out");
expect(body).not.toHaveProperty("assignee_child_ids");
await screen.findByText("Bins out");
expect(meta(screen.getByTestId("definition-11"))).toBe(
"Declan, Old · Every 2 weeks on Mon, Thu · Afternoon",
);
});
it("changing the active selection with an inactive assignee asks first", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-11"));
const s = sheet();
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
const confirm = within(s).getByTestId("unassign-confirm");
expect(confirm.textContent).toContain("Saving will unassign Old.");
expect(document.activeElement).toBe(confirm);
expect(api.writes()).toHaveLength(0);
// Going back sends nothing and restores the normal footer.
fireEvent.click(within(confirm).getByRole("button", { name: "Go back" }));
expect(within(s).queryByTestId("unassign-confirm")).toBeNull();
expect(api.writes()).toHaveLength(0);
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Unassign and save" }));
});
expect(api.writes()).toHaveLength(1);
expect((api.writes()[0].body as { assignee_child_ids: number[] }).assignee_child_ids).toEqual([
1, 3,
]);
});
it("reverting the selection to the original omits assignee_child_ids without asking", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-11"));
const s = sheet();
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(screen.queryByTestId("unassign-confirm")).toBeNull();
expect(api.writes()).toHaveLength(1);
expect(api.writes()[0].body).not.toHaveProperty("assignee_child_ids");
});
it("focus moves into the sheet on open and returns to the row on Escape", async () => {
await renderReady();
const row = screen.getByTestId("definition-10");
row.focus();
fireEvent.click(row);
const s = sheet();
expect(document.activeElement).toBe(within(s).getByRole("heading", { name: "Edit task" }));
fireEvent.keyDown(s, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
expect(document.activeElement).toBe(row);
expect(api.writes()).toHaveLength(0);
});
it("Tab and Shift+Tab wrap within the sheet", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
const s = sheet();
const title = within(s).getByRole("textbox", { name: "Title" });
const save = within(s).getByRole("button", { name: "Save changes" });
save.focus();
fireEvent.keyDown(s, { key: "Tab" });
expect(document.activeElement).toBe(title);
fireEvent.keyDown(s, { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(save);
});
it("Escape does not close the sheet while saving", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
let release: (response: Response) => void = () => {};
api.fetchMock.mockImplementationOnce(
() => new Promise<Response>((resolve) => (release = resolve)),
);
const s = sheet();
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
fireEvent.keyDown(s, { key: "Escape" });
expect(screen.getByRole("dialog")).toBeTruthy();
await act(async () => {
release(jsonResponse(BRUSH));
});
await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("the skip-on-away toggle reflects and persists a false value", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-11"));
const s = sheet();
const toggle = within(s).getByRole("switch", { name: "Skip on away days" });
expect(toggle.getAttribute("aria-checked")).toBe("false");
expect(toggle.className).not.toContain("defs-switch--on");
expect(within(s).getByText("No instance when the child is absent")).toBeTruthy();
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toHaveLength(1);
expect((api.writes()[0].body as { skip_on_away: boolean }).skip_on_away).toBe(false);
});
it("a 422 from save shows the API's detail and keeps the sheet open", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
api.failNextWrite(jsonResponse({ detail: "interval must be >= 1, got 0" }, 422));
await act(async () => {
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
});
expect(screen.getByTestId("sheet-error").textContent).toBe("interval must be >= 1, got 0");
expect(screen.getByRole("dialog")).toBeTruthy();
expect(api.listFetches()).toBe(1);
// Save is usable again after the failure.
expect(
(within(sheet()).getByRole("button", { name: "Save changes" }) as HTMLButtonElement).disabled,
).toBe(false);
});
it("a 403 from save shows the admin-group refusal", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
api.failNextWrite(jsonResponse({ detail: "Forbidden" }, 403));
await act(async () => {
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
});
expect(screen.getByTestId("sheet-error").textContent).toBe(
"Your account is not in the nestquest-admins group.",
);
});
it("guards against a double submit", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
const save = within(sheet()).getByRole("button", { name: "Save changes" });
await act(async () => {
fireEvent.click(save);
fireEvent.click(save);
});
expect(api.writes()).toHaveLength(1);
});
it("an incomplete new task is not submitted", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
expect(screen.getByTestId("sheet-error").textContent).toBe("Give the task a title.");
expect(api.writes()).toHaveLength(0);
});
it("Cancel closes the sheet without writing", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
fireEvent.click(within(sheet()).getByRole("button", { name: "Cancel" }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(meta(screen.getByTestId("definition-10"))).toBe("All three · Daily · Morning, Evening");
expect(api.writes()).toHaveLength(0);
});
it("a 403 on load renders the refusal", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => jsonResponse({ detail: "Forbidden" }, 403)),
);
render(<DefinitionsTab />);
expect((await screen.findByTestId("definitions-error")).textContent).toContain(
"Your account is not in the nestquest-admins group.",
);
});
it("scroll column carries the 92px bottom padding", async () => {
await renderReady();
expect(screen.getByTestId("definitions-scroll").style.paddingBottom).toBe("92px");
});
});
/* ── Occurrence preview ─────────────────────────────────────────────── */
/** Past the sheet's 300 ms preview debounce. */
function settleDebounce(): Promise<void> {
return act(() => new Promise<void>((resolve) => setTimeout(resolve, 400)));
}
function previewText(): string | null {
return screen.queryByTestId("occurrence-preview")?.textContent ?? null;
}
/** BINS' rule (every 2 weeks on Mon, Thu) from 2026-10-05. */
const BINS_DATES = ["2026-10-05", "2026-10-08", "2026-10-19", "2026-10-22", "2026-11-02"];
/** The same rule on Tuesdays only. */
const TUESDAY_DATES = ["2026-10-06", "2026-10-20", "2026-11-03", "2026-11-17", "2026-12-01"];
function deferred() {
let resolve!: (response: Response) => void;
const promise = new Promise<Response>((r) => {
resolve = r;
});
return { promise, resolve };
}
describe("DefinitionsTab occurrence preview", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("previews a weekly rule's next dates in plain language from the API", async () => {
await renderReady();
api.setPreview(async () => jsonResponse({ dates: BINS_DATES }));
fireEvent.click(screen.getByTestId("definition-11"));
await waitFor(() =>
expect(previewText()).toBe(
"Next: Mon 5 Oct · Thu 8 Oct · Mon 19 Oct · Thu 22 Oct · Mon 2 Nov · …",
),
);
expect(api.previews()).toEqual([
{
method: "POST",
path: "/api/v1/admin/quest-definitions/occurrences-preview",
body: { rule: BINS.rule, count: 5 },
},
]);
});
it("re-requests once per rule change, debounced, and not for non-rule edits", async () => {
await renderReady();
api.setPreview(async ({ rule }) =>
jsonResponse({ dates: rule.weekday_set?.includes(0) ? BINS_DATES : TUESDAY_DATES }),
);
fireEvent.click(screen.getByTestId("definition-11"));
const s = sheet();
await waitFor(() => expect(previewText()).toContain("Mon 5 Oct"));
// A title edit leaves the rule alone: no new request.
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Bins" },
});
// Three rule edits in quick succession collapse into one request.
const days = within(s).getByRole("group", { name: "Days" });
fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
fireEvent.click(within(days).getByRole("button", { name: "Thursday" }));
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
await waitFor(() =>
expect(previewText()).toBe(
"Next: Tue 6 Oct · Tue 20 Oct · Tue 3 Nov · Tue 17 Nov · Tue 1 Dec · …",
),
);
await settleDebounce();
expect(api.previews()).toHaveLength(2);
expect(api.previews()[1].body).toEqual({
rule: { ...BINS.rule, weekday_set: [1] },
count: 5,
});
// An interval change is a rule change too.
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
target: { value: "1" },
});
await waitFor(() => expect(api.previews()).toHaveLength(3));
expect((api.previews()[2].body as { rule: DefinitionRule }).rule.interval).toBe(1);
});
it("window and assignee edits never request, and a rule edit waits out the debounce", async () => {
await renderReady();
api.setPreview(async () => jsonResponse({ dates: BINS_DATES }));
fireEvent.click(screen.getByTestId("definition-11"));
const s = sheet();
await waitFor(() => expect(previewText()).toContain("Mon 5 Oct"));
expect(api.previews()).toHaveLength(1);
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
// Windows and assignees are not part of the rule.
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Maeve" }));
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(api.previews()).toHaveLength(1);
expect(previewText()).toContain("Mon 5 Oct");
// A rule edit is not requested until the 300 ms debounce elapses.
const days = within(s).getByRole("group", { name: "Days" });
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
await act(async () => {
vi.advanceTimersByTime(299);
});
expect(api.previews()).toHaveLength(1);
await act(async () => {
vi.advanceTimersByTime(1);
});
expect(api.previews()).toHaveLength(2);
expect((api.previews()[1].body as { rule: DefinitionRule }).rule.weekday_set).toEqual([
0, 1, 3,
]);
});
it("a rule edit hides the previous rule's dates until its own response arrives", async () => {
await renderReady();
const pending: ReturnType<typeof deferred>[] = [];
api.setPreview(() => {
const next = deferred();
pending.push(next);
return next.promise;
});
fireEvent.click(screen.getByTestId("definition-11"));
await waitFor(() => expect(pending).toHaveLength(1));
await act(async () => {
pending[0].resolve(jsonResponse({ dates: BINS_DATES }));
});
await waitFor(() => expect(previewText()).toContain("Mon 5 Oct"));
const days = within(sheet()).getByRole("group", { name: "Days" });
fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
// Within the debounce: the old dates are already gone.
expect(previewText()).toBeNull();
// Request in flight: still nothing shown for the new rule.
await waitFor(() => expect(pending).toHaveLength(2));
expect(previewText()).toBeNull();
await act(async () => {
pending[1].resolve(jsonResponse({ dates: ["2026-10-08", "2026-10-22"] }));
});
await waitFor(() => expect(previewText()).toBe("Next: Thu 8 Oct · Thu 22 Oct"));
});
it("a rule edit hides the previous rule's error until its own response arrives", async () => {
await renderReady();
const pending: ReturnType<typeof deferred>[] = [];
api.setPreview(() => {
const next = deferred();
pending.push(next);
return next.promise;
});
fireEvent.click(screen.getByTestId("definition-11"));
await waitFor(() => expect(pending).toHaveLength(1));
await act(async () => {
pending[0].resolve(jsonResponse({ detail: "rule rejected" }, 422));
});
await waitFor(() => expect(previewText()).toBe("Upcoming dates unavailable: rule rejected"));
const days = within(sheet()).getByRole("group", { name: "Days" });
fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
expect(previewText()).toBeNull();
await waitFor(() => expect(pending).toHaveLength(2));
expect(previewText()).toBeNull();
await act(async () => {
pending[1].resolve(jsonResponse({ dates: ["2026-10-08"] }));
});
await waitFor(() => expect(previewText()).toBe("Next: Thu 8 Oct"));
});
it("a stale response resolving after a newer one does not overwrite it", async () => {
await renderReady();
const pending: ReturnType<typeof deferred>[] = [];
api.setPreview(() => {
const next = deferred();
pending.push(next);
return next.promise;
});
fireEvent.click(screen.getByTestId("definition-11"));
await waitFor(() => expect(pending).toHaveLength(1));
const days = within(sheet()).getByRole("group", { name: "Days" });
fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
fireEvent.click(within(days).getByRole("button", { name: "Thursday" }));
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
await waitFor(() => expect(pending).toHaveLength(2));
await act(async () => {
pending[1].resolve(jsonResponse({ dates: TUESDAY_DATES }));
});
await waitFor(() => expect(previewText()).toContain("Tue 6 Oct"));
await act(async () => {
pending[0].resolve(jsonResponse({ dates: BINS_DATES }));
});
await settleDebounce();
expect(previewText()).toBe(
"Next: Tue 6 Oct · Tue 20 Oct · Tue 3 Nov · Tue 17 Nov · Tue 1 Dec · …",
);
});
it("a failed preview shows a non-blocking message and the sheet still saves", async () => {
await renderReady();
api.setPreview(async () => jsonResponse({ detail: "rule rejected" }, 422));
fireEvent.click(screen.getByTestId("definition-10"));
await waitFor(() => expect(previewText()).toBe("Upcoming dates unavailable: rule rejected"));
expect(screen.queryByTestId("sheet-error")).toBeNull();
await act(async () => {
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toHaveLength(1);
expect(api.writes()[0].method).toBe("PATCH");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("an incomplete draft does not call the preview endpoint", async () => {
await renderReady();
api.setPreview(async () => jsonResponse({ dates: TUESDAY_DATES }));
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), {
target: { value: "weekly" },
});
// Clear the default weekday: a weekly rule with no days is incomplete.
const days = within(s).getByRole("group", { name: "Days" });
for (const day of within(days).getAllByRole("button")) {
if (day.getAttribute("aria-pressed") === "true") fireEvent.click(day);
}
await settleDebounce();
expect(api.previews()).toHaveLength(0);
expect(previewText()).toBeNull();
// An unparseable interval is incomplete too.
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
target: { value: "" },
});
await settleDebounce();
expect(api.previews()).toHaveLength(0);
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
target: { value: "2" },
});
await waitFor(() => expect(previewText()).toContain("Tue 6 Oct"));
expect(api.previews()).toHaveLength(1);
});
});
-935
View File
@@ -1,935 +0,0 @@
/**
* Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2).
*
* The icon picker (§3.3) is a separate task: an existing definition's icon is
* carried through unchanged by never sending it.
*/
import {
useEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react";
import { ApiForbiddenError } from "../api/client";
import {
ApiRequestError,
createDefinition,
fetchChildren,
fetchDefinitions,
previewOccurrences,
updateDefinition,
type AdminChild,
type DefinitionCreateBody,
type DefinitionEditBody,
type DefinitionRule,
type QuestDefinition,
type WindowEntry,
type WindowName,
} from "../api/definitions";
import {
MONTH_SHORT,
WEEKDAY_SHORT,
WINDOW_LABELS,
WINDOW_ORDER,
assigneeSummary,
occurrenceLabel,
recurrenceSummary,
windowSummary,
} from "./format";
import {
CheckGlyph,
ChevronDownGlyph,
ChevronRightGlyph,
PlusGlyph,
TaskGlyph,
} from "./glyphs";
import "./DefinitionsTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
export const SCROLL_BOTTOM_PADDING = "92px";
type Repeats = "daily" | "weekly" | "monthly" | "yearly" | "custom";
const REPEATS_OPTIONS: { value: Repeats; label: string }[] = [
{ value: "daily", label: "Daily" },
{ value: "weekly", label: "Weekly" },
{ value: "monthly", label: "Monthly" },
{ value: "yearly", label: "Yearly" },
{ value: "custom", label: "Custom" },
];
const REPEATS_BY_RULE: Record<DefinitionRule["rule_type"], Repeats> = {
daily: "daily",
weekly: "weekly",
monthly_day: "monthly",
monthly_weekday: "monthly",
yearly: "yearly",
custom_days: "custom",
};
/** How many upcoming dates the edit sheet previews. */
const PREVIEW_COUNT = 5;
/** Quiet period after the last rule edit before the preview is requested. */
const PREVIEW_DEBOUNCE_MS = 300;
const WEEKDAY_LONG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
interface Draft {
title: string;
assigneeIds: number[];
repeats: Repeats;
interval: string;
weekdays: number[];
dayOfMonth: string;
month: string;
/** An existing "nth weekday" monthly rule, kept while Repeats stays Monthly. */
monthlyWeekday: { nth: number; weekday: number } | null;
windows: WindowName[];
dueTimes: Record<WindowName, string>;
skipOnAway: boolean;
startDate: string;
endDate: string | null;
}
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; definitions: QuestDefinition[]; children: AdminChild[] };
/** `null` = closed, `"new"` = creating, a definition = editing it. */
type SheetTarget = null | "new" | QuestDefinition;
function pad2(n: number): string {
return String(n).padStart(2, "0");
}
function localIsoDate(date: Date): string {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
function emptyDueTimes(): Record<WindowName, string> {
return { morning: "", afternoon: "", evening: "" };
}
function newDraft(activeChildren: AdminChild[]): Draft {
const today = new Date();
return {
title: "",
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
repeats: "daily",
interval: "1",
weekdays: [(today.getDay() + 6) % 7],
dayOfMonth: String(today.getDate()),
month: String(today.getMonth() + 1),
monthlyWeekday: null,
windows: [],
dueTimes: emptyDueTimes(),
// Mirrors the API's create default: no instance on an away day.
skipOnAway: true,
startDate: localIsoDate(today),
endDate: null,
};
}
function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): Draft {
const { rule } = definition;
const today = new Date();
const active = new Set(activeChildren.map((child) => child.id));
const dueTimes = emptyDueTimes();
for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? "";
return {
title: definition.title,
// Inactive children cannot be submitted (the API rejects them with 422).
assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)),
repeats: REPEATS_BY_RULE[rule.rule_type],
interval: String(rule.interval),
weekdays:
rule.rule_type === "weekly" || rule.rule_type === "custom_days"
? [...(rule.weekday_set ?? [])]
: [(today.getDay() + 6) % 7],
dayOfMonth: String(rule.day_of_month ?? today.getDate()),
month: String(rule.month ?? today.getMonth() + 1),
monthlyWeekday:
rule.rule_type === "monthly_weekday"
? { nth: rule.nth_weekday ?? 1, weekday: rule.nth_weekday_weekday ?? 0 }
: null,
windows: WINDOW_ORDER.filter((w) => definition.windows.some((d) => d.window === w)),
dueTimes,
skipOnAway: definition.skip_on_away,
startDate: rule.start_date,
endDate: rule.end_date,
};
}
function parseWhole(value: string, min: number, max: number): number | null {
if (!/^\d+$/.test(value.trim())) return null;
const n = Number(value);
return n >= min && n <= max ? n : null;
}
/** Build the request rule from the draft, or return a message naming what to fix. */
function buildRule(draft: Draft): DefinitionRule | string {
const interval = parseWhole(draft.interval, 1, 999);
if (interval === null) return "Interval must be a whole number of at least 1.";
const rule: DefinitionRule = {
rule_type: "daily",
interval,
weekday_set: null,
day_of_month: null,
nth_weekday: null,
nth_weekday_weekday: null,
month: null,
start_date: draft.startDate,
end_date: draft.endDate,
};
switch (draft.repeats) {
case "daily":
return rule;
case "weekly":
case "custom":
if (draft.weekdays.length === 0) return "Pick at least one day.";
return {
...rule,
rule_type: draft.repeats === "weekly" ? "weekly" : "custom_days",
weekday_set: [...draft.weekdays].sort((a, b) => a - b),
};
case "monthly": {
if (draft.monthlyWeekday) {
return {
...rule,
rule_type: "monthly_weekday",
nth_weekday: draft.monthlyWeekday.nth,
nth_weekday_weekday: draft.monthlyWeekday.weekday,
};
}
const day = parseWhole(draft.dayOfMonth, 1, 31);
if (day === null) return "Day of month must be 1 to 31.";
return { ...rule, rule_type: "monthly_day", day_of_month: day };
}
case "yearly": {
const month = parseWhole(draft.month, 1, 12);
const day = parseWhole(draft.dayOfMonth, 1, 31);
if (month === null || day === null) return "Pick a month and a day (1 to 31).";
return { ...rule, rule_type: "yearly", month, day_of_month: day };
}
}
}
/**
* `keepsAssignees`: an edit whose active selection is unchanged sends no
* assignee list, so an empty selection is fine when inactive children remain.
*/
function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody | string {
const title = draft.title.trim();
if (!title) return "Give the task a title.";
if (draft.assigneeIds.length === 0 && !keepsAssignees) return "Assign at least one child.";
if (draft.windows.length === 0) return "Pick at least one window.";
const rule = buildRule(draft);
if (typeof rule === "string") return rule;
const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map(
(w) => [w, draft.dueTimes[w] || null],
);
return {
title,
rule,
assignee_child_ids: [...draft.assigneeIds],
windows,
skip_on_away: draft.skipOnAway,
};
}
function saveErrorMessage(error: unknown): string {
if (error instanceof ApiForbiddenError) return error.message;
if (error instanceof ApiRequestError && error.detail) return error.detail;
return "The task could not be saved. Check your connection and try again.";
}
function toggle<T>(list: T[], value: T): T[] {
return list.includes(value) ? list.filter((item) => item !== value) : [...list, value];
}
function sameIds(a: number[], b: number[]): boolean {
return a.length === b.length && a.every((id) => b.includes(id));
}
const FOCUSABLE =
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])';
/* ── List ───────────────────────────────────────────────────────────── */
function FilterChips({
options,
filter,
onChange,
}: {
options: AdminChild[];
filter: number | "all";
onChange: (filter: number | "all") => void;
}) {
const chips: { key: number | "all"; label: string }[] = [
{ key: "all", label: "All" },
...options.map((child) => ({ key: child.id, label: child.display_name })),
];
return (
<div className="defs-chips" role="group" aria-label="Filter by child">
{chips.map((chip) => (
<button
key={chip.key}
type="button"
className={chip.key === filter ? "defs-chip defs-chip--active" : "defs-chip"}
aria-pressed={chip.key === filter}
onClick={() => onChange(chip.key)}
>
{chip.label}
</button>
))}
</div>
);
}
function DefinitionRow({
definition,
activeChildren,
editing,
onOpen,
}: {
definition: QuestDefinition;
activeChildren: AdminChild[];
editing: boolean;
onOpen: () => void;
}) {
let meta = [
assigneeSummary(definition.assignees, activeChildren),
recurrenceSummary(definition.rule),
windowSummary(definition.windows),
].join(" · ");
if (!definition.is_active) meta += " · inactive";
let className = "defs-row";
if (!definition.is_active) className += " defs-row--inactive";
if (editing) className += " defs-row--editing";
return (
<li>
<button
type="button"
className={className}
data-testid={`definition-${definition.id}`}
aria-expanded={editing}
onClick={onOpen}
>
<span className="defs-row-glyph">
<TaskGlyph />
</span>
<span className="defs-row-text">
<span className="defs-row-title">{definition.title}</span>
<span className="defs-row-meta">{editing ? "Editing" : meta}</span>
</span>
<span className="defs-row-chevron">
{editing ? <ChevronDownGlyph /> : <ChevronRightGlyph />}
</span>
</button>
</li>
);
}
/* ── Edit sheet ─────────────────────────────────────────────────────── */
type PreviewState =
| { kind: "idle" }
| { kind: "ready"; ruleKey: string; dates: string[] }
| { kind: "error"; ruleKey: string; message: string };
/**
* The next few dates the backend would materialize for the draft's rule.
* `ruleKey` is the built rule serialized (null while the draft is invalid), so
* the request only goes out when the rule itself changes — debounced, and a
* superseded request's late response is dropped by its effect's cleanup.
* A result is tagged with the rule it answers and only shown while that rule
* is still the draft's, so a changed rule never shows the previous dates.
*/
function OccurrencePreview({ ruleKey }: { ruleKey: string | null }) {
const [preview, setPreview] = useState<PreviewState>({ kind: "idle" });
useEffect(() => {
if (ruleKey === null) {
setPreview({ kind: "idle" });
return;
}
let cancelled = false;
const timer = setTimeout(() => {
previewOccurrences(JSON.parse(ruleKey) as DefinitionRule, { count: PREVIEW_COUNT })
.then((dates) => {
if (!cancelled) setPreview({ kind: "ready", ruleKey, dates });
})
.catch((error: unknown) => {
if (cancelled) return;
const detail =
error instanceof ApiForbiddenError ||
(error instanceof ApiRequestError && error.detail)
? `: ${error.message}`
: ".";
setPreview({
kind: "error",
ruleKey,
message: `Upcoming dates unavailable${detail}`,
});
});
}, PREVIEW_DEBOUNCE_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [ruleKey]);
if (preview.kind === "idle" || preview.ruleKey !== ruleKey) return null;
if (preview.kind === "error") {
return (
<p className="defs-hint defs-preview defs-preview--error" data-testid="occurrence-preview">
{preview.message}
</p>
);
}
let text: string;
if (preview.dates.length === 0) {
text = "No upcoming dates in the next year.";
} else {
const labels = preview.dates.map(occurrenceLabel);
// A full page means the rule keeps going past the last shown date.
if (preview.dates.length >= PREVIEW_COUNT) labels.push("…");
text = `Next: ${labels.join(" · ")}`;
}
return (
<p className="defs-hint defs-preview" data-testid="occurrence-preview" aria-live="polite">
{text}
</p>
);
}
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
return (
<div className="defs-field" role="group" aria-labelledby={id}>
<span className="defs-label" id={id}>
{label}
</span>
{children}
</div>
);
}
function EditSheet({
target,
activeChildren,
onCancel,
onSaved,
}: {
target: "new" | QuestDefinition;
activeChildren: AdminChild[];
onCancel: () => void;
onSaved: () => void;
}) {
const [draft, setDraft] = useState<Draft>(() =>
target === "new" ? newDraft(activeChildren) : draftFrom(target, activeChildren),
);
// The active selection as loaded: while it is unchanged, the PATCH omits
// assignee_child_ids, because the API replaces the whole set and inactive
// children cannot be sent back.
const [originalActiveIds] = useState(() => draft.assigneeIds);
const inactiveAssignees =
target === "new"
? []
: target.assignees.filter((a) => !activeChildren.some((child) => child.id === a.id));
const [pickerOpen, setPickerOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState<string | null>(null);
// A ref, not state: two taps in one frame must not both see `saving === false`.
const inFlight = useRef(false);
const sheetRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null);
const confirmRef = useRef<HTMLDivElement>(null);
// Modal focus: move in on open, hand back to the trigger on close.
useEffect(() => {
const trigger = document.activeElement as HTMLElement | null;
titleRef.current?.focus();
return () => {
if (trigger && trigger.isConnected) trigger.focus();
};
}, []);
useEffect(() => {
if (confirming) confirmRef.current?.focus();
}, [confirming]);
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape") {
event.preventDefault();
if (!inFlight.current) onCancel();
return;
}
if (event.key !== "Tab" || !sheetRef.current) return;
const focusable = Array.from(sheetRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const current = document.activeElement;
const outside = !focusable.includes(current as HTMLElement);
if (event.shiftKey && (current === first || outside)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (current === last || outside)) {
event.preventDefault();
first.focus();
}
}
const update = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
const selectedAssignees = activeChildren
.filter((child) => draft.assigneeIds.includes(child.id))
.map((child) => ({ id: child.id, display_name: child.display_name }));
const orderedWindows = WINDOW_ORDER.filter((w) => draft.windows.includes(w));
const previewRule = buildRule(draft);
const previewKey = typeof previewRule === "string" ? null : JSON.stringify(previewRule);
async function save(confirmed = false) {
if (inFlight.current) return;
const keepsAssignees = target !== "new" && sameIds(draft.assigneeIds, originalActiveIds);
const body = buildBody(draft, keepsAssignees);
if (typeof body === "string") {
setError(body);
setConfirming(false);
return;
}
if (target !== "new" && !keepsAssignees && inactiveAssignees.length > 0 && !confirmed) {
setError(null);
setConfirming(true);
return;
}
setConfirming(false);
inFlight.current = true;
setSaving(true);
setError(null);
try {
if (target === "new") {
await createDefinition(body);
} else {
const edit: DefinitionEditBody = { ...body };
if (keepsAssignees) delete edit.assignee_child_ids;
await updateDefinition(target.id, edit);
}
onSaved();
} catch (err) {
setError(saveErrorMessage(err));
inFlight.current = false;
setSaving(false);
}
}
return (
<div className="defs-sheet-layer">
<div
className="defs-scrim"
data-testid="sheet-scrim"
onClick={() => {
if (!inFlight.current) onCancel();
}}
/>
<div
ref={sheetRef}
className="defs-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="defs-sheet-title"
onKeyDown={onKeyDown}
>
<div className="defs-grabber" aria-hidden="true" />
<h3 className="defs-sheet-title" id="defs-sheet-title" ref={titleRef} tabIndex={-1}>
{target === "new" ? "New task" : "Edit task"}
</h3>
<div className="defs-fields">
<Field label="Title" id="defs-label-title">
<input
className="defs-input"
aria-labelledby="defs-label-title"
value={draft.title}
onChange={(e) => update({ title: e.target.value })}
/>
</Field>
<Field label="Assigned children" id="defs-label-children">
<button
type="button"
className="defs-select-row"
aria-expanded={pickerOpen}
aria-labelledby="defs-label-children defs-children-summary"
onClick={() => setPickerOpen((open) => !open)}
>
<span id="defs-children-summary">
{selectedAssignees.length > 0
? assigneeSummary(selectedAssignees, activeChildren)
: "Choose children"}
</span>
{pickerOpen ? <ChevronDownGlyph /> : <ChevronRightGlyph />}
</button>
{pickerOpen ? (
<ul className="defs-picker" aria-label="Children">
{activeChildren.map((child) => {
const checked = draft.assigneeIds.includes(child.id);
return (
<li key={child.id}>
<button
type="button"
role="checkbox"
aria-checked={checked}
className="defs-picker-option"
onClick={() => update({ assigneeIds: toggle(draft.assigneeIds, child.id) })}
>
<span>{child.display_name}</span>
<span className="defs-picker-check">{checked ? <CheckGlyph /> : null}</span>
</button>
</li>
);
})}
{inactiveAssignees.map((assignee) => (
<li
key={assignee.id}
className="defs-picker-option defs-picker-option--locked"
aria-disabled="true"
data-testid={`inactive-assignee-${assignee.id}`}
>
<span>{assignee.display_name}</span>
<span className="defs-picker-note">Inactive</span>
</li>
))}
</ul>
) : null}
{inactiveAssignees.length > 0 ? (
<p className="defs-hint">
Also assigned (inactive): {inactiveAssignees.map((a) => a.display_name).join(", ")}.
Kept unless you change the selection.
</p>
) : null}
</Field>
<Field label="Repeats" id="defs-label-repeats">
<div className="defs-repeats">
<select
className="defs-input"
aria-label="Repeats"
value={draft.repeats}
onChange={(e) =>
update({
repeats: e.target.value as Repeats,
// Leaving Monthly drops a carried nth-weekday rule for good.
monthlyWeekday: e.target.value === "monthly" ? draft.monthlyWeekday : null,
})
}
>
{REPEATS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<label className="defs-interval">
<span>Every</span>
<input
className="defs-input"
type="number"
min={1}
aria-label="Interval"
value={draft.interval}
onChange={(e) => update({ interval: e.target.value })}
/>
</label>
</div>
{draft.repeats === "weekly" || draft.repeats === "custom" ? (
<div className="defs-weekdays" role="group" aria-label="Days">
{WEEKDAY_SHORT.map((label, day) => (
<button
key={label}
type="button"
aria-pressed={draft.weekdays.includes(day)}
aria-label={WEEKDAY_LONG[day]}
className={
draft.weekdays.includes(day) ? "defs-day defs-day--on" : "defs-day"
}
onClick={() => update({ weekdays: toggle(draft.weekdays, day) })}
>
{label.charAt(0)}
</button>
))}
</div>
) : null}
{draft.repeats === "monthly" && draft.monthlyWeekday ? (
<p className="defs-hint">
{recurrenceSummary({
rule_type: "monthly_weekday",
interval: parseWhole(draft.interval, 1, 999) ?? 1,
weekday_set: null,
day_of_month: null,
nth_weekday: draft.monthlyWeekday.nth,
nth_weekday_weekday: draft.monthlyWeekday.weekday,
month: null,
start_date: draft.startDate,
end_date: draft.endDate,
})}
</p>
) : null}
{draft.repeats === "yearly" ? (
<select
className="defs-input"
aria-label="Month"
value={draft.month}
onChange={(e) => update({ month: e.target.value })}
>
{MONTH_SHORT.map((label, index) => (
<option key={label} value={String(index + 1)}>
{label}
</option>
))}
</select>
) : null}
{(draft.repeats === "monthly" && !draft.monthlyWeekday) || draft.repeats === "yearly" ? (
<input
className="defs-input"
type="number"
min={1}
max={31}
aria-label="Day of month"
value={draft.dayOfMonth}
onChange={(e) => update({ dayOfMonth: e.target.value })}
/>
) : null}
<OccurrencePreview ruleKey={previewKey} />
</Field>
<Field label="Due time" id="defs-label-due">
{orderedWindows.length === 0 ? (
<p className="defs-hint">Pick a window to set its due time.</p>
) : (
<div className="defs-due-times">
{orderedWindows.map((w) => (
<label key={w} className="defs-due">
<span>{WINDOW_LABELS[w]}</span>
<input
className="defs-input"
type="time"
aria-label={`${WINDOW_LABELS[w]} due time`}
value={draft.dueTimes[w]}
onChange={(e) =>
update({ dueTimes: { ...draft.dueTimes, [w]: e.target.value } })
}
/>
</label>
))}
</div>
)}
</Field>
<Field label="Window" id="defs-label-window">
<div className="defs-segmented">
{WINDOW_ORDER.map((w) => (
<button
key={w}
type="button"
aria-pressed={draft.windows.includes(w)}
className={
draft.windows.includes(w) ? "defs-segment defs-segment--on" : "defs-segment"
}
onClick={() => update({ windows: toggle(draft.windows, w) })}
>
{WINDOW_LABELS[w]}
</button>
))}
</div>
</Field>
<div className="defs-toggle-row">
<div>
<div className="defs-toggle-title" id="defs-label-skip">
Skip on away days
</div>
<div className="defs-toggle-sub">No instance when the child is absent</div>
</div>
<button
type="button"
role="switch"
aria-checked={draft.skipOnAway}
aria-labelledby="defs-label-skip"
className={draft.skipOnAway ? "defs-switch defs-switch--on" : "defs-switch"}
onClick={() => update({ skipOnAway: !draft.skipOnAway })}
>
<span className="defs-switch-knob" />
</button>
</div>
</div>
{error ? (
<p className="defs-sheet-error" role="alert" data-testid="sheet-error">
{error}
</p>
) : null}
{confirming ? (
<div
className="defs-confirm"
role="group"
aria-labelledby="defs-confirm-text"
data-testid="unassign-confirm"
ref={confirmRef}
tabIndex={-1}
>
<p id="defs-confirm-text">
Saving will unassign{" "}
{inactiveAssignees.map((a) => a.display_name).join(", ")}. Inactive children can't be
assigned again until they are reactivated.
</p>
<div className="defs-sheet-footer">
<button type="button" className="defs-cancel" onClick={() => setConfirming(false)}>
Go back
</button>
<button type="button" className="defs-save" onClick={() => save(true)}>
Unassign and save
</button>
</div>
</div>
) : (
<div className="defs-sheet-footer">
<button type="button" className="defs-cancel" onClick={onCancel} disabled={saving}>
Cancel
</button>
<button type="button" className="defs-save" onClick={() => save()} disabled={saving}>
{saving ? "Saving…" : "Save changes"}
</button>
</div>
)}
</div>
</div>
);
}
/* ── Screen ─────────────────────────────────────────────────────────── */
export default function DefinitionsTab() {
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
const [filter, setFilter] = useState<number | "all">("all");
const [sheet, setSheet] = useState<SheetTarget>(null);
useEffect(() => {
let cancelled = false;
// Keep the list on screen during a post-save re-fetch; spin only on first load.
setState((current) => (current.kind === "ready" ? current : { kind: "loading" }));
Promise.all([fetchDefinitions(), fetchChildren()])
.then(([definitions, children]) => {
if (!cancelled) setState({ kind: "ready", definitions, children });
})
.catch((error: unknown) => {
if (cancelled) return;
const message =
error instanceof ApiForbiddenError
? error.message
: "Tasks could not be loaded. Check your connection and try again.";
setState({ kind: "error", message });
});
return () => {
cancelled = true;
};
}, [attempt]);
const activeChildren =
state.kind === "ready"
? [...state.children]
.filter((child) => child.is_active)
.sort((a, b) => a.sort_order - b.sort_order)
: [];
let sub = "";
let body;
if (state.kind === "loading") {
body = (
<div className="defs-status" role="status" data-testid="definitions-loading">
Loading tasks…
</div>
);
} else if (state.kind === "error") {
body = (
<div className="defs-status defs-status--error" role="alert" data-testid="definitions-error">
<p>{state.message}</p>
<button type="button" className="defs-retry" onClick={() => setAttempt((n) => n + 1)}>
Try again
</button>
</div>
);
} else {
const { definitions } = state;
sub = `${definitions.length} definitions · ${activeChildren.length} children`;
const shown =
filter === "all"
? definitions
: definitions.filter((d) => d.assignees.some((a) => a.id === filter));
const editingId = sheet !== null && sheet !== "new" ? sheet.id : null;
body = (
<>
<FilterChips options={activeChildren} filter={filter} onChange={setFilter} />
{shown.length > 0 ? (
<ul className="defs-card defs-list" aria-label="Task definitions">
{shown.map((definition) => (
<DefinitionRow
key={definition.id}
definition={definition}
activeChildren={activeChildren}
editing={definition.id === editingId}
onOpen={() => setSheet(definition)}
/>
))}
</ul>
) : (
<div className="defs-card defs-empty">No tasks yet.</div>
)}
</>
);
}
return (
<div className="defs-screen">
<header className="defs-header">
<div>
<h2 className="defs-title">Tasks</h2>
<p className="defs-sub">{sub}</p>
</div>
<button
type="button"
className="defs-new"
disabled={state.kind !== "ready"}
onClick={() => setSheet("new")}
>
<PlusGlyph />
New
</button>
</header>
<div
className="defs-scroll"
data-testid="definitions-scroll"
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
>
{body}
</div>
{sheet !== null && state.kind === "ready" ? (
<EditSheet
key={sheet === "new" ? "new" : sheet.id}
target={sheet}
activeChildren={activeChildren}
onCancel={() => setSheet(null)}
onSaved={() => {
setSheet(null);
setAttempt((n) => n + 1);
}}
/>
) : null}
</div>
);
}
-111
View File
@@ -1,111 +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"];
export const MONTH_SHORT = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
const COUNT_WORDS = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"];
/**
* `Declan`, `Declan, Maeve`, or `All three` when every active child (two or
* more) is assigned.
*/
export function assigneeSummary(
assignees: Pick<DefinitionAssignee, "id" | "display_name">[],
activeChildren: AdminChild[],
): string {
if (assignees.length === 0) return "Unassigned";
const assigned = new Set(assignees.map((a) => a.id));
const everyone =
activeChildren.length > 1 &&
assigned.size === activeChildren.length &&
activeChildren.every((child) => assigned.has(child.id));
if (everyone) {
const word = COUNT_WORDS[activeChildren.length] ?? String(activeChildren.length);
return `All ${word}`;
}
return assignees.map((a) => a.display_name).join(", ");
}
function weekdayList(days: number[] | null): string {
return [...(days ?? [])]
.sort((a, b) => a - b)
.map((day) => WEEKDAY_SHORT[day])
.join(", ");
}
function ordinal(n: number): string {
if (n === -1) return "last";
const suffix = n === 1 ? "st" : n === 2 ? "nd" : n === 3 ? "rd" : "th";
return `${n}${suffix}`;
}
function every(interval: number, one: string, unit: string): string {
return interval === 1 ? one : `Every ${interval} ${unit}`;
}
export function recurrenceSummary(rule: DefinitionRule): string {
const n = rule.interval;
switch (rule.rule_type) {
case "daily":
return every(n, "Daily", "days");
case "weekly":
return `${every(n, "Weekly", "weeks")} on ${weekdayList(rule.weekday_set)}`;
case "monthly_day":
return `${every(n, "Monthly", "months")} on day ${rule.day_of_month}`;
case "monthly_weekday":
return `${every(n, "Monthly", "months")} on the ${ordinal(rule.nth_weekday ?? 1)} ${
WEEKDAY_SHORT[rule.nth_weekday_weekday ?? 0]
}`;
case "yearly": {
const month = MONTH_SHORT[(rule.month ?? 1) - 1];
const when = rule.day_of_month ? `on ${month} ${rule.day_of_month}` : `in ${month}`;
return `${every(n, "Yearly", "years")} ${when}`;
}
case "custom_days":
return `Custom${n === 1 ? "" : ` every ${n} weeks`} (${weekdayList(rule.weekday_set)})`;
}
}
export function windowSummary(windows: Pick<DefinitionWindow, "window">[]): string {
const names = new Set(windows.map((w) => w.window));
return WINDOW_ORDER.filter((w) => names.has(w))
.map((w) => WINDOW_LABELS[w])
.join(", ");
}
/**
* "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>
);
}
-71
View File
@@ -1,71 +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-info-bg: #dbeafe;
--nq-a-info-ink: #1e3a8a;
--nq-a-info-icon: #1d4ed8;
--nq-a-font: Nunito, system-ui, sans-serif;
--nq-a-size-screen: 24px;
--nq-a-size-stat: 22px;
--nq-a-size-row: 13.5px;
--nq-a-size-meta: 11px;
--nq-a-size-label: 10px;
--nq-a-track-label: 0.1em;
--nq-a-density-gap: 10px;
--nq-a-density-card-pad: 13px;
--nq-a-density-row-pad: 11px 13px;
--nq-a-density-radius: 8px;
--nq-a-tabbar-height: 70px;
--nq-a-tap-min: 44px;
--nq-a-radius-pill: 9999px;
--nq-a-radius-sheet: 20px 20px 0 0;
--nq-a-sheet-shadow: 0 -8px 28px rgba(9, 9, 11, 0.18);
--nq-a-progress-height: 6px;
--nq-ease-out: cubic-bezier(0, 0, 0.2, 1);
--nq-dur-base: 200ms;
--nq-dur-modal: 350ms;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
height: 100%;
}
body {
font-family: var(--nq-a-font);
background: var(--nq-a-page);
color: var(--nq-a-ink);
-webkit-font-smoothing: antialiased;
}
-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);
}
}
-300
View File
@@ -1,300 +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-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;
}
-223
View File
@@ -1,223 +0,0 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import TodayTab, { PERMISSION_NOTE } from "./TodayTab";
import type { AdminSnapshot, SnapshotInstance } from "../api/snapshot";
import { storeTokens } from "../auth/oidc";
function instance(overrides: Partial<SnapshotInstance>): SnapshotInstance {
return {
id: 0,
definition_id: 1,
child_id: 1,
title: "Quest",
icon: null,
window: "morning",
due_time: null,
state: "open",
overdue: false,
completed_at: null,
on_time: null,
...overrides,
};
}
const SNAPSHOT: AdminSnapshot = {
today_iso: "2026-09-23",
cycle_day: 5,
children: [
{
child_id: 1,
child_name: "Declan",
present: true,
next_present: null,
due_today: 7,
completed_today: 3,
remaining_today: 4,
completion_pct: 43,
instances: [
instance({ id: 11, title: "Brush teeth", due_time: "07:30", overdue: true }),
instance({
id: 12,
title: "Make bed",
state: "completed",
completed_at: "2026-09-23T11:04:00Z",
on_time: true,
}),
instance({ id: 13, title: "Homework", window: "afternoon", due_time: "16:00" }),
// Overdue but already completed: not counted as overdue.
instance({
id: 14,
title: "Pack bag",
state: "completed",
overdue: true,
completed_at: "2026-09-23T10:00:00Z",
on_time: false,
}),
],
},
{
child_id: 2,
child_name: "Maeve",
present: true,
next_present: null,
due_today: 4,
completed_today: 4,
remaining_today: 0,
completion_pct: 100,
instances: [],
},
{
child_id: 3,
child_name: "Rory",
present: false,
next_present: "2026-10-01",
due_today: 0,
completed_today: 0,
remaining_today: 0,
completion_pct: 0,
instances: [],
},
],
};
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
let fetchMock: ReturnType<typeof vi.fn>;
async function renderReady() {
fetchMock.mockResolvedValue(jsonResponse(SNAPSHOT));
render(<TodayTab />);
await screen.findByText("Declan");
}
describe("TodayTab", () => {
beforeAll(() => {
// A negative-offset zone: `new Date("2026-09-23")` would read as Sep 22 here.
vi.stubEnv("TZ", "America/Los_Angeles");
});
afterAll(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("fetches the admin snapshot with the Bearer token", async () => {
await renderReady();
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toMatch(/\/api\/v1\/admin\/snapshot$/);
expect(init.headers.Authorization).toBe("Bearer at-123");
});
it("header shows the weekday, date and cycle day", async () => {
await renderReady();
expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy();
expect(screen.getByText("Wednesday, September 23 · cycle day 5 of 14")).toBeTruthy();
expect(screen.getByRole("button", { name: "Settings" })).toBeTruthy();
});
it("stat row shows done, remaining and overdue totals", async () => {
await renderReady();
expect(screen.getByTestId("stat-done").textContent).toBe("7Done");
expect(screen.getByTestId("stat-remaining").textContent).toBe("4Remaining");
expect(screen.getByTestId("stat-overdue").textContent).toBe("1Overdue");
});
it("present child row shows progress meta and fill", async () => {
await renderReady();
const row = screen.getByTestId("child-1");
expect(within(row).getByText("3 of 7 · 1 overdue")).toBeTruthy();
const bar = within(row).getByRole("progressbar");
expect(bar.getAttribute("aria-valuenow")).toBe("43");
expect((bar.firstElementChild as HTMLElement).style.width).toBe("43%");
expect(row.className).not.toContain("today-child--away");
});
it("all-done child renders the success state", async () => {
await renderReady();
const meta = within(screen.getByTestId("child-2")).getByText("4 of 4 · all done");
expect(meta.className).toContain("today-child-meta--done");
});
it("away child shows the return date and the flat avatar", async () => {
await renderReady();
const row = screen.getByTestId("child-3");
expect(within(row).getByText("Away · returns Oct 1")).toBeTruthy();
expect(row.className).toContain("today-child--away");
expect(within(row).getByTestId("child-avatar").textContent).toBe("R");
});
it("needs-attention lists overdue and completed rows with their actions", async () => {
await renderReady();
const list = screen.getByRole("list", { name: "Needs attention" });
const overdue = within(list).getByTestId("attention-11");
expect(within(overdue).getByText("Brush teeth · Declan")).toBeTruthy();
expect(within(overdue).getByText("Overdue since 7:30 AM")).toBeTruthy();
expect(within(overdue).getByRole("button", { name: "Mark done" })).toBeTruthy();
const completed = within(list).getByTestId("attention-12");
expect(within(completed).getByText("Make bed · Declan")).toBeTruthy();
// 11:04Z is 4:04 AM in Los Angeles (PDT).
expect(within(completed).getByText("Completed 4:04 AM")).toBeTruthy();
expect(within(completed).getByRole("button", { name: "Undo" })).toBeTruthy();
// Open, not-overdue instances do not need attention.
expect(within(list).queryByTestId("attention-13")).toBeNull();
// No actor in the payload, so no "from panel".
expect(list.textContent).not.toContain("from panel");
});
it("permission note carries the exact copy", async () => {
await renderReady();
expect(PERMISSION_NOTE).toBe(
"Undo is admin-only. A panel tap can never reverse a completion.",
);
expect(screen.getByTestId("permission-note").textContent).toBe(PERMISSION_NOTE);
});
it("scroll column ends with 92px bottom padding", async () => {
await renderReady();
expect(screen.getByTestId("today-scroll").style.paddingBottom).toBe("92px");
});
it("shows a loading state while the snapshot is in flight", () => {
fetchMock.mockReturnValue(new Promise<Response>(() => {}));
render(<TodayTab />);
expect(screen.getByTestId("today-loading").textContent).toContain("Loading");
expect(screen.getByTestId("today-scroll").style.paddingBottom).toBe("92px");
});
it("shows an error state on a failed fetch and retries", async () => {
fetchMock.mockResolvedValueOnce(new Response("boom", { status: 500 }));
render(<TodayTab />);
const error = await screen.findByTestId("today-error");
expect(error.getAttribute("role")).toBe("alert");
expect(error.textContent).toContain("could not be loaded");
fetchMock.mockResolvedValueOnce(jsonResponse(SNAPSHOT));
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(await screen.findByText("Declan")).toBeTruthy();
});
it("shows the refusal message when the API answers 403", async () => {
fetchMock.mockResolvedValueOnce(new Response("", { status: 403 }));
render(<TodayTab />);
const error = await screen.findByTestId("today-error");
expect(error.textContent).toContain("nestquest-admins");
});
});
-277
View File
@@ -1,277 +0,0 @@
/**
* Today tab — compact density (design/ADMIN-SPEC.md §2.1–2.3, §2.5–2.6).
*/
import { useEffect, useState } from "react";
import {
fetchSnapshot,
type AdminSnapshot,
type SnapshotChild,
type SnapshotInstance,
} from "../api/snapshot";
import { ApiForbiddenError } from "../api/client";
import {
formatClockTime,
formatDueTime,
formatLongDate,
formatShortDate,
} from "./format";
import {
AlertCircleGlyph,
CheckCircleGlyph,
InfoGlyph,
SettingsGlyph,
} from "./glyphs";
import "./TodayTab.css";
const CYCLE_LENGTH_DAYS = 14;
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
export const SCROLL_BOTTOM_PADDING = "92px";
export const PERMISSION_NOTE =
"Undo is admin-only. A panel tap can never reverse a completion.";
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; snapshot: AdminSnapshot };
function isOverdueOpen(instance: SnapshotInstance): boolean {
return instance.overdue && instance.state === "open";
}
function ScreenHeader({ sub }: { sub: string }) {
return (
<header className="today-header">
<div>
<h2 className="today-title">Today</h2>
<p className="today-sub">{sub}</p>
</div>
{/* Settings screen is a later task; the control is not wired yet. */}
<button type="button" className="today-icon-button" aria-label="Settings">
<SettingsGlyph />
</button>
</header>
);
}
function StatRow({ snapshot }: { snapshot: AdminSnapshot }) {
const done = snapshot.children.reduce((sum, c) => sum + c.completed_today, 0);
const remaining = snapshot.children.reduce((sum, c) => sum + c.remaining_today, 0);
const overdue = snapshot.children.reduce(
(sum, c) => sum + c.instances.filter(isOverdueOpen).length,
0,
);
const stats = [
{ key: "done", label: "Done", value: done },
{ key: "remaining", label: "Remaining", value: remaining },
{ key: "overdue", label: "Overdue", value: overdue },
];
return (
<div className="today-stats">
{stats.map((stat) => (
<div key={stat.key} className="today-stat" data-testid={`stat-${stat.key}`}>
<div className={`today-stat-value today-stat-value--${stat.key}`}>
{stat.value}
</div>
<div className="today-stat-label">{stat.label}</div>
</div>
))}
</div>
);
}
function ChildRow({ child }: { child: SnapshotChild }) {
const overdue = child.instances.filter(isOverdueOpen).length;
const allDone = child.due_today > 0 && child.completed_today === child.due_today;
let meta: string;
let metaClass = "today-child-meta";
if (!child.present) {
meta = child.next_present
? `Away · returns ${formatShortDate(child.next_present)}`
: "Away";
} else if (allDone) {
meta = `${child.completed_today} of ${child.due_today} · all done`;
metaClass += " today-child-meta--done";
} else {
meta = `${child.completed_today} of ${child.due_today} · ${overdue} overdue`;
}
const pct = Math.max(0, Math.min(100, child.completion_pct));
// ADMIN-SPEC §2.3 also asks for a lowercase `override` pill when an absence
// comes from a presence override. The snapshot carries no field saying
// where an absence came from, so the pill is omitted rather than guessed.
return (
<li
className={child.present ? "today-child" : "today-child today-child--away"}
data-testid={`child-${child.child_id}`}
>
<span className="today-avatar" data-testid="child-avatar" aria-hidden="true">
{child.child_name.charAt(0).toUpperCase()}
</span>
<div className="today-child-text">
<div className="today-child-name">{child.child_name}</div>
<div className={metaClass}>{meta}</div>
</div>
<div
className="today-progress"
role="progressbar"
aria-label={`${child.child_name} progress`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
>
<div className="today-progress-fill" style={{ width: `${pct}%` }} />
</div>
</li>
);
}
interface AttentionItem {
instance: SnapshotInstance;
childName: string;
}
function attentionItems(snapshot: AdminSnapshot): AttentionItem[] {
const all = snapshot.children.flatMap((child) =>
child.instances.map((instance) => ({ instance, childName: child.child_name })),
);
const overdue = all.filter((item) => isOverdueOpen(item.instance));
const completed = all
.filter((item) => item.instance.state === "completed" && item.instance.completed_at)
.sort((a, b) =>
(b.instance.completed_at ?? "").localeCompare(a.instance.completed_at ?? ""),
);
return [...overdue, ...completed];
}
function AttentionRow({ item }: { item: AttentionItem }) {
const { instance, childName } = item;
const overdue = isOverdueOpen(instance);
// TODO(e57facc2): wire "Mark done" / "Undo" mutations and live updates.
// The admin plane has no complete route yet, so both actions are inert here.
return (
<li className="today-event" data-testid={`attention-${instance.id}`}>
<span
className={overdue ? "today-event-glyph today-event-glyph--overdue" : "today-event-glyph"}
>
{overdue ? <AlertCircleGlyph /> : <CheckCircleGlyph />}
</span>
<div className="today-event-text">
<div className="today-event-title">
{instance.title} · {childName}
</div>
{overdue ? (
<div className="today-event-meta today-event-meta--overdue">
{instance.due_time
? `Overdue since ${formatDueTime(instance.due_time)}`
: "Overdue"}
</div>
) : (
// The snapshot carries no completion actor, so "from panel" is not printed.
<div className="today-event-meta">
Completed {formatClockTime(instance.completed_at as string)}
</div>
)}
</div>
<button
type="button"
className={overdue ? "today-action today-action--done" : "today-action today-action--undo"}
>
{overdue ? "Mark done" : "Undo"}
</button>
</li>
);
}
function PermissionNote() {
return (
<div className="today-note" data-testid="permission-note">
<span className="today-note-glyph">
<InfoGlyph />
</span>
<span>{PERMISSION_NOTE}</span>
</div>
);
}
export default function TodayTab() {
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
setState({ kind: "loading" });
fetchSnapshot()
.then((snapshot) => {
if (!cancelled) setState({ kind: "ready", snapshot });
})
.catch((error: unknown) => {
if (cancelled) return;
const message =
error instanceof ApiForbiddenError
? error.message
: "Today's snapshot could not be loaded. Check your connection and try again.";
setState({ kind: "error", message });
});
return () => {
cancelled = true;
};
}, [attempt]);
let sub = "";
let body;
if (state.kind === "loading") {
body = (
<div className="today-status" role="status" data-testid="today-loading">
Loading today…
</div>
);
} else if (state.kind === "error") {
body = (
<div className="today-status today-status--error" role="alert" data-testid="today-error">
<p>{state.message}</p>
<button type="button" className="today-retry" onClick={() => setAttempt((n) => n + 1)}>
Try again
</button>
</div>
);
} else {
const { snapshot } = state;
sub = `${formatLongDate(snapshot.today_iso)} · cycle day ${snapshot.cycle_day} of ${CYCLE_LENGTH_DAYS}`;
const attention = attentionItems(snapshot);
body = (
<>
<StatRow snapshot={snapshot} />
<ul className="today-card today-list" aria-label="Children">
{snapshot.children.map((child) => (
<ChildRow key={child.child_id} child={child} />
))}
</ul>
<h3 className="today-section-label">Needs attention</h3>
{attention.length > 0 ? (
<ul className="today-card today-list" aria-label="Needs attention">
{attention.map((item) => (
<AttentionRow key={item.instance.id} item={item} />
))}
</ul>
) : (
<div className="today-card today-empty">Nothing needs attention.</div>
)}
<PermissionNote />
</>
);
}
return (
<div className="today-screen">
<ScreenHeader sub={sub} />
<div
className="today-scroll"
data-testid="today-scroll"
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
>
{body}
</div>
</div>
);
}
-50
View File
@@ -1,50 +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.
*/
const LOCALE = "en-US";
function calendarDate(iso: string): Date {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
if (!match) throw new Error(`Invalid calendar date: ${iso}`);
const [, year, month, day] = match;
return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
}
function format(date: Date, options: Intl.DateTimeFormatOptions): string {
// Newer ICU puts a narrow no-break space before AM/PM; normalise it.
return new Intl.DateTimeFormat(LOCALE, options)
.format(date)
.replace(/ /g, " ");
}
/** "Wednesday, September 23" */
export function formatLongDate(iso: string): string {
const date = calendarDate(iso);
const weekday = format(date, { weekday: "long", timeZone: "UTC" });
const monthDay = format(date, { month: "long", day: "numeric", timeZone: "UTC" });
return `${weekday}, ${monthDay}`;
}
/** "Sep 23" */
export function formatShortDate(iso: string): string {
return format(calendarDate(iso), { month: "short", day: "numeric", timeZone: "UTC" });
}
/** "09:00" -> "9:00 AM" (a wall-clock time, no zone conversion). */
export function formatDueTime(hhmm: string): string {
const match = /^(\d{2}):(\d{2})/.exec(hhmm);
if (!match) return hhmm;
const date = new Date(Date.UTC(2000, 0, 1, Number(match[1]), Number(match[2])));
return format(date, { hour: "numeric", minute: "2-digit", timeZone: "UTC" });
}
/** A UTC ISO timestamp shown as the viewer's local time, "7:04 AM". */
export function formatClockTime(isoTimestamp: string): string {
return format(new Date(isoTimestamp), { hour: "numeric", minute: "2-digit" });
}
-61
View File
@@ -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",
},
});
+1 -12
View File
@@ -48,7 +48,6 @@ 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):
@@ -58,11 +57,7 @@ def _build_lifespan(db_path: str):
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.
every route and SSE subscriber shares that single instance.
"""
@asynccontextmanager
@@ -70,16 +65,10 @@ def _build_lifespan(db_path: str):
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
+8 -92
View File
@@ -35,20 +35,16 @@ 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).
This module validates the TOKEN only — it is authentication, not
authorization. Membership in the ``nestquest-admins`` group is the
NEXT task, which extends :func:`require_admin_jwt` (the verified
claims are already its return value, so no rewrite is needed).
"""
from __future__ import annotations
import asyncio
import binascii
import json
import secrets
import time
from collections.abc import Awaitable, Callable
from typing import Annotated
@@ -74,11 +70,6 @@ _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.
@@ -275,19 +266,6 @@ class JwksCache:
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(
@@ -413,9 +391,9 @@ async def require_admin_jwt(
: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.
The nestquest-admins GROUP check is the NEXT task: it extends this
dependency (e.g. by inspecting the returned claims) so every admin
route inherits it — the 401 contract stays exactly this uniform.
"""
if authorization is None:
raise _reject()
@@ -425,66 +403,4 @@ async def require_admin_jwt(
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",
]
__all__ = ["JwksCache", "JwksFetcher", "require_admin_jwt", "verify_admin_token"]
-93
View File
@@ -116,11 +116,6 @@ core_snapshot = importlib.import_module(f"{_CORE_MODULE_NAME}.snapshot")
#: ``uncomplete_instance``) the panel's write routes call.
core_completion = importlib.import_module(f"{_CORE_MODULE_NAME}.completion")
#: The read-only history layer (``core.history.query_history``,
#: ``export_history_csv``, ``rows_to_csv``) the admin plane's history
#: query and CSV export routes call.
core_history = importlib.import_module(f"{_CORE_MODULE_NAME}.history")
#: The transition-event builders (``core.events.build_quest_completed_event``
#: et al.) the panel's write routes build their SSE events with.
core_events = importlib.import_module(f"{_CORE_MODULE_NAME}.events")
@@ -129,100 +124,12 @@ core_events = importlib.import_module(f"{_CORE_MODULE_NAME}.events")
#: the snapshot builder's ``settings`` parameter takes.
core_settings = importlib.import_module(f"{_CORE_MODULE_NAME}.settings")
#: The durable settings store (``core.settings_store.load_settings`` /
#: ``update_settings``) the admin plane's settings routes read and
#: write — the API's OWN settings source: a validated JSON document in
#: the existing ``nestquest_meta_state`` table, resolved through the
#: same core settings validators (the integration's HA config-entry
#: options remain a temporary separate source until Feature 18 wires
#: the client to the API).
core_settings_store = importlib.import_module(
f"{_CORE_MODULE_NAME}.settings_store"
)
#: The children business layer (``core.children.create_child`` et al.)
#: the admin plane's children routes call.
core_children = importlib.import_module(f"{_CORE_MODULE_NAME}.children")
#: The quest-definition business layer (``core.quest_definitions`` —
#: ``create_quest_definition``, ``edit_quest_definition``,
#: ``set_quest_definition_active``) the admin plane's quest-definition
#: routes call.
core_quest_definitions = importlib.import_module(
f"{_CORE_MODULE_NAME}.quest_definitions"
)
#: The recurrence model (``core.recurrence.ScheduleRule`` and
#: ``RuleValidationError``) the quest-definition routes build request
#: rules with — the model validates at construction, the routes never
#: re-implement rule validation.
core_recurrence = importlib.import_module(f"{_CORE_MODULE_NAME}.recurrence")
#: The presence model (``core.presence.PresenceEngine``,
#: ``PresenceSchedule``, ``PresenceOverride``) the admin plane's
#: presence routes serialize and the tests resolve presence with.
core_presence = importlib.import_module(f"{_CORE_MODULE_NAME}.presence")
#: The presence-write business layer
#: (``core.presence_management.set_presence_schedule`` et al.) the
#: admin plane's presence routes call — the ONE implementation the HA
#: services share.
core_presence_management = importlib.import_module(
f"{_CORE_MODULE_NAME}.presence_management"
)
#: The typed presence DAOs (``core.dao_presence``) the admin-plane tests
#: read stored schedules and overrides back through.
core_dao_presence = importlib.import_module(
f"{_CORE_MODULE_NAME}.dao_presence"
)
#: The materialization walk (``core.materialize.materialize``,
#: ``regenerate_for_definition``, ``regenerate_for_child``) the admin
#: plane's regenerate route calls — the same walk the integration's
#: config/presence changes trigger.
core_materialize = importlib.import_module(f"{_CORE_MODULE_NAME}.materialize")
#: The core constants module (``core.const.DEFAULT_HORIZON_DAYS``) the
#: regenerate route sizes the household rolling horizon with.
core_const = importlib.import_module(f"{_CORE_MODULE_NAME}.const")
#: The typed child DAO (``core.dao_children.ChildrenDao.get``) the
#: regenerate route reads a child's existence with before delegating.
core_dao_children = importlib.import_module(
f"{_CORE_MODULE_NAME}.dao_children"
)
#: The typed definition DAO (``core.dao_rules.QuestDefinitionsDao.get``)
#: the regenerate route reads a definition's existence with before
#: delegating.
core_dao_rules = importlib.import_module(f"{_CORE_MODULE_NAME}.dao_rules")
#: The HA-free missed sweep (``core.sweep.run_missed_sweep`` +
#: ``SWEEP_WATERMARK_KEY``) the admin plane's trigger route calls and
#: the daily scheduler runs — the ONE sweep policy the integration's
#: bus shim shares.
core_sweep = importlib.import_module(f"{_CORE_MODULE_NAME}.sweep")
__all__ = [
"core",
"core_children",
"core_completion",
"core_const",
"core_dao_children",
"core_dao_presence",
"core_dao_rules",
"core_db",
"core_events",
"core_history",
"core_materialize",
"core_migrations",
"core_presence",
"core_presence_management",
"core_quest_definitions",
"core_recurrence",
"core_snapshot",
"core_settings",
"core_settings_store",
"core_sweep",
]
+23 -2201
View File
File diff suppressed because it is too large Load Diff
+30 -7
View File
@@ -53,6 +53,9 @@ from __future__ import annotations
import datetime
import importlib
import json
from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
@@ -67,7 +70,6 @@ from api.nestquest_core import (
core_snapshot,
)
from api import transitions as api_transitions
from api.sse import transition_event_stream
#: The instance DAO, imported on the app's core copy for the
#: child-mismatch guard on the complete route.
@@ -326,6 +328,23 @@ async def panel_complete_instance(
return {"status": str(result)}
async def _sse_stream(request: Request) -> AsyncIterator[str]:
"""Yield the SSE frames for every published transition event.
One subscriber of the app's single publisher (resolved off
``app.state.publisher``); each event becomes one
``event: <type>\\ndata: <json>\\n\\n`` frame. Breaking out of the
publisher's subscription (on client disconnect) tears the
subscription down cleanly — no queue is leaked.
"""
publisher = request.app.state.publisher
async for event_type, payload in publisher.subscribe():
yield (
f"event: {event_type}\n"
f"data: {json.dumps(payload)}\n\n"
)
@router.get(
"/events",
summary="Stream panel transition events (SSE)",
@@ -338,13 +357,17 @@ async def panel_events(request: Request) -> StreamingResponse:
:func:`~api.dependencies.require_panel_token` dependency — the ONE
token check; this handler performs NO auth of its own).
Returns the shared :func:`api.sse.transition_event_stream` response
off the app's single in-process publisher (``app.state.publisher``,
installed in the lifespan). The stream ends when the client
disconnects, at which point the subscription is torn down (its
queue removed) with nothing leaked.
Returns a ``text/event-stream`` response whose body is produced by
:func:`_sse_stream` off the app's single in-process publisher
(``app.state.publisher``, installed in the lifespan). The stream
ends when the client disconnects, at which point the subscription
is torn down (its queue removed) with nothing leaked.
"""
return transition_event_stream(request)
return StreamingResponse(
_sse_stream(request),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache"},
)
__all__ = ["router"]
-173
View File
@@ -1,173 +0,0 @@
"""Daily missed-sweep background scheduler for the API service.
The API service has no Home Assistant, so nobody re-fires the
integration's day-rollover listener for it: this module owns the API
plane's own background job. One :class:`MissedSweepScheduler` per app
(installed on ``app.state`` by the lifespan) sleeps until the
configured ``day_rollover_time`` — read fresh from the settings store
at the top of EVERY cycle, so an admin's settings change applies on
the next scheduling decision without a restart — then runs the
HA-free core's :func:`nestquest_core.sweep.run_missed_sweep` for the
API host's local date and publishes each returned
``nestquest_quest_missed`` event on the app's ONE transition publisher
(the same SSE stream every route publishes through).
All sweep policy lives in the core (the watermark, the
``[watermark, today)`` window, the no-completion-event rule): this
scheduler only decides WHEN. A same-night rerun — e.g. two workers,
or a settings change moving the rollover past itself — fires nothing:
the core's watermark makes the run an empty no-op.
Testability: ``clock`` (an aware-now callable) and ``sleep`` (an
awaitable delay) are injectable, so tests pin the time, fast-forward
through the wait, and observe the computed delays without waiting a
real day. Cancellation: :meth:`stop` cancels the loop task and waits
for it, so the lifespan's shutdown is clean and a mid-run sweep is
interrupted before the connection closes. A failed iteration (a
database hiccup, a rejected setting) is logged and retried after a
fixed backoff rather than silently killing the job.
"""
from __future__ import annotations
import asyncio
import datetime
import logging
from api.nestquest_core import core_settings_store, core_sweep
LOGGER = logging.getLogger(__name__)
#: Backoff after a failed scheduling cycle (settings read or sweep
#: error), so a persistent failure retries every minute instead of
#: spinning or dying.
_RETRY_BACKOFF_SECONDS = 60.0
def _delay_seconds(target: datetime.time, now: datetime.datetime) -> float:
"""Seconds from ``now`` until the next occurrence of ``target``.
``target`` is a plain ``HH:MM`` time of day: the next wall-clock
occurrence strictly AFTER ``now`` (an equal time schedules for
tomorrow, so the just-run sweep is never instantly re-run).
"""
scheduled = now.replace(
hour=target.hour, minute=target.minute, second=0, microsecond=0
)
if scheduled <= now:
scheduled += datetime.timedelta(days=1)
return (scheduled - now).total_seconds()
class MissedSweepScheduler:
"""Run the missed sweep once per day at ``day_rollover_time``."""
def __init__(
self,
database: object,
*,
publisher: object | None = None,
clock: object | None = None,
sleep: object | None = None,
) -> None:
"""Create the scheduler (not yet running).
``database`` is the app's live :class:`~api.database.NestQuestDatabase`.
``publisher`` is the app's ONE
:class:`~api.events.TransitionPublisher` (publishing is skipped
when ``None``). ``clock`` defaults to the API host's local
aware now (``datetime.datetime.now().astimezone()``) — the same
single-clock discipline the routes' ``_local_now`` uses;
``sleep`` defaults to :func:`asyncio.sleep`.
"""
self._database = database
self._publisher = publisher
self._clock = clock if clock is not None else (
lambda: datetime.datetime.now().astimezone()
)
self._sleep = sleep if sleep is not None else asyncio.sleep
self._task: asyncio.Task | None = None
def start(self) -> None:
"""Start the background loop (idempotent: a running one stays)."""
if self._task is None or self._task.done():
self._task = asyncio.create_task(
self._run_forever(), name="nestquest-missed-sweep"
)
async def stop(self) -> None:
"""Cancel the loop and wait for it to finish (safe if never started)."""
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
@property
def running(self) -> bool:
"""Whether the background loop task is alive."""
return self._task is not None and not self._task.done()
async def _run_forever(self) -> None:
"""Schedule → sweep → repeat, forever (until cancelled)."""
while True:
try:
settings = await core_settings_store.load_settings(
self._database
)
rollover = datetime.time.fromisoformat(
settings.day_rollover_time
)
except Exception:
# Not silent: the failure is logged and retried after
# the backoff, so one bad settings document or a
# transient database error never kills the scheduler.
LOGGER.exception(
"NestQuest missed-sweep scheduler failed to resolve "
"the day rollover time; retrying in %gs",
_RETRY_BACKOFF_SECONDS,
)
await self._sleep(_RETRY_BACKOFF_SECONDS)
continue
delay = _delay_seconds(rollover, self._clock())
LOGGER.info(
"NestQuest missed sweep scheduled for %s (%.0fs from now)",
settings.day_rollover_time,
delay,
)
await self._sleep(delay)
try:
await self._run_once(self._clock().date())
except Exception:
# Not silent either: the sweep error is logged and the
# loop backs off before the next cycle, whose watermark
# re-read makes an already-swept day a no-op.
LOGGER.exception(
"NestQuest scheduled missed sweep failed; retrying in "
"%gs",
_RETRY_BACKOFF_SECONDS,
)
await self._sleep(_RETRY_BACKOFF_SECONDS)
async def _run_once(self, today: datetime.date) -> None:
"""Run ONE sweep for ``today`` and publish what it built.
A failure propagates to :meth:`_run_forever`, which logs it and
backs off before the next cycle; the watermark makes a re-run
of an already-swept day announce nothing twice.
"""
events = await core_sweep.run_missed_sweep(
self._database, today=today
)
for event_type, payload in events:
if self._publisher is not None:
self._publisher.publish(event_type, payload)
LOGGER.info(
"NestQuest scheduled missed sweep for %s published %d event(s)",
today.isoformat(),
len(events),
)
__all__ = ["MissedSweepScheduler"]
-50
View File
@@ -1,50 +0,0 @@
"""The ONE Server-Sent Events response helper for the API service.
Both SSE planes — the panel plane (``/api/v1/panel/events``, panel
service token, LAN-only) and the admin plane (``/api/v1/admin/events``,
Authentik admin JWT) — stream the SAME transitions off the SAME
in-process publisher (``app.state.publisher``, see :mod:`api.events`)
with the SAME framing and teardown, so the stream lives here once and
each plane's route only supplies its own router-level auth.
"""
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from fastapi import Request
from fastapi.responses import StreamingResponse
__all__ = ["transition_event_stream"]
async def _sse_stream(request: Request) -> AsyncIterator[str]:
"""Yield the SSE frames for every published transition event.
One subscriber of the app's single publisher (resolved off
``app.state.publisher``); each event becomes one
``event: <type>\\ndata: <json>\\n\\n`` frame. Breaking out of the
publisher's subscription (on client disconnect) tears the
subscription down cleanly — no queue is leaked.
"""
publisher = request.app.state.publisher
async for event_type, payload in publisher.subscribe():
yield (
f"event: {event_type}\n"
f"data: {json.dumps(payload)}\n\n"
)
def transition_event_stream(request: Request) -> StreamingResponse:
"""A ``text/event-stream`` response over the app's transition events.
Performs NO auth of its own: the calling route's router-level
dependency is the ONE check for its plane. The stream ends when
the client disconnects, at which point the subscription is torn
down (its queue removed) with nothing leaked.
"""
return StreamingResponse(
_sse_stream(request),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache"},
)
+483 -136
View File
@@ -1,78 +1,375 @@
"""The NestQuest integration (Feature 18: a thin client of the NestQuest API service).
The integration owns NO database: the API service (Feature 16) owns the
SQLite file, the migrations, and every business rule. Setup builds the
entry's ONE shared API client, the coordinator that polls the panel
snapshot through it, and the SSE subscription that re-fires the API's
transition frames on the HA bus, registers the single
``nestquest.complete_quest`` proxy service, registers the zero-config
panel dashboard (Feature 20), and forwards the sensor and
binary_sensor platforms.
"""
"""The NestQuest integration."""
from __future__ import annotations
import asyncio
import datetime
import inspect
import sqlite3
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.event import async_track_time_change
from .const import (
CONF_SNAPSHOT_STALENESS,
CONF_ADMIN_USER_IDS,
CONF_UPDATE_INTERVAL,
DEFAULT_SNAPSHOT_STALENESS,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
LOGGER,
PLATFORMS,
)
from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
from .dashboard import async_register_dashboard
from .core.settings import NestQuestSettings
from .frontend import async_register_frontend
from .coordinator import NestQuestCoordinator
from .db import NestQuestDatabase, make_database
from .materialize import materialize as _materialize_run
from .migrations import apply_migrations
from .services import async_deregister_services, async_register_services
from .sse import NestQuestEventStream
from .store import async_get_db_path
from .admin_allowlist import seed_setup_admin
from .sweep import run_missed_sweep
_LOGGER = LOGGER
#: SQLite reports "file is not a database" for corruption in every
#: sqlite3 version this integration targets; the version-specific
#: "encrypted or malformed" spelling (3.41+) maps to the same failure.
_CORRUPTION_MARKERS = (
"file is not a database",
"database disk image is malformed",
"encrypted or malformed",
"is not a database",
)
#: Primary sqlite error codes that mean "the file is corrupt" (SQLite
#: result codes, https://www.sqlite.org/c3ref/c_abort.html). Extended
#: codes carry the primary code in their low byte, so the match is on
#: errcode & 0xff.
_CORRUPT_PRIMARY_CODES = frozenset({11, 26}) # SQLITE_CORRUPT, SQLITE_NOTADB
def _is_corruption_error(error: BaseException) -> bool:
"""Return True when ``error`` indicates a corrupt database file.
Classification order: sqlite_errorcode (Python 3.11+) is primary
and locale-independent — SQLITE_CORRUPT and SQLITE_NOTADB, matched
on the primary byte so extended codes (SQLITE_CORRUPT_VTAB etc.)
classify correctly too. Message matching is the fallback for
older Pythons: the corruption spellings are the documented English
texts; a non-English locale could evade them, but the code check
already covers every locale on supported Pythons.
"""
if not isinstance(error, sqlite3.DatabaseError):
return False
errorcode = getattr(error, "sqlite_errorcode", None)
if errorcode is not None and (errorcode & 0xFF) in _CORRUPT_PRIMARY_CODES:
return True
message = str(error).lower()
return any(marker in message for marker in _CORRUPTION_MARKERS)
async def _preflight_existing_file(
hass: HomeAssistant, db_path: Path
) -> None:
"""Detect corruption in an EXISTING file before it is opened read-write.
Opening read-write persists WAL mode — for a rollback-journal file
that rewrites the header byte 18/19 and can create -wal/-shm
sidecars — which would modify a corrupt file before detection and
break the leave-untouched guarantee. This preflight therefore
validates the WHOLE file with a READ-ONLY connection (mode=ro +
immutable=1 URI, PRAGMA integrity_check, run on the executor like
every other DB call): corruption anywhere fails there, before
anything is written. ``immutable=1`` is SQLite's documented
guarantee that the connection performs NO writes and creates NO
sidecars — even a WAL-header file gets its -wal/-shm reads served
without creating the shared-memory file. Missing/empty files skip
the preflight (they are created fresh by the real open). All
filesystem stat/URI work runs inside the executor job with the
probe so the event loop never touches disk.
"""
def _probe() -> None:
# The file-state check and URI construction live inside the
# executor job: exists/stat/resolve are disk I/O.
if not db_path.exists():
return
# A zero-byte file is neither missing nor a valid database: it
# is a corrupt (truncated-to-nothing) existing file, likely an
# interrupted first write. The leave-untouched contract applies
# to it too: refuse rather than silently initialize over it.
if db_path.stat().st_size == 0:
raise sqlite3.DatabaseError(
"existing database file is empty (zero bytes): file is "
"not a database"
)
# Properly escape the path: '#'/'?'/'%' in a directory or file
# name would otherwise be parsed as URI fragments/parameters/
# escapes. immutable=1: SQLite guarantees no writes and no
# sidecar creation from this connection.
uri = db_path.resolve().as_uri() + "?mode=ro&immutable=1"
conn = sqlite3.connect(uri, uri=True)
try:
row = conn.execute("PRAGMA integrity_check").fetchone()
finally:
conn.close()
verdict = row[0] if row else "missing"
if verdict != "ok":
# A non-ok integrity verdict IS corruption: raise with the
# verdict text (which matches the corruption markers) and
# the NOTADB code so classification is code-based.
raise sqlite3.DatabaseError(
f"integrity_check verdict {verdict!r} on read-only "
f"probe: file is not a database"
)
try:
# Every DB-touching call goes through the executor (the mock
# harness's async_add_executor_job is awaitable too).
await hass.async_add_executor_job(_probe)
except sqlite3.DatabaseError as err:
# Non-ok integrity verdicts are corruption, full stop: there is
# no benign reading of a failed whole-file integrity check, so
# ANY DatabaseError out of the probe that carries a corruption
# marker or code propagates as corruption. Only exotic probe
# failures that classify as non-corruption (e.g. a transient
# 'database is locked' on the read) are downgraded to a debug
# log, letting the real open path surface them.
if _is_corruption_error(err):
raise
raise sqlite3.DatabaseError(
f"read-only corruption probe of {db_path} failed: {err}"
) from err
async def _async_open_database(
hass: HomeAssistant, db_path: Path
) -> NestQuestDatabase:
"""Open the NestQuest database, mapping failure modes for setup.
Three startup cases (Feature 02 done-condition):
- file missing: SQLite creates it fresh on open — no special path,
migrations stamp version 1 on the next step.
- file present and valid: opens normally.
- file present but corrupt: log an ERROR with the path, close
anything opened, and raise ConfigEntryNotReady so HA retries —
the file is left byte-for-byte untouched so the owner can
restore a backup. No code path here deletes or overwrites an
existing database file.
Corruption can surface at EITHER stage: a truncated header fails
in the read-only preflight (before the file is ever opened
read-write), while damaged later pages fail during migrations —
the preflight guarantees no WAL sidecar or header write happens
on a corrupt file before it is detected. Both are classified the
same way.
"""
try:
await _preflight_existing_file(hass, db_path)
database = await make_database(hass).open(db_path)
except asyncio.CancelledError:
raise
except sqlite3.DatabaseError as err:
if _is_corruption_error(err):
_LOGGER.error(
"NestQuest database at %s is corrupt: %s. Refusing to "
"start with a corrupt database; the file was left "
"untouched so a backup can be restored. Home Assistant "
"will retry setup.",
db_path,
err,
)
raise ConfigEntryNotReady(
f"NestQuest database corrupt: {db_path}"
) from err
# open() failed for a non-corruption reason (locked file,
# permission problem): retryable, but nothing was opened.
_LOGGER.error(
"NestQuest database at %s failed to open: %s. Home "
"Assistant will retry setup.",
db_path,
err,
)
raise ConfigEntryNotReady(
f"NestQuest database unavailable: {db_path}"
) from err
try:
await apply_migrations(database)
except (sqlite3.DatabaseError, RuntimeError) as err:
await database.close()
if _is_corruption_error(err):
_LOGGER.error(
"NestQuest database at %s is corrupt: %s. Refusing to "
"start with a corrupt database; the file was left "
"untouched so a backup can be restored. Home Assistant "
"will retry setup.",
db_path,
err,
)
raise ConfigEntryNotReady(
f"NestQuest database corrupt: {db_path}"
) from err
# A non-corruption failure (e.g. a locked file, a future schema
# version) is also a retryable setup problem, but without the
# corruption framing.
_LOGGER.error(
"NestQuest database at %s failed to open: %s. Home "
"Assistant will retry setup.",
db_path,
err,
)
raise ConfigEntryNotReady(
f"NestQuest database unavailable: {db_path}"
) from err
except asyncio.CancelledError:
await database.close()
raise
return database
@dataclass
class NestQuestRuntimeData:
"""Runtime data stored on a NestQuest config entry."""
entry_id: str
settings: NestQuestSettings
database: NestQuestDatabase
remove_update_listener: Callable[[], Any]
remove_time_change_listener: Callable[[], Any]
coordinator: Any = None
event_stream: NestQuestEventStream | None = None
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
"""Return every Home Assistant owner-account user id.
The last-resort admin seed: when no persisted allowlist copy and no
flow-context user survive to first setup, the HA owner accounts are
seeded so the household is never left with zero admins (an empty
allowlist fails closed, which would lock the household out of its
own integration). Owner accounts can already do everything in HA,
so granting NestQuest admin is not a privilege escalation.
"""
auth = getattr(hass, "auth", None)
if auth is None:
return []
users = await auth.async_get_users()
owners = [
user.id
for user in users
if getattr(user, "is_owner", False) and getattr(user, "id", None)
]
return owners
async def _run_horizon_materialization(
hass: HomeAssistant,
database: NestQuestDatabase,
settings: NestQuestSettings,
) -> None:
"""Materialize the rolling horizon ``[today, today + horizon_days]``.
"today" is computed in HA local time (``hass.config.time_zone``) rather
than the system clock, so the horizon tracks the household's own day.
The walk itself runs through :func:`~.materialize.materialize`, whose DB
access is executor-only.
This is the ONE generation path: the startup backfill, the daily rollover
listener and the ``regenerate`` service all funnel through it, so there is
no duplicated materialization logic. ``settings`` (REQUIRED — there is no
silent default; every caller already passes the entry's validated
:class:`NestQuestSettings`) carries the configured ``horizon_days``. The
window itself comes from :meth:`settings.horizon_window` so the helper is
real and shared (not recomputed inline here).
"""
time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
start, end = settings.horizon_window(today)
await _materialize_run(database, start.isoformat(), end.isoformat(), today=today)
def _register_day_rollover_listener(
hass: HomeAssistant,
database: NestQuestDatabase,
settings: NestQuestSettings,
) -> Callable[[], Any]:
"""Register the daily rollover listener and return its remover.
Fires once per day at the configured local ``day_rollover_time``:
first re-materializes the rolling horizon
``[today, today + horizon_days]`` through the shared
:func:`_run_horizon_materialization` path (the entry's configured,
validated ``horizon_days`` carried on ``settings``), then runs the
Feature 11 missed sweep — announcing yesterday's still-open quests
as missed AFTER today's instances exist, watermark-guarded so a
re-fire of the listener the same night announces nothing twice.
"""
hour, minute = settings.day_rollover_hour_minute
async def _run_materialization(_now: datetime.datetime) -> None:
await _run_horizon_materialization(hass, database, settings)
await run_missed_sweep(hass, database)
return async_track_time_change(
hass, _run_materialization, hour=hour, minute=minute
)
def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
"""Return a live runtime record with a coordinator, or None.
"""Return a live runtime record with a connected database, or None.
The domain-global service (the panel completion proxy) has no
database of its own; it resolves whichever config entry's runtime
data is currently live at call time, so it cannot hold a stale
handle to an unloaded entry's coordinator.
The domain-global services (e.g. ``regenerate``) have no database of
their own; they resolve whichever config entry's runtime data is
currently live at call time, so none of them can hold a stale handle
to a closed connection after another entry unloads. The record also
carries that entry's validated ``settings`` (e.g. the configured
horizon) so service handlers read horizon days off it rather than
re-reading HA config.
"""
for runtime_data in hass.data.get(DOMAIN, {}).values():
if getattr(runtime_data, "coordinator", None) is not None:
database = getattr(runtime_data, "database", None)
if database is not None and database.connected:
return runtime_data
return None
def _register_services(hass: HomeAssistant) -> None:
"""Register the domain-global NestQuest service once.
def _make_regenerate_handler(hass: HomeAssistant):
"""Return the Feature 07 ``regenerate`` handler closed over ``hass``."""
The service is DOMAIN-global — registered exactly once (guarded by
``has_service``) and NOT bound to any config entry — so the panel
completion proxy resolves the currently-live runtime at call time
async def _regenerate(_call: Any) -> None:
runtime_data = _find_live_runtime_data(hass)
if runtime_data is None:
_LOGGER.warning(
"NestQuest regenerate requested but no config entry has a "
"live database; ignoring"
)
return
await _run_horizon_materialization(
hass, runtime_data.database, runtime_data.settings
)
return _regenerate
def _register_services(hass: HomeAssistant) -> None:
"""Register domain-global NestQuest services once.
Services are DOMAIN-global — registered exactly once (guarded by
``has_service`` on regenerate) and NOT bound to any config entry —
so each handler resolves the currently-live database at call time
and a name collision or duplicate setup is impossible. Removal is
handled by the unload path only once the LAST entry is removed
(see :func:`_deregister_services`).
"""
async_register_services(hass, find_runtime=_find_live_runtime_data)
async_register_services(
hass,
find_runtime=_find_live_runtime_data,
regenerate_handler=_make_regenerate_handler(hass),
)
def _deregister_services(hass: HomeAssistant) -> None:
@@ -89,14 +386,48 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up NestQuest from a config entry."""
await async_register_frontend(hass)
# The zero-config panel dashboard (Feature 20): register the
# storage-mode strategy dashboard if no dashboard under the stable
# url_path exists yet. Unavailability of the Lovelace dashboards
# collection (YAML mode, lovelace not loaded) logs a warning and
# NEVER fails setup; an existing dashboard — ours or the user's —
# is left untouched, so a reload cannot duplicate it.
await async_register_dashboard(hass)
hass.data.setdefault(DOMAIN, {})
existing = hass.data[DOMAIN].get(entry.entry_id)
if existing is not None:
db = getattr(existing, "database", None)
if db is None or not db.connected:
# Reopen through the SAME safety path as first setup: a
# record without a live connection (a previous setup's
# close-on-failure, or a hand-replaced file) must get the
# missing/valid/corrupt classification too, never a raw
# sqlite3 error and never skipped migrations — and the
# same allowlist seeding, so a reopened fresh file is
# never ownerless.
db = await _async_open_database(
hass, await async_get_db_path(hass)
)
try:
stored_admin_ids = (
entry.options.get(CONF_ADMIN_USER_IDS)
or entry.data.get(CONF_ADMIN_USER_IDS)
)
context = getattr(entry, "context", None)
context_user_id = (
context.get("user_id")
if isinstance(context, dict)
else None
)
await seed_setup_admin(
db,
stored_admin_ids=stored_admin_ids,
context_user_id=context_user_id,
owner_ids=await _async_owner_user_ids(hass),
)
except BaseException:
# A seeding failure must not leak the just-opened
# connection: close it before the error propagates, so
# HA's setup retry starts from a clean handle (the
# first-setup path closes symmetrically).
await db.close()
raise
existing.database = db
entry.runtime_data = existing
return True
async def _async_update_listener(
listener_hass: HomeAssistant, listener_entry: ConfigEntry
@@ -104,94 +435,109 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Reload the entry when its options change."""
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
database = await _async_open_database(hass, await async_get_db_path(hass))
remove_update_listener: Callable[[], Any] | None = None
remove_time_change_listener: Callable[[], Any] | None = None
coordinator: Any = None
event_stream: NestQuestEventStream | None = None
try:
# Seed the admin allowlist on first setup so the owner is never
# locked out: an empty database takes the persisted admin copy
# — the options flow's saved list first (it is the current
# one), else the config flow's user from entry data — and only
# then the HA user still present on the entry context. No-op
# on a non-empty list (fail-closed narrowing is deliberate).
stored_admin_ids = (
entry.options.get(CONF_ADMIN_USER_IDS)
or entry.data.get(CONF_ADMIN_USER_IDS)
)
context = getattr(entry, "context", None)
context_user_id = (
context.get("user_id")
if isinstance(context, dict)
else None
)
owner_ids = await _async_owner_user_ids(hass)
await seed_setup_admin(
database,
stored_admin_ids=stored_admin_ids,
context_user_id=context_user_id,
owner_ids=owner_ids,
)
# Build the explicit settings object from entry.options ONCE: the
# horizon materialization and the daily-rollover listener both
# consume it, so core never reads HA config itself. A malformed
# stored option falls back PER FIELD (via from_options_resilient)
# so a single bad sibling option cannot reset the user's
# horizon_days or day_rollover_time — the two fields core
# consumes — to their defaults.
settings = NestQuestSettings.from_options_resilient(entry.options)
remove_update_listener = entry.add_update_listener(_async_update_listener)
remove_time_change_listener = _register_day_rollover_listener(
hass, database, settings
)
# Backfill any days the daily listener missed while the integration
# was off (or freshly installed): run the one idempotent walk over
# [today, today + horizon_days] once at setup, using the entry's
# configured horizon. This runs through the SAME path as the daily
# listener and regenerate service.
await _run_horizon_materialization(hass, database, settings)
# The missed sweep runs at startup too (Feature 11): if HA was
# down at the rollover time, the previous night's still-open
# quests are announced as missed now instead of waiting for
# the next midnight. Watermark-guarded, so this is a no-op
# when the night's sweep already ran.
await run_missed_sweep(hass, database)
_register_services(hass)
# ONE API client for the entry, shared by the coordinator's
# snapshot polling, the SSE subscription, and the completion
# proxy (the client is stateless — one session, one connection
# pool). An entry with no panel token yet gets the
# typed-error stub (unavailable entities, never a setup crash).
api_client = coordinator_client_from_entry(hass, entry)
# The shared coordinator: one refresh cycle every entity reads
# from (CONF_UPDATE_INTERVAL seconds, default five minutes).
# It polls the API service's panel snapshot; a failed poll
# keeps the entities rendering the last-good snapshot within
# CONF_SNAPSHOT_STALENESS, unavailable past it.
# The shared Feature 10 coordinator: one refresh cycle every
# entity reads from (CONF_UPDATE_INTERVAL seconds, default
# five minutes). Created AFTER services so a failure below
# unwinds them through the except path's listener handling.
coordinator = NestQuestCoordinator(
hass,
entry_id=entry.entry_id,
api_client=api_client,
database=database,
settings=settings,
update_interval_seconds=entry.options.get(
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
),
staleness_seconds=entry.options.get(
CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS
),
)
# The panel event subscription (the SSE task): a background
# task consumes the API service's transition-event stream and
# re-fires the four documented HA bus events. It reuses the
# coordinator's client, so it only starts when that client
# actually offers a stream — an unconfigured-token stub (or a
# test snapshot client without ``stream_events``) leaves it
# unstarted, and the typed-error path already covers
# availability for those entries.
if callable(getattr(api_client, "stream_events", None)):
event_stream = NestQuestEventStream(hass, api_client)
event_stream.start()
await coordinator.async_config_entry_first_refresh()
runtime_data = NestQuestRuntimeData(
entry_id=entry.entry_id,
settings=settings,
database=database,
remove_update_listener=remove_update_listener,
remove_time_change_listener=remove_time_change_listener,
coordinator=coordinator,
event_stream=event_stream,
)
# The runtime record is registered BEFORE the first refresh:
# the service proxy resolves the entry's runtime state through
# hass.data, and it must find the live coordinator there —
# never a half-set-up entry. The platforms forward below reads
# the coordinator off the entry (the HA runtime-data contract);
# a forward failure below unwinds it through the except path.
hass.data[DOMAIN][entry.entry_id] = runtime_data
# runtime_data is assigned BEFORE the platforms forward so each
# platform's async_setup_entry reads its coordinator off the
# entry (the HA runtime-data contract); a forward failure below
# unwinds it through the except path.
entry.runtime_data = runtime_data
# The first refresh polls the API snapshot; an API failure
# (unconfigured token, API service down) does NOT crash setup:
# the coordinator reports last_update_success=False, entities
# come up unavailable, and HA's standard retry cycle applies
# once the API is reachable. HA's real
# ``async_config_entry_first_refresh`` wraps ANY failed update
# pass in ConfigEntryNotReady — the original error survives
# only as ``__cause__``, never as the raised exception — so the
# catch keys on ConfigEntryNotReady, NOT on the typed API
# error. Everything else (a platform-forward failure,
# cancellation) still unwinds through the except block below.
try:
await coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady:
pass
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
except BaseException:
# A failure anywhere after the listener was registered must not
# leak it (or the SSE task): unwind EVERYTHING acquired before
# the failure, keeping the original error as the one raised.
# Removal is best-effort — a failing step must not mask the
# setup error that triggered it.
if event_stream is not None:
# A failure anywhere after the database is open must not leak the
# listeners already registered against that (now-closed) database:
# unwind EVERY remover we acquired before the failure, keeping the
# original error as the one raised. Removal is best-effort — a
# failing remover must not mask the setup error that triggered it.
for remove_listener in (
remove_time_change_listener,
remove_update_listener,
):
if remove_listener is None:
continue
try:
await event_stream.stop()
except BaseException:
pass
if remove_update_listener is not None:
try:
remove_update_listener()
result = remove_listener()
if inspect.isawaitable(result):
await result
except BaseException:
pass
# A coordinator created before the failure holds entity
# listeners pointed at the never-registered runtime — shut it
# down so no scheduled refresh outlives the failed setup.
# listeners pointed at the database being closed below —
# shut it down FIRST so no scheduled refresh can ever run
# against a closed connection.
if coordinator is not None:
try:
await coordinator.async_shutdown()
@@ -199,20 +545,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
pass
# A platform-forward failure reached this unwind AFTER
# runtime_data was assigned: clear it so no later unload path
# re-invokes the already-consumed update-listener remover
# through the stale record.
# closes the already-closed database through the stale record.
if getattr(entry, "runtime_data", None) is not None:
entry.runtime_data = None
# The runtime record was ALSO registered in hass.data before
# the first refresh: remove it here on ANY failure, or a later
# unload picks up the stale, already-torn-down record
# (hass.data wins over entry.runtime_data there) and
# re-invokes the already-consumed update-listener remover.
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
await database.close()
raise
# The runtime record was already registered before the first
# refresh; re-assert the (unchanged) record so the return path
# stays readable as one registration site.
assert remove_update_listener is not None
assert remove_time_change_listener is not None
hass.data[DOMAIN][entry.entry_id] = runtime_data
return True
@@ -227,50 +566,58 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if runtime_data is None:
runtime_data = getattr(entry, "runtime_data", None)
# Platforms unload FIRST (mirroring HA conventions): entity
# listeners must not outlive the coordinator the teardown below
# shuts down.
# listeners must not outlive the database and coordinator the
# teardown below closes.
platforms_unloaded = await hass.config_entries.async_unload_platforms(
entry, PLATFORMS
)
if platforms_unloaded is False and runtime_data is not None:
# Home Assistant KEPT one or more platforms loaded — their
# entities still live and must keep their coordinator. Restore
# entities still live and must keep their database. Restore
# the runtime record popped above and report failure so HA
# retries the unload later instead of leaving live entities
# backed by a torn-down coordinator.
# backed by a closed database.
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime_data
return False
unload_error: BaseException | None = None
if runtime_data is not None:
# The SSE subscription is stopped FIRST: its task must be gone
# (no frame can fire) before anything else is torn down.
event_stream = getattr(runtime_data, "event_stream", None)
if event_stream is not None:
stop_stream = getattr(event_stream, "stop", None)
if stop_stream is not None:
await stop_stream()
remove_update_listener = getattr(
runtime_data, "remove_update_listener", None
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
remove_time_change_listener = getattr(
runtime_data, "remove_time_change_listener", None
)
try:
if remove_update_listener is not None:
result = remove_update_listener()
if inspect.isawaitable(result):
await result
except BaseException as err:
# A failing remover must not skip the coordinator shutdown
# below; the first error is retained and re-raised only
# after the teardown ran.
if unload_error is None:
unload_error = err
for remove_listener in (
remove_update_listener,
remove_time_change_listener,
):
if remove_listener is None:
continue
try:
result = remove_listener()
if inspect.isawaitable(result):
await result
except BaseException as err:
# Cancel EVERY listener even when an earlier remover
# raises: a skipped remover would leave a daily
# callback firing against a closed database after
# unload. The first error is retained and re-raised
# only after both removers (and the DB close) ran.
if unload_error is None:
unload_error = err
finally:
# The coordinator's entity listeners must stop here: a
# scheduled refresh must never outlive the unload.
# The coordinator's entity listeners must stop BEFORE the
# database closes: a scheduled refresh starting during the
# close await would touch a cleared connection. (The
# platform unload above already removed the entities; this
# clears the coordinator's own remaining listeners.)
coordinator = getattr(runtime_data, "coordinator", None)
if coordinator is not None:
shutdown = getattr(coordinator, "async_shutdown", None)
if shutdown is not None:
await shutdown()
database = getattr(runtime_data, "database", None)
if database is not None:
await database.close()
# Domain-global services are torn down only once the FINAL entry
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
# entry is still loaded.
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.admin_allowlist` for the integration package.
The admin allowlist business layer lives in
:mod:`custom_components.nestquest.core.admin_allowlist` (extracted in
task 510c1f78); this module re-exports it so existing
``from .admin_allowlist import ...`` references inside the integration
keep working.
"""
from __future__ import annotations
from .core.admin_allowlist import * # noqa: F401,F403
-414
View File
@@ -1,414 +0,0 @@
"""Panel-plane API client for the NestQuest integration (Feature 18).
The integration talks to the NestQuest API service (Feature 16) over
HTTP instead of reading the database: this module is the ONE place that
speaks the API's panel plane — ``GET /api/v1/panel/snapshot`` (today's
household snapshot) and ``POST /api/v1/panel/instances/{id}/complete``
(completion as the tapped child profile) — authenticated with the
static panel service token as a Bearer credential on every request.
Transport-injected by construction: the constructor takes the base URL,
the panel token, and the HTTP session to use. The session speaks the
aiohttp ``ClientSession`` surface (``request(method, url, headers=...,
json=...)`` returning an async context manager whose response carries
``status`` and ``await text()``), so tests stub the transport with NO
network and NO aiohttp installed; production builds the client through
:func:`client_from_entry`, which hands in Home Assistant's shared
aiohttp session (``homeassistant.helpers.aiohttp_client
.async_get_clientsession``). The module itself imports NEITHER
homeassistant NOR aiohttp at runtime (aiohttp appears only behind
``TYPE_CHECKING`` for annotations), so the client is usable anywhere —
the HA coupling lives in the one production factory.
Every failure is typed: a transport failure (connection refused, DNS
failure, timeout — the exchange never produced a response) and every
non-2xx HTTP response raise :class:`NestQuestApiError`. The error
carries the HTTP status where there is one (so 401 and 404 are
distinguishable at the call site) and ``None`` when there is not; the
original transport error is chained as ``__cause__`` — nothing is
swallowed. There are NO retries and the client logs nothing: refresh
cadence is the coordinator's concern (the next task), and the panel
token is a secret that must never reach a log line or an error
message — the error texts name the method, path, and status only.
The panel plane's SSE event stream (``GET /api/v1/panel/events``) is
exposed by :meth:`NestQuestApiClient.stream_events` — an async
generator yielding ``(event_type, payload)`` per Server-Sent Events
frame (``event: <type>`` / ``data: <json>`` lines separated by a blank
line). Opening the stream gets the same one-timeout, typed-error
contract as every other call; reading it has NO body-wide timeout (a
healthy stream is long-lived — the subscription manager owns
reconnection and backoff).
"""
from __future__ import annotations
import asyncio
import inspect
import json
from collections.abc import AsyncIterator
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from .const import (
CONF_API_BASE_URL,
CONF_PANEL_TOKEN,
DEFAULT_API_BASE_URL,
DEFAULT_PANEL_TOKEN,
)
if TYPE_CHECKING:
import aiohttp
__all__ = [
"COMPLETE_INSTANCE_PATH",
"DEFAULT_TIMEOUT_SECONDS",
"EVENTS_PATH",
"NestQuestApiError",
"NestQuestApiClient",
"SNAPSHOT_PATH",
"client_from_entry",
"is_valid_base_url",
"resolve_api_config",
]
#: The panel plane's snapshot route (Feature 16).
SNAPSHOT_PATH = "/api/v1/panel/snapshot"
#: The panel plane's SSE event-stream route (Feature 16): each
#: transition arrives as an ``event: <type>`` / ``data: <json>`` frame.
EVENTS_PATH = "/api/v1/panel/events"
#: The panel plane's completion route template (Feature 16); the
#: ``{instance_id}`` placeholder is filled per call.
COMPLETE_INSTANCE_PATH = "/api/v1/panel/instances/{instance_id}/complete"
#: Request timeout, in seconds. One attempt, one timeout: a hung API
#: call surfaces as a typed error for the coordinator to report, never
#: a retry loop.
DEFAULT_TIMEOUT_SECONDS = 10.0
class NestQuestApiError(Exception):
"""A failed NestQuest API exchange, raised for every failure mode.
``status`` carries the HTTP status when a response arrived (a
non-2xx response — 401 for a bad token, 404 for an unknown
instance) and is ``None`` when the exchange failed at the transport
level (connection refused, DNS failure, timeout) — so callers
distinguish the two with ``is_transport_error``. The original
transport error, where there is one, is chained as ``__cause__``.
Messages carry the method, path, and status — never the request
headers, so the panel token can never leak through an error.
"""
def __init__(self, message: str, *, status: int | None = None) -> None:
super().__init__(message)
self.status = status
@property
def is_transport_error(self) -> bool:
"""True when the exchange failed before any response arrived."""
return self.status is None
def is_valid_base_url(value: object) -> bool:
"""Return True when ``value`` is a usable API base URL.
A full ``http://`` or ``https://`` URL with a host — the exact
shape the options flow validates (form ergonomics) and the client
constructor enforces (fail fast, never build requests against a
scheme-less or host-less string).
"""
if not isinstance(value, str):
return False
candidate = value.strip()
if not candidate:
return False
parsed = urlparse(candidate)
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
def resolve_api_config(entry: Any) -> tuple[str, str]:
"""Resolve ``(base_url, panel_token)`` off a config entry.
Precedence mirrors the options flow's stored-values resolution:
``entry.options`` first (the live options-flow copy), then
``entry.data`` (the config-flow-era copy), then the const defaults.
The token is a secret: callers must never log the returned pair.
"""
options = getattr(entry, "options", None) or {}
data = getattr(entry, "data", None) or {}
def _pick(key: str, default: str) -> str:
if key in options:
return options[key]
if key in data:
return data[key]
return default
return (
_pick(CONF_API_BASE_URL, DEFAULT_API_BASE_URL),
_pick(CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN),
)
def client_from_entry(hass: Any, entry: Any) -> NestQuestApiClient:
"""Build the production client for a config entry.
Reads the configured base URL and panel token off the entry
(:func:`resolve_api_config`) and hands the client Home Assistant's
SHARED aiohttp session (``homeassistant.helpers.aiohttp_client
.async_get_clientsession``) so panel calls ride HA's one connection
pool. The helper import is deferred into this function on purpose:
the test harness is mock-only and the module must import (and the
client must be stub-transport testable) without homeassistant or
aiohttp installed.
"""
from homeassistant.helpers.aiohttp_client import async_get_clientsession
base_url, panel_token = resolve_api_config(entry)
return NestQuestApiClient(
base_url, panel_token, async_get_clientsession(hass)
)
class NestQuestApiClient:
"""Async client for the NestQuest API service's panel plane.
ONE instance per config entry (the coordinator task wires it into
the refresh cycle). The client is deliberately thin: it adds the
Bearer credential and the base URL, enforces one timeout per
request, and turns every failure into :class:`NestQuestApiError` —
no business logic, no retries, no caching.
"""
def __init__(
self,
base_url: str,
panel_token: str,
session: aiohttp.ClientSession,
*,
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
) -> None:
"""Store the connection settings; the session is injected.
``session`` is an aiohttp ``ClientSession`` in production and a
stub transport in tests. ``base_url`` must be a full http(s)
URL and ``panel_token`` must be non-empty — a client built
without either can only fail later at request time with a
less-explicable error, so construction fails fast instead.
"""
if not is_valid_base_url(base_url):
raise ValueError(
f"base_url must be a full http(s) URL, got {base_url!r}"
)
if not isinstance(panel_token, str) or not panel_token.strip():
raise ValueError("panel_token must be a non-empty string")
if (
not isinstance(timeout_seconds, (int, float))
or isinstance(timeout_seconds, bool)
or timeout_seconds <= 0
):
raise ValueError(
f"timeout_seconds must be a positive number, got {timeout_seconds!r}"
)
self._base_url = base_url.strip().rstrip("/")
self._panel_token = panel_token
self._session = session
self._timeout_seconds = float(timeout_seconds)
async def get_snapshot(self) -> dict[str, Any]:
"""Fetch and parse today's household snapshot.
Returns the JSON the snapshot route returns (``today_iso``,
``cycle_day``, and the per-child list — see
``api/routes_panel.py`` for the documented shape). Raises
:class:`NestQuestApiError` on transport failure, timeout, or a
non-2xx response.
"""
return await self._request_json("GET", SNAPSHOT_PATH)
async def complete_instance(
self, instance_id: int, actor_child_id: int
) -> dict[str, Any]:
"""Complete one quest instance as the tapped child profile.
POSTs ``{"actor_child_id": actor_child_id}`` to the complete
route and returns the parsed response body (the route answers
``{"status": ...}`` both for a first completion and for an
idempotent repeat, so a retried panel tap succeeds). Raises
:class:`NestQuestApiError` on transport failure, timeout, or a
non-2xx response — 401 for a wrong token, 404 for an unknown
instance.
"""
path = COMPLETE_INSTANCE_PATH.format(instance_id=instance_id)
return await self._request_json(
"POST", path, json_body={"actor_child_id": actor_child_id}
)
async def stream_events(
self,
) -> AsyncIterator[tuple[str, dict[str, Any]]]:
"""Yield ``(event_type, payload)`` for every panel SSE frame.
Opens ``GET /api/v1/panel/events`` with the Bearer credential
and parses the Server-Sent Events framing the API service
emits: ``event: <type>`` and ``data: <json>`` lines closed by a
blank line (one frame per transition event; a frame's ``data``
may span several ``data:`` lines, joined with newlines — the
standard SSE rule). Line splits across chunk boundaries are
buffered, so frames are parsed correctly whatever chunking the
transport produces.
Opening the stream carries the SAME one-timeout, typed-error
contract as every other call: a non-2xx response raises
:class:`NestQuestApiError` with its status and a transport
failure (connection refused, DNS, the open timing out) raises
one with ``status=None`` and the original error chained. The
reading loop itself has NO timeout — a healthy stream is
long-lived — but any failure raised from the transport
mid-stream raises typed as well, so the consumer can reconnect.
A frame whose ``data`` is not valid JSON is a protocol failure
and raises typed, carrying the response status it arrived
with. Cancellation is never converted: closing the generator
(``aclose``, i.e. the consumer's task being cancelled) stops
cleanly and closes the underlying response in every path.
"""
url = f"{self._base_url}{EVENTS_PATH}"
headers = {"Authorization": f"Bearer {self._panel_token}"}
# Opening the response gets ONE request timeout, exactly like
# ``_request_json``; the read loop below runs WITHOUT one — a
# body-wide timeout would kill a healthy long-lived stream.
# The response context manager is entered manually (not via
# ``async with``) because the block must span the yields below;
# the ``finally`` closes it on EVERY exit path.
try:
async with asyncio.timeout(self._timeout_seconds):
stream_cm = self._session.request(
"GET", url, headers=headers, json=None
)
response = await stream_cm.__aenter__()
except asyncio.CancelledError:
# Cancellation is never converted (see ``_request_json``).
raise
except NestQuestApiError:
raise
except Exception as err:
raise NestQuestApiError(
f"NestQuest API GET {EVENTS_PATH} transport failure: {err}",
status=None,
) from err
status = response.status
if not 200 <= status < 300:
await self._close_stream(stream_cm)
raise NestQuestApiError(
f"NestQuest API GET {EVENTS_PATH} failed with HTTP {status}",
status=status,
)
try:
event_type: str | None = None
data_lines: list[str] = []
buffer = ""
async for chunk in response:
if isinstance(chunk, (bytes, bytearray)):
chunk = chunk.decode("utf-8")
buffer += chunk
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
if line.startswith("event:"):
event_type = line[len("event:"):].strip()
elif line.startswith("data:"):
data_lines.append(line[len("data:"):].strip())
elif not line and event_type is not None:
# A blank line ends the frame. A trailing
# incomplete frame (stream ends without its
# closing blank line) is discarded, per the
# SSE spec's treatment of incomplete events.
try:
payload = json.loads("\n".join(data_lines))
except ValueError as err:
raise NestQuestApiError(
f"NestQuest API GET {EVENTS_PATH} "
f"delivered a malformed SSE frame: {err}",
status=status,
) from err
yield event_type, payload
event_type = None
data_lines = []
except asyncio.CancelledError:
raise
except NestQuestApiError:
raise
except Exception as err:
raise NestQuestApiError(
f"NestQuest API GET {EVENTS_PATH} transport failure: {err}",
status=None,
) from err
finally:
await self._close_stream(stream_cm)
@staticmethod
async def _close_stream(stream_cm: Any) -> None:
"""Close an opened streaming response context manager.
Works for aiohttp's response context manager (whose
``__aexit__`` is a coroutine) and for test stubs that return
either a coroutine or a plain value.
"""
result = stream_cm.__aexit__(None, None, None)
if inspect.isawaitable(result):
await result
async def _request_json(
self,
method: str,
path: str,
*,
json_body: dict[str, Any] | None = None,
) -> Any:
"""Run ONE request and return the parsed JSON body.
The Bearer token rides the Authorization header only. A
non-2xx response raises :class:`NestQuestApiError` with its
status; anything the transport raises (connection, timeout) is
wrapped as a transport-level :class:`NestQuestApiError` with
the original error chained — never swallowed, never retried.
A 2xx body that is not valid JSON is a protocol failure and
raises typed too, carrying the status it arrived with.
"""
url = f"{self._base_url}{path}"
headers = {"Authorization": f"Bearer {self._panel_token}"}
try:
async with asyncio.timeout(self._timeout_seconds):
async with self._session.request(
method, url, headers=headers, json=json_body
) as response:
status = response.status
if not 200 <= status < 300:
raise NestQuestApiError(
f"NestQuest API {method} {path} failed with "
f"HTTP {status}",
status=status,
)
body_text = await response.text()
except asyncio.CancelledError:
# Cancellation is never converted: a shutting-down event
# loop must stop, not read a synthesized API error.
raise
except NestQuestApiError:
raise
except Exception as err:
raise NestQuestApiError(
f"NestQuest API {method} {path} transport failure: {err}",
status=None,
) from err
try:
return json.loads(body_text)
except ValueError as err:
raise NestQuestApiError(
f"NestQuest API {method} {path} returned malformed "
f"JSON: {err}",
status=status,
) from err
+11
View File
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.children` for the integration package.
The children registry business layer lives in
:mod:`custom_components.nestquest.core.children` (extracted in
task 510c1f78); this module re-exports it so existing
``from .children import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.children import * # noqa: F401,F403
+11
View File
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.completion` for the integration package.
The completion business layer lives in
:mod:`custom_components.nestquest.core.completion` (extracted in
task 510c1f78); this module re-exports it so existing
``from .completion import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.completion import * # noqa: F401,F403
+15 -7
View File
@@ -7,7 +7,7 @@ from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from .const import DOMAIN
from .const import CONF_ADMIN_USER_IDS, DOMAIN
class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@@ -30,11 +30,11 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) -> FlowResult:
"""Handle the user step of the config flow.
The integration needs no configuration to start (the API
connection is set in the options flow), so the user input is
stored as-is. The former admin-allowlist copy is gone: with
the database removed the allowlist lives in the API service's
own store, not on the entry.
The HA user completing the flow is persisted into the entry
data as the initial admin allowlist copy: real HA does not
guarantee the flow context survives to setup, and entry data is
the persisted record first setup seeds from — the owner is
never locked out.
"""
await self.async_set_unique_id(DOMAIN, raise_on_progress=False)
if self._async_in_progress() or self._async_current_entries():
@@ -43,4 +43,12 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is None:
return self.async_show_form(step_id="user")
return self.async_create_entry(title="NestQuest", data=dict(user_input))
data = dict(user_input)
flow_user_id = (
self.context.get("user_id")
if isinstance(self.context, dict)
else None
)
if flow_user_id:
data[CONF_ADMIN_USER_IDS] = [flow_user_id]
return self.async_create_entry(title="NestQuest", data=data)
+38 -302
View File
@@ -1,4 +1,4 @@
"""The shared NestQuest DataUpdateCoordinator (Feature 10, Feature 18).
"""The shared NestQuest DataUpdateCoordinator (Feature 10).
ONE refresh pass builds the whole entity snapshot: active children,
today's instances with their derived states (open / done / missed via
@@ -7,81 +7,54 @@ per-child rollup counts. Every Feature 10 entity reads
``coordinator.data`` — no entity queries the database itself, so one
refresh updates the whole board coherently.
Since Feature 18 the snapshot no longer comes from the local database:
the coordinator polls the NestQuest API service's panel plane —
``GET /api/v1/panel/snapshot`` through
:class:`~.api_client.NestQuestApiClient` on its update interval — and
RECONSTRUCTS the same core dataclasses the local builder produced
(:class:`~.core.snapshot.NestQuestSnapshot`,
:class:`~.core.snapshot.ChildDaySnapshot`,
:class:`~.core.snapshot.QuestInstanceView`). ``sensor.py`` and
``binary_sensor.py`` read ``coordinator.data`` and
:func:`~.core.snapshot.instance_payload` exactly as before, so every
entity_id and attribute payload shape is unchanged: the panel cards and
the four blueprints keep working untouched.
"Today" and "now" are HA-local (``hass.config.time_zone``), never the
host clock and never UTC, matching the materialization and completion
layers.
One loss is deliberate and documented (D-009 revisited): the panel
payload OMITS missed instances, so a snapshot rebuilt from the API can
no longer carry them — ``admin_instances`` (``include_missed=True``)
now equals ``instances`` (they can hold only open and completed rows).
The admin surface for missed quests is the PWA, which reads the API
service directly; the integration's entities never needed missed rows
for their states or counts (the counts arrive precomputed).
The snapshot assembly itself lives in the Home-Assistant-free
:mod:`custom_components.nestquest.core.snapshot` module
(:func:`~core.snapshot.build_snapshot`); this coordinator is now a thin
HA wrapper that resolves the local timezone and reads the settings handed
into its constructor, then delegates to that builder, passing the SAME
HA-local ``now`` it resolved in one clock read. The API service's panel
snapshot route shares the same builder, so both consumers render ONE
snapshot shape.
Failure handling: constructing the client fails fast (ValueError) when
the entry has no panel token configured, and a fetch failure raises the
typed :class:`~.api_client.NestQuestApiError`. Neither crashes
integration setup: the constructor logs a clear message and hands back
a client-shaped stub whose refresh raises the same typed error, and
``_async_update_data`` converts every typed error into
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed` — the
exception a HA ``DataUpdateCoordinator`` records as a failed pass
(``last_update_success = False``, ``data = None``), so the entities
come up unavailable. The typed error is never swallowed silently: it
is logged with its message and chained into the UpdateFailed. (The
conversion matters at setup: HA's real
``async_config_entry_first_refresh`` wraps any failed pass in
``ConfigEntryNotReady``, so a setup that keys on the original
exception type would never match it.)
Presence is resolved through the SAME engine the materializer uses
(:class:`~.presence.PresenceEngine`): schedules and overrides are read
with the public DAO methods and the engine's anchor-date arithmetic
answers ``is this child at this house today`` (D-004 — never ISO week
parity).
Last-good cache and staleness: every successful pass refreshes a cache
of the rebuilt snapshot stamped with the :func:`time.monotonic`
instant of the success. A typed failure is checked against that stamp
BEFORE the pass is declared failed: within the configured staleness
threshold (``CONF_SNAPSHOT_STALENESS``, seconds, default three update
intervals) the pass SUCCEEDS with the cached snapshot — the panel
keeps rendering last-good data and the entities stay available — and
the outage is logged as a warning. Entities go unavailable only once
the cache is older than the threshold (or before the first success);
a later successful fetch refreshes the cache and the entities recover
on their own, without a config-entry reload.
``cycle_day`` reports where today falls in the custody cycle of the
first active child (display order) that HAS a schedule — 1-based day
``N`` of ``cycle_length_weeks * 7``, computed from that schedule's
anchor date. ``0`` means no active child has a schedule at all, so
the admin header degrades to a harmless zero instead of guessing.
"""
from __future__ import annotations
import asyncio
import datetime
import time
from typing import Any
from zoneinfo import ZoneInfo
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
UpdateFailed,
)
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .api_client import NestQuestApiClient, NestQuestApiError, resolve_api_config
from .const import (
CONF_UPDATE_INTERVAL,
DEFAULT_SNAPSHOT_STALENESS,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
LOGGER,
MIN_UPDATE_INTERVAL,
)
from .core.db import NestQuestDatabase
from .core.settings import NestQuestSettings
from .core.snapshot import (
ChildDaySnapshot,
NestQuestSnapshot,
QuestInstanceView,
build_snapshot,
)
__all__ = [
@@ -92,131 +65,9 @@ __all__ = [
]
def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot:
"""Reconstruct the core snapshot dataclasses from the API's JSON.
The panel snapshot route (``api/routes_panel.py``) returns today's
household as plain JSON — ``today_iso``, ``cycle_day``, and the
per-child list with precomputed counts and the instances in the
documented payload shape. This function maps that shape BACK onto
the dataclasses ``sensor.py`` / ``binary_sensor.py`` read, field by
field:
- the API's ``state`` ``"completed"`` maps back to the core view's
``"done"`` (so :func:`~.core.snapshot.instance_payload` renders
``"completed"`` again — the round-trip is identity for open and
completed rows);
- ``overdue``, ``on_time`` (→ ``was_on_time``), ``completed_at``,
``window``, ``due_time``, ``icon`` and the per-child counts pass
through verbatim;
- ``present`` and ``next_present`` pass through verbatim (the API
resolved them through the same presence engine server-side);
- the instance list is rebuilt IN ORDER, so the panel payload the
sensors emit matches the route's order byte for byte.
Because the route omits missed instances (D-009), a snapshot built
here cannot contain any ``missed`` view — see the module docstring.
"""
children: list[ChildDaySnapshot] = []
for child in payload.get("children", []):
views = tuple(
QuestInstanceView(
instance_id=instance["id"],
definition_id=instance["definition_id"],
child_id=instance["child_id"],
title=instance["title"],
icon=instance.get("icon"),
window=instance["window"],
# The API payload does not carry due_date: every row
# is TODAY's instance (the route queries
# [today, today]), so the view's due_date is the
# snapshot's today_iso — the same value the local
# builder stamped on every today row.
due_date=payload["today_iso"],
due_time=instance.get("due_time"),
state=(
"done" if instance["state"] == "completed" else
instance["state"]
),
overdue=instance["overdue"],
completed_at=instance.get("completed_at"),
was_on_time=instance.get("on_time"),
)
for instance in child.get("instances", [])
)
children.append(
ChildDaySnapshot(
child_id=child["child_id"],
child_name=child["child_name"],
instances=views,
present=child["present"],
due_today=child["due_today"],
completed_today=child["completed_today"],
remaining_today=child["remaining_today"],
completion_pct=child["completion_pct"],
next_present=child.get("next_present"),
)
)
return NestQuestSnapshot(
today_iso=payload["today_iso"],
children=tuple(children),
cycle_day=payload["cycle_day"],
)
class _UnconfiguredApiClient:
"""Client-shaped stand-in used when the entry has no panel token.
Constructing a real :class:`~.api_client.NestQuestApiClient` with an
empty token raises ValueError by design (fail fast on a client that
could only ever 401). An entry that simply has not been configured
yet must not crash integration setup, so the coordinator swaps in
this stub: every refresh raises the SAME typed
:class:`~.api_client.NestQuestApiError` a transport failure would,
carrying a clear "API not configured" message;
:meth:`NestQuestCoordinator._async_update_data` converts it to
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, so
the coordinator marks the entities unavailable and the HA standard
retry cycle applies. The error is logged, never silent.
"""
__slots__ = ()
async def get_snapshot(self) -> dict[str, Any]:
"""Always raise: there is no configured API to fetch from."""
raise NestQuestApiError(
"NestQuest API not configured: set the panel token in the "
"integration options"
)
def _monotonic() -> float:
"""The monotonic clock the staleness math reads.
A one-line indirection over :func:`time.monotonic` so the staleness
tests can drive the clock deterministically (patch THIS function,
never the stdlib clock). Monotonic is deliberate: wall-clock jumps
(NTP corrections, suspends) must not shorten or extend the window.
"""
return time.monotonic()
class NestQuestCoordinator(DataUpdateCoordinator):
"""Shared refresh cycle for every NestQuest entity.
One refresh pass = one ``GET /api/v1/panel/snapshot`` through the
API client, reconstructed into the core snapshot dataclasses every
entity reads. A failed pass (unconfigured API, transport failure,
non-2xx response) raises the typed error out of the client;
``_async_update_data`` converts it to
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, the
exception HA's coordinator machinery records as a failed pass: the
coordinator's ``data`` keeps its last good value while
``last_update_success`` goes False — HA marks the entities
unavailable until a later pass succeeds. An unexpected
non-typed error still propagates out of ``_async_update_data``
and is logged (with its traceback) by HA's machinery, the same way.
Forced refreshes (the service paths pushing an immediate update
after a completion) are SERIALIZED per entry: overlapping
complete/uncomplete calls must not interleave their snapshot
@@ -224,15 +75,6 @@ class NestQuestCoordinator(DataUpdateCoordinator):
newer one and leave entities wrong until the next poll. Real
HA's coordinator already serializes internally; the lock keeps
the same guarantee on the stand-in and costs nothing upstream.
Last-good cache: a successful pass stamps the rebuilt snapshot
with the :func:`~.coordinator._monotonic` instant of the success;
a typed failure within the configured staleness threshold
(``staleness_seconds``, from ``CONF_SNAPSHOT_STALENESS``) serves
that cached snapshot instead of failing the pass, so a short API
outage keeps the board up. Past the threshold — or before the
first success — the pass fails as above, and a later successful
pass refreshes the cache and recovers the entities on its own.
"""
def __init__(
@@ -240,9 +82,9 @@ class NestQuestCoordinator(DataUpdateCoordinator):
hass: HomeAssistant,
*,
entry_id: str,
api_client: NestQuestApiClient,
database: NestQuestDatabase,
settings: NestQuestSettings,
update_interval_seconds: int | None = None,
staleness_seconds: int | None = None,
) -> None:
interval = update_interval_seconds
if (
@@ -251,19 +93,6 @@ class NestQuestCoordinator(DataUpdateCoordinator):
or interval < MIN_UPDATE_INTERVAL
):
interval = DEFAULT_UPDATE_INTERVAL
staleness = staleness_seconds
if (
not isinstance(staleness, int)
or isinstance(staleness, bool)
or staleness < interval
):
# A missing or nonsensical threshold falls back to the
# shipped default; a configured-but-too-short one (below
# even one poll interval) falls back to whichever of
# default/interval spans at least one failed poll, so the
# cache is never dead on arrival. (The options flow
# validates the same rule at the form.)
staleness = max(DEFAULT_SNAPSHOT_STALENESS, interval)
super().__init__(
hass,
LOGGER,
@@ -271,16 +100,9 @@ class NestQuestCoordinator(DataUpdateCoordinator):
update_interval=datetime.timedelta(seconds=interval),
)
self.entry_id = entry_id
self.api_client = api_client
self.database = database
self.settings = settings
self._refresh_lock = asyncio.Lock()
#: The configured last-good window, in SECONDS (const.py:
#: CONF_SNAPSHOT_STALENESS / DEFAULT_SNAPSHOT_STALENESS).
self._staleness_seconds = staleness
#: The last-good cache: the snapshot from the most recent
#: SUCCESSFUL fetch and the :func:`time.monotonic` instant it
#: landed. Both stay None until the first success.
self._last_good_snapshot: NestQuestSnapshot | None = None
self._last_good_monotonic: float | None = None
async def async_refresh(self) -> None:
"""One refresh at a time; a queued refresh runs AFTER the
@@ -289,98 +111,12 @@ class NestQuestCoordinator(DataUpdateCoordinator):
async with self._refresh_lock:
await super().async_refresh()
def _local_now(self) -> datetime.datetime:
time_zone = ZoneInfo(self.hass.config.time_zone)
return datetime.datetime.now(time_zone)
async def _async_update_data(self) -> NestQuestSnapshot:
"""One refresh pass: fetch the panel snapshot and rebuild it.
A successful pass rebuilds the snapshot and refreshes the
last-good cache (the snapshot plus the
:func:`time.monotonic` instant of the success).
A typed API failure (unconfigured token stub, transport
failure, non-2xx response) is first checked against that
stamp: WITHIN the configured staleness threshold (elapsed time
since the last successful fetch, inclusive of the threshold
itself) the pass SUCCEEDS with the cached snapshot — the panel
keeps rendering last-good data, the entities stay available —
and the outage is logged as a clear warning (never silent).
With no cache yet, or past the threshold, the typed error is
converted to
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`
— the exception HA's coordinator machinery records as a failed
pass (``last_update_success = False``, ``data`` unchanged) so
entities go unavailable and the setup-time first refresh
arrives as ``ConfigEntryNotReady`` — with the typed error
logged and chained. Anything else still propagates.
"""
try:
payload = await self.api_client.get_snapshot()
except NestQuestApiError as err:
now = _monotonic()
cached = self._last_good_snapshot
fetched_at = self._last_good_monotonic
if (
cached is not None
and fetched_at is not None
and now - fetched_at <= self._staleness_seconds
):
LOGGER.warning(
"NestQuest API refresh failed (%s); serving the "
"last-good snapshot fetched %.0f s ago, within the "
"%d s staleness threshold",
err,
now - fetched_at,
self._staleness_seconds,
)
return cached
LOGGER.error(
"NestQuest coordinator refresh failed: %s", err
)
raise UpdateFailed(str(err)) from err
snapshot = snapshot_from_api_payload(payload)
self._last_good_snapshot = snapshot
self._last_good_monotonic = _monotonic()
return snapshot
def coordinator_client_from_entry(
hass: HomeAssistant, entry: Any, *, session_factory: Any = None
) -> Any:
"""Build the refresh client for a config entry, tolerating no token.
Reads ``(base_url, panel_token)`` off the entry and constructs the
real client. An entry with no panel token configured (the shipped
default is the empty string) makes the constructor raise ValueError
by design; integration setup must not crash on an unconfigured
entry, so this factory swaps in the typed-error stub instead and
logs ONE clear message. The ValueError itself is never re-raised
past this point — it is converted, with the reason logged.
``session_factory`` overrides where the HTTP session comes from
(production: HA's shared aiohttp session via
:func:`~.api_client.client_from_entry`); it exists so tests can
inject a stub transport without importing aiohttp — the mock-only
test harness has no ``homeassistant.helpers.aiohttp_client``.
"""
base_url, panel_token = resolve_api_config(entry)
if not isinstance(panel_token, str) or not panel_token.strip():
LOGGER.warning(
"NestQuest API panel token is not configured for entry %s: "
"entities will stay unavailable until a token is set in the "
"integration options",
entry.entry_id,
now = self._local_now()
return await build_snapshot(
self.database, self.settings, now
)
return _UnconfiguredApiClient()
if session_factory is not None:
return NestQuestApiClient(
base_url, panel_token, session_factory(hass)
)
from custom_components.nestquest.api_client import client_from_entry
return client_from_entry(hass, entry)
#: Test seam: entry_id → client, consulted by the conftest's client
#: factory BEFORE the production path. Always empty in production
#: (nothing in the integration writes to it); tests register scripted
#: clients through it and clear it between tests.
_COORDINATOR_CLIENT_OVERRIDES: dict[str, Any] = {}
+25 -63
View File
@@ -20,41 +20,44 @@ TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The
# panel and admin cards listen for these; since Feature 18 the
# completed/uncompleted/day-complete transitions are published by the
# API service on its SSE stream and re-fired on the bus by the
# integration's subscription, and Feature 11's nightly sweep fires
# quest_missed.
# panel and admin cards listen for these; Feature 10 fires completed,
# uncompleted, and child_day_complete from the service paths, and
# Feature 11's nightly sweep fires quest_missed.
EVENT_QUEST_COMPLETED = "nestquest_quest_completed"
EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted"
EVENT_QUEST_MISSED = "nestquest_quest_missed"
EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete"
#: Canonical domain-global service name (design/ENTITIES-AND-SERVICES.md §2).
#: Since Feature 18 only the panel completion service remains: the admin
#: surface is the PWA and the API service's admin routes, so the former
#: HA admin services (uncomplete, quest-definition and presence
#: management, CSV export, child management, regenerate) are gone.
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
#: plus the existing Feature 07 regenerate service).
SERVICE_COMPLETE_QUEST = "complete_quest"
SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest"
SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition"
SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition"
SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active"
SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern"
SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override"
SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override"
SERVICE_EXPORT_HISTORY_CSV = "export_history_csv"
SERVICE_MANAGE_CHILD = "manage_child"
SERVICE_REGENERATE = "regenerate"
DOMAIN_SERVICES: tuple[str, ...] = (
SERVICE_COMPLETE_QUEST,
SERVICE_UNCOMPLETE_QUEST,
SERVICE_CREATE_QUEST_DEFINITION,
SERVICE_UPDATE_QUEST_DEFINITION,
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
SERVICE_SET_PRESENCE_PATTERN,
SERVICE_CREATE_PRESENCE_OVERRIDE,
SERVICE_DELETE_PRESENCE_OVERRIDE,
SERVICE_EXPORT_HISTORY_CSV,
SERVICE_MANAGE_CHILD,
SERVICE_REGENERATE,
)
SQLITE_DB_FILENAME = "nestquest.db"
# Zero-config panel dashboard (Feature 20): ONE storage-mode Lovelace
# dashboard registered on setup whose config carries the bundle's
# ``custom:nestquest-party`` strategy (frontend/src/strategy.ts) — the
# views are generated at render time from the household roster. The
# url_path IS the domain: the stable key idempotency is keyed on (a
# reload or a second entry never duplicates it, and a dashboard already
# registered under it — ours or the user's — is left untouched).
DASHBOARD_URL_PATH = DOMAIN
DASHBOARD_TITLE = "NestQuest"
DASHBOARD_ICON = "mdi:party-popper"
DASHBOARD_STRATEGY_TYPE = "custom:nestquest-party"
# Configuration keys and default values
CONF_HORIZON_DAYS = "horizon_days"
DEFAULT_HORIZON_DAYS = 14
@@ -78,21 +81,6 @@ CONF_UPDATE_INTERVAL = "update_interval"
DEFAULT_UPDATE_INTERVAL = 300
MIN_UPDATE_INTERVAL = 30
# Last-good snapshot staleness (Feature 18), in SECONDS: how long the
# coordinator keeps SERVING the last good snapshot after the API stops
# answering. A failed poll whose elapsed time since the last successful
# fetch is within the threshold returns the cached snapshot (the panel
# keeps rendering last-good data, entities stay available); past it the
# poll fails the HA way and the entities go unavailable. The default is
# 3x the default update interval (900 s = 15 minutes): three consecutive
# failed five-minute polls before the board is declared stale — enough
# to ride out an API service restart, short enough not to render a
# days-old board all day. Validation floor: the threshold must be at
# least the configured update interval, or the cache could never span
# even one failed poll.
CONF_SNAPSHOT_STALENESS = "snapshot_staleness"
DEFAULT_SNAPSHOT_STALENESS = 3 * DEFAULT_UPDATE_INTERVAL
# Notification automations (Feature 11). The notify target is a free-text
# Home Assistant service name (e.g. "notify.mobile_app_dads_phone") the
# household fills in — never a hard-coded personal target; the times are
@@ -113,32 +101,6 @@ CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
CONF_CELEBRATION_ENABLED = "celebration_enabled"
DEFAULT_AUTOMATION_ENABLED = True
# Panel-plane API client (Feature 18): the integration reaches the
# NestQuest API service (Feature 16) over HTTP instead of the database.
# The base URL points at the API service host serving ``/api/v1/panel/*``
# (uvicorn's default host/port is the sensible starting point); the panel
# service token is the static Bearer credential the panel plane requires
# (``NESTQUEST_PANEL_TOKEN`` on the API side). Both are stored on the
# entry like every other setting — options first, then the entry-data
# copy, then defaults. The TOKEN IS A SECRET: it is never logged, never
# echoed into error messages, and never committed. The shipped default
# token IS the empty string (DEFAULT_PANEL_TOKEN) — an entry with no
# configured token is therefore NOT a working client: the options flow
# accepts an empty token as "not configured yet" and only rejects
# malformed (padded/whitespace-only/non-string) values — the actual
# non-emptiness guard is enforced at client construction, where
# NestQuestApiClient fails fast on an empty/whitespace token
# (api_client.py). So a caller building the client from a bare
# entry must either ensure a token is configured first or handle the
# construction ValueError and surface an "API not configured" state —
# an unconfigured entry never yields a client that can only 401.
# This mirrors the API service's fail-closed stance (api/config.py: no
# default token exists).
CONF_API_BASE_URL = "api_base_url"
DEFAULT_API_BASE_URL = "http://127.0.0.1:8000"
CONF_PANEL_TOKEN = "panel_token"
DEFAULT_PANEL_TOKEN = ""
# Quest windows (D-008): a definition may declare several day windows;
# each produces its own instance per day and groups the panel's Quest
# Log into three columns. The clock ranges are stored here so they stay
+1 -35
View File
@@ -41,38 +41,4 @@ class MetaStateDao:
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
"ON CONFLICT (key) DO UPDATE SET value = excluded.value",
(key, value),
)
async def claim(self, key: str, value: str) -> bool:
"""Atomically set ``key`` to ``value`` unless it already is.
The single upsert statement carries a ``WHERE`` guard on the
conflict path (``t.value IS NOT excluded.value``), so the
read-compare-write is ONE SQLite statement — atomic at the
DATABASE level, not just within one process: two connections
(two processes sharing one SQLite file) cannot both observe
"not yet set" and both write, because the second writer's
statement re-evaluates the guard against the first writer's
committed value and matches zero rows.
Returns True only when THIS call changed the row: the first
claimant wins and every later claimant of the same value
returns False. A DIFFERENT value always wins (an advance, e.g.
the sweep watermark moving to a new date) and re-arms the
same-value loser check for the new value.
Runs under the shared connection lock and inside one
transaction, like :meth:`set`; ``cursor.rowcount`` (1 for a
matched write, 0 for a guarded no-op) is captured by the
wrapper on the executor thread, so no sqlite3 object crosses
back to the event loop.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
result = await self._database.execute(
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
"ON CONFLICT (key) DO UPDATE SET value = excluded.value "
f"WHERE {_TABLE}.value IS NOT excluded.value",
(key, value),
)
return result.rowcount == 1
)
@@ -270,57 +270,6 @@ class PresenceOverridesDao:
)
return [_override_from_row(row) for row in rows]
async def list_filtered(
self,
*,
child_id: int | None = None,
start: str | None = None,
end: str | None = None,
) -> list[PresenceOverrideRecord]:
"""Return the household's overrides, optionally narrowed.
Every filter is optional and they combine: ``child_id`` keeps
that child's overrides only (an unknown child raises ValueError
rather than reading as "no overrides"); ``start`` keeps
overrides ending on or after it and ``end`` keeps overrides
starting on or before it, so both together select the overrides
overlapping the closed range ``[start, end]`` (the
:meth:`list_by_child_and_range` boundary rule). Dates are
strict YYYY-MM-DD and ``end`` must not precede ``start``.
Ordered stably by start date, then child id, then id.
"""
if start is not None:
_validate_date(start, "start")
if end is not None:
_validate_date(end, "end")
if start is not None and end is not None and end < start:
raise ValueError(
f"end must be on or after start, got {end!r} < {start!r}"
)
clauses: list[str] = []
params: list[object] = []
if child_id is not None:
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
clauses.append("child_id = ?")
params.append(child_id)
if start is not None:
clauses.append("end_date >= ?")
params.append(start)
if end is not None:
clauses.append("start_date <= ?")
params.append(end)
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
rows = await self._database.fetch_all(
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides{where} "
"ORDER BY start_date, child_id, id",
tuple(params),
)
return [_override_from_row(row) for row in rows]
async def get(self, override_id: int) -> PresenceOverrideRecord | None:
"""Return one override by id, or None when it does not exist."""
row = await self._database.fetch_one(
+13 -118
View File
@@ -105,19 +105,6 @@ def _validate_window(window: str) -> None:
)
def _validate_skip_on_away(value: object) -> int:
"""Return ``value`` as 0/1, raising ValueError for anything else.
The fresh-schema CHECK constraint enforces this in SQLite, but a
migrated database gained the column through ``ADD COLUMN`` without
it, so every DAO write path validates here instead. Accepts a bool
or the ints 0/1; floats, strings and other ints are rejected.
"""
if not isinstance(value, int) or value not in (0, 1):
raise ValueError(f"skip_on_away must be 0 or 1, got {value!r}")
return int(value)
@dataclass(frozen=True)
class ScheduleRuleRecord:
"""One row of ``schedule_rules``."""
@@ -165,7 +152,6 @@ class QuestDefinitionRecord:
due_time: str | None
is_active: bool
created_at: str
skip_on_away: bool
@dataclass(frozen=True)
@@ -201,7 +187,7 @@ _RULE_COLUMNS = (
)
_DEFINITION_COLUMNS = (
"id, title, description, icon, schedule_rule_id, due_time, "
"is_active, created_at, skip_on_away"
"is_active, created_at"
)
_WINDOW_COLUMNS = "definition_id, window, due_time"
@@ -230,7 +216,6 @@ def _definition_from_row(row: tuple) -> QuestDefinitionRecord:
due_time=row[5],
is_active=bool(row[6]),
created_at=row[7],
skip_on_away=bool(row[8]),
)
@@ -647,7 +632,6 @@ class QuestDefinitionsDao:
due_time: str | None = None,
is_active: bool = True,
assignee_child_ids: list[int] | None = None,
skip_on_away: bool = True,
) -> QuestDefinitionRecord:
"""Insert one definition and return the record as stored.
@@ -660,7 +644,6 @@ class QuestDefinitionsDao:
empty list or None creates an unassigned definition, which
callers may fill via :meth:`add_assignee`.
"""
skip_on_away_value = _validate_skip_on_away(skip_on_away)
async with _connection_lock(self._database):
async with self._database.transaction():
await self._validate_rule_exists(schedule_rule_id)
@@ -669,8 +652,7 @@ class QuestDefinitionsDao:
result = await self._database.execute(
"INSERT INTO quest_definitions (title, description, "
"icon, schedule_rule_id, due_time, is_active, "
"created_at, skip_on_away) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(
title,
description,
@@ -679,7 +661,6 @@ class QuestDefinitionsDao:
due_time,
int(is_active),
created_at,
skip_on_away_value,
),
)
definition_id = result.lastrowid
@@ -703,7 +684,6 @@ class QuestDefinitionsDao:
*,
description: str | None = None,
icon: str | None = None,
skip_on_away: bool = True,
) -> QuestDefinitionSnapshot:
"""Insert a rule, definition, assignees and windows atomically.
@@ -733,7 +713,6 @@ class QuestDefinitionsDao:
_validate_date(schedule_rule.start_date, "start_date")
if schedule_rule.end_date is not None:
_validate_date(schedule_rule.end_date, "end_date")
skip_on_away_value = _validate_skip_on_away(skip_on_away)
async with _connection_lock(self._database):
async with self._database.transaction():
for child_id in assignee_child_ids:
@@ -742,18 +721,8 @@ class QuestDefinitionsDao:
result = await self._database.execute(
"INSERT INTO quest_definitions (title, description, "
"icon, schedule_rule_id, due_time, is_active, "
"created_at, skip_on_away) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
title,
description,
icon,
rule_id,
None,
1,
created_at,
skip_on_away_value,
),
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(title, description, icon, rule_id, None, 1, created_at),
)
definition_id = result.lastrowid
for child_id in assignee_child_ids:
@@ -795,10 +764,8 @@ class QuestDefinitionsDao:
icon: str | None | object = _UNSET,
rule: ScheduleRuleStorage | object = _UNSET,
windows: list[tuple[str, str | None]] | object = _UNSET,
skip_on_away: bool | object = _UNSET,
assignee_child_ids: list[int] | object = _UNSET,
) -> QuestDefinitionSnapshot:
"""Edit a definition's metadata, rule, windows and assignees atomically.
"""Edit a definition's metadata, rule and windows atomically.
Mirrors :meth:`create_with_rule_and_windows`: everything runs
inside ONE transaction under the connection lock, and the
@@ -807,10 +774,9 @@ class QuestDefinitionsDao:
``_UNSET`` meaning "leave this field alone"; ``None`` writes SQL
NULL so optional metadata (description, icon) can be cleared.
- ``title``/``description``/``icon``/``skip_on_away`` update the
definition row in place; the definition-level ``due_time``
column is never written (per-window due times supersede it,
D-008).
- ``title``/``description``/``icon`` update the definition row
in place; the definition-level ``due_time`` column is never
written (per-window due times supersede it, D-008).
- ``rule`` (a pre-validated :class:`ScheduleRuleStorage`)
overwrites the referenced ``schedule_rules`` row in place: the
definition keeps its ``schedule_rule_id``, so no new or
@@ -819,19 +785,13 @@ class QuestDefinitionsDao:
are deleted and the given set re-inserted, so missing windows
are added, changed due times updated and removed windows
dropped.
- ``assignee_child_ids`` REPLACES the whole assignee set: every
id is validated as existing-and-active BEFORE any write (the
same check :meth:`add_assignee` applies), then links for
children no longer listed are removed and links for newly
listed children inserted; unchanged links are left alone.
Activation is untouched. Raises ValueError when the definition
does not exist or an assignee is rejected; either way the
transaction rolls back and nothing is written. Edits change future instances only: no
Assignment is deliberately not settable here (multi-assignee is
managed by :meth:`add_assignee`/:meth:`remove_assignee`), and
activation is untouched. Raises ValueError when the definition
does not exist. Edits change future instances only: no
``quest_instances`` or ``completion_events`` row is touched.
"""
if skip_on_away is not _UNSET:
skip_on_away = _validate_skip_on_away(skip_on_away)
async with _connection_lock(self._database):
async with self._database.transaction():
definition = await self.get(definition_id)
@@ -839,9 +799,6 @@ class QuestDefinitionsDao:
raise ValueError(
f"quest definition {definition_id} does not exist"
)
if assignee_child_ids is not _UNSET:
for child_id in assignee_child_ids:
await self._validate_assignable_child(child_id)
assignments: list[str] = []
parameters: list[object] = []
for column, value in (
@@ -852,9 +809,6 @@ class QuestDefinitionsDao:
if value is not _UNSET:
assignments.append(f"{column} = ?")
parameters.append(value)
if skip_on_away is not _UNSET:
assignments.append("skip_on_away = ?")
parameters.append(skip_on_away)
if assignments:
parameters.append(definition_id)
await self._database.execute(
@@ -890,10 +844,6 @@ class QuestDefinitionsDao:
"VALUES (?, ?, ?)",
(definition_id, window, due_time),
)
if assignee_child_ids is not _UNSET:
await self._replace_assignees(
definition_id, assignee_child_ids
)
updated = await self.get(definition_id)
assignees = await self.list_assignees(definition_id)
window_records = await self.list_windows(definition_id)
@@ -909,36 +859,6 @@ class QuestDefinitionsDao:
windows=window_records,
)
async def _replace_assignees(
self, definition_id: int, child_ids: list[int]
) -> None:
"""Make the definition's assignee links exactly ``child_ids``.
Removes the links for children not in ``child_ids`` and inserts
the missing ones. MUST be called inside the connection lock and
an open transaction, after the ids were validated as assignable.
"""
rows = await self._database.fetch_all(
"SELECT child_id FROM quest_definition_assignees "
"WHERE definition_id = ?",
(definition_id,),
)
current = {row[0] for row in rows}
wanted = set(child_ids)
for child_id in sorted(current - wanted):
await self._database.execute(
"DELETE FROM quest_definition_assignees "
"WHERE definition_id = ? AND child_id = ?",
(definition_id, child_id),
)
for child_id in child_ids:
if child_id not in current:
await self._database.execute(
"INSERT INTO quest_definition_assignees "
"(definition_id, child_id) VALUES (?, ?)",
(definition_id, child_id),
)
async def _validate_rule_exists(self, schedule_rule_id: int) -> None:
"""Raise ValueError unless the rule exists.
@@ -987,13 +907,6 @@ class QuestDefinitionsDao:
)
return [_definition_from_row(row) for row in rows]
async def list_all(self) -> list[QuestDefinitionRecord]:
"""Return every definition, active AND inactive, oldest first."""
rows = await self._database.fetch_all(
f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions ORDER BY id"
)
return [_definition_from_row(row) for row in rows]
async def _snapshots_for(
self, definitions: list[QuestDefinitionRecord]
) -> list[QuestDefinitionSnapshot]:
@@ -1038,18 +951,6 @@ class QuestDefinitionsDao:
definitions = await self.list_active()
return await self._snapshots_for(definitions)
async def list_snapshots_all(self) -> list[QuestDefinitionSnapshot]:
"""Return every definition (active AND inactive) with its rule,
assignees and windows, all read inside one locked transaction.
Mirrors :meth:`list_snapshots_active` over :meth:`list_all`
(oldest first), with the same coherence guarantee.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
definitions = await self.list_all()
return await self._snapshots_for(definitions)
async def read_snapshots_active(self) -> list[QuestDefinitionSnapshot]:
"""Return every active definition's snapshot WITHOUT locking.
@@ -1089,16 +990,13 @@ class QuestDefinitionsDao:
description: str | None | object = _UNSET,
icon: str | None | object = _UNSET,
due_time: str | None | object = _UNSET,
skip_on_away: bool | None = None,
) -> int:
"""Update the given fields; returns rows updated (0 if absent).
Arguments default to the module sentinel ``_UNSET`` meaning
"leave this column alone"; passing ``None`` explicitly writes
SQL NULL, so callers can remove optional metadata
(description, icon, due_time). ``skip_on_away`` is NOT NULL,
so its ``None`` default leaves it unchanged; a supplied value
must be 0/1 (a bool). Assignment and activation are
(description, icon, due_time). Assignment and activation are
deliberately NOT settable here: they have their own explicit
operations (:meth:`add_assignee`, :meth:`remove_assignee`,
:meth:`set_active`) so they stay separately loggable and
@@ -1115,9 +1013,6 @@ class QuestDefinitionsDao:
if value is not _UNSET:
assignments.append(f"{column} = ?")
parameters.append(value)
if skip_on_away is not None:
assignments.append("skip_on_away = ?")
parameters.append(_validate_skip_on_away(skip_on_away))
if not assignments:
return 0
parameters.append(definition_id)
-405
View File
@@ -1,405 +0,0 @@
"""Read-only history layer for the admin plane (Admin spec §5, task 2d2dda53).
Assembles the History screen's event rows over the append-only
completion log: a query over a CLOSED date range ``[start_date,
end_date]`` for one of three filters —
- ``all`` — every completion event (completed AND uncompleted) whose
instance is due in the range;
- ``reversals`` — the uncompleted events only;
- ``missed`` — the DERIVED missed instances in the range.
Range semantics (Feature 13's rule, kept): the range scopes by the
INSTANCE's ``due_date`` — the day the task was owed — not the event's
wall-clock ``occurred_at``, because history reporting asks "what
happened to the tasks due that day". ``missed`` is derived, never
stored: rows come from :func:`~.completion.list_missed_for_child`
(past-due open instances, the same ``_derived_state`` rule the
coordinator uses), and nothing is ever appended for them.
Row shape: the Admin spec §5 event-row fields, fixed and documented in
:data:`CSV_COLUMNS` — ``occurred_at``, ``event_type``,
``child_name``, ``child_id``, ``quest_title``, ``instance_id``,
``window``, ``due_date``, ``due_time``, ``actor``, ``was_on_time``.
Derived missed rows carry ``event_type`` ``'missed'`` (never a stored
event_type), no ``occurred_at`` (there is no event), ``was_on_time``
``None`` and the spec's ``nightly sweep`` actor. A stored event's
``actor`` follows the spec's meta shape: the ``actor_user_id`` string
for a user event (``joshua``), ``panel (Child)`` naming the tapped
profile for a panel event.
Validation policy (module-level, shared by both the JSON query and the
CSV export): the filter must be one of :data:`FILTERS` and the range
must be two strict ``YYYY-MM-DD`` dates with ``end >= start``;
violations raise ``ValueError`` naming the offending field
(``filter`` / ``start`` / ``end``) BEFORE any read. The whole
household read runs under the task-reentrant connection lock so every
row of one answer is one snapshot — the same discipline
:mod:`.completion` gives its read-decide-write paths.
:func:`rows_to_csv` serializes rows with the :mod:`csv` module (proper
quoting, one stable header row) and :func:`export_history_csv` runs
query + serialization as ONE call, so the CSV export can never drift
from the JSON query's filtered range. No permission check lives here
(that is the admin router's single ``require_admin`` dependency).
"""
from __future__ import annotations
import csv
import datetime
import io
from dataclasses import dataclass
from .completion import _reentrant_connection_lock, list_missed_for_child
from .dao_children import ChildRecord, ChildrenDao
from .dao_instances import (
EVENT_UNCOMPLETED,
CompletionEventRecord,
CompletionEventsDao,
QuestInstanceRecord,
QuestInstancesDao,
)
from .dao_rules import QuestDefinitionsDao, _validate_date
from .db import NestQuestDatabase
#: The ``all`` filter: every completion event in the range.
FILTER_ALL = "all"
#: The ``reversals`` filter: uncompleted events only.
FILTER_REVERSALS = "reversals"
#: The ``missed`` filter: derived missed instances in the range.
FILTER_MISSED = "missed"
#: The filters the query accepts, in the Admin spec's chip order.
FILTERS = (FILTER_ALL, FILTER_REVERSALS, FILTER_MISSED)
#: The ``event_type`` a derived missed row reports. ``missed`` is a
#: DERIVED state, never a stored event_type (Feature 08).
EVENT_MISSED = "missed"
#: The actor a derived missed row reports (Admin spec §5 meta:
#: ``missed · nightly sweep``).
ACTOR_NIGHTLY_SWEEP = "nightly sweep"
#: The CSV column set, in header order — the Admin spec §5 event-row
#: fields. The header row is exactly this tuple, always, so a consumer
#: can key on column names.
CSV_COLUMNS = (
"occurred_at",
"event_type",
"child_name",
"child_id",
"quest_title",
"instance_id",
"window",
"due_date",
"due_time",
"actor",
"was_on_time",
)
@dataclass(frozen=True)
class HistoryRow:
"""One history row: a stored event or a derived missed instance.
The fields are :data:`CSV_COLUMNS` in order. For a derived missed
row ``occurred_at`` is ``None`` (no event exists) and ``was_on_time``
is ``None``; for a stored event ``event_type`` is
``'completed'``/``'uncompleted'`` and the fields come from the event
joined with its instance, child and definition.
"""
occurred_at: str | None
event_type: str
child_name: str
child_id: int
quest_title: str
instance_id: int
window: str
due_date: str
due_time: str | None
actor: str
was_on_time: bool | None
def _validate_filter(filter_name: str) -> str:
"""Return the filter, or raise ValueError naming the field."""
if filter_name not in FILTERS:
raise ValueError(
f"filter must be one of {', '.join(repr(f) for f in FILTERS)}, "
f"got {filter_name!r}"
)
return filter_name
def _validate_range(start_date: str, end_date: str) -> tuple[str, str]:
"""Validate the closed range and return it, else raise naming the field.
Both bounds must be strict ``YYYY-MM-DD`` dates (the shared
:func:`~.dao_rules._validate_date`, so non-padded or re-ordered
shapes are rejected and a non-string raises naming the field) and
``end`` must be on or after ``start``.
"""
_validate_date(start_date, "start")
_validate_date(end_date, "end")
if end_date < start_date:
raise ValueError(
f"end must be on or after start, got {end_date!r} < "
f"{start_date!r}"
)
return start_date, end_date
def _resolve_today(today: datetime.date | None) -> datetime.date:
"""Return the threaded HA-local date, or the host calendar date."""
if today is None:
return datetime.date.today()
return today
def _event_actor(
event: CompletionEventRecord, actor_child: ChildRecord | None
) -> str:
"""Return the spec-meta actor string for one stored event.
A user event reports the ``actor_user_id`` string (the spec's
``uncompleted · joshua (admin)`` shape); a panel event reports
``panel (Child)`` naming the tapped profile. The tapped child is
never hard-deleted, so the lookup below is a belt-and-braces
fallback to the stored id, never a silent rewrite of policy.
"""
if event.actor_source == "user":
return event.actor_user_id or ""
if actor_child is not None:
return f"panel ({actor_child.display_name})"
return f"panel ({event.actor_child_id})"
def _event_row(
event: CompletionEventRecord,
instance: QuestInstanceRecord,
child: ChildRecord,
quest_title: str,
actor_child: ChildRecord | None,
) -> HistoryRow:
"""Join one stored event with its instance, child and definition."""
return HistoryRow(
occurred_at=event.occurred_at,
event_type=event.event_type,
child_name=child.display_name,
child_id=event.child_id,
quest_title=quest_title,
instance_id=event.instance_id,
window=instance.window,
due_date=instance.due_date,
due_time=instance.due_time,
actor=_event_actor(event, actor_child),
was_on_time=event.was_on_time,
)
def _missed_row(
instance: QuestInstanceRecord,
child: ChildRecord,
quest_title: str,
) -> HistoryRow:
"""Build the derived row for one past-due open instance."""
return HistoryRow(
occurred_at=None,
event_type=EVENT_MISSED,
child_name=child.display_name,
child_id=instance.child_id,
quest_title=quest_title,
instance_id=instance.id,
window=instance.window,
due_date=instance.due_date,
due_time=instance.due_time,
actor=ACTOR_NIGHTLY_SWEEP,
was_on_time=None,
)
def _csv_values(row: HistoryRow) -> list[str]:
"""Return one CSV row's cell values in :data:`CSV_COLUMNS` order.
``None`` cells serialize as empty (a missed row has no
``occurred_at``); ``was_on_time`` serializes as ``true``/``false``
when the event carries the flag and empty when it does not.
"""
return [
row.occurred_at or "",
row.event_type,
row.child_name,
str(row.child_id),
row.quest_title,
str(row.instance_id),
row.window,
row.due_date,
row.due_time or "",
row.actor,
"" if row.was_on_time is None else str(row.was_on_time).lower(),
]
def rows_to_csv(rows: list[HistoryRow]) -> str:
"""Serialize history rows to CSV text with the documented header.
The FIRST row is always :data:`CSV_COLUMNS`, so the header is
stable and deterministic; the :mod:`csv` module handles quoting
(titles or names containing commas or quotes are quoted per RFC
4180) and CRLF line endings.
"""
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(CSV_COLUMNS)
for row in rows:
writer.writerow(_csv_values(row))
return buffer.getvalue()
async def query_history(
database: NestQuestDatabase,
filter_name: str,
start_date: str,
end_date: str,
*,
today: datetime.date | None = None,
) -> list[HistoryRow]:
"""Return the history rows for ``filter_name`` over the closed range.
Validates the filter and the strict ``YYYY-MM-DD`` range (raising
``ValueError`` naming ``filter`` / ``start`` / ``end``) BEFORE any
read, then builds the rows:
- ``all`` / ``reversals``: every child's events whose instances are
due in the range (:meth:`~.dao_instances.CompletionEventsDao.list_by_child_and_date_range`,
the Feature 13 due-date scope), ``reversals`` keeping only the
``uncompleted`` rows.
- ``missed``: :func:`~.completion.list_missed_for_child` per child —
the SAME derived-state rule the coordinator uses, never a new one,
and never a stored event.
Rows are ordered deterministically by ``due_date``, then instance,
then event id (the per-instance append order, so a completion and
its reversal keep their order). ``today`` pins the HA-local date
the missed derivation compares due dates against (the host calendar
date when omitted). The whole read runs under the task-reentrant
connection lock — :func:`list_missed_for_child` re-enters it — so
one answer is one snapshot.
"""
filter_name = _validate_filter(filter_name)
start_date, end_date = _validate_range(start_date, end_date)
today_date = _resolve_today(today)
keyed: list[tuple[tuple[str, int, int], HistoryRow]] = []
async with _reentrant_connection_lock(database):
children = await ChildrenDao(database).list_all()
children_by_id = {child.id: child for child in children}
instances = QuestInstancesDao(database)
definitions = QuestDefinitionsDao(database)
events = CompletionEventsDao(database)
titles: dict[int, str] = {}
if filter_name == FILTER_MISSED:
for child in children:
for instance in await list_missed_for_child(
database, child.id, start_date, end_date, today=today_date
):
if instance.definition_id not in titles:
definition = await definitions.get(
instance.definition_id
)
assert definition is not None, (
"a materialized instance's definition exists"
)
titles[instance.definition_id] = definition.title
keyed.append(
(
(instance.due_date, instance.id, 0),
_missed_row(
instance,
child,
titles[instance.definition_id],
),
)
)
keyed.sort(key=lambda pair: pair[0])
return [row for _, row in keyed]
for child in children:
for event in await events.list_by_child_and_date_range(
child.id, start_date, end_date
):
if filter_name == FILTER_REVERSALS and (
event.event_type != EVENT_UNCOMPLETED
):
continue
instance = await instances.get_by_id(event.instance_id)
assert instance is not None, (
"an event's instance is never deleted (append-only "
"history keeps its references)"
)
actor_child = (
children_by_id.get(event.actor_child_id)
if event.actor_child_id is not None
else None
)
if instance.definition_id not in titles:
definition = await definitions.get(instance.definition_id)
assert definition is not None, (
"a materialized instance's definition exists"
)
titles[instance.definition_id] = definition.title
keyed.append(
(
(instance.due_date, instance.id, event.id),
_event_row(
event,
instance,
child,
titles[instance.definition_id],
actor_child,
),
)
)
keyed.sort(key=lambda pair: pair[0])
return [row for _, row in keyed]
async def export_history_csv(
database: NestQuestDatabase,
filter_name: str,
start_date: str,
end_date: str,
*,
today: datetime.date | None = None,
) -> str:
"""Return the filtered history as CSV text, header row included.
ONE query + serialization call so the CSV export carries exactly
the JSON query's filtered range and order — the same
:func:`query_history` validation, rows and :func:`rows_to_csv`
serializer, never a second implementation.
"""
rows = await query_history(
database, filter_name, start_date, end_date, today=today
)
return rows_to_csv(rows)
__all__ = [
"ACTOR_NIGHTLY_SWEEP",
"CSV_COLUMNS",
"EVENT_MISSED",
"EVENT_UNCOMPLETED",
"FILTERS",
"FILTER_ALL",
"FILTER_MISSED",
"FILTER_REVERSALS",
"HistoryRow",
"export_history_csv",
"query_history",
"rows_to_csv",
]
+33 -186
View File
@@ -18,8 +18,7 @@ The walk is deterministic and idempotent:
:class:`~.presence.PresenceEngine` from that same coherent read.
- For each date in the range, each definition whose decoded rule
:func:`~.recurrence.occurs_on` on that date, each assignee who is
``is_present`` on that date (or every assignee, when the definition's
``skip_on_away`` is off) and each declared window, one instance is
``is_present`` on that date and each declared window, one instance is
written through :class:`~.dao_instances.QuestInstancesDao.upsert_if_valid`
with a ``generated_at`` stamp shared by the whole batch (one coherent
time per run).
@@ -64,14 +63,9 @@ the presence DAO (``dao_presence.py``) must stay free of business rules
from __future__ import annotations
import datetime
from typing import NamedTuple
from .const import DEFAULT_HORIZON_DAYS
from .dao_instances import (
CompletionEventsDao,
QuestInstancesDao,
_resolve_today,
)
from .dao_instances import QuestInstancesDao, _resolve_today
from .dao_rules import (
_validate_date,
load_materialization_input,
@@ -131,15 +125,8 @@ def _decode_rule(snapshot) -> ScheduleRule:
def _build_engine(
schedules_records,
overrides_records,
*,
extra_overrides: tuple[PresenceOverride, ...] = (),
) -> PresenceEngine:
"""Convert raw presence records into an immutable PresenceEngine.
``extra_overrides`` layers already-validated, NOT-yet-stored
overrides on top of the stored ones — the consequence preview's
proposed override (:func:`count_removed_by_override`).
"""
"""Convert raw presence records into an immutable PresenceEngine."""
schedules: dict[int, PresenceSchedule] = {}
for child_id, record in schedules_records.items():
schedules[child_id] = PresenceSchedule.decode(
@@ -157,75 +144,9 @@ def _build_engine(
)
for record in records
]
for override in extra_overrides:
overrides.setdefault(override.child_id, []).append(override)
return PresenceEngine(schedules, overrides)
class GeneratedTuple(NamedTuple):
"""One instance the walk WOULD write: its upsert key plus due time."""
definition_id: int
child_id: int
due_date: str
window: str
due_time: str | None
def _generation_preview(
snapshots,
engine: PresenceEngine,
start: datetime.date,
end: datetime.date,
*,
child_filter: set[int] | None = None,
) -> list[GeneratedTuple]:
"""Return the tuples the walk would write over ``[start, end]``.
The pure, read-only half of :func:`materialize`: for each date, each
definition whose decoded rule :func:`~.recurrence.occurs_on` it,
each assignee in ``child_filter`` (every assignee when None) who is
``is_present`` on it — or every assignee when the definition's
``skip_on_away`` is off — and each declared window, one tuple, in
the walk's write order. :func:`materialize` writes exactly these
tuples and :func:`count_removed_by_override` compares two of these
previews, so the write and the consequence preview can never
diverge. No database access.
"""
definitions = [
(snapshot.definition.id, _decode_rule(snapshot),
snapshot.assignees, snapshot.windows,
snapshot.definition.skip_on_away)
for snapshot in snapshots
]
generated: list[GeneratedTuple] = []
cursor = start
while cursor <= end:
iso = cursor.isoformat()
for (
definition_id, rule, assignees, windows, skip_on_away
) in definitions:
if not occurs_on(rule, cursor):
continue
for child in assignees:
if child_filter is not None and child.id not in child_filter:
continue
if skip_on_away and not engine.is_present(child.id, cursor):
continue
for window in windows:
generated.append(
GeneratedTuple(
definition_id,
child.id,
iso,
window.window,
window.due_time,
)
)
cursor += datetime.timedelta(days=1)
return generated
async def materialize(
database: NestQuestDatabase,
start_date: str,
@@ -285,6 +206,12 @@ async def materialize(
engine = _build_engine(schedules_records, overrides_records)
child_filter = None if child_ids is None else set(child_ids)
definitions = [
(snapshot.definition.id, _decode_rule(snapshot),
snapshot.assignees, snapshot.windows)
for snapshot in snapshots
]
generated_at = _now_stamp()
instances = QuestInstancesDao(database)
@@ -299,20 +226,30 @@ async def materialize(
start = max(start, today_date)
count = 0
for generated in _generation_preview(
snapshots, engine, start, end, child_filter=child_filter
):
written = await instances.upsert_if_valid(
generated.definition_id,
generated.child_id,
generated.due_date,
generated_at,
window=generated.window,
due_time=generated.due_time,
today=today,
)
if written is not None:
count += 1
cursor = start
while cursor <= end:
iso = cursor.isoformat()
for definition_id, rule, assignees, windows in definitions:
if not occurs_on(rule, cursor):
continue
for child in assignees:
if child_filter is not None and child.id not in child_filter:
continue
if not engine.is_present(child.id, cursor):
continue
for window in windows:
written = await instances.upsert_if_valid(
definition_id,
child.id,
iso,
generated_at,
window=window.window,
due_time=window.due_time,
today=today,
)
if written is not None:
count += 1
cursor += datetime.timedelta(days=1)
return count
@@ -438,93 +375,3 @@ async def regenerate_for_child(
return await materialize(
database, start.isoformat(), end_date, child_ids=[child_id], today=today
)
async def count_removed_by_override(
database: NestQuestDatabase,
override: PresenceOverride,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> int:
"""Count the upcoming instances saving ``override`` would remove.
Read-only: nothing is written. The window is the part of the
override's range inside the rolling horizon ``[today, today +
horizon_days]`` that :func:`regenerate_for_child` rebuilds after the
override is saved (outside the range the two presence answers agree,
so nothing there can change). The materialization input is read
ONCE (:func:`~.dao_rules.load_materialization_input`) and
:func:`_generation_preview` runs twice over it for the override's
child — with the stored presence, and with ``override`` layered on
top (:func:`_build_engine`'s ``extra_overrides``). The count is the
tuples the first preview generates and the second does not, MINUS
every (definition, date, window) whose stored instance already has a
completion event (D-005: regeneration never deletes those). A
definition with ``skip_on_away`` off generates regardless of
presence, so it contributes nothing; a present override only ever
ADDS tuples, so it counts 0.
An override overlapping a stored one (which the create path refuses)
resolves through the engine's latest-start-wins rule. ``today`` and
``horizon_days`` resolve exactly as :func:`regenerate_for_child`
resolves them; a malformed horizon raises ValueError.
"""
horizon = _resolve_horizon_days(horizon_days)
today_date = _resolve_today(today)
start = max(today_date, override.start_date)
end = min(
today_date + datetime.timedelta(days=horizon), override.end_date
)
if end < start:
return 0
start_date, end_date = start.isoformat(), end.isoformat()
child_id = override.child_id
snapshots, schedules_records, overrides_records = await (
load_materialization_input(database, start_date, end_date)
)
current = _generation_preview(
snapshots,
_build_engine(schedules_records, overrides_records),
start,
end,
child_filter={child_id},
)
proposed = set(
_generation_preview(
snapshots,
_build_engine(
schedules_records,
overrides_records,
extra_overrides=(override,),
),
start,
end,
child_filter={child_id},
)
)
removed = [generated for generated in current if generated not in proposed]
if not removed:
return 0
stored = await QuestInstancesDao(database).list_by_date_range(
child_id, start_date, end_date
)
touched = {
event.instance_id
for event in await CompletionEventsDao(
database
).list_by_child_and_date_range(child_id, start_date, end_date)
}
completed = {
(instance.definition_id, instance.due_date, instance.window)
for instance in stored
if instance.id in touched
}
return sum(
1
for generated in removed
if (generated.definition_id, generated.due_date, generated.window)
not in completed
)
+2 -29
View File
@@ -30,9 +30,8 @@ widened (definition_id, child_id, due_date, window) key, and migration
6 rebuilds ``completion_events`` with the D-008 ``actor_child_id``
column. Migration 7 adds the generic ``nestquest_meta_state``
key/value table for integration-owned bookkeeping (Feature 11's
missed-sweep watermark). Migration 8 adds the ``skip_on_away``
column to ``quest_definitions`` (Feature 22), defaulting to 1. Each
callable migration runs inside its own transaction
missed-sweep watermark). Each callable migration runs inside its own
transaction
together with its version stamp, so a crash mid-step rolls the
statements and the stamp back together.
@@ -401,28 +400,6 @@ async def _add_actor_child_id(
await database.execute("PRAGMA foreign_keys = ON")
async def _add_skip_on_away(
database: NestQuestDatabase, target_version: int
) -> None:
"""Add the ``skip_on_away`` column to ``quest_definitions``.
Strictly additive: ``ALTER TABLE ... ADD COLUMN`` with a NOT NULL
DEFAULT 1, so existing rows read as 1 (skip absent dates — the
behaviour before the flag existed). Fresh databases already carry
the column from the canonical DDL, so the ALTER is guarded and the
step only stamps there.
"""
async with database.transaction():
if not await _table_has_column(
database, "quest_definitions", "skip_on_away"
):
await database.execute(
"ALTER TABLE quest_definitions ADD COLUMN "
"skip_on_away INTEGER NOT NULL DEFAULT 1"
)
await stamp_schema_version(database, target_version)
#: The ordered migration list. Append-only: never edit an applied
#: entry, add the next one instead. (Migration 1's content was
#: rewritten pre-release per D-007 — no version 1 database shipped.)
@@ -446,10 +423,6 @@ MIGRATIONS: Sequence[MigrationStep] = [
# fresh databases (which already made it in migration 1) and
# pre-v7 databases. Strictly additive; no existing table changes.
list(SCHEMA_V7_META_STATE_DDL),
# Migration 8 (Feature 22): the skip_on_away flag on
# quest_definitions — an additive column defaulting to 1, so every
# pre-existing definition keeps today's skip-when-absent behaviour.
_add_skip_on_away,
]
@@ -1,238 +0,0 @@
"""Business layer for presence writes (Feature 09, API admin plane).
Sits between the write callers (the Feature 09 HA service handlers and
the API admin plane's presence routes) and the typed presence DAOs plus
the pure presence model: callers get the model validation, the upsert /
create / delete operations and the regeneration side effect here, and
never touch the DAO or SQL directly. This is the ONE implementation of
these business rules — every caller constructs the same
:class:`~.presence.PresenceSchedule` / :class:`~.presence.PresenceOverride`
models (whose constructors are total, so no re-validation happens in a
handler) and gets the same post-write
:func:`~.materialize.regenerate_for_child`, so a child's future quest
instances track its changed presence no matter which plane issued the
write. Like the model it is Home-Assistant-free: ``today`` is a plain
calendar date threaded through to the regeneration, defaulting to the
host clock there.
Upsert policy (Feature 05, unchanged): a child has AT MOST ONE presence
schedule — setting again REPLACES the existing row rather than
duplicating it. Overrides are deletable by design (children are not —
the children layer owns that guardrail). Deleting a nonexistent
override raises ValueError prefixed ``override_id:`` so a caller can
distinguish "unknown id" from a rejected argument the same way the
quest-definitions layer prefixes ``definition_id:``.
The admin plane's presence READS go through here too
(:func:`get_presence_schedule`, :func:`list_presence_overrides`), so a
reader never touches the DAO directly either.
"""
from __future__ import annotations
import datetime
from .dao_children import ChildrenDao
from .dao_presence import (
PresenceOverrideRecord,
PresenceOverridesDao,
PresenceSchedulesDao,
)
from .db import NestQuestDatabase
from .materialize import count_removed_by_override, regenerate_for_child
from .presence import PresenceOverride, PresenceSchedule
def _validate_override_id(value: object) -> int:
"""Reject non-int override ids (bools would silently address id 1)."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"override_id must be an integer, got {value!r}")
return value
async def set_presence_schedule(
database: NestQuestDatabase,
child_id: int,
cycle_length_weeks: int,
anchor_date: object,
pattern: dict[int, object],
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> PresenceSchedule:
"""Set (upsert) a child's repeating presence schedule.
The :class:`~.presence.PresenceSchedule` model validates at
construction — cycle length 1..4, a strict calendar anchor date,
and a pattern covering week indices 0..n-1 with weekday sets — so
no invalid schedule reaches the DAO. The encoded pattern is
UPSERTed by child (a second set REPLACES the child's existing
schedule; the schema's UNIQUE(child_id) makes duplication
unrepresentable), then :func:`~.materialize.regenerate_for_child`
re-materializes the child's future instances against the new
presence. ``today``/``horizon_days`` thread through to that
regeneration; raising ValueError names what to fix (an unknown
child reads ``child N does not exist``).
Returns the STORED schedule decoded back through the model — the
round-trip proves the encoded pattern the database now holds is
exactly the one the caller asked for.
"""
schedule = PresenceSchedule(
child_id, cycle_length_weeks, anchor_date, pattern
)
record = await PresenceSchedulesDao(database).upsert_by_child(
schedule.child_id,
schedule.cycle_length_weeks,
schedule.anchor_date.isoformat(),
schedule.encode(),
)
await regenerate_for_child(
database, schedule.child_id, today=today, horizon_days=horizon_days
)
return PresenceSchedule.decode(
record.child_id, record.anchor_date, record.pattern
)
async def create_presence_override(
database: NestQuestDatabase,
child_id: int,
start_date: object,
end_date: object,
is_present: bool,
note: str | None = None,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> PresenceOverrideRecord:
"""Create one date-range presence override and return it as stored.
The :class:`~.presence.PresenceOverride` model validates at
construction — strict calendar dates, ``end_date`` on or after
``start_date``, a real ``is_present`` bool, a string-or-None note —
then the DAO insert applies the same-child no-overlap policy (a
conflicting range raises ValueError naming the existing override)
and :func:`~.materialize.regenerate_for_child` re-materializes the
child's future instances against the override's presence answer.
An unknown child reads ``child N does not exist``.
Returns the stored record WITH its ``id`` — the handle a later
:func:`delete_presence_override` takes.
"""
override = PresenceOverride(
child_id, start_date, end_date, is_present, note=note
)
record = await PresenceOverridesDao(database).create(
override.child_id,
override.start_date.isoformat(),
override.end_date.isoformat(),
override.is_present,
note=override.note,
)
await regenerate_for_child(
database, override.child_id, today=today, horizon_days=horizon_days
)
return record
async def preview_presence_override_consequence(
database: NestQuestDatabase,
child_id: int,
start_date: object,
end_date: object,
is_present: bool,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> int:
"""Return how many upcoming instances saving this override removes.
The admin override editor's live consequence warning, computed
BEFORE anything is saved: the arguments build the SAME
:class:`~.presence.PresenceOverride` :func:`create_presence_override`
builds (so a malformed or inverted range, or a non-bool
``is_present``, raises the same ValueError), then
:func:`~.materialize.count_removed_by_override` compares what the
materialization walk generates for the child with and without it.
Nothing is written. An unknown child reads ``child N does not
exist``; an INACTIVE child has no instances to remove (the walk's
write path never materializes one), so it counts 0.
``today``/``horizon_days`` resolve as the post-save regeneration
resolves them.
"""
override = PresenceOverride(child_id, start_date, end_date, is_present)
child = await ChildrenDao(database).get(override.child_id)
if child is None:
raise ValueError(f"child {override.child_id} does not exist")
if not child.is_active:
return 0
return await count_removed_by_override(
database, override, today=today, horizon_days=horizon_days
)
async def get_presence_schedule(
database: NestQuestDatabase, child_id: int
) -> PresenceSchedule | None:
"""Return the child's presence schedule decoded, or None when unset.
None means the child exists and has no schedule row — present
every day (Feature 05). An unknown child raises ValueError
(``child N does not exist``) rather than reading as present.
"""
record = await PresenceSchedulesDao(database).get_by_child(child_id)
if record is None:
return None
return PresenceSchedule.decode(
record.child_id, record.anchor_date, record.pattern
)
async def list_presence_overrides(
database: NestQuestDatabase,
*,
child_id: int | None = None,
start: str | None = None,
end: str | None = None,
) -> list[PresenceOverrideRecord]:
"""Return the household's presence overrides, optionally filtered.
``child_id`` narrows to one child (unknown: ``child N does not
exist``); ``start``/``end`` keep the overrides overlapping that
closed range (strict YYYY-MM-DD, ``end >= start``). Ordered by
start date, then child id, then id.
"""
return await PresenceOverridesDao(database).list_filtered(
child_id=child_id, start=start, end=end
)
async def delete_presence_override(
database: NestQuestDatabase,
override_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> PresenceOverrideRecord:
"""Delete one presence override by id and return it as it was stored.
The override is looked up FIRST and an unknown id raises ValueError
prefixed ``override_id:`` (naming the id) BEFORE any delete, so a
mistyped id can never be silently swallowed by a no-op. The
deletion is followed by :func:`~.materialize.regenerate_for_child`
for the override's own child — the id decides the child, never a
caller-supplied one. ``today``/``horizon_days`` thread through to
that regeneration.
"""
_validate_override_id(override_id)
dao = PresenceOverridesDao(database)
override = await dao.get(override_id)
if override is None:
raise ValueError(
f"override_id: presence override {override_id} does not exist"
)
await dao.delete(override_id)
await regenerate_for_child(
database, override.child_id, today=today, horizon_days=horizon_days
)
return override
@@ -4,9 +4,8 @@ Sits between the Feature 09 service gate and the typed
:class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the
all-or-nothing create/edit here, and never touch the DAO SQL directly.
:func:`create_quest_definition` is the create path,
:func:`edit_quest_definition` the edit path (which may also replace the
whole assignee set), :func:`assign_child` and
:func:`unassign_child` the single-child assignment paths, and
:func:`edit_quest_definition` the edit path, :func:`assign_child` and
:func:`unassign_child` the assignment paths, and
:func:`set_quest_definition_active` the deactivate/reactivate path.
There is no permission check here (that is Feature 09).
@@ -192,17 +191,6 @@ def _validate_definition_id(value: object) -> int:
return value
def _validate_skip_on_away(value: object) -> bool:
"""Reject a non-bool ``skip_on_away`` before any write.
The DAO's ``int()`` would otherwise silently coerce strings and
numerics ("0", 1) into a state the caller never asked for.
"""
if not isinstance(value, bool):
raise ValueError(f"skip_on_away must be a real bool, got {value!r}")
return value
def _validate_child_id(value: object) -> int:
"""Reject non-int child ids (bools included) before any lookup.
@@ -224,7 +212,6 @@ async def create_quest_definition(
*,
description: str | None = None,
icon: str | None = None,
skip_on_away: bool = True,
) -> CreatedQuestDefinition:
"""Create a quest definition (rule + assignees + windows) atomically.
@@ -244,7 +231,6 @@ async def create_quest_definition(
window_specs = _normalize_windows(windows)
description_value = _validate_text(description, "description")
icon_value = _validate_text(icon, "icon")
skip_on_away_value = _validate_skip_on_away(skip_on_away)
storage = schedule_rule_to_storage(rule)
dao = QuestDefinitionsDao(database)
@@ -257,7 +243,6 @@ async def create_quest_definition(
window_specs,
description=description_value,
icon=icon_value,
skip_on_away=skip_on_away_value,
)
except ValueError as error:
# The DAO's assignee check names only the child; re-raise so the
@@ -286,12 +271,10 @@ async def edit_quest_definition(
icon: str | None | object = _UNSET,
rule: ScheduleRule | object = _UNSET,
windows: list[str | tuple[str, str | None]] | object = _UNSET,
skip_on_away: bool | None = None,
assignee_child_ids: list[int] | object = _UNSET,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> CreatedQuestDefinition:
"""Edit a quest definition's metadata, rule, windows and assignees atomically.
"""Edit a quest definition's metadata, rule and windows atomically.
Arguments default to the sentinel ``_UNSET`` meaning "leave this
field alone". A provided ``title`` is validated like create's
@@ -302,16 +285,9 @@ async def edit_quest_definition(
``schedule_rules`` row is updated, never orphaned or duplicated. A
provided ``windows`` list REPLACES the whole window set (each entry
a ``const.QUEST_WINDOWS`` name, optionally with a strict HH:MM due
time), matching create's non-empty contract. A provided
``skip_on_away`` (a real bool) replaces the stored flag; ``None``
leaves it unchanged. A provided ``assignee_child_ids`` REPLACES the
whole assignee set under create's contract (a non-empty list of
distinct plain ints, each an existing ACTIVE child — the DAO checks
that inside the edit's transaction): newly listed children are
assigned and unlisted ones unassigned. Raises ValueError when the
definition does not exist or any argument is rejected; a rejection
writes nothing, so the stored assignees (and every other field)
stay unchanged.
time), matching create's non-empty contract. Assignment is NOT
settable here. Raises ValueError when the definition does not
exist or any argument is rejected.
Returns the updated definition together with its decoded rule,
assignees and windows — all read back inside the DAO's transaction,
@@ -355,14 +331,6 @@ async def edit_quest_definition(
if windows is not _UNSET:
window_specs = _normalize_windows(windows)
assignee_ids: list[int] | object = _UNSET
if assignee_child_ids is not _UNSET:
assignee_ids = _validate_assignee_ids(assignee_child_ids)
skip_on_away_value: bool | object = _UNSET
if skip_on_away is not None:
skip_on_away_value = _validate_skip_on_away(skip_on_away)
dao = QuestDefinitionsDao(database)
try:
snapshot = await dao.edit_definition(
@@ -372,18 +340,14 @@ async def edit_quest_definition(
icon=icon_value,
rule=rule_storage,
windows=window_specs,
skip_on_away=skip_on_away_value,
assignee_child_ids=assignee_ids,
)
except ValueError as error:
# The DAO's missing-definition and assignee errors name only the
# id; re-raise so the public contract names the field the caller
# passed (matching create/assign/unassign/deactivate).
# The DAO's missing-definition error names only the id; re-raise
# so the public contract names the field the caller passed (matching
# assign/unassign/deactivate).
message = str(error)
if message.startswith("quest definition "):
raise ValueError(f"definition_id: {message}") from error
if message.startswith("child "):
raise ValueError(f"assignee_child_ids: {message}") from error
raise
decoded_rule = schedule_rule_from_storage(
@@ -594,21 +558,6 @@ async def list_active_definitions(
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
async def list_all_definitions(
database: NestQuestDatabase,
) -> list[CreatedQuestDefinition]:
"""Return EVERY definition, active AND inactive (rich view).
Mirrors :func:`list_active_definitions` without the activity
filter: ordered by rising definition id, each entry the same
:class:`CreatedQuestDefinition` snapshot, read by the DAO inside one
locked transaction.
"""
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_all()
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
async def list_definitions_for_child(
database: NestQuestDatabase,
child_id: int,
+1 -2
View File
@@ -257,8 +257,7 @@ QUEST_DEFINITIONS_TABLE_SQL = """(
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
due_time TEXT,
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
created_at TEXT NOT NULL,
skip_on_away INTEGER NOT NULL DEFAULT 1 CHECK (skip_on_away IN (0, 1))
created_at TEXT NOT NULL
)"""
SCHEMA_V1_QUEST_DEFINITIONS_DDL: list[str] = [
@@ -0,0 +1,46 @@
"""Explicit admin-only versus open service registry (Feature 09).
One mapping, one file: every registered ``nestquest`` service name is
either ``open`` or ``admin_only``. The permission decorator (later
Feature 09 tasks) reads this table; it does not take per-handler
policy arguments.
"""
from __future__ import annotations
from .const import (
DOMAIN_SERVICES,
SERVICE_COMPLETE_QUEST,
SERVICE_CREATE_PRESENCE_OVERRIDE,
SERVICE_CREATE_QUEST_DEFINITION,
SERVICE_DELETE_PRESENCE_OVERRIDE,
SERVICE_EXPORT_HISTORY_CSV,
SERVICE_MANAGE_CHILD,
SERVICE_REGENERATE,
SERVICE_SET_PRESENCE_PATTERN,
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
SERVICE_UNCOMPLETE_QUEST,
SERVICE_UPDATE_QUEST_DEFINITION,
)
OPEN = "open"
ADMIN_ONLY = "admin_only"
SERVICE_POLICY: dict[str, str] = {
SERVICE_COMPLETE_QUEST: OPEN,
SERVICE_UNCOMPLETE_QUEST: ADMIN_ONLY,
SERVICE_CREATE_QUEST_DEFINITION: ADMIN_ONLY,
SERVICE_UPDATE_QUEST_DEFINITION: ADMIN_ONLY,
SERVICE_SET_QUEST_DEFINITION_ACTIVE: ADMIN_ONLY,
SERVICE_SET_PRESENCE_PATTERN: ADMIN_ONLY,
SERVICE_CREATE_PRESENCE_OVERRIDE: ADMIN_ONLY,
SERVICE_DELETE_PRESENCE_OVERRIDE: ADMIN_ONLY,
SERVICE_EXPORT_HISTORY_CSV: ADMIN_ONLY,
SERVICE_MANAGE_CHILD: ADMIN_ONLY,
SERVICE_REGENERATE: ADMIN_ONLY,
}
if frozenset(SERVICE_POLICY) != frozenset(DOMAIN_SERVICES):
raise RuntimeError(
"SERVICE_POLICY must name exactly the registered nestquest services, "
f"got {sorted(SERVICE_POLICY)} vs {list(DOMAIN_SERVICES)}"
)
@@ -1,210 +0,0 @@
"""Durable settings store for the NestQuest API service.
The API service has NO Home Assistant config entry, so it cannot build
:class:`~.settings.NestQuestSettings` from ``entry.options`` the way the
integration does. This module gives the API its own durable settings
source: a JSON document kept in the EXISTING ``nestquest_meta_state``
key/value table (:class:`~.dao_meta.MetaStateDao`) under the well-known
key :data:`SETTINGS_META_KEY` — no schema change, no new table —
validated through the SAME field table the settings object is built
from (:data:`~.settings._FIELDS`, the table driving
``from_options``/``from_options_resilient``). No validation rule is
re-implemented here.
OWNERSHIP: the API now owns settings in the database. The
integration's Home-Assistant config-entry options remain a temporary,
SEPARATE source until Feature 18 converts the integration into a thin
API client; the two are deliberately NOT unified here, and neither
side reads the other's values.
STORED SERIALIZATION — the document at ``SETTINGS_META_KEY`` is a JSON
object holding exactly the TEN settings fields, keyed by field name
(which equals the ``CONF_*`` option key for every field), complete
after the first update:
- ``horizon_days`` — int >= 1,
- ``day_rollover_time``, ``morning_summary_time``,
``afternoon_reminder_time``, ``end_of_day_report_time`` — strict
24-hour ``HH:MM`` strings,
- ``notify_target`` — a string (a padded value is stripped by the
core's normalizer),
- ``morning_summary_enabled``, ``afternoon_reminder_enabled``,
``end_of_day_report_enabled``, ``celebration_enabled`` — real
booleans.
Only values whose core validators ACCEPTED are ever written, so a
stored document always round-trips through the core's option builders.
An absent key (a fresh database) resolves to the all-defaults
settings, and a corrupt document degrades to defaults or per-field
defaults rather than crashing the service (see :func:`load_settings`).
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import Mapping
from typing import Any
from .const import LOGGER
from .dao_meta import MetaStateDao
from .db import NestQuestDatabase
from .settings import NestQuestSettings, _FIELDS
#: The well-known ``nestquest_meta_state`` key holding the settings
#: document. One key, one JSON object — the same table the missed
#: sweep's watermark already uses, so no migration is needed.
SETTINGS_META_KEY = "settings"
#: The per-field resolution table keyed by field name: each row is the
#: ``(default, validator)`` pair the settings module's ``_FIELDS`` table
#: carries for that field. ``update_settings`` resolves each PROVIDED
#: field through its row's validator — the SAME core validators
#: ``from_options`` applies — and never re-implements one here.
#: (Field names equal the ``CONF_*`` option keys, so one dict serves
#: both the API's field names and the stored document's keys.)
_RESOLUTIONS: dict[str, tuple[Any, Any]] = {
key: (default, validator) for _field, key, default, validator in _FIELDS
}
#: The known field names, in the settings module's field order — the
#: allowed keys of an update body and the stored document's key set.
_FIELD_NAMES: tuple[str, ...] = tuple(
key for _field, key, _default, _validator in _FIELDS
)
#: One asyncio.Lock per (connection wrapper, running loop), the missed
#: sweep's pattern: the whole stored-document read → merge → write span
#: is one critical section, so two concurrent updates queue instead of
#: one losing the other's change — the loser re-reads the document
#: INSIDE the lock and merges onto the winner's settings.
_SETTINGS_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
def _settings_lock(database: NestQuestDatabase) -> asyncio.Lock:
"""Return the settings lock bound to ``database``'s running loop."""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _SETTINGS_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_SETTINGS_LOCKS[key] = lock
return lock
async def _load_document(database: NestQuestDatabase) -> dict[str, Any]:
"""Read and decode the stored settings document (empty when unusable).
An absent key — a fresh database that never updated settings — and
an unparseable value (invalid JSON, or JSON that is not an object)
return an empty mapping, which the option builders resolve to the
all-defaults settings; the corrupt cases log a warning first. A
parseable object is returned as stored (its per-field validity is
the builder's concern, not this reader's).
"""
raw = await MetaStateDao(database).get(SETTINGS_META_KEY)
if raw is None:
return {}
try:
document = json.loads(raw)
except ValueError:
LOGGER.warning(
"NestQuest stored settings (meta_state key %r) are not valid "
"JSON; falling back to the default settings",
SETTINGS_META_KEY,
)
return {}
if not isinstance(document, dict):
LOGGER.warning(
"NestQuest stored settings (meta_state key %r) are not a JSON "
"object; falling back to the default settings",
SETTINGS_META_KEY,
)
return {}
return document
def _named(field: str, error: ValueError) -> str:
"""Return a field-named message for a rejected validator value.
The horizon, time and notify-target validators already name their
field in the message; the boolean validator's message is generic
(``expected a boolean, got ...``), so it gets the field prefixed.
Every rejected update's error therefore names its field either way.
"""
message = str(error)
if message.startswith(f"{field} "):
return message
return f"{field}: {message}"
async def load_settings(database: NestQuestDatabase) -> NestQuestSettings:
"""Return the current effective settings stored for ``database``.
Reads :data:`SETTINGS_META_KEY` and resolves the stored document
through the core settings validators, with each absent field
falling back to its default. An absent key and an unparseable
document fall back to the all-defaults settings (via
:func:`_load_document`), and a parseable document with a single
invalid FIELD falls back PER FIELD — through
:meth:`NestQuestSettings.from_options_resilient`, the core's own
resilient builder — so a corrupt sibling never resets the stored
values around it. This never raises: the service must keep
serving reads even over a corrupt store.
"""
document = await _load_document(database)
return NestQuestSettings.from_options_resilient(document)
async def update_settings(
database: NestQuestDatabase, changes: Mapping[str, Any]
) -> NestQuestSettings:
"""Validate ``changes`` and persist them as the effective settings.
``changes`` maps settings field names to new values. EVERY name is
checked against the known field table BEFORE anything is validated
or written: an unknown name raises a ``ValueError`` naming it (and
the known names), leaving the stored settings untouched. Each
PROVIDED field is then resolved from_options-style — its
:data:`~.settings._FIELDS` validator applied, with a JSON ``null``
mapping to the field's default (the settings module's uniform
None-as-absent rule) — and an invalid value raises a ``ValueError``
naming the field, also BEFORE anything is written, so a rejected
update can never leave a half-applied document behind.
The validated changes are merged onto the current effective
settings and the merged (complete, ten-field) document is persisted
in one atomic upsert; the updated settings are returned. The whole
read → merge → write span runs inside the per-database settings
lock, so concurrent updates queue instead of interleaving.
"""
if not isinstance(changes, Mapping):
raise ValueError(
"settings changes must be a JSON object mapping field names "
"to values"
)
unknown = sorted(
str(name) for name in changes if name not in _RESOLUTIONS
)
if unknown:
listed = ", ".join(repr(name) for name in unknown)
known = ", ".join(_FIELD_NAMES)
raise ValueError(
f"unknown settings field(s): {listed}; known fields: {known}"
)
async with _settings_lock(database):
current = await load_settings(database)
document: dict[str, Any] = {
field: getattr(current, field) for field in _FIELD_NAMES
}
for name, raw_value in changes.items():
default, validator = _RESOLUTIONS[name]
try:
document[name] = validator(
default if raw_value is None else raw_value
)
except ValueError as error:
raise ValueError(_named(name, error)) from error
await MetaStateDao(database).set(
SETTINGS_META_KEY, json.dumps(document, sort_keys=True)
)
return NestQuestSettings(**document)
-170
View File
@@ -1,170 +0,0 @@
"""The nightly missed-quest sweep, Home-Assistant-free (Feature 11).
This is the HA-free heart of the missed sweep, shared by the two
planes: the Home Assistant integration's :mod:`~..sweep` shim (which
fires the returned events on ``hass.bus``) and the API service (which
publishes them on the SSE stream and can also trigger the run on
demand). It owns the ONE sweep policy:
At the configured day-rollover time (or on an explicit trigger) this
job finds every instance whose ``due_date`` is before the
household-local date, that has NO completion event, and that falls
after the persisted sweep watermark — and RETURNS one
``nestquest_quest_missed`` event per instance with the documented
payload. Like every core module, it never touches the Home Assistant
event bus and never mutates an instance: ``missed`` is derived state,
never an event_type (Feature 08's contract), so the sweep is read-only
over the domain tables.
Idempotency is the watermark: after a run, ``nestquest_meta_state``
records the household-local date the sweep ran, and the next run only
sweeps instances due in ``[watermark, today)``. A second run the same
night therefore fires nothing new, and a run after days of downtime
sweeps exactly the accumulated window once — no per-instance journal,
no re-announcing old misses. An instance swept as missed that is
later completed simply gains its completion event; history keeps both
rows (append-only), and the sweep's later runs skip it via the
no-completion-event rule.
The whole watermark read → query → build span runs inside the
per-database sweep lock (one lock per connection wrapper and running
loop, the settings store's pattern): an overlapping startup sweep and
a rollover sweep of the SAME process queue instead of interleaving.
The announce decision itself is the DATABASE-level claim
(:meth:`MetaStateDao.claim`), so two PROCESSES sharing one SQLite file
(the integration's rollover sweep and the API service's scheduler)
cannot both announce the same missed batch either: both may read the
old watermark and build the events, but the single conditional upsert
lets exactly one of them win the claim — the loser returns [] and
announces nothing.
The caller supplies ``today`` as a plain :class:`datetime.date`: the
household-local calendar date, read from ``hass.config.time_zone`` by
the integration or from the API host's local clock by the API. This
module never reads a timezone database or config of its own.
"""
from __future__ import annotations
import asyncio
import datetime
import logging
from .const import EVENT_QUEST_MISSED
from .dao_meta import MetaStateDao
from .dao_instances import QuestInstancesDao
from .db import NestQuestDatabase
# Intentional intra-package reuse of the shared payload builder: the
# missed event's payload is the documented §3 instance payload, and
# building it through the ONE builder keeps this sweep's payloads from
# drifting from the completion/uncompleted builders' shape.
from .events import _instance_payload
LOGGER = logging.getLogger(__name__)
#: meta_state key holding the household-local date the sweep last ran.
SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date"
#: One asyncio.Lock per (connection wrapper, running loop), mirroring
#: the settings store's pattern: the watermark read → query → build
#: span is one critical section, so an overlapping startup sweep and
#: rollover sweep of the SAME process queue instead of interleaving.
#: Same-process callers are fully serialised by it; the cross-process
#: case is closed by the database-level claim, not by this lock.
_SWEEP_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
def _sweep_lock(database: NestQuestDatabase) -> asyncio.Lock:
"""Return the sweep lock bound to ``database``'s running loop."""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _SWEEP_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_SWEEP_LOCKS[key] = lock
return lock
#: Strict UTC ISO-8601 timestamp (the DAO layers' policy): one shape,
#: explicit offset, second precision.
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
async def run_missed_sweep(
database: NestQuestDatabase,
*,
today: datetime.date,
) -> list[tuple[str, dict]]:
"""Build one missed event per unswept past-due open instance.
``today`` is the household-local calendar date the sweep runs for
(the caller owns the clock: the integration reads it from
``hass.config.time_zone``, the API from its host's local time).
Returns the ``(event_type, payload)`` tuples to announce, EMPTY
when the watermark already covers ``today`` (a same-night rerun) —
the caller decides how to publish them and announces nothing on
its own.
The watermark read → query → build span runs inside the
per-database sweep lock, so overlapping callers of ONE process
queue instead of interleaving. The announce decision is the
DATABASE-level claim (:meth:`MetaStateDao.claim` — a single
conditional upsert on the watermark, atomic across processes):
every caller builds the events optimistically, then only the
caller whose claim actually advanced the watermark returns them;
a loser (another process already announced for ``today``) returns
[] and announces nothing twice. Every event in one run shares one
``occurred_at`` stamp (one run, one announcement moment), and the
payload comes from the shared
:func:`~.events._instance_payload` builder so the shape can never
drift from the documented §3 contract.
Failure ordering: the watermark is advanced ONLY by the claim,
AFTER the events are fully built — a failure before the claim
leaves the watermark unadvanced, so a later run re-sweeps the same
window and nothing is lost.
"""
today_iso = today.isoformat()
dao = MetaStateDao(database)
async with _sweep_lock(database):
watermark = await dao.get(SWEEP_WATERMARK_KEY)
if watermark == today_iso:
LOGGER.debug(
"NestQuest missed sweep already ran for %s", today_iso
)
return []
instances_dao = QuestInstancesDao(database)
rows = await instances_dao.list_missed_unswept(today_iso, watermark)
now_stamp = datetime.datetime.now(datetime.timezone.utc).strftime(
_UTC_TIMESTAMP_FORMAT
)
events: list[tuple[str, dict]] = []
for instance in rows:
events.append(
(
EVENT_QUEST_MISSED,
await _instance_payload(
database, instance.id, now_stamp
),
)
)
# The database-level claim gates the announcement: only the
# caller that actually advanced the watermark may publish the
# built events, so two processes racing one SQLite file cannot
# both announce the same missed batch. A same-value loser's
# claim matches zero rows and this run announces nothing.
claimed = await dao.claim(SWEEP_WATERMARK_KEY, today_iso)
if not claimed:
LOGGER.info(
"NestQuest missed sweep for %s lost the watermark claim; "
"announcing nothing",
today_iso,
)
return []
LOGGER.info(
"NestQuest missed sweep for %s built %d event(s)",
today_iso,
len(events),
)
return events
@@ -0,0 +1,12 @@
"""Re-export of :mod:`custom_components.nestquest.core.dao_children` for the integration package.
The children/admin DAO lives in
:mod:`custom_components.nestquest.core.dao_children` (extracted in
task d0b691d8); this module re-exports it so existing
``from .dao_children import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.dao_children import * # noqa: F401,F403
from .core.dao_children import _CONNECTION_LOCKS, _connection_lock # noqa: F401
@@ -0,0 +1,12 @@
"""Re-export of :mod:`custom_components.nestquest.core.dao_instances` for the integration package.
The instances/completion-events DAO lives in
:mod:`custom_components.nestquest.core.dao_instances`
(extracted in task d0b691d8); this module re-exports it so existing
``from .dao_instances import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.dao_instances import * # noqa: F401,F403
from .core.dao_instances import _resolve_today # noqa: F401
+11
View File
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.dao_meta` for the integration package.
The meta-state DAO lives in
:mod:`custom_components.nestquest.core.dao_meta` (extracted in task
d0b691d8); this module re-exports it so existing
``from .dao_meta import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.dao_meta import * # noqa: F401,F403
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.dao_presence` for the integration package.
The presence DAOs live in
:mod:`custom_components.nestquest.core.dao_presence` (extracted in
task d0b691d8); this module re-exports them so existing
``from .dao_presence import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.dao_presence import * # noqa: F401,F403
+17
View File
@@ -0,0 +1,17 @@
"""Re-export of :mod:`custom_components.nestquest.core.dao_rules` for the integration package.
The schedule-rules/quest-definitions DAO lives in
:mod:`custom_components.nestquest.core.dao_rules` (extracted in task
d0b691d8); this module re-exports it so existing
``from .dao_rules import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.dao_rules import * # noqa: F401,F403
from .core.dao_rules import ( # noqa: F401
_UNSET,
_validate_date,
_validate_time,
_validate_window,
)
-215
View File
@@ -1,215 +0,0 @@
"""Zero-config panel dashboard registration for NestQuest (Feature 20).
On setup the integration registers ONE storage-mode Lovelace dashboard
whose CONFIG uses the bundle's ``custom:nestquest-party`` strategy (see
``frontend/src/strategy.ts``): the views themselves are GENERATED at
render time from the household roster, so the stored dashboard config
carries nothing but the strategy reference.
Registration mirrors the REAL two-step sequence the lovelace
integration itself uses when it creates the onboarding map dashboard
(``homeassistant/components/lovelace/__init__.py``,
``_create_map_dashboard``):
1. create the dashboard METADATA through the ``DashboardsCollection``
(``hass.data["lovelace"]["dashboards_collection"]``) — url_path,
title, icon, sidebar visibility. The strategy reference is NOT part
of the create payload: the collection's create schema allows only
the metadata fields, so a ``strategy`` key there fails the whole
create (this was the silent no-op the first implementation shipped).
2. save the dashboard CONFIG — ``{"strategy": ...}`` — through the
per-url_path CONTENT manager that the collection's change listener
registers under ``hass.data["lovelace"]["dashboards"][url_path]``
(a ``LovelaceStorage``), exactly the second step HA performs.
Registration is IDEMPOTENT — a dashboard already under the stable
``url_path`` (ours or a user's) skips BOTH steps and its stored config
is never overwritten — and the Lovelace surfaces being unavailable
(YAML mode, lovelace not loaded) is a logged warning, never a setup
failure.
"""
from __future__ import annotations
import asyncio
from homeassistant.core import HomeAssistant
from .const import (
DASHBOARD_ICON,
DASHBOARD_STRATEGY_TYPE,
DASHBOARD_TITLE,
DASHBOARD_URL_PATH,
LOGGER,
)
_LOGGER = LOGGER
# Single-flight guard for the check-then-create below: HA can set up
# several config entries CONCURRENTLY, and two overlapping setups would
# both pass the exists-check and race on the url_path create. The lock
# is per hass instance (kept as an attribute on the hass object, since
# hass.data is the integrations' shared registry the unload path
# empties) rather than one module-level asyncio.Lock — a bare
# module-level lock binds to whichever event loop first awaits it and
# raises RuntimeError on any other loop, while the race it must prevent
# only exists between setups running on the SAME hass anyway.
_REGISTRATION_LOCK_ATTR = "_nestquest_dashboard_registration_lock"
def _registration_lock(hass: HomeAssistant) -> asyncio.Lock:
"""Return the per-hass registration lock, creating it on first use."""
lock = getattr(hass, _REGISTRATION_LOCK_ATTR, None)
if lock is None:
lock = asyncio.Lock()
setattr(hass, _REGISTRATION_LOCK_ATTR, lock)
return lock
def _lovelace_data(hass: HomeAssistant) -> dict | None:
"""Return ``hass.data["lovelace"]`` when it is the real dict shape.
The lovelace integration stores a plain DICT of surfaces (``mode``,
``dashboards``, ``dashboards_collection``, ``resources``,
``yaml_dashboards``) under the ``lovelace`` key; anything else
(missing key, unexpected object) means the surfaces below cannot be
reached and registration must warn and continue.
"""
lovelace_data = hass.data.get("lovelace")
if not isinstance(lovelace_data, dict):
return None
return lovelace_data
def _dashboards_collection(hass: HomeAssistant):
"""Return the Lovelace dashboards METADATA collection, or None.
The collection lives at ``hass.data["lovelace"]
["dashboards_collection"]`` — NOT under ``["dashboards"]``, which
maps url_path to the per-dashboard CONTENT managers
(``LovelaceStorage``/``LovelaceYAML``). A missing key (lovelace
not loaded) and a missing/None entry (YAML mode never sets the
collection) both mean "cannot create storage dashboards here" —
callers log and continue. The presence of the surface is verified
by capability (``async_create_item`` callable), not by type, so no
lovelace import is needed.
"""
lovelace_data = _lovelace_data(hass)
if lovelace_data is None:
return None
collection = lovelace_data.get("dashboards_collection")
if collection is None or not callable(
getattr(collection, "async_create_item", None)
):
return None
return collection
def _dashboard_store(hass: HomeAssistant, url_path: str):
"""Return a url_path's dashboard CONTENT manager, or None.
After a metadata create, the collection's change listener registers
a ``LovelaceStorage`` for the new dashboard under
``hass.data["lovelace"]["dashboards"][url_path]``; the dashboard
CONFIG (the strategy reference) is saved through THAT manager —
the second step of HA's own dashboard creation.
"""
lovelace_data = _lovelace_data(hass)
if lovelace_data is None:
return None
dashboards = lovelace_data.get("dashboards")
if not isinstance(dashboards, dict):
return None
store = dashboards.get(url_path)
if store is None or not callable(getattr(store, "async_save", None)):
return None
return store
def _item_url_path(item) -> str | None:
"""Read a dashboard item's ``url_path`` through either access shape.
The collection hands back storage items as dicts while builtin
dashboards come back as config objects; the url_path is reachable
through ``item["url_path"]`` or ``item.url_path`` respectively.
"""
url_path = getattr(item, "url_path", None)
if isinstance(url_path, str):
return url_path
get_url_path = getattr(item, "get", None)
if callable(get_url_path):
value = get_url_path("url_path")
if isinstance(value, str):
return value
return None
def _existing_dashboard(items) -> bool:
"""Whether a dashboard under the stable url_path already exists."""
return any(_item_url_path(item) == DASHBOARD_URL_PATH for item in items)
async def async_register_dashboard(hass: HomeAssistant) -> bool:
"""Register the NestQuest panel dashboard exactly once.
Returns True when a dashboard under the stable ``url_path`` exists
after the call (pre-existing or created here), False when the
Lovelace surfaces were unavailable or refused. A dashboard already
registered under the stable ``url_path`` — whether ours from a
previous setup or a user's own — skips BOTH steps and is left
COMPLETELY alone: its stored config is never overwritten, it is
never deleted, never a duplicate. The strategy config is saved
ONLY for the dashboard created in THIS call, whose fresh content
store is empty, so the save cannot clobber anything.
"""
collection = _dashboards_collection(hass)
if collection is None:
_LOGGER.warning(
"Lovelace dashboards collection unavailable; the NestQuest "
"panel dashboard was not registered. Enable the Lovelace "
"integration in storage mode or create a dashboard with "
"strategy %s manually.",
DASHBOARD_STRATEGY_TYPE,
)
return False
try:
# Single-flight: two concurrent entry setups must not both pass
# the exists-check and race on the url_path create.
async with _registration_lock(hass):
# async_items() is a SYNC @callback on the real collection
# (helpers/collection.py ObservableCollection) — not awaited.
if _existing_dashboard(collection.async_items()):
return True
await collection.async_create_item(
{
"url_path": DASHBOARD_URL_PATH,
"title": DASHBOARD_TITLE,
"icon": DASHBOARD_ICON,
"show_in_sidebar": True,
"allow_single_word": True,
}
)
# Step two (mirroring HA's _create_map_dashboard): the
# strategy CONFIG goes through the fresh dashboard's
# content manager, not through the metadata collection.
store = _dashboard_store(hass, DASHBOARD_URL_PATH)
if store is None:
_LOGGER.warning(
"The NestQuest panel dashboard was registered but its "
"Lovelace content manager never appeared; add the "
"strategy %s to the dashboard manually.",
DASHBOARD_STRATEGY_TYPE,
)
return False
await store.async_save(
{"strategy": {"type": DASHBOARD_STRATEGY_TYPE}}
)
except Exception:
_LOGGER.warning(
"Registering the NestQuest panel dashboard in the Lovelace "
"dashboards collection failed; set it up manually with "
"strategy %s.",
DASHBOARD_STRATEGY_TYPE,
exc_info=True,
)
return False
return True
+31
View File
@@ -0,0 +1,31 @@
"""Re-export of :mod:`custom_components.nestquest.core.db` for the integration package.
The async SQLite wrapper lives in :mod:`custom_components.nestquest.core.db` (extracted in task
d0b691d8) and is Home-Assistant-free: :class:`NestQuestDatabase` takes
an executor callable rather than a ``hass`` object. This module
re-exports it and provides :func:`make_database`, a thin adapter that
constructs a wrapper backed by ``hass.async_add_executor_job``, so the
integration constructs the database the same way it always did.
"""
from __future__ import annotations
from typing import Any, TypeVar
from homeassistant.core import HomeAssistant
from .core.db import ExecutionResult, NestQuestDatabase
__all__ = ["ExecutionResult", "NestQuestDatabase", "make_database"]
T = TypeVar("T")
def make_database(hass: HomeAssistant) -> NestQuestDatabase:
"""Construct a :class:`NestQuestDatabase` wired to ``hass``'s executor.
Mirrors the pre-extraction ``NestQuestDatabase(hass)`` call: the
wrapper's executor is ``hass.async_add_executor_job``, so every
sqlite3 call runs on the HA executor thread and the event loop
never blocks.
"""
return NestQuestDatabase(hass.async_add_executor_job)
+79
View File
@@ -0,0 +1,79 @@
"""Re-export / HA wiring of :mod:`custom_components.nestquest.core.events` for the integration package.
The transition-event payload construction lives in
:mod:`custom_components.nestquest.core.events` (extracted in
task 510c1f78) and is HA-free: it builds and RETURNS the events. This
shim keeps the integration's old ``fire_quest_completed`` /
``fire_quest_uncompleted`` API (the complete/uncomplete service
handlers call them with ``hass``), reads the HA-local timezone from
``hass.config.time_zone`` to derive ``today``, asks the core builder
for the events, and fires each on ``hass.bus`` with the documented
payloads. The four blueprints keep working with identical payloads.
The completed event is fired BEFORE the day-complete rule evaluates:
``fire_quest_completed`` asks
:func:`build_quest_completed_event` for the completed event and fires
it immediately, THEN reads the HA-local ``today``, asks
:func:`build_child_day_complete_event` for the day-complete event, and
fires it (if any) afterwards. This restores the pre-extraction
ordering — a failure in the day-complete DB reads or the ZoneInfo
lookup cannot suppress the completed event.
"""
from __future__ import annotations
import datetime
from zoneinfo import ZoneInfo
from .core.db import NestQuestDatabase
from .core.events import (
build_child_day_complete_event,
build_quest_completed_event,
build_quest_uncompleted_events,
)
__all__ = ["fire_quest_completed", "fire_quest_uncompleted"]
async def fire_quest_completed(
hass,
database: NestQuestDatabase,
instance_id: int,
*,
was_on_time: bool | None,
) -> None:
"""Fire ``nestquest_quest_completed`` and, when the completion
cleared the child's whole day, ``nestquest_child_day_complete``.
Thin HA wiring over the HA-free
:func:`custom_components.nestquest.core.events.build_quest_completed_event`
and
:func:`build_child_day_complete_event`: the completed event is
built and fired FIRST (so a failure in the day-complete DB reads
or the HA-local ``today`` lookup cannot suppress it), then the
HA-local ``today`` (the day-complete rule's input) is read from
``hass.config.time_zone`` and the day-complete builder is asked
whether the child's whole day cleared. Identical payloads and
day-complete rule to the pre-extraction implementation.
"""
completed = await build_quest_completed_event(
database,
instance_id,
was_on_time=was_on_time,
)
hass.bus.async_fire(*completed)
today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
day_complete = await build_child_day_complete_event(
database, completed[1], today=today
)
if day_complete is not None:
hass.bus.async_fire(*day_complete)
async def fire_quest_uncompleted(
hass, database: NestQuestDatabase, instance_id: int
) -> None:
"""Fire ``nestquest_quest_uncompleted`` for an actual reversal."""
events = await build_quest_uncompleted_events(database, instance_id)
for event_type, payload in events:
hass.bus.async_fire(event_type, payload)
+2 -3
View File
@@ -7,13 +7,12 @@
"config_flow": true,
"dependencies": [
"frontend",
"http",
"lovelace"
"http"
],
"documentation": "https://github.com/Maniacs-Mansion/NestQuest",
"integration_type": "hub",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
"requirements": [],
"version": "0.6.0"
"version": "0.5.0"
}
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.materialize` for the integration package.
The materialization walk lives in
:mod:`custom_components.nestquest.core.materialize` (extracted in
task 510c1f78); this module re-exports it so existing
``from .materialize import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.materialize import * # noqa: F401,F403
+11
View File
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.migrations` for the integration package.
The versioned migration runner lives in
:mod:`custom_components.nestquest.core.migrations`
(extracted in task d0b691d8); this module re-exports it so existing
``from .migrations import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.migrations import * # noqa: F401,F403
+284 -123
View File
@@ -1,15 +1,4 @@
"""Options flow for NestQuest post-setup settings.
Since the DB removal (Feature 18) the integration is a thin client of
the NestQuest API service: the settings that only served the local
database (the generation horizon, the day rollover, the notification
defaults, the admin allowlist) are gone — the API service owns those in
its own settings store, and the automations take their behaviour from
blueprint inputs. What remains is what the CLIENT itself consumes: the
panel-plane API connection (base URL + token), the shared coordinator's
refresh interval, the last-good staleness threshold, and the
panel-facing idle timeout.
"""
"""Options flow for NestQuest post-setup settings."""
from __future__ import annotations
from typing import Any
@@ -17,39 +6,61 @@ from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_validation as cv
from .const import (
CONF_API_BASE_URL,
CONF_ADMIN_USER_IDS,
CONF_AFTERNOON_REMINDER_ENABLED,
CONF_AFTERNOON_REMINDER_TIME,
CONF_CELEBRATION_ENABLED,
CONF_DAY_ROLLOVER_TIME,
CONF_END_OF_DAY_REPORT_ENABLED,
CONF_END_OF_DAY_REPORT_TIME,
CONF_HORIZON_DAYS,
CONF_MORNING_SUMMARY_ENABLED,
CONF_MORNING_SUMMARY_TIME,
CONF_NOTIFY_TARGET,
CONF_PANEL_IDLE_TIMEOUT,
CONF_PANEL_TOKEN,
CONF_SNAPSHOT_STALENESS,
CONF_UPDATE_INTERVAL,
DEFAULT_API_BASE_URL,
DEFAULT_AFTERNOON_REMINDER_TIME,
DEFAULT_AUTOMATION_ENABLED,
DEFAULT_DAY_ROLLOVER_TIME,
DEFAULT_END_OF_DAY_REPORT_TIME,
DEFAULT_HORIZON_DAYS,
DEFAULT_MORNING_SUMMARY_TIME,
DEFAULT_PANEL_IDLE_TIMEOUT,
DEFAULT_PANEL_TOKEN,
DEFAULT_SNAPSHOT_STALENESS,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
MIN_UPDATE_INTERVAL,
TIME_PATTERN,
)
from .api_client import is_valid_base_url
from .admin_allowlist import list_admin_ids, set_admin_ids
MIN_HORIZON_DAYS = 1
MIN_PANEL_IDLE_TIMEOUT = 30
def _async_validate(
user_input: dict[str, Any],
current: dict[str, Any] | None = None,
) -> dict[str, str]:
"""Validate user input and return field-level errors instead of raising.
def _parse_time(value: str) -> bool:
"""Return True when value is a valid 24-hour HH:MM string."""
return TIME_PATTERN.fullmatch(value) is not None
``current`` carries the stored values (entry options → entry data →
defaults) so a raw submit that sets one field but omits a sibling it
is validated against (the staleness threshold vs. the update
interval) can still check the effective pair; raw submits omitting
BOTH keys keep their stored values untouched.
"""
def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
"""Validate user input and return field-level errors instead of raising."""
errors: dict[str, str] = {}
horizon_days = user_input.get(CONF_HORIZON_DAYS)
if (
isinstance(horizon_days, bool)
or not isinstance(horizon_days, int)
or horizon_days < MIN_HORIZON_DAYS
):
errors[CONF_HORIZON_DAYS] = "invalid"
day_rollover_time = user_input.get(CONF_DAY_ROLLOVER_TIME)
if not isinstance(day_rollover_time, str) or not _parse_time(day_rollover_time):
errors[CONF_DAY_ROLLOVER_TIME] = "invalid_time"
panel_idle_timeout = user_input.get(CONF_PANEL_IDLE_TIMEOUT)
if (
isinstance(panel_idle_timeout, bool)
@@ -70,127 +81,270 @@ def _async_validate(
):
errors[CONF_UPDATE_INTERVAL] = "invalid"
# The last-good staleness threshold (Feature 18) is OPTIONAL in raw
# submits the same way: a missing key keeps the current value. It
# must be a positive int, and at least the EFFECTIVE update interval
# (the one this submit carries, else the stored or default one) — a
# threshold shorter than the poll cadence could never serve even one
# cached snapshot.
staleness = user_input.get(CONF_SNAPSHOT_STALENESS)
if staleness is not None and (
isinstance(staleness, bool)
or not isinstance(staleness, int)
or staleness < 1
# The notification section (Feature 11) is OPTIONAL in raw submits
# the same way: a missing key keeps the current value. The three
# times are strict HH:MM; the notify target is a non-empty HA
# service name; the toggles are real booleans.
for time_key in (
CONF_MORNING_SUMMARY_TIME,
CONF_AFTERNOON_REMINDER_TIME,
CONF_END_OF_DAY_REPORT_TIME,
):
errors[CONF_SNAPSHOT_STALENESS] = "invalid"
elif staleness is not None and not errors.get(CONF_UPDATE_INTERVAL):
stored_interval = (current or {}).get(CONF_UPDATE_INTERVAL)
effective_interval = update_interval
if effective_interval is None and (
isinstance(stored_interval, int)
and not isinstance(stored_interval, bool)
and stored_interval >= MIN_UPDATE_INTERVAL
time_value = user_input.get(time_key)
if time_value is not None and (
not isinstance(time_value, str) or not _parse_time(time_value)
):
effective_interval = stored_interval
if effective_interval is None:
effective_interval = DEFAULT_UPDATE_INTERVAL
if staleness < effective_interval:
errors[CONF_SNAPSHOT_STALENESS] = "invalid_staleness"
# The panel-plane API connection (Feature 18) is OPTIONAL in raw
# submits the same way: a missing key keeps the current value. The
# base URL must be a full http(s) URL with a host — the same check
# the client's constructor enforces, so a malformed URL fails at
# the form, not at the first request. The token may be EMPTY (the
# API connection is simply not configured yet) but a non-empty one
# must be pasted unpadded: a padded or whitespace-only value is a
# clipboard mistake that would fail the token check at request
# time.
api_base_url = user_input.get(CONF_API_BASE_URL)
if api_base_url is not None and not is_valid_base_url(api_base_url):
errors[CONF_API_BASE_URL] = "invalid_url"
panel_token = user_input.get(CONF_PANEL_TOKEN)
if panel_token is not None and (
not isinstance(panel_token, str)
or panel_token != panel_token.strip()
errors[time_key] = "invalid_time"
notify_target = user_input.get(CONF_NOTIFY_TARGET)
if notify_target is not None and not isinstance(notify_target, str):
errors[CONF_NOTIFY_TARGET] = "invalid"
elif isinstance(notify_target, str) and notify_target != (
notify_target.strip()
):
errors[CONF_PANEL_TOKEN] = "invalid"
# An EMPTY target is valid — it means "no notifications
# configured yet" and is the shipped default; a value that
# differs from its own trim (padded or whitespace-only) is
# malformed and rejected.
errors[CONF_NOTIFY_TARGET] = "invalid"
for toggle_key in (
CONF_MORNING_SUMMARY_ENABLED,
CONF_AFTERNOON_REMINDER_ENABLED,
CONF_END_OF_DAY_REPORT_ENABLED,
CONF_CELEBRATION_ENABLED,
):
toggle_value = user_input.get(toggle_key)
if toggle_value is not None and not isinstance(toggle_value, bool):
errors[toggle_key] = "invalid"
return errors
def _build_schema(current: dict[str, Any]) -> vol.Schema:
def _strict_bool(value: object) -> bool:
"""Require a real bool; voluptuous ``bool`` would coerce strings."""
if not isinstance(value, bool):
raise vol.Invalid(f"expected a boolean, got {value!r}")
return value
def _build_schema(
current: dict[str, Any],
*,
admin_choices: dict[str, str] | None = None,
admin_default: list[str] | None = None,
) -> vol.Schema:
"""Build the form schema, pre-filled with the current values.
The API connection (Feature 18) carries the base URL the API client
talks to and the panel service token it presents as the Bearer
credential. Schema-validated submits always carry every field (the
defaults pre-fill from the stored values); raw callers omitting one
keep its stored value (see _async_validate).
The admin picker is a multi-select of EXISTING Home Assistant users
(never a free-text field — the allowlist stores user IDs); its
default is the current database allowlist, so an untouched submit
re-saves exactly who is already allowed. The notification section
(Feature 11) carries the household's notify target, the three
automation times, and four enable toggles — defaults from
:mod:`~.const`, never a hard-coded personal target.
"""
return vol.Schema(
{
vol.Required(
CONF_PANEL_IDLE_TIMEOUT,
default=current[CONF_PANEL_IDLE_TIMEOUT],
): int,
vol.Required(
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
): int,
# The last-good staleness threshold (Feature 18): how long
# the coordinator keeps serving the cached snapshot after
# the API stops answering.
vol.Required(
CONF_SNAPSHOT_STALENESS,
default=current[CONF_SNAPSHOT_STALENESS],
): int,
# Panel-plane API connection (Feature 18): the base URL the
# API client talks to and the panel service token it
# presents as the Bearer credential.
vol.Required(
CONF_API_BASE_URL, default=current[CONF_API_BASE_URL]
): str,
vol.Required(
CONF_PANEL_TOKEN, default=current[CONF_PANEL_TOKEN]
): str,
}
)
fields: dict[Any, Any] = {
vol.Required(
CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS]
): int,
vol.Required(
CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME]
): str,
vol.Required(
CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT]
): int,
vol.Required(
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
): int,
vol.Required(
CONF_NOTIFY_TARGET, default=current[CONF_NOTIFY_TARGET]
): str,
vol.Required(
CONF_MORNING_SUMMARY_TIME,
default=current[CONF_MORNING_SUMMARY_TIME],
): str,
vol.Required(
CONF_AFTERNOON_REMINDER_TIME,
default=current[CONF_AFTERNOON_REMINDER_TIME],
): str,
vol.Required(
CONF_END_OF_DAY_REPORT_TIME,
default=current[CONF_END_OF_DAY_REPORT_TIME],
): str,
vol.Required(
CONF_MORNING_SUMMARY_ENABLED,
default=current[CONF_MORNING_SUMMARY_ENABLED],
): _strict_bool,
vol.Required(
CONF_AFTERNOON_REMINDER_ENABLED,
default=current[CONF_AFTERNOON_REMINDER_ENABLED],
): _strict_bool,
vol.Required(
CONF_END_OF_DAY_REPORT_ENABLED,
default=current[CONF_END_OF_DAY_REPORT_ENABLED],
): _strict_bool,
vol.Required(
CONF_CELEBRATION_ENABLED,
default=current[CONF_CELEBRATION_ENABLED],
): _strict_bool,
}
if admin_choices is not None:
# HA's supported multi-select validator: the frontend can
# serialize the field as a real picker of the given choices.
fields[
vol.Optional(
CONF_ADMIN_USER_IDS, default=list(admin_default or [])
)
] = cv.multi_select(admin_choices)
return vol.Schema(fields)
class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
"""Handle the options flow for NestQuest."""
def _async_database(self):
"""Return the runtime database for this entry, or None.
Real HA only opens the options flow for an existing entry, so
the runtime record (and its database) exists; the None branch
covers direct construction in tests and degraded states.
"""
hass_data = getattr(self.hass, "data", None)
if not isinstance(hass_data, dict):
return None
record = hass_data.get(DOMAIN, {}).get(self.config_entry.entry_id)
database = getattr(record, "database", None)
return database if getattr(database, "connected", False) else None
async def _async_admin_choices(self) -> dict[str, str]:
"""Map HA user id -> display name for the picker.
Real Home Assistant always provides ``hass.auth``; a missing
one (direct construction in tests, degraded harness) degrades
to an empty picker rather than crashing the form.
"""
auth = getattr(self.hass, "auth", None)
if auth is None:
return {}
users = await auth.async_get_users()
return {
user.id: user.name
for user in users
if getattr(user, "id", None)
}
async def _async_current_admin_ids(self) -> list[str]:
"""Return the current allowlist, or [] without a database."""
database = self._async_database()
if database is None:
return []
return await list_admin_ids(database)
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the NestQuest options."""
admin_choices = await self._async_admin_choices()
admin_default = await self._async_current_admin_ids()
if user_input is None:
return self.async_show_form(
step_id="init",
data_schema=_build_schema(self._current_values()),
data_schema=_build_schema(
self._current_values(),
admin_choices=admin_choices,
admin_default=admin_default,
),
)
errors = _async_validate(user_input, self._current_values())
errors = _async_validate(user_input)
# The admin ids are validated only when the key is present:
# schema-validated submits always carry it (vol.Optional's
# default fills from the current allowlist), while raw callers
# omitting the key keep the field untouched.
admin_ids_present = CONF_ADMIN_USER_IDS in user_input
admin_ids = user_input.get(CONF_ADMIN_USER_IDS)
if errors:
return self.async_show_form(
step_id="init",
data_schema=_build_schema(self._current_values()),
data_schema=_build_schema(
self._current_values(),
admin_choices=admin_choices,
admin_default=admin_default,
),
errors=errors,
)
if admin_ids_present and (
not isinstance(admin_ids, list)
or any(not isinstance(value, str) for value in admin_ids)
):
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
elif admin_ids_present and not admin_ids:
# An empty allowlist fails closed: saving zero admins is
# refused, never silently applied.
errors[CONF_ADMIN_USER_IDS] = "no_admins"
elif admin_ids_present and any(
value not in admin_choices for value in admin_ids
):
# The schema enforces membership for real HA submits; this
# backs the raw-input path up so an unknown id can never
# reach the allowlist.
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
elif admin_ids_present and len(set(admin_ids)) != len(admin_ids):
# set_admin_ids refuses duplicates with ValueError; a flow
# submission must surface that as a field error instead of
# an unhandled crash.
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
if errors:
return self.async_show_form(
step_id="init",
data_schema=_build_schema(
self._current_values(),
admin_choices=admin_choices,
admin_default=admin_default,
),
errors=errors,
)
# Every remaining key is stored the fall-back-to-current way, so
# raw submits that predate a section keep that section's stored
# values.
# Apply the picker's selection to the allowlist when the
# runtime database is reachable; the selection is always
# stored on the entry as the disaster-recovery copy.
database = self._async_database()
if database is not None and admin_ids_present:
await set_admin_ids(database, admin_ids)
data = {
CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS],
CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME],
CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT],
CONF_UPDATE_INTERVAL: user_input.get(
CONF_UPDATE_INTERVAL,
self._current_values()[CONF_UPDATE_INTERVAL],
),
}
# The notification section (Feature 11) stores the household's
# choices the same fall-back-to-current way, so raw submits
# that predate the section keep their stored values.
current = self._current_values()
data: dict[str, Any] = {}
for key in (
CONF_PANEL_IDLE_TIMEOUT,
CONF_UPDATE_INTERVAL,
CONF_SNAPSHOT_STALENESS,
CONF_API_BASE_URL,
CONF_PANEL_TOKEN,
for notification_key in (
CONF_NOTIFY_TARGET,
CONF_MORNING_SUMMARY_TIME,
CONF_AFTERNOON_REMINDER_TIME,
CONF_END_OF_DAY_REPORT_TIME,
CONF_MORNING_SUMMARY_ENABLED,
CONF_AFTERNOON_REMINDER_ENABLED,
CONF_END_OF_DAY_REPORT_ENABLED,
CONF_CELEBRATION_ENABLED,
):
data[key] = user_input.get(key, current[key])
data[notification_key] = user_input.get(
notification_key, current[notification_key]
)
if admin_ids_present:
data[CONF_ADMIN_USER_IDS] = admin_ids
elif admin_default:
# HA replaces entry.options wholesale, so an omitted picker
# field must carry the CURRENT allowlist forward: dropping
# it would leave only the stale config-flow copy in entry
# data, and a later database loss would resurrect admins
# the owner has since removed.
data[CONF_ADMIN_USER_IDS] = list(admin_default)
return self.async_create_entry(title="NestQuest", data=data)
def _current_values(self) -> dict[str, Any]:
@@ -199,11 +353,18 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
entry_data = getattr(self.config_entry, "data", None) or {}
values: dict[str, Any] = {}
for key, default in (
(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS),
(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME),
(CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT),
(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL),
(CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS),
(CONF_API_BASE_URL, DEFAULT_API_BASE_URL),
(CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN),
(CONF_NOTIFY_TARGET, ""),
(CONF_MORNING_SUMMARY_TIME, DEFAULT_MORNING_SUMMARY_TIME),
(CONF_AFTERNOON_REMINDER_TIME, DEFAULT_AFTERNOON_REMINDER_TIME),
(CONF_END_OF_DAY_REPORT_TIME, DEFAULT_END_OF_DAY_REPORT_TIME),
(CONF_MORNING_SUMMARY_ENABLED, DEFAULT_AUTOMATION_ENABLED),
(CONF_AFTERNOON_REMINDER_ENABLED, DEFAULT_AUTOMATION_ENABLED),
(CONF_END_OF_DAY_REPORT_ENABLED, DEFAULT_AUTOMATION_ENABLED),
(CONF_CELEBRATION_ENABLED, DEFAULT_AUTOMATION_ENABLED),
):
if key in options:
values[key] = options[key]
@@ -211,4 +372,4 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
values[key] = entry_data[key]
else:
values[key] = default
return values
return values
@@ -0,0 +1,90 @@
"""Permission gate for NestQuest services (Feature 09).
A wrapper factory, applied to EVERY registered service at registration
time (no handler is exempt), reads ``call.context.user_id``, consults
the :data:`~.service_policy.SERVICE_POLICY` registry and
:func:`~.admin_allowlist.is_admin`, and raises
:class:`homeassistant.exceptions.Unauthorized` for an admin-only
operation called by a non-admin — including a call with NO user
context, which fails closed. ``complete_quest`` (policy ``open``)
proceeds regardless of the allowlist.
Logging (later Feature 09 tasks): every denied admin-only call logs a
warning and every successful one logs a debug line, both carrying the
service, the user id (or ``none``), and a UTC timestamp.
"""
from __future__ import annotations
import datetime
import logging
from collections.abc import Awaitable, Callable
from typing import Any
from homeassistant.exceptions import Unauthorized
from .admin_allowlist import is_admin
from .service_policy import ADMIN_ONLY
LOGGER = logging.getLogger(__name__)
ServiceHandler = Callable[[Any], Awaitable[None]]
def _context_user_id(call: Any) -> str | None:
context = getattr(call, "context", None)
user_id = getattr(context, "user_id", None) if context is not None else None
if isinstance(user_id, str) and user_id:
return user_id
return None
def _stamp() -> str:
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def permission_gate(
service: str,
policy: str,
database_of: Callable[[Any], Any],
) -> Callable[[ServiceHandler], ServiceHandler]:
"""Wrap ``handler`` with the Feature 09 permission check.
``database_of`` resolves the live database from the call-time
runtime (the same ``find_runtime`` the handler uses), so the gate
never opens its own connection. The verdict is fail-closed:
absent allowlist row, unknown id, no user context — all not admin.
"""
def _wrap(handler: ServiceHandler) -> ServiceHandler:
if policy != ADMIN_ONLY:
return handler
async def _gated(call: Any) -> None:
user_id = _context_user_id(call)
allowed = user_id is not None and await is_admin(
database_of(call), user_id
)
if not allowed:
LOGGER.warning(
"NestQuest denied %s: caller user_id=%s at %s",
service,
user_id if user_id is not None else "none",
_stamp(),
)
raise Unauthorized(
f"User '{user_id if user_id is not None else 'none'}' "
f"is not allowed to call nestquest.{service}"
)
LOGGER.debug(
"NestQuest allowed %s: caller user_id=%s at %s",
service,
user_id,
_stamp(),
)
await handler(call)
return _gated
return _wrap
+11
View File
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.presence` for the integration package.
The presence and custody model lives in
:mod:`custom_components.nestquest.core.presence` (extracted in
task 510c1f78); this module re-exports it so existing
``from .presence import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.presence import * # noqa: F401,F403
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.quest_definitions` for the integration package.
The quest definitions business layer lives in
:mod:`custom_components.nestquest.core.quest_definitions` (extracted in
task 510c1f78); this module re-exports it so existing
``from .quest_definitions import ...`` references inside the integration
keep working.
"""
from __future__ import annotations
from .core.quest_definitions import * # noqa: F401,F403
+10
View File
@@ -0,0 +1,10 @@
"""Re-export of :mod:`custom_components.nestquest.core.recurrence` for the integration package.
The recurrence model lives in :mod:`custom_components.nestquest.core.recurrence`
(extracted in task d0b691d8); this module re-exports it so existing
``from .recurrence import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.recurrence import * # noqa: F401,F403
+11
View File
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.schema` for the integration package.
The declarative schema DDL lives in
:mod:`custom_components.nestquest.core.schema` (extracted in
task d0b691d8); this module re-exports it so existing
``from .schema import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.schema import * # noqa: F401,F403
+3 -63
View File
@@ -32,7 +32,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from .const import DOMAIN
from .coordinator import (
@@ -42,21 +41,13 @@ from .coordinator import (
)
from .core.snapshot import instance_payload
#: HA's state machine refuses states longer than 255
#: Home Assistant's state machine refuses states longer than 255
#: characters (its hard recorder/state limit). These sensors' states
#: are numeric, but the guard is applied uniformly: a payload-bearing
#: sensor can never crash the state write, and later string-state
#: sensors reuse the same helper.
MAX_STATE_LENGTH = 255
#: Entity-id length cap the roster applies to HA's slugify: HA's
#: slugifier itself does NOT truncate, but the entity registry derives
#: entity ids as ``<domain>.<slug>`` and truncates the full entity_id at
#: MAX_LENGTH_STATE_ENTITY_ID (64), so for this module's ``sensor.``
#: entities the slugged object id alone may not exceed
#: 64 - len("sensor.").
_MAX_SLUG_LENGTH = 64 - len("sensor.")
def _state_within_limit(state: Any) -> Any:
"""Return ``state`` unchanged, refusing strings over HA's limit.
@@ -107,51 +98,6 @@ def _next_open_quest(
)
def _slugify(text: str) -> str:
"""Slugify a display name the way Home Assistant derives entity ids.
Runs ``homeassistant.util.slugify`` — the real slugifier HA runs
entity names through, which transliterates every script to ASCII
through unidecode (so non-Latin names survive: ``京子`` slugs to
``jing_zi``) before folding to lowercase and turning every run of
non-alphanumerics into one underscore, answering HA's ``unknown``
sentinel when a name slugs to nothing — then caps the result at
the object-id length the entity registry enforces (the registry
truncates the full ``sensor.`` + slug entity_id at 64 characters,
so the slug alone is capped like this; HA's slugify does not
truncate by itself).
"""
return slugify(text)[:_MAX_SLUG_LENGTH]
def _child_roster(children: Iterable[ChildDaySnapshot]) -> list[dict[str, Any]]:
"""The ordered child roster the panel cards discover children from.
One entry per ACTIVE child in the snapshot's order (the API's
sort_order, then id): ``child_id``, ``name``, and the ``slug`` the
entity-id derivation produces for the child's name — the piece the
cards need to build ``sensor.nestquest_<slug>_...`` ids. Duplicate
display names get the registry's ``_2``/``_3`` suffix in snapshot
order, matching how HA resolves the collision at registration. The
rollups' ``children`` attribute (per-child counts) is unchanged and
carries a different shape on purpose.
"""
slugs: dict[str, int] = {}
roster: list[dict[str, Any]] = []
for child in children:
base = _slugify(child.child_name)
taken = slugs.get(base, 0) + 1
slugs[base] = taken
roster.append(
{
"child_id": child.child_id,
"name": child.child_name,
"slug": base if taken == 1 else f"{base}_{taken}",
}
)
return roster
class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity):
"""Base for the per-child day sensors.
@@ -410,12 +356,7 @@ class _NestQuestHouseholdSensor(CoordinatorEntity, SensorEntity):
class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
"""Total quests due today across active children.
Also publishes ``child_roster`` (see :func:`_child_roster`): the
ordered child identity list the panel cards read to discover the
board's children when the card config carries no ``child_order``.
"""
"""Total quests due today across active children."""
_attr_name = "NestQuest household quests due today"
_attr_state_class = SensorStateClass.MEASUREMENT
@@ -439,8 +380,7 @@ class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
"children": {
str(child.child_id): child.due_today
for child in data.children
},
"child_roster": _child_roster(data.children),
}
}
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.service_policy` for the integration package.
The service policy registry lives in
:mod:`custom_components.nestquest.core.service_policy` (extracted in
task 510c1f78); this module re-exports it so existing
``from .service_policy import ...`` references inside the integration
keep working.
"""
from __future__ import annotations
from .core.service_policy import * # noqa: F401,F403
+506 -67
View File
@@ -1,36 +1,61 @@
"""The domain-global ``nestquest.complete_quest`` service (Feature 18).
"""Domain-global NestQuest service schemas and handlers (Feature 09).
Registration is domain-scoped and happens once (see
:func:`async_register_services`). Since Feature 18 the integration is
a client of the NestQuest API service, so ``complete_quest`` proxies
to the API's panel complete route through the entry's shared API
client instead of writing any database. The former HA admin services
(uncomplete, quest-definition and presence management, CSV export,
child management, regenerate) are removed: the admin surface is the
PWA and the API service's admin routes, so the only HA service left is
the panel completion path.
:func:`async_register_services`); handlers resolve a live database at
call time through the injected ``find_runtime`` callable. Permission
checks are Feature 09 later tasks — this module only validates payload
shape and forwards to the Feature 03/06/08 business layers (and the
presence DAOs). ``export_history_csv`` is registered with a schema but
raises until Feature 13. ``regenerate`` is supplied by the caller so
the Feature 07 handler stays the one in ``__init__``.
"""
from __future__ import annotations
import datetime
from collections.abc import Awaitable, Callable
from typing import Any
from zoneinfo import ZoneInfo
import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .api_client import NestQuestApiError
from . import children as children_layer
from . import completion as completion_layer
from . import quest_definitions as quest_definitions_layer
from .const import (
DOMAIN,
DOMAIN_SERVICES,
QUEST_WINDOWS,
SERVICE_COMPLETE_QUEST,
SERVICE_CREATE_PRESENCE_OVERRIDE,
SERVICE_CREATE_QUEST_DEFINITION,
SERVICE_DELETE_PRESENCE_OVERRIDE,
SERVICE_EXPORT_HISTORY_CSV,
SERVICE_MANAGE_CHILD,
SERVICE_REGENERATE,
SERVICE_SET_PRESENCE_PATTERN,
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
SERVICE_UNCOMPLETE_QUEST,
SERVICE_UPDATE_QUEST_DEFINITION,
)
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
from .events import fire_quest_completed, fire_quest_uncompleted
from .materialize import regenerate_for_child
from .permissions import permission_gate
from .presence import PresenceSchedule
from .recurrence import ScheduleRule
from .service_policy import SERVICE_POLICY
FindRuntime = Callable[[HomeAssistant], Any]
ServiceHandler = Callable[[Any], Awaitable[None]]
ACTOR_PANEL = "panel"
ACTOR_USER = "user"
MANAGE_CHILD_CREATE = "create"
MANAGE_CHILD_EDIT = "edit"
MANAGE_CHILD_SET_ACTIVE = "set_active"
MANAGE_CHILD_REORDER = "reorder"
def _strict_int(value: object) -> int:
@@ -40,6 +65,82 @@ def _strict_int(value: object) -> int:
return value
def _strict_bool(value: object) -> bool:
"""Reject ints and other truthy values; require a real bool."""
if type(value) is not bool:
raise vol.Invalid(f"expected bool, got {value!r}")
return value
def _weekday_list(value: object) -> list[int]:
if type(value) is not list:
raise vol.Invalid(f"expected a list of weekday ints, got {value!r}")
return [_strict_int(item) for item in value]
def _presence_pattern(value: object) -> dict[int, list[int]]:
if not isinstance(value, dict):
raise vol.Invalid(f"pattern must be a mapping, got {value!r}")
pattern: dict[int, list[int]] = {}
for week, days in value.items():
if isinstance(week, str):
try:
week_index = int(week)
except ValueError as err:
raise vol.Invalid(
f"pattern week index must be an integer, got {week!r}"
) from err
if str(week_index) != week:
raise vol.Invalid(
f"pattern week index must be an integer, got {week!r}"
)
else:
week_index = _strict_int(week)
pattern[week_index] = _weekday_list(days)
return pattern
def _window_entry(value: object) -> str | tuple[str, str | None]:
if isinstance(value, str):
if value not in QUEST_WINDOWS:
raise vol.Invalid(f"unknown window {value!r}")
return value
if isinstance(value, dict):
parsed = vol.Schema(
{
vol.Required("window"): vol.In(QUEST_WINDOWS),
vol.Optional("due_time"): str,
}
)(value)
due_time = parsed.get("due_time")
if due_time is None:
return parsed["window"]
return (parsed["window"], due_time)
raise vol.Invalid(
f"windows entry must be a window name or object, got {value!r}"
)
def _windows(value: object) -> list[str | tuple[str, str | None]]:
if type(value) is not list:
raise vol.Invalid(f"windows must be a list, got {value!r}")
if not value:
raise vol.Invalid("windows must not be empty")
return [_window_entry(entry) for entry in value]
def _int_list(value: object) -> list[int]:
if type(value) is not list:
raise vol.Invalid(f"expected a list of ints, got {value!r}")
return [_strict_int(item) for item in value]
def _rule_dict(value: object) -> dict:
if not isinstance(value, dict):
raise vol.Invalid(f"rule must be a dict, got {value!r}")
return value
SCHEMA_COMPLETE_QUEST = vol.Schema(
{
vol.Required("instance_id"): _strict_int,
@@ -49,22 +150,102 @@ SCHEMA_COMPLETE_QUEST = vol.Schema(
}
)
SCHEMA_UNCOMPLETE_QUEST = SCHEMA_COMPLETE_QUEST
SCHEMA_CREATE_QUEST_DEFINITION = vol.Schema(
{
vol.Required("title"): str,
vol.Required("rule"): _rule_dict,
vol.Required("assignee_child_ids"): vol.All(
_int_list, vol.Length(min=1)
),
vol.Required("windows"): _windows,
vol.Optional("description"): str,
vol.Optional("icon"): str,
}
)
SCHEMA_UPDATE_QUEST_DEFINITION = vol.Schema(
{
vol.Required("definition_id"): _strict_int,
vol.Optional("title"): str,
vol.Optional("description"): vol.Any(str, None),
vol.Optional("icon"): vol.Any(str, None),
vol.Optional("rule"): _rule_dict,
vol.Optional("windows"): _windows,
}
)
SCHEMA_SET_QUEST_DEFINITION_ACTIVE = vol.Schema(
{
vol.Required("definition_id"): _strict_int,
vol.Required("is_active"): _strict_bool,
}
)
SCHEMA_SET_PRESENCE_PATTERN = vol.Schema(
{
vol.Required("child_id"): _strict_int,
vol.Required("cycle_length_weeks"): _strict_int,
vol.Required("anchor_date"): str,
vol.Required("pattern"): _presence_pattern,
}
)
SCHEMA_CREATE_PRESENCE_OVERRIDE = vol.Schema(
{
vol.Required("child_id"): _strict_int,
vol.Required("start_date"): str,
vol.Required("end_date"): str,
vol.Required("is_present"): _strict_bool,
vol.Optional("note"): str,
}
)
SCHEMA_DELETE_PRESENCE_OVERRIDE = vol.Schema(
{
vol.Required("override_id"): _strict_int,
}
)
SCHEMA_EXPORT_HISTORY_CSV = vol.Schema({})
SCHEMA_MANAGE_CHILD = vol.Schema(
{
vol.Required("action"): vol.In(
[
MANAGE_CHILD_CREATE,
MANAGE_CHILD_EDIT,
MANAGE_CHILD_SET_ACTIVE,
MANAGE_CHILD_REORDER,
]
),
vol.Optional("child_id"): _strict_int,
vol.Optional("display_name"): str,
vol.Optional("colour"): str,
vol.Optional("avatar_ref"): str,
vol.Optional("sort_order"): _strict_int,
vol.Optional("is_active"): _strict_bool,
vol.Optional("ordered_ids"): _int_list,
}
)
def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime):
runtime = find_runtime(hass)
if runtime is None:
raise HomeAssistantError(
"NestQuest is not loaded; cannot run this service"
"NestQuest has no live database; cannot run this service"
)
return runtime
async def _refresh_entities(runtime: Any) -> None:
"""Force an immediate coordinator refresh after the service call.
"""Force an immediate coordinator refresh after a service mutation.
Only the SERVICE path calls this — the scheduled materialize and
Only the SERVICE paths call this — the scheduled materialize and
rollover internals are direct function calls and never widen this
gate — so the entities reflect a completion the moment the service
gate — so entities reflect a completion the moment the service
call returns instead of waiting for the poll interval.
"""
coordinator = getattr(runtime, "coordinator", None)
@@ -72,6 +253,12 @@ async def _refresh_entities(runtime: Any) -> None:
await coordinator.async_refresh()
def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]:
time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
return {"today": today, "horizon_days": runtime.settings.horizon_days}
def _actor_kwargs(call: Any) -> dict[str, Any]:
actor = call.data["actor"]
if actor == ACTOR_PANEL:
@@ -114,50 +301,243 @@ def _complete_quest(
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
instance_id = call.data["instance_id"]
# The panel contract (decision 9): a tap completes as the
# tapped child profile, the only actor shape the API's panel
# route accepts. The payload schema is unchanged — the panel
# cards call it with instance_id, actor=panel, actor_child_id.
actor = _actor_kwargs(call)
if actor["actor_source"] != ACTOR_PANEL:
raise HomeAssistantError(
"nestquest.complete_quest completes through the API's "
"panel route as the tapped child profile; actor must "
"be 'panel' with actor_child_id"
)
# The entry's ONE shared API client (built at setup): the
# coordinator's snapshot poll, the SSE subscription, and this
# proxy all ride the same instance. An entry with no panel
# token runs the unconfigured stub, which has no completion
# surface — fail fast with a clear, token-free message.
complete_instance = getattr(
getattr(runtime.coordinator, "api_client", None),
"complete_instance",
None,
# ``appended`` is decided under the same lock as the write, so
# a concurrent duplicate call (a double tap) can not produce a
# second transition event: the loser appends nothing.
result = await completion_layer.complete_instance(
runtime.database,
instance_id,
**_actor_kwargs(call),
)
if complete_instance is None:
raise HomeAssistantError(
"NestQuest API is not configured; cannot complete a "
"quest"
if result.appended:
await fire_quest_completed(
hass,
runtime.database,
instance_id,
was_on_time=result.was_on_time,
)
try:
await complete_instance(instance_id, actor["actor_child_id"])
except NestQuestApiError as err:
# The typed API failure surfaces as a service error the
# caller sees — never swallowed, never retried here. The
# typed message names method, path, and status only; the
# panel token never appears in it.
await _refresh_entities(runtime)
return _with_errors(_handler)
def _uncomplete_quest(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
instance_id = call.data["instance_id"]
result = await completion_layer.uncomplete_instance(
runtime.database,
instance_id,
**_actor_kwargs(call),
)
if result.appended:
await fire_quest_uncompleted(
hass, runtime.database, instance_id
)
await _refresh_entities(runtime)
return _with_errors(_handler)
def _create_quest_definition(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
database = _require_runtime(hass, find_runtime).database
data = call.data
await quest_definitions_layer.create_quest_definition(
database,
data["title"],
ScheduleRule.from_dict(data["rule"]),
data["assignee_child_ids"],
data["windows"],
description=data.get("description"),
icon=data.get("icon"),
)
return _with_errors(_handler)
def _update_quest_definition(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
data = call.data
kwargs: dict[str, Any] = _generation_kwargs(hass, runtime)
if "title" in data:
kwargs["title"] = data["title"]
if "description" in data:
kwargs["description"] = data["description"]
if "icon" in data:
kwargs["icon"] = data["icon"]
if "rule" in data:
kwargs["rule"] = ScheduleRule.from_dict(data["rule"])
if "windows" in data:
kwargs["windows"] = data["windows"]
await quest_definitions_layer.edit_quest_definition(
runtime.database, data["definition_id"], **kwargs
)
return _with_errors(_handler)
def _set_quest_definition_active(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
await quest_definitions_layer.set_quest_definition_active(
runtime.database,
call.data["definition_id"],
call.data["is_active"],
**_generation_kwargs(hass, runtime),
)
return _with_errors(_handler)
def _set_presence_pattern(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
data = call.data
schedule = PresenceSchedule(
data["child_id"],
data["cycle_length_weeks"],
data["anchor_date"],
data["pattern"],
)
await PresenceSchedulesDao(runtime.database).upsert_by_child(
schedule.child_id,
schedule.cycle_length_weeks,
schedule.anchor_date.isoformat(),
schedule.encode(),
)
await regenerate_for_child(
runtime.database,
schedule.child_id,
**_generation_kwargs(hass, runtime),
)
return _with_errors(_handler)
def _create_presence_override(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
data = call.data
await PresenceOverridesDao(runtime.database).create(
data["child_id"],
data["start_date"],
data["end_date"],
data["is_present"],
note=data.get("note"),
)
await regenerate_for_child(
runtime.database,
data["child_id"],
**_generation_kwargs(hass, runtime),
)
return _with_errors(_handler)
def _delete_presence_override(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
dao = PresenceOverridesDao(runtime.database)
override_id = call.data["override_id"]
override = await dao.get(override_id)
if override is None:
raise HomeAssistantError(
f"NestQuest quest completion failed: {err}"
) from err
# NO local completion write and NO local event: the API service
# emits the transition on its SSE stream and the integration's
# subscription re-fires it on the bus — firing it here too
# would double-fire. The immediate refresh keeps the old
# behaviour where the sensors reflect the completion the
# moment the service call returns instead of waiting for the
# poll interval.
await _refresh_entities(runtime)
f"override_id: presence override {override_id} does not exist"
)
await dao.delete(override_id)
await regenerate_for_child(
runtime.database,
override.child_id,
**_generation_kwargs(hass, runtime),
)
return _with_errors(_handler)
async def _export_history_csv(_call: Any) -> None:
raise HomeAssistantError(
"CSV history export is not implemented until Feature 13"
)
def _manage_child(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
database = _require_runtime(hass, find_runtime).database
data = call.data
action = data["action"]
if action == MANAGE_CHILD_CREATE:
display_name = data.get("display_name")
if display_name is None:
raise HomeAssistantError(
"display_name is required when action is create"
)
kwargs: dict[str, Any] = {}
if "colour" in data:
kwargs["colour"] = data["colour"]
if "avatar_ref" in data:
kwargs["avatar_ref"] = data["avatar_ref"]
if "sort_order" in data:
kwargs["sort_order"] = data["sort_order"]
await children_layer.create_child(
database, display_name, **kwargs
)
return
if action == MANAGE_CHILD_EDIT:
child_id = data.get("child_id")
if child_id is None:
raise HomeAssistantError(
"child_id is required when action is edit"
)
kwargs = {}
if "display_name" in data:
kwargs["display_name"] = data["display_name"]
if "colour" in data:
kwargs["colour"] = data["colour"]
if "avatar_ref" in data:
kwargs["avatar_ref"] = data["avatar_ref"]
if "sort_order" in data:
kwargs["sort_order"] = data["sort_order"]
await children_layer.edit_child(database, child_id, **kwargs)
return
if action == MANAGE_CHILD_SET_ACTIVE:
child_id = data.get("child_id")
is_active = data.get("is_active")
if child_id is None:
raise HomeAssistantError(
"child_id is required when action is set_active"
)
if is_active is None:
raise HomeAssistantError(
"is_active is required when action is set_active"
)
await children_layer.set_child_active(
database, child_id, is_active
)
return
ordered_ids = data.get("ordered_ids")
if ordered_ids is None:
raise HomeAssistantError(
"ordered_ids is required when action is reorder"
)
await children_layer.reorder_children(database, ordered_ids)
return _with_errors(_handler)
@@ -166,25 +546,84 @@ def async_register_services(
hass: HomeAssistant,
*,
find_runtime: FindRuntime,
regenerate_handler: ServiceHandler,
) -> None:
"""Register the panel completion service once at domain scope.
"""Register every canonical service once at domain scope.
The one remaining HA service is the panel completion path; every
admin operation moved to the API service's admin routes, so there
is no permission gate to apply here.
Every service — regenerate included — passes through the Feature
09 permission gate before its handler runs; the gate reads
:data:`~.service_policy.SERVICE_POLICY`, so no service can be
registered exempt from the policy.
"""
if hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST):
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
return
hass.services.async_register(
DOMAIN,
SERVICE_COMPLETE_QUEST,
_complete_quest(hass, find_runtime),
schema=SCHEMA_COMPLETE_QUEST,
)
gate = permission_gate
specs: list[tuple[str, ServiceHandler, object]] = [
(SERVICE_REGENERATE, regenerate_handler, None),
(
SERVICE_COMPLETE_QUEST,
_complete_quest(hass, find_runtime),
SCHEMA_COMPLETE_QUEST,
),
(
SERVICE_UNCOMPLETE_QUEST,
_uncomplete_quest(hass, find_runtime),
SCHEMA_UNCOMPLETE_QUEST,
),
(
SERVICE_CREATE_QUEST_DEFINITION,
_create_quest_definition(hass, find_runtime),
SCHEMA_CREATE_QUEST_DEFINITION,
),
(
SERVICE_UPDATE_QUEST_DEFINITION,
_update_quest_definition(hass, find_runtime),
SCHEMA_UPDATE_QUEST_DEFINITION,
),
(
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
_set_quest_definition_active(hass, find_runtime),
SCHEMA_SET_QUEST_DEFINITION_ACTIVE,
),
(
SERVICE_SET_PRESENCE_PATTERN,
_set_presence_pattern(hass, find_runtime),
SCHEMA_SET_PRESENCE_PATTERN,
),
(
SERVICE_CREATE_PRESENCE_OVERRIDE,
_create_presence_override(hass, find_runtime),
SCHEMA_CREATE_PRESENCE_OVERRIDE,
),
(
SERVICE_DELETE_PRESENCE_OVERRIDE,
_delete_presence_override(hass, find_runtime),
SCHEMA_DELETE_PRESENCE_OVERRIDE,
),
(
SERVICE_EXPORT_HISTORY_CSV,
_export_history_csv,
SCHEMA_EXPORT_HISTORY_CSV,
),
(
SERVICE_MANAGE_CHILD,
_manage_child(hass, find_runtime),
SCHEMA_MANAGE_CHILD,
),
]
def _database_of(_call: Any) -> Any:
return _require_runtime(hass, find_runtime).database
for service, handler, schema in specs:
gated = gate(
service, SERVICE_POLICY[service], _database_of
)(handler)
hass.services.async_register(DOMAIN, service, gated, schema=schema)
def async_deregister_services(hass: HomeAssistant) -> None:
"""Remove every canonical service when the last entry unloads."""
for service in DOMAIN_SERVICES:
if hass.services.has_service(DOMAIN, service):
hass.services.async_remove(DOMAIN, service)
hass.services.async_remove(DOMAIN, service)
+291 -4
View File
@@ -1,8 +1,15 @@
# Service declarations for the NestQuest integration (Home Assistant
# services.yaml metadata). Services are domain-scoped: nested under the
# "nestquest" domain, not repeated per config entry. Since Feature 18
# only the panel completion service remains — every admin operation
# moved to the PWA and the API service's admin routes.
# "nestquest" domain, not repeated per config entry.
regenerate:
name: Regenerate quest instances
description: >-
Regenerate every future quest instance for the household over the rolling
horizon, using the same idempotent materialization as the daily rollover
and the startup backfill. Safe to call at any time: a re-run refreshes the
same rows instead of duplicating them, and completed instances are never
touched.
complete_quest:
name: Complete quest
@@ -36,4 +43,284 @@ complete_quest:
description: Home Assistant user id when actor is user and no call context is present.
required: false
selector:
text:
text:
uncomplete_quest:
name: Uncomplete quest
description: Undo a quest instance completion.
fields:
instance_id:
name: Instance ID
description: The quest instance to uncomplete.
required: true
selector:
number:
mode: box
actor:
name: Actor
description: Who is undoing the completion (panel or user).
required: true
selector:
select:
options:
- panel
- user
actor_child_id:
name: Actor child ID
description: The tapped child profile. Required when actor is panel.
required: false
selector:
number:
mode: box
actor_user_id:
name: Actor user ID
description: Home Assistant user id when actor is user and no call context is present.
required: false
selector:
text:
create_quest_definition:
name: Create quest definition
description: Create a quest definition with its schedule rule, assignees and windows.
fields:
title:
name: Title
description: Display title for the quest.
required: true
selector:
text:
rule:
name: Rule
description: Recurrence rule as a mapping (rule_type plus shape fields).
required: true
selector:
object:
assignee_child_ids:
name: Assignee child IDs
description: Child ids this definition is assigned to.
required: true
selector:
object:
windows:
name: Windows
description: Day windows (morning, afternoon, evening), optionally with a due time.
required: true
selector:
object:
description:
name: Description
description: Optional longer description.
required: false
selector:
text:
icon:
name: Icon
description: Optional icon name.
required: false
selector:
text:
update_quest_definition:
name: Update quest definition
description: Edit a quest definition's title, description, icon, rule or windows.
fields:
definition_id:
name: Definition ID
description: The quest definition to update.
required: true
selector:
number:
mode: box
title:
name: Title
description: Replacement display title.
required: false
selector:
text:
description:
name: Description
description: Replacement description, or null to clear.
required: false
selector:
text:
icon:
name: Icon
description: Replacement icon, or null to clear.
required: false
selector:
text:
rule:
name: Rule
description: Replacement recurrence rule as a mapping.
required: false
selector:
object:
windows:
name: Windows
description: Replacement day windows.
required: false
selector:
object:
set_quest_definition_active:
name: Set quest definition active
description: Deactivate or reactivate a quest definition.
fields:
definition_id:
name: Definition ID
description: The quest definition to update.
required: true
selector:
number:
mode: box
is_active:
name: Active
description: Whether the definition should generate future instances.
required: true
selector:
boolean:
set_presence_pattern:
name: Set presence pattern
description: Upsert a child's repeating presence schedule.
fields:
child_id:
name: Child ID
description: The child whose schedule is being set.
required: true
selector:
number:
mode: box
cycle_length_weeks:
name: Cycle length weeks
description: Number of weeks in the repeating cycle (1 to 4).
required: true
selector:
number:
mode: box
min: 1
max: 4
anchor_date:
name: Anchor date
description: Cycle anchor as a YYYY-MM-DD date.
required: true
selector:
date:
pattern:
name: Pattern
description: Mapping of week index to present weekday ints (Monday is 0).
required: true
selector:
object:
create_presence_override:
name: Create presence override
description: Create a date-range presence override for a child.
fields:
child_id:
name: Child ID
description: The child the override applies to.
required: true
selector:
number:
mode: box
start_date:
name: Start date
description: Inclusive start date (YYYY-MM-DD).
required: true
selector:
date:
end_date:
name: End date
description: Inclusive end date (YYYY-MM-DD).
required: true
selector:
date:
is_present:
name: Present
description: Whether the child is present for this range.
required: true
selector:
boolean:
note:
name: Note
description: Optional note stored with the override.
required: false
selector:
text:
delete_presence_override:
name: Delete presence override
description: Delete a presence override by id.
fields:
override_id:
name: Override ID
description: The override to delete.
required: true
selector:
number:
mode: box
export_history_csv:
name: Export history CSV
description: Export completion history as CSV. Not implemented until Feature 13.
manage_child:
name: Manage child
description: Create, edit, activate or reorder child profiles.
fields:
action:
name: Action
description: create, edit, set_active, or reorder.
required: true
selector:
select:
options:
- create
- edit
- set_active
- reorder
child_id:
name: Child ID
description: Required for edit and set_active.
required: false
selector:
number:
mode: box
display_name:
name: Display name
description: Required for create; optional for edit.
required: false
selector:
text:
colour:
name: Colour
description: Optional colour string.
required: false
selector:
text:
avatar_ref:
name: Avatar
description: Optional avatar reference.
required: false
selector:
text:
sort_order:
name: Sort order
description: Optional panel sort order.
required: false
selector:
number:
mode: box
is_active:
name: Active
description: Required for set_active.
required: false
selector:
boolean:
ordered_ids:
name: Ordered IDs
description: Complete permutation of child ids. Required for reorder.
required: false
selector:
object:
-172
View File
@@ -1,172 +0,0 @@
"""HA-bus subscription over the API service's panel SSE stream.
The NestQuest API service (Feature 16) publishes every quest
transition on ``GET /api/v1/panel/events`` as Server-Sent Events
frames. This module is the integration-side subscriber: ONE
background task consumes :meth:`~.api_client.NestQuestApiClient
.stream_events` and re-fires each frame on ``hass.bus`` with the SAME
event type and payload fields the local transition path
(:mod:`~.events`) emits — ``nestquest_quest_completed``,
``nestquest_quest_uncompleted``, ``nestquest_quest_missed`` and
``nestquest_child_day_complete`` — so the four blueprints fire
identically whether a completion happens through the integration's own
service or through the API service's panel route.
Failure handling: a failed stream (API down, network blip, non-2xx, a
dropped connection) is retried FOREVER with a bounded exponential
backoff — :data:`RECONNECT_MIN_SECONDS` after the first failure,
doubling up to :data:`RECONNECT_MAX_SECONDS` — so a long API outage
neither pins the event loop nor spams the log, and a frame received on
a healthy stream resets the backoff. Nothing escapes the task: every
failure is logged with its reason and the loop reconnects; a
:class:`asyncio.CancelledError` (from :meth:`stop` or integration
unload) is the only thing that ends it, and it stops cleanly — no
frame fires after :meth:`stop` returns and the response is closed.
"""
from __future__ import annotations
import asyncio
from typing import Any
from .const import LOGGER
__all__ = [
"NestQuestEventStream",
"RECONNECT_MAX_SECONDS",
"RECONNECT_MIN_SECONDS",
]
#: Delay before the FIRST reconnect attempt after a stream failure.
#: One failed frame must not turn into a hot reconnect loop.
RECONNECT_MIN_SECONDS = 5.0
#: Ceiling for the exponential reconnect backoff: a long API outage
#: retries at a calm fixed cadence instead of growing without bound.
RECONNECT_MAX_SECONDS = 60.0
class NestQuestEventStream:
"""Consume the panel SSE stream in the background, fire the HA bus.
ONE instance per config entry, built in integration setup with the
SAME API client the coordinator polls (the client is stateless —
one shared session, one connection pool). ``start()`` schedules
the background task; ``stop()`` cancels and awaits it, so unload
never leaks the task and no frame fires after teardown. The task
itself is failure-proof: any exception from the stream (typed
:class:`~.api_client.NestQuestApiError` or anything else) is
logged and retried after the current backoff, never allowed to
escape the task.
"""
def __init__(
self,
hass: Any,
client: Any,
*,
reconnect_min_seconds: float = RECONNECT_MIN_SECONDS,
reconnect_max_seconds: float = RECONNECT_MAX_SECONDS,
) -> None:
"""Store the hass bus target, the API client, and the backoff.
The client is anything exposing ``stream_events()`` (the real
:class:`~.api_client.NestQuestApiClient` in production, a stub
in tests). The backoff bounds must be positive and the minimum
must not exceed the maximum — a misconfiguration should fail
loudly at construction, not quietly reconnect at delay 0.
"""
if (
not isinstance(reconnect_min_seconds, (int, float))
or isinstance(reconnect_min_seconds, bool)
or reconnect_min_seconds <= 0
or reconnect_min_seconds > reconnect_max_seconds
):
raise ValueError(
"reconnect_min_seconds must be a positive number no "
f"greater than reconnect_max_seconds, got "
f"{reconnect_min_seconds!r}"
)
if (
not isinstance(reconnect_max_seconds, (int, float))
or isinstance(reconnect_max_seconds, bool)
or reconnect_max_seconds <= 0
):
raise ValueError(
f"reconnect_max_seconds must be a positive number, got "
f"{reconnect_max_seconds!r}"
)
self._hass = hass
self._client = client
self._min_backoff = float(reconnect_min_seconds)
self._max_backoff = float(reconnect_max_seconds)
self._backoff = float(reconnect_min_seconds)
self._task: asyncio.Task | None = None
@property
def task(self) -> asyncio.Task | None:
"""The background task, or ``None`` when never started/stopped."""
return self._task
def start(self) -> None:
"""Schedule the subscription task (idempotent while it runs)."""
if self._task is not None and not self._task.done():
return
self._backoff = self._min_backoff
self._task = asyncio.create_task(
self._run(), name="nestquest-sse-subscription"
)
async def stop(self) -> None:
"""Cancel and await the subscription task.
Returns only after the task has actually finished, so unload
can rely on: no further frame is fired after ``stop()``, and
the underlying stream response has been closed. Safe to call
when the task was never started or already stopped.
"""
task, self._task = self._task, None
if task is None:
return
task.cancel()
try:
await task
except asyncio.CancelledError:
# The task's own cancellation surfaces here — expected and
# swallowed; a cancelling the CALLER would re-raise out of
# the surrounding await on its own.
pass
async def _run(self) -> None:
"""Consume stream frames forever, reconnecting on failure."""
while True:
try:
async for event_type, payload in self._client.stream_events():
# A frame arrived on a healthy stream: the next
# failure reconnects quickly again.
self._backoff = self._min_backoff
try:
self._hass.bus.async_fire(event_type, dict(payload))
except Exception as err:
# Firing one frame must never tear down a
# healthy connection: log and keep consuming.
LOGGER.error(
"NestQuest panel event %s could not be fired "
"on the Home Assistant event bus: %s",
event_type,
err,
)
except asyncio.CancelledError:
# Unload / shutdown: stop cleanly, never converted.
raise
except Exception as err:
delay = self._backoff
self._backoff = min(
self._backoff * 2, self._max_backoff
)
LOGGER.warning(
"NestQuest panel event stream failed (%s); "
"reconnecting in %.1f s",
err,
delay,
)
await asyncio.sleep(delay)

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