Compare commits

...
Author SHA1 Message Date
overseerandClaude Opus 5.5 8d74efd495 Hide a previous rule's preview while the edited rule is fetched
Tag each preview result with the rule it answers and render it only while
that rule is still the draft's, so a rule edit clears the old dates or error
immediately. Cover window and assignee edits not requesting, and the 300 ms
debounce boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:25:23 -04:00
overseerandClaude Opus 5.5 a105b2b160 Preview a definition's next occurrences in the edit sheet
The edit sheet now shows the next five dates the backend would materialize
for the draft rule ("Next: Mon 5 Oct · Thu 8 Oct · …"), fetched from
POST /api/v1/admin/quest-definitions/occurrences-preview. Requests are
debounced 300 ms, fire only when the built rule changes, skip incomplete
drafts, drop superseded responses, and a failure shows a non-blocking note.

Task 85b2f166-d9da-45b5-928e-3ff4d2cc6d89

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:19:29 -04:00
overseer d0c236b569 Merge dev into feature/24ce7ebf (Feature 23: admin API completion) 2026-09-23 22:17:08 -04:00
dev_agent b8532bacde Feature 23: Admin API Completion - preview, presence, live (#199-#202) (#203)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 22:16:47 -04:00
dev_agent 33d8e0d2ac Task 2e96585c: Definitions list and edit sheet (#198) 2026-09-23 21:26:27 -04:00
overseer dbe873eb2a Merge dev into feature/24ce7ebf (Feature 22 follow-up: editable assignees) 2026-09-23 21:11:21 -04:00
dev_agent 4b39af4b67 Feature 22 follow-up: Editable definition assignees (#196) (#197)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 21:10:39 -04:00
overseer 706a7954d8 Merge dev into feature/24ce7ebf (Feature 22: definitions read + skip_on_away) 2026-09-23 20:52:48 -04:00
dev_agent 42ea5710d7 Feature 22: Admin Definitions Read and skip_on_away (#193, #194) (#195)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 20:52:06 -04:00
dev_agent be4fd04754 Task e0a2b862: Today tab (#192) 2026-09-23 20:13:52 -04:00
overseer 4ff40f00a2 Merge dev into feature/24ce7ebf (bring the Feature 21 admin snapshot route) 2026-09-23 20:03:05 -04:00
dev_agent a808f89993 Feature 21: Admin Today Snapshot API (#190) (#191)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 20:01:25 -04:00
dev_agent f476821e8e Task 96ab3c0b: PWA manifest and service worker (#189) 2026-09-23 19:42:27 -04:00
dev_agent 75ad9892b8 Task 630c3472: Authentik OIDC PKCE login and refusal handling (#188) 2026-09-23 18:30:07 -04:00
dev_agent 48d0c4ef86 Task 0df715b5: React scaffold, build and serve (#187) 2026-09-23 16:39:36 -04:00
orchestrator-agent 22f1a6861d Merge pull request 'Reconcile dev from main (release 0.6.0 commits)' (#186) from main into dev 2026-09-23 16:16:23 -04:00
overseer 939556a38a Merge pull request 'Release: Re-imagined architecture - Features 15, 16, 18, 20 (2026-09-23)' (#185) from release/2026-09-23-reimagining into main
Reviewed-on: #185
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-23 14:55:38 -04:00
overseer 8676bdc6ad release: correct the 0.6.0 notes - no containerization artifacts ship in this release 2026-09-23 14:35:01 -04:00
overseer 0ed35ab85a release: advance version to 0.6.0 and add the re-imagined architecture release notes 2026-09-23 14:29:12 -04:00
overseer c57d950101 merge: origin/main into 0.6.0 release (bring the architecture doc; keep dev versions and notes) 2026-09-23 14:27:56 -04:00
dev_agent 70ee4ec111 Make the missed-sweep watermark claim safe across processes (#184)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 13:06:40 -04:00
dev_agent 415ababc8e Automatically prepare releases for completed features (#182)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 13:02:44 -04:00
dev_agent ee8ef6dcac Feature 20: Zero-Config Panel Dashboard (#180)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 12:27:35 -04:00
dev_agent 6059a67122 Feature 18: HA Integration as API Client (#173)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 09:19:48 -04:00
overseer 0972761994 Merge pull request 'Add the re-imagined architecture document' (#140) from docs/architecture-reimagining into main
Reviewed-on: #140
2026-09-21 16:09:26 -04:00
overseer 10f365ef9f Add the re-imagined architecture document 2026-09-21 13:10:57 -04:00
166 changed files with 21833 additions and 7837 deletions
+132 -56
View File
@@ -159,9 +159,50 @@ Sensors the automations read: `sensor.nestquest_<child>_quests_due_today`
Run these steps in order to display the NestQuest panel on a TouchHub
wall device. The panel is an HA Lovelace dashboard, so the kiosk browser
is simply a Home Assistant client.
is simply a Home Assistant client. The path below is zero-config: the
integration registers its panel dashboard itself on setup, so no YAML
and no hand-built views are involved.
1. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
1. **Install the integration and add the config entry.** Install
NestQuest via HACS or manually (see Installation above) and restart
Home Assistant. Then, in *Settings → Devices & Services*, click
**Add Integration**, search for **NestQuest**, and submit the flow —
it has no fields; it just creates the single config entry. Then open
the entry, click **Configure**, and fill the panel-plane API
connection in the options flow:
- **NestQuest API base URL** — the full `http://host:port` URL of
your NestQuest API service (default `http://127.0.0.1:8000`).
- **Panel service token** — the token the panel presents to the API
service as its Bearer credential. It may stay empty until you have
one; a pasted token must be unpadded.
The form's remaining fields (panel idle timeout, coordinator update
interval, last-good snapshot staleness) have defaults that serve the
panel and need no change here.
2. **The panel dashboard appears automatically.** On setup the
integration registers one storage-mode Lovelace dashboard: a sidebar
entry titled **NestQuest** at the URL path `/nestquest`, whose stored
config is nothing but the **`custom:nestquest-party` strategy**
(`strategy: { type: custom:nestquest-party }`). The strategy
generates the views at render time from the household roster:
- **The Party** — the party-board view at `/nestquest/party`; the
dashboard root `/nestquest` shows it as the first view.
- one quest-log view per child at `/nestquest/<child-slug>`
("Ada's Quest Log", …), in roster order.
Nothing is hand-configured: no YAML, no manual views, no
`child_order`, no `quest_log_path`/`board_path` — the board and the
quest logs compute their navigation paths from the dashboard's own
URL — and every generated view is already in **Panel** layout (see
the two constraints below). If the Lovelace surfaces are unavailable
(for example when Lovelace runs in YAML mode), setup logs a warning
and skips the registration; create the dashboard manually with the
same strategy as described in the manual alternative below.
3. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
a new user for the wall device. Make it **non-admin**, and ensure it is
not in the NestQuest admin allowlist. The allowlist blocks the NestQuest
**admin** operations — `uncomplete_quest`, `create_quest_definition`,
@@ -174,66 +215,101 @@ is simply a Home Assistant client.
Assistant user it may also reach other permitted Home Assistant entities
and services.
2. **Build a dashboard with only the NestQuest panel cards.** Create a
Lovelace dashboard whose **URL slug is `nestquest`** (Settings →
Dashboards → Edit → URL slug). A functional panel needs **two** views
in that dashboard, each with its own card config from
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
paths (the path segment after the dashboard slug):
4. **Open the dashboard and point TouchHub at it.** Open
`http://<home-assistant>/nestquest` in a browser first and check the
party board renders with your children's crests. Then, in the
TouchHub launcher, add the **Home Assistant Lovelace** app to the
dock and paste the dashboard URL from step 2 — the dashboard root,
`/nestquest` (TouchHub loads that URL directly as the kiosk landing
page). Set TouchHub **Auto-Return** to a value GREATER than the
quest log's `idle_return_seconds` (default 40s) — recommend **60s or
higher**. If Auto-Return is at or below 40s, TouchHub returns to its
launcher before the quest log's idle return can navigate back to the
party board.
- **View 1 path `board`** (URL `/nestquest/board`) — party board
(`type: custom:nestquest-party-board-card`): the household quest
board. Set `weather_entity` (optional; the dock falls back to
date/time), `quest_log_path: /nestquest/log` (the view the crest tap
navigates to), and `child_order: [slug1, slug2, slug3]` listing each
child's slug in display order.
- **View 2 path `log`** (URL `/nestquest/log`) — quest log
(`type: custom:nestquest-quest-log-card`): the per-child quest screen
the party board hops to. Set `board_path: /nestquest/board` (the
party-board view to return to), `weather_entity`,
`idle_return_seconds` (must be shorter than TouchHub Auto-Return),
`confirm_timeout_seconds`, and `complete_screen_seconds`. The quest
log reads the selected child's slug from the view's URL path, so the
board → log hop is a plain Lovelace navigation.
Each view's layout MUST be set to **Panel** (Edit Dashboard → pencil on the
view → View type: Panel). Home Assistant's default Masonry layout constrains
the 1080px-tall NestQuest card to a narrow column; Panel (single-card)
layout is what renders the card full-screen at 1920×1080.
The dashboard contains only the NestQuest panel card for each view —
no other cards, no admin controls.
The `quest_log_path` and `board_path` values must match the dashboard
slug and view paths above: `/<slug>/<view>`. If you chose `nestquest`
as the dashboard slug with view paths `board` and `log`, the card
configs are already correct. If you pick different names, substitute
your actual paths in **both** card configs — e.g. a dashboard slug
`chores` with view paths `main` and `history` needs
`quest_log_path: /chores/history` and `board_path: /chores/main`.
Navigation between the two views fails (the crest tap or back button
lands on a 404) if the card config paths do not match the dashboard
slug and view paths exactly.
See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML.
3. **Point TouchHub at it.** In the TouchHub launcher, add the **Home
Assistant Lovelace** app to the dock and paste the dashboard URL from
step 2 — the party-board view URL, e.g. `/nestquest/board` (TouchHub
loads that URL directly as the kiosk landing page). Set TouchHub
**Auto-Return** to a value GREATER than the quest log's
`idle_return_seconds` (default 40s) — recommend **60s or higher**. If
Auto-Return is at or below 40s, TouchHub returns to its launcher before
the quest log's idle return can navigate back to the party board.
4. **Log the kiosk session in.** Sign in on the device as the dedicated
kiosk user from step 1, and leave that session logged in.
5. **Log the kiosk session in.** Sign in on the device as the dedicated
kiosk user from step 3, and leave that session logged in.
TouchHub loads the dashboard URL directly. The NestQuest cards are normal
Lovelace cards, not an iframe, so no Home Assistant framing setting is
needed.
### The two constraints
- **Panel layout.** Every view must be in **Panel** (single-card)
layout: Home Assistant's default Masonry layout constrains the
1080px-tall NestQuest card to a narrow column, while Panel renders the
card full-screen at 1920×1080. The zero-config path already satisfies
the requirement — the strategy generates every view as
`type: panel` — while the manual alternative below requires setting
the layout by hand on each view (Edit Dashboard → pencil on the view
→ View type: Panel).
- **TouchHub Auto-Return.** TouchHub's **Auto-Return** must be greater
than the quest log's `idle_return_seconds` (default 40s) — recommend
**60s or higher** — so the launcher does not reclaim the wall device
before the quest log's own idle return navigates back to the party
board.
### Manual alternative: build the dashboard by hand
If you prefer not to use the zero-config dashboard above — or its
registration was skipped — configure a Lovelace dashboard whose **URL
slug is `nestquest`** (Settings → Dashboards → Edit → URL slug)
yourself. Two options, in decreasing order of convenience:
- **Use the strategy.** Give the dashboard
```yaml
strategy:
type: custom:nestquest-party
```
and no views of your own — the strategy generates the party-board and
quest-log views (each already in Panel layout) from the household
roster at render time. Optional `url_path` and `weather_entity` keys
may sit beside `strategy:`.
- **Build the two views by hand.** A functional panel needs **two**
views in that dashboard, each with its own card config from
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
paths (the path segment after the dashboard slug):
- **View 1 path `board`** (URL `/nestquest/board`) — party board
(`type: custom:nestquest-party-board-card`): the household quest
board. Set `weather_entity` (optional; the dock falls back to
date/time), `quest_log_path: /nestquest/log` (the view the crest tap
navigates to), and `child_order: [slug1, slug2, slug3]` listing each
child's slug in display order.
- **View 2 path `log`** (URL `/nestquest/log`) — quest log
(`type: custom:nestquest-quest-log-card`): the per-child quest screen
the party board hops to. Set `board_path: /nestquest/board` (the
party-board view to return to), `weather_entity`,
`idle_return_seconds` (must be shorter than TouchHub Auto-Return —
see the two constraints above), `confirm_timeout_seconds`, and
`complete_screen_seconds`. The quest log reads the selected child's
slug from the view's URL path, so the board → log hop is a plain
Lovelace navigation.
The dashboard contains only the NestQuest panel card for each view —
no other cards, no admin controls. Each view's layout MUST be set to
**Panel** — see the two constraints above for why; the strategy
generates it for you, hand-built views do not get it for free.
The `quest_log_path` and `board_path` values must match the dashboard
slug and view paths above: `/<slug>/<view>`. If you chose `nestquest`
as the dashboard slug with view paths `board` and `log`, the card
configs are already correct. If you pick different names, substitute
your actual paths in **both** card configs — e.g. a dashboard slug
`chores` with view paths `main` and `history` needs
`quest_log_path: /chores/history` and `board_path: /chores/main`.
Navigation between the two views fails (the crest tap or back button
lands on a 404) if the card config paths do not match the dashboard
slug and view paths exactly.
See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML.
## Developer Setup and Testing
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
+129 -21
View File
@@ -1,27 +1,135 @@
# NestQuest Release Notes
## Unreleased
## Version 0.6.0 — 2026-09-23
- **Storage layer extraction (task d0b691d8):** the SQLite storage layer
(schema, migrations, db, store, const, recurrence, DAOs) is bundled
under `custom_components/nestquest/core/`, with the integration's
original module names preserved as re-export shims.
- **Logger namespace change (breaking for logging configuration):** the
migrations runner now logs under
`custom_components.nestquest.core.migrations` instead of
`custom_components.nestquest.migrations`. Users who tuned the old
logger name (e.g. in `logger.yaml` or `configuration.yaml`) must
update the entry to keep that level, as the old name no longer
receives migration log output.
- **Additional logger namespace moves:** the children registry and the
admin allowlist business layers now log under
`custom_components.nestquest.core.children` and
`custom_components.nestquest.core.admin_allowlist` respectively
(moved with the engines/business-layer extraction, task 510c1f78).
The parent `custom_components.nestquest` logger still catches both,
so users who tune the parent rather than the leaf names are
unaffected; only users who pinned a leaf logger name by its old
spelling need to update.
### Scope
Sixth release of NestQuest, promoting the re-imagined architecture
(Features 15, 16, 18 and 20) and the foreground development-orchestration
tooling from `dev` onto 0.5.0. This is a major architectural change: the
Home Assistant integration becomes a thin client of a standalone NestQuest
API service that owns the database.
- **Feature 15 — Domain Core Extraction:** all domain code (schema,
migrations, database access, the DAOs, the pure recurrence/presence
engines, materialization, completion, the SSE-free event payload
builders, the panel snapshot builder, the children/definitions/allowlist
business layers, and an explicit settings object) now lives in a
Home-Assistant-free package bundled inside the integration at
`custom_components/nestquest/core/`. The SQLite schema and migration
versions are unchanged, so an existing `nestquest.db` opens without
migration. Covered by a no-Home-Assistant end-to-end smoke test.
(Tasks d0b691d8, 510c1f78, 4dd8f870, 628473ad.)
- **Feature 16 — NestQuest API Service:** a standalone FastAPI service
(`api/`) that imports `core/` and is the only writer of the database.
Panel plane (`/api/v1/panel/*`) is authenticated by a static service
token: household snapshot and an idempotent complete route. Admin plane
(`/api/v1/admin/*`): children, quest definitions, presence
schedule/override, uncomplete, regenerate, history query + CSV export,
and settings — authenticated by an Authentik OIDC JWT (issuer, audience,
JWKS signature and expiry) plus membership in `nestquest-admins`, with
the panel service token rejected outright. A server-sent-events stream
publishes the quest-completed, quest-uncompleted, quest-missed and
child-day-complete transitions; the missed sweep runs in the API with a
cross-process-safe watermark. `/health` and the OpenAPI document are
served. (Tasks 042dfc5c, ae7e90ae, 1860ef76, 4d7bae00, 44c93cfd,
e70e6bfb, 0f2afb29, e4845baa, b586a2f1, e4a9d31b, da0226b3, 2d2dda53,
2b3de7e5, 9047a97b, 9a118da9, 058c7b69, a232c4f5.)
- **Feature 18 — HA Integration as API Client:** the integration no longer
owns a database. It polls `GET /api/v1/panel/snapshot` and builds the
existing sensors (entity ids and per-child instance payload shape
unchanged), subscribes to the SSE stream and re-fires the four HA bus
events, proxies `nestquest.complete_quest` to the panel complete route,
and caches the last-good snapshot so a brief API outage does not blank
the panel. The HA admin services and the service permission gate are
removed; only the panel completion path remains. (Tasks 37d5e806,
6e70d166, b1ed9c45, 47c7c90e, d25b875f, d04f5bf3, ad67fc44.)
- **Feature 20 — Zero-Config Panel Dashboard:** the panel installs with no
hand-written card YAML and no manual dashboard creation. The cards
auto-discover children from a household entity attribute (ordered by the
API's `sort_order`), a Lovelace dashboard strategy generates the
party-board view and one quest-log view per child at render time with
computed navigation paths, and the integration registers the dashboard
on first setup. Navigation and the panel transitions match the design
mockups. (Tasks 1b6d91d2, c859af23, c2d4dd8e, 107b8fbe, 50067c1f,
fc69c940, 67775be9.)
- **Development orchestration tooling:** durable OpenCode controller and
role launchers, the foreground terminal orchestrator, the independent
review/approval gates, and the automatic release-preparation policy.
### Requirements
- Minimum Home Assistant version: **2024.6.0**.
- A running NestQuest API service, reachable at the base URL and panel
service token configured in the integration options. The API service
source ships in `api/`; there is no container image, compose file or
deployment runbook in this release, and Feature 17 — the deployment
(TLS reverse proxy, Authentik, network separation, backups) — is **not**
part of this release and must be completed before the integration is
functional. See `docs/ARCHITECTURE.md` for the target design.
### Breaking changes
- **The HA integration no longer owns a database.** It reads exclusively
from the NestQuest API. The local `nestquest.db` is no longer opened,
migrated or written by the integration (the file is left untouched on
disk). Without a reachable API the entities become unavailable after the
configured staleness threshold; the panel serves the last-good snapshot
in the meantime.
- **HA admin services are removed** (`uncomplete`, quest-definition
create/update/set-active, presence pattern/override, CSV export,
`manage_child`, `regenerate`) and the HA-side permission gate is gone.
Admin operations move to the API, and — once Feature 19 lands — the
admin PWA. Any automation that called an admin service must be
repointed at the API.
- **Logger namespaces moved** under `custom_components.nestquest.core.*`:
the migrations runner now logs under
`custom_components.nestquest.core.migrations` (previously
`custom_components.nestquest.migrations`), and the children registry and
admin allowlist log under `custom_components.nestquest.core.children` and
`custom_components.nestquest.core.admin_allowlist`. The parent
`custom_components.nestquest` logger still catches all three; only users
who pinned a leaf logger name by its old spelling must update.
- **The panel installs with no configuration.** `child_order`,
`quest_log_path` and `board_path` are no longer required (an explicit
value still wins). The integration registers a `custom:nestquest-party`
dashboard on setup; point TouchHub at it.
- **Schema is unchanged at version 7** — no migration runs on upgrade.
### Known Limitations
- **Feature 17 (API deployment and identity) is not in this release.** The
API service source ships in `api/`, but no container image, compose
file, TLS reverse proxy, Authentik application/provider/`nestquest-admins`
group, proven network separation of the panel routes, or backup/restore
is included. Feature 17's containerization groundwork was completed on
its own feature branch, which is **not** merged into `dev` and therefore
is not part of this release. Until that deployment is completed the
integration has nothing to talk to.
- **Feature 19 (admin PWA) is not in this release.** There is no admin UI
in 0.6.0; admin operations are reachable only through the API directly.
- Moving the household database from Home Assistant to the API box is a
manual file copy (the schema is unchanged).
- Single household; single API service.
### Rollback
Schema version is unchanged (7), so no migration is involved in either
direction. Nevertheless **back up `nestquest.db` (plus `-wal`/`-shm`)
before upgrading**: 0.6.0 never touches it, and a downgrade to 0.5.0
resumes using exactly that file.
**HACS installs:**
1. Downgrade via HACS (HACS > Integrations > NestQuest > Redownload > 0.5.0).
2. Restart Home Assistant.
**Manual installs:**
1. Replace `custom_components/nestquest/` with the 0.5.0 tree.
2. Restart Home Assistant.
**Repository operators:** git-revert the 0.6.0 release merge on `main`.
## Version 0.5.0 — 2026-09-18
+13
View File
@@ -0,0 +1,13 @@
# NestQuest Admin PWA — runtime configuration (Vite env).
# Copy to .env (or .env.local) and fill in real values. NEVER commit .env.
# Authentik OIDC provider. Defaults to the issuer below when unset.
VITE_AUTHENTIK_ISSUER=https://auth.cubecraftlabs.com/application/o/nestquest/
# REQUIRED. OAuth2/OIDC client id of the PUBLIC (PKCE) Authentik application.
# No default: the app shows a configuration error screen when this is missing.
# Never set a client_secret — this is a browser SPA using PKCE only.
VITE_AUTHENTIK_CLIENT_ID=
# NestQuest API base URL.
VITE_API_BASE_URL=https://nestquest.cubecraftlabs.com
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#3b3ab8" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>NestQuest Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3024
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"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.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

+14
View File
@@ -0,0 +1,14 @@
{
"name": "NestQuest Admin",
"short_name": "NestQuest",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#f4f4f6",
"theme_color": "#3b3ab8",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+88
View File
@@ -0,0 +1,88 @@
/* NestQuest Admin service worker — hand-written, served from the site root.
Precaches the app shell, serves static assets cache-first, and falls back
to the cached shell for navigations when offline. Only same-origin GET
requests are handled; cross-origin requests (Authentik, the API host) are
never intercepted or cached.
The build (vite-sw-plugin.ts) replaces PRECACHE_ASSETS with the hashed
bundles referenced by index.html and the revision marker with a content
hash, so each release installs a new worker with fresh caches. */
const REVISION = "__NQ_BUILD_REVISION__";
const CACHE_PREFIX = "nestquest-admin-";
const SHELL_CACHE = `${CACHE_PREFIX}shell-${REVISION}`;
const RUNTIME_CACHE = `${CACHE_PREFIX}runtime-${REVISION}`;
const CURRENT_CACHES = [SHELL_CACHE, RUNTIME_CACHE];
const PRECACHE_ASSETS = [];
const SHELL_URLS = [
...new Set([
"/",
"/index.html",
"/manifest.webmanifest",
"/icons/icon-192.png",
"/icons/icon-512.png",
"/icons/icon-maskable-512.png",
...PRECACHE_ASSETS,
]),
];
// Request destinations treated as static assets (cache-first). Anything else
// same-origin (e.g. API calls) goes straight to the network.
const STATIC_DESTINATIONS = ["script", "style", "image", "font", "manifest"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(SHELL_CACHE)
.then((cache) => cache.addAll(SHELL_URLS))
.then(() => self.skipWaiting()),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => key.startsWith(CACHE_PREFIX) && !CURRENT_CACHES.includes(key))
.map((key) => caches.delete(key)),
),
)
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return;
if (new URL(request.url).origin !== self.location.origin) return;
if (request.mode === "navigate") {
event.respondWith(
fetch(request).catch(() =>
caches.match("/index.html", { cacheName: SHELL_CACHE }).then(
(cached) => cached || Response.error(),
),
),
);
return;
}
if (STATIC_DESTINATIONS.includes(request.destination)) {
event.respondWith(cacheFirst(request));
}
});
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok && response.type === "basic") {
const cache = await caches.open(RUNTIME_CACHE);
await cache.put(request, response.clone());
}
return response;
}
+80
View File
@@ -0,0 +1,80 @@
// Generates the committed PWA icons under public/icons/ as genuine PNGs.
// Pure Node (zlib only) — no network, no image dependencies.
// Run: node scripts/generate-icons.mjs
import { writeFileSync } from "node:fs";
import { deflateSync } from "node:zlib";
import { fileURLToPath } from "node:url";
// Brand tokens (design/tokens/nestquest-admin-tokens.css).
const BLUE = [0x3b, 0x3a, 0xb8];
const PURPLE = [0xa0, 0x35, 0xcc];
const WHITE = [0xff, 0xff, 0xff];
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(buf) {
let c = 0xffffffff;
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([len, body, crc]);
}
function encodePng(size, pixel) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // colour type: RGB
const raw = Buffer.alloc(size * (size * 3 + 1));
for (let y = 0; y < size; y++) {
const row = y * (size * 3 + 1);
raw[row] = 0; // filter: none
for (let x = 0; x < size; x++) raw.set(pixel(x, y), row + 1 + x * 3);
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw, { level: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
// Flat brand mark: diagonal blue→purple gradient with a white ring ("nest")
// around a white dot ("quest"). `scale` shrinks the mark for the maskable
// safe zone (inner 80% circle).
function brandMark(size, scale) {
const c = size / 2;
const outer = size * 0.34 * scale;
const inner = size * 0.24 * scale;
const dot = size * 0.1 * scale;
return (x, y) => {
const d = Math.hypot(x + 0.5 - c, y + 0.5 - c);
if ((d <= outer && d >= inner) || d <= dot) return WHITE;
const t = (x + y) / (2 * (size - 1));
return BLUE.map((b, i) => Math.round(b + (PURPLE[i] - b) * t));
};
}
const outDir = new URL("../public/icons/", import.meta.url);
const icons = [
["icon-192.png", 192, 1],
["icon-512.png", 512, 1],
["icon-maskable-512.png", 512, 0.75],
];
for (const [name, size, scale] of icons) {
const path = fileURLToPath(new URL(name, outDir));
writeFileSync(path, encodePng(size, brandMark(size, scale)));
console.log(`wrote ${path}`);
}
+134
View File
@@ -0,0 +1,134 @@
/* 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
@@ -0,0 +1,48 @@
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
@@ -0,0 +1,60 @@
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
@@ -0,0 +1,73 @@
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
@@ -0,0 +1,176 @@
/**
* 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
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ApiForbiddenError,
ApiUnauthorizedError,
apiFetch,
} from "./client";
import { storeTokens } from "../auth/oidc";
import type { AppConfig } from "../config";
const app: AppConfig = {
issuer: "https://auth.example.com/application/o/nestquest",
clientId: "test-client-id",
apiBaseUrl: "https://api.example.com",
authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
tokenEndpoint: "https://auth.example.com/application/o/token/",
userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
redirectUri: "http://localhost:5173/auth/callback",
};
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("API client", () => {
beforeEach(() => {
sessionStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("attaches the Bearer token to API calls", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
await apiFetch("/api/v1/children", {}, app, fetchMock as any);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://api.example.com/api/v1/children");
expect(init.headers.Authorization).toBe("Bearer at-123");
});
it("throws ApiForbiddenError on 403 (authenticated but refused)", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 }));
await expect(
apiFetch("/api/v1/children", {}, app, fetchMock as any),
).rejects.toBeInstanceOf(ApiForbiddenError);
});
it("triggers a login redirect on 401 with no refresh token", async () => {
storeTokens({ access_token: "stale", expires_in: 600 });
const assign = vi.fn();
vi.stubGlobal("location", { ...window.location, assign });
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 401 }));
await expect(
apiFetch("/api/v1/children", {}, app, fetchMock as any),
).rejects.toBeInstanceOf(ApiUnauthorizedError);
expect(assign).toHaveBeenCalledTimes(1);
const url = new URL(assign.mock.calls[0][0] as string);
expect(url.origin + url.pathname).toBe(
"https://auth.example.com/application/o/authorize/",
);
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
});
it("preserves non-401/403 statuses on the post-refresh retry (500 is not mislabeled)", async () => {
storeTokens({ access_token: "stale", refresh_token: "rt-1", expires_in: 600 });
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response("", { status: 401 })) // initial API call
.mockResolvedValueOnce(
jsonResponse({ access_token: "fresh", expires_in: 600 }), // refresh grant
)
.mockResolvedValueOnce(new Response("", { status: 500 })); // retry
const response = await apiFetch("/api/v1/children", {}, app, fetchMock as any);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock.mock.calls[1][0]).toBe(app.tokenEndpoint);
expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe("Bearer fresh");
// A server failure must surface as the real status, not ApiUnauthorizedError.
expect(response.status).toBe(500);
});
});
+93
View File
@@ -0,0 +1,93 @@
/**
* Minimal fetch wrapper for the NestQuest API.
*
* - Attaches `Authorization: Bearer <access_token>` when a token is stored.
* - 401: not authenticated (or token expired) -> trigger a new login.
* - 403: authenticated but refused -> throws ApiForbiddenError so the UI can
* render an explicit refusal message.
*/
import { config, type AppConfig } from "../config";
import {
buildAuthorizeUrl,
getToken,
isExpired,
refreshAccessToken,
} from "../auth/oidc";
export class ApiForbiddenError extends Error {
constructor(message = "Your account is not in the nestquest-admins group.") {
super(message);
this.name = "ApiForbiddenError";
}
}
export class ApiUnauthorizedError extends Error {
constructor(message = "Not authenticated.") {
super(message);
this.name = "ApiUnauthorizedError";
}
}
export interface ApiRequestOptions extends Omit<RequestInit, "headers"> {
headers?: Record<string, string>;
}
/**
* Redirect the browser to Authentik to start a new login.
* Exported for testing injection.
*/
export async function triggerLogin(app: AppConfig = config): Promise<void> {
const url = await buildAuthorizeUrl(app);
window.location.assign(url);
}
export async function apiFetch(
path: string,
options: ApiRequestOptions = {},
app: AppConfig = config,
fetchImpl: typeof fetch = fetch,
): Promise<Response> {
if (isExpired() && (await refreshAccessToken(app, fetchImpl))) {
// token renewed; fall through and use the fresh one
}
const token = getToken();
const headers = {
...(options.headers ?? {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
const response = await fetchImpl(`${app.apiBaseUrl}${path}`, {
...options,
headers,
});
if (response.status === 401) {
// Not authenticated: either no token or an unusable one.
if (await refreshAccessToken(app, fetchImpl)) {
const retryToken = getToken();
const retryResponse = await fetchImpl(`${app.apiBaseUrl}${path}`, {
...options,
headers: {
...(options.headers ?? {}),
...(retryToken ? { Authorization: `Bearer ${retryToken}` } : {}),
},
});
if (retryResponse.status === 403) throw new ApiForbiddenError();
if (retryResponse.ok) return retryResponse;
// Only 401 means "unauthorized"; preserve every other status (e.g. a
// 500 from the API) so callers can react to the real response.
if (retryResponse.status === 401) throw new ApiUnauthorizedError();
return retryResponse;
}
await triggerLogin(app);
throw new ApiUnauthorizedError();
}
if (response.status === 403) {
// Authenticated, but Authentik's token does not belong to nestquest-admins.
throw new ApiForbiddenError();
}
return response;
}
+174
View File
@@ -0,0 +1,174 @@
/**
* Typed client for the admin quest-definition routes and the children list
* the assignee picker reads (/api/v1/admin/quest-definitions, /children).
*/
import { apiFetch } from "./client";
export type WindowName = "morning" | "afternoon" | "evening";
/** Model rule names, as the admin plane serializes ScheduleRule.to_dict. */
export type RuleType =
| "daily"
| "weekly"
| "monthly_day"
| "monthly_weekday"
| "yearly"
| "custom_days";
export interface DefinitionRule {
rule_type: RuleType;
interval: number;
/** 0 = Monday .. 6 = Sunday. */
weekday_set: number[] | null;
day_of_month: number | null;
nth_weekday: number | null;
nth_weekday_weekday: number | null;
month: number | null;
start_date: string;
end_date: string | null;
}
export interface DefinitionAssignee {
id: number;
display_name: string;
}
export interface DefinitionWindow {
window: WindowName;
/** Local wall-clock "HH:MM", or null. */
due_time: string | null;
}
export interface QuestDefinition {
id: number;
title: string;
description: string | null;
icon: string | null;
is_active: boolean;
skip_on_away: boolean;
rule: DefinitionRule;
assignees: DefinitionAssignee[];
windows: DefinitionWindow[];
}
export interface AdminChild {
id: number;
display_name: string;
colour: string | null;
avatar_ref: string | null;
sort_order: number;
is_active: boolean;
}
/** A window declaration as the create/edit bodies accept it. */
export type WindowEntry = WindowName | [WindowName, string | null];
export interface DefinitionCreateBody {
title: string;
rule: DefinitionRule;
assignee_child_ids: number[];
windows: WindowEntry[];
description?: string | null;
icon?: string | null;
skip_on_away?: boolean;
}
export type DefinitionEditBody = Partial<DefinitionCreateBody>;
export const DEFINITIONS_PATH = "/api/v1/admin/quest-definitions";
export const CHILDREN_PATH = "/api/v1/admin/children";
/** A non-2xx save response; `detail` is the API's message when it sent one. */
export class ApiRequestError extends Error {
constructor(
readonly status: number,
readonly detail: string | null,
) {
super(detail ?? `Request failed (HTTP ${status}).`);
this.name = "ApiRequestError";
}
}
/** FastAPI sends `detail` as a string (core errors) or a list of {msg} (body validation). */
function readDetail(body: unknown): string | null {
if (!body || typeof body !== "object" || !("detail" in body)) return null;
const { detail } = body as { detail: unknown };
if (typeof detail === "string") return detail;
if (Array.isArray(detail)) {
const messages = detail
.map((item) => (item && typeof item === "object" ? (item as { msg?: unknown }).msg : null))
.filter((msg): msg is string => typeof msg === "string");
return messages.length > 0 ? messages.join("; ") : null;
}
return null;
}
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
@@ -0,0 +1,55 @@
/**
* Typed client for GET /api/v1/admin/snapshot — today's household snapshot
* that drives the admin Today tab.
*/
import { apiFetch } from "./client";
export type InstanceWindow = "morning" | "afternoon" | "evening";
export type InstanceState = "open" | "completed" | "missed";
export interface SnapshotInstance {
id: number;
definition_id: number;
child_id: number;
title: string;
icon: string | null;
window: InstanceWindow;
/** Local wall-clock "HH:MM", or null when the definition has no due time. */
due_time: string | null;
state: InstanceState;
overdue: boolean;
/** UTC ISO timestamp of the completion event, when completed. */
completed_at: string | null;
on_time: boolean | null;
}
export interface SnapshotChild {
child_id: number;
child_name: string;
present: boolean;
/** "YYYY-MM-DD" of the next present day, or null. */
next_present: string | null;
due_today: number;
completed_today: number;
remaining_today: number;
completion_pct: number;
instances: SnapshotInstance[];
}
export interface AdminSnapshot {
/** "YYYY-MM-DD" in the household's local zone. */
today_iso: string;
/** 1-based day in the custody cycle. */
cycle_day: number;
children: SnapshotChild[];
}
export const SNAPSHOT_PATH = "/api/v1/admin/snapshot";
export async function fetchSnapshot(): Promise<AdminSnapshot> {
const response = await apiFetch(SNAPSHOT_PATH);
if (!response.ok) {
throw new Error(`Snapshot request failed (HTTP ${response.status}).`);
}
return (await response.json()) as AdminSnapshot;
}
+173
View File
@@ -0,0 +1,173 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildAuthorizeUrl,
clearAuth,
getToken,
handleCallback,
logout,
storeTokens,
} from "./oidc";
import type { AppConfig } from "../config";
function testApp(): AppConfig {
return {
issuer: "https://auth.example.com/application/o/nestquest",
clientId: "test-client-id",
apiBaseUrl: "https://api.example.com",
authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
tokenEndpoint: "https://auth.example.com/application/o/token/",
userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
redirectUri: "http://localhost:5173/auth/callback",
};
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("OIDC flow", () => {
beforeEach(() => {
sessionStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("buildAuthorizeUrl carries all required params and stores state/verifier", async () => {
const app = testApp();
const url = new URL(await buildAuthorizeUrl(app));
expect(url.origin + url.pathname).toBe(app.authorizeEndpoint);
expect(url.searchParams.get("response_type")).toBe("code");
expect(url.searchParams.get("client_id")).toBe("test-client-id");
expect(url.searchParams.get("redirect_uri")).toBe(app.redirectUri);
expect(url.searchParams.get("scope")).toBe("openid email profile");
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(url.searchParams.get("state")).toMatch(/^[A-Za-z0-9_-]+$/);
expect(sessionStorage.getItem("nestquest.oidc_state")).toBe(
url.searchParams.get("state"),
);
expect(sessionStorage.getItem("nestquest.code_verifier")).toMatch(/^[A-Za-z0-9-._~]{43,128}$/);
});
it("handleCallback rejects a mismatched state", async () => {
await buildAuthorizeUrl(testApp());
sessionStorage.setItem("nestquest.oidc_state", "different-state");
const fetchMock = vi.fn();
await expect(
handleCallback("?code=abc&state=tampered", testApp(), fetchMock as any),
).rejects.toThrow(/state mismatch/i);
expect(fetchMock).not.toHaveBeenCalled();
expect(getToken()).toBeUndefined();
});
it("handleCallback exchanges the code with the correct form body and stores the token", async () => {
await buildAuthorizeUrl(testApp());
const verifier = sessionStorage.getItem("nestquest.code_verifier")!;
const state = sessionStorage.getItem("nestquest.oidc_state")!;
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
access_token: "at-123",
refresh_token: "rt-456",
id_token: "id-789",
expires_in: 300,
}),
);
await handleCallback(`?code=abc&state=${state}`, testApp(), fetchMock as any);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://auth.example.com/application/o/token/");
expect(init.method).toBe("POST");
expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
const body = new URLSearchParams(init.body);
expect(body.get("grant_type")).toBe("authorization_code");
expect(body.get("code")).toBe("abc");
expect(body.get("redirect_uri")).toBe(testApp().redirectUri);
expect(body.get("client_id")).toBe("test-client-id");
expect(body.get("code_verifier")).toBe(verifier);
expect(body.has("client_secret")).toBe(false);
expect(getToken()).toBe("at-123");
// one-time values consumed
expect(sessionStorage.getItem("nestquest.oidc_state")).toBeNull();
expect(sessionStorage.getItem("nestquest.code_verifier")).toBeNull();
});
it("handleCallback surfaces provider errors", async () => {
await expect(
handleCallback("?error=access_denied&error_description=nope", testApp(), vi.fn() as any),
).rejects.toThrow(/nope|access_denied/);
});
it("clearAuth removes all stored auth data", () => {
storeTokens({ access_token: "a", refresh_token: "r", expires_in: 60 });
clearAuth();
expect(getToken()).toBeUndefined();
expect(sessionStorage.length).toBe(0);
});
it("logout clears auth and redirects to the end-session endpoint", async () => {
storeTokens({ access_token: "a", id_token: "idt", expires_in: 60 });
const assign = vi.fn();
const original = window.location;
vi.stubGlobal("location", { ...original, assign, origin: "http://localhost:5173" });
await logout(testApp());
expect(assign).toHaveBeenCalledTimes(1);
const url = new URL(assign.mock.calls[0][0] as string);
expect(url.toString()).toBe(
"https://auth.example.com/application/o/nestquest/end-session/?id_token_hint=idt&client_id=test-client-id&post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A5173",
);
expect(getToken()).toBeUndefined();
vi.unstubAllGlobals();
});
it("buildAuthorizeUrl resolves the authorize endpoint via discovery for a non-default issuer", async () => {
const issuer = "https://sso.discovery.example/application/o/discovered";
const discoveryDoc = {
issuer,
authorization_endpoint: "https://sso.discovery.example/application/o/authorize/",
token_endpoint: "https://sso.discovery.example/application/o/token/",
userinfo_endpoint: "https://sso.discovery.example/application/o/userinfo/",
end_session_endpoint: `${issuer}/end-session/`,
jwks_uri: `${issuer}/jwks/`,
};
const fetchMock = vi.fn().mockImplementation((url: string) =>
Promise.resolve(
String(url).endsWith("/.well-known/openid-configuration")
? jsonResponse(discoveryDoc)
: jsonResponse({}),
),
);
const app: AppConfig = {
issuer,
clientId: "test-client-id",
apiBaseUrl: "https://api.example.com",
redirectUri: "http://localhost:5173/auth/callback",
};
const url = new URL(await buildAuthorizeUrl(app, fetchMock as any));
// The authorize endpoint comes from the discovery document — Authentik
// serves it outside the app-specific issuer path, so it is NOT
// `${issuer}/authorize/` and must never be a hardcoded production host.
expect(url.origin + url.pathname).toBe(discoveryDoc.authorization_endpoint);
expect(url.searchParams.get("client_id")).toBe("test-client-id");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(String(fetchMock.mock.calls[0][0])).toBe(
`${issuer}/.well-known/openid-configuration`,
);
});
});
+225
View File
@@ -0,0 +1,225 @@
/**
* OIDC authorization-code + PKCE flow for a PUBLIC client.
*
* Never sends a client_secret anywhere. Tokens are stored in sessionStorage
* (per-tab, cleared on tab close) — never localStorage.
*/
import { config, resolveEndpoints, type AppConfig, type ResolvedAppConfig } from "../config";
import {
computeCodeChallenge,
generateCodeVerifier,
generateState,
} from "./pkce";
const STORAGE_KEYS = {
accessToken: "nestquest.access_token",
refreshToken: "nestquest.refresh_token",
expiresAt: "nestquest.expires_at",
idToken: "nestquest.id_token",
state: "nestquest.oidc_state",
codeVerifier: "nestquest.code_verifier",
} as const;
export class AuthError extends Error {}
export interface TokenResponse {
access_token?: string;
refresh_token?: string;
id_token?: string;
expires_in?: number;
token_type?: string;
}
function makeScopes(): string {
return "openid email profile";
}
/**
* Build the Authentik authorization redirect URL, storing state + verifier in
* sessionStorage so the callback can validate them. Endpoints are resolved
* from the issuer's OIDC discovery document when not set on the config.
*/
export async function buildAuthorizeUrl(
app: AppConfig = config,
fetchImpl: typeof fetch = fetch,
): Promise<string> {
if (!app.clientId) {
throw new AuthError(
"Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
);
}
const resolved = await resolveEndpoints(app, fetchImpl);
const state = generateState();
const codeVerifier = generateCodeVerifier();
const codeChallenge = await computeCodeChallenge(codeVerifier);
sessionStorage.setItem(STORAGE_KEYS.state, state);
sessionStorage.setItem(STORAGE_KEYS.codeVerifier, codeVerifier);
const url = new URL(resolved.authorizeEndpoint);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", app.clientId);
url.searchParams.set("redirect_uri", app.redirectUri);
url.searchParams.set("scope", makeScopes());
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
return url.toString();
}
/**
* Handle the OAuth callback: validate state, exchange the code at the token
* endpoint (PKCE, form-urlencoded, no client_secret), store tokens.
*/
export async function handleCallback(
query: URLSearchParams | string = window.location.search,
app: AppConfig = config,
fetchImpl: typeof fetch = fetch,
): Promise<void> {
const params =
typeof query === "string" ? new URLSearchParams(query) : query;
const providerError = params.get("error");
if (providerError) {
throw new AuthError(
params.get("error_description") || `Provider returned error: ${providerError}`,
);
}
const code = params.get("code");
const state = params.get("state");
if (!code || !state) {
throw new AuthError("Callback is missing the code or state parameter.");
}
const storedState = sessionStorage.getItem(STORAGE_KEYS.state);
const codeVerifier = sessionStorage.getItem(STORAGE_KEYS.codeVerifier);
if (!storedState || storedState !== state || !codeVerifier) {
throw new AuthError("State mismatch — possible CSRF; refusing to log in.");
}
if (!app.clientId) {
throw new AuthError(
"Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
);
}
const resolved: ResolvedAppConfig = await resolveEndpoints(app, fetchImpl);
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: app.redirectUri,
client_id: app.clientId,
code_verifier: codeVerifier,
});
const response = await fetchImpl(resolved.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
if (!response.ok) {
throw new AuthError(`Token exchange failed with HTTP ${response.status}`);
}
const tokens = (await response.json()) as TokenResponse;
if (!tokens.access_token) {
throw new AuthError("Token response did not include an access token.");
}
storeTokens(tokens);
// One-time use values are consumed on success.
sessionStorage.removeItem(STORAGE_KEYS.state);
sessionStorage.removeItem(STORAGE_KEYS.codeVerifier);
}
export function storeTokens(tokens: TokenResponse): void {
sessionStorage.setItem(STORAGE_KEYS.accessToken, tokens.access_token!);
if (tokens.refresh_token) {
sessionStorage.setItem(STORAGE_KEYS.refreshToken, tokens.refresh_token);
}
if (tokens.id_token) {
sessionStorage.setItem(STORAGE_KEYS.idToken, tokens.id_token);
}
if (typeof tokens.expires_in === "number") {
sessionStorage.setItem(
STORAGE_KEYS.expiresAt,
String(Date.now() + tokens.expires_in * 1000),
);
}
}
export function getToken(): string | undefined {
return sessionStorage.getItem(STORAGE_KEYS.accessToken) ?? undefined;
}
export function getRefreshToken(): string | undefined {
return sessionStorage.getItem(STORAGE_KEYS.refreshToken) ?? undefined;
}
export function getIdToken(): string | undefined {
return sessionStorage.getItem(STORAGE_KEYS.idToken) ?? undefined;
}
export function isAuthenticated(): boolean {
return Boolean(getToken());
}
/** True once the stored access token has passed its (approximate) expiry. */
export function isExpired(): boolean {
const expiresAt = sessionStorage.getItem(STORAGE_KEYS.expiresAt);
if (!expiresAt) return false;
return Date.now() >= Number(expiresAt);
}
/**
* Refresh the access token with the refresh token grant. Returns false when
* no refresh token is stored or the grant fails (caller should re-login).
*/
export async function refreshAccessToken(
app: AppConfig = config,
fetchImpl: typeof fetch = fetch,
): Promise<boolean> {
const refreshToken = getRefreshToken();
if (!refreshToken || !app.clientId) return false;
const resolved = await resolveEndpoints(app, fetchImpl);
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: app.clientId,
});
const response = await fetchImpl(resolved.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
if (!response.ok) return false;
const tokens = (await response.json()) as TokenResponse;
if (!tokens.access_token) return false;
storeTokens(tokens);
return true;
}
export function clearAuth(): void {
for (const key of Object.values(STORAGE_KEYS)) {
sessionStorage.removeItem(key);
}
}
/** Sign out: clear local auth, then send the browser to the end-session endpoint. */
export async function logout(
app: AppConfig = config,
fetchImpl: typeof fetch = fetch,
): Promise<void> {
const resolved = await resolveEndpoints(app, fetchImpl);
const idTokenHint = getIdToken();
clearAuth();
const params = new URLSearchParams();
if (idTokenHint) params.set("id_token_hint", idTokenHint);
if (app.clientId) params.set("client_id", app.clientId);
params.set("post_logout_redirect_uri", window.location.origin);
window.location.assign(`${resolved.endSessionEndpoint}?${params.toString()}`);
}
+41
View File
@@ -0,0 +1,41 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
base64UrlEncode,
computeCodeChallenge,
generateCodeVerifier,
generateState,
isLikelyValidVerifier,
} from "./pkce";
describe("PKCE helpers", () => {
it("base64UrlEncode has no padding and url-safe characters", () => {
// bytes chosen to force "+" and "/" and padding in plain base64
const bytes = new Uint8Array([0xfb, 0xff, 0xbf, 0xff]);
const encoded = base64UrlEncode(bytes);
expect(encoded).not.toMatch(/[+/=]/);
});
it("computes the RFC 7636 appendix-B S256 vector", async () => {
const verifier =
"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const challenge = await computeCodeChallenge(verifier);
expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
});
it("generates verifiers with valid charset and length", () => {
for (let i = 0; i < 20; i++) {
const verifier = generateCodeVerifier();
expect(isLikelyValidVerifier(verifier)).toBe(true);
}
});
it("rejects verifiers outside 43-128 chars or with reserved chars", () => {
expect(isLikelyValidVerifier("short")).toBe(false);
expect(isLikelyValidVerifier("a".repeat(129))).toBe(false);
expect(isLikelyValidVerifier("a".repeat(43) + "+")).toBe(false);
});
it("generates distinct random states", () => {
expect(generateState()).not.toBe(generateState());
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* PKCE (RFC 7636) helpers for a public SPA client.
*
* Uses the Web Crypto API, which requires a SECURE CONTEXT: the app must be
* served over https or http://localhost, otherwise crypto.subtle is
* unavailable and login cannot work.
*/
const VERIFIER_CHARSET =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
/** base64url-encode bytes with NO padding. */
export function base64UrlEncode(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function randomBase64Url(byteLength: number): string {
const bytes = new Uint8Array(byteLength);
crypto.getRandomValues(bytes);
return base64UrlEncode(bytes);
}
/**
* Random code_verifier: 43-128 chars, unreserved charset only.
* 32 random bytes -> 43 base64url chars, which is the minimum allowed length.
*/
export function generateCodeVerifier(): string {
return randomBase64Url(32);
}
/** S256 code_challenge = base64url(SHA-256(verifier)), no padding. */
export async function computeCodeChallenge(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier),
);
return base64UrlEncode(new Uint8Array(digest));
}
/** Random opaque state value for CSRF protection. */
export function generateState(): string {
return randomBase64Url(16);
}
export function isLikelyValidVerifier(verifier: string): boolean {
return (
verifier.length >= 43 &&
verifier.length <= 128 &&
[...verifier].every((char) => VERIFIER_CHARSET.includes(char))
);
}
+215
View File
@@ -0,0 +1,215 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
ConfigError,
discoveryUrlForIssuer,
readConfig,
resolveEndpoints,
resolveEndpointsFromDiscovery,
type AppConfig,
} from "./config";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
// Mirrors Authentik's layout: authorize/token/userinfo live OUTSIDE the
// application-specific issuer path, end-session/jwks under it.
function discoveryDocument(issuer: string) {
const normalized = issuer.replace(/\/+$/, "");
const origin = new URL(normalized).origin;
return {
issuer,
authorization_endpoint: `${origin}/application/o/authorize/`,
token_endpoint: `${origin}/application/o/token/`,
userinfo_endpoint: `${origin}/application/o/userinfo/`,
end_session_endpoint: `${normalized}/end-session/`,
jwks_uri: `${normalized}/jwks/`,
};
}
describe("config", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("readConfig keeps the production issuer as the default", () => {
const cfg = readConfig({});
expect(cfg.issuer).toBe("https://auth.cubecraftlabs.com/application/o/nestquest");
expect(cfg.clientId).toBeUndefined();
});
it("readConfig normalizes a trailing slash off a custom issuer", () => {
expect(
readConfig({ VITE_AUTHENTIK_ISSUER: "https://sso.custom.example/application/o/custom/" })
.issuer,
).toBe("https://sso.custom.example/application/o/custom");
});
it("readConfig hardcodes no OIDC endpoints — they come from discovery", () => {
const cfg = readConfig({});
expect(cfg.authorizeEndpoint).toBeUndefined();
expect(cfg.tokenEndpoint).toBeUndefined();
expect(cfg.userinfoEndpoint).toBeUndefined();
expect(cfg.endSessionEndpoint).toBeUndefined();
expect(cfg.jwksUri).toBeUndefined();
});
it("builds the discovery URL for issuers with and without a trailing slash", () => {
expect(discoveryUrlForIssuer("https://sso.example.com/application/o/app/")).toBe(
"https://sso.example.com/application/o/app/.well-known/openid-configuration",
);
expect(discoveryUrlForIssuer("https://sso.example.com/application/o/app")).toBe(
"https://sso.example.com/application/o/app/.well-known/openid-configuration",
);
});
it("resolves every endpoint from the discovery document of a non-default issuer", async () => {
const issuer = "https://sso.other-host.example/application/o/other-app";
const doc = discoveryDocument(issuer);
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
const app: AppConfig = {
issuer,
clientId: "client-id",
apiBaseUrl: "https://api.example.com",
redirectUri: "http://localhost:5173/auth/callback",
};
const resolved = await resolveEndpoints(app, fetchMock as unknown as typeof fetch);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://sso.other-host.example/application/o/other-app/.well-known/openid-configuration",
);
// Every endpoint comes verbatim from the (non-default) discovery document.
expect(resolved.authorizeEndpoint).toBe(doc.authorization_endpoint);
expect(resolved.tokenEndpoint).toBe(doc.token_endpoint);
expect(resolved.userinfoEndpoint).toBe(doc.userinfo_endpoint);
expect(resolved.endSessionEndpoint).toBe(doc.end_session_endpoint);
expect(resolved.jwksUri).toBe(doc.jwks_uri);
// Proves no hardcoded production host leaked into the resolved endpoints.
for (const endpoint of [
resolved.authorizeEndpoint,
resolved.tokenEndpoint,
resolved.userinfoEndpoint,
resolved.endSessionEndpoint,
resolved.jwksUri,
]) {
expect(endpoint).not.toMatch(/cubecraftlabs/);
}
});
it("resolves discovery for an issuer configured with a trailing slash", async () => {
const issuer = "https://sso.slashy.example/application/o/slashy/";
const doc = discoveryDocument(issuer);
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
const resolved = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
expect(fetchMock).toHaveBeenCalledWith(
"https://sso.slashy.example/application/o/slashy/.well-known/openid-configuration",
);
expect(resolved.authorizeEndpoint).toBe(doc.authorization_endpoint);
expect(resolved.endSessionEndpoint).toBe(doc.end_session_endpoint);
});
it("caches discovery per issuer for the session", async () => {
const issuer = "https://sso.cached.example/application/o/cached";
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(discoveryDocument(issuer)));
const first = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
const second = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(second).toBe(first);
});
it("surfaces a clear configuration error when discovery returns an HTTP error", async () => {
const issuer = "https://sso.http-error.example/application/o/broken";
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 500 }));
const error = await resolveEndpointsFromDiscovery(issuer, fetchMock as any).then(
() => null,
(error: unknown) => error,
);
expect(error).toBeInstanceOf(ConfigError);
expect((error as Error).message).toContain(issuer);
expect((error as Error).message).toContain("HTTP 500");
});
it("surfaces a clear configuration error when discovery is unreachable", async () => {
const issuer = "https://sso.unreachable.example/application/o/unreachable";
const fetchMock = vi.fn().mockRejectedValue(new TypeError("network down"));
await expect(
resolveEndpointsFromDiscovery(issuer, fetchMock as any),
).rejects.toBeInstanceOf(ConfigError);
});
it("surfaces a clear configuration error when the discovery document is incomplete", async () => {
const issuer = "https://sso.incomplete.example/application/o/incomplete";
const doc = discoveryDocument(issuer) as Record<string, string>;
delete doc.end_session_endpoint;
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
await expect(
resolveEndpointsFromDiscovery(issuer, fetchMock as any),
).rejects.toThrow(/end_session_endpoint/);
});
it("rejects a discovery document advertising a different issuer", async () => {
const issuer = "https://sso.mismatch.example/application/o/mismatch";
const doc = {
...discoveryDocument(issuer),
issuer: "https://sso.elsewhere.example/application/o/elsewhere",
};
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
await expect(
resolveEndpointsFromDiscovery(issuer, fetchMock as any),
).rejects.toThrow(/issuer mismatch/i);
});
it("the bundled config resolves its endpoints from discovery at runtime", async () => {
const issuer = "https://sso.bundled.example/application/o/bundled";
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(discoveryDocument(issuer)));
const bundled = readConfig({ VITE_AUTHENTIK_ISSUER: `${issuer}/`, VITE_AUTHENTIK_CLIENT_ID: "bundled-client" });
expect(bundled.authorizeEndpoint).toBeUndefined();
const resolved = await resolveEndpoints(bundled, fetchMock as any);
expect(resolved.authorizeEndpoint).toBe(
"https://sso.bundled.example/application/o/authorize/",
);
expect(resolved.tokenEndpoint).toBe("https://sso.bundled.example/application/o/token/");
expect(resolved.userinfoEndpoint).toBe(
"https://sso.bundled.example/application/o/userinfo/",
);
expect(resolved.endSessionEndpoint).toBe(
"https://sso.bundled.example/application/o/bundled/end-session/",
);
expect(resolved.jwksUri).toBe("https://sso.bundled.example/application/o/bundled/jwks/");
});
it("does not re-fetch discovery when the config already carries endpoints", async () => {
const app: AppConfig = {
issuer: "https://auth.example.com/application/o/nestquest",
clientId: "client-id",
apiBaseUrl: "https://api.example.com",
authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
tokenEndpoint: "https://auth.example.com/application/o/token/",
userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
redirectUri: "http://localhost:5173/auth/callback",
};
const fetchMock = vi.fn();
const resolved = await resolveEndpoints(app, fetchMock as any);
expect(fetchMock).not.toHaveBeenCalled();
expect(resolved).toBe(app);
});
});
+221
View File
@@ -0,0 +1,221 @@
/**
* Runtime configuration, read from Vite env (import.meta.env).
* See .env.example for the variables and their defaults.
*
* OIDC endpoints are NOT hardcoded here. They are resolved lazily from the
* issuer's OIDC discovery document (see resolveEndpoints), because Authentik
* serves authorize/token/userinfo OUTSIDE the application-specific issuer
* path (issuer is /application/o/<slug>/, authorize is /application/o/authorize/).
* Both hardcoding the production host and naively concatenating onto the
* issuer would break non-default issuers; discovery is the only correct way.
*/
export interface AppConfig {
issuer: string;
clientId: string | undefined;
apiBaseUrl: string;
/** Resolved from the issuer's OIDC discovery document — see resolveEndpoints(). */
authorizeEndpoint?: string;
tokenEndpoint?: string;
userinfoEndpoint?: string;
endSessionEndpoint?: string;
jwksUri?: string;
redirectUri: string;
}
/** AppConfig whose OIDC endpoints have been filled in by resolveEndpoints(). */
export type ResolvedAppConfig = AppConfig &
Required<
Pick<
AppConfig,
"authorizeEndpoint" | "tokenEndpoint" | "userinfoEndpoint" | "endSessionEndpoint"
>
>;
export interface DiscoveredEndpoints {
issuer: string;
authorizeEndpoint: string;
tokenEndpoint: string;
userinfoEndpoint: string;
endSessionEndpoint: string;
jwksUri: string;
}
/** Raised when OIDC discovery fails or the configuration is unusable. */
export class ConfigError extends Error {
constructor(message: string) {
super(message);
this.name = "ConfigError";
}
}
const REQUIRED_ENDPOINT_NAMES = [
"authorizeEndpoint",
"tokenEndpoint",
"userinfoEndpoint",
"endSessionEndpoint",
] as const;
export function readConfig(env: Record<string, string | undefined> = import.meta.env as any): AppConfig {
const issuer = (
env.VITE_AUTHENTIK_ISSUER || "https://auth.cubecraftlabs.com/application/o/nestquest/"
).replace(/\/$/, "");
const clientId = env.VITE_AUTHENTIK_CLIENT_ID;
const apiBaseUrl = env.VITE_API_BASE_URL || "https://nestquest.cubecraftlabs.com";
return {
issuer,
// No default: a missing client id must surface as a configuration error
// screen, not a broken redirect to Authentik.
clientId: clientId || undefined,
apiBaseUrl,
// authorize/token/userinfo/end-session endpoints are resolved from the
// issuer's discovery document at runtime — never hardcoded here.
redirectUri: `${window.location.origin}/auth/callback`,
};
}
export const config: AppConfig = readConfig();
interface DiscoveryDocument {
issuer?: unknown;
authorization_endpoint?: unknown;
token_endpoint?: unknown;
userinfo_endpoint?: unknown;
end_session_endpoint?: unknown;
jwks_uri?: unknown;
}
function normalizeIssuer(issuer: string): string {
return issuer.replace(/\/+$/, "");
}
function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
/** Discovery document location for an issuer, tolerant of a trailing slash. */
export function discoveryUrlForIssuer(issuer: string): string {
return `${normalizeIssuer(issuer)}/.well-known/openid-configuration`;
}
async function fetchDiscoveredEndpoints(
issuer: string,
fetchImpl: typeof fetch,
): Promise<DiscoveredEndpoints> {
const normalizedIssuer = normalizeIssuer(issuer);
const discoveryUrl = `${normalizedIssuer}/.well-known/openid-configuration`;
let response: Response;
try {
response = await fetchImpl(discoveryUrl);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new ConfigError(
`OIDC discovery request to ${discoveryUrl} failed (${reason}). ` +
"Check VITE_AUTHENTIK_ISSUER and network reachability.",
);
}
if (!response.ok) {
throw new ConfigError(
`OIDC discovery failed: ${discoveryUrl} returned HTTP ${response.status}. ` +
"Check VITE_AUTHENTIK_ISSUER.",
);
}
let doc: DiscoveryDocument;
try {
doc = (await response.json()) as DiscoveryDocument;
} catch {
throw new ConfigError(
`OIDC discovery document at ${discoveryUrl} is not valid JSON. Check VITE_AUTHENTIK_ISSUER.`,
);
}
const authorizationEndpoint = nonEmptyString(doc.authorization_endpoint);
const tokenEndpoint = nonEmptyString(doc.token_endpoint);
const userinfoEndpoint = nonEmptyString(doc.userinfo_endpoint);
const endSessionEndpoint = nonEmptyString(doc.end_session_endpoint);
const jwksUri = nonEmptyString(doc.jwks_uri);
const discoveredIssuer = nonEmptyString(doc.issuer);
const endpointFields: Array<[string, string | undefined]> = [
["authorization_endpoint", authorizationEndpoint],
["token_endpoint", tokenEndpoint],
["userinfo_endpoint", userinfoEndpoint],
["end_session_endpoint", endSessionEndpoint],
["jwks_uri", jwksUri],
];
const missing = endpointFields.filter(([, value]) => !value).map(([name]) => name);
if (missing.length > 0) {
throw new ConfigError(
`OIDC discovery document at ${discoveryUrl} is missing: ${missing.join(", ")}. ` +
"The issuer must point at an OIDC provider that publishes these endpoints.",
);
}
if (discoveredIssuer && normalizeIssuer(discoveredIssuer) !== normalizedIssuer) {
throw new ConfigError(
`OIDC discovery issuer mismatch: ${discoveryUrl} advertises "${discoveredIssuer}" ` +
`but VITE_AUTHENTIK_ISSUER is "${normalizedIssuer}".`,
);
}
return {
issuer: normalizedIssuer,
authorizeEndpoint: authorizationEndpoint!,
tokenEndpoint: tokenEndpoint!,
userinfoEndpoint: userinfoEndpoint!,
endSessionEndpoint: endSessionEndpoint!,
jwksUri: jwksUri!,
};
}
/**
* Session cache: one discovery fetch per issuer per SPA session. Failed
* lookups are evicted so a transient network error can be retried instead of
* poisoning the whole session.
*/
const discoveryCache = new Map<string, Promise<DiscoveredEndpoints>>();
export function resolveEndpointsFromDiscovery(
issuer: string,
fetchImpl: typeof fetch = fetch,
): Promise<DiscoveredEndpoints> {
const normalizedIssuer = normalizeIssuer(issuer);
const cached = discoveryCache.get(normalizedIssuer);
if (cached) return cached;
const pending = fetchDiscoveredEndpoints(normalizedIssuer, fetchImpl);
discoveryCache.set(normalizedIssuer, pending);
pending.catch(() => {
discoveryCache.delete(normalizedIssuer);
});
return pending;
}
/**
* Return `app` with all OIDC endpoints filled in. When the config already
* carries them (pre-resolved config, tests) this is a no-op; otherwise the
* issuer's discovery document is fetched (cached for the session) and its
* authorization/token/userinfo/end-session/jwks endpoints are used.
*/
export async function resolveEndpoints(
app: AppConfig,
fetchImpl: typeof fetch = fetch,
): Promise<ResolvedAppConfig> {
const missing = REQUIRED_ENDPOINT_NAMES.filter((name) => !app[name]);
if (missing.length === 0) {
return app as ResolvedAppConfig;
}
const discovered = await resolveEndpointsFromDiscovery(app.issuer, fetchImpl);
return {
...app,
authorizeEndpoint: discovered.authorizeEndpoint,
tokenEndpoint: discovered.tokenEndpoint,
userinfoEndpoint: discovered.userinfoEndpoint,
endSessionEndpoint: discovered.endSessionEndpoint,
jwksUri: discovered.jwksUri,
};
}
+561
View File
@@ -0,0 +1,561 @@
/* 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;
}
@@ -0,0 +1,920 @@
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
@@ -0,0 +1,935 @@
/**
* 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
@@ -0,0 +1,111 @@
/**
* 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
@@ -0,0 +1,67 @@
/**
* Inline stroke glyphs for the Definitions tab (Lucide-style 24px grid),
* CDN-free like the Today tab's. Definition icons arrive with the icon
* picker task; until then every row carries the generic task glyph.
*/
import type { ReactNode } from "react";
function Glyph({ size, children }: { size: number; children: ReactNode }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
);
}
export function TaskGlyph({ size = 17 }: { size?: number }) {
return (
<Glyph size={size}>
<rect x="8" y="2" width="8" height="4" rx="1" />
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<path d="m9 14 2 2 4-4" />
</Glyph>
);
}
export function PlusGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="M12 5v14M5 12h14" />
</Glyph>
);
}
export function ChevronRightGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="m9 18 6-6-6-6" />
</Glyph>
);
}
export function ChevronDownGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="m6 9 6 6 6-6" />
</Glyph>
);
}
export function CheckGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="M20 6 9 17l-5-5" />
</Glyph>
);
}
+71
View File
@@ -0,0 +1,71 @@
/* 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
@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import AppRoutes from "./AppRoutes";
import "./index.css";
import { registerServiceWorker } from "./pwa/register";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<AppRoutes />
</StrictMode>,
);
void registerServiceWorker();
+396
View File
@@ -0,0 +1,396 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
REVISION_MARKER,
STATIC_PRECACHE_FILES,
injectServiceWorker,
} from "../../vite-sw-plugin";
import { registerServiceWorker } from "./register";
const adminRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const adminPath = (rel: string) => resolve(adminRoot, rel);
const publicPath = (urlPath: string) => adminPath(`public${urlPath}`);
interface ManifestIcon {
src: string;
sizes: string;
type: string;
purpose?: string;
}
const manifest = JSON.parse(readFileSync(publicPath("/manifest.webmanifest"), "utf8"));
const icons: ManifestIcon[] = manifest.icons;
const swSource = readFileSync(publicPath("/sw.js"), "utf8");
const sizeOf = (icon: ManifestIcon) => Number(icon.sizes.split("x")[0]);
const purposes = (icon: ManifestIcon) => (icon.purpose ?? "any").split(/\s+/);
describe("web app manifest", () => {
it("meets the installability criteria", () => {
expect(manifest.name).toBe("NestQuest Admin");
expect(manifest.short_name).toBe("NestQuest");
expect(manifest.start_url).toBe("/");
expect(manifest.scope).toBe("/");
expect(manifest.display).toBe("standalone");
expect(manifest.background_color).toMatch(/^#[0-9a-f]{6}$/i);
expect(manifest.theme_color).toMatch(/^#[0-9a-f]{6}$/i);
const any = icons.filter((i) => purposes(i).includes("any"));
expect(any.some((i) => sizeOf(i) >= 192)).toBe(true);
expect(any.some((i) => sizeOf(i) >= 512)).toBe(true);
expect(icons.some((i) => purposes(i).includes("maskable"))).toBe(true);
});
it("uses the admin design token colours", () => {
const css = readFileSync(adminPath("src/index.css"), "utf8");
expect(css).toContain(`--nq-brand-blue: ${manifest.theme_color};`);
expect(css).toContain(`--nq-a-page: ${manifest.background_color};`);
});
it.each(icons.map((i) => [i.src, i] as const))(
"icon %s is a real PNG of its declared size",
(_src, icon) => {
const path = publicPath(icon.src);
expect(existsSync(path)).toBe(true);
const png = readFileSync(path);
expect([...png.subarray(0, 8)]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
expect(png.toString("ascii", 12, 16)).toBe("IHDR");
const [w, h] = icon.sizes.split("x").map(Number);
expect(png.readUInt32BE(16)).toBe(w);
expect(png.readUInt32BE(20)).toBe(h);
expect(icon.type).toBe("image/png");
},
);
});
describe("index.html and entry point", () => {
const html = readFileSync(adminPath("index.html"), "utf8");
it("links the manifest, theme colour and apple touch icon", () => {
expect(html).toContain('<link rel="manifest" href="/manifest.webmanifest" />');
expect(html).toContain(`<meta name="theme-color" content="${manifest.theme_color}" />`);
expect(html).toContain('<link rel="apple-touch-icon" href="/icons/icon-192.png" />');
});
it("main.tsx registers the service worker", () => {
const main = readFileSync(adminPath("src/main.tsx"), "utf8");
expect(main).toMatch(/import \{ registerServiceWorker \} from "\.\/pwa\/register"/);
expect(main).toMatch(/registerServiceWorker\(\)/);
});
});
// Minimal service-worker global scope: runs public/sw.js against in-memory
// caches and a stubbed network, so handler behaviour is checked without I/O.
function loadServiceWorker(
network: (url: string) => Promise<FakeResponse>,
source: string = swSource,
) {
const listeners: Record<string, (event: any) => void> = {};
const stores = new Map<string, Map<string, FakeResponse>>();
const openStore = (name: string) => {
if (!stores.has(name)) stores.set(name, new Map());
return stores.get(name)!;
};
const keyOf = (req: string | { url: string }) =>
new URL(typeof req === "string" ? req : req.url, "https://admin.test").href;
const fetchFn = vi.fn((req: string | { url: string }) => network(keyOf(req)));
const caches = {
open: async (name: string) => {
const store = openStore(name);
return {
addAll: async (urls: string[]) => {
for (const u of urls) store.set(keyOf(u), await network(keyOf(u)));
},
put: async (req: { url: string }, res: FakeResponse) => void store.set(keyOf(req), res),
};
},
keys: async () => [...stores.keys()],
delete: async (name: string) => stores.delete(name),
match: async (req: string | { url: string }, opts?: { cacheName?: string }) => {
const names = opts?.cacheName ? [opts.cacheName] : [...stores.keys()];
for (const n of names) {
const hit = stores.get(n)?.get(keyOf(req));
if (hit) return hit;
}
return undefined;
},
};
const self = {
location: new URL("https://admin.test/"),
addEventListener: (type: string, fn: (event: any) => void) => (listeners[type] = fn),
skipWaiting: vi.fn(async () => undefined),
clients: { claim: vi.fn(async () => undefined) },
};
const ResponseStub = { error: () => new FakeResponse("", false, "error") };
new Function("self", "caches", "fetch", "Response", source)(self, caches, fetchFn, ResponseStub);
const dispatch = async (type: string, init: object = {}) => {
let pending: Promise<unknown> | undefined;
const event = {
...init,
waitUntil: (p: Promise<unknown>) => (pending = p),
respondWith: (p: Promise<unknown>) => (pending = p),
};
listeners[type](event);
return { responded: pending !== undefined, result: await pending };
};
return { listeners, stores, self, fetchFn, dispatch, openStore };
}
class FakeResponse {
constructor(
readonly body: string,
readonly ok = true,
readonly type = "basic",
) {}
clone() {
return new FakeResponse(this.body, this.ok, this.type);
}
}
const request = (url: string, extra: object = {}) => ({
url: new URL(url, "https://admin.test").href,
method: "GET",
mode: "cors",
destination: "",
...extra,
});
describe("service worker (public/sw.js)", () => {
it("is a static asset served from the site root", () => {
expect(existsSync(publicPath("/sw.js"))).toBe(true);
expect(swSource).toMatch(/const SHELL_CACHE = /);
expect(swSource).toMatch(/addEventListener\("install"/);
expect(swSource).toMatch(/addEventListener\("activate"/);
expect(swSource).toMatch(/addEventListener\("fetch"/);
});
it("precaches the shell on install, prunes stale caches on activate", async () => {
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
sw.openStore("nestquest-admin-shell-v0");
await sw.dispatch("install");
expect(sw.self.skipWaiting).toHaveBeenCalled();
const precached = [...sw.stores.values()].flatMap((s) => [...s.keys()]);
for (const path of ["/", "/index.html", "/manifest.webmanifest", ...icons.map((i) => i.src)]) {
expect(precached).toContain(`https://admin.test${path}`);
}
await sw.dispatch("activate");
expect(sw.stores.has("nestquest-admin-shell-v0")).toBe(false);
expect(sw.self.clients.claim).toHaveBeenCalled();
});
it("serves the cached shell for navigations when offline", async () => {
let online = true;
const sw = loadServiceWorker(async (url) => {
if (!online) throw new TypeError("Failed to fetch");
return new FakeResponse(`net:${url}`);
});
await sw.dispatch("install");
online = false;
const { result } = await sw.dispatch("fetch", {
request: request("/children/42", { mode: "navigate", destination: "document" }),
});
expect((result as FakeResponse).body).toBe("net:https://admin.test/index.html");
});
it("serves static assets cache-first and fills the runtime cache", async () => {
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
const asset = request("/assets/index-abc123.js", { destination: "script" });
const first = await sw.dispatch("fetch", { request: asset });
expect((first.result as FakeResponse).body).toBe("net:https://admin.test/assets/index-abc123.js");
expect(sw.fetchFn).toHaveBeenCalledTimes(1);
const second = await sw.dispatch("fetch", { request: asset });
expect((second.result as FakeResponse).body).toBe("net:https://admin.test/assets/index-abc123.js");
expect(sw.fetchFn).toHaveBeenCalledTimes(1);
});
it("never handles cross-origin, non-GET or non-static requests", async () => {
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
const cases = [
request("https://auth.example.com/logo.png", { destination: "image" }),
request("/assets/app.js", { destination: "script", method: "POST" }),
request("/api/admin/children"),
];
for (const req of cases) {
const { responded } = await sw.dispatch("fetch", { request: req });
expect(responded).toBe(false);
}
expect(sw.fetchFn).not.toHaveBeenCalled();
});
});
// A Vite-style built index.html referencing hashed bundles.
const builtIndexHtml = (hash: string) => `<!doctype html>
<html lang="en">
<head>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<script type="module" crossorigin src="/assets/index-${hash}.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-${hash}.js">
<link rel="stylesheet" crossorigin href="/assets/index-${hash}.css">
<link rel="preconnect" href="https://auth.example.com" />
</head>
<body><div id="root"></div></body>
</html>`;
// Same-origin script/link URLs of an HTML document, parsed independently of
// the build plugin so the regression check does not trust its own extractor.
const referencedAssets = (html: string) =>
[...new DOMParser().parseFromString(html, "text/html").querySelectorAll("script[src], link[href]")]
.map((el) => new URL(el.getAttribute("src") ?? el.getAttribute("href")!, "https://admin.test/"))
.filter((url) => url.origin === "https://admin.test")
.map((url) => url.href);
// The unhashed files the build hashes into the revision, read from public/.
const staticFiles = STATIC_PRECACHE_FILES.map((path) => ({
path,
bytes: readFileSync(publicPath(`/${path}`)),
}));
const revisionOf = (src: string) => /const REVISION = "([0-9a-f]+)";/.exec(src)?.[1];
describe("build-time service worker injection", () => {
it("precaches every asset the cached index.html references on install (PWA-001)", async () => {
const html = builtIndexHtml("Ab12Cd34");
const built = injectServiceWorker(html, swSource, staticFiles);
const sw = loadServiceWorker(async (url) =>
new FakeResponse(url.endsWith("/index.html") ? html : `net:${url}`),
built);
await sw.dispatch("install");
const shell = [...sw.stores.entries()].find(([name]) => name.startsWith("nestquest-admin-shell-"));
expect(shell).toBeDefined();
const [, shellStore] = shell!;
const cachedHtml = shellStore.get("https://admin.test/index.html")!.body;
const assets = referencedAssets(cachedHtml);
expect(assets).toEqual(
expect.arrayContaining([
"https://admin.test/assets/index-Ab12Cd34.js",
"https://admin.test/assets/vendor-Ab12Cd34.js",
"https://admin.test/assets/index-Ab12Cd34.css",
]),
);
for (const asset of assets) expect([...shellStore.keys()]).toContain(asset);
expect([...shellStore.keys()]).not.toContain("https://auth.example.com/");
});
it("derives a new revision for different index.html content (PWA-002)", () => {
const a = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
const b = injectServiceWorker(builtIndexHtml("bbbb2222"), swSource, staticFiles);
expect(a).not.toContain(REVISION_MARKER);
expect(b).not.toContain(REVISION_MARKER);
expect(revisionOf(a)).toMatch(/^[0-9a-f]{12}$/);
expect(revisionOf(b)).toMatch(/^[0-9a-f]{12}$/);
expect(revisionOf(a)).not.toBe(revisionOf(b));
expect(injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles)).toBe(a);
});
it("derives a new revision when only a precached static file changes (PWA-002)", () => {
const html = builtIndexHtml("aaaa1111");
const base = revisionOf(injectServiceWorker(html, swSource, staticFiles));
const withChanged = (path: string) =>
staticFiles.map((f) =>
f.path === path ? { path, bytes: Buffer.concat([f.bytes, Buffer.from([0])]) } : f,
);
expect(revisionOf(injectServiceWorker(html, swSource, [...staticFiles].reverse()))).toBe(base);
for (const path of STATIC_PRECACHE_FILES) {
const changed = revisionOf(injectServiceWorker(html, swSource, withChanged(path)));
expect(changed, path).toMatch(/^[0-9a-f]{12}$/);
expect(changed, path).not.toBe(base);
}
});
it("hashes every unhashed URL sw.js precaches (PWA-002)", () => {
const shellUrls = [...swSource.matchAll(/^\s*"(\/[^"]*)",$/gm)].map((m) => m[1]);
const fixed = shellUrls.filter((url) => url !== "/" && url !== "/index.html");
expect(fixed.map((url) => url.slice(1)).sort()).toEqual([...STATIC_PRECACHE_FILES].sort());
});
it("names both caches after the build revision (PWA-002)", async () => {
const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
const revision = /const REVISION = "([0-9a-f]+)";/.exec(built)![1];
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built);
await sw.dispatch("install");
await sw.dispatch("fetch", { request: request("/assets/late.js", { destination: "script" }) });
expect([...sw.stores.keys()].sort()).toEqual([
`nestquest-admin-runtime-${revision}`,
`nestquest-admin-shell-${revision}`,
]);
});
it("fails fast when sw.js lacks the placeholders", () => {
expect(() => injectServiceWorker(builtIndexHtml("x"), "const SHELL_URLS = [];", staticFiles)).toThrow();
});
it("activation deletes only stale nestquest-admin caches (PWA-003)", async () => {
const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built);
for (const name of [
"nestquest-admin-shell-old",
"nestquest-admin-runtime-old",
"some-other-app-cache",
]) {
sw.openStore(name);
}
await sw.dispatch("install");
await sw.dispatch("activate");
const names = [...sw.stores.keys()];
expect(names).not.toContain("nestquest-admin-shell-old");
expect(names).not.toContain("nestquest-admin-runtime-old");
expect(names).toContain("some-other-app-cache");
expect(names.some((n) => n.startsWith("nestquest-admin-shell-") && n !== "nestquest-admin-shell-old")).toBe(true);
});
});
describe("registerServiceWorker", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const stubServiceWorker = (register: ReturnType<typeof vi.fn>) =>
vi.stubGlobal("navigator", { ...navigator, serviceWorker: { register } });
it("does not register outside production builds", async () => {
const register = vi.fn();
stubServiceWorker(register);
expect(import.meta.env.PROD).toBe(false);
await registerServiceWorker();
expect(register).not.toHaveBeenCalled();
});
it("registers /sw.js in production", async () => {
vi.stubEnv("PROD", true);
const register = vi.fn().mockResolvedValue({});
stubServiceWorker(register);
await registerServiceWorker();
expect(register).toHaveBeenCalledWith("/sw.js", { scope: "/" });
});
it("logs instead of throwing when registration fails", async () => {
vi.stubEnv("PROD", true);
stubServiceWorker(vi.fn().mockRejectedValue(new Error("boom")));
const error = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(registerServiceWorker()).resolves.toBeUndefined();
expect(error).toHaveBeenCalled();
});
it("does nothing when the browser lacks service workers", async () => {
vi.stubEnv("PROD", true);
vi.stubGlobal("navigator", {});
await expect(registerServiceWorker()).resolves.toBeUndefined();
});
});
+13
View File
@@ -0,0 +1,13 @@
/**
* Service worker registration for the installable admin PWA.
* Registers /sw.js only in production builds, so dev servers and tests never
* run a worker. Failures are logged, never thrown — the app works without it.
*/
export async function registerServiceWorker(): Promise<void> {
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
try {
await navigator.serviceWorker.register("/sw.js", { scope: "/" });
} catch (error) {
console.error("Service worker registration failed:", error);
}
}
+300
View File
@@ -0,0 +1,300 @@
/* 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
@@ -0,0 +1,223 @@
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
@@ -0,0 +1,277 @@
/**
* 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
@@ -0,0 +1,50 @@
/**
* Date/time formatting for the Today tab.
*
* Calendar dates ("YYYY-MM-DD") are parsed by their components and formatted
* in UTC, so the printed day never shifts with the viewer's offset — unlike
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
* day in negative-offset zones.
*/
const LOCALE = "en-US";
function calendarDate(iso: string): Date {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
if (!match) throw new Error(`Invalid calendar date: ${iso}`);
const [, year, month, day] = match;
return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
}
function format(date: Date, options: Intl.DateTimeFormatOptions): string {
// Newer ICU puts a narrow no-break space before AM/PM; normalise it.
return new Intl.DateTimeFormat(LOCALE, options)
.format(date)
.replace(/ /g, " ");
}
/** "Wednesday, September 23" */
export function formatLongDate(iso: string): string {
const date = calendarDate(iso);
const weekday = format(date, { weekday: "long", timeZone: "UTC" });
const monthDay = format(date, { month: "long", day: "numeric", timeZone: "UTC" });
return `${weekday}, ${monthDay}`;
}
/** "Sep 23" */
export function formatShortDate(iso: string): string {
return format(calendarDate(iso), { month: "short", day: "numeric", timeZone: "UTC" });
}
/** "09:00" -> "9:00 AM" (a wall-clock time, no zone conversion). */
export function formatDueTime(hhmm: string): string {
const match = /^(\d{2}):(\d{2})/.exec(hhmm);
if (!match) return hhmm;
const date = new Date(Date.UTC(2000, 0, 1, Number(match[1]), Number(match[2])));
return format(date, { hour: "numeric", minute: "2-digit", timeZone: "UTC" });
}
/** A UTC ISO timestamp shown as the viewer's local time, "7:04 AM". */
export function formatClockTime(isoTimestamp: string): string {
return format(new Date(isoTimestamp), { hour: "numeric", minute: "2-digit" });
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Inline stroke glyphs for the Today tab (Lucide-style 24px grid). The
* shipped Lucide subset is task b50b048f; these keep this screen CDN-free
* until then.
*/
import type { ReactNode } from "react";
function Glyph({ size, children }: { size: number; children: ReactNode }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
);
}
export function SettingsGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1 7 17M17 7l2.1-2.1" />
</Glyph>
);
}
export function InfoGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" />
</Glyph>
);
}
export function AlertCircleGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</Glyph>
);
}
export function CheckCircleGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</Glyph>
);
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"useDefineForClassFields": true
},
"include": ["src"]
}
+93
View File
@@ -0,0 +1,93 @@
/* Build-time service worker injection. After Vite writes the bundle, the
hashed asset URLs referenced by dist/index.html and a content-derived
revision are written into dist/sw.js, so the offline shell is complete right
after install and every release ships a new worker with fresh caches.
Build tooling only — never imported by the app bundle. */
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import type { Plugin } from "vite";
export const REVISION_MARKER = "__NQ_BUILD_REVISION__";
const PRECACHE_PLACEHOLDER = "const PRECACHE_ASSETS = [];";
// Unhashed files sw.js precaches by fixed URL, relative to the build output.
export const STATIC_PRECACHE_FILES = [
"manifest.webmanifest",
"icons/icon-192.png",
"icons/icon-512.png",
"icons/icon-maskable-512.png",
];
export interface PrecacheFile {
path: string;
bytes: string | Uint8Array;
}
// Every same-origin URL an index.html loads via <script src> or <link href>,
// normalised to an absolute path, in document order and without duplicates.
export function extractShellAssets(indexHtml: string): string[] {
const tags = indexHtml.match(/<(?:script|link)\b[^>]*>/gi) ?? [];
const urls = tags.flatMap((tag) => {
const attr = /\b(?:src|href)\s*=\s*["']([^"']+)["']/i.exec(tag);
if (!attr) return [];
const url = new URL(attr[1], "https://same-origin.invalid/");
return url.origin === "https://same-origin.invalid" ? [url.pathname] : [];
});
return [...new Set(urls)];
}
// Hash of every precached file, sorted by path; each entry is length-prefixed
// so no two different file sets hash the same concatenation.
export function computeRevision(files: PrecacheFile[]): string {
const hash = createHash("sha256");
for (const { path, bytes } of [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
const data = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : bytes;
hash.update(`${path}\0${data.byteLength}\0`).update(data);
}
return hash.digest("hex").slice(0, 12);
}
// Returns swSource with the precache list and the build revision filled in.
// The revision covers index.html (and so the hashed bundles it names), the
// sw.js source and the bytes of every unhashed static file it precaches.
export function injectServiceWorker(
indexHtml: string,
swSource: string,
staticFiles: PrecacheFile[],
): string {
if (!swSource.includes(PRECACHE_PLACEHOLDER) || !swSource.includes(REVISION_MARKER)) {
throw new Error("sw.js is missing the PRECACHE_ASSETS placeholder or revision marker");
}
const assets = extractShellAssets(indexHtml);
const revision = computeRevision([
{ path: "index.html", bytes: indexHtml },
{ path: "sw.js", bytes: swSource },
...staticFiles,
]);
return swSource
.replace(PRECACHE_PLACEHOLDER, `const PRECACHE_ASSETS = ${JSON.stringify(assets)};`)
.replaceAll(REVISION_MARKER, revision);
}
export function serviceWorkerInjectPlugin(): Plugin {
let outDir = "";
return {
name: "nestquest-sw-inject",
apply: "build",
configResolved(config) {
outDir = resolve(config.root, config.build.outDir);
},
writeBundle() {
const swPath = resolve(outDir, "sw.js");
const indexHtml = readFileSync(resolve(outDir, "index.html"), "utf8");
const staticFiles = STATIC_PRECACHE_FILES.map((path) => ({
path,
bytes: readFileSync(resolve(outDir, path)),
}));
writeFileSync(
swPath,
injectServiceWorker(indexHtml, readFileSync(swPath, "utf8"), staticFiles),
);
},
};
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { serviceWorkerInjectPlugin } from "./vite-sw-plugin";
export default defineConfig({
plugins: [react(), serviceWorkerInjectPlugin()],
test: {
environment: "jsdom",
},
});
+527 -18
View File
@@ -30,10 +30,10 @@ hand-rolled here), and the call goes to
``set_quest_definition_active``. The title policy, the window-name
and due-time policy, the assignee checks, the whole-set window
replacement and the never-hard-delete policy (deactivation is the only
removal path) all live in the core. Assignment is NOT settable
through the API: the core's edit path takes no assignees. The edit
route forwards only SUPPLIED fields (``exclude_unset``), like the
children edit.
removal path) all live in the core. The edit route forwards only
SUPPLIED fields (``exclude_unset``), like the children edit; a supplied
``assignee_child_ids`` list REPLACES the whole assignee set inside the
core edit's one transaction (and its one regeneration).
The presence routes follow the same pattern over
:mod:`nestquest_core.presence_management` — the ONE presence write
@@ -58,6 +58,24 @@ layer the HA services share, so the two planes cannot drift:
(children are never hard-deleted; overrides are deletable by
design) and regenerates that override's child. It answers
``{"status": "ok"}``.
- ``POST /presence-overrides/consequence`` previews an override
BEFORE it is saved: ONE ``preview_presence_override_consequence``
call answers ``{removed: N}``, the number of the child's open
upcoming instances the materialization walk would stop generating
(instances with a completion event are never counted, D-005).
Nothing is written; an unknown child is 404, a malformed or inverted
range 422.
- ``GET /children/{child_id}/presence-schedule`` reads the child's
schedule through ONE ``get_presence_schedule`` call, wrapped as
``{"schedule": ...}`` in the PUT response's shape — ``null`` when the
child has none (present every day), never a 404; an unknown child is
404.
- ``GET /presence-overrides`` lists the household's overrides through
ONE ``list_presence_overrides`` call, wrapped as
``{"overrides": [...]}`` in the POST response's shape, ordered by
start date, child id, id; the optional ``child_id``, ``start`` and
``end`` query filters (strict dates, ``end >= start``) are the
core's to validate.
The uncomplete and regenerate routes (task da0226b3) follow the same
pattern over :mod:`nestquest_core.completion` and
@@ -116,6 +134,22 @@ owns every rule so the two routes cannot drift:
a ``Content-Disposition`` attachment filename and the documented,
stable header row (Admin spec §5 event-row fields).
The snapshot route (task ef3f4877) is the admin-plane twin of the
panel's ``GET /api/v1/panel/snapshot`` for the admin PWA's Today tab —
the PWA is internet-facing and never holds the panel service token
(D-012), so it reads the same snapshot here:
- ``GET /snapshot`` threads ONE clock read (:func:`_local_now`) into
:func:`nestquest_core.snapshot.build_snapshot` and shapes each
child's instances through
:func:`nestquest_core.snapshot.instance_payload` with
``include_missed=True`` — admin payloads KEEP missed instances with
their ``missed`` state (D-009), where the panel omits them. The
body is the panel's documented shape (``today_iso``, ``cycle_day``,
``children`` with the per-child presence, rollup counts and
``instances``); the builder, the state derivation and the shaping
all live in the core.
The settings routes (task 2b3de7e5) follow the same pattern over
:mod:`nestquest_core.settings_store` — the API's OWN durable settings
store. The API now OWNS settings in the database (a validated JSON
@@ -156,6 +190,33 @@ integration's rollover listener shares:
read-only-over-the-domain-tables guarantee all live in the core; the
response reports how many transitions were published.
The occurrence-preview route (task 5f843564) lets the admin PWA's
Definitions edit sheet show the next dates a rule fires on WITHOUT
reimplementing the recurrence rules client-side:
- ``POST /quest-definitions/occurrences-preview`` takes the SAME
``rule`` object create/edit take (:class:`AdminRuleRequest`, built
through the SAME :func:`_rule_from_request` / core ``from_dict``
validation, so a rejected rule is 422 exactly as on create), an
optional ``start_date`` (default: the API host's local ``today``,
ONE :func:`_local_now` clock read) and an optional ``count`` (1..50,
default 10). The dates come from ONE
:func:`nestquest_core.recurrence.occurrences_between` call — the
same engine the materializer walks — so the preview can only agree
with what would be generated. The scan is BOUNDED server-side: the
window is ``[start_date, start_date + 366 days]`` inclusive,
whatever the caller sends, so every rule shape (a yearly rule
included) shows at least its next occurrence, and a caller can never
ask for an unbounded walk. Any strict ``start_date`` is accepted
rather than clamped or range-limited — the fixed window already
bounds the work — EXCEPT one whose window would reach 9999-12-31,
which is 422 (the core's day walk cannot step past the calendar's
last date). ``start_date`` strictness
(``YYYY-MM-DD``) is the core's own date validator; a non-strict or
unparseable value is 422. A ``count`` outside 1..50 is the body
model's 422 before any handler code runs. Nothing is read or
written: the route touches no database.
Error mapping (every children, quest-definition and presence route,
deliberately narrow):
@@ -166,8 +227,9 @@ deliberately narrow):
set_active on an unknown id) is mapped to 404.
- A core ``ValueError`` reporting a NON-EXISTENT DEFINITION (edit or
set_active on an unknown id — the core prefixes that error with
``definition_id:``) is mapped to 404. Create's rejected-assignee
error names a child from the BODY, not a path id, so it maps to 422.
``definition_id:``) is mapped to 404. Create's and edit's
rejected-assignee error names a child from the BODY, not a path id,
so it maps to 422.
- Every OTHER core ``ValueError`` — an empty display name, an explicit
``null`` colour/avatar_ref (clearing is not supported), a reorder
list that is not a complete permutation of the children (missing,
@@ -228,6 +290,16 @@ an omitted field is never passed to
:func:`nestquest_core.children.edit_child` and stays unchanged — while
an explicit ``null`` IS passed and is rejected by the core rather than
silently ignored.
The EVENTS route (``GET /events``) is the admin plane's live
transition stream for the Admin PWA: the SAME four transitions
(quest-completed, quest-uncompleted, quest-missed, child-day-complete)
off the SAME in-process publisher as the panel plane, framed by the
shared :func:`api.sse.transition_event_stream` helper. It inherits the
router's ONE :func:`~api.auth.require_admin` check (the Authorization
header — the PWA reads it with a fetch-based reader because
``EventSource`` cannot set headers), so the panel service token is
refused here exactly as on every other admin route.
"""
from __future__ import annotations
@@ -235,7 +307,14 @@ import datetime
from typing import Annotated, Literal, NoReturn
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, field_validator, model_validator
from fastapi.responses import StreamingResponse
from pydantic import (
BaseModel,
StrictBool,
StrictInt,
field_validator,
model_validator,
)
from api import transitions as api_transitions
from api.auth import require_admin
@@ -254,8 +333,10 @@ from api.nestquest_core import (
core_recurrence,
core_settings,
core_settings_store,
core_snapshot,
core_sweep,
)
from api.sse import transition_event_stream
#: All admin-plane routes share this router; the ONE admin dependency
#: (service-token refusal + JWT verification + nestquest-admins group
@@ -346,11 +427,18 @@ class AdminQuestDefinitionResponse(BaseModel):
description: str | None
icon: str | None
is_active: bool
skip_on_away: bool
rule: AdminRuleResponse
assignees: list[AdminAssigneeResponse]
windows: list[AdminDefinitionWindowResponse]
class AdminQuestDefinitionListResponse(BaseModel):
"""The definitions list: every definition, active AND inactive, by id."""
definitions: list[AdminQuestDefinitionResponse]
class AdminChildCreateRequest(BaseModel):
"""The body of ``POST /api/v1/admin/children``.
@@ -421,24 +509,67 @@ class AdminRuleRequest(BaseModel):
end_date: str | None = None
#: Upper bound on ``count`` for the occurrence-preview route.
_PREVIEW_MAX_COUNT = 50
#: Days after ``start_date`` the occurrence-preview window spans
#: (inclusive), so a yearly rule always shows its next occurrence.
_PREVIEW_WINDOW_DAYS = 366
class AdminOccurrencesPreviewRequest(BaseModel):
"""The body of ``POST /api/v1/admin/quest-definitions/occurrences-preview``.
``rule`` is the SAME object create/edit take. ``start_date`` is an
optional strict ``YYYY-MM-DD`` (validated by the core; default the
API host's today). ``count`` is a strict JSON integer in
1..``_PREVIEW_MAX_COUNT`` — anything else is 422 before any handler
code runs.
"""
rule: AdminRuleRequest
start_date: str | None = None
count: StrictInt = 10
@field_validator("count")
@classmethod
def _count_in_range(cls, value: int) -> int:
"""Reject a count outside 1.._PREVIEW_MAX_COUNT."""
if not 1 <= value <= _PREVIEW_MAX_COUNT:
raise ValueError(
f"count must be between 1 and {_PREVIEW_MAX_COUNT}"
)
return value
class AdminOccurrencesPreviewResponse(BaseModel):
"""The next occurrence dates (``YYYY-MM-DD``, ascending)."""
dates: list[str]
class AdminQuestDefinitionCreateRequest(BaseModel):
"""The body of ``POST /api/v1/admin/quest-definitions``.
``title``, ``rule``, ``assignee_child_ids`` and ``windows`` are
required (the core rejects an empty title, a non-list or empty
assignee/window list, and unknown window names); ``description``
and ``icon`` are optional and default to NULL through the core.
``windows`` entries are a window name (``morning``) or a two-item
and ``icon`` are optional and default to NULL through the core, and
``skip_on_away`` is optional and defaults to true (no instance on a
date the child is absent). ``windows`` entries are a window name (``morning``) or a two-item
``[window, due_time]`` pair whose due time is a strict 24-hour
HH:MM (or ``null`` for none) — both forms the core normalizes.
Assignee ids are strict JSON integers: a boolean, float or numeric
string is 422, never coerced to a child id.
"""
title: str
rule: AdminRuleRequest
assignee_child_ids: list[int]
assignee_child_ids: list[StrictInt]
windows: list[str | tuple[str, str | None]]
description: str | None = None
icon: str | None = None
skip_on_away: StrictBool = True
class AdminQuestDefinitionEditRequest(BaseModel):
@@ -450,10 +581,13 @@ class AdminQuestDefinitionEditRequest(BaseModel):
field stays unchanged. An explicit ``null`` ``description``/
``icon`` IS passed and CLEARS the stored value (the core supports
clearing here, unlike the children edit); an explicit ``null``
``rule`` or ``windows`` is passed and rejected by the core. A
supplied ``windows`` list REPLACES the whole window set, and
``assignees`` are deliberately absent — assignment is not settable
through this route.
``rule``, ``windows`` or ``assignee_child_ids`` is passed and
rejected by the core. A supplied ``windows`` list REPLACES the
whole window set, and a supplied ``assignee_child_ids`` list
REPLACES the whole assignee set (non-empty, every id an existing
active child — create's contract; a rejection changes nothing). An
omitted (or ``null``) ``skip_on_away`` keeps the stored value.
Assignee ids are strict JSON integers, as on create.
"""
title: str | None = None
@@ -461,6 +595,8 @@ class AdminQuestDefinitionEditRequest(BaseModel):
icon: str | None = None
rule: AdminRuleRequest | None = None
windows: list[str | tuple[str, str | None]] | None = None
skip_on_away: StrictBool | None = None
assignee_child_ids: list[StrictInt] | None = None
class AdminQuestDefinitionActiveRequest(BaseModel):
@@ -533,6 +669,50 @@ class AdminPresenceOverrideResponse(BaseModel):
note: str | None
class AdminPresenceScheduleReadResponse(BaseModel):
"""The ``GET /children/{child_id}/presence-schedule`` payload.
``schedule`` is ``None`` when the child has no schedule — the child
is present every day (Feature 05); absence is state, not an error.
"""
schedule: AdminPresenceScheduleResponse | None
class AdminPresenceOverrideListResponse(BaseModel):
"""The ``GET /presence-overrides`` payload: the matching overrides."""
overrides: list[AdminPresenceOverrideResponse]
class AdminPresenceOverrideConsequenceRequest(BaseModel):
"""The body of ``POST /api/v1/admin/presence-overrides/consequence``.
The create body's shape minus the note (which cannot change what is
generated): the strict dates, their order and the real
``is_present`` bool are enforced by the SAME
:class:`~nestquest_core.presence.PresenceOverride` constructor the
create route's core path builds; this model only guards the SHAPE.
Strict fields: a JSON ``true`` is not child id 1, nor ``1`` a bool.
"""
child_id: StrictInt
start_date: str
end_date: str
is_present: StrictBool
class AdminPresenceOverrideConsequenceResponse(BaseModel):
"""The consequence preview: ``removed`` upcoming instances.
The number of open (no completion event) upcoming quest instances
of the child that saving the override would remove — the "This
removes N upcoming tasks" warning. Always 0 for a present override.
"""
removed: int
class AdminUncompleteResponse(BaseModel):
"""The result of ``POST /api/v1/admin/instances/{id}/uncomplete``.
@@ -683,6 +863,59 @@ class AdminMissedSweepResponse(BaseModel):
fired: int
class AdminInstancePayload(BaseModel):
"""One instance in the admin snapshot payload.
Mirrors :func:`nestquest_core.snapshot.instance_payload` with
``include_missed=True``: ``state`` is ``open`` | ``completed`` |
``missed`` (admin payloads keep missed instances, D-009),
``on_time`` passes the completion event's flag through verbatim
(``None`` unless completed), and ``completed_at`` is the event's
UTC timestamp when completed.
"""
id: int
definition_id: int
child_id: int
title: str
icon: str | None
window: str
due_time: str | None
state: str
overdue: bool
completed_at: str | None
on_time: bool | None
class AdminChildSnapshotPayload(BaseModel):
"""One child's day: presence, rollup counts, and today's instances."""
child_id: int
child_name: str
present: bool
#: ISO date of the child's next present day while they are away;
#: ``None`` when the child is present today.
next_present: str | None
due_today: int
completed_today: int
remaining_today: int
completion_pct: int
instances: list[AdminInstancePayload]
class AdminSnapshotResponse(BaseModel):
"""Today's whole household snapshot for the admin Today tab.
The panel snapshot's shape (``today_iso``, ``cycle_day``,
``children`` in the household's sort order), with missed instances
kept in each child's ``instances``.
"""
today_iso: str
cycle_day: int
children: list[AdminChildSnapshotPayload]
#: 404 detail for a child id the core layer reports as non-existent.
_CHILD_NOT_FOUND_DETAIL = "Child not found"
@@ -845,6 +1078,40 @@ def _raise_sweep_error(error: ValueError) -> NoReturn:
raise HTTPException(status_code=422, detail=str(error)) from error
def _raise_preview_error(error: ValueError) -> NoReturn:
"""Map a core recurrence ``ValueError`` from the preview route to 422.
The preview route has no path ids, so it has no 404 case: a
rejected rule (:class:`~nestquest_core.recurrence.RuleValidationError`)
or a non-strict ``start_date`` is 422 with the core's message as
the detail. Only ``ValueError`` is caught, so an unexpected
failure re-raises rather than becoming a 4xx.
"""
raise HTTPException(status_code=422, detail=str(error)) from error
def _preview_window_end(start_date: str) -> str:
"""Return the preview window's inclusive end for ``start_date``.
``start_date`` + :data:`_PREVIEW_WINDOW_DAYS`. Parsing here only
locates the window; the STRICT ``YYYY-MM-DD`` check stays the
core's — :func:`~nestquest_core.recurrence.occurrences_between`
re-validates the same string and rejects any lenient form this
parse accepted. Raises ``ValueError`` for an unparseable value and
for a start whose window would reach the calendar's last date (the
core's day walk steps one day PAST its end, which overflows there).
"""
start = datetime.date.fromisoformat(start_date)
if datetime.date.max - start <= datetime.timedelta(
days=_PREVIEW_WINDOW_DAYS
):
raise ValueError(
f"start_date {start_date!r} is too close to the end of the "
"calendar for a preview window"
)
return (start + datetime.timedelta(days=_PREVIEW_WINDOW_DAYS)).isoformat()
def _local_now() -> datetime.datetime:
"""Return the API host's local time as ONE timezone-aware clock read.
@@ -887,6 +1154,7 @@ def _quest_definition_response(
description=bundle.definition.description,
icon=bundle.definition.icon,
is_active=bundle.definition.is_active,
skip_on_away=bundle.definition.skip_on_away,
rule=_rule_response(bundle.rule),
assignees=[
AdminAssigneeResponse(id=child.id, display_name=child.display_name)
@@ -1098,6 +1366,32 @@ async def admin_reorder_children(
return {"status": "ok"}
@router.get(
"/quest-definitions",
summary="List quest definitions",
response_model=AdminQuestDefinitionListResponse,
)
async def admin_list_quest_definitions(
request: Request,
) -> AdminQuestDefinitionListResponse:
"""Return every quest definition in rising id order.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: one
:func:`nestquest_core.quest_definitions.list_all_definitions` call,
active AND inactive definitions included (the admin screen manages
both), each serialized exactly as the create/edit responses are.
"""
state: DatabaseState = request.app.state.db
bundles = await core_quest_definitions.list_all_definitions(
state.database
)
return AdminQuestDefinitionListResponse(
definitions=[_quest_definition_response(bundle) for bundle in bundles]
)
@router.post(
"/quest-definitions",
summary="Create a quest definition",
@@ -1132,12 +1426,49 @@ async def admin_create_quest_definition(
body.windows,
description=body.description,
icon=body.icon,
skip_on_away=body.skip_on_away,
)
except ValueError as error:
_raise_quest_definition_error(error)
return _quest_definition_response(bundle)
@router.post(
"/quest-definitions/occurrences-preview",
summary="Preview the next occurrence dates of a schedule rule",
response_model=AdminOccurrencesPreviewResponse,
)
async def admin_preview_occurrences(
body: AdminOccurrencesPreviewRequest,
) -> AdminOccurrencesPreviewResponse:
"""Return the first ``count`` dates the supplied rule fires on.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: the ``rule``
becomes ONE :class:`~nestquest_core.recurrence.ScheduleRule`
(:func:`_rule_from_request`, the create/edit path), and the dates
come from ONE :func:`nestquest_core.recurrence.occurrences_between`
call over the bounded window ``[start_date, start_date + 366
days]`` — ``start_date`` defaulting to ONE :func:`_local_now` clock
read — truncated to ``count``. Every core ``ValueError`` is 422
(:func:`_raise_preview_error`). No database access.
"""
start_date = (
body.start_date
if body.start_date is not None
else _local_now().date().isoformat()
)
try:
rule = _rule_from_request(body.rule)
dates = core_recurrence.occurrences_between(
rule, start_date, _preview_window_end(start_date)
)
except ValueError as error:
_raise_preview_error(error)
return AdminOccurrencesPreviewResponse(dates=dates[: body.count])
@router.patch(
"/quest-definitions/{definition_id}",
summary="Edit a quest definition",
@@ -1157,10 +1488,12 @@ async def admin_edit_quest_definition(
field is never passed to
:func:`nestquest_core.quest_definitions.edit_quest_definition` and
stays unchanged; a supplied ``windows`` list replaces the WHOLE
window set in the core, and a supplied ``rule`` is rebuilt into a
ScheduleRule (:func:`_rule_from_request`, validated by the model).
An explicit ``null`` ``rule``/``windows`` reaches the core as None
and is rejected there (422), while an explicit ``null``
window set in the core, a supplied ``assignee_child_ids`` list
replaces the WHOLE assignee set in the same core transaction, and a
supplied ``rule`` is rebuilt into a ScheduleRule
(:func:`_rule_from_request`, validated by the model). An explicit
``null`` ``rule``/``windows``/``assignee_child_ids`` reaches the
core as None and is rejected there (422), while an explicit ``null``
``description``/``icon`` clears the stored value — the core edit
path supports clearing, unlike the children edit. An unknown
definition id is mapped to 404 and any other core ``ValueError``
@@ -1322,6 +1655,114 @@ async def admin_delete_presence_override(
return {"status": "ok"}
@router.post(
"/presence-overrides/consequence",
summary="Count the upcoming instances a presence override would remove",
response_model=AdminPresenceOverrideConsequenceResponse,
)
async def admin_presence_override_consequence(
body: AdminPresenceOverrideConsequenceRequest, request: Request
) -> AdminPresenceOverrideConsequenceResponse:
"""Return how many upcoming instances saving the override removes.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: ONE
:func:`nestquest_core.presence_management.preview_presence_override_consequence`
call, anchored on ONE :func:`_local_now` clock read, which compares
the materialization walk's output for the child with and without
the proposed override and excludes instances that already have a
completion event (D-005). Nothing is written.
Errors are mapped by :func:`_raise_child_error`: an unknown child
is 404; a malformed or inverted range is 422.
"""
state: DatabaseState = request.app.state.db
try:
removed = await (
core_presence_management.preview_presence_override_consequence(
state.database,
body.child_id,
body.start_date,
body.end_date,
body.is_present,
today=_local_now().date(),
)
)
except ValueError as error:
_raise_child_error(error)
return AdminPresenceOverrideConsequenceResponse(removed=removed)
@router.get(
"/children/{child_id}/presence-schedule",
summary="Read a child's repeating presence schedule",
response_model=AdminPresenceScheduleReadResponse,
)
async def admin_get_presence_schedule(
child_id: int, request: Request
) -> AdminPresenceScheduleReadResponse:
"""Return the child's presence schedule, or ``schedule: null``.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: one
:func:`nestquest_core.presence_management.get_presence_schedule`
call; a stored schedule is serialized exactly as the PUT response
is, and a child without one answers ``{"schedule": null}`` (present
every day). Errors are mapped by :func:`_raise_child_error`: an
unknown child is 404.
"""
state: DatabaseState = request.app.state.db
try:
schedule = await core_presence_management.get_presence_schedule(
state.database, child_id
)
except ValueError as error:
_raise_child_error(error)
return AdminPresenceScheduleReadResponse(
schedule=(
None if schedule is None else _presence_schedule_response(schedule)
)
)
@router.get(
"/presence-overrides",
summary="List presence overrides",
response_model=AdminPresenceOverrideListResponse,
)
async def admin_list_presence_overrides(
request: Request,
child_id: int | None = None,
start: str | None = None,
end: str | None = None,
) -> AdminPresenceOverrideListResponse:
"""Return the household's presence overrides, optionally filtered.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: the optional
``child_id``, ``start`` and ``end`` query parameters go straight to
:func:`nestquest_core.presence_management.list_presence_overrides`
— the date policy (strict ``YYYY-MM-DD``, ``end >= start``) and the
overlap rule live in the core — and each record is serialized
exactly as the POST response is, ordered by start date, child id,
id. Errors are mapped by :func:`_raise_child_error`: an unknown
``child_id`` is 404, a rejected date or range is 422.
"""
state: DatabaseState = request.app.state.db
try:
records = await core_presence_management.list_presence_overrides(
state.database, child_id=child_id, start=start, end=end
)
except ValueError as error:
_raise_child_error(error)
return AdminPresenceOverrideListResponse(
overrides=[_presence_override_response(record) for record in records]
)
@router.post(
"/instances/{instance_id}/uncomplete",
summary="Reverse one completion (append a reversal event)",
@@ -1729,4 +2170,72 @@ async def admin_run_missed_sweep(
return AdminMissedSweepResponse(fired=len(events))
@router.get(
"/snapshot",
summary="Today's household snapshot",
response_model=AdminSnapshotResponse,
)
async def admin_snapshot(request: Request) -> AdminSnapshotResponse:
"""Return today's full household snapshot for the admin Today tab.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: ONE clock read
(:func:`_local_now`) goes into
:func:`nestquest_core.snapshot.build_snapshot` against the live
database, and each child's instances are shaped with
``instance_payload(include_missed=True)`` — missed instances are
KEPT in the admin payload (D-009) — into
:class:`AdminSnapshotResponse`.
"""
state: DatabaseState = request.app.state.db
snapshot = await core_snapshot.build_snapshot(
state.database, core_settings.NestQuestSettings(), _local_now()
)
return AdminSnapshotResponse(
today_iso=snapshot.today_iso,
cycle_day=snapshot.cycle_day,
children=[
AdminChildSnapshotPayload(
child_id=child.child_id,
child_name=child.child_name,
present=child.present,
next_present=child.next_present,
due_today=child.due_today,
completed_today=child.completed_today,
remaining_today=child.remaining_today,
completion_pct=child.completion_pct,
instances=[
AdminInstancePayload.model_validate(instance)
for instance in core_snapshot.instance_payload(
child.instances, include_missed=True
)
],
)
for child in snapshot.children
],
)
@router.get(
"/events",
summary="Stream admin transition events (SSE)",
)
async def admin_events(request: Request) -> StreamingResponse:
"""Stream the app's transition events to an admin client
(Server-Sent Events).
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own), so an unauthenticated or
panel-token request is refused before any stream is established.
Returns the shared :func:`api.sse.transition_event_stream` response
off the app's single in-process publisher (``app.state.publisher``)
— the same publisher, framing and teardown as the panel stream.
"""
return transition_event_stream(request)
__all__ = ["router"]
+7 -30
View File
@@ -53,9 +53,6 @@ from __future__ import annotations
import datetime
import importlib
import json
from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
@@ -70,6 +67,7 @@ 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.
@@ -328,23 +326,6 @@ async def panel_complete_instance(
return {"status": str(result)}
async def _sse_stream(request: Request) -> AsyncIterator[str]:
"""Yield the SSE frames for every published transition event.
One subscriber of the app's single publisher (resolved off
``app.state.publisher``); each event becomes one
``event: <type>\\ndata: <json>\\n\\n`` frame. Breaking out of the
publisher's subscription (on client disconnect) tears the
subscription down cleanly — no queue is leaked.
"""
publisher = request.app.state.publisher
async for event_type, payload in publisher.subscribe():
yield (
f"event: {event_type}\n"
f"data: {json.dumps(payload)}\n\n"
)
@router.get(
"/events",
summary="Stream panel transition events (SSE)",
@@ -357,17 +338,13 @@ async def panel_events(request: Request) -> StreamingResponse:
:func:`~api.dependencies.require_panel_token` dependency — the ONE
token check; this handler performs NO auth of its own).
Returns a ``text/event-stream`` response whose body is produced by
:func:`_sse_stream` off the app's single in-process publisher
(``app.state.publisher``, installed in the lifespan). The stream
ends when the client disconnects, at which point the subscription
is torn down (its queue removed) with nothing leaked.
Returns the shared :func:`api.sse.transition_event_stream` response
off the app's single in-process publisher (``app.state.publisher``,
installed in the lifespan). The stream ends when the client
disconnects, at which point the subscription is torn down (its
queue removed) with nothing leaked.
"""
return StreamingResponse(
_sse_stream(request),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache"},
)
return transition_event_stream(request)
__all__ = ["router"]
+50
View File
@@ -0,0 +1,50 @@
"""The ONE Server-Sent Events response helper for the API service.
Both SSE planes — the panel plane (``/api/v1/panel/events``, panel
service token, LAN-only) and the admin plane (``/api/v1/admin/events``,
Authentik admin JWT) — stream the SAME transitions off the SAME
in-process publisher (``app.state.publisher``, see :mod:`api.events`)
with the SAME framing and teardown, so the stream lives here once and
each plane's route only supplies its own router-level auth.
"""
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from fastapi import Request
from fastapi.responses import StreamingResponse
__all__ = ["transition_event_stream"]
async def _sse_stream(request: Request) -> AsyncIterator[str]:
"""Yield the SSE frames for every published transition event.
One subscriber of the app's single publisher (resolved off
``app.state.publisher``); each event becomes one
``event: <type>\\ndata: <json>\\n\\n`` frame. Breaking out of the
publisher's subscription (on client disconnect) tears the
subscription down cleanly — no queue is leaked.
"""
publisher = request.app.state.publisher
async for event_type, payload in publisher.subscribe():
yield (
f"event: {event_type}\n"
f"data: {json.dumps(payload)}\n\n"
)
def transition_event_stream(request: Request) -> StreamingResponse:
"""A ``text/event-stream`` response over the app's transition events.
Performs NO auth of its own: the calling route's router-level
dependency is the ONE check for its plane. The stream ends when
the client disconnects, at which point the subscription is torn
down (its queue removed) with nothing leaked.
"""
return StreamingResponse(
_sse_stream(request),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache"},
)
+135 -482
View File
@@ -1,375 +1,78 @@
"""The NestQuest integration."""
"""The NestQuest integration (Feature 18: a thin client of the NestQuest API service).
The integration owns NO database: the API service (Feature 16) owns the
SQLite file, the migrations, and every business rule. Setup builds the
entry's ONE shared API client, the coordinator that polls the panel
snapshot through it, and the SSE subscription that re-fires the API's
transition frames on the HA bus, registers the single
``nestquest.complete_quest`` proxy service, registers the zero-config
panel dashboard (Feature 20), and forwards the sensor and
binary_sensor platforms.
"""
from __future__ import annotations
import asyncio
import datetime
import inspect
import sqlite3
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.event import async_track_time_change
from .const import (
CONF_ADMIN_USER_IDS,
CONF_SNAPSHOT_STALENESS,
CONF_UPDATE_INTERVAL,
DEFAULT_SNAPSHOT_STALENESS,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
LOGGER,
PLATFORMS,
)
from .core.settings import NestQuestSettings
from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
from .dashboard import async_register_dashboard
from .frontend import async_register_frontend
from .coordinator import NestQuestCoordinator
from .db import NestQuestDatabase, make_database
from .materialize import materialize as _materialize_run
from .migrations import apply_migrations
from .services import async_deregister_services, async_register_services
from .store import async_get_db_path
from .admin_allowlist import seed_setup_admin
from .sweep import run_missed_sweep
from .sse import NestQuestEventStream
_LOGGER = LOGGER
#: SQLite reports "file is not a database" for corruption in every
#: sqlite3 version this integration targets; the version-specific
#: "encrypted or malformed" spelling (3.41+) maps to the same failure.
_CORRUPTION_MARKERS = (
"file is not a database",
"database disk image is malformed",
"encrypted or malformed",
"is not a database",
)
#: Primary sqlite error codes that mean "the file is corrupt" (SQLite
#: result codes, https://www.sqlite.org/c3ref/c_abort.html). Extended
#: codes carry the primary code in their low byte, so the match is on
#: errcode & 0xff.
_CORRUPT_PRIMARY_CODES = frozenset({11, 26}) # SQLITE_CORRUPT, SQLITE_NOTADB
def _is_corruption_error(error: BaseException) -> bool:
"""Return True when ``error`` indicates a corrupt database file.
Classification order: sqlite_errorcode (Python 3.11+) is primary
and locale-independent — SQLITE_CORRUPT and SQLITE_NOTADB, matched
on the primary byte so extended codes (SQLITE_CORRUPT_VTAB etc.)
classify correctly too. Message matching is the fallback for
older Pythons: the corruption spellings are the documented English
texts; a non-English locale could evade them, but the code check
already covers every locale on supported Pythons.
"""
if not isinstance(error, sqlite3.DatabaseError):
return False
errorcode = getattr(error, "sqlite_errorcode", None)
if errorcode is not None and (errorcode & 0xFF) in _CORRUPT_PRIMARY_CODES:
return True
message = str(error).lower()
return any(marker in message for marker in _CORRUPTION_MARKERS)
async def _preflight_existing_file(
hass: HomeAssistant, db_path: Path
) -> None:
"""Detect corruption in an EXISTING file before it is opened read-write.
Opening read-write persists WAL mode — for a rollback-journal file
that rewrites the header byte 18/19 and can create -wal/-shm
sidecars — which would modify a corrupt file before detection and
break the leave-untouched guarantee. This preflight therefore
validates the WHOLE file with a READ-ONLY connection (mode=ro +
immutable=1 URI, PRAGMA integrity_check, run on the executor like
every other DB call): corruption anywhere fails there, before
anything is written. ``immutable=1`` is SQLite's documented
guarantee that the connection performs NO writes and creates NO
sidecars — even a WAL-header file gets its -wal/-shm reads served
without creating the shared-memory file. Missing/empty files skip
the preflight (they are created fresh by the real open). All
filesystem stat/URI work runs inside the executor job with the
probe so the event loop never touches disk.
"""
def _probe() -> None:
# The file-state check and URI construction live inside the
# executor job: exists/stat/resolve are disk I/O.
if not db_path.exists():
return
# A zero-byte file is neither missing nor a valid database: it
# is a corrupt (truncated-to-nothing) existing file, likely an
# interrupted first write. The leave-untouched contract applies
# to it too: refuse rather than silently initialize over it.
if db_path.stat().st_size == 0:
raise sqlite3.DatabaseError(
"existing database file is empty (zero bytes): file is "
"not a database"
)
# Properly escape the path: '#'/'?'/'%' in a directory or file
# name would otherwise be parsed as URI fragments/parameters/
# escapes. immutable=1: SQLite guarantees no writes and no
# sidecar creation from this connection.
uri = db_path.resolve().as_uri() + "?mode=ro&immutable=1"
conn = sqlite3.connect(uri, uri=True)
try:
row = conn.execute("PRAGMA integrity_check").fetchone()
finally:
conn.close()
verdict = row[0] if row else "missing"
if verdict != "ok":
# A non-ok integrity verdict IS corruption: raise with the
# verdict text (which matches the corruption markers) and
# the NOTADB code so classification is code-based.
raise sqlite3.DatabaseError(
f"integrity_check verdict {verdict!r} on read-only "
f"probe: file is not a database"
)
try:
# Every DB-touching call goes through the executor (the mock
# harness's async_add_executor_job is awaitable too).
await hass.async_add_executor_job(_probe)
except sqlite3.DatabaseError as err:
# Non-ok integrity verdicts are corruption, full stop: there is
# no benign reading of a failed whole-file integrity check, so
# ANY DatabaseError out of the probe that carries a corruption
# marker or code propagates as corruption. Only exotic probe
# failures that classify as non-corruption (e.g. a transient
# 'database is locked' on the read) are downgraded to a debug
# log, letting the real open path surface them.
if _is_corruption_error(err):
raise
raise sqlite3.DatabaseError(
f"read-only corruption probe of {db_path} failed: {err}"
) from err
async def _async_open_database(
hass: HomeAssistant, db_path: Path
) -> NestQuestDatabase:
"""Open the NestQuest database, mapping failure modes for setup.
Three startup cases (Feature 02 done-condition):
- file missing: SQLite creates it fresh on open — no special path,
migrations stamp version 1 on the next step.
- file present and valid: opens normally.
- file present but corrupt: log an ERROR with the path, close
anything opened, and raise ConfigEntryNotReady so HA retries —
the file is left byte-for-byte untouched so the owner can
restore a backup. No code path here deletes or overwrites an
existing database file.
Corruption can surface at EITHER stage: a truncated header fails
in the read-only preflight (before the file is ever opened
read-write), while damaged later pages fail during migrations —
the preflight guarantees no WAL sidecar or header write happens
on a corrupt file before it is detected. Both are classified the
same way.
"""
try:
await _preflight_existing_file(hass, db_path)
database = await make_database(hass).open(db_path)
except asyncio.CancelledError:
raise
except sqlite3.DatabaseError as err:
if _is_corruption_error(err):
_LOGGER.error(
"NestQuest database at %s is corrupt: %s. Refusing to "
"start with a corrupt database; the file was left "
"untouched so a backup can be restored. Home Assistant "
"will retry setup.",
db_path,
err,
)
raise ConfigEntryNotReady(
f"NestQuest database corrupt: {db_path}"
) from err
# open() failed for a non-corruption reason (locked file,
# permission problem): retryable, but nothing was opened.
_LOGGER.error(
"NestQuest database at %s failed to open: %s. Home "
"Assistant will retry setup.",
db_path,
err,
)
raise ConfigEntryNotReady(
f"NestQuest database unavailable: {db_path}"
) from err
try:
await apply_migrations(database)
except (sqlite3.DatabaseError, RuntimeError) as err:
await database.close()
if _is_corruption_error(err):
_LOGGER.error(
"NestQuest database at %s is corrupt: %s. Refusing to "
"start with a corrupt database; the file was left "
"untouched so a backup can be restored. Home Assistant "
"will retry setup.",
db_path,
err,
)
raise ConfigEntryNotReady(
f"NestQuest database corrupt: {db_path}"
) from err
# A non-corruption failure (e.g. a locked file, a future schema
# version) is also a retryable setup problem, but without the
# corruption framing.
_LOGGER.error(
"NestQuest database at %s failed to open: %s. Home "
"Assistant will retry setup.",
db_path,
err,
)
raise ConfigEntryNotReady(
f"NestQuest database unavailable: {db_path}"
) from err
except asyncio.CancelledError:
await database.close()
raise
return database
@dataclass
class NestQuestRuntimeData:
"""Runtime data stored on a NestQuest config entry."""
entry_id: str
settings: NestQuestSettings
database: NestQuestDatabase
remove_update_listener: Callable[[], Any]
remove_time_change_listener: Callable[[], Any]
coordinator: Any = None
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
"""Return every Home Assistant owner-account user id.
The last-resort admin seed: when no persisted allowlist copy and no
flow-context user survive to first setup, the HA owner accounts are
seeded so the household is never left with zero admins (an empty
allowlist fails closed, which would lock the household out of its
own integration). Owner accounts can already do everything in HA,
so granting NestQuest admin is not a privilege escalation.
"""
auth = getattr(hass, "auth", None)
if auth is None:
return []
users = await auth.async_get_users()
owners = [
user.id
for user in users
if getattr(user, "is_owner", False) and getattr(user, "id", None)
]
return owners
async def _run_horizon_materialization(
hass: HomeAssistant,
database: NestQuestDatabase,
settings: NestQuestSettings,
) -> None:
"""Materialize the rolling horizon ``[today, today + horizon_days]``.
"today" is computed in HA local time (``hass.config.time_zone``) rather
than the system clock, so the horizon tracks the household's own day.
The walk itself runs through :func:`~.materialize.materialize`, whose DB
access is executor-only.
This is the ONE generation path: the startup backfill, the daily rollover
listener and the ``regenerate`` service all funnel through it, so there is
no duplicated materialization logic. ``settings`` (REQUIRED — there is no
silent default; every caller already passes the entry's validated
:class:`NestQuestSettings`) carries the configured ``horizon_days``. The
window itself comes from :meth:`settings.horizon_window` so the helper is
real and shared (not recomputed inline here).
"""
time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
start, end = settings.horizon_window(today)
await _materialize_run(database, start.isoformat(), end.isoformat(), today=today)
def _register_day_rollover_listener(
hass: HomeAssistant,
database: NestQuestDatabase,
settings: NestQuestSettings,
) -> Callable[[], Any]:
"""Register the daily rollover listener and return its remover.
Fires once per day at the configured local ``day_rollover_time``:
first re-materializes the rolling horizon
``[today, today + horizon_days]`` through the shared
:func:`_run_horizon_materialization` path (the entry's configured,
validated ``horizon_days`` carried on ``settings``), then runs the
Feature 11 missed sweep — announcing yesterday's still-open quests
as missed AFTER today's instances exist, watermark-guarded so a
re-fire of the listener the same night announces nothing twice.
"""
hour, minute = settings.day_rollover_hour_minute
async def _run_materialization(_now: datetime.datetime) -> None:
await _run_horizon_materialization(hass, database, settings)
await run_missed_sweep(hass, database)
return async_track_time_change(
hass, _run_materialization, hour=hour, minute=minute
)
event_stream: NestQuestEventStream | None = None
def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
"""Return a live runtime record with a connected database, or None.
"""Return a live runtime record with a coordinator, or None.
The domain-global services (e.g. ``regenerate``) have no database of
their own; they resolve whichever config entry's runtime data is
currently live at call time, so none of them can hold a stale handle
to a closed connection after another entry unloads. The record also
carries that entry's validated ``settings`` (e.g. the configured
horizon) so service handlers read horizon days off it rather than
re-reading HA config.
The domain-global service (the panel completion proxy) has no
database of its own; it resolves whichever config entry's runtime
data is currently live at call time, so it cannot hold a stale
handle to an unloaded entry's coordinator.
"""
for runtime_data in hass.data.get(DOMAIN, {}).values():
database = getattr(runtime_data, "database", None)
if database is not None and database.connected:
if getattr(runtime_data, "coordinator", None) is not None:
return runtime_data
return None
def _make_regenerate_handler(hass: HomeAssistant):
"""Return the Feature 07 ``regenerate`` handler closed over ``hass``."""
async def _regenerate(_call: Any) -> None:
runtime_data = _find_live_runtime_data(hass)
if runtime_data is None:
_LOGGER.warning(
"NestQuest regenerate requested but no config entry has a "
"live database; ignoring"
)
return
await _run_horizon_materialization(
hass, runtime_data.database, runtime_data.settings
)
return _regenerate
def _register_services(hass: HomeAssistant) -> None:
"""Register domain-global NestQuest services once.
"""Register the domain-global NestQuest service once.
Services are DOMAIN-global — registered exactly once (guarded by
``has_service`` on regenerate) and NOT bound to any config entry —
so each handler resolves the currently-live database at call time
The service is DOMAIN-global — registered exactly once (guarded by
``has_service``) and NOT bound to any config entry — so the panel
completion proxy resolves the currently-live runtime at call time
and a name collision or duplicate setup is impossible. Removal is
handled by the unload path only once the LAST entry is removed
(see :func:`_deregister_services`).
"""
async_register_services(
hass,
find_runtime=_find_live_runtime_data,
regenerate_handler=_make_regenerate_handler(hass),
)
async_register_services(hass, find_runtime=_find_live_runtime_data)
def _deregister_services(hass: HomeAssistant) -> None:
@@ -386,48 +89,14 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up NestQuest from a config entry."""
await async_register_frontend(hass)
# The zero-config panel dashboard (Feature 20): register the
# storage-mode strategy dashboard if no dashboard under the stable
# url_path exists yet. Unavailability of the Lovelace dashboards
# collection (YAML mode, lovelace not loaded) logs a warning and
# NEVER fails setup; an existing dashboard — ours or the user's —
# is left untouched, so a reload cannot duplicate it.
await async_register_dashboard(hass)
hass.data.setdefault(DOMAIN, {})
existing = hass.data[DOMAIN].get(entry.entry_id)
if existing is not None:
db = getattr(existing, "database", None)
if db is None or not db.connected:
# Reopen through the SAME safety path as first setup: a
# record without a live connection (a previous setup's
# close-on-failure, or a hand-replaced file) must get the
# missing/valid/corrupt classification too, never a raw
# sqlite3 error and never skipped migrations — and the
# same allowlist seeding, so a reopened fresh file is
# never ownerless.
db = await _async_open_database(
hass, await async_get_db_path(hass)
)
try:
stored_admin_ids = (
entry.options.get(CONF_ADMIN_USER_IDS)
or entry.data.get(CONF_ADMIN_USER_IDS)
)
context = getattr(entry, "context", None)
context_user_id = (
context.get("user_id")
if isinstance(context, dict)
else None
)
await seed_setup_admin(
db,
stored_admin_ids=stored_admin_ids,
context_user_id=context_user_id,
owner_ids=await _async_owner_user_ids(hass),
)
except BaseException:
# A seeding failure must not leak the just-opened
# connection: close it before the error propagates, so
# HA's setup retry starts from a clean handle (the
# first-setup path closes symmetrically).
await db.close()
raise
existing.database = db
entry.runtime_data = existing
return True
async def _async_update_listener(
listener_hass: HomeAssistant, listener_entry: ConfigEntry
@@ -435,109 +104,94 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Reload the entry when its options change."""
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
database = await _async_open_database(hass, await async_get_db_path(hass))
remove_update_listener: Callable[[], Any] | None = None
remove_time_change_listener: Callable[[], Any] | None = None
coordinator: Any = None
event_stream: NestQuestEventStream | None = None
try:
# Seed the admin allowlist on first setup so the owner is never
# locked out: an empty database takes the persisted admin copy
# — the options flow's saved list first (it is the current
# one), else the config flow's user from entry data — and only
# then the HA user still present on the entry context. No-op
# on a non-empty list (fail-closed narrowing is deliberate).
stored_admin_ids = (
entry.options.get(CONF_ADMIN_USER_IDS)
or entry.data.get(CONF_ADMIN_USER_IDS)
)
context = getattr(entry, "context", None)
context_user_id = (
context.get("user_id")
if isinstance(context, dict)
else None
)
owner_ids = await _async_owner_user_ids(hass)
await seed_setup_admin(
database,
stored_admin_ids=stored_admin_ids,
context_user_id=context_user_id,
owner_ids=owner_ids,
)
# Build the explicit settings object from entry.options ONCE: the
# horizon materialization and the daily-rollover listener both
# consume it, so core never reads HA config itself. A malformed
# stored option falls back PER FIELD (via from_options_resilient)
# so a single bad sibling option cannot reset the user's
# horizon_days or day_rollover_time — the two fields core
# consumes — to their defaults.
settings = NestQuestSettings.from_options_resilient(entry.options)
remove_update_listener = entry.add_update_listener(_async_update_listener)
remove_time_change_listener = _register_day_rollover_listener(
hass, database, settings
)
# Backfill any days the daily listener missed while the integration
# was off (or freshly installed): run the one idempotent walk over
# [today, today + horizon_days] once at setup, using the entry's
# configured horizon. This runs through the SAME path as the daily
# listener and regenerate service.
await _run_horizon_materialization(hass, database, settings)
# The missed sweep runs at startup too (Feature 11): if HA was
# down at the rollover time, the previous night's still-open
# quests are announced as missed now instead of waiting for
# the next midnight. Watermark-guarded, so this is a no-op
# when the night's sweep already ran.
await run_missed_sweep(hass, database)
_register_services(hass)
# The shared Feature 10 coordinator: one refresh cycle every
# entity reads from (CONF_UPDATE_INTERVAL seconds, default
# five minutes). Created AFTER services so a failure below
# unwinds them through the except path's listener handling.
# ONE API client for the entry, shared by the coordinator's
# snapshot polling, the SSE subscription, and the completion
# proxy (the client is stateless — one session, one connection
# pool). An entry with no panel token yet gets the
# typed-error stub (unavailable entities, never a setup crash).
api_client = coordinator_client_from_entry(hass, entry)
# The shared coordinator: one refresh cycle every entity reads
# from (CONF_UPDATE_INTERVAL seconds, default five minutes).
# It polls the API service's panel snapshot; a failed poll
# keeps the entities rendering the last-good snapshot within
# CONF_SNAPSHOT_STALENESS, unavailable past it.
coordinator = NestQuestCoordinator(
hass,
entry_id=entry.entry_id,
database=database,
settings=settings,
api_client=api_client,
update_interval_seconds=entry.options.get(
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
),
staleness_seconds=entry.options.get(
CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS
),
)
await coordinator.async_config_entry_first_refresh()
# The panel event subscription (the SSE task): a background
# task consumes the API service's transition-event stream and
# re-fires the four documented HA bus events. It reuses the
# coordinator's client, so it only starts when that client
# actually offers a stream — an unconfigured-token stub (or a
# test snapshot client without ``stream_events``) leaves it
# unstarted, and the typed-error path already covers
# availability for those entries.
if callable(getattr(api_client, "stream_events", None)):
event_stream = NestQuestEventStream(hass, api_client)
event_stream.start()
runtime_data = NestQuestRuntimeData(
entry_id=entry.entry_id,
settings=settings,
database=database,
remove_update_listener=remove_update_listener,
remove_time_change_listener=remove_time_change_listener,
coordinator=coordinator,
event_stream=event_stream,
)
# runtime_data is assigned BEFORE the platforms forward so each
# platform's async_setup_entry reads its coordinator off the
# entry (the HA runtime-data contract); a forward failure below
# unwinds it through the except path.
# The runtime record is registered BEFORE the first refresh:
# the service proxy resolves the entry's runtime state through
# hass.data, and it must find the live coordinator there —
# never a half-set-up entry. The platforms forward below reads
# the coordinator off the entry (the HA runtime-data contract);
# a forward failure below unwinds it through the except path.
hass.data[DOMAIN][entry.entry_id] = runtime_data
entry.runtime_data = runtime_data
# The first refresh polls the API snapshot; an API failure
# (unconfigured token, API service down) does NOT crash setup:
# the coordinator reports last_update_success=False, entities
# come up unavailable, and HA's standard retry cycle applies
# once the API is reachable. HA's real
# ``async_config_entry_first_refresh`` wraps ANY failed update
# pass in ConfigEntryNotReady — the original error survives
# only as ``__cause__``, never as the raised exception — so the
# catch keys on ConfigEntryNotReady, NOT on the typed API
# error. Everything else (a platform-forward failure,
# cancellation) still unwinds through the except block below.
try:
await coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady:
pass
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
except BaseException:
# A failure anywhere after the database is open must not leak the
# listeners already registered against that (now-closed) database:
# unwind EVERY remover we acquired before the failure, keeping the
# original error as the one raised. Removal is best-effort — a
# failing remover must not mask the setup error that triggered it.
for remove_listener in (
remove_time_change_listener,
remove_update_listener,
):
if remove_listener is None:
continue
# A failure anywhere after the listener was registered must not
# leak it (or the SSE task): unwind EVERYTHING acquired before
# the failure, keeping the original error as the one raised.
# Removal is best-effort — a failing step must not mask the
# setup error that triggered it.
if event_stream is not None:
try:
result = remove_listener()
if inspect.isawaitable(result):
await result
await event_stream.stop()
except BaseException:
pass
if remove_update_listener is not None:
try:
remove_update_listener()
except BaseException:
pass
# A coordinator created before the failure holds entity
# listeners pointed at the database being closed below —
# shut it down FIRST so no scheduled refresh can ever run
# against a closed connection.
# listeners pointed at the never-registered runtime — shut it
# down so no scheduled refresh outlives the failed setup.
if coordinator is not None:
try:
await coordinator.async_shutdown()
@@ -545,13 +199,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
pass
# A platform-forward failure reached this unwind AFTER
# runtime_data was assigned: clear it so no later unload path
# closes the already-closed database through the stale record.
# re-invokes the already-consumed update-listener remover
# through the stale record.
if getattr(entry, "runtime_data", None) is not None:
entry.runtime_data = None
await database.close()
# The runtime record was ALSO registered in hass.data before
# the first refresh: remove it here on ANY failure, or a later
# unload picks up the stale, already-torn-down record
# (hass.data wins over entry.runtime_data there) and
# re-invokes the already-consumed update-listener remover.
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
raise
assert remove_update_listener is not None
assert remove_time_change_listener is not None
# The runtime record was already registered before the first
# refresh; re-assert the (unchanged) record so the return path
# stays readable as one registration site.
hass.data[DOMAIN][entry.entry_id] = runtime_data
return True
@@ -566,58 +227,50 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if runtime_data is None:
runtime_data = getattr(entry, "runtime_data", None)
# Platforms unload FIRST (mirroring HA conventions): entity
# listeners must not outlive the database and coordinator the
# teardown below closes.
# listeners must not outlive the coordinator the teardown below
# shuts down.
platforms_unloaded = await hass.config_entries.async_unload_platforms(
entry, PLATFORMS
)
if platforms_unloaded is False and runtime_data is not None:
# Home Assistant KEPT one or more platforms loaded — their
# entities still live and must keep their database. Restore
# entities still live and must keep their coordinator. Restore
# the runtime record popped above and report failure so HA
# retries the unload later instead of leaving live entities
# backed by a closed database.
# backed by a torn-down coordinator.
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime_data
return False
unload_error: BaseException | None = None
if runtime_data is not None:
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
remove_time_change_listener = getattr(
runtime_data, "remove_time_change_listener", None
# The SSE subscription is stopped FIRST: its task must be gone
# (no frame can fire) before anything else is torn down.
event_stream = getattr(runtime_data, "event_stream", None)
if event_stream is not None:
stop_stream = getattr(event_stream, "stop", None)
if stop_stream is not None:
await stop_stream()
remove_update_listener = getattr(
runtime_data, "remove_update_listener", None
)
try:
for remove_listener in (
remove_update_listener,
remove_time_change_listener,
):
if remove_listener is None:
continue
try:
result = remove_listener()
if inspect.isawaitable(result):
await result
except BaseException as err:
# Cancel EVERY listener even when an earlier remover
# raises: a skipped remover would leave a daily
# callback firing against a closed database after
# unload. The first error is retained and re-raised
# only after both removers (and the DB close) ran.
if unload_error is None:
unload_error = err
if remove_update_listener is not None:
result = remove_update_listener()
if inspect.isawaitable(result):
await result
except BaseException as err:
# A failing remover must not skip the coordinator shutdown
# below; the first error is retained and re-raised only
# after the teardown ran.
if unload_error is None:
unload_error = err
finally:
# The coordinator's entity listeners must stop BEFORE the
# database closes: a scheduled refresh starting during the
# close await would touch a cleared connection. (The
# platform unload above already removed the entities; this
# clears the coordinator's own remaining listeners.)
# The coordinator's entity listeners must stop here: a
# scheduled refresh must never outlive the unload.
coordinator = getattr(runtime_data, "coordinator", None)
if coordinator is not None:
shutdown = getattr(coordinator, "async_shutdown", None)
if shutdown is not None:
await shutdown()
database = getattr(runtime_data, "database", None)
if database is not None:
await database.close()
# Domain-global services are torn down only once the FINAL entry
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
# entry is still loaded.
@@ -1,11 +0,0 @@
"""Re-export of :mod:`custom_components.nestquest.core.admin_allowlist` for the integration package.
The admin allowlist business layer lives in
:mod:`custom_components.nestquest.core.admin_allowlist` (extracted in
task 510c1f78); this module re-exports it so existing
``from .admin_allowlist import ...`` references inside the integration
keep working.
"""
from __future__ import annotations
from .core.admin_allowlist import * # noqa: F401,F403
+414
View File
@@ -0,0 +1,414 @@
"""Panel-plane API client for the NestQuest integration (Feature 18).
The integration talks to the NestQuest API service (Feature 16) over
HTTP instead of reading the database: this module is the ONE place that
speaks the API's panel plane — ``GET /api/v1/panel/snapshot`` (today's
household snapshot) and ``POST /api/v1/panel/instances/{id}/complete``
(completion as the tapped child profile) — authenticated with the
static panel service token as a Bearer credential on every request.
Transport-injected by construction: the constructor takes the base URL,
the panel token, and the HTTP session to use. The session speaks the
aiohttp ``ClientSession`` surface (``request(method, url, headers=...,
json=...)`` returning an async context manager whose response carries
``status`` and ``await text()``), so tests stub the transport with NO
network and NO aiohttp installed; production builds the client through
:func:`client_from_entry`, which hands in Home Assistant's shared
aiohttp session (``homeassistant.helpers.aiohttp_client
.async_get_clientsession``). The module itself imports NEITHER
homeassistant NOR aiohttp at runtime (aiohttp appears only behind
``TYPE_CHECKING`` for annotations), so the client is usable anywhere —
the HA coupling lives in the one production factory.
Every failure is typed: a transport failure (connection refused, DNS
failure, timeout — the exchange never produced a response) and every
non-2xx HTTP response raise :class:`NestQuestApiError`. The error
carries the HTTP status where there is one (so 401 and 404 are
distinguishable at the call site) and ``None`` when there is not; the
original transport error is chained as ``__cause__`` — nothing is
swallowed. There are NO retries and the client logs nothing: refresh
cadence is the coordinator's concern (the next task), and the panel
token is a secret that must never reach a log line or an error
message — the error texts name the method, path, and status only.
The panel plane's SSE event stream (``GET /api/v1/panel/events``) is
exposed by :meth:`NestQuestApiClient.stream_events` — an async
generator yielding ``(event_type, payload)`` per Server-Sent Events
frame (``event: <type>`` / ``data: <json>`` lines separated by a blank
line). Opening the stream gets the same one-timeout, typed-error
contract as every other call; reading it has NO body-wide timeout (a
healthy stream is long-lived — the subscription manager owns
reconnection and backoff).
"""
from __future__ import annotations
import asyncio
import inspect
import json
from collections.abc import AsyncIterator
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from .const import (
CONF_API_BASE_URL,
CONF_PANEL_TOKEN,
DEFAULT_API_BASE_URL,
DEFAULT_PANEL_TOKEN,
)
if TYPE_CHECKING:
import aiohttp
__all__ = [
"COMPLETE_INSTANCE_PATH",
"DEFAULT_TIMEOUT_SECONDS",
"EVENTS_PATH",
"NestQuestApiError",
"NestQuestApiClient",
"SNAPSHOT_PATH",
"client_from_entry",
"is_valid_base_url",
"resolve_api_config",
]
#: The panel plane's snapshot route (Feature 16).
SNAPSHOT_PATH = "/api/v1/panel/snapshot"
#: The panel plane's SSE event-stream route (Feature 16): each
#: transition arrives as an ``event: <type>`` / ``data: <json>`` frame.
EVENTS_PATH = "/api/v1/panel/events"
#: The panel plane's completion route template (Feature 16); the
#: ``{instance_id}`` placeholder is filled per call.
COMPLETE_INSTANCE_PATH = "/api/v1/panel/instances/{instance_id}/complete"
#: Request timeout, in seconds. One attempt, one timeout: a hung API
#: call surfaces as a typed error for the coordinator to report, never
#: a retry loop.
DEFAULT_TIMEOUT_SECONDS = 10.0
class NestQuestApiError(Exception):
"""A failed NestQuest API exchange, raised for every failure mode.
``status`` carries the HTTP status when a response arrived (a
non-2xx response — 401 for a bad token, 404 for an unknown
instance) and is ``None`` when the exchange failed at the transport
level (connection refused, DNS failure, timeout) — so callers
distinguish the two with ``is_transport_error``. The original
transport error, where there is one, is chained as ``__cause__``.
Messages carry the method, path, and status — never the request
headers, so the panel token can never leak through an error.
"""
def __init__(self, message: str, *, status: int | None = None) -> None:
super().__init__(message)
self.status = status
@property
def is_transport_error(self) -> bool:
"""True when the exchange failed before any response arrived."""
return self.status is None
def is_valid_base_url(value: object) -> bool:
"""Return True when ``value`` is a usable API base URL.
A full ``http://`` or ``https://`` URL with a host — the exact
shape the options flow validates (form ergonomics) and the client
constructor enforces (fail fast, never build requests against a
scheme-less or host-less string).
"""
if not isinstance(value, str):
return False
candidate = value.strip()
if not candidate:
return False
parsed = urlparse(candidate)
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
def resolve_api_config(entry: Any) -> tuple[str, str]:
"""Resolve ``(base_url, panel_token)`` off a config entry.
Precedence mirrors the options flow's stored-values resolution:
``entry.options`` first (the live options-flow copy), then
``entry.data`` (the config-flow-era copy), then the const defaults.
The token is a secret: callers must never log the returned pair.
"""
options = getattr(entry, "options", None) or {}
data = getattr(entry, "data", None) or {}
def _pick(key: str, default: str) -> str:
if key in options:
return options[key]
if key in data:
return data[key]
return default
return (
_pick(CONF_API_BASE_URL, DEFAULT_API_BASE_URL),
_pick(CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN),
)
def client_from_entry(hass: Any, entry: Any) -> NestQuestApiClient:
"""Build the production client for a config entry.
Reads the configured base URL and panel token off the entry
(:func:`resolve_api_config`) and hands the client Home Assistant's
SHARED aiohttp session (``homeassistant.helpers.aiohttp_client
.async_get_clientsession``) so panel calls ride HA's one connection
pool. The helper import is deferred into this function on purpose:
the test harness is mock-only and the module must import (and the
client must be stub-transport testable) without homeassistant or
aiohttp installed.
"""
from homeassistant.helpers.aiohttp_client import async_get_clientsession
base_url, panel_token = resolve_api_config(entry)
return NestQuestApiClient(
base_url, panel_token, async_get_clientsession(hass)
)
class NestQuestApiClient:
"""Async client for the NestQuest API service's panel plane.
ONE instance per config entry (the coordinator task wires it into
the refresh cycle). The client is deliberately thin: it adds the
Bearer credential and the base URL, enforces one timeout per
request, and turns every failure into :class:`NestQuestApiError` —
no business logic, no retries, no caching.
"""
def __init__(
self,
base_url: str,
panel_token: str,
session: aiohttp.ClientSession,
*,
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
) -> None:
"""Store the connection settings; the session is injected.
``session`` is an aiohttp ``ClientSession`` in production and a
stub transport in tests. ``base_url`` must be a full http(s)
URL and ``panel_token`` must be non-empty — a client built
without either can only fail later at request time with a
less-explicable error, so construction fails fast instead.
"""
if not is_valid_base_url(base_url):
raise ValueError(
f"base_url must be a full http(s) URL, got {base_url!r}"
)
if not isinstance(panel_token, str) or not panel_token.strip():
raise ValueError("panel_token must be a non-empty string")
if (
not isinstance(timeout_seconds, (int, float))
or isinstance(timeout_seconds, bool)
or timeout_seconds <= 0
):
raise ValueError(
f"timeout_seconds must be a positive number, got {timeout_seconds!r}"
)
self._base_url = base_url.strip().rstrip("/")
self._panel_token = panel_token
self._session = session
self._timeout_seconds = float(timeout_seconds)
async def get_snapshot(self) -> dict[str, Any]:
"""Fetch and parse today's household snapshot.
Returns the JSON the snapshot route returns (``today_iso``,
``cycle_day``, and the per-child list — see
``api/routes_panel.py`` for the documented shape). Raises
:class:`NestQuestApiError` on transport failure, timeout, or a
non-2xx response.
"""
return await self._request_json("GET", SNAPSHOT_PATH)
async def complete_instance(
self, instance_id: int, actor_child_id: int
) -> dict[str, Any]:
"""Complete one quest instance as the tapped child profile.
POSTs ``{"actor_child_id": actor_child_id}`` to the complete
route and returns the parsed response body (the route answers
``{"status": ...}`` both for a first completion and for an
idempotent repeat, so a retried panel tap succeeds). Raises
:class:`NestQuestApiError` on transport failure, timeout, or a
non-2xx response — 401 for a wrong token, 404 for an unknown
instance.
"""
path = COMPLETE_INSTANCE_PATH.format(instance_id=instance_id)
return await self._request_json(
"POST", path, json_body={"actor_child_id": actor_child_id}
)
async def stream_events(
self,
) -> AsyncIterator[tuple[str, dict[str, Any]]]:
"""Yield ``(event_type, payload)`` for every panel SSE frame.
Opens ``GET /api/v1/panel/events`` with the Bearer credential
and parses the Server-Sent Events framing the API service
emits: ``event: <type>`` and ``data: <json>`` lines closed by a
blank line (one frame per transition event; a frame's ``data``
may span several ``data:`` lines, joined with newlines — the
standard SSE rule). Line splits across chunk boundaries are
buffered, so frames are parsed correctly whatever chunking the
transport produces.
Opening the stream carries the SAME one-timeout, typed-error
contract as every other call: a non-2xx response raises
:class:`NestQuestApiError` with its status and a transport
failure (connection refused, DNS, the open timing out) raises
one with ``status=None`` and the original error chained. The
reading loop itself has NO timeout — a healthy stream is
long-lived — but any failure raised from the transport
mid-stream raises typed as well, so the consumer can reconnect.
A frame whose ``data`` is not valid JSON is a protocol failure
and raises typed, carrying the response status it arrived
with. Cancellation is never converted: closing the generator
(``aclose``, i.e. the consumer's task being cancelled) stops
cleanly and closes the underlying response in every path.
"""
url = f"{self._base_url}{EVENTS_PATH}"
headers = {"Authorization": f"Bearer {self._panel_token}"}
# Opening the response gets ONE request timeout, exactly like
# ``_request_json``; the read loop below runs WITHOUT one — a
# body-wide timeout would kill a healthy long-lived stream.
# The response context manager is entered manually (not via
# ``async with``) because the block must span the yields below;
# the ``finally`` closes it on EVERY exit path.
try:
async with asyncio.timeout(self._timeout_seconds):
stream_cm = self._session.request(
"GET", url, headers=headers, json=None
)
response = await stream_cm.__aenter__()
except asyncio.CancelledError:
# Cancellation is never converted (see ``_request_json``).
raise
except NestQuestApiError:
raise
except Exception as err:
raise NestQuestApiError(
f"NestQuest API GET {EVENTS_PATH} transport failure: {err}",
status=None,
) from err
status = response.status
if not 200 <= status < 300:
await self._close_stream(stream_cm)
raise NestQuestApiError(
f"NestQuest API GET {EVENTS_PATH} failed with HTTP {status}",
status=status,
)
try:
event_type: str | None = None
data_lines: list[str] = []
buffer = ""
async for chunk in response:
if isinstance(chunk, (bytes, bytearray)):
chunk = chunk.decode("utf-8")
buffer += chunk
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
if line.startswith("event:"):
event_type = line[len("event:"):].strip()
elif line.startswith("data:"):
data_lines.append(line[len("data:"):].strip())
elif not line and event_type is not None:
# A blank line ends the frame. A trailing
# incomplete frame (stream ends without its
# closing blank line) is discarded, per the
# SSE spec's treatment of incomplete events.
try:
payload = json.loads("\n".join(data_lines))
except ValueError as err:
raise NestQuestApiError(
f"NestQuest API GET {EVENTS_PATH} "
f"delivered a malformed SSE frame: {err}",
status=status,
) from err
yield event_type, payload
event_type = None
data_lines = []
except asyncio.CancelledError:
raise
except NestQuestApiError:
raise
except Exception as err:
raise NestQuestApiError(
f"NestQuest API GET {EVENTS_PATH} transport failure: {err}",
status=None,
) from err
finally:
await self._close_stream(stream_cm)
@staticmethod
async def _close_stream(stream_cm: Any) -> None:
"""Close an opened streaming response context manager.
Works for aiohttp's response context manager (whose
``__aexit__`` is a coroutine) and for test stubs that return
either a coroutine or a plain value.
"""
result = stream_cm.__aexit__(None, None, None)
if inspect.isawaitable(result):
await result
async def _request_json(
self,
method: str,
path: str,
*,
json_body: dict[str, Any] | None = None,
) -> Any:
"""Run ONE request and return the parsed JSON body.
The Bearer token rides the Authorization header only. A
non-2xx response raises :class:`NestQuestApiError` with its
status; anything the transport raises (connection, timeout) is
wrapped as a transport-level :class:`NestQuestApiError` with
the original error chained — never swallowed, never retried.
A 2xx body that is not valid JSON is a protocol failure and
raises typed too, carrying the status it arrived with.
"""
url = f"{self._base_url}{path}"
headers = {"Authorization": f"Bearer {self._panel_token}"}
try:
async with asyncio.timeout(self._timeout_seconds):
async with self._session.request(
method, url, headers=headers, json=json_body
) as response:
status = response.status
if not 200 <= status < 300:
raise NestQuestApiError(
f"NestQuest API {method} {path} failed with "
f"HTTP {status}",
status=status,
)
body_text = await response.text()
except asyncio.CancelledError:
# Cancellation is never converted: a shutting-down event
# loop must stop, not read a synthesized API error.
raise
except NestQuestApiError:
raise
except Exception as err:
raise NestQuestApiError(
f"NestQuest API {method} {path} transport failure: {err}",
status=None,
) from err
try:
return json.loads(body_text)
except ValueError as err:
raise NestQuestApiError(
f"NestQuest API {method} {path} returned malformed "
f"JSON: {err}",
status=status,
) from err
-11
View File
@@ -1,11 +0,0 @@
"""Re-export of :mod:`custom_components.nestquest.core.children` for the integration package.
The children registry business layer lives in
:mod:`custom_components.nestquest.core.children` (extracted in
task 510c1f78); this module re-exports it so existing
``from .children import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.children import * # noqa: F401,F403
-11
View File
@@ -1,11 +0,0 @@
"""Re-export of :mod:`custom_components.nestquest.core.completion` for the integration package.
The completion business layer lives in
:mod:`custom_components.nestquest.core.completion` (extracted in
task 510c1f78); this module re-exports it so existing
``from .completion import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.completion import * # noqa: F401,F403
+7 -15
View File
@@ -7,7 +7,7 @@ from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from .const import CONF_ADMIN_USER_IDS, DOMAIN
from .const import DOMAIN
class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@@ -30,11 +30,11 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) -> FlowResult:
"""Handle the user step of the config flow.
The HA user completing the flow is persisted into the entry
data as the initial admin allowlist copy: real HA does not
guarantee the flow context survives to setup, and entry data is
the persisted record first setup seeds from — the owner is
never locked out.
The integration needs no configuration to start (the API
connection is set in the options flow), so the user input is
stored as-is. The former admin-allowlist copy is gone: with
the database removed the allowlist lives in the API service's
own store, not on the entry.
"""
await self.async_set_unique_id(DOMAIN, raise_on_progress=False)
if self._async_in_progress() or self._async_current_entries():
@@ -43,12 +43,4 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is None:
return self.async_show_form(step_id="user")
data = dict(user_input)
flow_user_id = (
self.context.get("user_id")
if isinstance(self.context, dict)
else None
)
if flow_user_id:
data[CONF_ADMIN_USER_IDS] = [flow_user_id]
return self.async_create_entry(title="NestQuest", data=data)
return self.async_create_entry(title="NestQuest", data=dict(user_input))
+302 -38
View File
@@ -1,4 +1,4 @@
"""The shared NestQuest DataUpdateCoordinator (Feature 10).
"""The shared NestQuest DataUpdateCoordinator (Feature 10, Feature 18).
ONE refresh pass builds the whole entity snapshot: active children,
today's instances with their derived states (open / done / missed via
@@ -7,54 +7,81 @@ per-child rollup counts. Every Feature 10 entity reads
``coordinator.data`` — no entity queries the database itself, so one
refresh updates the whole board coherently.
"Today" and "now" are HA-local (``hass.config.time_zone``), never the
host clock and never UTC, matching the materialization and completion
layers.
Since Feature 18 the snapshot no longer comes from the local database:
the coordinator polls the NestQuest API service's panel plane —
``GET /api/v1/panel/snapshot`` through
:class:`~.api_client.NestQuestApiClient` on its update interval — and
RECONSTRUCTS the same core dataclasses the local builder produced
(:class:`~.core.snapshot.NestQuestSnapshot`,
:class:`~.core.snapshot.ChildDaySnapshot`,
:class:`~.core.snapshot.QuestInstanceView`). ``sensor.py`` and
``binary_sensor.py`` read ``coordinator.data`` and
:func:`~.core.snapshot.instance_payload` exactly as before, so every
entity_id and attribute payload shape is unchanged: the panel cards and
the four blueprints keep working untouched.
The snapshot assembly itself lives in the Home-Assistant-free
:mod:`custom_components.nestquest.core.snapshot` module
(:func:`~core.snapshot.build_snapshot`); this coordinator is now a thin
HA wrapper that resolves the local timezone and reads the settings handed
into its constructor, then delegates to that builder, passing the SAME
HA-local ``now`` it resolved in one clock read. The API service's panel
snapshot route shares the same builder, so both consumers render ONE
snapshot shape.
One loss is deliberate and documented (D-009 revisited): the panel
payload OMITS missed instances, so a snapshot rebuilt from the API can
no longer carry them — ``admin_instances`` (``include_missed=True``)
now equals ``instances`` (they can hold only open and completed rows).
The admin surface for missed quests is the PWA, which reads the API
service directly; the integration's entities never needed missed rows
for their states or counts (the counts arrive precomputed).
Presence is resolved through the SAME engine the materializer uses
(:class:`~.presence.PresenceEngine`): schedules and overrides are read
with the public DAO methods and the engine's anchor-date arithmetic
answers ``is this child at this house today`` (D-004 — never ISO week
parity).
Failure handling: constructing the client fails fast (ValueError) when
the entry has no panel token configured, and a fetch failure raises the
typed :class:`~.api_client.NestQuestApiError`. Neither crashes
integration setup: the constructor logs a clear message and hands back
a client-shaped stub whose refresh raises the same typed error, and
``_async_update_data`` converts every typed error into
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed` — the
exception a HA ``DataUpdateCoordinator`` records as a failed pass
(``last_update_success = False``, ``data = None``), so the entities
come up unavailable. The typed error is never swallowed silently: it
is logged with its message and chained into the UpdateFailed. (The
conversion matters at setup: HA's real
``async_config_entry_first_refresh`` wraps any failed pass in
``ConfigEntryNotReady``, so a setup that keys on the original
exception type would never match it.)
``cycle_day`` reports where today falls in the custody cycle of the
first active child (display order) that HAS a schedule — 1-based day
``N`` of ``cycle_length_weeks * 7``, computed from that schedule's
anchor date. ``0`` means no active child has a schedule at all, so
the admin header degrades to a harmless zero instead of guessing.
Last-good cache and staleness: every successful pass refreshes a cache
of the rebuilt snapshot stamped with the :func:`time.monotonic`
instant of the success. A typed failure is checked against that stamp
BEFORE the pass is declared failed: within the configured staleness
threshold (``CONF_SNAPSHOT_STALENESS``, seconds, default three update
intervals) the pass SUCCEEDS with the cached snapshot — the panel
keeps rendering last-good data and the entities stay available — and
the outage is logged as a warning. Entities go unavailable only once
the cache is older than the threshold (or before the first success);
a later successful fetch refreshes the cache and the entities recover
on their own, without a config-entry reload.
"""
from __future__ import annotations
import asyncio
import datetime
from zoneinfo import ZoneInfo
import time
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
UpdateFailed,
)
from .api_client import NestQuestApiClient, NestQuestApiError, resolve_api_config
from .const import (
CONF_UPDATE_INTERVAL,
DEFAULT_SNAPSHOT_STALENESS,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
LOGGER,
MIN_UPDATE_INTERVAL,
)
from .core.db import NestQuestDatabase
from .core.settings import NestQuestSettings
from .core.snapshot import (
ChildDaySnapshot,
NestQuestSnapshot,
QuestInstanceView,
build_snapshot,
)
__all__ = [
@@ -65,9 +92,131 @@ __all__ = [
]
def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot:
"""Reconstruct the core snapshot dataclasses from the API's JSON.
The panel snapshot route (``api/routes_panel.py``) returns today's
household as plain JSON — ``today_iso``, ``cycle_day``, and the
per-child list with precomputed counts and the instances in the
documented payload shape. This function maps that shape BACK onto
the dataclasses ``sensor.py`` / ``binary_sensor.py`` read, field by
field:
- the API's ``state`` ``"completed"`` maps back to the core view's
``"done"`` (so :func:`~.core.snapshot.instance_payload` renders
``"completed"`` again — the round-trip is identity for open and
completed rows);
- ``overdue``, ``on_time`` (→ ``was_on_time``), ``completed_at``,
``window``, ``due_time``, ``icon`` and the per-child counts pass
through verbatim;
- ``present`` and ``next_present`` pass through verbatim (the API
resolved them through the same presence engine server-side);
- the instance list is rebuilt IN ORDER, so the panel payload the
sensors emit matches the route's order byte for byte.
Because the route omits missed instances (D-009), a snapshot built
here cannot contain any ``missed`` view — see the module docstring.
"""
children: list[ChildDaySnapshot] = []
for child in payload.get("children", []):
views = tuple(
QuestInstanceView(
instance_id=instance["id"],
definition_id=instance["definition_id"],
child_id=instance["child_id"],
title=instance["title"],
icon=instance.get("icon"),
window=instance["window"],
# The API payload does not carry due_date: every row
# is TODAY's instance (the route queries
# [today, today]), so the view's due_date is the
# snapshot's today_iso — the same value the local
# builder stamped on every today row.
due_date=payload["today_iso"],
due_time=instance.get("due_time"),
state=(
"done" if instance["state"] == "completed" else
instance["state"]
),
overdue=instance["overdue"],
completed_at=instance.get("completed_at"),
was_on_time=instance.get("on_time"),
)
for instance in child.get("instances", [])
)
children.append(
ChildDaySnapshot(
child_id=child["child_id"],
child_name=child["child_name"],
instances=views,
present=child["present"],
due_today=child["due_today"],
completed_today=child["completed_today"],
remaining_today=child["remaining_today"],
completion_pct=child["completion_pct"],
next_present=child.get("next_present"),
)
)
return NestQuestSnapshot(
today_iso=payload["today_iso"],
children=tuple(children),
cycle_day=payload["cycle_day"],
)
class _UnconfiguredApiClient:
"""Client-shaped stand-in used when the entry has no panel token.
Constructing a real :class:`~.api_client.NestQuestApiClient` with an
empty token raises ValueError by design (fail fast on a client that
could only ever 401). An entry that simply has not been configured
yet must not crash integration setup, so the coordinator swaps in
this stub: every refresh raises the SAME typed
:class:`~.api_client.NestQuestApiError` a transport failure would,
carrying a clear "API not configured" message;
:meth:`NestQuestCoordinator._async_update_data` converts it to
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, so
the coordinator marks the entities unavailable and the HA standard
retry cycle applies. The error is logged, never silent.
"""
__slots__ = ()
async def get_snapshot(self) -> dict[str, Any]:
"""Always raise: there is no configured API to fetch from."""
raise NestQuestApiError(
"NestQuest API not configured: set the panel token in the "
"integration options"
)
def _monotonic() -> float:
"""The monotonic clock the staleness math reads.
A one-line indirection over :func:`time.monotonic` so the staleness
tests can drive the clock deterministically (patch THIS function,
never the stdlib clock). Monotonic is deliberate: wall-clock jumps
(NTP corrections, suspends) must not shorten or extend the window.
"""
return time.monotonic()
class NestQuestCoordinator(DataUpdateCoordinator):
"""Shared refresh cycle for every NestQuest entity.
One refresh pass = one ``GET /api/v1/panel/snapshot`` through the
API client, reconstructed into the core snapshot dataclasses every
entity reads. A failed pass (unconfigured API, transport failure,
non-2xx response) raises the typed error out of the client;
``_async_update_data`` converts it to
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, the
exception HA's coordinator machinery records as a failed pass: the
coordinator's ``data`` keeps its last good value while
``last_update_success`` goes False — HA marks the entities
unavailable until a later pass succeeds. An unexpected
non-typed error still propagates out of ``_async_update_data``
and is logged (with its traceback) by HA's machinery, the same way.
Forced refreshes (the service paths pushing an immediate update
after a completion) are SERIALIZED per entry: overlapping
complete/uncomplete calls must not interleave their snapshot
@@ -75,6 +224,15 @@ class NestQuestCoordinator(DataUpdateCoordinator):
newer one and leave entities wrong until the next poll. Real
HA's coordinator already serializes internally; the lock keeps
the same guarantee on the stand-in and costs nothing upstream.
Last-good cache: a successful pass stamps the rebuilt snapshot
with the :func:`~.coordinator._monotonic` instant of the success;
a typed failure within the configured staleness threshold
(``staleness_seconds``, from ``CONF_SNAPSHOT_STALENESS``) serves
that cached snapshot instead of failing the pass, so a short API
outage keeps the board up. Past the threshold — or before the
first success — the pass fails as above, and a later successful
pass refreshes the cache and recovers the entities on its own.
"""
def __init__(
@@ -82,9 +240,9 @@ class NestQuestCoordinator(DataUpdateCoordinator):
hass: HomeAssistant,
*,
entry_id: str,
database: NestQuestDatabase,
settings: NestQuestSettings,
api_client: NestQuestApiClient,
update_interval_seconds: int | None = None,
staleness_seconds: int | None = None,
) -> None:
interval = update_interval_seconds
if (
@@ -93,6 +251,19 @@ class NestQuestCoordinator(DataUpdateCoordinator):
or interval < MIN_UPDATE_INTERVAL
):
interval = DEFAULT_UPDATE_INTERVAL
staleness = staleness_seconds
if (
not isinstance(staleness, int)
or isinstance(staleness, bool)
or staleness < interval
):
# A missing or nonsensical threshold falls back to the
# shipped default; a configured-but-too-short one (below
# even one poll interval) falls back to whichever of
# default/interval spans at least one failed poll, so the
# cache is never dead on arrival. (The options flow
# validates the same rule at the form.)
staleness = max(DEFAULT_SNAPSHOT_STALENESS, interval)
super().__init__(
hass,
LOGGER,
@@ -100,9 +271,16 @@ class NestQuestCoordinator(DataUpdateCoordinator):
update_interval=datetime.timedelta(seconds=interval),
)
self.entry_id = entry_id
self.database = database
self.settings = settings
self.api_client = api_client
self._refresh_lock = asyncio.Lock()
#: The configured last-good window, in SECONDS (const.py:
#: CONF_SNAPSHOT_STALENESS / DEFAULT_SNAPSHOT_STALENESS).
self._staleness_seconds = staleness
#: The last-good cache: the snapshot from the most recent
#: SUCCESSFUL fetch and the :func:`time.monotonic` instant it
#: landed. Both stay None until the first success.
self._last_good_snapshot: NestQuestSnapshot | None = None
self._last_good_monotonic: float | None = None
async def async_refresh(self) -> None:
"""One refresh at a time; a queued refresh runs AFTER the
@@ -111,12 +289,98 @@ class NestQuestCoordinator(DataUpdateCoordinator):
async with self._refresh_lock:
await super().async_refresh()
def _local_now(self) -> datetime.datetime:
time_zone = ZoneInfo(self.hass.config.time_zone)
return datetime.datetime.now(time_zone)
async def _async_update_data(self) -> NestQuestSnapshot:
now = self._local_now()
return await build_snapshot(
self.database, self.settings, now
"""One refresh pass: fetch the panel snapshot and rebuild it.
A successful pass rebuilds the snapshot and refreshes the
last-good cache (the snapshot plus the
:func:`time.monotonic` instant of the success).
A typed API failure (unconfigured token stub, transport
failure, non-2xx response) is first checked against that
stamp: WITHIN the configured staleness threshold (elapsed time
since the last successful fetch, inclusive of the threshold
itself) the pass SUCCEEDS with the cached snapshot — the panel
keeps rendering last-good data, the entities stay available —
and the outage is logged as a clear warning (never silent).
With no cache yet, or past the threshold, the typed error is
converted to
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`
— the exception HA's coordinator machinery records as a failed
pass (``last_update_success = False``, ``data`` unchanged) so
entities go unavailable and the setup-time first refresh
arrives as ``ConfigEntryNotReady`` — with the typed error
logged and chained. Anything else still propagates.
"""
try:
payload = await self.api_client.get_snapshot()
except NestQuestApiError as err:
now = _monotonic()
cached = self._last_good_snapshot
fetched_at = self._last_good_monotonic
if (
cached is not None
and fetched_at is not None
and now - fetched_at <= self._staleness_seconds
):
LOGGER.warning(
"NestQuest API refresh failed (%s); serving the "
"last-good snapshot fetched %.0f s ago, within the "
"%d s staleness threshold",
err,
now - fetched_at,
self._staleness_seconds,
)
return cached
LOGGER.error(
"NestQuest coordinator refresh failed: %s", err
)
raise UpdateFailed(str(err)) from err
snapshot = snapshot_from_api_payload(payload)
self._last_good_snapshot = snapshot
self._last_good_monotonic = _monotonic()
return snapshot
def coordinator_client_from_entry(
hass: HomeAssistant, entry: Any, *, session_factory: Any = None
) -> Any:
"""Build the refresh client for a config entry, tolerating no token.
Reads ``(base_url, panel_token)`` off the entry and constructs the
real client. An entry with no panel token configured (the shipped
default is the empty string) makes the constructor raise ValueError
by design; integration setup must not crash on an unconfigured
entry, so this factory swaps in the typed-error stub instead and
logs ONE clear message. The ValueError itself is never re-raised
past this point — it is converted, with the reason logged.
``session_factory`` overrides where the HTTP session comes from
(production: HA's shared aiohttp session via
:func:`~.api_client.client_from_entry`); it exists so tests can
inject a stub transport without importing aiohttp — the mock-only
test harness has no ``homeassistant.helpers.aiohttp_client``.
"""
base_url, panel_token = resolve_api_config(entry)
if not isinstance(panel_token, str) or not panel_token.strip():
LOGGER.warning(
"NestQuest API panel token is not configured for entry %s: "
"entities will stay unavailable until a token is set in the "
"integration options",
entry.entry_id,
)
return _UnconfiguredApiClient()
if session_factory is not None:
return NestQuestApiClient(
base_url, panel_token, session_factory(hass)
)
from custom_components.nestquest.api_client import client_from_entry
return client_from_entry(hass, entry)
#: Test seam: entry_id → client, consulted by the conftest's client
#: factory BEFORE the production path. Always empty in production
#: (nothing in the integration writes to it); tests register scripted
#: clients through it and clear it between tests.
_COORDINATOR_CLIENT_OVERRIDES: dict[str, Any] = {}
+63 -25
View File
@@ -20,44 +20,41 @@ TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The
# panel and admin cards listen for these; Feature 10 fires completed,
# uncompleted, and child_day_complete from the service paths, and
# Feature 11's nightly sweep fires quest_missed.
# panel and admin cards listen for these; since Feature 18 the
# completed/uncompleted/day-complete transitions are published by the
# API service on its SSE stream and re-fired on the bus by the
# integration's subscription, and Feature 11's nightly sweep fires
# quest_missed.
EVENT_QUEST_COMPLETED = "nestquest_quest_completed"
EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted"
EVENT_QUEST_MISSED = "nestquest_quest_missed"
EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete"
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
#: plus the existing Feature 07 regenerate service).
#: Canonical domain-global service name (design/ENTITIES-AND-SERVICES.md §2).
#: Since Feature 18 only the panel completion service remains: the admin
#: surface is the PWA and the API service's admin routes, so the former
#: HA admin services (uncomplete, quest-definition and presence
#: management, CSV export, child management, regenerate) are gone.
SERVICE_COMPLETE_QUEST = "complete_quest"
SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest"
SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition"
SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition"
SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active"
SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern"
SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override"
SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override"
SERVICE_EXPORT_HISTORY_CSV = "export_history_csv"
SERVICE_MANAGE_CHILD = "manage_child"
SERVICE_REGENERATE = "regenerate"
DOMAIN_SERVICES: tuple[str, ...] = (
SERVICE_COMPLETE_QUEST,
SERVICE_UNCOMPLETE_QUEST,
SERVICE_CREATE_QUEST_DEFINITION,
SERVICE_UPDATE_QUEST_DEFINITION,
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
SERVICE_SET_PRESENCE_PATTERN,
SERVICE_CREATE_PRESENCE_OVERRIDE,
SERVICE_DELETE_PRESENCE_OVERRIDE,
SERVICE_EXPORT_HISTORY_CSV,
SERVICE_MANAGE_CHILD,
SERVICE_REGENERATE,
)
SQLITE_DB_FILENAME = "nestquest.db"
# Zero-config panel dashboard (Feature 20): ONE storage-mode Lovelace
# dashboard registered on setup whose config carries the bundle's
# ``custom:nestquest-party`` strategy (frontend/src/strategy.ts) — the
# views are generated at render time from the household roster. The
# url_path IS the domain: the stable key idempotency is keyed on (a
# reload or a second entry never duplicates it, and a dashboard already
# registered under it — ours or the user's — is left untouched).
DASHBOARD_URL_PATH = DOMAIN
DASHBOARD_TITLE = "NestQuest"
DASHBOARD_ICON = "mdi:party-popper"
DASHBOARD_STRATEGY_TYPE = "custom:nestquest-party"
# Configuration keys and default values
CONF_HORIZON_DAYS = "horizon_days"
DEFAULT_HORIZON_DAYS = 14
@@ -81,6 +78,21 @@ CONF_UPDATE_INTERVAL = "update_interval"
DEFAULT_UPDATE_INTERVAL = 300
MIN_UPDATE_INTERVAL = 30
# Last-good snapshot staleness (Feature 18), in SECONDS: how long the
# coordinator keeps SERVING the last good snapshot after the API stops
# answering. A failed poll whose elapsed time since the last successful
# fetch is within the threshold returns the cached snapshot (the panel
# keeps rendering last-good data, entities stay available); past it the
# poll fails the HA way and the entities go unavailable. The default is
# 3x the default update interval (900 s = 15 minutes): three consecutive
# failed five-minute polls before the board is declared stale — enough
# to ride out an API service restart, short enough not to render a
# days-old board all day. Validation floor: the threshold must be at
# least the configured update interval, or the cache could never span
# even one failed poll.
CONF_SNAPSHOT_STALENESS = "snapshot_staleness"
DEFAULT_SNAPSHOT_STALENESS = 3 * DEFAULT_UPDATE_INTERVAL
# Notification automations (Feature 11). The notify target is a free-text
# Home Assistant service name (e.g. "notify.mobile_app_dads_phone") the
# household fills in — never a hard-coded personal target; the times are
@@ -101,6 +113,32 @@ 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
+35 -1
View File
@@ -41,4 +41,38 @@ class MetaStateDao:
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
"ON CONFLICT (key) DO UPDATE SET value = excluded.value",
(key, value),
)
)
async def claim(self, key: str, value: str) -> bool:
"""Atomically set ``key`` to ``value`` unless it already is.
The single upsert statement carries a ``WHERE`` guard on the
conflict path (``t.value IS NOT excluded.value``), so the
read-compare-write is ONE SQLite statement — atomic at the
DATABASE level, not just within one process: two connections
(two processes sharing one SQLite file) cannot both observe
"not yet set" and both write, because the second writer's
statement re-evaluates the guard against the first writer's
committed value and matches zero rows.
Returns True only when THIS call changed the row: the first
claimant wins and every later claimant of the same value
returns False. A DIFFERENT value always wins (an advance, e.g.
the sweep watermark moving to a new date) and re-arms the
same-value loser check for the new value.
Runs under the shared connection lock and inside one
transaction, like :meth:`set`; ``cursor.rowcount`` (1 for a
matched write, 0 for a guarded no-op) is captured by the
wrapper on the executor thread, so no sqlite3 object crosses
back to the event loop.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
result = await self._database.execute(
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
"ON CONFLICT (key) DO UPDATE SET value = excluded.value "
f"WHERE {_TABLE}.value IS NOT excluded.value",
(key, value),
)
return result.rowcount == 1
@@ -270,6 +270,57 @@ class PresenceOverridesDao:
)
return [_override_from_row(row) for row in rows]
async def list_filtered(
self,
*,
child_id: int | None = None,
start: str | None = None,
end: str | None = None,
) -> list[PresenceOverrideRecord]:
"""Return the household's overrides, optionally narrowed.
Every filter is optional and they combine: ``child_id`` keeps
that child's overrides only (an unknown child raises ValueError
rather than reading as "no overrides"); ``start`` keeps
overrides ending on or after it and ``end`` keeps overrides
starting on or before it, so both together select the overrides
overlapping the closed range ``[start, end]`` (the
:meth:`list_by_child_and_range` boundary rule). Dates are
strict YYYY-MM-DD and ``end`` must not precede ``start``.
Ordered stably by start date, then child id, then id.
"""
if start is not None:
_validate_date(start, "start")
if end is not None:
_validate_date(end, "end")
if start is not None and end is not None and end < start:
raise ValueError(
f"end must be on or after start, got {end!r} < {start!r}"
)
clauses: list[str] = []
params: list[object] = []
if child_id is not None:
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
clauses.append("child_id = ?")
params.append(child_id)
if start is not None:
clauses.append("end_date >= ?")
params.append(start)
if end is not None:
clauses.append("start_date <= ?")
params.append(end)
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
rows = await self._database.fetch_all(
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides{where} "
"ORDER BY start_date, child_id, id",
tuple(params),
)
return [_override_from_row(row) for row in rows]
async def get(self, override_id: int) -> PresenceOverrideRecord | None:
"""Return one override by id, or None when it does not exist."""
row = await self._database.fetch_one(
+118 -13
View File
@@ -105,6 +105,19 @@ def _validate_window(window: str) -> None:
)
def _validate_skip_on_away(value: object) -> int:
"""Return ``value`` as 0/1, raising ValueError for anything else.
The fresh-schema CHECK constraint enforces this in SQLite, but a
migrated database gained the column through ``ADD COLUMN`` without
it, so every DAO write path validates here instead. Accepts a bool
or the ints 0/1; floats, strings and other ints are rejected.
"""
if not isinstance(value, int) or value not in (0, 1):
raise ValueError(f"skip_on_away must be 0 or 1, got {value!r}")
return int(value)
@dataclass(frozen=True)
class ScheduleRuleRecord:
"""One row of ``schedule_rules``."""
@@ -152,6 +165,7 @@ class QuestDefinitionRecord:
due_time: str | None
is_active: bool
created_at: str
skip_on_away: bool
@dataclass(frozen=True)
@@ -187,7 +201,7 @@ _RULE_COLUMNS = (
)
_DEFINITION_COLUMNS = (
"id, title, description, icon, schedule_rule_id, due_time, "
"is_active, created_at"
"is_active, created_at, skip_on_away"
)
_WINDOW_COLUMNS = "definition_id, window, due_time"
@@ -216,6 +230,7 @@ def _definition_from_row(row: tuple) -> QuestDefinitionRecord:
due_time=row[5],
is_active=bool(row[6]),
created_at=row[7],
skip_on_away=bool(row[8]),
)
@@ -632,6 +647,7 @@ class QuestDefinitionsDao:
due_time: str | None = None,
is_active: bool = True,
assignee_child_ids: list[int] | None = None,
skip_on_away: bool = True,
) -> QuestDefinitionRecord:
"""Insert one definition and return the record as stored.
@@ -644,6 +660,7 @@ class QuestDefinitionsDao:
empty list or None creates an unassigned definition, which
callers may fill via :meth:`add_assignee`.
"""
skip_on_away_value = _validate_skip_on_away(skip_on_away)
async with _connection_lock(self._database):
async with self._database.transaction():
await self._validate_rule_exists(schedule_rule_id)
@@ -652,7 +669,8 @@ class QuestDefinitionsDao:
result = await self._database.execute(
"INSERT INTO quest_definitions (title, description, "
"icon, schedule_rule_id, due_time, is_active, "
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
"created_at, skip_on_away) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
title,
description,
@@ -661,6 +679,7 @@ class QuestDefinitionsDao:
due_time,
int(is_active),
created_at,
skip_on_away_value,
),
)
definition_id = result.lastrowid
@@ -684,6 +703,7 @@ class QuestDefinitionsDao:
*,
description: str | None = None,
icon: str | None = None,
skip_on_away: bool = True,
) -> QuestDefinitionSnapshot:
"""Insert a rule, definition, assignees and windows atomically.
@@ -713,6 +733,7 @@ class QuestDefinitionsDao:
_validate_date(schedule_rule.start_date, "start_date")
if schedule_rule.end_date is not None:
_validate_date(schedule_rule.end_date, "end_date")
skip_on_away_value = _validate_skip_on_away(skip_on_away)
async with _connection_lock(self._database):
async with self._database.transaction():
for child_id in assignee_child_ids:
@@ -721,8 +742,18 @@ class QuestDefinitionsDao:
result = await self._database.execute(
"INSERT INTO quest_definitions (title, description, "
"icon, schedule_rule_id, due_time, is_active, "
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(title, description, icon, rule_id, None, 1, created_at),
"created_at, skip_on_away) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
title,
description,
icon,
rule_id,
None,
1,
created_at,
skip_on_away_value,
),
)
definition_id = result.lastrowid
for child_id in assignee_child_ids:
@@ -764,8 +795,10 @@ class QuestDefinitionsDao:
icon: str | None | object = _UNSET,
rule: ScheduleRuleStorage | object = _UNSET,
windows: list[tuple[str, str | None]] | object = _UNSET,
skip_on_away: bool | object = _UNSET,
assignee_child_ids: list[int] | object = _UNSET,
) -> QuestDefinitionSnapshot:
"""Edit a definition's metadata, rule and windows atomically.
"""Edit a definition's metadata, rule, windows and assignees atomically.
Mirrors :meth:`create_with_rule_and_windows`: everything runs
inside ONE transaction under the connection lock, and the
@@ -774,9 +807,10 @@ class QuestDefinitionsDao:
``_UNSET`` meaning "leave this field alone"; ``None`` writes SQL
NULL so optional metadata (description, icon) can be cleared.
- ``title``/``description``/``icon`` update the definition row
in place; the definition-level ``due_time`` column is never
written (per-window due times supersede it, D-008).
- ``title``/``description``/``icon``/``skip_on_away`` update the
definition row in place; the definition-level ``due_time``
column is never written (per-window due times supersede it,
D-008).
- ``rule`` (a pre-validated :class:`ScheduleRuleStorage`)
overwrites the referenced ``schedule_rules`` row in place: the
definition keeps its ``schedule_rule_id``, so no new or
@@ -785,13 +819,19 @@ class QuestDefinitionsDao:
are deleted and the given set re-inserted, so missing windows
are added, changed due times updated and removed windows
dropped.
- ``assignee_child_ids`` REPLACES the whole assignee set: every
id is validated as existing-and-active BEFORE any write (the
same check :meth:`add_assignee` applies), then links for
children no longer listed are removed and links for newly
listed children inserted; unchanged links are left alone.
Assignment is deliberately not settable here (multi-assignee is
managed by :meth:`add_assignee`/:meth:`remove_assignee`), and
activation is untouched. Raises ValueError when the definition
does not exist. Edits change future instances only: no
Activation is untouched. Raises ValueError when the definition
does not exist or an assignee is rejected; either way the
transaction rolls back and nothing is written. Edits change future instances only: no
``quest_instances`` or ``completion_events`` row is touched.
"""
if skip_on_away is not _UNSET:
skip_on_away = _validate_skip_on_away(skip_on_away)
async with _connection_lock(self._database):
async with self._database.transaction():
definition = await self.get(definition_id)
@@ -799,6 +839,9 @@ class QuestDefinitionsDao:
raise ValueError(
f"quest definition {definition_id} does not exist"
)
if assignee_child_ids is not _UNSET:
for child_id in assignee_child_ids:
await self._validate_assignable_child(child_id)
assignments: list[str] = []
parameters: list[object] = []
for column, value in (
@@ -809,6 +852,9 @@ class QuestDefinitionsDao:
if value is not _UNSET:
assignments.append(f"{column} = ?")
parameters.append(value)
if skip_on_away is not _UNSET:
assignments.append("skip_on_away = ?")
parameters.append(skip_on_away)
if assignments:
parameters.append(definition_id)
await self._database.execute(
@@ -844,6 +890,10 @@ class QuestDefinitionsDao:
"VALUES (?, ?, ?)",
(definition_id, window, due_time),
)
if assignee_child_ids is not _UNSET:
await self._replace_assignees(
definition_id, assignee_child_ids
)
updated = await self.get(definition_id)
assignees = await self.list_assignees(definition_id)
window_records = await self.list_windows(definition_id)
@@ -859,6 +909,36 @@ class QuestDefinitionsDao:
windows=window_records,
)
async def _replace_assignees(
self, definition_id: int, child_ids: list[int]
) -> None:
"""Make the definition's assignee links exactly ``child_ids``.
Removes the links for children not in ``child_ids`` and inserts
the missing ones. MUST be called inside the connection lock and
an open transaction, after the ids were validated as assignable.
"""
rows = await self._database.fetch_all(
"SELECT child_id FROM quest_definition_assignees "
"WHERE definition_id = ?",
(definition_id,),
)
current = {row[0] for row in rows}
wanted = set(child_ids)
for child_id in sorted(current - wanted):
await self._database.execute(
"DELETE FROM quest_definition_assignees "
"WHERE definition_id = ? AND child_id = ?",
(definition_id, child_id),
)
for child_id in child_ids:
if child_id not in current:
await self._database.execute(
"INSERT INTO quest_definition_assignees "
"(definition_id, child_id) VALUES (?, ?)",
(definition_id, child_id),
)
async def _validate_rule_exists(self, schedule_rule_id: int) -> None:
"""Raise ValueError unless the rule exists.
@@ -907,6 +987,13 @@ class QuestDefinitionsDao:
)
return [_definition_from_row(row) for row in rows]
async def list_all(self) -> list[QuestDefinitionRecord]:
"""Return every definition, active AND inactive, oldest first."""
rows = await self._database.fetch_all(
f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions ORDER BY id"
)
return [_definition_from_row(row) for row in rows]
async def _snapshots_for(
self, definitions: list[QuestDefinitionRecord]
) -> list[QuestDefinitionSnapshot]:
@@ -951,6 +1038,18 @@ class QuestDefinitionsDao:
definitions = await self.list_active()
return await self._snapshots_for(definitions)
async def list_snapshots_all(self) -> list[QuestDefinitionSnapshot]:
"""Return every definition (active AND inactive) with its rule,
assignees and windows, all read inside one locked transaction.
Mirrors :meth:`list_snapshots_active` over :meth:`list_all`
(oldest first), with the same coherence guarantee.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
definitions = await self.list_all()
return await self._snapshots_for(definitions)
async def read_snapshots_active(self) -> list[QuestDefinitionSnapshot]:
"""Return every active definition's snapshot WITHOUT locking.
@@ -990,13 +1089,16 @@ class QuestDefinitionsDao:
description: str | None | object = _UNSET,
icon: str | None | object = _UNSET,
due_time: str | None | object = _UNSET,
skip_on_away: bool | None = None,
) -> int:
"""Update the given fields; returns rows updated (0 if absent).
Arguments default to the module sentinel ``_UNSET`` meaning
"leave this column alone"; passing ``None`` explicitly writes
SQL NULL, so callers can remove optional metadata
(description, icon, due_time). Assignment and activation are
(description, icon, due_time). ``skip_on_away`` is NOT NULL,
so its ``None`` default leaves it unchanged; a supplied value
must be 0/1 (a bool). Assignment and activation are
deliberately NOT settable here: they have their own explicit
operations (:meth:`add_assignee`, :meth:`remove_assignee`,
:meth:`set_active`) so they stay separately loggable and
@@ -1013,6 +1115,9 @@ class QuestDefinitionsDao:
if value is not _UNSET:
assignments.append(f"{column} = ?")
parameters.append(value)
if skip_on_away is not None:
assignments.append("skip_on_away = ?")
parameters.append(_validate_skip_on_away(skip_on_away))
if not assignments:
return 0
parameters.append(definition_id)
+186 -33
View File
@@ -18,7 +18,8 @@ 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 and each declared window, one instance 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
written through :class:`~.dao_instances.QuestInstancesDao.upsert_if_valid`
with a ``generated_at`` stamp shared by the whole batch (one coherent
time per run).
@@ -63,9 +64,14 @@ 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 QuestInstancesDao, _resolve_today
from .dao_instances import (
CompletionEventsDao,
QuestInstancesDao,
_resolve_today,
)
from .dao_rules import (
_validate_date,
load_materialization_input,
@@ -125,8 +131,15 @@ 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."""
"""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`).
"""
schedules: dict[int, PresenceSchedule] = {}
for child_id, record in schedules_records.items():
schedules[child_id] = PresenceSchedule.decode(
@@ -144,9 +157,75 @@ 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,
@@ -206,12 +285,6 @@ 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)
@@ -226,30 +299,20 @@ async def materialize(
start = max(start, today_date)
count = 0
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)
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
return count
@@ -375,3 +438,93 @@ 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
)
+29 -2
View File
@@ -30,8 +30,9 @@ 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). Each callable migration runs inside its own
transaction
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
together with its version stamp, so a crash mid-step rolls the
statements and the stamp back together.
@@ -400,6 +401,28 @@ 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.)
@@ -423,6 +446,10 @@ 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,
]
@@ -22,18 +22,23 @@ 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 regenerate_for_child
from .materialize import count_removed_by_override, regenerate_for_child
from .presence import PresenceOverride, PresenceSchedule
@@ -130,6 +135,78 @@ async def create_presence_override(
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,
@@ -4,8 +4,9 @@ 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, :func:`assign_child` and
:func:`unassign_child` the assignment paths, and
: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:`set_quest_definition_active` the deactivate/reactivate path.
There is no permission check here (that is Feature 09).
@@ -191,6 +192,17 @@ 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.
@@ -212,6 +224,7 @@ 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.
@@ -231,6 +244,7 @@ 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)
@@ -243,6 +257,7 @@ 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
@@ -271,10 +286,12 @@ 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 and windows atomically.
"""Edit a quest definition's metadata, rule, windows and assignees atomically.
Arguments default to the sentinel ``_UNSET`` meaning "leave this
field alone". A provided ``title`` is validated like create's
@@ -285,9 +302,16 @@ 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. Assignment is NOT
settable here. Raises ValueError when the definition does not
exist or any argument is rejected.
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.
Returns the updated definition together with its decoded rule,
assignees and windows — all read back inside the DAO's transaction,
@@ -331,6 +355,14 @@ 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(
@@ -340,14 +372,18 @@ 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 error names only the id; re-raise
# so the public contract names the field the caller passed (matching
# assign/unassign/deactivate).
# 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).
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(
@@ -558,6 +594,21 @@ 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,
+2 -1
View File
@@ -257,7 +257,8 @@ 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
created_at TEXT NOT NULL,
skip_on_away INTEGER NOT NULL DEFAULT 1 CHECK (skip_on_away IN (0, 1))
)"""
SCHEMA_V1_QUEST_DEFINITIONS_DDL: list[str] = [
@@ -1,46 +0,0 @@
"""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)}"
)
+42 -15
View File
@@ -26,12 +26,17 @@ 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 → write span runs inside the
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 queue instead of interleaving, and the loser re-reads
the watermark INSIDE the lock — sees the winner's date — and announces
nothing twice.
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
@@ -60,11 +65,11 @@ LOGGER = logging.getLogger(__name__)
SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date"
#: One asyncio.Lock per (connection wrapper, running loop), mirroring
#: the settings store's pattern: the whole watermark read → query →
#: build → write span is one critical section, so an overlapping
#: startup sweep and rollover sweep queue instead of double-announcing.
#: The loser re-reads the watermark INSIDE the lock, sees the winner's
#: date, and fires nothing.
#: 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] = {}
@@ -99,15 +104,24 @@ async def run_missed_sweep(
the caller decides how to publish them and announces nothing on
its own.
The whole watermark read → query → build → write span runs inside
the per-database sweep lock: an overlapping startup sweep and a
rollover sweep queue instead of interleaving, and the loser
re-reads the watermark inside the lock — sees the winner's date —
and announces nothing twice. Every event in one run shares one
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)
@@ -134,10 +148,23 @@ async def run_missed_sweep(
),
)
)
await dao.set(SWEEP_WATERMARK_KEY, today_iso)
# 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
@@ -1,12 +0,0 @@
"""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
@@ -1,12 +0,0 @@
"""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
@@ -1,11 +0,0 @@
"""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
@@ -1,11 +0,0 @@
"""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
@@ -1,17 +0,0 @@
"""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
@@ -0,0 +1,215 @@
"""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
@@ -1,31 +0,0 @@
"""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
@@ -1,79 +0,0 @@
"""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)
+3 -2
View File
@@ -7,12 +7,13 @@
"config_flow": true,
"dependencies": [
"frontend",
"http"
"http",
"lovelace"
],
"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.5.0"
"version": "0.6.0"
}
@@ -1,11 +0,0 @@
"""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
@@ -1,11 +0,0 @@
"""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
+123 -284
View File
@@ -1,4 +1,15 @@
"""Options flow for NestQuest post-setup settings."""
"""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.
"""
from __future__ import annotations
from typing import Any
@@ -6,61 +17,39 @@ 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_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_API_BASE_URL,
CONF_PANEL_IDLE_TIMEOUT,
CONF_PANEL_TOKEN,
CONF_SNAPSHOT_STALENESS,
CONF_UPDATE_INTERVAL,
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_API_BASE_URL,
DEFAULT_PANEL_IDLE_TIMEOUT,
DEFAULT_PANEL_TOKEN,
DEFAULT_SNAPSHOT_STALENESS,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
MIN_UPDATE_INTERVAL,
TIME_PATTERN,
)
from .admin_allowlist import list_admin_ids, set_admin_ids
from .api_client import is_valid_base_url
MIN_HORIZON_DAYS = 1
MIN_PANEL_IDLE_TIMEOUT = 30
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
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 _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
"""Validate user input and return field-level errors instead of raising."""
``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.
"""
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)
@@ -81,270 +70,127 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
):
errors[CONF_UPDATE_INTERVAL] = "invalid"
# 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,
# 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
):
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)
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
):
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()
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()
):
# 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"
errors[CONF_PANEL_TOKEN] = "invalid"
return errors
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:
def _build_schema(current: dict[str, Any]) -> vol.Schema:
"""Build the form schema, pre-filled with the current values.
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.
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).
"""
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)
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,
}
)
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(),
admin_choices=admin_choices,
admin_default=admin_default,
),
data_schema=_build_schema(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)
errors = _async_validate(user_input, self._current_values())
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,
)
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,
),
data_schema=_build_schema(self._current_values()),
errors=errors,
)
# 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.
# Every remaining key is stored the fall-back-to-current way, so
# raw submits that predate a section keep that section's stored
# values.
current = self._current_values()
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: dict[str, Any] = {}
for key in (
CONF_PANEL_IDLE_TIMEOUT,
CONF_UPDATE_INTERVAL,
CONF_SNAPSHOT_STALENESS,
CONF_API_BASE_URL,
CONF_PANEL_TOKEN,
):
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)
data[key] = user_input.get(key, current[key])
return self.async_create_entry(title="NestQuest", data=data)
def _current_values(self) -> dict[str, Any]:
@@ -353,18 +199,11 @@ 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_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),
(CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS),
(CONF_API_BASE_URL, DEFAULT_API_BASE_URL),
(CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN),
):
if key in options:
values[key] = options[key]
@@ -372,4 +211,4 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
values[key] = entry_data[key]
else:
values[key] = default
return values
return values
@@ -1,90 +0,0 @@
"""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
@@ -1,11 +0,0 @@
"""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
@@ -1,10 +0,0 @@
"""Re-export of :mod:`custom_components.nestquest.core.presence_management` for the integration package.
The presence-write business layer lives in
:mod:`custom_components.nestquest.core.presence_management` (added for
task e4a9d31b); this module re-exports it so the integration's service
handlers call the ONE implementation the API admin plane shares.
"""
from __future__ import annotations
from .core.presence_management import * # noqa: F401,F403
@@ -1,11 +0,0 @@
"""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
@@ -1,10 +0,0 @@
"""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
@@ -1,11 +0,0 @@
"""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
+63 -3
View File
@@ -32,6 +32,7 @@ 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 (
@@ -41,13 +42,21 @@ from .coordinator import (
)
from .core.snapshot import instance_payload
#: Home Assistant's state machine refuses states longer than 255
#: HA'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.
@@ -98,6 +107,51 @@ 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.
@@ -356,7 +410,12 @@ class _NestQuestHouseholdSensor(CoordinatorEntity, SensorEntity):
class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
"""Total quests due today across active children."""
"""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``.
"""
_attr_name = "NestQuest household quests due today"
_attr_state_class = SensorStateClass.MEASUREMENT
@@ -380,7 +439,8 @@ class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
"children": {
str(child.child_id): child.due_today
for child in data.children
}
},
"child_roster": _child_roster(data.children),
}
@@ -1,11 +0,0 @@
"""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
+68 -487
View File
@@ -1,61 +1,36 @@
"""Domain-global NestQuest service schemas and handlers (Feature 09).
"""The domain-global ``nestquest.complete_quest`` service (Feature 18).
Registration is domain-scoped and happens once (see
: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 (the
presence writes included, through :mod:`.presence_management` — the
ONE implementation the API admin plane shares).
``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__``.
: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.
"""
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 . import children as children_layer
from . import completion as completion_layer
from . import presence_management as presence_management_layer
from . import quest_definitions as quest_definitions_layer
from .api_client import NestQuestApiError
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 .events import fire_quest_completed, fire_quest_uncompleted
from .permissions import permission_gate
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:
@@ -65,82 +40,6 @@ 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,
@@ -150,102 +49,22 @@ 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 has no live database; cannot run this service"
"NestQuest is not loaded; cannot run this service"
)
return runtime
async def _refresh_entities(runtime: Any) -> None:
"""Force an immediate coordinator refresh after a service mutation.
"""Force an immediate coordinator refresh after the service call.
Only the SERVICE paths call this — the scheduled materialize and
Only the SERVICE path calls this — the scheduled materialize and
rollover internals are direct function calls and never widen this
gate — so entities reflect a completion the moment the service
gate — so the entities reflect a completion the moment the service
call returns instead of waiting for the poll interval.
"""
coordinator = getattr(runtime, "coordinator", None)
@@ -253,12 +72,6 @@ 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:
@@ -301,223 +114,50 @@ def _complete_quest(
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
instance_id = call.data["instance_id"]
# ``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 result.appended:
await fire_quest_completed(
hass,
runtime.database,
instance_id,
was_on_time=result.was_on_time,
)
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
await presence_management_layer.set_presence_schedule(
runtime.database,
data["child_id"],
data["cycle_length_weeks"],
data["anchor_date"],
data["pattern"],
**_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 presence_management_layer.create_presence_override(
runtime.database,
data["child_id"],
data["start_date"],
data["end_date"],
data["is_present"],
note=data.get("note"),
**_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)
await presence_management_layer.delete_presence_override(
runtime.database,
call.data["override_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:
# 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(
"ordered_ids is required when action is reorder"
"nestquest.complete_quest completes through the API's "
"panel route as the tapped child profile; actor must "
"be 'panel' with actor_child_id"
)
await children_layer.reorder_children(database, ordered_ids)
# 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,
)
if complete_instance is None:
raise HomeAssistantError(
"NestQuest API is not configured; cannot complete a "
"quest"
)
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.
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)
return _with_errors(_handler)
@@ -526,84 +166,25 @@ def async_register_services(
hass: HomeAssistant,
*,
find_runtime: FindRuntime,
regenerate_handler: ServiceHandler,
) -> None:
"""Register every canonical service once at domain scope.
"""Register the panel completion service once at domain scope.
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.
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.
"""
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
if hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST):
return
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)
hass.services.async_register(
DOMAIN,
SERVICE_COMPLETE_QUEST,
_complete_quest(hass, find_runtime),
schema=SCHEMA_COMPLETE_QUEST,
)
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)
+4 -291
View File
@@ -1,15 +1,8 @@
# 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.
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.
# "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.
complete_quest:
name: Complete quest
@@ -43,284 +36,4 @@ complete_quest:
description: Home Assistant user id when actor is user and no call context is present.
required: false
selector:
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:
text:
+172
View File
@@ -0,0 +1,172 @@
"""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)
-32
View File
@@ -1,32 +0,0 @@
"""Re-export of :mod:`custom_components.nestquest.core.store` for the integration package.
The path resolver lives in
:mod:`custom_components.nestquest.core.store` (extracted in task
d0b691d8) and is Home-Assistant-free:
:func:`custom_components.nestquest.core.store.async_get_db_path`
takes the full database path. This module re-exports it and provides
the original ``async_get_db_path(hass)`` signature so the integration
resolves the path from ``hass.config.path`` exactly as before.
"""
from __future__ import annotations
from pathlib import Path
from homeassistant.core import HomeAssistant
from .core.const import SQLITE_DB_FILENAME
from .core.store import async_get_db_path as _core_async_get_db_path
from .core.store import _ensure_dir
__all__ = ["async_get_db_path", "_ensure_dir"]
async def async_get_db_path(hass: HomeAssistant) -> Path:
"""Return the absolute path to the NestQuest SQLite database file.
Resolves the database file from ``hass.config.path`` and delegates to
:func:`custom_components.nestquest.core.store.async_get_db_path`,
which ensures the parent directory exists off the event loop via
:func:`asyncio.to_thread`.
"""
return await _core_async_get_db_path(hass.config.path(SQLITE_DB_FILENAME))
+6 -7
View File
@@ -18,20 +18,19 @@
"step": {
"init": {
"title": "NestQuest Settings",
"description": "Adjust how NestQuest plans, rolls over days, and idles its panel.",
"description": "Adjust how the NestQuest API client polls and reaches the NestQuest API service.",
"data": {
"horizon_days": "Generation horizon (days)",
"day_rollover_time": "Day rollover time (HH:MM)",
"panel_idle_timeout": "Panel idle timeout (seconds)",
"admin_user_ids": "Admin users (parents)"
"snapshot_staleness": "Last-good snapshot staleness (seconds)",
"api_base_url": "NestQuest API base URL (http://host:port)",
"panel_token": "Panel service token"
}
}
},
"errors": {
"invalid": "Invalid value.",
"invalid_time": "Time must use 24-hour HH:MM format.",
"no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.",
"invalid_admin": "Invalid admin selection."
"invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.",
"invalid_staleness": "Last-good snapshot staleness must be at least the update interval."
}
}
}
-72
View File
@@ -1,72 +0,0 @@
"""The nightly missed-quest sweep (Feature 11) — the Home Assistant shim.
The sweep's policy is the HA-free core's :mod:`~.core.sweep`: the
watermark read → ``list_missed_unswept`` query → payload build →
watermark span, all inside the per-database sweep lock, shared with
the API plane. This module is the integration's thin adapter over it:
at the configured day-rollover time (the same HA-local listener that
re-materializes the rolling horizon) and once at setup it calls the
core's :func:`~.core.sweep.run_missed_sweep` and fires each returned
event on ``hass.bus`` — ONE ``nestquest_quest_missed`` per past-due
open instance with the documented payload. The core never touches
Home Assistant; the firing lives here.
Idempotency is the core's watermark: after a run, ``nestquest_meta_state``
records the HA-local date the sweep ran, and the next run only sweeps
instances due in ``[watermark, today)`` — a second run the same night
fires nothing new, and a run after days of downtime sweeps exactly the
accumulated window once. An instance swept as missed that is later
completed simply gains its completion event; the sweep is read-only
over the domain tables (``missed`` is derived state, never an
event_type — Feature 08's contract).
"""
from __future__ import annotations
import datetime
import logging
from zoneinfo import ZoneInfo
from .core.sweep import (
SWEEP_WATERMARK_KEY,
run_missed_sweep as _run_core_missed_sweep,
)
from .db import NestQuestDatabase
LOGGER = logging.getLogger(__name__)
__all__ = ["SWEEP_WATERMARK_KEY", "run_missed_sweep"]
async def run_missed_sweep(
hass,
database: NestQuestDatabase,
*,
today: datetime.date | None = None,
) -> int:
"""Fire one missed event per unswept past-due open instance.
``today`` optionally pins the HA-local date (threaded like the
materialization and completion layers); when omitted it is read
from ``hass.config.time_zone``. Returns the number of events
fired. Fires nothing and returns 0 when the watermark already
covers ``today`` (a same-night rerun) — the watermark read, the
query, the payload build and the watermark write all live in the
core's locked
:func:`~.core.sweep.run_missed_sweep`; this shim only fires each
returned event on the bus and counts them.
"""
today_date = (
today
if today is not None
else datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
)
events = await _run_core_missed_sweep(database, today=today_date)
for event_type, payload in events:
hass.bus.async_fire(event_type, payload)
fired = len(events)
LOGGER.info(
"NestQuest missed sweep for %s fired %d event(s)",
today_date.isoformat(),
fired,
)
return fired
@@ -18,20 +18,19 @@
"step": {
"init": {
"title": "NestQuest Settings",
"description": "Adjust how NestQuest plans, rolls over days, and idles its panel.",
"description": "Adjust how the NestQuest API client polls and reaches the NestQuest API service.",
"data": {
"horizon_days": "Generation horizon (days)",
"day_rollover_time": "Day rollover time (HH:MM)",
"panel_idle_timeout": "Panel idle timeout (seconds)",
"admin_user_ids": "Admin users (parents)"
"snapshot_staleness": "Last-good snapshot staleness (seconds)",
"api_base_url": "NestQuest API base URL (http://host:port)",
"panel_token": "Panel service token"
}
}
},
"errors": {
"invalid": "Invalid value.",
"invalid_time": "Time must use 24-hour HH:MM format.",
"no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.",
"invalid_admin": "Invalid admin selection."
"invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.",
"invalid_staleness": "Last-good snapshot staleness must be at least the update interval."
}
}
}
File diff suppressed because it is too large Load Diff
+203
View File
@@ -0,0 +1,203 @@
# NestQuest Architecture
Status: approved direction (2026-09-21). This document supersedes the
single-process design implied by Features 01–14, in which the Home Assistant
integration owned the database and the parent admin surface was planned as a
Lovelace card inside Home Assistant.
## 1. Why this changed
The original design put everything inside the Home Assistant integration: the
SQLite database, the business logic, the kid panel cards, and (planned) the
parent admin card. Three problems surfaced:
1. **The panel needed hand configuration.** Standing up the kid panel required
hand-writing card YAML (`child_order`, `quest_log_path`, `board_path`) and
hand-creating a Lovelace dashboard. A fresh install rendered nothing.
2. **The admin surface fought Home Assistant.** The admin design
(`design/ADMIN-SPEC.md`) is drawn at 390×844 — it is a phone app. Trapping it
inside a Lovelace card meant fighting HA's UI constraints for a mobile-first
experience.
3. **Data was locked to one process.** A standalone admin web app cannot reach a
SQLite file inside Home Assistant's config directory.
The re-imagined design keeps the panel in Home Assistant (TouchHub loads an HA
dashboard), moves data ownership to a standalone API service, and promotes the
admin surface to a standalone mobile-first PWA.
## 2. Target architecture
```
DAKboard TouchHub
│ (loads an HA dashboard URL)
▼
HA box API box
┌───────────────────────────────┐ ┌───────────────────────────────────┐
│ Lovelace panel dashboard │ │ reverse proxy (TLS) │
│ ├ nestquest-party-board-card │ service │ ├ /api/v1/panel/* LAN-only │
│ └ nestquest-quest-log-card │ token │ └ /api/v1/admin/* public │
│ │ ─────────▶ │ FastAPI (imports core/) │
│ NestQuest integration │ │ └ SQLite (owned here) │
│ (thin client, no database) │ ◀───────── │ Authentik (OIDC) │
│ sensors + HA bus events │ SSE └───────────────────────────────────┘
└───────────────────────────────┘ ▲
PWA ───────────┘
https://nestquest.cubecraftlabs.com
```
One owner of the data (the API). Two clients (the HA integration, the PWA).
## 3. Data ownership
**The API service is the only writer.** The HA integration never opens the
database; it reads a snapshot and posts completions. This removes the
two-writer problem entirely.
- **Storage:** SQLite on the API box. Single household, trivial concurrency,
and the existing schema, migrations, and DAO tests are already SQLite. Postgres
buys nothing at this scale.
- **Schema:** unchanged. The cutover from the current HA-hosted database is a
file copy — same schema, same migration version.
- **Backups:** continuous replication (e.g. Litestream) plus a proven restore.
A configured-but-untested backup is not a backup.
## 4. Authentication: two planes
The kid panel and the admin PWA have opposite trust levels, so they use
separate credentials and separate network exposure.
### Panel plane (machine)
- **Credential:** a static service token.
- **Exposure:** `/api/v1/panel/*` is reachable **only from the LAN**.
- **Grants:** read the snapshot, and the single open write — complete a quest as
`actor: panel` with the tapped `actor_child_id`.
- **Refused:** admin routes reject the service token outright, not merely as a
non-admin. The internet cannot reach these routes at all.
This preserves the original fail-closed boundary: the kiosk can complete a
quest and nothing else.
### Admin plane (human)
- **Credential:** an Authentik OIDC JWT (authorization code + PKCE).
- **Exposure:** `/api/v1/admin/*` is internet-facing behind Authentik.
- **Grants:** everything a parent can do — definitions, assignment, presence,
children, un-complete, history, export, settings, regenerate.
- **Gate:** the API validates the JWT (issuer, audience, JWKS signature, expiry)
and requires membership in the `nestquest-admins` group. The Authentik group
is the single source of truth for "who is a parent", replacing the
integration-side admin allowlist.
## 5. Components
### `core/` — domain library
The extracted domain layer: schema, migrations, database access, DAOs, the pure
recurrence and presence engines, materialization, completion, event payload
construction, and the children/definitions/allowlist business layers. **No
Home Assistant imports.** Consumed by both the API and (during transition) the
integration. This is where the ~1500 existing tests live after extraction.
### `api/` — NestQuest API
A FastAPI service importing `core/`. Owns the database. Serves the panel and
admin planes, validates Authentik tokens, and publishes a transition event
stream (SSE) that the integration consumes. No business logic in route handlers.
### `custom_components/nestquest/` — HA integration (client)
A thin client. Polls the snapshot and builds the existing sensors; subscribes to
the SSE stream and re-fires the four HA bus events; proxies the panel completion.
Holds a last-good snapshot cache so a brief API outage does not blank the panel.
Owns no database and no business logic.
### `panel/` — Lovelace cards and dashboard strategy
The two kid-facing Lit cards plus a dashboard strategy that generates the panel
views at render time. Cards auto-discover children, so no `child_order` is
required; the strategy computes navigation paths, so `quest_log_path` and
`board_path` are not required.
### `admin/` — Admin PWA
A React progressive web app implementing `design/ADMIN-SPEC.md`, authenticated
via Authentik OIDC, installable on a phone. The only admin surface.
## 6. Zero-configuration panel
A fresh install should show the panel with no YAML:
1. **Auto-discovery.** The party-board card reads an ordered child list from a
household entity attribute instead of a configured `child_order`.
2. **Dashboard strategy.** A Lovelace strategy generates a party-board view and
one quest-log view per child, wiring navigation between them, so paths are
computed rather than configured. Explicit configuration still wins if a user
supplies it.
3. **Auto-registration.** The integration registers the dashboard on first
setup, so there is no manual dashboard step.
## 7. Home Assistant surface preserved
The re-imagining must not break what already works:
- **Sensors:** the per-child and household sensor entity_ids and the per-child
instance attribute payload shape are unchanged. The panel cards and the four
blueprints depend on them.
- **Events:** the four HA bus events (`nestquest_quest_completed`,
`nestquest_quest_uncompleted`, `nestquest_quest_missed`,
`nestquest_child_day_complete`) keep firing with their documented payloads.
They are now driven by the API's transition stream rather than by local
service handlers.
- **Blueprints:** the morning summary, afternoon reminder, end-of-day report, and
day-complete celebration continue to work with no change.
## 8. Events without an HA token on the API box
The API publishes transitions on an SSE stream; the integration subscribes and
re-fires the HA bus events locally. This deliberately avoids storing a Home
Assistant long-lived token on the API box, which would grant full HA access.
## 9. Migration phases
Mapped to the Maestro features:
| Phase | Feature | Outcome |
|---|---|---|
| 1 | 15 — Domain Core Extraction | `core/` runs with no HA imports; one implementation of the rules |
| 2 | 16 — NestQuest API Service | Panel and admin planes served with two-plane auth |
| 3 | 17 — API Deployment and Identity | TLS, Authentik, backups, network separation |
| 4 | 18 — HA Integration as API Client | Integration becomes a client; entity/event surface preserved |
| 5 | 19 — Admin PWA | Mobile-first React PWA replaces the admin card |
| 6 | 20 — Zero-Config Panel Dashboard | Panel installs itself; navigation and animations match the mockups |
Cutover: copy the existing SQLite file to the API box (same schema).
## 10. Locked decisions
- **API on its own box** — separate from Home Assistant.
- **Authentik** (`https://auth.cubecraftlabs.com`) for admin identity; a
`nestquest-admins` group is the admin source of truth.
- **Single household** — no tenant column, no multi-household auth.
- **React** for the admin PWA.
- **Drop the HA admin services** — the PWA is the only admin surface. The panel
completion path stays.
- **SQLite on the API box** initially; Postgres only if multi-household ever
becomes real.
## 11. Preserved vs. changed
**Preserved:** the tested Python domain layer, the panel card designs and
tokens, the sensor and event contract, the completion model, the append-only
event log, the panel's fail-closed "complete only" boundary.
**Changed:** data ownership moves to the API; the integration becomes a client;
the admin card becomes a PWA; panel cards become config-free with a
strategy-generated dashboard.
**New:** the API service, Authentik-gated admin auth, the PWA, and a
last-good-snapshot cache in the integration.
**Cost:** two new deployables (the API and the PWA) and one new box. Accepted
knowingly in exchange for a self-installing panel, an admin surface that is not
fighting HA's UI, and a data layer with real auth in front of it.
+522
View File
@@ -9,10 +9,197 @@
"lit": "^3.3.0"
},
"devDependencies": {
"jsdom": "^30.1.1",
"typescript": "^5.9.2",
"vite": "^7.1.5"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.1.tgz",
"integrity": "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.4.0",
"@csstools/css-color-parser": "^4.2.3",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.1",
"lru-cache": "^11.5.3"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz",
"integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"bidi-js": "^1.1.0",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.3"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
"integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz",
"integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz",
"integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.1",
"@csstools/css-calc": "^3.4.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz",
"integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -455,6 +642,24 @@
"node": ">=18"
}
},
"node_modules/@exodus/bytes": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz",
@@ -892,6 +1097,79 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/data-urls/node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@@ -967,6 +1245,66 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/html-encoding-sniffer": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
"integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "30.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz",
"integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^7.0.0",
"@asamuzakjp/dom-selector": "^9.2.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.13",
"@exodus/bytes": "^1.15.1",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^7.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"tough-cookie": "^6.0.2",
"undici": "^8.10.2",
"w3c-xmlserializer": "^6.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.1.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"canvas": "^3.2.3"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lit": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz",
@@ -998,6 +1336,23 @@
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/nanoid": {
"version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
@@ -1017,6 +1372,19 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1066,6 +1434,26 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rollup": {
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz",
@@ -1112,6 +1500,19 @@
"fsevents": "~2.3.2"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1139,6 +1540,52 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tldts": {
"version": "7.4.14",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.14.tgz",
"integrity": "sha512-EahQoi+Q5oqmG3yxRHx+bwn36OaLbtw6jnTFcGjc8fcmktzTgk45xRnzAx8Ch87PFEiqRhoc3FWBmY+LBrvEGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.14"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.14",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.14.tgz",
"integrity": "sha512-KYkjfJHnIC5t+Gy7hPrMHgJmWc/N9FfmS+fggRPSlpFckidpB9HD6OzPsSTkfDH+MpZgcu2tJPGgLfHl979N1Q==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1153,6 +1600,16 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.11.0.tgz",
"integrity": "sha512-AdzuGcAkzhbha3GgCSoUBx2P0quwym6qdFNCQz/fDGZc8w9XJ741wHUbt2kTmfPz+ZIOMkySFkiY7RXmiDDC8w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/vite": {
"version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
@@ -1227,6 +1684,71 @@
"optional": true
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
"integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "17.1.2",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz",
"integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
}
}
}
+1
View File
@@ -9,6 +9,7 @@
"lit": "^3.3.0"
},
"devDependencies": {
"jsdom": "^30.1.1",
"typescript": "^5.9.2",
"vite": "^7.1.5"
}
+36 -7
View File
@@ -143,6 +143,16 @@ function asNumber(value: unknown, fallback: number): number {
return Number.isFinite(parsed) ? parsed : fallback;
}
/** The slug a discovered roster entry carries, or "" when the entry is
* not an object with a non-empty slug string. */
function asRosterSlug(entry: unknown): string {
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
return "";
}
const slug = (entry as Record<string, unknown>).slug;
return typeof slug === "string" ? slug.trim() : "";
}
function clampPercent(value: number): number {
if (!Number.isFinite(value)) {
return 0;
@@ -892,15 +902,34 @@ export class NestQuestPartyBoardCard extends LitElement {
return [];
}
const order = this._config.child_order;
if (!Array.isArray(order)) {
if (Array.isArray(order)) {
return order
.filter(
(entry): entry is string =>
typeof entry === "string" && entry.trim().length > 0
)
.map((entry) => entry.trim());
}
return this._discoveredChildSlugs();
}
/** Zero-config discovery (Feature 20): the ordered child roster the
* integration publishes on the household rollup
* (custom_components/nestquest/sensor.py — ``child_roster``, the
* snapshot's sort_order, then id), each entry carrying the slug the
* per-child entity ids are built from. Consulted only when the
* config has no child_order — explicit configuration wins. */
private _discoveredChildSlugs(): string[] {
const household = this._state(
"sensor.nestquest_household_quests_due_today"
);
const roster = household?.attributes?.child_roster;
if (!Array.isArray(roster)) {
return [];
}
return order
.filter(
(entry): entry is string =>
typeof entry === "string" && entry.trim().length > 0
)
.map((entry) => entry.trim());
return roster
.map((entry) => asRosterSlug(entry))
.filter((slug) => slug.length > 0);
}
private _plates(): ChildPlate[] {
+1
View File
@@ -1,6 +1,7 @@
import "./cards/party-board";
import "./cards/quest-log";
import "./cards/admin";
import "./strategy";
const FONTS_HREF = "/nestquest-static/nestquest-fonts.css";
if (!document.querySelector('link[data-nq-fonts=""]')) {
+161
View File
@@ -0,0 +1,161 @@
import type { HassLike } from "./types";
type StateObject = {
state: unknown;
attributes?: Record<string, unknown> | null;
};
type RosterEntry = {
name: string;
slug: string;
};
type View = {
path?: string;
title?: string;
type: string;
cards: Record<string, unknown>[];
};
type LovelaceConfig = {
views: View[];
};
type StrategyConfig = Record<string, unknown>;
type StrategyClass = {
generate: (
config: StrategyConfig,
hass: HassLike | undefined
) => Promise<LovelaceConfig>;
};
declare global {
interface Window {
customStrategies?: Record<string, StrategyClass>;
}
}
function asString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
/** The slug a discovered roster entry carries, or "" when the entry is
* not an object with a non-empty slug string — the same shape check
* the party board applies to the same attribute. */
function asRosterSlug(entry: unknown): string {
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
return "";
}
const slug = (entry as Record<string, unknown>).slug;
return typeof slug === "string" ? slug.trim() : "";
}
/** The dashboard's URL path segment, used to COMPUTE the card paths the
* navigation needs. A dashboard lives at a single top-level segment
* (/<url_path>), so the first pathname segment is the dashboard root —
* this is where the strategy renders, and navigating back to it shows
* the first view (the party board). An explicit ``url_path`` in the
* strategy configuration wins for setups that need to pin it. */
function dashboardUrlPath(config: StrategyConfig): string {
const configured = asString(config.url_path).replace(/^\/+|\/+$/g, "");
if (configured) {
return configured;
}
const segment = window.location.pathname.split("/").filter(Boolean)[0];
return segment ?? "";
}
/** The display name a roster entry carries, or "" when it is not a
* non-empty string — the roster's ``name`` field (sensor.py
* ``_child_roster``), used for the quest-log view titles. */
function asRosterName(entry: unknown): string {
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
return "";
}
const name = (entry as Record<string, unknown>).name;
return typeof name === "string" ? name.trim() : "";
}
/** The ordered child roster the integration publishes on the household
* rollup (custom_components/nestquest/sensor.py — ``child_roster``,
* the snapshot's sort_order, then id). The same source the party
* board's plate discovery reads, consumed here at render time so the
* quest-log views exist for exactly the active children. */
function rosterEntries(hass: HassLike | undefined): RosterEntry[] {
const states = hass?.states as Record<string, StateObject> | undefined;
const household = states?.["sensor.nestquest_household_quests_due_today"];
const roster = household?.attributes?.child_roster;
if (!Array.isArray(roster)) {
return [];
}
return roster
.map((entry) => ({ slug: asRosterSlug(entry), name: asRosterName(entry) }))
.filter((entry) => entry.slug.length > 0);
}
/**
* Zero-config NestQuest dashboard strategy (Feature 20).
*
* A dashboard configured with ``strategy: { type: custom:nestquest-party }``
* gets its views GENERATED at render time from the household roster: one
* party-board view (the dashboard root) and one quest-log view per child,
* ordered by the roster's sort_order. The paths the cards navigate by are
* COMPUTED, not configured:
*
* - the board's ``quest_log_path`` is the dashboard root; tapping a crest
* pushes ``<root>/<slug>`` (party-board.ts ``_openQuestLog``);
* - each log view's ``path`` IS the child's slug, so the log resolves its
* child from the last pathname segment (quest-log.ts
* ``childSlugFromPath``) and its ``board_path`` back to the dashboard
* root (quest-log.ts ``_returnToBoard``).
*
* With zero children the board view still renders — the board card shows
* its own "not set up yet" notice for an empty roster, so no log views are
* needed. ``url_path`` and ``weather_entity`` may still be set explicitly
* in the strategy configuration; everything else is derived.
*/
export class NestQuestPartyStrategy {
static async generate(
config: StrategyConfig,
hass: HassLike | undefined
): Promise<LovelaceConfig> {
const root = `/${dashboardUrlPath(config)}`;
const weatherEntity = asString(config.weather_entity);
const boardCard: Record<string, unknown> = {
type: "custom:nestquest-party-board-card",
quest_log_path: root,
};
if (weatherEntity) {
boardCard.weather_entity = weatherEntity;
}
const logCard: Record<string, unknown> = {
type: "custom:nestquest-quest-log-card",
board_path: root,
};
if (weatherEntity) {
logCard.weather_entity = weatherEntity;
}
return {
views: [
{
path: "party",
title: "The Party",
type: "panel",
cards: [boardCard],
},
...rosterEntries(hass).map<View>((entry) => ({
path: entry.slug,
title: entry.name
? `${entry.name}'s Quest Log`
: `${entry.slug}'s Quest Log`,
type: "panel",
cards: [{ ...logCard }],
})),
],
};
}
}
window.customStrategies = window.customStrategies ?? {};
window.customStrategies["nestquest-party"] = NestQuestPartyStrategy;

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