Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
135cb9ee7f | ||
|
|
e38c7d6c9c | ||
|
|
05fbfee136 | ||
|
|
a1774cbe77 | ||
|
|
0c9f833863 | ||
|
|
52895199be | ||
|
|
0e726ac64f | ||
|
|
69db92b7b8 | ||
|
|
2db780e3ee | ||
|
|
6e25c9e42c | ||
|
|
427f82a8bd | ||
|
|
94a8dbd8c9 | ||
|
|
62ff614a1e | ||
|
|
48eae05e1f | ||
|
|
55b9ce915b | ||
|
|
80f25b2faf | ||
|
|
29674f7138 | ||
|
|
8a5872c728 | ||
|
|
b792ba50c1 | ||
|
|
49f40d99b6 | ||
|
|
37d66ce5d9 | ||
|
|
40d0e5d8f6 | ||
|
|
07f2eec3bf | ||
|
|
622c407fb6 | ||
|
|
8cfca07755 | ||
|
|
e091e9b1d1 | ||
|
|
4afd07dc46 | ||
|
|
1c1d8508e3 | ||
|
|
d8a7e43dd6 | ||
|
|
d3bf9e270c | ||
|
|
09f15af1c2 | ||
|
|
a90e3cd366 | ||
|
|
ad6e433743 | ||
|
|
1f6b2fe16f | ||
|
|
09972ccbfe | ||
|
|
33fa3b7c17 | ||
|
|
a329d10c0a | ||
|
|
773bee6191 | ||
|
|
1bf2122a4a | ||
|
|
02f4d8c8aa | ||
|
|
305b03e0b1 | ||
|
|
0d07fb4f46 | ||
|
|
b1c14ed7d9 | ||
|
|
09a5fcbcc2 | ||
|
|
d961d2e64f | ||
|
|
5316f95086 | ||
|
|
bdf6d0aca1 | ||
|
|
fb9907ae7f | ||
|
|
dbf410ada3 | ||
|
|
2906da7ed1 | ||
|
|
4f609d7bb9 | ||
|
|
39e0d715fc |
@@ -1,5 +1,425 @@
|
||||
# NestQuest Release Notes
|
||||
|
||||
## Version 0.9.8 — 2026-09-30
|
||||
|
||||
### Scope
|
||||
|
||||
Owner-directed change: the application's timezone is now **manual-only and
|
||||
admin-authoritative**. Nothing sets it automatically any more; an admin sets it
|
||||
from the admin app, and the saved value is the single source of local time for
|
||||
the whole application. This reverses the automatic Home Assistant zone adoption
|
||||
shipped in 0.9.6 and the admin app's browser-zone auto-set, and supersedes
|
||||
decision D-018 with D-019. (PRs #282, #283, #285, #286; feature
|
||||
`a3cd8e27` "Manual application timezone control".)
|
||||
|
||||
### Behaviour
|
||||
|
||||
- **No automatic timezone setting.** Removed: the API's adoption of Home
|
||||
Assistant's reported zone (the `X-NestQuest-Timezone` request header and the
|
||||
`adopt_reported_timezone` path), the integration's reporting of
|
||||
`hass.config.time_zone`, the admin app's browser-zone auto-detect and
|
||||
automatic save, and the `timezone_configured` marker.
|
||||
- **The admin sets it manually**, in the admin app's Preferences: a curated IANA
|
||||
zone picker plus a free "Other IANA zone" entry, with an explicit choice for
|
||||
empty ("API host local time"). Nothing is auto-filled or auto-saved.
|
||||
- **The saved value is authoritative across the whole application**: the panel
|
||||
snapshot's overdue/today derivation, both panel cards' dates and clocks,
|
||||
completion timestamps, the missed-sweep scheduler, and the admin reads all use
|
||||
it. The API publishes the effective zone (`api/host_zone.effective_timezone`:
|
||||
the saved value, else the API host's own zone) on the panel snapshot and the
|
||||
admin settings response, so the panel and admin app render in the same zone.
|
||||
- **The kids' panel now follows the application zone, not the browser.** This
|
||||
supersedes the panel-local overdue behaviour from 0.9.7; a quest is overdue
|
||||
when the application clock says so, not when the panel device's clock does.
|
||||
- **An admin save of the timezone or the day-rollover time re-plans the running
|
||||
missed-sweep scheduler.**
|
||||
- The integration tolerates an API snapshot without the timezone field, so the
|
||||
integration and the API may be upgraded in either order.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Home Assistant floor 2024.6.0 (unchanged).
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- None to the database. The schema is unchanged at version 9: no migration and
|
||||
no database backup is required for this release.
|
||||
- Behavioural reversal: if a timezone had been auto-set by 0.9.6–0.9.7, the
|
||||
stored value persists after upgrading (it is simply no longer changed
|
||||
automatically), so no action is needed unless a different zone is wanted.
|
||||
- With no timezone saved, the API host's local zone applies (UTC on the standard
|
||||
deployment). Set the timezone in the admin Preferences for correct
|
||||
household-local behaviour. A previously auto-set value counts as saved and
|
||||
will be kept.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- If no timezone has been saved, the API host zone applies; on the standard
|
||||
UTC-host deployment, overdue and day boundaries will be wrong until an admin
|
||||
sets the zone.
|
||||
- The admin app's timezone picker replaces any zone outside its curated list via
|
||||
the "Other IANA zone" entry; the value is validated as an IANA name.
|
||||
|
||||
### Rollback
|
||||
|
||||
- No schema change: rolling back to 0.9.7 requires no database restore.
|
||||
|
||||
## Version 0.9.7 — 2026-09-29
|
||||
|
||||
### Scope
|
||||
|
||||
Patch release carrying the panel-local overdue correction onto `main` (PR #279,
|
||||
task fd1093b5 "Tasks are still showing over due when the local time has not
|
||||
passed").
|
||||
|
||||
### Behaviour
|
||||
|
||||
- The kids' Quest Log card now decides whether an open quest is OVERDUE from
|
||||
the panel's OWN local clock (the wall time where the panel physically is)
|
||||
compared with the quest's due time, instead of trusting the API snapshot's
|
||||
`overdue` flag. A quest therefore cannot read OVERDUE before its due time has
|
||||
passed on the panel, regardless of the API host's timezone, Home Assistant's
|
||||
configured `time_zone`, or whether the 0.9.6 timezone changes have been
|
||||
deployed. A quest is overdue at the due minute and after (`>=`); a quest with
|
||||
no due time is never overdue.
|
||||
- The API's `overdue` field is unchanged; it still serves the admin plane and
|
||||
automations. This is a panel display correction only.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Home Assistant floor 2024.6.0 (unchanged).
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- None. The database schema is unchanged at version 9: no migration and no
|
||||
database backup is required for this release.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- The comparison uses only wall-clock time. If the API is unreachable across
|
||||
local midnight and the coordinator serves its last-good snapshot, a quest
|
||||
from the previous day with a later due time can briefly read as not overdue
|
||||
until that time passes again. On the normal path the API rolls the household
|
||||
day over and the missed sweep removes the previous day's open quests.
|
||||
- The Quest Log header clock still formats in Home Assistant's configured
|
||||
`time_zone`; if HA is set to a zone other than the household's, that header
|
||||
remains wrong. Set Home Assistant's own `time_zone` correctly — the admin/API
|
||||
timezone setting does not affect the header clock.
|
||||
|
||||
### Rollback
|
||||
|
||||
- No schema change: rolling back to 0.9.6 requires no database restore.
|
||||
|
||||
## Version 0.9.6 — 2026-09-28
|
||||
|
||||
### Scope
|
||||
|
||||
Patch release carrying the household time-zone fix onto `main` (PR #275, task
|
||||
6351b36d "Time zone errors"), plus its release-review correction (PR #277, task
|
||||
45243478).
|
||||
|
||||
### Behaviour
|
||||
|
||||
- The Home Assistant integration now reports `hass.config.time_zone` to the API
|
||||
in an `X-NestQuest-Timezone` header on every panel request.
|
||||
- While the API's `timezone` setting has never been explicitly chosen
|
||||
(`timezone_configured` false), the API adopts and persists the reported zone
|
||||
(under the settings lock), so the panel's overdue/today calculation, the
|
||||
admin plane and the missed-sweep scheduler are all household-local even when
|
||||
the API host runs UTC. No manual setup is required.
|
||||
- The missed-sweep scheduler re-plans immediately when the adopted zone changes
|
||||
while it is sleeping, and re-reads the stored zone before sweeping, so it no
|
||||
longer fires at the old (UTC) rollover.
|
||||
- An admin-chosen zone — including an explicit choice of empty (API-host local)
|
||||
— still wins over the reported zone. A timezone-only admin update is recorded
|
||||
as an explicit choice (`timezone_configured` true); an explicitly supplied
|
||||
`timezone_configured` value in the same update is honored as given. An invalid
|
||||
reported zone is logged and ignored; the panel keeps serving on the stored
|
||||
settings.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Home Assistant floor 2024.6.0 (unchanged).
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- None. The database schema is unchanged at version 9: no migration and no
|
||||
database backup is required for this release.
|
||||
- Deployment note: this fix needs BOTH the API service and the HA integration
|
||||
upgraded. The integration supplies the zone and the API adopts it; upgrading
|
||||
only one side leaves the previous behaviour. An install that sets or clears
|
||||
its zone on 0.9.6 or later keeps that choice; see Known limitations for a zone
|
||||
stored by an older release.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- A zone stored by a release before 0.9.6 without the explicit-choice marker
|
||||
(for example through the previously documented
|
||||
`PATCH /api/v1/admin/settings {"timezone": ...}`) is not distinguishable from
|
||||
an automatically adopted value, so 0.9.6 adopts Home Assistant's reported zone
|
||||
over it once. If a zone different from Home Assistant's is intended, re-save
|
||||
it in the admin Preferences, which now records the explicit choice.
|
||||
- If `timezone` is empty and `timezone_configured` is true (an explicit
|
||||
"host local" choice), that choice is respected and the API host's zone (UTC
|
||||
on the standard deployment) still applies until an admin selects a zone in
|
||||
the admin Preferences.
|
||||
- A change to Home Assistant's own `time_zone` takes effect after the
|
||||
integration reloads or Home Assistant restarts.
|
||||
|
||||
### Rollback
|
||||
|
||||
- No schema change: rolling back to 0.9.5 requires no database restore. The API
|
||||
and the integration may be rolled back independently; rolling back only the
|
||||
integration restores the previous stored-setting-only behaviour.
|
||||
|
||||
## Version 0.9.5 — 2026-09-27
|
||||
|
||||
### Scope
|
||||
|
||||
Patch release carrying the kids' panel Return button onto `main`.
|
||||
|
||||
- **Return button:** the Quest Log header now shows an explicit Return control —
|
||||
a left chevron plus "The Party", 72px high and sized to its label, sitting left
|
||||
of the crest — so a child
|
||||
can go straight back to the main Party board instead of waiting out the 40 s
|
||||
idle return. It calls the same navigation the idle return uses
|
||||
(`board_path` + `location-changed`), so there is exactly one navigation path.
|
||||
It is hidden when no board path is configured, appears on every header-bearing
|
||||
Quest Log screen (normal, away, and "no quests today"), and does not appear on
|
||||
the Quest-complete screen, which keeps its 12 s auto-return. The control meets
|
||||
the panel's hard rules (72px tap target, 26px label, `:active` feedback only,
|
||||
clear of the window columns and the dock), and the 40 s idle return and
|
||||
crest-tap navigation are unchanged. (PR #272.)
|
||||
|
||||
### Upgrade note
|
||||
|
||||
The database schema is unchanged at version **9**; no migration or backup step is
|
||||
needed. No runtime dependency is added — only the bundled panel card changed.
|
||||
|
||||
### Requirements
|
||||
|
||||
Home Assistant 2024.6.0 or newer. Install through HACS from the
|
||||
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
|
||||
mirror).
|
||||
|
||||
## Version 0.9.4 — 2026-09-26
|
||||
|
||||
### Scope
|
||||
|
||||
Patch release fixing the admin timezone control that shipped in 0.9.3.
|
||||
|
||||
- **Clearing the timezone now persists:** in 0.9.3 the Settings screen
|
||||
re-detected the browser zone whenever the timezone field was empty, so an admin
|
||||
could not leave it empty ("use the API host's local time") — closing and
|
||||
reopening the screen set it again. A new persisted, API-only
|
||||
`timezone_configured` marker (default `false`) now distinguishes "never set"
|
||||
from "admin chose empty": the one-time auto-set runs only when the timezone is
|
||||
empty AND the marker is false, and any saved timezone change — including
|
||||
clearing it — sets the marker, so an explicit empty choice survives. (PRs #266,
|
||||
#268.)
|
||||
- Nothing else changes: the `timezone` setting, its auto-set from the browser zone
|
||||
on a fresh install, the Add/Save flow, and the rest of the Settings screen are
|
||||
as in 0.9.3.
|
||||
|
||||
### Upgrade note
|
||||
|
||||
The database schema is unchanged at version **9**; no migration or backup step is
|
||||
needed. Settings stored before this release have no `timezone_configured` value
|
||||
and read it as `false`, so an install whose timezone is empty will auto-set the
|
||||
browser zone once on the next open — after which the choice persists.
|
||||
|
||||
### Requirements
|
||||
|
||||
Home Assistant 2024.6.0 or newer. Install through HACS from the
|
||||
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
|
||||
mirror).
|
||||
|
||||
## Version 0.9.3 — 2026-09-26
|
||||
|
||||
### Scope
|
||||
|
||||
Patch release carrying the admin timezone control onto `main`.
|
||||
|
||||
- **Admins set the system timezone from the PWA:** the Preferences screen gains a
|
||||
Timezone field (a text input with a local datalist of common IANA zones;
|
||||
`UTC` or `Area/Location`). On a fresh install the value is set AUTOMATICALLY
|
||||
from the browser's IANA zone when the setting is empty on first load — no
|
||||
geolocation prompt and no network lookup — and an admin can change or clear it
|
||||
at any time. Only a changed value is sent, and the API's 422 for an unknown zone
|
||||
is shown by the form. (PR #266.)
|
||||
- This makes the household-local clock (overdue, daily rollover) configurable
|
||||
from the UI, replacing the manual
|
||||
`PATCH /api/v1/admin/settings {"timezone": ...}` step noted in 0.9.1. The
|
||||
setting still defaults to the API host's local time when left empty.
|
||||
|
||||
### Upgrade note
|
||||
|
||||
The database schema is unchanged at version **9**; no migration or backup step is
|
||||
needed. No runtime dependency is added.
|
||||
|
||||
### Requirements
|
||||
|
||||
Home Assistant 2024.6.0 or newer. Install through HACS from the
|
||||
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
|
||||
mirror).
|
||||
|
||||
## Version 0.9.2 — 2026-09-26
|
||||
|
||||
### Scope
|
||||
|
||||
Patch release carrying the admin "Delete task" capability onto `main`.
|
||||
|
||||
- **Delete a task in the admin PWA:** the definition edit sheet gains an
|
||||
admin-only Delete action with hybrid semantics (D-017, owner decision "delete
|
||||
if there's no history, retire/deactivate if there is"). If the definition has
|
||||
**no completion history**, it is hard-deleted together with its instances,
|
||||
assignees, windows and its schedule rule (when unreferenced), all in one
|
||||
transaction under the connection lock. If it has **any completion history** (a
|
||||
completion or an un-completion), nothing is deleted: it is retired by
|
||||
deactivation instead, preserving the definition, rule, assignees, windows and
|
||||
all history, with future uncompleted instances cleared as with any
|
||||
deactivation. (PR #262.)
|
||||
- **API:** `DELETE /api/v1/admin/quest-definitions/{id}` lives on the admin plane
|
||||
(`require_admin`; a non-admin is refused and an unknown id is 404). The response
|
||||
reports `deleted`/`retired` and carries the retired definition (`null` when
|
||||
deleted). The kids' panel gains no control, and `QuestDefinitionsDao` now has
|
||||
exactly one, history-guarded, delete method.
|
||||
|
||||
### Upgrade note
|
||||
|
||||
The database schema is unchanged at version **9**; no migration or backup step is
|
||||
needed beyond the 0.8.0 note. No runtime dependency is added.
|
||||
|
||||
### Requirements
|
||||
|
||||
Home Assistant 2024.6.0 or newer. Install through HACS from the
|
||||
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
|
||||
mirror).
|
||||
|
||||
## Version 0.9.1 — 2026-09-26
|
||||
|
||||
### Scope
|
||||
|
||||
Patch release carrying the owner-reported UI-bug fixes for the kids' panel, plus a
|
||||
household-timezone correction, onto `main`.
|
||||
|
||||
- **Household-local time:** a new `timezone` setting (an IANA name such as
|
||||
`America/New_York`; empty keeps the API host's local time) is threaded into the
|
||||
API's clock reads, so overdue and the daily rollover use the household's day
|
||||
rather than a UTC host's. **Set it once after upgrading** (see the Upgrade
|
||||
note); the admin UI has no field for it yet. (PR #254.)
|
||||
- **Overdue and rollover correctness:** quests are no longer marked overdue
|
||||
before their due time, and the missed-sweep scheduler fires at the configured
|
||||
rollover across DST transitions — its delay is now a true elapsed-time
|
||||
difference, with documented handling of nonexistent/ambiguous local times.
|
||||
(PRs #254, #257.)
|
||||
- **Complete works on the panel:** a TypeScript ES2022 `useDefineForClassFields`
|
||||
default emitted native class fields that shadowed Lit's reactive accessors, so
|
||||
the confirm dialog never re-rendered and a tap appeared to do nothing; the
|
||||
panel now opens the confirm and completes. (PR #254.)
|
||||
- **Panel polish:** the party-board hint line no longer hides behind the dock
|
||||
(#244); the Quest Log header crest renders with its shield classes (#252); the
|
||||
party-board wordmark is the new compass-rose monogram plus Cinzel Decorative
|
||||
text, replacing the d20 dice (#253); and the quest meta line keeps its `meta`
|
||||
class when not late (#255).
|
||||
|
||||
### Upgrade note
|
||||
|
||||
The database schema is unchanged at version **9**. After upgrading, set the
|
||||
household timezone once so date, overdue and rollover behaviour match the house
|
||||
(otherwise it falls back to the API host's local time, which may be UTC):
|
||||
|
||||
```
|
||||
PATCH /api/v1/admin/settings {"timezone": "America/New_York"}
|
||||
```
|
||||
|
||||
No runtime dependency is added.
|
||||
|
||||
### Requirements
|
||||
|
||||
Home Assistant 2024.6.0 or newer. Install through HACS from the
|
||||
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
|
||||
mirror).
|
||||
|
||||
## Version 0.9.0 — 2026-09-26
|
||||
|
||||
### Scope
|
||||
|
||||
Minor release carrying expanded, customisable task icons — Lucide, Font Awesome
|
||||
Free and emoji — onto `main`.
|
||||
|
||||
- **Namespaced icon keys:** a quest definition's `icon` now accepts
|
||||
`lucide:<kebab-name>`, `fa:<kebab-name>` and `emoji:<grapheme>`. A legacy bare
|
||||
name (e.g. `dog`) is read as `lucide:dog`, so existing rows keep working with
|
||||
no stored-data migration. The strict write validator rejects an unknown
|
||||
namespace, a malformed name, an over-cap or markup-bearing emoji payload, and
|
||||
`javascript:`. (PRs #241, #249.)
|
||||
- **Shared icon registry:** one canonical set — the curated Lucide set plus a
|
||||
curated Font Awesome Free solid subset — generates identical,
|
||||
framework-agnostic registry modules for the admin and panel bundles, guarded by
|
||||
a drift test. There is no CDN and no runtime fetch, and only the curated Font
|
||||
Awesome glyphs are shipped. (PR #242.)
|
||||
- **Admin PWA picker:** the definition edit sheet offers the Lucide set, the
|
||||
Font Awesome subset and an emoji input; a selection stores the namespaced key
|
||||
and an existing namespaced or legacy icon pre-fills correctly. The definition
|
||||
list renders all three forms with a fallback glyph. Editing a definition whose
|
||||
stored icon is a legacy value no longer resubmits the unchanged icon.
|
||||
(PRs #243, #250.)
|
||||
- **Kids' panel:** open quest cards render the definition's icon (Lucide stroked,
|
||||
Font Awesome filled, emoji as text) with the existing star fallback; the sealed
|
||||
card keeps its star/seal glyph. (PR #245.)
|
||||
- **Docs and attribution:** ADMIN-SPEC and PANEL-SPEC describe the namespaced
|
||||
model; `THIRD-PARTY-NOTICES.md` records Font Awesome Free (icons CC BY 4.0,
|
||||
fonts SIL OFL 1.1, code MIT) and Lucide (ISC). (PR #246.)
|
||||
|
||||
### Upgrade note
|
||||
|
||||
The database schema is unchanged at version **9**; no migration or backup step
|
||||
is needed beyond the 0.8.0 note. This release adds no runtime dependency — the
|
||||
icon path data is embedded in the shipped bundles at build time.
|
||||
|
||||
### Requirements
|
||||
|
||||
Home Assistant 2024.6.0 or newer. Install through HACS from the
|
||||
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
|
||||
mirror).
|
||||
|
||||
## Version 0.8.0 — 2026-09-25
|
||||
|
||||
### Scope
|
||||
|
||||
Minor release carrying the multi-pattern presence model (schema 9), the
|
||||
Sunday-first calendar week, and the create-materializes fix, plus the
|
||||
definition start-date field, onto `main`.
|
||||
|
||||
- **Multiple presence patterns per child (schema 9):** a child now has zero or
|
||||
more presence patterns instead of at most one. Each pattern has a name, a kind
|
||||
(`home` or `away`), its own cycle length (1–4 weeks), its own anchor date and a
|
||||
per-week weekday set. Evaluation is first-match-wins — overrides, then away,
|
||||
then home, then absent when the child has at least one home pattern, else
|
||||
present. Migration 9 copies each existing schedule into a `home` pattern named
|
||||
"Home schedule" and drops the old `presence_schedules` table. (PRs #231, #232.)
|
||||
- **Sunday-first calendar week:** the admin Schedule month grid and the
|
||||
Definitions weekday picker now render weeks Sunday-first; the stored/API
|
||||
weekday numbering is unchanged (Monday=0..Sunday=6). (PR #230.)
|
||||
- **Fix — newly created tasks materialize immediately:** creating a quest
|
||||
definition now regenerates its future instances over the rolling horizon, so a
|
||||
new task reaches the panel without waiting for an unrelated write. (PRs #235,
|
||||
#236.)
|
||||
- **Definition start date:** the definition edit sheet gains a Start date field
|
||||
so a task can be scheduled to begin on a future date. (PRs #237, #238.)
|
||||
- **Docs:** the multi-pattern decision (D-016), the migration-9 rollback step in
|
||||
the runbook, and the ADMIN-SPEC Schedule section. (PRs #233, #234.)
|
||||
|
||||
### Upgrade note
|
||||
|
||||
This release runs schema version **9**. Version 0.7.1 refuses a version-9
|
||||
database, so take a copy of the SQLite file (or note the Litestream generation)
|
||||
before the first start on 0.8.0 if you need a rollback path.
|
||||
|
||||
### Requirements
|
||||
|
||||
|
||||
## Version 0.7.1 — 2026-09-24
|
||||
|
||||
### Scope
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Third-party notices
|
||||
|
||||
NestQuest bundles glyph data from the icon sets below. The curated names are listed in
|
||||
`tools/icons/curated-icons.json`; `tools/icons/generate.mjs` reads each glyph's SVG path
|
||||
data from the locally installed packages and generates it into both bundles
|
||||
(`admin/src/icons/registry.generated.ts` and `frontend/src/icons/registry.generated.ts`).
|
||||
Nothing is loaded from a CDN or fetched at runtime. The package versions used are recorded
|
||||
in the header of each generated file.
|
||||
|
||||
## Font Awesome Free
|
||||
|
||||
- Project: <https://fontawesome.com> · repository: <https://github.com/FortAwesome/Font-Awesome>
|
||||
- Package: `@fortawesome/free-solid-svg-icons`
|
||||
- Licence: <https://fontawesome.com/license/free>
|
||||
- Icons: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
|
||||
- Fonts: [SIL OFL 1.1](https://openfontlicense.org)
|
||||
- Code: [MIT](https://opensource.org/licenses/MIT)
|
||||
- Use in NestQuest: a curated subset of the Free **solid** icon set, embedded as SVG path
|
||||
data. No fonts are bundled.
|
||||
|
||||
Font Awesome Free by @fontawesome — https://fontawesome.com — icons licensed under CC BY 4.0.
|
||||
|
||||
## Lucide
|
||||
|
||||
- Project: <https://lucide.dev> · repository: <https://github.com/lucide-icons/lucide>
|
||||
- Package: `lucide-react`
|
||||
- Licence: [ISC](https://github.com/lucide-icons/lucide/blob/main/LICENSE)
|
||||
- Use in NestQuest: a curated subset of Lucide icons, embedded as SVG path data.
|
||||
|
||||
Lucide icons are Copyright (c) Lucide Contributors, licensed under the ISC License.
|
||||
Portions derived from Feather are Copyright (c) Cole Bemis, licensed under the MIT License.
|
||||
Generated
+26
-1
@@ -8,17 +8,18 @@
|
||||
"name": "nestquest-admin",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.47.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fortawesome/free-solid-svg-icons": "7.3.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"jsdom": "^30.1.1",
|
||||
"lucide-react": "1.47.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.5",
|
||||
"vitest": "^3.2.4"
|
||||
@@ -982,6 +983,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-common-types": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.3.1.tgz",
|
||||
"integrity": "sha512-k0C0sdHmZtAo6dRDtd1Z/qcpyHbL0CKsjV8seMY/21xGhY5Wsv0XRmiI/xEEH4y2c9b1+jvgNs/3EqhV27yUEA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/free-solid-svg-icons": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.3.1.tgz",
|
||||
"integrity": "sha512-v0BLa0eqg7ubvVWeNSHVBs8fWH/GJicERZoJaxJ3FE/lj67VSqzoMg9pzfZVOfLMX10y0pGwQAuxoRVVH2patg==",
|
||||
"dev": true,
|
||||
"license": "(CC-BY-4.0 AND MIT)",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "7.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -2262,6 +2286,7 @@
|
||||
"version": "1.47.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.47.0.tgz",
|
||||
"integrity": "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
|
||||
+2
-1
@@ -10,17 +10,18 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.47.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fortawesome/free-solid-svg-icons": "7.3.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"jsdom": "^30.1.1",
|
||||
"lucide-react": "1.47.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.5",
|
||||
"vitest": "^3.2.4"
|
||||
|
||||
+50
-7
@@ -2,10 +2,29 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import App from "./App";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
let settingsResponse: () => Promise<Response>;
|
||||
|
||||
describe("NestQuest Admin shell", () => {
|
||||
beforeEach(() => {
|
||||
// The Today tab fetches the admin snapshot; keep the shell tests offline.
|
||||
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
|
||||
settingsResponse = () =>
|
||||
Promise.resolve(jsonResponse({ timezone: "", effective_timezone: "Europe/London" }));
|
||||
// The shell loads the stored application timezone; every tab's own
|
||||
// requests stay pending so the shell tests remain offline.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((url: string) =>
|
||||
String(url).endsWith("/api/v1/admin/settings")
|
||||
? settingsResponse()
|
||||
: new Promise<Response>(() => {}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -22,10 +41,10 @@ describe("NestQuest Admin shell", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("starts on the Today screen and switches on tab click", () => {
|
||||
it("starts on the Today screen and switches on tab click", async () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy();
|
||||
expect(await screen.findByRole("heading", { name: "Today" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "History" }));
|
||||
|
||||
@@ -36,12 +55,36 @@ describe("NestQuest Admin shell", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("the Tasks tab renders the definitions screen", () => {
|
||||
it("the Tasks tab renders the definitions screen", async () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Tasks" }));
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy();
|
||||
expect(await screen.findByRole("heading", { name: "Tasks" })).toBeTruthy();
|
||||
expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders no screen until the stored application timezone is known", async () => {
|
||||
settingsResponse = () => new Promise<Response>(() => {});
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByTestId("timezone-loading")).toBeTruthy();
|
||||
expect(screen.queryByRole("heading", { name: "Today" })).toBeNull();
|
||||
});
|
||||
|
||||
it("a failed timezone load says so and retries", async () => {
|
||||
settingsResponse = () => Promise.resolve(jsonResponse({ detail: "boom" }, 500));
|
||||
render(<App />);
|
||||
|
||||
await screen.findByTestId("timezone-error");
|
||||
expect(screen.queryByRole("heading", { name: "Today" })).toBeNull();
|
||||
|
||||
settingsResponse = () =>
|
||||
Promise.resolve(
|
||||
jsonResponse({ timezone: "Pacific/Auckland", effective_timezone: "Pacific/Auckland" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Today" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
+13
-9
@@ -4,6 +4,7 @@ import { TransitionsProvider } from "./api/useTransitions";
|
||||
import DefinitionsTab from "./definitions/DefinitionsTab";
|
||||
import HistoryTab from "./history/HistoryTab";
|
||||
import ScheduleTab from "./schedule/ScheduleTab";
|
||||
import { AppTimezoneProvider } from "./time/AppTimezone";
|
||||
import TodayTab from "./today/TodayTab";
|
||||
|
||||
const TABS = [
|
||||
@@ -32,15 +33,18 @@ export default function App() {
|
||||
role="tabpanel"
|
||||
aria-labelledby={`tab-${active}`}
|
||||
>
|
||||
{active === "today" ? (
|
||||
<TodayTab />
|
||||
) : active === "tasks" ? (
|
||||
<DefinitionsTab />
|
||||
) : active === "schedule" ? (
|
||||
<ScheduleTab />
|
||||
) : (
|
||||
<HistoryTab />
|
||||
)}
|
||||
{/* Every screen's local time is the stored application timezone. */}
|
||||
<AppTimezoneProvider>
|
||||
{active === "today" ? (
|
||||
<TodayTab />
|
||||
) : active === "tasks" ? (
|
||||
<DefinitionsTab />
|
||||
) : active === "schedule" ? (
|
||||
<ScheduleTab />
|
||||
) : (
|
||||
<HistoryTab />
|
||||
)}
|
||||
</AppTimezoneProvider>
|
||||
</main>
|
||||
<nav className="admin-tabbar" aria-label="Admin sections">
|
||||
{TABS.map((tab) => (
|
||||
|
||||
@@ -147,10 +147,36 @@ export async function updateDefinition(
|
||||
return readJson<QuestDefinition>(await sendJson(`${DEFINITIONS_PATH}/${id}`, "PATCH", body));
|
||||
}
|
||||
|
||||
/** `DELETE /quest-definitions/{id}`: exactly one of `deleted` / `retired` is true. */
|
||||
export interface DefinitionDeleteResult {
|
||||
definition_id: number;
|
||||
deleted: boolean;
|
||||
retired: boolean;
|
||||
/** The retired (now inactive) definition; null when it was deleted. */
|
||||
definition: QuestDefinition | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a task if it has no completion history; otherwise the API retires
|
||||
* (deactivates) it. Retirement preserves the definition row, schedule rule,
|
||||
* assignees, windows and ALL durable completion history. Like any
|
||||
* deactivation it regenerates, so future UNCOMPLETED instances are cleared
|
||||
* (rolling, regenerable rows) and not recreated while inactive; durable
|
||||
* history lives only in the append-only `completion_events`.
|
||||
*/
|
||||
export async function deleteDefinition(id: number): Promise<DefinitionDeleteResult> {
|
||||
return readJson<DefinitionDeleteResult>(
|
||||
await apiFetch(`${DEFINITIONS_PATH}/${id}`, { method: "DELETE" }),
|
||||
);
|
||||
}
|
||||
|
||||
export const OCCURRENCES_PREVIEW_PATH = `${DEFINITIONS_PATH}/occurrences-preview`;
|
||||
|
||||
export interface OccurrencesPreviewOptions {
|
||||
/** "YYYY-MM-DD"; the API defaults to its host's today. */
|
||||
/**
|
||||
* "YYYY-MM-DD"; the API defaults to the household-local today in the
|
||||
* configured `timezone` (the API host's local time when that is empty).
|
||||
*/
|
||||
startDate?: string;
|
||||
/** 1..50; the API defaults to 10. */
|
||||
count?: number;
|
||||
|
||||
@@ -17,10 +17,17 @@ export interface HouseholdSettings {
|
||||
afternoon_reminder_enabled: boolean;
|
||||
end_of_day_report_enabled: boolean;
|
||||
celebration_enabled: boolean;
|
||||
/** IANA zone name (e.g. "America/New_York"); "" means the API host's local time. */
|
||||
timezone: string;
|
||||
/**
|
||||
* Read-only: the zone the API reads its clock in — `timezone`, or the API
|
||||
* host's own zone when that is "" ("" only when the host's zone has no name).
|
||||
*/
|
||||
effective_timezone: string;
|
||||
}
|
||||
|
||||
/** Only the supplied fields are updated; the API answers with all of them. */
|
||||
export type SettingsChanges = Partial<HouseholdSettings>;
|
||||
export type SettingsChanges = Partial<Omit<HouseholdSettings, "effective_timezone">>;
|
||||
|
||||
export const SETTINGS_PATH = "/api/v1/admin/settings";
|
||||
|
||||
|
||||
@@ -569,6 +569,26 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.defs-save--danger {
|
||||
background: var(--nq-a-danger);
|
||||
}
|
||||
|
||||
.defs-delete {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
padding: 11px 0;
|
||||
border: 1px solid var(--nq-a-danger-bg);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-danger-strong);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-delete:disabled,
|
||||
.defs-cancel:disabled,
|
||||
.defs-save:disabled {
|
||||
opacity: 0.6;
|
||||
@@ -583,3 +603,14 @@
|
||||
color: var(--nq-a-ink-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.defs-sublabel {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-emoji {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import DefinitionsTab from "./DefinitionsTab";
|
||||
import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons";
|
||||
import {
|
||||
DEFINITION_ICONS,
|
||||
DefinitionIcon,
|
||||
FA_DEFINITION_ICONS,
|
||||
emojiIconKey,
|
||||
faIconName,
|
||||
findDefinitionIcon,
|
||||
normalizeDefinitionIconName,
|
||||
} from "./definitionIcons";
|
||||
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
import { AppTimezoneProvider } from "../time/AppTimezone";
|
||||
|
||||
function child(id: number, name: string, overrides: Partial<AdminChild> = {}): AdminChild {
|
||||
return {
|
||||
@@ -113,6 +122,8 @@ function fakeApi(initial: QuestDefinition[]) {
|
||||
const calls: Call[] = [];
|
||||
let nextWrite: Response | null = null;
|
||||
let preview: PreviewHandler = async () => jsonResponse({ dates: [] });
|
||||
// Definitions with completion history: the API retires these on DELETE.
|
||||
const withHistory = new Set<number>();
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||
const path = new URL(String(input), "http://localhost").pathname;
|
||||
const method = init.method ?? "GET";
|
||||
@@ -153,6 +164,10 @@ function fakeApi(initial: QuestDefinition[]) {
|
||||
}
|
||||
const match = /^\/api\/v1\/admin\/quest-definitions\/(\d+)$/.exec(path);
|
||||
if (method === "PATCH" && match) {
|
||||
// Like the API's strict validate_icon: a Lucide name never has a colon.
|
||||
if (typeof body.icon === "string" && /^lucide:.*:/.test(body.icon)) {
|
||||
return jsonResponse({ detail: "icon is not a valid icon key" }, 422);
|
||||
}
|
||||
const id = Number(match[1]);
|
||||
definitions = definitions.map((d) =>
|
||||
d.id === id
|
||||
@@ -175,6 +190,16 @@ function fakeApi(initial: QuestDefinition[]) {
|
||||
);
|
||||
return jsonResponse(definitions.find((d) => d.id === id));
|
||||
}
|
||||
if (method === "DELETE" && match) {
|
||||
const id = Number(match[1]);
|
||||
if (withHistory.has(id)) {
|
||||
definitions = definitions.map((d) => (d.id === id ? { ...d, is_active: false } : d));
|
||||
const retired = definitions.find((d) => d.id === id);
|
||||
return jsonResponse({ definition_id: id, deleted: false, retired: true, definition: retired });
|
||||
}
|
||||
definitions = definitions.filter((d) => d.id !== id);
|
||||
return jsonResponse({ definition_id: id, deleted: true, retired: false, definition: null });
|
||||
}
|
||||
return jsonResponse({ detail: "unexpected" }, 500);
|
||||
});
|
||||
return {
|
||||
@@ -191,6 +216,9 @@ function fakeApi(initial: QuestDefinition[]) {
|
||||
failNextWrite: (response: Response) => {
|
||||
nextWrite = response;
|
||||
},
|
||||
markHistory: (id: number) => {
|
||||
withHistory.add(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -455,7 +483,6 @@ describe("DefinitionsTab", () => {
|
||||
path: "/api/v1/admin/quest-definitions/11",
|
||||
body: {
|
||||
title: "Take out the bins",
|
||||
icon: null,
|
||||
rule: {
|
||||
rule_type: "monthly_day",
|
||||
interval: 1,
|
||||
@@ -567,14 +594,15 @@ describe("DefinitionsTab", () => {
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
const s = sheet();
|
||||
const title = within(s).getByRole("textbox", { name: "Title" });
|
||||
const save = within(s).getByRole("button", { name: "Save changes" });
|
||||
// The edit sheet's last focusable control is its Delete button.
|
||||
const last = within(s).getByRole("button", { name: "Delete task" });
|
||||
|
||||
save.focus();
|
||||
last.focus();
|
||||
fireEvent.keyDown(s, { key: "Tab" });
|
||||
expect(document.activeElement).toBe(title);
|
||||
|
||||
fireEvent.keyDown(s, { key: "Tab", shiftKey: true });
|
||||
expect(document.activeElement).toBe(save);
|
||||
expect(document.activeElement).toBe(last);
|
||||
});
|
||||
|
||||
it("Escape does not close the sheet while saving", async () => {
|
||||
@@ -683,6 +711,73 @@ describe("DefinitionsTab", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("the Delete control is only offered for an existing task", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
expect(within(sheet()).queryByRole("button", { name: "Delete task" })).toBeNull();
|
||||
fireEvent.click(within(sheet()).getByRole("button", { name: "Cancel" }));
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
expect(within(sheet()).getByRole("button", { name: "Delete task" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Delete asks first, explaining delete-vs-retire; Go back writes nothing", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
fireEvent.click(within(sheet()).getByRole("button", { name: "Delete task" }));
|
||||
const confirm = screen.getByTestId("delete-confirm");
|
||||
expect(confirm.textContent).toContain("If it has no completion history it is deleted");
|
||||
expect(confirm.textContent).toContain("otherwise it is retired");
|
||||
expect(api.writes()).toHaveLength(0);
|
||||
fireEvent.click(within(confirm).getByRole("button", { name: "Go back" }));
|
||||
expect(screen.queryByTestId("delete-confirm")).toBeNull();
|
||||
expect(api.writes()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("confirming Delete on a task without history removes its row", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
fireEvent.click(within(sheet()).getByRole("button", { name: "Delete task" }));
|
||||
fireEvent.click(
|
||||
within(screen.getByTestId("delete-confirm")).getByRole("button", { name: "Delete task" }),
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByTestId("definition-10")).toBeNull());
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(api.writes()).toEqual([
|
||||
{ method: "DELETE", path: "/api/v1/admin/quest-definitions/10", body: null },
|
||||
]);
|
||||
expect(screen.getByTestId("definition-11")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("confirming Delete on a task with history shows it retired (inactive)", async () => {
|
||||
await renderReady();
|
||||
api.markHistory(10);
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
fireEvent.click(within(sheet()).getByRole("button", { name: "Delete task" }));
|
||||
fireEvent.click(
|
||||
within(screen.getByTestId("delete-confirm")).getByRole("button", { name: "Delete task" }),
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
const row = screen.getByTestId("definition-10");
|
||||
expect(row.className).toContain("defs-row--inactive");
|
||||
expect(meta(row)).toContain("· inactive");
|
||||
expect(api.writes().map((c) => c.method)).toEqual(["DELETE"]);
|
||||
});
|
||||
|
||||
it("a failed Delete shows the API's detail and keeps the sheet open", async () => {
|
||||
await renderReady();
|
||||
api.failNextWrite(jsonResponse({ detail: "Quest definition not found" }, 404));
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
fireEvent.click(within(sheet()).getByRole("button", { name: "Delete task" }));
|
||||
fireEvent.click(
|
||||
within(screen.getByTestId("delete-confirm")).getByRole("button", { name: "Delete task" }),
|
||||
);
|
||||
expect((await screen.findByTestId("sheet-error")).textContent).toBe(
|
||||
"Quest definition not found",
|
||||
);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
expect(screen.getByTestId("definition-10")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("scroll column carries the 92px bottom padding", async () => {
|
||||
await renderReady();
|
||||
expect(screen.getByTestId("definitions-scroll").style.paddingBottom).toBe("92px");
|
||||
@@ -1052,7 +1147,7 @@ describe("DefinitionsTab icon picker", () => {
|
||||
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect(api.writes()[0].method).toBe("POST");
|
||||
expect((api.writes()[0].body as { icon: string }).icon).toBe("bed");
|
||||
expect((api.writes()[0].body as { icon: string }).icon).toBe("lucide:bed");
|
||||
expect(rowIcon(await screen.findByTestId("definition-99"))).toBe("bed");
|
||||
});
|
||||
|
||||
@@ -1073,13 +1168,13 @@ describe("DefinitionsTab icon picker", () => {
|
||||
const { method, path, body } = api.writes()[0];
|
||||
expect(method).toBe("PATCH");
|
||||
expect(path).toBe("/api/v1/admin/quest-definitions/11");
|
||||
expect((body as { icon: string }).icon).toBe("trash-2");
|
||||
expect((body as { icon: string }).icon).toBe("lucide:trash-2");
|
||||
// Inactive "Old" stays assigned: the unchanged selection is still omitted.
|
||||
expect(body).not.toHaveProperty("assignee_child_ids");
|
||||
await waitFor(() => expect(rowIcon(screen.getByTestId("definition-11"))).toBe("trash-2"));
|
||||
});
|
||||
|
||||
it("an unchanged icon is still sent on edit and shows as selected", async () => {
|
||||
it("an unchanged icon is omitted on edit and shows as selected", async () => {
|
||||
await renderReady();
|
||||
expect(rowIcon(screen.getByTestId("definition-10"))).toBe("smile");
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
@@ -1093,7 +1188,7 @@ describe("DefinitionsTab icon picker", () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
|
||||
expect((api.writes()[0].body as { icon: string }).icon).toBe("smile");
|
||||
expect(api.writes()[0].body).not.toHaveProperty("icon");
|
||||
});
|
||||
|
||||
it("an unknown legacy icon falls back to the generic glyph and is kept on save", async () => {
|
||||
@@ -1113,8 +1208,573 @@ describe("DefinitionsTab icon picker", () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
|
||||
expect((api.writes()[0].body as { icon: string }).icon).toBe("rocket-ship-9000");
|
||||
expect(api.writes()[0].body).not.toHaveProperty("icon");
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(rowIcon(screen.getByTestId("definition-20"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DefinitionsTab namespaced icons", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function rowIcon(row: HTMLElement): string | null {
|
||||
const svg = row.querySelector(".defs-row-glyph svg");
|
||||
expect(svg).not.toBeNull();
|
||||
return svg!.getAttribute("data-icon");
|
||||
}
|
||||
|
||||
function iconRadio(s: HTMLElement, label: string): HTMLElement {
|
||||
return within(within(s).getByRole("radiogroup", { name: "Icon" })).getByRole("radio", {
|
||||
name: label,
|
||||
});
|
||||
}
|
||||
|
||||
it("normalizes lucide-namespaced and legacy bare values to one Lucide name", () => {
|
||||
expect(normalizeDefinitionIconName("lucide:dog")).toBe("dog");
|
||||
expect(normalizeDefinitionIconName("dog")).toBe("dog");
|
||||
expect(normalizeDefinitionIconName("fa:dog")).toBeNull();
|
||||
expect(normalizeDefinitionIconName("emoji:🐶")).toBeNull();
|
||||
expect(normalizeDefinitionIconName("mystery:dog")).toBeNull();
|
||||
expect(normalizeDefinitionIconName(null)).toBeNull();
|
||||
});
|
||||
|
||||
for (const icon of ["lucide:dog", "dog"]) {
|
||||
it(`renders and selects the Dog glyph for ${icon}`, async () => {
|
||||
await renderReady([{ ...BRUSH, id: 30, icon }]);
|
||||
const row = screen.getByTestId("definition-30");
|
||||
expect(rowIcon(row)).toBe("dog");
|
||||
|
||||
fireEvent.click(row);
|
||||
const s = sheet();
|
||||
expect(iconRadio(s, "Dog").getAttribute("aria-checked")).toBe("true");
|
||||
expect(iconRadio(s, "Dog").className).toContain("defs-icon--on");
|
||||
expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("false");
|
||||
});
|
||||
}
|
||||
|
||||
for (const icon of ["mystery:dog", "lucide:not-an-icon", "fa:not-an-icon", "emoji:a b"]) {
|
||||
it(`renders the fallback tile and selects nothing for ${icon}`, async () => {
|
||||
await renderReady([{ ...BRUSH, id: 31, icon }]);
|
||||
const row = screen.getByTestId("definition-31");
|
||||
expect(rowIcon(row)).toBeNull();
|
||||
|
||||
fireEvent.click(row);
|
||||
const s = sheet();
|
||||
for (const name of ["Icon", "Font Awesome"]) {
|
||||
const group = within(s).getByRole("radiogroup", { name });
|
||||
for (const radio of within(group).getAllByRole("radio")) {
|
||||
expect(radio.getAttribute("aria-checked")).toBe("false");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("tapping a Lucide choice on a namespaced task PATCHes lucide:<name>, and No icon clears it", async () => {
|
||||
await renderReady([{ ...BRUSH, id: 32, icon: "lucide:dog" }]);
|
||||
fireEvent.click(screen.getByTestId("definition-32"));
|
||||
let s = sheet();
|
||||
fireEvent.click(iconRadio(s, "Cat"));
|
||||
expect(iconRadio(s, "Cat").getAttribute("aria-checked")).toBe("true");
|
||||
expect(iconRadio(s, "Dog").getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
expect((api.writes()[0].body as { icon: string }).icon).toBe("lucide:cat");
|
||||
await waitFor(() => expect(rowIcon(screen.getByTestId("definition-32"))).toBe("cat"));
|
||||
|
||||
fireEvent.click(screen.getByTestId("definition-32"));
|
||||
s = sheet();
|
||||
expect(iconRadio(s, "Cat").getAttribute("aria-checked")).toBe("true");
|
||||
fireEvent.click(iconRadio(s, "No icon"));
|
||||
expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("true");
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
expect((api.writes()[1].body as { icon: string | null }).icon).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DefinitionsTab Font Awesome and emoji icons", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
/** What a row draws: `svg:<data-icon>`, `emoji:<text>`, or `fallback`. */
|
||||
function rowGlyph(row: HTMLElement): string {
|
||||
const glyph = row.querySelector(".defs-row-glyph");
|
||||
const emoji = glyph!.querySelector("[data-emoji]");
|
||||
if (emoji) return `emoji:${emoji.textContent}`;
|
||||
const svg = glyph!.querySelector("svg");
|
||||
expect(svg).not.toBeNull();
|
||||
const name = svg!.getAttribute("data-icon");
|
||||
if (name === null) return "fallback";
|
||||
return `${svg!.getAttribute("fill") === "none" ? "lucide" : "fa"}:${name}`;
|
||||
}
|
||||
|
||||
function radio(s: HTMLElement, group: "Icon" | "Font Awesome", label: string): HTMLElement {
|
||||
return within(within(s).getByRole("radiogroup", { name: group })).getByRole("radio", {
|
||||
name: label,
|
||||
});
|
||||
}
|
||||
|
||||
function emojiInput(s: HTMLElement): HTMLInputElement {
|
||||
return within(s).getByRole("textbox", { name: "Emoji" }) as HTMLInputElement;
|
||||
}
|
||||
|
||||
async function save(s: HTMLElement) {
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
}
|
||||
|
||||
function fillNewTask(s: HTMLElement) {
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||
target: { value: "Walk the dog" },
|
||||
});
|
||||
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
|
||||
}
|
||||
|
||||
it("ships the curated Font Awesome subset, each drawn as a filled glyph", () => {
|
||||
expect(FA_DEFINITION_ICONS.length).toBeGreaterThan(0);
|
||||
for (const entry of FA_DEFINITION_ICONS) {
|
||||
expect(entry.kind).toBe("fa");
|
||||
expect(findDefinitionIcon(`fa:${entry.name}`)).toBe(entry);
|
||||
const { container, unmount } = render(<DefinitionIcon name={`fa:${entry.name}`} />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg, entry.name).not.toBeNull();
|
||||
expect(svg!.getAttribute("data-icon")).toBe(entry.name);
|
||||
expect(svg!.getAttribute("fill")).toBe("currentColor");
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds and validates emoji keys like the registry", () => {
|
||||
expect(emojiIconKey("🐶")).toBe("emoji:🐶");
|
||||
expect(emojiIconKey("👨👩👧👦")).toBe("emoji:👨👩👧👦");
|
||||
expect(emojiIconKey("⭐".repeat(8))).toBe(`emoji:${"⭐".repeat(8)}`);
|
||||
for (const bad of ["", " ", " 🐶 ", "🐶 ", "\t🐶", "⭐".repeat(9), "a b", "<b>", "a&b", "🐶\u0007"]) {
|
||||
expect(emojiIconKey(bad), JSON.stringify(bad)).toBeNull();
|
||||
}
|
||||
expect(faIconName("fa:dog")).toBe("dog");
|
||||
expect(faIconName("lucide:dog")).toBeNull();
|
||||
expect(faIconName(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders lucide, legacy, fa and emoji rows, and the fallback for none or unknown", async () => {
|
||||
await renderReady([
|
||||
{ ...BRUSH, id: 40, icon: "lucide:dog" },
|
||||
{ ...BRUSH, id: 41, title: "Task 41", icon: "dog" },
|
||||
{ ...BRUSH, id: 42, title: "Task 42", icon: "fa:dog" },
|
||||
{ ...BRUSH, id: 43, title: "Task 43", icon: "emoji:🐶" },
|
||||
{ ...BRUSH, id: 44, title: "Task 44", icon: null },
|
||||
{ ...BRUSH, id: 45, title: "Task 45", icon: "" },
|
||||
{ ...BRUSH, id: 46, title: "Task 46", icon: "mystery:dog" },
|
||||
]);
|
||||
expect(rowGlyph(screen.getByTestId("definition-40"))).toBe("lucide:dog");
|
||||
expect(rowGlyph(screen.getByTestId("definition-41"))).toBe("lucide:dog");
|
||||
expect(rowGlyph(screen.getByTestId("definition-42"))).toBe("fa:dog");
|
||||
expect(rowGlyph(screen.getByTestId("definition-43"))).toBe("emoji:🐶");
|
||||
expect(rowGlyph(screen.getByTestId("definition-44"))).toBe("fallback");
|
||||
expect(rowGlyph(screen.getByTestId("definition-45"))).toBe("fallback");
|
||||
expect(rowGlyph(screen.getByTestId("definition-46"))).toBe("fallback");
|
||||
});
|
||||
|
||||
it("a Font Awesome choice is POSTed as fa:<name> and renders on its row", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
const s = sheet();
|
||||
fillNewTask(s);
|
||||
fireEvent.click(radio(s, "Font Awesome", "Dog"));
|
||||
expect(radio(s, "Font Awesome", "Dog").getAttribute("aria-checked")).toBe("true");
|
||||
expect(radio(s, "Font Awesome", "Dog").className).toContain("defs-icon--on");
|
||||
expect(radio(s, "Icon", "Dog").getAttribute("aria-checked")).toBe("false");
|
||||
expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
await save(s);
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect(api.writes()[0].method).toBe("POST");
|
||||
expect((api.writes()[0].body as { icon: string }).icon).toBe("fa:dog");
|
||||
expect(rowGlyph(await screen.findByTestId("definition-99"))).toBe("fa:dog");
|
||||
});
|
||||
|
||||
it("a typed emoji is POSTed as emoji:<grapheme> and renders on its row", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
const s = sheet();
|
||||
fillNewTask(s);
|
||||
fireEvent.change(emojiInput(s), { target: { value: "🐶" } });
|
||||
expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
await save(s);
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect((api.writes()[0].body as { icon: string }).icon).toBe("emoji:🐶");
|
||||
expect(rowGlyph(await screen.findByTestId("definition-99"))).toBe("emoji:🐶");
|
||||
});
|
||||
|
||||
it("a Lucide choice is POSTed as lucide:<name> after an emoji was typed", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
const s = sheet();
|
||||
fillNewTask(s);
|
||||
fireEvent.change(emojiInput(s), { target: { value: "🐶" } });
|
||||
fireEvent.click(radio(s, "Icon", "Dog"));
|
||||
expect(emojiInput(s).value).toBe("");
|
||||
|
||||
await save(s);
|
||||
expect((api.writes()[0].body as { icon: string }).icon).toBe("lucide:dog");
|
||||
expect(rowGlyph(await screen.findByTestId("definition-99"))).toBe("lucide:dog");
|
||||
});
|
||||
|
||||
for (const bad of [" 🐶 ", "🐶 ", " ", "a b", "<b>", "a&b", "⭐".repeat(9)]) {
|
||||
it(`an invalid emoji entry ${JSON.stringify(bad)} is not saved`, async () => {
|
||||
await renderReady([{ ...BRUSH, id: 50, icon: "lucide:dog" }]);
|
||||
fireEvent.click(screen.getByTestId("definition-50"));
|
||||
const s = sheet();
|
||||
fireEvent.change(emojiInput(s), { target: { value: bad } });
|
||||
|
||||
await save(s);
|
||||
expect(api.writes()).toHaveLength(0);
|
||||
expect(within(s).getByText(/Enter a single emoji/)).toBeTruthy();
|
||||
expect(screen.getByRole("dialog")).toBe(s);
|
||||
});
|
||||
}
|
||||
|
||||
it("prefills a fa:<name> task and PATCHes an emoji replacement", async () => {
|
||||
await renderReady([{ ...BRUSH, id: 51, icon: "fa:dog" }]);
|
||||
fireEvent.click(screen.getByTestId("definition-51"));
|
||||
const s = sheet();
|
||||
expect(radio(s, "Font Awesome", "Dog").getAttribute("aria-checked")).toBe("true");
|
||||
expect(radio(s, "Icon", "Dog").getAttribute("aria-checked")).toBe("false");
|
||||
expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("false");
|
||||
expect(emojiInput(s).value).toBe("");
|
||||
|
||||
fireEvent.change(emojiInput(s), { target: { value: "🐱" } });
|
||||
expect(radio(s, "Font Awesome", "Dog").getAttribute("aria-checked")).toBe("false");
|
||||
await save(s);
|
||||
const { method, path, body } = api.writes()[0];
|
||||
expect(method).toBe("PATCH");
|
||||
expect(path).toBe("/api/v1/admin/quest-definitions/51");
|
||||
expect((body as { icon: string }).icon).toBe("emoji:🐱");
|
||||
await waitFor(() => expect(rowGlyph(screen.getByTestId("definition-51"))).toBe("emoji:🐱"));
|
||||
});
|
||||
|
||||
it("prefills an emoji:<grapheme> task and PATCHes a Font Awesome replacement", async () => {
|
||||
await renderReady([{ ...BRUSH, id: 52, icon: "emoji:🐶" }]);
|
||||
fireEvent.click(screen.getByTestId("definition-52"));
|
||||
const s = sheet();
|
||||
expect(emojiInput(s).value).toBe("🐶");
|
||||
expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("false");
|
||||
for (const name of ["Icon", "Font Awesome"]) {
|
||||
const group = within(s).getByRole("radiogroup", { name });
|
||||
expect(within(group).queryAllByRole("radio", { checked: true })).toHaveLength(0);
|
||||
}
|
||||
|
||||
fireEvent.click(radio(s, "Font Awesome", "Pets"));
|
||||
expect(emojiInput(s).value).toBe("");
|
||||
await save(s);
|
||||
expect((api.writes()[0].body as { icon: string }).icon).toBe("fa:paw");
|
||||
await waitFor(() => expect(rowGlyph(screen.getByTestId("definition-52"))).toBe("fa:paw"));
|
||||
});
|
||||
|
||||
it("an unchanged emoji is omitted on edit", async () => {
|
||||
await renderReady([{ ...BRUSH, id: 53, icon: "emoji:🐶" }]);
|
||||
fireEvent.click(screen.getByTestId("definition-53"));
|
||||
const s = sheet();
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||
target: { value: "Feed the dog" },
|
||||
});
|
||||
await save(s);
|
||||
expect(api.writes()[0].body).not.toHaveProperty("icon");
|
||||
});
|
||||
|
||||
it("No icon and an emptied emoji field both clear the icon to null", async () => {
|
||||
await renderReady([
|
||||
{ ...BRUSH, id: 54, icon: "emoji:🐶" },
|
||||
{ ...BRUSH, id: 55, title: "Task 55", icon: "fa:dog" },
|
||||
]);
|
||||
fireEvent.click(screen.getByTestId("definition-54"));
|
||||
let s = sheet();
|
||||
fireEvent.change(emojiInput(s), { target: { value: "" } });
|
||||
expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("true");
|
||||
await save(s);
|
||||
expect((api.writes()[0].body as { icon: string | null }).icon).toBeNull();
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
|
||||
fireEvent.click(screen.getByTestId("definition-55"));
|
||||
s = sheet();
|
||||
fireEvent.click(radio(s, "Icon", "No icon"));
|
||||
expect(radio(s, "Font Awesome", "Dog").getAttribute("aria-checked")).toBe("false");
|
||||
await save(s);
|
||||
expect((api.writes()[1].body as { icon: string | null }).icon).toBeNull();
|
||||
await waitFor(() => expect(rowGlyph(screen.getByTestId("definition-55"))).toBe("fallback"));
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Legacy icons ───────────────────────────────────────────────────── */
|
||||
|
||||
describe("DefinitionsTab legacy icon edits", () => {
|
||||
// The API reads a stored legacy `mdi:tooth` back as `lucide:mdi:tooth`,
|
||||
// which its strict write validation rejects if sent back.
|
||||
const LEGACY: QuestDefinition = { ...BRUSH, id: 60, icon: "lucide:mdi:tooth" };
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function radio(s: HTMLElement, group: "Icon" | "Font Awesome", label: string): HTMLElement {
|
||||
return within(within(s).getByRole("radiogroup", { name: group })).getByRole("radio", {
|
||||
name: label,
|
||||
});
|
||||
}
|
||||
|
||||
async function save(s: HTMLElement) {
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
}
|
||||
|
||||
it("editing only the title omits the legacy icon and saves", async () => {
|
||||
await renderReady([LEGACY]);
|
||||
fireEvent.click(screen.getByTestId("definition-60"));
|
||||
const s = sheet();
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||
target: { value: "Brush teeth well" },
|
||||
});
|
||||
await save(s);
|
||||
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect(api.writes()[0].method).toBe("PATCH");
|
||||
expect(api.writes()[0].body).not.toHaveProperty("icon");
|
||||
expect((api.writes()[0].body as { title: string }).title).toBe("Brush teeth well");
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(await screen.findByText("Brush teeth well")).toBeTruthy();
|
||||
});
|
||||
|
||||
for (const [label, choose, sent] of [
|
||||
["a Lucide", (s: HTMLElement) => fireEvent.click(radio(s, "Icon", "Dog")), "lucide:dog"],
|
||||
["a Font Awesome", (s: HTMLElement) => fireEvent.click(radio(s, "Font Awesome", "Dog")), "fa:dog"],
|
||||
[
|
||||
"an emoji",
|
||||
(s: HTMLElement) =>
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Emoji" }), {
|
||||
target: { value: "🦷" },
|
||||
}),
|
||||
"emoji:🦷",
|
||||
],
|
||||
["No icon", (s: HTMLElement) => fireEvent.click(radio(s, "Icon", "No icon")), null],
|
||||
] as const) {
|
||||
it(`choosing ${label} replacement for the legacy icon sends ${JSON.stringify(sent)}`, async () => {
|
||||
await renderReady([LEGACY]);
|
||||
fireEvent.click(screen.getByTestId("definition-60"));
|
||||
const s = sheet();
|
||||
choose(s);
|
||||
await save(s);
|
||||
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect(api.writes()[0].body).toHaveProperty("icon", sent);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
}
|
||||
|
||||
it("an invalid emoji replacing the legacy icon is still rejected before the request", async () => {
|
||||
await renderReady([LEGACY]);
|
||||
fireEvent.click(screen.getByTestId("definition-60"));
|
||||
const s = sheet();
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Emoji" }), {
|
||||
target: { value: "a b" },
|
||||
});
|
||||
await save(s);
|
||||
|
||||
expect(api.writes()).toHaveLength(0);
|
||||
expect(within(s).getByText(/Enter a single emoji/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("creating a task still sends icon, even when none is chosen", async () => {
|
||||
await renderReady([LEGACY]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
const s = sheet();
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||
target: { value: "Floss" },
|
||||
});
|
||||
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
|
||||
await save(s);
|
||||
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect(api.writes()[0].method).toBe("POST");
|
||||
expect(api.writes()[0].body).toHaveProperty("icon", null);
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Start date ─────────────────────────────────────────────────────── */
|
||||
|
||||
function startDateInput(s: HTMLElement): HTMLInputElement {
|
||||
return within(s).getByLabelText("Start date", { selector: "input" }) as HTMLInputElement;
|
||||
}
|
||||
|
||||
describe("DefinitionsTab start date", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("a new task defaults to today and POSTs the chosen start date", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date(2026, 8, 23, 9, 0)); // Wed 23 Sep 2026, local
|
||||
await renderReady();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
const s = sheet();
|
||||
const input = startDateInput(s);
|
||||
expect(input.type).toBe("date");
|
||||
expect(input.value).toBe("2026-09-23");
|
||||
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||
target: { value: "Feed the cat" },
|
||||
});
|
||||
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Evening" }));
|
||||
fireEvent.change(input, { target: { value: "2026-10-15" } });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect(api.writes()[0].method).toBe("POST");
|
||||
expect((api.writes()[0].body as { rule: DefinitionRule }).rule.start_date).toBe("2026-10-15");
|
||||
});
|
||||
|
||||
it("an edit pre-fills the definition's start date and PATCHes a changed one", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
const s = sheet();
|
||||
const input = startDateInput(s);
|
||||
expect(input.value).toBe("2026-01-01");
|
||||
|
||||
fireEvent.change(input, { target: { value: "2026-11-02" } });
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
expect(api.writes()).toEqual([
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
path: "/api/v1/admin/quest-definitions/10",
|
||||
body: expect.objectContaining({ rule: rule({ start_date: "2026-11-02" }) }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("the occurrence preview requests the chosen start date", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
const s = sheet();
|
||||
await waitFor(() => expect(api.previews()).toHaveLength(1));
|
||||
|
||||
fireEvent.change(startDateInput(s), { target: { value: "2027-03-01" } });
|
||||
await settleDebounce();
|
||||
await waitFor(() => expect(api.previews()).toHaveLength(2));
|
||||
expect((api.previews()[1].body as { rule: DefinitionRule }).rule.start_date).toBe(
|
||||
"2027-03-01",
|
||||
);
|
||||
});
|
||||
|
||||
it("a cleared start date is not submitted", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
const s = sheet();
|
||||
fireEvent.change(startDateInput(s), { target: { value: "" } });
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
expect(api.writes()).toHaveLength(0);
|
||||
expect(within(s).getByText("Pick a start date.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DefinitionsTab start date in the stored application timezone", () => {
|
||||
async function newSheetStartDate(
|
||||
timezone: string,
|
||||
effective_timezone = timezone,
|
||||
): Promise<string> {
|
||||
api = fakeApi([BRUSH]);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
|
||||
String(input).endsWith("/api/v1/admin/settings")
|
||||
? Promise.resolve(jsonResponse({ timezone, effective_timezone }))
|
||||
: api.fetchMock(input, init),
|
||||
),
|
||||
);
|
||||
render(
|
||||
<AppTimezoneProvider>
|
||||
<DefinitionsTab />
|
||||
</AppTimezoneProvider>,
|
||||
);
|
||||
await screen.findByText("Brush teeth");
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
return startDateInput(sheet()).value;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("TZ", "UTC");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
// Wed 23 Sep 13:00 UTC is already Thu 24 Sep 01:00 in Auckland.
|
||||
vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-09-23T13:00:00Z") });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("a new task defaults to today in the stored zone", async () => {
|
||||
expect(await newSheetStartDate("Pacific/Auckland")).toBe("2026-09-24");
|
||||
});
|
||||
|
||||
it("an empty stored zone defaults to today in the API host's published zone", async () => {
|
||||
expect(await newSheetStartDate("", "Pacific/Auckland")).toBe("2026-09-24");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,12 +13,14 @@ import { ApiForbiddenError } from "../api/client";
|
||||
import {
|
||||
ApiRequestError,
|
||||
createDefinition,
|
||||
deleteDefinition,
|
||||
fetchChildren,
|
||||
fetchDefinitions,
|
||||
previewOccurrences,
|
||||
updateDefinition,
|
||||
type AdminChild,
|
||||
type DefinitionCreateBody,
|
||||
type DefinitionDeleteResult,
|
||||
type DefinitionEditBody,
|
||||
type DefinitionRule,
|
||||
type QuestDefinition,
|
||||
@@ -43,7 +45,21 @@ import {
|
||||
PlusGlyph,
|
||||
TaskGlyph,
|
||||
} from "./glyphs";
|
||||
import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons";
|
||||
import {
|
||||
DEFINITION_ICONS,
|
||||
DefinitionIcon,
|
||||
FA_DEFINITION_ICONS,
|
||||
emojiIconKey,
|
||||
emojiIconText,
|
||||
faIconKey,
|
||||
faIconName,
|
||||
lucideIconKey,
|
||||
normalizeDefinitionIconName,
|
||||
} from "./definitionIcons";
|
||||
import { GlyphSvg } from "../icons/GlyphSvg";
|
||||
import { weekdayOf } from "../schedule/cycle";
|
||||
import { useAppTimezone } from "../time/AppTimezone";
|
||||
import { todayIn } from "../time/zone";
|
||||
import "./DefinitionsTab.css";
|
||||
|
||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||
@@ -78,8 +94,13 @@ const WEEKDAY_LONG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "S
|
||||
|
||||
interface Draft {
|
||||
title: string;
|
||||
/** A Lucide name; a legacy value outside the subset is kept until replaced. */
|
||||
/**
|
||||
* The stored icon key (`lucide:`, `fa:` or `emoji:`); a legacy value
|
||||
* outside the curated sets is kept until replaced.
|
||||
*/
|
||||
icon: string | null;
|
||||
/** The emoji field's text; typing in it sets `icon` to `emoji:<text>`. */
|
||||
emoji: string;
|
||||
assigneeIds: number[];
|
||||
repeats: Repeats;
|
||||
interval: string;
|
||||
@@ -103,48 +124,43 @@ type LoadState =
|
||||
/** `null` = closed, `"new"` = creating, a definition = editing it. */
|
||||
type SheetTarget = null | "new" | QuestDefinition;
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
function localIsoDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||
}
|
||||
|
||||
function emptyDueTimes(): Record<WindowName, string> {
|
||||
return { morning: "", afternoon: "", evening: "" };
|
||||
}
|
||||
|
||||
function newDraft(activeChildren: AdminChild[]): Draft {
|
||||
const today = new Date();
|
||||
/** Draft defaults take `today` ("YYYY-MM-DD") in the application timezone. */
|
||||
function newDraft(activeChildren: AdminChild[], today: string): Draft {
|
||||
const [, month, day] = today.split("-").map(Number);
|
||||
return {
|
||||
title: "",
|
||||
icon: null,
|
||||
emoji: "",
|
||||
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
|
||||
repeats: "daily",
|
||||
interval: "1",
|
||||
weekdays: [(today.getDay() + 6) % 7],
|
||||
dayOfMonth: String(today.getDate()),
|
||||
month: String(today.getMonth() + 1),
|
||||
weekdays: [weekdayOf(today)],
|
||||
dayOfMonth: String(day),
|
||||
month: String(month),
|
||||
monthlyWeekday: null,
|
||||
windows: [],
|
||||
dueTimes: emptyDueTimes(),
|
||||
// Mirrors the API's create default: no instance on an away day.
|
||||
skipOnAway: true,
|
||||
startDate: localIsoDate(today),
|
||||
startDate: today,
|
||||
endDate: null,
|
||||
};
|
||||
}
|
||||
|
||||
function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): Draft {
|
||||
function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[], today: string): Draft {
|
||||
const { rule } = definition;
|
||||
const today = new Date();
|
||||
const [, month, day] = today.split("-").map(Number);
|
||||
const active = new Set(activeChildren.map((child) => child.id));
|
||||
const dueTimes = emptyDueTimes();
|
||||
for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? "";
|
||||
return {
|
||||
title: definition.title,
|
||||
icon: definition.icon,
|
||||
emoji: emojiIconText(definition.icon),
|
||||
// Inactive children cannot be submitted (the API rejects them with 422).
|
||||
assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)),
|
||||
repeats: REPEATS_BY_RULE[rule.rule_type],
|
||||
@@ -152,9 +168,9 @@ function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): D
|
||||
weekdays:
|
||||
rule.rule_type === "weekly" || rule.rule_type === "custom_days"
|
||||
? [...(rule.weekday_set ?? [])]
|
||||
: [(today.getDay() + 6) % 7],
|
||||
dayOfMonth: String(rule.day_of_month ?? today.getDate()),
|
||||
month: String(rule.month ?? today.getMonth() + 1),
|
||||
: [weekdayOf(today)],
|
||||
dayOfMonth: String(rule.day_of_month ?? day),
|
||||
month: String(rule.month ?? month),
|
||||
monthlyWeekday:
|
||||
rule.rule_type === "monthly_weekday"
|
||||
? { nth: rule.nth_weekday ?? 1, weekday: rule.nth_weekday_weekday ?? 0 }
|
||||
@@ -177,6 +193,7 @@ function parseWhole(value: string, min: number, max: number): number | null {
|
||||
function buildRule(draft: Draft): DefinitionRule | string {
|
||||
const interval = parseWhole(draft.interval, 1, 999);
|
||||
if (interval === null) return "Interval must be a whole number of at least 1.";
|
||||
if (draft.startDate === "") return "Pick a start date.";
|
||||
const rule: DefinitionRule = {
|
||||
rule_type: "daily",
|
||||
interval,
|
||||
@@ -230,6 +247,9 @@ function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody |
|
||||
if (!title) return "Give the task a title.";
|
||||
if (draft.assigneeIds.length === 0 && !keepsAssignees) return "Assign at least one child.";
|
||||
if (draft.windows.length === 0) return "Pick at least one window.";
|
||||
if (draft.emoji && draft.icon === null) {
|
||||
return "Enter a single emoji (no spaces or < > &), or clear the emoji field.";
|
||||
}
|
||||
const rule = buildRule(draft);
|
||||
if (typeof rule === "string") return rule;
|
||||
const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map(
|
||||
@@ -251,6 +271,12 @@ function saveErrorMessage(error: unknown): string {
|
||||
return "The task could not be saved. Check your connection and try again.";
|
||||
}
|
||||
|
||||
function deleteErrorMessage(error: unknown): string {
|
||||
if (error instanceof ApiForbiddenError) return error.message;
|
||||
if (error instanceof ApiRequestError && error.detail) return error.detail;
|
||||
return "The task could not be deleted. Check your connection and try again.";
|
||||
}
|
||||
|
||||
function toggle<T>(list: T[], value: T): T[] {
|
||||
return list.includes(value) ? list.filter((item) => item !== value) : [...list, value];
|
||||
}
|
||||
@@ -427,19 +453,27 @@ function EditSheet({
|
||||
activeChildren,
|
||||
onCancel,
|
||||
onSaved,
|
||||
onDeleted,
|
||||
}: {
|
||||
target: "new" | QuestDefinition;
|
||||
activeChildren: AdminChild[];
|
||||
onCancel: () => void;
|
||||
onSaved: () => void;
|
||||
onDeleted: (result: DefinitionDeleteResult) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Draft>(() =>
|
||||
target === "new" ? newDraft(activeChildren) : draftFrom(target, activeChildren),
|
||||
);
|
||||
const timeZone = useAppTimezone();
|
||||
const [draft, setDraft] = useState<Draft>(() => {
|
||||
const today = todayIn(timeZone);
|
||||
return target === "new" ? newDraft(activeChildren, today) : draftFrom(target, activeChildren, today);
|
||||
});
|
||||
// The active selection as loaded: while it is unchanged, the PATCH omits
|
||||
// assignee_child_ids, because the API replaces the whole set and inactive
|
||||
// children cannot be sent back.
|
||||
const [originalActiveIds] = useState(() => draft.assigneeIds);
|
||||
// The icon as loaded: while it is unchanged, the PATCH omits `icon` (absent
|
||||
// means unchanged), because a legacy value read back as `lucide:<old>` would
|
||||
// fail the API's strict icon validation if sent back.
|
||||
const [originalIcon] = useState(() => draft.icon);
|
||||
const inactiveAssignees =
|
||||
target === "new"
|
||||
? []
|
||||
@@ -447,6 +481,7 @@ function EditSheet({
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// A ref, not state: two taps in one frame must not both see `saving === false`.
|
||||
const inFlight = useRef(false);
|
||||
@@ -464,8 +499,8 @@ function EditSheet({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (confirming) confirmRef.current?.focus();
|
||||
}, [confirming]);
|
||||
if (confirming || confirmingDelete) confirmRef.current?.focus();
|
||||
}, [confirming, confirmingDelete]);
|
||||
|
||||
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === "Escape") {
|
||||
@@ -522,6 +557,7 @@ function EditSheet({
|
||||
} else {
|
||||
const edit: DefinitionEditBody = { ...body };
|
||||
if (keepsAssignees) delete edit.assignee_child_ids;
|
||||
if (draft.icon === originalIcon) delete edit.icon;
|
||||
await updateDefinition(target.id, edit);
|
||||
}
|
||||
onSaved();
|
||||
@@ -532,6 +568,21 @@ function EditSheet({
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (target === "new" || inFlight.current) return;
|
||||
setConfirmingDelete(false);
|
||||
inFlight.current = true;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
onDeleted(await deleteDefinition(target.id));
|
||||
} catch (err) {
|
||||
setError(deleteErrorMessage(err));
|
||||
inFlight.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="defs-sheet-layer">
|
||||
<div
|
||||
@@ -568,28 +619,67 @@ function EditSheet({
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={draft.icon === null}
|
||||
aria-checked={draft.icon === null && !draft.emoji}
|
||||
aria-label="No icon"
|
||||
className={draft.icon === null ? "defs-icon defs-icon--on" : "defs-icon"}
|
||||
onClick={() => update({ icon: null })}
|
||||
className={
|
||||
draft.icon === null && !draft.emoji ? "defs-icon defs-icon--on" : "defs-icon"
|
||||
}
|
||||
onClick={() => update({ icon: null, emoji: "" })}
|
||||
>
|
||||
<TaskGlyph size={18} />
|
||||
</button>
|
||||
{DEFINITION_ICONS.map(({ name, label, Icon }) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={draft.icon === name}
|
||||
aria-label={label}
|
||||
data-icon-option={name}
|
||||
className={draft.icon === name ? "defs-icon defs-icon--on" : "defs-icon"}
|
||||
onClick={() => update({ icon: name })}
|
||||
>
|
||||
<Icon size={18} aria-hidden="true" focusable="false" />
|
||||
</button>
|
||||
))}
|
||||
{DEFINITION_ICONS.map((icon) => {
|
||||
const { name, label } = icon;
|
||||
const on = normalizeDefinitionIconName(draft.icon) === name;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
aria-label={label}
|
||||
data-icon-option={name}
|
||||
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
|
||||
onClick={() => update({ icon: lucideIconKey(name), emoji: "" })}
|
||||
>
|
||||
<GlyphSvg icon={icon} size={18} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="defs-sublabel" id="defs-label-icon-fa">
|
||||
Font Awesome
|
||||
</span>
|
||||
<div className="defs-icon-grid" role="radiogroup" aria-labelledby="defs-label-icon-fa">
|
||||
{FA_DEFINITION_ICONS.map((icon) => {
|
||||
const { name, label } = icon;
|
||||
const on = faIconName(draft.icon) === name;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
aria-label={label}
|
||||
data-icon-option={faIconKey(name)}
|
||||
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
|
||||
onClick={() => update({ icon: faIconKey(name), emoji: "" })}
|
||||
>
|
||||
<GlyphSvg icon={icon} size={18} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<label className="defs-due">
|
||||
<span>Emoji</span>
|
||||
<input
|
||||
className="defs-input"
|
||||
aria-label="Emoji"
|
||||
placeholder="e.g. 🐶"
|
||||
value={draft.emoji}
|
||||
onChange={(e) => update({ emoji: e.target.value, icon: emojiIconKey(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
</Field>
|
||||
|
||||
<Field label="Assigned children" id="defs-label-children">
|
||||
@@ -740,6 +830,16 @@ function EditSheet({
|
||||
<OccurrencePreview ruleKey={previewKey} />
|
||||
</Field>
|
||||
|
||||
<Field label="Start date" id="defs-label-start">
|
||||
<input
|
||||
className="defs-input"
|
||||
type="date"
|
||||
aria-labelledby="defs-label-start"
|
||||
value={draft.startDate}
|
||||
onChange={(e) => update({ startDate: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Due time" id="defs-label-due">
|
||||
{orderedWindows.length === 0 ? (
|
||||
<p className="defs-hint">Pick a window to set its due time.</p>
|
||||
@@ -830,15 +930,56 @@ function EditSheet({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="defs-sheet-footer">
|
||||
<button type="button" className="defs-cancel" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="defs-save" onClick={() => save()} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</button>
|
||||
) : confirmingDelete ? (
|
||||
<div
|
||||
className="defs-confirm"
|
||||
role="group"
|
||||
aria-labelledby="defs-delete-confirm-text"
|
||||
data-testid="delete-confirm"
|
||||
ref={confirmRef}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<p id="defs-delete-confirm-text">
|
||||
Delete this task? If it has no completion history it is deleted; otherwise it is
|
||||
retired (deactivated) and its history is kept.
|
||||
</p>
|
||||
<div className="defs-sheet-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="defs-cancel"
|
||||
onClick={() => setConfirmingDelete(false)}
|
||||
>
|
||||
Go back
|
||||
</button>
|
||||
<button type="button" className="defs-save defs-save--danger" onClick={remove}>
|
||||
Delete task
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="defs-sheet-footer">
|
||||
<button type="button" className="defs-cancel" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="defs-save" onClick={() => save()} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</button>
|
||||
</div>
|
||||
{target !== "new" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="defs-delete"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setConfirmingDelete(true);
|
||||
}}
|
||||
>
|
||||
Delete task
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -962,6 +1103,21 @@ export default function DefinitionsTab() {
|
||||
setSheet(null);
|
||||
setAttempt((n) => n + 1);
|
||||
}}
|
||||
onDeleted={(result) => {
|
||||
setSheet(null);
|
||||
setState((current) =>
|
||||
current.kind !== "ready"
|
||||
? current
|
||||
: {
|
||||
...current,
|
||||
definitions: result.definition
|
||||
? current.definitions.map((d) =>
|
||||
d.id === result.definition_id ? result.definition! : d,
|
||||
)
|
||||
: current.definitions.filter((d) => d.id !== result.definition_id),
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,103 +1,90 @@
|
||||
/**
|
||||
* The shipped Lucide subset a definition's `icon` may name (ADMIN-SPEC §3.3).
|
||||
* Only these named imports are bundled — no CDN, no runtime fetch. The stored
|
||||
* value is the Lucide kebab-case name, so it stays meaningful to any surface.
|
||||
* The icons a definition's `icon` may name (ADMIN-SPEC §3.3): the curated
|
||||
* Lucide set, the curated Font Awesome Free solid subset, or an emoji. The
|
||||
* names, labels and path data come from the shared icon registry
|
||||
* (`src/icons/registry.generated.ts`, generated from
|
||||
* `tools/icons/curated-icons.json`) — no CDN, no runtime fetch. The stored
|
||||
* value is namespaced — `lucide:<name>`, `fa:<name>` or `emoji:<grapheme>` —
|
||||
* and a legacy bare `<kebab-name>` still names the same Lucide entry.
|
||||
*/
|
||||
import {
|
||||
Apple,
|
||||
Backpack,
|
||||
Bath,
|
||||
Bed,
|
||||
Bike,
|
||||
BookOpen,
|
||||
Brush,
|
||||
BrushCleaning,
|
||||
Calendar,
|
||||
Cat,
|
||||
Clock,
|
||||
CookingPot,
|
||||
Dog,
|
||||
Droplets,
|
||||
Fish,
|
||||
Gamepad2,
|
||||
Heart,
|
||||
House,
|
||||
ListChecks,
|
||||
Moon,
|
||||
Music,
|
||||
Pencil,
|
||||
Recycle,
|
||||
Shirt,
|
||||
ShowerHead,
|
||||
Smile,
|
||||
Sofa,
|
||||
SprayCan,
|
||||
Sprout,
|
||||
Star,
|
||||
Sun,
|
||||
Toilet,
|
||||
Trash2,
|
||||
Utensils,
|
||||
Volleyball,
|
||||
WashingMachine,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { GlyphSvg } from "../icons/GlyphSvg";
|
||||
import { FA_ICONS, LUCIDE_ICONS, resolveIcon, type GlyphIcon } from "../icons/registry.generated";
|
||||
import { TaskGlyph } from "./glyphs";
|
||||
|
||||
export interface DefinitionIconEntry {
|
||||
name: string;
|
||||
label: string;
|
||||
Icon: LucideIcon;
|
||||
export type DefinitionIconEntry = GlyphIcon;
|
||||
|
||||
export const DEFINITION_ICONS: readonly DefinitionIconEntry[] = LUCIDE_ICONS;
|
||||
|
||||
export const FA_DEFINITION_ICONS: readonly DefinitionIconEntry[] = FA_ICONS;
|
||||
|
||||
const LUCIDE_PREFIX = "lucide:";
|
||||
const FA_PREFIX = "fa:";
|
||||
const EMOJI_PREFIX = "emoji:";
|
||||
|
||||
/** The canonical stored key for a Lucide choice, e.g. `lucide:dog`. */
|
||||
export function lucideIconKey(name: string): string {
|
||||
return `${LUCIDE_PREFIX}${name}`;
|
||||
}
|
||||
|
||||
export const DEFINITION_ICONS: readonly DefinitionIconEntry[] = [
|
||||
{ name: "bed", label: "Bed", Icon: Bed },
|
||||
{ name: "bath", label: "Bath", Icon: Bath },
|
||||
{ name: "shower-head", label: "Shower", Icon: ShowerHead },
|
||||
{ name: "toilet", label: "Toilet", Icon: Toilet },
|
||||
{ name: "brush", label: "Brush", Icon: Brush },
|
||||
{ name: "smile", label: "Smile", Icon: Smile },
|
||||
{ name: "shirt", label: "Clothes", Icon: Shirt },
|
||||
{ name: "washing-machine", label: "Laundry", Icon: WashingMachine },
|
||||
{ name: "utensils", label: "Meal", Icon: Utensils },
|
||||
{ name: "cooking-pot", label: "Cooking", Icon: CookingPot },
|
||||
{ name: "apple", label: "Snack", Icon: Apple },
|
||||
{ name: "brush-cleaning", label: "Sweep", Icon: BrushCleaning },
|
||||
{ name: "spray-can", label: "Clean", Icon: SprayCan },
|
||||
{ name: "trash-2", label: "Bins", Icon: Trash2 },
|
||||
{ name: "recycle", label: "Recycling", Icon: Recycle },
|
||||
{ name: "sofa", label: "Tidy", Icon: Sofa },
|
||||
{ name: "house", label: "Home", Icon: House },
|
||||
{ name: "backpack", label: "School bag", Icon: Backpack },
|
||||
{ name: "book-open", label: "Reading", Icon: BookOpen },
|
||||
{ name: "pencil", label: "Homework", Icon: Pencil },
|
||||
{ name: "music", label: "Music", Icon: Music },
|
||||
{ name: "dog", label: "Dog", Icon: Dog },
|
||||
{ name: "cat", label: "Cat", Icon: Cat },
|
||||
{ name: "fish", label: "Fish", Icon: Fish },
|
||||
{ name: "sprout", label: "Plants", Icon: Sprout },
|
||||
{ name: "droplets", label: "Water", Icon: Droplets },
|
||||
{ name: "bike", label: "Bike", Icon: Bike },
|
||||
{ name: "volleyball", label: "Sport", Icon: Volleyball },
|
||||
{ name: "gamepad-2", label: "Games", Icon: Gamepad2 },
|
||||
{ name: "star", label: "Star", Icon: Star },
|
||||
{ name: "heart", label: "Heart", Icon: Heart },
|
||||
{ name: "sun", label: "Morning", Icon: Sun },
|
||||
{ name: "moon", label: "Bedtime", Icon: Moon },
|
||||
{ name: "clock", label: "Clock", Icon: Clock },
|
||||
{ name: "calendar", label: "Calendar", Icon: Calendar },
|
||||
{ name: "list-checks", label: "Checklist", Icon: ListChecks },
|
||||
];
|
||||
|
||||
const BY_NAME = new Map(DEFINITION_ICONS.map((entry) => [entry.name, entry]));
|
||||
|
||||
export function findDefinitionIcon(name: string | null): DefinitionIconEntry | null {
|
||||
return name === null ? null : (BY_NAME.get(name) ?? null);
|
||||
/** The canonical stored key for a Font Awesome choice, e.g. `fa:dog`. */
|
||||
export function faIconKey(name: string): string {
|
||||
return `${FA_PREFIX}${name}`;
|
||||
}
|
||||
|
||||
/** A definition's glyph; no icon, or one outside the subset, gets the generic task glyph. */
|
||||
/**
|
||||
* The stored key for typed emoji text, e.g. `emoji:🐶`. The raw text — not
|
||||
* trimmed, as the backend rejects any whitespace — must pass the registry's
|
||||
* emoji check (1-8 code points, no whitespace, control characters or `<`,
|
||||
* `>`, `&`), else null. Only an empty field means "no icon".
|
||||
*/
|
||||
export function emojiIconKey(text: string): string | null {
|
||||
const key = `${EMOJI_PREFIX}${text}`;
|
||||
return resolveIcon(key).kind === "emoji" ? key : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Lucide name an icon value refers to: `lucide:dog` and a legacy bare
|
||||
* `dog` both give `dog`. Any other namespace (`fa:`, `emoji:`, unknown) and
|
||||
* null give null — the admin has no Lucide entry for them.
|
||||
*/
|
||||
export function normalizeDefinitionIconName(value: string | null): string | null {
|
||||
if (value === null) return null;
|
||||
if (value.startsWith(LUCIDE_PREFIX)) return value.slice(LUCIDE_PREFIX.length);
|
||||
return value.includes(":") ? null : value;
|
||||
}
|
||||
|
||||
/** The Font Awesome name an `fa:<name>` value refers to; anything else gives null. */
|
||||
export function faIconName(value: string | null): string | null {
|
||||
return value?.startsWith(FA_PREFIX) ? value.slice(FA_PREFIX.length) : null;
|
||||
}
|
||||
|
||||
/** The emoji text an `emoji:<grapheme>` value holds; anything else gives "". */
|
||||
export function emojiIconText(value: string | null): string {
|
||||
return value?.startsWith(EMOJI_PREFIX) ? value.slice(EMOJI_PREFIX.length) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* The curated glyph (Lucide or Font Awesome) an icon value resolves to via
|
||||
* the shared parser; emoji, unknown and null values give null.
|
||||
*/
|
||||
export function findDefinitionIcon(value: string | null): DefinitionIconEntry | null {
|
||||
const icon = resolveIcon(value);
|
||||
return icon.kind === "lucide" || icon.kind === "fa" ? icon : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A definition's icon: a Lucide or Font Awesome glyph, an emoji drawn as
|
||||
* text, or — for no icon or an unknown value — the generic task glyph.
|
||||
*/
|
||||
export function DefinitionIcon({ name, size = 17 }: { name: string | null; size?: number }) {
|
||||
const entry = findDefinitionIcon(name);
|
||||
if (!entry) return <TaskGlyph size={size} />;
|
||||
return <entry.Icon size={size} aria-hidden="true" focusable="false" data-icon={entry.name} />;
|
||||
const icon = resolveIcon(name);
|
||||
if (icon.kind === "fallback") return <TaskGlyph size={size} />;
|
||||
if (icon.kind === "emoji") {
|
||||
return (
|
||||
<span className="defs-emoji" style={{ fontSize: size }} aria-hidden="true" data-emoji={icon.text}>
|
||||
{icon.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <GlyphSvg icon={icon} size={size} />;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { HistoryFilter, HistoryRow } from "../api/history";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
import { TransitionsProvider } from "../api/useTransitions";
|
||||
import { frame, testStream, type TestStream } from "../api/testStream";
|
||||
import { AppTimezoneProvider } from "../time/AppTimezone";
|
||||
|
||||
const TODAY = "2026-09-23";
|
||||
|
||||
@@ -629,3 +630,117 @@ describe("HistoryTab live updates", () => {
|
||||
expect(screen.getAllByTestId("history-row")).toHaveLength(ROWS.all.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HistoryTab in the stored application timezone", () => {
|
||||
// 13:00 UTC on Sep 23 is 01:00 on Sep 24 in Auckland (NZST, UTC+12).
|
||||
const NOW = new Date("2026-09-23T13:00:00Z");
|
||||
// Due 00:10 Auckland on Sep 24, done 00:30 Auckland (12:30 UTC on Sep 23).
|
||||
const LATE_NZ = row({
|
||||
instance_id: 7,
|
||||
quest_title: "Feed cat",
|
||||
due_date: "2026-09-24",
|
||||
due_time: "00:10",
|
||||
occurred_at: "2026-09-23T12:30:00Z",
|
||||
was_on_time: false,
|
||||
});
|
||||
// 22:00 Auckland on Sep 23; 10:00 UTC.
|
||||
const EVENING_NZ = row({ instance_id: 8, occurred_at: "2026-09-23T10:00:00Z" });
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function historyRequests(): URL[] {
|
||||
return fetchMock.mock.calls
|
||||
.map(([url]) => new URL(String(url), "http://x"))
|
||||
.filter((url) => url.pathname === "/api/v1/admin/history");
|
||||
}
|
||||
|
||||
async function renderWithZone(timezone: string, effective_timezone = timezone) {
|
||||
fetchMock = vi.fn((url: string) => {
|
||||
const parsed = new URL(url, "http://x");
|
||||
if (parsed.pathname.endsWith("/api/v1/admin/settings")) {
|
||||
return Promise.resolve(jsonResponse({ timezone, effective_timezone }));
|
||||
}
|
||||
const filter = parsed.searchParams.get("filter");
|
||||
const rows = filter === "all" ? [EVENING_NZ, LATE_NZ] : [];
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
filter,
|
||||
start: parsed.searchParams.get("start"),
|
||||
end: parsed.searchParams.get("end"),
|
||||
count: rows.length,
|
||||
rows,
|
||||
}),
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
render(
|
||||
<AppTimezoneProvider>
|
||||
<HistoryTab />
|
||||
</AppTimezoneProvider>,
|
||||
);
|
||||
await screen.findByTestId("stat-on-time");
|
||||
}
|
||||
|
||||
function times(section: HTMLElement): string[] {
|
||||
return Array.from(section.querySelectorAll(".history-event-time")).map(
|
||||
(el) => el.textContent ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("TZ", "UTC");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
vi.useFakeTimers({ toFake: ["Date"], now: NOW });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the stored zone", "Pacific/Auckland"],
|
||||
["the API host's published zone when none is stored", ""],
|
||||
])("today, day grouping, clock and lateness follow %s", async (_label, stored) => {
|
||||
await renderWithZone(stored, "Pacific/Auckland");
|
||||
|
||||
for (const url of historyRequests()) {
|
||||
expect(url.searchParams.get("end")).toBe("2026-09-24");
|
||||
expect(url.searchParams.get("start")).toBe("2026-09-11");
|
||||
}
|
||||
const today = screen.getByTestId("day-2026-09-24");
|
||||
expect(within(today).getByText("Today")).toBeTruthy();
|
||||
expect(times(today)).toEqual(["12:30 AM"]);
|
||||
expect(metas(today)).toEqual(["completed · panel (Declan) · late 20 min"]);
|
||||
|
||||
const yesterday = screen.getByTestId("day-2026-09-23");
|
||||
expect(within(yesterday).getByText("Yesterday")).toBeTruthy();
|
||||
expect(times(yesterday)).toEqual(["10:00 PM"]);
|
||||
});
|
||||
|
||||
it("only an API that publishes no zone leaves the browser's local day and clock", async () => {
|
||||
await renderWithZone("", "");
|
||||
|
||||
for (const url of historyRequests()) {
|
||||
expect(url.searchParams.get("end")).toBe("2026-09-23");
|
||||
}
|
||||
expect(screen.queryByTestId("day-2026-09-24")).toBeNull();
|
||||
const today = screen.getByTestId("day-2026-09-23");
|
||||
expect(within(today).getByText("Today")).toBeTruthy();
|
||||
expect(times(today)).toEqual(["12:30 PM", "10:00 AM"]);
|
||||
// Due 00:10 UTC on Sep 24 is after the 12:30 UTC completion: no minutes.
|
||||
expect(metas(today)).toEqual([
|
||||
"completed · panel (Declan) · late",
|
||||
"completed · panel (Declan) · on time",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,9 +9,10 @@ import { ApiForbiddenError } from "../api/client";
|
||||
import type { TransitionEvent } from "../api/events";
|
||||
import { useTransitions } from "../api/useTransitions";
|
||||
import { exportHistoryCsv, fetchHistory, type HistoryFilter, type HistoryRow } from "../api/history";
|
||||
import { localTodayIso } from "../schedule/cycle";
|
||||
import { dayLabel, groupByDay, rowMeta, rowTime, shiftIso } from "./format";
|
||||
import { DownloadGlyph, LockGlyph } from "./glyphs";
|
||||
import { useAppTimezone } from "../time/AppTimezone";
|
||||
import { todayIn } from "../time/zone";
|
||||
import "./HistoryTab.css";
|
||||
|
||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||
@@ -129,7 +130,7 @@ function StatRow({ stats }: { stats: HistoryStats }) {
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({ row }: { row: HistoryRow }) {
|
||||
function EventRow({ row, timeZone }: { row: HistoryRow; timeZone: string }) {
|
||||
return (
|
||||
<li className="history-event" data-testid="history-row">
|
||||
<span
|
||||
@@ -142,10 +143,10 @@ function EventRow({ row }: { row: HistoryRow }) {
|
||||
{row.quest_title} · {row.child_name}
|
||||
</div>
|
||||
<div className="history-event-meta" data-testid="history-meta">
|
||||
{rowMeta(row)}
|
||||
{rowMeta(row, timeZone)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="history-event-time">{rowTime(row)}</span>
|
||||
<span className="history-event-time">{rowTime(row, timeZone)}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -161,7 +162,10 @@ function FooterNote() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function HistoryTab({ today = localTodayIso() }: { today?: string }) {
|
||||
/** `today` ("YYYY-MM-DD") defaults to today in the application timezone. */
|
||||
export default function HistoryTab({ today: todayProp }: { today?: string }) {
|
||||
const timeZone = useAppTimezone();
|
||||
const today = todayProp ?? todayIn(timeZone);
|
||||
const [filter, setFilter] = useState<HistoryFilter>("all");
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
@@ -244,7 +248,7 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const groups = groupByDay(shown.data.rows);
|
||||
const groups = groupByDay(shown.data.rows, timeZone);
|
||||
body =
|
||||
groups.length > 0 ? (
|
||||
groups.map((group) => (
|
||||
@@ -252,7 +256,7 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string
|
||||
<h3 className="history-section-label">{dayLabel(group.day, today)}</h3>
|
||||
<ul className="history-card history-list">
|
||||
{group.rows.map((row, index) => (
|
||||
<EventRow key={`${row.instance_id}-${row.event_type}-${row.occurred_at ?? ""}-${index}`} row={row} />
|
||||
<EventRow key={`${row.instance_id}-${row.event_type}-${row.occurred_at ?? ""}-${index}`} row={row} timeZone={timeZone} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
+21
-24
@@ -4,12 +4,13 @@
|
||||
* Calendar dates ("YYYY-MM-DD") are handled by their components (never
|
||||
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
|
||||
* day in negative-offset zones); event stamps are UTC and shown in the
|
||||
* viewer's local time.
|
||||
* application timezone (`timeZone`: the stored IANA zone, "" = browser).
|
||||
*/
|
||||
import type { HistoryRow } from "../api/history";
|
||||
import { dayNumber, isoFromDayNumber, localTodayIso } from "../schedule/cycle";
|
||||
import { dayNumber, isoFromDayNumber } from "../schedule/cycle";
|
||||
import { shortDate } from "../schedule/format";
|
||||
import { formatClockTime, formatDueTime } from "../today/format";
|
||||
import { isoDateIn, wallClockInstant } from "../time/zone";
|
||||
|
||||
const MS_PER_MINUTE = 60_000;
|
||||
|
||||
@@ -19,25 +20,21 @@ export function shiftIso(iso: string, days: number): string {
|
||||
}
|
||||
|
||||
/** The local calendar day an event belongs to (due_date for missed rows). */
|
||||
export function rowDay(row: HistoryRow): string {
|
||||
return row.occurred_at ? localTodayIso(new Date(row.occurred_at)) : row.due_date;
|
||||
export function rowDay(row: HistoryRow, timeZone: string): string {
|
||||
return row.occurred_at ? isoDateIn(new Date(row.occurred_at), timeZone) : row.due_date;
|
||||
}
|
||||
|
||||
/** The row's due moment as local wall-clock time, or null without a due time. */
|
||||
function dueMoment(row: HistoryRow): Date | null {
|
||||
/** The row's due moment (epoch ms) as local wall-clock time, or null without a due time. */
|
||||
function dueMoment(row: HistoryRow, timeZone: string): number | null {
|
||||
const match = /^(\d{2}):(\d{2})/.exec(row.due_time ?? "");
|
||||
if (!match) return null;
|
||||
const [year, month, day] = row.due_date.split("-").map(Number);
|
||||
return new Date(year, month - 1, day, Number(match[1]), Number(match[2]));
|
||||
return wallClockInstant(row.due_date, Number(match[1]), Number(match[2]), timeZone);
|
||||
}
|
||||
|
||||
/** Newest first; a missed row sorts at its due moment (or the end of its day). */
|
||||
function sortKey(row: HistoryRow): number {
|
||||
function sortKey(row: HistoryRow, timeZone: string): number {
|
||||
if (row.occurred_at) return Date.parse(row.occurred_at);
|
||||
const due = dueMoment(row);
|
||||
if (due) return due.getTime();
|
||||
const [year, month, day] = row.due_date.split("-").map(Number);
|
||||
return new Date(year, month - 1, day, 23, 59).getTime();
|
||||
return dueMoment(row, timeZone) ?? wallClockInstant(row.due_date, 23, 59, timeZone);
|
||||
}
|
||||
|
||||
export interface DayGroup {
|
||||
@@ -46,10 +43,10 @@ export interface DayGroup {
|
||||
}
|
||||
|
||||
/** Rows grouped by day, newest day first and newest row first within a day. */
|
||||
export function groupByDay(rows: HistoryRow[]): DayGroup[] {
|
||||
export function groupByDay(rows: HistoryRow[], timeZone: string): DayGroup[] {
|
||||
const groups = new Map<string, HistoryRow[]>();
|
||||
for (const row of rows) {
|
||||
const day = rowDay(row);
|
||||
const day = rowDay(row, timeZone);
|
||||
groups.set(day, [...(groups.get(day) ?? []), row]);
|
||||
}
|
||||
return [...groups.entries()]
|
||||
@@ -57,7 +54,7 @@ export function groupByDay(rows: HistoryRow[]): DayGroup[] {
|
||||
.map(([day, dayRows]) => ({
|
||||
day,
|
||||
// Stable sort: rows at the same moment keep the API's append order.
|
||||
rows: [...dayRows].sort((a, b) => sortKey(b) - sortKey(a)),
|
||||
rows: [...dayRows].sort((a, b) => sortKey(b, timeZone) - sortKey(a, timeZone)),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -72,23 +69,23 @@ export function dayLabel(day: string, today: string): string {
|
||||
}
|
||||
|
||||
/** "on time", "late 22 min", or "late" when the lateness is not derivable. */
|
||||
function onTimeFlag(row: HistoryRow): string | null {
|
||||
function onTimeFlag(row: HistoryRow, timeZone: string): string | null {
|
||||
if (row.event_type !== "completed" || row.was_on_time === null) return null;
|
||||
if (row.was_on_time) return "on time";
|
||||
const due = dueMoment(row);
|
||||
if (!due || !row.occurred_at) return "late";
|
||||
const minutes = Math.round((Date.parse(row.occurred_at) - due.getTime()) / MS_PER_MINUTE);
|
||||
const due = dueMoment(row, timeZone);
|
||||
if (due === null || !row.occurred_at) return "late";
|
||||
const minutes = Math.round((Date.parse(row.occurred_at) - due) / MS_PER_MINUTE);
|
||||
return minutes > 0 ? `late ${minutes} min` : "late";
|
||||
}
|
||||
|
||||
/** `<event type> · <actor>` plus the on-time flag for completions. */
|
||||
export function rowMeta(row: HistoryRow): string {
|
||||
const flag = onTimeFlag(row);
|
||||
export function rowMeta(row: HistoryRow, timeZone: string): string {
|
||||
const flag = onTimeFlag(row, timeZone);
|
||||
return [row.event_type, row.actor, flag].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
/** The event's local clock time; a missed row shows its due time, if any. */
|
||||
export function rowTime(row: HistoryRow): string {
|
||||
if (row.occurred_at) return formatClockTime(row.occurred_at);
|
||||
export function rowTime(row: HistoryRow, timeZone: string): string {
|
||||
if (row.occurred_at) return formatClockTime(row.occurred_at, timeZone);
|
||||
return row.due_time ? formatDueTime(row.due_time) : "";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Draws a registry glyph (`registry.generated.ts`) as an inline SVG — the
|
||||
* path data is bundled, so there is no CDN and no runtime fetch. Lucide
|
||||
* glyphs are stroked on their 24px grid; Font Awesome glyphs are filled.
|
||||
*/
|
||||
import type { GlyphIcon } from "./registry.generated";
|
||||
|
||||
export function GlyphSvg({ icon, size }: { icon: GlyphIcon; size: number }) {
|
||||
const stroked = icon.kind === "lucide";
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={icon.viewBox}
|
||||
fill={stroked ? "none" : "currentColor"}
|
||||
stroke={stroked ? "currentColor" : undefined}
|
||||
strokeWidth={stroked ? 2 : undefined}
|
||||
strokeLinecap={stroked ? "round" : undefined}
|
||||
strokeLinejoin={stroked ? "round" : undefined}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
data-icon={icon.name}
|
||||
>
|
||||
{icon.paths.map((d, index) => (
|
||||
<path key={index} d={d} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,170 @@
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
// The offline generator is the only writer of both registry artefacts.
|
||||
import { OUTPUT_PATHS, REPO_ROOT, SOURCE_PATH, renderRegistry } from "../../../tools/icons/generate.mjs";
|
||||
import {
|
||||
EMOJI_MAX_CODE_POINTS,
|
||||
FALLBACK_ICON,
|
||||
FA_ICONS,
|
||||
FA_SOLID_ICONS,
|
||||
LUCIDE_ICONS,
|
||||
resolveIcon,
|
||||
} from "./registry.generated";
|
||||
|
||||
type Curated = { lucide: { name: string; label: string }[]; fa: { name: string; label: string }[] };
|
||||
|
||||
const curated = JSON.parse(readFileSync(SOURCE_PATH, "utf8")) as Curated;
|
||||
|
||||
type EmojiPayloadCase = { payload: string; valid: boolean; why: string };
|
||||
|
||||
const emojiPayloadCases = JSON.parse(
|
||||
readFileSync(join(REPO_ROOT, "tools", "icons", "emoji-payload-cases.json"), "utf8"),
|
||||
) as { cases: EmojiPayloadCase[] };
|
||||
|
||||
describe("shared icon registry — one source of truth", () => {
|
||||
it("both committed artefacts are byte-identical to a fresh generation", async () => {
|
||||
const fresh = await renderRegistry();
|
||||
expect(OUTPUT_PATHS).toHaveLength(2);
|
||||
for (const path of OUTPUT_PATHS) {
|
||||
expect(readFileSync(path, "utf8"), `${path} drifted — run node tools/icons/generate.mjs`).toBe(fresh);
|
||||
}
|
||||
});
|
||||
|
||||
it("the generated sets carry exactly the curated names and labels, in order", () => {
|
||||
expect(LUCIDE_ICONS.map(({ name, label }) => ({ name, label }))).toEqual(curated.lucide);
|
||||
expect(FA_ICONS.map(({ name, label }) => ({ name, label }))).toEqual(curated.fa);
|
||||
expect(curated.lucide).toHaveLength(36);
|
||||
expect(FA_ICONS.length).toBeGreaterThanOrEqual(15);
|
||||
expect(FA_ICONS.length).toBeLessThanOrEqual(25);
|
||||
});
|
||||
|
||||
it("every glyph is plain data: a viewBox and at least one path", () => {
|
||||
for (const icon of LUCIDE_ICONS) {
|
||||
expect(icon.kind).toBe("lucide");
|
||||
expect(icon.key).toBe(`lucide:${icon.name}`);
|
||||
expect(icon.viewBox).toBe("0 0 24 24");
|
||||
expect(icon.paths.length, icon.name).toBeGreaterThan(0);
|
||||
}
|
||||
for (const icon of FA_ICONS) {
|
||||
expect(icon.kind).toBe("fa");
|
||||
expect(icon.key).toBe(`fa:${icon.name}`);
|
||||
expect(icon.viewBox).toMatch(/^0 0 \d+ 512$/);
|
||||
expect(icon.paths.length, icon.name).toBeGreaterThan(0);
|
||||
}
|
||||
const solidNames = FA_SOLID_ICONS.map((icon) => icon.name);
|
||||
expect(solidNames.length).toBeGreaterThanOrEqual(1400);
|
||||
expect(solidNames).toEqual([...solidNames].sort());
|
||||
expect(new Set(solidNames).size).toBe(solidNames.length);
|
||||
for (const { name } of FA_ICONS) expect(solidNames, name).toContain(name);
|
||||
for (const icon of FA_SOLID_ICONS) {
|
||||
expect(icon.kind).toBe("fa");
|
||||
expect(icon.key).toBe(`fa:${icon.name}`);
|
||||
expect(icon.viewBox).toMatch(/^0 0 \d+ 512$/);
|
||||
expect(icon.paths.length, icon.name).toBeGreaterThan(0);
|
||||
}
|
||||
for (const icon of [...LUCIDE_ICONS, ...FA_ICONS, ...FA_SOLID_ICONS]) {
|
||||
for (const d of icon.paths) expect(d, icon.key).toMatch(/^[Mm][-0-9.,\sA-Za-z]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveIcon", () => {
|
||||
it("resolves lucide:, fa: and emoji: keys", () => {
|
||||
const dog = resolveIcon("lucide:dog");
|
||||
expect(dog.kind).toBe("lucide");
|
||||
expect(dog).toBe(LUCIDE_ICONS.find((icon) => icon.name === "dog"));
|
||||
|
||||
const broom = resolveIcon("fa:broom");
|
||||
expect(broom.kind).toBe("fa");
|
||||
expect(broom).toBe(FA_ICONS.find((icon) => icon.name === "broom"));
|
||||
|
||||
// Any Free Solid name resolves, not just the curated quick-pick set.
|
||||
const rocket = resolveIcon("fa:rocket");
|
||||
expect(rocket).toBe(FA_SOLID_ICONS.find((icon) => icon.name === "rocket"));
|
||||
expect(rocket).toMatchObject({ kind: "fa", key: "fa:rocket", label: "Rocket" });
|
||||
expect(resolveIcon("fa:person-dress-burst")).toMatchObject({ kind: "fa", label: "Person Dress Burst" });
|
||||
|
||||
expect(resolveIcon("emoji:🐶")).toEqual({ kind: "emoji", key: "emoji:🐶", text: "🐶" });
|
||||
expect(resolveIcon("emoji:👨👩👧")).toEqual({ kind: "emoji", key: "emoji:👨👩👧", text: "👨👩👧" });
|
||||
});
|
||||
|
||||
it("reads a legacy bare name as Lucide", () => {
|
||||
expect(resolveIcon("dog")).toBe(resolveIcon("lucide:dog"));
|
||||
expect(resolveIcon("trash-2")).toBe(LUCIDE_ICONS.find((icon) => icon.name === "trash-2"));
|
||||
});
|
||||
|
||||
it("falls back — never throws — for empty or unknown values", () => {
|
||||
for (const value of [
|
||||
null,
|
||||
undefined,
|
||||
"",
|
||||
" ",
|
||||
"rocket-ship-9000",
|
||||
"lucide:",
|
||||
"lucide:rocket-ship-9000",
|
||||
"fa:",
|
||||
"fa:rocket-ship-9000",
|
||||
"fa:Dog",
|
||||
"mystery:dog",
|
||||
"emoji:",
|
||||
"emoji:<b>",
|
||||
"emoji:a b",
|
||||
"emoji:123456789",
|
||||
]) {
|
||||
expect(resolveIcon(value), String(value)).toBe(FALLBACK_ICON);
|
||||
}
|
||||
expect(FALLBACK_ICON).toEqual({ kind: "fallback", key: null });
|
||||
});
|
||||
|
||||
// tests/test_core_icons.py asserts the same table against the backend's
|
||||
// validate_icon, so the two emoji rules cannot drift apart silently.
|
||||
it.each(emojiPayloadCases.cases)("emoji verdict matches the shared table: $why", ({ payload, valid }) => {
|
||||
const value = `emoji:${payload}`;
|
||||
expect(resolveIcon(value)).toEqual(valid ? { kind: "emoji", key: value, text: payload } : FALLBACK_ICON);
|
||||
});
|
||||
|
||||
it("the shared emoji table covers the contract edges", () => {
|
||||
const verdicts = new Map(emojiPayloadCases.cases.map(({ payload, valid }) => [payload, valid]));
|
||||
expect(verdicts.get("\ufeff")).toBe(false);
|
||||
expect(verdicts.get("👨\u200d👩\u200d👧\u200d👦")).toBe(true);
|
||||
expect(verdicts.get("🐕 ")).toBe(false);
|
||||
expect(verdicts.get("🐕\u0000")).toBe(false);
|
||||
for (const markup of ["<b>", "a>b", "a&b"]) expect(verdicts.get(markup)).toBe(false);
|
||||
expect(verdicts.get("⭐".repeat(EMOJI_MAX_CODE_POINTS))).toBe(true);
|
||||
expect(verdicts.get("⭐".repeat(EMOJI_MAX_CODE_POINTS + 1))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function filesUnder(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((entry) => {
|
||||
const path = join(dir, entry);
|
||||
return statSync(path).isDirectory() ? filesUnder(path) : [path];
|
||||
});
|
||||
}
|
||||
|
||||
describe("offline bundling", () => {
|
||||
const registryFiles = [SOURCE_PATH, join(REPO_ROOT, "tools", "icons", "generate.mjs"), ...OUTPUT_PATHS];
|
||||
|
||||
it("the registry has no remote URL, CDN reference or data-lucide runtime", () => {
|
||||
for (const path of registryFiles) {
|
||||
const text = readFileSync(path, "utf8");
|
||||
expect(text, path).not.toMatch(/https?:\/\//i);
|
||||
expect(text, path).not.toMatch(/\/\/[a-z0-9.-]+\.[a-z]{2,}\//i);
|
||||
expect(text, path).not.toMatch(/\bcdn\b|jsdelivr|unpkg|cdnjs|kit\.fontawesome/i);
|
||||
expect(text, path).not.toContain("data-lucide");
|
||||
expect(text, path).not.toMatch(/\bfetch\s*\(/);
|
||||
}
|
||||
});
|
||||
|
||||
it("no bundled source imports an icon package — only the curated generated data ships", () => {
|
||||
const sources = [join(REPO_ROOT, "admin", "src"), join(REPO_ROOT, "frontend", "src")]
|
||||
.flatMap(filesUnder)
|
||||
.filter((path) => /\.(ts|tsx)$/.test(path) && !path.endsWith(".test.ts") && !path.endsWith(".test.tsx"));
|
||||
expect(sources.length).toBeGreaterThan(0);
|
||||
for (const path of sources) {
|
||||
const text = readFileSync(path, "utf8");
|
||||
expect(text, path).not.toMatch(/from\s+["'](lucide|lucide-react|lucide-static|@fortawesome\/[^"']+)["']/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,10 @@ async function routeFetch(url: string, init: RequestInit = {}): Promise<Response
|
||||
overrides = overrides.filter((o) => o.id !== Number(deleteMatch[1]));
|
||||
return jsonResponse({ status: "ok" });
|
||||
}
|
||||
// The shell loads the application timezone first ("" = none published).
|
||||
if (path.endsWith("/admin/settings")) {
|
||||
return jsonResponse({ timezone: "", effective_timezone: "" });
|
||||
}
|
||||
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
|
||||
const patternsMatch = /\/children\/(\d+)\/presence-patterns$/.exec(path);
|
||||
if (patternsMatch) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import ScheduleTab from "./ScheduleTab";
|
||||
import type { AdminChild } from "../api/definitions";
|
||||
import type { PresenceOverride, PresencePattern } from "../api/presence";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
import { AppTimezoneProvider } from "../time/AppTimezone";
|
||||
import { cycleWeekIndex, monthDates, weekdayOf } from "./cycle";
|
||||
|
||||
function child(id: number, display_name: string, sort_order: number, is_active = true): AdminChild {
|
||||
@@ -655,3 +656,55 @@ describe("ScheduleTab", () => {
|
||||
expect(await screen.findByTestId("pattern-1")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ScheduleTab today in the stored application timezone", () => {
|
||||
async function renderWithZone(timezone: string, effective_timezone = timezone) {
|
||||
fetchMock = vi.fn(async (url: string, init?: RequestInit) =>
|
||||
String(url).endsWith("/api/v1/admin/settings")
|
||||
? jsonResponse({ timezone, effective_timezone })
|
||||
: routeFetch(url, init),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
render(
|
||||
<AppTimezoneProvider>
|
||||
<ScheduleTab />
|
||||
</AppTimezoneProvider>,
|
||||
);
|
||||
await screen.findByTestId("pattern-1");
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("TZ", "UTC");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
patterns = { 1: [DECLAN_HOME], 2: [MAEVE_THU_FRI, MAEVE_WEEKENDS], 3: [] };
|
||||
writeFailure = null;
|
||||
// 13:00 UTC on Sep 23 is already 01:00 on Sep 24 in Auckland.
|
||||
vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-09-23T13:00:00Z") });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("marks today in the stored zone", async () => {
|
||||
await renderWithZone("Pacific/Auckland");
|
||||
expect(day("2026-09-24").className).toContain("schedule-day--today");
|
||||
expect(day("2026-09-23").className).not.toContain("schedule-day--today");
|
||||
});
|
||||
|
||||
it("an empty stored zone marks today in the API host's published zone", async () => {
|
||||
await renderWithZone("", "Pacific/Auckland");
|
||||
expect(day("2026-09-24").className).toContain("schedule-day--today");
|
||||
expect(day("2026-09-23").className).not.toContain("schedule-day--today");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import {
|
||||
cycleLabel,
|
||||
dayState,
|
||||
localTodayIso,
|
||||
monthDates,
|
||||
monthStart,
|
||||
patternDaysLabel,
|
||||
@@ -38,6 +37,8 @@ import {
|
||||
} from "./glyphs";
|
||||
import OverrideEditor from "./OverrideEditor";
|
||||
import PatternEditor from "./PatternEditor";
|
||||
import { useAppTimezone } from "../time/AppTimezone";
|
||||
import { todayIn } from "../time/zone";
|
||||
import "./ScheduleTab.css";
|
||||
|
||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||
@@ -324,11 +325,13 @@ function OverrideRow({ override, childName, deletion, onAskDelete, onKeep, onDel
|
||||
}
|
||||
|
||||
export interface ScheduleTabProps {
|
||||
/** "YYYY-MM-DD"; defaults to the viewer's local date. */
|
||||
/** "YYYY-MM-DD"; defaults to today in the application timezone. */
|
||||
today?: string;
|
||||
}
|
||||
|
||||
export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProps) {
|
||||
export default function ScheduleTab({ today: todayProp }: ScheduleTabProps) {
|
||||
const timeZone = useAppTimezone();
|
||||
const today = todayProp ?? todayIn(timeZone);
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
|
||||
@@ -55,13 +55,6 @@ export function cycleWeekIndex(anchorDate: string, date: string, cycleLengthWeek
|
||||
return mod(Math.floor(days / 7), cycleLengthWeeks);
|
||||
}
|
||||
|
||||
/** The viewer's local calendar date. */
|
||||
export function localTodayIso(now: Date = new Date()): string {
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
return `${now.getFullYear()}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/** "YYYY-MM-01" of the month containing `iso`, shifted by `delta` months. */
|
||||
export function monthStart(iso: string, delta = 0): string {
|
||||
const [year, month] = iso.split("-").map(Number);
|
||||
|
||||
@@ -258,6 +258,12 @@
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.settings-hint {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.settings-input {
|
||||
width: 100%;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { StrictMode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import App from "../App";
|
||||
@@ -5,6 +6,7 @@ import SettingsScreen, { SCROLL_BOTTOM_PADDING } from "./SettingsScreen";
|
||||
import type { AdminChild } from "../api/definitions";
|
||||
import type { HouseholdSettings } from "../api/settings";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
import { AppTimezoneProvider, useAppTimezone } from "../time/AppTimezone";
|
||||
|
||||
const CHILDREN_PATH = "/api/v1/admin/children";
|
||||
const REORDER_PATH = `${CHILDREN_PATH}/reorder`;
|
||||
@@ -21,8 +23,18 @@ const DEFAULT_SETTINGS: HouseholdSettings = {
|
||||
afternoon_reminder_enabled: false,
|
||||
end_of_day_report_enabled: true,
|
||||
celebration_enabled: false,
|
||||
timezone: "America/Chicago",
|
||||
effective_timezone: "America/Chicago",
|
||||
};
|
||||
|
||||
/** The API host's own zone: the effective zone while none is stored. */
|
||||
const API_HOST_ZONE = "Europe/London";
|
||||
|
||||
/** The settings as the API answers: `effective_timezone` derived like the API's. */
|
||||
function settingsBody(): HouseholdSettings {
|
||||
return { ...settings, effective_timezone: settings.timezone || API_HOST_ZONE };
|
||||
}
|
||||
|
||||
function child(
|
||||
id: number,
|
||||
display_name: string,
|
||||
@@ -80,10 +92,10 @@ async function routeFetch(url: string, init: RequestInit = {}): Promise<Response
|
||||
return respond();
|
||||
}
|
||||
if (path === CHILDREN_PATH && method === "GET") return jsonResponse({ children });
|
||||
if (path === SETTINGS_PATH && method === "GET") return jsonResponse(settings);
|
||||
if (path === SETTINGS_PATH && method === "GET") return jsonResponse(settingsBody());
|
||||
if (path === SETTINGS_PATH && method === "PATCH") {
|
||||
settings = { ...settings, ...body };
|
||||
return jsonResponse(settings);
|
||||
return jsonResponse(settingsBody());
|
||||
}
|
||||
if (path === CHILDREN_PATH && method === "POST") {
|
||||
const created = child(9, body.display_name, body.sort_order ?? 0, {
|
||||
@@ -370,10 +382,11 @@ describe("SettingsScreen — preferences", () => {
|
||||
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
|
||||
}
|
||||
|
||||
it("renders the current value of all ten fields", async () => {
|
||||
it("renders the current value of all eleven settings form fields", async () => {
|
||||
const { form } = await renderPreferences();
|
||||
expect(field(form, "Planning horizon (days)").value).toBe("14");
|
||||
expect(field(form, "Day rollover").value).toBe("03:00");
|
||||
expect(field(form, "Timezone").value).toBe("America/Chicago");
|
||||
expect(field(form, "Notify target").value).toBe("notify.family");
|
||||
expect(field(form, "Morning summary time").value).toBe("07:00");
|
||||
expect(field(form, "Afternoon reminder time").value).toBe("15:30");
|
||||
@@ -496,6 +509,49 @@ describe("SettingsScreen — preferences", () => {
|
||||
expect(writes()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("a timezone picked from the list patches exactly the timezone", async () => {
|
||||
const { form } = await renderPreferences();
|
||||
fireEvent.change(field(form, "Timezone"), { target: { value: "America/New_York" } });
|
||||
save(form);
|
||||
await screen.findByText("Preferences saved.");
|
||||
expect(writes()).toEqual([
|
||||
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } },
|
||||
]);
|
||||
const reloaded = screen.getByRole("form", { name: "Preferences" });
|
||||
expect(field(reloaded, "Timezone").value).toBe("America/New_York");
|
||||
});
|
||||
|
||||
it.each(["New York", "America/New York", "EST5EDT?", "/Chicago"])(
|
||||
"an invalid other timezone %j is rejected before any request",
|
||||
async (value) => {
|
||||
const { form } = await renderPreferences();
|
||||
fireEvent.change(field(form, "Timezone"), { target: { value: "__other__" } });
|
||||
fireEvent.change(field(form, "Other timezone"), { target: { value } });
|
||||
save(form);
|
||||
expect(screen.getByTestId("preferences-message").textContent).toBe(
|
||||
"Timezone must be an IANA zone like America/New_York, or UTC.",
|
||||
);
|
||||
expect(field(form, "Other timezone").value).toBe(value);
|
||||
expect(writes()).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("a 422 on an unknown timezone shows the API detail and keeps the edit", async () => {
|
||||
const { form } = await renderPreferences();
|
||||
fireEvent.change(field(form, "Timezone"), { target: { value: "__other__" } });
|
||||
fireEvent.change(field(form, "Other timezone"), { target: { value: "Mars/Olympus_Mons" } });
|
||||
writeResponse = () =>
|
||||
jsonResponse({ detail: "timezone must be an IANA time zone name such as 'America/New_York'" }, 422);
|
||||
save(form);
|
||||
const message = await screen.findByTestId("preferences-message");
|
||||
expect(message.textContent).toBe(
|
||||
"The preferences could not be saved: timezone must be an IANA time zone name such as 'America/New_York'",
|
||||
);
|
||||
expect(field(form, "Other timezone").value).toBe("Mars/Olympus_Mons");
|
||||
expect(field(form, "Other timezone").disabled).toBe(false);
|
||||
expect(settings.timezone).toBe("America/Chicago");
|
||||
});
|
||||
|
||||
it("a failed load shows the reason and Try again refetches", async () => {
|
||||
let fail = true;
|
||||
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
|
||||
@@ -512,3 +568,276 @@ describe("SettingsScreen — preferences", () => {
|
||||
await screen.findByRole("form", { name: "Preferences" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsScreen — no timezone auto-set", () => {
|
||||
let zoneSpy: ReturnType<typeof vi.spyOn> | null;
|
||||
|
||||
function mockBrowserZone(timeZone: string | undefined) {
|
||||
const real = Intl.DateTimeFormat.prototype.resolvedOptions;
|
||||
zoneSpy = vi
|
||||
.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions")
|
||||
.mockImplementation(function (this: Intl.DateTimeFormat) {
|
||||
return { ...real.call(this), timeZone } as Intl.ResolvedDateTimeFormatOptions;
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
children = [child(1, "Declan", 1)];
|
||||
// A fresh install: no zone stored.
|
||||
settings = { ...DEFAULT_SETTINGS, timezone: "" };
|
||||
writeResponse = null;
|
||||
zoneSpy = null;
|
||||
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
zoneSpy?.mockRestore();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function renderForm() {
|
||||
render(<SettingsScreen onClose={vi.fn()} />);
|
||||
await screen.findByTestId("settings-child-1");
|
||||
return screen.findByRole("form", { name: "Preferences" });
|
||||
}
|
||||
|
||||
it("an empty timezone stays empty on load and nothing is written", async () => {
|
||||
mockBrowserZone("America/New_York");
|
||||
const form = await renderForm();
|
||||
expect(field(form, "Timezone").value).toBe("");
|
||||
expect(writes()).toEqual([]);
|
||||
expect(settings.timezone).toBe("");
|
||||
});
|
||||
|
||||
it("StrictMode's doubled effects write nothing either", async () => {
|
||||
mockBrowserZone("America/New_York");
|
||||
render(
|
||||
<StrictMode>
|
||||
<SettingsScreen onClose={vi.fn()} />
|
||||
</StrictMode>,
|
||||
);
|
||||
const form = await screen.findByRole("form", { name: "Preferences" });
|
||||
expect(field(form, "Timezone").value).toBe("");
|
||||
expect(writes()).toEqual([]);
|
||||
});
|
||||
|
||||
it("a stored timezone is shown as stored, never replaced by the browser zone", async () => {
|
||||
mockBrowserZone("America/New_York");
|
||||
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin" };
|
||||
const form = await renderForm();
|
||||
expect(field(form, "Timezone").value).toBe("Europe/Dublin");
|
||||
expect(writes()).toEqual([]);
|
||||
});
|
||||
|
||||
it("saving another field sends only that field, never a timezone", async () => {
|
||||
mockBrowserZone("America/New_York");
|
||||
const form = await renderForm();
|
||||
fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } });
|
||||
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
|
||||
await screen.findByText("Preferences saved.");
|
||||
expect(writes().map((w) => w.body)).toEqual([{ horizon_days: 21 }]);
|
||||
expect(settings.timezone).toBe("");
|
||||
});
|
||||
|
||||
it("an admin clearing the timezone saves exactly the empty zone", async () => {
|
||||
mockBrowserZone("America/New_York");
|
||||
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin" };
|
||||
const form = await renderForm();
|
||||
fireEvent.change(field(form, "Timezone"), { target: { value: "" } });
|
||||
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
|
||||
await screen.findByText("Preferences saved.");
|
||||
expect(writes().map((w) => w.body)).toEqual([{ timezone: "" }]);
|
||||
expect(field(screen.getByRole("form", { name: "Preferences" }), "Timezone").value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsScreen — manual timezone control", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
children = [child(1, "Declan", 1)];
|
||||
settings = { ...DEFAULT_SETTINGS, timezone: "" };
|
||||
writeResponse = null;
|
||||
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function renderForm() {
|
||||
render(<SettingsScreen onClose={vi.fn()} />);
|
||||
await screen.findByTestId("settings-child-1");
|
||||
return screen.findByRole("form", { name: "Preferences" });
|
||||
}
|
||||
|
||||
function save(form: HTMLElement) {
|
||||
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
|
||||
}
|
||||
|
||||
/** The Timezone control, which must be a real select, not a free-text box. */
|
||||
function picker(form: HTMLElement): HTMLSelectElement {
|
||||
const control = within(form).getByRole("combobox", { name: "Timezone" });
|
||||
expect(control).toBeInstanceOf(HTMLSelectElement);
|
||||
return control as HTMLSelectElement;
|
||||
}
|
||||
|
||||
it("is a picker of curated zones with host-local and other-zone choices", async () => {
|
||||
const form = await renderForm();
|
||||
const select = picker(form);
|
||||
const values = Array.from(select.options).map((o) => o.value);
|
||||
expect(values[0]).toBe("");
|
||||
expect(select.options[0].textContent).toBe("API host local time (no zone set)");
|
||||
for (const zone of [
|
||||
"UTC",
|
||||
"America/New_York",
|
||||
"America/Chicago",
|
||||
"America/Denver",
|
||||
"America/Phoenix",
|
||||
"America/Los_Angeles",
|
||||
"America/Anchorage",
|
||||
"Pacific/Honolulu",
|
||||
]) {
|
||||
expect(values).toContain(zone);
|
||||
}
|
||||
expect(select.options[select.options.length - 1].textContent).toBe("Other IANA zone…");
|
||||
expect(select.value).toBe("");
|
||||
expect(within(form).queryByLabelText("Other timezone")).toBeNull();
|
||||
});
|
||||
|
||||
it("explains that it is the application timezone for every surface", async () => {
|
||||
const form = await renderForm();
|
||||
const help = document.getElementById(picker(form).getAttribute("aria-describedby") ?? "");
|
||||
expect(help?.textContent).toMatch(/application timezone/);
|
||||
expect(help?.textContent).toMatch(/panel, the scheduler and this admin app/);
|
||||
expect(help?.textContent).toMatch(/API host's local time/);
|
||||
});
|
||||
|
||||
it("a picked zone saves via PATCH and is shown after a reload", async () => {
|
||||
const form = await renderForm();
|
||||
expect(writes()).toEqual([]);
|
||||
fireEvent.change(picker(form), { target: { value: "America/New_York" } });
|
||||
save(form);
|
||||
await screen.findByText("Preferences saved.");
|
||||
expect(writes()).toEqual([
|
||||
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } },
|
||||
]);
|
||||
cleanup();
|
||||
const reloaded = await renderForm();
|
||||
expect(picker(reloaded).value).toBe("America/New_York");
|
||||
expect(writes()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("an other zone typed by hand saves via PATCH and is shown after a reload", async () => {
|
||||
const form = await renderForm();
|
||||
fireEvent.change(picker(form), { target: { value: "__other__" } });
|
||||
const entry = within(form).getByLabelText("Other timezone") as HTMLInputElement;
|
||||
expect(entry.value).toBe("");
|
||||
fireEvent.change(entry, { target: { value: " America/Boise " } });
|
||||
save(form);
|
||||
await screen.findByText("Preferences saved.");
|
||||
expect(writes()).toEqual([
|
||||
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/Boise" } },
|
||||
]);
|
||||
cleanup();
|
||||
const reloaded = await renderForm();
|
||||
expect(picker(reloaded).value).toBe("__other__");
|
||||
expect((within(reloaded).getByLabelText("Other timezone") as HTMLInputElement).value).toBe(
|
||||
"America/Boise",
|
||||
);
|
||||
});
|
||||
|
||||
it("a saved zone outside the list is shown in the other-zone entry on load", async () => {
|
||||
settings = { ...DEFAULT_SETTINGS, timezone: "Asia/Kathmandu" };
|
||||
const form = await renderForm();
|
||||
expect(picker(form).value).toBe("__other__");
|
||||
expect((within(form).getByLabelText("Other timezone") as HTMLInputElement).value).toBe(
|
||||
"Asia/Kathmandu",
|
||||
);
|
||||
expect(writes()).toEqual([]);
|
||||
});
|
||||
|
||||
it("choosing API host local time clears the zone and persists after a reload", async () => {
|
||||
settings = { ...DEFAULT_SETTINGS, timezone: "America/Denver" };
|
||||
const form = await renderForm();
|
||||
expect(picker(form).value).toBe("America/Denver");
|
||||
fireEvent.change(picker(form), { target: { value: "" } });
|
||||
save(form);
|
||||
await screen.findByText("Preferences saved.");
|
||||
expect(writes().map((w) => w.body)).toEqual([{ timezone: "" }]);
|
||||
cleanup();
|
||||
const reloaded = await renderForm();
|
||||
expect(picker(reloaded).value).toBe("");
|
||||
});
|
||||
|
||||
it("picking a zone and then the saved one again sends nothing", async () => {
|
||||
settings = { ...DEFAULT_SETTINGS, timezone: "UTC" };
|
||||
const form = await renderForm();
|
||||
fireEvent.change(picker(form), { target: { value: "Europe/Paris" } });
|
||||
fireEvent.change(picker(form), { target: { value: "UTC" } });
|
||||
save(form);
|
||||
expect(screen.getByTestId("preferences-message").textContent).toBe("No changes to save.");
|
||||
expect(writes()).toEqual([]);
|
||||
});
|
||||
|
||||
it("never sends a timezone_configured field", async () => {
|
||||
const form = await renderForm();
|
||||
fireEvent.change(picker(form), { target: { value: "America/Los_Angeles" } });
|
||||
save(form);
|
||||
await screen.findByText("Preferences saved.");
|
||||
const again = screen.getByRole("form", { name: "Preferences" });
|
||||
fireEvent.change(picker(again), { target: { value: "" } });
|
||||
save(again);
|
||||
await waitFor(() => expect(writes()).toHaveLength(2));
|
||||
for (const write of writes()) {
|
||||
expect(Object.keys(write.body as object)).toEqual(["timezone"]);
|
||||
expect(write.body).not.toHaveProperty("timezone_configured");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsScreen — the saved zone applies app-wide", () => {
|
||||
function ZoneProbe() {
|
||||
return <output data-testid="app-zone">{useAppTimezone() || "(browser)"}</output>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
children = [child(1, "Declan", 1)];
|
||||
settings = { ...DEFAULT_SETTINGS, timezone: "" };
|
||||
writeResponse = null;
|
||||
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("a saved zone becomes the application timezone at once", async () => {
|
||||
render(
|
||||
<AppTimezoneProvider>
|
||||
<ZoneProbe />
|
||||
<SettingsScreen onClose={vi.fn()} />
|
||||
</AppTimezoneProvider>,
|
||||
);
|
||||
const form = await screen.findByRole("form", { name: "Preferences" });
|
||||
// No zone stored: the application runs in the API host's zone.
|
||||
expect(screen.getByTestId("app-zone").textContent).toBe(API_HOST_ZONE);
|
||||
|
||||
fireEvent.change(within(form).getByRole("combobox", { name: "Timezone" }), {
|
||||
target: { value: "America/New_York" },
|
||||
});
|
||||
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
|
||||
await screen.findByText("Preferences saved.");
|
||||
expect(screen.getByTestId("app-zone").textContent).toBe("America/New_York");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
* household's child profiles. Every write refetches the list.
|
||||
*
|
||||
* Preferences: the household settings (planning horizon, day rollover,
|
||||
* notifications). Save patches only the changed fields.
|
||||
* timezone, notifications). Save patches only the changed fields. The
|
||||
* timezone — the application's source of local time — is only ever set by
|
||||
* the admin picking or typing a zone here and saving it; every zone loaded
|
||||
* or saved here is published app-wide (time/AppTimezone.tsx).
|
||||
*
|
||||
* One write at a time across both sections.
|
||||
*/
|
||||
@@ -33,6 +36,7 @@ import {
|
||||
type SettingsChanges,
|
||||
} from "../api/settings";
|
||||
import { FOCUSABLE, inertOutside } from "../schedule/OverrideEditor";
|
||||
import { usePublishAppTimezone } from "../time/AppTimezone";
|
||||
import { ArrowDownGlyph, ArrowUpGlyph, ChevronLeftGlyph, PlusGlyph } from "./glyphs";
|
||||
import "./SettingsScreen.css";
|
||||
|
||||
@@ -252,6 +256,46 @@ const NOTIFICATIONS: { label: string; time: TimeField; toggle: ToggleField }[] =
|
||||
|
||||
const HH_MM = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
/** "UTC" or an IANA-looking "Area/Location" (the API checks it resolves). */
|
||||
const IANA_ZONE = /^(UTC|[A-Za-z]+(\/[A-Za-z0-9_+-]+)+)$/;
|
||||
|
||||
/** The Timezone picker's zones; any other IANA zone can be typed via "Other". */
|
||||
export const COMMON_TIMEZONES = [
|
||||
"UTC",
|
||||
"America/New_York",
|
||||
"America/Chicago",
|
||||
"America/Denver",
|
||||
"America/Phoenix",
|
||||
"America/Los_Angeles",
|
||||
"America/Anchorage",
|
||||
"Pacific/Honolulu",
|
||||
"America/Halifax",
|
||||
"America/St_Johns",
|
||||
"America/Toronto",
|
||||
"America/Vancouver",
|
||||
"America/Mexico_City",
|
||||
"America/Sao_Paulo",
|
||||
"Europe/London",
|
||||
"Europe/Dublin",
|
||||
"Europe/Paris",
|
||||
"Europe/Berlin",
|
||||
"Europe/Madrid",
|
||||
"Europe/Rome",
|
||||
"Europe/Athens",
|
||||
"Africa/Johannesburg",
|
||||
"Asia/Dubai",
|
||||
"Asia/Kolkata",
|
||||
"Asia/Singapore",
|
||||
"Asia/Shanghai",
|
||||
"Asia/Tokyo",
|
||||
"Australia/Perth",
|
||||
"Australia/Sydney",
|
||||
"Pacific/Auckland",
|
||||
];
|
||||
|
||||
/** The picker's "Other IANA zone…" option; never sent to the API. */
|
||||
const OTHER_ZONE = "__other__";
|
||||
|
||||
/** The form's working copy: the number is text so it can be mid-edit. */
|
||||
type PreferencesDraft = Omit<HouseholdSettings, "horizon_days"> & { horizon_days: string };
|
||||
|
||||
@@ -275,11 +319,16 @@ function buildChanges(draft: PreferencesDraft, loaded: HouseholdSettings): Setti
|
||||
for (const [key, label] of times) {
|
||||
if (!HH_MM.test(draft[key])) return `${label} must be a time like 07:30.`;
|
||||
}
|
||||
const timezone = draft.timezone.trim();
|
||||
if (timezone && !IANA_ZONE.test(timezone)) {
|
||||
return "Timezone must be an IANA zone like America/New_York, or UTC.";
|
||||
}
|
||||
|
||||
const next: HouseholdSettings = {
|
||||
...draft,
|
||||
horizon_days: horizonDays,
|
||||
notify_target: draft.notify_target.trim(),
|
||||
timezone,
|
||||
};
|
||||
const changes: SettingsChanges = {};
|
||||
for (const key of Object.keys(loaded) as (keyof HouseholdSettings)[]) {
|
||||
@@ -305,6 +354,10 @@ function PreferencesForm({
|
||||
}) {
|
||||
const [draft, setDraft] = useState<PreferencesDraft>(() => preferencesDraft(settings));
|
||||
const update = (patch: Partial<PreferencesDraft>) => setDraft((d) => ({ ...d, ...patch }));
|
||||
// A saved zone outside the curated list opens in the free-text entry.
|
||||
const [otherZone, setOtherZone] = useState(
|
||||
() => settings.timezone !== "" && !COMMON_TIMEZONES.includes(settings.timezone),
|
||||
);
|
||||
const disabled = saving || locked;
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
@@ -357,6 +410,49 @@ function PreferencesForm({
|
||||
/>
|
||||
</label>
|
||||
{timeInput("day_rollover_time", "Day rollover")}
|
||||
<div className="settings-field">
|
||||
<label className="settings-label" htmlFor="settings-timezone">
|
||||
Timezone
|
||||
</label>
|
||||
<select
|
||||
id="settings-timezone"
|
||||
className="settings-input"
|
||||
aria-describedby="settings-timezone-help"
|
||||
value={otherZone ? OTHER_ZONE : draft.timezone}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
const choice = event.target.value;
|
||||
setOtherZone(choice === OTHER_ZONE);
|
||||
if (choice !== OTHER_ZONE) update({ timezone: choice });
|
||||
}}
|
||||
>
|
||||
<option value="">API host local time (no zone set)</option>
|
||||
{COMMON_TIMEZONES.map((zone) => (
|
||||
<option key={zone} value={zone}>
|
||||
{zone}
|
||||
</option>
|
||||
))}
|
||||
<option value={OTHER_ZONE}>Other IANA zone…</option>
|
||||
</select>
|
||||
{otherZone ? (
|
||||
<input
|
||||
className="settings-input"
|
||||
type="text"
|
||||
aria-label="Other timezone"
|
||||
placeholder="Area/Location, e.g. America/Boise"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={draft.timezone}
|
||||
disabled={disabled}
|
||||
onChange={(event) => update({ timezone: event.target.value })}
|
||||
/>
|
||||
) : null}
|
||||
<p className="settings-hint" id="settings-timezone-help">
|
||||
The application timezone: the source of local time for the panel, the scheduler and this
|
||||
admin app. It changes only when you save it here; with no zone set, the API host's
|
||||
local time is used.
|
||||
</p>
|
||||
</div>
|
||||
<label className="settings-field">
|
||||
<span className="settings-label">Notify target</span>
|
||||
<input
|
||||
@@ -499,6 +595,7 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
const addRef = useRef<HTMLButtonElement>(null);
|
||||
const formWasOpen = useRef(false);
|
||||
const publishTimezone = usePublishAppTimezone();
|
||||
|
||||
// Modal focus: move in on open, keep the background out of reach, and hand
|
||||
// focus back to the opener (the Settings button) on close.
|
||||
@@ -540,7 +637,9 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
|
||||
setPreferences({ kind: "loading" });
|
||||
fetchSettings()
|
||||
.then((settings) => {
|
||||
if (!cancelled) setPreferences({ kind: "ready", settings });
|
||||
if (cancelled) return;
|
||||
setPreferences({ kind: "ready", settings });
|
||||
publishTimezone(settings.effective_timezone);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
@@ -634,6 +733,7 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
|
||||
try {
|
||||
const settings = await updateSettings(changes);
|
||||
setPreferences({ kind: "ready", settings });
|
||||
publishTimezone(settings.effective_timezone);
|
||||
setPreferencesMessage({ kind: "info", text: "Preferences saved." });
|
||||
} catch (error) {
|
||||
setPreferencesMessage({
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* The effective application timezone, loaded once per signed-in shell: the
|
||||
* API's `effective_timezone` — the stored zone, or the API host's own zone
|
||||
* when none is stored — so every screen's local time matches the API's.
|
||||
*
|
||||
* <AppTimezoneProvider> fetches the household settings and renders its
|
||||
* screens only once the zone is known (a failed load says so and offers a
|
||||
* retry), so no screen ever computes local time in a zone it will not keep.
|
||||
* The Settings screen publishes every zone it loads or saves, so a change
|
||||
* applies app-wide at once.
|
||||
*
|
||||
* Without a provider (a screen rendered on its own, e.g. in a test)
|
||||
* useAppTimezone() is "" — the documented unset value, i.e. browser-local.
|
||||
*/
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { ApiForbiddenError } from "../api/client";
|
||||
import { fetchSettings } from "../api/settings";
|
||||
|
||||
interface ZoneHub {
|
||||
timeZone: string;
|
||||
publish(timeZone: string): void;
|
||||
}
|
||||
|
||||
const AppTimezoneContext = createContext<ZoneHub | null>(null);
|
||||
|
||||
type LoadState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; timeZone: string };
|
||||
|
||||
export function AppTimezoneProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ kind: "loading" });
|
||||
fetchSettings()
|
||||
.then((settings) => {
|
||||
if (!cancelled) setState({ kind: "ready", timeZone: settings.effective_timezone });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
kind: "error",
|
||||
message:
|
||||
error instanceof ApiForbiddenError
|
||||
? error.message
|
||||
: "The application timezone could not be loaded. Check your connection and try again.",
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [attempt]);
|
||||
|
||||
if (state.kind === "loading") {
|
||||
return (
|
||||
<div className="admin-loading" role="status" data-testid="timezone-loading">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.kind === "error") {
|
||||
return (
|
||||
<div className="admin-error" role="alert" data-testid="timezone-error">
|
||||
<p>{state.message}</p>
|
||||
<button type="button" onClick={() => setAttempt((n) => n + 1)}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const hub: ZoneHub = {
|
||||
timeZone: state.timeZone,
|
||||
publish: (timeZone) =>
|
||||
setState((prev) =>
|
||||
prev.kind === "ready" && prev.timeZone === timeZone ? prev : { kind: "ready", timeZone },
|
||||
),
|
||||
};
|
||||
return <AppTimezoneContext.Provider value={hub}>{children}</AppTimezoneContext.Provider>;
|
||||
}
|
||||
|
||||
/** The effective application timezone: an IANA name, or "" (browser-local). */
|
||||
export function useAppTimezone(): string {
|
||||
return useContext(AppTimezoneContext)?.timeZone ?? "";
|
||||
}
|
||||
|
||||
/** Publish an effective zone just loaded or saved; a no-op without a provider. */
|
||||
export function usePublishAppTimezone(): (timeZone: string) => void {
|
||||
const hub = useContext(AppTimezoneContext);
|
||||
return (timeZone) => hub?.publish(timeZone);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { isoDateIn, todayIn, wallClockInstant, zoneOption } from "./zone";
|
||||
import { formatClockTime } from "../today/format";
|
||||
|
||||
// 13:00 UTC on Sep 23 is already 01:00 on Sep 24 in Auckland (NZST, UTC+12).
|
||||
const NOW = new Date("2026-09-23T13:00:00Z");
|
||||
|
||||
describe("application timezone helpers (process in UTC)", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("TZ", "UTC");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("a stored zone decides the calendar date; empty means the browser's zone", () => {
|
||||
expect(todayIn("Pacific/Auckland", NOW)).toBe("2026-09-24");
|
||||
expect(isoDateIn(NOW, "Pacific/Auckland")).toBe("2026-09-24");
|
||||
expect(todayIn("", NOW)).toBe("2026-09-23");
|
||||
});
|
||||
|
||||
it("wall-clock time converts in the stored zone, across its DST change", () => {
|
||||
// NZST (UTC+12) before 27 Sep 2026, NZDT (UTC+13) after.
|
||||
expect(wallClockInstant("2026-09-24", 0, 10, "Pacific/Auckland")).toBe(
|
||||
Date.parse("2026-09-23T12:10:00Z"),
|
||||
);
|
||||
expect(wallClockInstant("2026-09-28", 7, 30, "Pacific/Auckland")).toBe(
|
||||
Date.parse("2026-09-27T18:30:00Z"),
|
||||
);
|
||||
expect(wallClockInstant("2026-09-24", 0, 10, "")).toBe(Date.parse("2026-09-24T00:10:00Z"));
|
||||
});
|
||||
|
||||
it("the completion clock reads in the stored zone", () => {
|
||||
expect(formatClockTime("2026-09-23T11:04:00Z", "Pacific/Auckland")).toBe("11:04 PM");
|
||||
expect(formatClockTime("2026-09-23T11:04:00Z", "")).toBe("11:04 AM");
|
||||
});
|
||||
|
||||
it("an invalid stored zone fails fast", () => {
|
||||
expect(zoneOption("")).toBeUndefined();
|
||||
expect(() => todayIn("Not/AZone", NOW)).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty zone keeps browser-local behavior", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("TZ", "America/Los_Angeles");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("matches the browser's own calendar and wall clock", () => {
|
||||
// 13:00 UTC is 06:00 PDT on Sep 23.
|
||||
expect(todayIn("", NOW)).toBe("2026-09-23");
|
||||
expect(wallClockInstant("2026-09-23", 7, 30, "")).toBe(new Date(2026, 8, 23, 7, 30).getTime());
|
||||
expect(formatClockTime("2026-09-23T14:04:00Z", "")).toBe("7:04 AM");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Local time in the application timezone.
|
||||
*
|
||||
* The admin-saved zone (settings `timezone`, an IANA name) is the single
|
||||
* source of local-time truth for the whole application; the API publishes it
|
||||
* as `effective_timezone`, naming its host's own zone when none is stored.
|
||||
* "" means the API published no zone, and only then does the browser's own
|
||||
* zone apply. Every helper takes
|
||||
* that stored value as-is; an invalid zone name throws (Intl's RangeError).
|
||||
*/
|
||||
|
||||
/** The `timeZone` option for Intl: the zone, or undefined (browser) for "". */
|
||||
export function zoneOption(timeZone: string): string | undefined {
|
||||
return timeZone === "" ? undefined : timeZone;
|
||||
}
|
||||
|
||||
interface WallClock {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
}
|
||||
|
||||
function wallClock(instant: number, timeZone: string): WallClock {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: zoneOption(timeZone),
|
||||
hourCycle: "h23",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).formatToParts(instant);
|
||||
const part = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
Number(parts.find((p) => p.type === type)?.value);
|
||||
return {
|
||||
year: part("year"),
|
||||
month: part("month"),
|
||||
day: part("day"),
|
||||
hour: part("hour"),
|
||||
minute: part("minute"),
|
||||
second: part("second"),
|
||||
};
|
||||
}
|
||||
|
||||
/** The "YYYY-MM-DD" calendar date of `instant` in the zone. */
|
||||
export function isoDateIn(instant: Date, timeZone: string): string {
|
||||
const { year, month, day } = wallClock(instant.getTime(), timeZone);
|
||||
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Today's calendar date in the zone. */
|
||||
export function todayIn(timeZone: string, now: Date = new Date()): string {
|
||||
return isoDateIn(now, timeZone);
|
||||
}
|
||||
|
||||
/** The zone's UTC offset in ms at `instant` (wall clock minus UTC). */
|
||||
function offsetAt(instant: number, timeZone: string): number {
|
||||
const c = wallClock(instant, timeZone);
|
||||
const wholeSeconds = Math.floor(instant / 1000) * 1000;
|
||||
return Date.UTC(c.year, c.month - 1, c.day, c.hour, c.minute, c.second) - wholeSeconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Epoch ms at which the zone's wall clock reads `hour:minute` on the
|
||||
* "YYYY-MM-DD" date. Two passes settle the offset across a DST change.
|
||||
*/
|
||||
export function wallClockInstant(
|
||||
iso: string,
|
||||
hour: number,
|
||||
minute: number,
|
||||
timeZone: string,
|
||||
): number {
|
||||
const [year, month, day] = iso.split("-").map(Number);
|
||||
const asUtc = Date.UTC(year, month - 1, day, hour, minute);
|
||||
const guess = asUtc - offsetAt(asUtc, timeZone);
|
||||
return asUtc - offsetAt(guess, timeZone);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { AdminSnapshot, SnapshotInstance } from "../api/snapshot";
|
||||
import { TransitionsProvider } from "../api/useTransitions";
|
||||
import { frame, testStream, type TestStream } from "../api/testStream";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
import { AppTimezoneProvider } from "../time/AppTimezone";
|
||||
|
||||
function instance(overrides: Partial<SnapshotInstance>): SnapshotInstance {
|
||||
return {
|
||||
@@ -406,3 +407,62 @@ describe("TodayTab Undo and live updates", () => {
|
||||
await waitFor(() => expect(streams[0].cancelled()).toBe(true));
|
||||
});
|
||||
});
|
||||
|
||||
describe("TodayTab in the stored application timezone", () => {
|
||||
function renderWithZone(timezone: string, effective_timezone = timezone) {
|
||||
fetchMock.mockImplementation((url: string) =>
|
||||
Promise.resolve(
|
||||
String(url).endsWith("/api/v1/admin/settings")
|
||||
? jsonResponse({ timezone, effective_timezone })
|
||||
: jsonResponse(SNAPSHOT),
|
||||
),
|
||||
);
|
||||
render(
|
||||
<AppTimezoneProvider>
|
||||
<TodayTab />
|
||||
</AppTimezoneProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
// The browser runs in UTC; the household lives in Auckland (UTC+12 on Sep 23).
|
||||
vi.stubEnv("TZ", "UTC");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("shows the completion clock in the stored zone, not the browser's", async () => {
|
||||
renderWithZone("Pacific/Auckland");
|
||||
const completed = await screen.findByTestId("attention-12");
|
||||
// 11:04 UTC is 11:04 PM in Auckland.
|
||||
expect(within(completed).getByText("Completed 11:04 PM")).toBeTruthy();
|
||||
// The header's day is the API's household-local today_iso, as-is.
|
||||
expect(screen.getByText("Wednesday, September 23 · cycle day 5 of 14")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("an empty stored zone shows the clock in the API host's published zone", async () => {
|
||||
renderWithZone("", "Pacific/Auckland");
|
||||
const completed = await screen.findByTestId("attention-12");
|
||||
expect(within(completed).getByText("Completed 11:04 PM")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("only an API that publishes no zone leaves the browser's local clock", async () => {
|
||||
renderWithZone("", "");
|
||||
const completed = await screen.findByTestId("attention-12");
|
||||
expect(within(completed).getByText("Completed 11:04 AM")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ApiRequestError } from "../api/definitions";
|
||||
import { uncompleteInstance } from "../api/instances";
|
||||
import { useTransitions } from "../api/useTransitions";
|
||||
import SettingsScreen from "../settings/SettingsScreen";
|
||||
import { useAppTimezone } from "../time/AppTimezone";
|
||||
import {
|
||||
formatClockTime,
|
||||
formatDueTime,
|
||||
@@ -165,6 +166,7 @@ function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }
|
||||
const { instance, childName } = item;
|
||||
const overdue = isOverdueOpen(instance);
|
||||
const undoing = undo.pendingId === instance.id;
|
||||
const timeZone = useAppTimezone();
|
||||
// TODO(e57facc2): wire "Mark done". The admin plane has no complete route
|
||||
// yet, so that action is inert here.
|
||||
return (
|
||||
@@ -187,7 +189,7 @@ function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }
|
||||
) : (
|
||||
// The snapshot carries no completion actor, so "from panel" is not printed.
|
||||
<div className="today-event-meta">
|
||||
Completed {formatClockTime(instance.completed_at as string)}
|
||||
Completed {formatClockTime(instance.completed_at as string, timeZone)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
* Calendar dates ("YYYY-MM-DD") are parsed by their components and formatted
|
||||
* in UTC, so the printed day never shifts with the viewer's offset — unlike
|
||||
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
|
||||
* day in negative-offset zones.
|
||||
* day in negative-offset zones. Instants are shown in the application
|
||||
* timezone (time/zone.ts).
|
||||
*/
|
||||
import { zoneOption } from "../time/zone";
|
||||
|
||||
const LOCALE = "en-US";
|
||||
|
||||
@@ -44,7 +46,11 @@ export function formatDueTime(hhmm: string): string {
|
||||
return format(date, { hour: "numeric", minute: "2-digit", timeZone: "UTC" });
|
||||
}
|
||||
|
||||
/** A UTC ISO timestamp shown as the viewer's local time, "7:04 AM". */
|
||||
export function formatClockTime(isoTimestamp: string): string {
|
||||
return format(new Date(isoTimestamp), { hour: "numeric", minute: "2-digit" });
|
||||
/** A UTC ISO timestamp shown as wall-clock time in `timeZone` ("" = browser), "7:04 AM". */
|
||||
export function formatClockTime(isoTimestamp: string, timeZone: string): string {
|
||||
return format(new Date(isoTimestamp), {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZone: zoneOption(timeZone),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""The API's effective application timezone (task 7e173881).
|
||||
|
||||
The admin-stored ``timezone`` setting is the single source of local-time
|
||||
truth; stored empty, the API reads its clock in the API host's local
|
||||
time. :func:`effective_timezone` names the zone the API actually uses —
|
||||
the stored zone, or else the host's own IANA zone — so the panel cards
|
||||
and the admin app format local time in exactly the API's zone instead of
|
||||
guessing with Home Assistant's or the browser's. ``""`` only when the
|
||||
host's zone genuinely cannot be named.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
|
||||
def _valid_zone(name: str) -> str:
|
||||
"""Return ``name`` if it is a loadable IANA zone key, else ``""``."""
|
||||
name = name.strip().removeprefix(":")
|
||||
if not name:
|
||||
return ""
|
||||
try:
|
||||
ZoneInfo(name)
|
||||
except (ZoneInfoNotFoundError, ValueError):
|
||||
return ""
|
||||
return name
|
||||
|
||||
|
||||
def host_timezone(
|
||||
environ: Mapping[str, str] = os.environ,
|
||||
timezone_file: Path = Path("/etc/timezone"),
|
||||
localtime: Path = Path("/etc/localtime"),
|
||||
) -> str:
|
||||
"""Name the API host's local zone as an IANA key, or ``""``.
|
||||
|
||||
In the order the C library honours them: the ``TZ`` environment
|
||||
variable, then the ``/etc/localtime`` zoneinfo link (with Debian's
|
||||
``/etc/timezone`` naming it when the link is a copy). A host with
|
||||
none of them configured runs UTC. A setting that names no IANA
|
||||
zone (a POSIX rule string, a copied file) is ``""``.
|
||||
"""
|
||||
configured = environ.get("TZ")
|
||||
if configured is not None:
|
||||
# An empty TZ is UTC to the C library.
|
||||
return _valid_zone(configured) if configured.strip() else "UTC"
|
||||
if localtime.is_symlink():
|
||||
target = str(localtime.resolve())
|
||||
marker = "/zoneinfo/"
|
||||
if marker in target:
|
||||
return _valid_zone(target.split(marker, 1)[1])
|
||||
if timezone_file.is_file():
|
||||
return _valid_zone(timezone_file.read_text(encoding="utf-8"))
|
||||
if not localtime.exists():
|
||||
return "UTC"
|
||||
return ""
|
||||
|
||||
|
||||
def effective_timezone(stored: str) -> str:
|
||||
"""The zone the API reads its clock in: ``stored``, else the host's."""
|
||||
return stored or host_timezone()
|
||||
@@ -152,6 +152,10 @@ core_quest_definitions = importlib.import_module(
|
||||
f"{_CORE_MODULE_NAME}.quest_definitions"
|
||||
)
|
||||
|
||||
#: The icon-key module (``core.icons.normalize_icon_for_read``) the
|
||||
#: admin plane's quest-definition responses normalize ``icon`` through.
|
||||
core_icons = importlib.import_module(f"{_CORE_MODULE_NAME}.icons")
|
||||
|
||||
#: The recurrence model (``core.recurrence.ScheduleRule`` and
|
||||
#: ``RuleValidationError``) the quest-definition routes build request
|
||||
#: rules with — the model validates at construction, the routes never
|
||||
@@ -215,6 +219,7 @@ __all__ = [
|
||||
"core_db",
|
||||
"core_events",
|
||||
"core_history",
|
||||
"core_icons",
|
||||
"core_materialize",
|
||||
"core_migrations",
|
||||
"core_presence",
|
||||
|
||||
+174
-44
@@ -165,10 +165,15 @@ integration's Home-Assistant config-entry options remain a temporary,
|
||||
SEPARATE source until Feature 18 wires the client to the API — the two
|
||||
are deliberately not unified here:
|
||||
|
||||
- ``GET /settings`` returns the current effective settings: all ten
|
||||
- ``GET /settings`` returns the current effective settings: all eleven
|
||||
documented fields (the rolling horizon, the day rollover time, the
|
||||
notify target, the three notification times and the four enable
|
||||
toggles) — the defaults on a fresh database.
|
||||
notify target, the three notification times, the four enable
|
||||
toggles, and the ``timezone`` — the IANA household zone the
|
||||
household-local dates derive from, empty meaning host local; only an
|
||||
admin's ``PATCH`` ever sets it) — the defaults on a fresh database —
|
||||
plus the read-only ``effective_timezone`` (:mod:`api.host_zone`): the
|
||||
zone the API actually reads its clock in, which the admin app formats
|
||||
local time in.
|
||||
- ``PATCH /settings`` updates ONLY the supplied fields: the body is a
|
||||
JSON object of settings field names to new values, passed STRAIGHT
|
||||
to :func:`nestquest_core.settings_store.update_settings` — the
|
||||
@@ -184,18 +189,38 @@ The missed-sweep trigger (task 058c7b69) follows the same pattern over
|
||||
:mod:`nestquest_core.sweep` — the HA-free sweep policy the
|
||||
integration's rollover listener shares:
|
||||
|
||||
- ``POST /missed-sweep`` runs the sweep for the API host's local
|
||||
``today`` (ONE :func:`_local_now` clock read threaded into the core
|
||||
call) and publishes each returned ``(event_type, payload)`` pair
|
||||
DIRECTLY on the app's ONE transition publisher — the sweep-built
|
||||
payload, never re-fetched (the core commits its watermark before
|
||||
returning, so a failed re-fetch after it would lose the missed event
|
||||
forever), the same publish-what-the-core-built shape
|
||||
api/scheduler.py uses. The watermark rule
|
||||
(a same-day rerun announces nothing, a post-downtime run sweeps the
|
||||
accumulated window once), the no-completion-event rule and the
|
||||
read-only-over-the-domain-tables guarantee all live in the core; the
|
||||
response reports how many transitions were published.
|
||||
- ``POST /missed-sweep`` runs the sweep for the household-local
|
||||
``today`` (ONE :func:`_local_now` clock read re-expressed in the
|
||||
configured household ``timezone`` — empty falls back to host local —
|
||||
threaded into the core call) and publishes each returned
|
||||
``(event_type, payload)`` pair DIRECTLY on the app's ONE transition
|
||||
publisher — the sweep-built payload, never re-fetched (the core
|
||||
commits its watermark before returning, so a failed re-fetch after it
|
||||
would lose the missed event forever), the same
|
||||
publish-what-the-core-built shape api/scheduler.py uses. The
|
||||
watermark rule (a same-day rerun announces nothing, a post-downtime
|
||||
run sweeps the accumulated window once), the no-completion-event rule
|
||||
and the read-only-over-the-domain-tables guarantee all live in the
|
||||
core; the response reports how many transitions were published.
|
||||
|
||||
The quest-definition DELETE route (task 3efb07b4) is the hybrid admin
|
||||
delete (owner decision 2026-09-26: "delete if there's no history,
|
||||
retire/deactivate if there is"):
|
||||
|
||||
- ``DELETE /quest-definitions/{definition_id}`` is a thin adapter over
|
||||
:func:`nestquest_core.quest_definitions.delete_quest_definition`. A
|
||||
definition with NO completion event on any instance is hard-deleted
|
||||
(instances, assignees, windows, the row and its unreferenced rule, in
|
||||
one core transaction); one WITH history is retired by deactivation.
|
||||
Retirement preserves the definition row, schedule rule, assignees,
|
||||
windows and ALL durable completion history; like any deactivation it
|
||||
regenerates, so future UNCOMPLETED instances are cleared (rolling,
|
||||
regenerable rows) and none are recreated while the definition is
|
||||
inactive — the retired task stops generating and shows no open work.
|
||||
Durable instance history lives only in the append-only
|
||||
``completion_events``. The response reports which happened
|
||||
(``deleted`` / ``retired``) and carries the retired definition, or
|
||||
``null`` when deleted. An unknown id is 404.
|
||||
|
||||
The occurrence-preview route (task 5f843564) lets the admin PWA's
|
||||
Definitions edit sheet show the next dates a rule fires on WITHOUT
|
||||
@@ -205,9 +230,10 @@ reimplementing the recurrence rules client-side:
|
||||
``rule`` object create/edit take (:class:`AdminRuleRequest`, built
|
||||
through the SAME :func:`_rule_from_request` / core ``from_dict``
|
||||
validation, so a rejected rule is 422 exactly as on create), an
|
||||
optional ``start_date`` (default: the API host's local ``today``,
|
||||
ONE :func:`_local_now` clock read) and an optional ``count`` (1..50,
|
||||
default 10). The dates come from ONE
|
||||
optional ``start_date`` (default: the household-local ``today`` —
|
||||
ONE :func:`_local_now` clock read re-expressed in the configured
|
||||
household ``timezone``, empty falling back to host local) and an
|
||||
optional ``count`` (1..50, default 10). The dates come from ONE
|
||||
:func:`nestquest_core.recurrence.occurrences_between` call — the
|
||||
same engine the materializer walks — so the preview can only agree
|
||||
with what would be generated. The scan is BOUNDED server-side: the
|
||||
@@ -326,6 +352,7 @@ from pydantic import (
|
||||
model_validator,
|
||||
)
|
||||
|
||||
from api import host_zone
|
||||
from api import transitions as api_transitions
|
||||
from api.auth import require_admin
|
||||
from api.database import DatabaseState
|
||||
@@ -336,6 +363,7 @@ from api.nestquest_core import (
|
||||
core_dao_children,
|
||||
core_dao_rules,
|
||||
core_history,
|
||||
core_icons,
|
||||
core_materialize,
|
||||
core_presence_management,
|
||||
core_quest_definitions,
|
||||
@@ -448,6 +476,19 @@ class AdminQuestDefinitionListResponse(BaseModel):
|
||||
definitions: list[AdminQuestDefinitionResponse]
|
||||
|
||||
|
||||
class AdminQuestDefinitionDeleteResponse(BaseModel):
|
||||
"""The body ``DELETE /api/v1/admin/quest-definitions/{id}`` answers.
|
||||
|
||||
Exactly one of ``deleted`` / ``retired`` is true. ``definition`` is
|
||||
the retired (now inactive) definition, or null when it was deleted.
|
||||
"""
|
||||
|
||||
definition_id: int
|
||||
deleted: bool
|
||||
retired: bool
|
||||
definition: AdminQuestDefinitionResponse | None
|
||||
|
||||
|
||||
class AdminChildCreateRequest(BaseModel):
|
||||
"""The body of ``POST /api/v1/admin/children``.
|
||||
|
||||
@@ -531,9 +572,10 @@ class AdminOccurrencesPreviewRequest(BaseModel):
|
||||
|
||||
``rule`` is the SAME object create/edit take. ``start_date`` is an
|
||||
optional strict ``YYYY-MM-DD`` (validated by the core; default the
|
||||
API host's today). ``count`` is a strict JSON integer in
|
||||
1..``_PREVIEW_MAX_COUNT`` — anything else is 422 before any handler
|
||||
code runs.
|
||||
household-local today in the configured ``timezone``, empty falling
|
||||
back to the API host's local time). ``count`` is a strict JSON
|
||||
integer in 1..``_PREVIEW_MAX_COUNT`` — anything else is 422 before
|
||||
any handler code runs.
|
||||
"""
|
||||
|
||||
rule: AdminRuleRequest
|
||||
@@ -867,7 +909,7 @@ class AdminSettingsResponse(BaseModel):
|
||||
"""The effective settings as the admin plane serializes them.
|
||||
|
||||
Mirrors the core :class:`~nestquest_core.settings.NestQuestSettings`
|
||||
— all TEN documented fields, resolved (every absent stored field
|
||||
— all ELEVEN documented fields, resolved (every absent stored field
|
||||
already fell back to its default in the core). The same shape both
|
||||
the GET and the PATCH route answer with.
|
||||
"""
|
||||
@@ -882,6 +924,14 @@ class AdminSettingsResponse(BaseModel):
|
||||
afternoon_reminder_enabled: bool
|
||||
end_of_day_report_enabled: bool
|
||||
celebration_enabled: bool
|
||||
#: The household IANA time zone the admin stored; ``""`` means the
|
||||
#: API host's local time.
|
||||
timezone: str
|
||||
#: The zone the API actually reads its clock in — ``timezone``, or
|
||||
#: the API host's own zone when that is ``""`` (``""`` only when the
|
||||
#: host's zone cannot be named). Read-only; the admin app formats
|
||||
#: local time in it.
|
||||
effective_timezone: str
|
||||
|
||||
|
||||
class AdminMissedSweepResponse(BaseModel):
|
||||
@@ -1178,6 +1228,26 @@ def _local_now() -> datetime.datetime:
|
||||
return datetime.datetime.now().astimezone()
|
||||
|
||||
|
||||
async def _household_clock(
|
||||
database: object,
|
||||
) -> tuple[object, datetime.datetime]:
|
||||
"""Return the stored settings and ONE household-local clock read.
|
||||
|
||||
The API host may run UTC while the household does not, so the one
|
||||
:func:`_local_now` read is re-expressed in the stored settings'
|
||||
``timezone`` (:meth:`NestQuestSettings.household_now`); an unset
|
||||
zone keeps the host's local time.
|
||||
"""
|
||||
settings = await core_settings_store.load_settings(database)
|
||||
return settings, settings.household_now(_local_now())
|
||||
|
||||
|
||||
async def _household_today(database: object) -> datetime.date:
|
||||
"""Return the household-local date of ONE :func:`_household_clock` read."""
|
||||
_settings, now = await _household_clock(database)
|
||||
return now.date()
|
||||
|
||||
|
||||
def _rule_from_request(rule: AdminRuleRequest) -> core_recurrence.ScheduleRule:
|
||||
"""Build the core ScheduleRule from the request's ``rule`` object.
|
||||
|
||||
@@ -1205,7 +1275,7 @@ def _quest_definition_response(
|
||||
id=bundle.definition.id,
|
||||
title=bundle.definition.title,
|
||||
description=bundle.definition.description,
|
||||
icon=bundle.definition.icon,
|
||||
icon=core_icons.normalize_icon_for_read(bundle.definition.icon),
|
||||
is_active=bundle.definition.is_active,
|
||||
skip_on_away=bundle.definition.skip_on_away,
|
||||
rule=_rule_response(bundle.rule),
|
||||
@@ -1496,7 +1566,7 @@ async def admin_create_quest_definition(
|
||||
response_model=AdminOccurrencesPreviewResponse,
|
||||
)
|
||||
async def admin_preview_occurrences(
|
||||
body: AdminOccurrencesPreviewRequest,
|
||||
body: AdminOccurrencesPreviewRequest, request: Request
|
||||
) -> AdminOccurrencesPreviewResponse:
|
||||
"""Return the first ``count`` dates the supplied rule fires on.
|
||||
|
||||
@@ -1509,12 +1579,15 @@ async def admin_preview_occurrences(
|
||||
call over the bounded window ``[start_date, start_date + 366
|
||||
days]`` — ``start_date`` defaulting to ONE :func:`_local_now` clock
|
||||
read — truncated to ``count``. Every core ``ValueError`` is 422
|
||||
(:func:`_raise_preview_error`). No database access.
|
||||
(:func:`_raise_preview_error`). The only database access is the
|
||||
settings read behind the household clock.
|
||||
"""
|
||||
start_date = (
|
||||
body.start_date
|
||||
if body.start_date is not None
|
||||
else _local_now().date().isoformat()
|
||||
else (
|
||||
await _household_today(request.app.state.db.database)
|
||||
).isoformat()
|
||||
)
|
||||
try:
|
||||
rule = _rule_from_request(body.rule)
|
||||
@@ -1579,16 +1652,16 @@ async def admin_set_quest_definition_active(
|
||||
body: AdminQuestDefinitionActiveRequest,
|
||||
request: Request,
|
||||
) -> AdminQuestDefinitionResponse:
|
||||
"""Deactivate (or reactivate) a definition; it is NEVER deleted.
|
||||
"""Deactivate (or reactivate) a definition; it is never deleted here.
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
handler performs NO auth of its own). Thin adapter: ``is_active``
|
||||
goes straight to
|
||||
:func:`nestquest_core.quest_definitions.set_quest_definition_active`
|
||||
— deactivation is the only removal path (the row, rule, assignees
|
||||
and windows all survive, so completion history keeps its
|
||||
references). An unknown definition id is mapped to 404
|
||||
— the row, rule, assignees and windows all survive, so completion
|
||||
history keeps its references (the hybrid DELETE route is the only
|
||||
hard-delete path, and only for a definition without history). An unknown definition id is mapped to 404
|
||||
(:func:`_raise_quest_definition_error`); the body model already
|
||||
makes the core's real-bool check unreachable from this route.
|
||||
"""
|
||||
@@ -1602,6 +1675,44 @@ async def admin_set_quest_definition_active(
|
||||
return _quest_definition_response(bundle)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/quest-definitions/{definition_id}",
|
||||
summary="Delete a quest definition, or retire it if it has history",
|
||||
response_model=AdminQuestDefinitionDeleteResponse,
|
||||
)
|
||||
async def admin_delete_quest_definition(
|
||||
definition_id: int, request: Request
|
||||
) -> AdminQuestDefinitionDeleteResponse:
|
||||
"""Hard-delete a history-free definition; retire one with history.
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
handler performs NO auth of its own). Thin adapter: the id goes
|
||||
straight to
|
||||
:func:`nestquest_core.quest_definitions.delete_quest_definition`,
|
||||
which decides delete-vs-retire atomically in the core. An unknown
|
||||
definition id is mapped to 404
|
||||
(:func:`_raise_quest_definition_error`).
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
try:
|
||||
result = await core_quest_definitions.delete_quest_definition(
|
||||
state.database, definition_id
|
||||
)
|
||||
except ValueError as error:
|
||||
_raise_quest_definition_error(error)
|
||||
return AdminQuestDefinitionDeleteResponse(
|
||||
definition_id=result.definition_id,
|
||||
deleted=result.deleted,
|
||||
retired=result.retired,
|
||||
definition=(
|
||||
None
|
||||
if result.definition is None
|
||||
else _quest_definition_response(result.definition)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/children/{child_id}/presence-patterns",
|
||||
summary="List a child's repeating presence patterns",
|
||||
@@ -1839,7 +1950,7 @@ async def admin_presence_override_consequence(
|
||||
body.start_date,
|
||||
body.end_date,
|
||||
body.is_present,
|
||||
today=_local_now().date(),
|
||||
today=await _household_today(state.database),
|
||||
)
|
||||
)
|
||||
except ValueError as error:
|
||||
@@ -1926,7 +2037,7 @@ async def admin_uncomplete_instance(
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
database = state.database
|
||||
now = _local_now()
|
||||
_settings, now = await _household_clock(database)
|
||||
sub = claims.get("sub")
|
||||
try:
|
||||
result = await core_completion.uncomplete_instance(
|
||||
@@ -1992,7 +2103,7 @@ async def admin_regenerate(
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
database = state.database
|
||||
today = _local_now().date()
|
||||
today = await _household_today(state.database)
|
||||
|
||||
if body.scope == "household":
|
||||
end_date = today + datetime.timedelta(
|
||||
@@ -2077,8 +2188,9 @@ def _settings_response(
|
||||
"""Serialize one core NestQuestSettings into the documented payload.
|
||||
|
||||
Explicit field-by-field (not ``dataclasses.asdict``) so the payload
|
||||
stays pinned to the ten documented fields even if the core
|
||||
dataclass later grows another one.
|
||||
stays pinned to the eleven documented fields (plus the read-only
|
||||
``effective_timezone``) even if the core dataclass later grows
|
||||
another one.
|
||||
"""
|
||||
return AdminSettingsResponse(
|
||||
horizon_days=settings.horizon_days,
|
||||
@@ -2091,6 +2203,8 @@ def _settings_response(
|
||||
afternoon_reminder_enabled=settings.afternoon_reminder_enabled,
|
||||
end_of_day_report_enabled=settings.end_of_day_report_enabled,
|
||||
celebration_enabled=settings.celebration_enabled,
|
||||
timezone=settings.timezone,
|
||||
effective_timezone=host_zone.effective_timezone(settings.timezone),
|
||||
)
|
||||
|
||||
|
||||
@@ -2122,7 +2236,7 @@ async def admin_history_query(
|
||||
``ValueError`` is 422 (:func:`_raise_history_error`).
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
today = _local_now().date()
|
||||
today = await _household_today(state.database)
|
||||
try:
|
||||
rows = await core_history.query_history(
|
||||
state.database, filter, start, end, today=today
|
||||
@@ -2165,7 +2279,7 @@ async def admin_history_csv(
|
||||
JSON route's (:func:`_raise_history_error`).
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
today = _local_now().date()
|
||||
today = await _household_today(state.database)
|
||||
try:
|
||||
csv_text = await core_history.export_history_csv(
|
||||
state.database, filter, start, end, today=today
|
||||
@@ -2188,7 +2302,7 @@ async def admin_history_csv(
|
||||
response_model=AdminSettingsResponse,
|
||||
)
|
||||
async def admin_get_settings(request: Request) -> AdminSettingsResponse:
|
||||
"""Return the current effective settings (all ten documented fields).
|
||||
"""Return the current effective settings (all eleven documented fields).
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
@@ -2227,12 +2341,25 @@ async def admin_update_settings(
|
||||
every value has validated — so a rejected update never changes the
|
||||
stored settings. Every core ``ValueError`` is 422
|
||||
(:func:`_raise_settings_error`); anything else re-raises.
|
||||
|
||||
The write holds the missed-sweep scheduler's ``settings_lock`` and
|
||||
then tells it (:meth:`~api.scheduler.MissedSweepScheduler.settings_changed`),
|
||||
so a saved ``timezone`` or ``day_rollover_time`` re-plans a sleeping
|
||||
scheduler (an app without one — no lifespan — just writes).
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
scheduler = getattr(request.app.state, "sweep_scheduler", None)
|
||||
try:
|
||||
settings = await core_settings_store.update_settings(
|
||||
state.database, changes
|
||||
)
|
||||
if scheduler is None:
|
||||
settings = await core_settings_store.update_settings(
|
||||
state.database, changes
|
||||
)
|
||||
else:
|
||||
async with scheduler.settings_lock:
|
||||
settings = await core_settings_store.update_settings(
|
||||
state.database, changes
|
||||
)
|
||||
scheduler.settings_changed(settings)
|
||||
except ValueError as error:
|
||||
_raise_settings_error(error)
|
||||
return _settings_response(settings)
|
||||
@@ -2253,7 +2380,9 @@ async def admin_run_missed_sweep(
|
||||
handler performs NO auth of its own). Thin adapter over the SAME
|
||||
HA-free sweep policy the integration's rollover listener runs
|
||||
(:mod:`nestquest_core.sweep`): ONE timezone-aware clock read
|
||||
(:func:`_local_now`) pins the API host's local ``today``, and ONE
|
||||
(:func:`_local_now`, re-expressed in the configured household
|
||||
``timezone``; empty falls back to host local) pins the
|
||||
household-local ``today``, and ONE
|
||||
:func:`nestquest_core.sweep.run_missed_sweep` call does everything
|
||||
else — the past-due open-instance query, the documented payload
|
||||
build and the watermark update (a same-day rerun is an empty
|
||||
@@ -2280,7 +2409,7 @@ async def admin_run_missed_sweep(
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
database = state.database
|
||||
today = _local_now().date()
|
||||
today = await _household_today(state.database)
|
||||
try:
|
||||
events = await core_sweep.run_missed_sweep(database, today=today)
|
||||
except ValueError as error:
|
||||
@@ -2309,8 +2438,9 @@ async def admin_snapshot(request: Request) -> AdminSnapshotResponse:
|
||||
:class:`AdminSnapshotResponse`.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
settings, now = await _household_clock(state.database)
|
||||
snapshot = await core_snapshot.build_snapshot(
|
||||
state.database, core_settings.NestQuestSettings(), _local_now()
|
||||
state.database, settings, now
|
||||
)
|
||||
return AdminSnapshotResponse(
|
||||
today_iso=snapshot.today_iso,
|
||||
|
||||
+34
-9
@@ -8,9 +8,9 @@ this router inherits the check).
|
||||
|
||||
The snapshot route is a thin adapter over the bundled core: it performs
|
||||
NO business logic. It reads the live database off ``app.state.db``,
|
||||
resolves ONE timezone-aware ``now`` (the API host's local time — the
|
||||
API is the household's "local" host now that the domain no longer lives
|
||||
inside Home Assistant), calls the pure
|
||||
resolves ONE timezone-aware ``now`` (the host clock re-expressed in the
|
||||
household ``timezone`` the admin stored — the API host may run UTC
|
||||
while the household does not), calls the pure
|
||||
:func:`nestquest_core.snapshot.build_snapshot`, shapes each child's
|
||||
instances through :func:`nestquest_core.snapshot.instance_payload` with
|
||||
``include_missed=False`` (D-009: missed instances are OMITTED from the
|
||||
@@ -21,6 +21,7 @@ panel payload), and returns the documented JSON shape:
|
||||
{
|
||||
"today_iso": "2026-09-22",
|
||||
"cycle_day": 2,
|
||||
"timezone": "Pacific/Auckland",
|
||||
"children": [
|
||||
{
|
||||
"child_id": 1,
|
||||
@@ -45,7 +46,11 @@ panel payload), and returns the documented JSON shape:
|
||||
active children ``ORDER BY sort_order, id``); ``state`` is the
|
||||
documented panel spelling ``open`` | ``completed`` (§1 of
|
||||
design/ENTITIES-AND-SERVICES.md); ``cycle_day`` is today's 1-based day
|
||||
in the first scheduled child's custody cycle (0 when no child has one).
|
||||
in the first scheduled child's custody cycle (0 when no child has one);
|
||||
``timezone`` is the effective application timezone — the zone the admin
|
||||
stored, or the API host's own zone when none is (``""`` only when the
|
||||
host's zone cannot be named; :mod:`api.host_zone`) — which the panel
|
||||
cards format their visible date and clock in.
|
||||
The same :class:`PanelSnapshotResponse` Pydantic model is the route's
|
||||
``response_model``, so the OpenAPI document carries the shape too.
|
||||
"""
|
||||
@@ -63,9 +68,10 @@ from api.dependencies import require_panel_token
|
||||
from api.nestquest_core import (
|
||||
core_completion,
|
||||
core_events,
|
||||
core_settings,
|
||||
core_settings_store,
|
||||
core_snapshot,
|
||||
)
|
||||
from api import host_zone
|
||||
from api import transitions as api_transitions
|
||||
from api.sse import transition_event_stream
|
||||
|
||||
@@ -126,12 +132,14 @@ class PanelSnapshotResponse(BaseModel):
|
||||
|
||||
``today_iso`` anchors the payload's day; ``cycle_day`` is today's
|
||||
1-based day in the first scheduled child's custody cycle (0 when no
|
||||
child has a schedule); ``children`` is in the household's sort
|
||||
order.
|
||||
child has a schedule); ``timezone`` is the effective application
|
||||
timezone (:func:`api.host_zone.effective_timezone`); ``children`` is
|
||||
in the household's sort order.
|
||||
"""
|
||||
|
||||
today_iso: str
|
||||
cycle_day: int
|
||||
timezone: str
|
||||
children: list[PanelChildPayload]
|
||||
|
||||
|
||||
@@ -178,6 +186,21 @@ def _local_now() -> datetime.datetime:
|
||||
return datetime.datetime.now().astimezone()
|
||||
|
||||
|
||||
async def _household_clock(
|
||||
database: object,
|
||||
) -> tuple[object, datetime.datetime]:
|
||||
"""Return the stored settings and ONE household-local clock read.
|
||||
|
||||
The API host may run UTC while the household does not, so the one
|
||||
:func:`_local_now` read is re-expressed in the stored settings'
|
||||
``timezone`` (:meth:`NestQuestSettings.household_now`). The zone
|
||||
comes from the stored settings ONLY — nothing the request carries
|
||||
can change it.
|
||||
"""
|
||||
settings = await core_settings_store.load_settings(database)
|
||||
return settings, settings.household_now(_local_now())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/snapshot",
|
||||
summary="Today's household snapshot",
|
||||
@@ -196,12 +219,14 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
|
||||
:class:`PanelSnapshotResponse`.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
settings, now = await _household_clock(state.database)
|
||||
snapshot = await core_snapshot.build_snapshot(
|
||||
state.database, core_settings.NestQuestSettings(), _local_now()
|
||||
state.database, settings, now
|
||||
)
|
||||
return PanelSnapshotResponse(
|
||||
today_iso=snapshot.today_iso,
|
||||
cycle_day=snapshot.cycle_day,
|
||||
timezone=host_zone.effective_timezone(snapshot.timezone),
|
||||
children=[
|
||||
PanelChildPayload(
|
||||
child_id=child.child_id,
|
||||
@@ -282,7 +307,7 @@ async def panel_complete_instance(
|
||||
detail="Quest instance not found for this child",
|
||||
)
|
||||
|
||||
now = _local_now()
|
||||
_settings, now = await _household_clock(database)
|
||||
try:
|
||||
result = await core_completion.complete_instance(
|
||||
database,
|
||||
|
||||
+155
-23
@@ -8,10 +8,33 @@ configured ``day_rollover_time`` — read fresh from the settings store
|
||||
at the top of EVERY cycle, so an admin's settings change applies on
|
||||
the next scheduling decision without a restart — then runs the
|
||||
HA-free core's :func:`nestquest_core.sweep.run_missed_sweep` for the
|
||||
API host's local date and publishes each returned
|
||||
household-local date (the stored ``timezone`` setting; the API host's
|
||||
local date when unset) and publishes each returned
|
||||
``nestquest_quest_missed`` event on the app's ONE transition publisher
|
||||
(the same SSE stream every route publishes through).
|
||||
|
||||
A household zone changed while the loop is asleep
|
||||
must not leave it waiting for the OLD zone's rollover (a UTC host would
|
||||
otherwise sweep at UTC midnight — 20:00 in New York — marking quests
|
||||
missed hours early). :meth:`MissedSweepScheduler.settings_changed`
|
||||
wakes the sleeping loop to re-plan whenever the zone or rollover time
|
||||
it planned with has moved, and the loop re-reads the settings before
|
||||
sweeping and re-plans instead if they no longer match its plan.
|
||||
|
||||
Guarantee: no sweep runs for a plan a committed settings write has
|
||||
replaced. The settings writer — the admin ``PATCH /api/v1/admin/settings``
|
||||
route, the only one — holds :attr:`MissedSweepScheduler.settings_lock`
|
||||
across its write AND its :meth:`~MissedSweepScheduler.settings_changed`
|
||||
call; the loop holds the same lock across its plan read (read, record
|
||||
the plan, clear the wake flag) and across the pre-sweep re-read plus
|
||||
the sweep itself. So a write commits either before a plan read
|
||||
(which then sees it), after one (``settings_changed`` compares against
|
||||
that exact plan and wakes the wait), or after the sweep — never between
|
||||
a read and what the loop does with it. The lock is never held across
|
||||
the wait, and the loop takes nothing else before it, so a waiting
|
||||
writer cannot deadlock: it is delayed at most by one settings
|
||||
read or one sweep.
|
||||
|
||||
All sweep policy lives in the core (the watermark, the
|
||||
``[watermark, today)`` window, the no-completion-event rule): this
|
||||
scheduler only decides WHEN. A same-night rerun — e.g. two workers,
|
||||
@@ -43,19 +66,56 @@ LOGGER = logging.getLogger(__name__)
|
||||
_RETRY_BACKOFF_SECONDS = 60.0
|
||||
|
||||
|
||||
def _delay_seconds(target: datetime.time, now: datetime.datetime) -> float:
|
||||
"""Seconds from ``now`` until the next occurrence of ``target``.
|
||||
#: Upper bound on the minute-by-minute search past a nonexistent wall
|
||||
#: time; real DST gaps are an hour, the largest ever skipped a day.
|
||||
_MAX_GAP_MINUTES = 2 * 24 * 60
|
||||
|
||||
``target`` is a plain ``HH:MM`` time of day: the next wall-clock
|
||||
occurrence strictly AFTER ``now`` (an equal time schedules for
|
||||
tomorrow, so the just-run sweep is never instantly re-run).
|
||||
|
||||
def _resolve_wall_time(
|
||||
day: datetime.date, target: datetime.time, zone: datetime.tzinfo
|
||||
) -> datetime.datetime:
|
||||
"""The aware instant ``target`` (``HH:MM``) names on ``day`` in ``zone``.
|
||||
|
||||
A nonexistent wall time (inside a spring-forward gap) resolves to
|
||||
the first valid wall minute at/after it — the gap's end, e.g. 02:30
|
||||
on a 02:00→03:00 day becomes 03:00. An ambiguous wall time (the
|
||||
repeated fall-back hour) resolves to its FIRST occurrence
|
||||
(``fold=0``).
|
||||
"""
|
||||
scheduled = now.replace(
|
||||
hour=target.hour, minute=target.minute, second=0, microsecond=0
|
||||
wall = datetime.datetime.combine(
|
||||
day, datetime.time(target.hour, target.minute)
|
||||
)
|
||||
if scheduled <= now:
|
||||
scheduled += datetime.timedelta(days=1)
|
||||
return (scheduled - now).total_seconds()
|
||||
for _ in range(_MAX_GAP_MINUTES):
|
||||
candidate = wall.replace(tzinfo=zone)
|
||||
round_trip = candidate.astimezone(datetime.timezone.utc).astimezone(
|
||||
zone
|
||||
)
|
||||
if round_trip.replace(tzinfo=None) == wall:
|
||||
return candidate
|
||||
wall += datetime.timedelta(minutes=1)
|
||||
raise ValueError(f"no valid wall time at/after {target} on {day}")
|
||||
|
||||
|
||||
def _delay_seconds(target: datetime.time, now: datetime.datetime) -> float:
|
||||
"""Real seconds from ``now`` until the next occurrence of ``target``.
|
||||
|
||||
``target`` is a plain ``HH:MM`` household time of day, resolved in
|
||||
``now``'s zone by :func:`_resolve_wall_time` (nonexistent → first
|
||||
valid instant after the gap; ambiguous → first occurrence). The
|
||||
result is the next such instant strictly AFTER ``now`` (an equal
|
||||
time schedules for tomorrow, so the just-run sweep is never
|
||||
instantly re-run). Both instants are compared and subtracted in
|
||||
UTC: same-``tzinfo`` aware arithmetic is wall-clock arithmetic and
|
||||
would be an hour off across a DST transition.
|
||||
"""
|
||||
now_utc = now.astimezone(datetime.timezone.utc)
|
||||
day = now.date()
|
||||
while True:
|
||||
scheduled = _resolve_wall_time(day, target, now.tzinfo)
|
||||
scheduled_utc = scheduled.astimezone(datetime.timezone.utc)
|
||||
if scheduled_utc > now_utc:
|
||||
return (scheduled_utc - now_utc).total_seconds()
|
||||
day += datetime.timedelta(days=1)
|
||||
|
||||
|
||||
class MissedSweepScheduler:
|
||||
@@ -86,6 +146,14 @@ class MissedSweepScheduler:
|
||||
)
|
||||
self._sleep = sleep if sleep is not None else asyncio.sleep
|
||||
self._task: asyncio.Task | None = None
|
||||
#: ``(timezone, day_rollover_time)`` the pending sweep was
|
||||
#: planned with; ``None`` until the first plan.
|
||||
self._planned: tuple[str, str] | None = None
|
||||
#: Set by :meth:`settings_changed` to cut the current wait short.
|
||||
self._wake = asyncio.Event()
|
||||
#: Serialises settings writes with planning and sweeping (see
|
||||
#: the module docstring's guarantee).
|
||||
self.settings_lock = asyncio.Lock()
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the background loop (idempotent: a running one stays)."""
|
||||
@@ -104,6 +172,20 @@ class MissedSweepScheduler:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
def settings_changed(self, settings: object) -> None:
|
||||
"""Re-plan the pending sweep if ``settings`` moved its rollover.
|
||||
|
||||
Called with freshly stored settings (e.g. an admin's zone
|
||||
change), with :attr:`settings_lock` held across the write
|
||||
and this call so the comparison is against the loop's current
|
||||
plan, never a read still in flight. A differing ``timezone`` or
|
||||
``day_rollover_time`` wakes the sleeping loop, which reloads the
|
||||
settings and schedules the next sweep for the new household
|
||||
rollover; an unchanged plan is left sleeping.
|
||||
"""
|
||||
if (settings.timezone, settings.day_rollover_time) != self._planned:
|
||||
self._wake.set()
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
"""Whether the background loop task is alive."""
|
||||
@@ -113,12 +195,18 @@ class MissedSweepScheduler:
|
||||
"""Schedule → sweep → repeat, forever (until cancelled)."""
|
||||
while True:
|
||||
try:
|
||||
settings = await core_settings_store.load_settings(
|
||||
self._database
|
||||
)
|
||||
rollover = datetime.time.fromisoformat(
|
||||
settings.day_rollover_time
|
||||
)
|
||||
# Under the lock no settings write can commit between this read
|
||||
# and the recorded plan, so any later one wakes the wait.
|
||||
async with self.settings_lock:
|
||||
self._wake.clear()
|
||||
settings = await core_settings_store.load_settings(
|
||||
self._database
|
||||
)
|
||||
rollover = datetime.time.fromisoformat(
|
||||
settings.day_rollover_time
|
||||
)
|
||||
plan = (settings.timezone, settings.day_rollover_time)
|
||||
self._planned = plan
|
||||
except Exception:
|
||||
# Not silent: the failure is logged and retried after
|
||||
# the backoff, so one bad settings document or a
|
||||
@@ -130,19 +218,40 @@ class MissedSweepScheduler:
|
||||
)
|
||||
await self._sleep(_RETRY_BACKOFF_SECONDS)
|
||||
continue
|
||||
delay = _delay_seconds(rollover, self._clock())
|
||||
# The host may run UTC: the rollover is a household wall-clock
|
||||
# time, so the clock read is re-expressed in the stored zone.
|
||||
delay = _delay_seconds(
|
||||
rollover, settings.household_now(self._clock())
|
||||
)
|
||||
LOGGER.info(
|
||||
"NestQuest missed sweep scheduled for %s (%.0fs from now)",
|
||||
settings.day_rollover_time,
|
||||
delay,
|
||||
)
|
||||
await self._sleep(delay)
|
||||
try:
|
||||
await self._run_once(self._clock().date())
|
||||
if not await self._sleep_unless_woken(delay):
|
||||
continue
|
||||
# Re-read before sweeping: a zone or rollover change the
|
||||
# wake-up missed means this is not the household's
|
||||
# rollover any more, so re-plan instead of sweeping. The
|
||||
# lock keeps a settings write from committing between this
|
||||
# check and the sweep.
|
||||
async with self.settings_lock:
|
||||
settings = await core_settings_store.load_settings(
|
||||
self._database
|
||||
)
|
||||
if (
|
||||
settings.timezone,
|
||||
settings.day_rollover_time,
|
||||
) != plan:
|
||||
continue
|
||||
await self._run_once(
|
||||
settings.household_now(self._clock()).date()
|
||||
)
|
||||
except Exception:
|
||||
# Not silent either: the sweep error is logged and the
|
||||
# loop backs off before the next cycle, whose watermark
|
||||
# re-read makes an already-swept day a no-op.
|
||||
# Not silent either: a failed wait or sweep is logged and
|
||||
# the loop backs off before the next cycle, whose
|
||||
# watermark re-read makes an already-swept day a no-op.
|
||||
LOGGER.exception(
|
||||
"NestQuest scheduled missed sweep failed; retrying in "
|
||||
"%gs",
|
||||
@@ -150,6 +259,29 @@ class MissedSweepScheduler:
|
||||
)
|
||||
await self._sleep(_RETRY_BACKOFF_SECONDS)
|
||||
|
||||
async def _sleep_unless_woken(self, delay: float) -> bool:
|
||||
"""Wait ``delay``; ``False`` if :meth:`settings_changed` woke it.
|
||||
|
||||
Both children are cancelled AND awaited before returning (or
|
||||
before a cancellation propagates), so none outlives :meth:`stop`;
|
||||
a sleep that failed re-raises its exception here for the loop's
|
||||
retry policy.
|
||||
"""
|
||||
sleeper = asyncio.ensure_future(self._sleep(delay))
|
||||
waker = asyncio.ensure_future(self._wake.wait())
|
||||
try:
|
||||
await asyncio.wait(
|
||||
{sleeper, waker}, return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
finally:
|
||||
slept = sleeper.done()
|
||||
sleeper.cancel()
|
||||
waker.cancel()
|
||||
await asyncio.gather(sleeper, waker, return_exceptions=True)
|
||||
if slept:
|
||||
sleeper.result()
|
||||
return slept
|
||||
|
||||
async def _run_once(self, today: datetime.date) -> None:
|
||||
"""Run ONE sweep for ``today`` and publish what it built.
|
||||
|
||||
|
||||
@@ -217,6 +217,10 @@ class NestQuestApiClient:
|
||||
self._session = session
|
||||
self._timeout_seconds = float(timeout_seconds)
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
"""The Bearer credential every panel-plane request carries."""
|
||||
return {"Authorization": f"Bearer {self._panel_token}"}
|
||||
|
||||
async def get_snapshot(self) -> dict[str, Any]:
|
||||
"""Fetch and parse today's household snapshot.
|
||||
|
||||
@@ -275,7 +279,7 @@ class NestQuestApiClient:
|
||||
cleanly and closes the underlying response in every path.
|
||||
"""
|
||||
url = f"{self._base_url}{EVENTS_PATH}"
|
||||
headers = {"Authorization": f"Bearer {self._panel_token}"}
|
||||
headers = self._headers()
|
||||
# Opening the response gets ONE request timeout, exactly like
|
||||
# ``_request_json``; the read loop below runs WITHOUT one — a
|
||||
# body-wide timeout would kill a healthy long-lived stream.
|
||||
@@ -379,7 +383,7 @@ class NestQuestApiClient:
|
||||
raises typed too, carrying the status it arrived with.
|
||||
"""
|
||||
url = f"{self._base_url}{path}"
|
||||
headers = {"Authorization": f"Bearer {self._panel_token}"}
|
||||
headers = self._headers()
|
||||
try:
|
||||
async with asyncio.timeout(self._timeout_seconds):
|
||||
async with self._session.request(
|
||||
|
||||
@@ -9,7 +9,7 @@ snapshot (the same ChildDaySnapshot the day sensors read):
|
||||
celebration automation must never fire on an empty day (the
|
||||
day-complete event carries the same rule, Feature 10 task 7).
|
||||
- ``binary_sensor.nestquest_<child>_present_today`` — ON when the
|
||||
presence engine resolves the child present on the HA-local date
|
||||
presence engine resolves the child present on the household-local date
|
||||
(schedules and overrides, D-004 anchor-date arithmetic).
|
||||
|
||||
Naming and registry identity follow the day sensors' contract: names
|
||||
|
||||
@@ -111,6 +111,9 @@ def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot:
|
||||
through verbatim;
|
||||
- ``present`` and ``next_present`` pass through verbatim (the API
|
||||
resolved them through the same presence engine server-side);
|
||||
- ``timezone`` (the API's effective application timezone) passes
|
||||
through verbatim for the cards' date and clock; an API older than
|
||||
the field omits it, which reads as ``""`` (the cards' fallback);
|
||||
- the instance list is rebuilt IN ORDER, so the panel payload the
|
||||
sensors emit matches the route's order byte for byte.
|
||||
|
||||
@@ -161,6 +164,7 @@ def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot:
|
||||
today_iso=payload["today_iso"],
|
||||
children=tuple(children),
|
||||
cycle_day=payload["cycle_day"],
|
||||
timezone=payload.get("timezone", ""),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ un-completing (no-op if already open) is :func:`uncomplete_instance`.
|
||||
Instance current state is derived from the latest event via
|
||||
:func:`instance_state` (``open`` / ``done`` / ``missed``); there is
|
||||
no status column. ``missed`` is derived for past-due open instances
|
||||
(``due_date`` before HA-local ``today``) and is never stored as an
|
||||
(``due_date`` before household-local ``today``) and is never stored as an
|
||||
event_type.
|
||||
|
||||
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
|
||||
@@ -71,10 +71,10 @@ def _completion_moment(
|
||||
now: datetime.datetime | None,
|
||||
today: datetime.date | None,
|
||||
) -> tuple[datetime.date, datetime.time | None]:
|
||||
"""Return the HA-local completion date and optional wall-clock time.
|
||||
"""Return the household-local completion date and optional wall-clock time.
|
||||
|
||||
``today`` is the HA-local calendar date when threaded (Feature 07
|
||||
style). ``now`` is a timezone-aware HA-local datetime; its wall
|
||||
``today`` is the household-local calendar date when threaded (Feature 07
|
||||
style). ``now`` is a timezone-aware household-local datetime; its wall
|
||||
clock is used as-is and is never converted to UTC. When ``today``
|
||||
is omitted the date comes from ``now``. When both are omitted the
|
||||
host calendar date is the Feature 07 fallback — never the UTC
|
||||
@@ -98,7 +98,7 @@ def _completion_moment(
|
||||
|
||||
|
||||
def _resolve_today(today: datetime.date | None) -> datetime.date:
|
||||
"""Return the threaded HA-local date, or the host calendar date."""
|
||||
"""Return the threaded household-local date, or the host calendar date."""
|
||||
if today is None:
|
||||
return datetime.date.today()
|
||||
return today
|
||||
@@ -138,7 +138,7 @@ def derive_state(
|
||||
Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or
|
||||
``missed`` from the latest event plus the due date (``missed`` is
|
||||
derived, never an event_type). ``today`` is the caller-resolved
|
||||
HA-local date.
|
||||
household-local date.
|
||||
"""
|
||||
return _derived_state(instance, latest, today)
|
||||
|
||||
@@ -345,7 +345,7 @@ async def instance_state(
|
||||
"""Return ``open``, ``done``, or ``missed`` from the latest event.
|
||||
|
||||
No events, or a latest event of uncompleted, is ``open`` unless
|
||||
``due_date`` is before ``today`` (HA-local, threaded), in which
|
||||
``due_date`` is before ``today`` (household-local, threaded), in which
|
||||
case it is ``missed``. A latest event of completed is ``done``
|
||||
even when past due. ``missed`` is never stored as an event_type.
|
||||
Unknown ``instance_id`` raises ValueError naming the field.
|
||||
@@ -414,9 +414,9 @@ async def complete_instance(
|
||||
latest-event, and the append run under one connection lock so a
|
||||
concurrent complete cannot double-append.
|
||||
|
||||
``was_on_time`` is computed from the HA-local completion moment
|
||||
``was_on_time`` is computed from the household-local completion moment
|
||||
(threaded ``now`` / ``today``) against the instance ``due_date``
|
||||
and optional ``due_time``. ``now`` is the timezone-aware HA-local
|
||||
and optional ``due_time``. ``now`` is the timezone-aware household-local
|
||||
datetime; its wall clock is never converted to UTC. Same-day
|
||||
``due_time`` comparison requires ``now``.
|
||||
"""
|
||||
@@ -471,7 +471,7 @@ async def uncomplete_instance(
|
||||
"""Append an uncompleted event unless the instance is already open.
|
||||
|
||||
Returns the derived state ``open``, or ``missed`` when the instance
|
||||
is past due (``due_date`` before HA-local ``today``); ``appended``
|
||||
is past due (``due_date`` before household-local ``today``); ``appended``
|
||||
reports whether THIS call appended (False on an already-open
|
||||
no-op). Unknown ``instance_id`` raises ValueError naming the
|
||||
field. Existence, latest-event, and the append run under one
|
||||
@@ -529,7 +529,7 @@ async def list_missed_for_child(
|
||||
"""Return the child's missed instances due in the closed date range.
|
||||
|
||||
Missed means the latest event state is open and ``due_date`` is
|
||||
before ``today`` (HA-local, threaded). Uses
|
||||
before ``today`` (household-local, threaded). Uses
|
||||
:meth:`QuestInstancesDao.list_by_date_range` plus derived state.
|
||||
Never appends a missed event. Instances and latest events are
|
||||
read inside the connection lock so the list is one snapshot.
|
||||
|
||||
@@ -112,6 +112,17 @@ CONF_AFTERNOON_REMINDER_ENABLED = "afternoon_reminder_enabled"
|
||||
CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
|
||||
CONF_CELEBRATION_ENABLED = "celebration_enabled"
|
||||
DEFAULT_AUTOMATION_ENABLED = True
|
||||
# The household's IANA time zone (e.g. ``America/New_York``) the API
|
||||
# service reads its clock in. The empty string is the API host's own
|
||||
# local time. Only an admin sets it (manually, from the admin app);
|
||||
# nothing sets it automatically. The integration relays the API's
|
||||
# effective application timezone (the coordinator carries it and the
|
||||
# household sensor publishes it for the panel cards) and never derives
|
||||
# the application clock from Home Assistant's configuration; the Home
|
||||
# Assistant / browser zone applies only when the API publishes no zone
|
||||
# (e.g. an older API).
|
||||
CONF_TIMEZONE = "timezone"
|
||||
DEFAULT_TIMEZONE = ""
|
||||
|
||||
# Panel-plane API client (Feature 18): the integration reaches the
|
||||
# NestQuest API service (Feature 16) over HTTP instead of the database.
|
||||
|
||||
@@ -68,7 +68,7 @@ def _resolve_today(today: datetime.date | None) -> datetime.date:
|
||||
around midnight, so when the caller has already resolved the local
|
||||
date (``today``) that anchor is authoritative; ``None`` falls back to
|
||||
the host clock (:func:`_today`), which is exactly the previous
|
||||
behavior for callers with no HA-local date.
|
||||
behavior for callers with no household-local date.
|
||||
"""
|
||||
return _today() if today is None else today
|
||||
|
||||
@@ -225,7 +225,7 @@ class QuestInstancesDao:
|
||||
- ``due_date`` must be TODAY or later: instances are never
|
||||
generated in the past (Feature 07). The caller's
|
||||
materialization horizon is future-dated by definition.
|
||||
``today`` optionally pins the resolved HA-local date the
|
||||
``today`` optionally pins the resolved household-local date the
|
||||
guard compares against (falling back to the host clock when
|
||||
omitted) so a household time zone behind the host around
|
||||
midnight is not misread as "past".
|
||||
@@ -248,7 +248,7 @@ class QuestInstancesDao:
|
||||
# check runs after the lock is acquired, because a caller can
|
||||
# wait on the lock across midnight — a date that was "today"
|
||||
# at call time may be "yesterday" by the time the INSERT runs.
|
||||
# A caller-supplied ``today`` (HA-local) is used for BOTH
|
||||
# A caller-supplied ``today`` (household-local) is used for BOTH
|
||||
# checks and never re-read, since it is already the anchor the
|
||||
# batch runs against.
|
||||
today_date = _resolve_today(today)
|
||||
@@ -257,7 +257,7 @@ class QuestInstancesDao:
|
||||
async with self._database.transaction():
|
||||
# Re-read "today" under the lock: this is the date the
|
||||
# insert actually executes on, so the no-past rule
|
||||
# holds across midnight rollovers. A pinned HA-local
|
||||
# holds across midnight rollovers. A pinned household-local
|
||||
# anchor is exempt from the re-read (see above).
|
||||
if today is None:
|
||||
today_date = _today()
|
||||
@@ -364,7 +364,7 @@ class QuestInstancesDao:
|
||||
rather than raising, so the rest of the batch is unaffected. The
|
||||
no-past rule (instances are never generated in the past) is
|
||||
preserved from :meth:`upsert` and still raises ValueError; ``today``
|
||||
optionally pins the HA-local anchor it compares against. An
|
||||
optionally pins the household-local anchor it compares against. An
|
||||
instance that ALREADY has a completion event is also skipped
|
||||
(returns ``None``) rather than raised: it is immutable, so the
|
||||
walk leaves it untouched instead of failing the batch — the
|
||||
@@ -504,7 +504,7 @@ class QuestInstancesDao:
|
||||
append-only contract: an instance that was ever completed is
|
||||
never reported missed, even when later reversed — history keeps
|
||||
both rows and the sweep never mutates any). The watermark is
|
||||
the HA-local date the sweep last ran, and the window is
|
||||
the household-local date the sweep last ran, and the window is
|
||||
``[watermark, today)``: the nightly run entering day D+1 sweeps
|
||||
exactly day D's instances, so every day's quests are announced
|
||||
once — at the midnight after their due date — and a same-night
|
||||
@@ -544,9 +544,10 @@ class QuestInstancesDao:
|
||||
rolling horizon from now), so a caller cannot use this to
|
||||
rewrite past open instances either — the day-rollover missed
|
||||
sweep (Feature 11) handles past-due instances instead. ``today``
|
||||
optionally pins the HA-local anchor the clamp compares against
|
||||
(falling back to the host clock when omitted). Any instance with
|
||||
a completion event is never touched, regardless of its date.
|
||||
optionally pins the household-local anchor the clamp compares
|
||||
against (falling back to the host clock when omitted). Any
|
||||
instance with a completion event is never touched, regardless of
|
||||
its date.
|
||||
Returns the number deleted.
|
||||
"""
|
||||
_validate_date(cutoff_date, "cutoff_date")
|
||||
@@ -581,8 +582,9 @@ class QuestInstancesDao:
|
||||
one transaction — an instance that gains a completion event
|
||||
mid-flight cannot be deleted. The cutoff cannot be backdated below
|
||||
today, for the same reason as the definition-scoped method (see its
|
||||
docstring). ``today`` optionally pins the HA-local anchor the clamp
|
||||
compares against (falling back to the host clock when omitted).
|
||||
docstring). ``today`` optionally pins the household-local anchor
|
||||
the clamp compares against (falling back to the host clock when
|
||||
omitted).
|
||||
Any instance with a completion event is never touched, regardless
|
||||
of its date.
|
||||
|
||||
|
||||
@@ -13,8 +13,12 @@ Guardrail mapping:
|
||||
- ``schedule_rules`` deletion is delete-if-unreferenced: a rule still
|
||||
referenced by any quest definition is rejected (the done-condition),
|
||||
not cascaded.
|
||||
- ``quest_definitions`` has no delete path at all: definitions are
|
||||
deactivated, never hard-deleted, and history must survive.
|
||||
- ``quest_definitions`` has exactly one delete path,
|
||||
:meth:`QuestDefinitionsDao.delete_if_no_history` (owner decision
|
||||
2026-09-26): a definition with NO completion event on any of its
|
||||
instances is hard-deleted with its instances, assignees, windows and
|
||||
(if unreferenced) rule; a definition WITH history is never deleted —
|
||||
it is retired by deactivation, so history always survives.
|
||||
- Assignment is multi-assignee (D-008): :meth:`add_assignee` and
|
||||
:meth:`remove_assignee` change future instances only — the DAO writes
|
||||
the assignee table and nothing else; assignment edits never rewrite
|
||||
@@ -25,8 +29,8 @@ Guardrail mapping:
|
||||
:meth:`QuestDefinitionsDao.add_assignee` (active child); no
|
||||
permission checks here, that is the Feature 09 gate's job.
|
||||
|
||||
Concurrency: ``delete_rule_if_unreferenced`` and the assignee
|
||||
mutations perform their checks and writes inside one transaction under
|
||||
Concurrency: ``delete_rule_if_unreferenced``,
|
||||
``delete_if_no_history`` and the assignee mutations perform their checks and writes inside one transaction under
|
||||
the connection-scoped lock shared with :mod:`.dao_children`, so a
|
||||
definition referencing the rule (or an assignee appearing) cannot slip
|
||||
in between check and write on this connection.
|
||||
@@ -608,26 +612,42 @@ class ScheduleRulesDao:
|
||||
"""
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
row = await self._database.fetch_one(
|
||||
"SELECT 1 FROM quest_definitions "
|
||||
"WHERE schedule_rule_id = ? LIMIT 1",
|
||||
(rule_id,),
|
||||
return await _delete_rule_if_unreferenced_in_transaction(
|
||||
self._database, rule_id
|
||||
)
|
||||
if row is not None:
|
||||
return False
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM schedule_rules WHERE id = ?",
|
||||
(rule_id,),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
async def _delete_rule_if_unreferenced_in_transaction(
|
||||
database: NestQuestDatabase, rule_id: int
|
||||
) -> bool:
|
||||
"""Delete-if-unreferenced body; the CALLER holds lock + transaction.
|
||||
|
||||
Shared by :meth:`ScheduleRulesDao.delete_if_unreferenced` and
|
||||
:meth:`QuestDefinitionsDao.delete_if_no_history`, so the rule's
|
||||
reference check runs in whichever transaction removed the last
|
||||
definition that could reference it.
|
||||
"""
|
||||
row = await database.fetch_one(
|
||||
"SELECT 1 FROM quest_definitions "
|
||||
"WHERE schedule_rule_id = ? LIMIT 1",
|
||||
(rule_id,),
|
||||
)
|
||||
if row is not None:
|
||||
return False
|
||||
result = await database.execute(
|
||||
"DELETE FROM schedule_rules WHERE id = ?",
|
||||
(rule_id,),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
class QuestDefinitionsDao:
|
||||
"""Typed async access to ``quest_definitions`` and its assignees.
|
||||
|
||||
No delete method exists: definitions are deactivated via
|
||||
:meth:`set_active`, never hard-deleted, so completion history keeps
|
||||
its references (feature guardrail). Assignment is multi-assignee
|
||||
The only delete method is :meth:`delete_if_no_history`: a
|
||||
definition with completion history is never hard-deleted (it is
|
||||
deactivated via :meth:`set_active` instead), so completion history
|
||||
always keeps its references (feature guardrail, D-005). Assignment is multi-assignee
|
||||
(D-008): the definition row carries no child; assignees live in
|
||||
``quest_definition_assignees`` and are managed explicitly so the
|
||||
operations stay separately loggable and permission-gateable.
|
||||
@@ -1140,6 +1160,54 @@ class QuestDefinitionsDao:
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
async def delete_if_no_history(self, definition_id: int) -> bool | None:
|
||||
"""Hard-delete a definition that has NO completion history.
|
||||
|
||||
Returns None when the definition does not exist, False when any
|
||||
of its instances has a completion event (nothing is touched —
|
||||
the caller retires it by deactivation instead), and True when it
|
||||
was deleted: its ``quest_instances``, assignee and window rows,
|
||||
the definition row, and its schedule rule if no other definition
|
||||
references it.
|
||||
|
||||
The history check and every DELETE run inside ONE transaction
|
||||
under the connection lock, so a completion recorded concurrently
|
||||
cannot land between the check and the delete (D-005: an instance
|
||||
with a completion event is never deleted).
|
||||
"""
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
row = await self._database.fetch_one(
|
||||
"SELECT schedule_rule_id FROM quest_definitions "
|
||||
"WHERE id = ?",
|
||||
(definition_id,),
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
schedule_rule_id = row[0]
|
||||
history = await self._database.fetch_one(
|
||||
"SELECT 1 FROM completion_events "
|
||||
"JOIN quest_instances "
|
||||
"ON quest_instances.id = completion_events.instance_id "
|
||||
"WHERE quest_instances.definition_id = ? LIMIT 1",
|
||||
(definition_id,),
|
||||
)
|
||||
if history is not None:
|
||||
return False
|
||||
for statement in (
|
||||
"DELETE FROM quest_instances WHERE definition_id = ?",
|
||||
"DELETE FROM quest_definition_assignees "
|
||||
"WHERE definition_id = ?",
|
||||
"DELETE FROM quest_definition_windows "
|
||||
"WHERE definition_id = ?",
|
||||
"DELETE FROM quest_definitions WHERE id = ?",
|
||||
):
|
||||
await self._database.execute(statement, (definition_id,))
|
||||
await _delete_rule_if_unreferenced_in_transaction(
|
||||
self._database, schedule_rule_id
|
||||
)
|
||||
return True
|
||||
|
||||
async def add_assignee(
|
||||
self, definition_id: int, child_id: int
|
||||
) -> list[ChildRecord]:
|
||||
|
||||
@@ -39,10 +39,11 @@ ISO-8601 timestamp. Completion payloads additionally carry
|
||||
``was_on_time``. No other personal data: names and titles are the
|
||||
household's own records, and nothing beyond them leaks.
|
||||
|
||||
Timezone policy: the day-complete check needs the HA-local "today",
|
||||
which the caller passes in as a plain :class:`datetime.date`. Nothing
|
||||
here reads ``hass.config.time_zone`` — the integration owns HA
|
||||
timezone, the core owns the calendar arithmetic.
|
||||
Timezone policy: the day-complete check needs the household-local
|
||||
"today", which the caller passes in as a plain :class:`datetime.date`.
|
||||
Nothing here reads a timezone — the API owns the clock (the
|
||||
admin-stored application timezone, or the API host's local zone when
|
||||
none is stored), the core owns the calendar arithmetic.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -147,10 +148,10 @@ async def build_child_day_complete_event(
|
||||
completion of TODAY'S instance evaluates it: completing a
|
||||
back-dated or future instance must not announce a cleared day.
|
||||
|
||||
``today`` is the HA-local calendar date the day-complete rule is
|
||||
evaluated against; the integration reads it from
|
||||
``hass.config.time_zone`` and passes it in, so this module never
|
||||
touches HA config.
|
||||
``today`` is the household-local calendar date the day-complete
|
||||
rule is evaluated against; the API resolves it in the application
|
||||
timezone and passes it in, so this module never reads a clock or a
|
||||
timezone of its own.
|
||||
"""
|
||||
# Only a completion of TODAY'S instance evaluates the day-complete
|
||||
# rule: completing a back-dated or future instance must not
|
||||
|
||||
@@ -153,7 +153,7 @@ def _validate_range(start_date: str, end_date: str) -> tuple[str, str]:
|
||||
|
||||
|
||||
def _resolve_today(today: datetime.date | None) -> datetime.date:
|
||||
"""Return the threaded HA-local date, or the host calendar date."""
|
||||
"""Return the threaded household-local date, or the host calendar date."""
|
||||
if today is None:
|
||||
return datetime.date.today()
|
||||
return today
|
||||
@@ -283,7 +283,7 @@ async def query_history(
|
||||
|
||||
Rows are ordered deterministically by ``due_date``, then instance,
|
||||
then event id (the per-instance append order, so a completion and
|
||||
its reversal keep their order). ``today`` pins the HA-local date
|
||||
its reversal keep their order). ``today`` pins the household-local date
|
||||
the missed derivation compares due dates against (the host calendar
|
||||
date when omitted). The whole read runs under the task-reentrant
|
||||
connection lock — :func:`list_missed_for_child` re-enters it — so
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Namespaced quest-definition icon keys.
|
||||
|
||||
A definition's ``icon`` is stored as ``<namespace>:<name>`` where the
|
||||
namespace is one of :data:`ICON_NAMESPACES`:
|
||||
|
||||
- ``lucide:<name>`` / ``fa:<name>`` — ``<name>`` is a lowercase kebab
|
||||
name (ASCII ``[a-z0-9-]``, not starting or ending with ``-``).
|
||||
- ``emoji:<payload>`` — a short emoji string of at most
|
||||
:data:`EMOJI_MAX_CODE_POINTS` code points, with no whitespace (JS
|
||||
``\\s``, so including U+FEFF), no control characters and none of
|
||||
``<``, ``>``, ``&``.
|
||||
|
||||
Rows written before namespacing hold a bare Lucide name (``dog``).
|
||||
:func:`validate_icon` (writes) normalizes such a legacy value to
|
||||
``lucide:<name>``; :func:`normalize_icon_for_read` (reads) does the same
|
||||
leniently so legacy rows render without a data migration.
|
||||
|
||||
Nothing here imports :mod:`homeassistant`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
ICON_NAMESPACE_LUCIDE = "lucide"
|
||||
ICON_NAMESPACE_FA = "fa"
|
||||
ICON_NAMESPACE_EMOJI = "emoji"
|
||||
ICON_NAMESPACES = frozenset(
|
||||
{ICON_NAMESPACE_LUCIDE, ICON_NAMESPACE_FA, ICON_NAMESPACE_EMOJI}
|
||||
)
|
||||
|
||||
#: Upper bound on an emoji payload's length in code points — enough for
|
||||
#: ZWJ sequences such as a family emoji, too short for arbitrary text.
|
||||
EMOJI_MAX_CODE_POINTS = 8
|
||||
|
||||
_KEBAB_NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?")
|
||||
# U+FEFF (BOM / zero-width no-break space) is neither str.isspace() nor
|
||||
# ``Cc`` in Python, but JS ``\s`` matches it, so it is listed here to keep
|
||||
# this rule identical to the frontends' isEmojiPayload (/[\s<>&]|\p{Cc}/u).
|
||||
# Other format characters, notably the ZWJ U+200D that emoji sequences
|
||||
# need, stay valid in both runtimes.
|
||||
_EMOJI_FORBIDDEN = frozenset("<>&\ufeff")
|
||||
|
||||
|
||||
def _is_kebab_name(value: str) -> bool:
|
||||
return _KEBAB_NAME.fullmatch(value) is not None
|
||||
|
||||
|
||||
def _is_valid_emoji_payload(value: str) -> bool:
|
||||
if not value or len(value) > EMOJI_MAX_CODE_POINTS:
|
||||
return False
|
||||
return not any(
|
||||
char.isspace()
|
||||
or char in _EMOJI_FORBIDDEN
|
||||
or unicodedata.category(char) == "Cc"
|
||||
for char in value
|
||||
)
|
||||
|
||||
|
||||
def validate_icon(value: object) -> str | None:
|
||||
"""Return the canonical namespaced icon key, or raise naming ``icon``.
|
||||
|
||||
``None`` or an empty/whitespace-only string returns ``None`` (the
|
||||
icon is cleared). Otherwise the raw value is validated untrimmed, so
|
||||
edge whitespace or control characters are rejected rather than
|
||||
silently stripped. A value with no ``:`` is a legacy bare Lucide
|
||||
name and is stored as ``lucide:<value>``. An unknown namespace, an
|
||||
empty or malformed name, or an invalid emoji payload raises
|
||||
ValueError.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"icon must be a string, got {value!r}")
|
||||
if not value.strip():
|
||||
return None
|
||||
|
||||
namespace, separator, name = value.partition(":")
|
||||
if not separator:
|
||||
if not _is_kebab_name(value):
|
||||
raise ValueError(
|
||||
f"icon must be a lowercase kebab name, got {value!r}"
|
||||
)
|
||||
return f"{ICON_NAMESPACE_LUCIDE}:{value}"
|
||||
|
||||
if namespace not in ICON_NAMESPACES:
|
||||
raise ValueError(
|
||||
f"icon has an unknown namespace {namespace!r}; expected one of "
|
||||
f"{sorted(ICON_NAMESPACES)}"
|
||||
)
|
||||
if namespace == ICON_NAMESPACE_EMOJI:
|
||||
if not _is_valid_emoji_payload(name):
|
||||
raise ValueError(
|
||||
f"icon emoji must be 1-{EMOJI_MAX_CODE_POINTS} code points "
|
||||
f"with no whitespace, control characters or markup, "
|
||||
f"got {value!r}"
|
||||
)
|
||||
elif not _is_kebab_name(name):
|
||||
raise ValueError(
|
||||
f"icon {namespace} name must be a lowercase kebab name, "
|
||||
f"got {value!r}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def normalize_icon_for_read(value: str | None) -> str | None:
|
||||
"""Return a stored icon in namespaced form for output.
|
||||
|
||||
Lenient: ``None``/empty returns ``None``, a recognised namespace is
|
||||
returned unchanged, and anything else becomes ``lucide:<value>`` so
|
||||
legacy rows render (an unknown name falls through to the frontend's
|
||||
fallback icon rather than erroring).
|
||||
"""
|
||||
if not value or not value.strip():
|
||||
return None
|
||||
namespace, separator, _ = value.partition(":")
|
||||
if separator and namespace in ICON_NAMESPACES:
|
||||
return value
|
||||
return f"{ICON_NAMESPACE_LUCIDE}:{value}"
|
||||
@@ -255,12 +255,12 @@ async def materialize(
|
||||
after a presence change) cannot touch any unrelated child's rows.
|
||||
When omitted (or None) the walk covers every assignee as before.
|
||||
|
||||
``today`` optionally pins the resolved HA-local date the walk's
|
||||
``today`` optionally pins the resolved household-local date the walk's
|
||||
no-past guard compares against; it is threaded into every
|
||||
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` write. When
|
||||
omitted the host clock (``datetime.date.today``) is used, which is the
|
||||
historical default. A caller that computed the horizon in Home
|
||||
Assistant's configured time zone MUST pass that same date here so a
|
||||
historical default. A caller that computed the horizon in the
|
||||
household's application time zone MUST pass that same date here so a
|
||||
time zone behind the host around midnight is not rejected as "past".
|
||||
|
||||
``start_date`` is clamped up to the resolved "today" (the same
|
||||
@@ -298,8 +298,8 @@ async def materialize(
|
||||
start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
end = datetime.datetime.strptime(end_date, "%Y-%m-%d").date()
|
||||
|
||||
# Clamp a past start up to today (the HA-local anchor when the caller
|
||||
# pinned one, else the host clock) so the walk can never emit a
|
||||
# Clamp a past start up to today (the household-local anchor when the
|
||||
# caller pinned one, else the host clock) so the walk can never emit a
|
||||
# past-dated instance; the DAO's no-past guard remains the backstop.
|
||||
# A range entirely in the past therefore yields zero instances.
|
||||
today_date = _resolve_today(today)
|
||||
@@ -345,7 +345,7 @@ async def regenerate_for_definition(
|
||||
:func:`~.dao_instances._today` — so the delete cutoff and the
|
||||
re-materialization range share one anchor, and the walk's
|
||||
``upsert_if_valid`` no-past guard never rejects the regenerated
|
||||
range. ``today`` optionally pins a caller-resolved HA-local date
|
||||
range. ``today`` optionally pins a caller-resolved household-local date
|
||||
(threaded into both the delete cutoff and the re-materialization)
|
||||
so a household time zone behind the host around midnight stays
|
||||
consistent. ``horizon_days`` sizes the re-materialization window;
|
||||
@@ -413,9 +413,9 @@ async def regenerate_for_child(
|
||||
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`),
|
||||
so the delete cutoff and the re-materialization window share ONE
|
||||
execution-day anchor even when the call is queued across midnight.
|
||||
``today`` optionally pins a caller-resolved HA-local date, threaded
|
||||
``today`` optionally pins a caller-resolved household-local date, threaded
|
||||
into the delete cutoff and the re-materialization for the same
|
||||
host-vs-HA time-zone consistency. ``horizon_days`` sizes the
|
||||
host-vs-household time-zone consistency. ``horizon_days`` sizes the
|
||||
re-materialization window; when omitted it falls back to
|
||||
:data:`~.const.DEFAULT_HORIZON_DAYS`. A bool, non-int, or sub-1 value
|
||||
raises ValueError BEFORE any delete. Returns the number of instances
|
||||
|
||||
@@ -7,7 +7,9 @@ all-or-nothing create/edit here, and never touch the DAO SQL directly.
|
||||
:func:`edit_quest_definition` the edit path (which may also replace the
|
||||
whole assignee set), :func:`assign_child` and
|
||||
:func:`unassign_child` the single-child assignment paths, and
|
||||
:func:`set_quest_definition_active` the deactivate/reactivate path.
|
||||
:func:`set_quest_definition_active` the deactivate/reactivate path,
|
||||
and :func:`delete_quest_definition` the hybrid admin delete (hard-delete
|
||||
when there is no completion history, retire by deactivation otherwise).
|
||||
There is no permission check here (that is Feature 09).
|
||||
|
||||
Validation policy mirrors :mod:`.children`:
|
||||
@@ -59,6 +61,7 @@ from .dao_rules import (
|
||||
schedule_rule_to_storage,
|
||||
)
|
||||
from .db import NestQuestDatabase
|
||||
from .icons import validate_icon
|
||||
from .materialize import regenerate_for_definition
|
||||
from .recurrence import ScheduleRule, occurs_on
|
||||
|
||||
@@ -79,6 +82,21 @@ class CreatedQuestDefinition:
|
||||
windows: list[QuestDefinitionWindowRecord]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeletedQuestDefinition:
|
||||
"""What :func:`delete_quest_definition` actually did.
|
||||
|
||||
Exactly one of ``deleted`` / ``retired`` is True. ``definition`` is
|
||||
the retired (deactivated) definition's updated view, or None when
|
||||
the definition was hard-deleted and no longer exists.
|
||||
"""
|
||||
|
||||
definition_id: int
|
||||
deleted: bool
|
||||
retired: bool
|
||||
definition: CreatedQuestDefinition | None
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
"""Return the module's strict UTC second-precision stamp."""
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
@@ -248,7 +266,7 @@ async def create_quest_definition(
|
||||
assignee_ids = _validate_assignee_ids(assignee_child_ids)
|
||||
window_specs = _normalize_windows(windows)
|
||||
description_value = _validate_text(description, "description")
|
||||
icon_value = _validate_text(icon, "icon")
|
||||
icon_value = validate_icon(icon)
|
||||
skip_on_away_value = _validate_skip_on_away(skip_on_away)
|
||||
|
||||
storage = schedule_rule_to_storage(rule)
|
||||
@@ -327,7 +345,7 @@ async def edit_quest_definition(
|
||||
future instances only; no ``quest_instances`` or
|
||||
``completion_events`` row is touched.
|
||||
|
||||
``today`` optionally pins the caller-resolved HA-local date the
|
||||
``today`` optionally pins the caller-resolved household-local date the
|
||||
regeneration's no-past guard and horizon are anchored to (threaded
|
||||
into :func:`~.materialize.regenerate_for_definition`); it is a plain
|
||||
``datetime.date`` with no HA import here — the Feature 09 service
|
||||
@@ -351,7 +369,7 @@ async def edit_quest_definition(
|
||||
|
||||
icon_value: str | None | object = _UNSET
|
||||
if icon is not _UNSET:
|
||||
icon_value = _validate_text(icon, "icon")
|
||||
icon_value = validate_icon(icon)
|
||||
|
||||
rule_storage: ScheduleRuleStorage | object = _UNSET
|
||||
if rule is not _UNSET:
|
||||
@@ -431,7 +449,7 @@ async def assign_child(
|
||||
``quest_definition_assignees`` link is written; existing instances
|
||||
and completion history are never touched.
|
||||
|
||||
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||
``today`` optionally pins the caller-resolved household-local date threaded
|
||||
into the assignment's regeneration (see :func:`edit_quest_definition`).
|
||||
``horizon_days`` optionally sizes the regeneration's re-materialization
|
||||
window the same way.
|
||||
@@ -473,7 +491,7 @@ async def unassign_child(
|
||||
argument. Only the ``quest_definition_assignees`` link is written;
|
||||
existing instances and completion history are never touched.
|
||||
|
||||
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||
``today`` optionally pins the caller-resolved household-local date threaded
|
||||
into the unassignment's regeneration (see :func:`edit_quest_definition`).
|
||||
``horizon_days`` optionally sizes the regeneration's re-materialization
|
||||
window the same way.
|
||||
@@ -503,12 +521,19 @@ async def set_quest_definition_active(
|
||||
) -> CreatedQuestDefinition:
|
||||
"""Deactivate or reactivate a definition; returns the updated view.
|
||||
|
||||
Flipping the flag is the only removal path (Feature 06 guardrail):
|
||||
Flipping the flag is the only removal path for a definition with
|
||||
completion history (Feature 06 guardrail; a history-free definition
|
||||
may instead be hard-deleted by :func:`delete_quest_definition`):
|
||||
a deactivated definition stops future instance generation but its
|
||||
row, schedule rule, assignees and windows all survive, and existing
|
||||
instances plus completion history are never touched. Reactivation
|
||||
re-enables future generation; generating or removing instances is
|
||||
Feature 07 and does not happen here.
|
||||
row, schedule rule, assignees and windows all survive, and its
|
||||
durable completion history (the append-only ``completion_events``)
|
||||
is never touched. Like every Feature 06 config change, the flip is
|
||||
followed by :func:`~.materialize.regenerate_for_definition`: the
|
||||
definition's future (today onward) UNCOMPLETED instances are cleared
|
||||
— they are rolling, regenerable rows — and, while the definition is
|
||||
inactive, none are re-materialized. Completed and past instances
|
||||
are kept. Reactivation re-enables generation, and the same
|
||||
regeneration re-materializes the rolling horizon.
|
||||
|
||||
``definition_id`` must be a plain int (bools and floats rejected,
|
||||
since SQLite would bind ``True`` onto definition 1) and
|
||||
@@ -523,7 +548,7 @@ async def set_quest_definition_active(
|
||||
connection-scoped lock, so the returned bundle is a consistent
|
||||
snapshot that a concurrent mutation cannot race.
|
||||
|
||||
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||
``today`` optionally pins the caller-resolved household-local date threaded
|
||||
into the activation change's regeneration (see
|
||||
:func:`edit_quest_definition`). ``horizon_days`` optionally sizes the
|
||||
regeneration's re-materialization window the same way.
|
||||
@@ -561,6 +586,68 @@ async def set_quest_definition_active(
|
||||
)
|
||||
|
||||
|
||||
async def delete_quest_definition(
|
||||
database: NestQuestDatabase,
|
||||
definition_id: int,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> DeletedQuestDefinition:
|
||||
"""Delete a definition if it has no history; otherwise retire it.
|
||||
|
||||
Owner decision (2026-09-26): "delete if there's no history,
|
||||
retire/deactivate if there is". When NO instance of the definition
|
||||
has a completion event, the definition, its instances, assignees,
|
||||
windows and (if no other definition references it) its schedule
|
||||
rule are hard-deleted in ONE transaction by
|
||||
:meth:`~.dao_rules.QuestDefinitionsDao.delete_if_no_history` — the
|
||||
history check is inside that transaction, so a completion recorded
|
||||
concurrently can never be deleted (D-005). When history exists,
|
||||
the definition is retired through :func:`set_quest_definition_active`
|
||||
(``False``). Retirement preserves the definition row, schedule
|
||||
rule, assignees, windows and ALL durable completion history. Like
|
||||
any deactivation it regenerates, so future UNCOMPLETED instances are
|
||||
cleared (they are rolling, regenerable rows) and none are recreated
|
||||
while the definition is inactive — the retired task stops generating
|
||||
and no longer shows open work. Durable instance history lives only
|
||||
in the append-only ``completion_events``.
|
||||
|
||||
``definition_id`` must be a plain int (bools and floats rejected).
|
||||
Raises ValueError naming the field when the id is malformed or the
|
||||
definition does not exist. ``today`` / ``horizon_days`` are threaded
|
||||
into the retire path's regeneration, as in
|
||||
:func:`set_quest_definition_active`.
|
||||
"""
|
||||
_validate_definition_id(definition_id)
|
||||
deleted = await QuestDefinitionsDao(database).delete_if_no_history(
|
||||
definition_id
|
||||
)
|
||||
if deleted is None:
|
||||
raise ValueError(
|
||||
f"definition_id: quest definition {definition_id} does not exist"
|
||||
)
|
||||
if deleted:
|
||||
return DeletedQuestDefinition(
|
||||
definition_id=definition_id,
|
||||
deleted=True,
|
||||
retired=False,
|
||||
definition=None,
|
||||
)
|
||||
retired = await set_quest_definition_active(
|
||||
database,
|
||||
definition_id,
|
||||
False,
|
||||
today=today,
|
||||
horizon_days=horizon_days,
|
||||
)
|
||||
return DeletedQuestDefinition(
|
||||
definition_id=definition_id,
|
||||
deleted=False,
|
||||
retired=True,
|
||||
definition=retired,
|
||||
)
|
||||
|
||||
|
||||
def _bundle_snapshot(
|
||||
snapshot: QuestDefinitionSnapshot,
|
||||
) -> CreatedQuestDefinition:
|
||||
|
||||
@@ -35,6 +35,7 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Mapping
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from .const import (
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
@@ -47,12 +48,14 @@ from .const import (
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_NOTIFY_TARGET,
|
||||
CONF_TIMEZONE,
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DEFAULT_MORNING_SUMMARY_TIME,
|
||||
DEFAULT_TIMEZONE,
|
||||
LOGGER,
|
||||
TIME_PATTERN,
|
||||
)
|
||||
@@ -153,6 +156,29 @@ def _resolve_optional_str(value: Any) -> str:
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _resolve_timezone(value: Any) -> str:
|
||||
"""Validate a household time zone and return it.
|
||||
|
||||
The empty string means "the API host's local time" (the historical
|
||||
behaviour); anything else must be an IANA key :class:`ZoneInfo`
|
||||
resolves (e.g. ``America/New_York``). A non-str (including
|
||||
``None``) is rejected; the ``None``-as-absent mapping lives ONLY in
|
||||
:meth:`NestQuestSettings.from_options` (via :func:`_option_value`).
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"timezone must be a string, got {value!r}")
|
||||
if value == "":
|
||||
return value
|
||||
try:
|
||||
ZoneInfo(value)
|
||||
except (ZoneInfoNotFoundError, ValueError) as error:
|
||||
raise ValueError(
|
||||
f"timezone must be an IANA time zone name such as "
|
||||
f"'America/New_York', got {value!r}"
|
||||
) from error
|
||||
return value
|
||||
|
||||
|
||||
#: The single field table the two builders share. Each row is
|
||||
#: ``(field_name, CONF_* key, default, validator)``. ``from_options``
|
||||
#: applies every validator (raising on the first invalid value) while
|
||||
@@ -214,6 +240,7 @@ _FIELDS: tuple[tuple[str, str, Any, Callable[[Any], Any]], ...] = (
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
_resolve_bool,
|
||||
),
|
||||
("timezone", CONF_TIMEZONE, DEFAULT_TIMEZONE, _resolve_timezone),
|
||||
)
|
||||
|
||||
|
||||
@@ -243,6 +270,7 @@ class NestQuestSettings:
|
||||
afternoon_reminder_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||
end_of_day_report_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||
celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||
timezone: str = DEFAULT_TIMEZONE
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Validate STRICTLY at construction so a hand-built instance
|
||||
@@ -281,6 +309,22 @@ class NestQuestSettings:
|
||||
raise ValueError(
|
||||
f"{name} must be a boolean, got {value!r}"
|
||||
)
|
||||
_resolve_timezone(self.timezone)
|
||||
|
||||
def household_now(self, instant: datetime.datetime) -> datetime.datetime:
|
||||
"""Return the aware ``instant`` expressed in the household's zone.
|
||||
|
||||
The API service does not run inside Home Assistant, so its one
|
||||
clock read (an aware host-local ``now``) is converted here: with
|
||||
:attr:`timezone` set — only ever by an admin, from the admin
|
||||
app — the SAME instant is re-expressed in that zone, so
|
||||
``.date()`` and ``.time()`` are the household's wall clock even
|
||||
when the host runs UTC. With :attr:`timezone` empty the instant
|
||||
is returned unchanged (the host's local time).
|
||||
"""
|
||||
if not self.timezone:
|
||||
return instant
|
||||
return instant.astimezone(ZoneInfo(self.timezone))
|
||||
|
||||
@property
|
||||
def day_rollover_hour_minute(self) -> tuple[int, int]:
|
||||
@@ -296,7 +340,7 @@ class NestQuestSettings:
|
||||
"""Return the rolling horizon window ``[today, today + horizon_days]``.
|
||||
|
||||
``today`` is REQUIRED (there is no host-clock default): the
|
||||
integration passes the HA-local ``hass.config.time_zone`` date so
|
||||
API passes the household-local date (:meth:`household_now`) so
|
||||
the horizon tracks the household's own day, and a silent
|
||||
host-clock fallback would be a wrong-day footgun (a test or a
|
||||
caller that forgets to pass ``today`` would otherwise materialize
|
||||
|
||||
@@ -18,7 +18,7 @@ API client; the two are deliberately NOT unified here, and neither
|
||||
side reads the other's values.
|
||||
|
||||
STORED SERIALIZATION — the document at ``SETTINGS_META_KEY`` is a JSON
|
||||
object holding exactly the TEN settings fields, keyed by field name
|
||||
object holding exactly the TWELVE settings fields, keyed by field name
|
||||
(which equals the ``CONF_*`` option key for every field), complete
|
||||
after the first update:
|
||||
|
||||
@@ -30,7 +30,11 @@ after the first update:
|
||||
core's normalizer),
|
||||
- ``morning_summary_enabled``, ``afternoon_reminder_enabled``,
|
||||
``end_of_day_report_enabled``, ``celebration_enabled`` — real
|
||||
booleans.
|
||||
booleans,
|
||||
- ``timezone`` — ``""`` (the API host's local time) or an IANA time
|
||||
zone name such as ``America/New_York``. A document stored before
|
||||
this field existed simply lacks it and resolves to ``""``. Only an
|
||||
admin's explicit update ever writes it; nothing sets it automatically.
|
||||
|
||||
Only values whose core validators ACCEPTED are ever written, so a
|
||||
stored document always round-trips through the core's option builders.
|
||||
@@ -172,7 +176,7 @@ async def update_settings(
|
||||
update can never leave a half-applied document behind.
|
||||
|
||||
The validated changes are merged onto the current effective
|
||||
settings and the merged (complete, ten-field) document is persisted
|
||||
settings and the merged (complete, eleven-field) document is persisted
|
||||
in one atomic upsert; the updated settings are returned. The whole
|
||||
read → merge → write span runs inside the per-database settings
|
||||
lock, so concurrent updates queue instead of interleaving.
|
||||
@@ -193,18 +197,27 @@ async def update_settings(
|
||||
)
|
||||
async with _settings_lock(database):
|
||||
current = await load_settings(database)
|
||||
document: dict[str, Any] = {
|
||||
field: getattr(current, field) for field in _FIELD_NAMES
|
||||
}
|
||||
for name, raw_value in changes.items():
|
||||
default, validator = _RESOLUTIONS[name]
|
||||
try:
|
||||
document[name] = validator(
|
||||
default if raw_value is None else raw_value
|
||||
)
|
||||
except ValueError as error:
|
||||
raise ValueError(_named(name, error)) from error
|
||||
await MetaStateDao(database).set(
|
||||
SETTINGS_META_KEY, json.dumps(document, sort_keys=True)
|
||||
)
|
||||
return await _write_merged(database, current, changes)
|
||||
|
||||
|
||||
async def _write_merged(
|
||||
database: NestQuestDatabase,
|
||||
current: NestQuestSettings,
|
||||
changes: Mapping[str, Any],
|
||||
) -> NestQuestSettings:
|
||||
"""Validate ``changes``, merge onto ``current``, persist; lock held."""
|
||||
document: dict[str, Any] = {
|
||||
field: getattr(current, field) for field in _FIELD_NAMES
|
||||
}
|
||||
for name, raw_value in changes.items():
|
||||
default, validator = _RESOLUTIONS[name]
|
||||
try:
|
||||
document[name] = validator(
|
||||
default if raw_value is None else raw_value
|
||||
)
|
||||
except ValueError as error:
|
||||
raise ValueError(_named(name, error)) from error
|
||||
await MetaStateDao(database).set(
|
||||
SETTINGS_META_KEY, json.dumps(document, sort_keys=True)
|
||||
)
|
||||
return NestQuestSettings(**document)
|
||||
|
||||
@@ -18,7 +18,7 @@ the panel payload shaper (:func:`instance_payload`), and the async
|
||||
service's panel snapshot route share ONE implementation of both the
|
||||
snapshot assembly and the entity-facing attribute payload shape.
|
||||
Nothing here imports :mod:`homeassistant`; the builder takes the
|
||||
database, the settings object, and the HA-local ``now`` (a single
|
||||
database, the settings object, and the household-local ``now`` (a single
|
||||
clock read the caller already resolved, from which it derives
|
||||
``today``) and returns the snapshot, mirroring exactly what the
|
||||
coordinator used to do inline.
|
||||
@@ -42,6 +42,7 @@ from .dao_instances import CompletionEventsDao, QuestInstancesDao
|
||||
from .dao_presence import PresenceOverridesDao, PresencePatternsDao
|
||||
from .dao_rules import QuestDefinitionsDao
|
||||
from .db import NestQuestDatabase
|
||||
from .icons import normalize_icon_for_read
|
||||
from .presence import (
|
||||
MAX_PREVIEW_SCAN_DAYS,
|
||||
PresenceEngine,
|
||||
@@ -95,6 +96,11 @@ class NestQuestSnapshot:
|
||||
today_iso: str
|
||||
children: tuple[ChildDaySnapshot, ...]
|
||||
cycle_day: int
|
||||
#: The application timezone the admin stored (``""`` when unset —
|
||||
#: the host's local time; the API publishes its effective zone in
|
||||
#: its place, api/host_zone.py). The panel cards format their
|
||||
#: visible date and clock in it; it never re-resolves the day itself.
|
||||
timezone: str = ""
|
||||
|
||||
|
||||
def _cycle_day(
|
||||
@@ -181,7 +187,7 @@ async def build_snapshot(
|
||||
:func:`instance_payload` layer — this builder produces the shared
|
||||
shape both consumers read.
|
||||
|
||||
``now`` is the HA-local timezone-aware datetime the caller already
|
||||
``now`` is the household-local timezone-aware datetime the caller already
|
||||
resolved (a SINGLE clock read); the overdue check compares
|
||||
``now.time()`` against the instance's ``due_time`` for today's open
|
||||
instances. The builder performs NO internal clock read — it never
|
||||
@@ -262,7 +268,11 @@ async def build_snapshot(
|
||||
definition_id=instance.definition_id,
|
||||
child_id=instance.child_id,
|
||||
title=definition.title if definition else "Quest",
|
||||
icon=definition.icon if definition else None,
|
||||
icon=(
|
||||
normalize_icon_for_read(definition.icon)
|
||||
if definition
|
||||
else None
|
||||
),
|
||||
window=instance.window,
|
||||
due_date=instance.due_date,
|
||||
due_time=instance.due_time,
|
||||
@@ -314,4 +324,5 @@ async def build_snapshot(
|
||||
today_iso=today_iso,
|
||||
children=tuple(snapshots),
|
||||
cycle_day=_cycle_day(patterns, children, today),
|
||||
timezone=settings.timezone,
|
||||
)
|
||||
|
||||
@@ -39,9 +39,10 @@ lets exactly one of them win the claim — the loser returns [] and
|
||||
announces nothing.
|
||||
|
||||
The caller supplies ``today`` as a plain :class:`datetime.date`: the
|
||||
household-local calendar date, read from ``hass.config.time_zone`` by
|
||||
the integration or from the API host's local clock by the API. This
|
||||
module never reads a timezone database or config of its own.
|
||||
household-local calendar date, which the API reads in the
|
||||
admin-stored application timezone (the API host's local zone when none
|
||||
is stored). This module never reads a timezone database or config of
|
||||
its own.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -97,8 +98,9 @@ async def run_missed_sweep(
|
||||
"""Build one missed event per unswept past-due open instance.
|
||||
|
||||
``today`` is the household-local calendar date the sweep runs for
|
||||
(the caller owns the clock: the integration reads it from
|
||||
``hass.config.time_zone``, the API from its host's local time).
|
||||
(the caller owns the clock: the API reads it in the admin-stored
|
||||
application timezone, falling back to its host's local time when
|
||||
unset).
|
||||
Returns the ``(event_type, payload)`` tuples to announce, EMPTY
|
||||
when the watermark already covers ``today`` (a same-night rerun) —
|
||||
the caller decides how to publish them and announces nothing on
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
|
||||
"requirements": [],
|
||||
"version": "0.7.1"
|
||||
"version": "0.9.8"
|
||||
}
|
||||
|
||||
@@ -441,6 +441,10 @@ class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
|
||||
for child in data.children
|
||||
},
|
||||
"child_roster": _child_roster(data.children),
|
||||
# The API's effective application timezone ("" only when
|
||||
# it could not name one): the panel cards format their
|
||||
# visible date and clock in it.
|
||||
"timezone": data.timezone,
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+45
-3
@@ -64,7 +64,8 @@ Header `Tasks` / `7 definitions · 3 children` + gradient `New` button. Filter c
|
||||
(All / one per child): active `#18181D` fill white text, inactive white with border.
|
||||
Rows: glyph 17px · title 13.5px/700 · meta 11px/600 `<assignees> · <recurrence> · <windows>`
|
||||
· `chevron-right`. An inactive definition renders the whole row at
|
||||
`--nq-a-ink-tertiary` with `· inactive` in the meta — never hard-deleted (glossary).
|
||||
`--nq-a-ink-tertiary` with `· inactive` in the meta. A definition with completion
|
||||
history is never hard-deleted (glossary, D-017): Delete retires it to this inactive state.
|
||||
The row being edited takes `--nq-a-selected` with brand-blue glyph, `Editing` meta and
|
||||
`chevron-down`.
|
||||
|
||||
@@ -83,9 +84,50 @@ Bottom sheet, `--nq-a-radius-sheet`, `--nq-a-sheet-shadow`, 38×4px grabber, pad
|
||||
Footer: `Cancel` (flex 1, bordered) + `Save changes` (flex 2, gradient), both 13px vertical
|
||||
padding. Sheet enters over the dimmed list in `--nq-dur-modal`.
|
||||
|
||||
Delete (editing an existing definition only, never on `New`): a full-width bordered
|
||||
`Delete task` button below the footer, danger-ink text. Tapping it swaps the footer for a
|
||||
danger-tinted confirm — "Delete this task? If it has no completion history it is deleted;
|
||||
otherwise it is retired (deactivated) and its history is kept." — with `Go back` and a
|
||||
danger-filled `Delete task`. Confirming calls `DELETE /api/v1/admin/quest-definitions/{id}`
|
||||
(admin plane only; the kids' panel has no equivalent control). Hybrid outcome (D-017): no
|
||||
completion event on any instance → the definition, its instances, assignees, windows and
|
||||
unreferenced rule are hard-deleted and the row leaves the list; otherwise the definition
|
||||
is retired (deactivated) and the row stays, shown inactive. Retirement preserves the
|
||||
definition row, schedule rule, assignees, windows and all durable completion history. Like
|
||||
any deactivation it regenerates, so future uncompleted instances are cleared (they are
|
||||
rolling, regenerable rows) and none are recreated while the definition is inactive — the
|
||||
retired task stops generating and no longer shows open work. Durable instance history lives
|
||||
only in the append-only `completion_events`. An error keeps the sheet open with the API's
|
||||
detail.
|
||||
|
||||
### 3.3 Icon picker
|
||||
Each definition carries a Lucide glyph, shown on both surfaces. **The allowed subset is not
|
||||
yet defined** — pick 24–40 household-relevant glyphs and ship them as a grid picker.
|
||||
Each definition carries one optional icon, shown on both surfaces. The picker offers three
|
||||
sources:
|
||||
|
||||
- **Lucide** — a grid of the curated Lucide glyphs, drawn stroked.
|
||||
- **Font Awesome** — a grid of a curated subset of the Font Awesome Free *solid* set,
|
||||
drawn filled.
|
||||
- **Emoji** — a text input; the typed emoji is stored as entered (not trimmed) and must be
|
||||
1–8 code points with no whitespace, control characters, `<`, `>` or `&`.
|
||||
|
||||
Both curated lists come from `tools/icons/curated-icons.json`; their glyph path data is
|
||||
generated into the admin and panel bundles by `tools/icons/generate.mjs` (no CDN, no
|
||||
runtime fetch). See `THIRD-PARTY-NOTICES.md` for licences.
|
||||
|
||||
The stored value is a **namespaced key**:
|
||||
|
||||
| Source | Stored key | Example |
|
||||
|---|---|---|
|
||||
| Lucide | `lucide:<name>` | `lucide:dog` |
|
||||
| Font Awesome | `fa:<name>` | `fa:paw` |
|
||||
| Emoji | `emoji:<grapheme>` | `emoji:🐶` |
|
||||
|
||||
`<name>` is a lowercase kebab name (`[a-z0-9-]`, no leading or trailing `-`). A **legacy
|
||||
bare name** written before namespacing (`dog`) reads as `lucide:<name>` — the API returns it
|
||||
namespaced, and any write of a bare name is stored as `lucide:<name>`; no data migration. An
|
||||
empty value clears the icon. An empty, unknown, or unresolvable key renders the **fallback
|
||||
glyph** — in the admin, the generic task glyph (clipboard with a check); on the panel, the
|
||||
star (`PANEL-SPEC.md` §3.2).
|
||||
|
||||
## 4. Schedule (1f)
|
||||
|
||||
|
||||
@@ -78,5 +78,6 @@ The panel speaks tabletop; the admin speaks plainly. Both sit on the same `quest
|
||||
decide before shipping the card.
|
||||
- No gamification is in scope. XP, levels, streaks and rarity were explicitly deferred,
|
||||
but the append-only event log already supports computing them later.
|
||||
- Icon per quest definition is chosen by the parent from a Lucide subset; the subset is
|
||||
not yet defined (`ADMIN-SPEC.md` §3.3).
|
||||
- Icon per quest definition is chosen by the parent from curated Lucide and Font Awesome
|
||||
Free sets or as an emoji, stored as a namespaced key (`lucide:`/`fa:`/`emoji:`) —
|
||||
resolved 2026-09-26, see `ADMIN-SPEC.md` §3.3.
|
||||
|
||||
@@ -35,7 +35,7 @@ implementer's call), shaped:
|
||||
{
|
||||
"instances": [
|
||||
{ "id": 4821, "definition_id": 12, "child_id": 3, "title": "Fill bird feeder",
|
||||
"icon": "bird", "window": "afternoon", "due_time": "17:00",
|
||||
"icon": "lucide:bird", "window": "afternoon", "due_time": "17:00",
|
||||
"state": "open", "overdue": true, "completed_at": null, "on_time": null }
|
||||
]
|
||||
}
|
||||
|
||||
+27
-6
@@ -22,10 +22,14 @@ Screens `1a`, `1b`, `1j`, `1c` of the mockups. Two cards:
|
||||
|
||||
Default view on the wall. Layout top to bottom:
|
||||
|
||||
1. **Wordmark row** at y=64. Rule · d20 hexagon · `NestQuest` (66px/900 display, brand
|
||||
gradient via `background-clip: text`) · d20 · rule. d20 marks are 34px hexagons
|
||||
(`clip-path: polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)`) filled with
|
||||
the brand gradient, numeral 15px/700 Cinzel.
|
||||
1. **Wordmark row** at y=64. Rule · monogram · `NestQuest` (66px/900 Cinzel Decorative
|
||||
display, brand gradient via `background-clip: text`) · rule — the horizontal lockup of
|
||||
`design/logos/NestQuest_Logo.png`. The monogram is an 80px inline SVG (no raster, no
|
||||
remote asset): a compass rose — four long cardinal points, four short diagonal points,
|
||||
a ring broken by the points — around a d20 hexagon with facet lines and a knocked-out
|
||||
Cinzel Decorative "N", filled with the brand gradient (`--nq-brand-blue` →
|
||||
`--nq-brand-purple`, 135°). Gaps are SVG masks, so the mark stays transparent over the
|
||||
parchment.
|
||||
2. **Kicker** `The Party · <weekday>, <Month D>` — 21px/600 Cinzel, tracking .30em, uppercase.
|
||||
3. **Adventurer plates**, 3-up grid, 56px gap, page inset 110px, top y=262. One plate per
|
||||
child returned by the party sensor, in configured order.
|
||||
@@ -67,6 +71,14 @@ and time only rather than removing it (the strip is part of the frame).
|
||||
|
||||
Reached by tapping a present crest. Header at y=56, page inset 62px:
|
||||
|
||||
- **Return control** first in the header, left of the crest: a `--nq-p-button-height`
|
||||
72px button, radius 10px, `rgba(92,62,26,.1)` with a 2px `rgba(92,62,26,.5)` border
|
||||
(the confirm dialog's **Not yet** treatment), `chevron-left` 34px + `The Party`
|
||||
26px/700 Cinzel `--nq-p-ink`, 28px header gap to the crest. Tapping it takes the same
|
||||
path as the idle return (§3.5) — navigate to `board_path` — immediately. Press
|
||||
feedback `scale(.98)`, `--nq-dur-micro`. Not rendered when no `board_path` is
|
||||
configured. The party board is the main display and has no return control; the
|
||||
Quest complete screen (§5) keeps its auto-return.
|
||||
- Small crest 96×110 (initial 44px/700 display).
|
||||
- Title `<Name>'s Quest Log` 56px/700 display.
|
||||
- Sub `<weekday>, <Month D> · N of M claimed` 20px/600 Cinzel, tracking .24em, uppercase.
|
||||
@@ -91,7 +103,14 @@ collapse the column, the three-column rhythm is the layout.
|
||||
`--nq-p-quest-min-height` 116px, radius 12px, `--nq-p-card-open`, 1px
|
||||
`--nq-p-card-border`, `--nq-p-card-shadow`, padding 22px, 22px gap, row layout:
|
||||
|
||||
1. **Icon tile** 64×64, radius 10px, `--nq-p-icon-tile`, Lucide glyph 34px white.
|
||||
1. **Icon tile** 64×64, radius 10px, `--nq-p-icon-tile`, glyph 34px white — the
|
||||
definition's icon, resolved from its namespaced key (`ADMIN-SPEC.md` §3.3):
|
||||
`lucide:<name>` draws the curated Lucide glyph **stroked** (fill none, stroke-width 2,
|
||||
round caps and joins); `fa:<name>` draws the curated Font Awesome glyph **filled**;
|
||||
`emoji:<grapheme>` renders the emoji **as text**. A legacy bare name resolves as
|
||||
`lucide:<name>`. No icon, or any key that does not resolve (unknown namespace or name,
|
||||
malformed emoji), falls back to the **star** glyph. Glyph data is bundled from the
|
||||
shared registry — nothing is fetched at runtime.
|
||||
2. **Text** title 30px/700 Cinzel `--nq-p-ink`; meta 22px/600 Nunito
|
||||
`--nq-p-ink-secondary` reading `Due by <h:mm A>`. If past due:
|
||||
`Overdue · due <h:mm A>` in 22px/700 Cinzel uppercase, tracking .10em,
|
||||
@@ -106,7 +125,9 @@ Both the card body and the button open the confirm dialog (§4). Press feedback:
|
||||
### 3.3 Quest card — sealed
|
||||
|
||||
`--nq-p-card-done`, `opacity: .82`, no shadow, **not tappable**. Icon tile
|
||||
`--nq-p-icon-tile-done` with the glyph in `--nq-p-ink-secondary`. Title 30px/700 Cinzel
|
||||
`--nq-p-icon-tile-done` with the glyph in `--nq-p-ink-secondary`. The sealed tile keeps
|
||||
the static star glyph — it does **not** render the definition icon — alongside the wax
|
||||
seal below. Title 30px/700 Cinzel
|
||||
`--nq-p-ink-muted` with `text-decoration: line-through`. Meta reads
|
||||
`Claimed <h:mm A>` 22px/600 Nunito `--nq-p-ink-secondary`.
|
||||
|
||||
|
||||
@@ -99,6 +99,38 @@ A lone `home` pattern answers exactly as the old schedule did, so no
|
||||
child's presence changes. The drop is one-way and 0.7.1 refuses a
|
||||
version-9 database; see the rollback step in `deploy/RUNBOOK.md` §11.
|
||||
|
||||
### Application timezone (manual, admin-set)
|
||||
|
||||
The application timezone is set **manually by an admin from the admin
|
||||
app** — the Settings screen's timezone picker, which reads and writes the
|
||||
`timezone` field of `GET`/`PATCH /api/v1/admin/settings`. Nothing sets it
|
||||
automatically: there is no Home Assistant zone adoption, no browser-zone
|
||||
auto-detect, and no auto-save when a screen loads. Only an admin's `PATCH`
|
||||
ever changes the stored value.
|
||||
|
||||
The stored value is the **single source of local-time truth for the whole
|
||||
application.** The API reads its one clock and re-expresses it in that zone
|
||||
(`NestQuestSettings.household_now`), so every local date and time comes
|
||||
from it:
|
||||
|
||||
- the panel snapshot's today and overdue state, and both panel cards'
|
||||
visible dates and clocks (the snapshot publishes the zone as `timezone`,
|
||||
which the integration exposes on the household rollup sensor);
|
||||
- completion timestamps (the local completion date and the on-time check);
|
||||
- the missed-sweep scheduler's household rollover;
|
||||
- every admin read, and the admin app's local-time formatting.
|
||||
|
||||
**Empty means the API host's local zone.** While `timezone` is empty the
|
||||
API reads its clock in the host's local time, and publishes that zone as
|
||||
the effective zone (`api/host_zone.py`: the `effective_timezone` field of
|
||||
the admin settings response and the snapshot's `timezone`) until an admin
|
||||
sets one. No code fills the empty value in.
|
||||
|
||||
**A save re-plans the sweep.** An admin save that changes `timezone` or
|
||||
`day_rollover_time` notifies the running missed-sweep scheduler
|
||||
(`MissedSweepScheduler.settings_changed`, under its settings lock), which
|
||||
re-plans its next sweep for the new household rollover.
|
||||
|
||||
## 4. Authentication: two planes
|
||||
|
||||
The kid panel and the admin PWA have opposite trust levels, so they use
|
||||
|
||||
@@ -231,8 +231,76 @@ function conditionPhrase(condition: string, label: string): string {
|
||||
}
|
||||
|
||||
const SHIELD_CLIP = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)";
|
||||
const HEXAGON_CLIP =
|
||||
"polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)";
|
||||
// The brand monogram (design/logos/NestQuest_Logo.png): a compass rose —
|
||||
// four long cardinal points, four short diagonal points, a ring broken by
|
||||
// the points — around a d20 hexagon carrying a knocked-out "N". Drawn on
|
||||
// a 100-unit grid and filled with the brand gradient; the masks cut the
|
||||
// parchment gaps so the mark stays transparent on any backdrop.
|
||||
const MONOGRAM_POINTS =
|
||||
"M50 3 44.5 24.1 50 26 55.5 24.1Z M100 50 75.9 44.5 74 50 75.9 55.5Z " +
|
||||
"M50 97 55.5 75.9 50 74 44.5 75.9Z M0 50 24.1 55.5 26 50 24.1 44.5Z " +
|
||||
"M29.1 29.1 29.7 36.3 36.3 29.7Z M70.9 29.1 63.7 29.7 70.3 36.3Z " +
|
||||
"M70.9 70.9 70.3 63.7 63.7 70.3Z M29.1 70.9 36.3 70.3 29.7 63.7Z";
|
||||
const MONOGRAM_HEX = "M50 26.5 70.4 38.2 70.4 61.8 50 73.5 29.6 61.8 29.6 38.2Z";
|
||||
const MONOGRAM_FACETS =
|
||||
"M29.6 38.2H70.4M29.6 61.8H70.4M50 26.5 36.5 38.2M50 26.5 63.5 38.2" +
|
||||
"M50 73.5 36.5 61.8M50 73.5 63.5 61.8M36.5 38.2V61.8M63.5 38.2V61.8" +
|
||||
"M29.6 38.2 36.5 61.8M36.5 38.2 29.6 61.8" +
|
||||
"M70.4 38.2 63.5 61.8M63.5 38.2 70.4 61.8";
|
||||
const MONOGRAM = html`<svg
|
||||
class="monogram"
|
||||
viewBox="0 0 100 100"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="nq-monogram-fill"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="100"
|
||||
y2="100"
|
||||
>
|
||||
<stop offset="0" style="stop-color: var(--nq-brand-blue)" />
|
||||
<stop offset="1" style="stop-color: var(--nq-brand-purple)" />
|
||||
</linearGradient>
|
||||
<mask id="nq-monogram-points-gap" maskUnits="userSpaceOnUse">
|
||||
<rect width="100" height="100" fill="#fff" />
|
||||
<path d=${MONOGRAM_POINTS} stroke="#000" stroke-width="3.5" />
|
||||
</mask>
|
||||
<mask id="nq-monogram-facets" maskUnits="userSpaceOnUse">
|
||||
<rect width="100" height="100" fill="#fff" />
|
||||
<path d=${MONOGRAM_FACETS} stroke="#000" stroke-width="1.6" />
|
||||
<text
|
||||
x="50"
|
||||
y="59.4"
|
||||
text-anchor="middle"
|
||||
font-family="'Cinzel Decorative', Cinzel, serif"
|
||||
font-weight="900"
|
||||
font-size="25"
|
||||
fill="#fff"
|
||||
stroke="#000"
|
||||
stroke-width="2"
|
||||
paint-order="stroke"
|
||||
>N</text>
|
||||
</mask>
|
||||
</defs>
|
||||
<g fill="url(#nq-monogram-fill)">
|
||||
<path d=${MONOGRAM_POINTS} />
|
||||
<g mask="url(#nq-monogram-points-gap)">
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="34.25"
|
||||
fill="none"
|
||||
stroke="url(#nq-monogram-fill)"
|
||||
stroke-width="6.5"
|
||||
/>
|
||||
<path mask="url(#nq-monogram-facets)" d=${MONOGRAM_HEX} />
|
||||
</g>
|
||||
</g>
|
||||
</svg>`;
|
||||
|
||||
const boardStyles = css`
|
||||
* {
|
||||
@@ -299,23 +367,11 @@ const boardStyles = css`
|
||||
background: rgba(92, 62, 26, 0.35);
|
||||
}
|
||||
|
||||
.d20 {
|
||||
.monogram {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
clip-path: ${unsafeCSS(HEXAGON_CLIP)};
|
||||
background: var(--nq-brand-gradient);
|
||||
}
|
||||
|
||||
.d20-numeral {
|
||||
font-family: var(--nq-p-font-heading);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #ffffff;
|
||||
display: block;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
@@ -497,11 +553,12 @@ const boardStyles = css`
|
||||
|
||||
.hint {
|
||||
position: absolute;
|
||||
top: 930px;
|
||||
top: 896px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
font-family: var(--nq-p-font-body);
|
||||
font-size: 24px;
|
||||
font-size: 27px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
@@ -861,13 +918,8 @@ export class NestQuestPartyBoardCard extends LitElement {
|
||||
<div class="frame frame-inner"></div>
|
||||
<div class="wordmark-row">
|
||||
<span class="rule"></span>
|
||||
<span class="d20" aria-hidden="true">
|
||||
<span class="d20-numeral">20</span>
|
||||
</span>
|
||||
${MONOGRAM}
|
||||
<span class="wordmark">NestQuest</span>
|
||||
<span class="d20" aria-hidden="true">
|
||||
<span class="d20-numeral">20</span>
|
||||
</span>
|
||||
<span class="rule"></span>
|
||||
</div>
|
||||
<p class="kicker">The Party · ${formatDay(this._now, this._timeZone())}</p>
|
||||
@@ -885,8 +937,18 @@ export class NestQuestPartyBoardCard extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/** The zone the visible date and clock render in: the API's effective
|
||||
* application timezone — the stored zone, or the API host's when none
|
||||
* is stored (published on the household rollup by
|
||||
* custom_components/nestquest/sensor.py — ``timezone``). Only when the
|
||||
* rollup carries none does Home Assistant's zone (or the browser's)
|
||||
* apply. */
|
||||
private _timeZone(): string | undefined {
|
||||
return hassTimeZone(this.hass);
|
||||
const stored = this._state("sensor.nestquest_household_quests_due_today")
|
||||
?.attributes?.timezone;
|
||||
return typeof stored === "string" && stored.trim()
|
||||
? stored.trim()
|
||||
: hassTimeZone(this.hass);
|
||||
}
|
||||
|
||||
private _state(entityId: string): StateObject | null {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { LitElement, css, html, nothing, unsafeCSS, type TemplateResult } from "
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import panelTokens from "../../../custom_components/nestquest/www/nestquest-panel-tokens.css?inline";
|
||||
|
||||
import { renderQuestIcon } from "../icons/render";
|
||||
import { registerCustomCard, type CardConfig, type HassLike } from "../types";
|
||||
|
||||
type QuestLogCardConfig = CardConfig & {
|
||||
@@ -373,12 +374,43 @@ const logStyles = css`
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
button.return {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
height: var(--nq-p-button-height);
|
||||
padding: 0 28px 0 20px;
|
||||
border: 2px solid rgba(92, 62, 26, 0.5);
|
||||
border-radius: 10px;
|
||||
background: rgba(92, 62, 26, 0.1);
|
||||
color: var(--nq-p-ink);
|
||||
font-family: var(--nq-p-font-heading);
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: transform var(--nq-dur-micro) var(--nq-ease-out);
|
||||
}
|
||||
|
||||
button.return:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
button.return svg {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.crest {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 96px;
|
||||
height: 110px;
|
||||
padding: 3px;
|
||||
padding: 4px;
|
||||
clip-path: ${unsafeCSS(SHIELD_CLIP)};
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
@@ -614,6 +646,11 @@ const logStyles = css`
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.tile .emoji {
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sealed .tile {
|
||||
background: var(--nq-p-icon-tile-done);
|
||||
color: var(--nq-p-ink-secondary);
|
||||
@@ -1193,6 +1230,18 @@ const ICON_X = html`<svg
|
||||
<path d="m6 6 12 12"></path>
|
||||
</svg>`;
|
||||
|
||||
const ICON_CHEVRON_LEFT = html`<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6"></path>
|
||||
</svg>`;
|
||||
|
||||
const ICON_STAR = html`<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -1700,8 +1749,18 @@ export class NestQuestQuestLogCard extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/** The zone the visible date and clock render in: the API's effective
|
||||
* application timezone — the stored zone, or the API host's when none
|
||||
* is stored (published on the household rollup by
|
||||
* custom_components/nestquest/sensor.py — ``timezone``). Only when the
|
||||
* rollup carries none does Home Assistant's zone (or the browser's)
|
||||
* apply. */
|
||||
private _timeZone(): string | undefined {
|
||||
return hassTimeZone(this.hass);
|
||||
const stored = this._state("sensor.nestquest_household_quests_due_today")
|
||||
?.attributes?.timezone;
|
||||
return typeof stored === "string" && stored.trim()
|
||||
? stored.trim()
|
||||
: hassTimeZone(this.hass);
|
||||
}
|
||||
|
||||
private _state(entityId: string): StateObject | null {
|
||||
@@ -1938,7 +1997,19 @@ export class NestQuestQuestLogCard extends LitElement {
|
||||
const initial = (name?.charAt(0) || "?").toUpperCase();
|
||||
return html`
|
||||
<header class="header">
|
||||
<span class="crest${away ? " away" : nothing}" aria-hidden="true">
|
||||
${this._boardPath()
|
||||
? html`
|
||||
<button
|
||||
class="return"
|
||||
type="button"
|
||||
@click=${() => this._returnToBoard()}
|
||||
>
|
||||
${ICON_CHEVRON_LEFT}
|
||||
<span>The Party</span>
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
<span class="crest${away ? " away" : ""}" aria-hidden="true">
|
||||
<span class="crest-face">
|
||||
<span class="initial">${initial}</span>
|
||||
</span>
|
||||
@@ -2093,10 +2164,10 @@ export class NestQuestQuestLogCard extends LitElement {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="tile" aria-hidden="true">${ICON_STAR}</span>
|
||||
<span class="tile" aria-hidden="true">${renderQuestIcon(instance.icon, ICON_STAR)}</span>
|
||||
<div class="body">
|
||||
<span class="quest-title">${instance.title}</span>
|
||||
<span class="meta ${meta.late ? "late" : nothing}">${meta.text}</span>
|
||||
<span class="${meta.late ? "meta late" : "meta"}">${meta.text}</span>
|
||||
</div>
|
||||
<button
|
||||
class="complete"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Lit rendering for the shared icon registry (`registry.generated.ts`,
|
||||
* generated from tools/icons/curated-icons.json). The glyph path data is
|
||||
* bundled — no CDN and no runtime fetch.
|
||||
*/
|
||||
import { html, svg, type TemplateResult } from "lit";
|
||||
|
||||
import { resolveIcon } from "./registry.generated";
|
||||
|
||||
/**
|
||||
* A stored definition icon (`lucide:<name>`, `fa:<name>`, `emoji:<grapheme>`
|
||||
* or a legacy bare Lucide name) as a template; anything that resolves to the
|
||||
* registry fallback renders `fallback` instead.
|
||||
*/
|
||||
export function renderQuestIcon(
|
||||
value: string | null,
|
||||
fallback: TemplateResult
|
||||
): TemplateResult {
|
||||
const icon = resolveIcon(value);
|
||||
switch (icon.kind) {
|
||||
case "lucide":
|
||||
return html`<svg
|
||||
viewBox=${icon.viewBox}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
data-icon=${icon.key}
|
||||
>
|
||||
${icon.paths.map((d) => svg`<path d=${d}></path>`)}
|
||||
</svg>`;
|
||||
case "fa":
|
||||
return html`<svg
|
||||
viewBox=${icon.viewBox}
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
data-icon=${icon.key}
|
||||
>
|
||||
${icon.paths.map((d) => svg`<path d=${d}></path>`)}
|
||||
</svg>`;
|
||||
case "emoji":
|
||||
return html`<span class="emoji" data-icon=${icon.key}>${icon.text}</span>`;
|
||||
case "fallback":
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,15 @@
|
||||
*
|
||||
* Usage: node render-party-board.mjs < spec.json
|
||||
*
|
||||
* The spec JSON carries {bundle, tag, config, hass, url?, click_plate?};
|
||||
* The spec JSON carries {bundle, tag, config, hass, url?, click_plate?, now?};
|
||||
* stdout receives one JSON object {plates, tappable, location, html} where
|
||||
* plates is the ordered plate list the shadow DOM renders (name +
|
||||
* present/away/unknown kind), tappable the plate names rendered as tappable
|
||||
* <button> plates, and location the window pathname after the optional plate
|
||||
* tap. The Python panel tests drive this harness so the assertions run
|
||||
* against the exact bundle HACS ships.
|
||||
* tap. With now: <ISO instant> the card's clock is pinned there
|
||||
* (argument-less new Date() and Date.now() return it; the process TZ
|
||||
* decides its local wall time). The Python panel tests drive this harness
|
||||
* so the assertions run against the exact bundle HACS ships.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -39,6 +41,23 @@ globalThis.window = dom.window;
|
||||
// jsdom Event must shadow it for the dispatched event to be delivered.
|
||||
globalThis.Event = dom.window.Event;
|
||||
|
||||
// Pin the clock: argument-less construction and Date.now() read `pinned`,
|
||||
// every other Date use is the real class.
|
||||
if (spec.now !== undefined) {
|
||||
const pinned = new Date(spec.now).getTime();
|
||||
const RealDate = Date;
|
||||
class PinnedDate extends RealDate {
|
||||
constructor(...args) {
|
||||
super(...(args.length === 0 ? [pinned] : args));
|
||||
}
|
||||
static now() {
|
||||
return pinned;
|
||||
}
|
||||
}
|
||||
globalThis.Date = PinnedDate;
|
||||
dom.window.Date = PinnedDate;
|
||||
}
|
||||
|
||||
await import(pathToFileURL(spec.bundle).href);
|
||||
|
||||
const element = document.createElement(spec.tag);
|
||||
|
||||
@@ -7,10 +7,22 @@
|
||||
* the card is mounted at the given URL (it resolves its child from the
|
||||
* pathname's last segment), after the optional probe wait one snapshot of
|
||||
* the render is taken mid-wait, then after the optional idle wait stdout
|
||||
* receives one JSON object {location, headline, countdown, html, probe} —
|
||||
* the final window pathname, the rendered title, the complete-screen
|
||||
* countdown text, the rendered shadow DOM, and the probe snapshot when
|
||||
* probe_ms was given. The Python panel tests drive this harness so the
|
||||
* receives one JSON object {location, headline, countdown, html, tiles,
|
||||
* probe, tap} — the final window pathname, the rendered title, the
|
||||
* complete-screen countdown text, the rendered shadow DOM, each quest
|
||||
* card's icon tile, and the probe snapshot when probe_ms was given. With
|
||||
* tap_complete: <instance id> the harness taps that card's Complete button,
|
||||
* records whether the confirm dialog opened, taps the dialog's Complete, and
|
||||
* reports every hass.callService call as tap: {dialog_opened, service_calls}.
|
||||
* With tap_return: true the harness taps the header's return control and
|
||||
* reports it as returned: {rendered, label} before the final snapshot.
|
||||
* With now: <ISO instant> the card's clock is pinned there (argument-less
|
||||
* new Date() and Date.now() return it; the process TZ decides its local
|
||||
* wall time), and with advance_to: [<ISO instant>, ...] the clock moves to
|
||||
* each instant in turn and the card's own 60 s clock-tick callbacks fire,
|
||||
* one snapshot per instant reported as ticks (the top-level snapshot is
|
||||
* taken before the clock moves).
|
||||
* The Python panel tests drive this harness so the
|
||||
* assertions run against the exact bundle HACS ships.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
@@ -41,14 +53,74 @@ globalThis.window = dom.window;
|
||||
// jsdom Event must shadow it for the return navigation to be delivered.
|
||||
globalThis.Event = dom.window.Event;
|
||||
|
||||
// Pin the clock: argument-less construction and Date.now() read `pinned`,
|
||||
// every other Date use is the real class.
|
||||
let pinned = spec.now !== undefined ? new Date(spec.now).getTime() : null;
|
||||
if (pinned !== null) {
|
||||
const RealDate = Date;
|
||||
class PinnedDate extends RealDate {
|
||||
constructor(...args) {
|
||||
super(...(args.length === 0 ? [pinned] : args));
|
||||
}
|
||||
static now() {
|
||||
return pinned;
|
||||
}
|
||||
}
|
||||
globalThis.Date = PinnedDate;
|
||||
dom.window.Date = PinnedDate;
|
||||
}
|
||||
|
||||
// Record the card's 60 s clock ticks so advance_to can fire them on demand.
|
||||
const clockTicks = [];
|
||||
const realSetInterval = dom.window.setInterval.bind(dom.window);
|
||||
dom.window.setInterval = (callback, ms, ...rest) => {
|
||||
if (ms === 60000) {
|
||||
clockTicks.push(callback);
|
||||
}
|
||||
return realSetInterval(callback, ms, ...rest);
|
||||
};
|
||||
|
||||
await import(pathToFileURL(spec.bundle).href);
|
||||
|
||||
const serviceCalls = [];
|
||||
const hass = {
|
||||
...spec.hass,
|
||||
callService(domain, service, data) {
|
||||
serviceCalls.push({ domain, service, data });
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
const element = document.createElement("nestquest-quest-log-card");
|
||||
element.setConfig(spec.config);
|
||||
element.hass = spec.hass;
|
||||
element.hass = hass;
|
||||
document.body.appendChild(element);
|
||||
await element.updateComplete;
|
||||
|
||||
let tap = null;
|
||||
if (spec.tap_complete !== undefined) {
|
||||
element.shadowRoot
|
||||
.querySelector(`.quest[data-instance-id="${spec.tap_complete}"] button.complete`)
|
||||
.click();
|
||||
await element.updateComplete;
|
||||
const confirm = element.shadowRoot.querySelector("button.do-complete");
|
||||
confirm?.click();
|
||||
await element.updateComplete;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
tap = { dialog_opened: confirm !== null, service_calls: serviceCalls };
|
||||
}
|
||||
|
||||
let returned = null;
|
||||
if (spec.tap_return) {
|
||||
const control = element.shadowRoot.querySelector(".header button.return");
|
||||
returned = {
|
||||
rendered: control !== null,
|
||||
label: control?.textContent?.trim() ?? "",
|
||||
};
|
||||
control?.click();
|
||||
await element.updateComplete;
|
||||
}
|
||||
|
||||
const snapshot = () => ({
|
||||
location: window.location.pathname,
|
||||
headline: element.shadowRoot.querySelector(".title")?.textContent?.trim() ?? "",
|
||||
@@ -56,6 +128,22 @@ const snapshot = () => ({
|
||||
element.shadowRoot.querySelector(".complete-countdown")?.textContent?.trim() ??
|
||||
"",
|
||||
html: element.shadowRoot.innerHTML,
|
||||
// Each quest card's icon tile: the registry glyph it rendered (its
|
||||
// data-icon key and svg fill), its text (an emoji), and whether it shows
|
||||
// the static star (the star's polygon).
|
||||
tiles: [...element.shadowRoot.querySelectorAll(".quest")].map((quest) => {
|
||||
const tile = quest.querySelector(".tile");
|
||||
const glyph = tile?.querySelector("[data-icon]");
|
||||
return {
|
||||
id: Number(quest.dataset.instanceId),
|
||||
sealed: quest.classList.contains("sealed"),
|
||||
icon: glyph?.getAttribute("data-icon") ?? null,
|
||||
fill: glyph?.getAttribute("fill") ?? null,
|
||||
paths: tile?.querySelectorAll("path").length ?? 0,
|
||||
text: tile?.textContent?.trim() ?? "",
|
||||
star: Boolean(tile?.querySelector("polygon")),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
let probe = null;
|
||||
@@ -68,6 +156,18 @@ if (spec.idle_ms !== undefined) {
|
||||
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({ ...snapshot(), probe });
|
||||
const settled = snapshot();
|
||||
let ticks = null;
|
||||
if (spec.advance_to !== undefined) {
|
||||
ticks = [];
|
||||
for (const instant of spec.advance_to) {
|
||||
pinned = new Date(instant).getTime();
|
||||
clockTicks.forEach((callback) => callback());
|
||||
await element.updateComplete;
|
||||
ticks.push(snapshot());
|
||||
}
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({ ...settled, probe, tap, returned, ticks });
|
||||
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
|
||||
process.exit(0);
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nestquest"
|
||||
version = "0.7.1"
|
||||
version = "0.9.8"
|
||||
description = "NestQuest Home Assistant Integration"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
+3
-2
@@ -910,6 +910,7 @@ def coordinator_client_from_test_override():
|
||||
{
|
||||
"today_iso": datetime.date.today().isoformat(),
|
||||
"cycle_day": 0,
|
||||
"timezone": "",
|
||||
"children": [],
|
||||
}
|
||||
)
|
||||
@@ -1016,8 +1017,8 @@ def make_hass() -> tuple:
|
||||
hass.bus = EventBus()
|
||||
hass.http = HttpRegistry()
|
||||
hass.extra_js_urls: list[str] = []
|
||||
# A valid IANA time zone (the day-rollover listener reads it to
|
||||
# compute "today" in HA local time).
|
||||
# A valid IANA time zone for the fake Home Assistant config (the
|
||||
# NestQuest application timezone is the admin-set setting, not this).
|
||||
hass.config.time_zone = "UTC"
|
||||
|
||||
# The minimal entity registry: entities self-register through
|
||||
|
||||
@@ -35,6 +35,7 @@ for malformed bodies.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -223,7 +224,7 @@ async def test_create_persists_definition_with_assignees_and_windows(
|
||||
}
|
||||
assert payload["title"] == "Tidy the den"
|
||||
assert payload["description"] == "Put everything back where it belongs."
|
||||
assert payload["icon"] == "toy-box"
|
||||
assert payload["icon"] == "lucide:toy-box"
|
||||
assert payload["is_active"] is True
|
||||
assert payload["skip_on_away"] is True
|
||||
assert payload["rule"] == _weekly_rule_dict()
|
||||
@@ -245,7 +246,7 @@ async def test_create_persists_definition_with_assignees_and_windows(
|
||||
assert stored.definition.description == (
|
||||
"Put everything back where it belongs."
|
||||
)
|
||||
assert stored.definition.icon == "toy-box"
|
||||
assert stored.definition.icon == "lucide:toy-box"
|
||||
assert stored.definition.is_active is True
|
||||
assert stored.rule.to_dict() == _weekly_rule_dict()
|
||||
assert sorted(child.id for child in stored.assignees) == [ada, bo]
|
||||
@@ -396,7 +397,7 @@ async def test_edit_persists_supplied_fields_and_leaves_omitted_untouched(
|
||||
assert stored.definition.description == (
|
||||
"Put everything back where it belongs."
|
||||
)
|
||||
assert stored.definition.icon == "toy-box"
|
||||
assert stored.definition.icon == "lucide:toy-box"
|
||||
assert stored.rule.to_dict() == _weekly_rule_dict()
|
||||
# assignee_child_ids was omitted: the assignees are untouched.
|
||||
assert sorted(child.id for child in stored.assignees) == [ada, bo]
|
||||
@@ -468,6 +469,89 @@ async def test_edit_can_clear_description_and_icon_explicitly(
|
||||
assert stored.rule.to_dict() == _weekly_rule_dict()
|
||||
|
||||
|
||||
async def test_legacy_bare_icon_reads_back_namespaced(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A row stored with a pre-namespacing bare icon reads as ``lucide:``.
|
||||
|
||||
The row is rewritten through the DAO (bypassing the write-side
|
||||
validation, as a row from before namespacing would be) and then read
|
||||
back through BOTH consumers: the admin definition response and the
|
||||
kids' panel snapshot's per-instance ``icon``.
|
||||
"""
|
||||
client = admin_questdefs.client
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
today = datetime.date.today().isoformat()
|
||||
created = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Walk the dog",
|
||||
"rule": {"rule_type": "daily", "start_date": today},
|
||||
"assignee_child_ids": [ada],
|
||||
"windows": ["evening"],
|
||||
"icon": "fa:dog",
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
definition_id = created.json()["id"]
|
||||
|
||||
await _dao_rules.QuestDefinitionsDao(
|
||||
admin_questdefs.database
|
||||
).edit_definition(definition_id, icon="dog")
|
||||
stored = (await _definitions_by_id(admin_questdefs.database))[
|
||||
definition_id
|
||||
]
|
||||
assert stored.definition.icon == "dog"
|
||||
|
||||
listed = await client.get(
|
||||
"/api/v1/admin/quest-definitions", headers=_admin_headers()
|
||||
)
|
||||
assert listed.status_code == 200
|
||||
[definition] = [
|
||||
d for d in listed.json()["definitions"] if d["id"] == definition_id
|
||||
]
|
||||
assert definition["icon"] == "lucide:dog"
|
||||
|
||||
panel = await client.get(
|
||||
"/api/v1/panel/snapshot",
|
||||
headers={"Authorization": "Bearer admin-test-panel-token"},
|
||||
)
|
||||
assert panel.status_code == 200
|
||||
[child] = panel.json()["children"]
|
||||
icons = {
|
||||
instance["icon"]
|
||||
for instance in child["instances"]
|
||||
if instance["definition_id"] == definition_id
|
||||
}
|
||||
assert icons == {"lucide:dog"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"icon", ["foo:dog", "lucide:", "emoji:<b>", "javascript:alert(1)"]
|
||||
)
|
||||
async def test_create_invalid_icon_is_422_naming_icon(
|
||||
admin_questdefs: SimpleNamespace, icon: str
|
||||
) -> None:
|
||||
"""An invalid icon key is a 422 whose detail names the icon field."""
|
||||
client = admin_questdefs.client
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
response = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Walk the dog",
|
||||
"rule": _weekly_rule_body(),
|
||||
"assignee_child_ids": [ada],
|
||||
"windows": ["morning"],
|
||||
"icon": icon,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert "icon" in str(response.json()["detail"])
|
||||
assert await _definitions_by_id(admin_questdefs.database) == {}
|
||||
|
||||
|
||||
async def test_edit_unknown_definition_is_404(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
@@ -733,3 +817,141 @@ async def test_set_active_malformed_body_is_422(
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# --- delete: hybrid (delete if no history, retire if there is) --------------
|
||||
|
||||
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
||||
|
||||
|
||||
def test_delete_route_lives_on_the_admin_router() -> None:
|
||||
"""The DELETE route is on the admin router, which carries the ONE
|
||||
require_admin dependency — no second auth check is added."""
|
||||
from api.auth import require_admin
|
||||
from api.routes_admin import router as admin_router
|
||||
|
||||
assert require_admin in [d.dependency for d in admin_router.dependencies]
|
||||
matches = [
|
||||
route
|
||||
for route in admin_router.routes
|
||||
if route.path == "/api/v1/admin/quest-definitions/{definition_id}"
|
||||
and "DELETE" in route.methods
|
||||
]
|
||||
assert len(matches) == 1
|
||||
|
||||
|
||||
async def test_delete_refuses_non_admin_and_absent(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A non-admin JWT is 403 and an absent credential 401: nothing is
|
||||
deleted or retired."""
|
||||
client = admin_questdefs.client
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
definition_id = (await _create_definition(client, [ada])).json()["id"]
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
|
||||
forbidden = await client.delete(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
unauthenticated = await client.delete(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}"
|
||||
)
|
||||
assert unauthenticated.status_code == 401
|
||||
|
||||
record = await _dao_rules.QuestDefinitionsDao(
|
||||
admin_questdefs.database
|
||||
).get(definition_id)
|
||||
assert record is not None
|
||||
assert record.is_active is True
|
||||
|
||||
|
||||
async def test_delete_without_history_hard_deletes(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""No completion history: 200 ``deleted`` and the row and its
|
||||
unreferenced rule are gone."""
|
||||
client = admin_questdefs.client
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
definition_id = (await _create_definition(client, [ada])).json()["id"]
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
|
||||
rule_id = (await dao.get(definition_id)).schedule_rule_id
|
||||
|
||||
response = await client.delete(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"definition_id": definition_id,
|
||||
"deleted": True,
|
||||
"retired": False,
|
||||
"definition": None,
|
||||
}
|
||||
assert await dao.get(definition_id) is None
|
||||
assert await _dao_rules.ScheduleRulesDao(
|
||||
admin_questdefs.database
|
||||
).get(rule_id) is None
|
||||
|
||||
|
||||
async def test_delete_with_history_retires(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Completion history present: 200 ``retired`` with the now-inactive
|
||||
definition; the row, instance and history all remain."""
|
||||
client = admin_questdefs.client
|
||||
database = admin_questdefs.database
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
definition_id = (await _create_definition(client, [ada])).json()["id"]
|
||||
instance = await _dao_instances.QuestInstancesDao(database).upsert(
|
||||
definition_id,
|
||||
ada,
|
||||
datetime.date.today().isoformat(),
|
||||
"2026-09-14T12:00:00+00:00",
|
||||
window="morning",
|
||||
)
|
||||
events = _dao_instances.CompletionEventsDao(database)
|
||||
event = await events.append(
|
||||
instance.id,
|
||||
ada,
|
||||
"completed",
|
||||
"user",
|
||||
"2026-09-14T12:00:00+00:00",
|
||||
True,
|
||||
actor_user_id="admin-1",
|
||||
)
|
||||
|
||||
response = await client.delete(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["definition_id"] == definition_id
|
||||
assert payload["deleted"] is False
|
||||
assert payload["retired"] is True
|
||||
assert payload["definition"]["id"] == definition_id
|
||||
assert payload["definition"]["is_active"] is False
|
||||
assert payload["definition"]["title"] == "Tidy the den"
|
||||
record = await _dao_rules.QuestDefinitionsDao(database).get(definition_id)
|
||||
assert record is not None
|
||||
assert record.is_active is False
|
||||
assert await _dao_instances.QuestInstancesDao(database).get_by_id(
|
||||
instance.id
|
||||
) == instance
|
||||
assert await events.list_by_instance(instance.id) == [event]
|
||||
|
||||
|
||||
async def test_delete_unknown_definition_is_404(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A DELETE naming a definition that does not exist is 404."""
|
||||
response = await admin_questdefs.client.delete(
|
||||
"/api/v1/admin/quest-definitions/999",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Quest definition not found"
|
||||
|
||||
@@ -135,7 +135,7 @@ async def test_admin_lists_active_and_inactive_definitions_with_full_shape(
|
||||
"id": active_id,
|
||||
"title": "Tidy the den",
|
||||
"description": "Put everything back where it belongs.",
|
||||
"icon": "toy-box",
|
||||
"icon": "lucide:toy-box",
|
||||
"is_active": True,
|
||||
"skip_on_away": True,
|
||||
"rule": {
|
||||
|
||||
@@ -13,7 +13,8 @@ proven against the DATABASE the routes wrote, not a parallel copy.
|
||||
Proven per the done-condition, everything read back through the API:
|
||||
|
||||
- ``GET /api/v1/admin/settings`` returns the current effective settings
|
||||
— the ten documented fields at their defaults on a fresh database.
|
||||
— the eleven documented fields at their defaults on a fresh database,
|
||||
with no ``timezone_configured`` marker.
|
||||
- ``PATCH /api/v1/admin/settings`` PERSISTS the supplied fields and the
|
||||
NEXT read reflects them; a later partial update merges onto the
|
||||
current effective settings (omitted fields keep their stored value),
|
||||
@@ -40,6 +41,7 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from api import host_zone
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
@@ -59,9 +61,14 @@ _dao_meta = importlib.import_module("nestquest_core.dao_meta")
|
||||
#: independent of the core constant.
|
||||
SETTINGS_META_KEY = "settings"
|
||||
|
||||
#: The API host's own zone, pinned for every test (:func:`settings_client`):
|
||||
#: the ``effective_timezone`` while no zone is stored.
|
||||
API_HOST_ZONE = "Australia/Perth"
|
||||
|
||||
#: The all-defaults effective settings a fresh database reports — the
|
||||
#: documented ten fields, asserted literally so the payload contract is
|
||||
#: pinned here, independent of the core constants.
|
||||
#: documented eleven fields plus the read-only ``effective_timezone``,
|
||||
#: asserted literally so the payload contract is pinned here, independent
|
||||
#: of the core constants.
|
||||
DEFAULT_SETTINGS = {
|
||||
"horizon_days": 14,
|
||||
"day_rollover_time": "00:00",
|
||||
@@ -73,22 +80,35 @@ DEFAULT_SETTINGS = {
|
||||
"afternoon_reminder_enabled": True,
|
||||
"end_of_day_report_enabled": True,
|
||||
"celebration_enabled": True,
|
||||
"timezone": "",
|
||||
"effective_timezone": API_HOST_ZONE,
|
||||
}
|
||||
|
||||
|
||||
def _with_zone(zone: str) -> dict:
|
||||
"""The default settings with ``zone`` stored, as the API reports them."""
|
||||
return {
|
||||
**DEFAULT_SETTINGS,
|
||||
"timezone": zone,
|
||||
"effective_timezone": zone or API_HOST_ZONE,
|
||||
}
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def settings_client(temp_db_path: str) -> SimpleNamespace:
|
||||
async def settings_client(temp_db_path: str, monkeypatch) -> SimpleNamespace:
|
||||
"""An admin-plane app client plus its live database.
|
||||
|
||||
The same runner the children tests use (stubbed JWKS, no network);
|
||||
the fixture also exposes the app's ONE database connection so tests
|
||||
can read (or corrupt) the stored settings document itself.
|
||||
can read (or corrupt) the stored settings document itself. The API
|
||||
host's zone is pinned to :data:`API_HOST_ZONE`.
|
||||
"""
|
||||
monkeypatch.setattr(host_zone, "host_timezone", lambda: API_HOST_ZONE)
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield SimpleNamespace(
|
||||
@@ -103,7 +123,7 @@ async def settings_client(temp_db_path: str) -> SimpleNamespace:
|
||||
async def test_get_settings_returns_defaults_on_a_fresh_database(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""GET returns the ten documented fields at their defaults."""
|
||||
"""GET returns the eleven documented fields at their defaults."""
|
||||
response = await settings_client.client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
@@ -139,13 +159,15 @@ async def test_update_persists_and_the_next_read_reflects_it(
|
||||
assert reread.status_code == 200
|
||||
assert reread.json() == expected
|
||||
|
||||
# ...and the DATABASE holds the merged document: the complete ten
|
||||
# ...and the DATABASE holds the merged document: the complete twelve
|
||||
# fields, keyed by field name, under the well-known meta_state key.
|
||||
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
|
||||
SETTINGS_META_KEY
|
||||
)
|
||||
assert raw is not None
|
||||
assert json.loads(raw) == expected
|
||||
stored = dict(expected)
|
||||
del stored["effective_timezone"] # derived on read, never stored
|
||||
assert json.loads(raw) == stored
|
||||
|
||||
|
||||
async def test_partial_update_merges_onto_the_current_settings(
|
||||
@@ -221,6 +243,105 @@ async def test_null_resets_a_field_to_its_default(
|
||||
assert reread.json()["horizon_days"] == 14
|
||||
|
||||
|
||||
async def test_default_settings_carry_no_timezone_configured_marker(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The removed auto-set marker is absent from the default response."""
|
||||
response = await settings_client.client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "timezone_configured" not in response.json()
|
||||
assert response.json()["timezone"] == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("zone", ["America/New_York", ""])
|
||||
async def test_manual_timezone_patch_persists_and_is_returned(
|
||||
settings_client: SimpleNamespace, zone: str
|
||||
) -> None:
|
||||
"""An admin's timezone PATCH is stored exactly as given and read back."""
|
||||
client = settings_client.client
|
||||
seeded = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"timezone": "Europe/Dublin"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert seeded.status_code == 200
|
||||
|
||||
patched = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"timezone": zone},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
expected = _with_zone(zone)
|
||||
assert patched.json() == expected
|
||||
|
||||
reread = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert reread.json() == expected
|
||||
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
|
||||
SETTINGS_META_KEY
|
||||
)
|
||||
assert raw is not None
|
||||
stored = json.loads(raw)
|
||||
assert stored["timezone"] == zone
|
||||
assert "timezone_configured" not in stored
|
||||
|
||||
|
||||
@pytest.mark.parametrize("zone", ["America/Los_Angeles", ""])
|
||||
async def test_timezone_alone_sticks_on_a_fresh_database(
|
||||
settings_client: SimpleNamespace, zone: str
|
||||
) -> None:
|
||||
"""A lone ``timezone`` PATCH needs no other field to persist."""
|
||||
client = settings_client.client
|
||||
patched = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"timezone": zone},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
reread = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert reread.json() == _with_zone(zone)
|
||||
|
||||
|
||||
async def test_timezone_configured_is_an_unknown_field(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The removed marker is rejected like any unknown field; nothing is written."""
|
||||
response = await settings_client.client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"timezone_configured": True},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert "timezone_configured" in str(response.json()["detail"])
|
||||
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
|
||||
SETTINGS_META_KEY
|
||||
)
|
||||
assert raw is None
|
||||
|
||||
|
||||
async def test_stored_legacy_timezone_configured_key_is_ignored(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A document written before the marker was removed still loads."""
|
||||
await _dao_meta.MetaStateDao(settings_client.database).set(
|
||||
SETTINGS_META_KEY,
|
||||
json.dumps(
|
||||
{"timezone": "America/Chicago", "timezone_configured": True}
|
||||
),
|
||||
)
|
||||
response = await settings_client.client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == _with_zone("America/Chicago")
|
||||
|
||||
|
||||
# --- validation: rejected values never change the stored settings -------
|
||||
|
||||
|
||||
@@ -237,6 +358,9 @@ async def test_null_resets_a_field_to_its_default(
|
||||
({"morning_summary_enabled": "yes"}, "morning_summary_enabled"),
|
||||
({"celebration_enabled": 1}, "celebration_enabled"),
|
||||
({"notify_target": 5}, "notify_target"),
|
||||
({"timezone": "Mars/Olympus_Mons"}, "timezone"),
|
||||
({"timezone": "America/New York"}, "timezone"),
|
||||
({"timezone": 5}, "timezone"),
|
||||
],
|
||||
)
|
||||
async def test_invalid_value_is_rejected_and_stored_settings_unchanged(
|
||||
@@ -391,6 +515,13 @@ async def test_settings_routes_refuse_non_admin_and_absent_credentials(
|
||||
)
|
||||
assert absent_patch.status_code == 401
|
||||
|
||||
forbidden_zone = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"timezone": "America/New_York"},
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert forbidden_zone.status_code == 403
|
||||
|
||||
# No refused request changed anything: the settings are untouched.
|
||||
after = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
|
||||
@@ -37,7 +37,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
from api import routes_admin, routes_panel
|
||||
from api import host_zone, routes_admin, routes_panel
|
||||
from api.nestquest_core import core_snapshot
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
@@ -101,6 +101,8 @@ async def snapshot_household(
|
||||
|
||||
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
|
||||
monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now)
|
||||
# The API host runs UTC, the zone of the pinned clock.
|
||||
monkeypatch.setattr(host_zone, "host_timezone", lambda: "UTC")
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield SimpleNamespace(
|
||||
@@ -165,7 +167,10 @@ async def test_admin_jwt_returns_documented_snapshot(
|
||||
|
||||
Every field is asserted as an explicit literal, with ONE clock
|
||||
read for the request. With no missed view in the real snapshot,
|
||||
the admin body equals the panel body for the same household.
|
||||
the admin body equals the panel body for the same household — the
|
||||
panel body additionally carries the effective ``timezone`` its cards
|
||||
format the visible date and clock in (none stored here: the UTC
|
||||
host's own zone).
|
||||
"""
|
||||
client = snapshot_household.client
|
||||
seed = snapshot_household.seed
|
||||
@@ -241,7 +246,7 @@ async def test_admin_jwt_returns_documented_snapshot(
|
||||
"/api/v1/panel/snapshot", headers=_panel_headers()
|
||||
)
|
||||
assert panel.status_code == 200
|
||||
assert panel.json() == body
|
||||
assert panel.json() == {**body, "timezone": "UTC"}
|
||||
|
||||
|
||||
async def test_admin_keeps_missed_instance_panel_omits_it(
|
||||
|
||||
@@ -196,6 +196,29 @@ async def test_complete_instance_posts_body_and_headers() -> None:
|
||||
assert call["headers"]["Authorization"] == f"Bearer {TOKEN}"
|
||||
|
||||
|
||||
async def test_requests_carry_only_the_bearer_header() -> None:
|
||||
"""No timezone is reported to the API (task 7f685179): the Bearer
|
||||
credential is the only header on every request."""
|
||||
transport = StubTransport(
|
||||
StubResponse(200, json.dumps(SNAPSHOT_PAYLOAD)),
|
||||
StubResponse(200, '{"status": "done"}'),
|
||||
)
|
||||
client = make_client(transport)
|
||||
|
||||
await client.get_snapshot()
|
||||
await client.complete_instance(42, 3)
|
||||
|
||||
for call in transport.calls:
|
||||
assert call["headers"] == {"Authorization": f"Bearer {TOKEN}"}
|
||||
|
||||
|
||||
def test_client_takes_no_household_timezone() -> None:
|
||||
with pytest.raises(TypeError):
|
||||
NestQuestApiClient(
|
||||
BASE_URL, TOKEN, StubTransport(), household_timezone="UTC"
|
||||
)
|
||||
|
||||
|
||||
async def test_base_url_trailing_slash_is_normalized() -> None:
|
||||
"""A base URL with a trailing slash must not double the path slash."""
|
||||
transport = StubTransport(StubResponse(200, "{}"))
|
||||
@@ -392,11 +415,13 @@ def test_client_from_entry_reads_config_and_shared_session(monkeypatch) -> None:
|
||||
}
|
||||
)
|
||||
|
||||
# A bare object: building the client must not read hass.config.
|
||||
client = api_client.client_from_entry(object(), entry)
|
||||
|
||||
assert client._session is shared_session
|
||||
assert client._base_url == "http://panel.lan:8443"
|
||||
assert client._panel_token == "entry-token"
|
||||
assert client._headers() == {"Authorization": "Bearer entry-token"}
|
||||
|
||||
|
||||
def test_client_from_entry_unconfigured_token_raises_at_construction(
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""The API host's zone and the effective application timezone (task 7e173881).
|
||||
|
||||
With no zone stored the API reads its clock in the host's local time, so
|
||||
:func:`api.host_zone.effective_timezone` names that host zone — resolved
|
||||
the way the C library resolves local time — for the panel and admin app
|
||||
to format in. Every host configuration here is a temporary file tree.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from api import host_zone
|
||||
|
||||
|
||||
def _host(tmp_path: Path, *, link: str | None = None, name: str | None = None):
|
||||
"""A fake ``/etc``: ``localtime`` linked into a zoneinfo tree at
|
||||
``link``, and/or an ``/etc/timezone`` file holding ``name``."""
|
||||
localtime = tmp_path / "etc" / "localtime"
|
||||
timezone_file = tmp_path / "etc" / "timezone"
|
||||
localtime.parent.mkdir(parents=True)
|
||||
if link is not None:
|
||||
target = tmp_path / "usr" / "share" / "zoneinfo" / link
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_bytes(b"TZif")
|
||||
localtime.symlink_to(target)
|
||||
if name is not None:
|
||||
timezone_file.write_text(f"{name}\n", encoding="utf-8")
|
||||
return {"timezone_file": timezone_file, "localtime": localtime}
|
||||
|
||||
|
||||
def test_tz_environment_variable_wins(tmp_path: Path) -> None:
|
||||
paths = _host(tmp_path, link="Europe/Paris")
|
||||
assert host_zone.host_timezone({"TZ": "Asia/Kolkata"}, **paths) == "Asia/Kolkata"
|
||||
assert host_zone.host_timezone({"TZ": ":Asia/Kolkata"}, **paths) == "Asia/Kolkata"
|
||||
assert host_zone.host_timezone({"TZ": ""}, **paths) == "UTC"
|
||||
|
||||
|
||||
def test_localtime_link_names_the_zone(tmp_path: Path) -> None:
|
||||
paths = _host(tmp_path, link="America/Argentina/Buenos_Aires")
|
||||
assert host_zone.host_timezone({}, **paths) == "America/Argentina/Buenos_Aires"
|
||||
|
||||
|
||||
def test_etc_timezone_names_a_copied_localtime(tmp_path: Path) -> None:
|
||||
paths = _host(tmp_path, name="Pacific/Auckland")
|
||||
paths["localtime"].write_bytes(b"TZif")
|
||||
assert host_zone.host_timezone({}, **paths) == "Pacific/Auckland"
|
||||
|
||||
|
||||
def test_unconfigured_host_runs_utc(tmp_path: Path) -> None:
|
||||
assert host_zone.host_timezone({}, **_host(tmp_path)) == "UTC"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tz", ["EST+5", "Not/AZone", ":/etc/localtime"])
|
||||
def test_an_unnamed_host_zone_is_empty(tmp_path: Path, tz: str) -> None:
|
||||
assert host_zone.host_timezone({"TZ": tz}, **_host(tmp_path)) == ""
|
||||
|
||||
|
||||
def test_unnamed_copied_localtime_is_empty(tmp_path: Path) -> None:
|
||||
paths = _host(tmp_path)
|
||||
paths["localtime"].write_bytes(b"TZif")
|
||||
assert host_zone.host_timezone({}, **paths) == ""
|
||||
|
||||
|
||||
def test_effective_timezone_is_the_stored_zone_else_the_host_zone(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(host_zone, "host_timezone", lambda: "Europe/Dublin")
|
||||
assert host_zone.effective_timezone("America/Chicago") == "America/Chicago"
|
||||
assert host_zone.effective_timezone("") == "Europe/Dublin"
|
||||
@@ -0,0 +1,911 @@
|
||||
"""Household-local clock tests for the API service (task fa7475ba).
|
||||
|
||||
The deployed API container runs UTC while the household is US Eastern,
|
||||
so reading the HOST clock marked every morning quest overdue from the
|
||||
small hours. The API now re-expresses its ONE host clock read in the
|
||||
stored ``timezone`` setting (``NestQuestSettings.household_now``).
|
||||
|
||||
Every test pins the host clock to a UTC instant (the deployed
|
||||
container's zone) and sets the household zone through the real admin
|
||||
``PATCH /api/v1/admin/settings`` route, then reads BOTH snapshot shapes:
|
||||
|
||||
- one second before the 10:00 due time: NOT overdue (previously
|
||||
flagged, because 13:59:59 UTC > 10:00);
|
||||
- exactly at 10:00:00: NOT overdue (the builder's strict
|
||||
``now.time() > due`` convention);
|
||||
- one second after: overdue;
|
||||
- 23:30 household time, already the next day in UTC: ``today_iso`` is
|
||||
still the household's day.
|
||||
|
||||
An unset zone keeps the historical host-local behaviour, and the
|
||||
missed-sweep scheduler waits for the rollover in household time.
|
||||
Nothing sets the zone automatically: a timezone header on a panel
|
||||
request never changes the stored settings (task 7f685179).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
from api import host_zone, routes_admin, routes_panel
|
||||
from api import scheduler as scheduler_module
|
||||
from api.database import _executor
|
||||
from api.scheduler import MissedSweepScheduler, _delay_seconds
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
AdminRunner,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
from tests.test_api_panel import _seed_household
|
||||
|
||||
_settings = importlib.import_module("nestquest_core.settings")
|
||||
_settings_store = importlib.import_module("nestquest_core.settings_store")
|
||||
_core_db = importlib.import_module("nestquest_core.db")
|
||||
_core_migrations = importlib.import_module("nestquest_core.migrations")
|
||||
_dao_meta = importlib.import_module("nestquest_core.dao_meta")
|
||||
|
||||
#: The panel service token the admin harness app is configured with.
|
||||
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
||||
|
||||
UTC = ZoneInfo("UTC")
|
||||
HOUSEHOLD_ZONE = "America/New_York"
|
||||
|
||||
#: A fixed household day on Eastern Daylight Time (UTC-4).
|
||||
TODAY = datetime.date(2026, 9, 26)
|
||||
|
||||
|
||||
def _utc(hour: int, minute: int, second: int = 0, *, day: int = 26):
|
||||
"""An aware UTC instant on September ``day`` 2026."""
|
||||
return datetime.datetime(2026, 9, day, hour, minute, second, tzinfo=UTC)
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
def _panel_headers() -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def household(temp_db_path: str, monkeypatch) -> SimpleNamespace:
|
||||
"""The seeded household on the admin-plane app, host clock pinnable.
|
||||
|
||||
Ada's open "Pack bag" quest is due 10:00 on :data:`TODAY`. Both
|
||||
routes' host clock read returns ``clock["now"]`` (a UTC instant).
|
||||
"""
|
||||
seed = await _seed_household(temp_db_path, TODAY, _utc(16, 0))
|
||||
clock = {"now": _utc(12, 0)}
|
||||
monkeypatch.setattr(routes_admin, "_local_now", lambda: clock["now"])
|
||||
monkeypatch.setattr(routes_panel, "_local_now", lambda: clock["now"])
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
seed=seed,
|
||||
clock=clock,
|
||||
database=runner.app.state.db.database,
|
||||
app=runner.app,
|
||||
)
|
||||
|
||||
|
||||
async def _set_zone(household: SimpleNamespace, zone: str) -> None:
|
||||
response = await household.client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"timezone": zone},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["timezone"] == zone
|
||||
|
||||
|
||||
async def _pack_bag(household: SimpleNamespace, plane: str) -> tuple[str, dict]:
|
||||
"""Return ``(today_iso, Ada's Pack bag instance)`` from one snapshot."""
|
||||
headers = _admin_headers() if plane == "admin" else _panel_headers()
|
||||
response = await household.client.get(
|
||||
f"/api/v1/{plane}/snapshot", headers=headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
ada = next(
|
||||
child
|
||||
for child in body["children"]
|
||||
if child["child_id"] == household.seed.ada.id
|
||||
)
|
||||
(instance,) = ada["instances"]
|
||||
assert instance["title"] == "Pack bag"
|
||||
assert instance["due_time"] == "10:00"
|
||||
return body["today_iso"], instance
|
||||
|
||||
|
||||
@pytest.mark.parametrize("plane", ["panel", "admin"])
|
||||
@pytest.mark.parametrize(
|
||||
("host_now", "overdue"),
|
||||
[
|
||||
# 09:59:59 EDT — the pre-fix code compared 13:59:59 (UTC) > 10:00.
|
||||
(_utc(13, 59, 59), False),
|
||||
# 10:00:00 EDT exactly — strict ``>``: not yet overdue.
|
||||
(_utc(14, 0, 0), False),
|
||||
# 10:00:01 EDT — overdue.
|
||||
(_utc(14, 0, 1), True),
|
||||
],
|
||||
ids=["before-due", "at-due", "after-due"],
|
||||
)
|
||||
async def test_overdue_boundary_uses_household_zone(
|
||||
household: SimpleNamespace,
|
||||
plane: str,
|
||||
host_now: datetime.datetime,
|
||||
overdue: bool,
|
||||
) -> None:
|
||||
await _set_zone(household, HOUSEHOLD_ZONE)
|
||||
household.clock["now"] = host_now
|
||||
today_iso, instance = await _pack_bag(household, plane)
|
||||
assert today_iso == TODAY.isoformat()
|
||||
assert instance["state"] == "open"
|
||||
assert instance["overdue"] is overdue
|
||||
|
||||
|
||||
@pytest.mark.parametrize("plane", ["panel", "admin"])
|
||||
async def test_household_day_survives_utc_midnight(
|
||||
household: SimpleNamespace, plane: str
|
||||
) -> None:
|
||||
"""23:30 EDT is already tomorrow in UTC; the household day is today."""
|
||||
await _set_zone(household, HOUSEHOLD_ZONE)
|
||||
household.clock["now"] = _utc(3, 30, day=27)
|
||||
today_iso, instance = await _pack_bag(household, plane)
|
||||
assert today_iso == TODAY.isoformat()
|
||||
assert instance["overdue"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("plane", ["panel", "admin"])
|
||||
async def test_unset_zone_keeps_host_local_time(
|
||||
household: SimpleNamespace, plane: str
|
||||
) -> None:
|
||||
"""Backward compatible: no stored zone reads the host clock as-is."""
|
||||
household.clock["now"] = _utc(13, 59, 59)
|
||||
today_iso, instance = await _pack_bag(household, plane)
|
||||
assert today_iso == TODAY.isoformat()
|
||||
assert instance["overdue"] is True
|
||||
|
||||
|
||||
async def test_panel_completion_on_time_uses_household_zone(
|
||||
household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A 09:30 EDT tap on a 10:00 quest is on time (13:30 UTC is not)."""
|
||||
await _set_zone(household, HOUSEHOLD_ZONE)
|
||||
household.clock["now"] = _utc(13, 30)
|
||||
seed = household.seed
|
||||
response = await household.client.post(
|
||||
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
|
||||
headers=_panel_headers(),
|
||||
json={"actor_child_id": seed.ada.id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
_today_iso, instance = await _pack_bag(household, "admin")
|
||||
assert instance["state"] == "completed"
|
||||
assert instance["on_time"] is True
|
||||
|
||||
|
||||
async def test_invalid_zone_is_rejected(household: SimpleNamespace) -> None:
|
||||
response = await household.client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"timezone": "Mars/Olympus_Mons"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert "timezone" in str(response.json()["detail"])
|
||||
reread = await household.client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert reread.json()["timezone"] == ""
|
||||
|
||||
|
||||
# --- the settings object ---------------------------------------------------
|
||||
|
||||
|
||||
def test_settings_timezone_defaults_to_host_local() -> None:
|
||||
settings = _settings.NestQuestSettings()
|
||||
assert settings.timezone == ""
|
||||
instant = _utc(13, 0)
|
||||
assert settings.household_now(instant) is instant
|
||||
|
||||
|
||||
def test_settings_household_now_converts_the_same_instant() -> None:
|
||||
settings = _settings.NestQuestSettings(timezone=HOUSEHOLD_ZONE)
|
||||
local = settings.household_now(_utc(13, 0))
|
||||
assert local == _utc(13, 0)
|
||||
assert (local.date(), local.time()) == (TODAY, datetime.time(9, 0))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["Mars/Olympus_Mons", "../etc/passwd", 5, None])
|
||||
def test_settings_rejects_invalid_timezone(value: object) -> None:
|
||||
with pytest.raises(ValueError, match="timezone"):
|
||||
_settings.NestQuestSettings(timezone=value)
|
||||
|
||||
|
||||
def test_from_options_reads_and_resiliently_defaults_timezone() -> None:
|
||||
assert (
|
||||
_settings.NestQuestSettings.from_options(
|
||||
{"timezone": HOUSEHOLD_ZONE}
|
||||
).timezone
|
||||
== HOUSEHOLD_ZONE
|
||||
)
|
||||
assert _settings.NestQuestSettings.from_options({}).timezone == ""
|
||||
assert (
|
||||
_settings.NestQuestSettings.from_options_resilient(
|
||||
{"timezone": "Nowhere/Land", "horizon_days": 9}
|
||||
).timezone
|
||||
== ""
|
||||
)
|
||||
|
||||
|
||||
def test_settings_have_no_timezone_configured_marker() -> None:
|
||||
assert not hasattr(_settings.NestQuestSettings(), "timezone_configured")
|
||||
with pytest.raises(TypeError):
|
||||
_settings.NestQuestSettings(timezone_configured=True)
|
||||
# A stored document still carrying the removed key loads harmlessly.
|
||||
assert _settings.NestQuestSettings.from_options_resilient(
|
||||
{"timezone": HOUSEHOLD_ZONE, "timezone_configured": True}
|
||||
) == _settings.NestQuestSettings(timezone=HOUSEHOLD_ZONE)
|
||||
|
||||
|
||||
# --- the missed-sweep scheduler ----------------------------------------------
|
||||
|
||||
|
||||
async def test_scheduler_waits_for_rollover_in_household_time(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""09:00 EDT (13:00 UTC) to a 10:05 rollover is 65 minutes away."""
|
||||
database = _core_db.NestQuestDatabase(_executor)
|
||||
await database.open(temp_db_path)
|
||||
try:
|
||||
await _core_migrations.apply_migrations(database)
|
||||
await _settings_store.update_settings(
|
||||
database,
|
||||
{"day_rollover_time": "10:05", "timezone": HOUSEHOLD_ZONE},
|
||||
)
|
||||
delays: list[float] = []
|
||||
first_sleep = asyncio.Event()
|
||||
|
||||
async def sleep_stub(seconds: float) -> None:
|
||||
delays.append(seconds)
|
||||
first_sleep.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
scheduler = MissedSweepScheduler(
|
||||
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
|
||||
)
|
||||
scheduler.start()
|
||||
try:
|
||||
await asyncio.wait_for(first_sleep.wait(), timeout=5)
|
||||
finally:
|
||||
await scheduler.stop()
|
||||
assert delays == [pytest.approx(65 * 60)]
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
# --- the rollover delay across DST (America/New_York) ------------------------
|
||||
|
||||
NEW_YORK = ZoneInfo("America/New_York")
|
||||
|
||||
|
||||
def _ny(*args: int, fold: int = 0) -> datetime.datetime:
|
||||
return datetime.datetime(*args, tzinfo=NEW_YORK, fold=fold)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("now", "target", "expected"),
|
||||
[
|
||||
# Ordinary EDT day: 09:00 -> 10:05 is 65 minutes.
|
||||
(_ny(2026, 6, 15, 9, 0), datetime.time(10, 5), 65 * 60),
|
||||
# An equal time schedules for tomorrow (strictly after now).
|
||||
(_ny(2026, 6, 15, 10, 5), datetime.time(10, 5), 86400),
|
||||
# Spring-forward: 01:00 EST -> 03:00 EDT is one real hour.
|
||||
(_ny(2026, 3, 8, 1, 0), datetime.time(3, 0), 3600),
|
||||
# Fall-back: 00:30 EDT -> 02:00 EST is two and a half real hours.
|
||||
(_ny(2026, 11, 1, 0, 30), datetime.time(2, 0), 9000),
|
||||
# Nonexistent 02:30 on spring-forward day: the first valid instant
|
||||
# at/after it is 03:00 EDT, one real hour after 01:00 EST.
|
||||
(_ny(2026, 3, 8, 1, 0), datetime.time(2, 30), 3600),
|
||||
# Ambiguous 01:30 on fall-back day resolves to the FIRST (EDT)
|
||||
# occurrence: from 00:30 EDT that is one real hour.
|
||||
(_ny(2026, 11, 1, 0, 30), datetime.time(1, 30), 3600),
|
||||
# Already past the first 01:30 (now is 01:15 EST, the repeated
|
||||
# hour): not the second occurrence, but tomorrow's 01:30 EST.
|
||||
(_ny(2026, 11, 1, 1, 15, fold=1), datetime.time(1, 30), 87300),
|
||||
],
|
||||
)
|
||||
def test_delay_seconds_is_true_elapsed_time_across_dst(
|
||||
now: datetime.datetime, target: datetime.time, expected: float
|
||||
) -> None:
|
||||
assert _delay_seconds(target, now) == expected
|
||||
|
||||
|
||||
# --- nothing sets the zone automatically (task 7f685179) --------------------
|
||||
#
|
||||
# The zone is only ever set by an admin's explicit settings update. A
|
||||
# timezone header on a panel request — the one Home Assistant used to
|
||||
# report, or any look-alike — is ignored, and no read writes settings.
|
||||
|
||||
#: Timezone-looking request headers a panel request might carry.
|
||||
_TIMEZONE_HEADERS = [
|
||||
{"X-NestQuest-Timezone": HOUSEHOLD_ZONE},
|
||||
{"X-NestQuest-Time-Zone": HOUSEHOLD_ZONE},
|
||||
{"X-Timezone": HOUSEHOLD_ZONE},
|
||||
{"Time-Zone": HOUSEHOLD_ZONE},
|
||||
]
|
||||
|
||||
|
||||
async def _stored_document(household: SimpleNamespace) -> str | None:
|
||||
"""The raw stored settings document (``None`` when never written)."""
|
||||
return await _dao_meta.MetaStateDao(household.database).get(
|
||||
_settings_store.SETTINGS_META_KEY
|
||||
)
|
||||
|
||||
|
||||
async def _admin_settings(household: SimpleNamespace) -> dict:
|
||||
response = await household.client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extra", _TIMEZONE_HEADERS)
|
||||
async def test_panel_timezone_header_never_changes_settings(
|
||||
household: SimpleNamespace, extra: dict[str, str]
|
||||
) -> None:
|
||||
"""09:59:59 EDT on a UTC host, zone unset: the header is ignored."""
|
||||
household.clock["now"] = _utc(13, 59, 59)
|
||||
headers = {**_panel_headers(), **extra}
|
||||
snapshot = await household.client.get(
|
||||
"/api/v1/panel/snapshot", headers=headers
|
||||
)
|
||||
assert snapshot.status_code == 200
|
||||
seed = household.seed
|
||||
completed = await household.client.post(
|
||||
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
|
||||
headers=headers,
|
||||
json={"actor_child_id": seed.ada.id},
|
||||
)
|
||||
assert completed.status_code == 200
|
||||
# Nothing was written, and the clock stayed host local (UTC): the
|
||||
# 10:00 quest was overdue and the 13:59 UTC completion was late.
|
||||
assert await _stored_document(household) is None
|
||||
assert (await _admin_settings(household))["timezone"] == ""
|
||||
_today_iso, instance = await _pack_bag(household, "admin")
|
||||
assert instance["on_time"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extra", _TIMEZONE_HEADERS)
|
||||
async def test_panel_timezone_header_never_replaces_the_stored_zone(
|
||||
household: SimpleNamespace, extra: dict[str, str]
|
||||
) -> None:
|
||||
"""A stored zone stays exactly as the admin saved it."""
|
||||
await _set_zone(household, "America/Los_Angeles")
|
||||
before = await _stored_document(household)
|
||||
household.clock["now"] = _utc(16, 59, 59) # 09:59:59 PDT, 12:59:59 EDT
|
||||
response = await household.client.get(
|
||||
"/api/v1/panel/snapshot",
|
||||
headers={**_panel_headers(), **extra},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert await _stored_document(household) == before
|
||||
_today_iso, instance = await _pack_bag(household, "panel")
|
||||
assert instance["overdue"] is False
|
||||
|
||||
|
||||
async def test_fresh_install_zone_stays_empty_across_reads(
|
||||
household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Repeated panel and admin reads never write the settings document."""
|
||||
for hour in (3, 13, 23):
|
||||
household.clock["now"] = _utc(hour, 0)
|
||||
for extra in _TIMEZONE_HEADERS:
|
||||
response = await household.client.get(
|
||||
"/api/v1/panel/snapshot",
|
||||
headers={**_panel_headers(), **extra},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
await _pack_bag(household, "admin")
|
||||
settings = await _admin_settings(household)
|
||||
assert settings["timezone"] == ""
|
||||
assert "timezone_configured" not in settings
|
||||
assert await _stored_document(household) is None
|
||||
|
||||
|
||||
# --- the scheduler follows a changed zone (task 6351b36d) --------------------
|
||||
#
|
||||
# On a UTC host with no stored zone the scheduler first plans for UTC
|
||||
# midnight; a zone stored while it sleeps must move the sweep to the
|
||||
# household's midnight rather than sweep at 20:00 EDT.
|
||||
|
||||
#: 13:00 UTC (09:00 EDT) to UTC midnight.
|
||||
_UTC_MIDNIGHT_DELAY = 11 * 3600
|
||||
#: 13:00 UTC (09:00 EDT) to New York midnight (04:00 UTC).
|
||||
_NEW_YORK_MIDNIGHT_DELAY = 15 * 3600
|
||||
|
||||
|
||||
async def _store_zone(scheduler, database, zone: str) -> None:
|
||||
"""Store ``zone`` the way a settings writer must: under the lock."""
|
||||
async with scheduler.settings_lock:
|
||||
settings = await _settings_store.update_settings(
|
||||
database, {"timezone": zone}
|
||||
)
|
||||
scheduler.settings_changed(settings)
|
||||
|
||||
|
||||
async def test_scheduler_replans_when_zone_changes_while_sleeping(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner:
|
||||
app_state = runner.app.state
|
||||
await app_state.sweep_scheduler.stop()
|
||||
delays: list[float] = []
|
||||
slept = asyncio.Condition()
|
||||
|
||||
async def sleep_stub(seconds: float) -> None:
|
||||
async with slept:
|
||||
delays.append(seconds)
|
||||
slept.notify_all()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
scheduler = MissedSweepScheduler(
|
||||
app_state.db.database, clock=lambda: _utc(13, 0), sleep=sleep_stub
|
||||
)
|
||||
app_state.sweep_scheduler = scheduler
|
||||
scheduler.start()
|
||||
try:
|
||||
async with slept:
|
||||
await asyncio.wait_for(
|
||||
slept.wait_for(lambda: len(delays) == 1), timeout=5
|
||||
)
|
||||
await _store_zone(
|
||||
scheduler, app_state.db.database, HOUSEHOLD_ZONE
|
||||
)
|
||||
async with slept:
|
||||
await asyncio.wait_for(
|
||||
slept.wait_for(lambda: len(delays) == 2), timeout=5
|
||||
)
|
||||
finally:
|
||||
await scheduler.stop()
|
||||
assert delays == [
|
||||
pytest.approx(_UTC_MIDNIGHT_DELAY),
|
||||
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
|
||||
]
|
||||
|
||||
|
||||
async def test_scheduler_rechecks_settings_before_sweeping(
|
||||
temp_db_path: str, monkeypatch
|
||||
) -> None:
|
||||
"""A zone stored without a wake-up still stops the stale sweep."""
|
||||
database = _core_db.NestQuestDatabase(_executor)
|
||||
await database.open(temp_db_path)
|
||||
try:
|
||||
await _core_migrations.apply_migrations(database)
|
||||
sweeps: list[datetime.date] = []
|
||||
|
||||
async def sweep_stub(_database, *, today):
|
||||
sweeps.append(today)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
scheduler_module.core_sweep, "run_missed_sweep", sweep_stub
|
||||
)
|
||||
delays: list[float] = []
|
||||
replanned = asyncio.Event()
|
||||
|
||||
async def sleep_stub(seconds: float) -> None:
|
||||
delays.append(seconds)
|
||||
if len(delays) == 1:
|
||||
# The UTC rollover "arrives" after the zone was stored
|
||||
# behind the scheduler's back.
|
||||
await _settings_store.update_settings(
|
||||
database, {"timezone": HOUSEHOLD_ZONE}
|
||||
)
|
||||
return
|
||||
replanned.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
scheduler = MissedSweepScheduler(
|
||||
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
|
||||
)
|
||||
scheduler.start()
|
||||
try:
|
||||
await asyncio.wait_for(replanned.wait(), timeout=5)
|
||||
finally:
|
||||
await scheduler.stop()
|
||||
assert sweeps == []
|
||||
assert delays == [
|
||||
pytest.approx(_UTC_MIDNIGHT_DELAY),
|
||||
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
|
||||
]
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
# --- the admin settings route is the scheduler's writer (task 7e173881) ------
|
||||
#
|
||||
# ``PATCH /api/v1/admin/settings`` is the only settings writer: it holds
|
||||
# the scheduler's settings lock across the write and tells the scheduler,
|
||||
# so a saved zone or rollover re-plans a sleeping scheduler.
|
||||
|
||||
|
||||
async def _sleeping_scheduler(household: SimpleNamespace):
|
||||
"""Swap in a scheduler on the pinned clock; return it and its delays.
|
||||
|
||||
Waits until it sleeps on its first plan.
|
||||
"""
|
||||
await household.app.state.sweep_scheduler.stop()
|
||||
delays: list[float] = []
|
||||
slept = asyncio.Condition()
|
||||
|
||||
async def sleep_stub(seconds: float) -> None:
|
||||
async with slept:
|
||||
delays.append(seconds)
|
||||
slept.notify_all()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
scheduler = MissedSweepScheduler(
|
||||
household.database,
|
||||
clock=lambda: household.clock["now"],
|
||||
sleep=sleep_stub,
|
||||
)
|
||||
household.app.state.sweep_scheduler = scheduler
|
||||
scheduler.start()
|
||||
|
||||
async def wait_for_sleeps(count: int) -> None:
|
||||
async with slept:
|
||||
await asyncio.wait_for(
|
||||
slept.wait_for(lambda: len(delays) == count), timeout=5
|
||||
)
|
||||
|
||||
await wait_for_sleeps(1)
|
||||
return scheduler, delays, wait_for_sleeps
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changes", "replanned_delay"),
|
||||
[
|
||||
# 13:00 UTC is 09:00 EDT: New York midnight is 15 hours away.
|
||||
({"timezone": HOUSEHOLD_ZONE}, _NEW_YORK_MIDNIGHT_DELAY),
|
||||
# Still UTC: 13:00 to a 14:30 rollover is 90 minutes.
|
||||
({"day_rollover_time": "14:30"}, 90 * 60),
|
||||
],
|
||||
ids=["timezone", "day_rollover_time"],
|
||||
)
|
||||
async def test_admin_settings_patch_replans_a_sleeping_scheduler(
|
||||
household: SimpleNamespace, changes: dict, replanned_delay: float
|
||||
) -> None:
|
||||
household.clock["now"] = _utc(13, 0)
|
||||
scheduler, delays, wait_for_sleeps = await _sleeping_scheduler(household)
|
||||
try:
|
||||
response = await household.client.patch(
|
||||
"/api/v1/admin/settings", json=changes, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
await wait_for_sleeps(2)
|
||||
finally:
|
||||
await scheduler.stop()
|
||||
assert delays == [
|
||||
pytest.approx(_UTC_MIDNIGHT_DELAY),
|
||||
pytest.approx(replanned_delay),
|
||||
]
|
||||
|
||||
|
||||
async def test_admin_saved_zone_is_authoritative_across_surfaces(
|
||||
household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""ONE instant, read by every surface in exactly the admin's zone.
|
||||
|
||||
Asia/Kolkata (UTC+05:30) matches neither the UTC host nor any other
|
||||
zone in play. 04:30:01 UTC is 10:00:01 there: the 10:00 quest is
|
||||
overdue on both snapshots (it would not be in UTC, 04:30), the
|
||||
household day is the 26th, and the scheduler sleeps the 299 s to a
|
||||
10:05 rollover (UTC would be 5h35m).
|
||||
"""
|
||||
household.clock["now"] = _utc(4, 30, 1)
|
||||
scheduler, delays, wait_for_sleeps = await _sleeping_scheduler(household)
|
||||
try:
|
||||
response = await household.client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"timezone": "Asia/Kolkata", "day_rollover_time": "10:05"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
await wait_for_sleeps(2)
|
||||
for plane in ("panel", "admin"):
|
||||
today_iso, instance = await _pack_bag(household, plane)
|
||||
assert today_iso == TODAY.isoformat(), plane
|
||||
assert instance["overdue"] is True, plane
|
||||
finally:
|
||||
await scheduler.stop()
|
||||
assert delays[1] == pytest.approx(299)
|
||||
|
||||
|
||||
async def test_empty_zone_publishes_the_api_host_zone_to_panel_and_admin(
|
||||
household: SimpleNamespace, monkeypatch
|
||||
) -> None:
|
||||
"""With no zone stored, the API reads the host's local time — and the
|
||||
panel snapshot and the admin settings both publish that host's own
|
||||
zone, so the cards and the admin app format in the API's zone, never
|
||||
Home Assistant's or the browser's.
|
||||
|
||||
The host runs Pacific/Auckland: 12:30 UTC on the 26th is already
|
||||
00:30 on the 27th there, so the API's household day is the 27th while
|
||||
a UTC Home Assistant or browser would still read the 26th. Once the
|
||||
admin saves a zone, both publish exactly that zone.
|
||||
"""
|
||||
host = "Pacific/Auckland"
|
||||
monkeypatch.setattr(host_zone, "host_timezone", lambda: host)
|
||||
household.clock["now"] = _utc(12, 30).astimezone(ZoneInfo(host))
|
||||
|
||||
panel = await household.client.get(
|
||||
"/api/v1/panel/snapshot", headers=_panel_headers()
|
||||
)
|
||||
assert panel.status_code == 200
|
||||
settings = await _admin_settings(household)
|
||||
assert settings["timezone"] == ""
|
||||
assert panel.json()["timezone"] == settings["effective_timezone"] == host
|
||||
# The published zone reproduces the API's own household day.
|
||||
assert panel.json()["today_iso"] == "2026-09-27"
|
||||
assert (
|
||||
_utc(12, 30).astimezone(ZoneInfo(host)).date().isoformat()
|
||||
== panel.json()["today_iso"]
|
||||
)
|
||||
|
||||
await _set_zone(household, "America/Chicago")
|
||||
panel = await household.client.get(
|
||||
"/api/v1/panel/snapshot", headers=_panel_headers()
|
||||
)
|
||||
settings = await _admin_settings(household)
|
||||
assert settings["timezone"] == "America/Chicago"
|
||||
assert panel.json()["timezone"] == settings["effective_timezone"]
|
||||
assert settings["effective_timezone"] == "America/Chicago"
|
||||
|
||||
|
||||
# --- settings writes are serialised with planning and sweeping (TZ-003) ------
|
||||
#
|
||||
# A zone write racing the loop's own settings reads must never leave a
|
||||
# stale plan behind. Each test holds the loop at one of its reads and
|
||||
# issues a locked zone write there; when the scheduler's settings lock
|
||||
# is held at that point the write provably cannot commit until the
|
||||
# loop moves on, otherwise it is awaited to commit inside the window.
|
||||
|
||||
|
||||
def _hold_scheduler_read(monkeypatch, scheduler, nth: int, during):
|
||||
"""Run ``during(settings)`` inside the scheduler task's ``nth`` read."""
|
||||
real_load = _settings_store.load_settings
|
||||
reads = {"count": 0}
|
||||
|
||||
async def load_settings(database):
|
||||
settings = await real_load(database)
|
||||
if asyncio.current_task() is scheduler._task:
|
||||
reads["count"] += 1
|
||||
if reads["count"] == nth:
|
||||
await during(settings)
|
||||
return settings
|
||||
|
||||
monkeypatch.setattr(
|
||||
scheduler_module.core_settings_store, "load_settings", load_settings
|
||||
)
|
||||
return real_load
|
||||
|
||||
|
||||
async def _store_inside_read(scheduler, database, zone: str) -> asyncio.Task:
|
||||
"""Start a locked write of ``zone``; let it commit unless serialised."""
|
||||
write = asyncio.create_task(_store_zone(scheduler, database, zone))
|
||||
if not scheduler.settings_lock.locked():
|
||||
await write
|
||||
return write
|
||||
|
||||
|
||||
async def test_scheduler_follows_successive_zone_changes(
|
||||
temp_db_path: str, monkeypatch
|
||||
) -> None:
|
||||
"""New York -> UTC -> New York while the re-plan read is in flight."""
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner:
|
||||
app_state = runner.app.state
|
||||
database = app_state.db.database
|
||||
await app_state.sweep_scheduler.stop()
|
||||
delays: list[float] = []
|
||||
slept = asyncio.Condition()
|
||||
|
||||
async def sleep_stub(seconds: float) -> None:
|
||||
async with slept:
|
||||
delays.append(seconds)
|
||||
slept.notify_all()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
scheduler = MissedSweepScheduler(
|
||||
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
|
||||
)
|
||||
app_state.sweep_scheduler = scheduler
|
||||
await _store_zone(scheduler, database, HOUSEHOLD_ZONE)
|
||||
writes: list[asyncio.Task] = []
|
||||
|
||||
async def back_to_new_york(_settings) -> None:
|
||||
writes.append(
|
||||
await _store_inside_read(scheduler, database, HOUSEHOLD_ZONE)
|
||||
)
|
||||
|
||||
# Read 1 plans New York; read 2 is the re-plan after UTC.
|
||||
_hold_scheduler_read(monkeypatch, scheduler, 2, back_to_new_york)
|
||||
scheduler.start()
|
||||
try:
|
||||
async with slept:
|
||||
await asyncio.wait_for(
|
||||
slept.wait_for(lambda: len(delays) == 1), timeout=5
|
||||
)
|
||||
await _store_zone(scheduler, database, "UTC")
|
||||
async with slept:
|
||||
await asyncio.wait_for(
|
||||
slept.wait_for(lambda: len(delays) == 3), timeout=5
|
||||
)
|
||||
(write,) = writes
|
||||
await write
|
||||
finally:
|
||||
await scheduler.stop()
|
||||
# The in-flight read planned UTC; the New York write that followed
|
||||
# it woke the wait, so the scheduler sleeps on New York midnight.
|
||||
assert delays == [
|
||||
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
|
||||
pytest.approx(_UTC_MIDNIGHT_DELAY),
|
||||
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
|
||||
]
|
||||
|
||||
|
||||
async def test_zone_write_after_presweep_check_cannot_precede_the_sweep(
|
||||
temp_db_path: str, monkeypatch
|
||||
) -> None:
|
||||
"""No sweep runs once a zone write has replaced its plan."""
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner:
|
||||
app_state = runner.app.state
|
||||
await app_state.sweep_scheduler.stop()
|
||||
delays: list[float] = []
|
||||
replanned = asyncio.Event()
|
||||
|
||||
async def sleep_stub(seconds: float) -> None:
|
||||
delays.append(seconds)
|
||||
if len(delays) == 1:
|
||||
return # the UTC rollover arrives
|
||||
replanned.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
scheduler = MissedSweepScheduler(
|
||||
app_state.db.database, clock=lambda: _utc(13, 0), sleep=sleep_stub
|
||||
)
|
||||
app_state.sweep_scheduler = scheduler
|
||||
writes: list[asyncio.Task] = []
|
||||
|
||||
async def store_new_york(_settings) -> None:
|
||||
writes.append(
|
||||
await _store_inside_read(
|
||||
scheduler, app_state.db.database, HOUSEHOLD_ZONE
|
||||
)
|
||||
)
|
||||
|
||||
# Read 1 plans UTC; read 2 is the pre-sweep re-check.
|
||||
real_load = _hold_scheduler_read(
|
||||
monkeypatch, scheduler, 2, store_new_york
|
||||
)
|
||||
swept_under: list[str] = []
|
||||
|
||||
async def sweep_stub(database, *, today):
|
||||
swept_under.append((await real_load(database)).timezone)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
scheduler_module.core_sweep, "run_missed_sweep", sweep_stub
|
||||
)
|
||||
scheduler.start()
|
||||
try:
|
||||
await asyncio.wait_for(replanned.wait(), timeout=5)
|
||||
(write,) = writes
|
||||
await write
|
||||
finally:
|
||||
await scheduler.stop()
|
||||
# The sweep ran for the UTC plan with UTC still stored, never after
|
||||
# New York had been stored; the next plan is New York midnight.
|
||||
assert swept_under == [""]
|
||||
assert delays == [
|
||||
pytest.approx(_UTC_MIDNIGHT_DELAY),
|
||||
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
|
||||
]
|
||||
|
||||
|
||||
# --- the wait's child tasks (TZ-004) ------------------------------------------
|
||||
|
||||
|
||||
async def test_failed_sleep_is_retried_not_swept(
|
||||
temp_db_path: str, monkeypatch
|
||||
) -> None:
|
||||
"""A sleep that raises backs off and re-plans; nothing is swept."""
|
||||
database = _core_db.NestQuestDatabase(_executor)
|
||||
await database.open(temp_db_path)
|
||||
try:
|
||||
await _core_migrations.apply_migrations(database)
|
||||
sweeps: list[datetime.date] = []
|
||||
|
||||
async def sweep_stub(_database, *, today):
|
||||
sweeps.append(today)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
scheduler_module.core_sweep, "run_missed_sweep", sweep_stub
|
||||
)
|
||||
delays: list[float] = []
|
||||
replanned = asyncio.Event()
|
||||
|
||||
async def sleep_stub(seconds: float) -> None:
|
||||
delays.append(seconds)
|
||||
if len(delays) == 1:
|
||||
raise RuntimeError("timer failed")
|
||||
if len(delays) == 2:
|
||||
return # the retry backoff
|
||||
replanned.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
scheduler = MissedSweepScheduler(
|
||||
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
|
||||
)
|
||||
scheduler.start()
|
||||
try:
|
||||
await asyncio.wait_for(replanned.wait(), timeout=5)
|
||||
finally:
|
||||
await scheduler.stop()
|
||||
assert sweeps == []
|
||||
assert delays == [
|
||||
pytest.approx(_UTC_MIDNIGHT_DELAY),
|
||||
scheduler_module._RETRY_BACKOFF_SECONDS,
|
||||
pytest.approx(_UTC_MIDNIGHT_DELAY),
|
||||
]
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
async def test_stop_waits_for_the_sleep_to_finish_cancelling(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
database = _core_db.NestQuestDatabase(_executor)
|
||||
await database.open(temp_db_path)
|
||||
try:
|
||||
await _core_migrations.apply_migrations(database)
|
||||
sleeping = asyncio.Event()
|
||||
sleepers: list[asyncio.Task] = []
|
||||
cleaned_up: list[bool] = []
|
||||
|
||||
async def sleep_stub(_seconds: float) -> None:
|
||||
sleepers.append(asyncio.current_task())
|
||||
sleeping.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
# Cancellation cleanup that takes a loop turn.
|
||||
await asyncio.sleep(0)
|
||||
cleaned_up.append(True)
|
||||
|
||||
scheduler = MissedSweepScheduler(
|
||||
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
|
||||
)
|
||||
scheduler.start()
|
||||
try:
|
||||
await asyncio.wait_for(sleeping.wait(), timeout=5)
|
||||
finally:
|
||||
await scheduler.stop()
|
||||
(sleeper,) = sleepers
|
||||
assert sleeper.done()
|
||||
assert cleaned_up == [True]
|
||||
finally:
|
||||
await database.close()
|
||||
@@ -60,6 +60,7 @@ def _payload(children) -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 0,
|
||||
"timezone": "",
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ def _empty_snapshot() -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 0,
|
||||
"timezone": "",
|
||||
"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:
|
||||
"""HA-local 11:00 with due_time 12:00 is on time even when UTC is 19:00.
|
||||
"""Household-local 11:00, due 12:00, is on time even when UTC is 19:00.
|
||||
|
||||
The helper's zone is UTC-8, so 11:00 local is 19:00 UTC. Converting
|
||||
to UTC before comparing would mark this late.
|
||||
|
||||
@@ -93,6 +93,7 @@ class FakeMonotonic:
|
||||
FIXTURE_SNAPSHOT = {
|
||||
"today_iso": _today_iso(),
|
||||
"cycle_day": 3,
|
||||
"timezone": "",
|
||||
"children": [
|
||||
{
|
||||
"child_id": 1,
|
||||
@@ -686,6 +687,8 @@ async def test_coordinator_configured_token_builds_real_client(
|
||||
hass, entry, session_factory=_session_factory
|
||||
)
|
||||
assert isinstance(real_client, NestQuestApiClient)
|
||||
# Home Assistant's zone is not reported to the API (task 7f685179).
|
||||
assert real_client._headers() == {"Authorization": "Bearer tok"}
|
||||
|
||||
set_coordinator_client(entry, real_client)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
@@ -791,8 +794,37 @@ async def test_snapshot_from_api_payload_empty_household() -> None:
|
||||
"""An empty household (no children) is a valid snapshot: empty
|
||||
children tuple, cycle day passes through."""
|
||||
snapshot = snapshot_from_api_payload(
|
||||
{"today_iso": "2026-09-23", "cycle_day": 0, "children": []}
|
||||
{
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 0,
|
||||
"timezone": "",
|
||||
"children": [],
|
||||
}
|
||||
)
|
||||
assert snapshot.children == ()
|
||||
assert snapshot.cycle_day == 0
|
||||
assert snapshot.today_iso == "2026-09-23"
|
||||
|
||||
|
||||
#: The panel snapshot exactly as an API from before the ``timezone`` field
|
||||
#: serves it: an integration upgraded ahead of its API still reads this.
|
||||
LEGACY_SNAPSHOT = {
|
||||
key: value for key, value in FIXTURE_SNAPSHOT.items() if key != "timezone"
|
||||
}
|
||||
|
||||
|
||||
async def test_coordinator_reads_a_legacy_payload_without_timezone(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""An older API omits ``timezone``: every refresh still builds the
|
||||
snapshot (the cards then fall back to Home Assistant's zone)."""
|
||||
assert "timezone" not in LEGACY_SNAPSHOT
|
||||
client = StubSnapshotClient(LEGACY_SNAPSHOT)
|
||||
_entry, coordinator = await _wire_entry(hass, make_entry, client)
|
||||
assert coordinator.last_update_success is True
|
||||
|
||||
snapshot = await coordinator._async_update_data()
|
||||
assert snapshot.timezone == ""
|
||||
assert snapshot.cycle_day == 3
|
||||
assert [child.child_name for child in snapshot.children] == ["Ada", "Bo"]
|
||||
assert snapshot_from_api_payload(LEGACY_SNAPSHOT).timezone == ""
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Test the namespaced icon-key module (task 707994b7).
|
||||
|
||||
:func:`validate_icon` is the strict write-side check (create/edit);
|
||||
:func:`normalize_icon_for_read` the lenient read-side normalizer that
|
||||
lets legacy bare Lucide names render without a data migration.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.icons import (
|
||||
EMOJI_MAX_CODE_POINTS,
|
||||
ICON_NAMESPACES,
|
||||
normalize_icon_for_read,
|
||||
validate_icon,
|
||||
)
|
||||
|
||||
|
||||
def test_namespace_and_cap_constants() -> None:
|
||||
assert ICON_NAMESPACES == {"lucide", "fa", "emoji"}
|
||||
assert EMOJI_MAX_CODE_POINTS == 8
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("lucide:dog", "lucide:dog"),
|
||||
("fa:dog", "fa:dog"),
|
||||
("fa:paw-print", "fa:paw-print"),
|
||||
("lucide:toy-box-2", "lucide:toy-box-2"),
|
||||
("emoji:🐕", "emoji:🐕"),
|
||||
# A ZWJ family sequence (7 code points) is within the cap.
|
||||
("emoji:👨👩👧👦", "emoji:👨👩👧👦"),
|
||||
("emoji:" + "⭐" * EMOJI_MAX_CODE_POINTS, "emoji:" + "⭐" * 8),
|
||||
# Legacy bare Lucide names normalize to the lucide namespace.
|
||||
("dog", "lucide:dog"),
|
||||
],
|
||||
)
|
||||
def test_validate_icon_accepts(value: str, expected: str) -> None:
|
||||
assert validate_icon(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, "", " ", "\t\n"])
|
||||
def test_validate_icon_clears(value: str | None) -> None:
|
||||
assert validate_icon(value) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"foo:dog", # unknown namespace
|
||||
"javascript:alert(1)", # unknown namespace (and markup-ish)
|
||||
"mdi:dog", # not one of the supported namespaces
|
||||
"lucide:", # empty name
|
||||
"fa:", # empty name
|
||||
"emoji:", # empty payload
|
||||
"lucide:Dog", # uppercase
|
||||
"lucide:has space", # whitespace
|
||||
"lucide:-dog", # leading hyphen
|
||||
"lucide:dog-", # trailing hyphen
|
||||
"lucide:dog_bone", # not kebab
|
||||
"fa:dög", # non-ASCII
|
||||
"lucide:a:b", # colon inside the name
|
||||
"Dog", # bare legacy name must still be kebab
|
||||
"has space",
|
||||
"<script>",
|
||||
"emoji:" + "⭐" * (EMOJI_MAX_CODE_POINTS + 1), # over the cap
|
||||
"emoji:a b", # whitespace
|
||||
"emoji:<b>", # markup
|
||||
"emoji:a&b", # entity start
|
||||
"emoji:🐕\x00", # control character
|
||||
"emoji:🐕\x07🐕", # control character between emoji
|
||||
"emoji:\ufeff", # U+FEFF BOM: JS \s matches it, so reject to agree
|
||||
"emoji:🐕\ufeff", # trailing U+FEFF
|
||||
# Edge whitespace/controls are part of the value, never trimmed away.
|
||||
"emoji:🐕 ", # trailing space
|
||||
"emoji:🐕\n", # trailing newline
|
||||
"emoji:🐕\x1c", # trailing control character (str.strip() eats it)
|
||||
" emoji:🐕", # leading space
|
||||
"lucide:dog ", # trailing space
|
||||
" fa:dog ", # padded namespaced key
|
||||
" toy-box ", # padded legacy bare name
|
||||
],
|
||||
)
|
||||
def test_validate_icon_rejects_naming_the_field(value: str) -> None:
|
||||
with pytest.raises(ValueError, match=r"^icon\b"):
|
||||
validate_icon(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [42, True, ["dog"], {"icon": "dog"}])
|
||||
def test_validate_icon_rejects_non_strings(value: object) -> None:
|
||||
with pytest.raises(ValueError, match="icon must be a string"):
|
||||
validate_icon(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(None, None),
|
||||
("", None),
|
||||
(" ", None),
|
||||
("lucide:dog", "lucide:dog"),
|
||||
("fa:dog", "fa:dog"),
|
||||
("emoji:🐕", "emoji:🐕"),
|
||||
# Legacy bare names (and anything else) fall through to lucide so
|
||||
# the frontend's fallback handles an unknown name, never an error.
|
||||
("dog", "lucide:dog"),
|
||||
("mdi:tooth", "lucide:mdi:tooth"),
|
||||
("Not Kebab", "lucide:Not Kebab"),
|
||||
],
|
||||
)
|
||||
def test_normalize_icon_for_read(value: str | None, expected: str | None) -> None:
|
||||
assert normalize_icon_for_read(value) == expected
|
||||
|
||||
|
||||
# The emoji-payload rule is duplicated in the generated JS resolver
|
||||
# (``isEmojiPayload`` in tools/icons/generate.mjs). Both suites assert
|
||||
# this one table — admin/src/icons/registry.test.ts runs it through
|
||||
# resolveIcon — so a divergence between the runtimes fails a test.
|
||||
_EMOJI_PARITY_CASES = json.loads(
|
||||
(
|
||||
Path(__file__).parent.parent / "tools" / "icons" / "emoji-payload-cases.json"
|
||||
).read_text(encoding="utf-8")
|
||||
)["cases"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case", _EMOJI_PARITY_CASES, ids=[case["why"] for case in _EMOJI_PARITY_CASES]
|
||||
)
|
||||
def test_validate_icon_emoji_verdict_matches_shared_table(case: dict) -> None:
|
||||
value = "emoji:" + case["payload"]
|
||||
if case["valid"]:
|
||||
assert validate_icon(value) == value
|
||||
else:
|
||||
with pytest.raises(ValueError, match=r"^icon\b"):
|
||||
validate_icon(value)
|
||||
|
||||
|
||||
def test_emoji_parity_table_covers_the_contract_edges() -> None:
|
||||
payloads = {case["payload"]: case["valid"] for case in _EMOJI_PARITY_CASES}
|
||||
assert payloads["\ufeff"] is False
|
||||
assert payloads["🐕 "] is False
|
||||
assert payloads["🐕\x00"] is False
|
||||
assert payloads["<b>"] is False and payloads["a>b"] is False
|
||||
assert payloads["a&b"] is False
|
||||
assert payloads["⭐" * EMOJI_MAX_CODE_POINTS] is True
|
||||
assert payloads["⭐" * (EMOJI_MAX_CODE_POINTS + 1)] is False
|
||||
family = "👨\u200d👩\u200d👧\u200d👦"
|
||||
assert payloads[family] is True
|
||||
@@ -136,7 +136,7 @@ async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> Non
|
||||
|
||||
# Single clock read for the seed/snapshot anchor (no midnight skew);
|
||||
# the coordinator no longer owns a clock read (the API serves the
|
||||
# snapshot), so the test resolves the HA-local zone directly.
|
||||
# snapshot), so the test resolves its own local zone directly.
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
# Pin the build instant at 12:00 local: past Ada's 10:00 due time
|
||||
@@ -332,6 +332,7 @@ async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> Non
|
||||
return {
|
||||
"today_iso": built.today_iso,
|
||||
"cycle_day": built.cycle_day,
|
||||
"timezone": built.timezone,
|
||||
"children": [
|
||||
{
|
||||
"child_id": child.child_id,
|
||||
|
||||
@@ -996,8 +996,9 @@ def test_definition_set_active_missing_returns_zero(tmp_path) -> None:
|
||||
assert _with_db(tmp_path, "definition-set-active-missing.db")(_body) == 0
|
||||
|
||||
|
||||
def test_definition_no_delete_method_exists(tmp_path) -> None:
|
||||
"""Guardrail: definitions are deactivated, never hard-deleted."""
|
||||
def test_definition_only_delete_method_is_history_guarded(tmp_path) -> None:
|
||||
"""Guardrail: the ONLY definition delete path is the history-guarded
|
||||
one (owner decision 2026-09-26); there is no unconditional delete."""
|
||||
async def _body(database, rules, definitions, children, child):
|
||||
import inspect
|
||||
|
||||
@@ -1008,7 +1009,9 @@ def test_definition_no_delete_method_exists(tmp_path) -> None:
|
||||
)
|
||||
}
|
||||
assert "delete" not in methods
|
||||
assert not any(name.startswith("delete") for name in methods)
|
||||
assert {
|
||||
name for name in methods if name.startswith("delete")
|
||||
} == {"delete_if_no_history"}
|
||||
return methods
|
||||
|
||||
_with_db(tmp_path, "definition-no-delete.db")(_body)
|
||||
|
||||
@@ -67,6 +67,7 @@ def _payload(children) -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 0,
|
||||
"timezone": "",
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ def _payload(children, cycle_day=0) -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": cycle_day,
|
||||
"timezone": "",
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ def _payload(children) -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 0,
|
||||
"timezone": "",
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""The shared quest-icon registry: one curated source, two identical artefacts.
|
||||
|
||||
``tools/icons/curated-icons.json`` is the only hand-edited list; the offline
|
||||
generator ``tools/icons/generate.mjs`` writes the same module into the admin
|
||||
PWA and the panel bundle. The admin Vitest suite regenerates and compares
|
||||
byte-for-byte; these checks need no Node for the core guarantees.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
SOURCE = REPO_ROOT / "tools" / "icons" / "curated-icons.json"
|
||||
GENERATOR = REPO_ROOT / "tools" / "icons" / "generate.mjs"
|
||||
ARTEFACTS = (
|
||||
REPO_ROOT / "admin" / "src" / "icons" / "registry.generated.ts",
|
||||
REPO_ROOT / "frontend" / "src" / "icons" / "registry.generated.ts",
|
||||
)
|
||||
PANEL_BUNDLE = (
|
||||
REPO_ROOT / "custom_components" / "nestquest" / "www" / "nestquest-cards.js"
|
||||
)
|
||||
|
||||
|
||||
def _curated() -> dict[str, list[dict[str, str]]]:
|
||||
return json.loads(SOURCE.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _generated_set(text: str, const: str) -> list[dict]:
|
||||
match = re.search(
|
||||
rf"export const {const}: readonly GlyphIcon\[\] = (\[.*?\n\]);",
|
||||
text,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, f"{const} not found in the generated registry"
|
||||
return json.loads(match.group(1))
|
||||
|
||||
|
||||
def test_both_artefacts_are_byte_identical() -> None:
|
||||
admin, frontend = (path.read_bytes() for path in ARTEFACTS)
|
||||
assert admin == frontend
|
||||
|
||||
|
||||
def test_artefacts_carry_exactly_the_curated_names_and_labels() -> None:
|
||||
curated = _curated()
|
||||
text = ARTEFACTS[0].read_text(encoding="utf-8")
|
||||
for namespace, const in (("lucide", "LUCIDE_ICONS"), ("fa", "FA_ICONS")):
|
||||
entries = _generated_set(text, const)
|
||||
assert [
|
||||
{"name": e["name"], "label": e["label"]} for e in entries
|
||||
] == curated[namespace]
|
||||
for entry in entries:
|
||||
assert entry["kind"] == namespace
|
||||
assert entry["key"] == f"{namespace}:{entry['name']}"
|
||||
assert re.fullmatch(r"0 0 \d+ \d+", entry["viewBox"])
|
||||
assert entry["paths"] and all(entry["paths"])
|
||||
assert len(curated["lucide"]) == 36
|
||||
assert 15 <= len(curated["fa"]) <= 25
|
||||
|
||||
|
||||
def test_artefacts_carry_the_full_font_awesome_free_solid_set() -> None:
|
||||
text = ARTEFACTS[0].read_text(encoding="utf-8")
|
||||
solid = _generated_set(text, "FA_SOLID_ICONS")
|
||||
names = [entry["name"] for entry in solid]
|
||||
assert names == sorted(names)
|
||||
assert len(set(names)) == len(names)
|
||||
# The complete Free Solid set, not a curated pick (7.x ships ~1,400).
|
||||
assert len(solid) >= 1400
|
||||
for entry in solid:
|
||||
assert set(entry) == {"kind", "key", "name", "label", "viewBox", "paths"}
|
||||
assert entry["kind"] == "fa"
|
||||
assert entry["key"] == f"fa:{entry['name']}"
|
||||
assert entry["label"] == " ".join(
|
||||
part[0].upper() + part[1:] for part in entry["name"].split("-")
|
||||
)
|
||||
assert re.fullmatch(r"0 0 \d+ \d+", entry["viewBox"])
|
||||
assert entry["paths"] and all(entry["paths"])
|
||||
# A well-known glyph, a barely-known one, and every curated quick-pick.
|
||||
assert {"rocket", "mug-hot", "person-dress-burst"} <= set(names)
|
||||
assert {e["name"] for e in _curated()["fa"]} <= set(names)
|
||||
assert "rocket-ship-9000" not in names
|
||||
|
||||
|
||||
def test_resolve_icon_resolves_any_free_solid_name() -> None:
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
pytest.skip("needs node")
|
||||
script = (
|
||||
"const m = await import(process.argv[1]);"
|
||||
"const out = {};"
|
||||
"for (const v of JSON.parse(process.argv[2])) {"
|
||||
" const r = m.resolveIcon(v); out[v] = [r.kind, r.label ?? null]; }"
|
||||
"console.log(JSON.stringify(out));"
|
||||
)
|
||||
values = [
|
||||
"fa:rocket",
|
||||
"fa:mug-hot",
|
||||
"fa:person-dress-burst",
|
||||
"fa:paw",
|
||||
"fa:rocket-ship-9000",
|
||||
"fa:Rocket",
|
||||
"fa:",
|
||||
"lucide:dog",
|
||||
"dog",
|
||||
"lucide:rocket",
|
||||
]
|
||||
result = subprocess.run(
|
||||
[node, "--input-type=module", "-e", script,
|
||||
ARTEFACTS[0].as_uri(), json.dumps(values)],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0 and "Unknown file extension" in result.stderr:
|
||||
pytest.skip("node cannot strip TypeScript types (needs Node 22.18+)")
|
||||
assert result.returncode == 0, result.stderr
|
||||
resolved = json.loads(result.stdout)
|
||||
assert resolved["fa:rocket"] == ["fa", "Rocket"]
|
||||
assert resolved["fa:mug-hot"] == ["fa", "Mug Hot"]
|
||||
assert resolved["fa:person-dress-burst"] == ["fa", "Person Dress Burst"]
|
||||
# Curated entries keep their quick-pick label.
|
||||
assert resolved["fa:paw"] == ["fa", "Pets"]
|
||||
assert resolved["lucide:dog"][0] == "lucide"
|
||||
assert resolved["dog"][0] == "lucide"
|
||||
for unknown in ("fa:rocket-ship-9000", "fa:Rocket", "fa:", "lucide:rocket"):
|
||||
assert resolved[unknown] == ["fallback", None], unknown
|
||||
|
||||
|
||||
def test_registry_has_no_remote_url_cdn_or_lucide_runtime() -> None:
|
||||
for path in (SOURCE, GENERATOR, *ARTEFACTS):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
assert not re.search(r"https?://", text, re.IGNORECASE), path
|
||||
assert not re.search(
|
||||
r"\bcdn\b|jsdelivr|unpkg|cdnjs|kit\.fontawesome", text, re.IGNORECASE
|
||||
), path
|
||||
assert "data-lucide" not in text, path
|
||||
|
||||
|
||||
def test_panel_bundle_embeds_the_registry() -> None:
|
||||
bundle = PANEL_BUNDLE.read_text(encoding="utf-8")
|
||||
assert "fa:broom" in bundle
|
||||
assert "lucide:dog" in bundle
|
||||
assert "data-lucide" not in bundle
|
||||
|
||||
|
||||
def test_generator_reproduces_the_committed_artefacts() -> None:
|
||||
node = shutil.which("node")
|
||||
if node is None or not (REPO_ROOT / "admin" / "node_modules").is_dir():
|
||||
pytest.skip("needs node and admin/node_modules (npm ci in admin/)")
|
||||
result = subprocess.run(
|
||||
[node, str(GENERATOR), "--check"],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -940,14 +940,15 @@ def test_materialize_rejects_malformed_or_inverted_bounds(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None:
|
||||
"""The clamp and no-past guard compare against a caller-supplied HA-local today.
|
||||
"""The clamp and no-past guard compare against a caller-supplied today.
|
||||
|
||||
A household time zone behind the host clock around midnight resolves a
|
||||
HA-local "today" one calendar day earlier than the host's
|
||||
household-local "today" one calendar day earlier than the host's
|
||||
``date.today()``. The walk clamps its start to that anchor: without
|
||||
a pinned HA-local date the effective start is the host's today (one
|
||||
day after ha_today), so the now-past day is skipped rather than
|
||||
rejected, and only the host-today-onward days materialize.
|
||||
a pinned household-local date the effective start is the host's
|
||||
today (one day after ha_today), so the now-past day is skipped
|
||||
rather than rejected, and only the host-today-onward days
|
||||
materialize.
|
||||
"""
|
||||
async def _body(database):
|
||||
children = ChildrenDao(database)
|
||||
@@ -963,9 +964,9 @@ def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None:
|
||||
["morning"],
|
||||
)
|
||||
|
||||
# Without a pinned HA-local today, the walk clamps its start to the
|
||||
# host's today (one day after ha_today); the past day is dropped and
|
||||
# the 14 host-today-onward days materialize.
|
||||
# Without a pinned household-local today, the walk clamps its start
|
||||
# to the host's today (one day after ha_today); the past day is
|
||||
# dropped and the 14 host-today-onward days materialize.
|
||||
without_pin = await materialize(database, start_iso, end_iso)
|
||||
assert without_pin == 14
|
||||
|
||||
|
||||
+597
-4
@@ -21,6 +21,7 @@ end-to-end through the quest-log harness.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -302,14 +303,15 @@ def test_quest_log_entrances_use_the_design_timing_and_easing() -> None:
|
||||
".quest.tappable:active",
|
||||
"button.complete:active",
|
||||
"button.confirm-button:active",
|
||||
"button.return:active",
|
||||
):
|
||||
start = style.index(selector)
|
||||
block = style[start : style.index("}", start)]
|
||||
assert "transform: scale(0.98);" in block, selector
|
||||
assert (
|
||||
style.count("transition: transform var(--nq-dur-micro) var(--nq-ease-out);")
|
||||
== 3
|
||||
) # quest card, Complete button, confirm buttons
|
||||
== 4
|
||||
) # quest card, Complete button, confirm buttons, header return control
|
||||
board_style = _style_literal(_read(PARTY_BOARD), STYLE_CONSTANTS[PARTY_BOARD])
|
||||
start = board_style.index("button.plate:active")
|
||||
block = board_style[start : board_style.index("}", start)]
|
||||
@@ -411,6 +413,36 @@ def test_panel_hard_rules_hold_in_the_card_sources() -> None:
|
||||
assert "@click" not in sealed_template
|
||||
|
||||
|
||||
def test_party_board_hint_clears_the_dock() -> None:
|
||||
"""PANEL-SPEC §2/§2.3: the hint line reads fully above the dock at
|
||||
1920x1080. The hint's line box (top + font-size x line-height) must
|
||||
end above the dock's top edge (1080 - bottom inset - dock height)
|
||||
with a visible gap, at the 27px/600 design size. The hint is a <p>,
|
||||
so its user-agent 1em top margin must be reset or it shifts the box
|
||||
down by a whole font-size."""
|
||||
style = _style_literal(_read(PARTY_BOARD), STYLE_CONSTANTS[PARTY_BOARD])
|
||||
rules = {selector.strip(): body for selector, body in _css_rules(style)}
|
||||
sizes = _size_tokens(_read(REWARD_TOKENS))
|
||||
|
||||
def px(body: str, prop: str) -> float:
|
||||
declared = re.search(rf"(?<![-\w]){prop}:\s*([^;]+);", body)
|
||||
assert declared is not None, prop
|
||||
value = _font_size_px(declared.group(1), sizes)
|
||||
assert value is not None, f"{prop}: {declared.group(1)}"
|
||||
return value
|
||||
|
||||
board, hint, dock = rules[".board"], rules[".hint"], rules[".dock"]
|
||||
assert "margin: 0;" in hint
|
||||
line_height = float(re.search(r"line-height:\s*([\d.]+);", hint).group(1))
|
||||
hint_bottom = px(hint, "top") + px(hint, "font-size") * line_height
|
||||
dock_top = px(board, "height") - px(dock, "bottom") - px(dock, "height")
|
||||
assert px(dock, "height") == 84
|
||||
assert px(dock, "bottom") == 46
|
||||
assert hint_bottom + 12 <= dock_top, (hint_bottom, dock_top)
|
||||
assert px(hint, "font-size") == 27
|
||||
assert "font-weight: 600;" in hint
|
||||
|
||||
|
||||
def test_panel_text_sizes_respect_the_22px_floor() -> None:
|
||||
"""PANEL-SPEC §1: no body text below 22px. Every font-size in the
|
||||
panel styles resolves against the panel size tokens or an explicit
|
||||
@@ -535,6 +567,8 @@ def _render_party_board(
|
||||
states: dict,
|
||||
url: str | None = None,
|
||||
click_plate: str | None = None,
|
||||
now: str | None = None,
|
||||
tz: str | None = None,
|
||||
) -> dict:
|
||||
"""Render the built bundle's party board and return its plates.
|
||||
|
||||
@@ -543,7 +577,9 @@ def _render_party_board(
|
||||
mounts the card with the given config and hass snapshot, optionally
|
||||
taps the named plate, and prints the ordered plates its shadow DOM
|
||||
renders, which of them are tappable buttons, and the pathname after
|
||||
the optional tap.
|
||||
the optional tap. ``now`` pins the card's clock to an ISO instant
|
||||
and ``tz`` sets the node process's ``TZ`` — the panel browser's
|
||||
local zone.
|
||||
"""
|
||||
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
|
||||
assert RENDER_HARNESS.is_file()
|
||||
@@ -557,12 +593,15 @@ def _render_party_board(
|
||||
spec["url"] = url
|
||||
if click_plate is not None:
|
||||
spec["click_plate"] = click_plate
|
||||
if now is not None:
|
||||
spec["now"] = now
|
||||
completed = subprocess.run(
|
||||
["node", str(RENDER_HARNESS)],
|
||||
input=json.dumps(spec),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
env={**os.environ, "TZ": tz} if tz is not None else None,
|
||||
)
|
||||
assert completed.returncode == 0, (
|
||||
f"render harness failed: {completed.stderr}"
|
||||
@@ -570,6 +609,40 @@ def _render_party_board(
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_party_board_wordmark_row_is_the_monogram_lockup() -> None:
|
||||
"""PANEL-SPEC §2 item 1: the wordmark row is rule · compass-rose
|
||||
monogram · NestQuest · rule. The monogram is an inline SVG filled
|
||||
with the brand gradient — the old d20 hexagon pair and its "20"
|
||||
numerals are gone, and nothing is fetched from a remote URL or
|
||||
embedded as a raster logo."""
|
||||
source = _read(PARTY_BOARD)
|
||||
assert "d20" not in _style_literal(source, STYLE_CONSTANTS[PARTY_BOARD])
|
||||
assert 'class="d20' not in source
|
||||
assert "HEXAGON_CLIP" not in source
|
||||
|
||||
html = _render_party_board(
|
||||
{"type": "custom:nestquest-party-board-card"},
|
||||
_three_child_states(),
|
||||
)["html"]
|
||||
row = re.search(r'<div class="wordmark-row">(.*?)</div>', html, re.DOTALL)
|
||||
assert row is not None
|
||||
row_html = row.group(1)
|
||||
classes = re.findall(r'<(?:span|svg) class="([^"]+)"', row_html)
|
||||
assert classes == ["rule", "monogram", "wordmark", "rule"]
|
||||
assert '<svg class="monogram" viewBox="0 0 100 100" aria-hidden="true"' in row_html
|
||||
assert "var(--nq-brand-blue)" in row_html
|
||||
assert "var(--nq-brand-purple)" in row_html
|
||||
assert ">N</text>" in row_html
|
||||
assert ">NestQuest</span>" in row_html
|
||||
assert "d20" not in row_html
|
||||
assert "<img" not in row_html
|
||||
|
||||
bundle = _read(BUNDLE_PATH)
|
||||
assert "d20-numeral\">20<" not in bundle
|
||||
assert "data:image/png" not in bundle
|
||||
assert not re.search(r"https?://", bundle)
|
||||
|
||||
|
||||
def test_party_board_renders_discovered_children_in_sort_order() -> None:
|
||||
"""No child_order key: three plates render in the roster's sort
|
||||
order (the coordinator snapshot's order), one per child."""
|
||||
@@ -764,6 +837,11 @@ def _render_quest_log(
|
||||
url: str,
|
||||
idle_ms: int | None = None,
|
||||
probe_ms: int | None = None,
|
||||
tap_complete: int | None = None,
|
||||
tap_return: bool = False,
|
||||
now: str | None = None,
|
||||
advance_to: list[str] | None = None,
|
||||
tz: str | None = None,
|
||||
) -> dict:
|
||||
"""Render the built bundle's quest log and report its location.
|
||||
|
||||
@@ -773,7 +851,10 @@ def _render_quest_log(
|
||||
segment — waits the optional probe window and captures one snapshot
|
||||
of the render mid-wait, then the optional idle window, and prints
|
||||
the final pathname and rendered title (plus the probe snapshot when
|
||||
``probe_ms`` was given).
|
||||
``probe_ms`` was given). ``now`` pins the card's clock to an ISO
|
||||
instant, ``advance_to`` moves it through further instants firing the
|
||||
card's clock tick (one snapshot each, under ``ticks``), and ``tz``
|
||||
sets the node process's ``TZ`` — the panel browser's local zone.
|
||||
"""
|
||||
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
|
||||
assert QUEST_LOG_HARNESS.is_file()
|
||||
@@ -787,12 +868,21 @@ def _render_quest_log(
|
||||
spec["idle_ms"] = idle_ms
|
||||
if probe_ms is not None:
|
||||
spec["probe_ms"] = probe_ms
|
||||
if tap_complete is not None:
|
||||
spec["tap_complete"] = tap_complete
|
||||
if tap_return:
|
||||
spec["tap_return"] = True
|
||||
if now is not None:
|
||||
spec["now"] = now
|
||||
if advance_to is not None:
|
||||
spec["advance_to"] = advance_to
|
||||
completed = subprocess.run(
|
||||
["node", str(QUEST_LOG_HARNESS)],
|
||||
input=json.dumps(spec),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
env={**os.environ, "TZ": tz} if tz is not None else None,
|
||||
)
|
||||
assert completed.returncode == 0, (
|
||||
f"quest-log harness failed: {completed.stderr}"
|
||||
@@ -856,6 +946,40 @@ def test_quest_log_idle_return_navigates_to_the_board() -> None:
|
||||
assert result["location"] == "/nestquest"
|
||||
|
||||
|
||||
def test_quest_log_return_control_navigates_to_the_board() -> None:
|
||||
"""PANEL-SPEC §3: the Quest Log header carries an explicit return
|
||||
control labelled for a child. Tapping it takes the same path as the
|
||||
idle return — the strategy-computed board_path (the dashboard root) —
|
||||
without waiting out the idle window."""
|
||||
generated = _generate_dashboard(
|
||||
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
|
||||
)
|
||||
result = _render_quest_log(
|
||||
generated["views"][1]["cards"][0],
|
||||
_three_child_states(),
|
||||
url="http://homeassistant.local/nestquest/ada",
|
||||
tap_return=True,
|
||||
)
|
||||
assert result["headline"] == "Ada's Quest Log"
|
||||
assert result["returned"] == {"rendered": True, "label": "The Party"}
|
||||
assert result["location"] == "/nestquest"
|
||||
|
||||
|
||||
def test_quest_log_return_control_follows_the_panel_hard_rules() -> None:
|
||||
"""The return control is a 72px button with :active feedback only,
|
||||
lives in the header (never the dock), and reuses the idle return's
|
||||
_returnToBoard navigation rather than a second mechanism."""
|
||||
log = _read(QUEST_LOG)
|
||||
style = _style_literal(log, STYLE_CONSTANTS[QUEST_LOG])
|
||||
rules = {selector.strip(): body for selector, body in _css_rules(style)}
|
||||
assert "height: var(--nq-p-button-height);" in rules["button.return"]
|
||||
assert "transform: scale(0.98);" in rules["button.return:active"]
|
||||
header = log[log.index("private _renderHeader") : log.index("private _renderColumn")]
|
||||
assert 'class="return"' in header
|
||||
assert "@click=${() => this._returnToBoard()}" in header
|
||||
assert log.count("this._returnToBoard()") == 4
|
||||
|
||||
|
||||
def test_quest_complete_screen_returns_to_the_board_after_the_countdown() -> None:
|
||||
"""The 1c screen renders with its countdown line and auto-returns to
|
||||
the strategy-computed board path when it reaches zero. The design
|
||||
@@ -886,3 +1010,472 @@ def test_quest_complete_screen_returns_to_the_board_after_the_countdown() -> Non
|
||||
assert result["probe"]["countdown"] == "Returning to The Party in 1 seconds"
|
||||
# …and at zero the card has returned to the party board.
|
||||
assert result["location"] == "/nestquest"
|
||||
|
||||
|
||||
def test_tapping_complete_confirms_and_calls_the_service() -> None:
|
||||
"""Tapping an open quest's Complete opens the confirm dialog, and the
|
||||
dialog's Complete calls nestquest.complete_quest with the panel actor.
|
||||
With ``useDefineForClassFields`` on (the ES2022 default) the card's
|
||||
reactive fields were emitted as native class fields that shadowed
|
||||
Lit's accessors: ``_confirm`` changed but no re-render was scheduled,
|
||||
so the dialog never opened and the tap did nothing."""
|
||||
generated = _generate_dashboard(
|
||||
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
|
||||
)
|
||||
open_quest = {
|
||||
"id": 42,
|
||||
"definition_id": 7,
|
||||
"child_id": 1,
|
||||
"title": "Brush Teeth",
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "08:15",
|
||||
"state": "open",
|
||||
"overdue": True,
|
||||
"completed_at": None,
|
||||
"on_time": None,
|
||||
}
|
||||
states = {
|
||||
**_three_child_states(),
|
||||
"sensor.nestquest_ada_quests_due_today": _state(
|
||||
"1",
|
||||
child_name="Ada",
|
||||
child_id=1,
|
||||
present=True,
|
||||
instances=[open_quest],
|
||||
),
|
||||
}
|
||||
result = _render_quest_log(
|
||||
generated["views"][1]["cards"][0],
|
||||
states,
|
||||
url="http://homeassistant.local/nestquest/ada",
|
||||
tap_complete=open_quest["id"],
|
||||
)
|
||||
assert result["tap"]["dialog_opened"] is True
|
||||
assert result["tap"]["service_calls"] == [
|
||||
{
|
||||
"domain": "nestquest",
|
||||
"service": "complete_quest",
|
||||
"data": {
|
||||
"instance_id": open_quest["id"],
|
||||
"actor": "panel",
|
||||
"actor_child_id": open_quest["child_id"],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_card_build_keeps_lit_reactive_accessors() -> None:
|
||||
"""The frontend build must not emit class fields that shadow Lit's
|
||||
reactive property accessors (Lit's documented TypeScript setting)."""
|
||||
tsconfig = json.loads((FRONTEND_DIR / "tsconfig.json").read_text())
|
||||
assert tsconfig["compilerOptions"]["useDefineForClassFields"] is False
|
||||
|
||||
|
||||
def test_quest_log_crest_keeps_its_shield_classes() -> None:
|
||||
"""PANEL-SPEC §3 header crest: a present child's crest renders as the
|
||||
styled shield, not a bare span. Lit removes an attribute outright when
|
||||
any interpolated part of it is ``nothing``, so a class written as
|
||||
``crest${away ? " away" : nothing}`` lost ``crest`` for every present
|
||||
child and the header showed an unstyled sliver. The rendered markup
|
||||
must carry ``class="crest"`` (present) and ``class="crest away"``
|
||||
(away), each wrapping the face and initial."""
|
||||
generated = _generate_dashboard(
|
||||
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
|
||||
)
|
||||
log_config = generated["views"][1]["cards"][0]
|
||||
for slug, crest_class in (("ada", "crest"), ("bo", "crest away")):
|
||||
result = _render_quest_log(
|
||||
log_config,
|
||||
_three_child_states(),
|
||||
url=f"http://homeassistant.local/nestquest/{slug}",
|
||||
)
|
||||
header = result["html"][result["html"].index('<header class="header">') :]
|
||||
crest = re.match(
|
||||
r'<header class="header">\s*(?:<!--[^>]*-->\s*)*'
|
||||
r'(?:<button class="return"[^>]*>.*?</button>\s*(?:<!--[^>]*-->\s*)*)?'
|
||||
r'<span ([^>]*)>\s*'
|
||||
r'<span class="crest-face">\s*<span class="initial">',
|
||||
header,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert crest is not None, header[:600]
|
||||
assert f'class="{crest_class}"' in crest.group(1), crest.group(1)
|
||||
|
||||
|
||||
def test_quest_log_meta_line_keeps_its_meta_class() -> None:
|
||||
"""An open quest's meta line is styled by ``.quest .meta`` (and
|
||||
``.quest .meta.late`` when overdue). Written as
|
||||
``meta ${late ? "late" : nothing}``, Lit dropped the whole class
|
||||
attribute for every quest that was not late, leaving the due line
|
||||
unstyled. The rendered markup must carry ``class="meta"`` on time and
|
||||
``class="meta late"`` overdue — overdue as the API snapshot says."""
|
||||
generated = _generate_dashboard(
|
||||
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
|
||||
)
|
||||
|
||||
def quest(instance_id: int, title: str, overdue: bool) -> dict:
|
||||
return {
|
||||
"id": instance_id,
|
||||
"definition_id": instance_id,
|
||||
"child_id": 1,
|
||||
"title": title,
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "08:15",
|
||||
"state": "open",
|
||||
"overdue": overdue,
|
||||
"completed_at": None,
|
||||
"on_time": None,
|
||||
}
|
||||
|
||||
states = {
|
||||
**_three_child_states(),
|
||||
"sensor.nestquest_ada_quests_due_today": _state(
|
||||
"2",
|
||||
child_name="Ada",
|
||||
child_id=1,
|
||||
present=True,
|
||||
instances=[
|
||||
quest(41, "Make Bed", overdue=False),
|
||||
quest(42, "Brush Teeth", overdue=True),
|
||||
],
|
||||
),
|
||||
}
|
||||
result = _render_quest_log(
|
||||
generated["views"][1]["cards"][0],
|
||||
states,
|
||||
url="http://homeassistant.local/nestquest/ada",
|
||||
)
|
||||
metas = {}
|
||||
for card in result["html"].split('data-instance-id="')[1:]:
|
||||
meta = re.search(
|
||||
r'<span class="quest-title">.*?</span>'
|
||||
r"\s*(?:<!--[^>]*-->\s*)*<span([^>]*)>",
|
||||
card,
|
||||
re.S,
|
||||
)
|
||||
assert meta is not None, card[:800]
|
||||
metas[card[: card.index('"')]] = meta.group(1)
|
||||
assert set(metas) == {"41", "42"}, metas
|
||||
assert metas["41"].strip() == 'class="meta"', metas["41"]
|
||||
assert metas["42"].strip() == 'class="meta late"', metas["42"]
|
||||
|
||||
|
||||
def _quest_metas(html: str) -> dict[str, tuple[str, str]]:
|
||||
"""Map each rendered open quest's instance id to its meta line's
|
||||
attributes and text."""
|
||||
metas = {}
|
||||
for card in html.split('data-instance-id="')[1:]:
|
||||
meta = re.search(
|
||||
r'<span class="quest-title">.*?</span>'
|
||||
r"\s*(?:<!--[^>]*-->\s*)*<span([^>]*)>(.*?)</span>",
|
||||
card,
|
||||
re.S,
|
||||
)
|
||||
assert meta is not None, card[:800]
|
||||
text = " ".join(re.sub(r"<!--[^>]*-->", "", meta.group(2)).split())
|
||||
metas[card[: card.index('"')]] = (meta.group(1).strip(), text)
|
||||
return metas
|
||||
|
||||
|
||||
def test_quest_log_overdue_follows_the_api_snapshot_not_the_panel_clock() -> None:
|
||||
"""The stored application timezone is the single source of local-time
|
||||
truth: the API computes each instance's ``overdue`` in that zone and
|
||||
the card renders exactly that, whatever the panel browser's own clock
|
||||
or zone says. Here the browser runs in Pacific/Auckland and its clock
|
||||
is pinned (and later ticked) to instants where every due time has, or
|
||||
has not yet, arrived on its local wall clock — the card must still
|
||||
show the snapshot's state: the ``overdue: true`` 08:15 quest reads
|
||||
OVERDUE even though the browser reads long before 08:15, and the
|
||||
``overdue: false`` 07:00 quest stays due even though the browser reads
|
||||
long after 07:00."""
|
||||
generated = _generate_dashboard(
|
||||
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
|
||||
)
|
||||
|
||||
def quest(instance_id: int, title: str, due_time: str, overdue: bool) -> dict:
|
||||
return {
|
||||
"id": instance_id,
|
||||
"definition_id": instance_id,
|
||||
"child_id": 1,
|
||||
"title": title,
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": due_time,
|
||||
"state": "open",
|
||||
"overdue": overdue,
|
||||
"completed_at": None,
|
||||
"on_time": None,
|
||||
}
|
||||
|
||||
states = {
|
||||
**_three_child_states(),
|
||||
"sensor.nestquest_ada_quests_due_today": _state(
|
||||
"2",
|
||||
child_name="Ada",
|
||||
child_id=1,
|
||||
present=True,
|
||||
instances=[
|
||||
quest(42, "Brush Teeth", "08:15", overdue=True),
|
||||
quest(43, "Feed Fish", "07:00", overdue=False),
|
||||
],
|
||||
),
|
||||
}
|
||||
# 2026-09-29T17:00Z is 06:00 NZDT on the 30th: before both due times.
|
||||
# 2026-09-29T23:00Z is 12:00 NZDT: after both.
|
||||
result = _render_quest_log(
|
||||
generated["views"][1]["cards"][0],
|
||||
states,
|
||||
url="http://homeassistant.local/nestquest/ada",
|
||||
now="2026-09-29T17:00:00Z",
|
||||
advance_to=["2026-09-29T23:00:00Z"],
|
||||
tz="Pacific/Auckland",
|
||||
)
|
||||
overdue = ('class="meta late"', "Overdue · due 8:15 AM")
|
||||
due_by = ('class="meta"', "Due by 7:00 AM")
|
||||
before, (after,) = (
|
||||
_quest_metas(result["html"]),
|
||||
[_quest_metas(tick["html"]) for tick in result["ticks"]],
|
||||
)
|
||||
assert before == {"42": overdue, "43": due_by}, before
|
||||
assert after == {"42": overdue, "43": due_by}, after
|
||||
|
||||
|
||||
def test_panel_date_and_clock_follow_the_stored_application_timezone() -> None:
|
||||
"""The stored application timezone is the single source of local-time
|
||||
truth for the panel's visible date and clock too: the household rollup
|
||||
publishes it (``timezone`` — the API's effective zone) and both cards
|
||||
format in it — the party
|
||||
board's kicker and dock date and clock, the quest log's header date —
|
||||
even though Home Assistant's zone and the panel browser's zone are both
|
||||
UTC. At 2026-09-29T23:30Z it is still Tuesday 11:30 PM in UTC but
|
||||
already Wednesday 12:30 PM in Pacific/Auckland (NZDT)."""
|
||||
generated = _generate_dashboard(
|
||||
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
|
||||
)
|
||||
states = _three_child_states()
|
||||
states["sensor.nestquest_household_quests_due_today"]["attributes"][
|
||||
"timezone"
|
||||
] = "Pacific/Auckland"
|
||||
now = "2026-09-29T23:30:00Z"
|
||||
|
||||
def text(html: str) -> str:
|
||||
return re.sub(r"<!--.*?-->", "", html).replace("\u202f", " ")
|
||||
|
||||
def board_date_and_clock(html: str) -> tuple[str, str, str]:
|
||||
found = re.search(
|
||||
r'<p class="kicker">The Party · (.*?)</p>.*?'
|
||||
r'<span class="date">(.*?)</span>.*?<span class="clock">(.*?)</span>',
|
||||
text(html),
|
||||
re.DOTALL,
|
||||
)
|
||||
assert found is not None, html
|
||||
return found.group(1), found.group(2), found.group(3)
|
||||
|
||||
board = _render_party_board(
|
||||
generated["views"][0]["cards"][0], states, now=now, tz="UTC"
|
||||
)["html"]
|
||||
assert board_date_and_clock(board) == (
|
||||
"Wednesday, Sep 30",
|
||||
"Wednesday, Sep 30",
|
||||
"12:30 PM",
|
||||
)
|
||||
|
||||
log = _render_quest_log(
|
||||
generated["views"][1]["cards"][0],
|
||||
states,
|
||||
url="http://homeassistant.local/nestquest/ada",
|
||||
now=now,
|
||||
tz="UTC",
|
||||
)["html"]
|
||||
sub = re.search(r'<p class="sub">\s*(.*?) ·', text(log), re.DOTALL)
|
||||
assert sub is not None, log
|
||||
assert sub.group(1) == "Wednesday, Sep 30"
|
||||
|
||||
# With no zone stored the API publishes its host's own zone, so the
|
||||
# rollup always carries one; only a rollup without it (an API older
|
||||
# than the field) falls back to Home Assistant's zone (UTC here).
|
||||
del states["sensor.nestquest_household_quests_due_today"]["attributes"][
|
||||
"timezone"
|
||||
]
|
||||
fallback = _render_party_board(
|
||||
generated["views"][0]["cards"][0], states, now=now, tz="UTC"
|
||||
)["html"]
|
||||
assert board_date_and_clock(fallback) == (
|
||||
"Tuesday, Sep 29",
|
||||
"Tuesday, Sep 29",
|
||||
"11:30 PM",
|
||||
)
|
||||
|
||||
|
||||
def test_quest_log_crest_matches_the_spec_geometry() -> None:
|
||||
"""PANEL-SPEC §3 header crest: 96x110 shield, 4px ring of
|
||||
rgba(255,255,255,.22) around the --nq-p-crest face, 44px/700 display
|
||||
initial in full-opacity white; away swaps only the face gradient.
|
||||
jsdom cannot lay out, so this pins the stylesheet the rendered test
|
||||
above shows is applied."""
|
||||
source = QUEST_LOG.read_text(encoding="utf-8")
|
||||
|
||||
def rule(selector: str) -> str:
|
||||
found = re.search(
|
||||
r"\n " + re.escape(selector) + r" \{(.*?)\n \}", source, re.DOTALL
|
||||
)
|
||||
assert found is not None, selector
|
||||
return found.group(1)
|
||||
|
||||
crest = rule(".crest")
|
||||
for decl in (
|
||||
"width: 96px;",
|
||||
"height: 110px;",
|
||||
"padding: 4px;",
|
||||
"clip-path: ${unsafeCSS(SHIELD_CLIP)};",
|
||||
"background: rgba(255, 255, 255, 0.22);",
|
||||
):
|
||||
assert decl in crest, decl
|
||||
face = rule(".crest-face")
|
||||
assert "background: var(--nq-p-crest);" in face
|
||||
assert "clip-path: ${unsafeCSS(SHIELD_CLIP)};" in face
|
||||
initial = rule(".crest .initial")
|
||||
for decl in (
|
||||
"font-family: var(--nq-p-font-display);",
|
||||
"font-size: 44px;",
|
||||
"font-weight: 700;",
|
||||
"color: #ffffff;",
|
||||
):
|
||||
assert decl in initial, decl
|
||||
assert "opacity" not in initial
|
||||
away = rule(".crest.away .crest-face")
|
||||
assert away.strip() == "background: var(--nq-p-crest-away);"
|
||||
|
||||
|
||||
# --- Quest card icons (definition icon on the open tile) ---------------------
|
||||
|
||||
|
||||
def _quest_row(instance_id: int, icon: str | None, state: str = "open") -> dict:
|
||||
"""One ``instances`` attribute row for Ada's quests-due sensor."""
|
||||
row = {
|
||||
"id": instance_id,
|
||||
"child_id": 1,
|
||||
"title": f"Quest {instance_id}",
|
||||
"icon": icon,
|
||||
"window": "morning",
|
||||
"due_time": "08:00",
|
||||
"state": state,
|
||||
"overdue": False,
|
||||
"completed_at": None,
|
||||
"on_time": None,
|
||||
}
|
||||
if state == "completed":
|
||||
row["completed_at"] = "2026-09-26T07:30:00+00:00"
|
||||
row["on_time"] = True
|
||||
return row
|
||||
|
||||
|
||||
def _quest_log_tiles(rows: list[dict]) -> dict[int, dict]:
|
||||
"""Render Ada's quest log with ``rows`` and key its tiles by instance id."""
|
||||
states = _three_child_states()
|
||||
states["sensor.nestquest_ada_quests_due_today"] = _state(
|
||||
str(len(rows)), child_name="Ada", present=True, instances=rows
|
||||
)
|
||||
result = _render_quest_log(
|
||||
{"type": "custom:nestquest-quest-log-card"},
|
||||
states,
|
||||
url="http://homeassistant.local/nestquest/ada",
|
||||
)
|
||||
return {tile["id"]: tile for tile in result["tiles"]}
|
||||
|
||||
|
||||
def test_open_quest_tile_renders_the_definition_icon() -> None:
|
||||
"""The open card's tile shows the definition's namespaced icon, drawn
|
||||
from the bundled registry: Lucide as a stroked svg, Font Awesome as a
|
||||
filled svg, an emoji as its text, and a legacy bare name as Lucide."""
|
||||
tiles = _quest_log_tiles(
|
||||
[
|
||||
_quest_row(1, "lucide:bed"),
|
||||
_quest_row(2, "fa:broom"),
|
||||
_quest_row(3, "emoji:🦊"),
|
||||
_quest_row(4, "bath"),
|
||||
]
|
||||
)
|
||||
assert tiles[1]["icon"] == "lucide:bed"
|
||||
assert tiles[1]["fill"] == "none"
|
||||
assert tiles[1]["paths"] > 0
|
||||
assert tiles[2]["icon"] == "fa:broom"
|
||||
assert tiles[2]["fill"] == "currentColor"
|
||||
assert tiles[2]["paths"] > 0
|
||||
assert tiles[3]["icon"] == "emoji:🦊"
|
||||
assert tiles[3]["text"] == "🦊"
|
||||
assert tiles[4]["icon"] == "lucide:bath"
|
||||
assert tiles[4]["fill"] == "none"
|
||||
for tile in tiles.values():
|
||||
assert not tile["sealed"]
|
||||
assert not tile["star"]
|
||||
|
||||
|
||||
def test_open_quest_tile_falls_back_to_the_star() -> None:
|
||||
"""An unknown name, an unknown namespace, or no icon at all keeps the
|
||||
open card's current star glyph."""
|
||||
tiles = _quest_log_tiles(
|
||||
[
|
||||
_quest_row(1, "lucide:not-a-real-icon"),
|
||||
_quest_row(2, "fa:not-a-real-icon"),
|
||||
_quest_row(3, "mdi:bed"),
|
||||
_quest_row(4, ""),
|
||||
_quest_row(5, None),
|
||||
]
|
||||
)
|
||||
assert set(tiles) == {1, 2, 3, 4, 5}
|
||||
for tile in tiles.values():
|
||||
assert tile["star"]
|
||||
assert tile["icon"] is None
|
||||
|
||||
|
||||
def test_open_quest_tile_renders_any_free_solid_icon() -> None:
|
||||
"""A Font Awesome Free Solid name outside the curated quick-pick list
|
||||
still renders its glyph from the shipped bundle; an unknown ``fa:`` name
|
||||
keeps the star."""
|
||||
tiles = _quest_log_tiles(
|
||||
[
|
||||
_quest_row(1, "fa:rocket"),
|
||||
_quest_row(2, "fa:mug-hot"),
|
||||
_quest_row(3, "fa:definitely-not-an-icon"),
|
||||
]
|
||||
)
|
||||
for instance_id, key in ((1, "fa:rocket"), (2, "fa:mug-hot")):
|
||||
assert tiles[instance_id]["icon"] == key
|
||||
assert tiles[instance_id]["fill"] == "currentColor"
|
||||
assert tiles[instance_id]["paths"] > 0
|
||||
assert not tiles[instance_id]["star"]
|
||||
assert tiles[3]["star"]
|
||||
assert tiles[3]["icon"] is None
|
||||
|
||||
|
||||
def test_sealed_quest_tile_keeps_the_star() -> None:
|
||||
"""A sealed card keeps its star tile (and its wax seal) whatever the
|
||||
definition's icon; only the open card shows the quest's icon."""
|
||||
tiles = _quest_log_tiles(
|
||||
[
|
||||
_quest_row(1, "lucide:bed", state="completed"),
|
||||
_quest_row(2, "fa:broom", state="completed"),
|
||||
_quest_row(3, "emoji:🦊", state="completed"),
|
||||
]
|
||||
)
|
||||
for tile in tiles.values():
|
||||
assert tile["sealed"]
|
||||
assert tile["star"]
|
||||
assert tile["icon"] is None
|
||||
assert tile["text"] == ""
|
||||
|
||||
|
||||
def test_only_the_open_tile_calls_the_icon_resolver() -> None:
|
||||
source = _read(QUEST_LOG)
|
||||
sealed_start = source.index('<div class="quest sealed"')
|
||||
sealed_block = source[sealed_start : source.index("</div>", sealed_start)]
|
||||
assert '<span class="tile" aria-hidden="true">${ICON_STAR}</span>' in sealed_block
|
||||
assert "renderQuestIcon" not in sealed_block
|
||||
assert (
|
||||
source.count("renderQuestIcon(instance.icon, ICON_STAR)") == 1
|
||||
), "only the open card's tile resolves the definition icon"
|
||||
open_start = source.index('class="quest tappable"')
|
||||
assert "renderQuestIcon(instance.icon, ICON_STAR)" in source[open_start:]
|
||||
|
||||
@@ -22,8 +22,10 @@ from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.quest_definitions import (
|
||||
CreatedQuestDefinition,
|
||||
DeletedQuestDefinition,
|
||||
assign_child,
|
||||
create_quest_definition,
|
||||
delete_quest_definition,
|
||||
edit_quest_definition,
|
||||
list_active_definitions,
|
||||
list_definitions_firing_on,
|
||||
@@ -106,13 +108,13 @@ def test_create_one_child_one_window(tmp_path) -> None:
|
||||
[child.id],
|
||||
["morning"],
|
||||
description=" Twice daily ",
|
||||
icon=" mdi:tooth ",
|
||||
icon="lucide:tooth",
|
||||
)
|
||||
assert isinstance(result, CreatedQuestDefinition)
|
||||
definition = result.definition
|
||||
assert definition.title == "Brush teeth"
|
||||
assert definition.description == "Twice daily"
|
||||
assert definition.icon == "mdi:tooth"
|
||||
assert definition.icon == "lucide:tooth"
|
||||
assert definition.is_active is True
|
||||
# D-008: the definition-level due_time column is never set here.
|
||||
assert definition.due_time is None
|
||||
@@ -232,6 +234,89 @@ def test_create_rejects_non_string_title(tmp_path) -> None:
|
||||
_with_db(tmp_path, "create-bad-title-type.db")(_body)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("icon", "stored"),
|
||||
[
|
||||
("lucide:dog", "lucide:dog"),
|
||||
("fa:dog", "fa:dog"),
|
||||
("emoji:🐕", "emoji:🐕"),
|
||||
("dog", "lucide:dog"), # legacy bare name: canonical lucide key
|
||||
],
|
||||
)
|
||||
def test_create_stores_canonical_icon(tmp_path, icon, stored) -> None:
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
result = await create_quest_definition(
|
||||
database, "Walk dog", _daily_rule(), [child.id], ["morning"],
|
||||
icon=icon,
|
||||
)
|
||||
assert result.definition.icon == stored
|
||||
row = await definitions.get(result.definition.id)
|
||||
assert row is not None
|
||||
assert row.icon == stored
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "create-icon.db")(_body)
|
||||
|
||||
|
||||
_BAD_ICONS = [
|
||||
"foo:dog",
|
||||
"lucide:",
|
||||
"lucide:Dog",
|
||||
"lucide:has space",
|
||||
"emoji:" + "🐕" * 9,
|
||||
"emoji:a b",
|
||||
"emoji:<b>",
|
||||
"javascript:alert(1)",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", _BAD_ICONS)
|
||||
def test_create_rejects_invalid_icon_and_persists_nothing(tmp_path, bad) -> None:
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
with pytest.raises(ValueError, match=r"^icon\b"):
|
||||
await create_quest_definition(
|
||||
database, "Walk dog", _daily_rule(), [child.id], ["morning"],
|
||||
icon=bad,
|
||||
)
|
||||
assert await definitions.list_active() == []
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "create-bad-icon.db")(_body)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", _BAD_ICONS)
|
||||
def test_edit_rejects_invalid_icon_and_changes_nothing(tmp_path, bad) -> None:
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
created = await create_quest_definition(
|
||||
database, "Walk dog", _daily_rule(), [child.id], ["morning"],
|
||||
icon="lucide:dog",
|
||||
)
|
||||
with pytest.raises(ValueError, match=r"^icon\b"):
|
||||
await edit_quest_definition(
|
||||
database, created.definition.id, icon=bad
|
||||
)
|
||||
row = await definitions.get(created.definition.id)
|
||||
assert row is not None
|
||||
assert row.icon == "lucide:dog"
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "edit-bad-icon.db")(_body)
|
||||
|
||||
|
||||
def test_edit_normalizes_legacy_bare_icon(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
created = await create_quest_definition(
|
||||
database, "Walk dog", _daily_rule(), [child.id], ["morning"],
|
||||
)
|
||||
result = await edit_quest_definition(
|
||||
database, created.definition.id, icon="dog"
|
||||
)
|
||||
assert result.definition.icon == "lucide:dog"
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "edit-legacy-icon.db")(_body)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [None, "daily", 42, {"rule_type": "daily"}])
|
||||
def test_create_rejects_non_schedule_rule(tmp_path, bad) -> None:
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
@@ -535,18 +620,18 @@ def test_edit_title_description_icon(tmp_path) -> None:
|
||||
[child.id],
|
||||
["morning"],
|
||||
description="Old description",
|
||||
icon="mdi:old",
|
||||
icon="lucide:old",
|
||||
)
|
||||
result = await edit_quest_definition(
|
||||
database,
|
||||
created.definition.id,
|
||||
title=" Floss ",
|
||||
description=" New description ",
|
||||
icon=" mdi:new ",
|
||||
icon="fa:new",
|
||||
)
|
||||
assert result.definition.title == "Floss"
|
||||
assert result.definition.description == "New description"
|
||||
assert result.definition.icon == "mdi:new"
|
||||
assert result.definition.icon == "fa:new"
|
||||
# Untouched fields survive the edit unchanged.
|
||||
assert result.definition.id == created.definition.id
|
||||
assert result.definition.schedule_rule_id == created.definition.schedule_rule_id
|
||||
@@ -561,7 +646,7 @@ def test_edit_title_description_icon(tmp_path) -> None:
|
||||
assert stored is not None
|
||||
assert stored.title == "Floss"
|
||||
assert stored.description == "New description"
|
||||
assert stored.icon == "mdi:new"
|
||||
assert stored.icon == "fa:new"
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "edit-metadata.db")(_body)
|
||||
@@ -597,7 +682,7 @@ def test_edit_clears_optional_metadata(tmp_path) -> None:
|
||||
[child.id],
|
||||
["morning"],
|
||||
description="Old",
|
||||
icon="mdi:old",
|
||||
icon="lucide:old",
|
||||
)
|
||||
result = await edit_quest_definition(
|
||||
database,
|
||||
@@ -2043,3 +2128,188 @@ def test_wrappers_forward_horizon_days(tmp_path, mode) -> None:
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, f"wrapper-horizon-{mode}.db")(_body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# delete_quest_definition: hybrid delete (owner decision 2026-09-26)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _count(database, sql, definition_id) -> int:
|
||||
row = await database.fetch_one(sql, (definition_id,))
|
||||
return row[0]
|
||||
|
||||
|
||||
def test_delete_without_history_hard_deletes_everything(tmp_path) -> None:
|
||||
"""No completion event on any instance: the definition, its
|
||||
instances, assignees, windows and unreferenced rule are all gone."""
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
created = await create_quest_definition(
|
||||
database,
|
||||
"Brush teeth",
|
||||
_daily_rule(),
|
||||
[child.id],
|
||||
["morning", "evening"],
|
||||
)
|
||||
definition_id = created.definition.id
|
||||
rule_id = created.definition.schedule_rule_id
|
||||
instances_sql = (
|
||||
"SELECT COUNT(*) FROM quest_instances WHERE definition_id = ?"
|
||||
)
|
||||
# Create materialized future instances, so the delete has
|
||||
# instance rows to remove (not just an empty table).
|
||||
assert await _count(database, instances_sql, definition_id) > 0
|
||||
|
||||
result = await delete_quest_definition(database, definition_id)
|
||||
|
||||
assert result == DeletedQuestDefinition(
|
||||
definition_id=definition_id,
|
||||
deleted=True,
|
||||
retired=False,
|
||||
definition=None,
|
||||
)
|
||||
assert await definitions.get(definition_id) is None
|
||||
assert await rules.get(rule_id) is None
|
||||
assert await _count(database, instances_sql, definition_id) == 0
|
||||
for table in (
|
||||
"quest_definition_assignees",
|
||||
"quest_definition_windows",
|
||||
):
|
||||
assert await _count(
|
||||
database,
|
||||
f"SELECT COUNT(*) FROM {table} WHERE definition_id = ?",
|
||||
definition_id,
|
||||
) == 0
|
||||
# The child itself is untouched.
|
||||
assert (await children.get(child.id)) is not None
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "delete-no-history.db")(_body)
|
||||
|
||||
|
||||
def test_delete_without_history_keeps_a_shared_rule(tmp_path) -> None:
|
||||
"""The rule is deleted only when no other definition references it."""
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
rule = await rules.create("daily", "2026-09-14")
|
||||
first = await definitions.create(
|
||||
"First", rule.id, NOW, assignee_child_ids=[child.id]
|
||||
)
|
||||
second = await definitions.create(
|
||||
"Second", rule.id, NOW, assignee_child_ids=[child.id]
|
||||
)
|
||||
|
||||
result = await delete_quest_definition(database, first.id)
|
||||
|
||||
assert result.deleted is True
|
||||
assert await definitions.get(first.id) is None
|
||||
assert await definitions.get(second.id) is not None
|
||||
assert await rules.get(rule.id) is not None
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "delete-shared-rule.db")(_body)
|
||||
|
||||
|
||||
def test_delete_with_history_retires_and_preserves_everything(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""A completion event on ANY instance: the definition is retired
|
||||
(deactivated) and its row, rule, assignees, windows and completion
|
||||
history all remain. The completed instance survives; only future
|
||||
UNCOMPLETED instances are cleared by the deactivation's regeneration."""
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
created = await create_quest_definition(
|
||||
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
|
||||
)
|
||||
definition_id = created.definition.id
|
||||
rule_id = created.definition.schedule_rule_id
|
||||
instances = QuestInstancesDao(database)
|
||||
events = CompletionEventsDao(database)
|
||||
instance = await instances.upsert(
|
||||
definition_id, child.id, _today_iso(), NOW, window="morning"
|
||||
)
|
||||
event = await events.append(
|
||||
instance.id,
|
||||
child.id,
|
||||
"completed",
|
||||
"user",
|
||||
NOW,
|
||||
True,
|
||||
actor_user_id="admin-1",
|
||||
)
|
||||
|
||||
result = await delete_quest_definition(database, definition_id)
|
||||
|
||||
assert result.deleted is False
|
||||
assert result.retired is True
|
||||
assert result.definition_id == definition_id
|
||||
assert isinstance(result.definition, CreatedQuestDefinition)
|
||||
assert result.definition.definition.is_active is False
|
||||
stored = await definitions.get(definition_id)
|
||||
assert stored is not None
|
||||
assert stored.is_active is False
|
||||
assert await rules.get(rule_id) is not None
|
||||
assert [c.id for c in await definitions.list_assignees(
|
||||
definition_id
|
||||
)] == [child.id]
|
||||
assert [w.window for w in await definitions.list_windows(
|
||||
definition_id
|
||||
)] == ["morning"]
|
||||
assert await instances.get_by_id(instance.id) == instance
|
||||
assert await events.list_by_instance(instance.id) == [event]
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "delete-with-history.db")(_body)
|
||||
|
||||
|
||||
def test_delete_with_uncompleted_history_still_retires(tmp_path) -> None:
|
||||
"""ANY completion event counts as history — even when the latest
|
||||
event is an un-completion, the definition is retired, not deleted."""
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
created = await create_quest_definition(
|
||||
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
|
||||
)
|
||||
definition_id = created.definition.id
|
||||
instances = QuestInstancesDao(database)
|
||||
events = CompletionEventsDao(database)
|
||||
instance = await instances.upsert(
|
||||
definition_id, child.id, _today_iso(), NOW, window="morning"
|
||||
)
|
||||
await events.append(
|
||||
instance.id, child.id, "completed", "user", NOW, True,
|
||||
actor_user_id="admin-1",
|
||||
)
|
||||
await events.append(
|
||||
instance.id, child.id, "uncompleted", "user", NOW, None,
|
||||
actor_user_id="admin-1",
|
||||
)
|
||||
|
||||
result = await delete_quest_definition(database, definition_id)
|
||||
|
||||
assert result.retired is True
|
||||
assert await instances.get_by_id(instance.id) == instance
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "delete-uncompleted-history.db")(_body)
|
||||
|
||||
|
||||
def test_delete_nonexistent_definition_names_field(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
with pytest.raises(ValueError, match=r"^definition_id:"):
|
||||
await delete_quest_definition(database, 999)
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "delete-missing.db")(_body)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [True, 1.0, "1", None])
|
||||
def test_delete_rejects_non_int_definition_id(tmp_path, bad) -> None:
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
created = await create_quest_definition(
|
||||
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
|
||||
)
|
||||
with pytest.raises(ValueError, match="definition_id"):
|
||||
await delete_quest_definition(database, bad)
|
||||
assert await definitions.get(created.definition.id) is not None
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "delete-bad-id.db")(_body)
|
||||
|
||||
@@ -76,6 +76,7 @@ def _payload(children) -> dict:
|
||||
return {
|
||||
"today_iso": _today_iso(),
|
||||
"cycle_day": 0,
|
||||
"timezone": "",
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
@@ -569,6 +570,22 @@ async def test_household_due_sensor_publishes_the_ordered_child_roster(
|
||||
}
|
||||
|
||||
|
||||
async def test_household_due_sensor_publishes_the_stored_zone(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The rollup carries the snapshot's stored application zone verbatim
|
||||
(the panel cards format their date and clock in it); ``""`` — no zone
|
||||
stored — passes through unchanged for the cards' documented fallback."""
|
||||
zoned = {**_seeded_payload(), "timezone": "Pacific/Auckland"}
|
||||
entry, coordinator, _client = await _setup_entry(
|
||||
hass, make_entry, zoned, _seeded_payload()
|
||||
)
|
||||
rollup = hass.entities["nestquest_household_quests_due_today"]
|
||||
assert rollup.extra_state_attributes["timezone"] == "Pacific/Auckland"
|
||||
await coordinator.async_refresh()
|
||||
assert rollup.extra_state_attributes["timezone"] == ""
|
||||
|
||||
|
||||
async def test_household_roster_tracks_the_snapshot_on_refresh(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
|
||||
@@ -151,7 +151,12 @@ DOCUMENTED_FIELDS = {
|
||||
|
||||
#: A minimal panel snapshot payload for the full-setup wiring tests
|
||||
#: (the shape ``snapshot_from_api_payload`` consumes; no children).
|
||||
MINIMAL_SNAPSHOT = {"today_iso": "2026-09-23", "cycle_day": 1, "children": []}
|
||||
MINIMAL_SNAPSHOT = {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 1,
|
||||
"timezone": "",
|
||||
"children": [],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"lucide": [
|
||||
{ "name": "bed", "label": "Bed" },
|
||||
{ "name": "bath", "label": "Bath" },
|
||||
{ "name": "shower-head", "label": "Shower" },
|
||||
{ "name": "toilet", "label": "Toilet" },
|
||||
{ "name": "brush", "label": "Brush" },
|
||||
{ "name": "smile", "label": "Smile" },
|
||||
{ "name": "shirt", "label": "Clothes" },
|
||||
{ "name": "washing-machine", "label": "Laundry" },
|
||||
{ "name": "utensils", "label": "Meal" },
|
||||
{ "name": "cooking-pot", "label": "Cooking" },
|
||||
{ "name": "apple", "label": "Snack" },
|
||||
{ "name": "brush-cleaning", "label": "Sweep" },
|
||||
{ "name": "spray-can", "label": "Clean" },
|
||||
{ "name": "trash-2", "label": "Bins" },
|
||||
{ "name": "recycle", "label": "Recycling" },
|
||||
{ "name": "sofa", "label": "Tidy" },
|
||||
{ "name": "house", "label": "Home" },
|
||||
{ "name": "backpack", "label": "School bag" },
|
||||
{ "name": "book-open", "label": "Reading" },
|
||||
{ "name": "pencil", "label": "Homework" },
|
||||
{ "name": "music", "label": "Music" },
|
||||
{ "name": "dog", "label": "Dog" },
|
||||
{ "name": "cat", "label": "Cat" },
|
||||
{ "name": "fish", "label": "Fish" },
|
||||
{ "name": "sprout", "label": "Plants" },
|
||||
{ "name": "droplets", "label": "Water" },
|
||||
{ "name": "bike", "label": "Bike" },
|
||||
{ "name": "volleyball", "label": "Sport" },
|
||||
{ "name": "gamepad-2", "label": "Games" },
|
||||
{ "name": "star", "label": "Star" },
|
||||
{ "name": "heart", "label": "Heart" },
|
||||
{ "name": "sun", "label": "Morning" },
|
||||
{ "name": "moon", "label": "Bedtime" },
|
||||
{ "name": "clock", "label": "Clock" },
|
||||
{ "name": "calendar", "label": "Calendar" },
|
||||
{ "name": "list-checks", "label": "Checklist" }
|
||||
],
|
||||
"fa": [
|
||||
{ "name": "broom", "label": "Sweep" },
|
||||
{ "name": "spray-can-sparkles", "label": "Clean" },
|
||||
{ "name": "bucket", "label": "Mop" },
|
||||
{ "name": "soap", "label": "Wash hands" },
|
||||
{ "name": "sink", "label": "Dishes" },
|
||||
{ "name": "jug-detergent", "label": "Laundry" },
|
||||
{ "name": "socks", "label": "Socks" },
|
||||
{ "name": "shirt", "label": "Clothes" },
|
||||
{ "name": "bed", "label": "Bed" },
|
||||
{ "name": "couch", "label": "Tidy" },
|
||||
{ "name": "bath", "label": "Bath" },
|
||||
{ "name": "tooth", "label": "Teeth" },
|
||||
{ "name": "utensils", "label": "Meal" },
|
||||
{ "name": "bowl-food", "label": "Set the table" },
|
||||
{ "name": "basket-shopping", "label": "Groceries" },
|
||||
{ "name": "trash-can", "label": "Bins" },
|
||||
{ "name": "recycle", "label": "Recycling" },
|
||||
{ "name": "dog", "label": "Dog" },
|
||||
{ "name": "cat", "label": "Cat" },
|
||||
{ "name": "paw", "label": "Pets" },
|
||||
{ "name": "seedling", "label": "Plants" },
|
||||
{ "name": "book", "label": "Reading" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"_comment": "Shared emoji-payload verdicts. tests/test_core_icons.py (validate_icon) and admin/src/icons/registry.test.ts (resolveIcon) both assert every row, so the Python and JS rules cannot diverge silently.",
|
||||
"cases": [
|
||||
{ "payload": "🐕", "valid": true, "why": "plain emoji" },
|
||||
{ "payload": "👨👩👧👦", "valid": true, "why": "ZWJ family (U+200D allowed)" },
|
||||
{ "payload": "⭐⭐⭐⭐⭐⭐⭐⭐", "valid": true, "why": "exactly 8 code points" },
|
||||
{ "payload": "⭐⭐⭐⭐⭐⭐⭐⭐⭐", "valid": false, "why": "9 code points, over the cap" },
|
||||
{ "payload": "", "valid": false, "why": "empty" },
|
||||
{ "payload": "", "valid": false, "why": "U+FEFF BOM alone" },
|
||||
{ "payload": "🐕", "valid": false, "why": "trailing U+FEFF" },
|
||||
{ "payload": "🐕 ", "valid": false, "why": "trailing space" },
|
||||
{ "payload": "a b", "valid": false, "why": "inner space" },
|
||||
{ "payload": "🐕 ", "valid": false, "why": "no-break space" },
|
||||
{ "payload": "🐕 ", "valid": false, "why": "ideographic space" },
|
||||
{ "payload": "🐕
", "valid": false, "why": "line separator" },
|
||||
{ "payload": "🐕\n", "valid": false, "why": "newline" },
|
||||
{ "payload": "🐕\u0000", "valid": false, "why": "NUL control" },
|
||||
{ "payload": "🐕\u0007🐕", "valid": false, "why": "BEL control" },
|
||||
{ "payload": "🐕\u001c", "valid": false, "why": "file-separator control" },
|
||||
{ "payload": "🐕\u0085", "valid": false, "why": "NEL C1 control" },
|
||||
{ "payload": "🐕\u007f", "valid": false, "why": "DEL control" },
|
||||
{ "payload": "<b>", "valid": false, "why": "markup <" },
|
||||
{ "payload": "a>b", "valid": false, "why": "markup >" },
|
||||
{ "payload": "a&b", "valid": false, "why": "entity start &" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Offline generator for the shared quest-icon registry.
|
||||
*
|
||||
* Reads the curated names + labels in `curated-icons.json` (the one source of
|
||||
* truth for the quick-pick lists), pulls each glyph's SVG path data from
|
||||
* locally installed packages in `admin/node_modules` (lucide-react and
|
||||
* @fortawesome/free-solid-svg-icons — no network), and writes the same
|
||||
* TypeScript module into both bundles:
|
||||
*
|
||||
* admin/src/icons/registry.generated.ts
|
||||
* frontend/src/icons/registry.generated.ts
|
||||
*
|
||||
* node tools/icons/generate.mjs # write both artefacts
|
||||
* node tools/icons/generate.mjs --check # exit 1 if either artefact drifted
|
||||
*
|
||||
* Lucide circles, rects and lines are converted to path `d` strings so every
|
||||
* glyph is plain `{ viewBox, paths }` data any renderer can draw.
|
||||
*
|
||||
* Besides the curated sets, the module embeds the complete Font Awesome Free
|
||||
* Solid set (`FA_SOLID_ICONS`). Its source is the installed
|
||||
* @fortawesome/free-solid-svg-icons package itself: every exported icon
|
||||
* definition, de-duplicated by canonical `iconName` (the package also exports
|
||||
* each legacy alias, e.g. faHome for `house`, as the same definition). The
|
||||
* package carries no human labels, so each label is the kebab name in Title
|
||||
* Case (`spray-can-sparkles` → "Spray Can Sparkles").
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
export const REPO_ROOT = join(HERE, "..", "..");
|
||||
export const SOURCE_PATH = join(HERE, "curated-icons.json");
|
||||
export const OUTPUT_PATHS = [
|
||||
join(REPO_ROOT, "admin", "src", "icons", "registry.generated.ts"),
|
||||
join(REPO_ROOT, "frontend", "src", "icons", "registry.generated.ts"),
|
||||
];
|
||||
|
||||
const ADMIN_MODULES = join(REPO_ROOT, "admin", "node_modules");
|
||||
const LUCIDE_ICON_DIR = join(ADMIN_MODULES, "lucide-react", "dist", "esm", "icons");
|
||||
const requireFromAdmin = createRequire(join(REPO_ROOT, "admin", "package.json"));
|
||||
|
||||
function packageVersion(name) {
|
||||
const pkg = JSON.parse(readFileSync(join(ADMIN_MODULES, name, "package.json"), "utf8"));
|
||||
return pkg.version;
|
||||
}
|
||||
|
||||
function num(value, what) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) throw new Error(`${what}: not a number: ${value}`);
|
||||
return n;
|
||||
}
|
||||
|
||||
/** One Lucide icon-node element as a path `d` string. */
|
||||
function elementToPath(tag, attrs, icon) {
|
||||
const where = `lucide ${icon} <${tag}>`;
|
||||
const known = { path: ["d"], circle: ["cx", "cy", "r"], rect: ["x", "y", "width", "height", "rx", "ry"], line: ["x1", "y1", "x2", "y2"] };
|
||||
if (!(tag in known)) throw new Error(`${where}: unsupported element`);
|
||||
for (const attr of Object.keys(attrs)) {
|
||||
if (attr !== "key" && !known[tag].includes(attr)) throw new Error(`${where}: unsupported attribute ${attr}`);
|
||||
}
|
||||
if (tag === "path") return attrs.d;
|
||||
if (tag === "line") {
|
||||
return `M${num(attrs.x1, where)} ${num(attrs.y1, where)}L${num(attrs.x2, where)} ${num(attrs.y2, where)}`;
|
||||
}
|
||||
if (tag === "circle") {
|
||||
const [cx, cy, r] = [num(attrs.cx, where), num(attrs.cy, where), num(attrs.r, where)];
|
||||
return `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0`;
|
||||
}
|
||||
const [x, y, w, h] = [num(attrs.x ?? 0, where), num(attrs.y ?? 0, where), num(attrs.width, where), num(attrs.height, where)];
|
||||
const rxRaw = attrs.rx ?? attrs.ry;
|
||||
const ryRaw = attrs.ry ?? attrs.rx;
|
||||
const rx = Math.min(rxRaw === undefined ? 0 : num(rxRaw, where), w / 2);
|
||||
const ry = Math.min(ryRaw === undefined ? 0 : num(ryRaw, where), h / 2);
|
||||
if (rx === 0 || ry === 0) return `M${x} ${y}h${w}v${h}h${-w}z`;
|
||||
const iw = w - 2 * rx;
|
||||
const ih = h - 2 * ry;
|
||||
return (
|
||||
`M${x + rx} ${y}h${iw}a${rx} ${ry} 0 0 1 ${rx} ${ry}v${ih}` +
|
||||
`a${rx} ${ry} 0 0 1 ${-rx} ${ry}h${-iw}a${rx} ${ry} 0 0 1 ${-rx} ${-ry}` +
|
||||
`v${-ih}a${rx} ${ry} 0 0 1 ${rx} ${-ry}z`
|
||||
);
|
||||
}
|
||||
|
||||
/** A Lucide icon's node list; follows lucide-react's alias re-exports (smile → face-slightly-smiling). */
|
||||
async function lucideNode(name, seen = []) {
|
||||
if (seen.includes(name)) throw new Error(`lucide ${name}: alias cycle ${seen.join(" -> ")}`);
|
||||
const file = join(LUCIDE_ICON_DIR, `${name}.mjs`);
|
||||
const mod = await import(pathToFileURL(file).href);
|
||||
if (mod.__iconData) return mod.__iconData.node;
|
||||
const alias = /export \{ default \} from '\.\/([a-z0-9-]+)\.mjs';/.exec(readFileSync(file, "utf8"));
|
||||
if (!alias) throw new Error(`lucide ${name}: no icon data in ${file}`);
|
||||
return lucideNode(alias[1], [...seen, name]);
|
||||
}
|
||||
|
||||
function faExportName(name) {
|
||||
return "fa" + name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join("");
|
||||
}
|
||||
|
||||
function faGlyph(definition, label) {
|
||||
const { iconName: name } = definition;
|
||||
const [width, height, , , pathData] = definition.icon;
|
||||
const paths = Array.isArray(pathData) ? pathData.filter(Boolean) : [pathData];
|
||||
return { kind: "fa", key: `fa:${name}`, name, label, viewBox: `0 0 ${width} ${height}`, paths };
|
||||
}
|
||||
|
||||
function titleCase(name) {
|
||||
return name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
|
||||
}
|
||||
|
||||
/** Every Free Solid glyph, one per canonical name, sorted by name. */
|
||||
function allSolidGlyphs(faPackage) {
|
||||
const byName = new Map();
|
||||
for (const definition of Object.values(faPackage)) {
|
||||
if (!definition || typeof definition !== "object" || !Array.isArray(definition.icon)) continue;
|
||||
if (definition.prefix !== "fas") throw new Error(`fa ${definition.iconName}: unexpected prefix ${definition.prefix}`);
|
||||
const name = definition.iconName;
|
||||
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(name)) throw new Error(`fa ${name}: not a kebab name`);
|
||||
const seen = byName.get(name);
|
||||
if (seen && JSON.stringify(seen.icon) !== JSON.stringify(definition.icon)) {
|
||||
throw new Error(`fa ${name}: two exports disagree on the glyph`);
|
||||
}
|
||||
byName.set(name, definition);
|
||||
}
|
||||
if (byName.size === 0) throw new Error("fa: no icons found in @fortawesome/free-solid-svg-icons");
|
||||
return [...byName.keys()]
|
||||
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map((name) => faGlyph(byName.get(name), titleCase(name)));
|
||||
}
|
||||
|
||||
function checkCurated(list, namespace) {
|
||||
const seen = new Set();
|
||||
for (const { name, label } of list) {
|
||||
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(name)) throw new Error(`${namespace}:${name}: not a kebab name`);
|
||||
if (typeof label !== "string" || !label.trim()) throw new Error(`${namespace}:${name}: missing label`);
|
||||
if (seen.has(name)) throw new Error(`${namespace}:${name}: duplicate`);
|
||||
seen.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildEntries() {
|
||||
const curated = JSON.parse(readFileSync(SOURCE_PATH, "utf8"));
|
||||
checkCurated(curated.lucide, "lucide");
|
||||
checkCurated(curated.fa, "fa");
|
||||
|
||||
const lucide = [];
|
||||
for (const { name, label } of curated.lucide) {
|
||||
const node = await lucideNode(name);
|
||||
const paths = node.map(([tag, attrs]) => elementToPath(tag, attrs, name));
|
||||
lucide.push({ kind: "lucide", key: `lucide:${name}`, name, label, viewBox: "0 0 24 24", paths });
|
||||
}
|
||||
|
||||
const faPackage = requireFromAdmin("@fortawesome/free-solid-svg-icons");
|
||||
const fa = [];
|
||||
for (const { name, label } of curated.fa) {
|
||||
const definition = faPackage[faExportName(name)];
|
||||
if (!definition || definition.iconName !== name) throw new Error(`fa ${name}: not in @fortawesome/free-solid-svg-icons`);
|
||||
fa.push(faGlyph(definition, label));
|
||||
}
|
||||
return { lucide, fa, faSolid: allSolidGlyphs(faPackage) };
|
||||
}
|
||||
|
||||
const RESOLVER = `
|
||||
export const FALLBACK_ICON: FallbackIcon = { kind: "fallback", key: null };
|
||||
|
||||
/** Mirrors core/icons.py EMOJI_MAX_CODE_POINTS. */
|
||||
export const EMOJI_MAX_CODE_POINTS = 8;
|
||||
|
||||
const LUCIDE_BY_NAME: ReadonlyMap<string, GlyphIcon> = new Map(LUCIDE_ICONS.map((icon) => [icon.name, icon]));
|
||||
/** The full Free Solid set, with each curated entry (and its quick-pick label) taking precedence. */
|
||||
const FA_BY_NAME: ReadonlyMap<string, GlyphIcon> = new Map(
|
||||
[...FA_SOLID_ICONS, ...FA_ICONS].map((icon) => [icon.name, icon]),
|
||||
);
|
||||
|
||||
function isEmojiPayload(text: string): boolean {
|
||||
const codePoints = [...text];
|
||||
if (codePoints.length === 0 || codePoints.length > EMOJI_MAX_CODE_POINTS) return false;
|
||||
return !codePoints.some((char) => /[\\s<>&]|\\p{Cc}/u.test(char));
|
||||
}
|
||||
|
||||
/**
|
||||
* The renderable icon for a stored definition \`icon\` value.
|
||||
*
|
||||
* \`lucide:<name>\` and a legacy bare \`<name>\` resolve against the curated
|
||||
* Lucide set, \`fa:<name>\` against the full Font Awesome Free Solid set
|
||||
* (curated entries keep their quick-pick labels), and
|
||||
* \`emoji:<grapheme>\` to its text. Like the backend's
|
||||
* normalize_icon_for_read, nothing here throws: null, empty, an unknown
|
||||
* namespace or name, or a malformed emoji all resolve to FALLBACK_ICON.
|
||||
*/
|
||||
export function resolveIcon(value: string | null | undefined): ResolvedIcon {
|
||||
if (!value || !value.trim()) return FALLBACK_ICON;
|
||||
const colon = value.indexOf(":");
|
||||
if (colon === -1) return LUCIDE_BY_NAME.get(value) ?? FALLBACK_ICON;
|
||||
const namespace = value.slice(0, colon);
|
||||
const name = value.slice(colon + 1);
|
||||
if (namespace === "lucide") return LUCIDE_BY_NAME.get(name) ?? FALLBACK_ICON;
|
||||
if (namespace === "fa") return FA_BY_NAME.get(name) ?? FALLBACK_ICON;
|
||||
if (namespace === "emoji" && isEmojiPayload(name)) return { kind: "emoji", key: value, text: name };
|
||||
return FALLBACK_ICON;
|
||||
}
|
||||
`;
|
||||
|
||||
/** The generated module's full text (identical for both bundles). */
|
||||
export async function renderRegistry() {
|
||||
const { lucide, fa, faSolid } = await buildEntries();
|
||||
const source = relative(REPO_ROOT, SOURCE_PATH);
|
||||
return `// GENERATED by tools/icons/generate.mjs from ${source} — do not edit.
|
||||
// Regenerate with \`node tools/icons/generate.mjs\` (the drift tests fail if this
|
||||
// file differs from a fresh generation).
|
||||
//
|
||||
// Glyph path data is embedded at generation time; nothing is fetched at runtime.
|
||||
// Lucide glyphs: lucide-react ${packageVersion("lucide-react")} (ISC licence).
|
||||
// Font Awesome glyphs: @fortawesome/free-solid-svg-icons ${packageVersion("@fortawesome/free-solid-svg-icons")}
|
||||
// (Font Awesome Free, icons under CC BY 4.0).
|
||||
|
||||
/** A vector glyph: draw each path in \`paths\` inside an <svg viewBox>. Lucide
|
||||
* glyphs are stroked (fill none, stroke-width 2, round caps and joins);
|
||||
* Font Awesome glyphs are filled. */
|
||||
export interface GlyphIcon {
|
||||
kind: "lucide" | "fa";
|
||||
key: string;
|
||||
name: string;
|
||||
label: string;
|
||||
viewBox: string;
|
||||
paths: readonly string[];
|
||||
}
|
||||
|
||||
/** An emoji icon; render \`text\` as text. */
|
||||
export interface EmojiIcon {
|
||||
kind: "emoji";
|
||||
key: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** No usable icon: the caller draws its own default glyph. */
|
||||
export interface FallbackIcon {
|
||||
kind: "fallback";
|
||||
key: null;
|
||||
}
|
||||
|
||||
export type ResolvedIcon = GlyphIcon | EmojiIcon | FallbackIcon;
|
||||
|
||||
export const LUCIDE_ICONS: readonly GlyphIcon[] = ${JSON.stringify(lucide, null, 2)};
|
||||
|
||||
/** The curated Font Awesome quick-pick set. */
|
||||
export const FA_ICONS: readonly GlyphIcon[] = ${JSON.stringify(fa, null, 2)};
|
||||
|
||||
/** Every Font Awesome Free Solid glyph (${faSolid.length}), sorted by name; one entry per line. */
|
||||
export const FA_SOLID_ICONS: readonly GlyphIcon[] = [
|
||||
${faSolid.map((icon) => JSON.stringify(icon)).join(",\n")}
|
||||
];
|
||||
${RESOLVER}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const check = process.argv.includes("--check");
|
||||
const text = await renderRegistry();
|
||||
let drifted = false;
|
||||
for (const path of OUTPUT_PATHS) {
|
||||
const shown = relative(REPO_ROOT, path);
|
||||
if (check) {
|
||||
let current = null;
|
||||
try {
|
||||
current = readFileSync(path, "utf8");
|
||||
} catch {
|
||||
// missing counts as drift
|
||||
}
|
||||
if (current !== text) {
|
||||
drifted = true;
|
||||
console.error(`drift: ${shown} differs from a fresh generation`);
|
||||
}
|
||||
} else {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, text);
|
||||
console.log(`wrote ${shown}`);
|
||||
}
|
||||
}
|
||||
if (drifted) {
|
||||
console.error("run `node tools/icons/generate.mjs` and commit the result");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await main();
|
||||
}
|
||||
Reference in New Issue
Block a user