Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 135cb9ee7f Rebuild the panel bundle so cards render any Free Solid icon (task 3f21165f)
The icon registry already resolves the full Font Awesome Free Solid set
(task 7c3be300), but the committed panel bundle still carried only the
curated list. Rebuild it and add a panel-card test that a non-curated
Free Solid name renders its glyph while an unknown fa: name keeps the star.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:39:59 -04:00
dev_agent e38c7d6c9c Embed the full Font Awesome Free Solid set in the icon registry (task 7c3be300) (#289)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-09-30 17:35:09 -04:00
orchestrator-agent 05fbfee136 Merge main into dev after the 0.9.8 release 2026-09-30 14:51:27 -04:00
overseer a1774cbe77 Merge pull request 'Release: manual, admin-authoritative timezone (0.9.8) (2026-09-30)' (#287) from release/2026-09-30-manual-timezone into main
Reviewed-on: #287
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-30 14:45:59 -04:00
orchestrator-agent 0c9f833863 release: advance version to 0.9.8 and add the 0.9.8 release notes 2026-09-30 11:15:02 -04:00
dev_agent 52895199be Document the manual, admin-set application timezone (task e9aa78f3) (#286)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-09-30 11:13:24 -04:00
dev_agent 0e726ac64f Manual timezone is the single source of local-time truth (task 7e173881) (#285)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-09-30 10:02:05 -04:00
overseer 69db92b7b8 Admin app: explicit manual timezone picker (task ade35bd5) (#283)
Co-authored-by: Joshua <joshua@cnjmail.com>
Co-committed-by: Joshua <joshua@cnjmail.com>
2026-09-30 07:45:08 -04:00
overseer 2db780e3ee Remove every automatic timezone-setting mechanism (task 7f685179) (#282)
Co-authored-by: Joshua <joshua@cnjmail.com>
Co-committed-by: Joshua <joshua@cnjmail.com>
2026-09-30 07:22:57 -04:00
orchestrator-agent 6e25c9e42c Merge main into dev after the 0.9.7 release 2026-09-30 06:54:02 -04:00
overseer 427f82a8bd Merge pull request 'Release: panel-local overdue correction (0.9.7) (2026-09-29)' (#280) from release/2026-09-29-overdue-panel-clock into main
Reviewed-on: #280
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-29 09:01:03 -04:00
overseer 94a8dbd8c9 release: correct the 0.9.7 header-clock limitation note 2026-09-29 07:58:35 -04:00
overseer 62ff614a1e release: advance version to 0.9.7 and add the 0.9.7 release notes 2026-09-29 07:47:23 -04:00
overseer 48eae05e1f Decide quest-log overdue from the panel's own local clock (#279)
Co-authored-by: Joshua <joshua@cnjmail.com>
Co-committed-by: Joshua <joshua@cnjmail.com>
2026-09-29 07:44:12 -04:00
orchestrator-agent 55b9ce915b Merge main into dev after the 0.9.6 release 2026-09-29 07:19:01 -04:00
overseer 80f25b2faf Merge pull request 'Release: household time zone fix (0.9.6) (2026-09-28)' (#276) from release/2026-09-28-time-zone-errors into main
Reviewed-on: #276
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-29 06:43:54 -04:00
overseer 29674f7138 release: honor an explicit timezone_configured value in the 0.9.6 notes wording 2026-09-28 23:56:11 -04:00
overseer 8a5872c728 release: qualify the 0.9.6 notes for the explicit-choice fix and legacy stored zones 2026-09-28 23:44:23 -04:00
overseer b792ba50c1 Merge remote-tracking branch 'origin/dev' into release/2026-09-28-time-zone-errors 2026-09-28 23:44:05 -04:00
overseer 49f40d99b6 Record a timezone-only admin update as an explicit choice (task 45243478) (#277)
Co-authored-by: Joshua <joshua@cnjmail.com>
Co-committed-by: Joshua <joshua@cnjmail.com>
2026-09-28 23:43:42 -04:00
overseer 37d66ce5d9 release: advance version to 0.9.6 and add the 0.9.6 release notes 2026-09-28 23:12:27 -04:00
overseer 40d0e5d8f6 Adopt Home Assistant's time zone for the API household clock (task 6351b36d) (#275)
Co-authored-by: Joshua <joshua@cnjmail.com>
Co-committed-by: Joshua <joshua@cnjmail.com>
2026-09-28 23:04:16 -04:00
orchestrator-agent 07f2eec3bf Merge main into dev after the 0.9.5 release 2026-09-28 21:57:22 -04:00
overseer 622c407fb6 Merge pull request 'Release: kids' panel Return button (0.9.5) (2026-09-27)' (#273) from release/2026-09-27-return-button into main
Reviewed-on: #273
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-27 13:14:23 -04:00
orchestrator-agent 8cfca07755 release: drop the unsupported exact-width claim from the 0.9.5 notes 2026-09-27 13:07:00 -04:00
orchestrator-agent e091e9b1d1 release: advance version to 0.9.5 and add the 0.9.5 release notes 2026-09-27 17:04:54 +00:00
dev_agent 4afd07dc46 Kids' panel: explicit Return button to The Party (#272)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-27 13:01:53 -04:00
orchestrator-agent 1c1d8508e3 Merge pull request 'Reconcile dev with main after the 0.9.4 release' (#271) from main into dev 2026-09-27 12:51:23 -04:00
overseer d8a7e43dd6 Merge pull request 'Release: timezone persistence fix (0.9.4) (2026-09-26)' (#270) from release/2026-09-26-timezone-persistence into main
Reviewed-on: #270
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-26 15:00:55 -04:00
orchestrator-agent d3bf9e270c release: advance version to 0.9.4 and add the 0.9.4 release notes 2026-09-26 17:21:31 +00:00
orchestrator-agent 09f15af1c2 Merge pull request 'Reconcile dev with main after the 0.9.3 release' (#269) from main into dev 2026-09-26 13:20:05 -04:00
dev_agent a90e3cd366 Release correction: persist an explicit timezone choice (timezone_configured) (#268)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-26 13:17:38 -04:00
overseer ad6e433743 Merge pull request 'Release: admin timezone setting (0.9.3) (2026-09-26)' (#267) from release/2026-09-26-admin-timezone into main
Reviewed-on: #267
2026-09-26 13:13:08 -04:00
orchestrator-agent 1f6b2fe16f release: advance version to 0.9.3 and add the 0.9.3 release notes 2026-09-26 17:07:55 +00:00
dev_agent 09972ccbfe Admin settings: view/edit the system timezone (auto-set from the browser zone) (#266)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-26 13:05:30 -04:00
orchestrator-agent 33fa3b7c17 Merge pull request 'Reconcile dev with main after the 0.9.2 release' (#265) from main into dev 2026-09-26 12:56:13 -04:00
overseer a329d10c0a Merge pull request 'Release: admin delete/retire a task (0.9.2) (2026-09-26)' (#264) from release/2026-09-26-delete-task into main
Reviewed-on: #264
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-26 12:54:49 -04:00
orchestrator-agent 773bee6191 release: advance version to 0.9.2 and add the 0.9.2 release notes 2026-09-26 16:51:03 +00:00
orchestrator-agent 1bf2122a4a Merge pull request 'Reconcile dev with main after the 0.9.1 release' (#263) from main into dev 2026-09-26 12:49:37 -04:00
dev_agent 02f4d8c8aa Admin hybrid delete/retire of a quest definition (Delete Task) (#262)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-26 12:47:34 -04:00
overseer 305b03e0b1 Merge pull request 'Release: UI bug fixes (0.9.1) (2026-09-26)' (#261) from release/2026-09-26-ui-bugs into main
Reviewed-on: #261
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-26 12:32:03 -04:00
orchestrator-agent 0d07fb4f46 release: advance version to 0.9.1 and add the 0.9.1 release notes 2026-09-26 12:20:35 -04:00
orchestrator-agent b1c14ed7d9 Merge pull request 'Reconcile dev with main after the 0.9.0 release' (#260) from main into dev 2026-09-26 12:18:14 -04:00
overseer 09a5fcbcc2 Merge pull request 'Release: expanded and custom task icons (0.9.0) (2026-09-26)' (#251) from release/2026-09-26-task-icons into main
Reviewed-on: #251
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-26 12:17:19 -04:00
dev_agent d961d2e64f Feature: UI bugs (panel hint, crest, wordmark, overdue clock, complete, meta) (#256)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-26 10:22:01 -04:00
orchestrator-agent 5316f95086 release: advance version to 0.9.0 and add the 0.9.0 release notes 2026-09-26 09:04:40 -04:00
dev_agent bdf6d0aca1 Feature: expanded and custom task icons (Lucide + Font Awesome + emoji) (#248)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-26 09:02:07 -04:00
orchestrator-agent fb9907ae7f Merge pull request Reconcile dev with main after the 0.8.0 release 2026-09-25 14:53:18 -04:00
overseer dbf410ada3 Merge pull request 'Release: multi-pattern presence, Sunday-first week and admin fixes (0.8.0)' (#239) from release/2026-09-25-presence-patterns-and-start-date into main
Reviewed-on: #239
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-25 12:52:31 -04:00
overseer 2906da7ed1 release: clarify the 0.8.0 scope wording 2026-09-25 07:42:04 -04:00
overseer 4f609d7bb9 release: advance version to 0.8.0 and add the 0.8.0 release notes 2026-09-25 07:39:18 -04:00
dev_agent 39e0d715fc Feature: definition sheet start-date field (#238)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-25 07:38:14 -04:00
96 changed files with 14878 additions and 1643 deletions
+420
View File
@@ -1,5 +1,425 @@
# NestQuest Release Notes
## Version 0.9.8 — 2026-09-30
### Scope
Owner-directed change: the application's timezone is now **manual-only and
admin-authoritative**. Nothing sets it automatically any more; an admin sets it
from the admin app, and the saved value is the single source of local time for
the whole application. This reverses the automatic Home Assistant zone adoption
shipped in 0.9.6 and the admin app's browser-zone auto-set, and supersedes
decision D-018 with D-019. (PRs #282, #283, #285, #286; feature
`a3cd8e27` "Manual application timezone control".)
### Behaviour
- **No automatic timezone setting.** Removed: the API's adoption of Home
Assistant's reported zone (the `X-NestQuest-Timezone` request header and the
`adopt_reported_timezone` path), the integration's reporting of
`hass.config.time_zone`, the admin app's browser-zone auto-detect and
automatic save, and the `timezone_configured` marker.
- **The admin sets it manually**, in the admin app's Preferences: a curated IANA
zone picker plus a free "Other IANA zone" entry, with an explicit choice for
empty ("API host local time"). Nothing is auto-filled or auto-saved.
- **The saved value is authoritative across the whole application**: the panel
snapshot's overdue/today derivation, both panel cards' dates and clocks,
completion timestamps, the missed-sweep scheduler, and the admin reads all use
it. The API publishes the effective zone (`api/host_zone.effective_timezone`:
the saved value, else the API host's own zone) on the panel snapshot and the
admin settings response, so the panel and admin app render in the same zone.
- **The kids' panel now follows the application zone, not the browser.** This
supersedes the panel-local overdue behaviour from 0.9.7; a quest is overdue
when the application clock says so, not when the panel device's clock does.
- **An admin save of the timezone or the day-rollover time re-plans the running
missed-sweep scheduler.**
- The integration tolerates an API snapshot without the timezone field, so the
integration and the API may be upgraded in either order.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None to the database. The schema is unchanged at version 9: no migration and
no database backup is required for this release.
- Behavioural reversal: if a timezone had been auto-set by 0.9.6–0.9.7, the
stored value persists after upgrading (it is simply no longer changed
automatically), so no action is needed unless a different zone is wanted.
- With no timezone saved, the API host's local zone applies (UTC on the standard
deployment). Set the timezone in the admin Preferences for correct
household-local behaviour. A previously auto-set value counts as saved and
will be kept.
### Known limitations
- If no timezone has been saved, the API host zone applies; on the standard
UTC-host deployment, overdue and day boundaries will be wrong until an admin
sets the zone.
- The admin app's timezone picker replaces any zone outside its curated list via
the "Other IANA zone" entry; the value is validated as an IANA name.
### Rollback
- No schema change: rolling back to 0.9.7 requires no database restore.
## Version 0.9.7 — 2026-09-29
### Scope
Patch release carrying the panel-local overdue correction onto `main` (PR #279,
task fd1093b5 "Tasks are still showing over due when the local time has not
passed").
### Behaviour
- The kids' Quest Log card now decides whether an open quest is OVERDUE from
the panel's OWN local clock (the wall time where the panel physically is)
compared with the quest's due time, instead of trusting the API snapshot's
`overdue` flag. A quest therefore cannot read OVERDUE before its due time has
passed on the panel, regardless of the API host's timezone, Home Assistant's
configured `time_zone`, or whether the 0.9.6 timezone changes have been
deployed. A quest is overdue at the due minute and after (`>=`); a quest with
no due time is never overdue.
- The API's `overdue` field is unchanged; it still serves the admin plane and
automations. This is a panel display correction only.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None. The database schema is unchanged at version 9: no migration and no
database backup is required for this release.
### Known limitations
- The comparison uses only wall-clock time. If the API is unreachable across
local midnight and the coordinator serves its last-good snapshot, a quest
from the previous day with a later due time can briefly read as not overdue
until that time passes again. On the normal path the API rolls the household
day over and the missed sweep removes the previous day's open quests.
- The Quest Log header clock still formats in Home Assistant's configured
`time_zone`; if HA is set to a zone other than the household's, that header
remains wrong. Set Home Assistant's own `time_zone` correctly — the admin/API
timezone setting does not affect the header clock.
### Rollback
- No schema change: rolling back to 0.9.6 requires no database restore.
## Version 0.9.6 — 2026-09-28
### Scope
Patch release carrying the household time-zone fix onto `main` (PR #275, task
6351b36d "Time zone errors"), plus its release-review correction (PR #277, task
45243478).
### Behaviour
- The Home Assistant integration now reports `hass.config.time_zone` to the API
in an `X-NestQuest-Timezone` header on every panel request.
- While the API's `timezone` setting has never been explicitly chosen
(`timezone_configured` false), the API adopts and persists the reported zone
(under the settings lock), so the panel's overdue/today calculation, the
admin plane and the missed-sweep scheduler are all household-local even when
the API host runs UTC. No manual setup is required.
- The missed-sweep scheduler re-plans immediately when the adopted zone changes
while it is sleeping, and re-reads the stored zone before sweeping, so it no
longer fires at the old (UTC) rollover.
- An admin-chosen zone — including an explicit choice of empty (API-host local)
— still wins over the reported zone. A timezone-only admin update is recorded
as an explicit choice (`timezone_configured` true); an explicitly supplied
`timezone_configured` value in the same update is honored as given. An invalid
reported zone is logged and ignored; the panel keeps serving on the stored
settings.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None. The database schema is unchanged at version 9: no migration and no
database backup is required for this release.
- Deployment note: this fix needs BOTH the API service and the HA integration
upgraded. The integration supplies the zone and the API adopts it; upgrading
only one side leaves the previous behaviour. An install that sets or clears
its zone on 0.9.6 or later keeps that choice; see Known limitations for a zone
stored by an older release.
### Known limitations
- A zone stored by a release before 0.9.6 without the explicit-choice marker
(for example through the previously documented
`PATCH /api/v1/admin/settings {"timezone": ...}`) is not distinguishable from
an automatically adopted value, so 0.9.6 adopts Home Assistant's reported zone
over it once. If a zone different from Home Assistant's is intended, re-save
it in the admin Preferences, which now records the explicit choice.
- If `timezone` is empty and `timezone_configured` is true (an explicit
"host local" choice), that choice is respected and the API host's zone (UTC
on the standard deployment) still applies until an admin selects a zone in
the admin Preferences.
- A change to Home Assistant's own `time_zone` takes effect after the
integration reloads or Home Assistant restarts.
### Rollback
- No schema change: rolling back to 0.9.5 requires no database restore. The API
and the integration may be rolled back independently; rolling back only the
integration restores the previous stored-setting-only behaviour.
## Version 0.9.5 — 2026-09-27
### Scope
Patch release carrying the kids' panel Return button onto `main`.
- **Return button:** the Quest Log header now shows an explicit Return control —
a left chevron plus "The Party", 72px high and sized to its label, sitting left
of the crest — so a child
can go straight back to the main Party board instead of waiting out the 40 s
idle return. It calls the same navigation the idle return uses
(`board_path` + `location-changed`), so there is exactly one navigation path.
It is hidden when no board path is configured, appears on every header-bearing
Quest Log screen (normal, away, and "no quests today"), and does not appear on
the Quest-complete screen, which keeps its 12 s auto-return. The control meets
the panel's hard rules (72px tap target, 26px label, `:active` feedback only,
clear of the window columns and the dock), and the 40 s idle return and
crest-tap navigation are unchanged. (PR #272.)
### Upgrade note
The database schema is unchanged at version **9**; no migration or backup step is
needed. No runtime dependency is added — only the bundled panel card changed.
### Requirements
Home Assistant 2024.6.0 or newer. Install through HACS from the
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
mirror).
## Version 0.9.4 — 2026-09-26
### Scope
Patch release fixing the admin timezone control that shipped in 0.9.3.
- **Clearing the timezone now persists:** in 0.9.3 the Settings screen
re-detected the browser zone whenever the timezone field was empty, so an admin
could not leave it empty ("use the API host's local time") — closing and
reopening the screen set it again. A new persisted, API-only
`timezone_configured` marker (default `false`) now distinguishes "never set"
from "admin chose empty": the one-time auto-set runs only when the timezone is
empty AND the marker is false, and any saved timezone change — including
clearing it — sets the marker, so an explicit empty choice survives. (PRs #266,
#268.)
- Nothing else changes: the `timezone` setting, its auto-set from the browser zone
on a fresh install, the Add/Save flow, and the rest of the Settings screen are
as in 0.9.3.
### Upgrade note
The database schema is unchanged at version **9**; no migration or backup step is
needed. Settings stored before this release have no `timezone_configured` value
and read it as `false`, so an install whose timezone is empty will auto-set the
browser zone once on the next open — after which the choice persists.
### Requirements
Home Assistant 2024.6.0 or newer. Install through HACS from the
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
mirror).
## Version 0.9.3 — 2026-09-26
### Scope
Patch release carrying the admin timezone control onto `main`.
- **Admins set the system timezone from the PWA:** the Preferences screen gains a
Timezone field (a text input with a local datalist of common IANA zones;
`UTC` or `Area/Location`). On a fresh install the value is set AUTOMATICALLY
from the browser's IANA zone when the setting is empty on first load — no
geolocation prompt and no network lookup — and an admin can change or clear it
at any time. Only a changed value is sent, and the API's 422 for an unknown zone
is shown by the form. (PR #266.)
- This makes the household-local clock (overdue, daily rollover) configurable
from the UI, replacing the manual
`PATCH /api/v1/admin/settings {"timezone": ...}` step noted in 0.9.1. The
setting still defaults to the API host's local time when left empty.
### Upgrade note
The database schema is unchanged at version **9**; no migration or backup step is
needed. No runtime dependency is added.
### Requirements
Home Assistant 2024.6.0 or newer. Install through HACS from the
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
mirror).
## Version 0.9.2 — 2026-09-26
### Scope
Patch release carrying the admin "Delete task" capability onto `main`.
- **Delete a task in the admin PWA:** the definition edit sheet gains an
admin-only Delete action with hybrid semantics (D-017, owner decision "delete
if there's no history, retire/deactivate if there is"). If the definition has
**no completion history**, it is hard-deleted together with its instances,
assignees, windows and its schedule rule (when unreferenced), all in one
transaction under the connection lock. If it has **any completion history** (a
completion or an un-completion), nothing is deleted: it is retired by
deactivation instead, preserving the definition, rule, assignees, windows and
all history, with future uncompleted instances cleared as with any
deactivation. (PR #262.)
- **API:** `DELETE /api/v1/admin/quest-definitions/{id}` lives on the admin plane
(`require_admin`; a non-admin is refused and an unknown id is 404). The response
reports `deleted`/`retired` and carries the retired definition (`null` when
deleted). The kids' panel gains no control, and `QuestDefinitionsDao` now has
exactly one, history-guarded, delete method.
### Upgrade note
The database schema is unchanged at version **9**; no migration or backup step is
needed beyond the 0.8.0 note. No runtime dependency is added.
### Requirements
Home Assistant 2024.6.0 or newer. Install through HACS from the
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
mirror).
## Version 0.9.1 — 2026-09-26
### Scope
Patch release carrying the owner-reported UI-bug fixes for the kids' panel, plus a
household-timezone correction, onto `main`.
- **Household-local time:** a new `timezone` setting (an IANA name such as
`America/New_York`; empty keeps the API host's local time) is threaded into the
API's clock reads, so overdue and the daily rollover use the household's day
rather than a UTC host's. **Set it once after upgrading** (see the Upgrade
note); the admin UI has no field for it yet. (PR #254.)
- **Overdue and rollover correctness:** quests are no longer marked overdue
before their due time, and the missed-sweep scheduler fires at the configured
rollover across DST transitions — its delay is now a true elapsed-time
difference, with documented handling of nonexistent/ambiguous local times.
(PRs #254, #257.)
- **Complete works on the panel:** a TypeScript ES2022 `useDefineForClassFields`
default emitted native class fields that shadowed Lit's reactive accessors, so
the confirm dialog never re-rendered and a tap appeared to do nothing; the
panel now opens the confirm and completes. (PR #254.)
- **Panel polish:** the party-board hint line no longer hides behind the dock
(#244); the Quest Log header crest renders with its shield classes (#252); the
party-board wordmark is the new compass-rose monogram plus Cinzel Decorative
text, replacing the d20 dice (#253); and the quest meta line keeps its `meta`
class when not late (#255).
### Upgrade note
The database schema is unchanged at version **9**. After upgrading, set the
household timezone once so date, overdue and rollover behaviour match the house
(otherwise it falls back to the API host's local time, which may be UTC):
```
PATCH /api/v1/admin/settings {"timezone": "America/New_York"}
```
No runtime dependency is added.
### Requirements
Home Assistant 2024.6.0 or newer. Install through HACS from the
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
mirror).
## Version 0.9.0 — 2026-09-26
### Scope
Minor release carrying expanded, customisable task icons — Lucide, Font Awesome
Free and emoji — onto `main`.
- **Namespaced icon keys:** a quest definition's `icon` now accepts
`lucide:<kebab-name>`, `fa:<kebab-name>` and `emoji:<grapheme>`. A legacy bare
name (e.g. `dog`) is read as `lucide:dog`, so existing rows keep working with
no stored-data migration. The strict write validator rejects an unknown
namespace, a malformed name, an over-cap or markup-bearing emoji payload, and
`javascript:`. (PRs #241, #249.)
- **Shared icon registry:** one canonical set — the curated Lucide set plus a
curated Font Awesome Free solid subset — generates identical,
framework-agnostic registry modules for the admin and panel bundles, guarded by
a drift test. There is no CDN and no runtime fetch, and only the curated Font
Awesome glyphs are shipped. (PR #242.)
- **Admin PWA picker:** the definition edit sheet offers the Lucide set, the
Font Awesome subset and an emoji input; a selection stores the namespaced key
and an existing namespaced or legacy icon pre-fills correctly. The definition
list renders all three forms with a fallback glyph. Editing a definition whose
stored icon is a legacy value no longer resubmits the unchanged icon.
(PRs #243, #250.)
- **Kids' panel:** open quest cards render the definition's icon (Lucide stroked,
Font Awesome filled, emoji as text) with the existing star fallback; the sealed
card keeps its star/seal glyph. (PR #245.)
- **Docs and attribution:** ADMIN-SPEC and PANEL-SPEC describe the namespaced
model; `THIRD-PARTY-NOTICES.md` records Font Awesome Free (icons CC BY 4.0,
fonts SIL OFL 1.1, code MIT) and Lucide (ISC). (PR #246.)
### Upgrade note
The database schema is unchanged at version **9**; no migration or backup step
is needed beyond the 0.8.0 note. This release adds no runtime dependency — the
icon path data is embedded in the shipped bundles at build time.
### Requirements
Home Assistant 2024.6.0 or newer. Install through HACS from the
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
mirror).
## Version 0.8.0 — 2026-09-25
### Scope
Minor release carrying the multi-pattern presence model (schema 9), the
Sunday-first calendar week, and the create-materializes fix, plus the
definition start-date field, onto `main`.
- **Multiple presence patterns per child (schema 9):** a child now has zero or
more presence patterns instead of at most one. Each pattern has a name, a kind
(`home` or `away`), its own cycle length (1–4 weeks), its own anchor date and a
per-week weekday set. Evaluation is first-match-wins — overrides, then away,
then home, then absent when the child has at least one home pattern, else
present. Migration 9 copies each existing schedule into a `home` pattern named
"Home schedule" and drops the old `presence_schedules` table. (PRs #231, #232.)
- **Sunday-first calendar week:** the admin Schedule month grid and the
Definitions weekday picker now render weeks Sunday-first; the stored/API
weekday numbering is unchanged (Monday=0..Sunday=6). (PR #230.)
- **Fix — newly created tasks materialize immediately:** creating a quest
definition now regenerates its future instances over the rolling horizon, so a
new task reaches the panel without waiting for an unrelated write. (PRs #235,
#236.)
- **Definition start date:** the definition edit sheet gains a Start date field
so a task can be scheduled to begin on a future date. (PRs #237, #238.)
- **Docs:** the multi-pattern decision (D-016), the migration-9 rollback step in
the runbook, and the ADMIN-SPEC Schedule section. (PRs #233, #234.)
### Upgrade note
This release runs schema version **9**. Version 0.7.1 refuses a version-9
database, so take a copy of the SQLite file (or note the Litestream generation)
before the first start on 0.8.0 if you need a rollback path.
### Requirements
## Version 0.7.1 — 2026-09-24
### Scope
+31
View File
@@ -0,0 +1,31 @@
# Third-party notices
NestQuest bundles glyph data from the icon sets below. The curated names are listed in
`tools/icons/curated-icons.json`; `tools/icons/generate.mjs` reads each glyph's SVG path
data from the locally installed packages and generates it into both bundles
(`admin/src/icons/registry.generated.ts` and `frontend/src/icons/registry.generated.ts`).
Nothing is loaded from a CDN or fetched at runtime. The package versions used are recorded
in the header of each generated file.
## Font Awesome Free
- Project: <https://fontawesome.com> · repository: <https://github.com/FortAwesome/Font-Awesome>
- Package: `@fortawesome/free-solid-svg-icons`
- Licence: <https://fontawesome.com/license/free>
- Icons: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
- Fonts: [SIL OFL 1.1](https://openfontlicense.org)
- Code: [MIT](https://opensource.org/licenses/MIT)
- Use in NestQuest: a curated subset of the Free **solid** icon set, embedded as SVG path
data. No fonts are bundled.
Font Awesome Free by @fontawesome — https://fontawesome.com — icons licensed under CC BY 4.0.
## Lucide
- Project: <https://lucide.dev> · repository: <https://github.com/lucide-icons/lucide>
- Package: `lucide-react`
- Licence: [ISC](https://github.com/lucide-icons/lucide/blob/main/LICENSE)
- Use in NestQuest: a curated subset of Lucide icons, embedded as SVG path data.
Lucide icons are Copyright (c) Lucide Contributors, licensed under the ISC License.
Portions derived from Feather are Copyright (c) Cole Bemis, licensed under the MIT License.
+26 -1
View File
@@ -8,17 +8,18 @@
"name": "nestquest-admin",
"version": "0.0.0",
"dependencies": {
"lucide-react": "^1.47.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@fortawesome/free-solid-svg-icons": "7.3.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"jsdom": "^30.1.1",
"lucide-react": "1.47.0",
"typescript": "^5.9.2",
"vite": "^7.1.5",
"vitest": "^3.2.4"
@@ -982,6 +983,29 @@
}
}
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.3.1.tgz",
"integrity": "sha512-k0C0sdHmZtAo6dRDtd1Z/qcpyHbL0CKsjV8seMY/21xGhY5Wsv0XRmiI/xEEH4y2c9b1+jvgNs/3EqhV27yUEA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.3.1.tgz",
"integrity": "sha512-v0BLa0eqg7ubvVWeNSHVBs8fWH/GJicERZoJaxJ3FE/lj67VSqzoMg9pzfZVOfLMX10y0pGwQAuxoRVVH2patg==",
"dev": true,
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.3.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2262,6 +2286,7 @@
"version": "1.47.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.47.0.tgz",
"integrity": "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==",
"dev": true,
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+2 -1
View File
@@ -10,17 +10,18 @@
"test": "vitest run"
},
"dependencies": {
"lucide-react": "^1.47.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@fortawesome/free-solid-svg-icons": "7.3.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"jsdom": "^30.1.1",
"lucide-react": "1.47.0",
"typescript": "^5.9.2",
"vite": "^7.1.5",
"vitest": "^3.2.4"
+50 -7
View File
@@ -2,10 +2,29 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import App from "./App";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
let settingsResponse: () => Promise<Response>;
describe("NestQuest Admin shell", () => {
beforeEach(() => {
// The Today tab fetches the admin snapshot; keep the shell tests offline.
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
settingsResponse = () =>
Promise.resolve(jsonResponse({ timezone: "", effective_timezone: "Europe/London" }));
// The shell loads the stored application timezone; every tab's own
// requests stay pending so the shell tests remain offline.
vi.stubGlobal(
"fetch",
vi.fn((url: string) =>
String(url).endsWith("/api/v1/admin/settings")
? settingsResponse()
: new Promise<Response>(() => {}),
),
);
});
afterEach(() => {
@@ -22,10 +41,10 @@ describe("NestQuest Admin shell", () => {
}
});
it("starts on the Today screen and switches on tab click", () => {
it("starts on the Today screen and switches on tab click", async () => {
render(<App />);
expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy();
expect(await screen.findByRole("heading", { name: "Today" })).toBeTruthy();
fireEvent.click(screen.getByRole("tab", { name: "History" }));
@@ -36,12 +55,36 @@ describe("NestQuest Admin shell", () => {
);
});
it("the Tasks tab renders the definitions screen", () => {
it("the Tasks tab renders the definitions screen", async () => {
render(<App />);
fireEvent.click(screen.getByRole("tab", { name: "Tasks" }));
expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy();
expect(await screen.findByRole("heading", { name: "Tasks" })).toBeTruthy();
expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder");
});
});
it("renders no screen until the stored application timezone is known", async () => {
settingsResponse = () => new Promise<Response>(() => {});
render(<App />);
expect(screen.getByTestId("timezone-loading")).toBeTruthy();
expect(screen.queryByRole("heading", { name: "Today" })).toBeNull();
});
it("a failed timezone load says so and retries", async () => {
settingsResponse = () => Promise.resolve(jsonResponse({ detail: "boom" }, 500));
render(<App />);
await screen.findByTestId("timezone-error");
expect(screen.queryByRole("heading", { name: "Today" })).toBeNull();
settingsResponse = () =>
Promise.resolve(
jsonResponse({ timezone: "Pacific/Auckland", effective_timezone: "Pacific/Auckland" }),
);
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(await screen.findByRole("heading", { name: "Today" })).toBeTruthy();
});
});
+13 -9
View File
@@ -4,6 +4,7 @@ import { TransitionsProvider } from "./api/useTransitions";
import DefinitionsTab from "./definitions/DefinitionsTab";
import HistoryTab from "./history/HistoryTab";
import ScheduleTab from "./schedule/ScheduleTab";
import { AppTimezoneProvider } from "./time/AppTimezone";
import TodayTab from "./today/TodayTab";
const TABS = [
@@ -32,15 +33,18 @@ export default function App() {
role="tabpanel"
aria-labelledby={`tab-${active}`}
>
{active === "today" ? (
<TodayTab />
) : active === "tasks" ? (
<DefinitionsTab />
) : active === "schedule" ? (
<ScheduleTab />
) : (
<HistoryTab />
)}
{/* Every screen's local time is the stored application timezone. */}
<AppTimezoneProvider>
{active === "today" ? (
<TodayTab />
) : active === "tasks" ? (
<DefinitionsTab />
) : active === "schedule" ? (
<ScheduleTab />
) : (
<HistoryTab />
)}
</AppTimezoneProvider>
</main>
<nav className="admin-tabbar" aria-label="Admin sections">
{TABS.map((tab) => (
+27 -1
View File
@@ -147,10 +147,36 @@ export async function updateDefinition(
return readJson<QuestDefinition>(await sendJson(`${DEFINITIONS_PATH}/${id}`, "PATCH", body));
}
/** `DELETE /quest-definitions/{id}`: exactly one of `deleted` / `retired` is true. */
export interface DefinitionDeleteResult {
definition_id: number;
deleted: boolean;
retired: boolean;
/** The retired (now inactive) definition; null when it was deleted. */
definition: QuestDefinition | null;
}
/**
* Delete a task if it has no completion history; otherwise the API retires
* (deactivates) it. Retirement preserves the definition row, schedule rule,
* assignees, windows and ALL durable completion history. Like any
* deactivation it regenerates, so future UNCOMPLETED instances are cleared
* (rolling, regenerable rows) and not recreated while inactive; durable
* history lives only in the append-only `completion_events`.
*/
export async function deleteDefinition(id: number): Promise<DefinitionDeleteResult> {
return readJson<DefinitionDeleteResult>(
await apiFetch(`${DEFINITIONS_PATH}/${id}`, { method: "DELETE" }),
);
}
export const OCCURRENCES_PREVIEW_PATH = `${DEFINITIONS_PATH}/occurrences-preview`;
export interface OccurrencesPreviewOptions {
/** "YYYY-MM-DD"; the API defaults to its host's today. */
/**
* "YYYY-MM-DD"; the API defaults to the household-local today in the
* configured `timezone` (the API host's local time when that is empty).
*/
startDate?: string;
/** 1..50; the API defaults to 10. */
count?: number;
+8 -1
View File
@@ -17,10 +17,17 @@ export interface HouseholdSettings {
afternoon_reminder_enabled: boolean;
end_of_day_report_enabled: boolean;
celebration_enabled: boolean;
/** IANA zone name (e.g. "America/New_York"); "" means the API host's local time. */
timezone: string;
/**
* Read-only: the zone the API reads its clock in — `timezone`, or the API
* host's own zone when that is "" ("" only when the host's zone has no name).
*/
effective_timezone: string;
}
/** Only the supplied fields are updated; the API answers with all of them. */
export type SettingsChanges = Partial<HouseholdSettings>;
export type SettingsChanges = Partial<Omit<HouseholdSettings, "effective_timezone">>;
export const SETTINGS_PATH = "/api/v1/admin/settings";
+31
View File
@@ -569,6 +569,26 @@
color: #fff;
}
.defs-save--danger {
background: var(--nq-a-danger);
}
.defs-delete {
display: block;
width: 100%;
margin-top: 10px;
padding: 11px 0;
border: 1px solid var(--nq-a-danger-bg);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-danger-strong);
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.defs-delete:disabled,
.defs-cancel:disabled,
.defs-save:disabled {
opacity: 0.6;
@@ -583,3 +603,14 @@
color: var(--nq-a-ink-secondary);
font-style: italic;
}
.defs-sublabel {
margin-top: 4px;
font-size: 12px;
font-weight: 700;
color: var(--nq-a-ink-secondary);
}
.defs-emoji {
line-height: 1;
}
+671 -11
View File
@@ -1,9 +1,18 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import DefinitionsTab from "./DefinitionsTab";
import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons";
import {
DEFINITION_ICONS,
DefinitionIcon,
FA_DEFINITION_ICONS,
emojiIconKey,
faIconName,
findDefinitionIcon,
normalizeDefinitionIconName,
} from "./definitionIcons";
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
import { storeTokens } from "../auth/oidc";
import { AppTimezoneProvider } from "../time/AppTimezone";
function child(id: number, name: string, overrides: Partial<AdminChild> = {}): AdminChild {
return {
@@ -113,6 +122,8 @@ function fakeApi(initial: QuestDefinition[]) {
const calls: Call[] = [];
let nextWrite: Response | null = null;
let preview: PreviewHandler = async () => jsonResponse({ dates: [] });
// Definitions with completion history: the API retires these on DELETE.
const withHistory = new Set<number>();
const fetchMock = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
const path = new URL(String(input), "http://localhost").pathname;
const method = init.method ?? "GET";
@@ -153,6 +164,10 @@ function fakeApi(initial: QuestDefinition[]) {
}
const match = /^\/api\/v1\/admin\/quest-definitions\/(\d+)$/.exec(path);
if (method === "PATCH" && match) {
// Like the API's strict validate_icon: a Lucide name never has a colon.
if (typeof body.icon === "string" && /^lucide:.*:/.test(body.icon)) {
return jsonResponse({ detail: "icon is not a valid icon key" }, 422);
}
const id = Number(match[1]);
definitions = definitions.map((d) =>
d.id === id
@@ -175,6 +190,16 @@ function fakeApi(initial: QuestDefinition[]) {
);
return jsonResponse(definitions.find((d) => d.id === id));
}
if (method === "DELETE" && match) {
const id = Number(match[1]);
if (withHistory.has(id)) {
definitions = definitions.map((d) => (d.id === id ? { ...d, is_active: false } : d));
const retired = definitions.find((d) => d.id === id);
return jsonResponse({ definition_id: id, deleted: false, retired: true, definition: retired });
}
definitions = definitions.filter((d) => d.id !== id);
return jsonResponse({ definition_id: id, deleted: true, retired: false, definition: null });
}
return jsonResponse({ detail: "unexpected" }, 500);
});
return {
@@ -191,6 +216,9 @@ function fakeApi(initial: QuestDefinition[]) {
failNextWrite: (response: Response) => {
nextWrite = response;
},
markHistory: (id: number) => {
withHistory.add(id);
},
};
}
@@ -455,7 +483,6 @@ describe("DefinitionsTab", () => {
path: "/api/v1/admin/quest-definitions/11",
body: {
title: "Take out the bins",
icon: null,
rule: {
rule_type: "monthly_day",
interval: 1,
@@ -567,14 +594,15 @@ describe("DefinitionsTab", () => {
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" });
// The edit sheet's last focusable control is its Delete button.
const last = within(s).getByRole("button", { name: "Delete task" });
save.focus();
last.focus();
fireEvent.keyDown(s, { key: "Tab" });
expect(document.activeElement).toBe(title);
fireEvent.keyDown(s, { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(save);
expect(document.activeElement).toBe(last);
});
it("Escape does not close the sheet while saving", async () => {
@@ -683,6 +711,73 @@ describe("DefinitionsTab", () => {
);
});
it("the Delete control is only offered for an existing task", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
expect(within(sheet()).queryByRole("button", { name: "Delete task" })).toBeNull();
fireEvent.click(within(sheet()).getByRole("button", { name: "Cancel" }));
fireEvent.click(screen.getByTestId("definition-10"));
expect(within(sheet()).getByRole("button", { name: "Delete task" })).toBeTruthy();
});
it("Delete asks first, explaining delete-vs-retire; Go back writes nothing", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
fireEvent.click(within(sheet()).getByRole("button", { name: "Delete task" }));
const confirm = screen.getByTestId("delete-confirm");
expect(confirm.textContent).toContain("If it has no completion history it is deleted");
expect(confirm.textContent).toContain("otherwise it is retired");
expect(api.writes()).toHaveLength(0);
fireEvent.click(within(confirm).getByRole("button", { name: "Go back" }));
expect(screen.queryByTestId("delete-confirm")).toBeNull();
expect(api.writes()).toHaveLength(0);
});
it("confirming Delete on a task without history removes its row", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
fireEvent.click(within(sheet()).getByRole("button", { name: "Delete task" }));
fireEvent.click(
within(screen.getByTestId("delete-confirm")).getByRole("button", { name: "Delete task" }),
);
await waitFor(() => expect(screen.queryByTestId("definition-10")).toBeNull());
expect(screen.queryByRole("dialog")).toBeNull();
expect(api.writes()).toEqual([
{ method: "DELETE", path: "/api/v1/admin/quest-definitions/10", body: null },
]);
expect(screen.getByTestId("definition-11")).toBeTruthy();
});
it("confirming Delete on a task with history shows it retired (inactive)", async () => {
await renderReady();
api.markHistory(10);
fireEvent.click(screen.getByTestId("definition-10"));
fireEvent.click(within(sheet()).getByRole("button", { name: "Delete task" }));
fireEvent.click(
within(screen.getByTestId("delete-confirm")).getByRole("button", { name: "Delete task" }),
);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
const row = screen.getByTestId("definition-10");
expect(row.className).toContain("defs-row--inactive");
expect(meta(row)).toContain("· inactive");
expect(api.writes().map((c) => c.method)).toEqual(["DELETE"]);
});
it("a failed Delete shows the API's detail and keeps the sheet open", async () => {
await renderReady();
api.failNextWrite(jsonResponse({ detail: "Quest definition not found" }, 404));
fireEvent.click(screen.getByTestId("definition-10"));
fireEvent.click(within(sheet()).getByRole("button", { name: "Delete task" }));
fireEvent.click(
within(screen.getByTestId("delete-confirm")).getByRole("button", { name: "Delete task" }),
);
expect((await screen.findByTestId("sheet-error")).textContent).toBe(
"Quest definition not found",
);
expect(screen.getByRole("dialog")).toBeTruthy();
expect(screen.getByTestId("definition-10")).toBeTruthy();
});
it("scroll column carries the 92px bottom padding", async () => {
await renderReady();
expect(screen.getByTestId("definitions-scroll").style.paddingBottom).toBe("92px");
@@ -1052,7 +1147,7 @@ describe("DefinitionsTab icon picker", () => {
expect(api.writes()).toHaveLength(1);
expect(api.writes()[0].method).toBe("POST");
expect((api.writes()[0].body as { icon: string }).icon).toBe("bed");
expect((api.writes()[0].body as { icon: string }).icon).toBe("lucide:bed");
expect(rowIcon(await screen.findByTestId("definition-99"))).toBe("bed");
});
@@ -1073,13 +1168,13 @@ describe("DefinitionsTab icon picker", () => {
const { method, path, body } = api.writes()[0];
expect(method).toBe("PATCH");
expect(path).toBe("/api/v1/admin/quest-definitions/11");
expect((body as { icon: string }).icon).toBe("trash-2");
expect((body as { icon: string }).icon).toBe("lucide:trash-2");
// Inactive "Old" stays assigned: the unchanged selection is still omitted.
expect(body).not.toHaveProperty("assignee_child_ids");
await waitFor(() => expect(rowIcon(screen.getByTestId("definition-11"))).toBe("trash-2"));
});
it("an unchanged icon is still sent on edit and shows as selected", async () => {
it("an unchanged icon is omitted on edit and shows as selected", async () => {
await renderReady();
expect(rowIcon(screen.getByTestId("definition-10"))).toBe("smile");
fireEvent.click(screen.getByTestId("definition-10"));
@@ -1093,7 +1188,7 @@ describe("DefinitionsTab icon picker", () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect((api.writes()[0].body as { icon: string }).icon).toBe("smile");
expect(api.writes()[0].body).not.toHaveProperty("icon");
});
it("an unknown legacy icon falls back to the generic glyph and is kept on save", async () => {
@@ -1113,8 +1208,573 @@ describe("DefinitionsTab icon picker", () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect((api.writes()[0].body as { icon: string }).icon).toBe("rocket-ship-9000");
expect(api.writes()[0].body).not.toHaveProperty("icon");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(rowIcon(screen.getByTestId("definition-20"))).toBeNull();
});
});
describe("DefinitionsTab namespaced icons", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
function rowIcon(row: HTMLElement): string | null {
const svg = row.querySelector(".defs-row-glyph svg");
expect(svg).not.toBeNull();
return svg!.getAttribute("data-icon");
}
function iconRadio(s: HTMLElement, label: string): HTMLElement {
return within(within(s).getByRole("radiogroup", { name: "Icon" })).getByRole("radio", {
name: label,
});
}
it("normalizes lucide-namespaced and legacy bare values to one Lucide name", () => {
expect(normalizeDefinitionIconName("lucide:dog")).toBe("dog");
expect(normalizeDefinitionIconName("dog")).toBe("dog");
expect(normalizeDefinitionIconName("fa:dog")).toBeNull();
expect(normalizeDefinitionIconName("emoji:🐶")).toBeNull();
expect(normalizeDefinitionIconName("mystery:dog")).toBeNull();
expect(normalizeDefinitionIconName(null)).toBeNull();
});
for (const icon of ["lucide:dog", "dog"]) {
it(`renders and selects the Dog glyph for ${icon}`, async () => {
await renderReady([{ ...BRUSH, id: 30, icon }]);
const row = screen.getByTestId("definition-30");
expect(rowIcon(row)).toBe("dog");
fireEvent.click(row);
const s = sheet();
expect(iconRadio(s, "Dog").getAttribute("aria-checked")).toBe("true");
expect(iconRadio(s, "Dog").className).toContain("defs-icon--on");
expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("false");
});
}
for (const icon of ["mystery:dog", "lucide:not-an-icon", "fa:not-an-icon", "emoji:a b"]) {
it(`renders the fallback tile and selects nothing for ${icon}`, async () => {
await renderReady([{ ...BRUSH, id: 31, icon }]);
const row = screen.getByTestId("definition-31");
expect(rowIcon(row)).toBeNull();
fireEvent.click(row);
const s = sheet();
for (const name of ["Icon", "Font Awesome"]) {
const group = within(s).getByRole("radiogroup", { name });
for (const radio of within(group).getAllByRole("radio")) {
expect(radio.getAttribute("aria-checked")).toBe("false");
}
}
});
}
it("tapping a Lucide choice on a namespaced task PATCHes lucide:<name>, and No icon clears it", async () => {
await renderReady([{ ...BRUSH, id: 32, icon: "lucide:dog" }]);
fireEvent.click(screen.getByTestId("definition-32"));
let s = sheet();
fireEvent.click(iconRadio(s, "Cat"));
expect(iconRadio(s, "Cat").getAttribute("aria-checked")).toBe("true");
expect(iconRadio(s, "Dog").getAttribute("aria-checked")).toBe("false");
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect((api.writes()[0].body as { icon: string }).icon).toBe("lucide:cat");
await waitFor(() => expect(rowIcon(screen.getByTestId("definition-32"))).toBe("cat"));
fireEvent.click(screen.getByTestId("definition-32"));
s = sheet();
expect(iconRadio(s, "Cat").getAttribute("aria-checked")).toBe("true");
fireEvent.click(iconRadio(s, "No icon"));
expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("true");
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect((api.writes()[1].body as { icon: string | null }).icon).toBeNull();
});
});
describe("DefinitionsTab Font Awesome and emoji icons", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
/** What a row draws: `svg:<data-icon>`, `emoji:<text>`, or `fallback`. */
function rowGlyph(row: HTMLElement): string {
const glyph = row.querySelector(".defs-row-glyph");
const emoji = glyph!.querySelector("[data-emoji]");
if (emoji) return `emoji:${emoji.textContent}`;
const svg = glyph!.querySelector("svg");
expect(svg).not.toBeNull();
const name = svg!.getAttribute("data-icon");
if (name === null) return "fallback";
return `${svg!.getAttribute("fill") === "none" ? "lucide" : "fa"}:${name}`;
}
function radio(s: HTMLElement, group: "Icon" | "Font Awesome", label: string): HTMLElement {
return within(within(s).getByRole("radiogroup", { name: group })).getByRole("radio", {
name: label,
});
}
function emojiInput(s: HTMLElement): HTMLInputElement {
return within(s).getByRole("textbox", { name: "Emoji" }) as HTMLInputElement;
}
async function save(s: HTMLElement) {
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
}
function fillNewTask(s: HTMLElement) {
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Walk the dog" },
});
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
}
it("ships the curated Font Awesome subset, each drawn as a filled glyph", () => {
expect(FA_DEFINITION_ICONS.length).toBeGreaterThan(0);
for (const entry of FA_DEFINITION_ICONS) {
expect(entry.kind).toBe("fa");
expect(findDefinitionIcon(`fa:${entry.name}`)).toBe(entry);
const { container, unmount } = render(<DefinitionIcon name={`fa:${entry.name}`} />);
const svg = container.querySelector("svg");
expect(svg, entry.name).not.toBeNull();
expect(svg!.getAttribute("data-icon")).toBe(entry.name);
expect(svg!.getAttribute("fill")).toBe("currentColor");
unmount();
}
});
it("builds and validates emoji keys like the registry", () => {
expect(emojiIconKey("🐶")).toBe("emoji:🐶");
expect(emojiIconKey("👨‍👩‍👧‍👦")).toBe("emoji:👨‍👩‍👧‍👦");
expect(emojiIconKey("⭐".repeat(8))).toBe(`emoji:${"⭐".repeat(8)}`);
for (const bad of ["", " ", " 🐶 ", "🐶 ", "\t🐶", "⭐".repeat(9), "a b", "<b>", "a&b", "🐶\u0007"]) {
expect(emojiIconKey(bad), JSON.stringify(bad)).toBeNull();
}
expect(faIconName("fa:dog")).toBe("dog");
expect(faIconName("lucide:dog")).toBeNull();
expect(faIconName(null)).toBeNull();
});
it("renders lucide, legacy, fa and emoji rows, and the fallback for none or unknown", async () => {
await renderReady([
{ ...BRUSH, id: 40, icon: "lucide:dog" },
{ ...BRUSH, id: 41, title: "Task 41", icon: "dog" },
{ ...BRUSH, id: 42, title: "Task 42", icon: "fa:dog" },
{ ...BRUSH, id: 43, title: "Task 43", icon: "emoji:🐶" },
{ ...BRUSH, id: 44, title: "Task 44", icon: null },
{ ...BRUSH, id: 45, title: "Task 45", icon: "" },
{ ...BRUSH, id: 46, title: "Task 46", icon: "mystery:dog" },
]);
expect(rowGlyph(screen.getByTestId("definition-40"))).toBe("lucide:dog");
expect(rowGlyph(screen.getByTestId("definition-41"))).toBe("lucide:dog");
expect(rowGlyph(screen.getByTestId("definition-42"))).toBe("fa:dog");
expect(rowGlyph(screen.getByTestId("definition-43"))).toBe("emoji:🐶");
expect(rowGlyph(screen.getByTestId("definition-44"))).toBe("fallback");
expect(rowGlyph(screen.getByTestId("definition-45"))).toBe("fallback");
expect(rowGlyph(screen.getByTestId("definition-46"))).toBe("fallback");
});
it("a Font Awesome choice is POSTed as fa:<name> and renders on its row", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fillNewTask(s);
fireEvent.click(radio(s, "Font Awesome", "Dog"));
expect(radio(s, "Font Awesome", "Dog").getAttribute("aria-checked")).toBe("true");
expect(radio(s, "Font Awesome", "Dog").className).toContain("defs-icon--on");
expect(radio(s, "Icon", "Dog").getAttribute("aria-checked")).toBe("false");
expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("false");
await save(s);
expect(api.writes()).toHaveLength(1);
expect(api.writes()[0].method).toBe("POST");
expect((api.writes()[0].body as { icon: string }).icon).toBe("fa:dog");
expect(rowGlyph(await screen.findByTestId("definition-99"))).toBe("fa:dog");
});
it("a typed emoji is POSTed as emoji:<grapheme> and renders on its row", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fillNewTask(s);
fireEvent.change(emojiInput(s), { target: { value: "🐶" } });
expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("false");
await save(s);
expect(api.writes()).toHaveLength(1);
expect((api.writes()[0].body as { icon: string }).icon).toBe("emoji:🐶");
expect(rowGlyph(await screen.findByTestId("definition-99"))).toBe("emoji:🐶");
});
it("a Lucide choice is POSTed as lucide:<name> after an emoji was typed", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fillNewTask(s);
fireEvent.change(emojiInput(s), { target: { value: "🐶" } });
fireEvent.click(radio(s, "Icon", "Dog"));
expect(emojiInput(s).value).toBe("");
await save(s);
expect((api.writes()[0].body as { icon: string }).icon).toBe("lucide:dog");
expect(rowGlyph(await screen.findByTestId("definition-99"))).toBe("lucide:dog");
});
for (const bad of [" 🐶 ", "🐶 ", " ", "a b", "<b>", "a&b", "⭐".repeat(9)]) {
it(`an invalid emoji entry ${JSON.stringify(bad)} is not saved`, async () => {
await renderReady([{ ...BRUSH, id: 50, icon: "lucide:dog" }]);
fireEvent.click(screen.getByTestId("definition-50"));
const s = sheet();
fireEvent.change(emojiInput(s), { target: { value: bad } });
await save(s);
expect(api.writes()).toHaveLength(0);
expect(within(s).getByText(/Enter a single emoji/)).toBeTruthy();
expect(screen.getByRole("dialog")).toBe(s);
});
}
it("prefills a fa:<name> task and PATCHes an emoji replacement", async () => {
await renderReady([{ ...BRUSH, id: 51, icon: "fa:dog" }]);
fireEvent.click(screen.getByTestId("definition-51"));
const s = sheet();
expect(radio(s, "Font Awesome", "Dog").getAttribute("aria-checked")).toBe("true");
expect(radio(s, "Icon", "Dog").getAttribute("aria-checked")).toBe("false");
expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("false");
expect(emojiInput(s).value).toBe("");
fireEvent.change(emojiInput(s), { target: { value: "🐱" } });
expect(radio(s, "Font Awesome", "Dog").getAttribute("aria-checked")).toBe("false");
await save(s);
const { method, path, body } = api.writes()[0];
expect(method).toBe("PATCH");
expect(path).toBe("/api/v1/admin/quest-definitions/51");
expect((body as { icon: string }).icon).toBe("emoji:🐱");
await waitFor(() => expect(rowGlyph(screen.getByTestId("definition-51"))).toBe("emoji:🐱"));
});
it("prefills an emoji:<grapheme> task and PATCHes a Font Awesome replacement", async () => {
await renderReady([{ ...BRUSH, id: 52, icon: "emoji:🐶" }]);
fireEvent.click(screen.getByTestId("definition-52"));
const s = sheet();
expect(emojiInput(s).value).toBe("🐶");
expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("false");
for (const name of ["Icon", "Font Awesome"]) {
const group = within(s).getByRole("radiogroup", { name });
expect(within(group).queryAllByRole("radio", { checked: true })).toHaveLength(0);
}
fireEvent.click(radio(s, "Font Awesome", "Pets"));
expect(emojiInput(s).value).toBe("");
await save(s);
expect((api.writes()[0].body as { icon: string }).icon).toBe("fa:paw");
await waitFor(() => expect(rowGlyph(screen.getByTestId("definition-52"))).toBe("fa:paw"));
});
it("an unchanged emoji is omitted on edit", async () => {
await renderReady([{ ...BRUSH, id: 53, icon: "emoji:🐶" }]);
fireEvent.click(screen.getByTestId("definition-53"));
const s = sheet();
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Feed the dog" },
});
await save(s);
expect(api.writes()[0].body).not.toHaveProperty("icon");
});
it("No icon and an emptied emoji field both clear the icon to null", async () => {
await renderReady([
{ ...BRUSH, id: 54, icon: "emoji:🐶" },
{ ...BRUSH, id: 55, title: "Task 55", icon: "fa:dog" },
]);
fireEvent.click(screen.getByTestId("definition-54"));
let s = sheet();
fireEvent.change(emojiInput(s), { target: { value: "" } });
expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("true");
await save(s);
expect((api.writes()[0].body as { icon: string | null }).icon).toBeNull();
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
fireEvent.click(screen.getByTestId("definition-55"));
s = sheet();
fireEvent.click(radio(s, "Icon", "No icon"));
expect(radio(s, "Font Awesome", "Dog").getAttribute("aria-checked")).toBe("false");
await save(s);
expect((api.writes()[1].body as { icon: string | null }).icon).toBeNull();
await waitFor(() => expect(rowGlyph(screen.getByTestId("definition-55"))).toBe("fallback"));
});
});
/* ── Legacy icons ───────────────────────────────────────────────────── */
describe("DefinitionsTab legacy icon edits", () => {
// The API reads a stored legacy `mdi:tooth` back as `lucide:mdi:tooth`,
// which its strict write validation rejects if sent back.
const LEGACY: QuestDefinition = { ...BRUSH, id: 60, icon: "lucide:mdi:tooth" };
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
function radio(s: HTMLElement, group: "Icon" | "Font Awesome", label: string): HTMLElement {
return within(within(s).getByRole("radiogroup", { name: group })).getByRole("radio", {
name: label,
});
}
async function save(s: HTMLElement) {
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
}
it("editing only the title omits the legacy icon and saves", async () => {
await renderReady([LEGACY]);
fireEvent.click(screen.getByTestId("definition-60"));
const s = sheet();
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Brush teeth well" },
});
await save(s);
expect(api.writes()).toHaveLength(1);
expect(api.writes()[0].method).toBe("PATCH");
expect(api.writes()[0].body).not.toHaveProperty("icon");
expect((api.writes()[0].body as { title: string }).title).toBe("Brush teeth well");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(await screen.findByText("Brush teeth well")).toBeTruthy();
});
for (const [label, choose, sent] of [
["a Lucide", (s: HTMLElement) => fireEvent.click(radio(s, "Icon", "Dog")), "lucide:dog"],
["a Font Awesome", (s: HTMLElement) => fireEvent.click(radio(s, "Font Awesome", "Dog")), "fa:dog"],
[
"an emoji",
(s: HTMLElement) =>
fireEvent.change(within(s).getByRole("textbox", { name: "Emoji" }), {
target: { value: "🦷" },
}),
"emoji:🦷",
],
["No icon", (s: HTMLElement) => fireEvent.click(radio(s, "Icon", "No icon")), null],
] as const) {
it(`choosing ${label} replacement for the legacy icon sends ${JSON.stringify(sent)}`, async () => {
await renderReady([LEGACY]);
fireEvent.click(screen.getByTestId("definition-60"));
const s = sheet();
choose(s);
await save(s);
expect(api.writes()).toHaveLength(1);
expect(api.writes()[0].body).toHaveProperty("icon", sent);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
}
it("an invalid emoji replacing the legacy icon is still rejected before the request", async () => {
await renderReady([LEGACY]);
fireEvent.click(screen.getByTestId("definition-60"));
const s = sheet();
fireEvent.change(within(s).getByRole("textbox", { name: "Emoji" }), {
target: { value: "a b" },
});
await save(s);
expect(api.writes()).toHaveLength(0);
expect(within(s).getByText(/Enter a single emoji/)).toBeTruthy();
});
it("creating a task still sends icon, even when none is chosen", async () => {
await renderReady([LEGACY]);
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Floss" },
});
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
await save(s);
expect(api.writes()).toHaveLength(1);
expect(api.writes()[0].method).toBe("POST");
expect(api.writes()[0].body).toHaveProperty("icon", null);
});
});
/* ── Start date ─────────────────────────────────────────────────────── */
function startDateInput(s: HTMLElement): HTMLInputElement {
return within(s).getByLabelText("Start date", { selector: "input" }) as HTMLInputElement;
}
describe("DefinitionsTab start date", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("a new task defaults to today and POSTs the chosen start date", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date(2026, 8, 23, 9, 0)); // Wed 23 Sep 2026, local
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
const input = startDateInput(s);
expect(input.type).toBe("date");
expect(input.value).toBe("2026-09-23");
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("button", { name: "Evening" }));
fireEvent.change(input, { target: { value: "2026-10-15" } });
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toHaveLength(1);
expect(api.writes()[0].method).toBe("POST");
expect((api.writes()[0].body as { rule: DefinitionRule }).rule.start_date).toBe("2026-10-15");
});
it("an edit pre-fills the definition's start date and PATCHes a changed one", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
const s = sheet();
const input = startDateInput(s);
expect(input.value).toBe("2026-01-01");
fireEvent.change(input, { target: { value: "2026-11-02" } });
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toEqual([
expect.objectContaining({
method: "PATCH",
path: "/api/v1/admin/quest-definitions/10",
body: expect.objectContaining({ rule: rule({ start_date: "2026-11-02" }) }),
}),
]);
});
it("the occurrence preview requests the chosen start date", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
const s = sheet();
await waitFor(() => expect(api.previews()).toHaveLength(1));
fireEvent.change(startDateInput(s), { target: { value: "2027-03-01" } });
await settleDebounce();
await waitFor(() => expect(api.previews()).toHaveLength(2));
expect((api.previews()[1].body as { rule: DefinitionRule }).rule.start_date).toBe(
"2027-03-01",
);
});
it("a cleared start date is not submitted", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
const s = sheet();
fireEvent.change(startDateInput(s), { target: { value: "" } });
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toHaveLength(0);
expect(within(s).getByText("Pick a start date.")).toBeTruthy();
});
});
describe("DefinitionsTab start date in the stored application timezone", () => {
async function newSheetStartDate(
timezone: string,
effective_timezone = timezone,
): Promise<string> {
api = fakeApi([BRUSH]);
vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
String(input).endsWith("/api/v1/admin/settings")
? Promise.resolve(jsonResponse({ timezone, effective_timezone }))
: api.fetchMock(input, init),
),
);
render(
<AppTimezoneProvider>
<DefinitionsTab />
</AppTimezoneProvider>,
);
await screen.findByText("Brush teeth");
fireEvent.click(screen.getByRole("button", { name: "New" }));
return startDateInput(sheet()).value;
}
beforeAll(() => {
vi.stubEnv("TZ", "UTC");
});
afterAll(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
// Wed 23 Sep 13:00 UTC is already Thu 24 Sep 01:00 in Auckland.
vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-09-23T13:00:00Z") });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("a new task defaults to today in the stored zone", async () => {
expect(await newSheetStartDate("Pacific/Auckland")).toBe("2026-09-24");
});
it("an empty stored zone defaults to today in the API host's published zone", async () => {
expect(await newSheetStartDate("", "Pacific/Auckland")).toBe("2026-09-24");
});
});
+207 -51
View File
@@ -13,12 +13,14 @@ import { ApiForbiddenError } from "../api/client";
import {
ApiRequestError,
createDefinition,
deleteDefinition,
fetchChildren,
fetchDefinitions,
previewOccurrences,
updateDefinition,
type AdminChild,
type DefinitionCreateBody,
type DefinitionDeleteResult,
type DefinitionEditBody,
type DefinitionRule,
type QuestDefinition,
@@ -43,7 +45,21 @@ import {
PlusGlyph,
TaskGlyph,
} from "./glyphs";
import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons";
import {
DEFINITION_ICONS,
DefinitionIcon,
FA_DEFINITION_ICONS,
emojiIconKey,
emojiIconText,
faIconKey,
faIconName,
lucideIconKey,
normalizeDefinitionIconName,
} from "./definitionIcons";
import { GlyphSvg } from "../icons/GlyphSvg";
import { weekdayOf } from "../schedule/cycle";
import { useAppTimezone } from "../time/AppTimezone";
import { todayIn } from "../time/zone";
import "./DefinitionsTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
@@ -78,8 +94,13 @@ const WEEKDAY_LONG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "S
interface Draft {
title: string;
/** A Lucide name; a legacy value outside the subset is kept until replaced. */
/**
* The stored icon key (`lucide:`, `fa:` or `emoji:`); a legacy value
* outside the curated sets is kept until replaced.
*/
icon: string | null;
/** The emoji field's text; typing in it sets `icon` to `emoji:<text>`. */
emoji: string;
assigneeIds: number[];
repeats: Repeats;
interval: string;
@@ -103,48 +124,43 @@ type LoadState =
/** `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();
/** Draft defaults take `today` ("YYYY-MM-DD") in the application timezone. */
function newDraft(activeChildren: AdminChild[], today: string): Draft {
const [, month, day] = today.split("-").map(Number);
return {
title: "",
icon: null,
emoji: "",
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),
weekdays: [weekdayOf(today)],
dayOfMonth: String(day),
month: String(month),
monthlyWeekday: null,
windows: [],
dueTimes: emptyDueTimes(),
// Mirrors the API's create default: no instance on an away day.
skipOnAway: true,
startDate: localIsoDate(today),
startDate: today,
endDate: null,
};
}
function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): Draft {
function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[], today: string): Draft {
const { rule } = definition;
const today = new Date();
const [, month, day] = today.split("-").map(Number);
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,
icon: definition.icon,
emoji: emojiIconText(definition.icon),
// 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],
@@ -152,9 +168,9 @@ function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): D
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),
: [weekdayOf(today)],
dayOfMonth: String(rule.day_of_month ?? day),
month: String(rule.month ?? month),
monthlyWeekday:
rule.rule_type === "monthly_weekday"
? { nth: rule.nth_weekday ?? 1, weekday: rule.nth_weekday_weekday ?? 0 }
@@ -177,6 +193,7 @@ function parseWhole(value: string, min: number, max: number): number | null {
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.";
if (draft.startDate === "") return "Pick a start date.";
const rule: DefinitionRule = {
rule_type: "daily",
interval,
@@ -230,6 +247,9 @@ function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody |
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.";
if (draft.emoji && draft.icon === null) {
return "Enter a single emoji (no spaces or < > &), or clear the emoji field.";
}
const rule = buildRule(draft);
if (typeof rule === "string") return rule;
const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map(
@@ -251,6 +271,12 @@ function saveErrorMessage(error: unknown): string {
return "The task could not be saved. Check your connection and try again.";
}
function deleteErrorMessage(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 deleted. 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];
}
@@ -427,19 +453,27 @@ function EditSheet({
activeChildren,
onCancel,
onSaved,
onDeleted,
}: {
target: "new" | QuestDefinition;
activeChildren: AdminChild[];
onCancel: () => void;
onSaved: () => void;
onDeleted: (result: DefinitionDeleteResult) => void;
}) {
const [draft, setDraft] = useState<Draft>(() =>
target === "new" ? newDraft(activeChildren) : draftFrom(target, activeChildren),
);
const timeZone = useAppTimezone();
const [draft, setDraft] = useState<Draft>(() => {
const today = todayIn(timeZone);
return target === "new" ? newDraft(activeChildren, today) : draftFrom(target, activeChildren, today);
});
// 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);
// The icon as loaded: while it is unchanged, the PATCH omits `icon` (absent
// means unchanged), because a legacy value read back as `lucide:<old>` would
// fail the API's strict icon validation if sent back.
const [originalIcon] = useState(() => draft.icon);
const inactiveAssignees =
target === "new"
? []
@@ -447,6 +481,7 @@ function EditSheet({
const [pickerOpen, setPickerOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [confirming, setConfirming] = useState(false);
const [confirmingDelete, setConfirmingDelete] = 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);
@@ -464,8 +499,8 @@ function EditSheet({
}, []);
useEffect(() => {
if (confirming) confirmRef.current?.focus();
}, [confirming]);
if (confirming || confirmingDelete) confirmRef.current?.focus();
}, [confirming, confirmingDelete]);
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape") {
@@ -522,6 +557,7 @@ function EditSheet({
} else {
const edit: DefinitionEditBody = { ...body };
if (keepsAssignees) delete edit.assignee_child_ids;
if (draft.icon === originalIcon) delete edit.icon;
await updateDefinition(target.id, edit);
}
onSaved();
@@ -532,6 +568,21 @@ function EditSheet({
}
}
async function remove() {
if (target === "new" || inFlight.current) return;
setConfirmingDelete(false);
inFlight.current = true;
setSaving(true);
setError(null);
try {
onDeleted(await deleteDefinition(target.id));
} catch (err) {
setError(deleteErrorMessage(err));
inFlight.current = false;
setSaving(false);
}
}
return (
<div className="defs-sheet-layer">
<div
@@ -568,28 +619,67 @@ function EditSheet({
<button
type="button"
role="radio"
aria-checked={draft.icon === null}
aria-checked={draft.icon === null && !draft.emoji}
aria-label="No icon"
className={draft.icon === null ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: null })}
className={
draft.icon === null && !draft.emoji ? "defs-icon defs-icon--on" : "defs-icon"
}
onClick={() => update({ icon: null, emoji: "" })}
>
<TaskGlyph size={18} />
</button>
{DEFINITION_ICONS.map(({ name, label, Icon }) => (
<button
key={name}
type="button"
role="radio"
aria-checked={draft.icon === name}
aria-label={label}
data-icon-option={name}
className={draft.icon === name ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: name })}
>
<Icon size={18} aria-hidden="true" focusable="false" />
</button>
))}
{DEFINITION_ICONS.map((icon) => {
const { name, label } = icon;
const on = normalizeDefinitionIconName(draft.icon) === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={on}
aria-label={label}
data-icon-option={name}
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: lucideIconKey(name), emoji: "" })}
>
<GlyphSvg icon={icon} size={18} />
</button>
);
})}
</div>
<span className="defs-sublabel" id="defs-label-icon-fa">
Font Awesome
</span>
<div className="defs-icon-grid" role="radiogroup" aria-labelledby="defs-label-icon-fa">
{FA_DEFINITION_ICONS.map((icon) => {
const { name, label } = icon;
const on = faIconName(draft.icon) === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={on}
aria-label={label}
data-icon-option={faIconKey(name)}
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: faIconKey(name), emoji: "" })}
>
<GlyphSvg icon={icon} size={18} />
</button>
);
})}
</div>
<label className="defs-due">
<span>Emoji</span>
<input
className="defs-input"
aria-label="Emoji"
placeholder="e.g. 🐶"
value={draft.emoji}
onChange={(e) => update({ emoji: e.target.value, icon: emojiIconKey(e.target.value) })}
/>
</label>
</Field>
<Field label="Assigned children" id="defs-label-children">
@@ -740,6 +830,16 @@ function EditSheet({
<OccurrencePreview ruleKey={previewKey} />
</Field>
<Field label="Start date" id="defs-label-start">
<input
className="defs-input"
type="date"
aria-labelledby="defs-label-start"
value={draft.startDate}
onChange={(e) => update({ startDate: e.target.value })}
/>
</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>
@@ -830,15 +930,56 @@ function EditSheet({
</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>
) : confirmingDelete ? (
<div
className="defs-confirm"
role="group"
aria-labelledby="defs-delete-confirm-text"
data-testid="delete-confirm"
ref={confirmRef}
tabIndex={-1}
>
<p id="defs-delete-confirm-text">
Delete this task? If it has no completion history it is deleted; otherwise it is
retired (deactivated) and its history is kept.
</p>
<div className="defs-sheet-footer">
<button
type="button"
className="defs-cancel"
onClick={() => setConfirmingDelete(false)}
>
Go back
</button>
<button type="button" className="defs-save defs-save--danger" onClick={remove}>
Delete task
</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>
{target !== "new" ? (
<button
type="button"
className="defs-delete"
disabled={saving}
onClick={() => {
setError(null);
setConfirmingDelete(true);
}}
>
Delete task
</button>
) : null}
</>
)}
</div>
</div>
@@ -962,6 +1103,21 @@ export default function DefinitionsTab() {
setSheet(null);
setAttempt((n) => n + 1);
}}
onDeleted={(result) => {
setSheet(null);
setState((current) =>
current.kind !== "ready"
? current
: {
...current,
definitions: result.definition
? current.definitions.map((d) =>
d.id === result.definition_id ? result.definition! : d,
)
: current.definitions.filter((d) => d.id !== result.definition_id),
},
);
}}
/>
) : null}
</div>
+80 -93
View File
@@ -1,103 +1,90 @@
/**
* The shipped Lucide subset a definition's `icon` may name (ADMIN-SPEC §3.3).
* Only these named imports are bundled — no CDN, no runtime fetch. The stored
* value is the Lucide kebab-case name, so it stays meaningful to any surface.
* The icons a definition's `icon` may name (ADMIN-SPEC §3.3): the curated
* Lucide set, the curated Font Awesome Free solid subset, or an emoji. The
* names, labels and path data come from the shared icon registry
* (`src/icons/registry.generated.ts`, generated from
* `tools/icons/curated-icons.json`) — no CDN, no runtime fetch. The stored
* value is namespaced — `lucide:<name>`, `fa:<name>` or `emoji:<grapheme>` —
* and a legacy bare `<kebab-name>` still names the same Lucide entry.
*/
import {
Apple,
Backpack,
Bath,
Bed,
Bike,
BookOpen,
Brush,
BrushCleaning,
Calendar,
Cat,
Clock,
CookingPot,
Dog,
Droplets,
Fish,
Gamepad2,
Heart,
House,
ListChecks,
Moon,
Music,
Pencil,
Recycle,
Shirt,
ShowerHead,
Smile,
Sofa,
SprayCan,
Sprout,
Star,
Sun,
Toilet,
Trash2,
Utensils,
Volleyball,
WashingMachine,
type LucideIcon,
} from "lucide-react";
import { GlyphSvg } from "../icons/GlyphSvg";
import { FA_ICONS, LUCIDE_ICONS, resolveIcon, type GlyphIcon } from "../icons/registry.generated";
import { TaskGlyph } from "./glyphs";
export interface DefinitionIconEntry {
name: string;
label: string;
Icon: LucideIcon;
export type DefinitionIconEntry = GlyphIcon;
export const DEFINITION_ICONS: readonly DefinitionIconEntry[] = LUCIDE_ICONS;
export const FA_DEFINITION_ICONS: readonly DefinitionIconEntry[] = FA_ICONS;
const LUCIDE_PREFIX = "lucide:";
const FA_PREFIX = "fa:";
const EMOJI_PREFIX = "emoji:";
/** The canonical stored key for a Lucide choice, e.g. `lucide:dog`. */
export function lucideIconKey(name: string): string {
return `${LUCIDE_PREFIX}${name}`;
}
export const DEFINITION_ICONS: readonly DefinitionIconEntry[] = [
{ name: "bed", label: "Bed", Icon: Bed },
{ name: "bath", label: "Bath", Icon: Bath },
{ name: "shower-head", label: "Shower", Icon: ShowerHead },
{ name: "toilet", label: "Toilet", Icon: Toilet },
{ name: "brush", label: "Brush", Icon: Brush },
{ name: "smile", label: "Smile", Icon: Smile },
{ name: "shirt", label: "Clothes", Icon: Shirt },
{ name: "washing-machine", label: "Laundry", Icon: WashingMachine },
{ name: "utensils", label: "Meal", Icon: Utensils },
{ name: "cooking-pot", label: "Cooking", Icon: CookingPot },
{ name: "apple", label: "Snack", Icon: Apple },
{ name: "brush-cleaning", label: "Sweep", Icon: BrushCleaning },
{ name: "spray-can", label: "Clean", Icon: SprayCan },
{ name: "trash-2", label: "Bins", Icon: Trash2 },
{ name: "recycle", label: "Recycling", Icon: Recycle },
{ name: "sofa", label: "Tidy", Icon: Sofa },
{ name: "house", label: "Home", Icon: House },
{ name: "backpack", label: "School bag", Icon: Backpack },
{ name: "book-open", label: "Reading", Icon: BookOpen },
{ name: "pencil", label: "Homework", Icon: Pencil },
{ name: "music", label: "Music", Icon: Music },
{ name: "dog", label: "Dog", Icon: Dog },
{ name: "cat", label: "Cat", Icon: Cat },
{ name: "fish", label: "Fish", Icon: Fish },
{ name: "sprout", label: "Plants", Icon: Sprout },
{ name: "droplets", label: "Water", Icon: Droplets },
{ name: "bike", label: "Bike", Icon: Bike },
{ name: "volleyball", label: "Sport", Icon: Volleyball },
{ name: "gamepad-2", label: "Games", Icon: Gamepad2 },
{ name: "star", label: "Star", Icon: Star },
{ name: "heart", label: "Heart", Icon: Heart },
{ name: "sun", label: "Morning", Icon: Sun },
{ name: "moon", label: "Bedtime", Icon: Moon },
{ name: "clock", label: "Clock", Icon: Clock },
{ name: "calendar", label: "Calendar", Icon: Calendar },
{ name: "list-checks", label: "Checklist", Icon: ListChecks },
];
const BY_NAME = new Map(DEFINITION_ICONS.map((entry) => [entry.name, entry]));
export function findDefinitionIcon(name: string | null): DefinitionIconEntry | null {
return name === null ? null : (BY_NAME.get(name) ?? null);
/** The canonical stored key for a Font Awesome choice, e.g. `fa:dog`. */
export function faIconKey(name: string): string {
return `${FA_PREFIX}${name}`;
}
/** A definition's glyph; no icon, or one outside the subset, gets the generic task glyph. */
/**
* The stored key for typed emoji text, e.g. `emoji:🐶`. The raw text — not
* trimmed, as the backend rejects any whitespace — must pass the registry's
* emoji check (1-8 code points, no whitespace, control characters or `<`,
* `>`, `&`), else null. Only an empty field means "no icon".
*/
export function emojiIconKey(text: string): string | null {
const key = `${EMOJI_PREFIX}${text}`;
return resolveIcon(key).kind === "emoji" ? key : null;
}
/**
* The Lucide name an icon value refers to: `lucide:dog` and a legacy bare
* `dog` both give `dog`. Any other namespace (`fa:`, `emoji:`, unknown) and
* null give null — the admin has no Lucide entry for them.
*/
export function normalizeDefinitionIconName(value: string | null): string | null {
if (value === null) return null;
if (value.startsWith(LUCIDE_PREFIX)) return value.slice(LUCIDE_PREFIX.length);
return value.includes(":") ? null : value;
}
/** The Font Awesome name an `fa:<name>` value refers to; anything else gives null. */
export function faIconName(value: string | null): string | null {
return value?.startsWith(FA_PREFIX) ? value.slice(FA_PREFIX.length) : null;
}
/** The emoji text an `emoji:<grapheme>` value holds; anything else gives "". */
export function emojiIconText(value: string | null): string {
return value?.startsWith(EMOJI_PREFIX) ? value.slice(EMOJI_PREFIX.length) : "";
}
/**
* The curated glyph (Lucide or Font Awesome) an icon value resolves to via
* the shared parser; emoji, unknown and null values give null.
*/
export function findDefinitionIcon(value: string | null): DefinitionIconEntry | null {
const icon = resolveIcon(value);
return icon.kind === "lucide" || icon.kind === "fa" ? icon : null;
}
/**
* A definition's icon: a Lucide or Font Awesome glyph, an emoji drawn as
* text, or — for no icon or an unknown value — the generic task glyph.
*/
export function DefinitionIcon({ name, size = 17 }: { name: string | null; size?: number }) {
const entry = findDefinitionIcon(name);
if (!entry) return <TaskGlyph size={size} />;
return <entry.Icon size={size} aria-hidden="true" focusable="false" data-icon={entry.name} />;
const icon = resolveIcon(name);
if (icon.kind === "fallback") return <TaskGlyph size={size} />;
if (icon.kind === "emoji") {
return (
<span className="defs-emoji" style={{ fontSize: size }} aria-hidden="true" data-emoji={icon.text}>
{icon.text}
</span>
);
}
return <GlyphSvg icon={icon} size={size} />;
}
+115
View File
@@ -5,6 +5,7 @@ import type { HistoryFilter, HistoryRow } from "../api/history";
import { storeTokens } from "../auth/oidc";
import { TransitionsProvider } from "../api/useTransitions";
import { frame, testStream, type TestStream } from "../api/testStream";
import { AppTimezoneProvider } from "../time/AppTimezone";
const TODAY = "2026-09-23";
@@ -629,3 +630,117 @@ describe("HistoryTab live updates", () => {
expect(screen.getAllByTestId("history-row")).toHaveLength(ROWS.all.length);
});
});
describe("HistoryTab in the stored application timezone", () => {
// 13:00 UTC on Sep 23 is 01:00 on Sep 24 in Auckland (NZST, UTC+12).
const NOW = new Date("2026-09-23T13:00:00Z");
// Due 00:10 Auckland on Sep 24, done 00:30 Auckland (12:30 UTC on Sep 23).
const LATE_NZ = row({
instance_id: 7,
quest_title: "Feed cat",
due_date: "2026-09-24",
due_time: "00:10",
occurred_at: "2026-09-23T12:30:00Z",
was_on_time: false,
});
// 22:00 Auckland on Sep 23; 10:00 UTC.
const EVENING_NZ = row({ instance_id: 8, occurred_at: "2026-09-23T10:00:00Z" });
let fetchMock: ReturnType<typeof vi.fn>;
function historyRequests(): URL[] {
return fetchMock.mock.calls
.map(([url]) => new URL(String(url), "http://x"))
.filter((url) => url.pathname === "/api/v1/admin/history");
}
async function renderWithZone(timezone: string, effective_timezone = timezone) {
fetchMock = vi.fn((url: string) => {
const parsed = new URL(url, "http://x");
if (parsed.pathname.endsWith("/api/v1/admin/settings")) {
return Promise.resolve(jsonResponse({ timezone, effective_timezone }));
}
const filter = parsed.searchParams.get("filter");
const rows = filter === "all" ? [EVENING_NZ, LATE_NZ] : [];
return Promise.resolve(
jsonResponse({
filter,
start: parsed.searchParams.get("start"),
end: parsed.searchParams.get("end"),
count: rows.length,
rows,
}),
);
});
vi.stubGlobal("fetch", fetchMock);
render(
<AppTimezoneProvider>
<HistoryTab />
</AppTimezoneProvider>,
);
await screen.findByTestId("stat-on-time");
}
function times(section: HTMLElement): string[] {
return Array.from(section.querySelectorAll(".history-event-time")).map(
(el) => el.textContent ?? "",
);
}
beforeAll(() => {
vi.stubEnv("TZ", "UTC");
});
afterAll(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
vi.useFakeTimers({ toFake: ["Date"], now: NOW });
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});
it.each([
["the stored zone", "Pacific/Auckland"],
["the API host's published zone when none is stored", ""],
])("today, day grouping, clock and lateness follow %s", async (_label, stored) => {
await renderWithZone(stored, "Pacific/Auckland");
for (const url of historyRequests()) {
expect(url.searchParams.get("end")).toBe("2026-09-24");
expect(url.searchParams.get("start")).toBe("2026-09-11");
}
const today = screen.getByTestId("day-2026-09-24");
expect(within(today).getByText("Today")).toBeTruthy();
expect(times(today)).toEqual(["12:30 AM"]);
expect(metas(today)).toEqual(["completed · panel (Declan) · late 20 min"]);
const yesterday = screen.getByTestId("day-2026-09-23");
expect(within(yesterday).getByText("Yesterday")).toBeTruthy();
expect(times(yesterday)).toEqual(["10:00 PM"]);
});
it("only an API that publishes no zone leaves the browser's local day and clock", async () => {
await renderWithZone("", "");
for (const url of historyRequests()) {
expect(url.searchParams.get("end")).toBe("2026-09-23");
}
expect(screen.queryByTestId("day-2026-09-24")).toBeNull();
const today = screen.getByTestId("day-2026-09-23");
expect(within(today).getByText("Today")).toBeTruthy();
expect(times(today)).toEqual(["12:30 PM", "10:00 AM"]);
// Due 00:10 UTC on Sep 24 is after the 12:30 UTC completion: no minutes.
expect(metas(today)).toEqual([
"completed · panel (Declan) · late",
"completed · panel (Declan) · on time",
]);
});
});
+11 -7
View File
@@ -9,9 +9,10 @@ import { ApiForbiddenError } from "../api/client";
import type { TransitionEvent } from "../api/events";
import { useTransitions } from "../api/useTransitions";
import { exportHistoryCsv, fetchHistory, type HistoryFilter, type HistoryRow } from "../api/history";
import { localTodayIso } from "../schedule/cycle";
import { dayLabel, groupByDay, rowMeta, rowTime, shiftIso } from "./format";
import { DownloadGlyph, LockGlyph } from "./glyphs";
import { useAppTimezone } from "../time/AppTimezone";
import { todayIn } from "../time/zone";
import "./HistoryTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
@@ -129,7 +130,7 @@ function StatRow({ stats }: { stats: HistoryStats }) {
);
}
function EventRow({ row }: { row: HistoryRow }) {
function EventRow({ row, timeZone }: { row: HistoryRow; timeZone: string }) {
return (
<li className="history-event" data-testid="history-row">
<span
@@ -142,10 +143,10 @@ function EventRow({ row }: { row: HistoryRow }) {
{row.quest_title} · {row.child_name}
</div>
<div className="history-event-meta" data-testid="history-meta">
{rowMeta(row)}
{rowMeta(row, timeZone)}
</div>
</div>
<span className="history-event-time">{rowTime(row)}</span>
<span className="history-event-time">{rowTime(row, timeZone)}</span>
</li>
);
}
@@ -161,7 +162,10 @@ function FooterNote() {
);
}
export default function HistoryTab({ today = localTodayIso() }: { today?: string }) {
/** `today` ("YYYY-MM-DD") defaults to today in the application timezone. */
export default function HistoryTab({ today: todayProp }: { today?: string }) {
const timeZone = useAppTimezone();
const today = todayProp ?? todayIn(timeZone);
const [filter, setFilter] = useState<HistoryFilter>("all");
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
@@ -244,7 +248,7 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string
</div>
);
} else {
const groups = groupByDay(shown.data.rows);
const groups = groupByDay(shown.data.rows, timeZone);
body =
groups.length > 0 ? (
groups.map((group) => (
@@ -252,7 +256,7 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string
<h3 className="history-section-label">{dayLabel(group.day, today)}</h3>
<ul className="history-card history-list">
{group.rows.map((row, index) => (
<EventRow key={`${row.instance_id}-${row.event_type}-${row.occurred_at ?? ""}-${index}`} row={row} />
<EventRow key={`${row.instance_id}-${row.event_type}-${row.occurred_at ?? ""}-${index}`} row={row} timeZone={timeZone} />
))}
</ul>
</section>
+21 -24
View File
@@ -4,12 +4,13 @@
* Calendar dates ("YYYY-MM-DD") are handled by their components (never
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
* day in negative-offset zones); event stamps are UTC and shown in the
* viewer's local time.
* application timezone (`timeZone`: the stored IANA zone, "" = browser).
*/
import type { HistoryRow } from "../api/history";
import { dayNumber, isoFromDayNumber, localTodayIso } from "../schedule/cycle";
import { dayNumber, isoFromDayNumber } from "../schedule/cycle";
import { shortDate } from "../schedule/format";
import { formatClockTime, formatDueTime } from "../today/format";
import { isoDateIn, wallClockInstant } from "../time/zone";
const MS_PER_MINUTE = 60_000;
@@ -19,25 +20,21 @@ export function shiftIso(iso: string, days: number): string {
}
/** The local calendar day an event belongs to (due_date for missed rows). */
export function rowDay(row: HistoryRow): string {
return row.occurred_at ? localTodayIso(new Date(row.occurred_at)) : row.due_date;
export function rowDay(row: HistoryRow, timeZone: string): string {
return row.occurred_at ? isoDateIn(new Date(row.occurred_at), timeZone) : row.due_date;
}
/** The row's due moment as local wall-clock time, or null without a due time. */
function dueMoment(row: HistoryRow): Date | null {
/** The row's due moment (epoch ms) as local wall-clock time, or null without a due time. */
function dueMoment(row: HistoryRow, timeZone: string): number | null {
const match = /^(\d{2}):(\d{2})/.exec(row.due_time ?? "");
if (!match) return null;
const [year, month, day] = row.due_date.split("-").map(Number);
return new Date(year, month - 1, day, Number(match[1]), Number(match[2]));
return wallClockInstant(row.due_date, Number(match[1]), Number(match[2]), timeZone);
}
/** Newest first; a missed row sorts at its due moment (or the end of its day). */
function sortKey(row: HistoryRow): number {
function sortKey(row: HistoryRow, timeZone: string): number {
if (row.occurred_at) return Date.parse(row.occurred_at);
const due = dueMoment(row);
if (due) return due.getTime();
const [year, month, day] = row.due_date.split("-").map(Number);
return new Date(year, month - 1, day, 23, 59).getTime();
return dueMoment(row, timeZone) ?? wallClockInstant(row.due_date, 23, 59, timeZone);
}
export interface DayGroup {
@@ -46,10 +43,10 @@ export interface DayGroup {
}
/** Rows grouped by day, newest day first and newest row first within a day. */
export function groupByDay(rows: HistoryRow[]): DayGroup[] {
export function groupByDay(rows: HistoryRow[], timeZone: string): DayGroup[] {
const groups = new Map<string, HistoryRow[]>();
for (const row of rows) {
const day = rowDay(row);
const day = rowDay(row, timeZone);
groups.set(day, [...(groups.get(day) ?? []), row]);
}
return [...groups.entries()]
@@ -57,7 +54,7 @@ export function groupByDay(rows: HistoryRow[]): DayGroup[] {
.map(([day, dayRows]) => ({
day,
// Stable sort: rows at the same moment keep the API's append order.
rows: [...dayRows].sort((a, b) => sortKey(b) - sortKey(a)),
rows: [...dayRows].sort((a, b) => sortKey(b, timeZone) - sortKey(a, timeZone)),
}));
}
@@ -72,23 +69,23 @@ export function dayLabel(day: string, today: string): string {
}
/** "on time", "late 22 min", or "late" when the lateness is not derivable. */
function onTimeFlag(row: HistoryRow): string | null {
function onTimeFlag(row: HistoryRow, timeZone: string): string | null {
if (row.event_type !== "completed" || row.was_on_time === null) return null;
if (row.was_on_time) return "on time";
const due = dueMoment(row);
if (!due || !row.occurred_at) return "late";
const minutes = Math.round((Date.parse(row.occurred_at) - due.getTime()) / MS_PER_MINUTE);
const due = dueMoment(row, timeZone);
if (due === null || !row.occurred_at) return "late";
const minutes = Math.round((Date.parse(row.occurred_at) - due) / MS_PER_MINUTE);
return minutes > 0 ? `late ${minutes} min` : "late";
}
/** `<event type> · <actor>` plus the on-time flag for completions. */
export function rowMeta(row: HistoryRow): string {
const flag = onTimeFlag(row);
export function rowMeta(row: HistoryRow, timeZone: string): string {
const flag = onTimeFlag(row, timeZone);
return [row.event_type, row.actor, flag].filter(Boolean).join(" · ");
}
/** The event's local clock time; a missed row shows its due time, if any. */
export function rowTime(row: HistoryRow): string {
if (row.occurred_at) return formatClockTime(row.occurred_at);
export function rowTime(row: HistoryRow, timeZone: string): string {
if (row.occurred_at) return formatClockTime(row.occurred_at, timeZone);
return row.due_time ? formatDueTime(row.due_time) : "";
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Draws a registry glyph (`registry.generated.ts`) as an inline SVG — the
* path data is bundled, so there is no CDN and no runtime fetch. Lucide
* glyphs are stroked on their 24px grid; Font Awesome glyphs are filled.
*/
import type { GlyphIcon } from "./registry.generated";
export function GlyphSvg({ icon, size }: { icon: GlyphIcon; size: number }) {
const stroked = icon.kind === "lucide";
return (
<svg
width={size}
height={size}
viewBox={icon.viewBox}
fill={stroked ? "none" : "currentColor"}
stroke={stroked ? "currentColor" : undefined}
strokeWidth={stroked ? 2 : undefined}
strokeLinecap={stroked ? "round" : undefined}
strokeLinejoin={stroked ? "round" : undefined}
aria-hidden="true"
focusable="false"
data-icon={icon.name}
>
{icon.paths.map((d, index) => (
<path key={index} d={d} />
))}
</svg>
);
}
File diff suppressed because it is too large Load Diff
+170
View File
@@ -0,0 +1,170 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
// The offline generator is the only writer of both registry artefacts.
import { OUTPUT_PATHS, REPO_ROOT, SOURCE_PATH, renderRegistry } from "../../../tools/icons/generate.mjs";
import {
EMOJI_MAX_CODE_POINTS,
FALLBACK_ICON,
FA_ICONS,
FA_SOLID_ICONS,
LUCIDE_ICONS,
resolveIcon,
} from "./registry.generated";
type Curated = { lucide: { name: string; label: string }[]; fa: { name: string; label: string }[] };
const curated = JSON.parse(readFileSync(SOURCE_PATH, "utf8")) as Curated;
type EmojiPayloadCase = { payload: string; valid: boolean; why: string };
const emojiPayloadCases = JSON.parse(
readFileSync(join(REPO_ROOT, "tools", "icons", "emoji-payload-cases.json"), "utf8"),
) as { cases: EmojiPayloadCase[] };
describe("shared icon registry — one source of truth", () => {
it("both committed artefacts are byte-identical to a fresh generation", async () => {
const fresh = await renderRegistry();
expect(OUTPUT_PATHS).toHaveLength(2);
for (const path of OUTPUT_PATHS) {
expect(readFileSync(path, "utf8"), `${path} drifted — run node tools/icons/generate.mjs`).toBe(fresh);
}
});
it("the generated sets carry exactly the curated names and labels, in order", () => {
expect(LUCIDE_ICONS.map(({ name, label }) => ({ name, label }))).toEqual(curated.lucide);
expect(FA_ICONS.map(({ name, label }) => ({ name, label }))).toEqual(curated.fa);
expect(curated.lucide).toHaveLength(36);
expect(FA_ICONS.length).toBeGreaterThanOrEqual(15);
expect(FA_ICONS.length).toBeLessThanOrEqual(25);
});
it("every glyph is plain data: a viewBox and at least one path", () => {
for (const icon of LUCIDE_ICONS) {
expect(icon.kind).toBe("lucide");
expect(icon.key).toBe(`lucide:${icon.name}`);
expect(icon.viewBox).toBe("0 0 24 24");
expect(icon.paths.length, icon.name).toBeGreaterThan(0);
}
for (const icon of FA_ICONS) {
expect(icon.kind).toBe("fa");
expect(icon.key).toBe(`fa:${icon.name}`);
expect(icon.viewBox).toMatch(/^0 0 \d+ 512$/);
expect(icon.paths.length, icon.name).toBeGreaterThan(0);
}
const solidNames = FA_SOLID_ICONS.map((icon) => icon.name);
expect(solidNames.length).toBeGreaterThanOrEqual(1400);
expect(solidNames).toEqual([...solidNames].sort());
expect(new Set(solidNames).size).toBe(solidNames.length);
for (const { name } of FA_ICONS) expect(solidNames, name).toContain(name);
for (const icon of FA_SOLID_ICONS) {
expect(icon.kind).toBe("fa");
expect(icon.key).toBe(`fa:${icon.name}`);
expect(icon.viewBox).toMatch(/^0 0 \d+ 512$/);
expect(icon.paths.length, icon.name).toBeGreaterThan(0);
}
for (const icon of [...LUCIDE_ICONS, ...FA_ICONS, ...FA_SOLID_ICONS]) {
for (const d of icon.paths) expect(d, icon.key).toMatch(/^[Mm][-0-9.,\sA-Za-z]+$/);
}
});
});
describe("resolveIcon", () => {
it("resolves lucide:, fa: and emoji: keys", () => {
const dog = resolveIcon("lucide:dog");
expect(dog.kind).toBe("lucide");
expect(dog).toBe(LUCIDE_ICONS.find((icon) => icon.name === "dog"));
const broom = resolveIcon("fa:broom");
expect(broom.kind).toBe("fa");
expect(broom).toBe(FA_ICONS.find((icon) => icon.name === "broom"));
// Any Free Solid name resolves, not just the curated quick-pick set.
const rocket = resolveIcon("fa:rocket");
expect(rocket).toBe(FA_SOLID_ICONS.find((icon) => icon.name === "rocket"));
expect(rocket).toMatchObject({ kind: "fa", key: "fa:rocket", label: "Rocket" });
expect(resolveIcon("fa:person-dress-burst")).toMatchObject({ kind: "fa", label: "Person Dress Burst" });
expect(resolveIcon("emoji:🐶")).toEqual({ kind: "emoji", key: "emoji:🐶", text: "🐶" });
expect(resolveIcon("emoji:👨‍👩‍👧")).toEqual({ kind: "emoji", key: "emoji:👨‍👩‍👧", text: "👨‍👩‍👧" });
});
it("reads a legacy bare name as Lucide", () => {
expect(resolveIcon("dog")).toBe(resolveIcon("lucide:dog"));
expect(resolveIcon("trash-2")).toBe(LUCIDE_ICONS.find((icon) => icon.name === "trash-2"));
});
it("falls back — never throws — for empty or unknown values", () => {
for (const value of [
null,
undefined,
"",
" ",
"rocket-ship-9000",
"lucide:",
"lucide:rocket-ship-9000",
"fa:",
"fa:rocket-ship-9000",
"fa:Dog",
"mystery:dog",
"emoji:",
"emoji:<b>",
"emoji:a b",
"emoji:123456789",
]) {
expect(resolveIcon(value), String(value)).toBe(FALLBACK_ICON);
}
expect(FALLBACK_ICON).toEqual({ kind: "fallback", key: null });
});
// tests/test_core_icons.py asserts the same table against the backend's
// validate_icon, so the two emoji rules cannot drift apart silently.
it.each(emojiPayloadCases.cases)("emoji verdict matches the shared table: $why", ({ payload, valid }) => {
const value = `emoji:${payload}`;
expect(resolveIcon(value)).toEqual(valid ? { kind: "emoji", key: value, text: payload } : FALLBACK_ICON);
});
it("the shared emoji table covers the contract edges", () => {
const verdicts = new Map(emojiPayloadCases.cases.map(({ payload, valid }) => [payload, valid]));
expect(verdicts.get("\ufeff")).toBe(false);
expect(verdicts.get("👨\u200d👩\u200d👧\u200d👦")).toBe(true);
expect(verdicts.get("🐕 ")).toBe(false);
expect(verdicts.get("🐕\u0000")).toBe(false);
for (const markup of ["<b>", "a>b", "a&b"]) expect(verdicts.get(markup)).toBe(false);
expect(verdicts.get("⭐".repeat(EMOJI_MAX_CODE_POINTS))).toBe(true);
expect(verdicts.get("⭐".repeat(EMOJI_MAX_CODE_POINTS + 1))).toBe(false);
});
});
function filesUnder(dir: string): string[] {
return readdirSync(dir).flatMap((entry) => {
const path = join(dir, entry);
return statSync(path).isDirectory() ? filesUnder(path) : [path];
});
}
describe("offline bundling", () => {
const registryFiles = [SOURCE_PATH, join(REPO_ROOT, "tools", "icons", "generate.mjs"), ...OUTPUT_PATHS];
it("the registry has no remote URL, CDN reference or data-lucide runtime", () => {
for (const path of registryFiles) {
const text = readFileSync(path, "utf8");
expect(text, path).not.toMatch(/https?:\/\//i);
expect(text, path).not.toMatch(/\/\/[a-z0-9.-]+\.[a-z]{2,}\//i);
expect(text, path).not.toMatch(/\bcdn\b|jsdelivr|unpkg|cdnjs|kit\.fontawesome/i);
expect(text, path).not.toContain("data-lucide");
expect(text, path).not.toMatch(/\bfetch\s*\(/);
}
});
it("no bundled source imports an icon package — only the curated generated data ships", () => {
const sources = [join(REPO_ROOT, "admin", "src"), join(REPO_ROOT, "frontend", "src")]
.flatMap(filesUnder)
.filter((path) => /\.(ts|tsx)$/.test(path) && !path.endsWith(".test.ts") && !path.endsWith(".test.tsx"));
expect(sources.length).toBeGreaterThan(0);
for (const path of sources) {
const text = readFileSync(path, "utf8");
expect(text, path).not.toMatch(/from\s+["'](lucide|lucide-react|lucide-static|@fortawesome\/[^"']+)["']/);
}
});
});
@@ -74,6 +74,10 @@ async function routeFetch(url: string, init: RequestInit = {}): Promise<Response
overrides = overrides.filter((o) => o.id !== Number(deleteMatch[1]));
return jsonResponse({ status: "ok" });
}
// The shell loads the application timezone first ("" = none published).
if (path.endsWith("/admin/settings")) {
return jsonResponse({ timezone: "", effective_timezone: "" });
}
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
const patternsMatch = /\/children\/(\d+)\/presence-patterns$/.exec(path);
if (patternsMatch) {
+54 -1
View File
@@ -1,9 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import ScheduleTab from "./ScheduleTab";
import type { AdminChild } from "../api/definitions";
import type { PresenceOverride, PresencePattern } from "../api/presence";
import { storeTokens } from "../auth/oidc";
import { AppTimezoneProvider } from "../time/AppTimezone";
import { cycleWeekIndex, monthDates, weekdayOf } from "./cycle";
function child(id: number, display_name: string, sort_order: number, is_active = true): AdminChild {
@@ -655,3 +656,55 @@ describe("ScheduleTab", () => {
expect(await screen.findByTestId("pattern-1")).toBeTruthy();
});
});
describe("ScheduleTab today in the stored application timezone", () => {
async function renderWithZone(timezone: string, effective_timezone = timezone) {
fetchMock = vi.fn(async (url: string, init?: RequestInit) =>
String(url).endsWith("/api/v1/admin/settings")
? jsonResponse({ timezone, effective_timezone })
: routeFetch(url, init),
);
vi.stubGlobal("fetch", fetchMock);
render(
<AppTimezoneProvider>
<ScheduleTab />
</AppTimezoneProvider>,
);
await screen.findByTestId("pattern-1");
}
beforeAll(() => {
vi.stubEnv("TZ", "UTC");
});
afterAll(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
patterns = { 1: [DECLAN_HOME], 2: [MAEVE_THU_FRI, MAEVE_WEEKENDS], 3: [] };
writeFailure = null;
// 13:00 UTC on Sep 23 is already 01:00 on Sep 24 in Auckland.
vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-09-23T13:00:00Z") });
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("marks today in the stored zone", async () => {
await renderWithZone("Pacific/Auckland");
expect(day("2026-09-24").className).toContain("schedule-day--today");
expect(day("2026-09-23").className).not.toContain("schedule-day--today");
});
it("an empty stored zone marks today in the API host's published zone", async () => {
await renderWithZone("", "Pacific/Auckland");
expect(day("2026-09-24").className).toContain("schedule-day--today");
expect(day("2026-09-23").className).not.toContain("schedule-day--today");
});
});
+6 -3
View File
@@ -20,7 +20,6 @@ import {
import {
cycleLabel,
dayState,
localTodayIso,
monthDates,
monthStart,
patternDaysLabel,
@@ -38,6 +37,8 @@ import {
} from "./glyphs";
import OverrideEditor from "./OverrideEditor";
import PatternEditor from "./PatternEditor";
import { useAppTimezone } from "../time/AppTimezone";
import { todayIn } from "../time/zone";
import "./ScheduleTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
@@ -324,11 +325,13 @@ function OverrideRow({ override, childName, deletion, onAskDelete, onKeep, onDel
}
export interface ScheduleTabProps {
/** "YYYY-MM-DD"; defaults to the viewer's local date. */
/** "YYYY-MM-DD"; defaults to today in the application timezone. */
today?: string;
}
export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProps) {
export default function ScheduleTab({ today: todayProp }: ScheduleTabProps) {
const timeZone = useAppTimezone();
const today = todayProp ?? todayIn(timeZone);
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
const [selectedId, setSelectedId] = useState<number | null>(null);
-7
View File
@@ -55,13 +55,6 @@ export function cycleWeekIndex(anchorDate: string, date: string, cycleLengthWeek
return mod(Math.floor(days / 7), cycleLengthWeeks);
}
/** The viewer's local calendar date. */
export function localTodayIso(now: Date = new Date()): string {
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${now.getFullYear()}-${month}-${day}`;
}
/** "YYYY-MM-01" of the month containing `iso`, shifted by `delta` months. */
export function monthStart(iso: string, delta = 0): string {
const [year, month] = iso.split("-").map(Number);
+6
View File
@@ -258,6 +258,12 @@
color: var(--nq-a-ink-secondary);
}
.settings-hint {
margin: 0;
font-size: 12.5px;
color: var(--nq-a-ink-secondary);
}
.settings-input {
width: 100%;
min-height: var(--nq-a-tap-min);
+332 -3
View File
@@ -1,3 +1,4 @@
import { StrictMode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import App from "../App";
@@ -5,6 +6,7 @@ import SettingsScreen, { SCROLL_BOTTOM_PADDING } from "./SettingsScreen";
import type { AdminChild } from "../api/definitions";
import type { HouseholdSettings } from "../api/settings";
import { storeTokens } from "../auth/oidc";
import { AppTimezoneProvider, useAppTimezone } from "../time/AppTimezone";
const CHILDREN_PATH = "/api/v1/admin/children";
const REORDER_PATH = `${CHILDREN_PATH}/reorder`;
@@ -21,8 +23,18 @@ const DEFAULT_SETTINGS: HouseholdSettings = {
afternoon_reminder_enabled: false,
end_of_day_report_enabled: true,
celebration_enabled: false,
timezone: "America/Chicago",
effective_timezone: "America/Chicago",
};
/** The API host's own zone: the effective zone while none is stored. */
const API_HOST_ZONE = "Europe/London";
/** The settings as the API answers: `effective_timezone` derived like the API's. */
function settingsBody(): HouseholdSettings {
return { ...settings, effective_timezone: settings.timezone || API_HOST_ZONE };
}
function child(
id: number,
display_name: string,
@@ -80,10 +92,10 @@ async function routeFetch(url: string, init: RequestInit = {}): Promise<Response
return respond();
}
if (path === CHILDREN_PATH && method === "GET") return jsonResponse({ children });
if (path === SETTINGS_PATH && method === "GET") return jsonResponse(settings);
if (path === SETTINGS_PATH && method === "GET") return jsonResponse(settingsBody());
if (path === SETTINGS_PATH && method === "PATCH") {
settings = { ...settings, ...body };
return jsonResponse(settings);
return jsonResponse(settingsBody());
}
if (path === CHILDREN_PATH && method === "POST") {
const created = child(9, body.display_name, body.sort_order ?? 0, {
@@ -370,10 +382,11 @@ describe("SettingsScreen — preferences", () => {
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
}
it("renders the current value of all ten fields", async () => {
it("renders the current value of all eleven settings form fields", async () => {
const { form } = await renderPreferences();
expect(field(form, "Planning horizon (days)").value).toBe("14");
expect(field(form, "Day rollover").value).toBe("03:00");
expect(field(form, "Timezone").value).toBe("America/Chicago");
expect(field(form, "Notify target").value).toBe("notify.family");
expect(field(form, "Morning summary time").value).toBe("07:00");
expect(field(form, "Afternoon reminder time").value).toBe("15:30");
@@ -496,6 +509,49 @@ describe("SettingsScreen — preferences", () => {
expect(writes()).toHaveLength(1);
});
it("a timezone picked from the list patches exactly the timezone", async () => {
const { form } = await renderPreferences();
fireEvent.change(field(form, "Timezone"), { target: { value: "America/New_York" } });
save(form);
await screen.findByText("Preferences saved.");
expect(writes()).toEqual([
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } },
]);
const reloaded = screen.getByRole("form", { name: "Preferences" });
expect(field(reloaded, "Timezone").value).toBe("America/New_York");
});
it.each(["New York", "America/New York", "EST5EDT?", "/Chicago"])(
"an invalid other timezone %j is rejected before any request",
async (value) => {
const { form } = await renderPreferences();
fireEvent.change(field(form, "Timezone"), { target: { value: "__other__" } });
fireEvent.change(field(form, "Other timezone"), { target: { value } });
save(form);
expect(screen.getByTestId("preferences-message").textContent).toBe(
"Timezone must be an IANA zone like America/New_York, or UTC.",
);
expect(field(form, "Other timezone").value).toBe(value);
expect(writes()).toEqual([]);
},
);
it("a 422 on an unknown timezone shows the API detail and keeps the edit", async () => {
const { form } = await renderPreferences();
fireEvent.change(field(form, "Timezone"), { target: { value: "__other__" } });
fireEvent.change(field(form, "Other timezone"), { target: { value: "Mars/Olympus_Mons" } });
writeResponse = () =>
jsonResponse({ detail: "timezone must be an IANA time zone name such as 'America/New_York'" }, 422);
save(form);
const message = await screen.findByTestId("preferences-message");
expect(message.textContent).toBe(
"The preferences could not be saved: timezone must be an IANA time zone name such as 'America/New_York'",
);
expect(field(form, "Other timezone").value).toBe("Mars/Olympus_Mons");
expect(field(form, "Other timezone").disabled).toBe(false);
expect(settings.timezone).toBe("America/Chicago");
});
it("a failed load shows the reason and Try again refetches", async () => {
let fail = true;
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
@@ -512,3 +568,276 @@ describe("SettingsScreen — preferences", () => {
await screen.findByRole("form", { name: "Preferences" });
});
});
describe("SettingsScreen — no timezone auto-set", () => {
let zoneSpy: ReturnType<typeof vi.spyOn> | null;
function mockBrowserZone(timeZone: string | undefined) {
const real = Intl.DateTimeFormat.prototype.resolvedOptions;
zoneSpy = vi
.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions")
.mockImplementation(function (this: Intl.DateTimeFormat) {
return { ...real.call(this), timeZone } as Intl.ResolvedDateTimeFormatOptions;
});
}
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
children = [child(1, "Declan", 1)];
// A fresh install: no zone stored.
settings = { ...DEFAULT_SETTINGS, timezone: "" };
writeResponse = null;
zoneSpy = null;
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
zoneSpy?.mockRestore();
vi.unstubAllGlobals();
});
async function renderForm() {
render(<SettingsScreen onClose={vi.fn()} />);
await screen.findByTestId("settings-child-1");
return screen.findByRole("form", { name: "Preferences" });
}
it("an empty timezone stays empty on load and nothing is written", async () => {
mockBrowserZone("America/New_York");
const form = await renderForm();
expect(field(form, "Timezone").value).toBe("");
expect(writes()).toEqual([]);
expect(settings.timezone).toBe("");
});
it("StrictMode's doubled effects write nothing either", async () => {
mockBrowserZone("America/New_York");
render(
<StrictMode>
<SettingsScreen onClose={vi.fn()} />
</StrictMode>,
);
const form = await screen.findByRole("form", { name: "Preferences" });
expect(field(form, "Timezone").value).toBe("");
expect(writes()).toEqual([]);
});
it("a stored timezone is shown as stored, never replaced by the browser zone", async () => {
mockBrowserZone("America/New_York");
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin" };
const form = await renderForm();
expect(field(form, "Timezone").value).toBe("Europe/Dublin");
expect(writes()).toEqual([]);
});
it("saving another field sends only that field, never a timezone", async () => {
mockBrowserZone("America/New_York");
const form = await renderForm();
fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } });
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
await screen.findByText("Preferences saved.");
expect(writes().map((w) => w.body)).toEqual([{ horizon_days: 21 }]);
expect(settings.timezone).toBe("");
});
it("an admin clearing the timezone saves exactly the empty zone", async () => {
mockBrowserZone("America/New_York");
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin" };
const form = await renderForm();
fireEvent.change(field(form, "Timezone"), { target: { value: "" } });
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
await screen.findByText("Preferences saved.");
expect(writes().map((w) => w.body)).toEqual([{ timezone: "" }]);
expect(field(screen.getByRole("form", { name: "Preferences" }), "Timezone").value).toBe("");
});
});
describe("SettingsScreen — manual timezone control", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
children = [child(1, "Declan", 1)];
settings = { ...DEFAULT_SETTINGS, timezone: "" };
writeResponse = null;
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
async function renderForm() {
render(<SettingsScreen onClose={vi.fn()} />);
await screen.findByTestId("settings-child-1");
return screen.findByRole("form", { name: "Preferences" });
}
function save(form: HTMLElement) {
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
}
/** The Timezone control, which must be a real select, not a free-text box. */
function picker(form: HTMLElement): HTMLSelectElement {
const control = within(form).getByRole("combobox", { name: "Timezone" });
expect(control).toBeInstanceOf(HTMLSelectElement);
return control as HTMLSelectElement;
}
it("is a picker of curated zones with host-local and other-zone choices", async () => {
const form = await renderForm();
const select = picker(form);
const values = Array.from(select.options).map((o) => o.value);
expect(values[0]).toBe("");
expect(select.options[0].textContent).toBe("API host local time (no zone set)");
for (const zone of [
"UTC",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Phoenix",
"America/Los_Angeles",
"America/Anchorage",
"Pacific/Honolulu",
]) {
expect(values).toContain(zone);
}
expect(select.options[select.options.length - 1].textContent).toBe("Other IANA zone…");
expect(select.value).toBe("");
expect(within(form).queryByLabelText("Other timezone")).toBeNull();
});
it("explains that it is the application timezone for every surface", async () => {
const form = await renderForm();
const help = document.getElementById(picker(form).getAttribute("aria-describedby") ?? "");
expect(help?.textContent).toMatch(/application timezone/);
expect(help?.textContent).toMatch(/panel, the scheduler and this admin app/);
expect(help?.textContent).toMatch(/API host's local time/);
});
it("a picked zone saves via PATCH and is shown after a reload", async () => {
const form = await renderForm();
expect(writes()).toEqual([]);
fireEvent.change(picker(form), { target: { value: "America/New_York" } });
save(form);
await screen.findByText("Preferences saved.");
expect(writes()).toEqual([
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } },
]);
cleanup();
const reloaded = await renderForm();
expect(picker(reloaded).value).toBe("America/New_York");
expect(writes()).toHaveLength(1);
});
it("an other zone typed by hand saves via PATCH and is shown after a reload", async () => {
const form = await renderForm();
fireEvent.change(picker(form), { target: { value: "__other__" } });
const entry = within(form).getByLabelText("Other timezone") as HTMLInputElement;
expect(entry.value).toBe("");
fireEvent.change(entry, { target: { value: " America/Boise " } });
save(form);
await screen.findByText("Preferences saved.");
expect(writes()).toEqual([
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/Boise" } },
]);
cleanup();
const reloaded = await renderForm();
expect(picker(reloaded).value).toBe("__other__");
expect((within(reloaded).getByLabelText("Other timezone") as HTMLInputElement).value).toBe(
"America/Boise",
);
});
it("a saved zone outside the list is shown in the other-zone entry on load", async () => {
settings = { ...DEFAULT_SETTINGS, timezone: "Asia/Kathmandu" };
const form = await renderForm();
expect(picker(form).value).toBe("__other__");
expect((within(form).getByLabelText("Other timezone") as HTMLInputElement).value).toBe(
"Asia/Kathmandu",
);
expect(writes()).toEqual([]);
});
it("choosing API host local time clears the zone and persists after a reload", async () => {
settings = { ...DEFAULT_SETTINGS, timezone: "America/Denver" };
const form = await renderForm();
expect(picker(form).value).toBe("America/Denver");
fireEvent.change(picker(form), { target: { value: "" } });
save(form);
await screen.findByText("Preferences saved.");
expect(writes().map((w) => w.body)).toEqual([{ timezone: "" }]);
cleanup();
const reloaded = await renderForm();
expect(picker(reloaded).value).toBe("");
});
it("picking a zone and then the saved one again sends nothing", async () => {
settings = { ...DEFAULT_SETTINGS, timezone: "UTC" };
const form = await renderForm();
fireEvent.change(picker(form), { target: { value: "Europe/Paris" } });
fireEvent.change(picker(form), { target: { value: "UTC" } });
save(form);
expect(screen.getByTestId("preferences-message").textContent).toBe("No changes to save.");
expect(writes()).toEqual([]);
});
it("never sends a timezone_configured field", async () => {
const form = await renderForm();
fireEvent.change(picker(form), { target: { value: "America/Los_Angeles" } });
save(form);
await screen.findByText("Preferences saved.");
const again = screen.getByRole("form", { name: "Preferences" });
fireEvent.change(picker(again), { target: { value: "" } });
save(again);
await waitFor(() => expect(writes()).toHaveLength(2));
for (const write of writes()) {
expect(Object.keys(write.body as object)).toEqual(["timezone"]);
expect(write.body).not.toHaveProperty("timezone_configured");
}
});
});
describe("SettingsScreen — the saved zone applies app-wide", () => {
function ZoneProbe() {
return <output data-testid="app-zone">{useAppTimezone() || "(browser)"}</output>;
}
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
children = [child(1, "Declan", 1)];
settings = { ...DEFAULT_SETTINGS, timezone: "" };
writeResponse = null;
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("a saved zone becomes the application timezone at once", async () => {
render(
<AppTimezoneProvider>
<ZoneProbe />
<SettingsScreen onClose={vi.fn()} />
</AppTimezoneProvider>,
);
const form = await screen.findByRole("form", { name: "Preferences" });
// No zone stored: the application runs in the API host's zone.
expect(screen.getByTestId("app-zone").textContent).toBe(API_HOST_ZONE);
fireEvent.change(within(form).getByRole("combobox", { name: "Timezone" }), {
target: { value: "America/New_York" },
});
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
await screen.findByText("Preferences saved.");
expect(screen.getByTestId("app-zone").textContent).toBe("America/New_York");
});
});
+102 -2
View File
@@ -5,7 +5,10 @@
* household's child profiles. Every write refetches the list.
*
* Preferences: the household settings (planning horizon, day rollover,
* notifications). Save patches only the changed fields.
* timezone, notifications). Save patches only the changed fields. The
* timezone — the application's source of local time — is only ever set by
* the admin picking or typing a zone here and saving it; every zone loaded
* or saved here is published app-wide (time/AppTimezone.tsx).
*
* One write at a time across both sections.
*/
@@ -33,6 +36,7 @@ import {
type SettingsChanges,
} from "../api/settings";
import { FOCUSABLE, inertOutside } from "../schedule/OverrideEditor";
import { usePublishAppTimezone } from "../time/AppTimezone";
import { ArrowDownGlyph, ArrowUpGlyph, ChevronLeftGlyph, PlusGlyph } from "./glyphs";
import "./SettingsScreen.css";
@@ -252,6 +256,46 @@ const NOTIFICATIONS: { label: string; time: TimeField; toggle: ToggleField }[] =
const HH_MM = /^([01]\d|2[0-3]):[0-5]\d$/;
/** "UTC" or an IANA-looking "Area/Location" (the API checks it resolves). */
const IANA_ZONE = /^(UTC|[A-Za-z]+(\/[A-Za-z0-9_+-]+)+)$/;
/** The Timezone picker's zones; any other IANA zone can be typed via "Other". */
export const COMMON_TIMEZONES = [
"UTC",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Phoenix",
"America/Los_Angeles",
"America/Anchorage",
"Pacific/Honolulu",
"America/Halifax",
"America/St_Johns",
"America/Toronto",
"America/Vancouver",
"America/Mexico_City",
"America/Sao_Paulo",
"Europe/London",
"Europe/Dublin",
"Europe/Paris",
"Europe/Berlin",
"Europe/Madrid",
"Europe/Rome",
"Europe/Athens",
"Africa/Johannesburg",
"Asia/Dubai",
"Asia/Kolkata",
"Asia/Singapore",
"Asia/Shanghai",
"Asia/Tokyo",
"Australia/Perth",
"Australia/Sydney",
"Pacific/Auckland",
];
/** The picker's "Other IANA zone…" option; never sent to the API. */
const OTHER_ZONE = "__other__";
/** The form's working copy: the number is text so it can be mid-edit. */
type PreferencesDraft = Omit<HouseholdSettings, "horizon_days"> & { horizon_days: string };
@@ -275,11 +319,16 @@ function buildChanges(draft: PreferencesDraft, loaded: HouseholdSettings): Setti
for (const [key, label] of times) {
if (!HH_MM.test(draft[key])) return `${label} must be a time like 07:30.`;
}
const timezone = draft.timezone.trim();
if (timezone && !IANA_ZONE.test(timezone)) {
return "Timezone must be an IANA zone like America/New_York, or UTC.";
}
const next: HouseholdSettings = {
...draft,
horizon_days: horizonDays,
notify_target: draft.notify_target.trim(),
timezone,
};
const changes: SettingsChanges = {};
for (const key of Object.keys(loaded) as (keyof HouseholdSettings)[]) {
@@ -305,6 +354,10 @@ function PreferencesForm({
}) {
const [draft, setDraft] = useState<PreferencesDraft>(() => preferencesDraft(settings));
const update = (patch: Partial<PreferencesDraft>) => setDraft((d) => ({ ...d, ...patch }));
// A saved zone outside the curated list opens in the free-text entry.
const [otherZone, setOtherZone] = useState(
() => settings.timezone !== "" && !COMMON_TIMEZONES.includes(settings.timezone),
);
const disabled = saving || locked;
function submit(event: FormEvent) {
@@ -357,6 +410,49 @@ function PreferencesForm({
/>
</label>
{timeInput("day_rollover_time", "Day rollover")}
<div className="settings-field">
<label className="settings-label" htmlFor="settings-timezone">
Timezone
</label>
<select
id="settings-timezone"
className="settings-input"
aria-describedby="settings-timezone-help"
value={otherZone ? OTHER_ZONE : draft.timezone}
disabled={disabled}
onChange={(event) => {
const choice = event.target.value;
setOtherZone(choice === OTHER_ZONE);
if (choice !== OTHER_ZONE) update({ timezone: choice });
}}
>
<option value="">API host local time (no zone set)</option>
{COMMON_TIMEZONES.map((zone) => (
<option key={zone} value={zone}>
{zone}
</option>
))}
<option value={OTHER_ZONE}>Other IANA zone…</option>
</select>
{otherZone ? (
<input
className="settings-input"
type="text"
aria-label="Other timezone"
placeholder="Area/Location, e.g. America/Boise"
autoComplete="off"
spellCheck={false}
value={draft.timezone}
disabled={disabled}
onChange={(event) => update({ timezone: event.target.value })}
/>
) : null}
<p className="settings-hint" id="settings-timezone-help">
The application timezone: the source of local time for the panel, the scheduler and this
admin app. It changes only when you save it here; with no zone set, the API host&apos;s
local time is used.
</p>
</div>
<label className="settings-field">
<span className="settings-label">Notify target</span>
<input
@@ -499,6 +595,7 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
const titleRef = useRef<HTMLHeadingElement>(null);
const addRef = useRef<HTMLButtonElement>(null);
const formWasOpen = useRef(false);
const publishTimezone = usePublishAppTimezone();
// Modal focus: move in on open, keep the background out of reach, and hand
// focus back to the opener (the Settings button) on close.
@@ -540,7 +637,9 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
setPreferences({ kind: "loading" });
fetchSettings()
.then((settings) => {
if (!cancelled) setPreferences({ kind: "ready", settings });
if (cancelled) return;
setPreferences({ kind: "ready", settings });
publishTimezone(settings.effective_timezone);
})
.catch((error: unknown) => {
if (cancelled) return;
@@ -634,6 +733,7 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
try {
const settings = await updateSettings(changes);
setPreferences({ kind: "ready", settings });
publishTimezone(settings.effective_timezone);
setPreferencesMessage({ kind: "info", text: "Preferences saved." });
} catch (error) {
setPreferencesMessage({
+93
View File
@@ -0,0 +1,93 @@
/**
* The effective application timezone, loaded once per signed-in shell: the
* API's `effective_timezone` — the stored zone, or the API host's own zone
* when none is stored — so every screen's local time matches the API's.
*
* <AppTimezoneProvider> fetches the household settings and renders its
* screens only once the zone is known (a failed load says so and offers a
* retry), so no screen ever computes local time in a zone it will not keep.
* The Settings screen publishes every zone it loads or saves, so a change
* applies app-wide at once.
*
* Without a provider (a screen rendered on its own, e.g. in a test)
* useAppTimezone() is "" — the documented unset value, i.e. browser-local.
*/
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { ApiForbiddenError } from "../api/client";
import { fetchSettings } from "../api/settings";
interface ZoneHub {
timeZone: string;
publish(timeZone: string): void;
}
const AppTimezoneContext = createContext<ZoneHub | null>(null);
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; timeZone: string };
export function AppTimezoneProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
setState({ kind: "loading" });
fetchSettings()
.then((settings) => {
if (!cancelled) setState({ kind: "ready", timeZone: settings.effective_timezone });
})
.catch((error: unknown) => {
if (cancelled) return;
setState({
kind: "error",
message:
error instanceof ApiForbiddenError
? error.message
: "The application timezone could not be loaded. Check your connection and try again.",
});
});
return () => {
cancelled = true;
};
}, [attempt]);
if (state.kind === "loading") {
return (
<div className="admin-loading" role="status" data-testid="timezone-loading">
Loading…
</div>
);
}
if (state.kind === "error") {
return (
<div className="admin-error" role="alert" data-testid="timezone-error">
<p>{state.message}</p>
<button type="button" onClick={() => setAttempt((n) => n + 1)}>
Try again
</button>
</div>
);
}
const hub: ZoneHub = {
timeZone: state.timeZone,
publish: (timeZone) =>
setState((prev) =>
prev.kind === "ready" && prev.timeZone === timeZone ? prev : { kind: "ready", timeZone },
),
};
return <AppTimezoneContext.Provider value={hub}>{children}</AppTimezoneContext.Provider>;
}
/** The effective application timezone: an IANA name, or "" (browser-local). */
export function useAppTimezone(): string {
return useContext(AppTimezoneContext)?.timeZone ?? "";
}
/** Publish an effective zone just loaded or saved; a no-op without a provider. */
export function usePublishAppTimezone(): (timeZone: string) => void {
const hub = useContext(AppTimezoneContext);
return (timeZone) => hub?.publish(timeZone);
}
+60
View File
@@ -0,0 +1,60 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { isoDateIn, todayIn, wallClockInstant, zoneOption } from "./zone";
import { formatClockTime } from "../today/format";
// 13:00 UTC on Sep 23 is already 01:00 on Sep 24 in Auckland (NZST, UTC+12).
const NOW = new Date("2026-09-23T13:00:00Z");
describe("application timezone helpers (process in UTC)", () => {
beforeAll(() => {
vi.stubEnv("TZ", "UTC");
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("a stored zone decides the calendar date; empty means the browser's zone", () => {
expect(todayIn("Pacific/Auckland", NOW)).toBe("2026-09-24");
expect(isoDateIn(NOW, "Pacific/Auckland")).toBe("2026-09-24");
expect(todayIn("", NOW)).toBe("2026-09-23");
});
it("wall-clock time converts in the stored zone, across its DST change", () => {
// NZST (UTC+12) before 27 Sep 2026, NZDT (UTC+13) after.
expect(wallClockInstant("2026-09-24", 0, 10, "Pacific/Auckland")).toBe(
Date.parse("2026-09-23T12:10:00Z"),
);
expect(wallClockInstant("2026-09-28", 7, 30, "Pacific/Auckland")).toBe(
Date.parse("2026-09-27T18:30:00Z"),
);
expect(wallClockInstant("2026-09-24", 0, 10, "")).toBe(Date.parse("2026-09-24T00:10:00Z"));
});
it("the completion clock reads in the stored zone", () => {
expect(formatClockTime("2026-09-23T11:04:00Z", "Pacific/Auckland")).toBe("11:04 PM");
expect(formatClockTime("2026-09-23T11:04:00Z", "")).toBe("11:04 AM");
});
it("an invalid stored zone fails fast", () => {
expect(zoneOption("")).toBeUndefined();
expect(() => todayIn("Not/AZone", NOW)).toThrow(RangeError);
});
});
describe("empty zone keeps browser-local behavior", () => {
beforeAll(() => {
vi.stubEnv("TZ", "America/Los_Angeles");
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("matches the browser's own calendar and wall clock", () => {
// 13:00 UTC is 06:00 PDT on Sep 23.
expect(todayIn("", NOW)).toBe("2026-09-23");
expect(wallClockInstant("2026-09-23", 7, 30, "")).toBe(new Date(2026, 8, 23, 7, 30).getTime());
expect(formatClockTime("2026-09-23T14:04:00Z", "")).toBe("7:04 AM");
});
});
+81
View File
@@ -0,0 +1,81 @@
/**
* Local time in the application timezone.
*
* The admin-saved zone (settings `timezone`, an IANA name) is the single
* source of local-time truth for the whole application; the API publishes it
* as `effective_timezone`, naming its host's own zone when none is stored.
* "" means the API published no zone, and only then does the browser's own
* zone apply. Every helper takes
* that stored value as-is; an invalid zone name throws (Intl's RangeError).
*/
/** The `timeZone` option for Intl: the zone, or undefined (browser) for "". */
export function zoneOption(timeZone: string): string | undefined {
return timeZone === "" ? undefined : timeZone;
}
interface WallClock {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
}
function wallClock(instant: number, timeZone: string): WallClock {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: zoneOption(timeZone),
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).formatToParts(instant);
const part = (type: Intl.DateTimeFormatPartTypes) =>
Number(parts.find((p) => p.type === type)?.value);
return {
year: part("year"),
month: part("month"),
day: part("day"),
hour: part("hour"),
minute: part("minute"),
second: part("second"),
};
}
/** The "YYYY-MM-DD" calendar date of `instant` in the zone. */
export function isoDateIn(instant: Date, timeZone: string): string {
const { year, month, day } = wallClock(instant.getTime(), timeZone);
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
/** Today's calendar date in the zone. */
export function todayIn(timeZone: string, now: Date = new Date()): string {
return isoDateIn(now, timeZone);
}
/** The zone's UTC offset in ms at `instant` (wall clock minus UTC). */
function offsetAt(instant: number, timeZone: string): number {
const c = wallClock(instant, timeZone);
const wholeSeconds = Math.floor(instant / 1000) * 1000;
return Date.UTC(c.year, c.month - 1, c.day, c.hour, c.minute, c.second) - wholeSeconds;
}
/**
* Epoch ms at which the zone's wall clock reads `hour:minute` on the
* "YYYY-MM-DD" date. Two passes settle the offset across a DST change.
*/
export function wallClockInstant(
iso: string,
hour: number,
minute: number,
timeZone: string,
): number {
const [year, month, day] = iso.split("-").map(Number);
const asUtc = Date.UTC(year, month - 1, day, hour, minute);
const guess = asUtc - offsetAt(asUtc, timeZone);
return asUtc - offsetAt(guess, timeZone);
}
+60
View File
@@ -5,6 +5,7 @@ import type { AdminSnapshot, SnapshotInstance } from "../api/snapshot";
import { TransitionsProvider } from "../api/useTransitions";
import { frame, testStream, type TestStream } from "../api/testStream";
import { storeTokens } from "../auth/oidc";
import { AppTimezoneProvider } from "../time/AppTimezone";
function instance(overrides: Partial<SnapshotInstance>): SnapshotInstance {
return {
@@ -406,3 +407,62 @@ describe("TodayTab Undo and live updates", () => {
await waitFor(() => expect(streams[0].cancelled()).toBe(true));
});
});
describe("TodayTab in the stored application timezone", () => {
function renderWithZone(timezone: string, effective_timezone = timezone) {
fetchMock.mockImplementation((url: string) =>
Promise.resolve(
String(url).endsWith("/api/v1/admin/settings")
? jsonResponse({ timezone, effective_timezone })
: jsonResponse(SNAPSHOT),
),
);
render(
<AppTimezoneProvider>
<TodayTab />
</AppTimezoneProvider>,
);
}
beforeAll(() => {
// The browser runs in UTC; the household lives in Auckland (UTC+12 on Sep 23).
vi.stubEnv("TZ", "UTC");
});
afterAll(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("shows the completion clock in the stored zone, not the browser's", async () => {
renderWithZone("Pacific/Auckland");
const completed = await screen.findByTestId("attention-12");
// 11:04 UTC is 11:04 PM in Auckland.
expect(within(completed).getByText("Completed 11:04 PM")).toBeTruthy();
// The header's day is the API's household-local today_iso, as-is.
expect(screen.getByText("Wednesday, September 23 · cycle day 5 of 14")).toBeTruthy();
});
it("an empty stored zone shows the clock in the API host's published zone", async () => {
renderWithZone("", "Pacific/Auckland");
const completed = await screen.findByTestId("attention-12");
expect(within(completed).getByText("Completed 11:04 PM")).toBeTruthy();
});
it("only an API that publishes no zone leaves the browser's local clock", async () => {
renderWithZone("", "");
const completed = await screen.findByTestId("attention-12");
expect(within(completed).getByText("Completed 11:04 AM")).toBeTruthy();
});
});
+3 -1
View File
@@ -13,6 +13,7 @@ import { ApiRequestError } from "../api/definitions";
import { uncompleteInstance } from "../api/instances";
import { useTransitions } from "../api/useTransitions";
import SettingsScreen from "../settings/SettingsScreen";
import { useAppTimezone } from "../time/AppTimezone";
import {
formatClockTime,
formatDueTime,
@@ -165,6 +166,7 @@ function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }
const { instance, childName } = item;
const overdue = isOverdueOpen(instance);
const undoing = undo.pendingId === instance.id;
const timeZone = useAppTimezone();
// TODO(e57facc2): wire "Mark done". The admin plane has no complete route
// yet, so that action is inert here.
return (
@@ -187,7 +189,7 @@ function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }
) : (
// The snapshot carries no completion actor, so "from panel" is not printed.
<div className="today-event-meta">
Completed {formatClockTime(instance.completed_at as string)}
Completed {formatClockTime(instance.completed_at as string, timeZone)}
</div>
)}
</div>
+10 -4
View File
@@ -4,8 +4,10 @@
* 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.
* day in negative-offset zones. Instants are shown in the application
* timezone (time/zone.ts).
*/
import { zoneOption } from "../time/zone";
const LOCALE = "en-US";
@@ -44,7 +46,11 @@ export function formatDueTime(hhmm: string): string {
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" });
/** A UTC ISO timestamp shown as wall-clock time in `timeZone` ("" = browser), "7:04 AM". */
export function formatClockTime(isoTimestamp: string, timeZone: string): string {
return format(new Date(isoTimestamp), {
hour: "numeric",
minute: "2-digit",
timeZone: zoneOption(timeZone),
});
}
+62
View File
@@ -0,0 +1,62 @@
"""The API's effective application timezone (task 7e173881).
The admin-stored ``timezone`` setting is the single source of local-time
truth; stored empty, the API reads its clock in the API host's local
time. :func:`effective_timezone` names the zone the API actually uses —
the stored zone, or else the host's own IANA zone — so the panel cards
and the admin app format local time in exactly the API's zone instead of
guessing with Home Assistant's or the browser's. ``""`` only when the
host's zone genuinely cannot be named.
"""
from __future__ import annotations
import os
from collections.abc import Mapping
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
def _valid_zone(name: str) -> str:
"""Return ``name`` if it is a loadable IANA zone key, else ``""``."""
name = name.strip().removeprefix(":")
if not name:
return ""
try:
ZoneInfo(name)
except (ZoneInfoNotFoundError, ValueError):
return ""
return name
def host_timezone(
environ: Mapping[str, str] = os.environ,
timezone_file: Path = Path("/etc/timezone"),
localtime: Path = Path("/etc/localtime"),
) -> str:
"""Name the API host's local zone as an IANA key, or ``""``.
In the order the C library honours them: the ``TZ`` environment
variable, then the ``/etc/localtime`` zoneinfo link (with Debian's
``/etc/timezone`` naming it when the link is a copy). A host with
none of them configured runs UTC. A setting that names no IANA
zone (a POSIX rule string, a copied file) is ``""``.
"""
configured = environ.get("TZ")
if configured is not None:
# An empty TZ is UTC to the C library.
return _valid_zone(configured) if configured.strip() else "UTC"
if localtime.is_symlink():
target = str(localtime.resolve())
marker = "/zoneinfo/"
if marker in target:
return _valid_zone(target.split(marker, 1)[1])
if timezone_file.is_file():
return _valid_zone(timezone_file.read_text(encoding="utf-8"))
if not localtime.exists():
return "UTC"
return ""
def effective_timezone(stored: str) -> str:
"""The zone the API reads its clock in: ``stored``, else the host's."""
return stored or host_timezone()
+5
View File
@@ -152,6 +152,10 @@ core_quest_definitions = importlib.import_module(
f"{_CORE_MODULE_NAME}.quest_definitions"
)
#: The icon-key module (``core.icons.normalize_icon_for_read``) the
#: admin plane's quest-definition responses normalize ``icon`` through.
core_icons = importlib.import_module(f"{_CORE_MODULE_NAME}.icons")
#: The recurrence model (``core.recurrence.ScheduleRule`` and
#: ``RuleValidationError``) the quest-definition routes build request
#: rules with — the model validates at construction, the routes never
@@ -215,6 +219,7 @@ __all__ = [
"core_db",
"core_events",
"core_history",
"core_icons",
"core_materialize",
"core_migrations",
"core_presence",
+174 -44
View File
@@ -165,10 +165,15 @@ integration's Home-Assistant config-entry options remain a temporary,
SEPARATE source until Feature 18 wires the client to the API — the two
are deliberately not unified here:
- ``GET /settings`` returns the current effective settings: all ten
- ``GET /settings`` returns the current effective settings: all eleven
documented fields (the rolling horizon, the day rollover time, the
notify target, the three notification times and the four enable
toggles) — the defaults on a fresh database.
notify target, the three notification times, the four enable
toggles, and the ``timezone`` — the IANA household zone the
household-local dates derive from, empty meaning host local; only an
admin's ``PATCH`` ever sets it) — the defaults on a fresh database —
plus the read-only ``effective_timezone`` (:mod:`api.host_zone`): the
zone the API actually reads its clock in, which the admin app formats
local time in.
- ``PATCH /settings`` updates ONLY the supplied fields: the body is a
JSON object of settings field names to new values, passed STRAIGHT
to :func:`nestquest_core.settings_store.update_settings` — the
@@ -184,18 +189,38 @@ The missed-sweep trigger (task 058c7b69) follows the same pattern over
:mod:`nestquest_core.sweep` — the HA-free sweep policy the
integration's rollover listener shares:
- ``POST /missed-sweep`` runs the sweep for the API host's local
``today`` (ONE :func:`_local_now` clock read threaded into the core
call) and publishes each returned ``(event_type, payload)`` pair
DIRECTLY on the app's ONE transition publisher — the sweep-built
payload, never re-fetched (the core commits its watermark before
returning, so a failed re-fetch after it would lose the missed event
forever), the same publish-what-the-core-built shape
api/scheduler.py uses. The watermark rule
(a same-day rerun announces nothing, a post-downtime run sweeps the
accumulated window once), the no-completion-event rule and the
read-only-over-the-domain-tables guarantee all live in the core; the
response reports how many transitions were published.
- ``POST /missed-sweep`` runs the sweep for the household-local
``today`` (ONE :func:`_local_now` clock read re-expressed in the
configured household ``timezone`` — empty falls back to host local —
threaded into the core call) and publishes each returned
``(event_type, payload)`` pair DIRECTLY on the app's ONE transition
publisher — the sweep-built payload, never re-fetched (the core
commits its watermark before returning, so a failed re-fetch after it
would lose the missed event forever), the same
publish-what-the-core-built shape api/scheduler.py uses. The
watermark rule (a same-day rerun announces nothing, a post-downtime
run sweeps the accumulated window once), the no-completion-event rule
and the read-only-over-the-domain-tables guarantee all live in the
core; the response reports how many transitions were published.
The quest-definition DELETE route (task 3efb07b4) is the hybrid admin
delete (owner decision 2026-09-26: "delete if there's no history,
retire/deactivate if there is"):
- ``DELETE /quest-definitions/{definition_id}`` is a thin adapter over
:func:`nestquest_core.quest_definitions.delete_quest_definition`. A
definition with NO completion event on any instance is hard-deleted
(instances, assignees, windows, the row and its unreferenced rule, in
one core transaction); one WITH history is retired by deactivation.
Retirement preserves the definition row, schedule rule, assignees,
windows and ALL durable completion history; like any deactivation it
regenerates, so future UNCOMPLETED instances are cleared (rolling,
regenerable rows) and none are recreated while the definition is
inactive — the retired task stops generating and shows no open work.
Durable instance history lives only in the append-only
``completion_events``. The response reports which happened
(``deleted`` / ``retired``) and carries the retired definition, or
``null`` when deleted. An unknown id is 404.
The occurrence-preview route (task 5f843564) lets the admin PWA's
Definitions edit sheet show the next dates a rule fires on WITHOUT
@@ -205,9 +230,10 @@ reimplementing the recurrence rules client-side:
``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
optional ``start_date`` (default: the household-local ``today`` —
ONE :func:`_local_now` clock read re-expressed in the configured
household ``timezone``, empty falling back to host local) 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
@@ -326,6 +352,7 @@ from pydantic import (
model_validator,
)
from api import host_zone
from api import transitions as api_transitions
from api.auth import require_admin
from api.database import DatabaseState
@@ -336,6 +363,7 @@ from api.nestquest_core import (
core_dao_children,
core_dao_rules,
core_history,
core_icons,
core_materialize,
core_presence_management,
core_quest_definitions,
@@ -448,6 +476,19 @@ class AdminQuestDefinitionListResponse(BaseModel):
definitions: list[AdminQuestDefinitionResponse]
class AdminQuestDefinitionDeleteResponse(BaseModel):
"""The body ``DELETE /api/v1/admin/quest-definitions/{id}`` answers.
Exactly one of ``deleted`` / ``retired`` is true. ``definition`` is
the retired (now inactive) definition, or null when it was deleted.
"""
definition_id: int
deleted: bool
retired: bool
definition: AdminQuestDefinitionResponse | None
class AdminChildCreateRequest(BaseModel):
"""The body of ``POST /api/v1/admin/children``.
@@ -531,9 +572,10 @@ class AdminOccurrencesPreviewRequest(BaseModel):
``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.
household-local today in the configured ``timezone``, empty falling
back to the API host's local time). ``count`` is a strict JSON
integer in 1..``_PREVIEW_MAX_COUNT`` — anything else is 422 before
any handler code runs.
"""
rule: AdminRuleRequest
@@ -867,7 +909,7 @@ class AdminSettingsResponse(BaseModel):
"""The effective settings as the admin plane serializes them.
Mirrors the core :class:`~nestquest_core.settings.NestQuestSettings`
— all TEN documented fields, resolved (every absent stored field
— all ELEVEN documented fields, resolved (every absent stored field
already fell back to its default in the core). The same shape both
the GET and the PATCH route answer with.
"""
@@ -882,6 +924,14 @@ class AdminSettingsResponse(BaseModel):
afternoon_reminder_enabled: bool
end_of_day_report_enabled: bool
celebration_enabled: bool
#: The household IANA time zone the admin stored; ``""`` means the
#: API host's local time.
timezone: str
#: The zone the API actually reads its clock in — ``timezone``, or
#: the API host's own zone when that is ``""`` (``""`` only when the
#: host's zone cannot be named). Read-only; the admin app formats
#: local time in it.
effective_timezone: str
class AdminMissedSweepResponse(BaseModel):
@@ -1178,6 +1228,26 @@ def _local_now() -> datetime.datetime:
return datetime.datetime.now().astimezone()
async def _household_clock(
database: object,
) -> tuple[object, datetime.datetime]:
"""Return the stored settings and ONE household-local clock read.
The API host may run UTC while the household does not, so the one
:func:`_local_now` read is re-expressed in the stored settings'
``timezone`` (:meth:`NestQuestSettings.household_now`); an unset
zone keeps the host's local time.
"""
settings = await core_settings_store.load_settings(database)
return settings, settings.household_now(_local_now())
async def _household_today(database: object) -> datetime.date:
"""Return the household-local date of ONE :func:`_household_clock` read."""
_settings, now = await _household_clock(database)
return now.date()
def _rule_from_request(rule: AdminRuleRequest) -> core_recurrence.ScheduleRule:
"""Build the core ScheduleRule from the request's ``rule`` object.
@@ -1205,7 +1275,7 @@ def _quest_definition_response(
id=bundle.definition.id,
title=bundle.definition.title,
description=bundle.definition.description,
icon=bundle.definition.icon,
icon=core_icons.normalize_icon_for_read(bundle.definition.icon),
is_active=bundle.definition.is_active,
skip_on_away=bundle.definition.skip_on_away,
rule=_rule_response(bundle.rule),
@@ -1496,7 +1566,7 @@ async def admin_create_quest_definition(
response_model=AdminOccurrencesPreviewResponse,
)
async def admin_preview_occurrences(
body: AdminOccurrencesPreviewRequest,
body: AdminOccurrencesPreviewRequest, request: Request
) -> AdminOccurrencesPreviewResponse:
"""Return the first ``count`` dates the supplied rule fires on.
@@ -1509,12 +1579,15 @@ async def admin_preview_occurrences(
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.
(:func:`_raise_preview_error`). The only database access is the
settings read behind the household clock.
"""
start_date = (
body.start_date
if body.start_date is not None
else _local_now().date().isoformat()
else (
await _household_today(request.app.state.db.database)
).isoformat()
)
try:
rule = _rule_from_request(body.rule)
@@ -1579,16 +1652,16 @@ async def admin_set_quest_definition_active(
body: AdminQuestDefinitionActiveRequest,
request: Request,
) -> AdminQuestDefinitionResponse:
"""Deactivate (or reactivate) a definition; it is NEVER deleted.
"""Deactivate (or reactivate) a definition; it is never deleted here.
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: ``is_active``
goes straight to
:func:`nestquest_core.quest_definitions.set_quest_definition_active`
— deactivation is the only removal path (the row, rule, assignees
and windows all survive, so completion history keeps its
references). An unknown definition id is mapped to 404
— the row, rule, assignees and windows all survive, so completion
history keeps its references (the hybrid DELETE route is the only
hard-delete path, and only for a definition without history). An unknown definition id is mapped to 404
(:func:`_raise_quest_definition_error`); the body model already
makes the core's real-bool check unreachable from this route.
"""
@@ -1602,6 +1675,44 @@ async def admin_set_quest_definition_active(
return _quest_definition_response(bundle)
@router.delete(
"/quest-definitions/{definition_id}",
summary="Delete a quest definition, or retire it if it has history",
response_model=AdminQuestDefinitionDeleteResponse,
)
async def admin_delete_quest_definition(
definition_id: int, request: Request
) -> AdminQuestDefinitionDeleteResponse:
"""Hard-delete a history-free definition; retire one with history.
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 id goes
straight to
:func:`nestquest_core.quest_definitions.delete_quest_definition`,
which decides delete-vs-retire atomically in the core. An unknown
definition id is mapped to 404
(:func:`_raise_quest_definition_error`).
"""
state: DatabaseState = request.app.state.db
try:
result = await core_quest_definitions.delete_quest_definition(
state.database, definition_id
)
except ValueError as error:
_raise_quest_definition_error(error)
return AdminQuestDefinitionDeleteResponse(
definition_id=result.definition_id,
deleted=result.deleted,
retired=result.retired,
definition=(
None
if result.definition is None
else _quest_definition_response(result.definition)
),
)
@router.get(
"/children/{child_id}/presence-patterns",
summary="List a child's repeating presence patterns",
@@ -1839,7 +1950,7 @@ async def admin_presence_override_consequence(
body.start_date,
body.end_date,
body.is_present,
today=_local_now().date(),
today=await _household_today(state.database),
)
)
except ValueError as error:
@@ -1926,7 +2037,7 @@ async def admin_uncomplete_instance(
"""
state: DatabaseState = request.app.state.db
database = state.database
now = _local_now()
_settings, now = await _household_clock(database)
sub = claims.get("sub")
try:
result = await core_completion.uncomplete_instance(
@@ -1992,7 +2103,7 @@ async def admin_regenerate(
"""
state: DatabaseState = request.app.state.db
database = state.database
today = _local_now().date()
today = await _household_today(state.database)
if body.scope == "household":
end_date = today + datetime.timedelta(
@@ -2077,8 +2188,9 @@ def _settings_response(
"""Serialize one core NestQuestSettings into the documented payload.
Explicit field-by-field (not ``dataclasses.asdict``) so the payload
stays pinned to the ten documented fields even if the core
dataclass later grows another one.
stays pinned to the eleven documented fields (plus the read-only
``effective_timezone``) even if the core dataclass later grows
another one.
"""
return AdminSettingsResponse(
horizon_days=settings.horizon_days,
@@ -2091,6 +2203,8 @@ def _settings_response(
afternoon_reminder_enabled=settings.afternoon_reminder_enabled,
end_of_day_report_enabled=settings.end_of_day_report_enabled,
celebration_enabled=settings.celebration_enabled,
timezone=settings.timezone,
effective_timezone=host_zone.effective_timezone(settings.timezone),
)
@@ -2122,7 +2236,7 @@ async def admin_history_query(
``ValueError`` is 422 (:func:`_raise_history_error`).
"""
state: DatabaseState = request.app.state.db
today = _local_now().date()
today = await _household_today(state.database)
try:
rows = await core_history.query_history(
state.database, filter, start, end, today=today
@@ -2165,7 +2279,7 @@ async def admin_history_csv(
JSON route's (:func:`_raise_history_error`).
"""
state: DatabaseState = request.app.state.db
today = _local_now().date()
today = await _household_today(state.database)
try:
csv_text = await core_history.export_history_csv(
state.database, filter, start, end, today=today
@@ -2188,7 +2302,7 @@ async def admin_history_csv(
response_model=AdminSettingsResponse,
)
async def admin_get_settings(request: Request) -> AdminSettingsResponse:
"""Return the current effective settings (all ten documented fields).
"""Return the current effective settings (all eleven documented fields).
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
@@ -2227,12 +2341,25 @@ async def admin_update_settings(
every value has validated — so a rejected update never changes the
stored settings. Every core ``ValueError`` is 422
(:func:`_raise_settings_error`); anything else re-raises.
The write holds the missed-sweep scheduler's ``settings_lock`` and
then tells it (:meth:`~api.scheduler.MissedSweepScheduler.settings_changed`),
so a saved ``timezone`` or ``day_rollover_time`` re-plans a sleeping
scheduler (an app without one — no lifespan — just writes).
"""
state: DatabaseState = request.app.state.db
scheduler = getattr(request.app.state, "sweep_scheduler", None)
try:
settings = await core_settings_store.update_settings(
state.database, changes
)
if scheduler is None:
settings = await core_settings_store.update_settings(
state.database, changes
)
else:
async with scheduler.settings_lock:
settings = await core_settings_store.update_settings(
state.database, changes
)
scheduler.settings_changed(settings)
except ValueError as error:
_raise_settings_error(error)
return _settings_response(settings)
@@ -2253,7 +2380,9 @@ async def admin_run_missed_sweep(
handler performs NO auth of its own). Thin adapter over the SAME
HA-free sweep policy the integration's rollover listener runs
(:mod:`nestquest_core.sweep`): ONE timezone-aware clock read
(:func:`_local_now`) pins the API host's local ``today``, and ONE
(:func:`_local_now`, re-expressed in the configured household
``timezone``; empty falls back to host local) pins the
household-local ``today``, and ONE
:func:`nestquest_core.sweep.run_missed_sweep` call does everything
else — the past-due open-instance query, the documented payload
build and the watermark update (a same-day rerun is an empty
@@ -2280,7 +2409,7 @@ async def admin_run_missed_sweep(
"""
state: DatabaseState = request.app.state.db
database = state.database
today = _local_now().date()
today = await _household_today(state.database)
try:
events = await core_sweep.run_missed_sweep(database, today=today)
except ValueError as error:
@@ -2309,8 +2438,9 @@ async def admin_snapshot(request: Request) -> AdminSnapshotResponse:
:class:`AdminSnapshotResponse`.
"""
state: DatabaseState = request.app.state.db
settings, now = await _household_clock(state.database)
snapshot = await core_snapshot.build_snapshot(
state.database, core_settings.NestQuestSettings(), _local_now()
state.database, settings, now
)
return AdminSnapshotResponse(
today_iso=snapshot.today_iso,
+34 -9
View File
@@ -8,9 +8,9 @@ this router inherits the check).
The snapshot route is a thin adapter over the bundled core: it performs
NO business logic. It reads the live database off ``app.state.db``,
resolves ONE timezone-aware ``now`` (the API host's local time — the
API is the household's "local" host now that the domain no longer lives
inside Home Assistant), calls the pure
resolves ONE timezone-aware ``now`` (the host clock re-expressed in the
household ``timezone`` the admin stored — the API host may run UTC
while the household does not), calls the pure
:func:`nestquest_core.snapshot.build_snapshot`, shapes each child's
instances through :func:`nestquest_core.snapshot.instance_payload` with
``include_missed=False`` (D-009: missed instances are OMITTED from the
@@ -21,6 +21,7 @@ panel payload), and returns the documented JSON shape:
{
"today_iso": "2026-09-22",
"cycle_day": 2,
"timezone": "Pacific/Auckland",
"children": [
{
"child_id": 1,
@@ -45,7 +46,11 @@ panel payload), and returns the documented JSON shape:
active children ``ORDER BY sort_order, id``); ``state`` is the
documented panel spelling ``open`` | ``completed`` (§1 of
design/ENTITIES-AND-SERVICES.md); ``cycle_day`` is today's 1-based day
in the first scheduled child's custody cycle (0 when no child has one).
in the first scheduled child's custody cycle (0 when no child has one);
``timezone`` is the effective application timezone — the zone the admin
stored, or the API host's own zone when none is (``""`` only when the
host's zone cannot be named; :mod:`api.host_zone`) — which the panel
cards format their visible date and clock in.
The same :class:`PanelSnapshotResponse` Pydantic model is the route's
``response_model``, so the OpenAPI document carries the shape too.
"""
@@ -63,9 +68,10 @@ from api.dependencies import require_panel_token
from api.nestquest_core import (
core_completion,
core_events,
core_settings,
core_settings_store,
core_snapshot,
)
from api import host_zone
from api import transitions as api_transitions
from api.sse import transition_event_stream
@@ -126,12 +132,14 @@ class PanelSnapshotResponse(BaseModel):
``today_iso`` anchors the payload's day; ``cycle_day`` is today's
1-based day in the first scheduled child's custody cycle (0 when no
child has a schedule); ``children`` is in the household's sort
order.
child has a schedule); ``timezone`` is the effective application
timezone (:func:`api.host_zone.effective_timezone`); ``children`` is
in the household's sort order.
"""
today_iso: str
cycle_day: int
timezone: str
children: list[PanelChildPayload]
@@ -178,6 +186,21 @@ def _local_now() -> datetime.datetime:
return datetime.datetime.now().astimezone()
async def _household_clock(
database: object,
) -> tuple[object, datetime.datetime]:
"""Return the stored settings and ONE household-local clock read.
The API host may run UTC while the household does not, so the one
:func:`_local_now` read is re-expressed in the stored settings'
``timezone`` (:meth:`NestQuestSettings.household_now`). The zone
comes from the stored settings ONLY — nothing the request carries
can change it.
"""
settings = await core_settings_store.load_settings(database)
return settings, settings.household_now(_local_now())
@router.get(
"/snapshot",
summary="Today's household snapshot",
@@ -196,12 +219,14 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
:class:`PanelSnapshotResponse`.
"""
state: DatabaseState = request.app.state.db
settings, now = await _household_clock(state.database)
snapshot = await core_snapshot.build_snapshot(
state.database, core_settings.NestQuestSettings(), _local_now()
state.database, settings, now
)
return PanelSnapshotResponse(
today_iso=snapshot.today_iso,
cycle_day=snapshot.cycle_day,
timezone=host_zone.effective_timezone(snapshot.timezone),
children=[
PanelChildPayload(
child_id=child.child_id,
@@ -282,7 +307,7 @@ async def panel_complete_instance(
detail="Quest instance not found for this child",
)
now = _local_now()
_settings, now = await _household_clock(database)
try:
result = await core_completion.complete_instance(
database,
+155 -23
View File
@@ -8,10 +8,33 @@ configured ``day_rollover_time`` — read fresh from the settings store
at the top of EVERY cycle, so an admin's settings change applies on
the next scheduling decision without a restart — then runs the
HA-free core's :func:`nestquest_core.sweep.run_missed_sweep` for the
API host's local date and publishes each returned
household-local date (the stored ``timezone`` setting; the API host's
local date when unset) and publishes each returned
``nestquest_quest_missed`` event on the app's ONE transition publisher
(the same SSE stream every route publishes through).
A household zone changed while the loop is asleep
must not leave it waiting for the OLD zone's rollover (a UTC host would
otherwise sweep at UTC midnight — 20:00 in New York — marking quests
missed hours early). :meth:`MissedSweepScheduler.settings_changed`
wakes the sleeping loop to re-plan whenever the zone or rollover time
it planned with has moved, and the loop re-reads the settings before
sweeping and re-plans instead if they no longer match its plan.
Guarantee: no sweep runs for a plan a committed settings write has
replaced. The settings writer — the admin ``PATCH /api/v1/admin/settings``
route, the only one — holds :attr:`MissedSweepScheduler.settings_lock`
across its write AND its :meth:`~MissedSweepScheduler.settings_changed`
call; the loop holds the same lock across its plan read (read, record
the plan, clear the wake flag) and across the pre-sweep re-read plus
the sweep itself. So a write commits either before a plan read
(which then sees it), after one (``settings_changed`` compares against
that exact plan and wakes the wait), or after the sweep — never between
a read and what the loop does with it. The lock is never held across
the wait, and the loop takes nothing else before it, so a waiting
writer cannot deadlock: it is delayed at most by one settings
read or one sweep.
All sweep policy lives in the core (the watermark, the
``[watermark, today)`` window, the no-completion-event rule): this
scheduler only decides WHEN. A same-night rerun — e.g. two workers,
@@ -43,19 +66,56 @@ LOGGER = logging.getLogger(__name__)
_RETRY_BACKOFF_SECONDS = 60.0
def _delay_seconds(target: datetime.time, now: datetime.datetime) -> float:
"""Seconds from ``now`` until the next occurrence of ``target``.
#: Upper bound on the minute-by-minute search past a nonexistent wall
#: time; real DST gaps are an hour, the largest ever skipped a day.
_MAX_GAP_MINUTES = 2 * 24 * 60
``target`` is a plain ``HH:MM`` time of day: the next wall-clock
occurrence strictly AFTER ``now`` (an equal time schedules for
tomorrow, so the just-run sweep is never instantly re-run).
def _resolve_wall_time(
day: datetime.date, target: datetime.time, zone: datetime.tzinfo
) -> datetime.datetime:
"""The aware instant ``target`` (``HH:MM``) names on ``day`` in ``zone``.
A nonexistent wall time (inside a spring-forward gap) resolves to
the first valid wall minute at/after it — the gap's end, e.g. 02:30
on a 02:00→03:00 day becomes 03:00. An ambiguous wall time (the
repeated fall-back hour) resolves to its FIRST occurrence
(``fold=0``).
"""
scheduled = now.replace(
hour=target.hour, minute=target.minute, second=0, microsecond=0
wall = datetime.datetime.combine(
day, datetime.time(target.hour, target.minute)
)
if scheduled <= now:
scheduled += datetime.timedelta(days=1)
return (scheduled - now).total_seconds()
for _ in range(_MAX_GAP_MINUTES):
candidate = wall.replace(tzinfo=zone)
round_trip = candidate.astimezone(datetime.timezone.utc).astimezone(
zone
)
if round_trip.replace(tzinfo=None) == wall:
return candidate
wall += datetime.timedelta(minutes=1)
raise ValueError(f"no valid wall time at/after {target} on {day}")
def _delay_seconds(target: datetime.time, now: datetime.datetime) -> float:
"""Real seconds from ``now`` until the next occurrence of ``target``.
``target`` is a plain ``HH:MM`` household time of day, resolved in
``now``'s zone by :func:`_resolve_wall_time` (nonexistent → first
valid instant after the gap; ambiguous → first occurrence). The
result is the next such instant strictly AFTER ``now`` (an equal
time schedules for tomorrow, so the just-run sweep is never
instantly re-run). Both instants are compared and subtracted in
UTC: same-``tzinfo`` aware arithmetic is wall-clock arithmetic and
would be an hour off across a DST transition.
"""
now_utc = now.astimezone(datetime.timezone.utc)
day = now.date()
while True:
scheduled = _resolve_wall_time(day, target, now.tzinfo)
scheduled_utc = scheduled.astimezone(datetime.timezone.utc)
if scheduled_utc > now_utc:
return (scheduled_utc - now_utc).total_seconds()
day += datetime.timedelta(days=1)
class MissedSweepScheduler:
@@ -86,6 +146,14 @@ class MissedSweepScheduler:
)
self._sleep = sleep if sleep is not None else asyncio.sleep
self._task: asyncio.Task | None = None
#: ``(timezone, day_rollover_time)`` the pending sweep was
#: planned with; ``None`` until the first plan.
self._planned: tuple[str, str] | None = None
#: Set by :meth:`settings_changed` to cut the current wait short.
self._wake = asyncio.Event()
#: Serialises settings writes with planning and sweeping (see
#: the module docstring's guarantee).
self.settings_lock = asyncio.Lock()
def start(self) -> None:
"""Start the background loop (idempotent: a running one stays)."""
@@ -104,6 +172,20 @@ class MissedSweepScheduler:
pass
self._task = None
def settings_changed(self, settings: object) -> None:
"""Re-plan the pending sweep if ``settings`` moved its rollover.
Called with freshly stored settings (e.g. an admin's zone
change), with :attr:`settings_lock` held across the write
and this call so the comparison is against the loop's current
plan, never a read still in flight. A differing ``timezone`` or
``day_rollover_time`` wakes the sleeping loop, which reloads the
settings and schedules the next sweep for the new household
rollover; an unchanged plan is left sleeping.
"""
if (settings.timezone, settings.day_rollover_time) != self._planned:
self._wake.set()
@property
def running(self) -> bool:
"""Whether the background loop task is alive."""
@@ -113,12 +195,18 @@ class MissedSweepScheduler:
"""Schedule → sweep → repeat, forever (until cancelled)."""
while True:
try:
settings = await core_settings_store.load_settings(
self._database
)
rollover = datetime.time.fromisoformat(
settings.day_rollover_time
)
# Under the lock no settings write can commit between this read
# and the recorded plan, so any later one wakes the wait.
async with self.settings_lock:
self._wake.clear()
settings = await core_settings_store.load_settings(
self._database
)
rollover = datetime.time.fromisoformat(
settings.day_rollover_time
)
plan = (settings.timezone, settings.day_rollover_time)
self._planned = plan
except Exception:
# Not silent: the failure is logged and retried after
# the backoff, so one bad settings document or a
@@ -130,19 +218,40 @@ class MissedSweepScheduler:
)
await self._sleep(_RETRY_BACKOFF_SECONDS)
continue
delay = _delay_seconds(rollover, self._clock())
# The host may run UTC: the rollover is a household wall-clock
# time, so the clock read is re-expressed in the stored zone.
delay = _delay_seconds(
rollover, settings.household_now(self._clock())
)
LOGGER.info(
"NestQuest missed sweep scheduled for %s (%.0fs from now)",
settings.day_rollover_time,
delay,
)
await self._sleep(delay)
try:
await self._run_once(self._clock().date())
if not await self._sleep_unless_woken(delay):
continue
# Re-read before sweeping: a zone or rollover change the
# wake-up missed means this is not the household's
# rollover any more, so re-plan instead of sweeping. The
# lock keeps a settings write from committing between this
# check and the sweep.
async with self.settings_lock:
settings = await core_settings_store.load_settings(
self._database
)
if (
settings.timezone,
settings.day_rollover_time,
) != plan:
continue
await self._run_once(
settings.household_now(self._clock()).date()
)
except Exception:
# Not silent either: the sweep error is logged and the
# loop backs off before the next cycle, whose watermark
# re-read makes an already-swept day a no-op.
# Not silent either: a failed wait or sweep is logged and
# the loop backs off before the next cycle, whose
# watermark re-read makes an already-swept day a no-op.
LOGGER.exception(
"NestQuest scheduled missed sweep failed; retrying in "
"%gs",
@@ -150,6 +259,29 @@ class MissedSweepScheduler:
)
await self._sleep(_RETRY_BACKOFF_SECONDS)
async def _sleep_unless_woken(self, delay: float) -> bool:
"""Wait ``delay``; ``False`` if :meth:`settings_changed` woke it.
Both children are cancelled AND awaited before returning (or
before a cancellation propagates), so none outlives :meth:`stop`;
a sleep that failed re-raises its exception here for the loop's
retry policy.
"""
sleeper = asyncio.ensure_future(self._sleep(delay))
waker = asyncio.ensure_future(self._wake.wait())
try:
await asyncio.wait(
{sleeper, waker}, return_when=asyncio.FIRST_COMPLETED
)
finally:
slept = sleeper.done()
sleeper.cancel()
waker.cancel()
await asyncio.gather(sleeper, waker, return_exceptions=True)
if slept:
sleeper.result()
return slept
async def _run_once(self, today: datetime.date) -> None:
"""Run ONE sweep for ``today`` and publish what it built.
+6 -2
View File
@@ -217,6 +217,10 @@ class NestQuestApiClient:
self._session = session
self._timeout_seconds = float(timeout_seconds)
def _headers(self) -> dict[str, str]:
"""The Bearer credential every panel-plane request carries."""
return {"Authorization": f"Bearer {self._panel_token}"}
async def get_snapshot(self) -> dict[str, Any]:
"""Fetch and parse today's household snapshot.
@@ -275,7 +279,7 @@ class NestQuestApiClient:
cleanly and closes the underlying response in every path.
"""
url = f"{self._base_url}{EVENTS_PATH}"
headers = {"Authorization": f"Bearer {self._panel_token}"}
headers = self._headers()
# 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.
@@ -379,7 +383,7 @@ class NestQuestApiClient:
raises typed too, carrying the status it arrived with.
"""
url = f"{self._base_url}{path}"
headers = {"Authorization": f"Bearer {self._panel_token}"}
headers = self._headers()
try:
async with asyncio.timeout(self._timeout_seconds):
async with self._session.request(
+1 -1
View File
@@ -9,7 +9,7 @@ snapshot (the same ChildDaySnapshot the day sensors read):
celebration automation must never fire on an empty day (the
day-complete event carries the same rule, Feature 10 task 7).
- ``binary_sensor.nestquest_<child>_present_today`` — ON when the
presence engine resolves the child present on the HA-local date
presence engine resolves the child present on the household-local date
(schedules and overrides, D-004 anchor-date arithmetic).
Naming and registry identity follow the day sensors' contract: names
@@ -111,6 +111,9 @@ def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot:
through verbatim;
- ``present`` and ``next_present`` pass through verbatim (the API
resolved them through the same presence engine server-side);
- ``timezone`` (the API's effective application timezone) passes
through verbatim for the cards' date and clock; an API older than
the field omits it, which reads as ``""`` (the cards' fallback);
- the instance list is rebuilt IN ORDER, so the panel payload the
sensors emit matches the route's order byte for byte.
@@ -161,6 +164,7 @@ def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot:
today_iso=payload["today_iso"],
children=tuple(children),
cycle_day=payload["cycle_day"],
timezone=payload.get("timezone", ""),
)
+11 -11
View File
@@ -10,7 +10,7 @@ un-completing (no-op if already open) is :func:`uncomplete_instance`.
Instance current state is derived from the latest event via
:func:`instance_state` (``open`` / ``done`` / ``missed``); there is
no status column. ``missed`` is derived for past-due open instances
(``due_date`` before HA-local ``today``) and is never stored as an
(``due_date`` before household-local ``today``) and is never stored as an
event_type.
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
@@ -71,10 +71,10 @@ def _completion_moment(
now: datetime.datetime | None,
today: datetime.date | None,
) -> tuple[datetime.date, datetime.time | None]:
"""Return the HA-local completion date and optional wall-clock time.
"""Return the household-local completion date and optional wall-clock time.
``today`` is the HA-local calendar date when threaded (Feature 07
style). ``now`` is a timezone-aware HA-local datetime; its wall
``today`` is the household-local calendar date when threaded (Feature 07
style). ``now`` is a timezone-aware household-local datetime; its wall
clock is used as-is and is never converted to UTC. When ``today``
is omitted the date comes from ``now``. When both are omitted the
host calendar date is the Feature 07 fallback — never the UTC
@@ -98,7 +98,7 @@ def _completion_moment(
def _resolve_today(today: datetime.date | None) -> datetime.date:
"""Return the threaded HA-local date, or the host calendar date."""
"""Return the threaded household-local date, or the host calendar date."""
if today is None:
return datetime.date.today()
return today
@@ -138,7 +138,7 @@ def derive_state(
Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or
``missed`` from the latest event plus the due date (``missed`` is
derived, never an event_type). ``today`` is the caller-resolved
HA-local date.
household-local date.
"""
return _derived_state(instance, latest, today)
@@ -345,7 +345,7 @@ async def instance_state(
"""Return ``open``, ``done``, or ``missed`` from the latest event.
No events, or a latest event of uncompleted, is ``open`` unless
``due_date`` is before ``today`` (HA-local, threaded), in which
``due_date`` is before ``today`` (household-local, threaded), in which
case it is ``missed``. A latest event of completed is ``done``
even when past due. ``missed`` is never stored as an event_type.
Unknown ``instance_id`` raises ValueError naming the field.
@@ -414,9 +414,9 @@ async def complete_instance(
latest-event, and the append run under one connection lock so a
concurrent complete cannot double-append.
``was_on_time`` is computed from the HA-local completion moment
``was_on_time`` is computed from the household-local completion moment
(threaded ``now`` / ``today``) against the instance ``due_date``
and optional ``due_time``. ``now`` is the timezone-aware HA-local
and optional ``due_time``. ``now`` is the timezone-aware household-local
datetime; its wall clock is never converted to UTC. Same-day
``due_time`` comparison requires ``now``.
"""
@@ -471,7 +471,7 @@ async def uncomplete_instance(
"""Append an uncompleted event unless the instance is already open.
Returns the derived state ``open``, or ``missed`` when the instance
is past due (``due_date`` before HA-local ``today``); ``appended``
is past due (``due_date`` before household-local ``today``); ``appended``
reports whether THIS call appended (False on an already-open
no-op). Unknown ``instance_id`` raises ValueError naming the
field. Existence, latest-event, and the append run under one
@@ -529,7 +529,7 @@ async def list_missed_for_child(
"""Return the child's missed instances due in the closed date range.
Missed means the latest event state is open and ``due_date`` is
before ``today`` (HA-local, threaded). Uses
before ``today`` (household-local, threaded). Uses
:meth:`QuestInstancesDao.list_by_date_range` plus derived state.
Never appends a missed event. Instances and latest events are
read inside the connection lock so the list is one snapshot.
+11
View File
@@ -112,6 +112,17 @@ CONF_AFTERNOON_REMINDER_ENABLED = "afternoon_reminder_enabled"
CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
CONF_CELEBRATION_ENABLED = "celebration_enabled"
DEFAULT_AUTOMATION_ENABLED = True
# The household's IANA time zone (e.g. ``America/New_York``) the API
# service reads its clock in. The empty string is the API host's own
# local time. Only an admin sets it (manually, from the admin app);
# nothing sets it automatically. The integration relays the API's
# effective application timezone (the coordinator carries it and the
# household sensor publishes it for the panel cards) and never derives
# the application clock from Home Assistant's configuration; the Home
# Assistant / browser zone applies only when the API publishes no zone
# (e.g. an older API).
CONF_TIMEZONE = "timezone"
DEFAULT_TIMEZONE = ""
# Panel-plane API client (Feature 18): the integration reaches the
# NestQuest API service (Feature 16) over HTTP instead of the database.
@@ -68,7 +68,7 @@ def _resolve_today(today: datetime.date | None) -> datetime.date:
around midnight, so when the caller has already resolved the local
date (``today``) that anchor is authoritative; ``None`` falls back to
the host clock (:func:`_today`), which is exactly the previous
behavior for callers with no HA-local date.
behavior for callers with no household-local date.
"""
return _today() if today is None else today
@@ -225,7 +225,7 @@ class QuestInstancesDao:
- ``due_date`` must be TODAY or later: instances are never
generated in the past (Feature 07). The caller's
materialization horizon is future-dated by definition.
``today`` optionally pins the resolved HA-local date the
``today`` optionally pins the resolved household-local date the
guard compares against (falling back to the host clock when
omitted) so a household time zone behind the host around
midnight is not misread as "past".
@@ -248,7 +248,7 @@ class QuestInstancesDao:
# check runs after the lock is acquired, because a caller can
# wait on the lock across midnight — a date that was "today"
# at call time may be "yesterday" by the time the INSERT runs.
# A caller-supplied ``today`` (HA-local) is used for BOTH
# A caller-supplied ``today`` (household-local) is used for BOTH
# checks and never re-read, since it is already the anchor the
# batch runs against.
today_date = _resolve_today(today)
@@ -257,7 +257,7 @@ class QuestInstancesDao:
async with self._database.transaction():
# Re-read "today" under the lock: this is the date the
# insert actually executes on, so the no-past rule
# holds across midnight rollovers. A pinned HA-local
# holds across midnight rollovers. A pinned household-local
# anchor is exempt from the re-read (see above).
if today is None:
today_date = _today()
@@ -364,7 +364,7 @@ class QuestInstancesDao:
rather than raising, so the rest of the batch is unaffected. The
no-past rule (instances are never generated in the past) is
preserved from :meth:`upsert` and still raises ValueError; ``today``
optionally pins the HA-local anchor it compares against. An
optionally pins the household-local anchor it compares against. An
instance that ALREADY has a completion event is also skipped
(returns ``None``) rather than raised: it is immutable, so the
walk leaves it untouched instead of failing the batch — the
@@ -504,7 +504,7 @@ class QuestInstancesDao:
append-only contract: an instance that was ever completed is
never reported missed, even when later reversed — history keeps
both rows and the sweep never mutates any). The watermark is
the HA-local date the sweep last ran, and the window is
the household-local date the sweep last ran, and the window is
``[watermark, today)``: the nightly run entering day D+1 sweeps
exactly day D's instances, so every day's quests are announced
once — at the midnight after their due date — and a same-night
@@ -544,9 +544,10 @@ class QuestInstancesDao:
rolling horizon from now), so a caller cannot use this to
rewrite past open instances either — the day-rollover missed
sweep (Feature 11) handles past-due instances instead. ``today``
optionally pins the HA-local anchor the clamp compares against
(falling back to the host clock when omitted). Any instance with
a completion event is never touched, regardless of its date.
optionally pins the household-local anchor the clamp compares
against (falling back to the host clock when omitted). Any
instance with a completion event is never touched, regardless of
its date.
Returns the number deleted.
"""
_validate_date(cutoff_date, "cutoff_date")
@@ -581,8 +582,9 @@ class QuestInstancesDao:
one transaction — an instance that gains a completion event
mid-flight cannot be deleted. The cutoff cannot be backdated below
today, for the same reason as the definition-scoped method (see its
docstring). ``today`` optionally pins the HA-local anchor the clamp
compares against (falling back to the host clock when omitted).
docstring). ``today`` optionally pins the household-local anchor
the clamp compares against (falling back to the host clock when
omitted).
Any instance with a completion event is never touched, regardless
of its date.
+86 -18
View File
@@ -13,8 +13,12 @@ Guardrail mapping:
- ``schedule_rules`` deletion is delete-if-unreferenced: a rule still
referenced by any quest definition is rejected (the done-condition),
not cascaded.
- ``quest_definitions`` has no delete path at all: definitions are
deactivated, never hard-deleted, and history must survive.
- ``quest_definitions`` has exactly one delete path,
:meth:`QuestDefinitionsDao.delete_if_no_history` (owner decision
2026-09-26): a definition with NO completion event on any of its
instances is hard-deleted with its instances, assignees, windows and
(if unreferenced) rule; a definition WITH history is never deleted —
it is retired by deactivation, so history always survives.
- Assignment is multi-assignee (D-008): :meth:`add_assignee` and
:meth:`remove_assignee` change future instances only — the DAO writes
the assignee table and nothing else; assignment edits never rewrite
@@ -25,8 +29,8 @@ Guardrail mapping:
:meth:`QuestDefinitionsDao.add_assignee` (active child); no
permission checks here, that is the Feature 09 gate's job.
Concurrency: ``delete_rule_if_unreferenced`` and the assignee
mutations perform their checks and writes inside one transaction under
Concurrency: ``delete_rule_if_unreferenced``,
``delete_if_no_history`` and the assignee mutations perform their checks and writes inside one transaction under
the connection-scoped lock shared with :mod:`.dao_children`, so a
definition referencing the rule (or an assignee appearing) cannot slip
in between check and write on this connection.
@@ -608,26 +612,42 @@ class ScheduleRulesDao:
"""
async with _connection_lock(self._database):
async with self._database.transaction():
row = await self._database.fetch_one(
"SELECT 1 FROM quest_definitions "
"WHERE schedule_rule_id = ? LIMIT 1",
(rule_id,),
return await _delete_rule_if_unreferenced_in_transaction(
self._database, rule_id
)
if row is not None:
return False
result = await self._database.execute(
"DELETE FROM schedule_rules WHERE id = ?",
(rule_id,),
)
return result.rowcount > 0
async def _delete_rule_if_unreferenced_in_transaction(
database: NestQuestDatabase, rule_id: int
) -> bool:
"""Delete-if-unreferenced body; the CALLER holds lock + transaction.
Shared by :meth:`ScheduleRulesDao.delete_if_unreferenced` and
:meth:`QuestDefinitionsDao.delete_if_no_history`, so the rule's
reference check runs in whichever transaction removed the last
definition that could reference it.
"""
row = await database.fetch_one(
"SELECT 1 FROM quest_definitions "
"WHERE schedule_rule_id = ? LIMIT 1",
(rule_id,),
)
if row is not None:
return False
result = await database.execute(
"DELETE FROM schedule_rules WHERE id = ?",
(rule_id,),
)
return result.rowcount > 0
class QuestDefinitionsDao:
"""Typed async access to ``quest_definitions`` and its assignees.
No delete method exists: definitions are deactivated via
:meth:`set_active`, never hard-deleted, so completion history keeps
its references (feature guardrail). Assignment is multi-assignee
The only delete method is :meth:`delete_if_no_history`: a
definition with completion history is never hard-deleted (it is
deactivated via :meth:`set_active` instead), so completion history
always keeps its references (feature guardrail, D-005). Assignment is multi-assignee
(D-008): the definition row carries no child; assignees live in
``quest_definition_assignees`` and are managed explicitly so the
operations stay separately loggable and permission-gateable.
@@ -1140,6 +1160,54 @@ class QuestDefinitionsDao:
)
return result.rowcount
async def delete_if_no_history(self, definition_id: int) -> bool | None:
"""Hard-delete a definition that has NO completion history.
Returns None when the definition does not exist, False when any
of its instances has a completion event (nothing is touched —
the caller retires it by deactivation instead), and True when it
was deleted: its ``quest_instances``, assignee and window rows,
the definition row, and its schedule rule if no other definition
references it.
The history check and every DELETE run inside ONE transaction
under the connection lock, so a completion recorded concurrently
cannot land between the check and the delete (D-005: an instance
with a completion event is never deleted).
"""
async with _connection_lock(self._database):
async with self._database.transaction():
row = await self._database.fetch_one(
"SELECT schedule_rule_id FROM quest_definitions "
"WHERE id = ?",
(definition_id,),
)
if row is None:
return None
schedule_rule_id = row[0]
history = await self._database.fetch_one(
"SELECT 1 FROM completion_events "
"JOIN quest_instances "
"ON quest_instances.id = completion_events.instance_id "
"WHERE quest_instances.definition_id = ? LIMIT 1",
(definition_id,),
)
if history is not None:
return False
for statement in (
"DELETE FROM quest_instances WHERE definition_id = ?",
"DELETE FROM quest_definition_assignees "
"WHERE definition_id = ?",
"DELETE FROM quest_definition_windows "
"WHERE definition_id = ?",
"DELETE FROM quest_definitions WHERE id = ?",
):
await self._database.execute(statement, (definition_id,))
await _delete_rule_if_unreferenced_in_transaction(
self._database, schedule_rule_id
)
return True
async def add_assignee(
self, definition_id: int, child_id: int
) -> list[ChildRecord]:
+9 -8
View File
@@ -39,10 +39,11 @@ ISO-8601 timestamp. Completion payloads additionally carry
``was_on_time``. No other personal data: names and titles are the
household's own records, and nothing beyond them leaks.
Timezone policy: the day-complete check needs the HA-local "today",
which the caller passes in as a plain :class:`datetime.date`. Nothing
here reads ``hass.config.time_zone`` — the integration owns HA
timezone, the core owns the calendar arithmetic.
Timezone policy: the day-complete check needs the household-local
"today", which the caller passes in as a plain :class:`datetime.date`.
Nothing here reads a timezone — the API owns the clock (the
admin-stored application timezone, or the API host's local zone when
none is stored), the core owns the calendar arithmetic.
"""
from __future__ import annotations
@@ -147,10 +148,10 @@ async def build_child_day_complete_event(
completion of TODAY'S instance evaluates it: completing a
back-dated or future instance must not announce a cleared day.
``today`` is the HA-local calendar date the day-complete rule is
evaluated against; the integration reads it from
``hass.config.time_zone`` and passes it in, so this module never
touches HA config.
``today`` is the household-local calendar date the day-complete
rule is evaluated against; the API resolves it in the application
timezone and passes it in, so this module never reads a clock or a
timezone of its own.
"""
# Only a completion of TODAY'S instance evaluates the day-complete
# rule: completing a back-dated or future instance must not
+2 -2
View File
@@ -153,7 +153,7 @@ def _validate_range(start_date: str, end_date: str) -> tuple[str, str]:
def _resolve_today(today: datetime.date | None) -> datetime.date:
"""Return the threaded HA-local date, or the host calendar date."""
"""Return the threaded household-local date, or the host calendar date."""
if today is None:
return datetime.date.today()
return today
@@ -283,7 +283,7 @@ async def query_history(
Rows are ordered deterministically by ``due_date``, then instance,
then event id (the per-instance append order, so a completion and
its reversal keep their order). ``today`` pins the HA-local date
its reversal keep their order). ``today`` pins the household-local date
the missed derivation compares due dates against (the host calendar
date when omitted). The whole read runs under the task-reentrant
connection lock — :func:`list_missed_for_child` re-enters it — so
+119
View File
@@ -0,0 +1,119 @@
"""Namespaced quest-definition icon keys.
A definition's ``icon`` is stored as ``<namespace>:<name>`` where the
namespace is one of :data:`ICON_NAMESPACES`:
- ``lucide:<name>`` / ``fa:<name>`` — ``<name>`` is a lowercase kebab
name (ASCII ``[a-z0-9-]``, not starting or ending with ``-``).
- ``emoji:<payload>`` — a short emoji string of at most
:data:`EMOJI_MAX_CODE_POINTS` code points, with no whitespace (JS
``\\s``, so including U+FEFF), no control characters and none of
``<``, ``>``, ``&``.
Rows written before namespacing hold a bare Lucide name (``dog``).
:func:`validate_icon` (writes) normalizes such a legacy value to
``lucide:<name>``; :func:`normalize_icon_for_read` (reads) does the same
leniently so legacy rows render without a data migration.
Nothing here imports :mod:`homeassistant`.
"""
from __future__ import annotations
import re
import unicodedata
ICON_NAMESPACE_LUCIDE = "lucide"
ICON_NAMESPACE_FA = "fa"
ICON_NAMESPACE_EMOJI = "emoji"
ICON_NAMESPACES = frozenset(
{ICON_NAMESPACE_LUCIDE, ICON_NAMESPACE_FA, ICON_NAMESPACE_EMOJI}
)
#: Upper bound on an emoji payload's length in code points — enough for
#: ZWJ sequences such as a family emoji, too short for arbitrary text.
EMOJI_MAX_CODE_POINTS = 8
_KEBAB_NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?")
# U+FEFF (BOM / zero-width no-break space) is neither str.isspace() nor
# ``Cc`` in Python, but JS ``\s`` matches it, so it is listed here to keep
# this rule identical to the frontends' isEmojiPayload (/[\s<>&]|\p{Cc}/u).
# Other format characters, notably the ZWJ U+200D that emoji sequences
# need, stay valid in both runtimes.
_EMOJI_FORBIDDEN = frozenset("<>&\ufeff")
def _is_kebab_name(value: str) -> bool:
return _KEBAB_NAME.fullmatch(value) is not None
def _is_valid_emoji_payload(value: str) -> bool:
if not value or len(value) > EMOJI_MAX_CODE_POINTS:
return False
return not any(
char.isspace()
or char in _EMOJI_FORBIDDEN
or unicodedata.category(char) == "Cc"
for char in value
)
def validate_icon(value: object) -> str | None:
"""Return the canonical namespaced icon key, or raise naming ``icon``.
``None`` or an empty/whitespace-only string returns ``None`` (the
icon is cleared). Otherwise the raw value is validated untrimmed, so
edge whitespace or control characters are rejected rather than
silently stripped. A value with no ``:`` is a legacy bare Lucide
name and is stored as ``lucide:<value>``. An unknown namespace, an
empty or malformed name, or an invalid emoji payload raises
ValueError.
"""
if value is None:
return None
if not isinstance(value, str):
raise ValueError(f"icon must be a string, got {value!r}")
if not value.strip():
return None
namespace, separator, name = value.partition(":")
if not separator:
if not _is_kebab_name(value):
raise ValueError(
f"icon must be a lowercase kebab name, got {value!r}"
)
return f"{ICON_NAMESPACE_LUCIDE}:{value}"
if namespace not in ICON_NAMESPACES:
raise ValueError(
f"icon has an unknown namespace {namespace!r}; expected one of "
f"{sorted(ICON_NAMESPACES)}"
)
if namespace == ICON_NAMESPACE_EMOJI:
if not _is_valid_emoji_payload(name):
raise ValueError(
f"icon emoji must be 1-{EMOJI_MAX_CODE_POINTS} code points "
f"with no whitespace, control characters or markup, "
f"got {value!r}"
)
elif not _is_kebab_name(name):
raise ValueError(
f"icon {namespace} name must be a lowercase kebab name, "
f"got {value!r}"
)
return value
def normalize_icon_for_read(value: str | None) -> str | None:
"""Return a stored icon in namespaced form for output.
Lenient: ``None``/empty returns ``None``, a recognised namespace is
returned unchanged, and anything else becomes ``lucide:<value>`` so
legacy rows render (an unknown name falls through to the frontend's
fallback icon rather than erroring).
"""
if not value or not value.strip():
return None
namespace, separator, _ = value.partition(":")
if separator and namespace in ICON_NAMESPACES:
return value
return f"{ICON_NAMESPACE_LUCIDE}:{value}"
@@ -255,12 +255,12 @@ async def materialize(
after a presence change) cannot touch any unrelated child's rows.
When omitted (or None) the walk covers every assignee as before.
``today`` optionally pins the resolved HA-local date the walk's
``today`` optionally pins the resolved household-local date the walk's
no-past guard compares against; it is threaded into every
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` write. When
omitted the host clock (``datetime.date.today``) is used, which is the
historical default. A caller that computed the horizon in Home
Assistant's configured time zone MUST pass that same date here so a
historical default. A caller that computed the horizon in the
household's application time zone MUST pass that same date here so a
time zone behind the host around midnight is not rejected as "past".
``start_date`` is clamped up to the resolved "today" (the same
@@ -298,8 +298,8 @@ async def materialize(
start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.datetime.strptime(end_date, "%Y-%m-%d").date()
# Clamp a past start up to today (the HA-local anchor when the caller
# pinned one, else the host clock) so the walk can never emit a
# Clamp a past start up to today (the household-local anchor when the
# caller pinned one, else the host clock) so the walk can never emit a
# past-dated instance; the DAO's no-past guard remains the backstop.
# A range entirely in the past therefore yields zero instances.
today_date = _resolve_today(today)
@@ -345,7 +345,7 @@ async def regenerate_for_definition(
:func:`~.dao_instances._today` — so the delete cutoff and the
re-materialization range share one anchor, and the walk's
``upsert_if_valid`` no-past guard never rejects the regenerated
range. ``today`` optionally pins a caller-resolved HA-local date
range. ``today`` optionally pins a caller-resolved household-local date
(threaded into both the delete cutoff and the re-materialization)
so a household time zone behind the host around midnight stays
consistent. ``horizon_days`` sizes the re-materialization window;
@@ -413,9 +413,9 @@ async def regenerate_for_child(
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`),
so the delete cutoff and the re-materialization window share ONE
execution-day anchor even when the call is queued across midnight.
``today`` optionally pins a caller-resolved HA-local date, threaded
``today`` optionally pins a caller-resolved household-local date, threaded
into the delete cutoff and the re-materialization for the same
host-vs-HA time-zone consistency. ``horizon_days`` sizes the
host-vs-household time-zone consistency. ``horizon_days`` sizes the
re-materialization window; when omitted it falls back to
:data:`~.const.DEFAULT_HORIZON_DAYS`. A bool, non-int, or sub-1 value
raises ValueError BEFORE any delete. Returns the number of instances
@@ -7,7 +7,9 @@ all-or-nothing create/edit here, and never touch the DAO SQL directly.
: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.
:func:`set_quest_definition_active` the deactivate/reactivate path,
and :func:`delete_quest_definition` the hybrid admin delete (hard-delete
when there is no completion history, retire by deactivation otherwise).
There is no permission check here (that is Feature 09).
Validation policy mirrors :mod:`.children`:
@@ -59,6 +61,7 @@ from .dao_rules import (
schedule_rule_to_storage,
)
from .db import NestQuestDatabase
from .icons import validate_icon
from .materialize import regenerate_for_definition
from .recurrence import ScheduleRule, occurs_on
@@ -79,6 +82,21 @@ class CreatedQuestDefinition:
windows: list[QuestDefinitionWindowRecord]
@dataclass(frozen=True)
class DeletedQuestDefinition:
"""What :func:`delete_quest_definition` actually did.
Exactly one of ``deleted`` / ``retired`` is True. ``definition`` is
the retired (deactivated) definition's updated view, or None when
the definition was hard-deleted and no longer exists.
"""
definition_id: int
deleted: bool
retired: bool
definition: CreatedQuestDefinition | None
def _now_stamp() -> str:
"""Return the module's strict UTC second-precision stamp."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
@@ -248,7 +266,7 @@ async def create_quest_definition(
assignee_ids = _validate_assignee_ids(assignee_child_ids)
window_specs = _normalize_windows(windows)
description_value = _validate_text(description, "description")
icon_value = _validate_text(icon, "icon")
icon_value = validate_icon(icon)
skip_on_away_value = _validate_skip_on_away(skip_on_away)
storage = schedule_rule_to_storage(rule)
@@ -327,7 +345,7 @@ async def edit_quest_definition(
future instances only; no ``quest_instances`` or
``completion_events`` row is touched.
``today`` optionally pins the caller-resolved HA-local date the
``today`` optionally pins the caller-resolved household-local date the
regeneration's no-past guard and horizon are anchored to (threaded
into :func:`~.materialize.regenerate_for_definition`); it is a plain
``datetime.date`` with no HA import here — the Feature 09 service
@@ -351,7 +369,7 @@ async def edit_quest_definition(
icon_value: str | None | object = _UNSET
if icon is not _UNSET:
icon_value = _validate_text(icon, "icon")
icon_value = validate_icon(icon)
rule_storage: ScheduleRuleStorage | object = _UNSET
if rule is not _UNSET:
@@ -431,7 +449,7 @@ async def assign_child(
``quest_definition_assignees`` link is written; existing instances
and completion history are never touched.
``today`` optionally pins the caller-resolved HA-local date threaded
``today`` optionally pins the caller-resolved household-local date threaded
into the assignment's regeneration (see :func:`edit_quest_definition`).
``horizon_days`` optionally sizes the regeneration's re-materialization
window the same way.
@@ -473,7 +491,7 @@ async def unassign_child(
argument. Only the ``quest_definition_assignees`` link is written;
existing instances and completion history are never touched.
``today`` optionally pins the caller-resolved HA-local date threaded
``today`` optionally pins the caller-resolved household-local date threaded
into the unassignment's regeneration (see :func:`edit_quest_definition`).
``horizon_days`` optionally sizes the regeneration's re-materialization
window the same way.
@@ -503,12 +521,19 @@ async def set_quest_definition_active(
) -> CreatedQuestDefinition:
"""Deactivate or reactivate a definition; returns the updated view.
Flipping the flag is the only removal path (Feature 06 guardrail):
Flipping the flag is the only removal path for a definition with
completion history (Feature 06 guardrail; a history-free definition
may instead be hard-deleted by :func:`delete_quest_definition`):
a deactivated definition stops future instance generation but its
row, schedule rule, assignees and windows all survive, and existing
instances plus completion history are never touched. Reactivation
re-enables future generation; generating or removing instances is
Feature 07 and does not happen here.
row, schedule rule, assignees and windows all survive, and its
durable completion history (the append-only ``completion_events``)
is never touched. Like every Feature 06 config change, the flip is
followed by :func:`~.materialize.regenerate_for_definition`: the
definition's future (today onward) UNCOMPLETED instances are cleared
— they are rolling, regenerable rows — and, while the definition is
inactive, none are re-materialized. Completed and past instances
are kept. Reactivation re-enables generation, and the same
regeneration re-materializes the rolling horizon.
``definition_id`` must be a plain int (bools and floats rejected,
since SQLite would bind ``True`` onto definition 1) and
@@ -523,7 +548,7 @@ async def set_quest_definition_active(
connection-scoped lock, so the returned bundle is a consistent
snapshot that a concurrent mutation cannot race.
``today`` optionally pins the caller-resolved HA-local date threaded
``today`` optionally pins the caller-resolved household-local date threaded
into the activation change's regeneration (see
:func:`edit_quest_definition`). ``horizon_days`` optionally sizes the
regeneration's re-materialization window the same way.
@@ -561,6 +586,68 @@ async def set_quest_definition_active(
)
async def delete_quest_definition(
database: NestQuestDatabase,
definition_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> DeletedQuestDefinition:
"""Delete a definition if it has no history; otherwise retire it.
Owner decision (2026-09-26): "delete if there's no history,
retire/deactivate if there is". When NO instance of the definition
has a completion event, the definition, its instances, assignees,
windows and (if no other definition references it) its schedule
rule are hard-deleted in ONE transaction by
:meth:`~.dao_rules.QuestDefinitionsDao.delete_if_no_history` — the
history check is inside that transaction, so a completion recorded
concurrently can never be deleted (D-005). When history exists,
the definition is retired through :func:`set_quest_definition_active`
(``False``). Retirement preserves the definition row, schedule
rule, assignees, windows and ALL durable completion history. Like
any deactivation it regenerates, so future UNCOMPLETED instances are
cleared (they are rolling, regenerable rows) and none are recreated
while the definition is inactive — the retired task stops generating
and no longer shows open work. Durable instance history lives only
in the append-only ``completion_events``.
``definition_id`` must be a plain int (bools and floats rejected).
Raises ValueError naming the field when the id is malformed or the
definition does not exist. ``today`` / ``horizon_days`` are threaded
into the retire path's regeneration, as in
:func:`set_quest_definition_active`.
"""
_validate_definition_id(definition_id)
deleted = await QuestDefinitionsDao(database).delete_if_no_history(
definition_id
)
if deleted is None:
raise ValueError(
f"definition_id: quest definition {definition_id} does not exist"
)
if deleted:
return DeletedQuestDefinition(
definition_id=definition_id,
deleted=True,
retired=False,
definition=None,
)
retired = await set_quest_definition_active(
database,
definition_id,
False,
today=today,
horizon_days=horizon_days,
)
return DeletedQuestDefinition(
definition_id=definition_id,
deleted=False,
retired=True,
definition=retired,
)
def _bundle_snapshot(
snapshot: QuestDefinitionSnapshot,
) -> CreatedQuestDefinition:
+45 -1
View File
@@ -35,6 +35,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any, Mapping
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from .const import (
CONF_AFTERNOON_REMINDER_ENABLED,
@@ -47,12 +48,14 @@ from .const import (
CONF_MORNING_SUMMARY_ENABLED,
CONF_MORNING_SUMMARY_TIME,
CONF_NOTIFY_TARGET,
CONF_TIMEZONE,
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_TIMEZONE,
LOGGER,
TIME_PATTERN,
)
@@ -153,6 +156,29 @@ def _resolve_optional_str(value: Any) -> str:
return value.strip()
def _resolve_timezone(value: Any) -> str:
"""Validate a household time zone and return it.
The empty string means "the API host's local time" (the historical
behaviour); anything else must be an IANA key :class:`ZoneInfo`
resolves (e.g. ``America/New_York``). A non-str (including
``None``) is rejected; the ``None``-as-absent mapping lives ONLY in
:meth:`NestQuestSettings.from_options` (via :func:`_option_value`).
"""
if not isinstance(value, str):
raise ValueError(f"timezone must be a string, got {value!r}")
if value == "":
return value
try:
ZoneInfo(value)
except (ZoneInfoNotFoundError, ValueError) as error:
raise ValueError(
f"timezone must be an IANA time zone name such as "
f"'America/New_York', got {value!r}"
) from error
return value
#: The single field table the two builders share. Each row is
#: ``(field_name, CONF_* key, default, validator)``. ``from_options``
#: applies every validator (raising on the first invalid value) while
@@ -214,6 +240,7 @@ _FIELDS: tuple[tuple[str, str, Any, Callable[[Any], Any]], ...] = (
DEFAULT_AUTOMATION_ENABLED,
_resolve_bool,
),
("timezone", CONF_TIMEZONE, DEFAULT_TIMEZONE, _resolve_timezone),
)
@@ -243,6 +270,7 @@ class NestQuestSettings:
afternoon_reminder_enabled: bool = DEFAULT_AUTOMATION_ENABLED
end_of_day_report_enabled: bool = DEFAULT_AUTOMATION_ENABLED
celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED
timezone: str = DEFAULT_TIMEZONE
def __post_init__(self) -> None:
# Validate STRICTLY at construction so a hand-built instance
@@ -281,6 +309,22 @@ class NestQuestSettings:
raise ValueError(
f"{name} must be a boolean, got {value!r}"
)
_resolve_timezone(self.timezone)
def household_now(self, instant: datetime.datetime) -> datetime.datetime:
"""Return the aware ``instant`` expressed in the household's zone.
The API service does not run inside Home Assistant, so its one
clock read (an aware host-local ``now``) is converted here: with
:attr:`timezone` set — only ever by an admin, from the admin
app — the SAME instant is re-expressed in that zone, so
``.date()`` and ``.time()`` are the household's wall clock even
when the host runs UTC. With :attr:`timezone` empty the instant
is returned unchanged (the host's local time).
"""
if not self.timezone:
return instant
return instant.astimezone(ZoneInfo(self.timezone))
@property
def day_rollover_hour_minute(self) -> tuple[int, int]:
@@ -296,7 +340,7 @@ class NestQuestSettings:
"""Return the rolling horizon window ``[today, today + horizon_days]``.
``today`` is REQUIRED (there is no host-clock default): the
integration passes the HA-local ``hass.config.time_zone`` date so
API passes the household-local date (:meth:`household_now`) so
the horizon tracks the household's own day, and a silent
host-clock fallback would be a wrong-day footgun (a test or a
caller that forgets to pass ``today`` would otherwise materialize
@@ -18,7 +18,7 @@ API client; the two are deliberately NOT unified here, and neither
side reads the other's values.
STORED SERIALIZATION — the document at ``SETTINGS_META_KEY`` is a JSON
object holding exactly the TEN settings fields, keyed by field name
object holding exactly the TWELVE settings fields, keyed by field name
(which equals the ``CONF_*`` option key for every field), complete
after the first update:
@@ -30,7 +30,11 @@ after the first update:
core's normalizer),
- ``morning_summary_enabled``, ``afternoon_reminder_enabled``,
``end_of_day_report_enabled``, ``celebration_enabled`` — real
booleans.
booleans,
- ``timezone`` — ``""`` (the API host's local time) or an IANA time
zone name such as ``America/New_York``. A document stored before
this field existed simply lacks it and resolves to ``""``. Only an
admin's explicit update ever writes it; nothing sets it automatically.
Only values whose core validators ACCEPTED are ever written, so a
stored document always round-trips through the core's option builders.
@@ -172,7 +176,7 @@ async def update_settings(
update can never leave a half-applied document behind.
The validated changes are merged onto the current effective
settings and the merged (complete, ten-field) document is persisted
settings and the merged (complete, eleven-field) document is persisted
in one atomic upsert; the updated settings are returned. The whole
read → merge → write span runs inside the per-database settings
lock, so concurrent updates queue instead of interleaving.
@@ -193,18 +197,27 @@ async def update_settings(
)
async with _settings_lock(database):
current = await load_settings(database)
document: dict[str, Any] = {
field: getattr(current, field) for field in _FIELD_NAMES
}
for name, raw_value in changes.items():
default, validator = _RESOLUTIONS[name]
try:
document[name] = validator(
default if raw_value is None else raw_value
)
except ValueError as error:
raise ValueError(_named(name, error)) from error
await MetaStateDao(database).set(
SETTINGS_META_KEY, json.dumps(document, sort_keys=True)
)
return await _write_merged(database, current, changes)
async def _write_merged(
database: NestQuestDatabase,
current: NestQuestSettings,
changes: Mapping[str, Any],
) -> NestQuestSettings:
"""Validate ``changes``, merge onto ``current``, persist; lock held."""
document: dict[str, Any] = {
field: getattr(current, field) for field in _FIELD_NAMES
}
for name, raw_value in changes.items():
default, validator = _RESOLUTIONS[name]
try:
document[name] = validator(
default if raw_value is None else raw_value
)
except ValueError as error:
raise ValueError(_named(name, error)) from error
await MetaStateDao(database).set(
SETTINGS_META_KEY, json.dumps(document, sort_keys=True)
)
return NestQuestSettings(**document)
+14 -3
View File
@@ -18,7 +18,7 @@ the panel payload shaper (:func:`instance_payload`), and the async
service's panel snapshot route share ONE implementation of both the
snapshot assembly and the entity-facing attribute payload shape.
Nothing here imports :mod:`homeassistant`; the builder takes the
database, the settings object, and the HA-local ``now`` (a single
database, the settings object, and the household-local ``now`` (a single
clock read the caller already resolved, from which it derives
``today``) and returns the snapshot, mirroring exactly what the
coordinator used to do inline.
@@ -42,6 +42,7 @@ from .dao_instances import CompletionEventsDao, QuestInstancesDao
from .dao_presence import PresenceOverridesDao, PresencePatternsDao
from .dao_rules import QuestDefinitionsDao
from .db import NestQuestDatabase
from .icons import normalize_icon_for_read
from .presence import (
MAX_PREVIEW_SCAN_DAYS,
PresenceEngine,
@@ -95,6 +96,11 @@ class NestQuestSnapshot:
today_iso: str
children: tuple[ChildDaySnapshot, ...]
cycle_day: int
#: The application timezone the admin stored (``""`` when unset —
#: the host's local time; the API publishes its effective zone in
#: its place, api/host_zone.py). The panel cards format their
#: visible date and clock in it; it never re-resolves the day itself.
timezone: str = ""
def _cycle_day(
@@ -181,7 +187,7 @@ async def build_snapshot(
:func:`instance_payload` layer — this builder produces the shared
shape both consumers read.
``now`` is the HA-local timezone-aware datetime the caller already
``now`` is the household-local timezone-aware datetime the caller already
resolved (a SINGLE clock read); the overdue check compares
``now.time()`` against the instance's ``due_time`` for today's open
instances. The builder performs NO internal clock read — it never
@@ -262,7 +268,11 @@ async def build_snapshot(
definition_id=instance.definition_id,
child_id=instance.child_id,
title=definition.title if definition else "Quest",
icon=definition.icon if definition else None,
icon=(
normalize_icon_for_read(definition.icon)
if definition
else None
),
window=instance.window,
due_date=instance.due_date,
due_time=instance.due_time,
@@ -314,4 +324,5 @@ async def build_snapshot(
today_iso=today_iso,
children=tuple(snapshots),
cycle_day=_cycle_day(patterns, children, today),
timezone=settings.timezone,
)
+7 -5
View File
@@ -39,9 +39,10 @@ lets exactly one of them win the claim — the loser returns [] and
announces nothing.
The caller supplies ``today`` as a plain :class:`datetime.date`: the
household-local calendar date, read from ``hass.config.time_zone`` by
the integration or from the API host's local clock by the API. This
module never reads a timezone database or config of its own.
household-local calendar date, which the API reads in the
admin-stored application timezone (the API host's local zone when none
is stored). This module never reads a timezone database or config of
its own.
"""
from __future__ import annotations
@@ -97,8 +98,9 @@ async def run_missed_sweep(
"""Build one missed event per unswept past-due open instance.
``today`` is the household-local calendar date the sweep runs for
(the caller owns the clock: the integration reads it from
``hass.config.time_zone``, the API from its host's local time).
(the caller owns the clock: the API reads it in the admin-stored
application timezone, falling back to its host's local time when
unset).
Returns the ``(event_type, payload)`` tuples to announce, EMPTY
when the watermark already covers ``today`` (a same-night rerun) —
the caller decides how to publish them and announces nothing on
+1 -1
View File
@@ -15,5 +15,5 @@
"iot_class": "local_polling",
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
"requirements": [],
"version": "0.7.1"
"version": "0.9.8"
}
+4
View File
@@ -441,6 +441,10 @@ class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
for child in data.children
},
"child_roster": _child_roster(data.children),
# The API's effective application timezone ("" only when
# it could not name one): the panel cards format their
# visible date and clock in it.
"timezone": data.timezone,
}
File diff suppressed because it is too large Load Diff
+45 -3
View File
@@ -64,7 +64,8 @@ Header `Tasks` / `7 definitions · 3 children` + gradient `New` button. Filter c
(All / one per child): active `#18181D` fill white text, inactive white with border.
Rows: glyph 17px · title 13.5px/700 · meta 11px/600 `<assignees> · <recurrence> · <windows>`
· `chevron-right`. An inactive definition renders the whole row at
`--nq-a-ink-tertiary` with `· inactive` in the meta — never hard-deleted (glossary).
`--nq-a-ink-tertiary` with `· inactive` in the meta. A definition with completion
history is never hard-deleted (glossary, D-017): Delete retires it to this inactive state.
The row being edited takes `--nq-a-selected` with brand-blue glyph, `Editing` meta and
`chevron-down`.
@@ -83,9 +84,50 @@ Bottom sheet, `--nq-a-radius-sheet`, `--nq-a-sheet-shadow`, 38×4px grabber, pad
Footer: `Cancel` (flex 1, bordered) + `Save changes` (flex 2, gradient), both 13px vertical
padding. Sheet enters over the dimmed list in `--nq-dur-modal`.
Delete (editing an existing definition only, never on `New`): a full-width bordered
`Delete task` button below the footer, danger-ink text. Tapping it swaps the footer for a
danger-tinted confirm — "Delete this task? If it has no completion history it is deleted;
otherwise it is retired (deactivated) and its history is kept." — with `Go back` and a
danger-filled `Delete task`. Confirming calls `DELETE /api/v1/admin/quest-definitions/{id}`
(admin plane only; the kids' panel has no equivalent control). Hybrid outcome (D-017): no
completion event on any instance → the definition, its instances, assignees, windows and
unreferenced rule are hard-deleted and the row leaves the list; otherwise the definition
is retired (deactivated) and the row stays, shown inactive. Retirement preserves the
definition row, schedule rule, assignees, windows and all durable completion history. Like
any deactivation it regenerates, so future uncompleted instances are cleared (they are
rolling, regenerable rows) and none are recreated while the definition is inactive — the
retired task stops generating and no longer shows open work. Durable instance history lives
only in the append-only `completion_events`. An error keeps the sheet open with the API's
detail.
### 3.3 Icon picker
Each definition carries a Lucide glyph, shown on both surfaces. **The allowed subset is not
yet defined** — pick 24–40 household-relevant glyphs and ship them as a grid picker.
Each definition carries one optional icon, shown on both surfaces. The picker offers three
sources:
- **Lucide** — a grid of the curated Lucide glyphs, drawn stroked.
- **Font Awesome** — a grid of a curated subset of the Font Awesome Free *solid* set,
drawn filled.
- **Emoji** — a text input; the typed emoji is stored as entered (not trimmed) and must be
1–8 code points with no whitespace, control characters, `<`, `>` or `&`.
Both curated lists come from `tools/icons/curated-icons.json`; their glyph path data is
generated into the admin and panel bundles by `tools/icons/generate.mjs` (no CDN, no
runtime fetch). See `THIRD-PARTY-NOTICES.md` for licences.
The stored value is a **namespaced key**:
| Source | Stored key | Example |
|---|---|---|
| Lucide | `lucide:<name>` | `lucide:dog` |
| Font Awesome | `fa:<name>` | `fa:paw` |
| Emoji | `emoji:<grapheme>` | `emoji:🐶` |
`<name>` is a lowercase kebab name (`[a-z0-9-]`, no leading or trailing `-`). A **legacy
bare name** written before namespacing (`dog`) reads as `lucide:<name>` — the API returns it
namespaced, and any write of a bare name is stored as `lucide:<name>`; no data migration. An
empty value clears the icon. An empty, unknown, or unresolvable key renders the **fallback
glyph** — in the admin, the generic task glyph (clipboard with a check); on the panel, the
star (`PANEL-SPEC.md` §3.2).
## 4. Schedule (1f)
+3 -2
View File
@@ -78,5 +78,6 @@ The panel speaks tabletop; the admin speaks plainly. Both sit on the same `quest
decide before shipping the card.
- No gamification is in scope. XP, levels, streaks and rarity were explicitly deferred,
but the append-only event log already supports computing them later.
- Icon per quest definition is chosen by the parent from a Lucide subset; the subset is
not yet defined (`ADMIN-SPEC.md` §3.3).
- Icon per quest definition is chosen by the parent from curated Lucide and Font Awesome
Free sets or as an emoji, stored as a namespaced key (`lucide:`/`fa:`/`emoji:`) —
resolved 2026-09-26, see `ADMIN-SPEC.md` §3.3.
+1 -1
View File
@@ -35,7 +35,7 @@ implementer's call), shaped:
{
"instances": [
{ "id": 4821, "definition_id": 12, "child_id": 3, "title": "Fill bird feeder",
"icon": "bird", "window": "afternoon", "due_time": "17:00",
"icon": "lucide:bird", "window": "afternoon", "due_time": "17:00",
"state": "open", "overdue": true, "completed_at": null, "on_time": null }
]
}
+27 -6
View File
@@ -22,10 +22,14 @@ Screens `1a`, `1b`, `1j`, `1c` of the mockups. Two cards:
Default view on the wall. Layout top to bottom:
1. **Wordmark row** at y=64. Rule · d20 hexagon · `NestQuest` (66px/900 display, brand
gradient via `background-clip: text`) · d20 · rule. d20 marks are 34px hexagons
(`clip-path: polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)`) filled with
the brand gradient, numeral 15px/700 Cinzel.
1. **Wordmark row** at y=64. Rule · monogram · `NestQuest` (66px/900 Cinzel Decorative
display, brand gradient via `background-clip: text`) · rule — the horizontal lockup of
`design/logos/NestQuest_Logo.png`. The monogram is an 80px inline SVG (no raster, no
remote asset): a compass rose — four long cardinal points, four short diagonal points,
a ring broken by the points — around a d20 hexagon with facet lines and a knocked-out
Cinzel Decorative "N", filled with the brand gradient (`--nq-brand-blue` →
`--nq-brand-purple`, 135°). Gaps are SVG masks, so the mark stays transparent over the
parchment.
2. **Kicker** `The Party · <weekday>, <Month D>` — 21px/600 Cinzel, tracking .30em, uppercase.
3. **Adventurer plates**, 3-up grid, 56px gap, page inset 110px, top y=262. One plate per
child returned by the party sensor, in configured order.
@@ -67,6 +71,14 @@ and time only rather than removing it (the strip is part of the frame).
Reached by tapping a present crest. Header at y=56, page inset 62px:
- **Return control** first in the header, left of the crest: a `--nq-p-button-height`
72px button, radius 10px, `rgba(92,62,26,.1)` with a 2px `rgba(92,62,26,.5)` border
(the confirm dialog's **Not yet** treatment), `chevron-left` 34px + `The Party`
26px/700 Cinzel `--nq-p-ink`, 28px header gap to the crest. Tapping it takes the same
path as the idle return (§3.5) — navigate to `board_path` — immediately. Press
feedback `scale(.98)`, `--nq-dur-micro`. Not rendered when no `board_path` is
configured. The party board is the main display and has no return control; the
Quest complete screen (§5) keeps its auto-return.
- Small crest 96×110 (initial 44px/700 display).
- Title `<Name>'s Quest Log` 56px/700 display.
- Sub `<weekday>, <Month D> · N of M claimed` 20px/600 Cinzel, tracking .24em, uppercase.
@@ -91,7 +103,14 @@ collapse the column, the three-column rhythm is the layout.
`--nq-p-quest-min-height` 116px, radius 12px, `--nq-p-card-open`, 1px
`--nq-p-card-border`, `--nq-p-card-shadow`, padding 22px, 22px gap, row layout:
1. **Icon tile** 64×64, radius 10px, `--nq-p-icon-tile`, Lucide glyph 34px white.
1. **Icon tile** 64×64, radius 10px, `--nq-p-icon-tile`, glyph 34px white — the
definition's icon, resolved from its namespaced key (`ADMIN-SPEC.md` §3.3):
`lucide:<name>` draws the curated Lucide glyph **stroked** (fill none, stroke-width 2,
round caps and joins); `fa:<name>` draws the curated Font Awesome glyph **filled**;
`emoji:<grapheme>` renders the emoji **as text**. A legacy bare name resolves as
`lucide:<name>`. No icon, or any key that does not resolve (unknown namespace or name,
malformed emoji), falls back to the **star** glyph. Glyph data is bundled from the
shared registry — nothing is fetched at runtime.
2. **Text** title 30px/700 Cinzel `--nq-p-ink`; meta 22px/600 Nunito
`--nq-p-ink-secondary` reading `Due by <h:mm A>`. If past due:
`Overdue · due <h:mm A>` in 22px/700 Cinzel uppercase, tracking .10em,
@@ -106,7 +125,9 @@ Both the card body and the button open the confirm dialog (§4). Press feedback:
### 3.3 Quest card — sealed
`--nq-p-card-done`, `opacity: .82`, no shadow, **not tappable**. Icon tile
`--nq-p-icon-tile-done` with the glyph in `--nq-p-ink-secondary`. Title 30px/700 Cinzel
`--nq-p-icon-tile-done` with the glyph in `--nq-p-ink-secondary`. The sealed tile keeps
the static star glyph — it does **not** render the definition icon — alongside the wax
seal below. Title 30px/700 Cinzel
`--nq-p-ink-muted` with `text-decoration: line-through`. Meta reads
`Claimed <h:mm A>` 22px/600 Nunito `--nq-p-ink-secondary`.
+32
View File
@@ -99,6 +99,38 @@ A lone `home` pattern answers exactly as the old schedule did, so no
child's presence changes. The drop is one-way and 0.7.1 refuses a
version-9 database; see the rollback step in `deploy/RUNBOOK.md` §11.
### Application timezone (manual, admin-set)
The application timezone is set **manually by an admin from the admin
app** — the Settings screen's timezone picker, which reads and writes the
`timezone` field of `GET`/`PATCH /api/v1/admin/settings`. Nothing sets it
automatically: there is no Home Assistant zone adoption, no browser-zone
auto-detect, and no auto-save when a screen loads. Only an admin's `PATCH`
ever changes the stored value.
The stored value is the **single source of local-time truth for the whole
application.** The API reads its one clock and re-expresses it in that zone
(`NestQuestSettings.household_now`), so every local date and time comes
from it:
- the panel snapshot's today and overdue state, and both panel cards'
visible dates and clocks (the snapshot publishes the zone as `timezone`,
which the integration exposes on the household rollup sensor);
- completion timestamps (the local completion date and the on-time check);
- the missed-sweep scheduler's household rollover;
- every admin read, and the admin app's local-time formatting.
**Empty means the API host's local zone.** While `timezone` is empty the
API reads its clock in the host's local time, and publishes that zone as
the effective zone (`api/host_zone.py`: the `effective_timezone` field of
the admin settings response and the snapshot's `timezone`) until an admin
sets one. No code fills the empty value in.
**A save re-plans the sweep.** An admin save that changes `timezone` or
`day_rollover_time` notifies the running missed-sweep scheduler
(`MissedSweepScheduler.settings_changed`, under its settings lock), which
re-plans its next sweep for the new household rollover.
## 4. Authentication: two planes
The kid panel and the admin PWA have opposite trust levels, so they use
+89 -27
View File
@@ -231,8 +231,76 @@ function conditionPhrase(condition: string, label: string): string {
}
const SHIELD_CLIP = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)";
const HEXAGON_CLIP =
"polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)";
// The brand monogram (design/logos/NestQuest_Logo.png): a compass rose —
// four long cardinal points, four short diagonal points, a ring broken by
// the points — around a d20 hexagon carrying a knocked-out "N". Drawn on
// a 100-unit grid and filled with the brand gradient; the masks cut the
// parchment gaps so the mark stays transparent on any backdrop.
const MONOGRAM_POINTS =
"M50 3 44.5 24.1 50 26 55.5 24.1Z M100 50 75.9 44.5 74 50 75.9 55.5Z " +
"M50 97 55.5 75.9 50 74 44.5 75.9Z M0 50 24.1 55.5 26 50 24.1 44.5Z " +
"M29.1 29.1 29.7 36.3 36.3 29.7Z M70.9 29.1 63.7 29.7 70.3 36.3Z " +
"M70.9 70.9 70.3 63.7 63.7 70.3Z M29.1 70.9 36.3 70.3 29.7 63.7Z";
const MONOGRAM_HEX = "M50 26.5 70.4 38.2 70.4 61.8 50 73.5 29.6 61.8 29.6 38.2Z";
const MONOGRAM_FACETS =
"M29.6 38.2H70.4M29.6 61.8H70.4M50 26.5 36.5 38.2M50 26.5 63.5 38.2" +
"M50 73.5 36.5 61.8M50 73.5 63.5 61.8M36.5 38.2V61.8M63.5 38.2V61.8" +
"M29.6 38.2 36.5 61.8M36.5 38.2 29.6 61.8" +
"M70.4 38.2 63.5 61.8M63.5 38.2 70.4 61.8";
const MONOGRAM = html`<svg
class="monogram"
viewBox="0 0 100 100"
aria-hidden="true"
focusable="false"
>
<defs>
<linearGradient
id="nq-monogram-fill"
gradientUnits="userSpaceOnUse"
x1="0"
y1="0"
x2="100"
y2="100"
>
<stop offset="0" style="stop-color: var(--nq-brand-blue)" />
<stop offset="1" style="stop-color: var(--nq-brand-purple)" />
</linearGradient>
<mask id="nq-monogram-points-gap" maskUnits="userSpaceOnUse">
<rect width="100" height="100" fill="#fff" />
<path d=${MONOGRAM_POINTS} stroke="#000" stroke-width="3.5" />
</mask>
<mask id="nq-monogram-facets" maskUnits="userSpaceOnUse">
<rect width="100" height="100" fill="#fff" />
<path d=${MONOGRAM_FACETS} stroke="#000" stroke-width="1.6" />
<text
x="50"
y="59.4"
text-anchor="middle"
font-family="'Cinzel Decorative', Cinzel, serif"
font-weight="900"
font-size="25"
fill="#fff"
stroke="#000"
stroke-width="2"
paint-order="stroke"
>N</text>
</mask>
</defs>
<g fill="url(#nq-monogram-fill)">
<path d=${MONOGRAM_POINTS} />
<g mask="url(#nq-monogram-points-gap)">
<circle
cx="50"
cy="50"
r="34.25"
fill="none"
stroke="url(#nq-monogram-fill)"
stroke-width="6.5"
/>
<path mask="url(#nq-monogram-facets)" d=${MONOGRAM_HEX} />
</g>
</g>
</svg>`;
const boardStyles = css`
* {
@@ -299,23 +367,11 @@ const boardStyles = css`
background: rgba(92, 62, 26, 0.35);
}
.d20 {
.monogram {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
clip-path: ${unsafeCSS(HEXAGON_CLIP)};
background: var(--nq-brand-gradient);
}
.d20-numeral {
font-family: var(--nq-p-font-heading);
font-size: 15px;
font-weight: 700;
line-height: 1;
color: #ffffff;
display: block;
width: 80px;
height: 80px;
}
.wordmark {
@@ -497,11 +553,12 @@ const boardStyles = css`
.hint {
position: absolute;
top: 930px;
top: 896px;
left: 0;
right: 0;
margin: 0;
font-family: var(--nq-p-font-body);
font-size: 24px;
font-size: 27px;
font-weight: 600;
line-height: 1.4;
text-align: center;
@@ -861,13 +918,8 @@ export class NestQuestPartyBoardCard extends LitElement {
<div class="frame frame-inner"></div>
<div class="wordmark-row">
<span class="rule"></span>
<span class="d20" aria-hidden="true">
<span class="d20-numeral">20</span>
</span>
${MONOGRAM}
<span class="wordmark">NestQuest</span>
<span class="d20" aria-hidden="true">
<span class="d20-numeral">20</span>
</span>
<span class="rule"></span>
</div>
<p class="kicker">The Party · ${formatDay(this._now, this._timeZone())}</p>
@@ -885,8 +937,18 @@ export class NestQuestPartyBoardCard extends LitElement {
`;
}
/** The zone the visible date and clock render in: the API's effective
* application timezone — the stored zone, or the API host's when none
* is stored (published on the household rollup by
* custom_components/nestquest/sensor.py — ``timezone``). Only when the
* rollup carries none does Home Assistant's zone (or the browser's)
* apply. */
private _timeZone(): string | undefined {
return hassTimeZone(this.hass);
const stored = this._state("sensor.nestquest_household_quests_due_today")
?.attributes?.timezone;
return typeof stored === "string" && stored.trim()
? stored.trim()
: hassTimeZone(this.hass);
}
private _state(entityId: string): StateObject | null {
+76 -5
View File
@@ -2,6 +2,7 @@ import { LitElement, css, html, nothing, unsafeCSS, type TemplateResult } from "
import { repeat } from "lit/directives/repeat.js";
import panelTokens from "../../../custom_components/nestquest/www/nestquest-panel-tokens.css?inline";
import { renderQuestIcon } from "../icons/render";
import { registerCustomCard, type CardConfig, type HassLike } from "../types";
type QuestLogCardConfig = CardConfig & {
@@ -373,12 +374,43 @@ const logStyles = css`
gap: 28px;
}
button.return {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
height: var(--nq-p-button-height);
padding: 0 28px 0 20px;
border: 2px solid rgba(92, 62, 26, 0.5);
border-radius: 10px;
background: rgba(92, 62, 26, 0.1);
color: var(--nq-p-ink);
font-family: var(--nq-p-font-heading);
font-size: 26px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform var(--nq-dur-micro) var(--nq-ease-out);
}
button.return:active {
transform: scale(0.98);
}
button.return svg {
width: 34px;
height: 34px;
}
.crest {
position: relative;
flex: none;
width: 96px;
height: 110px;
padding: 3px;
padding: 4px;
clip-path: ${unsafeCSS(SHIELD_CLIP)};
background: rgba(255, 255, 255, 0.22);
}
@@ -614,6 +646,11 @@ const logStyles = css`
height: 34px;
}
.tile .emoji {
font-size: 30px;
line-height: 1;
}
.sealed .tile {
background: var(--nq-p-icon-tile-done);
color: var(--nq-p-ink-secondary);
@@ -1193,6 +1230,18 @@ const ICON_X = html`<svg
<path d="m6 6 12 12"></path>
</svg>`;
const ICON_CHEVRON_LEFT = html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m15 18-6-6 6-6"></path>
</svg>`;
const ICON_STAR = html`<svg
viewBox="0 0 24 24"
fill="none"
@@ -1700,8 +1749,18 @@ export class NestQuestQuestLogCard extends LitElement {
`;
}
/** The zone the visible date and clock render in: the API's effective
* application timezone — the stored zone, or the API host's when none
* is stored (published on the household rollup by
* custom_components/nestquest/sensor.py — ``timezone``). Only when the
* rollup carries none does Home Assistant's zone (or the browser's)
* apply. */
private _timeZone(): string | undefined {
return hassTimeZone(this.hass);
const stored = this._state("sensor.nestquest_household_quests_due_today")
?.attributes?.timezone;
return typeof stored === "string" && stored.trim()
? stored.trim()
: hassTimeZone(this.hass);
}
private _state(entityId: string): StateObject | null {
@@ -1938,7 +1997,19 @@ export class NestQuestQuestLogCard extends LitElement {
const initial = (name?.charAt(0) || "?").toUpperCase();
return html`
<header class="header">
<span class="crest${away ? " away" : nothing}" aria-hidden="true">
${this._boardPath()
? html`
<button
class="return"
type="button"
@click=${() => this._returnToBoard()}
>
${ICON_CHEVRON_LEFT}
<span>The Party</span>
</button>
`
: nothing}
<span class="crest${away ? " away" : ""}" aria-hidden="true">
<span class="crest-face">
<span class="initial">${initial}</span>
</span>
@@ -2093,10 +2164,10 @@ export class NestQuestQuestLogCard extends LitElement {
}
}}
>
<span class="tile" aria-hidden="true">${ICON_STAR}</span>
<span class="tile" aria-hidden="true">${renderQuestIcon(instance.icon, ICON_STAR)}</span>
<div class="body">
<span class="quest-title">${instance.title}</span>
<span class="meta ${meta.late ? "late" : nothing}">${meta.text}</span>
<span class="${meta.late ? "meta late" : "meta"}">${meta.text}</span>
</div>
<button
class="complete"
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
/**
* Lit rendering for the shared icon registry (`registry.generated.ts`,
* generated from tools/icons/curated-icons.json). The glyph path data is
* bundled — no CDN and no runtime fetch.
*/
import { html, svg, type TemplateResult } from "lit";
import { resolveIcon } from "./registry.generated";
/**
* A stored definition icon (`lucide:<name>`, `fa:<name>`, `emoji:<grapheme>`
* or a legacy bare Lucide name) as a template; anything that resolves to the
* registry fallback renders `fallback` instead.
*/
export function renderQuestIcon(
value: string | null,
fallback: TemplateResult
): TemplateResult {
const icon = resolveIcon(value);
switch (icon.kind) {
case "lucide":
return html`<svg
viewBox=${icon.viewBox}
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
data-icon=${icon.key}
>
${icon.paths.map((d) => svg`<path d=${d}></path>`)}
</svg>`;
case "fa":
return html`<svg
viewBox=${icon.viewBox}
fill="currentColor"
aria-hidden="true"
data-icon=${icon.key}
>
${icon.paths.map((d) => svg`<path d=${d}></path>`)}
</svg>`;
case "emoji":
return html`<span class="emoji" data-icon=${icon.key}>${icon.text}</span>`;
case "fallback":
return fallback;
}
}
+22 -3
View File
@@ -3,13 +3,15 @@
*
* Usage: node render-party-board.mjs < spec.json
*
* The spec JSON carries {bundle, tag, config, hass, url?, click_plate?};
* The spec JSON carries {bundle, tag, config, hass, url?, click_plate?, now?};
* stdout receives one JSON object {plates, tappable, location, html} where
* plates is the ordered plate list the shadow DOM renders (name +
* present/away/unknown kind), tappable the plate names rendered as tappable
* <button> plates, and location the window pathname after the optional plate
* tap. The Python panel tests drive this harness so the assertions run
* against the exact bundle HACS ships.
* tap. With now: <ISO instant> the card's clock is pinned there
* (argument-less new Date() and Date.now() return it; the process TZ
* decides its local wall time). The Python panel tests drive this harness
* so the assertions run against the exact bundle HACS ships.
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
@@ -39,6 +41,23 @@ globalThis.window = dom.window;
// jsdom Event must shadow it for the dispatched event to be delivered.
globalThis.Event = dom.window.Event;
// Pin the clock: argument-less construction and Date.now() read `pinned`,
// every other Date use is the real class.
if (spec.now !== undefined) {
const pinned = new Date(spec.now).getTime();
const RealDate = Date;
class PinnedDate extends RealDate {
constructor(...args) {
super(...(args.length === 0 ? [pinned] : args));
}
static now() {
return pinned;
}
}
globalThis.Date = PinnedDate;
dom.window.Date = PinnedDate;
}
await import(pathToFileURL(spec.bundle).href);
const element = document.createElement(spec.tag);
+106 -6
View File
@@ -7,10 +7,22 @@
* the card is mounted at the given URL (it resolves its child from the
* pathname's last segment), after the optional probe wait one snapshot of
* the render is taken mid-wait, then after the optional idle wait stdout
* receives one JSON object {location, headline, countdown, html, probe} —
* the final window pathname, the rendered title, the complete-screen
* countdown text, the rendered shadow DOM, and the probe snapshot when
* probe_ms was given. The Python panel tests drive this harness so the
* receives one JSON object {location, headline, countdown, html, tiles,
* probe, tap} — the final window pathname, the rendered title, the
* complete-screen countdown text, the rendered shadow DOM, each quest
* card's icon tile, and the probe snapshot when probe_ms was given. With
* tap_complete: <instance id> the harness taps that card's Complete button,
* records whether the confirm dialog opened, taps the dialog's Complete, and
* reports every hass.callService call as tap: {dialog_opened, service_calls}.
* With tap_return: true the harness taps the header's return control and
* reports it as returned: {rendered, label} before the final snapshot.
* With now: <ISO instant> the card's clock is pinned there (argument-less
* new Date() and Date.now() return it; the process TZ decides its local
* wall time), and with advance_to: [<ISO instant>, ...] the clock moves to
* each instant in turn and the card's own 60 s clock-tick callbacks fire,
* one snapshot per instant reported as ticks (the top-level snapshot is
* taken before the clock moves).
* The Python panel tests drive this harness so the
* assertions run against the exact bundle HACS ships.
*/
import { readFileSync } from "node:fs";
@@ -41,14 +53,74 @@ globalThis.window = dom.window;
// jsdom Event must shadow it for the return navigation to be delivered.
globalThis.Event = dom.window.Event;
// Pin the clock: argument-less construction and Date.now() read `pinned`,
// every other Date use is the real class.
let pinned = spec.now !== undefined ? new Date(spec.now).getTime() : null;
if (pinned !== null) {
const RealDate = Date;
class PinnedDate extends RealDate {
constructor(...args) {
super(...(args.length === 0 ? [pinned] : args));
}
static now() {
return pinned;
}
}
globalThis.Date = PinnedDate;
dom.window.Date = PinnedDate;
}
// Record the card's 60 s clock ticks so advance_to can fire them on demand.
const clockTicks = [];
const realSetInterval = dom.window.setInterval.bind(dom.window);
dom.window.setInterval = (callback, ms, ...rest) => {
if (ms === 60000) {
clockTicks.push(callback);
}
return realSetInterval(callback, ms, ...rest);
};
await import(pathToFileURL(spec.bundle).href);
const serviceCalls = [];
const hass = {
...spec.hass,
callService(domain, service, data) {
serviceCalls.push({ domain, service, data });
return Promise.resolve();
},
};
const element = document.createElement("nestquest-quest-log-card");
element.setConfig(spec.config);
element.hass = spec.hass;
element.hass = hass;
document.body.appendChild(element);
await element.updateComplete;
let tap = null;
if (spec.tap_complete !== undefined) {
element.shadowRoot
.querySelector(`.quest[data-instance-id="${spec.tap_complete}"] button.complete`)
.click();
await element.updateComplete;
const confirm = element.shadowRoot.querySelector("button.do-complete");
confirm?.click();
await element.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 0));
tap = { dialog_opened: confirm !== null, service_calls: serviceCalls };
}
let returned = null;
if (spec.tap_return) {
const control = element.shadowRoot.querySelector(".header button.return");
returned = {
rendered: control !== null,
label: control?.textContent?.trim() ?? "",
};
control?.click();
await element.updateComplete;
}
const snapshot = () => ({
location: window.location.pathname,
headline: element.shadowRoot.querySelector(".title")?.textContent?.trim() ?? "",
@@ -56,6 +128,22 @@ const snapshot = () => ({
element.shadowRoot.querySelector(".complete-countdown")?.textContent?.trim() ??
"",
html: element.shadowRoot.innerHTML,
// Each quest card's icon tile: the registry glyph it rendered (its
// data-icon key and svg fill), its text (an emoji), and whether it shows
// the static star (the star's polygon).
tiles: [...element.shadowRoot.querySelectorAll(".quest")].map((quest) => {
const tile = quest.querySelector(".tile");
const glyph = tile?.querySelector("[data-icon]");
return {
id: Number(quest.dataset.instanceId),
sealed: quest.classList.contains("sealed"),
icon: glyph?.getAttribute("data-icon") ?? null,
fill: glyph?.getAttribute("fill") ?? null,
paths: tile?.querySelectorAll("path").length ?? 0,
text: tile?.textContent?.trim() ?? "",
star: Boolean(tile?.querySelector("polygon")),
};
}),
});
let probe = null;
@@ -68,6 +156,18 @@ if (spec.idle_ms !== undefined) {
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
}
const payload = JSON.stringify({ ...snapshot(), probe });
const settled = snapshot();
let ticks = null;
if (spec.advance_to !== undefined) {
ticks = [];
for (const instant of spec.advance_to) {
pinned = new Date(instant).getTime();
clockTicks.forEach((callback) => callback());
await element.updateComplete;
ticks.push(snapshot());
}
}
const payload = JSON.stringify({ ...settled, probe, tap, returned, ticks });
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0);
+1
View File
@@ -1,6 +1,7 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": false,
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nestquest"
version = "0.7.1"
version = "0.9.8"
description = "NestQuest Home Assistant Integration"
readme = "README.md"
requires-python = ">=3.11"
+3 -2
View File
@@ -910,6 +910,7 @@ def coordinator_client_from_test_override():
{
"today_iso": datetime.date.today().isoformat(),
"cycle_day": 0,
"timezone": "",
"children": [],
}
)
@@ -1016,8 +1017,8 @@ def make_hass() -> tuple:
hass.bus = EventBus()
hass.http = HttpRegistry()
hass.extra_js_urls: list[str] = []
# A valid IANA time zone (the day-rollover listener reads it to
# compute "today" in HA local time).
# A valid IANA time zone for the fake Home Assistant config (the
# NestQuest application timezone is the admin-set setting, not this).
hass.config.time_zone = "UTC"
# The minimal entity registry: entities self-register through
+225 -3
View File
@@ -35,6 +35,7 @@ for malformed bodies.
"""
from __future__ import annotations
import datetime
import importlib
from types import SimpleNamespace
@@ -223,7 +224,7 @@ async def test_create_persists_definition_with_assignees_and_windows(
}
assert payload["title"] == "Tidy the den"
assert payload["description"] == "Put everything back where it belongs."
assert payload["icon"] == "toy-box"
assert payload["icon"] == "lucide:toy-box"
assert payload["is_active"] is True
assert payload["skip_on_away"] is True
assert payload["rule"] == _weekly_rule_dict()
@@ -245,7 +246,7 @@ async def test_create_persists_definition_with_assignees_and_windows(
assert stored.definition.description == (
"Put everything back where it belongs."
)
assert stored.definition.icon == "toy-box"
assert stored.definition.icon == "lucide:toy-box"
assert stored.definition.is_active is True
assert stored.rule.to_dict() == _weekly_rule_dict()
assert sorted(child.id for child in stored.assignees) == [ada, bo]
@@ -396,7 +397,7 @@ async def test_edit_persists_supplied_fields_and_leaves_omitted_untouched(
assert stored.definition.description == (
"Put everything back where it belongs."
)
assert stored.definition.icon == "toy-box"
assert stored.definition.icon == "lucide:toy-box"
assert stored.rule.to_dict() == _weekly_rule_dict()
# assignee_child_ids was omitted: the assignees are untouched.
assert sorted(child.id for child in stored.assignees) == [ada, bo]
@@ -468,6 +469,89 @@ async def test_edit_can_clear_description_and_icon_explicitly(
assert stored.rule.to_dict() == _weekly_rule_dict()
async def test_legacy_bare_icon_reads_back_namespaced(
admin_questdefs: SimpleNamespace,
) -> None:
"""A row stored with a pre-namespacing bare icon reads as ``lucide:``.
The row is rewritten through the DAO (bypassing the write-side
validation, as a row from before namespacing would be) and then read
back through BOTH consumers: the admin definition response and the
kids' panel snapshot's per-instance ``icon``.
"""
client = admin_questdefs.client
ada = (await _create_children(client, ("Ada",)))[0]
today = datetime.date.today().isoformat()
created = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Walk the dog",
"rule": {"rule_type": "daily", "start_date": today},
"assignee_child_ids": [ada],
"windows": ["evening"],
"icon": "fa:dog",
},
headers=_admin_headers(),
)
assert created.status_code == 201
definition_id = created.json()["id"]
await _dao_rules.QuestDefinitionsDao(
admin_questdefs.database
).edit_definition(definition_id, icon="dog")
stored = (await _definitions_by_id(admin_questdefs.database))[
definition_id
]
assert stored.definition.icon == "dog"
listed = await client.get(
"/api/v1/admin/quest-definitions", headers=_admin_headers()
)
assert listed.status_code == 200
[definition] = [
d for d in listed.json()["definitions"] if d["id"] == definition_id
]
assert definition["icon"] == "lucide:dog"
panel = await client.get(
"/api/v1/panel/snapshot",
headers={"Authorization": "Bearer admin-test-panel-token"},
)
assert panel.status_code == 200
[child] = panel.json()["children"]
icons = {
instance["icon"]
for instance in child["instances"]
if instance["definition_id"] == definition_id
}
assert icons == {"lucide:dog"}
@pytest.mark.parametrize(
"icon", ["foo:dog", "lucide:", "emoji:<b>", "javascript:alert(1)"]
)
async def test_create_invalid_icon_is_422_naming_icon(
admin_questdefs: SimpleNamespace, icon: str
) -> None:
"""An invalid icon key is a 422 whose detail names the icon field."""
client = admin_questdefs.client
ada = (await _create_children(client, ("Ada",)))[0]
response = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Walk the dog",
"rule": _weekly_rule_body(),
"assignee_child_ids": [ada],
"windows": ["morning"],
"icon": icon,
},
headers=_admin_headers(),
)
assert response.status_code == 422
assert "icon" in str(response.json()["detail"])
assert await _definitions_by_id(admin_questdefs.database) == {}
async def test_edit_unknown_definition_is_404(
admin_questdefs: SimpleNamespace,
) -> None:
@@ -733,3 +817,141 @@ async def test_set_active_malformed_body_is_422(
headers=_admin_headers(),
)
assert response.status_code == 422
# --- delete: hybrid (delete if no history, retire if there is) --------------
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
def test_delete_route_lives_on_the_admin_router() -> None:
"""The DELETE route is on the admin router, which carries the ONE
require_admin dependency — no second auth check is added."""
from api.auth import require_admin
from api.routes_admin import router as admin_router
assert require_admin in [d.dependency for d in admin_router.dependencies]
matches = [
route
for route in admin_router.routes
if route.path == "/api/v1/admin/quest-definitions/{definition_id}"
and "DELETE" in route.methods
]
assert len(matches) == 1
async def test_delete_refuses_non_admin_and_absent(
admin_questdefs: SimpleNamespace,
) -> None:
"""A non-admin JWT is 403 and an absent credential 401: nothing is
deleted or retired."""
client = admin_questdefs.client
ada = (await _create_children(client, ("Ada",)))[0]
definition_id = (await _create_definition(client, [ada])).json()["id"]
non_admin = make_token(groups=("some-other-group",))
forbidden = await client.delete(
f"/api/v1/admin/quest-definitions/{definition_id}",
headers={"Authorization": f"Bearer {non_admin}"},
)
assert forbidden.status_code == 403
unauthenticated = await client.delete(
f"/api/v1/admin/quest-definitions/{definition_id}"
)
assert unauthenticated.status_code == 401
record = await _dao_rules.QuestDefinitionsDao(
admin_questdefs.database
).get(definition_id)
assert record is not None
assert record.is_active is True
async def test_delete_without_history_hard_deletes(
admin_questdefs: SimpleNamespace,
) -> None:
"""No completion history: 200 ``deleted`` and the row and its
unreferenced rule are gone."""
client = admin_questdefs.client
ada = (await _create_children(client, ("Ada",)))[0]
definition_id = (await _create_definition(client, [ada])).json()["id"]
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
rule_id = (await dao.get(definition_id)).schedule_rule_id
response = await client.delete(
f"/api/v1/admin/quest-definitions/{definition_id}",
headers=_admin_headers(),
)
assert response.status_code == 200
assert response.json() == {
"definition_id": definition_id,
"deleted": True,
"retired": False,
"definition": None,
}
assert await dao.get(definition_id) is None
assert await _dao_rules.ScheduleRulesDao(
admin_questdefs.database
).get(rule_id) is None
async def test_delete_with_history_retires(
admin_questdefs: SimpleNamespace,
) -> None:
"""Completion history present: 200 ``retired`` with the now-inactive
definition; the row, instance and history all remain."""
client = admin_questdefs.client
database = admin_questdefs.database
ada = (await _create_children(client, ("Ada",)))[0]
definition_id = (await _create_definition(client, [ada])).json()["id"]
instance = await _dao_instances.QuestInstancesDao(database).upsert(
definition_id,
ada,
datetime.date.today().isoformat(),
"2026-09-14T12:00:00+00:00",
window="morning",
)
events = _dao_instances.CompletionEventsDao(database)
event = await events.append(
instance.id,
ada,
"completed",
"user",
"2026-09-14T12:00:00+00:00",
True,
actor_user_id="admin-1",
)
response = await client.delete(
f"/api/v1/admin/quest-definitions/{definition_id}",
headers=_admin_headers(),
)
assert response.status_code == 200
payload = response.json()
assert payload["definition_id"] == definition_id
assert payload["deleted"] is False
assert payload["retired"] is True
assert payload["definition"]["id"] == definition_id
assert payload["definition"]["is_active"] is False
assert payload["definition"]["title"] == "Tidy the den"
record = await _dao_rules.QuestDefinitionsDao(database).get(definition_id)
assert record is not None
assert record.is_active is False
assert await _dao_instances.QuestInstancesDao(database).get_by_id(
instance.id
) == instance
assert await events.list_by_instance(instance.id) == [event]
async def test_delete_unknown_definition_is_404(
admin_questdefs: SimpleNamespace,
) -> None:
"""A DELETE naming a definition that does not exist is 404."""
response = await admin_questdefs.client.delete(
"/api/v1/admin/quest-definitions/999",
headers=_admin_headers(),
)
assert response.status_code == 404
assert response.json()["detail"] == "Quest definition not found"
+1 -1
View File
@@ -135,7 +135,7 @@ async def test_admin_lists_active_and_inactive_definitions_with_full_shape(
"id": active_id,
"title": "Tidy the den",
"description": "Put everything back where it belongs.",
"icon": "toy-box",
"icon": "lucide:toy-box",
"is_active": True,
"skip_on_away": True,
"rule": {
+139 -8
View File
@@ -13,7 +13,8 @@ proven against the DATABASE the routes wrote, not a parallel copy.
Proven per the done-condition, everything read back through the API:
- ``GET /api/v1/admin/settings`` returns the current effective settings
— the ten documented fields at their defaults on a fresh database.
— the eleven documented fields at their defaults on a fresh database,
with no ``timezone_configured`` marker.
- ``PATCH /api/v1/admin/settings`` PERSISTS the supplied fields and the
NEXT read reflects them; a later partial update merges onto the
current effective settings (omitted fields keep their stored value),
@@ -40,6 +41,7 @@ from types import SimpleNamespace
import pytest
from api import host_zone
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
@@ -59,9 +61,14 @@ _dao_meta = importlib.import_module("nestquest_core.dao_meta")
#: independent of the core constant.
SETTINGS_META_KEY = "settings"
#: The API host's own zone, pinned for every test (:func:`settings_client`):
#: the ``effective_timezone`` while no zone is stored.
API_HOST_ZONE = "Australia/Perth"
#: The all-defaults effective settings a fresh database reports — the
#: documented ten fields, asserted literally so the payload contract is
#: pinned here, independent of the core constants.
#: documented eleven fields plus the read-only ``effective_timezone``,
#: asserted literally so the payload contract is pinned here, independent
#: of the core constants.
DEFAULT_SETTINGS = {
"horizon_days": 14,
"day_rollover_time": "00:00",
@@ -73,22 +80,35 @@ DEFAULT_SETTINGS = {
"afternoon_reminder_enabled": True,
"end_of_day_report_enabled": True,
"celebration_enabled": True,
"timezone": "",
"effective_timezone": API_HOST_ZONE,
}
def _with_zone(zone: str) -> dict:
"""The default settings with ``zone`` stored, as the API reports them."""
return {
**DEFAULT_SETTINGS,
"timezone": zone,
"effective_timezone": zone or API_HOST_ZONE,
}
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
@pytest.fixture
async def settings_client(temp_db_path: str) -> SimpleNamespace:
async def settings_client(temp_db_path: str, monkeypatch) -> SimpleNamespace:
"""An admin-plane app client plus its live database.
The same runner the children tests use (stubbed JWKS, no network);
the fixture also exposes the app's ONE database connection so tests
can read (or corrupt) the stored settings document itself.
can read (or corrupt) the stored settings document itself. The API
host's zone is pinned to :data:`API_HOST_ZONE`.
"""
monkeypatch.setattr(host_zone, "host_timezone", lambda: API_HOST_ZONE)
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(
@@ -103,7 +123,7 @@ async def settings_client(temp_db_path: str) -> SimpleNamespace:
async def test_get_settings_returns_defaults_on_a_fresh_database(
settings_client: SimpleNamespace,
) -> None:
"""GET returns the ten documented fields at their defaults."""
"""GET returns the eleven documented fields at their defaults."""
response = await settings_client.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
@@ -139,13 +159,15 @@ async def test_update_persists_and_the_next_read_reflects_it(
assert reread.status_code == 200
assert reread.json() == expected
# ...and the DATABASE holds the merged document: the complete ten
# ...and the DATABASE holds the merged document: the complete twelve
# fields, keyed by field name, under the well-known meta_state key.
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
SETTINGS_META_KEY
)
assert raw is not None
assert json.loads(raw) == expected
stored = dict(expected)
del stored["effective_timezone"] # derived on read, never stored
assert json.loads(raw) == stored
async def test_partial_update_merges_onto_the_current_settings(
@@ -221,6 +243,105 @@ async def test_null_resets_a_field_to_its_default(
assert reread.json()["horizon_days"] == 14
async def test_default_settings_carry_no_timezone_configured_marker(
settings_client: SimpleNamespace,
) -> None:
"""The removed auto-set marker is absent from the default response."""
response = await settings_client.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert response.status_code == 200
assert "timezone_configured" not in response.json()
assert response.json()["timezone"] == ""
@pytest.mark.parametrize("zone", ["America/New_York", ""])
async def test_manual_timezone_patch_persists_and_is_returned(
settings_client: SimpleNamespace, zone: str
) -> None:
"""An admin's timezone PATCH is stored exactly as given and read back."""
client = settings_client.client
seeded = await client.patch(
"/api/v1/admin/settings",
json={"timezone": "Europe/Dublin"},
headers=_admin_headers(),
)
assert seeded.status_code == 200
patched = await client.patch(
"/api/v1/admin/settings",
json={"timezone": zone},
headers=_admin_headers(),
)
assert patched.status_code == 200
expected = _with_zone(zone)
assert patched.json() == expected
reread = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert reread.json() == expected
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
SETTINGS_META_KEY
)
assert raw is not None
stored = json.loads(raw)
assert stored["timezone"] == zone
assert "timezone_configured" not in stored
@pytest.mark.parametrize("zone", ["America/Los_Angeles", ""])
async def test_timezone_alone_sticks_on_a_fresh_database(
settings_client: SimpleNamespace, zone: str
) -> None:
"""A lone ``timezone`` PATCH needs no other field to persist."""
client = settings_client.client
patched = await client.patch(
"/api/v1/admin/settings",
json={"timezone": zone},
headers=_admin_headers(),
)
assert patched.status_code == 200
reread = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert reread.json() == _with_zone(zone)
async def test_timezone_configured_is_an_unknown_field(
settings_client: SimpleNamespace,
) -> None:
"""The removed marker is rejected like any unknown field; nothing is written."""
response = await settings_client.client.patch(
"/api/v1/admin/settings",
json={"timezone_configured": True},
headers=_admin_headers(),
)
assert response.status_code == 422
assert "timezone_configured" in str(response.json()["detail"])
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
SETTINGS_META_KEY
)
assert raw is None
async def test_stored_legacy_timezone_configured_key_is_ignored(
settings_client: SimpleNamespace,
) -> None:
"""A document written before the marker was removed still loads."""
await _dao_meta.MetaStateDao(settings_client.database).set(
SETTINGS_META_KEY,
json.dumps(
{"timezone": "America/Chicago", "timezone_configured": True}
),
)
response = await settings_client.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert response.status_code == 200
assert response.json() == _with_zone("America/Chicago")
# --- validation: rejected values never change the stored settings -------
@@ -237,6 +358,9 @@ async def test_null_resets_a_field_to_its_default(
({"morning_summary_enabled": "yes"}, "morning_summary_enabled"),
({"celebration_enabled": 1}, "celebration_enabled"),
({"notify_target": 5}, "notify_target"),
({"timezone": "Mars/Olympus_Mons"}, "timezone"),
({"timezone": "America/New York"}, "timezone"),
({"timezone": 5}, "timezone"),
],
)
async def test_invalid_value_is_rejected_and_stored_settings_unchanged(
@@ -391,6 +515,13 @@ async def test_settings_routes_refuse_non_admin_and_absent_credentials(
)
assert absent_patch.status_code == 401
forbidden_zone = await client.patch(
"/api/v1/admin/settings",
json={"timezone": "America/New_York"},
headers={"Authorization": f"Bearer {non_admin}"},
)
assert forbidden_zone.status_code == 403
# No refused request changed anything: the settings are untouched.
after = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
+8 -3
View File
@@ -37,7 +37,7 @@ from zoneinfo import ZoneInfo
import pytest
from api import routes_admin, routes_panel
from api import host_zone, routes_admin, routes_panel
from api.nestquest_core import core_snapshot
from tests.admin_jwt_harness import (
ADMIN_KEY,
@@ -101,6 +101,8 @@ async def snapshot_household(
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now)
# The API host runs UTC, the zone of the pinned clock.
monkeypatch.setattr(host_zone, "host_timezone", lambda: "UTC")
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(
@@ -165,7 +167,10 @@ async def test_admin_jwt_returns_documented_snapshot(
Every field is asserted as an explicit literal, with ONE clock
read for the request. With no missed view in the real snapshot,
the admin body equals the panel body for the same household.
the admin body equals the panel body for the same household — the
panel body additionally carries the effective ``timezone`` its cards
format the visible date and clock in (none stored here: the UTC
host's own zone).
"""
client = snapshot_household.client
seed = snapshot_household.seed
@@ -241,7 +246,7 @@ async def test_admin_jwt_returns_documented_snapshot(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
assert panel.status_code == 200
assert panel.json() == body
assert panel.json() == {**body, "timezone": "UTC"}
async def test_admin_keeps_missed_instance_panel_omits_it(
+25
View File
@@ -196,6 +196,29 @@ async def test_complete_instance_posts_body_and_headers() -> None:
assert call["headers"]["Authorization"] == f"Bearer {TOKEN}"
async def test_requests_carry_only_the_bearer_header() -> None:
"""No timezone is reported to the API (task 7f685179): the Bearer
credential is the only header on every request."""
transport = StubTransport(
StubResponse(200, json.dumps(SNAPSHOT_PAYLOAD)),
StubResponse(200, '{"status": "done"}'),
)
client = make_client(transport)
await client.get_snapshot()
await client.complete_instance(42, 3)
for call in transport.calls:
assert call["headers"] == {"Authorization": f"Bearer {TOKEN}"}
def test_client_takes_no_household_timezone() -> None:
with pytest.raises(TypeError):
NestQuestApiClient(
BASE_URL, TOKEN, StubTransport(), household_timezone="UTC"
)
async def test_base_url_trailing_slash_is_normalized() -> None:
"""A base URL with a trailing slash must not double the path slash."""
transport = StubTransport(StubResponse(200, "{}"))
@@ -392,11 +415,13 @@ def test_client_from_entry_reads_config_and_shared_session(monkeypatch) -> None:
}
)
# A bare object: building the client must not read hass.config.
client = api_client.client_from_entry(object(), entry)
assert client._session is shared_session
assert client._base_url == "http://panel.lan:8443"
assert client._panel_token == "entry-token"
assert client._headers() == {"Authorization": "Bearer entry-token"}
def test_client_from_entry_unconfigured_token_raises_at_construction(
+71
View File
@@ -0,0 +1,71 @@
"""The API host's zone and the effective application timezone (task 7e173881).
With no zone stored the API reads its clock in the host's local time, so
:func:`api.host_zone.effective_timezone` names that host zone — resolved
the way the C library resolves local time — for the panel and admin app
to format in. Every host configuration here is a temporary file tree.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from api import host_zone
def _host(tmp_path: Path, *, link: str | None = None, name: str | None = None):
"""A fake ``/etc``: ``localtime`` linked into a zoneinfo tree at
``link``, and/or an ``/etc/timezone`` file holding ``name``."""
localtime = tmp_path / "etc" / "localtime"
timezone_file = tmp_path / "etc" / "timezone"
localtime.parent.mkdir(parents=True)
if link is not None:
target = tmp_path / "usr" / "share" / "zoneinfo" / link
target.parent.mkdir(parents=True)
target.write_bytes(b"TZif")
localtime.symlink_to(target)
if name is not None:
timezone_file.write_text(f"{name}\n", encoding="utf-8")
return {"timezone_file": timezone_file, "localtime": localtime}
def test_tz_environment_variable_wins(tmp_path: Path) -> None:
paths = _host(tmp_path, link="Europe/Paris")
assert host_zone.host_timezone({"TZ": "Asia/Kolkata"}, **paths) == "Asia/Kolkata"
assert host_zone.host_timezone({"TZ": ":Asia/Kolkata"}, **paths) == "Asia/Kolkata"
assert host_zone.host_timezone({"TZ": ""}, **paths) == "UTC"
def test_localtime_link_names_the_zone(tmp_path: Path) -> None:
paths = _host(tmp_path, link="America/Argentina/Buenos_Aires")
assert host_zone.host_timezone({}, **paths) == "America/Argentina/Buenos_Aires"
def test_etc_timezone_names_a_copied_localtime(tmp_path: Path) -> None:
paths = _host(tmp_path, name="Pacific/Auckland")
paths["localtime"].write_bytes(b"TZif")
assert host_zone.host_timezone({}, **paths) == "Pacific/Auckland"
def test_unconfigured_host_runs_utc(tmp_path: Path) -> None:
assert host_zone.host_timezone({}, **_host(tmp_path)) == "UTC"
@pytest.mark.parametrize("tz", ["EST+5", "Not/AZone", ":/etc/localtime"])
def test_an_unnamed_host_zone_is_empty(tmp_path: Path, tz: str) -> None:
assert host_zone.host_timezone({"TZ": tz}, **_host(tmp_path)) == ""
def test_unnamed_copied_localtime_is_empty(tmp_path: Path) -> None:
paths = _host(tmp_path)
paths["localtime"].write_bytes(b"TZif")
assert host_zone.host_timezone({}, **paths) == ""
def test_effective_timezone_is_the_stored_zone_else_the_host_zone(
monkeypatch,
) -> None:
monkeypatch.setattr(host_zone, "host_timezone", lambda: "Europe/Dublin")
assert host_zone.effective_timezone("America/Chicago") == "America/Chicago"
assert host_zone.effective_timezone("") == "Europe/Dublin"
+911
View File
@@ -0,0 +1,911 @@
"""Household-local clock tests for the API service (task fa7475ba).
The deployed API container runs UTC while the household is US Eastern,
so reading the HOST clock marked every morning quest overdue from the
small hours. The API now re-expresses its ONE host clock read in the
stored ``timezone`` setting (``NestQuestSettings.household_now``).
Every test pins the host clock to a UTC instant (the deployed
container's zone) and sets the household zone through the real admin
``PATCH /api/v1/admin/settings`` route, then reads BOTH snapshot shapes:
- one second before the 10:00 due time: NOT overdue (previously
flagged, because 13:59:59 UTC > 10:00);
- exactly at 10:00:00: NOT overdue (the builder's strict
``now.time() > due`` convention);
- one second after: overdue;
- 23:30 household time, already the next day in UTC: ``today_iso`` is
still the household's day.
An unset zone keeps the historical host-local behaviour, and the
missed-sweep scheduler waits for the rollover in household time.
Nothing sets the zone automatically: a timezone header on a panel
request never changes the stored settings (task 7f685179).
"""
from __future__ import annotations
import asyncio
import datetime
import importlib
from types import SimpleNamespace
from zoneinfo import ZoneInfo
import pytest
from api import host_zone, routes_admin, routes_panel
from api import scheduler as scheduler_module
from api.database import _executor
from api.scheduler import MissedSweepScheduler, _delay_seconds
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
AdminRunner,
jwks_for,
make_token,
)
from tests.test_api_panel import _seed_household
_settings = importlib.import_module("nestquest_core.settings")
_settings_store = importlib.import_module("nestquest_core.settings_store")
_core_db = importlib.import_module("nestquest_core.db")
_core_migrations = importlib.import_module("nestquest_core.migrations")
_dao_meta = importlib.import_module("nestquest_core.dao_meta")
#: The panel service token the admin harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
UTC = ZoneInfo("UTC")
HOUSEHOLD_ZONE = "America/New_York"
#: A fixed household day on Eastern Daylight Time (UTC-4).
TODAY = datetime.date(2026, 9, 26)
def _utc(hour: int, minute: int, second: int = 0, *, day: int = 26):
"""An aware UTC instant on September ``day`` 2026."""
return datetime.datetime(2026, 9, day, hour, minute, second, tzinfo=UTC)
def _admin_headers() -> dict[str, str]:
return {"Authorization": f"Bearer {make_token()}"}
def _panel_headers() -> dict[str, str]:
return {"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}
@pytest.fixture
async def household(temp_db_path: str, monkeypatch) -> SimpleNamespace:
"""The seeded household on the admin-plane app, host clock pinnable.
Ada's open "Pack bag" quest is due 10:00 on :data:`TODAY`. Both
routes' host clock read returns ``clock["now"]`` (a UTC instant).
"""
seed = await _seed_household(temp_db_path, TODAY, _utc(16, 0))
clock = {"now": _utc(12, 0)}
monkeypatch.setattr(routes_admin, "_local_now", lambda: clock["now"])
monkeypatch.setattr(routes_panel, "_local_now", lambda: clock["now"])
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(
client=client,
seed=seed,
clock=clock,
database=runner.app.state.db.database,
app=runner.app,
)
async def _set_zone(household: SimpleNamespace, zone: str) -> None:
response = await household.client.patch(
"/api/v1/admin/settings",
json={"timezone": zone},
headers=_admin_headers(),
)
assert response.status_code == 200
assert response.json()["timezone"] == zone
async def _pack_bag(household: SimpleNamespace, plane: str) -> tuple[str, dict]:
"""Return ``(today_iso, Ada's Pack bag instance)`` from one snapshot."""
headers = _admin_headers() if plane == "admin" else _panel_headers()
response = await household.client.get(
f"/api/v1/{plane}/snapshot", headers=headers
)
assert response.status_code == 200
body = response.json()
ada = next(
child
for child in body["children"]
if child["child_id"] == household.seed.ada.id
)
(instance,) = ada["instances"]
assert instance["title"] == "Pack bag"
assert instance["due_time"] == "10:00"
return body["today_iso"], instance
@pytest.mark.parametrize("plane", ["panel", "admin"])
@pytest.mark.parametrize(
("host_now", "overdue"),
[
# 09:59:59 EDT — the pre-fix code compared 13:59:59 (UTC) > 10:00.
(_utc(13, 59, 59), False),
# 10:00:00 EDT exactly — strict ``>``: not yet overdue.
(_utc(14, 0, 0), False),
# 10:00:01 EDT — overdue.
(_utc(14, 0, 1), True),
],
ids=["before-due", "at-due", "after-due"],
)
async def test_overdue_boundary_uses_household_zone(
household: SimpleNamespace,
plane: str,
host_now: datetime.datetime,
overdue: bool,
) -> None:
await _set_zone(household, HOUSEHOLD_ZONE)
household.clock["now"] = host_now
today_iso, instance = await _pack_bag(household, plane)
assert today_iso == TODAY.isoformat()
assert instance["state"] == "open"
assert instance["overdue"] is overdue
@pytest.mark.parametrize("plane", ["panel", "admin"])
async def test_household_day_survives_utc_midnight(
household: SimpleNamespace, plane: str
) -> None:
"""23:30 EDT is already tomorrow in UTC; the household day is today."""
await _set_zone(household, HOUSEHOLD_ZONE)
household.clock["now"] = _utc(3, 30, day=27)
today_iso, instance = await _pack_bag(household, plane)
assert today_iso == TODAY.isoformat()
assert instance["overdue"] is True
@pytest.mark.parametrize("plane", ["panel", "admin"])
async def test_unset_zone_keeps_host_local_time(
household: SimpleNamespace, plane: str
) -> None:
"""Backward compatible: no stored zone reads the host clock as-is."""
household.clock["now"] = _utc(13, 59, 59)
today_iso, instance = await _pack_bag(household, plane)
assert today_iso == TODAY.isoformat()
assert instance["overdue"] is True
async def test_panel_completion_on_time_uses_household_zone(
household: SimpleNamespace,
) -> None:
"""A 09:30 EDT tap on a 10:00 quest is on time (13:30 UTC is not)."""
await _set_zone(household, HOUSEHOLD_ZONE)
household.clock["now"] = _utc(13, 30)
seed = household.seed
response = await household.client.post(
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
headers=_panel_headers(),
json={"actor_child_id": seed.ada.id},
)
assert response.status_code == 200
_today_iso, instance = await _pack_bag(household, "admin")
assert instance["state"] == "completed"
assert instance["on_time"] is True
async def test_invalid_zone_is_rejected(household: SimpleNamespace) -> None:
response = await household.client.patch(
"/api/v1/admin/settings",
json={"timezone": "Mars/Olympus_Mons"},
headers=_admin_headers(),
)
assert response.status_code == 422
assert "timezone" in str(response.json()["detail"])
reread = await household.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert reread.json()["timezone"] == ""
# --- the settings object ---------------------------------------------------
def test_settings_timezone_defaults_to_host_local() -> None:
settings = _settings.NestQuestSettings()
assert settings.timezone == ""
instant = _utc(13, 0)
assert settings.household_now(instant) is instant
def test_settings_household_now_converts_the_same_instant() -> None:
settings = _settings.NestQuestSettings(timezone=HOUSEHOLD_ZONE)
local = settings.household_now(_utc(13, 0))
assert local == _utc(13, 0)
assert (local.date(), local.time()) == (TODAY, datetime.time(9, 0))
@pytest.mark.parametrize("value", ["Mars/Olympus_Mons", "../etc/passwd", 5, None])
def test_settings_rejects_invalid_timezone(value: object) -> None:
with pytest.raises(ValueError, match="timezone"):
_settings.NestQuestSettings(timezone=value)
def test_from_options_reads_and_resiliently_defaults_timezone() -> None:
assert (
_settings.NestQuestSettings.from_options(
{"timezone": HOUSEHOLD_ZONE}
).timezone
== HOUSEHOLD_ZONE
)
assert _settings.NestQuestSettings.from_options({}).timezone == ""
assert (
_settings.NestQuestSettings.from_options_resilient(
{"timezone": "Nowhere/Land", "horizon_days": 9}
).timezone
== ""
)
def test_settings_have_no_timezone_configured_marker() -> None:
assert not hasattr(_settings.NestQuestSettings(), "timezone_configured")
with pytest.raises(TypeError):
_settings.NestQuestSettings(timezone_configured=True)
# A stored document still carrying the removed key loads harmlessly.
assert _settings.NestQuestSettings.from_options_resilient(
{"timezone": HOUSEHOLD_ZONE, "timezone_configured": True}
) == _settings.NestQuestSettings(timezone=HOUSEHOLD_ZONE)
# --- the missed-sweep scheduler ----------------------------------------------
async def test_scheduler_waits_for_rollover_in_household_time(
temp_db_path: str,
) -> None:
"""09:00 EDT (13:00 UTC) to a 10:05 rollover is 65 minutes away."""
database = _core_db.NestQuestDatabase(_executor)
await database.open(temp_db_path)
try:
await _core_migrations.apply_migrations(database)
await _settings_store.update_settings(
database,
{"day_rollover_time": "10:05", "timezone": HOUSEHOLD_ZONE},
)
delays: list[float] = []
first_sleep = asyncio.Event()
async def sleep_stub(seconds: float) -> None:
delays.append(seconds)
first_sleep.set()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
scheduler.start()
try:
await asyncio.wait_for(first_sleep.wait(), timeout=5)
finally:
await scheduler.stop()
assert delays == [pytest.approx(65 * 60)]
finally:
await database.close()
# --- the rollover delay across DST (America/New_York) ------------------------
NEW_YORK = ZoneInfo("America/New_York")
def _ny(*args: int, fold: int = 0) -> datetime.datetime:
return datetime.datetime(*args, tzinfo=NEW_YORK, fold=fold)
@pytest.mark.parametrize(
("now", "target", "expected"),
[
# Ordinary EDT day: 09:00 -> 10:05 is 65 minutes.
(_ny(2026, 6, 15, 9, 0), datetime.time(10, 5), 65 * 60),
# An equal time schedules for tomorrow (strictly after now).
(_ny(2026, 6, 15, 10, 5), datetime.time(10, 5), 86400),
# Spring-forward: 01:00 EST -> 03:00 EDT is one real hour.
(_ny(2026, 3, 8, 1, 0), datetime.time(3, 0), 3600),
# Fall-back: 00:30 EDT -> 02:00 EST is two and a half real hours.
(_ny(2026, 11, 1, 0, 30), datetime.time(2, 0), 9000),
# Nonexistent 02:30 on spring-forward day: the first valid instant
# at/after it is 03:00 EDT, one real hour after 01:00 EST.
(_ny(2026, 3, 8, 1, 0), datetime.time(2, 30), 3600),
# Ambiguous 01:30 on fall-back day resolves to the FIRST (EDT)
# occurrence: from 00:30 EDT that is one real hour.
(_ny(2026, 11, 1, 0, 30), datetime.time(1, 30), 3600),
# Already past the first 01:30 (now is 01:15 EST, the repeated
# hour): not the second occurrence, but tomorrow's 01:30 EST.
(_ny(2026, 11, 1, 1, 15, fold=1), datetime.time(1, 30), 87300),
],
)
def test_delay_seconds_is_true_elapsed_time_across_dst(
now: datetime.datetime, target: datetime.time, expected: float
) -> None:
assert _delay_seconds(target, now) == expected
# --- nothing sets the zone automatically (task 7f685179) --------------------
#
# The zone is only ever set by an admin's explicit settings update. A
# timezone header on a panel request — the one Home Assistant used to
# report, or any look-alike — is ignored, and no read writes settings.
#: Timezone-looking request headers a panel request might carry.
_TIMEZONE_HEADERS = [
{"X-NestQuest-Timezone": HOUSEHOLD_ZONE},
{"X-NestQuest-Time-Zone": HOUSEHOLD_ZONE},
{"X-Timezone": HOUSEHOLD_ZONE},
{"Time-Zone": HOUSEHOLD_ZONE},
]
async def _stored_document(household: SimpleNamespace) -> str | None:
"""The raw stored settings document (``None`` when never written)."""
return await _dao_meta.MetaStateDao(household.database).get(
_settings_store.SETTINGS_META_KEY
)
async def _admin_settings(household: SimpleNamespace) -> dict:
response = await household.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert response.status_code == 200
return response.json()
@pytest.mark.parametrize("extra", _TIMEZONE_HEADERS)
async def test_panel_timezone_header_never_changes_settings(
household: SimpleNamespace, extra: dict[str, str]
) -> None:
"""09:59:59 EDT on a UTC host, zone unset: the header is ignored."""
household.clock["now"] = _utc(13, 59, 59)
headers = {**_panel_headers(), **extra}
snapshot = await household.client.get(
"/api/v1/panel/snapshot", headers=headers
)
assert snapshot.status_code == 200
seed = household.seed
completed = await household.client.post(
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
headers=headers,
json={"actor_child_id": seed.ada.id},
)
assert completed.status_code == 200
# Nothing was written, and the clock stayed host local (UTC): the
# 10:00 quest was overdue and the 13:59 UTC completion was late.
assert await _stored_document(household) is None
assert (await _admin_settings(household))["timezone"] == ""
_today_iso, instance = await _pack_bag(household, "admin")
assert instance["on_time"] is False
@pytest.mark.parametrize("extra", _TIMEZONE_HEADERS)
async def test_panel_timezone_header_never_replaces_the_stored_zone(
household: SimpleNamespace, extra: dict[str, str]
) -> None:
"""A stored zone stays exactly as the admin saved it."""
await _set_zone(household, "America/Los_Angeles")
before = await _stored_document(household)
household.clock["now"] = _utc(16, 59, 59) # 09:59:59 PDT, 12:59:59 EDT
response = await household.client.get(
"/api/v1/panel/snapshot",
headers={**_panel_headers(), **extra},
)
assert response.status_code == 200
assert await _stored_document(household) == before
_today_iso, instance = await _pack_bag(household, "panel")
assert instance["overdue"] is False
async def test_fresh_install_zone_stays_empty_across_reads(
household: SimpleNamespace,
) -> None:
"""Repeated panel and admin reads never write the settings document."""
for hour in (3, 13, 23):
household.clock["now"] = _utc(hour, 0)
for extra in _TIMEZONE_HEADERS:
response = await household.client.get(
"/api/v1/panel/snapshot",
headers={**_panel_headers(), **extra},
)
assert response.status_code == 200
await _pack_bag(household, "admin")
settings = await _admin_settings(household)
assert settings["timezone"] == ""
assert "timezone_configured" not in settings
assert await _stored_document(household) is None
# --- the scheduler follows a changed zone (task 6351b36d) --------------------
#
# On a UTC host with no stored zone the scheduler first plans for UTC
# midnight; a zone stored while it sleeps must move the sweep to the
# household's midnight rather than sweep at 20:00 EDT.
#: 13:00 UTC (09:00 EDT) to UTC midnight.
_UTC_MIDNIGHT_DELAY = 11 * 3600
#: 13:00 UTC (09:00 EDT) to New York midnight (04:00 UTC).
_NEW_YORK_MIDNIGHT_DELAY = 15 * 3600
async def _store_zone(scheduler, database, zone: str) -> None:
"""Store ``zone`` the way a settings writer must: under the lock."""
async with scheduler.settings_lock:
settings = await _settings_store.update_settings(
database, {"timezone": zone}
)
scheduler.settings_changed(settings)
async def test_scheduler_replans_when_zone_changes_while_sleeping(
temp_db_path: str,
) -> None:
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner:
app_state = runner.app.state
await app_state.sweep_scheduler.stop()
delays: list[float] = []
slept = asyncio.Condition()
async def sleep_stub(seconds: float) -> None:
async with slept:
delays.append(seconds)
slept.notify_all()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
app_state.db.database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
app_state.sweep_scheduler = scheduler
scheduler.start()
try:
async with slept:
await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 1), timeout=5
)
await _store_zone(
scheduler, app_state.db.database, HOUSEHOLD_ZONE
)
async with slept:
await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 2), timeout=5
)
finally:
await scheduler.stop()
assert delays == [
pytest.approx(_UTC_MIDNIGHT_DELAY),
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
]
async def test_scheduler_rechecks_settings_before_sweeping(
temp_db_path: str, monkeypatch
) -> None:
"""A zone stored without a wake-up still stops the stale sweep."""
database = _core_db.NestQuestDatabase(_executor)
await database.open(temp_db_path)
try:
await _core_migrations.apply_migrations(database)
sweeps: list[datetime.date] = []
async def sweep_stub(_database, *, today):
sweeps.append(today)
return []
monkeypatch.setattr(
scheduler_module.core_sweep, "run_missed_sweep", sweep_stub
)
delays: list[float] = []
replanned = asyncio.Event()
async def sleep_stub(seconds: float) -> None:
delays.append(seconds)
if len(delays) == 1:
# The UTC rollover "arrives" after the zone was stored
# behind the scheduler's back.
await _settings_store.update_settings(
database, {"timezone": HOUSEHOLD_ZONE}
)
return
replanned.set()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
scheduler.start()
try:
await asyncio.wait_for(replanned.wait(), timeout=5)
finally:
await scheduler.stop()
assert sweeps == []
assert delays == [
pytest.approx(_UTC_MIDNIGHT_DELAY),
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
]
finally:
await database.close()
# --- the admin settings route is the scheduler's writer (task 7e173881) ------
#
# ``PATCH /api/v1/admin/settings`` is the only settings writer: it holds
# the scheduler's settings lock across the write and tells the scheduler,
# so a saved zone or rollover re-plans a sleeping scheduler.
async def _sleeping_scheduler(household: SimpleNamespace):
"""Swap in a scheduler on the pinned clock; return it and its delays.
Waits until it sleeps on its first plan.
"""
await household.app.state.sweep_scheduler.stop()
delays: list[float] = []
slept = asyncio.Condition()
async def sleep_stub(seconds: float) -> None:
async with slept:
delays.append(seconds)
slept.notify_all()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
household.database,
clock=lambda: household.clock["now"],
sleep=sleep_stub,
)
household.app.state.sweep_scheduler = scheduler
scheduler.start()
async def wait_for_sleeps(count: int) -> None:
async with slept:
await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == count), timeout=5
)
await wait_for_sleeps(1)
return scheduler, delays, wait_for_sleeps
@pytest.mark.parametrize(
("changes", "replanned_delay"),
[
# 13:00 UTC is 09:00 EDT: New York midnight is 15 hours away.
({"timezone": HOUSEHOLD_ZONE}, _NEW_YORK_MIDNIGHT_DELAY),
# Still UTC: 13:00 to a 14:30 rollover is 90 minutes.
({"day_rollover_time": "14:30"}, 90 * 60),
],
ids=["timezone", "day_rollover_time"],
)
async def test_admin_settings_patch_replans_a_sleeping_scheduler(
household: SimpleNamespace, changes: dict, replanned_delay: float
) -> None:
household.clock["now"] = _utc(13, 0)
scheduler, delays, wait_for_sleeps = await _sleeping_scheduler(household)
try:
response = await household.client.patch(
"/api/v1/admin/settings", json=changes, headers=_admin_headers()
)
assert response.status_code == 200
await wait_for_sleeps(2)
finally:
await scheduler.stop()
assert delays == [
pytest.approx(_UTC_MIDNIGHT_DELAY),
pytest.approx(replanned_delay),
]
async def test_admin_saved_zone_is_authoritative_across_surfaces(
household: SimpleNamespace,
) -> None:
"""ONE instant, read by every surface in exactly the admin's zone.
Asia/Kolkata (UTC+05:30) matches neither the UTC host nor any other
zone in play. 04:30:01 UTC is 10:00:01 there: the 10:00 quest is
overdue on both snapshots (it would not be in UTC, 04:30), the
household day is the 26th, and the scheduler sleeps the 299 s to a
10:05 rollover (UTC would be 5h35m).
"""
household.clock["now"] = _utc(4, 30, 1)
scheduler, delays, wait_for_sleeps = await _sleeping_scheduler(household)
try:
response = await household.client.patch(
"/api/v1/admin/settings",
json={"timezone": "Asia/Kolkata", "day_rollover_time": "10:05"},
headers=_admin_headers(),
)
assert response.status_code == 200
await wait_for_sleeps(2)
for plane in ("panel", "admin"):
today_iso, instance = await _pack_bag(household, plane)
assert today_iso == TODAY.isoformat(), plane
assert instance["overdue"] is True, plane
finally:
await scheduler.stop()
assert delays[1] == pytest.approx(299)
async def test_empty_zone_publishes_the_api_host_zone_to_panel_and_admin(
household: SimpleNamespace, monkeypatch
) -> None:
"""With no zone stored, the API reads the host's local time — and the
panel snapshot and the admin settings both publish that host's own
zone, so the cards and the admin app format in the API's zone, never
Home Assistant's or the browser's.
The host runs Pacific/Auckland: 12:30 UTC on the 26th is already
00:30 on the 27th there, so the API's household day is the 27th while
a UTC Home Assistant or browser would still read the 26th. Once the
admin saves a zone, both publish exactly that zone.
"""
host = "Pacific/Auckland"
monkeypatch.setattr(host_zone, "host_timezone", lambda: host)
household.clock["now"] = _utc(12, 30).astimezone(ZoneInfo(host))
panel = await household.client.get(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
assert panel.status_code == 200
settings = await _admin_settings(household)
assert settings["timezone"] == ""
assert panel.json()["timezone"] == settings["effective_timezone"] == host
# The published zone reproduces the API's own household day.
assert panel.json()["today_iso"] == "2026-09-27"
assert (
_utc(12, 30).astimezone(ZoneInfo(host)).date().isoformat()
== panel.json()["today_iso"]
)
await _set_zone(household, "America/Chicago")
panel = await household.client.get(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
settings = await _admin_settings(household)
assert settings["timezone"] == "America/Chicago"
assert panel.json()["timezone"] == settings["effective_timezone"]
assert settings["effective_timezone"] == "America/Chicago"
# --- settings writes are serialised with planning and sweeping (TZ-003) ------
#
# A zone write racing the loop's own settings reads must never leave a
# stale plan behind. Each test holds the loop at one of its reads and
# issues a locked zone write there; when the scheduler's settings lock
# is held at that point the write provably cannot commit until the
# loop moves on, otherwise it is awaited to commit inside the window.
def _hold_scheduler_read(monkeypatch, scheduler, nth: int, during):
"""Run ``during(settings)`` inside the scheduler task's ``nth`` read."""
real_load = _settings_store.load_settings
reads = {"count": 0}
async def load_settings(database):
settings = await real_load(database)
if asyncio.current_task() is scheduler._task:
reads["count"] += 1
if reads["count"] == nth:
await during(settings)
return settings
monkeypatch.setattr(
scheduler_module.core_settings_store, "load_settings", load_settings
)
return real_load
async def _store_inside_read(scheduler, database, zone: str) -> asyncio.Task:
"""Start a locked write of ``zone``; let it commit unless serialised."""
write = asyncio.create_task(_store_zone(scheduler, database, zone))
if not scheduler.settings_lock.locked():
await write
return write
async def test_scheduler_follows_successive_zone_changes(
temp_db_path: str, monkeypatch
) -> None:
"""New York -> UTC -> New York while the re-plan read is in flight."""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner:
app_state = runner.app.state
database = app_state.db.database
await app_state.sweep_scheduler.stop()
delays: list[float] = []
slept = asyncio.Condition()
async def sleep_stub(seconds: float) -> None:
async with slept:
delays.append(seconds)
slept.notify_all()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
app_state.sweep_scheduler = scheduler
await _store_zone(scheduler, database, HOUSEHOLD_ZONE)
writes: list[asyncio.Task] = []
async def back_to_new_york(_settings) -> None:
writes.append(
await _store_inside_read(scheduler, database, HOUSEHOLD_ZONE)
)
# Read 1 plans New York; read 2 is the re-plan after UTC.
_hold_scheduler_read(monkeypatch, scheduler, 2, back_to_new_york)
scheduler.start()
try:
async with slept:
await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 1), timeout=5
)
await _store_zone(scheduler, database, "UTC")
async with slept:
await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 3), timeout=5
)
(write,) = writes
await write
finally:
await scheduler.stop()
# The in-flight read planned UTC; the New York write that followed
# it woke the wait, so the scheduler sleeps on New York midnight.
assert delays == [
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
pytest.approx(_UTC_MIDNIGHT_DELAY),
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
]
async def test_zone_write_after_presweep_check_cannot_precede_the_sweep(
temp_db_path: str, monkeypatch
) -> None:
"""No sweep runs once a zone write has replaced its plan."""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner:
app_state = runner.app.state
await app_state.sweep_scheduler.stop()
delays: list[float] = []
replanned = asyncio.Event()
async def sleep_stub(seconds: float) -> None:
delays.append(seconds)
if len(delays) == 1:
return # the UTC rollover arrives
replanned.set()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
app_state.db.database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
app_state.sweep_scheduler = scheduler
writes: list[asyncio.Task] = []
async def store_new_york(_settings) -> None:
writes.append(
await _store_inside_read(
scheduler, app_state.db.database, HOUSEHOLD_ZONE
)
)
# Read 1 plans UTC; read 2 is the pre-sweep re-check.
real_load = _hold_scheduler_read(
monkeypatch, scheduler, 2, store_new_york
)
swept_under: list[str] = []
async def sweep_stub(database, *, today):
swept_under.append((await real_load(database)).timezone)
return []
monkeypatch.setattr(
scheduler_module.core_sweep, "run_missed_sweep", sweep_stub
)
scheduler.start()
try:
await asyncio.wait_for(replanned.wait(), timeout=5)
(write,) = writes
await write
finally:
await scheduler.stop()
# The sweep ran for the UTC plan with UTC still stored, never after
# New York had been stored; the next plan is New York midnight.
assert swept_under == [""]
assert delays == [
pytest.approx(_UTC_MIDNIGHT_DELAY),
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
]
# --- the wait's child tasks (TZ-004) ------------------------------------------
async def test_failed_sleep_is_retried_not_swept(
temp_db_path: str, monkeypatch
) -> None:
"""A sleep that raises backs off and re-plans; nothing is swept."""
database = _core_db.NestQuestDatabase(_executor)
await database.open(temp_db_path)
try:
await _core_migrations.apply_migrations(database)
sweeps: list[datetime.date] = []
async def sweep_stub(_database, *, today):
sweeps.append(today)
return []
monkeypatch.setattr(
scheduler_module.core_sweep, "run_missed_sweep", sweep_stub
)
delays: list[float] = []
replanned = asyncio.Event()
async def sleep_stub(seconds: float) -> None:
delays.append(seconds)
if len(delays) == 1:
raise RuntimeError("timer failed")
if len(delays) == 2:
return # the retry backoff
replanned.set()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
scheduler.start()
try:
await asyncio.wait_for(replanned.wait(), timeout=5)
finally:
await scheduler.stop()
assert sweeps == []
assert delays == [
pytest.approx(_UTC_MIDNIGHT_DELAY),
scheduler_module._RETRY_BACKOFF_SECONDS,
pytest.approx(_UTC_MIDNIGHT_DELAY),
]
finally:
await database.close()
async def test_stop_waits_for_the_sleep_to_finish_cancelling(
temp_db_path: str,
) -> None:
database = _core_db.NestQuestDatabase(_executor)
await database.open(temp_db_path)
try:
await _core_migrations.apply_migrations(database)
sleeping = asyncio.Event()
sleepers: list[asyncio.Task] = []
cleaned_up: list[bool] = []
async def sleep_stub(_seconds: float) -> None:
sleepers.append(asyncio.current_task())
sleeping.set()
try:
await asyncio.Event().wait()
finally:
# Cancellation cleanup that takes a loop turn.
await asyncio.sleep(0)
cleaned_up.append(True)
scheduler = MissedSweepScheduler(
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
scheduler.start()
try:
await asyncio.wait_for(sleeping.wait(), timeout=5)
finally:
await scheduler.stop()
(sleeper,) = sleepers
assert sleeper.done()
assert cleaned_up == [True]
finally:
await database.close()
+1
View File
@@ -60,6 +60,7 @@ def _payload(children) -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": 0,
"timezone": "",
"children": list(children),
}
+1
View File
@@ -55,6 +55,7 @@ def _empty_snapshot() -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": 0,
"timezone": "",
"children": [],
}
+1 -1
View File
@@ -834,7 +834,7 @@ def test_complete_instance_no_due_time_after_due_date_is_late(
def test_complete_instance_ha_local_wall_clock_not_utc(tmp_path) -> None:
"""HA-local 11:00 with due_time 12:00 is on time even when UTC is 19:00.
"""Household-local 11:00, due 12:00, is on time even when UTC is 19:00.
The helper's zone is UTC-8, so 11:00 local is 19:00 UTC. Converting
to UTC before comparing would mark this late.
+33 -1
View File
@@ -93,6 +93,7 @@ class FakeMonotonic:
FIXTURE_SNAPSHOT = {
"today_iso": _today_iso(),
"cycle_day": 3,
"timezone": "",
"children": [
{
"child_id": 1,
@@ -686,6 +687,8 @@ async def test_coordinator_configured_token_builds_real_client(
hass, entry, session_factory=_session_factory
)
assert isinstance(real_client, NestQuestApiClient)
# Home Assistant's zone is not reported to the API (task 7f685179).
assert real_client._headers() == {"Authorization": "Bearer tok"}
set_coordinator_client(entry, real_client)
assert await async_setup_entry(hass, entry) is True
@@ -791,8 +794,37 @@ async def test_snapshot_from_api_payload_empty_household() -> None:
"""An empty household (no children) is a valid snapshot: empty
children tuple, cycle day passes through."""
snapshot = snapshot_from_api_payload(
{"today_iso": "2026-09-23", "cycle_day": 0, "children": []}
{
"today_iso": "2026-09-23",
"cycle_day": 0,
"timezone": "",
"children": [],
}
)
assert snapshot.children == ()
assert snapshot.cycle_day == 0
assert snapshot.today_iso == "2026-09-23"
#: The panel snapshot exactly as an API from before the ``timezone`` field
#: serves it: an integration upgraded ahead of its API still reads this.
LEGACY_SNAPSHOT = {
key: value for key, value in FIXTURE_SNAPSHOT.items() if key != "timezone"
}
async def test_coordinator_reads_a_legacy_payload_without_timezone(
hass, make_entry
) -> None:
"""An older API omits ``timezone``: every refresh still builds the
snapshot (the cards then fall back to Home Assistant's zone)."""
assert "timezone" not in LEGACY_SNAPSHOT
client = StubSnapshotClient(LEGACY_SNAPSHOT)
_entry, coordinator = await _wire_entry(hass, make_entry, client)
assert coordinator.last_update_success is True
snapshot = await coordinator._async_update_data()
assert snapshot.timezone == ""
assert snapshot.cycle_day == 3
assert [child.child_name for child in snapshot.children] == ["Ada", "Bo"]
assert snapshot_from_api_payload(LEGACY_SNAPSHOT).timezone == ""
+152
View File
@@ -0,0 +1,152 @@
"""Test the namespaced icon-key module (task 707994b7).
:func:`validate_icon` is the strict write-side check (create/edit);
:func:`normalize_icon_for_read` the lenient read-side normalizer that
lets legacy bare Lucide names render without a data migration.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from custom_components.nestquest.core.icons import (
EMOJI_MAX_CODE_POINTS,
ICON_NAMESPACES,
normalize_icon_for_read,
validate_icon,
)
def test_namespace_and_cap_constants() -> None:
assert ICON_NAMESPACES == {"lucide", "fa", "emoji"}
assert EMOJI_MAX_CODE_POINTS == 8
@pytest.mark.parametrize(
("value", "expected"),
[
("lucide:dog", "lucide:dog"),
("fa:dog", "fa:dog"),
("fa:paw-print", "fa:paw-print"),
("lucide:toy-box-2", "lucide:toy-box-2"),
("emoji:🐕", "emoji:🐕"),
# A ZWJ family sequence (7 code points) is within the cap.
("emoji:👨‍👩‍👧‍👦", "emoji:👨‍👩‍👧‍👦"),
("emoji:" + "⭐" * EMOJI_MAX_CODE_POINTS, "emoji:" + "⭐" * 8),
# Legacy bare Lucide names normalize to the lucide namespace.
("dog", "lucide:dog"),
],
)
def test_validate_icon_accepts(value: str, expected: str) -> None:
assert validate_icon(value) == expected
@pytest.mark.parametrize("value", [None, "", " ", "\t\n"])
def test_validate_icon_clears(value: str | None) -> None:
assert validate_icon(value) is None
@pytest.mark.parametrize(
"value",
[
"foo:dog", # unknown namespace
"javascript:alert(1)", # unknown namespace (and markup-ish)
"mdi:dog", # not one of the supported namespaces
"lucide:", # empty name
"fa:", # empty name
"emoji:", # empty payload
"lucide:Dog", # uppercase
"lucide:has space", # whitespace
"lucide:-dog", # leading hyphen
"lucide:dog-", # trailing hyphen
"lucide:dog_bone", # not kebab
"fa:dög", # non-ASCII
"lucide:a:b", # colon inside the name
"Dog", # bare legacy name must still be kebab
"has space",
"<script>",
"emoji:" + "⭐" * (EMOJI_MAX_CODE_POINTS + 1), # over the cap
"emoji:a b", # whitespace
"emoji:<b>", # markup
"emoji:a&b", # entity start
"emoji:🐕\x00", # control character
"emoji:🐕\x07🐕", # control character between emoji
"emoji:\ufeff", # U+FEFF BOM: JS \s matches it, so reject to agree
"emoji:🐕\ufeff", # trailing U+FEFF
# Edge whitespace/controls are part of the value, never trimmed away.
"emoji:🐕 ", # trailing space
"emoji:🐕\n", # trailing newline
"emoji:🐕\x1c", # trailing control character (str.strip() eats it)
" emoji:🐕", # leading space
"lucide:dog ", # trailing space
" fa:dog ", # padded namespaced key
" toy-box ", # padded legacy bare name
],
)
def test_validate_icon_rejects_naming_the_field(value: str) -> None:
with pytest.raises(ValueError, match=r"^icon\b"):
validate_icon(value)
@pytest.mark.parametrize("value", [42, True, ["dog"], {"icon": "dog"}])
def test_validate_icon_rejects_non_strings(value: object) -> None:
with pytest.raises(ValueError, match="icon must be a string"):
validate_icon(value)
@pytest.mark.parametrize(
("value", "expected"),
[
(None, None),
("", None),
(" ", None),
("lucide:dog", "lucide:dog"),
("fa:dog", "fa:dog"),
("emoji:🐕", "emoji:🐕"),
# Legacy bare names (and anything else) fall through to lucide so
# the frontend's fallback handles an unknown name, never an error.
("dog", "lucide:dog"),
("mdi:tooth", "lucide:mdi:tooth"),
("Not Kebab", "lucide:Not Kebab"),
],
)
def test_normalize_icon_for_read(value: str | None, expected: str | None) -> None:
assert normalize_icon_for_read(value) == expected
# The emoji-payload rule is duplicated in the generated JS resolver
# (``isEmojiPayload`` in tools/icons/generate.mjs). Both suites assert
# this one table — admin/src/icons/registry.test.ts runs it through
# resolveIcon — so a divergence between the runtimes fails a test.
_EMOJI_PARITY_CASES = json.loads(
(
Path(__file__).parent.parent / "tools" / "icons" / "emoji-payload-cases.json"
).read_text(encoding="utf-8")
)["cases"]
@pytest.mark.parametrize(
"case", _EMOJI_PARITY_CASES, ids=[case["why"] for case in _EMOJI_PARITY_CASES]
)
def test_validate_icon_emoji_verdict_matches_shared_table(case: dict) -> None:
value = "emoji:" + case["payload"]
if case["valid"]:
assert validate_icon(value) == value
else:
with pytest.raises(ValueError, match=r"^icon\b"):
validate_icon(value)
def test_emoji_parity_table_covers_the_contract_edges() -> None:
payloads = {case["payload"]: case["valid"] for case in _EMOJI_PARITY_CASES}
assert payloads["\ufeff"] is False
assert payloads["🐕 "] is False
assert payloads["🐕\x00"] is False
assert payloads["<b>"] is False and payloads["a>b"] is False
assert payloads["a&b"] is False
assert payloads["⭐" * EMOJI_MAX_CODE_POINTS] is True
assert payloads["⭐" * (EMOJI_MAX_CODE_POINTS + 1)] is False
family = "👨\u200d👩\u200d👧\u200d👦"
assert payloads[family] is True
+2 -1
View File
@@ -136,7 +136,7 @@ async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> Non
# Single clock read for the seed/snapshot anchor (no midnight skew);
# the coordinator no longer owns a clock read (the API serves the
# snapshot), so the test resolves the HA-local zone directly.
# snapshot), so the test resolves its own local zone directly.
time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
# Pin the build instant at 12:00 local: past Ada's 10:00 due time
@@ -332,6 +332,7 @@ async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> Non
return {
"today_iso": built.today_iso,
"cycle_day": built.cycle_day,
"timezone": built.timezone,
"children": [
{
"child_id": child.child_id,
+6 -3
View File
@@ -996,8 +996,9 @@ def test_definition_set_active_missing_returns_zero(tmp_path) -> None:
assert _with_db(tmp_path, "definition-set-active-missing.db")(_body) == 0
def test_definition_no_delete_method_exists(tmp_path) -> None:
"""Guardrail: definitions are deactivated, never hard-deleted."""
def test_definition_only_delete_method_is_history_guarded(tmp_path) -> None:
"""Guardrail: the ONLY definition delete path is the history-guarded
one (owner decision 2026-09-26); there is no unconditional delete."""
async def _body(database, rules, definitions, children, child):
import inspect
@@ -1008,7 +1009,9 @@ def test_definition_no_delete_method_exists(tmp_path) -> None:
)
}
assert "delete" not in methods
assert not any(name.startswith("delete") for name in methods)
assert {
name for name in methods if name.startswith("delete")
} == {"delete_if_no_history"}
return methods
_with_db(tmp_path, "definition-no-delete.db")(_body)
+1
View File
@@ -67,6 +67,7 @@ def _payload(children) -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": 0,
"timezone": "",
"children": list(children),
}
+1
View File
@@ -76,6 +76,7 @@ def _payload(children, cycle_day=0) -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": cycle_day,
"timezone": "",
"children": list(children),
}
+1
View File
@@ -70,6 +70,7 @@ def _payload(children) -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": 0,
"timezone": "",
"children": list(children),
}
+163
View File
@@ -0,0 +1,163 @@
"""The shared quest-icon registry: one curated source, two identical artefacts.
``tools/icons/curated-icons.json`` is the only hand-edited list; the offline
generator ``tools/icons/generate.mjs`` writes the same module into the admin
PWA and the panel bundle. The admin Vitest suite regenerates and compares
byte-for-byte; these checks need no Node for the core guarantees.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).parent.parent
SOURCE = REPO_ROOT / "tools" / "icons" / "curated-icons.json"
GENERATOR = REPO_ROOT / "tools" / "icons" / "generate.mjs"
ARTEFACTS = (
REPO_ROOT / "admin" / "src" / "icons" / "registry.generated.ts",
REPO_ROOT / "frontend" / "src" / "icons" / "registry.generated.ts",
)
PANEL_BUNDLE = (
REPO_ROOT / "custom_components" / "nestquest" / "www" / "nestquest-cards.js"
)
def _curated() -> dict[str, list[dict[str, str]]]:
return json.loads(SOURCE.read_text(encoding="utf-8"))
def _generated_set(text: str, const: str) -> list[dict]:
match = re.search(
rf"export const {const}: readonly GlyphIcon\[\] = (\[.*?\n\]);",
text,
re.DOTALL,
)
assert match, f"{const} not found in the generated registry"
return json.loads(match.group(1))
def test_both_artefacts_are_byte_identical() -> None:
admin, frontend = (path.read_bytes() for path in ARTEFACTS)
assert admin == frontend
def test_artefacts_carry_exactly_the_curated_names_and_labels() -> None:
curated = _curated()
text = ARTEFACTS[0].read_text(encoding="utf-8")
for namespace, const in (("lucide", "LUCIDE_ICONS"), ("fa", "FA_ICONS")):
entries = _generated_set(text, const)
assert [
{"name": e["name"], "label": e["label"]} for e in entries
] == curated[namespace]
for entry in entries:
assert entry["kind"] == namespace
assert entry["key"] == f"{namespace}:{entry['name']}"
assert re.fullmatch(r"0 0 \d+ \d+", entry["viewBox"])
assert entry["paths"] and all(entry["paths"])
assert len(curated["lucide"]) == 36
assert 15 <= len(curated["fa"]) <= 25
def test_artefacts_carry_the_full_font_awesome_free_solid_set() -> None:
text = ARTEFACTS[0].read_text(encoding="utf-8")
solid = _generated_set(text, "FA_SOLID_ICONS")
names = [entry["name"] for entry in solid]
assert names == sorted(names)
assert len(set(names)) == len(names)
# The complete Free Solid set, not a curated pick (7.x ships ~1,400).
assert len(solid) >= 1400
for entry in solid:
assert set(entry) == {"kind", "key", "name", "label", "viewBox", "paths"}
assert entry["kind"] == "fa"
assert entry["key"] == f"fa:{entry['name']}"
assert entry["label"] == " ".join(
part[0].upper() + part[1:] for part in entry["name"].split("-")
)
assert re.fullmatch(r"0 0 \d+ \d+", entry["viewBox"])
assert entry["paths"] and all(entry["paths"])
# A well-known glyph, a barely-known one, and every curated quick-pick.
assert {"rocket", "mug-hot", "person-dress-burst"} <= set(names)
assert {e["name"] for e in _curated()["fa"]} <= set(names)
assert "rocket-ship-9000" not in names
def test_resolve_icon_resolves_any_free_solid_name() -> None:
node = shutil.which("node")
if node is None:
pytest.skip("needs node")
script = (
"const m = await import(process.argv[1]);"
"const out = {};"
"for (const v of JSON.parse(process.argv[2])) {"
" const r = m.resolveIcon(v); out[v] = [r.kind, r.label ?? null]; }"
"console.log(JSON.stringify(out));"
)
values = [
"fa:rocket",
"fa:mug-hot",
"fa:person-dress-burst",
"fa:paw",
"fa:rocket-ship-9000",
"fa:Rocket",
"fa:",
"lucide:dog",
"dog",
"lucide:rocket",
]
result = subprocess.run(
[node, "--input-type=module", "-e", script,
ARTEFACTS[0].as_uri(), json.dumps(values)],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0 and "Unknown file extension" in result.stderr:
pytest.skip("node cannot strip TypeScript types (needs Node 22.18+)")
assert result.returncode == 0, result.stderr
resolved = json.loads(result.stdout)
assert resolved["fa:rocket"] == ["fa", "Rocket"]
assert resolved["fa:mug-hot"] == ["fa", "Mug Hot"]
assert resolved["fa:person-dress-burst"] == ["fa", "Person Dress Burst"]
# Curated entries keep their quick-pick label.
assert resolved["fa:paw"] == ["fa", "Pets"]
assert resolved["lucide:dog"][0] == "lucide"
assert resolved["dog"][0] == "lucide"
for unknown in ("fa:rocket-ship-9000", "fa:Rocket", "fa:", "lucide:rocket"):
assert resolved[unknown] == ["fallback", None], unknown
def test_registry_has_no_remote_url_cdn_or_lucide_runtime() -> None:
for path in (SOURCE, GENERATOR, *ARTEFACTS):
text = path.read_text(encoding="utf-8")
assert not re.search(r"https?://", text, re.IGNORECASE), path
assert not re.search(
r"\bcdn\b|jsdelivr|unpkg|cdnjs|kit\.fontawesome", text, re.IGNORECASE
), path
assert "data-lucide" not in text, path
def test_panel_bundle_embeds_the_registry() -> None:
bundle = PANEL_BUNDLE.read_text(encoding="utf-8")
assert "fa:broom" in bundle
assert "lucide:dog" in bundle
assert "data-lucide" not in bundle
def test_generator_reproduces_the_committed_artefacts() -> None:
node = shutil.which("node")
if node is None or not (REPO_ROOT / "admin" / "node_modules").is_dir():
pytest.skip("needs node and admin/node_modules (npm ci in admin/)")
result = subprocess.run(
[node, str(GENERATOR), "--check"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
+9 -8
View File
@@ -940,14 +940,15 @@ def test_materialize_rejects_malformed_or_inverted_bounds(tmp_path) -> None:
def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None:
"""The clamp and no-past guard compare against a caller-supplied HA-local today.
"""The clamp and no-past guard compare against a caller-supplied today.
A household time zone behind the host clock around midnight resolves a
HA-local "today" one calendar day earlier than the host's
household-local "today" one calendar day earlier than the host's
``date.today()``. The walk clamps its start to that anchor: without
a pinned HA-local date the effective start is the host's today (one
day after ha_today), so the now-past day is skipped rather than
rejected, and only the host-today-onward days materialize.
a pinned household-local date the effective start is the host's
today (one day after ha_today), so the now-past day is skipped
rather than rejected, and only the host-today-onward days
materialize.
"""
async def _body(database):
children = ChildrenDao(database)
@@ -963,9 +964,9 @@ def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None:
["morning"],
)
# Without a pinned HA-local today, the walk clamps its start to the
# host's today (one day after ha_today); the past day is dropped and
# the 14 host-today-onward days materialize.
# Without a pinned household-local today, the walk clamps its start
# to the host's today (one day after ha_today); the past day is
# dropped and the 14 host-today-onward days materialize.
without_pin = await materialize(database, start_iso, end_iso)
assert without_pin == 14
+597 -4
View File
@@ -21,6 +21,7 @@ end-to-end through the quest-log harness.
from __future__ import annotations
import json
import os
import re
import subprocess
from pathlib import Path
@@ -302,14 +303,15 @@ def test_quest_log_entrances_use_the_design_timing_and_easing() -> None:
".quest.tappable:active",
"button.complete:active",
"button.confirm-button:active",
"button.return:active",
):
start = style.index(selector)
block = style[start : style.index("}", start)]
assert "transform: scale(0.98);" in block, selector
assert (
style.count("transition: transform var(--nq-dur-micro) var(--nq-ease-out);")
== 3
) # quest card, Complete button, confirm buttons
== 4
) # quest card, Complete button, confirm buttons, header return control
board_style = _style_literal(_read(PARTY_BOARD), STYLE_CONSTANTS[PARTY_BOARD])
start = board_style.index("button.plate:active")
block = board_style[start : board_style.index("}", start)]
@@ -411,6 +413,36 @@ def test_panel_hard_rules_hold_in_the_card_sources() -> None:
assert "@click" not in sealed_template
def test_party_board_hint_clears_the_dock() -> None:
"""PANEL-SPEC §2/§2.3: the hint line reads fully above the dock at
1920x1080. The hint's line box (top + font-size x line-height) must
end above the dock's top edge (1080 - bottom inset - dock height)
with a visible gap, at the 27px/600 design size. The hint is a <p>,
so its user-agent 1em top margin must be reset or it shifts the box
down by a whole font-size."""
style = _style_literal(_read(PARTY_BOARD), STYLE_CONSTANTS[PARTY_BOARD])
rules = {selector.strip(): body for selector, body in _css_rules(style)}
sizes = _size_tokens(_read(REWARD_TOKENS))
def px(body: str, prop: str) -> float:
declared = re.search(rf"(?<![-\w]){prop}:\s*([^;]+);", body)
assert declared is not None, prop
value = _font_size_px(declared.group(1), sizes)
assert value is not None, f"{prop}: {declared.group(1)}"
return value
board, hint, dock = rules[".board"], rules[".hint"], rules[".dock"]
assert "margin: 0;" in hint
line_height = float(re.search(r"line-height:\s*([\d.]+);", hint).group(1))
hint_bottom = px(hint, "top") + px(hint, "font-size") * line_height
dock_top = px(board, "height") - px(dock, "bottom") - px(dock, "height")
assert px(dock, "height") == 84
assert px(dock, "bottom") == 46
assert hint_bottom + 12 <= dock_top, (hint_bottom, dock_top)
assert px(hint, "font-size") == 27
assert "font-weight: 600;" in hint
def test_panel_text_sizes_respect_the_22px_floor() -> None:
"""PANEL-SPEC §1: no body text below 22px. Every font-size in the
panel styles resolves against the panel size tokens or an explicit
@@ -535,6 +567,8 @@ def _render_party_board(
states: dict,
url: str | None = None,
click_plate: str | None = None,
now: str | None = None,
tz: str | None = None,
) -> dict:
"""Render the built bundle's party board and return its plates.
@@ -543,7 +577,9 @@ def _render_party_board(
mounts the card with the given config and hass snapshot, optionally
taps the named plate, and prints the ordered plates its shadow DOM
renders, which of them are tappable buttons, and the pathname after
the optional tap.
the optional tap. ``now`` pins the card's clock to an ISO instant
and ``tz`` sets the node process's ``TZ`` — the panel browser's
local zone.
"""
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
assert RENDER_HARNESS.is_file()
@@ -557,12 +593,15 @@ def _render_party_board(
spec["url"] = url
if click_plate is not None:
spec["click_plate"] = click_plate
if now is not None:
spec["now"] = now
completed = subprocess.run(
["node", str(RENDER_HARNESS)],
input=json.dumps(spec),
capture_output=True,
text=True,
timeout=120,
env={**os.environ, "TZ": tz} if tz is not None else None,
)
assert completed.returncode == 0, (
f"render harness failed: {completed.stderr}"
@@ -570,6 +609,40 @@ def _render_party_board(
return json.loads(completed.stdout)
def test_party_board_wordmark_row_is_the_monogram_lockup() -> None:
"""PANEL-SPEC §2 item 1: the wordmark row is rule · compass-rose
monogram · NestQuest · rule. The monogram is an inline SVG filled
with the brand gradient — the old d20 hexagon pair and its "20"
numerals are gone, and nothing is fetched from a remote URL or
embedded as a raster logo."""
source = _read(PARTY_BOARD)
assert "d20" not in _style_literal(source, STYLE_CONSTANTS[PARTY_BOARD])
assert 'class="d20' not in source
assert "HEXAGON_CLIP" not in source
html = _render_party_board(
{"type": "custom:nestquest-party-board-card"},
_three_child_states(),
)["html"]
row = re.search(r'<div class="wordmark-row">(.*?)</div>', html, re.DOTALL)
assert row is not None
row_html = row.group(1)
classes = re.findall(r'<(?:span|svg) class="([^"]+)"', row_html)
assert classes == ["rule", "monogram", "wordmark", "rule"]
assert '<svg class="monogram" viewBox="0 0 100 100" aria-hidden="true"' in row_html
assert "var(--nq-brand-blue)" in row_html
assert "var(--nq-brand-purple)" in row_html
assert ">N</text>" in row_html
assert ">NestQuest</span>" in row_html
assert "d20" not in row_html
assert "<img" not in row_html
bundle = _read(BUNDLE_PATH)
assert "d20-numeral\">20<" not in bundle
assert "data:image/png" not in bundle
assert not re.search(r"https?://", bundle)
def test_party_board_renders_discovered_children_in_sort_order() -> None:
"""No child_order key: three plates render in the roster's sort
order (the coordinator snapshot's order), one per child."""
@@ -764,6 +837,11 @@ def _render_quest_log(
url: str,
idle_ms: int | None = None,
probe_ms: int | None = None,
tap_complete: int | None = None,
tap_return: bool = False,
now: str | None = None,
advance_to: list[str] | None = None,
tz: str | None = None,
) -> dict:
"""Render the built bundle's quest log and report its location.
@@ -773,7 +851,10 @@ def _render_quest_log(
segment — waits the optional probe window and captures one snapshot
of the render mid-wait, then the optional idle window, and prints
the final pathname and rendered title (plus the probe snapshot when
``probe_ms`` was given).
``probe_ms`` was given). ``now`` pins the card's clock to an ISO
instant, ``advance_to`` moves it through further instants firing the
card's clock tick (one snapshot each, under ``ticks``), and ``tz``
sets the node process's ``TZ`` — the panel browser's local zone.
"""
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
assert QUEST_LOG_HARNESS.is_file()
@@ -787,12 +868,21 @@ def _render_quest_log(
spec["idle_ms"] = idle_ms
if probe_ms is not None:
spec["probe_ms"] = probe_ms
if tap_complete is not None:
spec["tap_complete"] = tap_complete
if tap_return:
spec["tap_return"] = True
if now is not None:
spec["now"] = now
if advance_to is not None:
spec["advance_to"] = advance_to
completed = subprocess.run(
["node", str(QUEST_LOG_HARNESS)],
input=json.dumps(spec),
capture_output=True,
text=True,
timeout=120,
env={**os.environ, "TZ": tz} if tz is not None else None,
)
assert completed.returncode == 0, (
f"quest-log harness failed: {completed.stderr}"
@@ -856,6 +946,40 @@ def test_quest_log_idle_return_navigates_to_the_board() -> None:
assert result["location"] == "/nestquest"
def test_quest_log_return_control_navigates_to_the_board() -> None:
"""PANEL-SPEC §3: the Quest Log header carries an explicit return
control labelled for a child. Tapping it takes the same path as the
idle return — the strategy-computed board_path (the dashboard root) —
without waiting out the idle window."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
result = _render_quest_log(
generated["views"][1]["cards"][0],
_three_child_states(),
url="http://homeassistant.local/nestquest/ada",
tap_return=True,
)
assert result["headline"] == "Ada's Quest Log"
assert result["returned"] == {"rendered": True, "label": "The Party"}
assert result["location"] == "/nestquest"
def test_quest_log_return_control_follows_the_panel_hard_rules() -> None:
"""The return control is a 72px button with :active feedback only,
lives in the header (never the dock), and reuses the idle return's
_returnToBoard navigation rather than a second mechanism."""
log = _read(QUEST_LOG)
style = _style_literal(log, STYLE_CONSTANTS[QUEST_LOG])
rules = {selector.strip(): body for selector, body in _css_rules(style)}
assert "height: var(--nq-p-button-height);" in rules["button.return"]
assert "transform: scale(0.98);" in rules["button.return:active"]
header = log[log.index("private _renderHeader") : log.index("private _renderColumn")]
assert 'class="return"' in header
assert "@click=${() => this._returnToBoard()}" in header
assert log.count("this._returnToBoard()") == 4
def test_quest_complete_screen_returns_to_the_board_after_the_countdown() -> None:
"""The 1c screen renders with its countdown line and auto-returns to
the strategy-computed board path when it reaches zero. The design
@@ -886,3 +1010,472 @@ def test_quest_complete_screen_returns_to_the_board_after_the_countdown() -> Non
assert result["probe"]["countdown"] == "Returning to The Party in 1 seconds"
# …and at zero the card has returned to the party board.
assert result["location"] == "/nestquest"
def test_tapping_complete_confirms_and_calls_the_service() -> None:
"""Tapping an open quest's Complete opens the confirm dialog, and the
dialog's Complete calls nestquest.complete_quest with the panel actor.
With ``useDefineForClassFields`` on (the ES2022 default) the card's
reactive fields were emitted as native class fields that shadowed
Lit's accessors: ``_confirm`` changed but no re-render was scheduled,
so the dialog never opened and the tap did nothing."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
open_quest = {
"id": 42,
"definition_id": 7,
"child_id": 1,
"title": "Brush Teeth",
"icon": None,
"window": "morning",
"due_time": "08:15",
"state": "open",
"overdue": True,
"completed_at": None,
"on_time": None,
}
states = {
**_three_child_states(),
"sensor.nestquest_ada_quests_due_today": _state(
"1",
child_name="Ada",
child_id=1,
present=True,
instances=[open_quest],
),
}
result = _render_quest_log(
generated["views"][1]["cards"][0],
states,
url="http://homeassistant.local/nestquest/ada",
tap_complete=open_quest["id"],
)
assert result["tap"]["dialog_opened"] is True
assert result["tap"]["service_calls"] == [
{
"domain": "nestquest",
"service": "complete_quest",
"data": {
"instance_id": open_quest["id"],
"actor": "panel",
"actor_child_id": open_quest["child_id"],
},
}
]
def test_card_build_keeps_lit_reactive_accessors() -> None:
"""The frontend build must not emit class fields that shadow Lit's
reactive property accessors (Lit's documented TypeScript setting)."""
tsconfig = json.loads((FRONTEND_DIR / "tsconfig.json").read_text())
assert tsconfig["compilerOptions"]["useDefineForClassFields"] is False
def test_quest_log_crest_keeps_its_shield_classes() -> None:
"""PANEL-SPEC §3 header crest: a present child's crest renders as the
styled shield, not a bare span. Lit removes an attribute outright when
any interpolated part of it is ``nothing``, so a class written as
``crest${away ? " away" : nothing}`` lost ``crest`` for every present
child and the header showed an unstyled sliver. The rendered markup
must carry ``class="crest"`` (present) and ``class="crest away"``
(away), each wrapping the face and initial."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
log_config = generated["views"][1]["cards"][0]
for slug, crest_class in (("ada", "crest"), ("bo", "crest away")):
result = _render_quest_log(
log_config,
_three_child_states(),
url=f"http://homeassistant.local/nestquest/{slug}",
)
header = result["html"][result["html"].index('<header class="header">') :]
crest = re.match(
r'<header class="header">\s*(?:<!--[^>]*-->\s*)*'
r'(?:<button class="return"[^>]*>.*?</button>\s*(?:<!--[^>]*-->\s*)*)?'
r'<span ([^>]*)>\s*'
r'<span class="crest-face">\s*<span class="initial">',
header,
re.DOTALL,
)
assert crest is not None, header[:600]
assert f'class="{crest_class}"' in crest.group(1), crest.group(1)
def test_quest_log_meta_line_keeps_its_meta_class() -> None:
"""An open quest's meta line is styled by ``.quest .meta`` (and
``.quest .meta.late`` when overdue). Written as
``meta ${late ? "late" : nothing}``, Lit dropped the whole class
attribute for every quest that was not late, leaving the due line
unstyled. The rendered markup must carry ``class="meta"`` on time and
``class="meta late"`` overdue — overdue as the API snapshot says."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
def quest(instance_id: int, title: str, overdue: bool) -> dict:
return {
"id": instance_id,
"definition_id": instance_id,
"child_id": 1,
"title": title,
"icon": None,
"window": "morning",
"due_time": "08:15",
"state": "open",
"overdue": overdue,
"completed_at": None,
"on_time": None,
}
states = {
**_three_child_states(),
"sensor.nestquest_ada_quests_due_today": _state(
"2",
child_name="Ada",
child_id=1,
present=True,
instances=[
quest(41, "Make Bed", overdue=False),
quest(42, "Brush Teeth", overdue=True),
],
),
}
result = _render_quest_log(
generated["views"][1]["cards"][0],
states,
url="http://homeassistant.local/nestquest/ada",
)
metas = {}
for card in result["html"].split('data-instance-id="')[1:]:
meta = re.search(
r'<span class="quest-title">.*?</span>'
r"\s*(?:<!--[^>]*-->\s*)*<span([^>]*)>",
card,
re.S,
)
assert meta is not None, card[:800]
metas[card[: card.index('"')]] = meta.group(1)
assert set(metas) == {"41", "42"}, metas
assert metas["41"].strip() == 'class="meta"', metas["41"]
assert metas["42"].strip() == 'class="meta late"', metas["42"]
def _quest_metas(html: str) -> dict[str, tuple[str, str]]:
"""Map each rendered open quest's instance id to its meta line's
attributes and text."""
metas = {}
for card in html.split('data-instance-id="')[1:]:
meta = re.search(
r'<span class="quest-title">.*?</span>'
r"\s*(?:<!--[^>]*-->\s*)*<span([^>]*)>(.*?)</span>",
card,
re.S,
)
assert meta is not None, card[:800]
text = " ".join(re.sub(r"<!--[^>]*-->", "", meta.group(2)).split())
metas[card[: card.index('"')]] = (meta.group(1).strip(), text)
return metas
def test_quest_log_overdue_follows_the_api_snapshot_not_the_panel_clock() -> None:
"""The stored application timezone is the single source of local-time
truth: the API computes each instance's ``overdue`` in that zone and
the card renders exactly that, whatever the panel browser's own clock
or zone says. Here the browser runs in Pacific/Auckland and its clock
is pinned (and later ticked) to instants where every due time has, or
has not yet, arrived on its local wall clock — the card must still
show the snapshot's state: the ``overdue: true`` 08:15 quest reads
OVERDUE even though the browser reads long before 08:15, and the
``overdue: false`` 07:00 quest stays due even though the browser reads
long after 07:00."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
def quest(instance_id: int, title: str, due_time: str, overdue: bool) -> dict:
return {
"id": instance_id,
"definition_id": instance_id,
"child_id": 1,
"title": title,
"icon": None,
"window": "morning",
"due_time": due_time,
"state": "open",
"overdue": overdue,
"completed_at": None,
"on_time": None,
}
states = {
**_three_child_states(),
"sensor.nestquest_ada_quests_due_today": _state(
"2",
child_name="Ada",
child_id=1,
present=True,
instances=[
quest(42, "Brush Teeth", "08:15", overdue=True),
quest(43, "Feed Fish", "07:00", overdue=False),
],
),
}
# 2026-09-29T17:00Z is 06:00 NZDT on the 30th: before both due times.
# 2026-09-29T23:00Z is 12:00 NZDT: after both.
result = _render_quest_log(
generated["views"][1]["cards"][0],
states,
url="http://homeassistant.local/nestquest/ada",
now="2026-09-29T17:00:00Z",
advance_to=["2026-09-29T23:00:00Z"],
tz="Pacific/Auckland",
)
overdue = ('class="meta late"', "Overdue · due 8:15 AM")
due_by = ('class="meta"', "Due by 7:00 AM")
before, (after,) = (
_quest_metas(result["html"]),
[_quest_metas(tick["html"]) for tick in result["ticks"]],
)
assert before == {"42": overdue, "43": due_by}, before
assert after == {"42": overdue, "43": due_by}, after
def test_panel_date_and_clock_follow_the_stored_application_timezone() -> None:
"""The stored application timezone is the single source of local-time
truth for the panel's visible date and clock too: the household rollup
publishes it (``timezone`` — the API's effective zone) and both cards
format in it — the party
board's kicker and dock date and clock, the quest log's header date —
even though Home Assistant's zone and the panel browser's zone are both
UTC. At 2026-09-29T23:30Z it is still Tuesday 11:30 PM in UTC but
already Wednesday 12:30 PM in Pacific/Auckland (NZDT)."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
states = _three_child_states()
states["sensor.nestquest_household_quests_due_today"]["attributes"][
"timezone"
] = "Pacific/Auckland"
now = "2026-09-29T23:30:00Z"
def text(html: str) -> str:
return re.sub(r"<!--.*?-->", "", html).replace("\u202f", " ")
def board_date_and_clock(html: str) -> tuple[str, str, str]:
found = re.search(
r'<p class="kicker">The Party · (.*?)</p>.*?'
r'<span class="date">(.*?)</span>.*?<span class="clock">(.*?)</span>',
text(html),
re.DOTALL,
)
assert found is not None, html
return found.group(1), found.group(2), found.group(3)
board = _render_party_board(
generated["views"][0]["cards"][0], states, now=now, tz="UTC"
)["html"]
assert board_date_and_clock(board) == (
"Wednesday, Sep 30",
"Wednesday, Sep 30",
"12:30 PM",
)
log = _render_quest_log(
generated["views"][1]["cards"][0],
states,
url="http://homeassistant.local/nestquest/ada",
now=now,
tz="UTC",
)["html"]
sub = re.search(r'<p class="sub">\s*(.*?) ·', text(log), re.DOTALL)
assert sub is not None, log
assert sub.group(1) == "Wednesday, Sep 30"
# With no zone stored the API publishes its host's own zone, so the
# rollup always carries one; only a rollup without it (an API older
# than the field) falls back to Home Assistant's zone (UTC here).
del states["sensor.nestquest_household_quests_due_today"]["attributes"][
"timezone"
]
fallback = _render_party_board(
generated["views"][0]["cards"][0], states, now=now, tz="UTC"
)["html"]
assert board_date_and_clock(fallback) == (
"Tuesday, Sep 29",
"Tuesday, Sep 29",
"11:30 PM",
)
def test_quest_log_crest_matches_the_spec_geometry() -> None:
"""PANEL-SPEC §3 header crest: 96x110 shield, 4px ring of
rgba(255,255,255,.22) around the --nq-p-crest face, 44px/700 display
initial in full-opacity white; away swaps only the face gradient.
jsdom cannot lay out, so this pins the stylesheet the rendered test
above shows is applied."""
source = QUEST_LOG.read_text(encoding="utf-8")
def rule(selector: str) -> str:
found = re.search(
r"\n " + re.escape(selector) + r" \{(.*?)\n \}", source, re.DOTALL
)
assert found is not None, selector
return found.group(1)
crest = rule(".crest")
for decl in (
"width: 96px;",
"height: 110px;",
"padding: 4px;",
"clip-path: ${unsafeCSS(SHIELD_CLIP)};",
"background: rgba(255, 255, 255, 0.22);",
):
assert decl in crest, decl
face = rule(".crest-face")
assert "background: var(--nq-p-crest);" in face
assert "clip-path: ${unsafeCSS(SHIELD_CLIP)};" in face
initial = rule(".crest .initial")
for decl in (
"font-family: var(--nq-p-font-display);",
"font-size: 44px;",
"font-weight: 700;",
"color: #ffffff;",
):
assert decl in initial, decl
assert "opacity" not in initial
away = rule(".crest.away .crest-face")
assert away.strip() == "background: var(--nq-p-crest-away);"
# --- Quest card icons (definition icon on the open tile) ---------------------
def _quest_row(instance_id: int, icon: str | None, state: str = "open") -> dict:
"""One ``instances`` attribute row for Ada's quests-due sensor."""
row = {
"id": instance_id,
"child_id": 1,
"title": f"Quest {instance_id}",
"icon": icon,
"window": "morning",
"due_time": "08:00",
"state": state,
"overdue": False,
"completed_at": None,
"on_time": None,
}
if state == "completed":
row["completed_at"] = "2026-09-26T07:30:00+00:00"
row["on_time"] = True
return row
def _quest_log_tiles(rows: list[dict]) -> dict[int, dict]:
"""Render Ada's quest log with ``rows`` and key its tiles by instance id."""
states = _three_child_states()
states["sensor.nestquest_ada_quests_due_today"] = _state(
str(len(rows)), child_name="Ada", present=True, instances=rows
)
result = _render_quest_log(
{"type": "custom:nestquest-quest-log-card"},
states,
url="http://homeassistant.local/nestquest/ada",
)
return {tile["id"]: tile for tile in result["tiles"]}
def test_open_quest_tile_renders_the_definition_icon() -> None:
"""The open card's tile shows the definition's namespaced icon, drawn
from the bundled registry: Lucide as a stroked svg, Font Awesome as a
filled svg, an emoji as its text, and a legacy bare name as Lucide."""
tiles = _quest_log_tiles(
[
_quest_row(1, "lucide:bed"),
_quest_row(2, "fa:broom"),
_quest_row(3, "emoji:🦊"),
_quest_row(4, "bath"),
]
)
assert tiles[1]["icon"] == "lucide:bed"
assert tiles[1]["fill"] == "none"
assert tiles[1]["paths"] > 0
assert tiles[2]["icon"] == "fa:broom"
assert tiles[2]["fill"] == "currentColor"
assert tiles[2]["paths"] > 0
assert tiles[3]["icon"] == "emoji:🦊"
assert tiles[3]["text"] == "🦊"
assert tiles[4]["icon"] == "lucide:bath"
assert tiles[4]["fill"] == "none"
for tile in tiles.values():
assert not tile["sealed"]
assert not tile["star"]
def test_open_quest_tile_falls_back_to_the_star() -> None:
"""An unknown name, an unknown namespace, or no icon at all keeps the
open card's current star glyph."""
tiles = _quest_log_tiles(
[
_quest_row(1, "lucide:not-a-real-icon"),
_quest_row(2, "fa:not-a-real-icon"),
_quest_row(3, "mdi:bed"),
_quest_row(4, ""),
_quest_row(5, None),
]
)
assert set(tiles) == {1, 2, 3, 4, 5}
for tile in tiles.values():
assert tile["star"]
assert tile["icon"] is None
def test_open_quest_tile_renders_any_free_solid_icon() -> None:
"""A Font Awesome Free Solid name outside the curated quick-pick list
still renders its glyph from the shipped bundle; an unknown ``fa:`` name
keeps the star."""
tiles = _quest_log_tiles(
[
_quest_row(1, "fa:rocket"),
_quest_row(2, "fa:mug-hot"),
_quest_row(3, "fa:definitely-not-an-icon"),
]
)
for instance_id, key in ((1, "fa:rocket"), (2, "fa:mug-hot")):
assert tiles[instance_id]["icon"] == key
assert tiles[instance_id]["fill"] == "currentColor"
assert tiles[instance_id]["paths"] > 0
assert not tiles[instance_id]["star"]
assert tiles[3]["star"]
assert tiles[3]["icon"] is None
def test_sealed_quest_tile_keeps_the_star() -> None:
"""A sealed card keeps its star tile (and its wax seal) whatever the
definition's icon; only the open card shows the quest's icon."""
tiles = _quest_log_tiles(
[
_quest_row(1, "lucide:bed", state="completed"),
_quest_row(2, "fa:broom", state="completed"),
_quest_row(3, "emoji:🦊", state="completed"),
]
)
for tile in tiles.values():
assert tile["sealed"]
assert tile["star"]
assert tile["icon"] is None
assert tile["text"] == ""
def test_only_the_open_tile_calls_the_icon_resolver() -> None:
source = _read(QUEST_LOG)
sealed_start = source.index('<div class="quest sealed"')
sealed_block = source[sealed_start : source.index("</div>", sealed_start)]
assert '<span class="tile" aria-hidden="true">${ICON_STAR}</span>' in sealed_block
assert "renderQuestIcon" not in sealed_block
assert (
source.count("renderQuestIcon(instance.icon, ICON_STAR)") == 1
), "only the open card's tile resolves the definition icon"
open_start = source.index('class="quest tappable"')
assert "renderQuestIcon(instance.icon, ICON_STAR)" in source[open_start:]
+277 -7
View File
@@ -22,8 +22,10 @@ from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.migrations import apply_migrations
from custom_components.nestquest.core.quest_definitions import (
CreatedQuestDefinition,
DeletedQuestDefinition,
assign_child,
create_quest_definition,
delete_quest_definition,
edit_quest_definition,
list_active_definitions,
list_definitions_firing_on,
@@ -106,13 +108,13 @@ def test_create_one_child_one_window(tmp_path) -> None:
[child.id],
["morning"],
description=" Twice daily ",
icon=" mdi:tooth ",
icon="lucide:tooth",
)
assert isinstance(result, CreatedQuestDefinition)
definition = result.definition
assert definition.title == "Brush teeth"
assert definition.description == "Twice daily"
assert definition.icon == "mdi:tooth"
assert definition.icon == "lucide:tooth"
assert definition.is_active is True
# D-008: the definition-level due_time column is never set here.
assert definition.due_time is None
@@ -232,6 +234,89 @@ def test_create_rejects_non_string_title(tmp_path) -> None:
_with_db(tmp_path, "create-bad-title-type.db")(_body)
@pytest.mark.parametrize(
("icon", "stored"),
[
("lucide:dog", "lucide:dog"),
("fa:dog", "fa:dog"),
("emoji:🐕", "emoji:🐕"),
("dog", "lucide:dog"), # legacy bare name: canonical lucide key
],
)
def test_create_stores_canonical_icon(tmp_path, icon, stored) -> None:
async def _body(database, children, rules, definitions, child):
result = await create_quest_definition(
database, "Walk dog", _daily_rule(), [child.id], ["morning"],
icon=icon,
)
assert result.definition.icon == stored
row = await definitions.get(result.definition.id)
assert row is not None
assert row.icon == stored
return None
_with_db(tmp_path, "create-icon.db")(_body)
_BAD_ICONS = [
"foo:dog",
"lucide:",
"lucide:Dog",
"lucide:has space",
"emoji:" + "🐕" * 9,
"emoji:a b",
"emoji:<b>",
"javascript:alert(1)",
]
@pytest.mark.parametrize("bad", _BAD_ICONS)
def test_create_rejects_invalid_icon_and_persists_nothing(tmp_path, bad) -> None:
async def _body(database, children, rules, definitions, child):
with pytest.raises(ValueError, match=r"^icon\b"):
await create_quest_definition(
database, "Walk dog", _daily_rule(), [child.id], ["morning"],
icon=bad,
)
assert await definitions.list_active() == []
return None
_with_db(tmp_path, "create-bad-icon.db")(_body)
@pytest.mark.parametrize("bad", _BAD_ICONS)
def test_edit_rejects_invalid_icon_and_changes_nothing(tmp_path, bad) -> None:
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database, "Walk dog", _daily_rule(), [child.id], ["morning"],
icon="lucide:dog",
)
with pytest.raises(ValueError, match=r"^icon\b"):
await edit_quest_definition(
database, created.definition.id, icon=bad
)
row = await definitions.get(created.definition.id)
assert row is not None
assert row.icon == "lucide:dog"
return None
_with_db(tmp_path, "edit-bad-icon.db")(_body)
def test_edit_normalizes_legacy_bare_icon(tmp_path) -> None:
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database, "Walk dog", _daily_rule(), [child.id], ["morning"],
)
result = await edit_quest_definition(
database, created.definition.id, icon="dog"
)
assert result.definition.icon == "lucide:dog"
return None
_with_db(tmp_path, "edit-legacy-icon.db")(_body)
@pytest.mark.parametrize("bad", [None, "daily", 42, {"rule_type": "daily"}])
def test_create_rejects_non_schedule_rule(tmp_path, bad) -> None:
async def _body(database, children, rules, definitions, child):
@@ -535,18 +620,18 @@ def test_edit_title_description_icon(tmp_path) -> None:
[child.id],
["morning"],
description="Old description",
icon="mdi:old",
icon="lucide:old",
)
result = await edit_quest_definition(
database,
created.definition.id,
title=" Floss ",
description=" New description ",
icon=" mdi:new ",
icon="fa:new",
)
assert result.definition.title == "Floss"
assert result.definition.description == "New description"
assert result.definition.icon == "mdi:new"
assert result.definition.icon == "fa:new"
# Untouched fields survive the edit unchanged.
assert result.definition.id == created.definition.id
assert result.definition.schedule_rule_id == created.definition.schedule_rule_id
@@ -561,7 +646,7 @@ def test_edit_title_description_icon(tmp_path) -> None:
assert stored is not None
assert stored.title == "Floss"
assert stored.description == "New description"
assert stored.icon == "mdi:new"
assert stored.icon == "fa:new"
return None
_with_db(tmp_path, "edit-metadata.db")(_body)
@@ -597,7 +682,7 @@ def test_edit_clears_optional_metadata(tmp_path) -> None:
[child.id],
["morning"],
description="Old",
icon="mdi:old",
icon="lucide:old",
)
result = await edit_quest_definition(
database,
@@ -2043,3 +2128,188 @@ def test_wrappers_forward_horizon_days(tmp_path, mode) -> None:
return None
_with_db(tmp_path, f"wrapper-horizon-{mode}.db")(_body)
# ---------------------------------------------------------------------------
# delete_quest_definition: hybrid delete (owner decision 2026-09-26)
# ---------------------------------------------------------------------------
async def _count(database, sql, definition_id) -> int:
row = await database.fetch_one(sql, (definition_id,))
return row[0]
def test_delete_without_history_hard_deletes_everything(tmp_path) -> None:
"""No completion event on any instance: the definition, its
instances, assignees, windows and unreferenced rule are all gone."""
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database,
"Brush teeth",
_daily_rule(),
[child.id],
["morning", "evening"],
)
definition_id = created.definition.id
rule_id = created.definition.schedule_rule_id
instances_sql = (
"SELECT COUNT(*) FROM quest_instances WHERE definition_id = ?"
)
# Create materialized future instances, so the delete has
# instance rows to remove (not just an empty table).
assert await _count(database, instances_sql, definition_id) > 0
result = await delete_quest_definition(database, definition_id)
assert result == DeletedQuestDefinition(
definition_id=definition_id,
deleted=True,
retired=False,
definition=None,
)
assert await definitions.get(definition_id) is None
assert await rules.get(rule_id) is None
assert await _count(database, instances_sql, definition_id) == 0
for table in (
"quest_definition_assignees",
"quest_definition_windows",
):
assert await _count(
database,
f"SELECT COUNT(*) FROM {table} WHERE definition_id = ?",
definition_id,
) == 0
# The child itself is untouched.
assert (await children.get(child.id)) is not None
return None
_with_db(tmp_path, "delete-no-history.db")(_body)
def test_delete_without_history_keeps_a_shared_rule(tmp_path) -> None:
"""The rule is deleted only when no other definition references it."""
async def _body(database, children, rules, definitions, child):
rule = await rules.create("daily", "2026-09-14")
first = await definitions.create(
"First", rule.id, NOW, assignee_child_ids=[child.id]
)
second = await definitions.create(
"Second", rule.id, NOW, assignee_child_ids=[child.id]
)
result = await delete_quest_definition(database, first.id)
assert result.deleted is True
assert await definitions.get(first.id) is None
assert await definitions.get(second.id) is not None
assert await rules.get(rule.id) is not None
return None
_with_db(tmp_path, "delete-shared-rule.db")(_body)
def test_delete_with_history_retires_and_preserves_everything(
tmp_path,
) -> None:
"""A completion event on ANY instance: the definition is retired
(deactivated) and its row, rule, assignees, windows and completion
history all remain. The completed instance survives; only future
UNCOMPLETED instances are cleared by the deactivation's regeneration."""
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
)
definition_id = created.definition.id
rule_id = created.definition.schedule_rule_id
instances = QuestInstancesDao(database)
events = CompletionEventsDao(database)
instance = await instances.upsert(
definition_id, child.id, _today_iso(), NOW, window="morning"
)
event = await events.append(
instance.id,
child.id,
"completed",
"user",
NOW,
True,
actor_user_id="admin-1",
)
result = await delete_quest_definition(database, definition_id)
assert result.deleted is False
assert result.retired is True
assert result.definition_id == definition_id
assert isinstance(result.definition, CreatedQuestDefinition)
assert result.definition.definition.is_active is False
stored = await definitions.get(definition_id)
assert stored is not None
assert stored.is_active is False
assert await rules.get(rule_id) is not None
assert [c.id for c in await definitions.list_assignees(
definition_id
)] == [child.id]
assert [w.window for w in await definitions.list_windows(
definition_id
)] == ["morning"]
assert await instances.get_by_id(instance.id) == instance
assert await events.list_by_instance(instance.id) == [event]
return None
_with_db(tmp_path, "delete-with-history.db")(_body)
def test_delete_with_uncompleted_history_still_retires(tmp_path) -> None:
"""ANY completion event counts as history — even when the latest
event is an un-completion, the definition is retired, not deleted."""
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
)
definition_id = created.definition.id
instances = QuestInstancesDao(database)
events = CompletionEventsDao(database)
instance = await instances.upsert(
definition_id, child.id, _today_iso(), NOW, window="morning"
)
await events.append(
instance.id, child.id, "completed", "user", NOW, True,
actor_user_id="admin-1",
)
await events.append(
instance.id, child.id, "uncompleted", "user", NOW, None,
actor_user_id="admin-1",
)
result = await delete_quest_definition(database, definition_id)
assert result.retired is True
assert await instances.get_by_id(instance.id) == instance
return None
_with_db(tmp_path, "delete-uncompleted-history.db")(_body)
def test_delete_nonexistent_definition_names_field(tmp_path) -> None:
async def _body(database, children, rules, definitions, child):
with pytest.raises(ValueError, match=r"^definition_id:"):
await delete_quest_definition(database, 999)
return None
_with_db(tmp_path, "delete-missing.db")(_body)
@pytest.mark.parametrize("bad", [True, 1.0, "1", None])
def test_delete_rejects_non_int_definition_id(tmp_path, bad) -> None:
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
)
with pytest.raises(ValueError, match="definition_id"):
await delete_quest_definition(database, bad)
assert await definitions.get(created.definition.id) is not None
return None
_with_db(tmp_path, "delete-bad-id.db")(_body)
+17
View File
@@ -76,6 +76,7 @@ def _payload(children) -> dict:
return {
"today_iso": _today_iso(),
"cycle_day": 0,
"timezone": "",
"children": list(children),
}
@@ -569,6 +570,22 @@ async def test_household_due_sensor_publishes_the_ordered_child_roster(
}
async def test_household_due_sensor_publishes_the_stored_zone(
hass, make_entry
) -> None:
"""The rollup carries the snapshot's stored application zone verbatim
(the panel cards format their date and clock in it); ``""`` — no zone
stored — passes through unchanged for the cards' documented fallback."""
zoned = {**_seeded_payload(), "timezone": "Pacific/Auckland"}
entry, coordinator, _client = await _setup_entry(
hass, make_entry, zoned, _seeded_payload()
)
rollup = hass.entities["nestquest_household_quests_due_today"]
assert rollup.extra_state_attributes["timezone"] == "Pacific/Auckland"
await coordinator.async_refresh()
assert rollup.extra_state_attributes["timezone"] == ""
async def test_household_roster_tracks_the_snapshot_on_refresh(
hass, make_entry
) -> None:
+6 -1
View File
@@ -151,7 +151,12 @@ DOCUMENTED_FIELDS = {
#: A minimal panel snapshot payload for the full-setup wiring tests
#: (the shape ``snapshot_from_api_payload`` consumes; no children).
MINIMAL_SNAPSHOT = {"today_iso": "2026-09-23", "cycle_day": 1, "children": []}
MINIMAL_SNAPSHOT = {
"today_iso": "2026-09-23",
"cycle_day": 1,
"timezone": "",
"children": [],
}
# ---------------------------------------------------------------------------
+64
View File
@@ -0,0 +1,64 @@
{
"lucide": [
{ "name": "bed", "label": "Bed" },
{ "name": "bath", "label": "Bath" },
{ "name": "shower-head", "label": "Shower" },
{ "name": "toilet", "label": "Toilet" },
{ "name": "brush", "label": "Brush" },
{ "name": "smile", "label": "Smile" },
{ "name": "shirt", "label": "Clothes" },
{ "name": "washing-machine", "label": "Laundry" },
{ "name": "utensils", "label": "Meal" },
{ "name": "cooking-pot", "label": "Cooking" },
{ "name": "apple", "label": "Snack" },
{ "name": "brush-cleaning", "label": "Sweep" },
{ "name": "spray-can", "label": "Clean" },
{ "name": "trash-2", "label": "Bins" },
{ "name": "recycle", "label": "Recycling" },
{ "name": "sofa", "label": "Tidy" },
{ "name": "house", "label": "Home" },
{ "name": "backpack", "label": "School bag" },
{ "name": "book-open", "label": "Reading" },
{ "name": "pencil", "label": "Homework" },
{ "name": "music", "label": "Music" },
{ "name": "dog", "label": "Dog" },
{ "name": "cat", "label": "Cat" },
{ "name": "fish", "label": "Fish" },
{ "name": "sprout", "label": "Plants" },
{ "name": "droplets", "label": "Water" },
{ "name": "bike", "label": "Bike" },
{ "name": "volleyball", "label": "Sport" },
{ "name": "gamepad-2", "label": "Games" },
{ "name": "star", "label": "Star" },
{ "name": "heart", "label": "Heart" },
{ "name": "sun", "label": "Morning" },
{ "name": "moon", "label": "Bedtime" },
{ "name": "clock", "label": "Clock" },
{ "name": "calendar", "label": "Calendar" },
{ "name": "list-checks", "label": "Checklist" }
],
"fa": [
{ "name": "broom", "label": "Sweep" },
{ "name": "spray-can-sparkles", "label": "Clean" },
{ "name": "bucket", "label": "Mop" },
{ "name": "soap", "label": "Wash hands" },
{ "name": "sink", "label": "Dishes" },
{ "name": "jug-detergent", "label": "Laundry" },
{ "name": "socks", "label": "Socks" },
{ "name": "shirt", "label": "Clothes" },
{ "name": "bed", "label": "Bed" },
{ "name": "couch", "label": "Tidy" },
{ "name": "bath", "label": "Bath" },
{ "name": "tooth", "label": "Teeth" },
{ "name": "utensils", "label": "Meal" },
{ "name": "bowl-food", "label": "Set the table" },
{ "name": "basket-shopping", "label": "Groceries" },
{ "name": "trash-can", "label": "Bins" },
{ "name": "recycle", "label": "Recycling" },
{ "name": "dog", "label": "Dog" },
{ "name": "cat", "label": "Cat" },
{ "name": "paw", "label": "Pets" },
{ "name": "seedling", "label": "Plants" },
{ "name": "book", "label": "Reading" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"_comment": "Shared emoji-payload verdicts. tests/test_core_icons.py (validate_icon) and admin/src/icons/registry.test.ts (resolveIcon) both assert every row, so the Python and JS rules cannot diverge silently.",
"cases": [
{ "payload": "🐕", "valid": true, "why": "plain emoji" },
{ "payload": "👨‍👩‍👧‍👦", "valid": true, "why": "ZWJ family (U+200D allowed)" },
{ "payload": "⭐⭐⭐⭐⭐⭐⭐⭐", "valid": true, "why": "exactly 8 code points" },
{ "payload": "⭐⭐⭐⭐⭐⭐⭐⭐⭐", "valid": false, "why": "9 code points, over the cap" },
{ "payload": "", "valid": false, "why": "empty" },
{ "payload": "", "valid": false, "why": "U+FEFF BOM alone" },
{ "payload": "🐕", "valid": false, "why": "trailing U+FEFF" },
{ "payload": "🐕 ", "valid": false, "why": "trailing space" },
{ "payload": "a b", "valid": false, "why": "inner space" },
{ "payload": "🐕 ", "valid": false, "why": "no-break space" },
{ "payload": "🐕 ", "valid": false, "why": "ideographic space" },
{ "payload": "🐕
", "valid": false, "why": "line separator" },
{ "payload": "🐕\n", "valid": false, "why": "newline" },
{ "payload": "🐕\u0000", "valid": false, "why": "NUL control" },
{ "payload": "🐕\u0007🐕", "valid": false, "why": "BEL control" },
{ "payload": "🐕\u001c", "valid": false, "why": "file-separator control" },
{ "payload": "🐕\u0085", "valid": false, "why": "NEL C1 control" },
{ "payload": "🐕\u007f", "valid": false, "why": "DEL control" },
{ "payload": "<b>", "valid": false, "why": "markup <" },
{ "payload": "a>b", "valid": false, "why": "markup >" },
{ "payload": "a&b", "valid": false, "why": "entity start &" }
]
}
+288
View File
@@ -0,0 +1,288 @@
/**
* Offline generator for the shared quest-icon registry.
*
* Reads the curated names + labels in `curated-icons.json` (the one source of
* truth for the quick-pick lists), pulls each glyph's SVG path data from
* locally installed packages in `admin/node_modules` (lucide-react and
* @fortawesome/free-solid-svg-icons — no network), and writes the same
* TypeScript module into both bundles:
*
* admin/src/icons/registry.generated.ts
* frontend/src/icons/registry.generated.ts
*
* node tools/icons/generate.mjs # write both artefacts
* node tools/icons/generate.mjs --check # exit 1 if either artefact drifted
*
* Lucide circles, rects and lines are converted to path `d` strings so every
* glyph is plain `{ viewBox, paths }` data any renderer can draw.
*
* Besides the curated sets, the module embeds the complete Font Awesome Free
* Solid set (`FA_SOLID_ICONS`). Its source is the installed
* @fortawesome/free-solid-svg-icons package itself: every exported icon
* definition, de-duplicated by canonical `iconName` (the package also exports
* each legacy alias, e.g. faHome for `house`, as the same definition). The
* package carries no human labels, so each label is the kebab name in Title
* Case (`spray-can-sparkles` → "Spray Can Sparkles").
*/
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, relative } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
export const REPO_ROOT = join(HERE, "..", "..");
export const SOURCE_PATH = join(HERE, "curated-icons.json");
export const OUTPUT_PATHS = [
join(REPO_ROOT, "admin", "src", "icons", "registry.generated.ts"),
join(REPO_ROOT, "frontend", "src", "icons", "registry.generated.ts"),
];
const ADMIN_MODULES = join(REPO_ROOT, "admin", "node_modules");
const LUCIDE_ICON_DIR = join(ADMIN_MODULES, "lucide-react", "dist", "esm", "icons");
const requireFromAdmin = createRequire(join(REPO_ROOT, "admin", "package.json"));
function packageVersion(name) {
const pkg = JSON.parse(readFileSync(join(ADMIN_MODULES, name, "package.json"), "utf8"));
return pkg.version;
}
function num(value, what) {
const n = Number(value);
if (!Number.isFinite(n)) throw new Error(`${what}: not a number: ${value}`);
return n;
}
/** One Lucide icon-node element as a path `d` string. */
function elementToPath(tag, attrs, icon) {
const where = `lucide ${icon} <${tag}>`;
const known = { path: ["d"], circle: ["cx", "cy", "r"], rect: ["x", "y", "width", "height", "rx", "ry"], line: ["x1", "y1", "x2", "y2"] };
if (!(tag in known)) throw new Error(`${where}: unsupported element`);
for (const attr of Object.keys(attrs)) {
if (attr !== "key" && !known[tag].includes(attr)) throw new Error(`${where}: unsupported attribute ${attr}`);
}
if (tag === "path") return attrs.d;
if (tag === "line") {
return `M${num(attrs.x1, where)} ${num(attrs.y1, where)}L${num(attrs.x2, where)} ${num(attrs.y2, where)}`;
}
if (tag === "circle") {
const [cx, cy, r] = [num(attrs.cx, where), num(attrs.cy, where), num(attrs.r, where)];
return `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0`;
}
const [x, y, w, h] = [num(attrs.x ?? 0, where), num(attrs.y ?? 0, where), num(attrs.width, where), num(attrs.height, where)];
const rxRaw = attrs.rx ?? attrs.ry;
const ryRaw = attrs.ry ?? attrs.rx;
const rx = Math.min(rxRaw === undefined ? 0 : num(rxRaw, where), w / 2);
const ry = Math.min(ryRaw === undefined ? 0 : num(ryRaw, where), h / 2);
if (rx === 0 || ry === 0) return `M${x} ${y}h${w}v${h}h${-w}z`;
const iw = w - 2 * rx;
const ih = h - 2 * ry;
return (
`M${x + rx} ${y}h${iw}a${rx} ${ry} 0 0 1 ${rx} ${ry}v${ih}` +
`a${rx} ${ry} 0 0 1 ${-rx} ${ry}h${-iw}a${rx} ${ry} 0 0 1 ${-rx} ${-ry}` +
`v${-ih}a${rx} ${ry} 0 0 1 ${rx} ${-ry}z`
);
}
/** A Lucide icon's node list; follows lucide-react's alias re-exports (smile → face-slightly-smiling). */
async function lucideNode(name, seen = []) {
if (seen.includes(name)) throw new Error(`lucide ${name}: alias cycle ${seen.join(" -> ")}`);
const file = join(LUCIDE_ICON_DIR, `${name}.mjs`);
const mod = await import(pathToFileURL(file).href);
if (mod.__iconData) return mod.__iconData.node;
const alias = /export \{ default \} from '\.\/([a-z0-9-]+)\.mjs';/.exec(readFileSync(file, "utf8"));
if (!alias) throw new Error(`lucide ${name}: no icon data in ${file}`);
return lucideNode(alias[1], [...seen, name]);
}
function faExportName(name) {
return "fa" + name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join("");
}
function faGlyph(definition, label) {
const { iconName: name } = definition;
const [width, height, , , pathData] = definition.icon;
const paths = Array.isArray(pathData) ? pathData.filter(Boolean) : [pathData];
return { kind: "fa", key: `fa:${name}`, name, label, viewBox: `0 0 ${width} ${height}`, paths };
}
function titleCase(name) {
return name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
}
/** Every Free Solid glyph, one per canonical name, sorted by name. */
function allSolidGlyphs(faPackage) {
const byName = new Map();
for (const definition of Object.values(faPackage)) {
if (!definition || typeof definition !== "object" || !Array.isArray(definition.icon)) continue;
if (definition.prefix !== "fas") throw new Error(`fa ${definition.iconName}: unexpected prefix ${definition.prefix}`);
const name = definition.iconName;
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(name)) throw new Error(`fa ${name}: not a kebab name`);
const seen = byName.get(name);
if (seen && JSON.stringify(seen.icon) !== JSON.stringify(definition.icon)) {
throw new Error(`fa ${name}: two exports disagree on the glyph`);
}
byName.set(name, definition);
}
if (byName.size === 0) throw new Error("fa: no icons found in @fortawesome/free-solid-svg-icons");
return [...byName.keys()]
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
.map((name) => faGlyph(byName.get(name), titleCase(name)));
}
function checkCurated(list, namespace) {
const seen = new Set();
for (const { name, label } of list) {
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(name)) throw new Error(`${namespace}:${name}: not a kebab name`);
if (typeof label !== "string" || !label.trim()) throw new Error(`${namespace}:${name}: missing label`);
if (seen.has(name)) throw new Error(`${namespace}:${name}: duplicate`);
seen.add(name);
}
}
async function buildEntries() {
const curated = JSON.parse(readFileSync(SOURCE_PATH, "utf8"));
checkCurated(curated.lucide, "lucide");
checkCurated(curated.fa, "fa");
const lucide = [];
for (const { name, label } of curated.lucide) {
const node = await lucideNode(name);
const paths = node.map(([tag, attrs]) => elementToPath(tag, attrs, name));
lucide.push({ kind: "lucide", key: `lucide:${name}`, name, label, viewBox: "0 0 24 24", paths });
}
const faPackage = requireFromAdmin("@fortawesome/free-solid-svg-icons");
const fa = [];
for (const { name, label } of curated.fa) {
const definition = faPackage[faExportName(name)];
if (!definition || definition.iconName !== name) throw new Error(`fa ${name}: not in @fortawesome/free-solid-svg-icons`);
fa.push(faGlyph(definition, label));
}
return { lucide, fa, faSolid: allSolidGlyphs(faPackage) };
}
const RESOLVER = `
export const FALLBACK_ICON: FallbackIcon = { kind: "fallback", key: null };
/** Mirrors core/icons.py EMOJI_MAX_CODE_POINTS. */
export const EMOJI_MAX_CODE_POINTS = 8;
const LUCIDE_BY_NAME: ReadonlyMap<string, GlyphIcon> = new Map(LUCIDE_ICONS.map((icon) => [icon.name, icon]));
/** The full Free Solid set, with each curated entry (and its quick-pick label) taking precedence. */
const FA_BY_NAME: ReadonlyMap<string, GlyphIcon> = new Map(
[...FA_SOLID_ICONS, ...FA_ICONS].map((icon) => [icon.name, icon]),
);
function isEmojiPayload(text: string): boolean {
const codePoints = [...text];
if (codePoints.length === 0 || codePoints.length > EMOJI_MAX_CODE_POINTS) return false;
return !codePoints.some((char) => /[\\s<>&]|\\p{Cc}/u.test(char));
}
/**
* The renderable icon for a stored definition \`icon\` value.
*
* \`lucide:<name>\` and a legacy bare \`<name>\` resolve against the curated
* Lucide set, \`fa:<name>\` against the full Font Awesome Free Solid set
* (curated entries keep their quick-pick labels), and
* \`emoji:<grapheme>\` to its text. Like the backend's
* normalize_icon_for_read, nothing here throws: null, empty, an unknown
* namespace or name, or a malformed emoji all resolve to FALLBACK_ICON.
*/
export function resolveIcon(value: string | null | undefined): ResolvedIcon {
if (!value || !value.trim()) return FALLBACK_ICON;
const colon = value.indexOf(":");
if (colon === -1) return LUCIDE_BY_NAME.get(value) ?? FALLBACK_ICON;
const namespace = value.slice(0, colon);
const name = value.slice(colon + 1);
if (namespace === "lucide") return LUCIDE_BY_NAME.get(name) ?? FALLBACK_ICON;
if (namespace === "fa") return FA_BY_NAME.get(name) ?? FALLBACK_ICON;
if (namespace === "emoji" && isEmojiPayload(name)) return { kind: "emoji", key: value, text: name };
return FALLBACK_ICON;
}
`;
/** The generated module's full text (identical for both bundles). */
export async function renderRegistry() {
const { lucide, fa, faSolid } = await buildEntries();
const source = relative(REPO_ROOT, SOURCE_PATH);
return `// GENERATED by tools/icons/generate.mjs from ${source} — do not edit.
// Regenerate with \`node tools/icons/generate.mjs\` (the drift tests fail if this
// file differs from a fresh generation).
//
// Glyph path data is embedded at generation time; nothing is fetched at runtime.
// Lucide glyphs: lucide-react ${packageVersion("lucide-react")} (ISC licence).
// Font Awesome glyphs: @fortawesome/free-solid-svg-icons ${packageVersion("@fortawesome/free-solid-svg-icons")}
// (Font Awesome Free, icons under CC BY 4.0).
/** A vector glyph: draw each path in \`paths\` inside an <svg viewBox>. Lucide
* glyphs are stroked (fill none, stroke-width 2, round caps and joins);
* Font Awesome glyphs are filled. */
export interface GlyphIcon {
kind: "lucide" | "fa";
key: string;
name: string;
label: string;
viewBox: string;
paths: readonly string[];
}
/** An emoji icon; render \`text\` as text. */
export interface EmojiIcon {
kind: "emoji";
key: string;
text: string;
}
/** No usable icon: the caller draws its own default glyph. */
export interface FallbackIcon {
kind: "fallback";
key: null;
}
export type ResolvedIcon = GlyphIcon | EmojiIcon | FallbackIcon;
export const LUCIDE_ICONS: readonly GlyphIcon[] = ${JSON.stringify(lucide, null, 2)};
/** The curated Font Awesome quick-pick set. */
export const FA_ICONS: readonly GlyphIcon[] = ${JSON.stringify(fa, null, 2)};
/** Every Font Awesome Free Solid glyph (${faSolid.length}), sorted by name; one entry per line. */
export const FA_SOLID_ICONS: readonly GlyphIcon[] = [
${faSolid.map((icon) => JSON.stringify(icon)).join(",\n")}
];
${RESOLVER}`;
}
async function main() {
const check = process.argv.includes("--check");
const text = await renderRegistry();
let drifted = false;
for (const path of OUTPUT_PATHS) {
const shown = relative(REPO_ROOT, path);
if (check) {
let current = null;
try {
current = readFileSync(path, "utf8");
} catch {
// missing counts as drift
}
if (current !== text) {
drifted = true;
console.error(`drift: ${shown} differs from a fresh generation`);
}
} else {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, text);
console.log(`wrote ${shown}`);
}
}
if (drifted) {
console.error("run `node tools/icons/generate.mjs` and commit the result");
process.exit(1);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}
Generated
+1 -1
View File
@@ -373,7 +373,7 @@ wheels = [
[[package]]
name = "nestquest"
version = "0.7.1"
version = "0.9.8"
source = { virtual = "." }
[package.dev-dependencies]