Compare commits

..
Author SHA1 Message Date
Joshua King 875d328c6f updates 2026-10-19 16:10:24 -04:00
dev_agent da375a2cd7 Add the NestQuest logo to the README and prepare HACS brand icons (task ac84fa96) (#304)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 17:02:24 -04:00
orchestrator-agent 9cec30b88a Merge main into dev after the 0.10.3 release 2026-10-06 13:53:13 -04:00
overseer ab3859a6cf Merge pull request 'Release: admin PWA desktop icon (0.10.3) (2026-10-06)' (#302) from release/2026-10-06-pwa-icon into main
Reviewed-on: #302
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-06 13:46:56 -04:00
orchestrator-agent b7e5f1fada release: advance version to 0.10.3 and add the 0.10.3 release notes 2026-10-06 13:41:08 -04:00
dev_agent 16fb466480 Generate the PWA icons from the NestQuest logo (task 55e1e687) (#301)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 13:37:41 -04:00
orchestrator-agent bf22aa8daa Merge main into dev after the 0.10.2 release 2026-10-06 13:11:32 -04:00
overseer f75969afe1 Merge pull request 'Release: panel SSE idle-timeout fix (0.10.2) (2026-10-06)' (#299) from release/2026-10-06-sse-timeout into main
Reviewed-on: #299
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-06 13:09:48 -04:00
orchestrator-agent b590cce9d9 release: advance version to 0.10.2 and add the 0.10.2 release notes 2026-10-06 09:14:53 -04:00
dev_agent 2939bede08 Disable the aiohttp total timeout on the panel SSE stream (task 6c4b0fa4) (#298)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 09:12:40 -04:00
orchestrator-agent 0ccf787dc6 Merge main into dev after the 0.10.1 release 2026-10-06 08:50:37 -04:00
overseer e033ad2ad5 Merge pull request 'Release: panel SSE stream fix (0.10.1) (2026-10-06)' (#296) from release/2026-10-06-sse-fix into main
Reviewed-on: #296
2026-10-06 08:31:07 -04:00
orchestrator-agent b000579533 release: advance version to 0.10.1 and add the 0.10.1 release notes 2026-10-06 08:20:26 -04:00
dev_agent d00f4ea173 Read the panel SSE body from response.content (task 4cbf2e96) (#295)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 08:15:45 -04:00
orchestrator-agent f2ff73ea31 Merge main into dev after the 0.10.0 release 2026-10-01 13:33:22 -04:00
overseer 602fe7b220 Merge pull request 'Release: custom Font Awesome icons (0.10.0) (2026-09-30)' (#293) from release/2026-09-30-custom-fa-icons into main
Reviewed-on: #293
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-30 20:23:11 -04:00
orchestrator-agent 840bdeb87d release: advance version to 0.10.0 and add the 0.10.0 release notes 2026-09-30 19:21:50 -04:00
dev_agent b629251905 Document the full-set searchable Font Awesome picker (task e2fc2dbc) (#292)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-09-30 19:16:39 -04:00
dev_agent 966beb4862 Searchable Font Awesome Free Solid picker (task 9ca0bc36) (#290)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-09-30 18:31:19 -04:00
dev_agent 40cb3f667a Panel renders any Free Solid Font Awesome icon (task 3f21165f) (#291)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-09-30 18:04:27 -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
95 changed files with 9215 additions and 1895 deletions
+35 -5
View File
@@ -1,3 +1,7 @@
<p align="center">
<img src="design/logos/NestQuest-NoBG.png" alt="NestQuest logo" width="200">
</p>
# NestQuest - House Chaos Coordination # NestQuest - House Chaos Coordination
Home Assistant custom integration for managing chores, custody schedules, and house coordination. Home Assistant custom integration for managing chores, custody schedules, and house coordination.
@@ -69,12 +73,12 @@ actions:
## Automation Blueprints ## Automation Blueprints
NestQuest ships four automation blueprints in NestQuest ships five automation blueprints in
`custom_components/nestquest/blueprints/automation/`. Import them in `custom_components/nestquest/blueprints/automation/`. Import them in
Home Assistant under **Settings → Automations & Scenes → Blueprints** Home Assistant under **Settings → Automations & Scenes → Blueprints**
(or place the files in your `config/blueprints/automation/` folder), (or place the files in your `config/blueprints/automation/` folder),
then create one automation per blueprint, filling the inputs with your then create automations from the blueprints, filling the inputs with your
household's choices. household's choices. The UniFi blueprint needs one automation per child.
### Wiring to the integration's notification settings ### Wiring to the integration's notification settings
@@ -96,7 +100,7 @@ The end-of-day report deliberately runs BEFORE the midnight missed
sweep and reads the same day's data — do not schedule it at the sweep and reads the same day's data — do not schedule it at the
day-rollover time. day-rollover time.
### The four blueprints ### The five blueprints
1. **Morning summary** (`morning_summary.yaml`) — at the configured 1. **Morning summary** (`morning_summary.yaml`) — at the configured
time, lists every child whose `binary_sensor.nestquest_<child>_present_today` time, lists every child whose `binary_sensor.nestquest_<child>_present_today`
@@ -124,10 +128,36 @@ day-rollover time.
not re-fire, but a second child clearing the same day still not re-fire, but a second child clearing the same day still
celebrates. celebrates.
5. **UniFi quest access** (`unifi_quest_access.yaml`) — create one
automation per child. Select that child's quests-remaining-today
sensor, present-today sensor, and all of their UniFi client access
switches. At 18:25 local
time Monday-Friday or 14:00 Saturday-Sunday, clients with open
quests lose all network access; access returns as soon as the count
reaches zero. When the child is **On travels** (`present today` is off),
access stays on regardless of open quests. An unavailable presence sensor
also leaves access on; verify that sensor before relying on enforcement.
Returning after the cutoff reapplies the rule immediately. At midnight
the old day's quests stop affecting access.
The automation also reconciles after Home Assistant starts and every
five minutes. If the quest sensor is unavailable after the cutoff,
it keeps the child blocked until a valid zero is observed.
First configure the [Home Assistant UniFi Network integration](https://www.home-assistant.io/integrations/unifi/)
with a local account that can manage clients. Under its **Network
access controlled clients** option, add each child's devices by MAC
address, then verify the resulting client access switches manually.
Those switches are **on = allowed** and **off = blocked**. Do not
select gateway, access-point, Home Assistant, or adult-device
switches. To override in an emergency, disable that child's
automation and turn their client switches on; re-enable the
automation to resume the quest rule. Device MAC randomization or
cellular data can bypass a MAC-based Wi-Fi block.
### Example package ### Example package
```yaml ```yaml
# One automation per blueprint — Settings → Automations & Scenes → # Create automations from blueprints — Settings → Automations & Scenes →
# Create → Blueprint, then pick each NestQuest blueprint. # Create → Blueprint, then pick each NestQuest blueprint.
# #
# morning_summary: morning_time 08:00, notify_target notify.mobile_app_your_phone # morning_summary: morning_time 08:00, notify_target notify.mobile_app_your_phone
+315
View File
@@ -1,5 +1,320 @@
# NestQuest Release Notes # NestQuest Release Notes
## Version 0.10.3 — 2026-10-06
### Scope
Patch release carrying the admin PWA desktop-icon update onto `main` (PR #301,
task 55e1e687).
### Behaviour
- The admin PWA's installable icons (192, 512, and 512 maskable) are now
generated from `design/logos/NestQuest_Logo.png` instead of placeholder
graphics, so a home-screen install of the admin app shows the NestQuest logo.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None. Schema unchanged at version 9; no migration, no database backup
required.
### Rollback
- No schema change: rolling back to 0.10.2 requires no database restore.
## Version 0.10.2 — 2026-10-06
### Scope
Patch release carrying the panel SSE idle-timeout fix onto `main` (PR #298, task
6c4b0fa4).
### Behaviour
- The panel event-stream request is no longer subject to aiohttp's default
~300 s total timeout; it now uses an unbounded total with a bounded connect,
so a healthy stream stays open instead of reconnecting every ~5 minutes.
Because the API does not replay transitions onto the Home Assistant event bus,
this also shrinks the windows in which a transition could be missed.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None. Schema unchanged at version 9; no migration, no database backup
required.
### Rollback
- No schema change: rolling back to 0.10.1 requires no database restore.
## Version 0.10.1 — 2026-10-06
### Scope
Patch release carrying the panel SSE stream fix onto `main` (PR #295, task
4cbf2e96).
### Behaviour
- The Home Assistant integration now reads the panel event stream from the
response body (`response.content`), fixing the logged failure
`'async for' requires an object with __aiter__ method, got HassClientResponse`
that logged ~935 times and prevented live panel events (completions,
un-completions, day-complete, missed) from re-firing from the API's SSE
stream. SSE line-buffering and JSON-frame parsing are unchanged.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None. Schema unchanged at version 9; no migration, no database backup
required.
### Known limitations
- Intermittent `GET /api/v1/panel/events failed with HTTP 502` during API
restarts/redeploys is expected (the integration reconnects with backoff).
- The event stream still reconnects periodically because Home Assistant's
shared aiohttp session applies a default total timeout — tracked as a
follow-up; it does not affect correctness.
### Rollback
- No schema change: rolling back to 0.10.0 requires no database restore.
## Version 0.10.0 — 2026-09-30
### Scope
Feature release: choose task icons by typing any Font Awesome Free **solid**
icon name, not just the curated subset. The shared icon registry now embeds the
full Font Awesome Free solid set offline (no CDN — the panel may be offline),
the admin icon picker is searchable with free-text entry and validation, and the
kids' panel renders any Free Solid icon. (Feature `6c681b17`; PRs #289–#292.)
### Behaviour
- **Full Free Solid set**: the panel and admin bundles now carry ~1,422 Font
Awesome Free solid glyphs as inline SVG path data, in addition to the existing
curated quick-pick lists (Lucide and Font Awesome). `fa:<name>` resolves any
Free Solid icon; unknown names still render the fallback glyph.
- **Searchable picker**: the admin definition icon picker's Font Awesome tab has
a search input. Empty shows the curated quick picks; typing filters the full
set into a bounded (≤40 per page) result grid, with Previous / Show more.
- **Free-text entry**: typing an exact Free Solid name selects and stores
`fa:<name>`. A partial, misspelled, Pro, or Brands name is rejected with an
inline message and nothing is saved.
- Stored format is unchanged: `lucide:<name>`, `fa:<name>`, `emoji:<grapheme>`,
or a legacy bare name.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None to the database: schema unchanged at version 9, no migration and no
database backup required.
- Font Awesome **Pro** and **Brands/Regular** icons are NOT included — only the
Free Solid set. A Pro/Brands name is rejected in the picker (and an unknown
`fa:` name falls back on the panel).
### Known limitations
- **Bundle size**: the embedded icon set increases the committed panel bundle to
~1.06 MB raw (~276 kB gzip) and the admin PWA bundle similarly. This is
expected for the full Free Solid set.
- Alias names (e.g. Font Awesome's `fa:home` alias of `fa:house`) are
de-duplicated to the canonical name, so `fa:house` resolves but `fa:home`
falls back.
- The backend still validates only the `fa:<kebab-name>` format; unknown names
are caught in the picker and render as the fallback on the panel.
### Rollback
- No schema change: rolling back to 0.9.8 requires no database restore.
## Version 0.9.8 — 2026-09-30
### Scope
Owner-directed change: the application's timezone is now **manual-only and
admin-authoritative**. Nothing sets it automatically any more; an admin sets it
from the admin app, and the saved value is the single source of local time for
the whole application. This reverses the automatic Home Assistant zone adoption
shipped in 0.9.6 and the admin app's browser-zone auto-set, and supersedes
decision D-018 with D-019. (PRs #282, #283, #285, #286; feature
`a3cd8e27` "Manual application timezone control".)
### Behaviour
- **No automatic timezone setting.** Removed: the API's adoption of Home
Assistant's reported zone (the `X-NestQuest-Timezone` request header and the
`adopt_reported_timezone` path), the integration's reporting of
`hass.config.time_zone`, the admin app's browser-zone auto-detect and
automatic save, and the `timezone_configured` marker.
- **The admin sets it manually**, in the admin app's Preferences: a curated IANA
zone picker plus a free "Other IANA zone" entry, with an explicit choice for
empty ("API host local time"). Nothing is auto-filled or auto-saved.
- **The saved value is authoritative across the whole application**: the panel
snapshot's overdue/today derivation, both panel cards' dates and clocks,
completion timestamps, the missed-sweep scheduler, and the admin reads all use
it. The API publishes the effective zone (`api/host_zone.effective_timezone`:
the saved value, else the API host's own zone) on the panel snapshot and the
admin settings response, so the panel and admin app render in the same zone.
- **The kids' panel now follows the application zone, not the browser.** This
supersedes the panel-local overdue behaviour from 0.9.7; a quest is overdue
when the application clock says so, not when the panel device's clock does.
- **An admin save of the timezone or the day-rollover time re-plans the running
missed-sweep scheduler.**
- The integration tolerates an API snapshot without the timezone field, so the
integration and the API may be upgraded in either order.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None to the database. The schema is unchanged at version 9: no migration and
no database backup is required for this release.
- Behavioural reversal: if a timezone had been auto-set by 0.9.6–0.9.7, the
stored value persists after upgrading (it is simply no longer changed
automatically), so no action is needed unless a different zone is wanted.
- With no timezone saved, the API host's local zone applies (UTC on the standard
deployment). Set the timezone in the admin Preferences for correct
household-local behaviour. A previously auto-set value counts as saved and
will be kept.
### Known limitations
- If no timezone has been saved, the API host zone applies; on the standard
UTC-host deployment, overdue and day boundaries will be wrong until an admin
sets the zone.
- The admin app's timezone picker replaces any zone outside its curated list via
the "Other IANA zone" entry; the value is validated as an IANA name.
### Rollback
- No schema change: rolling back to 0.9.7 requires no database restore.
## Version 0.9.7 — 2026-09-29
### Scope
Patch release carrying the panel-local overdue correction onto `main` (PR #279,
task fd1093b5 "Tasks are still showing over due when the local time has not
passed").
### Behaviour
- The kids' Quest Log card now decides whether an open quest is OVERDUE from
the panel's OWN local clock (the wall time where the panel physically is)
compared with the quest's due time, instead of trusting the API snapshot's
`overdue` flag. A quest therefore cannot read OVERDUE before its due time has
passed on the panel, regardless of the API host's timezone, Home Assistant's
configured `time_zone`, or whether the 0.9.6 timezone changes have been
deployed. A quest is overdue at the due minute and after (`>=`); a quest with
no due time is never overdue.
- The API's `overdue` field is unchanged; it still serves the admin plane and
automations. This is a panel display correction only.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None. The database schema is unchanged at version 9: no migration and no
database backup is required for this release.
### Known limitations
- The comparison uses only wall-clock time. If the API is unreachable across
local midnight and the coordinator serves its last-good snapshot, a quest
from the previous day with a later due time can briefly read as not overdue
until that time passes again. On the normal path the API rolls the household
day over and the missed sweep removes the previous day's open quests.
- The Quest Log header clock still formats in Home Assistant's configured
`time_zone`; if HA is set to a zone other than the household's, that header
remains wrong. Set Home Assistant's own `time_zone` correctly — the admin/API
timezone setting does not affect the header clock.
### Rollback
- No schema change: rolling back to 0.9.6 requires no database restore.
## Version 0.9.6 — 2026-09-28
### Scope
Patch release carrying the household time-zone fix onto `main` (PR #275, task
6351b36d "Time zone errors"), plus its release-review correction (PR #277, task
45243478).
### Behaviour
- The Home Assistant integration now reports `hass.config.time_zone` to the API
in an `X-NestQuest-Timezone` header on every panel request.
- While the API's `timezone` setting has never been explicitly chosen
(`timezone_configured` false), the API adopts and persists the reported zone
(under the settings lock), so the panel's overdue/today calculation, the
admin plane and the missed-sweep scheduler are all household-local even when
the API host runs UTC. No manual setup is required.
- The missed-sweep scheduler re-plans immediately when the adopted zone changes
while it is sleeping, and re-reads the stored zone before sweeping, so it no
longer fires at the old (UTC) rollover.
- An admin-chosen zone — including an explicit choice of empty (API-host local)
— still wins over the reported zone. A timezone-only admin update is recorded
as an explicit choice (`timezone_configured` true); an explicitly supplied
`timezone_configured` value in the same update is honored as given. An invalid
reported zone is logged and ignored; the panel keeps serving on the stored
settings.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None. The database schema is unchanged at version 9: no migration and no
database backup is required for this release.
- Deployment note: this fix needs BOTH the API service and the HA integration
upgraded. The integration supplies the zone and the API adopts it; upgrading
only one side leaves the previous behaviour. An install that sets or clears
its zone on 0.9.6 or later keeps that choice; see Known limitations for a zone
stored by an older release.
### Known limitations
- A zone stored by a release before 0.9.6 without the explicit-choice marker
(for example through the previously documented
`PATCH /api/v1/admin/settings {"timezone": ...}`) is not distinguishable from
an automatically adopted value, so 0.9.6 adopts Home Assistant's reported zone
over it once. If a zone different from Home Assistant's is intended, re-save
it in the admin Preferences, which now records the explicit choice.
- If `timezone` is empty and `timezone_configured` is true (an explicit
"host local" choice), that choice is respected and the API host's zone (UTC
on the standard deployment) still applies until an admin selects a zone in
the admin Preferences.
- A change to Home Assistant's own `time_zone` takes effect after the
integration reloads or Home Assistant restarts.
### Rollback
- No schema change: rolling back to 0.9.5 requires no database restore. The API
and the integration may be rolled back independently; rolling back only the
integration restores the previous stored-setting-only behaviour.
## Version 0.9.5 — 2026-09-27 ## Version 0.9.5 — 2026-09-27
### Scope ### Scope
+5 -5
View File
@@ -1,8 +1,8 @@
# Third-party notices # Third-party notices
NestQuest bundles glyph data from the icon sets below. The curated names are listed in NestQuest bundles glyph data from the icon sets below. The curated Lucide names and the
`tools/icons/curated-icons.json`; `tools/icons/generate.mjs` reads each glyph's SVG path Font Awesome quick-pick names are listed in `tools/icons/curated-icons.json`;
data from the locally installed packages and generates it into both bundles `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`). (`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 Nothing is loaded from a CDN or fetched at runtime. The package versions used are recorded
in the header of each generated file. in the header of each generated file.
@@ -15,8 +15,8 @@ in the header of each generated file.
- Icons: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) - Icons: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
- Fonts: [SIL OFL 1.1](https://openfontlicense.org) - Fonts: [SIL OFL 1.1](https://openfontlicense.org)
- Code: [MIT](https://opensource.org/licenses/MIT) - Code: [MIT](https://opensource.org/licenses/MIT)
- Use in NestQuest: a curated subset of the Free **solid** icon set, embedded as SVG path - Use in NestQuest: the full Free **solid** icon set, embedded as SVG path data (a curated
data. No fonts are bundled. subset doubles as the admin picker's quick picks). No fonts are bundled.
Font Awesome Free by @fontawesome — https://fontawesome.com — icons licensed under CC BY 4.0. Font Awesome Free by @fontawesome — https://fontawesome.com — icons licensed under CC BY 4.0.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 178 KiB

+112 -22
View File
@@ -1,14 +1,12 @@
// Generates the committed PWA icons under public/icons/ as genuine PNGs. // Generates the committed PWA icons under public/icons/ from the brand logo
// (design/logos/NestQuest_Logo.png), box-filter downscaled.
// Pure Node (zlib only) — no network, no image dependencies. // Pure Node (zlib only) — no network, no image dependencies.
// Run: node scripts/generate-icons.mjs // Run: node scripts/generate-icons.mjs
import { writeFileSync } from "node:fs"; import { readFileSync, writeFileSync } from "node:fs";
import { deflateSync } from "node:zlib"; import { deflateSync, inflateSync } from "node:zlib";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
// Brand tokens (design/tokens/nestquest-admin-tokens.css). const SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const BLUE = [0x3b, 0x3a, 0xb8];
const PURPLE = [0xa0, 0x35, 0xcc];
const WHITE = [0xff, 0xff, 0xff];
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
let c = n; let c = n;
@@ -44,37 +42,129 @@ function encodePng(size, pixel) {
for (let x = 0; x < size; x++) raw.set(pixel(x, y), row + 1 + x * 3); for (let x = 0; x < size; x++) raw.set(pixel(x, y), row + 1 + x * 3);
} }
return Buffer.concat([ return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), SIG,
chunk("IHDR", ihdr), chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw, { level: 9 })), chunk("IDAT", deflateSync(raw, { level: 9 })),
chunk("IEND", Buffer.alloc(0)), chunk("IEND", Buffer.alloc(0)),
]); ]);
} }
// Flat brand mark: diagonal blue→purple gradient with a white ring ("nest") // Minimal decoder for the source logo: 8-bit RGB, non-interlaced only.
// around a white dot ("quest"). `scale` shrinks the mark for the maskable // Anything else fails loudly rather than producing a wrong icon.
// safe zone (inner 80% circle). function decodePng(buf) {
function brandMark(size, scale) { if (!buf.subarray(0, 8).equals(SIG)) throw new Error("not a PNG");
const c = size / 2; let width, height;
const outer = size * 0.34 * scale; const idat = [];
const inner = size * 0.24 * scale; for (let off = 8; off < buf.length; ) {
const dot = size * 0.1 * scale; const len = buf.readUInt32BE(off);
const type = buf.toString("ascii", off + 4, off + 8);
const data = buf.subarray(off + 8, off + 8 + len);
if (type === "IHDR") {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
const [depth, colour, , , interlace] = data.subarray(8);
if (depth !== 8 || colour !== 2 || interlace !== 0) {
throw new Error(`unsupported PNG: depth ${depth}, colour type ${colour}, interlace ${interlace}`);
}
} else if (type === "IDAT") idat.push(data);
else if (type === "IEND") break;
off += 12 + len;
}
const raw = inflateSync(Buffer.concat(idat));
const stride = width * 3;
const px = Buffer.alloc(stride * height);
for (let y = 0; y < height; y++) {
const filter = raw[y * (stride + 1)];
const src = y * (stride + 1) + 1;
const dst = y * stride;
for (let i = 0; i < stride; i++) {
const a = i >= 3 ? px[dst + i - 3] : 0;
const b = y > 0 ? px[dst - stride + i] : 0;
const c = i >= 3 && y > 0 ? px[dst - stride + i - 3] : 0;
let p;
if (filter === 0) p = 0;
else if (filter === 1) p = a;
else if (filter === 2) p = b;
else if (filter === 3) p = (a + b) >> 1;
else if (filter === 4) {
const pa = Math.abs(b - c), pb = Math.abs(a - c), pc = Math.abs(a + b - 2 * c);
p = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
} else throw new Error(`bad PNG filter ${filter} on row ${y}`);
px[dst + i] = (raw[src + i] + p) & 0xff;
}
}
return { width, height, px };
}
// Box-filter (area-average) resize along one axis: each output sample is the
// coverage-weighted mean of the source samples it spans.
function boxWeights(srcLen, dstLen) {
const scale = srcLen / dstLen;
return Array.from({ length: dstLen }, (_, o) => {
const start = o * scale, end = start + scale;
const taps = [];
for (let s = Math.floor(start); s < Math.min(Math.ceil(end), srcLen); s++) {
taps.push([s, (Math.min(end, s + 1) - Math.max(start, s)) / scale]);
}
return taps;
});
}
function resize({ width, height, px }, size) {
const wx = boxWeights(width, size);
const wy = boxWeights(height, size);
const tmp = new Float64Array(size * height * 3); // horizontal pass
for (let y = 0; y < height; y++) {
for (let x = 0; x < size; x++) {
for (const [s, w] of wx[x]) {
for (let k = 0; k < 3; k++) tmp[(y * size + x) * 3 + k] += px[(y * width + s) * 3 + k] * w;
}
}
}
const out = new Uint8Array(size * size * 3); // vertical pass
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
for (let k = 0; k < 3; k++) {
let v = 0;
for (const [s, w] of wy[y]) v += tmp[(s * size + x) * 3 + k] * w;
out[(y * size + x) * 3 + k] = Math.min(255, Math.round(v));
}
}
}
return (x, y) => out.subarray((y * size + x) * 3, (y * size + x) * 3 + 3);
}
// Mean of the four corner pixels — the logo's solid background colour.
function cornerColour({ width, height, px }) {
const corners = [[0, 0], [width - 1, 0], [0, height - 1], [width - 1, height - 1]];
return [0, 1, 2].map((k) =>
Math.round(corners.reduce((sum, [x, y]) => sum + px[(y * width + x) * 3 + k], 0) / 4),
);
}
// `scale` < 1 shrinks the logo onto a background-coloured canvas so a
// maskable icon keeps the whole emblem inside the safe zone (inner 80% circle).
function logoIcon(logo, size, scale) {
if (scale === 1) return resize(logo, size);
const inner = Math.round(size * scale);
const offset = Math.floor((size - inner) / 2);
const pixel = resize(logo, inner);
const bg = cornerColour(logo);
return (x, y) => { return (x, y) => {
const d = Math.hypot(x + 0.5 - c, y + 0.5 - c); const ix = x - offset, iy = y - offset;
if ((d <= outer && d >= inner) || d <= dot) return WHITE; return ix >= 0 && iy >= 0 && ix < inner && iy < inner ? pixel(ix, iy) : bg;
const t = (x + y) / (2 * (size - 1));
return BLUE.map((b, i) => Math.round(b + (PURPLE[i] - b) * t));
}; };
} }
const logo = decodePng(readFileSync(new URL("../../design/logos/NestQuest_Logo.png", import.meta.url)));
const outDir = new URL("../public/icons/", import.meta.url); const outDir = new URL("../public/icons/", import.meta.url);
const icons = [ const icons = [
["icon-192.png", 192, 1], ["icon-192.png", 192, 1],
["icon-512.png", 512, 1], ["icon-512.png", 512, 1],
["icon-maskable-512.png", 512, 0.75], ["icon-maskable-512.png", 512, 0.8],
]; ];
for (const [name, size, scale] of icons) { for (const [name, size, scale] of icons) {
const path = fileURLToPath(new URL(name, outDir)); const path = fileURLToPath(new URL(name, outDir));
writeFileSync(path, encodePng(size, brandMark(size, scale))); writeFileSync(path, encodePng(size, logoIcon(logo, size, scale)));
console.log(`wrote ${path}`); console.log(`wrote ${path}`);
} }
+49 -6
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 { cleanup, fireEvent, render, screen } from "@testing-library/react";
import App from "./App"; 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", () => { describe("NestQuest Admin shell", () => {
beforeEach(() => { beforeEach(() => {
// The Today tab fetches the admin snapshot; keep the shell tests offline. settingsResponse = () =>
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {}))); 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(() => { 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 />); render(<App />);
expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy(); expect(await screen.findByRole("heading", { name: "Today" })).toBeTruthy();
fireEvent.click(screen.getByRole("tab", { name: "History" })); 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 />); render(<App />);
fireEvent.click(screen.getByRole("tab", { name: "Tasks" })); 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"); 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();
});
}); });
+4
View File
@@ -4,6 +4,7 @@ import { TransitionsProvider } from "./api/useTransitions";
import DefinitionsTab from "./definitions/DefinitionsTab"; import DefinitionsTab from "./definitions/DefinitionsTab";
import HistoryTab from "./history/HistoryTab"; import HistoryTab from "./history/HistoryTab";
import ScheduleTab from "./schedule/ScheduleTab"; import ScheduleTab from "./schedule/ScheduleTab";
import { AppTimezoneProvider } from "./time/AppTimezone";
import TodayTab from "./today/TodayTab"; import TodayTab from "./today/TodayTab";
const TABS = [ const TABS = [
@@ -32,6 +33,8 @@ export default function App() {
role="tabpanel" role="tabpanel"
aria-labelledby={`tab-${active}`} aria-labelledby={`tab-${active}`}
> >
{/* Every screen's local time is the stored application timezone. */}
<AppTimezoneProvider>
{active === "today" ? ( {active === "today" ? (
<TodayTab /> <TodayTab />
) : active === "tasks" ? ( ) : active === "tasks" ? (
@@ -41,6 +44,7 @@ export default function App() {
) : ( ) : (
<HistoryTab /> <HistoryTab />
)} )}
</AppTimezoneProvider>
</main> </main>
<nav className="admin-tabbar" aria-label="Admin sections"> <nav className="admin-tabbar" aria-label="Admin sections">
{TABS.map((tab) => ( {TABS.map((tab) => (
+4 -5
View File
@@ -20,15 +20,14 @@ export interface HouseholdSettings {
/** IANA zone name (e.g. "America/New_York"); "" means the API host's local time. */ /** IANA zone name (e.g. "America/New_York"); "" means the API host's local time. */
timezone: string; timezone: string;
/** /**
* True once the admin explicitly chose `timezone` (even ""); false on a * Read-only: the zone the API reads its clock in — `timezone`, or the API
* fresh install, where the screen auto-sets the browser's zone. API-only: * host's own zone when that is "" ("" only when the host's zone has no name).
* never shown in the form.
*/ */
timezone_configured: boolean; effective_timezone: string;
} }
/** Only the supplied fields are updated; the API answers with all of them. */ /** 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"; export const SETTINGS_PATH = "/api/v1/admin/settings";
+31
View File
@@ -614,3 +614,34 @@
.defs-emoji { .defs-emoji {
line-height: 1; line-height: 1;
} }
/* Font Awesome search: glyph + label tiles, inline validation, paging */
.defs-icon-grid--results {
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
}
.defs-icon-result {
flex-direction: column;
gap: 4px;
padding: 6px 4px;
font-family: inherit;
}
.defs-icon-label {
max-width: 100%;
overflow: hidden;
font-size: 11px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.defs-hint--error {
color: var(--nq-a-danger-strong);
}
.defs-show-more {
align-self: flex-start;
display: flex;
gap: 8px;
}
+298 -2
View File
@@ -1,4 +1,4 @@
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 { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import DefinitionsTab from "./DefinitionsTab"; import DefinitionsTab from "./DefinitionsTab";
import { import {
@@ -7,11 +7,14 @@ import {
FA_DEFINITION_ICONS, FA_DEFINITION_ICONS,
emojiIconKey, emojiIconKey,
faIconName, faIconName,
faTypedIconKey,
findDefinitionIcon, findDefinitionIcon,
normalizeDefinitionIconName, normalizeDefinitionIconName,
searchFaIcons,
} from "./definitionIcons"; } from "./definitionIcons";
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions"; import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
import { storeTokens } from "../auth/oidc"; import { storeTokens } from "../auth/oidc";
import { AppTimezoneProvider } from "../time/AppTimezone";
function child(id: number, name: string, overrides: Partial<AdminChild> = {}): AdminChild { function child(id: number, name: string, overrides: Partial<AdminChild> = {}): AdminChild {
return { return {
@@ -1352,7 +1355,7 @@ describe("DefinitionsTab Font Awesome and emoji icons", () => {
fireEvent.click(within(s).getByRole("button", { name: "Morning" })); fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
} }
it("ships the curated Font Awesome subset, each drawn as a filled glyph", () => { it("ships the curated Font Awesome quick picks, each drawn as a filled glyph", () => {
expect(FA_DEFINITION_ICONS.length).toBeGreaterThan(0); expect(FA_DEFINITION_ICONS.length).toBeGreaterThan(0);
for (const entry of FA_DEFINITION_ICONS) { for (const entry of FA_DEFINITION_ICONS) {
expect(entry.kind).toBe("fa"); expect(entry.kind).toBe("fa");
@@ -1528,6 +1531,245 @@ describe("DefinitionsTab Font Awesome and emoji icons", () => {
}); });
}); });
/* ── Font Awesome search ────────────────────────────────────────────── */
describe("DefinitionsTab Font Awesome search", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
const CURATED = new Set(FA_DEFINITION_ICONS.map((icon) => icon.name));
function searchInput(s: HTMLElement): HTMLInputElement {
return within(s).getByRole("searchbox", { name: "Search Font Awesome icons" }) as HTMLInputElement;
}
function results(s: HTMLElement): HTMLElement {
return within(s).getByRole("radiogroup", { name: "Font Awesome search results" });
}
function resultKeys(s: HTMLElement): string[] {
return within(results(s))
.getAllByRole("radio")
.map((option) => option.getAttribute("data-icon-option")!);
}
function rowFaIcon(row: HTMLElement): string | null {
const svg = row.querySelector(".defs-row-glyph svg");
return svg!.getAttribute("fill") === "none" ? null : svg!.getAttribute("data-icon");
}
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("searches the full Free Solid set by name or label, exact and prefix matches first", () => {
expect(searchFaIcons("")).toEqual([]);
expect(searchFaIcons(" ")).toEqual([]);
const dog = searchFaIcons(" DOG ").map((icon) => icon.name);
expect(dog[0]).toBe("dog");
expect(dog).toEqual(expect.arrayContaining(["hotdog", "shield-dog"]));
expect(dog.some((name) => !CURATED.has(name))).toBe(true);
// A label match: "Shield Dog" contains "shield d" though no name does.
expect(searchFaIcons("shield d").map((icon) => icon.name)).toContain("shield-dog");
expect(searchFaIcons("dog-leashed")).toEqual([]);
expect(faTypedIconKey("dragon")).toBe("fa:dragon");
expect(faTypedIconKey(" Dragon ")).toBe("fa:dragon");
for (const bad of ["", "drag", "dgo", "dog-leashed", "github", "fa:dog"]) {
expect(faTypedIconKey(bad), bad).toBeNull();
}
});
it("shows the curated quick picks while the search is empty", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
expect(searchInput(s).value).toBe("");
const quick = within(s).getByRole("radiogroup", { name: "Font Awesome" });
expect(within(quick).getAllByRole("radio")).toHaveLength(FA_DEFINITION_ICONS.length);
expect(within(s).queryByRole("radiogroup", { name: "Font Awesome search results" })).toBeNull();
});
it("a query swaps the quick picks for one bounded, labelled page of results", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fireEvent.change(searchInput(s), { target: { value: "a" } });
const total = searchFaIcons("a").length;
expect(total).toBeGreaterThan(80);
expect(within(s).queryByRole("radiogroup", { name: "Font Awesome" })).toBeNull();
expect(resultKeys(s)).toHaveLength(40);
expect(within(results(s)).getAllByRole("radio")[0].textContent).toBe(searchFaIcons("a")[0].label);
expect(within(s).getByText(`Showing 1–40 of ${total}`)).toBeTruthy();
expect(within(s).queryByRole("button", { name: "Previous" })).toBeNull();
// "Show more" replaces the page rather than appending to it.
const firstPage = resultKeys(s);
fireEvent.click(within(s).getByRole("button", { name: "Show more" }));
expect(resultKeys(s)).toEqual(searchFaIcons("a").slice(40, 80).map((icon) => `fa:${icon.name}`));
expect(resultKeys(s).some((key) => firstPage.includes(key))).toBe(false);
expect(within(s).getByText(`Showing 41–80 of ${total}`)).toBeTruthy();
fireEvent.click(within(s).getByRole("button", { name: "Previous" }));
expect(resultKeys(s)).toEqual(firstPage);
// A new query starts again from the first page.
fireEvent.change(searchInput(s), { target: { value: "dog" } });
expect(resultKeys(s)[0]).toBe("fa:dog");
expect(resultKeys(s)).toContain("fa:hotdog");
expect(within(s).queryByRole("button", { name: "Show more" })).toBeNull();
fireEvent.change(searchInput(s), { target: { value: "" } });
expect(within(s).getByRole("radiogroup", { name: "Font Awesome" })).toBeTruthy();
});
it("paging through every match never mounts more than one page", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fireEvent.change(searchInput(s), { target: { value: "-" } });
const all = searchFaIcons("-").map((icon) => `fa:${icon.name}`);
expect(all.length).toBeGreaterThan(900);
const seen: string[] = [];
for (;;) {
expect(results(s).children.length).toBeGreaterThan(0);
expect(results(s).children.length).toBeLessThanOrEqual(40);
seen.push(...resultKeys(s));
const more = within(s).queryByRole("button", { name: "Show more" });
if (!more) break;
fireEvent.click(more);
}
// Every match stays reachable, one page at a time, in order.
expect(seen).toEqual(all);
const lastStart = Math.floor((all.length - 1) / 40) * 40 + 1;
expect(within(s).getByText(`Showing ${lastStart}–${all.length} of ${all.length}`)).toBeTruthy();
});
it("selecting a search result POSTs fa:<name> and renders on its row", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fillNewTask(s);
fireEvent.change(searchInput(s), { target: { value: "hot" } });
expect(within(s).getByRole("alert").textContent).toMatch(/Select a Font Awesome icon from the results/);
const hotdog = within(results(s)).getByRole("radio", { name: "Hotdog" });
fireEvent.click(hotdog);
expect(hotdog.getAttribute("aria-checked")).toBe("true");
expect(within(s).queryByRole("alert")).toBeNull();
expect(searchInput(s).value).toBe("hot");
await save(s);
expect((api.writes()[0].body as { icon: string }).icon).toBe("fa:hotdog");
await waitFor(() => expect(rowFaIcon(screen.getByTestId("definition-99"))).toBe("hotdog"));
});
it("a typed non-curated Free Solid name is accepted and PATCHed", async () => {
expect(CURATED.has("dragon")).toBe(false);
await renderReady([{ ...BRUSH, id: 70, icon: "lucide:dog" }]);
fireEvent.click(screen.getByTestId("definition-70"));
const s = sheet();
fireEvent.change(searchInput(s), { target: { value: "Dragon" } });
expect(within(s).queryByRole("alert")).toBeNull();
expect(within(results(s)).getByRole("radio", { name: "Dragon" }).getAttribute("aria-checked")).toBe(
"true",
);
const lucide = within(s).getByRole("radiogroup", { name: "Icon" });
expect(within(lucide).queryAllByRole("radio", { checked: true })).toHaveLength(0);
await save(s);
expect((api.writes()[0].body as { icon: string }).icon).toBe("fa:dragon");
await waitFor(() => expect(rowFaIcon(screen.getByTestId("definition-70"))).toBe("dragon"));
});
for (const [typed, message] of [
["do", /Select a Font Awesome icon from the results/],
["dgo", /“dgo” is not a Font Awesome Free Solid icon/],
["dog-leashed", /“dog-leashed” is not a Font Awesome Free Solid icon/],
["github", /“github” is not a Font Awesome Free Solid icon/],
] as const) {
it(`typed ${JSON.stringify(typed)} is rejected with a message and nothing is saved`, async () => {
await renderReady([{ ...BRUSH, id: 71, icon: "lucide:dog" }]);
fireEvent.click(screen.getByTestId("definition-71"));
const s = sheet();
fireEvent.change(searchInput(s), { target: { value: typed } });
expect(within(s).getByRole("alert").textContent).toMatch(message);
expect(within(s).getByRole("radio", { name: "No icon" }).getAttribute("aria-checked")).toBe("false");
await save(s);
expect(api.writes()).toHaveLength(0);
expect(within(s).getByTestId("sheet-error").textContent).toMatch(message);
expect(screen.getByRole("dialog")).toBe(s);
});
}
it("clearing the search, or No icon, removes the icon", async () => {
await renderReady([
{ ...BRUSH, id: 72, icon: "fa:dragon" },
{ ...BRUSH, id: 73, title: "Task 73", icon: "fa:dragon" },
]);
fireEvent.click(screen.getByTestId("definition-72"));
let s = sheet();
fireEvent.change(searchInput(s), { target: { value: "" } });
expect(within(s).getByRole("radio", { name: "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-73"));
s = sheet();
fireEvent.click(within(s).getByRole("radio", { name: "No icon" }));
expect(searchInput(s).value).toBe("");
await save(s);
expect((api.writes()[1].body as { icon: string | null }).icon).toBeNull();
});
it("a saved non-curated fa:<name> renders on its row and opens selected in the results", async () => {
await renderReady([{ ...BRUSH, id: 74, icon: "fa:dragon" }]);
expect(rowFaIcon(screen.getByTestId("definition-74"))).toBe("dragon");
fireEvent.click(screen.getByTestId("definition-74"));
const s = sheet();
expect(searchInput(s).value).toBe("dragon");
expect(within(s).queryByRole("alert")).toBeNull();
expect(within(results(s)).getByRole("radio", { name: "Dragon" }).getAttribute("aria-checked")).toBe(
"true",
);
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Feed the dragon" },
});
await save(s);
expect(api.writes()[0].body).not.toHaveProperty("icon");
});
it("a saved curated fa:<name> opens in the quick picks with an empty search", async () => {
await renderReady([{ ...BRUSH, id: 75, icon: "fa:dog" }]);
fireEvent.click(screen.getByTestId("definition-75"));
const s = sheet();
expect(searchInput(s).value).toBe("");
const quick = within(s).getByRole("radiogroup", { name: "Font Awesome" });
expect(within(quick).getByRole("radio", { name: "Dog" }).getAttribute("aria-checked")).toBe("true");
});
});
/* ── Legacy icons ───────────────────────────────────────────────────── */ /* ── Legacy icons ───────────────────────────────────────────────────── */
describe("DefinitionsTab legacy icon edits", () => { describe("DefinitionsTab legacy icon edits", () => {
@@ -1723,3 +1965,57 @@ describe("DefinitionsTab start date", () => {
expect(within(s).getByText("Pick a start date.")).toBeTruthy(); 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");
});
});
+118 -30
View File
@@ -4,6 +4,7 @@
*/ */
import { import {
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
type KeyboardEvent as ReactKeyboardEvent, type KeyboardEvent as ReactKeyboardEvent,
@@ -53,10 +54,17 @@ import {
emojiIconText, emojiIconText,
faIconKey, faIconKey,
faIconName, faIconName,
faTextProblem,
faTypedIconKey,
findDefinitionIcon,
lucideIconKey, lucideIconKey,
normalizeDefinitionIconName, normalizeDefinitionIconName,
searchFaIcons,
} from "./definitionIcons"; } from "./definitionIcons";
import { GlyphSvg } from "../icons/GlyphSvg"; import { GlyphSvg } from "../icons/GlyphSvg";
import { weekdayOf } from "../schedule/cycle";
import { useAppTimezone } from "../time/AppTimezone";
import { todayIn } from "../time/zone";
import "./DefinitionsTab.css"; import "./DefinitionsTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */ /** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
@@ -84,6 +92,9 @@ const REPEATS_BY_RULE: Record<DefinitionRule["rule_type"], Repeats> = {
/** How many upcoming dates the edit sheet previews. */ /** How many upcoming dates the edit sheet previews. */
const PREVIEW_COUNT = 5; const PREVIEW_COUNT = 5;
/** Font Awesome search results per page; only one page is ever mounted. */
const FA_RESULTS_PAGE = 40;
/** Quiet period after the last rule edit before the preview is requested. */ /** Quiet period after the last rule edit before the preview is requested. */
const PREVIEW_DEBOUNCE_MS = 300; const PREVIEW_DEBOUNCE_MS = 300;
@@ -98,6 +109,11 @@ interface Draft {
icon: string | null; icon: string | null;
/** The emoji field's text; typing in it sets `icon` to `emoji:<text>`. */ /** The emoji field's text; typing in it sets `icon` to `emoji:<text>`. */
emoji: string; emoji: string;
/**
* The Font Awesome search text; typing an exact Free Solid name sets
* `icon` to `fa:<name>`, and picking a search result keeps the text.
*/
faText: string;
assigneeIds: number[]; assigneeIds: number[];
repeats: Repeats; repeats: Repeats;
interval: string; interval: string;
@@ -121,50 +137,47 @@ type LoadState =
/** `null` = closed, `"new"` = creating, a definition = editing it. */ /** `null` = closed, `"new"` = creating, a definition = editing it. */
type SheetTarget = null | "new" | QuestDefinition; 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> { function emptyDueTimes(): Record<WindowName, string> {
return { morning: "", afternoon: "", evening: "" }; return { morning: "", afternoon: "", evening: "" };
} }
function newDraft(activeChildren: AdminChild[]): Draft { /** Draft defaults take `today` ("YYYY-MM-DD") in the application timezone. */
const today = new Date(); function newDraft(activeChildren: AdminChild[], today: string): Draft {
const [, month, day] = today.split("-").map(Number);
return { return {
title: "", title: "",
icon: null, icon: null,
emoji: "", emoji: "",
faText: "",
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [], assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
repeats: "daily", repeats: "daily",
interval: "1", interval: "1",
weekdays: [(today.getDay() + 6) % 7], weekdays: [weekdayOf(today)],
dayOfMonth: String(today.getDate()), dayOfMonth: String(day),
month: String(today.getMonth() + 1), month: String(month),
monthlyWeekday: null, monthlyWeekday: null,
windows: [], windows: [],
dueTimes: emptyDueTimes(), dueTimes: emptyDueTimes(),
// Mirrors the API's create default: no instance on an away day. // Mirrors the API's create default: no instance on an away day.
skipOnAway: true, skipOnAway: true,
startDate: localIsoDate(today), startDate: today,
endDate: null, endDate: null,
}; };
} }
function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): Draft { function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[], today: string): Draft {
const { rule } = definition; const { rule } = definition;
const today = new Date(); const [, month, day] = today.split("-").map(Number);
const active = new Set(activeChildren.map((child) => child.id)); const active = new Set(activeChildren.map((child) => child.id));
const dueTimes = emptyDueTimes(); const dueTimes = emptyDueTimes();
for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? ""; for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? "";
const fa = findDefinitionIcon(definition.icon);
return { return {
title: definition.title, title: definition.title,
icon: definition.icon, icon: definition.icon,
emoji: emojiIconText(definition.icon), emoji: emojiIconText(definition.icon),
// A Free Solid icon outside the quick picks opens as its search result.
faText: fa?.kind === "fa" && !FA_DEFINITION_ICONS.includes(fa) ? fa.name : "",
// Inactive children cannot be submitted (the API rejects them with 422). // Inactive children cannot be submitted (the API rejects them with 422).
assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)), assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)),
repeats: REPEATS_BY_RULE[rule.rule_type], repeats: REPEATS_BY_RULE[rule.rule_type],
@@ -172,9 +185,9 @@ function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): D
weekdays: weekdays:
rule.rule_type === "weekly" || rule.rule_type === "custom_days" rule.rule_type === "weekly" || rule.rule_type === "custom_days"
? [...(rule.weekday_set ?? [])] ? [...(rule.weekday_set ?? [])]
: [(today.getDay() + 6) % 7], : [weekdayOf(today)],
dayOfMonth: String(rule.day_of_month ?? today.getDate()), dayOfMonth: String(rule.day_of_month ?? day),
month: String(rule.month ?? today.getMonth() + 1), month: String(rule.month ?? month),
monthlyWeekday: monthlyWeekday:
rule.rule_type === "monthly_weekday" rule.rule_type === "monthly_weekday"
? { nth: rule.nth_weekday ?? 1, weekday: rule.nth_weekday_weekday ?? 0 } ? { nth: rule.nth_weekday ?? 1, weekday: rule.nth_weekday_weekday ?? 0 }
@@ -254,6 +267,7 @@ function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody |
if (draft.emoji && draft.icon === null) { if (draft.emoji && draft.icon === null) {
return "Enter a single emoji (no spaces or < > &), or clear the emoji field."; return "Enter a single emoji (no spaces or < > &), or clear the emoji field.";
} }
if (draft.faText.trim() && draft.icon === null) return faTextProblem(draft.faText);
const rule = buildRule(draft); const rule = buildRule(draft);
if (typeof rule === "string") return rule; if (typeof rule === "string") return rule;
const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map( const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map(
@@ -465,9 +479,11 @@ function EditSheet({
onSaved: () => void; onSaved: () => void;
onDeleted: (result: DefinitionDeleteResult) => void; onDeleted: (result: DefinitionDeleteResult) => void;
}) { }) {
const [draft, setDraft] = useState<Draft>(() => const timeZone = useAppTimezone();
target === "new" ? newDraft(activeChildren) : draftFrom(target, activeChildren), 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 // The active selection as loaded: while it is unchanged, the PATCH omits
// assignee_child_ids, because the API replaces the whole set and inactive // assignee_child_ids, because the API replaces the whole set and inactive
// children cannot be sent back. // children cannot be sent back.
@@ -481,6 +497,11 @@ function EditSheet({
? [] ? []
: target.assignees.filter((a) => !activeChildren.some((child) => child.id === a.id)); : target.assignees.filter((a) => !activeChildren.some((child) => child.id === a.id));
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const faResults = useMemo(() => searchFaIcons(draft.faText), [draft.faText]);
const [faPage, setFaPage] = useState(0);
const faStart = faPage * FA_RESULTS_PAGE;
const faEnd = Math.min(faStart + FA_RESULTS_PAGE, faResults.length);
const noIcon = draft.icon === null && !draft.emoji && !draft.faText.trim();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false); const [confirmingDelete, setConfirmingDelete] = useState(false);
@@ -621,12 +642,10 @@ function EditSheet({
<button <button
type="button" type="button"
role="radio" role="radio"
aria-checked={draft.icon === null && !draft.emoji} aria-checked={noIcon}
aria-label="No icon" aria-label="No icon"
className={ className={noIcon ? "defs-icon defs-icon--on" : "defs-icon"}
draft.icon === null && !draft.emoji ? "defs-icon defs-icon--on" : "defs-icon" onClick={() => update({ icon: null, emoji: "", faText: "" })}
}
onClick={() => update({ icon: null, emoji: "" })}
> >
<TaskGlyph size={18} /> <TaskGlyph size={18} />
</button> </button>
@@ -642,7 +661,7 @@ function EditSheet({
aria-label={label} aria-label={label}
data-icon-option={name} data-icon-option={name}
className={on ? "defs-icon defs-icon--on" : "defs-icon"} className={on ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: lucideIconKey(name), emoji: "" })} onClick={() => update({ icon: lucideIconKey(name), emoji: "", faText: "" })}
> >
<GlyphSvg icon={icon} size={18} /> <GlyphSvg icon={icon} size={18} />
</button> </button>
@@ -652,6 +671,72 @@ function EditSheet({
<span className="defs-sublabel" id="defs-label-icon-fa"> <span className="defs-sublabel" id="defs-label-icon-fa">
Font Awesome Font Awesome
</span> </span>
<input
className="defs-input"
type="search"
aria-label="Search Font Awesome icons"
placeholder="Search or type an icon name, e.g. dog"
value={draft.faText}
onChange={(e) => {
setFaPage(0);
update({ faText: e.target.value, icon: faTypedIconKey(e.target.value), emoji: "" });
}}
/>
{draft.faText.trim() && draft.icon === null ? (
<p className="defs-hint defs-hint--error" role="alert">
{faTextProblem(draft.faText)}
</p>
) : null}
{draft.faText.trim() ? (
faResults.length > 0 ? (
<>
<div
className="defs-icon-grid defs-icon-grid--results"
role="radiogroup"
aria-label="Font Awesome search results"
>
{faResults.slice(faStart, faEnd).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-result defs-icon--on" : "defs-icon defs-icon-result"
}
onClick={() => update({ icon: faIconKey(name), emoji: "" })}
>
<GlyphSvg icon={icon} size={18} />
<span className="defs-icon-label">{label}</span>
</button>
);
})}
</div>
<p className="defs-hint">
Showing {faStart + 1}–{faEnd} of {faResults.length}
</p>
{faResults.length > FA_RESULTS_PAGE ? (
<div className="defs-show-more">
{faPage > 0 ? (
<button type="button" className="defs-retry" onClick={() => setFaPage((page) => page - 1)}>
Previous
</button>
) : null}
{faEnd < faResults.length ? (
<button type="button" className="defs-retry" onClick={() => setFaPage((page) => page + 1)}>
Show more
</button>
) : null}
</div>
) : null}
</>
) : null
) : (
<div className="defs-icon-grid" role="radiogroup" aria-labelledby="defs-label-icon-fa"> <div className="defs-icon-grid" role="radiogroup" aria-labelledby="defs-label-icon-fa">
{FA_DEFINITION_ICONS.map((icon) => { {FA_DEFINITION_ICONS.map((icon) => {
const { name, label } = icon; const { name, label } = icon;
@@ -665,13 +750,14 @@ function EditSheet({
aria-label={label} aria-label={label}
data-icon-option={faIconKey(name)} data-icon-option={faIconKey(name)}
className={on ? "defs-icon defs-icon--on" : "defs-icon"} className={on ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: faIconKey(name), emoji: "" })} onClick={() => update({ icon: faIconKey(name), emoji: "", faText: "" })}
> >
<GlyphSvg icon={icon} size={18} /> <GlyphSvg icon={icon} size={18} />
</button> </button>
); );
})} })}
</div> </div>
)}
<label className="defs-due"> <label className="defs-due">
<span>Emoji</span> <span>Emoji</span>
<input <input
@@ -679,7 +765,9 @@ function EditSheet({
aria-label="Emoji" aria-label="Emoji"
placeholder="e.g. 🐶" placeholder="e.g. 🐶"
value={draft.emoji} value={draft.emoji}
onChange={(e) => update({ emoji: e.target.value, icon: emojiIconKey(e.target.value) })} onChange={(e) =>
update({ emoji: e.target.value, icon: emojiIconKey(e.target.value), faText: "" })
}
/> />
</label> </label>
</Field> </Field>
+54 -9
View File
@@ -1,14 +1,23 @@
/** /**
* The icons a definition's `icon` may name (ADMIN-SPEC §3.3): the curated * 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 * Lucide set, any icon in the full Font Awesome Free Solid set, or an emoji.
* names, labels and path data come from the shared icon registry * The Font Awesome quick picks (`FA_DEFINITION_ICONS`) are only a curated
* (`src/icons/registry.generated.ts`, generated from * shortcut list; `fa:<name>` resolves against the full set
* `tools/icons/curated-icons.json`) — no CDN, no runtime fetch. The stored * (`FA_SOLID_ICONS`). The names, labels and path data come from the shared
* value is namespaced — `lucide:<name>`, `fa:<name>` or `emoji:<grapheme>` — * icon registry (`src/icons/registry.generated.ts`, generated by
* and a legacy bare `<kebab-name>` still names the same Lucide entry. * `tools/icons/generate.mjs` from `tools/icons/curated-icons.json` and the
* installed Free Solid package) — no CDN, no runtime fetch. The stored value
* is namespaced — `lucide:<name>`, `fa:<name>` or `emoji:<grapheme>` — and a
* legacy bare `<kebab-name>` still names the same Lucide entry.
*/ */
import { GlyphSvg } from "../icons/GlyphSvg"; import { GlyphSvg } from "../icons/GlyphSvg";
import { FA_ICONS, LUCIDE_ICONS, resolveIcon, type GlyphIcon } from "../icons/registry.generated"; import {
FA_ICONS,
FA_SOLID_ICONS,
LUCIDE_ICONS,
resolveIcon,
type GlyphIcon,
} from "../icons/registry.generated";
import { TaskGlyph } from "./glyphs"; import { TaskGlyph } from "./glyphs";
export type DefinitionIconEntry = GlyphIcon; export type DefinitionIconEntry = GlyphIcon;
@@ -58,14 +67,50 @@ export function faIconName(value: string | null): string | null {
return value?.startsWith(FA_PREFIX) ? value.slice(FA_PREFIX.length) : null; return value?.startsWith(FA_PREFIX) ? value.slice(FA_PREFIX.length) : null;
} }
/**
* The Font Awesome Free Solid icons whose name or label contains `query`
* (trimmed, case-insensitive): an exact name first, then name-prefix
* matches, then the rest, each in registry (name) order. An empty query
* gives no results — the curated quick picks cover that case.
*/
export function searchFaIcons(query: string): DefinitionIconEntry[] {
const q = query.trim().toLowerCase();
if (!q) return [];
const rank = (icon: DefinitionIconEntry) => (icon.name === q ? 0 : icon.name.startsWith(q) ? 1 : 2);
return FA_SOLID_ICONS.filter(
(icon) => icon.name.includes(q) || icon.label.toLowerCase().includes(q),
).sort((a, b) => rank(a) - rank(b));
}
/**
* The stored key for a typed Font Awesome name, e.g. `Dog` → `fa:dog`: the
* trimmed, lower-cased text must be the exact name of a Free Solid icon,
* else null (a misspelling, a partial search, or a Pro/Brands name).
*/
export function faTypedIconKey(text: string): string | null {
const key = faIconKey(text.trim().toLowerCase());
return resolveIcon(key).kind === "fa" ? key : null;
}
/**
* Why Font Awesome search text cannot be saved as it stands: it matches
* icons but names none of them, or it matches nothing in the Free Solid set.
*/
export function faTextProblem(text: string): string {
return searchFaIcons(text).length > 0
? "Select a Font Awesome icon from the results, or type its full name."
: `“${text.trim()}” is not a Font Awesome Free Solid icon.`;
}
/** The emoji text an `emoji:<grapheme>` value holds; anything else gives "". */ /** The emoji text an `emoji:<grapheme>` value holds; anything else gives "". */
export function emojiIconText(value: string | null): string { export function emojiIconText(value: string | null): string {
return value?.startsWith(EMOJI_PREFIX) ? value.slice(EMOJI_PREFIX.length) : ""; return value?.startsWith(EMOJI_PREFIX) ? value.slice(EMOJI_PREFIX.length) : "";
} }
/** /**
* The curated glyph (Lucide or Font Awesome) an icon value resolves to via * The glyph an icon value resolves to via the shared parser: a curated
* the shared parser; emoji, unknown and null values give null. * Lucide entry or any Font Awesome Free Solid icon (quick pick or not);
* emoji, unknown and null values give null.
*/ */
export function findDefinitionIcon(value: string | null): DefinitionIconEntry | null { export function findDefinitionIcon(value: string | null): DefinitionIconEntry | null {
const icon = resolveIcon(value); const icon = resolveIcon(value);
+115
View File
@@ -5,6 +5,7 @@ import type { HistoryFilter, HistoryRow } from "../api/history";
import { storeTokens } from "../auth/oidc"; import { storeTokens } from "../auth/oidc";
import { TransitionsProvider } from "../api/useTransitions"; import { TransitionsProvider } from "../api/useTransitions";
import { frame, testStream, type TestStream } from "../api/testStream"; import { frame, testStream, type TestStream } from "../api/testStream";
import { AppTimezoneProvider } from "../time/AppTimezone";
const TODAY = "2026-09-23"; const TODAY = "2026-09-23";
@@ -629,3 +630,117 @@ describe("HistoryTab live updates", () => {
expect(screen.getAllByTestId("history-row")).toHaveLength(ROWS.all.length); 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 type { TransitionEvent } from "../api/events";
import { useTransitions } from "../api/useTransitions"; import { useTransitions } from "../api/useTransitions";
import { exportHistoryCsv, fetchHistory, type HistoryFilter, type HistoryRow } from "../api/history"; import { exportHistoryCsv, fetchHistory, type HistoryFilter, type HistoryRow } from "../api/history";
import { localTodayIso } from "../schedule/cycle";
import { dayLabel, groupByDay, rowMeta, rowTime, shiftIso } from "./format"; import { dayLabel, groupByDay, rowMeta, rowTime, shiftIso } from "./format";
import { DownloadGlyph, LockGlyph } from "./glyphs"; import { DownloadGlyph, LockGlyph } from "./glyphs";
import { useAppTimezone } from "../time/AppTimezone";
import { todayIn } from "../time/zone";
import "./HistoryTab.css"; import "./HistoryTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */ /** 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 ( return (
<li className="history-event" data-testid="history-row"> <li className="history-event" data-testid="history-row">
<span <span
@@ -142,10 +143,10 @@ function EventRow({ row }: { row: HistoryRow }) {
{row.quest_title} · {row.child_name} {row.quest_title} · {row.child_name}
</div> </div>
<div className="history-event-meta" data-testid="history-meta"> <div className="history-event-meta" data-testid="history-meta">
{rowMeta(row)} {rowMeta(row, timeZone)}
</div> </div>
</div> </div>
<span className="history-event-time">{rowTime(row)}</span> <span className="history-event-time">{rowTime(row, timeZone)}</span>
</li> </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 [filter, setFilter] = useState<HistoryFilter>("all");
const [state, setState] = useState<LoadState>({ kind: "loading" }); const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0); const [attempt, setAttempt] = useState(0);
@@ -244,7 +248,7 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string
</div> </div>
); );
} else { } else {
const groups = groupByDay(shown.data.rows); const groups = groupByDay(shown.data.rows, timeZone);
body = body =
groups.length > 0 ? ( groups.length > 0 ? (
groups.map((group) => ( 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> <h3 className="history-section-label">{dayLabel(group.day, today)}</h3>
<ul className="history-card history-list"> <ul className="history-card history-list">
{group.rows.map((row, index) => ( {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> </ul>
</section> </section>
+21 -24
View File
@@ -4,12 +4,13 @@
* Calendar dates ("YYYY-MM-DD") are handled by their components (never * 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 * `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 * 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 type { HistoryRow } from "../api/history";
import { dayNumber, isoFromDayNumber, localTodayIso } from "../schedule/cycle"; import { dayNumber, isoFromDayNumber } from "../schedule/cycle";
import { shortDate } from "../schedule/format"; import { shortDate } from "../schedule/format";
import { formatClockTime, formatDueTime } from "../today/format"; import { formatClockTime, formatDueTime } from "../today/format";
import { isoDateIn, wallClockInstant } from "../time/zone";
const MS_PER_MINUTE = 60_000; 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). */ /** The local calendar day an event belongs to (due_date for missed rows). */
export function rowDay(row: HistoryRow): string { export function rowDay(row: HistoryRow, timeZone: string): string {
return row.occurred_at ? localTodayIso(new Date(row.occurred_at)) : row.due_date; 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. */ /** The row's due moment (epoch ms) as local wall-clock time, or null without a due time. */
function dueMoment(row: HistoryRow): Date | null { function dueMoment(row: HistoryRow, timeZone: string): number | null {
const match = /^(\d{2}):(\d{2})/.exec(row.due_time ?? ""); const match = /^(\d{2}):(\d{2})/.exec(row.due_time ?? "");
if (!match) return null; if (!match) return null;
const [year, month, day] = row.due_date.split("-").map(Number); return wallClockInstant(row.due_date, Number(match[1]), Number(match[2]), timeZone);
return new Date(year, month - 1, day, Number(match[1]), Number(match[2]));
} }
/** Newest first; a missed row sorts at its due moment (or the end of its day). */ /** 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); if (row.occurred_at) return Date.parse(row.occurred_at);
const due = dueMoment(row); return dueMoment(row, timeZone) ?? wallClockInstant(row.due_date, 23, 59, timeZone);
if (due) return due.getTime();
const [year, month, day] = row.due_date.split("-").map(Number);
return new Date(year, month - 1, day, 23, 59).getTime();
} }
export interface DayGroup { export interface DayGroup {
@@ -46,10 +43,10 @@ export interface DayGroup {
} }
/** Rows grouped by day, newest day first and newest row first within a day. */ /** 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[]>(); const groups = new Map<string, HistoryRow[]>();
for (const row of rows) { for (const row of rows) {
const day = rowDay(row); const day = rowDay(row, timeZone);
groups.set(day, [...(groups.get(day) ?? []), row]); groups.set(day, [...(groups.get(day) ?? []), row]);
} }
return [...groups.entries()] return [...groups.entries()]
@@ -57,7 +54,7 @@ export function groupByDay(rows: HistoryRow[]): DayGroup[] {
.map(([day, dayRows]) => ({ .map(([day, dayRows]) => ({
day, day,
// Stable sort: rows at the same moment keep the API's append order. // 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. */ /** "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.event_type !== "completed" || row.was_on_time === null) return null;
if (row.was_on_time) return "on time"; if (row.was_on_time) return "on time";
const due = dueMoment(row); const due = dueMoment(row, timeZone);
if (!due || !row.occurred_at) return "late"; if (due === null || !row.occurred_at) return "late";
const minutes = Math.round((Date.parse(row.occurred_at) - due.getTime()) / MS_PER_MINUTE); const minutes = Math.round((Date.parse(row.occurred_at) - due) / MS_PER_MINUTE);
return minutes > 0 ? `late ${minutes} min` : "late"; return minutes > 0 ? `late ${minutes} min` : "late";
} }
/** `<event type> · <actor>` plus the on-time flag for completions. */ /** `<event type> · <actor>` plus the on-time flag for completions. */
export function rowMeta(row: HistoryRow): string { export function rowMeta(row: HistoryRow, timeZone: string): string {
const flag = onTimeFlag(row); const flag = onTimeFlag(row, timeZone);
return [row.event_type, row.actor, flag].filter(Boolean).join(" · "); 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. */ /** The event's local clock time; a missed row shows its due time, if any. */
export function rowTime(row: HistoryRow): string { export function rowTime(row: HistoryRow, timeZone: string): string {
if (row.occurred_at) return formatClockTime(row.occurred_at); if (row.occurred_at) return formatClockTime(row.occurred_at, timeZone);
return row.due_time ? formatDueTime(row.due_time) : ""; return row.due_time ? formatDueTime(row.due_time) : "";
} }
File diff suppressed because it is too large Load Diff
+27 -3
View File
@@ -3,7 +3,14 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
// The offline generator is the only writer of both registry artefacts. // 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 { OUTPUT_PATHS, REPO_ROOT, SOURCE_PATH, renderRegistry } from "../../../tools/icons/generate.mjs";
import { EMOJI_MAX_CODE_POINTS, FALLBACK_ICON, FA_ICONS, LUCIDE_ICONS, resolveIcon } from "./registry.generated"; 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 }[] }; type Curated = { lucide: { name: string; label: string }[]; fa: { name: string; label: string }[] };
@@ -45,7 +52,18 @@ describe("shared icon registry — one source of truth", () => {
expect(icon.viewBox).toMatch(/^0 0 \d+ 512$/); expect(icon.viewBox).toMatch(/^0 0 \d+ 512$/);
expect(icon.paths.length, icon.name).toBeGreaterThan(0); expect(icon.paths.length, icon.name).toBeGreaterThan(0);
} }
for (const icon of [...LUCIDE_ICONS, ...FA_ICONS]) { 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]+$/); for (const d of icon.paths) expect(d, icon.key).toMatch(/^[Mm][-0-9.,\sA-Za-z]+$/);
} }
}); });
@@ -61,6 +79,12 @@ describe("resolveIcon", () => {
expect(broom.kind).toBe("fa"); expect(broom.kind).toBe("fa");
expect(broom).toBe(FA_ICONS.find((icon) => icon.name === "broom")); 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: "🐶" });
expect(resolveIcon("emoji:👨‍👩‍👧")).toEqual({ kind: "emoji", key: "emoji:👨‍👩‍👧", text: "👨‍👩‍👧" }); expect(resolveIcon("emoji:👨‍👩‍👧")).toEqual({ kind: "emoji", key: "emoji:👨‍👩‍👧", text: "👨‍👩‍👧" });
}); });
@@ -133,7 +157,7 @@ describe("offline bundling", () => {
} }
}); });
it("no bundled source imports an icon package — only the curated generated data ships", () => { it("no bundled source imports an icon package — only the generated registry data ships", () => {
const sources = [join(REPO_ROOT, "admin", "src"), join(REPO_ROOT, "frontend", "src")] const sources = [join(REPO_ROOT, "admin", "src"), join(REPO_ROOT, "frontend", "src")]
.flatMap(filesUnder) .flatMap(filesUnder)
.filter((path) => /\.(ts|tsx)$/.test(path) && !path.endsWith(".test.ts") && !path.endsWith(".test.tsx")); .filter((path) => /\.(ts|tsx)$/.test(path) && !path.endsWith(".test.ts") && !path.endsWith(".test.tsx"));
@@ -74,6 +74,10 @@ async function routeFetch(url: string, init: RequestInit = {}): Promise<Response
overrides = overrides.filter((o) => o.id !== Number(deleteMatch[1])); overrides = overrides.filter((o) => o.id !== Number(deleteMatch[1]));
return jsonResponse({ status: "ok" }); 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 }); if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
const patternsMatch = /\/children\/(\d+)\/presence-patterns$/.exec(path); const patternsMatch = /\/children\/(\d+)\/presence-patterns$/.exec(path);
if (patternsMatch) { 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 { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import ScheduleTab from "./ScheduleTab"; import ScheduleTab from "./ScheduleTab";
import type { AdminChild } from "../api/definitions"; import type { AdminChild } from "../api/definitions";
import type { PresenceOverride, PresencePattern } from "../api/presence"; import type { PresenceOverride, PresencePattern } from "../api/presence";
import { storeTokens } from "../auth/oidc"; import { storeTokens } from "../auth/oidc";
import { AppTimezoneProvider } from "../time/AppTimezone";
import { cycleWeekIndex, monthDates, weekdayOf } from "./cycle"; import { cycleWeekIndex, monthDates, weekdayOf } from "./cycle";
function child(id: number, display_name: string, sort_order: number, is_active = true): AdminChild { 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(); 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 { import {
cycleLabel, cycleLabel,
dayState, dayState,
localTodayIso,
monthDates, monthDates,
monthStart, monthStart,
patternDaysLabel, patternDaysLabel,
@@ -38,6 +37,8 @@ import {
} from "./glyphs"; } from "./glyphs";
import OverrideEditor from "./OverrideEditor"; import OverrideEditor from "./OverrideEditor";
import PatternEditor from "./PatternEditor"; import PatternEditor from "./PatternEditor";
import { useAppTimezone } from "../time/AppTimezone";
import { todayIn } from "../time/zone";
import "./ScheduleTab.css"; import "./ScheduleTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */ /** 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 { 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; 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 [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0); const [attempt, setAttempt] = useState(0);
const [selectedId, setSelectedId] = useState<number | null>(null); 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); 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. */ /** "YYYY-MM-01" of the month containing `iso`, shifted by `delta` months. */
export function monthStart(iso: string, delta = 0): string { export function monthStart(iso: string, delta = 0): string {
const [year, month] = iso.split("-").map(Number); const [year, month] = iso.split("-").map(Number);
+6
View File
@@ -258,6 +258,12 @@
color: var(--nq-a-ink-secondary); color: var(--nq-a-ink-secondary);
} }
.settings-hint {
margin: 0;
font-size: 12.5px;
color: var(--nq-a-ink-secondary);
}
.settings-input { .settings-input {
width: 100%; width: 100%;
min-height: var(--nq-a-tap-min); min-height: var(--nq-a-tap-min);
+235 -82
View File
@@ -6,6 +6,7 @@ import SettingsScreen, { SCROLL_BOTTOM_PADDING } from "./SettingsScreen";
import type { AdminChild } from "../api/definitions"; import type { AdminChild } from "../api/definitions";
import type { HouseholdSettings } from "../api/settings"; import type { HouseholdSettings } from "../api/settings";
import { storeTokens } from "../auth/oidc"; import { storeTokens } from "../auth/oidc";
import { AppTimezoneProvider, useAppTimezone } from "../time/AppTimezone";
const CHILDREN_PATH = "/api/v1/admin/children"; const CHILDREN_PATH = "/api/v1/admin/children";
const REORDER_PATH = `${CHILDREN_PATH}/reorder`; const REORDER_PATH = `${CHILDREN_PATH}/reorder`;
@@ -23,9 +24,17 @@ const DEFAULT_SETTINGS: HouseholdSettings = {
end_of_day_report_enabled: true, end_of_day_report_enabled: true,
celebration_enabled: false, celebration_enabled: false,
timezone: "America/Chicago", timezone: "America/Chicago",
timezone_configured: true, 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( function child(
id: number, id: number,
display_name: string, display_name: string,
@@ -83,10 +92,10 @@ async function routeFetch(url: string, init: RequestInit = {}): Promise<Response
return respond(); return respond();
} }
if (path === CHILDREN_PATH && method === "GET") return jsonResponse({ children }); 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") { if (path === SETTINGS_PATH && method === "PATCH") {
settings = { ...settings, ...body }; settings = { ...settings, ...body };
return jsonResponse(settings); return jsonResponse(settingsBody());
} }
if (path === CHILDREN_PATH && method === "POST") { if (path === CHILDREN_PATH && method === "POST") {
const created = child(9, body.display_name, body.sort_order ?? 0, { const created = child(9, body.display_name, body.sort_order ?? 0, {
@@ -500,37 +509,37 @@ describe("SettingsScreen — preferences", () => {
expect(writes()).toHaveLength(1); expect(writes()).toHaveLength(1);
}); });
it("a changed timezone patches exactly the timezone", async () => { it("a timezone picked from the list patches exactly the timezone", async () => {
const { form } = await renderPreferences(); const { form } = await renderPreferences();
const input = field(form, "Timezone"); fireEvent.change(field(form, "Timezone"), { target: { value: "America/New_York" } });
expect(input.getAttribute("list")).toBe("settings-timezones");
fireEvent.change(input, { target: { value: " Europe/London " } });
save(form); save(form);
await screen.findByText("Preferences saved."); await screen.findByText("Preferences saved.");
expect(writes()).toEqual([ expect(writes()).toEqual([
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "Europe/London", timezone_configured: true } }, { method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } },
]); ]);
const reloaded = screen.getByRole("form", { name: "Preferences" }); const reloaded = screen.getByRole("form", { name: "Preferences" });
expect(field(reloaded, "Timezone").value).toBe("Europe/London"); expect(field(reloaded, "Timezone").value).toBe("America/New_York");
}); });
it.each(["New York", "America/New York", "EST5EDT?", "/Chicago"])( it.each(["New York", "America/New York", "EST5EDT?", "/Chicago"])(
"an invalid timezone %j is rejected before any request", "an invalid other timezone %j is rejected before any request",
async (value) => { async (value) => {
const { form } = await renderPreferences(); const { form } = await renderPreferences();
fireEvent.change(field(form, "Timezone"), { target: { value } }); fireEvent.change(field(form, "Timezone"), { target: { value: "__other__" } });
fireEvent.change(field(form, "Other timezone"), { target: { value } });
save(form); save(form);
expect(screen.getByTestId("preferences-message").textContent).toBe( expect(screen.getByTestId("preferences-message").textContent).toBe(
"Timezone must be an IANA zone like America/New_York, or UTC.", "Timezone must be an IANA zone like America/New_York, or UTC.",
); );
expect(field(form, "Timezone").value).toBe(value); expect(field(form, "Other timezone").value).toBe(value);
expect(writes()).toEqual([]); expect(writes()).toEqual([]);
}, },
); );
it("a 422 on an unknown timezone shows the API detail and keeps the edit", async () => { it("a 422 on an unknown timezone shows the API detail and keeps the edit", async () => {
const { form } = await renderPreferences(); const { form } = await renderPreferences();
fireEvent.change(field(form, "Timezone"), { target: { value: "Mars/Olympus_Mons" } }); fireEvent.change(field(form, "Timezone"), { target: { value: "__other__" } });
fireEvent.change(field(form, "Other timezone"), { target: { value: "Mars/Olympus_Mons" } });
writeResponse = () => writeResponse = () =>
jsonResponse({ detail: "timezone must be an IANA time zone name such as 'America/New_York'" }, 422); jsonResponse({ detail: "timezone must be an IANA time zone name such as 'America/New_York'" }, 422);
save(form); save(form);
@@ -538,8 +547,9 @@ describe("SettingsScreen — preferences", () => {
expect(message.textContent).toBe( expect(message.textContent).toBe(
"The preferences could not be saved: timezone must be an IANA time zone name such as 'America/New_York'", "The preferences could not be saved: timezone must be an IANA time zone name such as 'America/New_York'",
); );
expect(field(form, "Timezone").value).toBe("Mars/Olympus_Mons"); expect(field(form, "Other timezone").value).toBe("Mars/Olympus_Mons");
expect(field(form, "Timezone").disabled).toBe(false); 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 () => { it("a failed load shows the reason and Try again refetches", async () => {
@@ -559,7 +569,7 @@ describe("SettingsScreen — preferences", () => {
}); });
}); });
describe("SettingsScreen — timezone auto-set", () => { describe("SettingsScreen — no timezone auto-set", () => {
let zoneSpy: ReturnType<typeof vi.spyOn> | null; let zoneSpy: ReturnType<typeof vi.spyOn> | null;
function mockBrowserZone(timeZone: string | undefined) { function mockBrowserZone(timeZone: string | undefined) {
@@ -575,8 +585,8 @@ describe("SettingsScreen — timezone auto-set", () => {
sessionStorage.clear(); sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 }); storeTokens({ access_token: "at-123", expires_in: 600 });
children = [child(1, "Declan", 1)]; children = [child(1, "Declan", 1)];
// A fresh install: no zone, and the admin never chose one. // A fresh install: no zone stored.
settings = { ...DEFAULT_SETTINGS, timezone: "", timezone_configured: false }; settings = { ...DEFAULT_SETTINGS, timezone: "" };
writeResponse = null; writeResponse = null;
zoneSpy = null; zoneSpy = null;
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init)); fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
@@ -595,25 +605,15 @@ describe("SettingsScreen — timezone auto-set", () => {
return screen.findByRole("form", { name: "Preferences" }); return screen.findByRole("form", { name: "Preferences" });
} }
it("an unset timezone is set once, on load, to the browser's zone and shown", async () => { it("an empty timezone stays empty on load and nothing is written", async () => {
mockBrowserZone("America/New_York"); mockBrowserZone("America/New_York");
const form = await renderForm(); const form = await renderForm();
expect(field(form, "Timezone").value).toBe("America/New_York"); expect(field(form, "Timezone").value).toBe("");
expect(writes()).toEqual([ expect(writes()).toEqual([]);
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York", timezone_configured: true } }, expect(settings.timezone).toBe("");
]);
expect(settings.timezone).toBe("America/New_York");
expect(settings.timezone_configured).toBe(true);
// A later save of another field does not re-send the timezone.
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()).toHaveLength(2);
expect(writes()[1].body).toEqual({ horizon_days: 21 });
}); });
it("StrictMode's doubled effects still auto-set exactly once", async () => { it("StrictMode's doubled effects write nothing either", async () => {
mockBrowserZone("America/New_York"); mockBrowserZone("America/New_York");
render( render(
<StrictMode> <StrictMode>
@@ -621,70 +621,223 @@ describe("SettingsScreen — timezone auto-set", () => {
</StrictMode>, </StrictMode>,
); );
const form = await screen.findByRole("form", { name: "Preferences" }); const form = await screen.findByRole("form", { name: "Preferences" });
expect(field(form, "Timezone").value).toBe("America/New_York");
expect(writes()).toEqual([
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York", timezone_configured: true } },
]);
});
it("an admin clearing the timezone is not re-set by the browser zone, even after a reopen", async () => {
mockBrowserZone("America/New_York");
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: "America/New_York", timezone_configured: true },
{ timezone: "", timezone_configured: true },
]);
const reloaded = screen.getByRole("form", { name: "Preferences" });
expect(field(reloaded, "Timezone").value).toBe("");
// Close and reopen: the screen re-fetches "" but the choice persisted.
cleanup();
const reopened = await renderForm();
expect(field(reopened, "Timezone").value).toBe("");
expect(writes()).toHaveLength(2);
});
it("an empty timezone the admin chose is never auto-set", async () => {
mockBrowserZone("America/New_York");
settings = { ...settings, timezone_configured: true };
const form = await renderForm();
expect(field(form, "Timezone").value).toBe(""); expect(field(form, "Timezone").value).toBe("");
expect(writes()).toEqual([]); expect(writes()).toEqual([]);
}); });
it("a set timezone is shown and never overwritten by the browser zone", async () => { it("a stored timezone is shown as stored, never replaced by the browser zone", async () => {
mockBrowserZone("America/New_York"); mockBrowserZone("America/New_York");
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin", timezone_configured: false }; settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin" };
const form = await renderForm(); const form = await renderForm();
expect(field(form, "Timezone").value).toBe("Europe/Dublin"); expect(field(form, "Timezone").value).toBe("Europe/Dublin");
expect(writes()).toEqual([]); expect(writes()).toEqual([]);
}); });
it.each([ it("saving another field sends only that field, never a timezone", async () => {
["nothing", undefined], mockBrowserZone("America/New_York");
["an empty zone", ""],
["an unknown zone", "Etc/Unknown"],
["a non-IANA zone", "not a zone"],
])("detection returning %s leaves the field empty and sends nothing", async (_case, zone) => {
mockBrowserZone(zone);
const form = await renderForm(); const form = await renderForm();
expect(field(form, "Timezone").value).toBe(""); fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } });
expect(screen.queryByTestId("preferences-message")).toBeNull(); 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([]); expect(writes()).toEqual([]);
}); });
it("a rejected auto-set shows why and leaves the field empty for manual entry", async () => { it("choosing API host local time clears the zone and persists after a reload", async () => {
mockBrowserZone("America/New_York"); settings = { ...DEFAULT_SETTINGS, timezone: "America/Denver" };
writeResponse = () => jsonResponse({ detail: "timezone is not known" }, 422);
const form = await renderForm(); const form = await renderForm();
expect(field(form, "Timezone").value).toBe(""); expect(picker(form).value).toBe("America/Denver");
expect(field(form, "Timezone").disabled).toBe(false); fireEvent.change(picker(form), { target: { value: "" } });
expect(screen.getByTestId("preferences-message").textContent).toBe( save(form);
"The timezone could not be set to America/New_York: timezone is not known", 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>,
); );
expect(writes()).toHaveLength(1); 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");
}); });
}); });
+50 -52
View File
@@ -5,9 +5,10 @@
* household's child profiles. Every write refetches the list. * household's child profiles. Every write refetches the list.
* *
* Preferences: the household settings (planning horizon, day rollover, * Preferences: the household settings (planning horizon, day rollover,
* timezone, notifications). Save patches only the changed fields. A timezone * timezone, notifications). Save patches only the changed fields. The
* the admin never chose is set once, on load, to the browser's own zone; * timezone — the application's source of local time — is only ever set by
* saving the timezone (even clearing it) marks it chosen, so it sticks. * 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. * One write at a time across both sections.
*/ */
@@ -35,6 +36,7 @@ import {
type SettingsChanges, type SettingsChanges,
} from "../api/settings"; } from "../api/settings";
import { FOCUSABLE, inertOutside } from "../schedule/OverrideEditor"; import { FOCUSABLE, inertOutside } from "../schedule/OverrideEditor";
import { usePublishAppTimezone } from "../time/AppTimezone";
import { ArrowDownGlyph, ArrowUpGlyph, ChevronLeftGlyph, PlusGlyph } from "./glyphs"; import { ArrowDownGlyph, ArrowUpGlyph, ChevronLeftGlyph, PlusGlyph } from "./glyphs";
import "./SettingsScreen.css"; import "./SettingsScreen.css";
@@ -257,7 +259,7 @@ const HH_MM = /^([01]\d|2[0-3]):[0-5]\d$/;
/** "UTC" or an IANA-looking "Area/Location" (the API checks it resolves). */ /** "UTC" or an IANA-looking "Area/Location" (the API checks it resolves). */
const IANA_ZONE = /^(UTC|[A-Za-z]+(\/[A-Za-z0-9_+-]+)+)$/; const IANA_ZONE = /^(UTC|[A-Za-z]+(\/[A-Za-z0-9_+-]+)+)$/;
/** The Timezone field's suggestions; any other IANA zone can be typed. */ /** The Timezone picker's zones; any other IANA zone can be typed via "Other". */
export const COMMON_TIMEZONES = [ export const COMMON_TIMEZONES = [
"UTC", "UTC",
"America/New_York", "America/New_York",
@@ -291,18 +293,8 @@ export const COMMON_TIMEZONES = [
"Pacific/Auckland", "Pacific/Auckland",
]; ];
/** /** The picker's "Other IANA zone…" option; never sent to the API. */
* The browser's own IANA zone — the location-derived default for an unset const OTHER_ZONE = "__other__";
* household timezone — or "" when it is missing or not a usable zone name.
*/
export function detectTimezone(): string {
try {
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return typeof zone === "string" && zone !== "Etc/Unknown" && IANA_ZONE.test(zone) ? zone : "";
} catch {
return "";
}
}
/** The form's working copy: the number is text so it can be mid-edit. */ /** The form's working copy: the number is text so it can be mid-edit. */
type PreferencesDraft = Omit<HouseholdSettings, "horizon_days"> & { horizon_days: string }; type PreferencesDraft = Omit<HouseholdSettings, "horizon_days"> & { horizon_days: string };
@@ -342,9 +334,6 @@ function buildChanges(draft: PreferencesDraft, loaded: HouseholdSettings): Setti
for (const key of Object.keys(loaded) as (keyof HouseholdSettings)[]) { for (const key of Object.keys(loaded) as (keyof HouseholdSettings)[]) {
if (next[key] !== loaded[key]) (changes as Record<string, unknown>)[key] = next[key]; if (next[key] !== loaded[key]) (changes as Record<string, unknown>)[key] = next[key];
} }
// An explicit choice — including "" (the API host's local time) — must
// persist, or the next load would auto-set the browser's zone over it.
if ("timezone" in changes) changes.timezone_configured = true;
if (Object.keys(changes).length === 0) return "No changes to save."; if (Object.keys(changes).length === 0) return "No changes to save.";
return changes; return changes;
} }
@@ -365,6 +354,10 @@ function PreferencesForm({
}) { }) {
const [draft, setDraft] = useState<PreferencesDraft>(() => preferencesDraft(settings)); const [draft, setDraft] = useState<PreferencesDraft>(() => preferencesDraft(settings));
const update = (patch: Partial<PreferencesDraft>) => setDraft((d) => ({ ...d, ...patch })); 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; const disabled = saving || locked;
function submit(event: FormEvent) { function submit(event: FormEvent) {
@@ -417,25 +410,49 @@ function PreferencesForm({
/> />
</label> </label>
{timeInput("day_rollover_time", "Day rollover")} {timeInput("day_rollover_time", "Day rollover")}
<label className="settings-field"> <div className="settings-field">
<span className="settings-label">Timezone</span> <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 <input
className="settings-input" className="settings-input"
type="text" type="text"
list="settings-timezones" aria-label="Other timezone"
placeholder="Host local time" placeholder="Area/Location, e.g. America/Boise"
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
value={draft.timezone} value={draft.timezone}
disabled={disabled} disabled={disabled}
onChange={(event) => update({ timezone: event.target.value })} onChange={(event) => update({ timezone: event.target.value })}
/> />
</label> ) : null}
<datalist id="settings-timezones"> <p className="settings-hint" id="settings-timezone-help">
{COMMON_TIMEZONES.map((zone) => ( The application timezone: the source of local time for the panel, the scheduler and this
<option key={zone} value={zone} /> admin app. It changes only when you save it here; with no zone set, the API host&apos;s
))} local time is used.
</datalist> </p>
</div>
<label className="settings-field"> <label className="settings-field">
<span className="settings-label">Notify target</span> <span className="settings-label">Notify target</span>
<input <input
@@ -574,12 +591,11 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
} | null>(null); } | null>(null);
// A ref, not state: two taps in one frame must not both see `busy === null`. // A ref, not state: two taps in one frame must not both see `busy === null`.
const inFlight = useRef(false); const inFlight = useRef(false);
// The never-chosen-timezone auto-set fires at most once per screen.
const timezoneAutoSet = useRef(false);
const screenRef = useRef<HTMLDivElement>(null); const screenRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null); const titleRef = useRef<HTMLHeadingElement>(null);
const addRef = useRef<HTMLButtonElement>(null); const addRef = useRef<HTMLButtonElement>(null);
const formWasOpen = useRef(false); const formWasOpen = useRef(false);
const publishTimezone = usePublishAppTimezone();
// Modal focus: move in on open, keep the background out of reach, and hand // Modal focus: move in on open, keep the background out of reach, and hand
// focus back to the opener (the Settings button) on close. // focus back to the opener (the Settings button) on close.
@@ -620,29 +636,10 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
let cancelled = false; let cancelled = false;
setPreferences({ kind: "loading" }); setPreferences({ kind: "loading" });
fetchSettings() fetchSettings()
.then(async (settings) => { .then((settings) => {
if (cancelled) return;
// Never chosen: adopt the browser's zone before the form shows, so
// nothing the admin enters can be overwritten by it. An empty zone the
// admin chose (timezone_configured) is left alone.
const unchosen = settings.timezone === "" && !settings.timezone_configured;
const detected = unchosen ? detectTimezone() : "";
if (!detected || timezoneAutoSet.current) {
setPreferences({ kind: "ready", settings });
return;
}
timezoneAutoSet.current = true;
try {
const saved = await updateSettings({ timezone: detected, timezone_configured: true });
if (!cancelled) setPreferences({ kind: "ready", settings: saved });
} catch (error) {
if (cancelled) return; if (cancelled) return;
setPreferences({ kind: "ready", settings }); setPreferences({ kind: "ready", settings });
setPreferencesMessage({ publishTimezone(settings.effective_timezone);
kind: "error",
text: errorMessage(error, `The timezone could not be set to ${detected}`),
});
}
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (cancelled) return; if (cancelled) return;
@@ -736,6 +733,7 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
try { try {
const settings = await updateSettings(changes); const settings = await updateSettings(changes);
setPreferences({ kind: "ready", settings }); setPreferences({ kind: "ready", settings });
publishTimezone(settings.effective_timezone);
setPreferencesMessage({ kind: "info", text: "Preferences saved." }); setPreferencesMessage({ kind: "info", text: "Preferences saved." });
} catch (error) { } catch (error) {
setPreferencesMessage({ 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 { TransitionsProvider } from "../api/useTransitions";
import { frame, testStream, type TestStream } from "../api/testStream"; import { frame, testStream, type TestStream } from "../api/testStream";
import { storeTokens } from "../auth/oidc"; import { storeTokens } from "../auth/oidc";
import { AppTimezoneProvider } from "../time/AppTimezone";
function instance(overrides: Partial<SnapshotInstance>): SnapshotInstance { function instance(overrides: Partial<SnapshotInstance>): SnapshotInstance {
return { return {
@@ -406,3 +407,62 @@ describe("TodayTab Undo and live updates", () => {
await waitFor(() => expect(streams[0].cancelled()).toBe(true)); await waitFor(() => expect(streams[0].cancelled()).toBe(true));
}); });
}); });
describe("TodayTab in the stored application timezone", () => {
function renderWithZone(timezone: string, effective_timezone = timezone) {
fetchMock.mockImplementation((url: string) =>
Promise.resolve(
String(url).endsWith("/api/v1/admin/settings")
? jsonResponse({ timezone, effective_timezone })
: jsonResponse(SNAPSHOT),
),
);
render(
<AppTimezoneProvider>
<TodayTab />
</AppTimezoneProvider>,
);
}
beforeAll(() => {
// The browser runs in UTC; the household lives in Auckland (UTC+12 on Sep 23).
vi.stubEnv("TZ", "UTC");
});
afterAll(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("shows the completion clock in the stored zone, not the browser's", async () => {
renderWithZone("Pacific/Auckland");
const completed = await screen.findByTestId("attention-12");
// 11:04 UTC is 11:04 PM in Auckland.
expect(within(completed).getByText("Completed 11:04 PM")).toBeTruthy();
// The header's day is the API's household-local today_iso, as-is.
expect(screen.getByText("Wednesday, September 23 · cycle day 5 of 14")).toBeTruthy();
});
it("an empty stored zone shows the clock in the API host's published zone", async () => {
renderWithZone("", "Pacific/Auckland");
const completed = await screen.findByTestId("attention-12");
expect(within(completed).getByText("Completed 11:04 PM")).toBeTruthy();
});
it("only an API that publishes no zone leaves the browser's local clock", async () => {
renderWithZone("", "");
const completed = await screen.findByTestId("attention-12");
expect(within(completed).getByText("Completed 11:04 AM")).toBeTruthy();
});
});
+3 -1
View File
@@ -13,6 +13,7 @@ import { ApiRequestError } from "../api/definitions";
import { uncompleteInstance } from "../api/instances"; import { uncompleteInstance } from "../api/instances";
import { useTransitions } from "../api/useTransitions"; import { useTransitions } from "../api/useTransitions";
import SettingsScreen from "../settings/SettingsScreen"; import SettingsScreen from "../settings/SettingsScreen";
import { useAppTimezone } from "../time/AppTimezone";
import { import {
formatClockTime, formatClockTime,
formatDueTime, formatDueTime,
@@ -165,6 +166,7 @@ function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }
const { instance, childName } = item; const { instance, childName } = item;
const overdue = isOverdueOpen(instance); const overdue = isOverdueOpen(instance);
const undoing = undo.pendingId === instance.id; const undoing = undo.pendingId === instance.id;
const timeZone = useAppTimezone();
// TODO(e57facc2): wire "Mark done". The admin plane has no complete route // TODO(e57facc2): wire "Mark done". The admin plane has no complete route
// yet, so that action is inert here. // yet, so that action is inert here.
return ( 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. // The snapshot carries no completion actor, so "from panel" is not printed.
<div className="today-event-meta"> <div className="today-event-meta">
Completed {formatClockTime(instance.completed_at as string)} Completed {formatClockTime(instance.completed_at as string, timeZone)}
</div> </div>
)} )}
</div> </div>
+10 -4
View File
@@ -4,8 +4,10 @@
* Calendar dates ("YYYY-MM-DD") are parsed by their components and formatted * 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 * 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 * `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"; const LOCALE = "en-US";
@@ -44,7 +46,11 @@ export function formatDueTime(hhmm: string): string {
return format(date, { hour: "numeric", minute: "2-digit", timeZone: "UTC" }); return format(date, { hour: "numeric", minute: "2-digit", timeZone: "UTC" });
} }
/** A UTC ISO timestamp shown as the viewer's local time, "7:04 AM". */ /** A UTC ISO timestamp shown as wall-clock time in `timeZone` ("" = browser), "7:04 AM". */
export function formatClockTime(isoTimestamp: string): string { export function formatClockTime(isoTimestamp: string, timeZone: string): string {
return format(new Date(isoTimestamp), { hour: "numeric", minute: "2-digit" }); 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()
+34 -15
View File
@@ -165,13 +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 SEPARATE source until Feature 18 wires the client to the API — the two
are deliberately not unified here: are deliberately not unified here:
- ``GET /settings`` returns the current effective settings: all twelve - ``GET /settings`` returns the current effective settings: all eleven
documented fields (the rolling horizon, the day rollover time, the documented fields (the rolling horizon, the day rollover time, the
notify target, the three notification times, the four enable notify target, the three notification times, the four enable
toggles, the ``timezone`` — the IANA household zone the toggles, and the ``timezone`` — the IANA household zone the
household-local dates derive from, empty meaning host local — and household-local dates derive from, empty meaning host local; only an
``timezone_configured``, whether the admin explicitly chose that admin's ``PATCH`` ever sets it) — the defaults on a fresh database —
zone) — 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 - ``PATCH /settings`` updates ONLY the supplied fields: the body is a
JSON object of settings field names to new values, passed STRAIGHT JSON object of settings field names to new values, passed STRAIGHT
to :func:`nestquest_core.settings_store.update_settings` — the to :func:`nestquest_core.settings_store.update_settings` — the
@@ -350,6 +352,7 @@ from pydantic import (
model_validator, model_validator,
) )
from api import host_zone
from api import transitions as api_transitions from api import transitions as api_transitions
from api.auth import require_admin from api.auth import require_admin
from api.database import DatabaseState from api.database import DatabaseState
@@ -906,7 +909,7 @@ class AdminSettingsResponse(BaseModel):
"""The effective settings as the admin plane serializes them. """The effective settings as the admin plane serializes them.
Mirrors the core :class:`~nestquest_core.settings.NestQuestSettings` Mirrors the core :class:`~nestquest_core.settings.NestQuestSettings`
— all TWELVE 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 already fell back to its default in the core). The same shape both
the GET and the PATCH route answer with. the GET and the PATCH route answer with.
""" """
@@ -921,12 +924,14 @@ class AdminSettingsResponse(BaseModel):
afternoon_reminder_enabled: bool afternoon_reminder_enabled: bool
end_of_day_report_enabled: bool end_of_day_report_enabled: bool
celebration_enabled: bool celebration_enabled: bool
#: The household IANA time zone the API reads its clock in; ``""`` #: The household IANA time zone the admin stored; ``""`` means the
#: means the API host's local time. #: API host's local time.
timezone: str timezone: str
#: Whether the admin explicitly chose ``timezone`` (even ``""``); #: The zone the API actually reads its clock in — ``timezone``, or
#: ``False`` lets the admin UI auto-set the browser's zone once. #: the API host's own zone when that is ``""`` (``""`` only when the
timezone_configured: bool #: host's zone cannot be named). Read-only; the admin app formats
#: local time in it.
effective_timezone: str
class AdminMissedSweepResponse(BaseModel): class AdminMissedSweepResponse(BaseModel):
@@ -2183,8 +2188,9 @@ def _settings_response(
"""Serialize one core NestQuestSettings into the documented payload. """Serialize one core NestQuestSettings into the documented payload.
Explicit field-by-field (not ``dataclasses.asdict``) so the payload Explicit field-by-field (not ``dataclasses.asdict``) so the payload
stays pinned to the twelve documented fields even if the core stays pinned to the eleven documented fields (plus the read-only
dataclass later grows another one. ``effective_timezone``) even if the core dataclass later grows
another one.
""" """
return AdminSettingsResponse( return AdminSettingsResponse(
horizon_days=settings.horizon_days, horizon_days=settings.horizon_days,
@@ -2198,7 +2204,7 @@ def _settings_response(
end_of_day_report_enabled=settings.end_of_day_report_enabled, end_of_day_report_enabled=settings.end_of_day_report_enabled,
celebration_enabled=settings.celebration_enabled, celebration_enabled=settings.celebration_enabled,
timezone=settings.timezone, timezone=settings.timezone,
timezone_configured=settings.timezone_configured, effective_timezone=host_zone.effective_timezone(settings.timezone),
) )
@@ -2296,7 +2302,7 @@ async def admin_history_csv(
response_model=AdminSettingsResponse, response_model=AdminSettingsResponse,
) )
async def admin_get_settings(request: Request) -> AdminSettingsResponse: async def admin_get_settings(request: Request) -> AdminSettingsResponse:
"""Return the current effective settings (all twelve documented fields). """Return the current effective settings (all eleven documented fields).
Requires a valid admin JWT (the router's shared Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this :func:`~api.auth.require_admin` dependency — the ONE check; this
@@ -2335,12 +2341,25 @@ async def admin_update_settings(
every value has validated — so a rejected update never changes the every value has validated — so a rejected update never changes the
stored settings. Every core ``ValueError`` is 422 stored settings. Every core ``ValueError`` is 422
(:func:`_raise_settings_error`); anything else re-raises. (: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 state: DatabaseState = request.app.state.db
scheduler = getattr(request.app.state, "sweep_scheduler", None)
try: try:
if scheduler is None:
settings = await core_settings_store.update_settings( settings = await core_settings_store.update_settings(
state.database, changes 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: except ValueError as error:
_raise_settings_error(error) _raise_settings_error(error)
return _settings_response(settings) return _settings_response(settings)
+22 -47
View File
@@ -9,9 +9,8 @@ this router inherits the check).
The snapshot route is a thin adapter over the bundled core: it performs 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``, NO business logic. It reads the live database off ``app.state.db``,
resolves ONE timezone-aware ``now`` (the host clock re-expressed in the resolves ONE timezone-aware ``now`` (the host clock re-expressed in the
household ``timezone`` — the API host may run UTC while the household household ``timezone`` the admin stored — the API host may run UTC
does not; until the admin chooses a zone it is the Home Assistant zone while the household does not), calls the pure
the integration reports in :data:`PANEL_TIMEZONE_HEADER`), calls the pure
:func:`nestquest_core.snapshot.build_snapshot`, shapes each child's :func:`nestquest_core.snapshot.build_snapshot`, shapes each child's
instances through :func:`nestquest_core.snapshot.instance_payload` with instances through :func:`nestquest_core.snapshot.instance_payload` with
``include_missed=False`` (D-009: missed instances are OMITTED from the ``include_missed=False`` (D-009: missed instances are OMITTED from the
@@ -22,6 +21,7 @@ panel payload), and returns the documented JSON shape:
{ {
"today_iso": "2026-09-22", "today_iso": "2026-09-22",
"cycle_day": 2, "cycle_day": 2,
"timezone": "Pacific/Auckland",
"children": [ "children": [
{ {
"child_id": 1, "child_id": 1,
@@ -46,7 +46,11 @@ panel payload), and returns the documented JSON shape:
active children ``ORDER BY sort_order, id``); ``state`` is the active children ``ORDER BY sort_order, id``); ``state`` is the
documented panel spelling ``open`` | ``completed`` (§1 of documented panel spelling ``open`` | ``completed`` (§1 of
design/ENTITIES-AND-SERVICES.md); ``cycle_day`` is today's 1-based day 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 The same :class:`PanelSnapshotResponse` Pydantic model is the route's
``response_model``, so the OpenAPI document carries the shape too. ``response_model``, so the OpenAPI document carries the shape too.
""" """
@@ -54,7 +58,6 @@ from __future__ import annotations
import datetime import datetime
import importlib import importlib
import logging
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -64,20 +67,14 @@ from api.database import DatabaseState
from api.dependencies import require_panel_token from api.dependencies import require_panel_token
from api.nestquest_core import ( from api.nestquest_core import (
core_completion, core_completion,
core_const,
core_events, core_events,
core_settings_store, core_settings_store,
core_snapshot, core_snapshot,
) )
from api import host_zone
from api import transitions as api_transitions from api import transitions as api_transitions
from api.sse import transition_event_stream from api.sse import transition_event_stream
LOGGER = logging.getLogger(__name__)
#: The request header the integration reports Home Assistant's
#: configured IANA ``time_zone`` in.
PANEL_TIMEZONE_HEADER = core_const.PANEL_TIMEZONE_HEADER
#: The instance DAO, imported on the app's core copy for the #: The instance DAO, imported on the app's core copy for the
#: child-mismatch guard on the complete route. #: child-mismatch guard on the complete route.
_dao_instances = importlib.import_module("nestquest_core.dao_instances") _dao_instances = importlib.import_module("nestquest_core.dao_instances")
@@ -135,12 +132,14 @@ class PanelSnapshotResponse(BaseModel):
``today_iso`` anchors the payload's day; ``cycle_day`` is today's ``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 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 child has a schedule); ``timezone`` is the effective application
order. timezone (:func:`api.host_zone.effective_timezone`); ``children`` is
in the household's sort order.
""" """
today_iso: str today_iso: str
cycle_day: int cycle_day: int
timezone: str
children: list[PanelChildPayload] children: list[PanelChildPayload]
@@ -188,41 +187,16 @@ def _local_now() -> datetime.datetime:
async def _household_clock( async def _household_clock(
database: object, request: Request database: object,
) -> tuple[object, datetime.datetime]: ) -> tuple[object, datetime.datetime]:
"""Return the settings and ONE household-local clock read. """Return the stored settings and ONE household-local clock read.
The API host may run UTC while the household does not, so the one The API host may run UTC while the household does not, so the one
:func:`_local_now` read is re-expressed in the settings' ``timezone`` :func:`_local_now` read is re-expressed in the stored settings'
(:meth:`NestQuestSettings.household_now`). The integration reports ``timezone`` (:meth:`NestQuestSettings.household_now`). The zone
Home Assistant's ``time_zone`` in :data:`PANEL_TIMEZONE_HEADER`; comes from the stored settings ONLY — nothing the request carries
until the admin chooses a zone it is adopted and persisted can change it.
(:func:`nestquest_core.settings_store.adopt_reported_timezone`), so
the admin plane follows it too, and the already-sleeping missed-sweep
scheduler is told (:meth:`MissedSweepScheduler.settings_changed`) so
it re-plans for the household rollover — under the scheduler's
``settings_lock``, so the adoption never lands between its settings
check and its sweep. An invalid
reported zone is logged and ignored — the panel keeps serving on
the stored settings.
""" """
reported = request.headers.get(PANEL_TIMEZONE_HEADER, "").strip()
if reported:
scheduler = request.app.state.sweep_scheduler
try:
async with scheduler.settings_lock:
settings = await core_settings_store.adopt_reported_timezone(
database, reported
)
scheduler.settings_changed(settings)
except ValueError:
LOGGER.warning(
"Ignoring invalid %s header value %r",
PANEL_TIMEZONE_HEADER,
reported,
)
settings = await core_settings_store.load_settings(database)
else:
settings = await core_settings_store.load_settings(database) settings = await core_settings_store.load_settings(database)
return settings, settings.household_now(_local_now()) return settings, settings.household_now(_local_now())
@@ -245,13 +219,14 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
:class:`PanelSnapshotResponse`. :class:`PanelSnapshotResponse`.
""" """
state: DatabaseState = request.app.state.db state: DatabaseState = request.app.state.db
settings, now = await _household_clock(state.database, request) settings, now = await _household_clock(state.database)
snapshot = await core_snapshot.build_snapshot( snapshot = await core_snapshot.build_snapshot(
state.database, settings, now state.database, settings, now
) )
return PanelSnapshotResponse( return PanelSnapshotResponse(
today_iso=snapshot.today_iso, today_iso=snapshot.today_iso,
cycle_day=snapshot.cycle_day, cycle_day=snapshot.cycle_day,
timezone=host_zone.effective_timezone(snapshot.timezone),
children=[ children=[
PanelChildPayload( PanelChildPayload(
child_id=child.child_id, child_id=child.child_id,
@@ -332,7 +307,7 @@ async def panel_complete_instance(
detail="Quest instance not found for this child", detail="Quest instance not found for this child",
) )
_settings, now = await _household_clock(database, request) _settings, now = await _household_clock(database)
try: try:
result = await core_completion.complete_instance( result = await core_completion.complete_instance(
database, database,
+11 -10
View File
@@ -13,7 +13,7 @@ local date when unset) and publishes each returned
``nestquest_quest_missed`` event on the app's ONE transition publisher ``nestquest_quest_missed`` event on the app's ONE transition publisher
(the same SSE stream every route publishes through). (the same SSE stream every route publishes through).
A household zone adopted from Home Assistant while the loop is asleep A household zone changed while the loop is asleep
must not leave it waiting for the OLD zone's rollover (a UTC host would 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 otherwise sweep at UTC midnight — 20:00 in New York — marking quests
missed hours early). :meth:`MissedSweepScheduler.settings_changed` missed hours early). :meth:`MissedSweepScheduler.settings_changed`
@@ -21,17 +21,18 @@ 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 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. sweeping and re-plans instead if they no longer match its plan.
Guarantee: no sweep runs for a plan a committed adoption has replaced. Guarantee: no sweep runs for a plan a committed settings write has
An adopting writer holds :attr:`MissedSweepScheduler.settings_lock` 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` across its write AND its :meth:`~MissedSweepScheduler.settings_changed`
call; the loop holds the same lock across its plan read (read, record 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 plan, clear the wake flag) and across the pre-sweep re-read plus
the sweep itself. So an adoption commits either before a plan read the sweep itself. So a write commits either before a plan read
(which then sees it), after one (``settings_changed`` compares against (which then sees it), after one (``settings_changed`` compares against
that exact plan and wakes the wait), or after the sweep — never between 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 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 the wait, and the loop takes nothing else before it, so a waiting
panel request cannot deadlock: it is delayed at most by one settings writer cannot deadlock: it is delayed at most by one settings
read or one sweep. read or one sweep.
All sweep policy lives in the core (the watermark, the All sweep policy lives in the core (the watermark, the
@@ -150,7 +151,7 @@ class MissedSweepScheduler:
self._planned: tuple[str, str] | None = None self._planned: tuple[str, str] | None = None
#: Set by :meth:`settings_changed` to cut the current wait short. #: Set by :meth:`settings_changed` to cut the current wait short.
self._wake = asyncio.Event() self._wake = asyncio.Event()
#: Serialises settings adoption with planning and sweeping (see #: Serialises settings writes with planning and sweeping (see
#: the module docstring's guarantee). #: the module docstring's guarantee).
self.settings_lock = asyncio.Lock() self.settings_lock = asyncio.Lock()
@@ -174,8 +175,8 @@ class MissedSweepScheduler:
def settings_changed(self, settings: object) -> None: def settings_changed(self, settings: object) -> None:
"""Re-plan the pending sweep if ``settings`` moved its rollover. """Re-plan the pending sweep if ``settings`` moved its rollover.
Called with freshly stored settings (e.g. a zone adopted from Called with freshly stored settings (e.g. an admin's zone
Home Assistant), with :attr:`settings_lock` held across the write change), with :attr:`settings_lock` held across the write
and this call so the comparison is against the loop's current and this call so the comparison is against the loop's current
plan, never a read still in flight. A differing ``timezone`` or plan, never a read still in flight. A differing ``timezone`` or
``day_rollover_time`` wakes the sleeping loop, which reloads the ``day_rollover_time`` wakes the sleeping loop, which reloads the
@@ -194,7 +195,7 @@ class MissedSweepScheduler:
"""Schedule → sweep → repeat, forever (until cancelled).""" """Schedule → sweep → repeat, forever (until cancelled)."""
while True: while True:
try: try:
# Under the lock no adoption can commit between this read # Under the lock no settings write can commit between this read
# and the recorded plan, so any later one wakes the wait. # and the recorded plan, so any later one wakes the wait.
async with self.settings_lock: async with self.settings_lock:
self._wake.clear() self._wake.clear()
@@ -233,7 +234,7 @@ class MissedSweepScheduler:
# Re-read before sweeping: a zone or rollover change the # Re-read before sweeping: a zone or rollover change the
# wake-up missed means this is not the household's # wake-up missed means this is not the household's
# rollover any more, so re-plan instead of sweeping. The # rollover any more, so re-plan instead of sweeping. The
# lock keeps an adoption from committing between this # lock keeps a settings write from committing between this
# check and the sweep. # check and the sweep.
async with self.settings_lock: async with self.settings_lock:
settings = await core_settings_store.load_settings( settings = await core_settings_store.load_settings(
+30 -27
View File
@@ -6,9 +6,6 @@ speaks the API's panel plane — ``GET /api/v1/panel/snapshot`` (today's
household snapshot) and ``POST /api/v1/panel/instances/{id}/complete`` household snapshot) and ``POST /api/v1/panel/instances/{id}/complete``
(completion as the tapped child profile) — authenticated with the (completion as the tapped child profile) — authenticated with the
static panel service token as a Bearer credential on every request. static panel service token as a Bearer credential on every request.
Every request also reports Home Assistant's configured ``time_zone`` in
the ``X-NestQuest-Timezone`` header, so the API (which may run UTC)
reads the household's clock until the admin chooses a zone there.
Transport-injected by construction: the constructor takes the base URL, Transport-injected by construction: the constructor takes the base URL,
the panel token, and the HTTP session to use. The session speaks the the panel token, and the HTTP session to use. The session speaks the
@@ -19,9 +16,11 @@ network and NO aiohttp installed; production builds the client through
:func:`client_from_entry`, which hands in Home Assistant's shared :func:`client_from_entry`, which hands in Home Assistant's shared
aiohttp session (``homeassistant.helpers.aiohttp_client aiohttp session (``homeassistant.helpers.aiohttp_client
.async_get_clientsession``). The module itself imports NEITHER .async_get_clientsession``). The module itself imports NEITHER
homeassistant NOR aiohttp at runtime (aiohttp appears only behind homeassistant NOR aiohttp at module import (aiohttp appears only
``TYPE_CHECKING`` for annotations), so the client is usable anywhere — behind ``TYPE_CHECKING`` for annotations, plus one lazy import inside
the HA coupling lives in the one production factory. :meth:`NestQuestApiClient.stream_events` for its ``ClientTimeout``), so
the client is importable anywhere — the HA coupling lives in the one
production factory.
Every failure is typed: a transport failure (connection refused, DNS Every failure is typed: a transport failure (connection refused, DNS
failure, timeout — the exchange never produced a response) and every failure, timeout — the exchange never produced a response) and every
@@ -41,7 +40,11 @@ frame (``event: <type>`` / ``data: <json>`` lines separated by a blank
line). Opening the stream gets the same one-timeout, typed-error line). Opening the stream gets the same one-timeout, typed-error
contract as every other call; reading it has NO body-wide timeout (a contract as every other call; reading it has NO body-wide timeout (a
healthy stream is long-lived — the subscription manager owns healthy stream is long-lived — the subscription manager owns
reconnection and backoff). reconnection and backoff). That includes the session's own default:
Home Assistant's shared session carries ``ClientTimeout(total=300)``,
which would cut a healthy stream every five minutes, so the stream
request overrides it with ``total=None`` (keeping a finite
``sock_connect``).
""" """
from __future__ import annotations from __future__ import annotations
@@ -57,7 +60,6 @@ from .const import (
CONF_PANEL_TOKEN, CONF_PANEL_TOKEN,
DEFAULT_API_BASE_URL, DEFAULT_API_BASE_URL,
DEFAULT_PANEL_TOKEN, DEFAULT_PANEL_TOKEN,
PANEL_TIMEZONE_HEADER,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -160,8 +162,7 @@ def client_from_entry(hass: Any, entry: Any) -> NestQuestApiClient:
"""Build the production client for a config entry. """Build the production client for a config entry.
Reads the configured base URL and panel token off the entry Reads the configured base URL and panel token off the entry
(:func:`resolve_api_config`), reports ``hass.config.time_zone`` as (:func:`resolve_api_config`) and hands the client Home Assistant's
the household zone, and hands the client Home Assistant's
SHARED aiohttp session (``homeassistant.helpers.aiohttp_client SHARED aiohttp session (``homeassistant.helpers.aiohttp_client
.async_get_clientsession``) so panel calls ride HA's one connection .async_get_clientsession``) so panel calls ride HA's one connection
pool. The helper import is deferred into this function on purpose: pool. The helper import is deferred into this function on purpose:
@@ -173,10 +174,7 @@ def client_from_entry(hass: Any, entry: Any) -> NestQuestApiClient:
base_url, panel_token = resolve_api_config(entry) base_url, panel_token = resolve_api_config(entry)
return NestQuestApiClient( return NestQuestApiClient(
base_url, base_url, panel_token, async_get_clientsession(hass)
panel_token,
async_get_clientsession(hass),
household_timezone=hass.config.time_zone,
) )
@@ -197,14 +195,9 @@ class NestQuestApiClient:
session: aiohttp.ClientSession, session: aiohttp.ClientSession,
*, *,
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
household_timezone: str | None = None,
) -> None: ) -> None:
"""Store the connection settings; the session is injected. """Store the connection settings; the session is injected.
``household_timezone`` is Home Assistant's configured IANA
``time_zone``; when given it rides every request in the
``X-NestQuest-Timezone`` header.
``session`` is an aiohttp ``ClientSession`` in production and a ``session`` is an aiohttp ``ClientSession`` in production and a
stub transport in tests. ``base_url`` must be a full http(s) stub transport in tests. ``base_url`` must be a full http(s)
URL and ``panel_token`` must be non-empty — a client built URL and ``panel_token`` must be non-empty — a client built
@@ -229,14 +222,10 @@ class NestQuestApiClient:
self._panel_token = panel_token self._panel_token = panel_token
self._session = session self._session = session
self._timeout_seconds = float(timeout_seconds) self._timeout_seconds = float(timeout_seconds)
self._household_timezone = household_timezone
def _headers(self) -> dict[str, str]: def _headers(self) -> dict[str, str]:
"""The Bearer credential plus the reported household zone.""" """The Bearer credential every panel-plane request carries."""
headers = {"Authorization": f"Bearer {self._panel_token}"} return {"Authorization": f"Bearer {self._panel_token}"}
if self._household_timezone:
headers[PANEL_TIMEZONE_HEADER] = self._household_timezone
return headers
async def get_snapshot(self) -> dict[str, Any]: async def get_snapshot(self) -> dict[str, Any]:
"""Fetch and parse today's household snapshot. """Fetch and parse today's household snapshot.
@@ -295,8 +284,19 @@ class NestQuestApiClient:
(``aclose``, i.e. the consumer's task being cancelled) stops (``aclose``, i.e. the consumer's task being cancelled) stops
cleanly and closes the underlying response in every path. cleanly and closes the underlying response in every path.
""" """
# Lazy: aiohttp ships with Home Assistant, but importing it at
# module level would make the client unimportable without it.
import aiohttp
url = f"{self._base_url}{EVENTS_PATH}" url = f"{self._base_url}{EVENTS_PATH}"
headers = self._headers() headers = self._headers()
# The session's default total timeout (HA's shared session:
# 300 s) would cut a healthy stream mid-body, and events fired
# while reconnecting are not replayed — so the stream request
# is unbounded in total time; only connecting stays bounded.
stream_timeout = aiohttp.ClientTimeout(
total=None, sock_connect=self._timeout_seconds
)
# Opening the response gets ONE request timeout, exactly like # Opening the response gets ONE request timeout, exactly like
# ``_request_json``; the read loop below runs WITHOUT one — a # ``_request_json``; the read loop below runs WITHOUT one — a
# body-wide timeout would kill a healthy long-lived stream. # body-wide timeout would kill a healthy long-lived stream.
@@ -306,7 +306,7 @@ class NestQuestApiClient:
try: try:
async with asyncio.timeout(self._timeout_seconds): async with asyncio.timeout(self._timeout_seconds):
stream_cm = self._session.request( stream_cm = self._session.request(
"GET", url, headers=headers, json=None "GET", url, headers=headers, json=None, timeout=stream_timeout
) )
response = await stream_cm.__aenter__() response = await stream_cm.__aenter__()
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -332,7 +332,10 @@ class NestQuestApiClient:
event_type: str | None = None event_type: str | None = None
data_lines: list[str] = [] data_lines: list[str] = []
buffer = "" buffer = ""
async for chunk in response: # The body is ``response.content`` (aiohttp's StreamReader,
# yielding bytes); the response object itself — HA's
# ``HassClientResponse`` included — is NOT async-iterable.
async for chunk in response.content:
if isinstance(chunk, (bytes, bytearray)): if isinstance(chunk, (bytes, bytearray)):
chunk = chunk.decode("utf-8") chunk = chunk.decode("utf-8")
buffer += chunk buffer += chunk
+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 celebration automation must never fire on an empty day (the
day-complete event carries the same rule, Feature 10 task 7). day-complete event carries the same rule, Feature 10 task 7).
- ``binary_sensor.nestquest_<child>_present_today`` — ON when the - ``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). (schedules and overrides, D-004 anchor-date arithmetic).
Naming and registry identity follow the day sensors' contract: names Naming and registry identity follow the day sensors' contract: names
@@ -0,0 +1,96 @@
blueprint:
name: NestQuest UniFi quest access
description: >-
One automation per child. Completely block the selected UniFi clients
after the local weekday/weekend cutoff while that child has quests
remaining TODAY. Restore network access immediately when the count
reaches zero, and at midnight even if yesterday was incomplete.
A missing or unavailable quest sensor is treated as still open after
the cutoff; verify the sensor before enabling this automation. The
selected UniFi client switches are ON when access is allowed and
OFF when blocked. Do not select infrastructure or adult devices.
domain: automation
input:
remaining_sensor:
name: Child's quests remaining today
description: Select this child's NestQuest quests remaining today sensor.
selector:
entity:
filter:
- integration: nestquest
domain: sensor
client_switches:
name: Child's UniFi client access switches
description: >-
Select every UniFi client access switch for this child. These
switches are ON when network access is allowed and OFF when blocked.
selector:
entity:
multiple: true
filter:
- integration: unifi
domain: switch
weekday_cutoff:
name: Monday-Friday cutoff
default: "18:25:00"
selector:
time: {}
weekend_cutoff:
name: Saturday-Sunday cutoff
default: "14:00:00"
selector:
time: {}
mode: restart
variables:
remaining_sensor: !input remaining_sensor
client_switches: !input client_switches
weekday_cutoff: !input weekday_cutoff
weekend_cutoff: !input weekend_cutoff
triggers:
- trigger: state
entity_id: !input remaining_sensor
- trigger: time
at: !input weekday_cutoff
- trigger: time
at: !input weekend_cutoff
- trigger: time
at: "00:00:00"
- trigger: homeassistant
event: start
# Reconcile after missed events or a temporary UniFi outage.
- trigger: time_pattern
minutes: "/5"
conditions:
- condition: template
value_template: "{{ client_switches | count > 0 }}"
actions:
- variables:
should_block: >-
{{ now().strftime('%H:%M') >=
(weekday_cutoff[:5] if now().weekday() < 5 else weekend_cutoff[:5])
and (states(remaining_sensor) | int(-1)) != 0 }}
- repeat:
for_each: "{{ client_switches }}"
sequence:
- choose:
- conditions:
- condition: template
value_template: >-
{{ should_block | bool and is_state(repeat.item, 'on') }}
sequence:
- action: switch.turn_off
target:
entity_id: "{{ repeat.item }}"
- conditions:
- condition: template
value_template: >-
{{ not (should_block | bool) and is_state(repeat.item, 'off') }}
sequence:
- action: switch.turn_on
target:
entity_id: "{{ repeat.item }}"
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

+5 -4
View File
@@ -111,6 +111,9 @@ def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot:
through verbatim; through verbatim;
- ``present`` and ``next_present`` pass through verbatim (the API - ``present`` and ``next_present`` pass through verbatim (the API
resolved them through the same presence engine server-side); 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 - the instance list is rebuilt IN ORDER, so the panel payload the
sensors emit matches the route's order byte for byte. 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"], today_iso=payload["today_iso"],
children=tuple(children), children=tuple(children),
cycle_day=payload["cycle_day"], cycle_day=payload["cycle_day"],
timezone=payload.get("timezone", ""),
) )
@@ -372,10 +376,7 @@ def coordinator_client_from_entry(
return _UnconfiguredApiClient() return _UnconfiguredApiClient()
if session_factory is not None: if session_factory is not None:
return NestQuestApiClient( return NestQuestApiClient(
base_url, base_url, panel_token, session_factory(hass)
panel_token,
session_factory(hass),
household_timezone=hass.config.time_zone,
) )
from custom_components.nestquest.api_client import client_from_entry from custom_components.nestquest.api_client import client_from_entry
+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 Instance current state is derived from the latest event via
:func:`instance_state` (``open`` / ``done`` / ``missed``); there is :func:`instance_state` (``open`` / ``done`` / ``missed``); there is
no status column. ``missed`` is derived for past-due open instances 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. event_type.
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
@@ -71,10 +71,10 @@ def _completion_moment(
now: datetime.datetime | None, now: datetime.datetime | None,
today: datetime.date | None, today: datetime.date | None,
) -> tuple[datetime.date, datetime.time | 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 ``today`` is the household-local calendar date when threaded (Feature 07
style). ``now`` is a timezone-aware HA-local datetime; its wall style). ``now`` is a timezone-aware household-local datetime; its wall
clock is used as-is and is never converted to UTC. When ``today`` 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 is omitted the date comes from ``now``. When both are omitted the
host calendar date is the Feature 07 fallback — never the UTC 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: 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: if today is None:
return datetime.date.today() return datetime.date.today()
return today return today
@@ -138,7 +138,7 @@ def derive_state(
Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or
``missed`` from the latest event plus the due date (``missed`` is ``missed`` from the latest event plus the due date (``missed`` is
derived, never an event_type). ``today`` is the caller-resolved derived, never an event_type). ``today`` is the caller-resolved
HA-local date. household-local date.
""" """
return _derived_state(instance, latest, today) return _derived_state(instance, latest, today)
@@ -345,7 +345,7 @@ async def instance_state(
"""Return ``open``, ``done``, or ``missed`` from the latest event. """Return ``open``, ``done``, or ``missed`` from the latest event.
No events, or a latest event of uncompleted, is ``open`` unless 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`` case it is ``missed``. A latest event of completed is ``done``
even when past due. ``missed`` is never stored as an event_type. even when past due. ``missed`` is never stored as an event_type.
Unknown ``instance_id`` raises ValueError naming the field. 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 latest-event, and the append run under one connection lock so a
concurrent complete cannot double-append. 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`` (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 datetime; its wall clock is never converted to UTC. Same-day
``due_time`` comparison requires ``now``. ``due_time`` comparison requires ``now``.
""" """
@@ -471,7 +471,7 @@ async def uncomplete_instance(
"""Append an uncompleted event unless the instance is already open. """Append an uncompleted event unless the instance is already open.
Returns the derived state ``open``, or ``missed`` when the instance 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 reports whether THIS call appended (False on an already-open
no-op). Unknown ``instance_id`` raises ValueError naming the no-op). Unknown ``instance_id`` raises ValueError naming the
field. Existence, latest-event, and the append run under one 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. """Return the child's missed instances due in the closed date range.
Missed means the latest event state is open and ``due_date`` is 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. :meth:`QuestInstancesDao.list_by_date_range` plus derived state.
Never appends a missed event. Instances and latest events are Never appends a missed event. Instances and latest events are
read inside the connection lock so the list is one snapshot. read inside the connection lock so the list is one snapshot.
+7 -16
View File
@@ -114,24 +114,15 @@ CONF_CELEBRATION_ENABLED = "celebration_enabled"
DEFAULT_AUTOMATION_ENABLED = True DEFAULT_AUTOMATION_ENABLED = True
# The household's IANA time zone (e.g. ``America/New_York``) the API # 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 # service reads its clock in. The empty string is the API host's own
# local time. While the admin has not chosen a zone # local time. Only an admin sets it (manually, from the admin app);
# (``timezone_configured`` is ``False``) the API adopts the zone the # nothing sets it automatically. The integration relays the API's
# integration reports from Home Assistant's configured ``time_zone`` # effective application timezone (the coordinator carries it and the
# (:data:`PANEL_TIMEZONE_HEADER`), so the default is the household's # household sensor publishes it for the panel cards) and never derives
# zone without any manual setup. The integration itself always uses # the application clock from Home Assistant's configuration; the Home
# Home Assistant's ``time_zone`` and ignores this field. # Assistant / browser zone applies only when the API publishes no zone
# (e.g. an older API).
CONF_TIMEZONE = "timezone" CONF_TIMEZONE = "timezone"
DEFAULT_TIMEZONE = "" DEFAULT_TIMEZONE = ""
# Whether the admin has made an explicit ``timezone`` choice. ``False``
# (a fresh install) lets the API follow Home Assistant's reported zone
# and the admin UI auto-set the browser's zone once while none is
# stored; ``True`` pins the stored value — including an explicitly-chosen
# empty ``timezone`` (the API host's local time) — so neither changes it.
CONF_TIMEZONE_CONFIGURED = "timezone_configured"
DEFAULT_TIMEZONE_CONFIGURED = False
# The panel-plane request header carrying Home Assistant's configured
# IANA ``time_zone`` from the integration to the API service.
PANEL_TIMEZONE_HEADER = "X-NestQuest-Timezone"
# Panel-plane API client (Feature 18): the integration reaches the # Panel-plane API client (Feature 18): the integration reaches the
# NestQuest API service (Feature 16) over HTTP instead of the database. # 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 around midnight, so when the caller has already resolved the local
date (``today``) that anchor is authoritative; ``None`` falls back to date (``today``) that anchor is authoritative; ``None`` falls back to
the host clock (:func:`_today`), which is exactly the previous 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 return _today() if today is None else today
@@ -225,7 +225,7 @@ class QuestInstancesDao:
- ``due_date`` must be TODAY or later: instances are never - ``due_date`` must be TODAY or later: instances are never
generated in the past (Feature 07). The caller's generated in the past (Feature 07). The caller's
materialization horizon is future-dated by definition. 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 guard compares against (falling back to the host clock when
omitted) so a household time zone behind the host around omitted) so a household time zone behind the host around
midnight is not misread as "past". midnight is not misread as "past".
@@ -248,7 +248,7 @@ class QuestInstancesDao:
# check runs after the lock is acquired, because a caller can # check runs after the lock is acquired, because a caller can
# wait on the lock across midnight — a date that was "today" # wait on the lock across midnight — a date that was "today"
# at call time may be "yesterday" by the time the INSERT runs. # 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 # checks and never re-read, since it is already the anchor the
# batch runs against. # batch runs against.
today_date = _resolve_today(today) today_date = _resolve_today(today)
@@ -257,7 +257,7 @@ class QuestInstancesDao:
async with self._database.transaction(): async with self._database.transaction():
# Re-read "today" under the lock: this is the date the # Re-read "today" under the lock: this is the date the
# insert actually executes on, so the no-past rule # 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). # anchor is exempt from the re-read (see above).
if today is None: if today is None:
today_date = _today() today_date = _today()
@@ -364,7 +364,7 @@ class QuestInstancesDao:
rather than raising, so the rest of the batch is unaffected. The rather than raising, so the rest of the batch is unaffected. The
no-past rule (instances are never generated in the past) is no-past rule (instances are never generated in the past) is
preserved from :meth:`upsert` and still raises ValueError; ``today`` 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 instance that ALREADY has a completion event is also skipped
(returns ``None``) rather than raised: it is immutable, so the (returns ``None``) rather than raised: it is immutable, so the
walk leaves it untouched instead of failing the batch — 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 append-only contract: an instance that was ever completed is
never reported missed, even when later reversed — history keeps never reported missed, even when later reversed — history keeps
both rows and the sweep never mutates any). The watermark is 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 ``[watermark, today)``: the nightly run entering day D+1 sweeps
exactly day D's instances, so every day's quests are announced exactly day D's instances, so every day's quests are announced
once — at the midnight after their due date — and a same-night 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 rolling horizon from now), so a caller cannot use this to
rewrite past open instances either — the day-rollover missed rewrite past open instances either — the day-rollover missed
sweep (Feature 11) handles past-due instances instead. ``today`` sweep (Feature 11) handles past-due instances instead. ``today``
optionally pins the HA-local anchor the clamp compares against optionally pins the household-local anchor the clamp compares
(falling back to the host clock when omitted). Any instance with against (falling back to the host clock when omitted). Any
a completion event is never touched, regardless of its date. instance with a completion event is never touched, regardless of
its date.
Returns the number deleted. Returns the number deleted.
""" """
_validate_date(cutoff_date, "cutoff_date") _validate_date(cutoff_date, "cutoff_date")
@@ -581,8 +582,9 @@ class QuestInstancesDao:
one transaction — an instance that gains a completion event one transaction — an instance that gains a completion event
mid-flight cannot be deleted. The cutoff cannot be backdated below mid-flight cannot be deleted. The cutoff cannot be backdated below
today, for the same reason as the definition-scoped method (see its today, for the same reason as the definition-scoped method (see its
docstring). ``today`` optionally pins the HA-local anchor the clamp docstring). ``today`` optionally pins the household-local anchor
compares against (falling back to the host clock when omitted). the clamp compares against (falling back to the host clock when
omitted).
Any instance with a completion event is never touched, regardless Any instance with a completion event is never touched, regardless
of its date. of its date.
+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 ``was_on_time``. No other personal data: names and titles are the
household's own records, and nothing beyond them leaks. household's own records, and nothing beyond them leaks.
Timezone policy: the day-complete check needs the HA-local "today", Timezone policy: the day-complete check needs the household-local
which the caller passes in as a plain :class:`datetime.date`. Nothing "today", which the caller passes in as a plain :class:`datetime.date`.
here reads ``hass.config.time_zone`` — the integration owns HA Nothing here reads a timezone — the API owns the clock (the
timezone, the core owns the calendar arithmetic. 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 from __future__ import annotations
@@ -147,10 +148,10 @@ async def build_child_day_complete_event(
completion of TODAY'S instance evaluates it: completing a completion of TODAY'S instance evaluates it: completing a
back-dated or future instance must not announce a cleared day. back-dated or future instance must not announce a cleared day.
``today`` is the HA-local calendar date the day-complete rule is ``today`` is the household-local calendar date the day-complete
evaluated against; the integration reads it from rule is evaluated against; the API resolves it in the application
``hass.config.time_zone`` and passes it in, so this module never timezone and passes it in, so this module never reads a clock or a
touches HA config. timezone of its own.
""" """
# Only a completion of TODAY'S instance evaluates the day-complete # Only a completion of TODAY'S instance evaluates the day-complete
# rule: completing a back-dated or future instance must not # 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: 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: if today is None:
return datetime.date.today() return datetime.date.today()
return today return today
@@ -283,7 +283,7 @@ async def query_history(
Rows are ordered deterministically by ``due_date``, then instance, Rows are ordered deterministically by ``due_date``, then instance,
then event id (the per-instance append order, so a completion and 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 the missed derivation compares due dates against (the host calendar
date when omitted). The whole read runs under the task-reentrant date when omitted). The whole read runs under the task-reentrant
connection lock — :func:`list_missed_for_child` re-enters it — so connection lock — :func:`list_missed_for_child` re-enters it — so
@@ -255,12 +255,12 @@ async def materialize(
after a presence change) cannot touch any unrelated child's rows. after a presence change) cannot touch any unrelated child's rows.
When omitted (or None) the walk covers every assignee as before. 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 no-past guard compares against; it is threaded into every
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` write. When :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` write. When
omitted the host clock (``datetime.date.today``) is used, which is the omitted the host clock (``datetime.date.today``) is used, which is the
historical default. A caller that computed the horizon in Home historical default. A caller that computed the horizon in the
Assistant's configured time zone MUST pass that same date here so a 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". time zone behind the host around midnight is not rejected as "past".
``start_date`` is clamped up to the resolved "today" (the same ``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() start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.datetime.strptime(end_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 # Clamp a past start up to today (the household-local anchor when the
# pinned one, else the host clock) so the walk can never emit a # 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. # past-dated instance; the DAO's no-past guard remains the backstop.
# A range entirely in the past therefore yields zero instances. # A range entirely in the past therefore yields zero instances.
today_date = _resolve_today(today) today_date = _resolve_today(today)
@@ -345,7 +345,7 @@ async def regenerate_for_definition(
:func:`~.dao_instances._today` — so the delete cutoff and the :func:`~.dao_instances._today` — so the delete cutoff and the
re-materialization range share one anchor, and the walk's re-materialization range share one anchor, and the walk's
``upsert_if_valid`` no-past guard never rejects the regenerated ``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) (threaded into both the delete cutoff and the re-materialization)
so a household time zone behind the host around midnight stays so a household time zone behind the host around midnight stays
consistent. ``horizon_days`` sizes the re-materialization window; 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`), :meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`),
so the delete cutoff and the re-materialization window share ONE so the delete cutoff and the re-materialization window share ONE
execution-day anchor even when the call is queued across midnight. 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 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 re-materialization window; when omitted it falls back to
:data:`~.const.DEFAULT_HORIZON_DAYS`. A bool, non-int, or sub-1 value :data:`~.const.DEFAULT_HORIZON_DAYS`. A bool, non-int, or sub-1 value
raises ValueError BEFORE any delete. Returns the number of instances raises ValueError BEFORE any delete. Returns the number of instances
@@ -345,7 +345,7 @@ async def edit_quest_definition(
future instances only; no ``quest_instances`` or future instances only; no ``quest_instances`` or
``completion_events`` row is touched. ``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 regeneration's no-past guard and horizon are anchored to (threaded
into :func:`~.materialize.regenerate_for_definition`); it is a plain into :func:`~.materialize.regenerate_for_definition`); it is a plain
``datetime.date`` with no HA import here — the Feature 09 service ``datetime.date`` with no HA import here — the Feature 09 service
@@ -449,7 +449,7 @@ async def assign_child(
``quest_definition_assignees`` link is written; existing instances ``quest_definition_assignees`` link is written; existing instances
and completion history are never touched. 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`). into the assignment's regeneration (see :func:`edit_quest_definition`).
``horizon_days`` optionally sizes the regeneration's re-materialization ``horizon_days`` optionally sizes the regeneration's re-materialization
window the same way. window the same way.
@@ -491,7 +491,7 @@ async def unassign_child(
argument. Only the ``quest_definition_assignees`` link is written; argument. Only the ``quest_definition_assignees`` link is written;
existing instances and completion history are never touched. 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`). into the unassignment's regeneration (see :func:`edit_quest_definition`).
``horizon_days`` optionally sizes the regeneration's re-materialization ``horizon_days`` optionally sizes the regeneration's re-materialization
window the same way. window the same way.
@@ -548,7 +548,7 @@ async def set_quest_definition_active(
connection-scoped lock, so the returned bundle is a consistent connection-scoped lock, so the returned bundle is a consistent
snapshot that a concurrent mutation cannot race. 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 into the activation change's regeneration (see
:func:`edit_quest_definition`). ``horizon_days`` optionally sizes the :func:`edit_quest_definition`). ``horizon_days`` optionally sizes the
regeneration's re-materialization window the same way. regeneration's re-materialization window the same way.
+6 -20
View File
@@ -49,7 +49,6 @@ from .const import (
CONF_MORNING_SUMMARY_TIME, CONF_MORNING_SUMMARY_TIME,
CONF_NOTIFY_TARGET, CONF_NOTIFY_TARGET,
CONF_TIMEZONE, CONF_TIMEZONE,
CONF_TIMEZONE_CONFIGURED,
DEFAULT_AFTERNOON_REMINDER_TIME, DEFAULT_AFTERNOON_REMINDER_TIME,
DEFAULT_AUTOMATION_ENABLED, DEFAULT_AUTOMATION_ENABLED,
DEFAULT_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME,
@@ -57,7 +56,6 @@ from .const import (
DEFAULT_HORIZON_DAYS, DEFAULT_HORIZON_DAYS,
DEFAULT_MORNING_SUMMARY_TIME, DEFAULT_MORNING_SUMMARY_TIME,
DEFAULT_TIMEZONE, DEFAULT_TIMEZONE,
DEFAULT_TIMEZONE_CONFIGURED,
LOGGER, LOGGER,
TIME_PATTERN, TIME_PATTERN,
) )
@@ -243,12 +241,6 @@ _FIELDS: tuple[tuple[str, str, Any, Callable[[Any], Any]], ...] = (
_resolve_bool, _resolve_bool,
), ),
("timezone", CONF_TIMEZONE, DEFAULT_TIMEZONE, _resolve_timezone), ("timezone", CONF_TIMEZONE, DEFAULT_TIMEZONE, _resolve_timezone),
(
"timezone_configured",
CONF_TIMEZONE_CONFIGURED,
DEFAULT_TIMEZONE_CONFIGURED,
_resolve_bool,
),
) )
@@ -279,10 +271,6 @@ class NestQuestSettings:
end_of_day_report_enabled: bool = DEFAULT_AUTOMATION_ENABLED end_of_day_report_enabled: bool = DEFAULT_AUTOMATION_ENABLED
celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED
timezone: str = DEFAULT_TIMEZONE timezone: str = DEFAULT_TIMEZONE
#: ``True`` once the admin explicitly chose :attr:`timezone` (even
#: the empty host-local value); ``False`` on a fresh install, where
#: the admin UI may auto-set the browser's zone.
timezone_configured: bool = DEFAULT_TIMEZONE_CONFIGURED
def __post_init__(self) -> None: def __post_init__(self) -> None:
# Validate STRICTLY at construction so a hand-built instance # Validate STRICTLY at construction so a hand-built instance
@@ -315,7 +303,6 @@ class NestQuestSettings:
"afternoon_reminder_enabled", "afternoon_reminder_enabled",
"end_of_day_report_enabled", "end_of_day_report_enabled",
"celebration_enabled", "celebration_enabled",
"timezone_configured",
): ):
value = getattr(self, name) value = getattr(self, name)
if not isinstance(value, bool): if not isinstance(value, bool):
@@ -329,12 +316,11 @@ class NestQuestSettings:
The API service does not run inside Home Assistant, so its one The API service does not run inside Home Assistant, so its one
clock read (an aware host-local ``now``) is converted here: with clock read (an aware host-local ``now``) is converted here: with
:attr:`timezone` set — the admin's choice, or else the zone the :attr:`timezone` set — only ever by an admin, from the admin
integration reports from Home Assistant — the SAME instant is app — the SAME instant is re-expressed in that zone, so
re-expressed in that zone, so ``.date()`` and ``.time()`` are the ``.date()`` and ``.time()`` are the household's wall clock even
household's wall clock even when the host runs UTC. With when the host runs UTC. With :attr:`timezone` empty the instant
:attr:`timezone` empty the instant is returned unchanged (the is returned unchanged (the host's local time).
host's local time).
""" """
if not self.timezone: if not self.timezone:
return instant return instant
@@ -354,7 +340,7 @@ class NestQuestSettings:
"""Return the rolling horizon window ``[today, today + horizon_days]``. """Return the rolling horizon window ``[today, today + horizon_days]``.
``today`` is REQUIRED (there is no host-clock default): the ``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 the horizon tracks the household's own day, and a silent
host-clock fallback would be a wrong-day footgun (a test or a host-clock fallback would be a wrong-day footgun (a test or a
caller that forgets to pass ``today`` would otherwise materialize caller that forgets to pass ``today`` would otherwise materialize
@@ -33,12 +33,8 @@ after the first update:
booleans, booleans,
- ``timezone`` — ``""`` (the API host's local time) or an IANA time - ``timezone`` — ``""`` (the API host's local time) or an IANA time
zone name such as ``America/New_York``. A document stored before zone name such as ``America/New_York``. A document stored before
this field existed simply lacks it and resolves to ``""``, this field existed simply lacks it and resolves to ``""``. Only an
- ``timezone_configured`` — a real boolean: ``True`` once the admin admin's explicit update ever writes it; nothing sets it automatically.
explicitly chose ``timezone`` (including the empty host-local value),
so neither Home Assistant's reported zone
(:func:`adopt_reported_timezone`) nor the admin UI changes it again. A document stored before
this field existed lacks it and resolves to ``False``.
Only values whose core validators ACCEPTED are ever written, so a Only values whose core validators ACCEPTED are ever written, so a
stored document always round-trips through the core's option builders. stored document always round-trips through the core's option builders.
@@ -180,7 +176,7 @@ async def update_settings(
update can never leave a half-applied document behind. update can never leave a half-applied document behind.
The validated changes are merged onto the current effective The validated changes are merged onto the current effective
settings and the merged (complete, twelve-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 in one atomic upsert; the updated settings are returned. The whole
read → merge → write span runs inside the per-database settings read → merge → write span runs inside the per-database settings
lock, so concurrent updates queue instead of interleaving. lock, so concurrent updates queue instead of interleaving.
@@ -204,28 +200,6 @@ async def update_settings(
return await _write_merged(database, current, changes) return await _write_merged(database, current, changes)
async def adopt_reported_timezone(
database: NestQuestDatabase, zone: str
) -> NestQuestSettings:
"""Adopt Home Assistant's reported ``zone`` unless the admin chose one.
While ``timezone_configured`` is ``False`` the stored ``timezone``
follows the zone the integration reports, so the API's clock is the
household's without any manual setup; ``timezone_configured`` stays
``False`` (the value is automatic, not an admin choice). Once the
admin has chosen — including an explicitly-chosen empty zone — the
stored settings are returned untouched. The check and the write
share the settings lock, so a concurrent admin choice is never
overwritten. An invalid ``zone`` raises a ``ValueError`` naming
``timezone`` and writes nothing.
"""
async with _settings_lock(database):
current = await load_settings(database)
if current.timezone_configured or current.timezone == zone:
return current
return await _write_merged(database, current, {"timezone": zone})
async def _write_merged( async def _write_merged(
database: NestQuestDatabase, database: NestQuestDatabase,
current: NestQuestSettings, current: NestQuestSettings,
+8 -2
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 service's panel snapshot route share ONE implementation of both the
snapshot assembly and the entity-facing attribute payload shape. snapshot assembly and the entity-facing attribute payload shape.
Nothing here imports :mod:`homeassistant`; the builder takes the 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 clock read the caller already resolved, from which it derives
``today``) and returns the snapshot, mirroring exactly what the ``today``) and returns the snapshot, mirroring exactly what the
coordinator used to do inline. coordinator used to do inline.
@@ -96,6 +96,11 @@ class NestQuestSnapshot:
today_iso: str today_iso: str
children: tuple[ChildDaySnapshot, ...] children: tuple[ChildDaySnapshot, ...]
cycle_day: int 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( def _cycle_day(
@@ -182,7 +187,7 @@ async def build_snapshot(
:func:`instance_payload` layer — this builder produces the shared :func:`instance_payload` layer — this builder produces the shared
shape both consumers read. 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 resolved (a SINGLE clock read); the overdue check compares
``now.time()`` against the instance's ``due_time`` for today's open ``now.time()`` against the instance's ``due_time`` for today's open
instances. The builder performs NO internal clock read — it never instances. The builder performs NO internal clock read — it never
@@ -319,4 +324,5 @@ async def build_snapshot(
today_iso=today_iso, today_iso=today_iso,
children=tuple(snapshots), children=tuple(snapshots),
cycle_day=_cycle_day(patterns, children, today), cycle_day=_cycle_day(patterns, children, today),
timezone=settings.timezone,
) )
+7 -6
View File
@@ -39,9 +39,10 @@ lets exactly one of them win the claim — the loser returns [] and
announces nothing. announces nothing.
The caller supplies ``today`` as a plain :class:`datetime.date`: the The caller supplies ``today`` as a plain :class:`datetime.date`: the
household-local calendar date, read from ``hass.config.time_zone`` by household-local calendar date, which the API reads in the
the integration or from the configured household timezone by the API. admin-stored application timezone (the API host's local zone when none
This module never reads a timezone database or config of its own. is stored). This module never reads a timezone database or config of
its own.
""" """
from __future__ import annotations from __future__ import annotations
@@ -97,9 +98,9 @@ async def run_missed_sweep(
"""Build one missed event per unswept past-due open instance. """Build one missed event per unswept past-due open instance.
``today`` is the household-local calendar date the sweep runs for ``today`` is the household-local calendar date the sweep runs for
(the caller owns the clock: the integration reads it from (the caller owns the clock: the API reads it in the admin-stored
``hass.config.time_zone``, the API from the configured household application timezone, falling back to its host's local time when
timezone, falling back to its host's local time when unset). unset).
Returns the ``(event_type, payload)`` tuples to announce, EMPTY Returns the ``(event_type, payload)`` tuples to announce, EMPTY
when the watermark already covers ``today`` (a same-night rerun) — when the watermark already covers ``today`` (a same-night rerun) —
the caller decides how to publish them and announces nothing on the caller decides how to publish them and announces nothing on
+1 -1
View File
@@ -15,5 +15,5 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues", "issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
"requirements": [], "requirements": [],
"version": "0.9.5" "version": "0.10.3"
} }
+4
View File
@@ -441,6 +441,10 @@ class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
for child in data.children for child in data.children
}, },
"child_roster": _child_roster(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
+16 -5
View File
@@ -105,14 +105,25 @@ Each definition carries one optional icon, shown on both surfaces. The picker of
sources: sources:
- **Lucide** — a grid of the curated Lucide glyphs, drawn stroked. - **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, - **Font Awesome** — a search over the **full** Font Awesome Free *solid* set (every
drawn filled. glyph in `@fortawesome/free-solid-svg-icons`), plus a curated quick-pick grid, all drawn
filled. With the search field empty the quick-pick grid shows; typing filters the full
set by name or label (an exact name ranks first, then name prefixes, then the rest),
shown 40 results per page with *Previous* / *Show more*.
**Free-text rule:** an admin may type a Free Solid icon name directly (trimmed,
case-insensitive — `Dog` stores `fa:dog`). Text that is not the exact name of a Free
Solid icon — a partial search, a misspelling, or a Pro or Brands name — is rejected in
the picker with an inline error and cannot be saved until a result is selected or the
full name is typed.
- **Emoji** — a text input; the typed emoji is stored as entered (not trimmed) and must be - **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 `&`. 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 The curated Lucide set and the Font Awesome quick-pick list come from
generated into the admin and panel bundles by `tools/icons/generate.mjs` (no CDN, no `tools/icons/curated-icons.json`; `tools/icons/generate.mjs` generates their glyph path
runtime fetch). See `THIRD-PARTY-NOTICES.md` for licences. data, together with the full Font Awesome Free Solid set, into the admin and panel bundles
(no CDN, no runtime fetch). Embedding the full set makes both bundles heavier — the panel
bundle (`nestquest-cards.js`) is about 1 MB raw / 276 kB gzip. See
`THIRD-PARTY-NOTICES.md` for licences.
The stored value is a **namespaced key**: The stored value is a **namespaced key**:
+2 -2
View File
@@ -78,6 +78,6 @@ The panel speaks tabletop; the admin speaks plainly. Both sit on the same `quest
decide before shipping the card. decide before shipping the card.
- No gamification is in scope. XP, levels, streaks and rarity were explicitly deferred, - No gamification is in scope. XP, levels, streaks and rarity were explicitly deferred,
but the append-only event log already supports computing them later. but the append-only event log already supports computing them later.
- Icon per quest definition is chosen by the parent from curated Lucide and Font Awesome - Icon per quest definition is chosen by the parent from the curated Lucide set, the full
Free sets or as an emoji, stored as a namespaced key (`lucide:`/`fa:`/`emoji:`) — Font Awesome Free Solid set (searchable, with curated quick picks) or as an emoji, stored as a namespaced key (`lucide:`/`fa:`/`emoji:`) —
resolved 2026-09-26, see `ADMIN-SPEC.md` §3.3. resolved 2026-09-26, see `ADMIN-SPEC.md` §3.3.
+2 -1
View File
@@ -106,7 +106,8 @@ collapse the column, the three-column rhythm is the layout.
1. **Icon tile** 64×64, radius 10px, `--nq-p-icon-tile`, glyph 34px white — the 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): 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, `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**; round caps and joins); `fa:<name>` draws the Font Awesome Free Solid glyph **filled** (any icon in the full
set, not only the quick picks);
`emoji:<grapheme>` renders the emoji **as text**. A legacy bare name resolves as `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, `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 malformed emoji), falls back to the **star** glyph. Glyph data is bundled from the
+48
View File
@@ -0,0 +1,48 @@
# NestQuest brand icons in Home Assistant
## Where the icon comes from
Since Home Assistant 2026.3, a custom integration can ship its own brand
images in `custom_components/<domain>/brand/`, and Home Assistant serves them
ahead of the brands CDN (`brands.home-assistant.io`). NestQuest ships:
| File | Size |
|------|------|
| `custom_components/nestquest/brand/icon.png` | 256×256 |
| `custom_components/nestquest/brand/icon@2x.png` | 512×512 |
| `custom_components/nestquest/brand/logo.png` | 256×256 |
| `custom_components/nestquest/brand/logo@2x.png` | 512×512 |
HACS installs the whole `custom_components/nestquest/` directory, so these
images arrive with the integration. On Home Assistant 2026.3 or newer they
appear on the integrations page and the integration tile, through Home
Assistant's brand endpoint. Older Home Assistant versions ignore the directory
and show the generic placeholder icon.
The `custom_integrations` folder of the `home-assistant/brands` repository is
legacy for this purpose; NestQuest does not depend on it.
## Known caveat: the HACS store card
The HACS store card (the repository listing inside HACS, shown before and
after installing) is rendered by HACS itself, not by the integration. It may
still take its image from the brands CDN or fall back to the avatar of the
repository owner (the `Maniacs-Mansion` organization). Whether HACS reads the
local `brand/` directory for that card has not been verified, so that icon
may stay generic. The repository page inside HACS shows the README, which
embeds the logo from `design/logos/NestQuest-NoBG.png`.
## Regenerating
The PNGs are generated offline and deterministically (pure Node, zlib only)
from `design/logos/NestQuest-NoBG.png`, box-filter downscaled with
transparency kept. From the repository root:
```bash
node design/logos/generate-brand-icons.mjs # write the four PNGs
node design/logos/generate-brand-icons.mjs --check # exit 1 if any drifted
```
`tests/test_brand_icons.py` runs the `--check` and asserts the four files
exist with the expected sizes, so a missing or hand-edited image fails the
test suite.
+187
View File
@@ -0,0 +1,187 @@
// Generates the integration's local brand images in
// custom_components/nestquest/brand/ (read by Home Assistant 2026.3+) from the
// transparent logo (design/logos/NestQuest-NoBG.png), box-filter downscaled
// with alpha kept. Adapted from admin/scripts/generate-icons.mjs for RGBA.
// Pure Node (zlib only) — no network, no image dependencies.
// Run (from the repository root):
// node design/logos/generate-brand-icons.mjs # write the PNGs
// node design/logos/generate-brand-icons.mjs --check # exit 1 if any drifted
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { deflateSync, inflateSync } from "node:zlib";
import { fileURLToPath } from "node:url";
const SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(buf) {
let c = 0xffffffff;
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([len, body, crc]);
}
function encodePng(size, rgba) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // colour type: RGBA
const stride = size * 4;
const raw = Buffer.alloc(size * (stride + 1));
for (let y = 0; y < size; y++) {
raw[y * (stride + 1)] = 0; // filter: none
raw.set(rgba.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
}
return Buffer.concat([
SIG,
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw, { level: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
// Minimal decoder for the source logo: 8-bit RGBA, non-interlaced only.
// Anything else fails loudly rather than producing a wrong icon.
function decodePng(buf) {
if (!buf.subarray(0, 8).equals(SIG)) throw new Error("not a PNG");
let width, height;
const idat = [];
for (let off = 8; off < buf.length; ) {
const len = buf.readUInt32BE(off);
const type = buf.toString("ascii", off + 4, off + 8);
const data = buf.subarray(off + 8, off + 8 + len);
if (type === "IHDR") {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
const [depth, colour, , , interlace] = data.subarray(8);
if (depth !== 8 || colour !== 6 || interlace !== 0) {
throw new Error(`unsupported PNG: depth ${depth}, colour type ${colour}, interlace ${interlace}`);
}
} else if (type === "IDAT") idat.push(data);
else if (type === "IEND") break;
off += 12 + len;
}
const raw = inflateSync(Buffer.concat(idat));
const stride = width * 4;
const px = Buffer.alloc(stride * height);
for (let y = 0; y < height; y++) {
const filter = raw[y * (stride + 1)];
const src = y * (stride + 1) + 1;
const dst = y * stride;
for (let i = 0; i < stride; i++) {
const a = i >= 4 ? px[dst + i - 4] : 0;
const b = y > 0 ? px[dst - stride + i] : 0;
const c = i >= 4 && y > 0 ? px[dst - stride + i - 4] : 0;
let p;
if (filter === 0) p = 0;
else if (filter === 1) p = a;
else if (filter === 2) p = b;
else if (filter === 3) p = (a + b) >> 1;
else if (filter === 4) {
const pa = Math.abs(b - c), pb = Math.abs(a - c), pc = Math.abs(a + b - 2 * c);
p = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
} else throw new Error(`bad PNG filter ${filter} on row ${y}`);
px[dst + i] = (raw[src + i] + p) & 0xff;
}
}
if (width !== height) throw new Error(`logo must be square, got ${width}x${height}`);
return { width, height, px };
}
// Box-filter (area-average) resize along one axis: each output sample is the
// coverage-weighted mean of the source samples it spans.
function boxWeights(srcLen, dstLen) {
const scale = srcLen / dstLen;
return Array.from({ length: dstLen }, (_, o) => {
const start = o * scale, end = start + scale;
const taps = [];
for (let s = Math.floor(start); s < Math.min(Math.ceil(end), srcLen); s++) {
taps.push([s, (Math.min(end, s + 1) - Math.max(start, s)) / scale]);
}
return taps;
});
}
// Colour is averaged premultiplied by alpha, so fully transparent source
// pixels (whatever RGB they carry) cannot bleed a fringe into the edges.
function resize({ width, height, px }, size) {
const wx = boxWeights(width, size);
const wy = boxWeights(height, size);
const tmp = new Float64Array(size * height * 4); // horizontal pass, premultiplied
for (let y = 0; y < height; y++) {
for (let x = 0; x < size; x++) {
const t = (y * size + x) * 4;
for (const [s, w] of wx[x]) {
const p = (y * width + s) * 4;
const aw = px[p + 3] * w;
for (let k = 0; k < 3; k++) tmp[t + k] += px[p + k] * aw;
tmp[t + 3] += aw;
}
}
}
const out = new Uint8Array(size * size * 4); // vertical pass, un-premultiply
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const acc = [0, 0, 0, 0];
for (const [s, w] of wy[y]) {
const t = (s * size + x) * 4;
for (let k = 0; k < 4; k++) acc[k] += tmp[t + k] * w;
}
const o = (y * size + x) * 4;
const alpha = Math.min(255, Math.round(acc[3]));
for (let k = 0; k < 3; k++) {
out[o + k] = alpha === 0 ? 0 : Math.min(255, Math.round(acc[k] / acc[3]));
}
out[o + 3] = alpha;
}
}
return out;
}
const logo = decodePng(readFileSync(new URL("NestQuest-NoBG.png", import.meta.url)));
const outDir = new URL("../../custom_components/nestquest/brand/", import.meta.url);
const files = [
["icon.png", 256],
["icon@2x.png", 512],
["logo.png", 256],
["logo@2x.png", 512],
];
const check = process.argv.includes("--check");
let drifted = false;
if (!check) mkdirSync(outDir, { recursive: true });
for (const [name, size] of files) {
const path = fileURLToPath(new URL(name, outDir));
const png = encodePng(size, resize(logo, size));
if (check) {
let current = null;
try {
current = readFileSync(path);
} catch {
// missing counts as drift
}
if (!current || !current.equals(png)) {
drifted = true;
console.error(`drift: ${path} differs from a fresh generation`);
}
} else {
writeFileSync(path, png);
console.log(`wrote ${path}`);
}
}
if (drifted) {
console.error("run `node design/logos/generate-brand-icons.mjs` and commit the result");
process.exit(1);
}
+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 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. 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 ## 4. Authentication: two planes
The kid panel and the admin PWA have opposite trust levels, so they use The kid panel and the admin PWA have opposite trust levels, so they use
+11 -1
View File
@@ -937,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 { 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 { private _state(entityId: string): StateObject | null {
+11 -1
View File
@@ -1749,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 { 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 { private _state(entityId: string): StateObject | null {
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,7 +1,8 @@
/** /**
* Lit rendering for the shared icon registry (`registry.generated.ts`, * Lit rendering for the shared icon registry (`registry.generated.ts`,
* generated from tools/icons/curated-icons.json). The glyph path data is * generated by tools/icons/generate.mjs: the curated Lucide set plus the full
* bundled — no CDN and no runtime fetch. * Font Awesome Free Solid set). The glyph path data is bundled — no CDN and
* no runtime fetch.
*/ */
import { html, svg, type TemplateResult } from "lit"; import { html, svg, type TemplateResult } from "lit";
+22 -3
View File
@@ -3,13 +3,15 @@
* *
* Usage: node render-party-board.mjs < spec.json * 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 * stdout receives one JSON object {plates, tappable, location, html} where
* plates is the ordered plate list the shadow DOM renders (name + * plates is the ordered plate list the shadow DOM renders (name +
* present/away/unknown kind), tappable the plate names rendered as tappable * present/away/unknown kind), tappable the plate names rendered as tappable
* <button> plates, and location the window pathname after the optional plate * <button> plates, and location the window pathname after the optional plate
* tap. The Python panel tests drive this harness so the assertions run * tap. With now: <ISO instant> the card's clock is pinned there
* against the exact bundle HACS ships. * (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 { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url"; 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. // jsdom Event must shadow it for the dispatched event to be delivered.
globalThis.Event = dom.window.Event; 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); await import(pathToFileURL(spec.bundle).href);
const element = document.createElement(spec.tag); const element = document.createElement(spec.tag);
+46 -1
View File
@@ -16,6 +16,12 @@
* reports every hass.callService call as tap: {dialog_opened, service_calls}. * reports every hass.callService call as tap: {dialog_opened, service_calls}.
* With tap_return: true the harness taps the header's return control and * With tap_return: true the harness taps the header's return control and
* reports it as returned: {rendered, label} before the final snapshot. * 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 * The Python panel tests drive this harness so the
* assertions run against the exact bundle HACS ships. * assertions run against the exact bundle HACS ships.
*/ */
@@ -47,6 +53,33 @@ globalThis.window = dom.window;
// jsdom Event must shadow it for the return navigation to be delivered. // jsdom Event must shadow it for the return navigation to be delivered.
globalThis.Event = dom.window.Event; 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); await import(pathToFileURL(spec.bundle).href);
const serviceCalls = []; const serviceCalls = [];
@@ -123,6 +156,18 @@ if (spec.idle_ms !== undefined) {
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms)); await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
} }
const payload = JSON.stringify({ ...snapshot(), probe, tap, returned }); 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())); await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0); process.exit(0);
Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "nestquest" name = "nestquest"
version = "0.9.5" version = "0.10.3"
description = "NestQuest Home Assistant Integration" description = "NestQuest Home Assistant Integration"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+26 -2
View File
@@ -879,6 +879,29 @@ def set_coordinator_client(entry, client):
return client return client
@dataclass(frozen=True)
class FakeClientTimeout:
"""Stand-in for ``aiohttp.ClientTimeout`` (the fields the client sets)."""
total: float | None = None
sock_connect: float | None = None
@pytest.fixture
def fake_aiohttp(monkeypatch):
"""Install a stand-in ``aiohttp`` module for one test.
aiohttp ships only with Home Assistant, so this mock-only harness
does not have it; the API client imports it lazily inside
``stream_events`` (for ``ClientTimeout``). Tests driving the real
stream path request this fixture; the module is removed again when
the test ends.
"""
module = SimpleNamespace(ClientTimeout=FakeClientTimeout)
monkeypatch.setitem(sys.modules, "aiohttp", module)
return module
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def coordinator_client_from_test_override(): def coordinator_client_from_test_override():
"""Resolve every entry's coordinator client through the test seam. """Resolve every entry's coordinator client through the test seam.
@@ -910,6 +933,7 @@ def coordinator_client_from_test_override():
{ {
"today_iso": datetime.date.today().isoformat(), "today_iso": datetime.date.today().isoformat(),
"cycle_day": 0, "cycle_day": 0,
"timezone": "",
"children": [], "children": [],
} }
) )
@@ -1016,8 +1040,8 @@ def make_hass() -> tuple:
hass.bus = EventBus() hass.bus = EventBus()
hass.http = HttpRegistry() hass.http = HttpRegistry()
hass.extra_js_urls: list[str] = [] hass.extra_js_urls: list[str] = []
# A valid IANA time zone (the day-rollover listener reads it to # A valid IANA time zone for the fake Home Assistant config (the
# compute "today" in HA local time). # NestQuest application timezone is the admin-set setting, not this).
hass.config.time_zone = "UTC" hass.config.time_zone = "UTC"
# The minimal entity registry: entities self-register through # The minimal entity registry: entities self-register through
+115 -22
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: Proven per the done-condition, everything read back through the API:
- ``GET /api/v1/admin/settings`` returns the current effective settings - ``GET /api/v1/admin/settings`` returns the current effective settings
— the twelve 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 - ``PATCH /api/v1/admin/settings`` PERSISTS the supplied fields and the
NEXT read reflects them; a later partial update merges onto the NEXT read reflects them; a later partial update merges onto the
current effective settings (omitted fields keep their stored value), current effective settings (omitted fields keep their stored value),
@@ -40,6 +41,7 @@ from types import SimpleNamespace
import pytest import pytest
from api import host_zone
from tests.admin_jwt_harness import ( from tests.admin_jwt_harness import (
ADMIN_KEY, ADMIN_KEY,
KID, KID,
@@ -59,9 +61,14 @@ _dao_meta = importlib.import_module("nestquest_core.dao_meta")
#: independent of the core constant. #: independent of the core constant.
SETTINGS_META_KEY = "settings" 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 #: The all-defaults effective settings a fresh database reports — the
#: documented twelve fields, asserted literally so the payload contract is #: documented eleven fields plus the read-only ``effective_timezone``,
#: pinned here, independent of the core constants. #: asserted literally so the payload contract is pinned here, independent
#: of the core constants.
DEFAULT_SETTINGS = { DEFAULT_SETTINGS = {
"horizon_days": 14, "horizon_days": 14,
"day_rollover_time": "00:00", "day_rollover_time": "00:00",
@@ -74,23 +81,34 @@ DEFAULT_SETTINGS = {
"end_of_day_report_enabled": True, "end_of_day_report_enabled": True,
"celebration_enabled": True, "celebration_enabled": True,
"timezone": "", "timezone": "",
"timezone_configured": False, "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]: def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT.""" """The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"} return {"Authorization": f"Bearer {make_token()}"}
@pytest.fixture @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. """An admin-plane app client plus its live database.
The same runner the children tests use (stubbed JWKS, no network); The same runner the children tests use (stubbed JWKS, no network);
the fixture also exposes the app's ONE database connection so tests 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)) runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client: async with runner as client:
yield SimpleNamespace( yield SimpleNamespace(
@@ -105,7 +123,7 @@ async def settings_client(temp_db_path: str) -> SimpleNamespace:
async def test_get_settings_returns_defaults_on_a_fresh_database( async def test_get_settings_returns_defaults_on_a_fresh_database(
settings_client: SimpleNamespace, settings_client: SimpleNamespace,
) -> None: ) -> None:
"""GET returns the twelve documented fields at their defaults.""" """GET returns the eleven documented fields at their defaults."""
response = await settings_client.client.get( response = await settings_client.client.get(
"/api/v1/admin/settings", headers=_admin_headers() "/api/v1/admin/settings", headers=_admin_headers()
) )
@@ -147,7 +165,9 @@ async def test_update_persists_and_the_next_read_reflects_it(
SETTINGS_META_KEY SETTINGS_META_KEY
) )
assert raw is not None 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( async def test_partial_update_merges_onto_the_current_settings(
@@ -223,27 +243,38 @@ async def test_null_resets_a_field_to_its_default(
assert reread.json()["horizon_days"] == 14 assert reread.json()["horizon_days"] == 14
async def test_timezone_configured_round_trips_with_an_explicit_empty_zone( async def test_default_settings_carry_no_timezone_configured_marker(
settings_client: SimpleNamespace, settings_client: SimpleNamespace,
) -> None: ) -> None:
"""An explicit empty (host-local) zone persists as configured. """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"] == ""
The admin clearing the zone PATCHes ``timezone: ""`` together with
``timezone_configured: true``; the NEXT read — a reopened settings @pytest.mark.parametrize("zone", ["America/New_York", ""])
screen — sees the marker, so the empty value is a persisted choice async def test_manual_timezone_patch_persists_and_is_returned(
rather than a fresh install to auto-set. settings_client: SimpleNamespace, zone: str
""" ) -> None:
"""An admin's timezone PATCH is stored exactly as given and read back."""
client = settings_client.client client = settings_client.client
fresh = await client.get("/api/v1/admin/settings", headers=_admin_headers()) seeded = await client.patch(
assert fresh.json()["timezone_configured"] is False "/api/v1/admin/settings",
json={"timezone": "Europe/Dublin"},
headers=_admin_headers(),
)
assert seeded.status_code == 200
patched = await client.patch( patched = await client.patch(
"/api/v1/admin/settings", "/api/v1/admin/settings",
json={"timezone": "", "timezone_configured": True}, json={"timezone": zone},
headers=_admin_headers(), headers=_admin_headers(),
) )
assert patched.status_code == 200 assert patched.status_code == 200
expected = {**DEFAULT_SETTINGS, "timezone_configured": True} expected = _with_zone(zone)
assert patched.json() == expected assert patched.json() == expected
reread = await client.get( reread = await client.get(
@@ -254,7 +285,61 @@ async def test_timezone_configured_round_trips_with_an_explicit_empty_zone(
SETTINGS_META_KEY SETTINGS_META_KEY
) )
assert raw is not None assert raw is not None
assert json.loads(raw)["timezone_configured"] is True 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 ------- # --- validation: rejected values never change the stored settings -------
@@ -273,8 +358,9 @@ async def test_timezone_configured_round_trips_with_an_explicit_empty_zone(
({"morning_summary_enabled": "yes"}, "morning_summary_enabled"), ({"morning_summary_enabled": "yes"}, "morning_summary_enabled"),
({"celebration_enabled": 1}, "celebration_enabled"), ({"celebration_enabled": 1}, "celebration_enabled"),
({"notify_target": 5}, "notify_target"), ({"notify_target": 5}, "notify_target"),
({"timezone_configured": "true"}, "timezone_configured"), ({"timezone": "Mars/Olympus_Mons"}, "timezone"),
({"timezone_configured": 1}, "timezone_configured"), ({"timezone": "America/New York"}, "timezone"),
({"timezone": 5}, "timezone"),
], ],
) )
async def test_invalid_value_is_rejected_and_stored_settings_unchanged( async def test_invalid_value_is_rejected_and_stored_settings_unchanged(
@@ -429,6 +515,13 @@ async def test_settings_routes_refuse_non_admin_and_absent_credentials(
) )
assert absent_patch.status_code == 401 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. # No refused request changed anything: the settings are untouched.
after = await client.get( after = await client.get(
"/api/v1/admin/settings", headers=_admin_headers() "/api/v1/admin/settings", headers=_admin_headers()
+8 -3
View File
@@ -37,7 +37,7 @@ from zoneinfo import ZoneInfo
import pytest 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 api.nestquest_core import core_snapshot
from tests.admin_jwt_harness import ( from tests.admin_jwt_harness import (
ADMIN_KEY, ADMIN_KEY,
@@ -101,6 +101,8 @@ async def snapshot_household(
monkeypatch.setattr(routes_admin, "_local_now", _admin_now) monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_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)) runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client: async with runner as client:
yield SimpleNamespace( 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 Every field is asserted as an explicit literal, with ONE clock
read for the request. With no missed view in the real snapshot, 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 client = snapshot_household.client
seed = snapshot_household.seed seed = snapshot_household.seed
@@ -241,7 +246,7 @@ async def test_admin_jwt_returns_documented_snapshot(
"/api/v1/panel/snapshot", headers=_panel_headers() "/api/v1/panel/snapshot", headers=_panel_headers()
) )
assert panel.status_code == 200 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( async def test_admin_keeps_missed_instance_panel_omits_it(
+128 -23
View File
@@ -17,7 +17,7 @@ import types
import pytest import pytest
from conftest import make_config_entry from conftest import FakeClientTimeout, make_config_entry
from custom_components.nestquest import api_client from custom_components.nestquest import api_client
from custom_components.nestquest.api_client import ( from custom_components.nestquest.api_client import (
@@ -33,6 +33,10 @@ from custom_components.nestquest.const import (
DEFAULT_PANEL_TOKEN, DEFAULT_PANEL_TOKEN,
) )
# ``stream_events`` imports aiohttp lazily; the harness has none, so
# every test here runs with the stand-in module installed.
pytestmark = pytest.mark.usefixtures("fake_aiohttp")
BASE_URL = "http://api.test:8000" BASE_URL = "http://api.test:8000"
TOKEN = "test-panel-token" TOKEN = "test-panel-token"
@@ -124,6 +128,7 @@ class StubTransport:
"url": url, "url": url,
"headers": kwargs.get("headers"), "headers": kwargs.get("headers"),
"json": kwargs.get("json"), "json": kwargs.get("json"),
"timeout": kwargs.get("timeout"),
} }
) )
if not self._outcomes: if not self._outcomes:
@@ -196,25 +201,27 @@ async def test_complete_instance_posts_body_and_headers() -> None:
assert call["headers"]["Authorization"] == f"Bearer {TOKEN}" assert call["headers"]["Authorization"] == f"Bearer {TOKEN}"
async def test_household_timezone_rides_every_request_header() -> None: async def test_requests_carry_only_the_bearer_header() -> None:
"""Home Assistant's zone is reported so the API reads the household """No timezone is reported to the API (task 7f685179): the Bearer
clock (task 6351b36d); it is sent only when the client has one.""" credential is the only header on every request."""
transport = StubTransport( transport = StubTransport(
StubResponse(200, json.dumps(SNAPSHOT_PAYLOAD)), StubResponse(200, json.dumps(SNAPSHOT_PAYLOAD)),
StubResponse(200, '{"status": "done"}'), StubResponse(200, '{"status": "done"}'),
) )
client = NestQuestApiClient( client = make_client(transport)
BASE_URL, TOKEN, transport, household_timezone="America/New_York"
)
await client.get_snapshot() await client.get_snapshot()
await client.complete_instance(42, 3) await client.complete_instance(42, 3)
for call in transport.calls: for call in transport.calls:
assert call["headers"] == { assert call["headers"] == {"Authorization": f"Bearer {TOKEN}"}
"Authorization": f"Bearer {TOKEN}",
"X-NestQuest-Timezone": "America/New_York",
} 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: async def test_base_url_trailing_slash_is_normalized() -> None:
@@ -413,20 +420,13 @@ def test_client_from_entry_reads_config_and_shared_session(monkeypatch) -> None:
} }
) )
hass = types.SimpleNamespace( # A bare object: building the client must not read hass.config.
config=types.SimpleNamespace(time_zone="America/New_York") client = api_client.client_from_entry(object(), entry)
)
client = api_client.client_from_entry(hass, entry)
assert client._session is shared_session assert client._session is shared_session
assert client._base_url == "http://panel.lan:8443" assert client._base_url == "http://panel.lan:8443"
assert client._panel_token == "entry-token" assert client._panel_token == "entry-token"
assert client._household_timezone == "America/New_York" assert client._headers() == {"Authorization": "Bearer entry-token"}
#: A minimal hass stand-in carrying the configured time zone.
_HASS_UTC = types.SimpleNamespace(config=types.SimpleNamespace(time_zone="UTC"))
def test_client_from_entry_unconfigured_token_raises_at_construction( def test_client_from_entry_unconfigured_token_raises_at_construction(
@@ -449,12 +449,12 @@ def test_client_from_entry_unconfigured_token_raises_at_construction(
assert resolve_api_config(entry)[1] == DEFAULT_PANEL_TOKEN == "" assert resolve_api_config(entry)[1] == DEFAULT_PANEL_TOKEN == ""
with pytest.raises(ValueError, match="panel_token must be a non-empty"): with pytest.raises(ValueError, match="panel_token must be a non-empty"):
api_client.client_from_entry(_HASS_UTC, entry) api_client.client_from_entry(object(), entry)
# Same for a whitespace-only stored token: whitespace is not a token. # Same for a whitespace-only stored token: whitespace is not a token.
entry_ws = make_config_entry(options={CONF_PANEL_TOKEN: " "}) entry_ws = make_config_entry(options={CONF_PANEL_TOKEN: " "})
with pytest.raises(ValueError, match="panel_token must be a non-empty"): with pytest.raises(ValueError, match="panel_token must be a non-empty"):
api_client.client_from_entry(_HASS_UTC, entry_ws) api_client.client_from_entry(object(), entry_ws)
async def test_client_logs_nothing() -> None: async def test_client_logs_nothing() -> None:
@@ -478,3 +478,108 @@ async def test_client_logs_nothing() -> None:
finally: finally:
logging.getLogger().removeHandler(handler) logging.getLogger().removeHandler(handler)
assert records == [] assert records == []
# ---------------------------------------------------------------------------
# stream_events: the body is read from ``response.content``
# ---------------------------------------------------------------------------
class _StubStreamContent:
"""A fake aiohttp ``StreamReader``: async-iterable over byte chunks."""
def __init__(self, chunks) -> None:
self._chunks = list(chunks)
def __aiter__(self):
return self._iterate()
async def _iterate(self):
for chunk in self._chunks:
yield chunk
class StubStreamResponse:
"""A fake aiohttp/HA ``ClientResponse`` for a streaming body.
Like the real ``HassClientResponse`` it does NOT implement
``__aiter__`` — the body is iterated through ``.content`` — so a
client that iterates the response object itself raises the
``'async for' requires an object with __aiter__ method`` TypeError.
"""
def __init__(self, status: int, chunks) -> None:
self.status = status
self.content = _StubStreamContent(chunks)
async def test_stream_events_reads_frames_from_response_content() -> None:
"""Frames are parsed from ``response.content`` byte chunks, including
a frame split across chunk boundaries; a trailing incomplete frame
is discarded."""
assert not hasattr(StubStreamResponse, "__aiter__")
chunks = [
b'event: nestquest_quest_completed\ndata: {"instance_id"',
b': 7}\n',
b'\nevent: nestquest_child_day_complete\n',
b'data: {"child_id": 1}\n\n',
b'event: nestquest_quest_missed\ndata: {"instance_id": 9}\n',
]
transport = StubTransport(StubStreamResponse(200, chunks))
client = make_client(transport)
frames = [frame async for frame in client.stream_events()]
assert frames == [
("nestquest_quest_completed", {"instance_id": 7}),
("nestquest_child_day_complete", {"child_id": 1}),
]
assert transport.calls[0]["method"] == "GET"
assert transport.calls[0]["url"] == f"{BASE_URL}/api/v1/panel/events"
async def test_stream_events_non_2xx_raises_typed_with_status() -> None:
transport = StubTransport(StubStreamResponse(502, []))
client = make_client(transport)
with pytest.raises(NestQuestApiError) as excinfo:
async for _ in client.stream_events():
pass
assert excinfo.value.status == 502
assert not excinfo.value.is_transport_error
async def test_stream_events_invalid_json_frame_raises_typed() -> None:
transport = StubTransport(
StubStreamResponse(200, [b"event: nestquest_quest_completed\n", b"data: {nope\n\n"])
)
client = make_client(transport)
with pytest.raises(NestQuestApiError) as excinfo:
async for _ in client.stream_events():
pass
assert excinfo.value.status == 200
assert not excinfo.value.is_transport_error
assert isinstance(excinfo.value.__cause__, ValueError)
async def test_stream_events_disables_the_session_total_timeout() -> None:
"""The stream request overrides the session's default total timeout
(HA's shared session: 300 s, which would cut a healthy stream) with
``total=None`` while keeping a finite connect timeout; the plain
request calls pass no per-request timeout."""
transport = StubTransport(
StubStreamResponse(200, []), StubResponse(200, json.dumps(SNAPSHOT_PAYLOAD))
)
client = make_client(transport, timeout_seconds=7.0)
frames = [frame async for frame in client.stream_events()]
await client.get_snapshot()
assert frames == []
stream_timeout = transport.calls[0]["timeout"]
assert isinstance(stream_timeout, FakeClientTimeout)
assert stream_timeout.total is None
assert stream_timeout.sock_connect == 7.0
assert transport.calls[1]["timeout"] is None
+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"
+272 -168
View File
@@ -19,6 +19,8 @@ container's zone) and sets the household zone through the real admin
An unset zone keeps the historical host-local behaviour, and the An unset zone keeps the historical host-local behaviour, and the
missed-sweep scheduler waits for the rollover in household time. 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 from __future__ import annotations
@@ -30,7 +32,7 @@ from zoneinfo import ZoneInfo
import pytest import pytest
from api import routes_admin, routes_panel from api import host_zone, routes_admin, routes_panel
from api import scheduler as scheduler_module from api import scheduler as scheduler_module
from api.database import _executor from api.database import _executor
from api.scheduler import MissedSweepScheduler, _delay_seconds from api.scheduler import MissedSweepScheduler, _delay_seconds
@@ -47,6 +49,7 @@ _settings = importlib.import_module("nestquest_core.settings")
_settings_store = importlib.import_module("nestquest_core.settings_store") _settings_store = importlib.import_module("nestquest_core.settings_store")
_core_db = importlib.import_module("nestquest_core.db") _core_db = importlib.import_module("nestquest_core.db")
_core_migrations = importlib.import_module("nestquest_core.migrations") _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. #: The panel service token the admin harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token" HARNESS_PANEL_TOKEN = "admin-test-panel-token"
@@ -84,7 +87,13 @@ async def household(temp_db_path: str, monkeypatch) -> SimpleNamespace:
monkeypatch.setattr(routes_panel, "_local_now", lambda: clock["now"]) monkeypatch.setattr(routes_panel, "_local_now", lambda: clock["now"])
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client: async with runner as client:
yield SimpleNamespace(client=client, seed=seed, clock=clock) 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: async def _set_zone(household: SimpleNamespace, zone: str) -> None:
@@ -237,23 +246,14 @@ def test_from_options_reads_and_resiliently_defaults_timezone() -> None:
) )
def test_settings_timezone_configured_defaults_false_and_is_strict() -> None: def test_settings_have_no_timezone_configured_marker() -> None:
assert _settings.NestQuestSettings().timezone_configured is False assert not hasattr(_settings.NestQuestSettings(), "timezone_configured")
assert _settings.NestQuestSettings.from_options({}).timezone_configured is False with pytest.raises(TypeError):
assert ( _settings.NestQuestSettings(timezone_configured=True)
_settings.NestQuestSettings.from_options( # A stored document still carrying the removed key loads harmlessly.
{"timezone_configured": True} assert _settings.NestQuestSettings.from_options_resilient(
).timezone_configured {"timezone": HOUSEHOLD_ZONE, "timezone_configured": True}
is True ) == _settings.NestQuestSettings(timezone=HOUSEHOLD_ZONE)
)
for value in ("true", 1, None):
with pytest.raises(ValueError, match="timezone_configured"):
_settings.NestQuestSettings(timezone_configured=value)
if value is not None:
with pytest.raises(ValueError):
_settings.NestQuestSettings.from_options(
{"timezone_configured": value}
)
# --- the missed-sweep scheduler ---------------------------------------------- # --- the missed-sweep scheduler ----------------------------------------------
@@ -329,37 +329,29 @@ def test_delay_seconds_is_true_elapsed_time_across_dst(
assert _delay_seconds(target, now) == expected assert _delay_seconds(target, now) == expected
# --- the Home Assistant-reported zone (task 6351b36d) ------------------------ # --- nothing sets the zone automatically (task 7f685179) --------------------
# #
# The deployed household never set ``timezone``, so the UTC API host marked # The zone is only ever set by an admin's explicit settings update. A
# morning quests overdue. The integration reports Home Assistant's # timezone header on a panel request — the one Home Assistant used to
# ``time_zone`` on every panel request; while the admin has not chosen a # report, or any look-alike — is ignored, and no read writes settings.
# zone the API adopts it (persisted, so the scheduler and the admin plane
# follow too), and an explicit admin choice still wins. #: 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},
]
def _reporting_panel_headers(zone: str) -> dict[str, str]: async def _stored_document(household: SimpleNamespace) -> str | None:
return {**_panel_headers(), "X-NestQuest-Timezone": zone} """The raw stored settings document (``None`` when never written)."""
return await _dao_meta.MetaStateDao(household.database).get(
_settings_store.SETTINGS_META_KEY
async def _panel_pack_bag(
household: SimpleNamespace, zone: str
) -> tuple[str, dict]:
response = await household.client.get(
"/api/v1/panel/snapshot", headers=_reporting_panel_headers(zone)
) )
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"]
return body["today_iso"], instance
async def _stored_settings(household: SimpleNamespace) -> dict: async def _admin_settings(household: SimpleNamespace) -> dict:
response = await household.client.get( response = await household.client.get(
"/api/v1/admin/settings", headers=_admin_headers() "/api/v1/admin/settings", headers=_admin_headers()
) )
@@ -367,99 +359,73 @@ async def _stored_settings(household: SimpleNamespace) -> dict:
return response.json() return response.json()
async def test_unset_zone_adopts_reported_zone_for_overdue( @pytest.mark.parametrize("extra", _TIMEZONE_HEADERS)
household: SimpleNamespace, async def test_panel_timezone_header_never_changes_settings(
household: SimpleNamespace, extra: dict[str, str]
) -> None: ) -> None:
"""09:59:59 EDT on a UTC host, zone never set: NOT overdue.""" """09:59:59 EDT on a UTC host, zone unset: the header is ignored."""
household.clock["now"] = _utc(13, 59, 59) household.clock["now"] = _utc(13, 59, 59)
today_iso, instance = await _panel_pack_bag(household, HOUSEHOLD_ZONE) headers = {**_panel_headers(), **extra}
assert today_iso == TODAY.isoformat() snapshot = await household.client.get(
assert instance["overdue"] is False "/api/v1/panel/snapshot", headers=headers
# Persisted as the automatic (not admin-chosen) household zone, so )
# the admin plane and the missed-sweep scheduler read it too. assert snapshot.status_code == 200
stored = await _stored_settings(household)
assert stored["timezone"] == HOUSEHOLD_ZONE
assert stored["timezone_configured"] is False
_today, admin_instance = await _pack_bag(household, "admin")
assert admin_instance["overdue"] is False
async def test_unset_zone_adopts_reported_zone_for_today(
household: SimpleNamespace,
) -> None:
"""23:30 EDT is tomorrow in UTC; the household day is still today."""
household.clock["now"] = _utc(3, 30, day=27)
today_iso, _instance = await _panel_pack_bag(household, HOUSEHOLD_ZONE)
assert today_iso == TODAY.isoformat()
async def test_reported_zone_completion_is_on_time(
household: SimpleNamespace,
) -> None:
"""A 09:30 EDT tap with the zone never set is on time."""
household.clock["now"] = _utc(13, 30)
seed = household.seed seed = household.seed
response = await household.client.post( completed = await household.client.post(
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete", f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
headers=_reporting_panel_headers(HOUSEHOLD_ZONE), headers=headers,
json={"actor_child_id": seed.ada.id}, json={"actor_child_id": seed.ada.id},
) )
assert response.status_code == 200 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") _today_iso, instance = await _pack_bag(household, "admin")
assert instance["on_time"] is True assert instance["on_time"] is False
async def test_reported_zone_follows_home_assistant_changes( @pytest.mark.parametrize("extra", _TIMEZONE_HEADERS)
household: SimpleNamespace, async def test_panel_timezone_header_never_replaces_the_stored_zone(
household: SimpleNamespace, extra: dict[str, str]
) -> None: ) -> None:
"""An automatically adopted zone tracks a later Home Assistant change.""" """A stored zone stays exactly as the admin saved it."""
await _panel_pack_bag(household, "America/Chicago") await _set_zone(household, "America/Los_Angeles")
await _panel_pack_bag(household, HOUSEHOLD_ZONE) before = await _stored_document(household)
assert (await _stored_settings(household))["timezone"] == HOUSEHOLD_ZONE household.clock["now"] = _utc(16, 59, 59) # 09:59:59 PDT, 12:59:59 EDT
response = await household.client.get(
"/api/v1/panel/snapshot",
async def _adopt_new_york(household: SimpleNamespace) -> None: headers={**_panel_headers(), **extra},
"""Prove automatic adoption first, so later assertions can't pass
on code that ignores the reported zone altogether."""
await _panel_pack_bag(household, HOUSEHOLD_ZONE)
stored = await _stored_settings(household)
assert stored["timezone"] == HOUSEHOLD_ZONE
assert stored["timezone_configured"] is False
@pytest.mark.parametrize("chosen", ["America/Los_Angeles", ""])
async def test_admin_chosen_zone_wins_over_reported_zone(
household: SimpleNamespace, chosen: str
) -> None:
await _adopt_new_york(household)
response = await household.client.patch(
"/api/v1/admin/settings",
json={"timezone": chosen, "timezone_configured": True},
headers=_admin_headers(),
) )
assert response.status_code == 200 assert response.status_code == 200
# A conflicting report is no longer adopted. assert await _stored_document(household) == before
await _panel_pack_bag(household, "America/Chicago") _today_iso, instance = await _pack_bag(household, "panel")
stored = await _stored_settings(household) assert instance["overdue"] is False
assert stored["timezone"] == chosen
assert stored["timezone_configured"] is True
async def test_invalid_reported_zone_is_ignored( async def test_fresh_install_zone_stays_empty_across_reads(
household: SimpleNamespace, household: SimpleNamespace,
) -> None: ) -> None:
await _adopt_new_york(household) """Repeated panel and admin reads never write the settings document."""
household.clock["now"] = _utc(13, 59, 59) for hour in (3, 13, 23):
_today_iso, instance = await _panel_pack_bag(household, "Mars/Olympus_Mons") household.clock["now"] = _utc(hour, 0)
# Still read in the adopted zone: 09:59:59 EDT is not overdue. for extra in _TIMEZONE_HEADERS:
assert instance["overdue"] is False response = await household.client.get(
assert (await _stored_settings(household))["timezone"] == HOUSEHOLD_ZONE "/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 an adopted zone (task 6351b36d) ------------------- # --- the scheduler follows a changed zone (task 6351b36d) --------------------
# #
# On a UTC host with no stored zone the scheduler first plans for UTC # On a UTC host with no stored zone the scheduler first plans for UTC
# midnight; a zone adopted while it sleeps must move the sweep to the # midnight; a zone stored while it sleeps must move the sweep to the
# household's midnight rather than sweep at 20:00 EDT. # household's midnight rather than sweep at 20:00 EDT.
#: 13:00 UTC (09:00 EDT) to UTC midnight. #: 13:00 UTC (09:00 EDT) to UTC midnight.
@@ -468,11 +434,20 @@ _UTC_MIDNIGHT_DELAY = 11 * 3600
_NEW_YORK_MIDNIGHT_DELAY = 15 * 3600 _NEW_YORK_MIDNIGHT_DELAY = 15 * 3600
async def test_scheduler_replans_when_panel_adopts_zone_while_sleeping( 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, temp_db_path: str,
) -> None: ) -> None:
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client: async with runner:
app_state = runner.app.state app_state = runner.app.state
await app_state.sweep_scheduler.stop() await app_state.sweep_scheduler.stop()
delays: list[float] = [] delays: list[float] = []
@@ -494,11 +469,9 @@ async def test_scheduler_replans_when_panel_adopts_zone_while_sleeping(
await asyncio.wait_for( await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 1), timeout=5 slept.wait_for(lambda: len(delays) == 1), timeout=5
) )
response = await client.get( await _store_zone(
"/api/v1/panel/snapshot", scheduler, app_state.db.database, HOUSEHOLD_ZONE
headers=_reporting_panel_headers(HOUSEHOLD_ZONE),
) )
assert response.status_code == 200
async with slept: async with slept:
await asyncio.wait_for( await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 2), timeout=5 slept.wait_for(lambda: len(delays) == 2), timeout=5
@@ -536,8 +509,8 @@ async def test_scheduler_rechecks_settings_before_sweeping(
if len(delays) == 1: if len(delays) == 1:
# The UTC rollover "arrives" after the zone was stored # The UTC rollover "arrives" after the zone was stored
# behind the scheduler's back. # behind the scheduler's back.
await _settings_store.adopt_reported_timezone( await _settings_store.update_settings(
database, HOUSEHOLD_ZONE database, {"timezone": HOUSEHOLD_ZONE}
) )
return return
replanned.set() replanned.set()
@@ -560,12 +533,152 @@ async def test_scheduler_rechecks_settings_before_sweeping(
await database.close() await database.close()
# --- adoption is serialised with planning and sweeping (TZ-003) -------------- # --- the admin settings route is the scheduler's writer (task 7e173881) ------
# #
# An adoption racing the loop's own settings reads must never leave a # ``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 # stale plan behind. Each test holds the loop at one of its reads and
# issues a real panel adoption there; when the scheduler's settings lock # issues a locked zone write there; when the scheduler's settings lock
# is held at that point the adoption provably cannot commit until the # is held at that point the write provably cannot commit until the
# loop moves on, otherwise it is awaited to commit inside the window. # loop moves on, otherwise it is awaited to commit inside the window.
@@ -588,24 +701,12 @@ def _hold_scheduler_read(monkeypatch, scheduler, nth: int, during):
return real_load return real_load
async def _adopt_via(client, zone: str) -> None: async def _store_inside_read(scheduler, database, zone: str) -> asyncio.Task:
response = await client.get( """Start a locked write of ``zone``; let it commit unless serialised."""
"/api/v1/panel/snapshot", headers=_reporting_panel_headers(zone) write = asyncio.create_task(_store_zone(scheduler, database, zone))
) if not scheduler.settings_lock.locked():
assert response.status_code == 200 await write
return write
async def _adopt_inside_read(client, scheduler, zone: str) -> asyncio.Task:
"""Start a panel adoption of ``zone``; let it commit unless serialised."""
adoption = asyncio.create_task(
client.get(
"/api/v1/panel/snapshot", headers=_reporting_panel_headers(zone)
)
)
lock = getattr(scheduler, "settings_lock", None)
if lock is None or not lock.locked():
await adoption
return adoption
async def test_scheduler_follows_successive_zone_changes( async def test_scheduler_follows_successive_zone_changes(
@@ -613,8 +714,9 @@ async def test_scheduler_follows_successive_zone_changes(
) -> None: ) -> None:
"""New York -> UTC -> New York while the re-plan read is in flight.""" """New York -> UTC -> New York while the re-plan read is in flight."""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client: async with runner:
app_state = runner.app.state app_state = runner.app.state
database = app_state.db.database
await app_state.sweep_scheduler.stop() await app_state.sweep_scheduler.stop()
delays: list[float] = [] delays: list[float] = []
slept = asyncio.Condition() slept = asyncio.Condition()
@@ -626,15 +728,15 @@ async def test_scheduler_follows_successive_zone_changes(
await asyncio.Event().wait() await asyncio.Event().wait()
scheduler = MissedSweepScheduler( scheduler = MissedSweepScheduler(
app_state.db.database, clock=lambda: _utc(13, 0), sleep=sleep_stub database, clock=lambda: _utc(13, 0), sleep=sleep_stub
) )
app_state.sweep_scheduler = scheduler app_state.sweep_scheduler = scheduler
await _adopt_via(client, HOUSEHOLD_ZONE) await _store_zone(scheduler, database, HOUSEHOLD_ZONE)
adoptions: list[asyncio.Task] = [] writes: list[asyncio.Task] = []
async def back_to_new_york(_settings) -> None: async def back_to_new_york(_settings) -> None:
adoptions.append( writes.append(
await _adopt_inside_read(client, scheduler, HOUSEHOLD_ZONE) await _store_inside_read(scheduler, database, HOUSEHOLD_ZONE)
) )
# Read 1 plans New York; read 2 is the re-plan after UTC. # Read 1 plans New York; read 2 is the re-plan after UTC.
@@ -645,16 +747,16 @@ async def test_scheduler_follows_successive_zone_changes(
await asyncio.wait_for( await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 1), timeout=5 slept.wait_for(lambda: len(delays) == 1), timeout=5
) )
await _adopt_via(client, "UTC") await _store_zone(scheduler, database, "UTC")
async with slept: async with slept:
await asyncio.wait_for( await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 3), timeout=5 slept.wait_for(lambda: len(delays) == 3), timeout=5
) )
(adoption,) = adoptions (write,) = writes
assert (await adoption).status_code == 200 await write
finally: finally:
await scheduler.stop() await scheduler.stop()
# The in-flight read planned UTC; the New York adoption that followed # The in-flight read planned UTC; the New York write that followed
# it woke the wait, so the scheduler sleeps on New York midnight. # it woke the wait, so the scheduler sleeps on New York midnight.
assert delays == [ assert delays == [
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY), pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
@@ -663,12 +765,12 @@ async def test_scheduler_follows_successive_zone_changes(
] ]
async def test_adoption_after_presweep_check_cannot_precede_the_sweep( async def test_zone_write_after_presweep_check_cannot_precede_the_sweep(
temp_db_path: str, monkeypatch temp_db_path: str, monkeypatch
) -> None: ) -> None:
"""No sweep runs once an adoption has replaced its plan.""" """No sweep runs once a zone write has replaced its plan."""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client: async with runner:
app_state = runner.app.state app_state = runner.app.state
await app_state.sweep_scheduler.stop() await app_state.sweep_scheduler.stop()
delays: list[float] = [] delays: list[float] = []
@@ -685,16 +787,18 @@ async def test_adoption_after_presweep_check_cannot_precede_the_sweep(
app_state.db.database, clock=lambda: _utc(13, 0), sleep=sleep_stub app_state.db.database, clock=lambda: _utc(13, 0), sleep=sleep_stub
) )
app_state.sweep_scheduler = scheduler app_state.sweep_scheduler = scheduler
adoptions: list[asyncio.Task] = [] writes: list[asyncio.Task] = []
async def adopt_new_york(_settings) -> None: async def store_new_york(_settings) -> None:
adoptions.append( writes.append(
await _adopt_inside_read(client, scheduler, HOUSEHOLD_ZONE) await _store_inside_read(
scheduler, app_state.db.database, HOUSEHOLD_ZONE
)
) )
# Read 1 plans UTC; read 2 is the pre-sweep re-check. # Read 1 plans UTC; read 2 is the pre-sweep re-check.
real_load = _hold_scheduler_read( real_load = _hold_scheduler_read(
monkeypatch, scheduler, 2, adopt_new_york monkeypatch, scheduler, 2, store_new_york
) )
swept_under: list[str] = [] swept_under: list[str] = []
@@ -708,12 +812,12 @@ async def test_adoption_after_presweep_check_cannot_precede_the_sweep(
scheduler.start() scheduler.start()
try: try:
await asyncio.wait_for(replanned.wait(), timeout=5) await asyncio.wait_for(replanned.wait(), timeout=5)
(adoption,) = adoptions (write,) = writes
assert (await adoption).status_code == 200 await write
finally: finally:
await scheduler.stop() await scheduler.stop()
# The sweep ran for the UTC plan with UTC still stored, never after # The sweep ran for the UTC plan with UTC still stored, never after
# New York had been adopted; the next plan is New York midnight. # New York had been stored; the next plan is New York midnight.
assert swept_under == [""] assert swept_under == [""]
assert delays == [ assert delays == [
pytest.approx(_UTC_MIDNIGHT_DELAY), pytest.approx(_UTC_MIDNIGHT_DELAY),
+1
View File
@@ -60,6 +60,7 @@ def _payload(children) -> dict:
return { return {
"today_iso": "2026-09-23", "today_iso": "2026-09-23",
"cycle_day": 0, "cycle_day": 0,
"timezone": "",
"children": list(children), "children": list(children),
} }
+123
View File
@@ -0,0 +1,123 @@
"""Behavior and wiring tests for the UniFi quest-access blueprint."""
from __future__ import annotations
from datetime import datetime
import pytest
from tests.blueprint_helpers import BlueprintInput, blueprint, render
def _blueprint() -> dict:
return blueprint("unifi_quest_access")
def test_inputs_and_reconciliation_triggers() -> None:
bp = _blueprint()
inputs = bp["blueprint"]["input"]
assert set(inputs) == {
"remaining_sensor",
"present_sensor",
"client_switches",
"weekday_cutoff",
"weekend_cutoff",
}
assert inputs["weekday_cutoff"]["default"] == "18:25:00"
assert inputs["weekend_cutoff"]["default"] == "14:00:00"
assert inputs["remaining_sensor"]["selector"]["entity"]["filter"] == [
{"integration": "nestquest", "domain": "sensor"}
]
assert inputs["present_sensor"]["selector"]["entity"]["filter"] == [
{"integration": "nestquest", "domain": "binary_sensor"}
]
assert inputs["client_switches"]["selector"]["entity"] == {
"multiple": True,
"filter": [{"integration": "unifi", "domain": "switch"}],
}
assert bp["variables"]["remaining_sensor"] == BlueprintInput(
"remaining_sensor"
)
assert bp["variables"]["present_sensor"] == BlueprintInput(
"present_sensor"
)
assert bp["variables"]["client_switches"] == BlueprintInput(
"client_switches"
)
assert bp["mode"] == "restart"
triggers = bp["triggers"]
assert {trigger["trigger"] for trigger in triggers} == {
"state",
"time",
"homeassistant",
"time_pattern",
}
assert {trigger["at"] for trigger in triggers if trigger["trigger"] == "time"} == {
BlueprintInput("weekday_cutoff"),
BlueprintInput("weekend_cutoff"),
"00:00:00",
}
assert any(
trigger.get("entity_id") == BlueprintInput("remaining_sensor")
for trigger in triggers
)
assert any(
trigger.get("entity_id") == BlueprintInput("present_sensor")
for trigger in triggers
)
assert any(
trigger.get("event") == "start" for trigger in triggers
)
assert any(
trigger.get("minutes") == "/5" for trigger in triggers
)
@pytest.mark.parametrize(
("when", "remaining", "presence", "should_block"),
[
(datetime(2026, 10, 19, 18, 24), "2", "on", False), # Monday, pre-cutoff
(datetime(2026, 10, 19, 18, 25), "2", "on", True),
(datetime(2026, 10, 19, 19, 0), "0", "on", False),
(datetime(2026, 10, 19, 19, 0), "unknown", "on", True),
(datetime(2026, 10, 19, 19, 0), "2", "off", False), # On travels
(datetime(2026, 10, 19, 19, 0), "2", "unavailable", False),
(datetime(2026, 10, 17, 13, 59), "1", "on", False), # Saturday
(datetime(2026, 10, 17, 14, 0), "1", "on", True),
(datetime(2026, 10, 18, 14, 0), "0", "on", False), # Sunday
(datetime(2026, 10, 20, 0, 0), "1", "on", False), # no carry-over
],
)
def test_access_decision(
when: datetime, remaining: str, presence: str, should_block: bool
) -> None:
decision = _blueprint()["actions"][0]["variables"]["should_block"]
actual = render(
decision,
now=lambda: when,
states=lambda entity_id: remaining,
is_state=lambda entity_id, state: presence == state,
remaining_sensor="sensor.selected_child",
present_sensor="binary_sensor.selected_child_present_today",
weekday_cutoff="18:25:00",
weekend_cutoff="14:00:00",
).strip()
assert actual == str(should_block)
def test_only_out_of_sync_clients_receive_commands() -> None:
bp = _blueprint()
loop = bp["actions"][1]["repeat"]
assert loop["for_each"] == "{{ client_switches }}"
choices = loop["sequence"][0]["choose"]
assert len(choices) == 2
block, allow = choices
assert "is_state(repeat.item, 'on')" in block["conditions"][0]["value_template"]
assert block["sequence"][0]["action"] == "switch.turn_off"
assert "is_state(repeat.item, 'off')" in allow["conditions"][0]["value_template"]
assert allow["sequence"][0]["action"] == "switch.turn_on"
assert all(
choice["sequence"][0]["target"]["entity_id"] == "{{ repeat.item }}"
for choice in choices
)
+55
View File
@@ -0,0 +1,55 @@
"""The integration's local brand images (Home Assistant 2026.3+).
``custom_components/nestquest/brand/`` ships through HACS with the
integration, and Home Assistant serves those images ahead of the brands CDN.
They are generated offline from ``design/logos/NestQuest-NoBG.png`` by
``design/logos/generate-brand-icons.mjs``; these checks fail if the directory
goes missing, changes shape, or drifts from a fresh generation.
"""
from __future__ import annotations
import shutil
import struct
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).parent.parent
BRAND_DIR = REPO_ROOT / "custom_components" / "nestquest" / "brand"
GENERATOR = REPO_ROOT / "design" / "logos" / "generate-brand-icons.mjs"
EXPECTED = {
"icon.png": 256,
"icon@2x.png": 512,
"logo.png": 256,
"logo@2x.png": 512,
}
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
def test_brand_dir_holds_exactly_the_four_images() -> None:
assert sorted(p.name for p in BRAND_DIR.iterdir()) == sorted(EXPECTED)
@pytest.mark.parametrize(("name", "size"), sorted(EXPECTED.items()))
def test_brand_image_is_square_rgba_png(name: str, size: int) -> None:
data = (BRAND_DIR / name).read_bytes()
assert data[:8] == PNG_SIGNATURE
assert data[12:16] == b"IHDR"
width, height, depth, colour = struct.unpack(">IIBB", data[16:26])
assert (width, height) == (size, size)
assert (depth, colour) == (8, 6) # 8-bit RGBA keeps the transparency
def test_generator_reproduces_the_committed_images() -> None:
node = shutil.which("node")
if node is None:
pytest.skip("needs node")
result = subprocess.run(
[node, str(GENERATOR), "--check"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
+1
View File
@@ -55,6 +55,7 @@ def _empty_snapshot() -> dict:
return { return {
"today_iso": "2026-09-23", "today_iso": "2026-09-23",
"cycle_day": 0, "cycle_day": 0,
"timezone": "",
"children": [], "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: 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 The helper's zone is UTC-8, so 11:00 local is 19:00 UTC. Converting
to UTC before comparing would mark this late. to UTC before comparing would mark this late.
+33 -3
View File
@@ -93,6 +93,7 @@ class FakeMonotonic:
FIXTURE_SNAPSHOT = { FIXTURE_SNAPSHOT = {
"today_iso": _today_iso(), "today_iso": _today_iso(),
"cycle_day": 3, "cycle_day": 3,
"timezone": "",
"children": [ "children": [
{ {
"child_id": 1, "child_id": 1,
@@ -686,8 +687,8 @@ async def test_coordinator_configured_token_builds_real_client(
hass, entry, session_factory=_session_factory hass, entry, session_factory=_session_factory
) )
assert isinstance(real_client, NestQuestApiClient) assert isinstance(real_client, NestQuestApiClient)
# Home Assistant's zone is reported to the API (task 6351b36d). # Home Assistant's zone is not reported to the API (task 7f685179).
assert real_client._household_timezone == hass.config.time_zone assert real_client._headers() == {"Authorization": "Bearer tok"}
set_coordinator_client(entry, real_client) set_coordinator_client(entry, real_client)
assert await async_setup_entry(hass, entry) is True assert await async_setup_entry(hass, entry) is True
@@ -793,8 +794,37 @@ async def test_snapshot_from_api_payload_empty_household() -> None:
"""An empty household (no children) is a valid snapshot: empty """An empty household (no children) is a valid snapshot: empty
children tuple, cycle day passes through.""" children tuple, cycle day passes through."""
snapshot = snapshot_from_api_payload( 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.children == ()
assert snapshot.cycle_day == 0 assert snapshot.cycle_day == 0
assert snapshot.today_iso == "2026-09-23" 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 == ""
+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); # Single clock read for the seed/snapshot anchor (no midnight skew);
# the coordinator no longer owns a clock read (the API serves the # 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) time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date() today = datetime.datetime.now(time_zone).date()
# Pin the build instant at 12:00 local: past Ada's 10:00 due time # 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 { return {
"today_iso": built.today_iso, "today_iso": built.today_iso,
"cycle_day": built.cycle_day, "cycle_day": built.cycle_day,
"timezone": built.timezone,
"children": [ "children": [
{ {
"child_id": child.child_id, "child_id": child.child_id,
+1
View File
@@ -67,6 +67,7 @@ def _payload(children) -> dict:
return { return {
"today_iso": "2026-09-23", "today_iso": "2026-09-23",
"cycle_day": 0, "cycle_day": 0,
"timezone": "",
"children": list(children), "children": list(children),
} }
+1
View File
@@ -76,6 +76,7 @@ def _payload(children, cycle_day=0) -> dict:
return { return {
"today_iso": "2026-09-23", "today_iso": "2026-09-23",
"cycle_day": cycle_day, "cycle_day": cycle_day,
"timezone": "",
"children": list(children), "children": list(children),
} }
+1
View File
@@ -70,6 +70,7 @@ def _payload(children) -> dict:
return { return {
"today_iso": "2026-09-23", "today_iso": "2026-09-23",
"cycle_day": 0, "cycle_day": 0,
"timezone": "",
"children": list(children), "children": list(children),
} }
+69
View File
@@ -63,6 +63,75 @@ def test_artefacts_carry_exactly_the_curated_names_and_labels() -> None:
assert 15 <= len(curated["fa"]) <= 25 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: def test_registry_has_no_remote_url_cdn_or_lucide_runtime() -> None:
for path in (SOURCE, GENERATOR, *ARTEFACTS): for path in (SOURCE, GENERATOR, *ARTEFACTS):
text = path.read_text(encoding="utf-8") text = path.read_text(encoding="utf-8")
+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: 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 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 ``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 a pinned household-local date the effective start is the host's
day after ha_today), so the now-past day is skipped rather than today (one day after ha_today), so the now-past day is skipped
rejected, and only the host-today-onward days materialize. rather than rejected, and only the host-today-onward days
materialize.
""" """
async def _body(database): async def _body(database):
children = ChildrenDao(database) children = ChildrenDao(database)
@@ -963,9 +964,9 @@ def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None:
["morning"], ["morning"],
) )
# Without a pinned HA-local today, the walk clamps its start to the # Without a pinned household-local today, the walk clamps its start
# host's today (one day after ha_today); the past day is dropped and # to the host's today (one day after ha_today); the past day is
# the 14 host-today-onward days materialize. # dropped and the 14 host-today-onward days materialize.
without_pin = await materialize(database, start_iso, end_iso) without_pin = await materialize(database, start_iso, end_iso)
assert without_pin == 14 assert without_pin == 14
+189 -3
View File
@@ -21,6 +21,7 @@ end-to-end through the quest-log harness.
from __future__ import annotations from __future__ import annotations
import json import json
import os
import re import re
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -566,6 +567,8 @@ def _render_party_board(
states: dict, states: dict,
url: str | None = None, url: str | None = None,
click_plate: str | None = None, click_plate: str | None = None,
now: str | None = None,
tz: str | None = None,
) -> dict: ) -> dict:
"""Render the built bundle's party board and return its plates. """Render the built bundle's party board and return its plates.
@@ -574,7 +577,9 @@ def _render_party_board(
mounts the card with the given config and hass snapshot, optionally mounts the card with the given config and hass snapshot, optionally
taps the named plate, and prints the ordered plates its shadow DOM taps the named plate, and prints the ordered plates its shadow DOM
renders, which of them are tappable buttons, and the pathname after 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 BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
assert RENDER_HARNESS.is_file() assert RENDER_HARNESS.is_file()
@@ -588,12 +593,15 @@ def _render_party_board(
spec["url"] = url spec["url"] = url
if click_plate is not None: if click_plate is not None:
spec["click_plate"] = click_plate spec["click_plate"] = click_plate
if now is not None:
spec["now"] = now
completed = subprocess.run( completed = subprocess.run(
["node", str(RENDER_HARNESS)], ["node", str(RENDER_HARNESS)],
input=json.dumps(spec), input=json.dumps(spec),
capture_output=True, capture_output=True,
text=True, text=True,
timeout=120, timeout=120,
env={**os.environ, "TZ": tz} if tz is not None else None,
) )
assert completed.returncode == 0, ( assert completed.returncode == 0, (
f"render harness failed: {completed.stderr}" f"render harness failed: {completed.stderr}"
@@ -831,6 +839,9 @@ def _render_quest_log(
probe_ms: int | None = None, probe_ms: int | None = None,
tap_complete: int | None = None, tap_complete: int | None = None,
tap_return: bool = False, tap_return: bool = False,
now: str | None = None,
advance_to: list[str] | None = None,
tz: str | None = None,
) -> dict: ) -> dict:
"""Render the built bundle's quest log and report its location. """Render the built bundle's quest log and report its location.
@@ -840,7 +851,10 @@ def _render_quest_log(
segment — waits the optional probe window and captures one snapshot segment — waits the optional probe window and captures one snapshot
of the render mid-wait, then the optional idle window, and prints of the render mid-wait, then the optional idle window, and prints
the final pathname and rendered title (plus the probe snapshot when 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 BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
assert QUEST_LOG_HARNESS.is_file() assert QUEST_LOG_HARNESS.is_file()
@@ -858,12 +872,17 @@ def _render_quest_log(
spec["tap_complete"] = tap_complete spec["tap_complete"] = tap_complete
if tap_return: if tap_return:
spec["tap_return"] = True 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( completed = subprocess.run(
["node", str(QUEST_LOG_HARNESS)], ["node", str(QUEST_LOG_HARNESS)],
input=json.dumps(spec), input=json.dumps(spec),
capture_output=True, capture_output=True,
text=True, text=True,
timeout=120, timeout=120,
env={**os.environ, "TZ": tz} if tz is not None else None,
) )
assert completed.returncode == 0, ( assert completed.returncode == 0, (
f"quest-log harness failed: {completed.stderr}" f"quest-log harness failed: {completed.stderr}"
@@ -1090,7 +1109,7 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
``meta ${late ? "late" : nothing}``, Lit dropped the whole class ``meta ${late ? "late" : nothing}``, Lit dropped the whole class
attribute for every quest that was not late, leaving the due line attribute for every quest that was not late, leaving the due line
unstyled. The rendered markup must carry ``class="meta"`` on time and unstyled. The rendered markup must carry ``class="meta"`` on time and
``class="meta late"`` overdue.""" ``class="meta late"`` overdue — overdue as the API snapshot says."""
generated = _generate_dashboard( generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest" {}, _three_child_states(), url="http://homeassistant.local/nestquest"
) )
@@ -1143,6 +1162,153 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
assert metas["42"].strip() == 'class="meta late"', metas["42"] 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: def test_quest_log_crest_matches_the_spec_geometry() -> None:
"""PANEL-SPEC §3 header crest: 96x110 shield, 4px ring of """PANEL-SPEC §3 header crest: 96x110 shield, 4px ring of
rgba(255,255,255,.22) around the --nq-p-crest face, 44px/700 display rgba(255,255,255,.22) around the --nq-p-crest face, 44px/700 display
@@ -1265,6 +1431,26 @@ def test_open_quest_tile_falls_back_to_the_star() -> None:
assert tile["icon"] is None 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: def test_sealed_quest_tile_keeps_the_star() -> None:
"""A sealed card keeps its star tile (and its wax seal) whatever the """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.""" definition's icon; only the open card shows the quest's icon."""
+17
View File
@@ -76,6 +76,7 @@ def _payload(children) -> dict:
return { return {
"today_iso": _today_iso(), "today_iso": _today_iso(),
"cycle_day": 0, "cycle_day": 0,
"timezone": "",
"children": list(children), "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( async def test_household_roster_tracks_the_snapshot_on_refresh(
hass, make_entry hass, make_entry
) -> None: ) -> None:
+26 -25
View File
@@ -44,6 +44,10 @@ from custom_components.nestquest.api_client import (
) )
from custom_components.nestquest.sse import NestQuestEventStream from custom_components.nestquest.sse import NestQuestEventStream
# ``stream_events`` imports aiohttp lazily; the harness has none, so
# every test here runs with the stand-in module installed.
pytestmark = pytest.mark.usefixtures("fake_aiohttp")
BASE_URL = "http://api.test:8000" BASE_URL = "http://api.test:8000"
TOKEN = "test-panel-token" TOKEN = "test-panel-token"
@@ -151,7 +155,12 @@ DOCUMENTED_FIELDS = {
#: A minimal panel snapshot payload for the full-setup wiring tests #: A minimal panel snapshot payload for the full-setup wiring tests
#: (the shape ``snapshot_from_api_payload`` consumes; no children). #: (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": [],
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -160,19 +169,29 @@ MINIMAL_SNAPSHOT = {"today_iso": "2026-09-23", "cycle_day": 1, "children": []}
class _StreamingResponse: class _StreamingResponse:
"""A fake aiohttp streaming response: ``status`` + chunk iteration. """A fake aiohttp streaming response: ``status`` + ``.content``.
``chunks`` are the body pieces the stream yields in order; a chunk Like aiohttp's ``ClientResponse`` (and HA's ``HassClientResponse``)
may be an exception, raised when the iteration reaches it (a the response itself is NOT async-iterable; the body is iterated
mid-stream transport drop). ``hold_open`` keeps the stream open through ``.content``. ``chunks`` are the body pieces the stream
forever after the chunks run out, for cancellation tests. yields in order; a chunk may be an exception, raised when the
iteration reaches it (a mid-stream transport drop). ``hold_open``
keeps the stream open forever after the chunks run out, for
cancellation tests.
""" """
def __init__(self, status: int, chunks, *, hold_open: bool = False) -> None: def __init__(self, status: int, chunks, *, hold_open: bool = False) -> None:
self.status = status self.status = status
self.content = _StreamingContent(chunks, hold_open)
self.closed = False
class _StreamingContent:
"""The fake response body: an async-iterable chunk stream."""
def __init__(self, chunks, hold_open: bool) -> None:
self._chunks = list(chunks) self._chunks = list(chunks)
self._hold_open = hold_open self._hold_open = hold_open
self.closed = False
def __aiter__(self): def __aiter__(self):
return self._iterate() return self._iterate()
@@ -351,24 +370,6 @@ async def test_stream_events_yields_documented_frames_and_sends_bearer() -> None
assert call["json"] is None assert call["json"] is None
async def test_stream_events_reports_household_timezone() -> None:
"""The SSE GET carries Home Assistant's zone beside the Bearer token
(task 6351b36d), like the snapshot and completion requests."""
frame = _frame_text(*DOCUMENTED_FRAMES[0])
transport = _StreamTransport(_StreamingResponse(200, [frame]))
client = NestQuestApiClient(
BASE_URL, TOKEN, transport, household_timezone="America/New_York"
)
frames = [frame async for frame in client.stream_events()]
assert frames == [DOCUMENTED_FRAMES[0]]
assert transport.calls[0]["headers"] == {
"Authorization": f"Bearer {TOKEN}",
"X-NestQuest-Timezone": "America/New_York",
}
async def test_stream_events_buffers_lines_across_chunks_and_joins_data() -> None: async def test_stream_events_buffers_lines_across_chunks_and_joins_data() -> None:
"""Chunk-split lines parse, and multi-line data joins per SSE rules.""" """Chunk-split lines parse, and multi-line data joins per SSE rules."""
first_type, first_payload = DOCUMENTED_FRAMES[0] first_type, first_payload = DOCUMENTED_FRAMES[0]
+60 -10
View File
@@ -2,9 +2,10 @@
* Offline generator for the shared quest-icon registry. * Offline generator for the shared quest-icon registry.
* *
* Reads the curated names + labels in `curated-icons.json` (the one source of * Reads the curated names + labels in `curated-icons.json` (the one source of
* truth), pulls each glyph's SVG path data from locally installed packages in * truth for the quick-pick lists), pulls each glyph's SVG path data from
* `admin/node_modules` (lucide-react and @fortawesome/free-solid-svg-icons — * locally installed packages in `admin/node_modules` (lucide-react and
* no network), and writes the same TypeScript module into both bundles: * @fortawesome/free-solid-svg-icons — no network), and writes the same
* TypeScript module into both bundles:
* *
* admin/src/icons/registry.generated.ts * admin/src/icons/registry.generated.ts
* frontend/src/icons/registry.generated.ts * frontend/src/icons/registry.generated.ts
@@ -14,6 +15,16 @@
* *
* Lucide circles, rects and lines are converted to path `d` strings so every * Lucide circles, rects and lines are converted to path `d` strings so every
* glyph is plain `{ viewBox, paths }` data any renderer can draw. * glyph is plain `{ viewBox, paths }` data any renderer can draw.
*
* Besides the curated sets (the Lucide set and the Font Awesome quick picks),
* the module embeds the complete Font Awesome Free Solid set (`FA_SOLID_ICONS`),
* which is what `fa:<name>` resolves against. 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"). Embedding the full set
* makes both bundles heavier: the panel bundle is about 1 MB raw / 276 kB gzip.
*/ */
import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
@@ -89,6 +100,37 @@ function faExportName(name) {
return "fa" + name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(""); 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) { function checkCurated(list, namespace) {
const seen = new Set(); const seen = new Set();
for (const { name, label } of list) { for (const { name, label } of list) {
@@ -116,11 +158,9 @@ async function buildEntries() {
for (const { name, label } of curated.fa) { for (const { name, label } of curated.fa) {
const definition = faPackage[faExportName(name)]; const definition = faPackage[faExportName(name)];
if (!definition || definition.iconName !== name) throw new Error(`fa ${name}: not in @fortawesome/free-solid-svg-icons`); if (!definition || definition.iconName !== name) throw new Error(`fa ${name}: not in @fortawesome/free-solid-svg-icons`);
const [width, height, , , pathData] = definition.icon; fa.push(faGlyph(definition, label));
const paths = Array.isArray(pathData) ? pathData.filter(Boolean) : [pathData];
fa.push({ kind: "fa", key: `fa:${name}`, name, label, viewBox: `0 0 ${width} ${height}`, paths });
} }
return { lucide, fa }; return { lucide, fa, faSolid: allSolidGlyphs(faPackage) };
} }
const RESOLVER = ` const RESOLVER = `
@@ -130,7 +170,10 @@ export const FALLBACK_ICON: FallbackIcon = { kind: "fallback", key: null };
export const EMOJI_MAX_CODE_POINTS = 8; export const EMOJI_MAX_CODE_POINTS = 8;
const LUCIDE_BY_NAME: ReadonlyMap<string, GlyphIcon> = new Map(LUCIDE_ICONS.map((icon) => [icon.name, icon])); const LUCIDE_BY_NAME: ReadonlyMap<string, GlyphIcon> = new Map(LUCIDE_ICONS.map((icon) => [icon.name, icon]));
const FA_BY_NAME: ReadonlyMap<string, GlyphIcon> = new Map(FA_ICONS.map((icon) => [icon.name, icon])); /** 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 { function isEmojiPayload(text: string): boolean {
const codePoints = [...text]; const codePoints = [...text];
@@ -142,7 +185,8 @@ function isEmojiPayload(text: string): boolean {
* The renderable icon for a stored definition \`icon\` value. * The renderable icon for a stored definition \`icon\` value.
* *
* \`lucide:<name>\` and a legacy bare \`<name>\` resolve against the curated * \`lucide:<name>\` and a legacy bare \`<name>\` resolve against the curated
* Lucide set, \`fa:<name>\` against the curated Font Awesome set, and * 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 * \`emoji:<grapheme>\` to its text. Like the backend's
* normalize_icon_for_read, nothing here throws: null, empty, an unknown * normalize_icon_for_read, nothing here throws: null, empty, an unknown
* namespace or name, or a malformed emoji all resolve to FALLBACK_ICON. * namespace or name, or a malformed emoji all resolve to FALLBACK_ICON.
@@ -162,7 +206,7 @@ export function resolveIcon(value: string | null | undefined): ResolvedIcon {
/** The generated module's full text (identical for both bundles). */ /** The generated module's full text (identical for both bundles). */
export async function renderRegistry() { export async function renderRegistry() {
const { lucide, fa } = await buildEntries(); const { lucide, fa, faSolid } = await buildEntries();
const source = relative(REPO_ROOT, SOURCE_PATH); const source = relative(REPO_ROOT, SOURCE_PATH);
return `// GENERATED by tools/icons/generate.mjs from ${source} — do not edit. 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 // Regenerate with \`node tools/icons/generate.mjs\` (the drift tests fail if this
@@ -202,7 +246,13 @@ export type ResolvedIcon = GlyphIcon | EmojiIcon | FallbackIcon;
export const LUCIDE_ICONS: readonly GlyphIcon[] = ${JSON.stringify(lucide, null, 2)}; 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)}; 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}`; ${RESOLVER}`;
} }
Generated
+1 -1
View File
@@ -373,7 +373,7 @@ wheels = [
[[package]] [[package]]
name = "nestquest" name = "nestquest"
version = "0.9.5" version = "0.10.3"
source = { virtual = "." } source = { virtual = "." }
[package.dev-dependencies] [package.dev-dependencies]