Compare commits

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

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 178 KiB

+112 -22
View File
@@ -1,14 +1,12 @@
// Generates the committed PWA icons under public/icons/ as genuine PNGs.
// Generates the committed PWA icons under public/icons/ from the brand logo
// (design/logos/NestQuest_Logo.png), box-filter downscaled.
// Pure Node (zlib only) — no network, no image dependencies.
// Run: node scripts/generate-icons.mjs
import { writeFileSync } from "node:fs";
import { deflateSync } from "node:zlib";
import { readFileSync, writeFileSync } from "node:fs";
import { deflateSync, inflateSync } from "node:zlib";
import { fileURLToPath } from "node:url";
// Brand tokens (design/tokens/nestquest-admin-tokens.css).
const BLUE = [0x3b, 0x3a, 0xb8];
const PURPLE = [0xa0, 0x35, 0xcc];
const WHITE = [0xff, 0xff, 0xff];
const SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
let c = n;
@@ -44,37 +42,129 @@ function encodePng(size, pixel) {
for (let x = 0; x < size; x++) raw.set(pixel(x, y), row + 1 + x * 3);
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
SIG,
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw, { level: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
// Flat brand mark: diagonal blue→purple gradient with a white ring ("nest")
// around a white dot ("quest"). `scale` shrinks the mark for the maskable
// safe zone (inner 80% circle).
function brandMark(size, scale) {
const c = size / 2;
const outer = size * 0.34 * scale;
const inner = size * 0.24 * scale;
const dot = size * 0.1 * scale;
// Minimal decoder for the source logo: 8-bit RGB, non-interlaced only.
// Anything else fails loudly rather than producing a wrong icon.
function decodePng(buf) {
if (!buf.subarray(0, 8).equals(SIG)) throw new Error("not a PNG");
let width, height;
const idat = [];
for (let off = 8; off < buf.length; ) {
const len = buf.readUInt32BE(off);
const type = buf.toString("ascii", off + 4, off + 8);
const data = buf.subarray(off + 8, off + 8 + len);
if (type === "IHDR") {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
const [depth, colour, , , interlace] = data.subarray(8);
if (depth !== 8 || colour !== 2 || interlace !== 0) {
throw new Error(`unsupported PNG: depth ${depth}, colour type ${colour}, interlace ${interlace}`);
}
} else if (type === "IDAT") idat.push(data);
else if (type === "IEND") break;
off += 12 + len;
}
const raw = inflateSync(Buffer.concat(idat));
const stride = width * 3;
const px = Buffer.alloc(stride * height);
for (let y = 0; y < height; y++) {
const filter = raw[y * (stride + 1)];
const src = y * (stride + 1) + 1;
const dst = y * stride;
for (let i = 0; i < stride; i++) {
const a = i >= 3 ? px[dst + i - 3] : 0;
const b = y > 0 ? px[dst - stride + i] : 0;
const c = i >= 3 && y > 0 ? px[dst - stride + i - 3] : 0;
let p;
if (filter === 0) p = 0;
else if (filter === 1) p = a;
else if (filter === 2) p = b;
else if (filter === 3) p = (a + b) >> 1;
else if (filter === 4) {
const pa = Math.abs(b - c), pb = Math.abs(a - c), pc = Math.abs(a + b - 2 * c);
p = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
} else throw new Error(`bad PNG filter ${filter} on row ${y}`);
px[dst + i] = (raw[src + i] + p) & 0xff;
}
}
return { width, height, px };
}
// Box-filter (area-average) resize along one axis: each output sample is the
// coverage-weighted mean of the source samples it spans.
function boxWeights(srcLen, dstLen) {
const scale = srcLen / dstLen;
return Array.from({ length: dstLen }, (_, o) => {
const start = o * scale, end = start + scale;
const taps = [];
for (let s = Math.floor(start); s < Math.min(Math.ceil(end), srcLen); s++) {
taps.push([s, (Math.min(end, s + 1) - Math.max(start, s)) / scale]);
}
return taps;
});
}
function resize({ width, height, px }, size) {
const wx = boxWeights(width, size);
const wy = boxWeights(height, size);
const tmp = new Float64Array(size * height * 3); // horizontal pass
for (let y = 0; y < height; y++) {
for (let x = 0; x < size; x++) {
for (const [s, w] of wx[x]) {
for (let k = 0; k < 3; k++) tmp[(y * size + x) * 3 + k] += px[(y * width + s) * 3 + k] * w;
}
}
}
const out = new Uint8Array(size * size * 3); // vertical pass
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
for (let k = 0; k < 3; k++) {
let v = 0;
for (const [s, w] of wy[y]) v += tmp[(s * size + x) * 3 + k] * w;
out[(y * size + x) * 3 + k] = Math.min(255, Math.round(v));
}
}
}
return (x, y) => out.subarray((y * size + x) * 3, (y * size + x) * 3 + 3);
}
// Mean of the four corner pixels — the logo's solid background colour.
function cornerColour({ width, height, px }) {
const corners = [[0, 0], [width - 1, 0], [0, height - 1], [width - 1, height - 1]];
return [0, 1, 2].map((k) =>
Math.round(corners.reduce((sum, [x, y]) => sum + px[(y * width + x) * 3 + k], 0) / 4),
);
}
// `scale` < 1 shrinks the logo onto a background-coloured canvas so a
// maskable icon keeps the whole emblem inside the safe zone (inner 80% circle).
function logoIcon(logo, size, scale) {
if (scale === 1) return resize(logo, size);
const inner = Math.round(size * scale);
const offset = Math.floor((size - inner) / 2);
const pixel = resize(logo, inner);
const bg = cornerColour(logo);
return (x, y) => {
const d = Math.hypot(x + 0.5 - c, y + 0.5 - c);
if ((d <= outer && d >= inner) || d <= dot) return WHITE;
const t = (x + y) / (2 * (size - 1));
return BLUE.map((b, i) => Math.round(b + (PURPLE[i] - b) * t));
const ix = x - offset, iy = y - offset;
return ix >= 0 && iy >= 0 && ix < inner && iy < inner ? pixel(ix, iy) : bg;
};
}
const logo = decodePng(readFileSync(new URL("../../design/logos/NestQuest_Logo.png", import.meta.url)));
const outDir = new URL("../public/icons/", import.meta.url);
const icons = [
["icon-192.png", 192, 1],
["icon-512.png", 512, 1],
["icon-maskable-512.png", 512, 0.75],
["icon-maskable-512.png", 512, 0.8],
];
for (const [name, size, scale] of icons) {
const path = fileURLToPath(new URL(name, outDir));
writeFileSync(path, encodePng(size, brandMark(size, scale)));
writeFileSync(path, encodePng(size, logoIcon(logo, size, scale)));
console.log(`wrote ${path}`);
}
+50 -7
View File
@@ -2,10 +2,29 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import App from "./App";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
let settingsResponse: () => Promise<Response>;
describe("NestQuest Admin shell", () => {
beforeEach(() => {
// The Today tab fetches the admin snapshot; keep the shell tests offline.
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
settingsResponse = () =>
Promise.resolve(jsonResponse({ timezone: "", effective_timezone: "Europe/London" }));
// The shell loads the stored application timezone; every tab's own
// requests stay pending so the shell tests remain offline.
vi.stubGlobal(
"fetch",
vi.fn((url: string) =>
String(url).endsWith("/api/v1/admin/settings")
? settingsResponse()
: new Promise<Response>(() => {}),
),
);
});
afterEach(() => {
@@ -22,10 +41,10 @@ describe("NestQuest Admin shell", () => {
}
});
it("starts on the Today screen and switches on tab click", () => {
it("starts on the Today screen and switches on tab click", async () => {
render(<App />);
expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy();
expect(await screen.findByRole("heading", { name: "Today" })).toBeTruthy();
fireEvent.click(screen.getByRole("tab", { name: "History" }));
@@ -36,12 +55,36 @@ describe("NestQuest Admin shell", () => {
);
});
it("the Tasks tab renders the definitions screen", () => {
it("the Tasks tab renders the definitions screen", async () => {
render(<App />);
fireEvent.click(screen.getByRole("tab", { name: "Tasks" }));
expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy();
expect(await screen.findByRole("heading", { name: "Tasks" })).toBeTruthy();
expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder");
});
});
it("renders no screen until the stored application timezone is known", async () => {
settingsResponse = () => new Promise<Response>(() => {});
render(<App />);
expect(screen.getByTestId("timezone-loading")).toBeTruthy();
expect(screen.queryByRole("heading", { name: "Today" })).toBeNull();
});
it("a failed timezone load says so and retries", async () => {
settingsResponse = () => Promise.resolve(jsonResponse({ detail: "boom" }, 500));
render(<App />);
await screen.findByTestId("timezone-error");
expect(screen.queryByRole("heading", { name: "Today" })).toBeNull();
settingsResponse = () =>
Promise.resolve(
jsonResponse({ timezone: "Pacific/Auckland", effective_timezone: "Pacific/Auckland" }),
);
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(await screen.findByRole("heading", { name: "Today" })).toBeTruthy();
});
});
+13 -9
View File
@@ -4,6 +4,7 @@ import { TransitionsProvider } from "./api/useTransitions";
import DefinitionsTab from "./definitions/DefinitionsTab";
import HistoryTab from "./history/HistoryTab";
import ScheduleTab from "./schedule/ScheduleTab";
import { AppTimezoneProvider } from "./time/AppTimezone";
import TodayTab from "./today/TodayTab";
const TABS = [
@@ -32,15 +33,18 @@ export default function App() {
role="tabpanel"
aria-labelledby={`tab-${active}`}
>
{active === "today" ? (
<TodayTab />
) : active === "tasks" ? (
<DefinitionsTab />
) : active === "schedule" ? (
<ScheduleTab />
) : (
<HistoryTab />
)}
{/* Every screen's local time is the stored application timezone. */}
<AppTimezoneProvider>
{active === "today" ? (
<TodayTab />
) : active === "tasks" ? (
<DefinitionsTab />
) : active === "schedule" ? (
<ScheduleTab />
) : (
<HistoryTab />
)}
</AppTimezoneProvider>
</main>
<nav className="admin-tabbar" aria-label="Admin sections">
{TABS.map((tab) => (
+6 -1
View File
@@ -19,10 +19,15 @@ export interface HouseholdSettings {
celebration_enabled: boolean;
/** IANA zone name (e.g. "America/New_York"); "" means the API host's local time. */
timezone: string;
/**
* Read-only: the zone the API reads its clock in — `timezone`, or the API
* host's own zone when that is "" ("" only when the host's zone has no name).
*/
effective_timezone: string;
}
/** Only the supplied fields are updated; the API answers with all of them. */
export type SettingsChanges = Partial<HouseholdSettings>;
export type SettingsChanges = Partial<Omit<HouseholdSettings, "effective_timezone">>;
export const SETTINGS_PATH = "/api/v1/admin/settings";
+31
View File
@@ -614,3 +614,34 @@
.defs-emoji {
line-height: 1;
}
/* Font Awesome search: glyph + label tiles, inline validation, paging */
.defs-icon-grid--results {
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
}
.defs-icon-result {
flex-direction: column;
gap: 4px;
padding: 6px 4px;
font-family: inherit;
}
.defs-icon-label {
max-width: 100%;
overflow: hidden;
font-size: 11px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.defs-hint--error {
color: var(--nq-a-danger-strong);
}
.defs-show-more {
align-self: flex-start;
display: flex;
gap: 8px;
}
+298 -2
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import DefinitionsTab from "./DefinitionsTab";
import {
@@ -7,11 +7,14 @@ import {
FA_DEFINITION_ICONS,
emojiIconKey,
faIconName,
faTypedIconKey,
findDefinitionIcon,
normalizeDefinitionIconName,
searchFaIcons,
} 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 {
@@ -1352,7 +1355,7 @@ describe("DefinitionsTab Font Awesome and emoji icons", () => {
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
}
it("ships the curated Font Awesome subset, each drawn as a filled glyph", () => {
it("ships the curated Font Awesome quick picks, each drawn as a filled glyph", () => {
expect(FA_DEFINITION_ICONS.length).toBeGreaterThan(0);
for (const entry of FA_DEFINITION_ICONS) {
expect(entry.kind).toBe("fa");
@@ -1528,6 +1531,245 @@ describe("DefinitionsTab Font Awesome and emoji icons", () => {
});
});
/* ── Font Awesome search ────────────────────────────────────────────── */
describe("DefinitionsTab Font Awesome search", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
const CURATED = new Set(FA_DEFINITION_ICONS.map((icon) => icon.name));
function searchInput(s: HTMLElement): HTMLInputElement {
return within(s).getByRole("searchbox", { name: "Search Font Awesome icons" }) as HTMLInputElement;
}
function results(s: HTMLElement): HTMLElement {
return within(s).getByRole("radiogroup", { name: "Font Awesome search results" });
}
function resultKeys(s: HTMLElement): string[] {
return within(results(s))
.getAllByRole("radio")
.map((option) => option.getAttribute("data-icon-option")!);
}
function rowFaIcon(row: HTMLElement): string | null {
const svg = row.querySelector(".defs-row-glyph svg");
return svg!.getAttribute("fill") === "none" ? null : svg!.getAttribute("data-icon");
}
async function save(s: HTMLElement) {
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
}
function fillNewTask(s: HTMLElement) {
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Walk the dog" },
});
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
}
it("searches the full Free Solid set by name or label, exact and prefix matches first", () => {
expect(searchFaIcons("")).toEqual([]);
expect(searchFaIcons(" ")).toEqual([]);
const dog = searchFaIcons(" DOG ").map((icon) => icon.name);
expect(dog[0]).toBe("dog");
expect(dog).toEqual(expect.arrayContaining(["hotdog", "shield-dog"]));
expect(dog.some((name) => !CURATED.has(name))).toBe(true);
// A label match: "Shield Dog" contains "shield d" though no name does.
expect(searchFaIcons("shield d").map((icon) => icon.name)).toContain("shield-dog");
expect(searchFaIcons("dog-leashed")).toEqual([]);
expect(faTypedIconKey("dragon")).toBe("fa:dragon");
expect(faTypedIconKey(" Dragon ")).toBe("fa:dragon");
for (const bad of ["", "drag", "dgo", "dog-leashed", "github", "fa:dog"]) {
expect(faTypedIconKey(bad), bad).toBeNull();
}
});
it("shows the curated quick picks while the search is empty", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
expect(searchInput(s).value).toBe("");
const quick = within(s).getByRole("radiogroup", { name: "Font Awesome" });
expect(within(quick).getAllByRole("radio")).toHaveLength(FA_DEFINITION_ICONS.length);
expect(within(s).queryByRole("radiogroup", { name: "Font Awesome search results" })).toBeNull();
});
it("a query swaps the quick picks for one bounded, labelled page of results", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fireEvent.change(searchInput(s), { target: { value: "a" } });
const total = searchFaIcons("a").length;
expect(total).toBeGreaterThan(80);
expect(within(s).queryByRole("radiogroup", { name: "Font Awesome" })).toBeNull();
expect(resultKeys(s)).toHaveLength(40);
expect(within(results(s)).getAllByRole("radio")[0].textContent).toBe(searchFaIcons("a")[0].label);
expect(within(s).getByText(`Showing 1–40 of ${total}`)).toBeTruthy();
expect(within(s).queryByRole("button", { name: "Previous" })).toBeNull();
// "Show more" replaces the page rather than appending to it.
const firstPage = resultKeys(s);
fireEvent.click(within(s).getByRole("button", { name: "Show more" }));
expect(resultKeys(s)).toEqual(searchFaIcons("a").slice(40, 80).map((icon) => `fa:${icon.name}`));
expect(resultKeys(s).some((key) => firstPage.includes(key))).toBe(false);
expect(within(s).getByText(`Showing 41–80 of ${total}`)).toBeTruthy();
fireEvent.click(within(s).getByRole("button", { name: "Previous" }));
expect(resultKeys(s)).toEqual(firstPage);
// A new query starts again from the first page.
fireEvent.change(searchInput(s), { target: { value: "dog" } });
expect(resultKeys(s)[0]).toBe("fa:dog");
expect(resultKeys(s)).toContain("fa:hotdog");
expect(within(s).queryByRole("button", { name: "Show more" })).toBeNull();
fireEvent.change(searchInput(s), { target: { value: "" } });
expect(within(s).getByRole("radiogroup", { name: "Font Awesome" })).toBeTruthy();
});
it("paging through every match never mounts more than one page", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fireEvent.change(searchInput(s), { target: { value: "-" } });
const all = searchFaIcons("-").map((icon) => `fa:${icon.name}`);
expect(all.length).toBeGreaterThan(900);
const seen: string[] = [];
for (;;) {
expect(results(s).children.length).toBeGreaterThan(0);
expect(results(s).children.length).toBeLessThanOrEqual(40);
seen.push(...resultKeys(s));
const more = within(s).queryByRole("button", { name: "Show more" });
if (!more) break;
fireEvent.click(more);
}
// Every match stays reachable, one page at a time, in order.
expect(seen).toEqual(all);
const lastStart = Math.floor((all.length - 1) / 40) * 40 + 1;
expect(within(s).getByText(`Showing ${lastStart}–${all.length} of ${all.length}`)).toBeTruthy();
});
it("selecting a search result POSTs fa:<name> and renders on its row", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fillNewTask(s);
fireEvent.change(searchInput(s), { target: { value: "hot" } });
expect(within(s).getByRole("alert").textContent).toMatch(/Select a Font Awesome icon from the results/);
const hotdog = within(results(s)).getByRole("radio", { name: "Hotdog" });
fireEvent.click(hotdog);
expect(hotdog.getAttribute("aria-checked")).toBe("true");
expect(within(s).queryByRole("alert")).toBeNull();
expect(searchInput(s).value).toBe("hot");
await save(s);
expect((api.writes()[0].body as { icon: string }).icon).toBe("fa:hotdog");
await waitFor(() => expect(rowFaIcon(screen.getByTestId("definition-99"))).toBe("hotdog"));
});
it("a typed non-curated Free Solid name is accepted and PATCHed", async () => {
expect(CURATED.has("dragon")).toBe(false);
await renderReady([{ ...BRUSH, id: 70, icon: "lucide:dog" }]);
fireEvent.click(screen.getByTestId("definition-70"));
const s = sheet();
fireEvent.change(searchInput(s), { target: { value: "Dragon" } });
expect(within(s).queryByRole("alert")).toBeNull();
expect(within(results(s)).getByRole("radio", { name: "Dragon" }).getAttribute("aria-checked")).toBe(
"true",
);
const lucide = within(s).getByRole("radiogroup", { name: "Icon" });
expect(within(lucide).queryAllByRole("radio", { checked: true })).toHaveLength(0);
await save(s);
expect((api.writes()[0].body as { icon: string }).icon).toBe("fa:dragon");
await waitFor(() => expect(rowFaIcon(screen.getByTestId("definition-70"))).toBe("dragon"));
});
for (const [typed, message] of [
["do", /Select a Font Awesome icon from the results/],
["dgo", /“dgo” is not a Font Awesome Free Solid icon/],
["dog-leashed", /“dog-leashed” is not a Font Awesome Free Solid icon/],
["github", /“github” is not a Font Awesome Free Solid icon/],
] as const) {
it(`typed ${JSON.stringify(typed)} is rejected with a message and nothing is saved`, async () => {
await renderReady([{ ...BRUSH, id: 71, icon: "lucide:dog" }]);
fireEvent.click(screen.getByTestId("definition-71"));
const s = sheet();
fireEvent.change(searchInput(s), { target: { value: typed } });
expect(within(s).getByRole("alert").textContent).toMatch(message);
expect(within(s).getByRole("radio", { name: "No icon" }).getAttribute("aria-checked")).toBe("false");
await save(s);
expect(api.writes()).toHaveLength(0);
expect(within(s).getByTestId("sheet-error").textContent).toMatch(message);
expect(screen.getByRole("dialog")).toBe(s);
});
}
it("clearing the search, or No icon, removes the icon", async () => {
await renderReady([
{ ...BRUSH, id: 72, icon: "fa:dragon" },
{ ...BRUSH, id: 73, title: "Task 73", icon: "fa:dragon" },
]);
fireEvent.click(screen.getByTestId("definition-72"));
let s = sheet();
fireEvent.change(searchInput(s), { target: { value: "" } });
expect(within(s).getByRole("radio", { name: "No icon" }).getAttribute("aria-checked")).toBe("true");
await save(s);
expect((api.writes()[0].body as { icon: string | null }).icon).toBeNull();
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
fireEvent.click(screen.getByTestId("definition-73"));
s = sheet();
fireEvent.click(within(s).getByRole("radio", { name: "No icon" }));
expect(searchInput(s).value).toBe("");
await save(s);
expect((api.writes()[1].body as { icon: string | null }).icon).toBeNull();
});
it("a saved non-curated fa:<name> renders on its row and opens selected in the results", async () => {
await renderReady([{ ...BRUSH, id: 74, icon: "fa:dragon" }]);
expect(rowFaIcon(screen.getByTestId("definition-74"))).toBe("dragon");
fireEvent.click(screen.getByTestId("definition-74"));
const s = sheet();
expect(searchInput(s).value).toBe("dragon");
expect(within(s).queryByRole("alert")).toBeNull();
expect(within(results(s)).getByRole("radio", { name: "Dragon" }).getAttribute("aria-checked")).toBe(
"true",
);
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Feed the dragon" },
});
await save(s);
expect(api.writes()[0].body).not.toHaveProperty("icon");
});
it("a saved curated fa:<name> opens in the quick picks with an empty search", async () => {
await renderReady([{ ...BRUSH, id: 75, icon: "fa:dog" }]);
fireEvent.click(screen.getByTestId("definition-75"));
const s = sheet();
expect(searchInput(s).value).toBe("");
const quick = within(s).getByRole("radiogroup", { name: "Font Awesome" });
expect(within(quick).getByRole("radio", { name: "Dog" }).getAttribute("aria-checked")).toBe("true");
});
});
/* ── Legacy icons ───────────────────────────────────────────────────── */
describe("DefinitionsTab legacy icon edits", () => {
@@ -1723,3 +1965,57 @@ describe("DefinitionsTab start date", () => {
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");
});
});
+136 -48
View File
@@ -4,6 +4,7 @@
*/
import {
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
@@ -53,10 +54,17 @@ import {
emojiIconText,
faIconKey,
faIconName,
faTextProblem,
faTypedIconKey,
findDefinitionIcon,
lucideIconKey,
normalizeDefinitionIconName,
searchFaIcons,
} 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. */
@@ -84,6 +92,9 @@ const REPEATS_BY_RULE: Record<DefinitionRule["rule_type"], Repeats> = {
/** How many upcoming dates the edit sheet previews. */
const PREVIEW_COUNT = 5;
/** Font Awesome search results per page; only one page is ever mounted. */
const FA_RESULTS_PAGE = 40;
/** Quiet period after the last rule edit before the preview is requested. */
const PREVIEW_DEBOUNCE_MS = 300;
@@ -98,6 +109,11 @@ interface Draft {
icon: string | null;
/** The emoji field's text; typing in it sets `icon` to `emoji:<text>`. */
emoji: string;
/**
* The Font Awesome search text; typing an exact Free Solid name sets
* `icon` to `fa:<name>`, and picking a search result keeps the text.
*/
faText: string;
assigneeIds: number[];
repeats: Repeats;
interval: string;
@@ -121,50 +137,47 @@ 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: "",
faText: "",
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 ?? "";
const fa = findDefinitionIcon(definition.icon);
return {
title: definition.title,
icon: definition.icon,
emoji: emojiIconText(definition.icon),
// A Free Solid icon outside the quick picks opens as its search result.
faText: fa?.kind === "fa" && !FA_DEFINITION_ICONS.includes(fa) ? fa.name : "",
// Inactive children cannot be submitted (the API rejects them with 422).
assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)),
repeats: REPEATS_BY_RULE[rule.rule_type],
@@ -172,9 +185,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 }
@@ -254,6 +267,7 @@ function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody |
if (draft.emoji && draft.icon === null) {
return "Enter a single emoji (no spaces or < > &), or clear the emoji field.";
}
if (draft.faText.trim() && draft.icon === null) return faTextProblem(draft.faText);
const rule = buildRule(draft);
if (typeof rule === "string") return rule;
const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map(
@@ -465,9 +479,11 @@ function EditSheet({
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.
@@ -481,6 +497,11 @@ function EditSheet({
? []
: target.assignees.filter((a) => !activeChildren.some((child) => child.id === a.id));
const [pickerOpen, setPickerOpen] = useState(false);
const faResults = useMemo(() => searchFaIcons(draft.faText), [draft.faText]);
const [faPage, setFaPage] = useState(0);
const faStart = faPage * FA_RESULTS_PAGE;
const faEnd = Math.min(faStart + FA_RESULTS_PAGE, faResults.length);
const noIcon = draft.icon === null && !draft.emoji && !draft.faText.trim();
const [saving, setSaving] = useState(false);
const [confirming, setConfirming] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
@@ -621,12 +642,10 @@ function EditSheet({
<button
type="button"
role="radio"
aria-checked={draft.icon === null && !draft.emoji}
aria-checked={noIcon}
aria-label="No icon"
className={
draft.icon === null && !draft.emoji ? "defs-icon defs-icon--on" : "defs-icon"
}
onClick={() => update({ icon: null, emoji: "" })}
className={noIcon ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: null, emoji: "", faText: "" })}
>
<TaskGlyph size={18} />
</button>
@@ -642,7 +661,7 @@ function EditSheet({
aria-label={label}
data-icon-option={name}
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: lucideIconKey(name), emoji: "" })}
onClick={() => update({ icon: lucideIconKey(name), emoji: "", faText: "" })}
>
<GlyphSvg icon={icon} size={18} />
</button>
@@ -652,26 +671,93 @@ function EditSheet({
<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: "" })}
<input
className="defs-input"
type="search"
aria-label="Search Font Awesome icons"
placeholder="Search or type an icon name, e.g. dog"
value={draft.faText}
onChange={(e) => {
setFaPage(0);
update({ faText: e.target.value, icon: faTypedIconKey(e.target.value), emoji: "" });
}}
/>
{draft.faText.trim() && draft.icon === null ? (
<p className="defs-hint defs-hint--error" role="alert">
{faTextProblem(draft.faText)}
</p>
) : null}
{draft.faText.trim() ? (
faResults.length > 0 ? (
<>
<div
className="defs-icon-grid defs-icon-grid--results"
role="radiogroup"
aria-label="Font Awesome search results"
>
<GlyphSvg icon={icon} size={18} />
</button>
);
})}
</div>
{faResults.slice(faStart, faEnd).map((icon) => {
const { name, label } = icon;
const on = faIconName(draft.icon) === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={on}
aria-label={label}
data-icon-option={faIconKey(name)}
className={
on ? "defs-icon defs-icon-result defs-icon--on" : "defs-icon defs-icon-result"
}
onClick={() => update({ icon: faIconKey(name), emoji: "" })}
>
<GlyphSvg icon={icon} size={18} />
<span className="defs-icon-label">{label}</span>
</button>
);
})}
</div>
<p className="defs-hint">
Showing {faStart + 1}–{faEnd} of {faResults.length}
</p>
{faResults.length > FA_RESULTS_PAGE ? (
<div className="defs-show-more">
{faPage > 0 ? (
<button type="button" className="defs-retry" onClick={() => setFaPage((page) => page - 1)}>
Previous
</button>
) : null}
{faEnd < faResults.length ? (
<button type="button" className="defs-retry" onClick={() => setFaPage((page) => page + 1)}>
Show more
</button>
) : null}
</div>
) : null}
</>
) : null
) : (
<div className="defs-icon-grid" role="radiogroup" aria-labelledby="defs-label-icon-fa">
{FA_DEFINITION_ICONS.map((icon) => {
const { name, label } = icon;
const on = faIconName(draft.icon) === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={on}
aria-label={label}
data-icon-option={faIconKey(name)}
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: faIconKey(name), emoji: "", faText: "" })}
>
<GlyphSvg icon={icon} size={18} />
</button>
);
})}
</div>
)}
<label className="defs-due">
<span>Emoji</span>
<input
@@ -679,7 +765,9 @@ function EditSheet({
aria-label="Emoji"
placeholder="e.g. 🐶"
value={draft.emoji}
onChange={(e) => update({ emoji: e.target.value, icon: emojiIconKey(e.target.value) })}
onChange={(e) =>
update({ emoji: e.target.value, icon: emojiIconKey(e.target.value), faText: "" })
}
/>
</label>
</Field>
+54 -9
View File
@@ -1,14 +1,23 @@
/**
* 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.
* Lucide set, any icon in the full Font Awesome Free Solid set, or an emoji.
* The Font Awesome quick picks (`FA_DEFINITION_ICONS`) are only a curated
* shortcut list; `fa:<name>` resolves against the full set
* (`FA_SOLID_ICONS`). The names, labels and path data come from the shared
* icon registry (`src/icons/registry.generated.ts`, generated by
* `tools/icons/generate.mjs` from `tools/icons/curated-icons.json` and the
* installed Free Solid package) — no CDN, no runtime fetch. The stored value
* is namespaced — `lucide:<name>`, `fa:<name>` or `emoji:<grapheme>` — and a
* legacy bare `<kebab-name>` still names the same Lucide entry.
*/
import { GlyphSvg } from "../icons/GlyphSvg";
import { FA_ICONS, LUCIDE_ICONS, resolveIcon, type GlyphIcon } from "../icons/registry.generated";
import {
FA_ICONS,
FA_SOLID_ICONS,
LUCIDE_ICONS,
resolveIcon,
type GlyphIcon,
} from "../icons/registry.generated";
import { TaskGlyph } from "./glyphs";
export type DefinitionIconEntry = GlyphIcon;
@@ -58,14 +67,50 @@ export function faIconName(value: string | null): string | null {
return value?.startsWith(FA_PREFIX) ? value.slice(FA_PREFIX.length) : null;
}
/**
* The Font Awesome Free Solid icons whose name or label contains `query`
* (trimmed, case-insensitive): an exact name first, then name-prefix
* matches, then the rest, each in registry (name) order. An empty query
* gives no results — the curated quick picks cover that case.
*/
export function searchFaIcons(query: string): DefinitionIconEntry[] {
const q = query.trim().toLowerCase();
if (!q) return [];
const rank = (icon: DefinitionIconEntry) => (icon.name === q ? 0 : icon.name.startsWith(q) ? 1 : 2);
return FA_SOLID_ICONS.filter(
(icon) => icon.name.includes(q) || icon.label.toLowerCase().includes(q),
).sort((a, b) => rank(a) - rank(b));
}
/**
* The stored key for a typed Font Awesome name, e.g. `Dog` → `fa:dog`: the
* trimmed, lower-cased text must be the exact name of a Free Solid icon,
* else null (a misspelling, a partial search, or a Pro/Brands name).
*/
export function faTypedIconKey(text: string): string | null {
const key = faIconKey(text.trim().toLowerCase());
return resolveIcon(key).kind === "fa" ? key : null;
}
/**
* Why Font Awesome search text cannot be saved as it stands: it matches
* icons but names none of them, or it matches nothing in the Free Solid set.
*/
export function faTextProblem(text: string): string {
return searchFaIcons(text).length > 0
? "Select a Font Awesome icon from the results, or type its full name."
: `“${text.trim()}” is not a Font Awesome Free Solid icon.`;
}
/** The emoji text an `emoji:<grapheme>` value holds; anything else gives "". */
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.
* The glyph an icon value resolves to via the shared parser: a curated
* Lucide entry or any Font Awesome Free Solid icon (quick pick or not);
* emoji, unknown and null values give null.
*/
export function findDefinitionIcon(value: string | null): DefinitionIconEntry | null {
const icon = resolveIcon(value);
+115
View File
@@ -5,6 +5,7 @@ import type { HistoryFilter, HistoryRow } from "../api/history";
import { storeTokens } from "../auth/oidc";
import { TransitionsProvider } from "../api/useTransitions";
import { frame, testStream, type TestStream } from "../api/testStream";
import { AppTimezoneProvider } from "../time/AppTimezone";
const TODAY = "2026-09-23";
@@ -629,3 +630,117 @@ describe("HistoryTab live updates", () => {
expect(screen.getAllByTestId("history-row")).toHaveLength(ROWS.all.length);
});
});
describe("HistoryTab in the stored application timezone", () => {
// 13:00 UTC on Sep 23 is 01:00 on Sep 24 in Auckland (NZST, UTC+12).
const NOW = new Date("2026-09-23T13:00:00Z");
// Due 00:10 Auckland on Sep 24, done 00:30 Auckland (12:30 UTC on Sep 23).
const LATE_NZ = row({
instance_id: 7,
quest_title: "Feed cat",
due_date: "2026-09-24",
due_time: "00:10",
occurred_at: "2026-09-23T12:30:00Z",
was_on_time: false,
});
// 22:00 Auckland on Sep 23; 10:00 UTC.
const EVENING_NZ = row({ instance_id: 8, occurred_at: "2026-09-23T10:00:00Z" });
let fetchMock: ReturnType<typeof vi.fn>;
function historyRequests(): URL[] {
return fetchMock.mock.calls
.map(([url]) => new URL(String(url), "http://x"))
.filter((url) => url.pathname === "/api/v1/admin/history");
}
async function renderWithZone(timezone: string, effective_timezone = timezone) {
fetchMock = vi.fn((url: string) => {
const parsed = new URL(url, "http://x");
if (parsed.pathname.endsWith("/api/v1/admin/settings")) {
return Promise.resolve(jsonResponse({ timezone, effective_timezone }));
}
const filter = parsed.searchParams.get("filter");
const rows = filter === "all" ? [EVENING_NZ, LATE_NZ] : [];
return Promise.resolve(
jsonResponse({
filter,
start: parsed.searchParams.get("start"),
end: parsed.searchParams.get("end"),
count: rows.length,
rows,
}),
);
});
vi.stubGlobal("fetch", fetchMock);
render(
<AppTimezoneProvider>
<HistoryTab />
</AppTimezoneProvider>,
);
await screen.findByTestId("stat-on-time");
}
function times(section: HTMLElement): string[] {
return Array.from(section.querySelectorAll(".history-event-time")).map(
(el) => el.textContent ?? "",
);
}
beforeAll(() => {
vi.stubEnv("TZ", "UTC");
});
afterAll(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
vi.useFakeTimers({ toFake: ["Date"], now: NOW });
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});
it.each([
["the stored zone", "Pacific/Auckland"],
["the API host's published zone when none is stored", ""],
])("today, day grouping, clock and lateness follow %s", async (_label, stored) => {
await renderWithZone(stored, "Pacific/Auckland");
for (const url of historyRequests()) {
expect(url.searchParams.get("end")).toBe("2026-09-24");
expect(url.searchParams.get("start")).toBe("2026-09-11");
}
const today = screen.getByTestId("day-2026-09-24");
expect(within(today).getByText("Today")).toBeTruthy();
expect(times(today)).toEqual(["12:30 AM"]);
expect(metas(today)).toEqual(["completed · panel (Declan) · late 20 min"]);
const yesterday = screen.getByTestId("day-2026-09-23");
expect(within(yesterday).getByText("Yesterday")).toBeTruthy();
expect(times(yesterday)).toEqual(["10:00 PM"]);
});
it("only an API that publishes no zone leaves the browser's local day and clock", async () => {
await renderWithZone("", "");
for (const url of historyRequests()) {
expect(url.searchParams.get("end")).toBe("2026-09-23");
}
expect(screen.queryByTestId("day-2026-09-24")).toBeNull();
const today = screen.getByTestId("day-2026-09-23");
expect(within(today).getByText("Today")).toBeTruthy();
expect(times(today)).toEqual(["12:30 PM", "10:00 AM"]);
// Due 00:10 UTC on Sep 24 is after the 12:30 UTC completion: no minutes.
expect(metas(today)).toEqual([
"completed · panel (Declan) · late",
"completed · panel (Declan) · on time",
]);
});
});
+11 -7
View File
@@ -9,9 +9,10 @@ import { ApiForbiddenError } from "../api/client";
import type { TransitionEvent } from "../api/events";
import { useTransitions } from "../api/useTransitions";
import { exportHistoryCsv, fetchHistory, type HistoryFilter, type HistoryRow } from "../api/history";
import { localTodayIso } from "../schedule/cycle";
import { dayLabel, groupByDay, rowMeta, rowTime, shiftIso } from "./format";
import { DownloadGlyph, LockGlyph } from "./glyphs";
import { useAppTimezone } from "../time/AppTimezone";
import { todayIn } from "../time/zone";
import "./HistoryTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
@@ -129,7 +130,7 @@ function StatRow({ stats }: { stats: HistoryStats }) {
);
}
function EventRow({ row }: { row: HistoryRow }) {
function EventRow({ row, timeZone }: { row: HistoryRow; timeZone: string }) {
return (
<li className="history-event" data-testid="history-row">
<span
@@ -142,10 +143,10 @@ function EventRow({ row }: { row: HistoryRow }) {
{row.quest_title} · {row.child_name}
</div>
<div className="history-event-meta" data-testid="history-meta">
{rowMeta(row)}
{rowMeta(row, timeZone)}
</div>
</div>
<span className="history-event-time">{rowTime(row)}</span>
<span className="history-event-time">{rowTime(row, timeZone)}</span>
</li>
);
}
@@ -161,7 +162,10 @@ function FooterNote() {
);
}
export default function HistoryTab({ today = localTodayIso() }: { today?: string }) {
/** `today` ("YYYY-MM-DD") defaults to today in the application timezone. */
export default function HistoryTab({ today: todayProp }: { today?: string }) {
const timeZone = useAppTimezone();
const today = todayProp ?? todayIn(timeZone);
const [filter, setFilter] = useState<HistoryFilter>("all");
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
@@ -244,7 +248,7 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string
</div>
);
} else {
const groups = groupByDay(shown.data.rows);
const groups = groupByDay(shown.data.rows, timeZone);
body =
groups.length > 0 ? (
groups.map((group) => (
@@ -252,7 +256,7 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string
<h3 className="history-section-label">{dayLabel(group.day, today)}</h3>
<ul className="history-card history-list">
{group.rows.map((row, index) => (
<EventRow key={`${row.instance_id}-${row.event_type}-${row.occurred_at ?? ""}-${index}`} row={row} />
<EventRow key={`${row.instance_id}-${row.event_type}-${row.occurred_at ?? ""}-${index}`} row={row} timeZone={timeZone} />
))}
</ul>
</section>
+21 -24
View File
@@ -4,12 +4,13 @@
* Calendar dates ("YYYY-MM-DD") are handled by their components (never
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
* day in negative-offset zones); event stamps are UTC and shown in the
* viewer's local time.
* application timezone (`timeZone`: the stored IANA zone, "" = browser).
*/
import type { HistoryRow } from "../api/history";
import { dayNumber, isoFromDayNumber, localTodayIso } from "../schedule/cycle";
import { dayNumber, isoFromDayNumber } from "../schedule/cycle";
import { shortDate } from "../schedule/format";
import { formatClockTime, formatDueTime } from "../today/format";
import { isoDateIn, wallClockInstant } from "../time/zone";
const MS_PER_MINUTE = 60_000;
@@ -19,25 +20,21 @@ export function shiftIso(iso: string, days: number): string {
}
/** The local calendar day an event belongs to (due_date for missed rows). */
export function rowDay(row: HistoryRow): string {
return row.occurred_at ? localTodayIso(new Date(row.occurred_at)) : row.due_date;
export function rowDay(row: HistoryRow, timeZone: string): string {
return row.occurred_at ? isoDateIn(new Date(row.occurred_at), timeZone) : row.due_date;
}
/** The row's due moment as local wall-clock time, or null without a due time. */
function dueMoment(row: HistoryRow): Date | null {
/** The row's due moment (epoch ms) as local wall-clock time, or null without a due time. */
function dueMoment(row: HistoryRow, timeZone: string): number | null {
const match = /^(\d{2}):(\d{2})/.exec(row.due_time ?? "");
if (!match) return null;
const [year, month, day] = row.due_date.split("-").map(Number);
return new Date(year, month - 1, day, Number(match[1]), Number(match[2]));
return wallClockInstant(row.due_date, Number(match[1]), Number(match[2]), timeZone);
}
/** Newest first; a missed row sorts at its due moment (or the end of its day). */
function sortKey(row: HistoryRow): number {
function sortKey(row: HistoryRow, timeZone: string): number {
if (row.occurred_at) return Date.parse(row.occurred_at);
const due = dueMoment(row);
if (due) return due.getTime();
const [year, month, day] = row.due_date.split("-").map(Number);
return new Date(year, month - 1, day, 23, 59).getTime();
return dueMoment(row, timeZone) ?? wallClockInstant(row.due_date, 23, 59, timeZone);
}
export interface DayGroup {
@@ -46,10 +43,10 @@ export interface DayGroup {
}
/** Rows grouped by day, newest day first and newest row first within a day. */
export function groupByDay(rows: HistoryRow[]): DayGroup[] {
export function groupByDay(rows: HistoryRow[], timeZone: string): DayGroup[] {
const groups = new Map<string, HistoryRow[]>();
for (const row of rows) {
const day = rowDay(row);
const day = rowDay(row, timeZone);
groups.set(day, [...(groups.get(day) ?? []), row]);
}
return [...groups.entries()]
@@ -57,7 +54,7 @@ export function groupByDay(rows: HistoryRow[]): DayGroup[] {
.map(([day, dayRows]) => ({
day,
// Stable sort: rows at the same moment keep the API's append order.
rows: [...dayRows].sort((a, b) => sortKey(b) - sortKey(a)),
rows: [...dayRows].sort((a, b) => sortKey(b, timeZone) - sortKey(a, timeZone)),
}));
}
@@ -72,23 +69,23 @@ export function dayLabel(day: string, today: string): string {
}
/** "on time", "late 22 min", or "late" when the lateness is not derivable. */
function onTimeFlag(row: HistoryRow): string | null {
function onTimeFlag(row: HistoryRow, timeZone: string): string | null {
if (row.event_type !== "completed" || row.was_on_time === null) return null;
if (row.was_on_time) return "on time";
const due = dueMoment(row);
if (!due || !row.occurred_at) return "late";
const minutes = Math.round((Date.parse(row.occurred_at) - due.getTime()) / MS_PER_MINUTE);
const due = dueMoment(row, timeZone);
if (due === null || !row.occurred_at) return "late";
const minutes = Math.round((Date.parse(row.occurred_at) - due) / MS_PER_MINUTE);
return minutes > 0 ? `late ${minutes} min` : "late";
}
/** `<event type> · <actor>` plus the on-time flag for completions. */
export function rowMeta(row: HistoryRow): string {
const flag = onTimeFlag(row);
export function rowMeta(row: HistoryRow, timeZone: string): string {
const flag = onTimeFlag(row, timeZone);
return [row.event_type, row.actor, flag].filter(Boolean).join(" · ");
}
/** The event's local clock time; a missed row shows its due time, if any. */
export function rowTime(row: HistoryRow): string {
if (row.occurred_at) return formatClockTime(row.occurred_at);
export function rowTime(row: HistoryRow, timeZone: string): string {
if (row.occurred_at) return formatClockTime(row.occurred_at, timeZone);
return row.due_time ? formatDueTime(row.due_time) : "";
}
File diff suppressed because it is too large Load Diff
+27 -3
View File
@@ -3,7 +3,14 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
// 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, LUCIDE_ICONS, resolveIcon } from "./registry.generated";
import {
EMOJI_MAX_CODE_POINTS,
FALLBACK_ICON,
FA_ICONS,
FA_SOLID_ICONS,
LUCIDE_ICONS,
resolveIcon,
} from "./registry.generated";
type Curated = { lucide: { name: string; label: string }[]; fa: { name: string; label: string }[] };
@@ -45,7 +52,18 @@ describe("shared icon registry — one source of truth", () => {
expect(icon.viewBox).toMatch(/^0 0 \d+ 512$/);
expect(icon.paths.length, icon.name).toBeGreaterThan(0);
}
for (const icon of [...LUCIDE_ICONS, ...FA_ICONS]) {
const solidNames = FA_SOLID_ICONS.map((icon) => icon.name);
expect(solidNames.length).toBeGreaterThanOrEqual(1400);
expect(solidNames).toEqual([...solidNames].sort());
expect(new Set(solidNames).size).toBe(solidNames.length);
for (const { name } of FA_ICONS) expect(solidNames, name).toContain(name);
for (const icon of FA_SOLID_ICONS) {
expect(icon.kind).toBe("fa");
expect(icon.key).toBe(`fa:${icon.name}`);
expect(icon.viewBox).toMatch(/^0 0 \d+ 512$/);
expect(icon.paths.length, icon.name).toBeGreaterThan(0);
}
for (const icon of [...LUCIDE_ICONS, ...FA_ICONS, ...FA_SOLID_ICONS]) {
for (const d of icon.paths) expect(d, icon.key).toMatch(/^[Mm][-0-9.,\sA-Za-z]+$/);
}
});
@@ -61,6 +79,12 @@ describe("resolveIcon", () => {
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: "👨‍👩‍👧" });
});
@@ -133,7 +157,7 @@ describe("offline bundling", () => {
}
});
it("no bundled source imports an icon package — only the curated generated data ships", () => {
it("no bundled source imports an icon package — only the generated registry data ships", () => {
const sources = [join(REPO_ROOT, "admin", "src"), join(REPO_ROOT, "frontend", "src")]
.flatMap(filesUnder)
.filter((path) => /\.(ts|tsx)$/.test(path) && !path.endsWith(".test.ts") && !path.endsWith(".test.tsx"));
@@ -74,6 +74,10 @@ async function routeFetch(url: string, init: RequestInit = {}): Promise<Response
overrides = overrides.filter((o) => o.id !== Number(deleteMatch[1]));
return jsonResponse({ status: "ok" });
}
// The shell loads the application timezone first ("" = none published).
if (path.endsWith("/admin/settings")) {
return jsonResponse({ timezone: "", effective_timezone: "" });
}
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
const patternsMatch = /\/children\/(\d+)\/presence-patterns$/.exec(path);
if (patternsMatch) {
+54 -1
View File
@@ -1,9 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import ScheduleTab from "./ScheduleTab";
import type { AdminChild } from "../api/definitions";
import type { PresenceOverride, PresencePattern } from "../api/presence";
import { storeTokens } from "../auth/oidc";
import { AppTimezoneProvider } from "../time/AppTimezone";
import { cycleWeekIndex, monthDates, weekdayOf } from "./cycle";
function child(id: number, display_name: string, sort_order: number, is_active = true): AdminChild {
@@ -655,3 +656,55 @@ describe("ScheduleTab", () => {
expect(await screen.findByTestId("pattern-1")).toBeTruthy();
});
});
describe("ScheduleTab today in the stored application timezone", () => {
async function renderWithZone(timezone: string, effective_timezone = timezone) {
fetchMock = vi.fn(async (url: string, init?: RequestInit) =>
String(url).endsWith("/api/v1/admin/settings")
? jsonResponse({ timezone, effective_timezone })
: routeFetch(url, init),
);
vi.stubGlobal("fetch", fetchMock);
render(
<AppTimezoneProvider>
<ScheduleTab />
</AppTimezoneProvider>,
);
await screen.findByTestId("pattern-1");
}
beforeAll(() => {
vi.stubEnv("TZ", "UTC");
});
afterAll(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
patterns = { 1: [DECLAN_HOME], 2: [MAEVE_THU_FRI, MAEVE_WEEKENDS], 3: [] };
writeFailure = null;
// 13:00 UTC on Sep 23 is already 01:00 on Sep 24 in Auckland.
vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-09-23T13:00:00Z") });
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("marks today in the stored zone", async () => {
await renderWithZone("Pacific/Auckland");
expect(day("2026-09-24").className).toContain("schedule-day--today");
expect(day("2026-09-23").className).not.toContain("schedule-day--today");
});
it("an empty stored zone marks today in the API host's published zone", async () => {
await renderWithZone("", "Pacific/Auckland");
expect(day("2026-09-24").className).toContain("schedule-day--today");
expect(day("2026-09-23").className).not.toContain("schedule-day--today");
});
});
+6 -3
View File
@@ -20,7 +20,6 @@ import {
import {
cycleLabel,
dayState,
localTodayIso,
monthDates,
monthStart,
patternDaysLabel,
@@ -38,6 +37,8 @@ import {
} from "./glyphs";
import OverrideEditor from "./OverrideEditor";
import PatternEditor from "./PatternEditor";
import { useAppTimezone } from "../time/AppTimezone";
import { todayIn } from "../time/zone";
import "./ScheduleTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
@@ -324,11 +325,13 @@ function OverrideRow({ override, childName, deletion, onAskDelete, onKeep, onDel
}
export interface ScheduleTabProps {
/** "YYYY-MM-DD"; defaults to the viewer's local date. */
/** "YYYY-MM-DD"; defaults to today in the application timezone. */
today?: string;
}
export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProps) {
export default function ScheduleTab({ today: todayProp }: ScheduleTabProps) {
const timeZone = useAppTimezone();
const today = todayProp ?? todayIn(timeZone);
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
const [selectedId, setSelectedId] = useState<number | null>(null);
-7
View File
@@ -55,13 +55,6 @@ export function cycleWeekIndex(anchorDate: string, date: string, cycleLengthWeek
return mod(Math.floor(days / 7), cycleLengthWeeks);
}
/** The viewer's local calendar date. */
export function localTodayIso(now: Date = new Date()): string {
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${now.getFullYear()}-${month}-${day}`;
}
/** "YYYY-MM-01" of the month containing `iso`, shifted by `delta` months. */
export function monthStart(iso: string, delta = 0): string {
const [year, month] = iso.split("-").map(Number);
+6
View File
@@ -258,6 +258,12 @@
color: var(--nq-a-ink-secondary);
}
.settings-hint {
margin: 0;
font-size: 12.5px;
color: var(--nq-a-ink-secondary);
}
.settings-input {
width: 100%;
min-height: var(--nq-a-tap-min);
+212 -14
View File
@@ -6,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`;
@@ -23,8 +24,17 @@ const DEFAULT_SETTINGS: HouseholdSettings = {
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,
@@ -82,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, {
@@ -499,37 +509,37 @@ describe("SettingsScreen — preferences", () => {
expect(writes()).toHaveLength(1);
});
it("a changed timezone patches exactly the timezone", async () => {
it("a timezone picked from the list patches exactly the timezone", async () => {
const { form } = await renderPreferences();
const input = field(form, "Timezone");
expect(input.getAttribute("list")).toBe("settings-timezones");
fireEvent.change(input, { target: { value: " Europe/London " } });
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: "Europe/London" } },
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } },
]);
const reloaded = screen.getByRole("form", { name: "Preferences" });
expect(field(reloaded, "Timezone").value).toBe("Europe/London");
expect(field(reloaded, "Timezone").value).toBe("America/New_York");
});
it.each(["New York", "America/New York", "EST5EDT?", "/Chicago"])(
"an invalid timezone %j is rejected before any request",
"an invalid other timezone %j is rejected before any request",
async (value) => {
const { form } = await renderPreferences();
fireEvent.change(field(form, "Timezone"), { target: { value } });
fireEvent.change(field(form, "Timezone"), { target: { value: "__other__" } });
fireEvent.change(field(form, "Other timezone"), { target: { value } });
save(form);
expect(screen.getByTestId("preferences-message").textContent).toBe(
"Timezone must be an IANA zone like America/New_York, or UTC.",
);
expect(field(form, "Timezone").value).toBe(value);
expect(field(form, "Other timezone").value).toBe(value);
expect(writes()).toEqual([]);
},
);
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: "Mars/Olympus_Mons" } });
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);
@@ -537,8 +547,9 @@ describe("SettingsScreen — preferences", () => {
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, "Timezone").value).toBe("Mars/Olympus_Mons");
expect(field(form, "Timezone").disabled).toBe(false);
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 () => {
@@ -643,3 +654,190 @@ describe("SettingsScreen — no timezone auto-set", () => {
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");
});
});
+58 -20
View File
@@ -6,7 +6,9 @@
*
* Preferences: the household settings (planning horizon, day rollover,
* timezone, notifications). Save patches only the changed fields. The
* timezone is only ever set by the admin saving it here.
* 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.
*/
@@ -34,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";
@@ -256,7 +259,7 @@ const HH_MM = /^([01]\d|2[0-3]):[0-5]\d$/;
/** "UTC" or an IANA-looking "Area/Location" (the API checks it resolves). */
const IANA_ZONE = /^(UTC|[A-Za-z]+(\/[A-Za-z0-9_+-]+)+)$/;
/** The Timezone field's suggestions; any other IANA zone can be typed. */
/** The Timezone picker's zones; any other IANA zone can be typed via "Other". */
export const COMMON_TIMEZONES = [
"UTC",
"America/New_York",
@@ -290,6 +293,9 @@ export const COMMON_TIMEZONES = [
"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 };
@@ -348,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) {
@@ -400,25 +410,49 @@ function PreferencesForm({
/>
</label>
{timeInput("day_rollover_time", "Day rollover")}
<label className="settings-field">
<span className="settings-label">Timezone</span>
<input
<div className="settings-field">
<label className="settings-label" htmlFor="settings-timezone">
Timezone
</label>
<select
id="settings-timezone"
className="settings-input"
type="text"
list="settings-timezones"
placeholder="Host local time"
autoComplete="off"
spellCheck={false}
value={draft.timezone}
aria-describedby="settings-timezone-help"
value={otherZone ? OTHER_ZONE : draft.timezone}
disabled={disabled}
onChange={(event) => update({ timezone: event.target.value })}
/>
</label>
<datalist id="settings-timezones">
{COMMON_TIMEZONES.map((zone) => (
<option key={zone} value={zone} />
))}
</datalist>
onChange={(event) => {
const choice = event.target.value;
setOtherZone(choice === OTHER_ZONE);
if (choice !== OTHER_ZONE) update({ timezone: choice });
}}
>
<option value="">API host local time (no zone set)</option>
{COMMON_TIMEZONES.map((zone) => (
<option key={zone} value={zone}>
{zone}
</option>
))}
<option value={OTHER_ZONE}>Other IANA zone…</option>
</select>
{otherZone ? (
<input
className="settings-input"
type="text"
aria-label="Other timezone"
placeholder="Area/Location, e.g. America/Boise"
autoComplete="off"
spellCheck={false}
value={draft.timezone}
disabled={disabled}
onChange={(event) => update({ timezone: event.target.value })}
/>
) : null}
<p className="settings-hint" id="settings-timezone-help">
The application timezone: the source of local time for the panel, the scheduler and this
admin app. It changes only when you save it here; with no zone set, the API host&apos;s
local time is used.
</p>
</div>
<label className="settings-field">
<span className="settings-label">Notify target</span>
<input
@@ -561,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.
@@ -602,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;
@@ -696,6 +733,7 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
try {
const settings = await updateSettings(changes);
setPreferences({ kind: "ready", settings });
publishTimezone(settings.effective_timezone);
setPreferencesMessage({ kind: "info", text: "Preferences saved." });
} catch (error) {
setPreferencesMessage({
+93
View File
@@ -0,0 +1,93 @@
/**
* The effective application timezone, loaded once per signed-in shell: the
* API's `effective_timezone` — the stored zone, or the API host's own zone
* when none is stored — so every screen's local time matches the API's.
*
* <AppTimezoneProvider> fetches the household settings and renders its
* screens only once the zone is known (a failed load says so and offers a
* retry), so no screen ever computes local time in a zone it will not keep.
* The Settings screen publishes every zone it loads or saves, so a change
* applies app-wide at once.
*
* Without a provider (a screen rendered on its own, e.g. in a test)
* useAppTimezone() is "" — the documented unset value, i.e. browser-local.
*/
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { ApiForbiddenError } from "../api/client";
import { fetchSettings } from "../api/settings";
interface ZoneHub {
timeZone: string;
publish(timeZone: string): void;
}
const AppTimezoneContext = createContext<ZoneHub | null>(null);
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; timeZone: string };
export function AppTimezoneProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
setState({ kind: "loading" });
fetchSettings()
.then((settings) => {
if (!cancelled) setState({ kind: "ready", timeZone: settings.effective_timezone });
})
.catch((error: unknown) => {
if (cancelled) return;
setState({
kind: "error",
message:
error instanceof ApiForbiddenError
? error.message
: "The application timezone could not be loaded. Check your connection and try again.",
});
});
return () => {
cancelled = true;
};
}, [attempt]);
if (state.kind === "loading") {
return (
<div className="admin-loading" role="status" data-testid="timezone-loading">
Loading…
</div>
);
}
if (state.kind === "error") {
return (
<div className="admin-error" role="alert" data-testid="timezone-error">
<p>{state.message}</p>
<button type="button" onClick={() => setAttempt((n) => n + 1)}>
Try again
</button>
</div>
);
}
const hub: ZoneHub = {
timeZone: state.timeZone,
publish: (timeZone) =>
setState((prev) =>
prev.kind === "ready" && prev.timeZone === timeZone ? prev : { kind: "ready", timeZone },
),
};
return <AppTimezoneContext.Provider value={hub}>{children}</AppTimezoneContext.Provider>;
}
/** The effective application timezone: an IANA name, or "" (browser-local). */
export function useAppTimezone(): string {
return useContext(AppTimezoneContext)?.timeZone ?? "";
}
/** Publish an effective zone just loaded or saved; a no-op without a provider. */
export function usePublishAppTimezone(): (timeZone: string) => void {
const hub = useContext(AppTimezoneContext);
return (timeZone) => hub?.publish(timeZone);
}
+60
View File
@@ -0,0 +1,60 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { isoDateIn, todayIn, wallClockInstant, zoneOption } from "./zone";
import { formatClockTime } from "../today/format";
// 13:00 UTC on Sep 23 is already 01:00 on Sep 24 in Auckland (NZST, UTC+12).
const NOW = new Date("2026-09-23T13:00:00Z");
describe("application timezone helpers (process in UTC)", () => {
beforeAll(() => {
vi.stubEnv("TZ", "UTC");
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("a stored zone decides the calendar date; empty means the browser's zone", () => {
expect(todayIn("Pacific/Auckland", NOW)).toBe("2026-09-24");
expect(isoDateIn(NOW, "Pacific/Auckland")).toBe("2026-09-24");
expect(todayIn("", NOW)).toBe("2026-09-23");
});
it("wall-clock time converts in the stored zone, across its DST change", () => {
// NZST (UTC+12) before 27 Sep 2026, NZDT (UTC+13) after.
expect(wallClockInstant("2026-09-24", 0, 10, "Pacific/Auckland")).toBe(
Date.parse("2026-09-23T12:10:00Z"),
);
expect(wallClockInstant("2026-09-28", 7, 30, "Pacific/Auckland")).toBe(
Date.parse("2026-09-27T18:30:00Z"),
);
expect(wallClockInstant("2026-09-24", 0, 10, "")).toBe(Date.parse("2026-09-24T00:10:00Z"));
});
it("the completion clock reads in the stored zone", () => {
expect(formatClockTime("2026-09-23T11:04:00Z", "Pacific/Auckland")).toBe("11:04 PM");
expect(formatClockTime("2026-09-23T11:04:00Z", "")).toBe("11:04 AM");
});
it("an invalid stored zone fails fast", () => {
expect(zoneOption("")).toBeUndefined();
expect(() => todayIn("Not/AZone", NOW)).toThrow(RangeError);
});
});
describe("empty zone keeps browser-local behavior", () => {
beforeAll(() => {
vi.stubEnv("TZ", "America/Los_Angeles");
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("matches the browser's own calendar and wall clock", () => {
// 13:00 UTC is 06:00 PDT on Sep 23.
expect(todayIn("", NOW)).toBe("2026-09-23");
expect(wallClockInstant("2026-09-23", 7, 30, "")).toBe(new Date(2026, 8, 23, 7, 30).getTime());
expect(formatClockTime("2026-09-23T14:04:00Z", "")).toBe("7:04 AM");
});
});
+81
View File
@@ -0,0 +1,81 @@
/**
* Local time in the application timezone.
*
* The admin-saved zone (settings `timezone`, an IANA name) is the single
* source of local-time truth for the whole application; the API publishes it
* as `effective_timezone`, naming its host's own zone when none is stored.
* "" means the API published no zone, and only then does the browser's own
* zone apply. Every helper takes
* that stored value as-is; an invalid zone name throws (Intl's RangeError).
*/
/** The `timeZone` option for Intl: the zone, or undefined (browser) for "". */
export function zoneOption(timeZone: string): string | undefined {
return timeZone === "" ? undefined : timeZone;
}
interface WallClock {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
}
function wallClock(instant: number, timeZone: string): WallClock {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: zoneOption(timeZone),
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).formatToParts(instant);
const part = (type: Intl.DateTimeFormatPartTypes) =>
Number(parts.find((p) => p.type === type)?.value);
return {
year: part("year"),
month: part("month"),
day: part("day"),
hour: part("hour"),
minute: part("minute"),
second: part("second"),
};
}
/** The "YYYY-MM-DD" calendar date of `instant` in the zone. */
export function isoDateIn(instant: Date, timeZone: string): string {
const { year, month, day } = wallClock(instant.getTime(), timeZone);
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
/** Today's calendar date in the zone. */
export function todayIn(timeZone: string, now: Date = new Date()): string {
return isoDateIn(now, timeZone);
}
/** The zone's UTC offset in ms at `instant` (wall clock minus UTC). */
function offsetAt(instant: number, timeZone: string): number {
const c = wallClock(instant, timeZone);
const wholeSeconds = Math.floor(instant / 1000) * 1000;
return Date.UTC(c.year, c.month - 1, c.day, c.hour, c.minute, c.second) - wholeSeconds;
}
/**
* Epoch ms at which the zone's wall clock reads `hour:minute` on the
* "YYYY-MM-DD" date. Two passes settle the offset across a DST change.
*/
export function wallClockInstant(
iso: string,
hour: number,
minute: number,
timeZone: string,
): number {
const [year, month, day] = iso.split("-").map(Number);
const asUtc = Date.UTC(year, month - 1, day, hour, minute);
const guess = asUtc - offsetAt(asUtc, timeZone);
return asUtc - offsetAt(guess, timeZone);
}
+60
View File
@@ -5,6 +5,7 @@ import type { AdminSnapshot, SnapshotInstance } from "../api/snapshot";
import { TransitionsProvider } from "../api/useTransitions";
import { frame, testStream, type TestStream } from "../api/testStream";
import { storeTokens } from "../auth/oidc";
import { AppTimezoneProvider } from "../time/AppTimezone";
function instance(overrides: Partial<SnapshotInstance>): SnapshotInstance {
return {
@@ -406,3 +407,62 @@ describe("TodayTab Undo and live updates", () => {
await waitFor(() => expect(streams[0].cancelled()).toBe(true));
});
});
describe("TodayTab in the stored application timezone", () => {
function renderWithZone(timezone: string, effective_timezone = timezone) {
fetchMock.mockImplementation((url: string) =>
Promise.resolve(
String(url).endsWith("/api/v1/admin/settings")
? jsonResponse({ timezone, effective_timezone })
: jsonResponse(SNAPSHOT),
),
);
render(
<AppTimezoneProvider>
<TodayTab />
</AppTimezoneProvider>,
);
}
beforeAll(() => {
// The browser runs in UTC; the household lives in Auckland (UTC+12 on Sep 23).
vi.stubEnv("TZ", "UTC");
});
afterAll(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("shows the completion clock in the stored zone, not the browser's", async () => {
renderWithZone("Pacific/Auckland");
const completed = await screen.findByTestId("attention-12");
// 11:04 UTC is 11:04 PM in Auckland.
expect(within(completed).getByText("Completed 11:04 PM")).toBeTruthy();
// The header's day is the API's household-local today_iso, as-is.
expect(screen.getByText("Wednesday, September 23 · cycle day 5 of 14")).toBeTruthy();
});
it("an empty stored zone shows the clock in the API host's published zone", async () => {
renderWithZone("", "Pacific/Auckland");
const completed = await screen.findByTestId("attention-12");
expect(within(completed).getByText("Completed 11:04 PM")).toBeTruthy();
});
it("only an API that publishes no zone leaves the browser's local clock", async () => {
renderWithZone("", "");
const completed = await screen.findByTestId("attention-12");
expect(within(completed).getByText("Completed 11:04 AM")).toBeTruthy();
});
});
+3 -1
View File
@@ -13,6 +13,7 @@ import { ApiRequestError } from "../api/definitions";
import { uncompleteInstance } from "../api/instances";
import { useTransitions } from "../api/useTransitions";
import SettingsScreen from "../settings/SettingsScreen";
import { useAppTimezone } from "../time/AppTimezone";
import {
formatClockTime,
formatDueTime,
@@ -165,6 +166,7 @@ function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }
const { instance, childName } = item;
const overdue = isOverdueOpen(instance);
const undoing = undo.pendingId === instance.id;
const timeZone = useAppTimezone();
// TODO(e57facc2): wire "Mark done". The admin plane has no complete route
// yet, so that action is inert here.
return (
@@ -187,7 +189,7 @@ function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }
) : (
// The snapshot carries no completion actor, so "from panel" is not printed.
<div className="today-event-meta">
Completed {formatClockTime(instance.completed_at as string)}
Completed {formatClockTime(instance.completed_at as string, timeZone)}
</div>
)}
</div>
+10 -4
View File
@@ -4,8 +4,10 @@
* Calendar dates ("YYYY-MM-DD") are parsed by their components and formatted
* in UTC, so the printed day never shifts with the viewer's offset — unlike
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
* day in negative-offset zones.
* day in negative-offset zones. Instants are shown in the application
* timezone (time/zone.ts).
*/
import { zoneOption } from "../time/zone";
const LOCALE = "en-US";
@@ -44,7 +46,11 @@ export function formatDueTime(hhmm: string): string {
return format(date, { hour: "numeric", minute: "2-digit", timeZone: "UTC" });
}
/** A UTC ISO timestamp shown as the viewer's local time, "7:04 AM". */
export function formatClockTime(isoTimestamp: string): string {
return format(new Date(isoTimestamp), { hour: "numeric", minute: "2-digit" });
/** A UTC ISO timestamp shown as wall-clock time in `timeZone` ("" = browser), "7:04 AM". */
export function formatClockTime(isoTimestamp: string, timeZone: string): string {
return format(new Date(isoTimestamp), {
hour: "numeric",
minute: "2-digit",
timeZone: zoneOption(timeZone),
});
}
+62
View File
@@ -0,0 +1,62 @@
"""The API's effective application timezone (task 7e173881).
The admin-stored ``timezone`` setting is the single source of local-time
truth; stored empty, the API reads its clock in the API host's local
time. :func:`effective_timezone` names the zone the API actually uses —
the stored zone, or else the host's own IANA zone — so the panel cards
and the admin app format local time in exactly the API's zone instead of
guessing with Home Assistant's or the browser's. ``""`` only when the
host's zone genuinely cannot be named.
"""
from __future__ import annotations
import os
from collections.abc import Mapping
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
def _valid_zone(name: str) -> str:
"""Return ``name`` if it is a loadable IANA zone key, else ``""``."""
name = name.strip().removeprefix(":")
if not name:
return ""
try:
ZoneInfo(name)
except (ZoneInfoNotFoundError, ValueError):
return ""
return name
def host_timezone(
environ: Mapping[str, str] = os.environ,
timezone_file: Path = Path("/etc/timezone"),
localtime: Path = Path("/etc/localtime"),
) -> str:
"""Name the API host's local zone as an IANA key, or ``""``.
In the order the C library honours them: the ``TZ`` environment
variable, then the ``/etc/localtime`` zoneinfo link (with Debian's
``/etc/timezone`` naming it when the link is a copy). A host with
none of them configured runs UTC. A setting that names no IANA
zone (a POSIX rule string, a copied file) is ``""``.
"""
configured = environ.get("TZ")
if configured is not None:
# An empty TZ is UTC to the C library.
return _valid_zone(configured) if configured.strip() else "UTC"
if localtime.is_symlink():
target = str(localtime.resolve())
marker = "/zoneinfo/"
if marker in target:
return _valid_zone(target.split(marker, 1)[1])
if timezone_file.is_file():
return _valid_zone(timezone_file.read_text(encoding="utf-8"))
if not localtime.exists():
return "UTC"
return ""
def effective_timezone(stored: str) -> str:
"""The zone the API reads its clock in: ``stored``, else the host's."""
return stored or host_timezone()
+32 -8
View File
@@ -170,7 +170,10 @@ are deliberately not unified here:
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.
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
@@ -349,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
@@ -920,9 +924,14 @@ class AdminSettingsResponse(BaseModel):
afternoon_reminder_enabled: bool
end_of_day_report_enabled: bool
celebration_enabled: bool
#: The household IANA time zone the API reads its clock in; ``""``
#: means the API host's local time.
#: 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):
@@ -2179,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 eleven 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,
@@ -2194,6 +2204,7 @@ def _settings_response(
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),
)
@@ -2330,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)
+12 -3
View File
@@ -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.
"""
@@ -66,6 +71,7 @@ from api.nestquest_core import (
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]
@@ -218,6 +226,7 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
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,
+2 -1
View File
@@ -22,7 +22,8 @@ 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. A settings writer holds :attr:`MissedSweepScheduler.settings_lock`
replaced. The settings writer — the admin ``PATCH /api/v1/admin/settings``
route, the only one — holds :attr:`MissedSweepScheduler.settings_lock`
across its write AND its :meth:`~MissedSweepScheduler.settings_changed`
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
+26 -6
View File
@@ -16,9 +16,11 @@ network and NO aiohttp installed; production builds the client through
:func:`client_from_entry`, which hands in Home Assistant's shared
aiohttp session (``homeassistant.helpers.aiohttp_client
.async_get_clientsession``). The module itself imports NEITHER
homeassistant NOR aiohttp at runtime (aiohttp appears only behind
``TYPE_CHECKING`` for annotations), so the client is usable anywhere —
the HA coupling lives in the one production factory.
homeassistant NOR aiohttp at module import (aiohttp appears only
behind ``TYPE_CHECKING`` for annotations, plus one lazy import inside
:meth:`NestQuestApiClient.stream_events` for its ``ClientTimeout``), so
the client is importable anywhere — the HA coupling lives in the one
production factory.
Every failure is typed: a transport failure (connection refused, DNS
failure, timeout — the exchange never produced a response) and every
@@ -38,7 +40,11 @@ frame (``event: <type>`` / ``data: <json>`` lines separated by a blank
line). Opening the stream gets the same one-timeout, typed-error
contract as every other call; reading it has NO body-wide timeout (a
healthy stream is long-lived — the subscription manager owns
reconnection and backoff).
reconnection and backoff). That includes the session's own default:
Home Assistant's shared session carries ``ClientTimeout(total=300)``,
which would cut a healthy stream every five minutes, so the stream
request overrides it with ``total=None`` (keeping a finite
``sock_connect``).
"""
from __future__ import annotations
@@ -278,8 +284,19 @@ class NestQuestApiClient:
(``aclose``, i.e. the consumer's task being cancelled) stops
cleanly and closes the underlying response in every path.
"""
# Lazy: aiohttp ships with Home Assistant, but importing it at
# module level would make the client unimportable without it.
import aiohttp
url = f"{self._base_url}{EVENTS_PATH}"
headers = self._headers()
# The session's default total timeout (HA's shared session:
# 300 s) would cut a healthy stream mid-body, and events fired
# while reconnecting are not replayed — so the stream request
# is unbounded in total time; only connecting stays bounded.
stream_timeout = aiohttp.ClientTimeout(
total=None, sock_connect=self._timeout_seconds
)
# Opening the response gets ONE request timeout, exactly like
# ``_request_json``; the read loop below runs WITHOUT one — a
# body-wide timeout would kill a healthy long-lived stream.
@@ -289,7 +306,7 @@ class NestQuestApiClient:
try:
async with asyncio.timeout(self._timeout_seconds):
stream_cm = self._session.request(
"GET", url, headers=headers, json=None
"GET", url, headers=headers, json=None, timeout=stream_timeout
)
response = await stream_cm.__aenter__()
except asyncio.CancelledError:
@@ -315,7 +332,10 @@ class NestQuestApiClient:
event_type: str | None = None
data_lines: list[str] = []
buffer = ""
async for chunk in response:
# The body is ``response.content`` (aiohttp's StreamReader,
# yielding bytes); the response object itself — HA's
# ``HassClientResponse`` included — is NOT async-iterable.
async for chunk in response.content:
if isinstance(chunk, (bytes, bytearray)):
chunk = chunk.decode("utf-8")
buffer += chunk
+1 -1
View File
@@ -9,7 +9,7 @@ snapshot (the same ChildDaySnapshot the day sensors read):
celebration automation must never fire on an empty day (the
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
@@ -0,0 +1,96 @@
blueprint:
name: NestQuest UniFi quest access
description: >-
One automation per child. Completely block the selected UniFi clients
after the local weekday/weekend cutoff while that child has quests
remaining TODAY. Restore network access immediately when the count
reaches zero, and at midnight even if yesterday was incomplete.
A missing or unavailable quest sensor is treated as still open after
the cutoff; verify the sensor before enabling this automation. The
selected UniFi client switches are ON when access is allowed and
OFF when blocked. Do not select infrastructure or adult devices.
domain: automation
input:
remaining_sensor:
name: Child's quests remaining today
description: Select this child's NestQuest quests remaining today sensor.
selector:
entity:
filter:
- integration: nestquest
domain: sensor
client_switches:
name: Child's UniFi client access switches
description: >-
Select every UniFi client access switch for this child. These
switches are ON when network access is allowed and OFF when blocked.
selector:
entity:
multiple: true
filter:
- integration: unifi
domain: switch
weekday_cutoff:
name: Monday-Friday cutoff
default: "18:25:00"
selector:
time: {}
weekend_cutoff:
name: Saturday-Sunday cutoff
default: "14:00:00"
selector:
time: {}
mode: restart
variables:
remaining_sensor: !input remaining_sensor
client_switches: !input client_switches
weekday_cutoff: !input weekday_cutoff
weekend_cutoff: !input weekend_cutoff
triggers:
- trigger: state
entity_id: !input remaining_sensor
- trigger: time
at: !input weekday_cutoff
- trigger: time
at: !input weekend_cutoff
- trigger: time
at: "00:00:00"
- trigger: homeassistant
event: start
# Reconcile after missed events or a temporary UniFi outage.
- trigger: time_pattern
minutes: "/5"
conditions:
- condition: template
value_template: "{{ client_switches | count > 0 }}"
actions:
- variables:
should_block: >-
{{ now().strftime('%H:%M') >=
(weekday_cutoff[:5] if now().weekday() < 5 else weekend_cutoff[:5])
and (states(remaining_sensor) | int(-1)) != 0 }}
- repeat:
for_each: "{{ client_switches }}"
sequence:
- choose:
- conditions:
- condition: template
value_template: >-
{{ should_block | bool and is_state(repeat.item, 'on') }}
sequence:
- action: switch.turn_off
target:
entity_id: "{{ repeat.item }}"
- conditions:
- condition: template
value_template: >-
{{ not (should_block | bool) and is_state(repeat.item, 'off') }}
sequence:
- action: switch.turn_on
target:
entity_id: "{{ repeat.item }}"
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

@@ -111,6 +111,9 @@ def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot:
through verbatim;
- ``present`` and ``next_present`` pass through verbatim (the API
resolved them through the same presence engine server-side);
- ``timezone`` (the API's effective application timezone) passes
through verbatim for the cards' date and clock; an API older than
the field omits it, which reads as ``""`` (the cards' fallback);
- the instance list is rebuilt IN ORDER, so the panel payload the
sensors emit matches the route's order byte for byte.
@@ -161,6 +164,7 @@ def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot:
today_iso=payload["today_iso"],
children=tuple(children),
cycle_day=payload["cycle_day"],
timezone=payload.get("timezone", ""),
)
+11 -11
View File
@@ -10,7 +10,7 @@ un-completing (no-op if already open) is :func:`uncomplete_instance`.
Instance current state is derived from the latest event via
:func:`instance_state` (``open`` / ``done`` / ``missed``); there is
no status column. ``missed`` is derived for past-due open instances
(``due_date`` before HA-local ``today``) and is never stored as an
(``due_date`` before household-local ``today``) and is never stored as an
event_type.
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
@@ -71,10 +71,10 @@ def _completion_moment(
now: datetime.datetime | None,
today: datetime.date | None,
) -> tuple[datetime.date, datetime.time | None]:
"""Return the HA-local completion date and optional wall-clock time.
"""Return the household-local completion date and optional wall-clock time.
``today`` is the HA-local calendar date when threaded (Feature 07
style). ``now`` is a timezone-aware HA-local datetime; its wall
``today`` is the household-local calendar date when threaded (Feature 07
style). ``now`` is a timezone-aware household-local datetime; its wall
clock is used as-is and is never converted to UTC. When ``today``
is omitted the date comes from ``now``. When both are omitted the
host calendar date is the Feature 07 fallback — never the UTC
@@ -98,7 +98,7 @@ def _completion_moment(
def _resolve_today(today: datetime.date | None) -> datetime.date:
"""Return the threaded HA-local date, or the host calendar date."""
"""Return the threaded household-local date, or the host calendar date."""
if today is None:
return datetime.date.today()
return today
@@ -138,7 +138,7 @@ def derive_state(
Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or
``missed`` from the latest event plus the due date (``missed`` is
derived, never an event_type). ``today`` is the caller-resolved
HA-local date.
household-local date.
"""
return _derived_state(instance, latest, today)
@@ -345,7 +345,7 @@ async def instance_state(
"""Return ``open``, ``done``, or ``missed`` from the latest event.
No events, or a latest event of uncompleted, is ``open`` unless
``due_date`` is before ``today`` (HA-local, threaded), in which
``due_date`` is before ``today`` (household-local, threaded), in which
case it is ``missed``. A latest event of completed is ``done``
even when past due. ``missed`` is never stored as an event_type.
Unknown ``instance_id`` raises ValueError naming the field.
@@ -414,9 +414,9 @@ async def complete_instance(
latest-event, and the append run under one connection lock so a
concurrent complete cannot double-append.
``was_on_time`` is computed from the HA-local completion moment
``was_on_time`` is computed from the household-local completion moment
(threaded ``now`` / ``today``) against the instance ``due_date``
and optional ``due_time``. ``now`` is the timezone-aware HA-local
and optional ``due_time``. ``now`` is the timezone-aware household-local
datetime; its wall clock is never converted to UTC. Same-day
``due_time`` comparison requires ``now``.
"""
@@ -471,7 +471,7 @@ async def uncomplete_instance(
"""Append an uncompleted event unless the instance is already open.
Returns the derived state ``open``, or ``missed`` when the instance
is past due (``due_date`` before HA-local ``today``); ``appended``
is past due (``due_date`` before household-local ``today``); ``appended``
reports whether THIS call appended (False on an already-open
no-op). Unknown ``instance_id`` raises ValueError naming the
field. Existence, latest-event, and the append run under one
@@ -529,7 +529,7 @@ async def list_missed_for_child(
"""Return the child's missed instances due in the closed date range.
Missed means the latest event state is open and ``due_date`` is
before ``today`` (HA-local, threaded). Uses
before ``today`` (household-local, threaded). Uses
:meth:`QuestInstancesDao.list_by_date_range` plus derived state.
Never appends a missed event. Instances and latest events are
read inside the connection lock so the list is one snapshot.
+6 -2
View File
@@ -115,8 +115,12 @@ 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 itself always uses
# Home Assistant's ``time_zone`` and ignores this field.
# 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 = ""
@@ -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.
+9 -8
View File
@@ -39,10 +39,11 @@ ISO-8601 timestamp. Completion payloads additionally carry
``was_on_time``. No other personal data: names and titles are the
household's own records, and nothing beyond them leaks.
Timezone policy: the day-complete check needs the HA-local "today",
which the caller passes in as a plain :class:`datetime.date`. Nothing
here reads ``hass.config.time_zone`` — the integration owns HA
timezone, the core owns the calendar arithmetic.
Timezone policy: the day-complete check needs the household-local
"today", which the caller passes in as a plain :class:`datetime.date`.
Nothing here reads a timezone — the API owns the clock (the
admin-stored application timezone, or the API host's local zone when
none is stored), the core owns the calendar arithmetic.
"""
from __future__ import annotations
@@ -147,10 +148,10 @@ async def build_child_day_complete_event(
completion of TODAY'S instance evaluates it: completing a
back-dated or future instance must not announce a cleared day.
``today`` is the HA-local calendar date the day-complete rule is
evaluated against; the integration reads it from
``hass.config.time_zone`` and passes it in, so this module never
touches HA config.
``today`` is the household-local calendar date the day-complete
rule is evaluated against; the API resolves it in the application
timezone and passes it in, so this module never reads a clock or a
timezone of its own.
"""
# Only a completion of TODAY'S instance evaluates the day-complete
# rule: completing a back-dated or future instance must not
+2 -2
View File
@@ -153,7 +153,7 @@ def _validate_range(start_date: str, end_date: str) -> tuple[str, str]:
def _resolve_today(today: datetime.date | None) -> datetime.date:
"""Return the threaded HA-local date, or the host calendar date."""
"""Return the threaded household-local date, or the host calendar date."""
if today is None:
return datetime.date.today()
return today
@@ -283,7 +283,7 @@ async def query_history(
Rows are ordered deterministically by ``due_date``, then instance,
then event id (the per-instance append order, so a completion and
its reversal keep their order). ``today`` pins the HA-local date
its reversal keep their order). ``today`` pins the household-local date
the missed derivation compares due dates against (the host calendar
date when omitted). The whole read runs under the task-reentrant
connection lock — :func:`list_missed_for_child` re-enters it — so
@@ -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
@@ -345,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
@@ -449,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.
@@ -491,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.
@@ -548,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.
+6 -7
View File
@@ -316,12 +316,11 @@ class NestQuestSettings:
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 — the admin's choice, or else the zone the
integration reports from Home Assistant — 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).
: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
@@ -341,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
+8 -2
View File
@@ -18,7 +18,7 @@ the panel payload shaper (:func:`instance_payload`), and the async
service's panel snapshot route share ONE implementation of both the
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.
@@ -96,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(
@@ -182,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
@@ -319,4 +324,5 @@ async def build_snapshot(
today_iso=today_iso,
children=tuple(snapshots),
cycle_day=_cycle_day(patterns, children, today),
timezone=settings.timezone,
)
+7 -6
View File
@@ -39,9 +39,10 @@ lets exactly one of them win the claim — the loser returns [] and
announces nothing.
The caller supplies ``today`` as a plain :class:`datetime.date`: the
household-local calendar date, read from ``hass.config.time_zone`` by
the integration or from the configured household timezone 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,9 +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 the configured household
timezone, falling back to its host's local time when unset).
(the caller owns the clock: the API reads it in the admin-stored
application timezone, falling back to its host's local time when
unset).
Returns the ``(event_type, payload)`` tuples to announce, EMPTY
when the watermark already covers ``today`` (a same-night rerun) —
the caller decides how to publish them and announces nothing on
+1 -1
View File
@@ -15,5 +15,5 @@
"iot_class": "local_polling",
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
"requirements": [],
"version": "0.9.7"
"version": "0.10.3"
}
+4
View File
@@ -441,6 +441,10 @@ class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
for child in data.children
},
"child_roster": _child_roster(data.children),
# The API's effective application timezone ("" only when
# it could not name one): the panel cards format their
# visible date and clock in it.
"timezone": data.timezone,
}
File diff suppressed because it is too large Load Diff
+16 -5
View File
@@ -105,14 +105,25 @@ Each definition carries one optional icon, shown on both surfaces. The picker of
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.
- **Font Awesome** — a search over the **full** Font Awesome Free *solid* set (every
glyph in `@fortawesome/free-solid-svg-icons`), plus a curated quick-pick grid, all drawn
filled. With the search field empty the quick-pick grid shows; typing filters the full
set by name or label (an exact name ranks first, then name prefixes, then the rest),
shown 40 results per page with *Previous* / *Show more*.
**Free-text rule:** an admin may type a Free Solid icon name directly (trimmed,
case-insensitive — `Dog` stores `fa:dog`). Text that is not the exact name of a Free
Solid icon — a partial search, a misspelling, or a Pro or Brands name — is rejected in
the picker with an inline error and cannot be saved until a result is selected or the
full name is typed.
- **Emoji** — a text input; the typed emoji is stored as entered (not trimmed) and must be
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 curated Lucide set and the Font Awesome quick-pick list come from
`tools/icons/curated-icons.json`; `tools/icons/generate.mjs` generates their glyph path
data, together with the full Font Awesome Free Solid set, into the admin and panel bundles
(no CDN, no runtime fetch). Embedding the full set makes both bundles heavier — the panel
bundle (`nestquest-cards.js`) is about 1 MB raw / 276 kB gzip. See
`THIRD-PARTY-NOTICES.md` for licences.
The stored value is a **namespaced key**:
+2 -2
View File
@@ -78,6 +78,6 @@ The panel speaks tabletop; the admin speaks plainly. Both sit on the same `quest
decide before shipping the card.
- 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 curated Lucide and Font Awesome
Free sets or as an emoji, stored as a namespaced key (`lucide:`/`fa:`/`emoji:`) —
- Icon per quest definition is chosen by the parent from the curated Lucide set, the full
Font Awesome Free Solid set (searchable, with curated quick picks) or as an emoji, stored as a namespaced key (`lucide:`/`fa:`/`emoji:`) —
resolved 2026-09-26, see `ADMIN-SPEC.md` §3.3.
+2 -1
View File
@@ -106,7 +106,8 @@ collapse the column, the three-column rhythm is the layout.
1. **Icon tile** 64×64, radius 10px, `--nq-p-icon-tile`, glyph 34px white — the
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**;
round caps and joins); `fa:<name>` draws the Font Awesome Free Solid glyph **filled** (any icon in the full
set, not only the quick picks);
`emoji:<grapheme>` renders the emoji **as text**. A legacy bare name resolves as
`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
+48
View File
@@ -0,0 +1,48 @@
# NestQuest brand icons in Home Assistant
## Where the icon comes from
Since Home Assistant 2026.3, a custom integration can ship its own brand
images in `custom_components/<domain>/brand/`, and Home Assistant serves them
ahead of the brands CDN (`brands.home-assistant.io`). NestQuest ships:
| File | Size |
|------|------|
| `custom_components/nestquest/brand/icon.png` | 256×256 |
| `custom_components/nestquest/brand/icon@2x.png` | 512×512 |
| `custom_components/nestquest/brand/logo.png` | 256×256 |
| `custom_components/nestquest/brand/logo@2x.png` | 512×512 |
HACS installs the whole `custom_components/nestquest/` directory, so these
images arrive with the integration. On Home Assistant 2026.3 or newer they
appear on the integrations page and the integration tile, through Home
Assistant's brand endpoint. Older Home Assistant versions ignore the directory
and show the generic placeholder icon.
The `custom_integrations` folder of the `home-assistant/brands` repository is
legacy for this purpose; NestQuest does not depend on it.
## Known caveat: the HACS store card
The HACS store card (the repository listing inside HACS, shown before and
after installing) is rendered by HACS itself, not by the integration. It may
still take its image from the brands CDN or fall back to the avatar of the
repository owner (the `Maniacs-Mansion` organization). Whether HACS reads the
local `brand/` directory for that card has not been verified, so that icon
may stay generic. The repository page inside HACS shows the README, which
embeds the logo from `design/logos/NestQuest-NoBG.png`.
## Regenerating
The PNGs are generated offline and deterministically (pure Node, zlib only)
from `design/logos/NestQuest-NoBG.png`, box-filter downscaled with
transparency kept. From the repository root:
```bash
node design/logos/generate-brand-icons.mjs # write the four PNGs
node design/logos/generate-brand-icons.mjs --check # exit 1 if any drifted
```
`tests/test_brand_icons.py` runs the `--check` and asserts the four files
exist with the expected sizes, so a missing or hand-edited image fails the
test suite.
+187
View File
@@ -0,0 +1,187 @@
// Generates the integration's local brand images in
// custom_components/nestquest/brand/ (read by Home Assistant 2026.3+) from the
// transparent logo (design/logos/NestQuest-NoBG.png), box-filter downscaled
// with alpha kept. Adapted from admin/scripts/generate-icons.mjs for RGBA.
// Pure Node (zlib only) — no network, no image dependencies.
// Run (from the repository root):
// node design/logos/generate-brand-icons.mjs # write the PNGs
// node design/logos/generate-brand-icons.mjs --check # exit 1 if any drifted
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { deflateSync, inflateSync } from "node:zlib";
import { fileURLToPath } from "node:url";
const SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(buf) {
let c = 0xffffffff;
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([len, body, crc]);
}
function encodePng(size, rgba) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // colour type: RGBA
const stride = size * 4;
const raw = Buffer.alloc(size * (stride + 1));
for (let y = 0; y < size; y++) {
raw[y * (stride + 1)] = 0; // filter: none
raw.set(rgba.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
}
return Buffer.concat([
SIG,
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw, { level: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
// Minimal decoder for the source logo: 8-bit RGBA, non-interlaced only.
// Anything else fails loudly rather than producing a wrong icon.
function decodePng(buf) {
if (!buf.subarray(0, 8).equals(SIG)) throw new Error("not a PNG");
let width, height;
const idat = [];
for (let off = 8; off < buf.length; ) {
const len = buf.readUInt32BE(off);
const type = buf.toString("ascii", off + 4, off + 8);
const data = buf.subarray(off + 8, off + 8 + len);
if (type === "IHDR") {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
const [depth, colour, , , interlace] = data.subarray(8);
if (depth !== 8 || colour !== 6 || interlace !== 0) {
throw new Error(`unsupported PNG: depth ${depth}, colour type ${colour}, interlace ${interlace}`);
}
} else if (type === "IDAT") idat.push(data);
else if (type === "IEND") break;
off += 12 + len;
}
const raw = inflateSync(Buffer.concat(idat));
const stride = width * 4;
const px = Buffer.alloc(stride * height);
for (let y = 0; y < height; y++) {
const filter = raw[y * (stride + 1)];
const src = y * (stride + 1) + 1;
const dst = y * stride;
for (let i = 0; i < stride; i++) {
const a = i >= 4 ? px[dst + i - 4] : 0;
const b = y > 0 ? px[dst - stride + i] : 0;
const c = i >= 4 && y > 0 ? px[dst - stride + i - 4] : 0;
let p;
if (filter === 0) p = 0;
else if (filter === 1) p = a;
else if (filter === 2) p = b;
else if (filter === 3) p = (a + b) >> 1;
else if (filter === 4) {
const pa = Math.abs(b - c), pb = Math.abs(a - c), pc = Math.abs(a + b - 2 * c);
p = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
} else throw new Error(`bad PNG filter ${filter} on row ${y}`);
px[dst + i] = (raw[src + i] + p) & 0xff;
}
}
if (width !== height) throw new Error(`logo must be square, got ${width}x${height}`);
return { width, height, px };
}
// Box-filter (area-average) resize along one axis: each output sample is the
// coverage-weighted mean of the source samples it spans.
function boxWeights(srcLen, dstLen) {
const scale = srcLen / dstLen;
return Array.from({ length: dstLen }, (_, o) => {
const start = o * scale, end = start + scale;
const taps = [];
for (let s = Math.floor(start); s < Math.min(Math.ceil(end), srcLen); s++) {
taps.push([s, (Math.min(end, s + 1) - Math.max(start, s)) / scale]);
}
return taps;
});
}
// Colour is averaged premultiplied by alpha, so fully transparent source
// pixels (whatever RGB they carry) cannot bleed a fringe into the edges.
function resize({ width, height, px }, size) {
const wx = boxWeights(width, size);
const wy = boxWeights(height, size);
const tmp = new Float64Array(size * height * 4); // horizontal pass, premultiplied
for (let y = 0; y < height; y++) {
for (let x = 0; x < size; x++) {
const t = (y * size + x) * 4;
for (const [s, w] of wx[x]) {
const p = (y * width + s) * 4;
const aw = px[p + 3] * w;
for (let k = 0; k < 3; k++) tmp[t + k] += px[p + k] * aw;
tmp[t + 3] += aw;
}
}
}
const out = new Uint8Array(size * size * 4); // vertical pass, un-premultiply
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const acc = [0, 0, 0, 0];
for (const [s, w] of wy[y]) {
const t = (s * size + x) * 4;
for (let k = 0; k < 4; k++) acc[k] += tmp[t + k] * w;
}
const o = (y * size + x) * 4;
const alpha = Math.min(255, Math.round(acc[3]));
for (let k = 0; k < 3; k++) {
out[o + k] = alpha === 0 ? 0 : Math.min(255, Math.round(acc[k] / acc[3]));
}
out[o + 3] = alpha;
}
}
return out;
}
const logo = decodePng(readFileSync(new URL("NestQuest-NoBG.png", import.meta.url)));
const outDir = new URL("../../custom_components/nestquest/brand/", import.meta.url);
const files = [
["icon.png", 256],
["icon@2x.png", 512],
["logo.png", 256],
["logo@2x.png", 512],
];
const check = process.argv.includes("--check");
let drifted = false;
if (!check) mkdirSync(outDir, { recursive: true });
for (const [name, size] of files) {
const path = fileURLToPath(new URL(name, outDir));
const png = encodePng(size, resize(logo, size));
if (check) {
let current = null;
try {
current = readFileSync(path);
} catch {
// missing counts as drift
}
if (!current || !current.equals(png)) {
drifted = true;
console.error(`drift: ${path} differs from a fresh generation`);
}
} else {
writeFileSync(path, png);
console.log(`wrote ${path}`);
}
}
if (drifted) {
console.error("run `node design/logos/generate-brand-icons.mjs` and commit the result");
process.exit(1);
}
+32
View File
@@ -99,6 +99,38 @@ A lone `home` pattern answers exactly as the old schedule did, so no
child's presence changes. The drop is one-way and 0.7.1 refuses a
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
+11 -1
View File
@@ -937,8 +937,18 @@ export class NestQuestPartyBoardCard extends LitElement {
`;
}
/** The zone the visible date and clock render in: the API's effective
* application timezone — the stored zone, or the API host's when none
* is stored (published on the household rollup by
* custom_components/nestquest/sensor.py — ``timezone``). Only when the
* rollup carries none does Home Assistant's zone (or the browser's)
* apply. */
private _timeZone(): string | undefined {
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 {
+15 -27
View File
@@ -36,6 +36,7 @@ type PanelInstance = {
window: string;
due_time: string | null;
state: string;
overdue: boolean;
completed_at: string | null;
on_time: boolean | null;
};
@@ -153,17 +154,6 @@ function formatWallClock(value: string | null): string | null {
return `${hour12}:${match[2]} ${suffix}`;
}
/** Minutes past midnight of a household ``HH:MM`` wall time, or null. */
function wallClockMinutes(value: string | null): number | null {
const match = value ? /^(\d{1,2}):(\d{2})$/.exec(value.trim()) : null;
if (!match) {
return null;
}
const hours = Number(match[1]);
const minutes = Number(match[2]);
return hours > 23 || minutes > 59 ? null : hours * 60 + minutes;
}
function asString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
@@ -263,6 +253,7 @@ function toPanelInstance(value: unknown): PanelInstance | null {
window: asString(row.window).toLowerCase(),
due_time: asString(row.due_time) || null,
state,
overdue: row.overdue === true,
completed_at: asString(row.completed_at) || null,
on_time: row.on_time === true ? true : row.on_time === false ? false : null,
};
@@ -1758,22 +1749,18 @@ export class NestQuestQuestLogCard extends LitElement {
`;
}
/** Overdue by the panel's own clock: the browser runs on the
household's real local time, so an open quest turns overdue once its
``due_time`` wall time has arrived there — whatever zone the API or
Home Assistant computed the API's ``overdue`` flag in. With no due
time it is never overdue (the API's rule too). ``_now`` is reactive
and ticks every 60 s, so the line flips on the minute it falls due. */
private _isOverdue(instance: PanelInstance): boolean {
const due = wallClockMinutes(instance.due_time);
if (instance.state !== "open" || due === null) {
return false;
}
return this._now.getHours() * 60 + this._now.getMinutes() >= due;
}
/** 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 {
@@ -1856,6 +1843,7 @@ export class NestQuestQuestLogCard extends LitElement {
continue;
}
instance.state = "completed";
instance.overdue = false;
instance.completed_at = stampedAt;
}
if (settled) {
@@ -2152,7 +2140,7 @@ export class NestQuestQuestLogCard extends LitElement {
`;
}
const dueAt = formatWallClock(instance.due_time);
const meta = this._isOverdue(instance)
const meta = instance.overdue
? {
text: dueAt ? `Overdue · due ${dueAt}` : "Overdue",
late: true,
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,7 +1,8 @@
/**
* Lit rendering for the shared icon registry (`registry.generated.ts`,
* generated from tools/icons/curated-icons.json). The glyph path data is
* bundled — no CDN and no runtime fetch.
* generated by tools/icons/generate.mjs: the curated Lucide set plus the full
* Font Awesome Free Solid set). The glyph path data is bundled — no CDN and
* no runtime fetch.
*/
import { html, svg, type TemplateResult } from "lit";
+22 -3
View File
@@ -3,13 +3,15 @@
*
* Usage: node render-party-board.mjs < spec.json
*
* The spec JSON carries {bundle, tag, config, hass, url?, click_plate?};
* The spec JSON carries {bundle, tag, config, hass, url?, click_plate?, now?};
* stdout receives one JSON object {plates, tappable, location, html} where
* plates is the ordered plate list the shadow DOM renders (name +
* present/away/unknown kind), tappable the plate names rendered as tappable
* <button> plates, and location the window pathname after the optional plate
* tap. The Python panel tests drive this harness so the assertions run
* against the exact bundle HACS ships.
* tap. With now: <ISO instant> the card's clock is pinned there
* (argument-less new Date() and Date.now() return it; the process TZ
* decides its local wall time). The Python panel tests drive this harness
* so the assertions run against the exact bundle HACS ships.
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
@@ -39,6 +41,23 @@ globalThis.window = dom.window;
// jsdom Event must shadow it for the dispatched event to be delivered.
globalThis.Event = dom.window.Event;
// Pin the clock: argument-less construction and Date.now() read `pinned`,
// every other Date use is the real class.
if (spec.now !== undefined) {
const pinned = new Date(spec.now).getTime();
const RealDate = Date;
class PinnedDate extends RealDate {
constructor(...args) {
super(...(args.length === 0 ? [pinned] : args));
}
static now() {
return pinned;
}
}
globalThis.Date = PinnedDate;
dom.window.Date = PinnedDate;
}
await import(pathToFileURL(spec.bundle).href);
const element = document.createElement(spec.tag);
Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nestquest"
version = "0.9.7"
version = "0.10.3"
description = "NestQuest Home Assistant Integration"
readme = "README.md"
requires-python = ">=3.11"
+26 -2
View File
@@ -879,6 +879,29 @@ def set_coordinator_client(entry, client):
return client
@dataclass(frozen=True)
class FakeClientTimeout:
"""Stand-in for ``aiohttp.ClientTimeout`` (the fields the client sets)."""
total: float | None = None
sock_connect: float | None = None
@pytest.fixture
def fake_aiohttp(monkeypatch):
"""Install a stand-in ``aiohttp`` module for one test.
aiohttp ships only with Home Assistant, so this mock-only harness
does not have it; the API client imports it lazily inside
``stream_events`` (for ``ClientTimeout``). Tests driving the real
stream path request this fixture; the module is removed again when
the test ends.
"""
module = SimpleNamespace(ClientTimeout=FakeClientTimeout)
monkeypatch.setitem(sys.modules, "aiohttp", module)
return module
@pytest.fixture(autouse=True)
def coordinator_client_from_test_override():
"""Resolve every entry's coordinator client through the test seam.
@@ -910,6 +933,7 @@ def coordinator_client_from_test_override():
{
"today_iso": datetime.date.today().isoformat(),
"cycle_day": 0,
"timezone": "",
"children": [],
}
)
@@ -1016,8 +1040,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
+55 -7
View File
@@ -41,6 +41,7 @@ from types import SimpleNamespace
import pytest
from api import host_zone
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
@@ -60,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 eleven 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",
@@ -75,22 +81,34 @@ DEFAULT_SETTINGS = {
"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(
@@ -147,7 +165,9 @@ async def test_update_persists_and_the_next_read_reflects_it(
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(
@@ -254,7 +274,7 @@ async def test_manual_timezone_patch_persists_and_is_returned(
headers=_admin_headers(),
)
assert patched.status_code == 200
expected = {**DEFAULT_SETTINGS, "timezone": zone}
expected = _with_zone(zone)
assert patched.json() == expected
reread = await client.get(
@@ -270,6 +290,24 @@ async def test_manual_timezone_patch_persists_and_is_returned(
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:
@@ -301,7 +339,7 @@ async def test_stored_legacy_timezone_configured_key_is_ignored(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert response.status_code == 200
assert response.json() == {**DEFAULT_SETTINGS, "timezone": "America/Chicago"}
assert response.json() == _with_zone("America/Chicago")
# --- validation: rejected values never change the stored settings -------
@@ -320,6 +358,9 @@ async def test_stored_legacy_timezone_configured_key_is_ignored(
({"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(
@@ -474,6 +515,13 @@ async def test_settings_routes_refuse_non_admin_and_absent_credentials(
)
assert absent_patch.status_code == 401
forbidden_zone = await client.patch(
"/api/v1/admin/settings",
json={"timezone": "America/New_York"},
headers={"Authorization": f"Bearer {non_admin}"},
)
assert forbidden_zone.status_code == 403
# No refused request changed anything: the settings are untouched.
after = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
+8 -3
View File
@@ -37,7 +37,7 @@ from zoneinfo import ZoneInfo
import pytest
from api import routes_admin, routes_panel
from api import host_zone, routes_admin, routes_panel
from api.nestquest_core import core_snapshot
from tests.admin_jwt_harness import (
ADMIN_KEY,
@@ -101,6 +101,8 @@ async def snapshot_household(
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now)
# The API host runs UTC, the zone of the pinned clock.
monkeypatch.setattr(host_zone, "host_timezone", lambda: "UTC")
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(
@@ -165,7 +167,10 @@ async def test_admin_jwt_returns_documented_snapshot(
Every field is asserted as an explicit literal, with ONE clock
read for the request. With no missed view in the real snapshot,
the admin body equals the panel body for the same household.
the admin body equals the panel body for the same household — the
panel body additionally carries the effective ``timezone`` its cards
format the visible date and clock in (none stored here: the UTC
host's own zone).
"""
client = snapshot_household.client
seed = snapshot_household.seed
@@ -241,7 +246,7 @@ async def test_admin_jwt_returns_documented_snapshot(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
assert panel.status_code == 200
assert panel.json() == body
assert panel.json() == {**body, "timezone": "UTC"}
async def test_admin_keeps_missed_instance_panel_omits_it(
+112 -2
View File
@@ -17,7 +17,7 @@ import types
import pytest
from conftest import make_config_entry
from conftest import FakeClientTimeout, make_config_entry
from custom_components.nestquest import api_client
from custom_components.nestquest.api_client import (
@@ -33,6 +33,10 @@ from custom_components.nestquest.const import (
DEFAULT_PANEL_TOKEN,
)
# ``stream_events`` imports aiohttp lazily; the harness has none, so
# every test here runs with the stand-in module installed.
pytestmark = pytest.mark.usefixtures("fake_aiohttp")
BASE_URL = "http://api.test:8000"
TOKEN = "test-panel-token"
@@ -124,6 +128,7 @@ class StubTransport:
"url": url,
"headers": kwargs.get("headers"),
"json": kwargs.get("json"),
"timeout": kwargs.get("timeout"),
}
)
if not self._outcomes:
@@ -472,4 +477,109 @@ async def test_client_logs_nothing() -> None:
await client.get_snapshot()
finally:
logging.getLogger().removeHandler(handler)
assert records == []
assert records == []
# ---------------------------------------------------------------------------
# stream_events: the body is read from ``response.content``
# ---------------------------------------------------------------------------
class _StubStreamContent:
"""A fake aiohttp ``StreamReader``: async-iterable over byte chunks."""
def __init__(self, chunks) -> None:
self._chunks = list(chunks)
def __aiter__(self):
return self._iterate()
async def _iterate(self):
for chunk in self._chunks:
yield chunk
class StubStreamResponse:
"""A fake aiohttp/HA ``ClientResponse`` for a streaming body.
Like the real ``HassClientResponse`` it does NOT implement
``__aiter__`` — the body is iterated through ``.content`` — so a
client that iterates the response object itself raises the
``'async for' requires an object with __aiter__ method`` TypeError.
"""
def __init__(self, status: int, chunks) -> None:
self.status = status
self.content = _StubStreamContent(chunks)
async def test_stream_events_reads_frames_from_response_content() -> None:
"""Frames are parsed from ``response.content`` byte chunks, including
a frame split across chunk boundaries; a trailing incomplete frame
is discarded."""
assert not hasattr(StubStreamResponse, "__aiter__")
chunks = [
b'event: nestquest_quest_completed\ndata: {"instance_id"',
b': 7}\n',
b'\nevent: nestquest_child_day_complete\n',
b'data: {"child_id": 1}\n\n',
b'event: nestquest_quest_missed\ndata: {"instance_id": 9}\n',
]
transport = StubTransport(StubStreamResponse(200, chunks))
client = make_client(transport)
frames = [frame async for frame in client.stream_events()]
assert frames == [
("nestquest_quest_completed", {"instance_id": 7}),
("nestquest_child_day_complete", {"child_id": 1}),
]
assert transport.calls[0]["method"] == "GET"
assert transport.calls[0]["url"] == f"{BASE_URL}/api/v1/panel/events"
async def test_stream_events_non_2xx_raises_typed_with_status() -> None:
transport = StubTransport(StubStreamResponse(502, []))
client = make_client(transport)
with pytest.raises(NestQuestApiError) as excinfo:
async for _ in client.stream_events():
pass
assert excinfo.value.status == 502
assert not excinfo.value.is_transport_error
async def test_stream_events_invalid_json_frame_raises_typed() -> None:
transport = StubTransport(
StubStreamResponse(200, [b"event: nestquest_quest_completed\n", b"data: {nope\n\n"])
)
client = make_client(transport)
with pytest.raises(NestQuestApiError) as excinfo:
async for _ in client.stream_events():
pass
assert excinfo.value.status == 200
assert not excinfo.value.is_transport_error
assert isinstance(excinfo.value.__cause__, ValueError)
async def test_stream_events_disables_the_session_total_timeout() -> None:
"""The stream request overrides the session's default total timeout
(HA's shared session: 300 s, which would cut a healthy stream) with
``total=None`` while keeping a finite connect timeout; the plain
request calls pass no per-request timeout."""
transport = StubTransport(
StubStreamResponse(200, []), StubResponse(200, json.dumps(SNAPSHOT_PAYLOAD))
)
client = make_client(transport, timeout_seconds=7.0)
frames = [frame async for frame in client.stream_events()]
await client.get_snapshot()
assert frames == []
stream_timeout = transport.calls[0]["timeout"]
assert isinstance(stream_timeout, FakeClientTimeout)
assert stream_timeout.total is None
assert stream_timeout.sock_connect == 7.0
assert transport.calls[1]["timeout"] is None
+71
View File
@@ -0,0 +1,71 @@
"""The API host's zone and the effective application timezone (task 7e173881).
With no zone stored the API reads its clock in the host's local time, so
:func:`api.host_zone.effective_timezone` names that host zone — resolved
the way the C library resolves local time — for the panel and admin app
to format in. Every host configuration here is a temporary file tree.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from api import host_zone
def _host(tmp_path: Path, *, link: str | None = None, name: str | None = None):
"""A fake ``/etc``: ``localtime`` linked into a zoneinfo tree at
``link``, and/or an ``/etc/timezone`` file holding ``name``."""
localtime = tmp_path / "etc" / "localtime"
timezone_file = tmp_path / "etc" / "timezone"
localtime.parent.mkdir(parents=True)
if link is not None:
target = tmp_path / "usr" / "share" / "zoneinfo" / link
target.parent.mkdir(parents=True)
target.write_bytes(b"TZif")
localtime.symlink_to(target)
if name is not None:
timezone_file.write_text(f"{name}\n", encoding="utf-8")
return {"timezone_file": timezone_file, "localtime": localtime}
def test_tz_environment_variable_wins(tmp_path: Path) -> None:
paths = _host(tmp_path, link="Europe/Paris")
assert host_zone.host_timezone({"TZ": "Asia/Kolkata"}, **paths) == "Asia/Kolkata"
assert host_zone.host_timezone({"TZ": ":Asia/Kolkata"}, **paths) == "Asia/Kolkata"
assert host_zone.host_timezone({"TZ": ""}, **paths) == "UTC"
def test_localtime_link_names_the_zone(tmp_path: Path) -> None:
paths = _host(tmp_path, link="America/Argentina/Buenos_Aires")
assert host_zone.host_timezone({}, **paths) == "America/Argentina/Buenos_Aires"
def test_etc_timezone_names_a_copied_localtime(tmp_path: Path) -> None:
paths = _host(tmp_path, name="Pacific/Auckland")
paths["localtime"].write_bytes(b"TZif")
assert host_zone.host_timezone({}, **paths) == "Pacific/Auckland"
def test_unconfigured_host_runs_utc(tmp_path: Path) -> None:
assert host_zone.host_timezone({}, **_host(tmp_path)) == "UTC"
@pytest.mark.parametrize("tz", ["EST+5", "Not/AZone", ":/etc/localtime"])
def test_an_unnamed_host_zone_is_empty(tmp_path: Path, tz: str) -> None:
assert host_zone.host_timezone({"TZ": tz}, **_host(tmp_path)) == ""
def test_unnamed_copied_localtime_is_empty(tmp_path: Path) -> None:
paths = _host(tmp_path)
paths["localtime"].write_bytes(b"TZif")
assert host_zone.host_timezone({}, **paths) == ""
def test_effective_timezone_is_the_stored_zone_else_the_host_zone(
monkeypatch,
) -> None:
monkeypatch.setattr(host_zone, "host_timezone", lambda: "Europe/Dublin")
assert host_zone.effective_timezone("America/Chicago") == "America/Chicago"
assert host_zone.effective_timezone("") == "Europe/Dublin"
+142 -1
View File
@@ -32,7 +32,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 import scheduler as scheduler_module
from api.database import _executor
from api.scheduler import MissedSweepScheduler, _delay_seconds
@@ -92,6 +92,7 @@ async def household(temp_db_path: str, monkeypatch) -> SimpleNamespace:
seed=seed,
clock=clock,
database=runner.app.state.db.database,
app=runner.app,
)
@@ -532,6 +533,146 @@ async def test_scheduler_rechecks_settings_before_sweeping(
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
+1
View File
@@ -60,6 +60,7 @@ def _payload(children) -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": 0,
"timezone": "",
"children": list(children),
}
+123
View File
@@ -0,0 +1,123 @@
"""Behavior and wiring tests for the UniFi quest-access blueprint."""
from __future__ import annotations
from datetime import datetime
import pytest
from tests.blueprint_helpers import BlueprintInput, blueprint, render
def _blueprint() -> dict:
return blueprint("unifi_quest_access")
def test_inputs_and_reconciliation_triggers() -> None:
bp = _blueprint()
inputs = bp["blueprint"]["input"]
assert set(inputs) == {
"remaining_sensor",
"present_sensor",
"client_switches",
"weekday_cutoff",
"weekend_cutoff",
}
assert inputs["weekday_cutoff"]["default"] == "18:25:00"
assert inputs["weekend_cutoff"]["default"] == "14:00:00"
assert inputs["remaining_sensor"]["selector"]["entity"]["filter"] == [
{"integration": "nestquest", "domain": "sensor"}
]
assert inputs["present_sensor"]["selector"]["entity"]["filter"] == [
{"integration": "nestquest", "domain": "binary_sensor"}
]
assert inputs["client_switches"]["selector"]["entity"] == {
"multiple": True,
"filter": [{"integration": "unifi", "domain": "switch"}],
}
assert bp["variables"]["remaining_sensor"] == BlueprintInput(
"remaining_sensor"
)
assert bp["variables"]["present_sensor"] == BlueprintInput(
"present_sensor"
)
assert bp["variables"]["client_switches"] == BlueprintInput(
"client_switches"
)
assert bp["mode"] == "restart"
triggers = bp["triggers"]
assert {trigger["trigger"] for trigger in triggers} == {
"state",
"time",
"homeassistant",
"time_pattern",
}
assert {trigger["at"] for trigger in triggers if trigger["trigger"] == "time"} == {
BlueprintInput("weekday_cutoff"),
BlueprintInput("weekend_cutoff"),
"00:00:00",
}
assert any(
trigger.get("entity_id") == BlueprintInput("remaining_sensor")
for trigger in triggers
)
assert any(
trigger.get("entity_id") == BlueprintInput("present_sensor")
for trigger in triggers
)
assert any(
trigger.get("event") == "start" for trigger in triggers
)
assert any(
trigger.get("minutes") == "/5" for trigger in triggers
)
@pytest.mark.parametrize(
("when", "remaining", "presence", "should_block"),
[
(datetime(2026, 10, 19, 18, 24), "2", "on", False), # Monday, pre-cutoff
(datetime(2026, 10, 19, 18, 25), "2", "on", True),
(datetime(2026, 10, 19, 19, 0), "0", "on", False),
(datetime(2026, 10, 19, 19, 0), "unknown", "on", True),
(datetime(2026, 10, 19, 19, 0), "2", "off", False), # On travels
(datetime(2026, 10, 19, 19, 0), "2", "unavailable", False),
(datetime(2026, 10, 17, 13, 59), "1", "on", False), # Saturday
(datetime(2026, 10, 17, 14, 0), "1", "on", True),
(datetime(2026, 10, 18, 14, 0), "0", "on", False), # Sunday
(datetime(2026, 10, 20, 0, 0), "1", "on", False), # no carry-over
],
)
def test_access_decision(
when: datetime, remaining: str, presence: str, should_block: bool
) -> None:
decision = _blueprint()["actions"][0]["variables"]["should_block"]
actual = render(
decision,
now=lambda: when,
states=lambda entity_id: remaining,
is_state=lambda entity_id, state: presence == state,
remaining_sensor="sensor.selected_child",
present_sensor="binary_sensor.selected_child_present_today",
weekday_cutoff="18:25:00",
weekend_cutoff="14:00:00",
).strip()
assert actual == str(should_block)
def test_only_out_of_sync_clients_receive_commands() -> None:
bp = _blueprint()
loop = bp["actions"][1]["repeat"]
assert loop["for_each"] == "{{ client_switches }}"
choices = loop["sequence"][0]["choose"]
assert len(choices) == 2
block, allow = choices
assert "is_state(repeat.item, 'on')" in block["conditions"][0]["value_template"]
assert block["sequence"][0]["action"] == "switch.turn_off"
assert "is_state(repeat.item, 'off')" in allow["conditions"][0]["value_template"]
assert allow["sequence"][0]["action"] == "switch.turn_on"
assert all(
choice["sequence"][0]["target"]["entity_id"] == "{{ repeat.item }}"
for choice in choices
)
+55
View File
@@ -0,0 +1,55 @@
"""The integration's local brand images (Home Assistant 2026.3+).
``custom_components/nestquest/brand/`` ships through HACS with the
integration, and Home Assistant serves those images ahead of the brands CDN.
They are generated offline from ``design/logos/NestQuest-NoBG.png`` by
``design/logos/generate-brand-icons.mjs``; these checks fail if the directory
goes missing, changes shape, or drifts from a fresh generation.
"""
from __future__ import annotations
import shutil
import struct
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).parent.parent
BRAND_DIR = REPO_ROOT / "custom_components" / "nestquest" / "brand"
GENERATOR = REPO_ROOT / "design" / "logos" / "generate-brand-icons.mjs"
EXPECTED = {
"icon.png": 256,
"icon@2x.png": 512,
"logo.png": 256,
"logo@2x.png": 512,
}
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
def test_brand_dir_holds_exactly_the_four_images() -> None:
assert sorted(p.name for p in BRAND_DIR.iterdir()) == sorted(EXPECTED)
@pytest.mark.parametrize(("name", "size"), sorted(EXPECTED.items()))
def test_brand_image_is_square_rgba_png(name: str, size: int) -> None:
data = (BRAND_DIR / name).read_bytes()
assert data[:8] == PNG_SIGNATURE
assert data[12:16] == b"IHDR"
width, height, depth, colour = struct.unpack(">IIBB", data[16:26])
assert (width, height) == (size, size)
assert (depth, colour) == (8, 6) # 8-bit RGBA keeps the transparency
def test_generator_reproduces_the_committed_images() -> None:
node = shutil.which("node")
if node is None:
pytest.skip("needs node")
result = subprocess.run(
[node, str(GENERATOR), "--check"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
+1
View File
@@ -55,6 +55,7 @@ def _empty_snapshot() -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": 0,
"timezone": "",
"children": [],
}
+1 -1
View File
@@ -834,7 +834,7 @@ def test_complete_instance_no_due_time_after_due_date_is_late(
def test_complete_instance_ha_local_wall_clock_not_utc(tmp_path) -> None:
"""HA-local 11:00 with due_time 12:00 is on time even when UTC is 19:00.
"""Household-local 11:00, due 12:00, is on time even when UTC is 19:00.
The helper's zone is UTC-8, so 11:00 local is 19:00 UTC. Converting
to UTC before comparing would mark this late.
+31 -1
View File
@@ -93,6 +93,7 @@ class FakeMonotonic:
FIXTURE_SNAPSHOT = {
"today_iso": _today_iso(),
"cycle_day": 3,
"timezone": "",
"children": [
{
"child_id": 1,
@@ -793,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 == ""
+2 -1
View File
@@ -136,7 +136,7 @@ async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> Non
# Single clock read for the seed/snapshot anchor (no midnight skew);
# the coordinator no longer owns a clock read (the API serves the
# snapshot), so the test resolves the HA-local zone directly.
# snapshot), so the test resolves its own local zone directly.
time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
# Pin the build instant at 12:00 local: past Ada's 10:00 due time
@@ -332,6 +332,7 @@ async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> Non
return {
"today_iso": built.today_iso,
"cycle_day": built.cycle_day,
"timezone": built.timezone,
"children": [
{
"child_id": child.child_id,
+1
View File
@@ -67,6 +67,7 @@ def _payload(children) -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": 0,
"timezone": "",
"children": list(children),
}
+1
View File
@@ -76,6 +76,7 @@ def _payload(children, cycle_day=0) -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": cycle_day,
"timezone": "",
"children": list(children),
}
+1
View File
@@ -70,6 +70,7 @@ def _payload(children) -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": 0,
"timezone": "",
"children": list(children),
}
+69
View File
@@ -63,6 +63,75 @@ def test_artefacts_carry_exactly_the_curated_names_and_labels() -> None:
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")
+9 -8
View File
@@ -940,14 +940,15 @@ def test_materialize_rejects_malformed_or_inverted_bounds(tmp_path) -> None:
def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None:
"""The clamp and no-past guard compare against a caller-supplied HA-local today.
"""The clamp and no-past guard compare against a caller-supplied today.
A household time zone behind the host clock around midnight resolves a
HA-local "today" one calendar day earlier than the host's
household-local "today" one calendar day earlier than the host's
``date.today()``. The walk clamps its start to that anchor: without
a pinned HA-local date the effective start is the host's today (one
day after ha_today), so the now-past day is skipped rather than
rejected, and only the host-today-onward days materialize.
a pinned household-local date the effective start is the host's
today (one day after ha_today), so the now-past day is skipped
rather than rejected, and only the host-today-onward days
materialize.
"""
async def _body(database):
children = ChildrenDao(database)
@@ -963,9 +964,9 @@ def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None:
["morning"],
)
# Without a pinned HA-local today, the walk clamps its start to the
# host's today (one day after ha_today); the past day is dropped and
# the 14 host-today-onward days materialize.
# Without a pinned household-local today, the walk clamps its start
# to the host's today (one day after ha_today); the past day is
# dropped and the 14 host-today-onward days materialize.
without_pin = await materialize(database, start_iso, end_iso)
assert without_pin == 14
+126 -32
View File
@@ -567,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.
@@ -575,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()
@@ -589,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}"
@@ -1102,13 +1109,12 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
``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 by the panel's pinned clock
(07:15 local: the 07:00 quest is late, the 08:15 one is not)."""
``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, due_time: str) -> dict:
def quest(instance_id: int, title: str, overdue: bool) -> dict:
return {
"id": instance_id,
"definition_id": instance_id,
@@ -1116,9 +1122,9 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
"title": title,
"icon": None,
"window": "morning",
"due_time": due_time,
"due_time": "08:15",
"state": "open",
"overdue": False,
"overdue": overdue,
"completed_at": None,
"on_time": None,
}
@@ -1131,8 +1137,8 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
child_id=1,
present=True,
instances=[
quest(41, "Make Bed", "08:15"),
quest(42, "Brush Teeth", "07:00"),
quest(41, "Make Bed", overdue=False),
quest(42, "Brush Teeth", overdue=True),
],
),
}
@@ -1140,8 +1146,6 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
generated["views"][1]["cards"][0],
states,
url="http://homeassistant.local/nestquest/ada",
now="2026-09-29T11:15:00Z",
tz="America/New_York",
)
metas = {}
for card in result["html"].split('data-instance-id="')[1:]:
@@ -1175,19 +1179,22 @@ def _quest_metas(html: str) -> dict[str, tuple[str, str]]:
return metas
def test_quest_log_overdue_follows_the_panel_local_clock() -> None:
"""Overdue is decided by the panel's own local clock, not the API's
``overdue`` flag. The owner saw 8:15 AM quests read OVERDUE at 7:15 AM
America/New_York: the API's flag had been computed on a UTC wall clock
(11:15). Here the API still says ``overdue: true`` and Home
Assistant reports ``time_zone`` UTC, yet at 07:15 local the 08:15 quest
is due, at 08:15 (and 08:16, via the card's own clock tick) it is
overdue, and a quest with no due time is never overdue."""
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 | None) -> dict:
def quest(instance_id: int, title: str, due_time: str, overdue: bool) -> dict:
return {
"id": instance_id,
"definition_id": instance_id,
@@ -1197,7 +1204,7 @@ def test_quest_log_overdue_follows_the_panel_local_clock() -> None:
"window": "morning",
"due_time": due_time,
"state": "open",
"overdue": True,
"overdue": overdue,
"completed_at": None,
"on_time": None,
}
@@ -1210,29 +1217,96 @@ def test_quest_log_overdue_follows_the_panel_local_clock() -> None:
child_id=1,
present=True,
instances=[
quest(42, "Brush Teeth", "08:15"),
quest(43, "Feed Fish", None),
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-29T11:15:00Z",
advance_to=["2026-09-29T12:15:00Z", "2026-09-29T12:16:00Z"],
tz="America/New_York",
now="2026-09-29T17:00:00Z",
advance_to=["2026-09-29T23:00:00Z"],
tz="Pacific/Auckland",
)
due_by = ('class="meta"', "Due by 8:15 AM")
overdue = ('class="meta late"', "Overdue · due 8:15 AM")
not_due = ('class="meta"', "Due today")
at_0715, at_0815, at_0816 = (
due_by = ('class="meta"', "Due by 7:00 AM")
before, (after,) = (
_quest_metas(result["html"]),
*(_quest_metas(tick["html"]) for tick in result["ticks"]),
[_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",
)
assert at_0715 == {"42": due_by, "43": not_due}, at_0715
assert at_0815 == {"42": overdue, "43": not_due}, at_0815
assert at_0816 == {"42": overdue, "43": not_due}, at_0816
def test_quest_log_crest_matches_the_spec_geometry() -> None:
@@ -1357,6 +1431,26 @@ def test_open_quest_tile_falls_back_to_the_star() -> None:
assert tile["icon"] is None
def test_open_quest_tile_renders_any_free_solid_icon() -> None:
"""A Font Awesome Free Solid name outside the curated quick-pick list
still renders its glyph from the shipped bundle; an unknown ``fa:`` name
keeps the star."""
tiles = _quest_log_tiles(
[
_quest_row(1, "fa:rocket"),
_quest_row(2, "fa:mug-hot"),
_quest_row(3, "fa:definitely-not-an-icon"),
]
)
for instance_id, key in ((1, "fa:rocket"), (2, "fa:mug-hot")):
assert tiles[instance_id]["icon"] == key
assert tiles[instance_id]["fill"] == "currentColor"
assert tiles[instance_id]["paths"] > 0
assert not tiles[instance_id]["star"]
assert tiles[3]["star"]
assert tiles[3]["icon"] is None
def test_sealed_quest_tile_keeps_the_star() -> None:
"""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."""
+17
View File
@@ -76,6 +76,7 @@ def _payload(children) -> dict:
return {
"today_iso": _today_iso(),
"cycle_day": 0,
"timezone": "",
"children": list(children),
}
@@ -569,6 +570,22 @@ async def test_household_due_sensor_publishes_the_ordered_child_roster(
}
async def test_household_due_sensor_publishes_the_stored_zone(
hass, make_entry
) -> None:
"""The rollup carries the snapshot's stored application zone verbatim
(the panel cards format their date and clock in it); ``""`` — no zone
stored — passes through unchanged for the cards' documented fallback."""
zoned = {**_seeded_payload(), "timezone": "Pacific/Auckland"}
entry, coordinator, _client = await _setup_entry(
hass, make_entry, zoned, _seeded_payload()
)
rollup = hass.entities["nestquest_household_quests_due_today"]
assert rollup.extra_state_attributes["timezone"] == "Pacific/Auckland"
await coordinator.async_refresh()
assert rollup.extra_state_attributes["timezone"] == ""
async def test_household_roster_tracks_the_snapshot_on_refresh(
hass, make_entry
) -> None:
+26 -7
View File
@@ -44,6 +44,10 @@ from custom_components.nestquest.api_client import (
)
from custom_components.nestquest.sse import NestQuestEventStream
# ``stream_events`` imports aiohttp lazily; the harness has none, so
# every test here runs with the stand-in module installed.
pytestmark = pytest.mark.usefixtures("fake_aiohttp")
BASE_URL = "http://api.test:8000"
TOKEN = "test-panel-token"
@@ -151,7 +155,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": [],
}
# ---------------------------------------------------------------------------
@@ -160,19 +169,29 @@ MINIMAL_SNAPSHOT = {"today_iso": "2026-09-23", "cycle_day": 1, "children": []}
class _StreamingResponse:
"""A fake aiohttp streaming response: ``status`` + chunk iteration.
"""A fake aiohttp streaming response: ``status`` + ``.content``.
``chunks`` are the body pieces the stream yields in order; a chunk
may be an exception, raised when the iteration reaches it (a
mid-stream transport drop). ``hold_open`` keeps the stream open
forever after the chunks run out, for cancellation tests.
Like aiohttp's ``ClientResponse`` (and HA's ``HassClientResponse``)
the response itself is NOT async-iterable; the body is iterated
through ``.content``. ``chunks`` are the body pieces the stream
yields in order; a chunk may be an exception, raised when the
iteration reaches it (a mid-stream transport drop). ``hold_open``
keeps the stream open forever after the chunks run out, for
cancellation tests.
"""
def __init__(self, status: int, chunks, *, hold_open: bool = False) -> None:
self.status = status
self.content = _StreamingContent(chunks, hold_open)
self.closed = False
class _StreamingContent:
"""The fake response body: an async-iterable chunk stream."""
def __init__(self, chunks, hold_open: bool) -> None:
self._chunks = list(chunks)
self._hold_open = hold_open
self.closed = False
def __aiter__(self):
return self._iterate()
+60 -10
View File
@@ -2,9 +2,10 @@
* Offline generator for the shared quest-icon registry.
*
* Reads the curated names + labels in `curated-icons.json` (the one source of
* truth), pulls each glyph's SVG path data from locally installed packages in
* `admin/node_modules` (lucide-react and @fortawesome/free-solid-svg-icons —
* no network), and writes the same TypeScript module into both bundles:
* 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
@@ -14,6 +15,16 @@
*
* 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 Lucide set and the Font Awesome quick picks),
* the module embeds the complete Font Awesome Free Solid set (`FA_SOLID_ICONS`),
* which is what `fa:<name>` resolves against. Its source is the installed
* @fortawesome/free-solid-svg-icons package itself: every exported icon
* definition, de-duplicated by canonical `iconName` (the package also exports
* each legacy alias, e.g. faHome for `house`, as the same definition). The
* package carries no human labels, so each label is the kebab name in Title
* Case (`spray-can-sparkles` → "Spray Can Sparkles"). Embedding the full set
* makes both bundles heavier: the panel bundle is about 1 MB raw / 276 kB gzip.
*/
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { createRequire } from "node:module";
@@ -89,6 +100,37 @@ 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) {
@@ -116,11 +158,9 @@ async function buildEntries() {
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`);
const [width, height, , , pathData] = definition.icon;
const paths = Array.isArray(pathData) ? pathData.filter(Boolean) : [pathData];
fa.push({ kind: "fa", key: `fa:${name}`, name, label, viewBox: `0 0 ${width} ${height}`, paths });
fa.push(faGlyph(definition, label));
}
return { lucide, fa };
return { lucide, fa, faSolid: allSolidGlyphs(faPackage) };
}
const RESOLVER = `
@@ -130,7 +170,10 @@ export const FALLBACK_ICON: FallbackIcon = { kind: "fallback", key: null };
export const EMOJI_MAX_CODE_POINTS = 8;
const LUCIDE_BY_NAME: ReadonlyMap<string, GlyphIcon> = new Map(LUCIDE_ICONS.map((icon) => [icon.name, icon]));
const FA_BY_NAME: ReadonlyMap<string, GlyphIcon> = new Map(FA_ICONS.map((icon) => [icon.name, icon]));
/** The full Free Solid set, with each curated entry (and its quick-pick label) taking precedence. */
const FA_BY_NAME: ReadonlyMap<string, GlyphIcon> = new Map(
[...FA_SOLID_ICONS, ...FA_ICONS].map((icon) => [icon.name, icon]),
);
function isEmojiPayload(text: string): boolean {
const codePoints = [...text];
@@ -142,7 +185,8 @@ function isEmojiPayload(text: string): boolean {
* 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 curated Font Awesome set, and
* Lucide set, \`fa:<name>\` against the full Font Awesome Free Solid set
* (curated entries keep their quick-pick labels), and
* \`emoji:<grapheme>\` to its text. Like the backend's
* normalize_icon_for_read, nothing here throws: null, empty, an unknown
* namespace or name, or a malformed emoji all resolve to FALLBACK_ICON.
@@ -162,7 +206,7 @@ export function resolveIcon(value: string | null | undefined): ResolvedIcon {
/** The generated module's full text (identical for both bundles). */
export async function renderRegistry() {
const { lucide, fa } = await buildEntries();
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
@@ -202,7 +246,13 @@ 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}`;
}
Generated
+1 -1
View File
@@ -373,7 +373,7 @@ wheels = [
[[package]]
name = "nestquest"
version = "0.9.7"
version = "0.10.3"
source = { virtual = "." }
[package.dev-dependencies]