Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf118cf305 | ||
|
|
bcf387d902 | ||
|
|
09d6a55e04 |
@@ -1,7 +1,3 @@
|
|||||||
<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.
|
||||||
@@ -73,12 +69,12 @@ actions:
|
|||||||
|
|
||||||
## Automation Blueprints
|
## Automation Blueprints
|
||||||
|
|
||||||
NestQuest ships five automation blueprints in
|
NestQuest ships four 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 automations from the blueprints, filling the inputs with your
|
then create one automation per blueprint, filling the inputs with your
|
||||||
household's choices. The UniFi blueprint needs one automation per child.
|
household's choices.
|
||||||
|
|
||||||
### Wiring to the integration's notification settings
|
### Wiring to the integration's notification settings
|
||||||
|
|
||||||
@@ -100,7 +96,7 @@ The end-of-day report deliberately runs BEFORE the midnight missed
|
|||||||
sweep and reads the same day's data — do not schedule it at the
|
sweep and reads the same day's data — do not schedule it at the
|
||||||
day-rollover time.
|
day-rollover time.
|
||||||
|
|
||||||
### The five blueprints
|
### The four 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`
|
||||||
@@ -128,36 +124,10 @@ 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
|
||||||
# Create automations from blueprints — Settings → Automations & Scenes →
|
# One automation per blueprint — 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
|
||||||
|
|||||||
@@ -1,320 +1,5 @@
|
|||||||
# 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
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Third-party notices
|
# Third-party notices
|
||||||
|
|
||||||
NestQuest bundles glyph data from the icon sets below. The curated Lucide names and the
|
NestQuest bundles glyph data from the icon sets below. The curated names are listed in
|
||||||
Font Awesome quick-pick names are listed in `tools/icons/curated-icons.json`;
|
`tools/icons/curated-icons.json`; `tools/icons/generate.mjs` reads each glyph's SVG path
|
||||||
`tools/icons/generate.mjs` reads each glyph's SVG path data from the locally installed packages and generates it into both bundles
|
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: the full Free **solid** icon set, embedded as SVG path data (a curated
|
- Use in NestQuest: a curated subset of the Free **solid** icon set, embedded as SVG path
|
||||||
subset doubles as the admin picker's quick picks). No fonts are bundled.
|
data. 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.
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 265 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 8.2 KiB |
@@ -1,12 +1,14 @@
|
|||||||
// Generates the committed PWA icons under public/icons/ from the brand logo
|
// Generates the committed PWA icons under public/icons/ as genuine PNGs.
|
||||||
// (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 { readFileSync, writeFileSync } from "node:fs";
|
import { writeFileSync } from "node:fs";
|
||||||
import { deflateSync, inflateSync } from "node:zlib";
|
import { deflateSync } from "node:zlib";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
// Brand tokens (design/tokens/nestquest-admin-tokens.css).
|
||||||
|
const BLUE = [0x3b, 0x3a, 0xb8];
|
||||||
|
const PURPLE = [0xa0, 0x35, 0xcc];
|
||||||
|
const WHITE = [0xff, 0xff, 0xff];
|
||||||
|
|
||||||
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
||||||
let c = n;
|
let c = n;
|
||||||
@@ -42,129 +44,37 @@ 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([
|
||||||
SIG,
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||||
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)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimal decoder for the source logo: 8-bit RGB, non-interlaced only.
|
// Flat brand mark: diagonal blue→purple gradient with a white ring ("nest")
|
||||||
// Anything else fails loudly rather than producing a wrong icon.
|
// around a white dot ("quest"). `scale` shrinks the mark for the maskable
|
||||||
function decodePng(buf) {
|
// safe zone (inner 80% circle).
|
||||||
if (!buf.subarray(0, 8).equals(SIG)) throw new Error("not a PNG");
|
function brandMark(size, scale) {
|
||||||
let width, height;
|
const c = size / 2;
|
||||||
const idat = [];
|
const outer = size * 0.34 * scale;
|
||||||
for (let off = 8; off < buf.length; ) {
|
const inner = size * 0.24 * scale;
|
||||||
const len = buf.readUInt32BE(off);
|
const dot = size * 0.1 * scale;
|
||||||
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 ix = x - offset, iy = y - offset;
|
const d = Math.hypot(x + 0.5 - c, y + 0.5 - c);
|
||||||
return ix >= 0 && iy >= 0 && ix < inner && iy < inner ? pixel(ix, iy) : bg;
|
if ((d <= outer && d >= inner) || d <= dot) return WHITE;
|
||||||
|
const t = (x + y) / (2 * (size - 1));
|
||||||
|
return BLUE.map((b, i) => Math.round(b + (PURPLE[i] - b) * t));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const 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.8],
|
["icon-maskable-512.png", 512, 0.75],
|
||||||
];
|
];
|
||||||
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, logoIcon(logo, size, scale)));
|
writeFileSync(path, encodePng(size, brandMark(size, scale)));
|
||||||
console.log(`wrote ${path}`);
|
console.log(`wrote ${path}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,29 +2,10 @@ 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(() => {
|
||||||
settingsResponse = () =>
|
// The Today tab fetches the admin snapshot; keep the shell tests offline.
|
||||||
Promise.resolve(jsonResponse({ timezone: "", effective_timezone: "Europe/London" }));
|
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
|
||||||
// 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(() => {
|
||||||
@@ -41,10 +22,10 @@ describe("NestQuest Admin shell", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts on the Today screen and switches on tab click", async () => {
|
it("starts on the Today screen and switches on tab click", () => {
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
expect(await screen.findByRole("heading", { name: "Today" })).toBeTruthy();
|
expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "History" }));
|
fireEvent.click(screen.getByRole("tab", { name: "History" }));
|
||||||
|
|
||||||
@@ -55,36 +36,12 @@ describe("NestQuest Admin shell", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the Tasks tab renders the definitions screen", async () => {
|
it("the Tasks tab renders the definitions screen", () => {
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Tasks" }));
|
fireEvent.click(screen.getByRole("tab", { name: "Tasks" }));
|
||||||
|
|
||||||
expect(await screen.findByRole("heading", { name: "Tasks" })).toBeTruthy();
|
expect(screen.getByRole("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,7 +4,6 @@ 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 = [
|
||||||
@@ -33,18 +32,15 @@ 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. */}
|
{active === "today" ? (
|
||||||
<AppTimezoneProvider>
|
<TodayTab />
|
||||||
{active === "today" ? (
|
) : active === "tasks" ? (
|
||||||
<TodayTab />
|
<DefinitionsTab />
|
||||||
) : active === "tasks" ? (
|
) : active === "schedule" ? (
|
||||||
<DefinitionsTab />
|
<ScheduleTab />
|
||||||
) : active === "schedule" ? (
|
) : (
|
||||||
<ScheduleTab />
|
<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) => (
|
||||||
|
|||||||
@@ -20,14 +20,15 @@ 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;
|
||||||
/**
|
/**
|
||||||
* Read-only: the zone the API reads its clock in — `timezone`, or the API
|
* True once the admin explicitly chose `timezone` (even ""); false on a
|
||||||
* host's own zone when that is "" ("" only when the host's zone has no name).
|
* fresh install, where the screen auto-sets the browser's zone. API-only:
|
||||||
|
* never shown in the form.
|
||||||
*/
|
*/
|
||||||
effective_timezone: string;
|
timezone_configured: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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<Omit<HouseholdSettings, "effective_timezone">>;
|
export type SettingsChanges = Partial<HouseholdSettings>;
|
||||||
|
|
||||||
export const SETTINGS_PATH = "/api/v1/admin/settings";
|
export const SETTINGS_PATH = "/api/v1/admin/settings";
|
||||||
|
|
||||||
|
|||||||
@@ -614,34 +614,3 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, 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,14 +7,11 @@ 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 {
|
||||||
@@ -1355,7 +1352,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 quick picks, each drawn as a filled glyph", () => {
|
it("ships the curated Font Awesome subset, 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");
|
||||||
@@ -1531,245 +1528,6 @@ 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", () => {
|
||||||
@@ -1965,57 +1723,3 @@ 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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type KeyboardEvent as ReactKeyboardEvent,
|
type KeyboardEvent as ReactKeyboardEvent,
|
||||||
@@ -54,17 +53,10 @@ 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. */
|
||||||
@@ -92,9 +84,6 @@ 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;
|
||||||
|
|
||||||
@@ -109,11 +98,6 @@ 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;
|
||||||
@@ -137,47 +121,50 @@ 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: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Draft defaults take `today` ("YYYY-MM-DD") in the application timezone. */
|
function newDraft(activeChildren: AdminChild[]): Draft {
|
||||||
function newDraft(activeChildren: AdminChild[], today: string): Draft {
|
const today = new Date();
|
||||||
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: [weekdayOf(today)],
|
weekdays: [(today.getDay() + 6) % 7],
|
||||||
dayOfMonth: String(day),
|
dayOfMonth: String(today.getDate()),
|
||||||
month: String(month),
|
month: String(today.getMonth() + 1),
|
||||||
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: today,
|
startDate: localIsoDate(today),
|
||||||
endDate: null,
|
endDate: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[], today: string): Draft {
|
function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): Draft {
|
||||||
const { rule } = definition;
|
const { rule } = definition;
|
||||||
const [, month, day] = today.split("-").map(Number);
|
const today = new Date();
|
||||||
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],
|
||||||
@@ -185,9 +172,9 @@ function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[], to
|
|||||||
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 ?? [])]
|
||||||
: [weekdayOf(today)],
|
: [(today.getDay() + 6) % 7],
|
||||||
dayOfMonth: String(rule.day_of_month ?? day),
|
dayOfMonth: String(rule.day_of_month ?? today.getDate()),
|
||||||
month: String(rule.month ?? month),
|
month: String(rule.month ?? today.getMonth() + 1),
|
||||||
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 }
|
||||||
@@ -267,7 +254,6 @@ 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(
|
||||||
@@ -479,11 +465,9 @@ function EditSheet({
|
|||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
onDeleted: (result: DefinitionDeleteResult) => void;
|
onDeleted: (result: DefinitionDeleteResult) => void;
|
||||||
}) {
|
}) {
|
||||||
const timeZone = useAppTimezone();
|
const [draft, setDraft] = useState<Draft>(() =>
|
||||||
const [draft, setDraft] = useState<Draft>(() => {
|
target === "new" ? newDraft(activeChildren) : draftFrom(target, activeChildren),
|
||||||
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.
|
||||||
@@ -497,11 +481,6 @@ 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);
|
||||||
@@ -642,10 +621,12 @@ function EditSheet({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={noIcon}
|
aria-checked={draft.icon === null && !draft.emoji}
|
||||||
aria-label="No icon"
|
aria-label="No icon"
|
||||||
className={noIcon ? "defs-icon defs-icon--on" : "defs-icon"}
|
className={
|
||||||
onClick={() => update({ icon: null, emoji: "", faText: "" })}
|
draft.icon === null && !draft.emoji ? "defs-icon defs-icon--on" : "defs-icon"
|
||||||
|
}
|
||||||
|
onClick={() => update({ icon: null, emoji: "" })}
|
||||||
>
|
>
|
||||||
<TaskGlyph size={18} />
|
<TaskGlyph size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -661,7 +642,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: "", faText: "" })}
|
onClick={() => update({ icon: lucideIconKey(name), emoji: "" })}
|
||||||
>
|
>
|
||||||
<GlyphSvg icon={icon} size={18} />
|
<GlyphSvg icon={icon} size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -671,93 +652,26 @@ 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
|
<div className="defs-icon-grid" role="radiogroup" aria-labelledby="defs-label-icon-fa">
|
||||||
className="defs-input"
|
{FA_DEFINITION_ICONS.map((icon) => {
|
||||||
type="search"
|
const { name, label } = icon;
|
||||||
aria-label="Search Font Awesome icons"
|
const on = faIconName(draft.icon) === name;
|
||||||
placeholder="Search or type an icon name, e.g. dog"
|
return (
|
||||||
value={draft.faText}
|
<button
|
||||||
onChange={(e) => {
|
key={name}
|
||||||
setFaPage(0);
|
type="button"
|
||||||
update({ faText: e.target.value, icon: faTypedIconKey(e.target.value), emoji: "" });
|
role="radio"
|
||||||
}}
|
aria-checked={on}
|
||||||
/>
|
aria-label={label}
|
||||||
{draft.faText.trim() && draft.icon === null ? (
|
data-icon-option={faIconKey(name)}
|
||||||
<p className="defs-hint defs-hint--error" role="alert">
|
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
|
||||||
{faTextProblem(draft.faText)}
|
onClick={() => update({ icon: faIconKey(name), emoji: "" })}
|
||||||
</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) => {
|
<GlyphSvg icon={icon} size={18} />
|
||||||
const { name, label } = icon;
|
</button>
|
||||||
const on = faIconName(draft.icon) === name;
|
);
|
||||||
return (
|
})}
|
||||||
<button
|
</div>
|
||||||
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">
|
|
||||||
{FA_DEFINITION_ICONS.map((icon) => {
|
|
||||||
const { name, label } = icon;
|
|
||||||
const on = faIconName(draft.icon) === name;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={name}
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={on}
|
|
||||||
aria-label={label}
|
|
||||||
data-icon-option={faIconKey(name)}
|
|
||||||
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
|
|
||||||
onClick={() => update({ icon: faIconKey(name), emoji: "", faText: "" })}
|
|
||||||
>
|
|
||||||
<GlyphSvg icon={icon} size={18} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<label className="defs-due">
|
<label className="defs-due">
|
||||||
<span>Emoji</span>
|
<span>Emoji</span>
|
||||||
<input
|
<input
|
||||||
@@ -765,9 +679,7 @@ function EditSheet({
|
|||||||
aria-label="Emoji"
|
aria-label="Emoji"
|
||||||
placeholder="e.g. 🐶"
|
placeholder="e.g. 🐶"
|
||||||
value={draft.emoji}
|
value={draft.emoji}
|
||||||
onChange={(e) =>
|
onChange={(e) => update({ emoji: e.target.value, icon: emojiIconKey(e.target.value) })}
|
||||||
update({ emoji: e.target.value, icon: emojiIconKey(e.target.value), faText: "" })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -1,23 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* 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, any icon in the full Font Awesome Free Solid set, or an emoji.
|
* Lucide set, the curated Font Awesome Free solid subset, or an emoji. The
|
||||||
* The Font Awesome quick picks (`FA_DEFINITION_ICONS`) are only a curated
|
* names, labels and path data come from the shared icon registry
|
||||||
* shortcut list; `fa:<name>` resolves against the full set
|
* (`src/icons/registry.generated.ts`, generated from
|
||||||
* (`FA_SOLID_ICONS`). The names, labels and path data come from the shared
|
* `tools/icons/curated-icons.json`) — no CDN, no runtime fetch. The stored
|
||||||
* icon registry (`src/icons/registry.generated.ts`, generated by
|
* value is namespaced — `lucide:<name>`, `fa:<name>` or `emoji:<grapheme>` —
|
||||||
* `tools/icons/generate.mjs` from `tools/icons/curated-icons.json` and the
|
* and a legacy bare `<kebab-name>` still names the same Lucide entry.
|
||||||
* 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 {
|
import { FA_ICONS, LUCIDE_ICONS, resolveIcon, type GlyphIcon } from "../icons/registry.generated";
|
||||||
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;
|
||||||
@@ -67,50 +58,14 @@ 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 glyph an icon value resolves to via the shared parser: a curated
|
* The curated glyph (Lucide or Font Awesome) an icon value resolves to via
|
||||||
* Lucide entry or any Font Awesome Free Solid icon (quick pick or not);
|
* the shared parser; emoji, unknown and null values give null.
|
||||||
* 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);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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";
|
||||||
|
|
||||||
@@ -630,117 +629,3 @@ 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",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -9,10 +9,9 @@ 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. */
|
||||||
@@ -130,7 +129,7 @@ function StatRow({ stats }: { stats: HistoryStats }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventRow({ row, timeZone }: { row: HistoryRow; timeZone: string }) {
|
function EventRow({ row }: { row: HistoryRow }) {
|
||||||
return (
|
return (
|
||||||
<li className="history-event" data-testid="history-row">
|
<li className="history-event" data-testid="history-row">
|
||||||
<span
|
<span
|
||||||
@@ -143,10 +142,10 @@ function EventRow({ row, timeZone }: { row: HistoryRow; timeZone: string }) {
|
|||||||
{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, timeZone)}
|
{rowMeta(row)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="history-event-time">{rowTime(row, timeZone)}</span>
|
<span className="history-event-time">{rowTime(row)}</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -162,10 +161,7 @@ function FooterNote() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `today` ("YYYY-MM-DD") defaults to today in the application timezone. */
|
export default function HistoryTab({ today = localTodayIso() }: { today?: string }) {
|
||||||
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);
|
||||||
@@ -248,7 +244,7 @@ export default function HistoryTab({ today: todayProp }: { today?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const groups = groupByDay(shown.data.rows, timeZone);
|
const groups = groupByDay(shown.data.rows);
|
||||||
body =
|
body =
|
||||||
groups.length > 0 ? (
|
groups.length > 0 ? (
|
||||||
groups.map((group) => (
|
groups.map((group) => (
|
||||||
@@ -256,7 +252,7 @@ export default function HistoryTab({ today: todayProp }: { 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} timeZone={timeZone} />
|
<EventRow key={`${row.instance_id}-${row.event_type}-${row.occurred_at ?? ""}-${index}`} row={row} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -4,13 +4,12 @@
|
|||||||
* 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
|
||||||
* application timezone (`timeZone`: the stored IANA zone, "" = browser).
|
* viewer's local time.
|
||||||
*/
|
*/
|
||||||
import type { HistoryRow } from "../api/history";
|
import type { HistoryRow } from "../api/history";
|
||||||
import { dayNumber, isoFromDayNumber } from "../schedule/cycle";
|
import { dayNumber, isoFromDayNumber, localTodayIso } 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;
|
||||||
|
|
||||||
@@ -20,21 +19,25 @@ 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, timeZone: string): string {
|
export function rowDay(row: HistoryRow): string {
|
||||||
return row.occurred_at ? isoDateIn(new Date(row.occurred_at), timeZone) : row.due_date;
|
return row.occurred_at ? localTodayIso(new Date(row.occurred_at)) : row.due_date;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The row's due moment (epoch ms) as local wall-clock time, or null without a due time. */
|
/** The row's due moment as local wall-clock time, or null without a due time. */
|
||||||
function dueMoment(row: HistoryRow, timeZone: string): number | null {
|
function dueMoment(row: HistoryRow): Date | 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;
|
||||||
return wallClockInstant(row.due_date, Number(match[1]), Number(match[2]), timeZone);
|
const [year, month, day] = row.due_date.split("-").map(Number);
|
||||||
|
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, timeZone: string): number {
|
function sortKey(row: HistoryRow): number {
|
||||||
if (row.occurred_at) return Date.parse(row.occurred_at);
|
if (row.occurred_at) return Date.parse(row.occurred_at);
|
||||||
return dueMoment(row, timeZone) ?? wallClockInstant(row.due_date, 23, 59, timeZone);
|
const due = dueMoment(row);
|
||||||
|
if (due) return due.getTime();
|
||||||
|
const [year, month, day] = row.due_date.split("-").map(Number);
|
||||||
|
return new Date(year, month - 1, day, 23, 59).getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DayGroup {
|
export interface DayGroup {
|
||||||
@@ -43,10 +46,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[], timeZone: string): DayGroup[] {
|
export function groupByDay(rows: HistoryRow[]): 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, timeZone);
|
const day = rowDay(row);
|
||||||
groups.set(day, [...(groups.get(day) ?? []), row]);
|
groups.set(day, [...(groups.get(day) ?? []), row]);
|
||||||
}
|
}
|
||||||
return [...groups.entries()]
|
return [...groups.entries()]
|
||||||
@@ -54,7 +57,7 @@ export function groupByDay(rows: HistoryRow[], timeZone: string): 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, timeZone) - sortKey(a, timeZone)),
|
rows: [...dayRows].sort((a, b) => sortKey(b) - sortKey(a)),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,23 +72,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, timeZone: string): string | null {
|
function onTimeFlag(row: HistoryRow): 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, timeZone);
|
const due = dueMoment(row);
|
||||||
if (due === null || !row.occurred_at) return "late";
|
if (!due || !row.occurred_at) return "late";
|
||||||
const minutes = Math.round((Date.parse(row.occurred_at) - due) / MS_PER_MINUTE);
|
const minutes = Math.round((Date.parse(row.occurred_at) - due.getTime()) / 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, timeZone: string): string {
|
export function rowMeta(row: HistoryRow): string {
|
||||||
const flag = onTimeFlag(row, timeZone);
|
const flag = onTimeFlag(row);
|
||||||
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, timeZone: string): string {
|
export function rowTime(row: HistoryRow): string {
|
||||||
if (row.occurred_at) return formatClockTime(row.occurred_at, timeZone);
|
if (row.occurred_at) return formatClockTime(row.occurred_at);
|
||||||
return row.due_time ? formatDueTime(row.due_time) : "";
|
return row.due_time ? formatDueTime(row.due_time) : "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,7 @@ 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 {
|
import { EMOJI_MAX_CODE_POINTS, FALLBACK_ICON, FA_ICONS, LUCIDE_ICONS, resolveIcon } from "./registry.generated";
|
||||||
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 }[] };
|
||||||
|
|
||||||
@@ -52,18 +45,7 @@ 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);
|
||||||
}
|
}
|
||||||
const solidNames = FA_SOLID_ICONS.map((icon) => icon.name);
|
for (const icon of [...LUCIDE_ICONS, ...FA_ICONS]) {
|
||||||
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]+$/);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -79,12 +61,6 @@ 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: "👨👩👧" });
|
||||||
});
|
});
|
||||||
@@ -157,7 +133,7 @@ describe("offline bundling", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("no bundled source imports an icon package — only the generated registry data ships", () => {
|
it("no bundled source imports an icon package — only the curated generated data ships", () => {
|
||||||
const sources = [join(REPO_ROOT, "admin", "src"), join(REPO_ROOT, "frontend", "src")]
|
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,10 +74,6 @@ 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) {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, 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 {
|
||||||
@@ -656,55 +655,3 @@ 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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
cycleLabel,
|
cycleLabel,
|
||||||
dayState,
|
dayState,
|
||||||
|
localTodayIso,
|
||||||
monthDates,
|
monthDates,
|
||||||
monthStart,
|
monthStart,
|
||||||
patternDaysLabel,
|
patternDaysLabel,
|
||||||
@@ -37,8 +38,6 @@ 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. */
|
||||||
@@ -325,13 +324,11 @@ function OverrideRow({ override, childName, deletion, onAskDelete, onKeep, onDel
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduleTabProps {
|
export interface ScheduleTabProps {
|
||||||
/** "YYYY-MM-DD"; defaults to today in the application timezone. */
|
/** "YYYY-MM-DD"; defaults to the viewer's local date. */
|
||||||
today?: string;
|
today?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ScheduleTab({ today: todayProp }: ScheduleTabProps) {
|
export default function ScheduleTab({ today = localTodayIso() }: 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);
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ 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);
|
||||||
|
|||||||
@@ -258,12 +258,6 @@
|
|||||||
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);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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`;
|
||||||
@@ -24,17 +23,9 @@ 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",
|
||||||
effective_timezone: "America/Chicago",
|
timezone_configured: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 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,
|
||||||
@@ -92,10 +83,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(settingsBody());
|
if (path === SETTINGS_PATH && method === "GET") return jsonResponse(settings);
|
||||||
if (path === SETTINGS_PATH && method === "PATCH") {
|
if (path === SETTINGS_PATH && method === "PATCH") {
|
||||||
settings = { ...settings, ...body };
|
settings = { ...settings, ...body };
|
||||||
return jsonResponse(settingsBody());
|
return jsonResponse(settings);
|
||||||
}
|
}
|
||||||
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, {
|
||||||
@@ -509,37 +500,37 @@ describe("SettingsScreen — preferences", () => {
|
|||||||
expect(writes()).toHaveLength(1);
|
expect(writes()).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a timezone picked from the list patches exactly the timezone", async () => {
|
it("a changed timezone patches exactly the timezone", async () => {
|
||||||
const { form } = await renderPreferences();
|
const { form } = await renderPreferences();
|
||||||
fireEvent.change(field(form, "Timezone"), { target: { value: "America/New_York" } });
|
const input = field(form, "Timezone");
|
||||||
|
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: "America/New_York" } },
|
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "Europe/London", timezone_configured: true } },
|
||||||
]);
|
]);
|
||||||
const reloaded = screen.getByRole("form", { name: "Preferences" });
|
const reloaded = screen.getByRole("form", { name: "Preferences" });
|
||||||
expect(field(reloaded, "Timezone").value).toBe("America/New_York");
|
expect(field(reloaded, "Timezone").value).toBe("Europe/London");
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["New York", "America/New York", "EST5EDT?", "/Chicago"])(
|
it.each(["New York", "America/New York", "EST5EDT?", "/Chicago"])(
|
||||||
"an invalid other timezone %j is rejected before any request",
|
"an invalid 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: "__other__" } });
|
fireEvent.change(field(form, "Timezone"), { target: { value } });
|
||||||
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, "Other timezone").value).toBe(value);
|
expect(field(form, "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: "__other__" } });
|
fireEvent.change(field(form, "Timezone"), { target: { value: "Mars/Olympus_Mons" } });
|
||||||
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);
|
||||||
@@ -547,9 +538,8 @@ 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, "Other timezone").value).toBe("Mars/Olympus_Mons");
|
expect(field(form, "Timezone").value).toBe("Mars/Olympus_Mons");
|
||||||
expect(field(form, "Other timezone").disabled).toBe(false);
|
expect(field(form, "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 () => {
|
||||||
@@ -569,7 +559,7 @@ describe("SettingsScreen — preferences", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("SettingsScreen — no timezone auto-set", () => {
|
describe("SettingsScreen — 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) {
|
||||||
@@ -585,8 +575,8 @@ describe("SettingsScreen — no 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 stored.
|
// A fresh install: no zone, and the admin never chose one.
|
||||||
settings = { ...DEFAULT_SETTINGS, timezone: "" };
|
settings = { ...DEFAULT_SETTINGS, timezone: "", timezone_configured: false };
|
||||||
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));
|
||||||
@@ -605,15 +595,25 @@ describe("SettingsScreen — no timezone auto-set", () => {
|
|||||||
return screen.findByRole("form", { name: "Preferences" });
|
return screen.findByRole("form", { name: "Preferences" });
|
||||||
}
|
}
|
||||||
|
|
||||||
it("an empty timezone stays empty on load and nothing is written", async () => {
|
it("an unset timezone is set once, on load, to the browser's zone and shown", async () => {
|
||||||
mockBrowserZone("America/New_York");
|
mockBrowserZone("America/New_York");
|
||||||
const form = await renderForm();
|
const form = await renderForm();
|
||||||
expect(field(form, "Timezone").value).toBe("");
|
expect(field(form, "Timezone").value).toBe("America/New_York");
|
||||||
expect(writes()).toEqual([]);
|
expect(writes()).toEqual([
|
||||||
expect(settings.timezone).toBe("");
|
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York", timezone_configured: true } },
|
||||||
|
]);
|
||||||
|
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 write nothing either", async () => {
|
it("StrictMode's doubled effects still auto-set exactly once", async () => {
|
||||||
mockBrowserZone("America/New_York");
|
mockBrowserZone("America/New_York");
|
||||||
render(
|
render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
@@ -621,223 +621,70 @@ describe("SettingsScreen — no 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 stored timezone is shown as stored, never replaced by the browser zone", async () => {
|
it("a set timezone is shown and never overwritten by the browser zone", async () => {
|
||||||
mockBrowserZone("America/New_York");
|
mockBrowserZone("America/New_York");
|
||||||
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin" };
|
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin", timezone_configured: false };
|
||||||
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("saving another field sends only that field, never a timezone", async () => {
|
it.each([
|
||||||
mockBrowserZone("America/New_York");
|
["nothing", undefined],
|
||||||
const form = await renderForm();
|
["an empty zone", ""],
|
||||||
fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } });
|
["an unknown zone", "Etc/Unknown"],
|
||||||
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
|
["a non-IANA zone", "not a zone"],
|
||||||
await screen.findByText("Preferences saved.");
|
])("detection returning %s leaves the field empty and sends nothing", async (_case, zone) => {
|
||||||
expect(writes().map((w) => w.body)).toEqual([{ horizon_days: 21 }]);
|
mockBrowserZone(zone);
|
||||||
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();
|
const form = await renderForm();
|
||||||
|
expect(field(form, "Timezone").value).toBe("");
|
||||||
|
expect(screen.queryByTestId("preferences-message")).toBeNull();
|
||||||
expect(writes()).toEqual([]);
|
expect(writes()).toEqual([]);
|
||||||
fireEvent.change(picker(form), { target: { value: "America/New_York" } });
|
});
|
||||||
save(form);
|
|
||||||
await screen.findByText("Preferences saved.");
|
it("a rejected auto-set shows why and leaves the field empty for manual entry", async () => {
|
||||||
expect(writes()).toEqual([
|
mockBrowserZone("America/New_York");
|
||||||
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } },
|
writeResponse = () => jsonResponse({ detail: "timezone is not known" }, 422);
|
||||||
]);
|
const form = await renderForm();
|
||||||
cleanup();
|
expect(field(form, "Timezone").value).toBe("");
|
||||||
const reloaded = await renderForm();
|
expect(field(form, "Timezone").disabled).toBe(false);
|
||||||
expect(picker(reloaded).value).toBe("America/New_York");
|
expect(screen.getByTestId("preferences-message").textContent).toBe(
|
||||||
|
"The timezone could not be set to America/New_York: timezone is not known",
|
||||||
|
);
|
||||||
expect(writes()).toHaveLength(1);
|
expect(writes()).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("an other zone typed by hand saves via PATCH and is shown after a reload", async () => {
|
|
||||||
const form = await renderForm();
|
|
||||||
fireEvent.change(picker(form), { target: { value: "__other__" } });
|
|
||||||
const entry = within(form).getByLabelText("Other timezone") as HTMLInputElement;
|
|
||||||
expect(entry.value).toBe("");
|
|
||||||
fireEvent.change(entry, { target: { value: " America/Boise " } });
|
|
||||||
save(form);
|
|
||||||
await screen.findByText("Preferences saved.");
|
|
||||||
expect(writes()).toEqual([
|
|
||||||
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/Boise" } },
|
|
||||||
]);
|
|
||||||
cleanup();
|
|
||||||
const reloaded = await renderForm();
|
|
||||||
expect(picker(reloaded).value).toBe("__other__");
|
|
||||||
expect((within(reloaded).getByLabelText("Other timezone") as HTMLInputElement).value).toBe(
|
|
||||||
"America/Boise",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a saved zone outside the list is shown in the other-zone entry on load", async () => {
|
|
||||||
settings = { ...DEFAULT_SETTINGS, timezone: "Asia/Kathmandu" };
|
|
||||||
const form = await renderForm();
|
|
||||||
expect(picker(form).value).toBe("__other__");
|
|
||||||
expect((within(form).getByLabelText("Other timezone") as HTMLInputElement).value).toBe(
|
|
||||||
"Asia/Kathmandu",
|
|
||||||
);
|
|
||||||
expect(writes()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("choosing API host local time clears the zone and persists after a reload", async () => {
|
|
||||||
settings = { ...DEFAULT_SETTINGS, timezone: "America/Denver" };
|
|
||||||
const form = await renderForm();
|
|
||||||
expect(picker(form).value).toBe("America/Denver");
|
|
||||||
fireEvent.change(picker(form), { target: { value: "" } });
|
|
||||||
save(form);
|
|
||||||
await screen.findByText("Preferences saved.");
|
|
||||||
expect(writes().map((w) => w.body)).toEqual([{ timezone: "" }]);
|
|
||||||
cleanup();
|
|
||||||
const reloaded = await renderForm();
|
|
||||||
expect(picker(reloaded).value).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("picking a zone and then the saved one again sends nothing", async () => {
|
|
||||||
settings = { ...DEFAULT_SETTINGS, timezone: "UTC" };
|
|
||||||
const form = await renderForm();
|
|
||||||
fireEvent.change(picker(form), { target: { value: "Europe/Paris" } });
|
|
||||||
fireEvent.change(picker(form), { target: { value: "UTC" } });
|
|
||||||
save(form);
|
|
||||||
expect(screen.getByTestId("preferences-message").textContent).toBe("No changes to save.");
|
|
||||||
expect(writes()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never sends a timezone_configured field", async () => {
|
|
||||||
const form = await renderForm();
|
|
||||||
fireEvent.change(picker(form), { target: { value: "America/Los_Angeles" } });
|
|
||||||
save(form);
|
|
||||||
await screen.findByText("Preferences saved.");
|
|
||||||
const again = screen.getByRole("form", { name: "Preferences" });
|
|
||||||
fireEvent.change(picker(again), { target: { value: "" } });
|
|
||||||
save(again);
|
|
||||||
await waitFor(() => expect(writes()).toHaveLength(2));
|
|
||||||
for (const write of writes()) {
|
|
||||||
expect(Object.keys(write.body as object)).toEqual(["timezone"]);
|
|
||||||
expect(write.body).not.toHaveProperty("timezone_configured");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("SettingsScreen — the saved zone applies app-wide", () => {
|
|
||||||
function ZoneProbe() {
|
|
||||||
return <output data-testid="app-zone">{useAppTimezone() || "(browser)"}</output>;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
sessionStorage.clear();
|
|
||||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
|
||||||
children = [child(1, "Declan", 1)];
|
|
||||||
settings = { ...DEFAULT_SETTINGS, timezone: "" };
|
|
||||||
writeResponse = null;
|
|
||||||
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a saved zone becomes the application timezone at once", async () => {
|
|
||||||
render(
|
|
||||||
<AppTimezoneProvider>
|
|
||||||
<ZoneProbe />
|
|
||||||
<SettingsScreen onClose={vi.fn()} />
|
|
||||||
</AppTimezoneProvider>,
|
|
||||||
);
|
|
||||||
const form = await screen.findByRole("form", { name: "Preferences" });
|
|
||||||
// No zone stored: the application runs in the API host's zone.
|
|
||||||
expect(screen.getByTestId("app-zone").textContent).toBe(API_HOST_ZONE);
|
|
||||||
|
|
||||||
fireEvent.change(within(form).getByRole("combobox", { name: "Timezone" }), {
|
|
||||||
target: { value: "America/New_York" },
|
|
||||||
});
|
|
||||||
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
|
|
||||||
await screen.findByText("Preferences saved.");
|
|
||||||
expect(screen.getByTestId("app-zone").textContent).toBe("America/New_York");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,10 +5,9 @@
|
|||||||
* 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. The
|
* timezone, notifications). Save patches only the changed fields. A timezone
|
||||||
* timezone — the application's source of local time — is only ever set by
|
* the admin never chose is set once, on load, to the browser's own zone;
|
||||||
* the admin picking or typing a zone here and saving it; every zone loaded
|
* saving the timezone (even clearing it) marks it chosen, so it sticks.
|
||||||
* 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.
|
||||||
*/
|
*/
|
||||||
@@ -36,7 +35,6 @@ 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";
|
||||||
|
|
||||||
@@ -259,7 +257,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 picker's zones; any other IANA zone can be typed via "Other". */
|
/** The Timezone field's suggestions; any other IANA zone can be typed. */
|
||||||
export const COMMON_TIMEZONES = [
|
export const COMMON_TIMEZONES = [
|
||||||
"UTC",
|
"UTC",
|
||||||
"America/New_York",
|
"America/New_York",
|
||||||
@@ -293,8 +291,18 @@ export const COMMON_TIMEZONES = [
|
|||||||
"Pacific/Auckland",
|
"Pacific/Auckland",
|
||||||
];
|
];
|
||||||
|
|
||||||
/** The picker's "Other IANA zone…" option; never sent to the API. */
|
/**
|
||||||
const OTHER_ZONE = "__other__";
|
* The browser's own IANA zone — the location-derived default for an unset
|
||||||
|
* 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 };
|
||||||
@@ -334,6 +342,9 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -354,10 +365,6 @@ 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) {
|
||||||
@@ -410,49 +417,25 @@ function PreferencesForm({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{timeInput("day_rollover_time", "Day rollover")}
|
{timeInput("day_rollover_time", "Day rollover")}
|
||||||
<div className="settings-field">
|
<label className="settings-field">
|
||||||
<label className="settings-label" htmlFor="settings-timezone">
|
<span className="settings-label">Timezone</span>
|
||||||
Timezone
|
<input
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="settings-timezone"
|
|
||||||
className="settings-input"
|
className="settings-input"
|
||||||
aria-describedby="settings-timezone-help"
|
type="text"
|
||||||
value={otherZone ? OTHER_ZONE : draft.timezone}
|
list="settings-timezones"
|
||||||
|
placeholder="Host local time"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
value={draft.timezone}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(event) => {
|
onChange={(event) => update({ timezone: event.target.value })}
|
||||||
const choice = event.target.value;
|
/>
|
||||||
setOtherZone(choice === OTHER_ZONE);
|
</label>
|
||||||
if (choice !== OTHER_ZONE) update({ timezone: choice });
|
<datalist id="settings-timezones">
|
||||||
}}
|
{COMMON_TIMEZONES.map((zone) => (
|
||||||
>
|
<option key={zone} value={zone} />
|
||||||
<option value="">API host local time (no zone set)</option>
|
))}
|
||||||
{COMMON_TIMEZONES.map((zone) => (
|
</datalist>
|
||||||
<option key={zone} value={zone}>
|
|
||||||
{zone}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
<option value={OTHER_ZONE}>Other IANA zone…</option>
|
|
||||||
</select>
|
|
||||||
{otherZone ? (
|
|
||||||
<input
|
|
||||||
className="settings-input"
|
|
||||||
type="text"
|
|
||||||
aria-label="Other timezone"
|
|
||||||
placeholder="Area/Location, e.g. America/Boise"
|
|
||||||
autoComplete="off"
|
|
||||||
spellCheck={false}
|
|
||||||
value={draft.timezone}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(event) => update({ timezone: event.target.value })}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<p className="settings-hint" id="settings-timezone-help">
|
|
||||||
The application timezone: the source of local time for the panel, the scheduler and this
|
|
||||||
admin app. It changes only when you save it here; with no zone set, the API host's
|
|
||||||
local time is used.
|
|
||||||
</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
|
||||||
@@ -591,11 +574,12 @@ 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.
|
||||||
@@ -636,10 +620,29 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setPreferences({ kind: "loading" });
|
setPreferences({ kind: "loading" });
|
||||||
fetchSettings()
|
fetchSettings()
|
||||||
.then((settings) => {
|
.then(async (settings) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setPreferences({ kind: "ready", settings });
|
// Never chosen: adopt the browser's zone before the form shows, so
|
||||||
publishTimezone(settings.effective_timezone);
|
// 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;
|
||||||
|
setPreferences({ kind: "ready", settings });
|
||||||
|
setPreferencesMessage({
|
||||||
|
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;
|
||||||
@@ -733,7 +736,6 @@ 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({
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
/**
|
|
||||||
* The effective application timezone, loaded once per signed-in shell: the
|
|
||||||
* API's `effective_timezone` — the stored zone, or the API host's own zone
|
|
||||||
* when none is stored — so every screen's local time matches the API's.
|
|
||||||
*
|
|
||||||
* <AppTimezoneProvider> fetches the household settings and renders its
|
|
||||||
* screens only once the zone is known (a failed load says so and offers a
|
|
||||||
* retry), so no screen ever computes local time in a zone it will not keep.
|
|
||||||
* The Settings screen publishes every zone it loads or saves, so a change
|
|
||||||
* applies app-wide at once.
|
|
||||||
*
|
|
||||||
* Without a provider (a screen rendered on its own, e.g. in a test)
|
|
||||||
* useAppTimezone() is "" — the documented unset value, i.e. browser-local.
|
|
||||||
*/
|
|
||||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
|
||||||
import { ApiForbiddenError } from "../api/client";
|
|
||||||
import { fetchSettings } from "../api/settings";
|
|
||||||
|
|
||||||
interface ZoneHub {
|
|
||||||
timeZone: string;
|
|
||||||
publish(timeZone: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AppTimezoneContext = createContext<ZoneHub | null>(null);
|
|
||||||
|
|
||||||
type LoadState =
|
|
||||||
| { kind: "loading" }
|
|
||||||
| { kind: "error"; message: string }
|
|
||||||
| { kind: "ready"; timeZone: string };
|
|
||||||
|
|
||||||
export function AppTimezoneProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
|
||||||
const [attempt, setAttempt] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
setState({ kind: "loading" });
|
|
||||||
fetchSettings()
|
|
||||||
.then((settings) => {
|
|
||||||
if (!cancelled) setState({ kind: "ready", timeZone: settings.effective_timezone });
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setState({
|
|
||||||
kind: "error",
|
|
||||||
message:
|
|
||||||
error instanceof ApiForbiddenError
|
|
||||||
? error.message
|
|
||||||
: "The application timezone could not be loaded. Check your connection and try again.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [attempt]);
|
|
||||||
|
|
||||||
if (state.kind === "loading") {
|
|
||||||
return (
|
|
||||||
<div className="admin-loading" role="status" data-testid="timezone-loading">
|
|
||||||
Loading…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (state.kind === "error") {
|
|
||||||
return (
|
|
||||||
<div className="admin-error" role="alert" data-testid="timezone-error">
|
|
||||||
<p>{state.message}</p>
|
|
||||||
<button type="button" onClick={() => setAttempt((n) => n + 1)}>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const hub: ZoneHub = {
|
|
||||||
timeZone: state.timeZone,
|
|
||||||
publish: (timeZone) =>
|
|
||||||
setState((prev) =>
|
|
||||||
prev.kind === "ready" && prev.timeZone === timeZone ? prev : { kind: "ready", timeZone },
|
|
||||||
),
|
|
||||||
};
|
|
||||||
return <AppTimezoneContext.Provider value={hub}>{children}</AppTimezoneContext.Provider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The effective application timezone: an IANA name, or "" (browser-local). */
|
|
||||||
export function useAppTimezone(): string {
|
|
||||||
return useContext(AppTimezoneContext)?.timeZone ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Publish an effective zone just loaded or saved; a no-op without a provider. */
|
|
||||||
export function usePublishAppTimezone(): (timeZone: string) => void {
|
|
||||||
const hub = useContext(AppTimezoneContext);
|
|
||||||
return (timeZone) => hub?.publish(timeZone);
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
||||||
import { isoDateIn, todayIn, wallClockInstant, zoneOption } from "./zone";
|
|
||||||
import { formatClockTime } from "../today/format";
|
|
||||||
|
|
||||||
// 13:00 UTC on Sep 23 is already 01:00 on Sep 24 in Auckland (NZST, UTC+12).
|
|
||||||
const NOW = new Date("2026-09-23T13:00:00Z");
|
|
||||||
|
|
||||||
describe("application timezone helpers (process in UTC)", () => {
|
|
||||||
beforeAll(() => {
|
|
||||||
vi.stubEnv("TZ", "UTC");
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a stored zone decides the calendar date; empty means the browser's zone", () => {
|
|
||||||
expect(todayIn("Pacific/Auckland", NOW)).toBe("2026-09-24");
|
|
||||||
expect(isoDateIn(NOW, "Pacific/Auckland")).toBe("2026-09-24");
|
|
||||||
expect(todayIn("", NOW)).toBe("2026-09-23");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("wall-clock time converts in the stored zone, across its DST change", () => {
|
|
||||||
// NZST (UTC+12) before 27 Sep 2026, NZDT (UTC+13) after.
|
|
||||||
expect(wallClockInstant("2026-09-24", 0, 10, "Pacific/Auckland")).toBe(
|
|
||||||
Date.parse("2026-09-23T12:10:00Z"),
|
|
||||||
);
|
|
||||||
expect(wallClockInstant("2026-09-28", 7, 30, "Pacific/Auckland")).toBe(
|
|
||||||
Date.parse("2026-09-27T18:30:00Z"),
|
|
||||||
);
|
|
||||||
expect(wallClockInstant("2026-09-24", 0, 10, "")).toBe(Date.parse("2026-09-24T00:10:00Z"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("the completion clock reads in the stored zone", () => {
|
|
||||||
expect(formatClockTime("2026-09-23T11:04:00Z", "Pacific/Auckland")).toBe("11:04 PM");
|
|
||||||
expect(formatClockTime("2026-09-23T11:04:00Z", "")).toBe("11:04 AM");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("an invalid stored zone fails fast", () => {
|
|
||||||
expect(zoneOption("")).toBeUndefined();
|
|
||||||
expect(() => todayIn("Not/AZone", NOW)).toThrow(RangeError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("empty zone keeps browser-local behavior", () => {
|
|
||||||
beforeAll(() => {
|
|
||||||
vi.stubEnv("TZ", "America/Los_Angeles");
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches the browser's own calendar and wall clock", () => {
|
|
||||||
// 13:00 UTC is 06:00 PDT on Sep 23.
|
|
||||||
expect(todayIn("", NOW)).toBe("2026-09-23");
|
|
||||||
expect(wallClockInstant("2026-09-23", 7, 30, "")).toBe(new Date(2026, 8, 23, 7, 30).getTime());
|
|
||||||
expect(formatClockTime("2026-09-23T14:04:00Z", "")).toBe("7:04 AM");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
/**
|
|
||||||
* Local time in the application timezone.
|
|
||||||
*
|
|
||||||
* The admin-saved zone (settings `timezone`, an IANA name) is the single
|
|
||||||
* source of local-time truth for the whole application; the API publishes it
|
|
||||||
* as `effective_timezone`, naming its host's own zone when none is stored.
|
|
||||||
* "" means the API published no zone, and only then does the browser's own
|
|
||||||
* zone apply. Every helper takes
|
|
||||||
* that stored value as-is; an invalid zone name throws (Intl's RangeError).
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** The `timeZone` option for Intl: the zone, or undefined (browser) for "". */
|
|
||||||
export function zoneOption(timeZone: string): string | undefined {
|
|
||||||
return timeZone === "" ? undefined : timeZone;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WallClock {
|
|
||||||
year: number;
|
|
||||||
month: number;
|
|
||||||
day: number;
|
|
||||||
hour: number;
|
|
||||||
minute: number;
|
|
||||||
second: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function wallClock(instant: number, timeZone: string): WallClock {
|
|
||||||
const parts = new Intl.DateTimeFormat("en-US", {
|
|
||||||
timeZone: zoneOption(timeZone),
|
|
||||||
hourCycle: "h23",
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
}).formatToParts(instant);
|
|
||||||
const part = (type: Intl.DateTimeFormatPartTypes) =>
|
|
||||||
Number(parts.find((p) => p.type === type)?.value);
|
|
||||||
return {
|
|
||||||
year: part("year"),
|
|
||||||
month: part("month"),
|
|
||||||
day: part("day"),
|
|
||||||
hour: part("hour"),
|
|
||||||
minute: part("minute"),
|
|
||||||
second: part("second"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The "YYYY-MM-DD" calendar date of `instant` in the zone. */
|
|
||||||
export function isoDateIn(instant: Date, timeZone: string): string {
|
|
||||||
const { year, month, day } = wallClock(instant.getTime(), timeZone);
|
|
||||||
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Today's calendar date in the zone. */
|
|
||||||
export function todayIn(timeZone: string, now: Date = new Date()): string {
|
|
||||||
return isoDateIn(now, timeZone);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The zone's UTC offset in ms at `instant` (wall clock minus UTC). */
|
|
||||||
function offsetAt(instant: number, timeZone: string): number {
|
|
||||||
const c = wallClock(instant, timeZone);
|
|
||||||
const wholeSeconds = Math.floor(instant / 1000) * 1000;
|
|
||||||
return Date.UTC(c.year, c.month - 1, c.day, c.hour, c.minute, c.second) - wholeSeconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Epoch ms at which the zone's wall clock reads `hour:minute` on the
|
|
||||||
* "YYYY-MM-DD" date. Two passes settle the offset across a DST change.
|
|
||||||
*/
|
|
||||||
export function wallClockInstant(
|
|
||||||
iso: string,
|
|
||||||
hour: number,
|
|
||||||
minute: number,
|
|
||||||
timeZone: string,
|
|
||||||
): number {
|
|
||||||
const [year, month, day] = iso.split("-").map(Number);
|
|
||||||
const asUtc = Date.UTC(year, month - 1, day, hour, minute);
|
|
||||||
const guess = asUtc - offsetAt(asUtc, timeZone);
|
|
||||||
return asUtc - offsetAt(guess, timeZone);
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ 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 {
|
||||||
@@ -407,62 +406,3 @@ 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ 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,
|
||||||
@@ -166,7 +165,6 @@ 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 (
|
||||||
@@ -189,7 +187,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, timeZone)}
|
Completed {formatClockTime(instance.completed_at as string)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,10 +4,8 @@
|
|||||||
* 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. Instants are shown in the application
|
* day in negative-offset zones.
|
||||||
* timezone (time/zone.ts).
|
|
||||||
*/
|
*/
|
||||||
import { zoneOption } from "../time/zone";
|
|
||||||
|
|
||||||
const LOCALE = "en-US";
|
const LOCALE = "en-US";
|
||||||
|
|
||||||
@@ -46,11 +44,7 @@ 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 wall-clock time in `timeZone` ("" = browser), "7:04 AM". */
|
/** A UTC ISO timestamp shown as the viewer's local time, "7:04 AM". */
|
||||||
export function formatClockTime(isoTimestamp: string, timeZone: string): string {
|
export function formatClockTime(isoTimestamp: string): string {
|
||||||
return format(new Date(isoTimestamp), {
|
return format(new Date(isoTimestamp), { hour: "numeric", minute: "2-digit" });
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
timeZone: zoneOption(timeZone),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
"""The API's effective application timezone (task 7e173881).
|
|
||||||
|
|
||||||
The admin-stored ``timezone`` setting is the single source of local-time
|
|
||||||
truth; stored empty, the API reads its clock in the API host's local
|
|
||||||
time. :func:`effective_timezone` names the zone the API actually uses —
|
|
||||||
the stored zone, or else the host's own IANA zone — so the panel cards
|
|
||||||
and the admin app format local time in exactly the API's zone instead of
|
|
||||||
guessing with Home Assistant's or the browser's. ``""`` only when the
|
|
||||||
host's zone genuinely cannot be named.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from pathlib import Path
|
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
||||||
|
|
||||||
|
|
||||||
def _valid_zone(name: str) -> str:
|
|
||||||
"""Return ``name`` if it is a loadable IANA zone key, else ``""``."""
|
|
||||||
name = name.strip().removeprefix(":")
|
|
||||||
if not name:
|
|
||||||
return ""
|
|
||||||
try:
|
|
||||||
ZoneInfo(name)
|
|
||||||
except (ZoneInfoNotFoundError, ValueError):
|
|
||||||
return ""
|
|
||||||
return name
|
|
||||||
|
|
||||||
|
|
||||||
def host_timezone(
|
|
||||||
environ: Mapping[str, str] = os.environ,
|
|
||||||
timezone_file: Path = Path("/etc/timezone"),
|
|
||||||
localtime: Path = Path("/etc/localtime"),
|
|
||||||
) -> str:
|
|
||||||
"""Name the API host's local zone as an IANA key, or ``""``.
|
|
||||||
|
|
||||||
In the order the C library honours them: the ``TZ`` environment
|
|
||||||
variable, then the ``/etc/localtime`` zoneinfo link (with Debian's
|
|
||||||
``/etc/timezone`` naming it when the link is a copy). A host with
|
|
||||||
none of them configured runs UTC. A setting that names no IANA
|
|
||||||
zone (a POSIX rule string, a copied file) is ``""``.
|
|
||||||
"""
|
|
||||||
configured = environ.get("TZ")
|
|
||||||
if configured is not None:
|
|
||||||
# An empty TZ is UTC to the C library.
|
|
||||||
return _valid_zone(configured) if configured.strip() else "UTC"
|
|
||||||
if localtime.is_symlink():
|
|
||||||
target = str(localtime.resolve())
|
|
||||||
marker = "/zoneinfo/"
|
|
||||||
if marker in target:
|
|
||||||
return _valid_zone(target.split(marker, 1)[1])
|
|
||||||
if timezone_file.is_file():
|
|
||||||
return _valid_zone(timezone_file.read_text(encoding="utf-8"))
|
|
||||||
if not localtime.exists():
|
|
||||||
return "UTC"
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def effective_timezone(stored: str) -> str:
|
|
||||||
"""The zone the API reads its clock in: ``stored``, else the host's."""
|
|
||||||
return stored or host_timezone()
|
|
||||||
@@ -165,15 +165,13 @@ 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 eleven
|
- ``GET /settings`` returns the current effective settings: all twelve
|
||||||
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, and the ``timezone`` — the IANA household zone the
|
toggles, the ``timezone`` — the IANA household zone the
|
||||||
household-local dates derive from, empty meaning host local; only an
|
household-local dates derive from, empty meaning host local — and
|
||||||
admin's ``PATCH`` ever sets it) — the defaults on a fresh database —
|
``timezone_configured``, whether the admin explicitly chose that
|
||||||
plus the read-only ``effective_timezone`` (:mod:`api.host_zone`): the
|
zone) — the defaults on a fresh database.
|
||||||
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
|
||||||
@@ -352,7 +350,6 @@ 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
|
||||||
@@ -909,7 +906,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 ELEVEN documented fields, resolved (every absent stored field
|
— all TWELVE 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.
|
||||||
"""
|
"""
|
||||||
@@ -924,14 +921,12 @@ 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 admin stored; ``""`` means the
|
#: The household IANA time zone the API reads its clock in; ``""``
|
||||||
#: API host's local time.
|
#: means the API host's local time.
|
||||||
timezone: str
|
timezone: str
|
||||||
#: The zone the API actually reads its clock in — ``timezone``, or
|
#: Whether the admin explicitly chose ``timezone`` (even ``""``);
|
||||||
#: the API host's own zone when that is ``""`` (``""`` only when the
|
#: ``False`` lets the admin UI auto-set the browser's zone once.
|
||||||
#: host's zone cannot be named). Read-only; the admin app formats
|
timezone_configured: bool
|
||||||
#: local time in it.
|
|
||||||
effective_timezone: str
|
|
||||||
|
|
||||||
|
|
||||||
class AdminMissedSweepResponse(BaseModel):
|
class AdminMissedSweepResponse(BaseModel):
|
||||||
@@ -2188,9 +2183,8 @@ 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 eleven documented fields (plus the read-only
|
stays pinned to the twelve documented fields even if the core
|
||||||
``effective_timezone``) even if the core dataclass later grows
|
dataclass later grows another one.
|
||||||
another one.
|
|
||||||
"""
|
"""
|
||||||
return AdminSettingsResponse(
|
return AdminSettingsResponse(
|
||||||
horizon_days=settings.horizon_days,
|
horizon_days=settings.horizon_days,
|
||||||
@@ -2204,7 +2198,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,
|
||||||
effective_timezone=host_zone.effective_timezone(settings.timezone),
|
timezone_configured=settings.timezone_configured,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -2302,7 +2296,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 eleven documented fields).
|
"""Return the current effective settings (all twelve 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
|
||||||
@@ -2341,25 +2335,12 @@ 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)
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ 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 admin stored — the API host may run UTC
|
household ``timezone`` — the API host may run UTC while the household
|
||||||
while the household does not), calls the pure
|
does not; until the admin chooses a zone it is the Home Assistant zone
|
||||||
|
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
|
||||||
@@ -21,7 +22,6 @@ 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,11 +46,7 @@ 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.
|
||||||
"""
|
"""
|
||||||
@@ -58,6 +54,7 @@ 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
|
||||||
@@ -67,14 +64,20 @@ 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")
|
||||||
@@ -132,14 +135,12 @@ 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); ``timezone`` is the effective application
|
child has a schedule); ``children`` is in the household's sort
|
||||||
timezone (:func:`api.host_zone.effective_timezone`); ``children`` is
|
order.
|
||||||
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]
|
||||||
|
|
||||||
|
|
||||||
@@ -187,17 +188,42 @@ def _local_now() -> datetime.datetime:
|
|||||||
|
|
||||||
|
|
||||||
async def _household_clock(
|
async def _household_clock(
|
||||||
database: object,
|
database: object, request: Request
|
||||||
) -> tuple[object, datetime.datetime]:
|
) -> tuple[object, datetime.datetime]:
|
||||||
"""Return the stored settings and ONE household-local clock read.
|
"""Return the 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 stored settings'
|
:func:`_local_now` read is re-expressed in the settings' ``timezone``
|
||||||
``timezone`` (:meth:`NestQuestSettings.household_now`). The zone
|
(:meth:`NestQuestSettings.household_now`). The integration reports
|
||||||
comes from the stored settings ONLY — nothing the request carries
|
Home Assistant's ``time_zone`` in :data:`PANEL_TIMEZONE_HEADER`;
|
||||||
can change it.
|
until the admin chooses a zone it is adopted and persisted
|
||||||
|
(: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.
|
||||||
"""
|
"""
|
||||||
settings = await core_settings_store.load_settings(database)
|
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)
|
||||||
return settings, settings.household_now(_local_now())
|
return settings, settings.household_now(_local_now())
|
||||||
|
|
||||||
|
|
||||||
@@ -219,14 +245,13 @@ 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)
|
settings, now = await _household_clock(state.database, request)
|
||||||
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,
|
||||||
@@ -307,7 +332,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)
|
_settings, now = await _household_clock(database, request)
|
||||||
try:
|
try:
|
||||||
result = await core_completion.complete_instance(
|
result = await core_completion.complete_instance(
|
||||||
database,
|
database,
|
||||||
|
|||||||
@@ -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 changed while the loop is asleep
|
A household zone adopted from Home Assistant 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,18 +21,17 @@ 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 settings write has
|
Guarantee: no sweep runs for a plan a committed adoption has replaced.
|
||||||
replaced. The settings writer — the admin ``PATCH /api/v1/admin/settings``
|
An adopting writer holds :attr:`MissedSweepScheduler.settings_lock`
|
||||||
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 a write commits either before a plan read
|
the sweep itself. So an adoption 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
|
||||||
writer cannot deadlock: it is delayed at most by one settings
|
panel request 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
|
||||||
@@ -151,7 +150,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 writes with planning and sweeping (see
|
#: Serialises settings adoption 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()
|
||||||
|
|
||||||
@@ -175,8 +174,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. an admin's zone
|
Called with freshly stored settings (e.g. a zone adopted from
|
||||||
change), with :attr:`settings_lock` held across the write
|
Home Assistant), 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
|
||||||
@@ -195,7 +194,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 settings write can commit between this read
|
# Under the lock no adoption 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()
|
||||||
@@ -234,7 +233,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 a settings write from committing between this
|
# lock keeps an adoption 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(
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ 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
|
||||||
@@ -16,11 +19,9 @@ 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 module import (aiohttp appears only
|
homeassistant NOR aiohttp at runtime (aiohttp appears only behind
|
||||||
behind ``TYPE_CHECKING`` for annotations, plus one lazy import inside
|
``TYPE_CHECKING`` for annotations), so the client is usable anywhere —
|
||||||
:meth:`NestQuestApiClient.stream_events` for its ``ClientTimeout``), so
|
the HA coupling lives in the one production factory.
|
||||||
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
|
||||||
@@ -40,11 +41,7 @@ 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). That includes the session's own default:
|
reconnection and backoff).
|
||||||
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
|
||||||
|
|
||||||
@@ -60,6 +57,7 @@ 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:
|
||||||
@@ -162,7 +160,8 @@ 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`) and hands the client Home Assistant's
|
(:func:`resolve_api_config`), reports ``hass.config.time_zone`` as
|
||||||
|
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:
|
||||||
@@ -174,7 +173,10 @@ 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, panel_token, async_get_clientsession(hass)
|
base_url,
|
||||||
|
panel_token,
|
||||||
|
async_get_clientsession(hass),
|
||||||
|
household_timezone=hass.config.time_zone,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -195,9 +197,14 @@ 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
|
||||||
@@ -222,10 +229,14 @@ 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 every panel-plane request carries."""
|
"""The Bearer credential plus the reported household zone."""
|
||||||
return {"Authorization": f"Bearer {self._panel_token}"}
|
headers = {"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.
|
||||||
@@ -284,19 +295,8 @@ 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, timeout=stream_timeout
|
"GET", url, headers=headers, json=None
|
||||||
)
|
)
|
||||||
response = await stream_cm.__aenter__()
|
response = await stream_cm.__aenter__()
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -332,10 +332,7 @@ class NestQuestApiClient:
|
|||||||
event_type: str | None = None
|
event_type: str | None = None
|
||||||
data_lines: list[str] = []
|
data_lines: list[str] = []
|
||||||
buffer = ""
|
buffer = ""
|
||||||
# The body is ``response.content`` (aiohttp's StreamReader,
|
async for chunk in response:
|
||||||
# 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
|
||||||
|
|||||||
@@ -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 household-local date
|
presence engine resolves the child present on the HA-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
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
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 }}"
|
|
||||||
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 191 KiB |
@@ -111,9 +111,6 @@ 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.
|
||||||
|
|
||||||
@@ -164,7 +161,6 @@ 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", ""),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -376,7 +372,10 @@ 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, panel_token, session_factory(hass)
|
base_url,
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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 household-local ``today``) and is never stored as an
|
(``due_date`` before HA-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 household-local completion date and optional wall-clock time.
|
"""Return the HA-local completion date and optional wall-clock time.
|
||||||
|
|
||||||
``today`` is the household-local calendar date when threaded (Feature 07
|
``today`` is the HA-local calendar date when threaded (Feature 07
|
||||||
style). ``now`` is a timezone-aware household-local datetime; its wall
|
style). ``now`` is a timezone-aware HA-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 household-local date, or the host calendar date."""
|
"""Return the threaded HA-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
|
||||||
household-local date.
|
HA-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`` (household-local, threaded), in which
|
``due_date`` is before ``today`` (HA-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 household-local completion moment
|
``was_on_time`` is computed from the HA-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 household-local
|
and optional ``due_time``. ``now`` is the timezone-aware HA-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 household-local ``today``); ``appended``
|
is past due (``due_date`` before HA-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`` (household-local, threaded). Uses
|
before ``today`` (HA-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.
|
||||||
|
|||||||
@@ -114,15 +114,24 @@ 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. Only an admin sets it (manually, from the admin app);
|
# local time. While the admin has not chosen a zone
|
||||||
# nothing sets it automatically. The integration relays the API's
|
# (``timezone_configured`` is ``False``) the API adopts the zone the
|
||||||
# effective application timezone (the coordinator carries it and the
|
# integration reports from Home Assistant's configured ``time_zone``
|
||||||
# household sensor publishes it for the panel cards) and never derives
|
# (:data:`PANEL_TIMEZONE_HEADER`), so the default is the household's
|
||||||
# the application clock from Home Assistant's configuration; the Home
|
# zone without any manual setup. The integration itself always uses
|
||||||
# Assistant / browser zone applies only when the API publishes no zone
|
# Home Assistant's ``time_zone`` and ignores this field.
|
||||||
# (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 household-local date.
|
behavior for callers with no HA-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 household-local date the
|
``today`` optionally pins the resolved HA-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`` (household-local) is used for BOTH
|
# A caller-supplied ``today`` (HA-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 household-local
|
# holds across midnight rollovers. A pinned HA-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 household-local anchor it compares against. An
|
optionally pins the HA-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 household-local date the sweep last ran, and the window is
|
the HA-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,10 +544,9 @@ 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 household-local anchor the clamp compares
|
optionally pins the HA-local anchor the clamp compares against
|
||||||
against (falling back to the host clock when omitted). Any
|
(falling back to the host clock when omitted). Any instance with
|
||||||
instance with a completion event is never touched, regardless of
|
a completion event is never touched, regardless of its date.
|
||||||
its date.
|
|
||||||
Returns the number deleted.
|
Returns the number deleted.
|
||||||
"""
|
"""
|
||||||
_validate_date(cutoff_date, "cutoff_date")
|
_validate_date(cutoff_date, "cutoff_date")
|
||||||
@@ -582,9 +581,8 @@ 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 household-local anchor
|
docstring). ``today`` optionally pins the HA-local anchor the clamp
|
||||||
the clamp compares against (falling back to the host clock when
|
compares against (falling back to the host clock when omitted).
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -39,11 +39,10 @@ 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 household-local
|
Timezone policy: the day-complete check needs the HA-local "today",
|
||||||
"today", which the caller passes in as a plain :class:`datetime.date`.
|
which the caller passes in as a plain :class:`datetime.date`. Nothing
|
||||||
Nothing here reads a timezone — the API owns the clock (the
|
here reads ``hass.config.time_zone`` — the integration owns HA
|
||||||
admin-stored application timezone, or the API host's local zone when
|
timezone, the core owns the calendar arithmetic.
|
||||||
none is stored), the core owns the calendar arithmetic.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -148,10 +147,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 household-local calendar date the day-complete
|
``today`` is the HA-local calendar date the day-complete rule is
|
||||||
rule is evaluated against; the API resolves it in the application
|
evaluated against; the integration reads it from
|
||||||
timezone and passes it in, so this module never reads a clock or a
|
``hass.config.time_zone`` and passes it in, so this module never
|
||||||
timezone of its own.
|
touches HA config.
|
||||||
"""
|
"""
|
||||||
# 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
|
||||||
|
|||||||
@@ -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 household-local date, or the host calendar date."""
|
"""Return the threaded HA-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 household-local date
|
its reversal keep their order). ``today`` pins the HA-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 household-local date the walk's
|
``today`` optionally pins the resolved HA-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 the
|
historical default. A caller that computed the horizon in Home
|
||||||
household's application time zone MUST pass that same date here so a
|
Assistant's configured 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 household-local anchor when the
|
# Clamp a past start up to today (the HA-local anchor when the caller
|
||||||
# caller pinned one, else the host clock) so the walk can never emit a
|
# 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 household-local date
|
range. ``today`` optionally pins a caller-resolved HA-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 household-local date, threaded
|
``today`` optionally pins a caller-resolved HA-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-household time-zone consistency. ``horizon_days`` sizes the
|
host-vs-HA 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 household-local date the
|
``today`` optionally pins the caller-resolved HA-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 household-local date threaded
|
``today`` optionally pins the caller-resolved HA-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 household-local date threaded
|
``today`` optionally pins the caller-resolved HA-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 household-local date threaded
|
``today`` optionally pins the caller-resolved HA-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.
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ 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,
|
||||||
@@ -56,6 +57,7 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -241,6 +243,12 @@ _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,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -271,6 +279,10 @@ 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
|
||||||
@@ -303,6 +315,7 @@ 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):
|
||||||
@@ -316,11 +329,12 @@ 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 — only ever by an admin, from the admin
|
:attr:`timezone` set — the admin's choice, or else the zone the
|
||||||
app — the SAME instant is re-expressed in that zone, so
|
integration reports from Home Assistant — the SAME instant is
|
||||||
``.date()`` and ``.time()`` are the household's wall clock even
|
re-expressed in that zone, so ``.date()`` and ``.time()`` are the
|
||||||
when the host runs UTC. With :attr:`timezone` empty the instant
|
household's wall clock even when the host runs UTC. With
|
||||||
is returned unchanged (the host's local time).
|
:attr:`timezone` empty the instant is returned unchanged (the
|
||||||
|
host's local time).
|
||||||
"""
|
"""
|
||||||
if not self.timezone:
|
if not self.timezone:
|
||||||
return instant
|
return instant
|
||||||
@@ -340,7 +354,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
|
||||||
API passes the household-local date (:meth:`household_now`) so
|
integration passes the HA-local ``hass.config.time_zone`` date 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,8 +33,12 @@ 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 ``""``. Only an
|
this field existed simply lacks it and resolves to ``""``,
|
||||||
admin's explicit update ever writes it; nothing sets it automatically.
|
- ``timezone_configured`` — a real boolean: ``True`` once the admin
|
||||||
|
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.
|
||||||
@@ -176,7 +180,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, eleven-field) document is persisted
|
settings and the merged (complete, twelve-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.
|
||||||
@@ -200,6 +204,28 @@ 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,
|
||||||
|
|||||||
@@ -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 household-local ``now`` (a single
|
database, the settings object, and the HA-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,11 +96,6 @@ 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(
|
||||||
@@ -187,7 +182,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 household-local timezone-aware datetime the caller already
|
``now`` is the HA-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
|
||||||
@@ -324,5 +319,4 @@ 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,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,10 +39,9 @@ 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, which the API reads in the
|
household-local calendar date, read from ``hass.config.time_zone`` by
|
||||||
admin-stored application timezone (the API host's local zone when none
|
the integration or from the configured household timezone by the API.
|
||||||
is stored). This module never reads a timezone database or config of
|
This module never reads a timezone database or config of its own.
|
||||||
its own.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -98,9 +97,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 API reads it in the admin-stored
|
(the caller owns the clock: the integration reads it from
|
||||||
application timezone, falling back to its host's local time when
|
``hass.config.time_zone``, the API from the configured household
|
||||||
unset).
|
timezone, falling back to its host's local time when 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
|
||||||
|
|||||||
@@ -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.10.3"
|
"version": "0.9.5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -441,10 +441,6 @@ 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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -105,25 +105,14 @@ 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 search over the **full** Font Awesome Free *solid* set (every
|
- **Font Awesome** — a grid of a curated subset of the Font Awesome Free *solid* set,
|
||||||
glyph in `@fortawesome/free-solid-svg-icons`), plus a curated quick-pick grid, all drawn
|
drawn filled.
|
||||||
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 `&`.
|
||||||
|
|
||||||
The curated Lucide set and the Font Awesome quick-pick list come from
|
Both curated lists come from `tools/icons/curated-icons.json`; their glyph path data is
|
||||||
`tools/icons/curated-icons.json`; `tools/icons/generate.mjs` generates their glyph path
|
generated into the admin and panel bundles by `tools/icons/generate.mjs` (no CDN, no
|
||||||
data, together with the full Font Awesome Free Solid set, into the admin and panel bundles
|
runtime fetch). See `THIRD-PARTY-NOTICES.md` for licences.
|
||||||
(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**:
|
||||||
|
|
||||||
|
|||||||
@@ -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 the curated Lucide set, the full
|
- Icon per quest definition is chosen by the parent from curated Lucide and Font Awesome
|
||||||
Font Awesome Free Solid set (searchable, with curated quick picks) or as an emoji, stored as a namespaced key (`lucide:`/`fa:`/`emoji:`) —
|
Free sets 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.
|
||||||
|
|||||||
@@ -106,8 +106,7 @@ 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 Font Awesome Free Solid glyph **filled** (any icon in the full
|
round caps and joins); `fa:<name>` draws the curated Font Awesome glyph **filled**;
|
||||||
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
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
// 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);
|
|
||||||
}
|
|
||||||
@@ -99,38 +99,6 @@ 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
|
||||||
|
|||||||
@@ -937,18 +937,8 @@ 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 {
|
||||||
const stored = this._state("sensor.nestquest_household_quests_due_today")
|
return hassTimeZone(this.hass);
|
||||||
?.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 {
|
||||||
|
|||||||
@@ -1749,18 +1749,8 @@ 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 {
|
||||||
const stored = this._state("sensor.nestquest_household_quests_due_today")
|
return hassTimeZone(this.hass);
|
||||||
?.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 {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Lit rendering for the shared icon registry (`registry.generated.ts`,
|
* Lit rendering for the shared icon registry (`registry.generated.ts`,
|
||||||
* generated by tools/icons/generate.mjs: the curated Lucide set plus the full
|
* generated from tools/icons/curated-icons.json). The glyph path data is
|
||||||
* Font Awesome Free Solid set). The glyph path data is bundled — no CDN and
|
* bundled — no CDN and no runtime fetch.
|
||||||
* no runtime fetch.
|
|
||||||
*/
|
*/
|
||||||
import { html, svg, type TemplateResult } from "lit";
|
import { html, svg, type TemplateResult } from "lit";
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,13 @@
|
|||||||
*
|
*
|
||||||
* 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?, now?};
|
* The spec JSON carries {bundle, tag, config, hass, url?, click_plate?};
|
||||||
* 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. With now: <ISO instant> the card's clock is pinned there
|
* tap. The Python panel tests drive this harness so the assertions run
|
||||||
* (argument-less new Date() and Date.now() return it; the process TZ
|
* against the exact bundle HACS ships.
|
||||||
* 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";
|
||||||
@@ -41,23 +39,6 @@ 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);
|
||||||
|
|||||||
@@ -16,12 +16,6 @@
|
|||||||
* 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.
|
||||||
*/
|
*/
|
||||||
@@ -53,33 +47,6 @@ 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 = [];
|
||||||
@@ -156,18 +123,6 @@ if (spec.idle_ms !== undefined) {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
|
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
const settled = snapshot();
|
const payload = JSON.stringify({ ...snapshot(), probe, tap, returned });
|
||||||
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);
|
||||||
|
Before Width: | Height: | Size: 375 KiB |
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nestquest"
|
name = "nestquest"
|
||||||
version = "0.10.3"
|
version = "0.9.5"
|
||||||
description = "NestQuest Home Assistant Integration"
|
description = "NestQuest Home Assistant Integration"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -879,29 +879,6 @@ 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.
|
||||||
@@ -933,7 +910,6 @@ 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": [],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1040,8 +1016,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 for the fake Home Assistant config (the
|
# A valid IANA time zone (the day-rollover listener reads it to
|
||||||
# NestQuest application timezone is the admin-set setting, not this).
|
# compute "today" in HA local time).
|
||||||
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
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ 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 eleven documented fields at their defaults on a fresh database,
|
— the twelve 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),
|
||||||
@@ -41,7 +40,6 @@ 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,
|
||||||
@@ -61,14 +59,9 @@ _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 eleven fields plus the read-only ``effective_timezone``,
|
#: documented twelve fields, asserted literally so the payload contract is
|
||||||
#: asserted literally so the payload contract is pinned here, independent
|
#: pinned here, independent of the core constants.
|
||||||
#: of the core constants.
|
|
||||||
DEFAULT_SETTINGS = {
|
DEFAULT_SETTINGS = {
|
||||||
"horizon_days": 14,
|
"horizon_days": 14,
|
||||||
"day_rollover_time": "00:00",
|
"day_rollover_time": "00:00",
|
||||||
@@ -81,34 +74,23 @@ DEFAULT_SETTINGS = {
|
|||||||
"end_of_day_report_enabled": True,
|
"end_of_day_report_enabled": True,
|
||||||
"celebration_enabled": True,
|
"celebration_enabled": True,
|
||||||
"timezone": "",
|
"timezone": "",
|
||||||
"effective_timezone": API_HOST_ZONE,
|
"timezone_configured": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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, monkeypatch) -> SimpleNamespace:
|
async def settings_client(temp_db_path: str) -> 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. The API
|
can read (or corrupt) the stored settings document itself.
|
||||||
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(
|
||||||
@@ -123,7 +105,7 @@ async def settings_client(temp_db_path: str, monkeypatch) -> 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 eleven documented fields at their defaults."""
|
"""GET returns the twelve 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()
|
||||||
)
|
)
|
||||||
@@ -165,9 +147,7 @@ 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
|
||||||
stored = dict(expected)
|
assert json.loads(raw) == 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(
|
||||||
@@ -243,38 +223,27 @@ 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_default_settings_carry_no_timezone_configured_marker(
|
async def test_timezone_configured_round_trips_with_an_explicit_empty_zone(
|
||||||
settings_client: SimpleNamespace,
|
settings_client: SimpleNamespace,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""The removed auto-set marker is absent from the default response."""
|
"""An explicit empty (host-local) zone persists as configured.
|
||||||
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
|
||||||
@pytest.mark.parametrize("zone", ["America/New_York", ""])
|
``timezone_configured: true``; the NEXT read — a reopened settings
|
||||||
async def test_manual_timezone_patch_persists_and_is_returned(
|
screen — sees the marker, so the empty value is a persisted choice
|
||||||
settings_client: SimpleNamespace, zone: str
|
rather than a fresh install to auto-set.
|
||||||
) -> None:
|
"""
|
||||||
"""An admin's timezone PATCH is stored exactly as given and read back."""
|
|
||||||
client = settings_client.client
|
client = settings_client.client
|
||||||
seeded = await client.patch(
|
fresh = await client.get("/api/v1/admin/settings", headers=_admin_headers())
|
||||||
"/api/v1/admin/settings",
|
assert fresh.json()["timezone_configured"] is False
|
||||||
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": zone},
|
json={"timezone": "", "timezone_configured": True},
|
||||||
headers=_admin_headers(),
|
headers=_admin_headers(),
|
||||||
)
|
)
|
||||||
assert patched.status_code == 200
|
assert patched.status_code == 200
|
||||||
expected = _with_zone(zone)
|
expected = {**DEFAULT_SETTINGS, "timezone_configured": True}
|
||||||
assert patched.json() == expected
|
assert patched.json() == expected
|
||||||
|
|
||||||
reread = await client.get(
|
reread = await client.get(
|
||||||
@@ -285,61 +254,7 @@ async def test_manual_timezone_patch_persists_and_is_returned(
|
|||||||
SETTINGS_META_KEY
|
SETTINGS_META_KEY
|
||||||
)
|
)
|
||||||
assert raw is not None
|
assert raw is not None
|
||||||
stored = json.loads(raw)
|
assert json.loads(raw)["timezone_configured"] is True
|
||||||
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 -------
|
||||||
@@ -358,9 +273,8 @@ async def test_stored_legacy_timezone_configured_key_is_ignored(
|
|||||||
({"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": "Mars/Olympus_Mons"}, "timezone"),
|
({"timezone_configured": "true"}, "timezone_configured"),
|
||||||
({"timezone": "America/New York"}, "timezone"),
|
({"timezone_configured": 1}, "timezone_configured"),
|
||||||
({"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(
|
||||||
@@ -515,13 +429,6 @@ 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()
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from api import host_zone, routes_admin, routes_panel
|
from api import 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,8 +101,6 @@ 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(
|
||||||
@@ -167,10 +165,7 @@ 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
|
the admin body equals the panel body for the same household.
|
||||||
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
|
||||||
@@ -246,7 +241,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, "timezone": "UTC"}
|
assert panel.json() == body
|
||||||
|
|
||||||
|
|
||||||
async def test_admin_keeps_missed_instance_panel_omits_it(
|
async def test_admin_keeps_missed_instance_panel_omits_it(
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import types
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from conftest import FakeClientTimeout, make_config_entry
|
from conftest import 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,10 +33,6 @@ 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"
|
||||||
|
|
||||||
@@ -128,7 +124,6 @@ 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:
|
||||||
@@ -201,27 +196,25 @@ 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_requests_carry_only_the_bearer_header() -> None:
|
async def test_household_timezone_rides_every_request_header() -> None:
|
||||||
"""No timezone is reported to the API (task 7f685179): the Bearer
|
"""Home Assistant's zone is reported so the API reads the household
|
||||||
credential is the only header on every request."""
|
clock (task 6351b36d); it is sent only when the client has one."""
|
||||||
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 = make_client(transport)
|
client = NestQuestApiClient(
|
||||||
|
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"] == {"Authorization": f"Bearer {TOKEN}"}
|
assert call["headers"] == {
|
||||||
|
"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:
|
||||||
@@ -420,13 +413,20 @@ def test_client_from_entry_reads_config_and_shared_session(monkeypatch) -> None:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# A bare object: building the client must not read hass.config.
|
hass = types.SimpleNamespace(
|
||||||
client = api_client.client_from_entry(object(), entry)
|
config=types.SimpleNamespace(time_zone="America/New_York")
|
||||||
|
)
|
||||||
|
|
||||||
|
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._headers() == {"Authorization": "Bearer entry-token"}
|
assert client._household_timezone == "America/New_York"
|
||||||
|
|
||||||
|
|
||||||
|
#: 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(object(), entry)
|
api_client.client_from_entry(_HASS_UTC, 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(object(), entry_ws)
|
api_client.client_from_entry(_HASS_UTC, entry_ws)
|
||||||
|
|
||||||
|
|
||||||
async def test_client_logs_nothing() -> None:
|
async def test_client_logs_nothing() -> None:
|
||||||
@@ -478,108 +478,3 @@ 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
|
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
"""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"
|
|
||||||
@@ -19,8 +19,6 @@ 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
|
||||||
|
|
||||||
@@ -32,7 +30,7 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from api import host_zone, routes_admin, routes_panel
|
from api import 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
|
||||||
@@ -49,7 +47,6 @@ _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"
|
||||||
@@ -87,13 +84,7 @@ 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(
|
yield SimpleNamespace(client=client, seed=seed, clock=clock)
|
||||||
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:
|
||||||
@@ -246,14 +237,23 @@ def test_from_options_reads_and_resiliently_defaults_timezone() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_settings_have_no_timezone_configured_marker() -> None:
|
def test_settings_timezone_configured_defaults_false_and_is_strict() -> None:
|
||||||
assert not hasattr(_settings.NestQuestSettings(), "timezone_configured")
|
assert _settings.NestQuestSettings().timezone_configured is False
|
||||||
with pytest.raises(TypeError):
|
assert _settings.NestQuestSettings.from_options({}).timezone_configured is False
|
||||||
_settings.NestQuestSettings(timezone_configured=True)
|
assert (
|
||||||
# A stored document still carrying the removed key loads harmlessly.
|
_settings.NestQuestSettings.from_options(
|
||||||
assert _settings.NestQuestSettings.from_options_resilient(
|
{"timezone_configured": True}
|
||||||
{"timezone": HOUSEHOLD_ZONE, "timezone_configured": True}
|
).timezone_configured
|
||||||
) == _settings.NestQuestSettings(timezone=HOUSEHOLD_ZONE)
|
is True
|
||||||
|
)
|
||||||
|
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,29 +329,37 @@ def test_delay_seconds_is_true_elapsed_time_across_dst(
|
|||||||
assert _delay_seconds(target, now) == expected
|
assert _delay_seconds(target, now) == expected
|
||||||
|
|
||||||
|
|
||||||
# --- nothing sets the zone automatically (task 7f685179) --------------------
|
# --- the Home Assistant-reported zone (task 6351b36d) ------------------------
|
||||||
#
|
#
|
||||||
# The zone is only ever set by an admin's explicit settings update. A
|
# The deployed household never set ``timezone``, so the UTC API host marked
|
||||||
# timezone header on a panel request — the one Home Assistant used to
|
# morning quests overdue. The integration reports Home Assistant's
|
||||||
# report, or any look-alike — is ignored, and no read writes settings.
|
# ``time_zone`` on every panel request; while the admin has not chosen a
|
||||||
|
# zone the API adopts it (persisted, so the scheduler and the admin plane
|
||||||
#: Timezone-looking request headers a panel request might carry.
|
# follow too), and an explicit admin choice still wins.
|
||||||
_TIMEZONE_HEADERS = [
|
|
||||||
{"X-NestQuest-Timezone": HOUSEHOLD_ZONE},
|
|
||||||
{"X-NestQuest-Time-Zone": HOUSEHOLD_ZONE},
|
|
||||||
{"X-Timezone": HOUSEHOLD_ZONE},
|
|
||||||
{"Time-Zone": HOUSEHOLD_ZONE},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def _stored_document(household: SimpleNamespace) -> str | None:
|
def _reporting_panel_headers(zone: str) -> dict[str, str]:
|
||||||
"""The raw stored settings document (``None`` when never written)."""
|
return {**_panel_headers(), "X-NestQuest-Timezone": zone}
|
||||||
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 _admin_settings(household: SimpleNamespace) -> dict:
|
async def _stored_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()
|
||||||
)
|
)
|
||||||
@@ -359,73 +367,99 @@ async def _admin_settings(household: SimpleNamespace) -> dict:
|
|||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("extra", _TIMEZONE_HEADERS)
|
async def test_unset_zone_adopts_reported_zone_for_overdue(
|
||||||
async def test_panel_timezone_header_never_changes_settings(
|
|
||||||
household: SimpleNamespace, extra: dict[str, str]
|
|
||||||
) -> None:
|
|
||||||
"""09:59:59 EDT on a UTC host, zone unset: the header is ignored."""
|
|
||||||
household.clock["now"] = _utc(13, 59, 59)
|
|
||||||
headers = {**_panel_headers(), **extra}
|
|
||||||
snapshot = await household.client.get(
|
|
||||||
"/api/v1/panel/snapshot", headers=headers
|
|
||||||
)
|
|
||||||
assert snapshot.status_code == 200
|
|
||||||
seed = household.seed
|
|
||||||
completed = await household.client.post(
|
|
||||||
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
|
|
||||||
headers=headers,
|
|
||||||
json={"actor_child_id": seed.ada.id},
|
|
||||||
)
|
|
||||||
assert completed.status_code == 200
|
|
||||||
# Nothing was written, and the clock stayed host local (UTC): the
|
|
||||||
# 10:00 quest was overdue and the 13:59 UTC completion was late.
|
|
||||||
assert await _stored_document(household) is None
|
|
||||||
assert (await _admin_settings(household))["timezone"] == ""
|
|
||||||
_today_iso, instance = await _pack_bag(household, "admin")
|
|
||||||
assert instance["on_time"] is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("extra", _TIMEZONE_HEADERS)
|
|
||||||
async def test_panel_timezone_header_never_replaces_the_stored_zone(
|
|
||||||
household: SimpleNamespace, extra: dict[str, str]
|
|
||||||
) -> None:
|
|
||||||
"""A stored zone stays exactly as the admin saved it."""
|
|
||||||
await _set_zone(household, "America/Los_Angeles")
|
|
||||||
before = await _stored_document(household)
|
|
||||||
household.clock["now"] = _utc(16, 59, 59) # 09:59:59 PDT, 12:59:59 EDT
|
|
||||||
response = await household.client.get(
|
|
||||||
"/api/v1/panel/snapshot",
|
|
||||||
headers={**_panel_headers(), **extra},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert await _stored_document(household) == before
|
|
||||||
_today_iso, instance = await _pack_bag(household, "panel")
|
|
||||||
assert instance["overdue"] is False
|
|
||||||
|
|
||||||
|
|
||||||
async def test_fresh_install_zone_stays_empty_across_reads(
|
|
||||||
household: SimpleNamespace,
|
household: SimpleNamespace,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Repeated panel and admin reads never write the settings document."""
|
"""09:59:59 EDT on a UTC host, zone never set: NOT overdue."""
|
||||||
for hour in (3, 13, 23):
|
household.clock["now"] = _utc(13, 59, 59)
|
||||||
household.clock["now"] = _utc(hour, 0)
|
today_iso, instance = await _panel_pack_bag(household, HOUSEHOLD_ZONE)
|
||||||
for extra in _TIMEZONE_HEADERS:
|
assert today_iso == TODAY.isoformat()
|
||||||
response = await household.client.get(
|
assert instance["overdue"] is False
|
||||||
"/api/v1/panel/snapshot",
|
# Persisted as the automatic (not admin-chosen) household zone, so
|
||||||
headers={**_panel_headers(), **extra},
|
# the admin plane and the missed-sweep scheduler read it too.
|
||||||
)
|
stored = await _stored_settings(household)
|
||||||
assert response.status_code == 200
|
assert stored["timezone"] == HOUSEHOLD_ZONE
|
||||||
await _pack_bag(household, "admin")
|
assert stored["timezone_configured"] is False
|
||||||
settings = await _admin_settings(household)
|
_today, admin_instance = await _pack_bag(household, "admin")
|
||||||
assert settings["timezone"] == ""
|
assert admin_instance["overdue"] is False
|
||||||
assert "timezone_configured" not in settings
|
|
||||||
assert await _stored_document(household) is None
|
|
||||||
|
|
||||||
|
|
||||||
# --- the scheduler follows a changed zone (task 6351b36d) --------------------
|
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
|
||||||
|
response = await household.client.post(
|
||||||
|
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
|
||||||
|
headers=_reporting_panel_headers(HOUSEHOLD_ZONE),
|
||||||
|
json={"actor_child_id": seed.ada.id},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
_today_iso, instance = await _pack_bag(household, "admin")
|
||||||
|
assert instance["on_time"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reported_zone_follows_home_assistant_changes(
|
||||||
|
household: SimpleNamespace,
|
||||||
|
) -> None:
|
||||||
|
"""An automatically adopted zone tracks a later Home Assistant change."""
|
||||||
|
await _panel_pack_bag(household, "America/Chicago")
|
||||||
|
await _panel_pack_bag(household, HOUSEHOLD_ZONE)
|
||||||
|
assert (await _stored_settings(household))["timezone"] == HOUSEHOLD_ZONE
|
||||||
|
|
||||||
|
|
||||||
|
async def _adopt_new_york(household: SimpleNamespace) -> None:
|
||||||
|
"""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
|
||||||
|
# A conflicting report is no longer adopted.
|
||||||
|
await _panel_pack_bag(household, "America/Chicago")
|
||||||
|
stored = await _stored_settings(household)
|
||||||
|
assert stored["timezone"] == chosen
|
||||||
|
assert stored["timezone_configured"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invalid_reported_zone_is_ignored(
|
||||||
|
household: SimpleNamespace,
|
||||||
|
) -> None:
|
||||||
|
await _adopt_new_york(household)
|
||||||
|
household.clock["now"] = _utc(13, 59, 59)
|
||||||
|
_today_iso, instance = await _panel_pack_bag(household, "Mars/Olympus_Mons")
|
||||||
|
# Still read in the adopted zone: 09:59:59 EDT is not overdue.
|
||||||
|
assert instance["overdue"] is False
|
||||||
|
assert (await _stored_settings(household))["timezone"] == HOUSEHOLD_ZONE
|
||||||
|
|
||||||
|
|
||||||
|
# --- the scheduler follows an adopted 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 stored while it sleeps must move the sweep to the
|
# midnight; a zone adopted 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.
|
||||||
@@ -434,20 +468,11 @@ _UTC_MIDNIGHT_DELAY = 11 * 3600
|
|||||||
_NEW_YORK_MIDNIGHT_DELAY = 15 * 3600
|
_NEW_YORK_MIDNIGHT_DELAY = 15 * 3600
|
||||||
|
|
||||||
|
|
||||||
async def _store_zone(scheduler, database, zone: str) -> None:
|
async def test_scheduler_replans_when_panel_adopts_zone_while_sleeping(
|
||||||
"""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:
|
async with runner as client:
|
||||||
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] = []
|
||||||
@@ -469,9 +494,11 @@ async def test_scheduler_replans_when_zone_changes_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
|
||||||
)
|
)
|
||||||
await _store_zone(
|
response = await client.get(
|
||||||
scheduler, app_state.db.database, HOUSEHOLD_ZONE
|
"/api/v1/panel/snapshot",
|
||||||
|
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
|
||||||
@@ -509,8 +536,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.update_settings(
|
await _settings_store.adopt_reported_timezone(
|
||||||
database, {"timezone": HOUSEHOLD_ZONE}
|
database, HOUSEHOLD_ZONE
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
replanned.set()
|
replanned.set()
|
||||||
@@ -533,152 +560,12 @@ async def test_scheduler_rechecks_settings_before_sweeping(
|
|||||||
await database.close()
|
await database.close()
|
||||||
|
|
||||||
|
|
||||||
# --- the admin settings route is the scheduler's writer (task 7e173881) ------
|
# --- adoption is serialised with planning and sweeping (TZ-003) --------------
|
||||||
#
|
#
|
||||||
# ``PATCH /api/v1/admin/settings`` is the only settings writer: it holds
|
# An adoption racing the loop's own settings reads must never leave a
|
||||||
# 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 locked zone write there; when the scheduler's settings lock
|
# issues a real panel adoption there; when the scheduler's settings lock
|
||||||
# is held at that point the write provably cannot commit until the
|
# is held at that point the adoption 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.
|
||||||
|
|
||||||
|
|
||||||
@@ -701,12 +588,24 @@ def _hold_scheduler_read(monkeypatch, scheduler, nth: int, during):
|
|||||||
return real_load
|
return real_load
|
||||||
|
|
||||||
|
|
||||||
async def _store_inside_read(scheduler, database, zone: str) -> asyncio.Task:
|
async def _adopt_via(client, zone: str) -> None:
|
||||||
"""Start a locked write of ``zone``; let it commit unless serialised."""
|
response = await client.get(
|
||||||
write = asyncio.create_task(_store_zone(scheduler, database, zone))
|
"/api/v1/panel/snapshot", headers=_reporting_panel_headers(zone)
|
||||||
if not scheduler.settings_lock.locked():
|
)
|
||||||
await write
|
assert response.status_code == 200
|
||||||
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(
|
||||||
@@ -714,9 +613,8 @@ 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:
|
async with runner as client:
|
||||||
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()
|
||||||
@@ -728,15 +626,15 @@ async def test_scheduler_follows_successive_zone_changes(
|
|||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
scheduler = MissedSweepScheduler(
|
scheduler = MissedSweepScheduler(
|
||||||
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
|
||||||
await _store_zone(scheduler, database, HOUSEHOLD_ZONE)
|
await _adopt_via(client, HOUSEHOLD_ZONE)
|
||||||
writes: list[asyncio.Task] = []
|
adoptions: list[asyncio.Task] = []
|
||||||
|
|
||||||
async def back_to_new_york(_settings) -> None:
|
async def back_to_new_york(_settings) -> None:
|
||||||
writes.append(
|
adoptions.append(
|
||||||
await _store_inside_read(scheduler, database, HOUSEHOLD_ZONE)
|
await _adopt_inside_read(client, scheduler, 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.
|
||||||
@@ -747,16 +645,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 _store_zone(scheduler, database, "UTC")
|
await _adopt_via(client, "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
|
||||||
)
|
)
|
||||||
(write,) = writes
|
(adoption,) = adoptions
|
||||||
await write
|
assert (await adoption).status_code == 200
|
||||||
finally:
|
finally:
|
||||||
await scheduler.stop()
|
await scheduler.stop()
|
||||||
# The in-flight read planned UTC; the New York write that followed
|
# The in-flight read planned UTC; the New York adoption 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),
|
||||||
@@ -765,12 +663,12 @@ async def test_scheduler_follows_successive_zone_changes(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def test_zone_write_after_presweep_check_cannot_precede_the_sweep(
|
async def test_adoption_after_presweep_check_cannot_precede_the_sweep(
|
||||||
temp_db_path: str, monkeypatch
|
temp_db_path: str, monkeypatch
|
||||||
) -> None:
|
) -> None:
|
||||||
"""No sweep runs once a zone write has replaced its plan."""
|
"""No sweep runs once an adoption 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:
|
async with runner as client:
|
||||||
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] = []
|
||||||
@@ -787,18 +685,16 @@ async def test_zone_write_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
|
||||||
writes: list[asyncio.Task] = []
|
adoptions: list[asyncio.Task] = []
|
||||||
|
|
||||||
async def store_new_york(_settings) -> None:
|
async def adopt_new_york(_settings) -> None:
|
||||||
writes.append(
|
adoptions.append(
|
||||||
await _store_inside_read(
|
await _adopt_inside_read(client, scheduler, HOUSEHOLD_ZONE)
|
||||||
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, store_new_york
|
monkeypatch, scheduler, 2, adopt_new_york
|
||||||
)
|
)
|
||||||
swept_under: list[str] = []
|
swept_under: list[str] = []
|
||||||
|
|
||||||
@@ -812,12 +708,12 @@ async def test_zone_write_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)
|
||||||
(write,) = writes
|
(adoption,) = adoptions
|
||||||
await write
|
assert (await adoption).status_code == 200
|
||||||
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 stored; the next plan is New York midnight.
|
# New York had been adopted; 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),
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ 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,123 +0,0 @@
|
|||||||
"""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
|
|
||||||
)
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -55,7 +55,6 @@ 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": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
"""Household-local 11:00, due 12:00, is on time even when UTC is 19:00.
|
"""HA-local 11:00 with due_time 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.
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ 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,
|
||||||
@@ -687,8 +686,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 not reported to the API (task 7f685179).
|
# Home Assistant's zone is reported to the API (task 6351b36d).
|
||||||
assert real_client._headers() == {"Authorization": "Bearer tok"}
|
assert real_client._household_timezone == hass.config.time_zone
|
||||||
|
|
||||||
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
|
||||||
@@ -794,37 +793,8 @@ 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 == ""
|
|
||||||
|
|||||||
@@ -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 its own local zone directly.
|
# snapshot), so the test resolves the HA-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,7 +332,6 @@ 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,
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ 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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ 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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ 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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,75 +63,6 @@ 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")
|
||||||
|
|||||||
@@ -940,15 +940,14 @@ 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 today.
|
"""The clamp and no-past guard compare against a caller-supplied HA-local 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
|
||||||
household-local "today" one calendar day earlier than the host's
|
HA-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 household-local date the effective start is the host's
|
a pinned HA-local date the effective start is the host's today (one
|
||||||
today (one day after ha_today), so the now-past day is skipped
|
day after ha_today), so the now-past day is skipped rather than
|
||||||
rather than rejected, and only the host-today-onward days
|
rejected, and only the host-today-onward days materialize.
|
||||||
materialize.
|
|
||||||
"""
|
"""
|
||||||
async def _body(database):
|
async def _body(database):
|
||||||
children = ChildrenDao(database)
|
children = ChildrenDao(database)
|
||||||
@@ -964,9 +963,9 @@ def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None:
|
|||||||
["morning"],
|
["morning"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Without a pinned household-local today, the walk clamps its start
|
# Without a pinned HA-local today, the walk clamps its start to the
|
||||||
# to the host's today (one day after ha_today); the past day is
|
# host's today (one day after ha_today); the past day is dropped and
|
||||||
# dropped and the 14 host-today-onward days materialize.
|
# 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
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ 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
|
||||||
@@ -567,8 +566,6 @@ 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.
|
||||||
|
|
||||||
@@ -577,9 +574,7 @@ 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. ``now`` pins the card's clock to an ISO instant
|
the optional tap.
|
||||||
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()
|
||||||
@@ -593,15 +588,12 @@ 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}"
|
||||||
@@ -839,9 +831,6 @@ 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.
|
||||||
|
|
||||||
@@ -851,10 +840,7 @@ 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). ``now`` pins the card's clock to an ISO
|
``probe_ms`` was given).
|
||||||
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()
|
||||||
@@ -872,17 +858,12 @@ 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}"
|
||||||
@@ -1109,7 +1090,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 — overdue as the API snapshot says."""
|
``class="meta late"`` overdue."""
|
||||||
generated = _generate_dashboard(
|
generated = _generate_dashboard(
|
||||||
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
|
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
|
||||||
)
|
)
|
||||||
@@ -1162,153 +1143,6 @@ 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
|
||||||
@@ -1431,26 +1265,6 @@ 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."""
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ 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),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,22 +569,6 @@ 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:
|
||||||
|
|||||||
@@ -44,10 +44,6 @@ 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"
|
||||||
|
|
||||||
@@ -155,12 +151,7 @@ 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 = {
|
MINIMAL_SNAPSHOT = {"today_iso": "2026-09-23", "cycle_day": 1, "children": []}
|
||||||
"today_iso": "2026-09-23",
|
|
||||||
"cycle_day": 1,
|
|
||||||
"timezone": "",
|
|
||||||
"children": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -169,29 +160,19 @@ MINIMAL_SNAPSHOT = {
|
|||||||
|
|
||||||
|
|
||||||
class _StreamingResponse:
|
class _StreamingResponse:
|
||||||
"""A fake aiohttp streaming response: ``status`` + ``.content``.
|
"""A fake aiohttp streaming response: ``status`` + chunk iteration.
|
||||||
|
|
||||||
Like aiohttp's ``ClientResponse`` (and HA's ``HassClientResponse``)
|
``chunks`` are the body pieces the stream yields in order; a chunk
|
||||||
the response itself is NOT async-iterable; the body is iterated
|
may be an exception, raised when the iteration reaches it (a
|
||||||
through ``.content``. ``chunks`` are the body pieces the stream
|
mid-stream transport drop). ``hold_open`` keeps the stream open
|
||||||
yields in order; a chunk may be an exception, raised when the
|
forever after the chunks run out, for cancellation tests.
|
||||||
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()
|
||||||
@@ -370,6 +351,24 @@ 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]
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
* 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 for the quick-pick lists), pulls each glyph's SVG path data from
|
* truth), pulls each glyph's SVG path data from locally installed packages in
|
||||||
* locally installed packages in `admin/node_modules` (lucide-react and
|
* `admin/node_modules` (lucide-react and @fortawesome/free-solid-svg-icons —
|
||||||
* @fortawesome/free-solid-svg-icons — no network), and writes the same
|
* no network), and writes the same TypeScript module into both bundles:
|
||||||
* 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
|
||||||
@@ -15,16 +14,6 @@
|
|||||||
*
|
*
|
||||||
* 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";
|
||||||
@@ -100,37 +89,6 @@ 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) {
|
||||||
@@ -158,9 +116,11 @@ 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`);
|
||||||
fa.push(faGlyph(definition, label));
|
const [width, height, , , pathData] = definition.icon;
|
||||||
|
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, faSolid: allSolidGlyphs(faPackage) };
|
return { lucide, fa };
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESOLVER = `
|
const RESOLVER = `
|
||||||
@@ -170,10 +130,7 @@ 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]));
|
||||||
/** 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_ICONS.map((icon) => [icon.name, icon]));
|
||||||
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];
|
||||||
@@ -185,8 +142,7 @@ 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 full Font Awesome Free Solid set
|
* Lucide set, \`fa:<name>\` against the curated Font Awesome set, and
|
||||||
* (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.
|
||||||
@@ -206,7 +162,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, faSolid } = await buildEntries();
|
const { lucide, fa } = 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
|
||||||
@@ -246,13 +202,7 @@ 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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||