Compare commits
257
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54867996b2 | ||
|
|
26f7d3a7d1 | ||
|
|
75b2ac888a | ||
|
|
bc3e64b4f8 | ||
|
|
cc970bb646 | ||
|
|
7b5177d055 | ||
|
|
a22f990bd6 | ||
|
|
4b56e2f68a | ||
|
|
d0c236b569 | ||
|
|
b8532bacde | ||
|
|
33d8e0d2ac | ||
|
|
dbe873eb2a | ||
|
|
4b39af4b67 | ||
|
|
706a7954d8 | ||
|
|
42ea5710d7 | ||
|
|
be4fd04754 | ||
|
|
4ff40f00a2 | ||
|
|
a808f89993 | ||
|
|
f476821e8e | ||
|
|
75ad9892b8 | ||
|
|
48d0c4ef86 | ||
|
|
22f1a6861d | ||
|
|
939556a38a | ||
|
|
8676bdc6ad | ||
|
|
0ed35ab85a | ||
|
|
c57d950101 | ||
|
|
70ee4ec111 | ||
|
|
415ababc8e | ||
|
|
ee8ef6dcac | ||
|
|
6059a67122 | ||
|
|
d78ef196d0 | ||
|
|
b5c2b55d9b | ||
|
|
8ddae78551 | ||
|
|
4ac4d3f1ec | ||
|
|
0972761994 | ||
|
|
f71124c3ed | ||
|
|
e78edbae3b | ||
|
|
b3715ccdcd | ||
|
|
315010b901 | ||
|
|
1503774e85 | ||
|
|
87757aaaf9 | ||
|
|
10f365ef9f | ||
|
|
3ee4c99e5f | ||
|
|
d4ea552ad7 | ||
|
|
4e52e8dec8 | ||
|
|
ee1fae984a | ||
|
|
418fc69f36 | ||
|
|
b6b1ba65b4 | ||
|
|
37f7d25cd6 | ||
|
|
fe71d37bb0 | ||
|
|
1c08c793e3 | ||
|
|
7386999cf7 | ||
|
|
43ad44ee48 | ||
|
|
4cb07fbca4 | ||
|
|
030ba83b14 | ||
|
|
a2c027deca | ||
|
|
2b45ebf035 | ||
|
|
028215fa38 | ||
|
|
0d2e0d6f26 | ||
|
|
1911b2bf6e | ||
|
|
89f517b3ad | ||
|
|
edca25affa | ||
|
|
7c62c50526 | ||
|
|
ca93176768 | ||
|
|
e508c80465 | ||
|
|
3a2e6faa69 | ||
|
|
5092369d49 | ||
|
|
f6166f25d9 | ||
|
|
bf84793a1c | ||
|
|
9d3cb30617 | ||
|
|
b913772007 | ||
|
|
d2f9c50930 | ||
|
|
19a17b647d | ||
|
|
715aac994a | ||
|
|
ad6f1768d7 | ||
|
|
fcc7a29f05 | ||
|
|
f31bda2714 | ||
|
|
dc7a82f6e2 | ||
|
|
1d5143f850 | ||
|
|
fbde3eeaa1 | ||
|
|
3c832c1cee | ||
|
|
8c421abeb2 | ||
|
|
8cd43141a9 | ||
|
|
3de9f4b970 | ||
|
|
25005cda77 | ||
|
|
35e66bd9e6 | ||
|
|
a65e32f577 | ||
|
|
36263d3f19 | ||
|
|
58cd68354f | ||
|
|
a9d8f1d225 | ||
|
|
933e29ae76 | ||
|
|
a5dbae5d50 | ||
|
|
ad56d13c6e | ||
|
|
b90f55f750 | ||
|
|
2a4eec658f | ||
|
|
fe38e5040e | ||
|
|
fa153059cb | ||
|
|
5bca24910a | ||
|
|
99a8a133e2 | ||
|
|
df4c9eb21a | ||
|
|
236e8f4adf | ||
|
|
1f7a7ba46c | ||
|
|
813055a24c | ||
|
|
7a0c8d1885 | ||
|
|
00409a76db | ||
|
|
322038998b | ||
|
|
5c99987e7f | ||
|
|
979eafc5a2 | ||
|
|
3ac1d32e1b | ||
|
|
fc81b4b813 | ||
|
|
c1efd39ce3 | ||
|
|
cf67e9bed9 | ||
|
|
b5058a3bed | ||
|
|
eb4de7b565 | ||
|
|
d0f49b44b5 | ||
|
|
2a0e568940 | ||
|
|
37d7b7e6c3 | ||
|
|
dd7ea10936 | ||
|
|
4281b9d01c | ||
|
|
a2e9bdffee | ||
|
|
b1c42f10d0 | ||
|
|
2d30f0ce77 | ||
|
|
a6d9ec927b | ||
|
|
bcdf03dac7 | ||
|
|
52cbf33f33 | ||
|
|
c4fb2fbac5 | ||
|
|
0820544fce | ||
|
|
318d77f443 | ||
|
|
2ec016ebfe | ||
|
|
c3495bb277 | ||
|
|
775a9906e7 | ||
|
|
293efe9705 | ||
|
|
22797cbc7e | ||
|
|
743703e943 | ||
|
|
6e32761b0c | ||
|
|
3add2d2f48 | ||
|
|
c001aca62d | ||
|
|
353d88e3af | ||
|
|
f6aad9d20f | ||
|
|
5706d8b7a6 | ||
|
|
be6367b600 | ||
|
|
ca26ced59f | ||
|
|
2146531665 | ||
|
|
168bb7b2f1 | ||
|
|
4544f78390 | ||
|
|
9836a6f524 | ||
|
|
d6823a54bc | ||
|
|
fa40e21dc2 | ||
|
|
e60a9892ff | ||
|
|
1432dba74c | ||
|
|
1b6973efd3 | ||
|
|
4606d7ddd0 | ||
|
|
802258d431 | ||
|
|
d9cdac87d7 | ||
|
|
cbd29f5ab7 | ||
|
|
43a7a722b9 | ||
|
|
b2f55ffb90 | ||
|
|
58b11b59cf | ||
|
|
9d21f29eea | ||
|
|
fec607f039 | ||
|
|
a18c0060e4 | ||
|
|
20d7efb04b | ||
|
|
6551a87bd6 | ||
|
|
e9dc0a1445 | ||
|
|
1bb0617471 | ||
|
|
221ab2c20b | ||
|
|
bdf3513609 | ||
|
|
830d663a45 | ||
|
|
bff2f4ff5e | ||
|
|
8f04d93363 | ||
|
|
d9326deeb5 | ||
|
|
94f54523f9 | ||
|
|
51b1388233 | ||
|
|
744a40dcb5 | ||
|
|
e15648232f | ||
|
|
6d48f433b7 | ||
|
|
540f59a353 | ||
|
|
ee994a3938 | ||
|
|
77c4db7b3c | ||
|
|
5885d5e972 | ||
|
|
7ae4018e59 | ||
|
|
cb3c347d61 | ||
|
|
d76cde1af5 | ||
|
|
b3957ecedf | ||
|
|
c2cdbceb09 | ||
|
|
7cde256e2a | ||
|
|
7a1cef1e54 | ||
|
|
913d3658b8 | ||
|
|
0e4f890ef9 | ||
|
|
7ebd893823 | ||
|
|
068ace5693 | ||
|
|
f0fa39660c | ||
|
|
d90c170460 | ||
|
|
0d61ea2c88 | ||
|
|
b7f7e092a0 | ||
|
|
5fe6cb72ab | ||
|
|
7a585afb9e | ||
|
|
ecffa5bb0e | ||
|
|
cfe1609df2 | ||
|
|
5c74034f81 | ||
|
|
1d247fbca1 | ||
|
|
6108755f0c | ||
|
|
d5d972b872 | ||
|
|
09363a57cf | ||
|
|
5578507ce8 | ||
|
|
712527d5ba | ||
|
|
61b77cc2db | ||
|
|
a2ea42673c | ||
|
|
82d1be97fa | ||
|
|
2263481f1e | ||
|
|
522875642d | ||
|
|
9eea769c7e | ||
|
|
b955ab487b | ||
|
|
fe90064b65 | ||
|
|
25f1479444 | ||
|
|
ebc2064222 | ||
|
|
25f0a43e16 | ||
|
|
c988f6508b | ||
|
|
cdfb681f46 | ||
|
|
3ecca2d212 | ||
|
|
30d103aefb | ||
|
|
658afec491 | ||
|
|
b1157b3d6d | ||
|
|
13e00bc850 | ||
|
|
3e2860b2f1 | ||
|
|
b289e5a998 | ||
|
|
0637245d33 | ||
|
|
c157f6cb9e | ||
|
|
21948fdee4 | ||
|
|
8c99024dd0 | ||
|
|
b4c9ad35c3 | ||
|
|
4230c1d0d4 | ||
|
|
5a962e0ed7 | ||
|
|
db4f695541 | ||
|
|
d81f38528a | ||
|
|
23a52707af | ||
|
|
3b638cae6f | ||
|
|
ceb32074e7 | ||
|
|
a30bafcb68 | ||
|
|
c90892f082 | ||
|
|
1e1444d1a0 | ||
|
|
c6203327bd | ||
|
|
0db7d4d519 | ||
|
|
353f01e98c | ||
|
|
12bb4d58b4 | ||
|
|
29c058a045 | ||
|
|
c27ed24020 | ||
|
|
43b20f2b12 | ||
|
|
2c8c90a24e | ||
|
|
e98642e236 | ||
|
|
01905ec9ee | ||
|
|
30748f786a | ||
|
|
3dae2eadd7 | ||
|
|
eaf2e45e9a | ||
|
|
5e65cde078 | ||
|
|
748c8242c3 | ||
|
|
22f8b5dc55 |
@@ -4,3 +4,4 @@ __pycache__/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
*.metadata.json
|
||||
frontend/node_modules/
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
description: Independently verify NestQuest merge gates and merge approved heads
|
||||
mode: primary
|
||||
model: openrouter/deepseek/deepseek-v4.1-flash
|
||||
permission:
|
||||
edit: deny
|
||||
write: deny
|
||||
task: deny
|
||||
bash:
|
||||
"*": deny
|
||||
"/home/overseer/.local/share/nestquest-controller/scripts/nq-approve-merge *": allow
|
||||
external_directory:
|
||||
"*": ask
|
||||
"/tmp/opencode/*": allow
|
||||
"/home/overseer/.local/share/nestquest-controller/*": allow
|
||||
---
|
||||
|
||||
You are the NestQuest feature-to-dev approver, separate from the controller and
|
||||
developer sessions. Verify the current PR head equals the independently
|
||||
reviewed and approved commit,
|
||||
required checks pass, blocking findings are resolved, branch protection is
|
||||
satisfied, and Maestro has accurate links. Use only the approver Gitea identity
|
||||
for the merge. Do not change code or approve a stale head. Use only the
|
||||
installed nq-approve-merge launcher for Gitea inspection and merging. Merge
|
||||
only into dev; never merge into main. If any gate is
|
||||
missing, refuse the merge and give the exact missing evidence. Report the PR,
|
||||
reviewed head, resulting merge commit, and target branch.
|
||||
|
||||
Broker syntax: `nq-approve-merge inspect PR_NUMBER` or
|
||||
`nq-approve-merge merge PR_NUMBER REVIEWED_HEAD REVIEW_FILE`. Run one broker
|
||||
command per Bash call. The broker validates the current PR and review again
|
||||
before merging; do not substitute an ad hoc Gitea command.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
description: Implement one assigned NestQuest task in an isolated worktree
|
||||
mode: primary
|
||||
model: openrouter/z-ai/glm-5.3-flash
|
||||
permission:
|
||||
task: deny
|
||||
external_directory:
|
||||
"*": ask
|
||||
"/tmp/opencode/*": allow
|
||||
---
|
||||
|
||||
You are the NestQuest development agent. Implement only the assigned Maestro
|
||||
task in the supplied worktree. Follow the task's acceptance criteria and the
|
||||
repository's instructions. Run relevant tests, commit on the task branch, and
|
||||
report the commit and test results. Keep changes inside the assigned worktree.
|
||||
Do not approve your own pull request. You may merge your task PR into the
|
||||
feature branch only after the independent reviewer approved its exact current
|
||||
head and the controller verified every task merge gate. Never merge a feature
|
||||
into dev or a release into main. Do not start another task, change role, or
|
||||
use another model. Do not read or print credential files except when the
|
||||
approved task merge requires the existing Gitea development credential.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
description: Reconcile NestQuest work and dispatch the next verified action
|
||||
mode: primary
|
||||
model: openrouter/deepseek/deepseek-v4.1-flash
|
||||
permission:
|
||||
doom_loop: deny
|
||||
task: deny
|
||||
external_directory:
|
||||
"*": ask
|
||||
"/tmp/opencode/*": allow
|
||||
"/home/overseer/.local/share/opencode/tool-output/*": allow
|
||||
---
|
||||
|
||||
You are the NestQuest controller. Work in the NestQuest repository. Maestro is
|
||||
the source of truth for feature and task state; CTXD is the source of truth for
|
||||
project instructions. At the start of every invocation, read the current
|
||||
NestQuest context in CTXD, including OPENCODE-ORCHESTRATOR-PROMPT.MD, then
|
||||
reconcile it with Maestro, Git, pull requests, and checks before dispatching.
|
||||
Follow the review, identity, and branch rules in that prompt.
|
||||
If the CTXD project or prompt is unavailable, do not dispatch or merge work;
|
||||
report the missing source and end with ORCHESTRATOR_STATE: BLOCKED.
|
||||
|
||||
One invocation is a controller tick, not an entire feature lifetime. Prefer a
|
||||
state-changing action over another identical status query. Never repeat an
|
||||
unchanged tool call three times. If work is asynchronous, wait or inspect a
|
||||
specific changed result. Do not claim that you are continuing while ending
|
||||
the turn without naming the next action.
|
||||
|
||||
The NESTQUEST_CONTROL_HOME environment variable names the installed launchers
|
||||
(normally /home/overseer/.local/share/nestquest-controller).
|
||||
For implementation, run "$NESTQUEST_CONTROL_HOME/scripts/nq-agent" with role
|
||||
developer, a task worktree, title, and task prompt file. For independent
|
||||
review, run "$NESTQUEST_CONTROL_HOME/scripts/nq-review" with the worktree,
|
||||
feature base ref, exact head ref, review prompt file, PR number, and an output
|
||||
file under /tmp/opencode. It publishes an exact-head Gitea approval only after
|
||||
Claude approves. The developer may merge
|
||||
a task PR into its feature branch only after independent approval of the exact
|
||||
head and verification of every task gate. For feature-to-dev approval and
|
||||
merge, run "$NESTQUEST_CONTROL_HOME/scripts/nq-agent" with role approver in
|
||||
a separate session after the independent review. Save the Claude review output
|
||||
in a file under /tmp/opencode and give that file and its reviewed head to the
|
||||
approver. Never substitute a different model or reviewer silently.
|
||||
|
||||
When a feature is integrated into dev, reconcile Maestro, then immediately
|
||||
select and dispatch the next eligible feature. Feature completion is a
|
||||
transition, not the end of the controller's job. A release awaiting Joshua's
|
||||
main merge does not prevent work on independent eligible features.
|
||||
|
||||
End each invocation with exactly one final line using one of these values:
|
||||
|
||||
ORCHESTRATOR_STATE: CONTINUE
|
||||
ORCHESTRATOR_STATE: QUIESCENT
|
||||
ORCHESTRATOR_STATE: BLOCKED
|
||||
|
||||
Use CONTINUE when there is any eligible or in-flight work; include the feature,
|
||||
task, and next concrete action immediately above the final line. Use QUIESCENT
|
||||
only after verifying no eligible or in-flight work remains in Maestro and the
|
||||
repository. Use BLOCKED only when a specific decision or access is required;
|
||||
include evidence and the needed human action. If you cannot establish the
|
||||
state, use BLOCKED. Never emit a success claim based only on a child's summary.
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
description: Run the NestQuest orchestration loop in this foreground terminal
|
||||
mode: primary
|
||||
model: openrouter/deepseek/deepseek-v4.1-flash
|
||||
permission:
|
||||
doom_loop: deny
|
||||
task: deny
|
||||
external_directory:
|
||||
"*": ask
|
||||
"/tmp/opencode/*": allow
|
||||
"/home/overseer/.local/share/opencode/tool-output/*": allow
|
||||
---
|
||||
|
||||
You are the foreground NestQuest orchestrator. The user explicitly launched
|
||||
this session to run the development queue now. Read the supplied terminal
|
||||
orchestrator prompt and current CTXD project context, but follow the
|
||||
FOREGROUND TERMINAL RUNTIME for this session instead of CTXD's service runtime
|
||||
section.
|
||||
|
||||
Continuously reconcile Maestro, CTXD, Git, pull requests, checks, and child
|
||||
agent results. Execute the next useful transition in this same session. Do
|
||||
not finish after dispatch, review, remediation, or merge while eligible work
|
||||
remains. Use the installed role launchers for developer, reviewer, and
|
||||
approver isolation. Never start or wait for the systemd controller service.
|
||||
Never repeat an identical unchanged tool call three times; wait on the
|
||||
specific child or inspect newly changed evidence.
|
||||
|
||||
End only at a global terminal condition, an explicit user stop, a genuine
|
||||
global access/decision block with no other eligible work, or a host-forced
|
||||
yield. On a forced yield, return a continuation checkpoint that can be
|
||||
resumed in the same OpenCode session.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
description: Run the NestQuest orchestrator now in this OpenCode terminal
|
||||
agent: terminal-orchestrator
|
||||
---
|
||||
|
||||
Run the NestQuest project queue now in this foreground OpenCode session.
|
||||
Read the current CTXD prompt and Maestro state, but use the FOREGROUND
|
||||
TERMINAL RUNTIME instead of the service runtime. Reconcile repository and
|
||||
pull-request state, then execute CONTROLLER_TICK repeatedly until a global
|
||||
terminal condition, explicit user stop, genuine global block, or host-forced
|
||||
yield. Do not start or wait for the systemd controller.
|
||||
@@ -0,0 +1,77 @@
|
||||
# NestQuest foreground orchestrator
|
||||
|
||||
OpenCode 1.18.32 loads the named agents from `.opencode/agents/`. Their
|
||||
models match the current NestQuest CTXD prompt: DeepSeek V4.1 Flash for the
|
||||
foreground orchestrator and approver, and GLM 5.3 Flash for development.
|
||||
Run orchestration directly from a shell:
|
||||
|
||||
```bash
|
||||
nq-orchestrate
|
||||
nq-orchestrate mission.md
|
||||
cat mission.md | nq-orchestrate -
|
||||
nq-orchestrate --print-prompt > nestquest-orchestrator.md
|
||||
```
|
||||
|
||||
The launcher renders the project prompt with the foreground terminal runtime,
|
||||
stops the dormant legacy service, refuses immediately when another NestQuest
|
||||
TUI is open, and then replaces itself with OpenCode using
|
||||
`terminal-orchestrator`. There is no background polling process and no wait
|
||||
for a controller tick. Exit or interrupt the TUI to stop the foreground run;
|
||||
resume that OpenCode session when a host-forced yield needs continuation.
|
||||
|
||||
`scripts/nq-agent` launches developer and approver sessions with fixed agent
|
||||
definitions and no `--auto` flag. Headless OpenCode runs use `--pure` so the
|
||||
global idle-notification plugin does not notify on every controller tick.
|
||||
The approver's shell permission permits only the installed
|
||||
`nq-approve-merge` broker for Gitea. That broker requires exact-head
|
||||
Claude approval evidence, an exact-head Gitea approval by `review_agent`,
|
||||
the `dev` target, a mergeable PR, and passing configured checks before it
|
||||
requests a squash merge with Gitea's atomic `head_commit_id` guard.
|
||||
`scripts/nq-review` launches the independent
|
||||
Claude review in a noninteractive read-only session in the task worktree,
|
||||
checks the head again afterward, saves
|
||||
the output under `/tmp/opencode`, and posts the Gitea approval.
|
||||
The approver verifies the reviewed head again before a merge.
|
||||
|
||||
The terminal launcher uses a stable copy of this configuration and scripts
|
||||
at `/home/overseer/.local/share/nestquest-controller`; it does not depend on
|
||||
which task branch is checked out in the primary NestQuest directory. Install
|
||||
that copy, run `scripts/nq-controller --check`, and verify the OpenCode
|
||||
agents (terminal-orchestrator, developer, and approver)
|
||||
with `opencode debug agent <name>` while `OPENCODE_CONFIG_DIR` points at the
|
||||
installed `.opencode` directory. Install `scripts/nq-orchestrate` in
|
||||
`~/.local/bin/`. For the global slash command, also install
|
||||
`.opencode/agents/terminal-orchestrator.md` under
|
||||
`~/.config/opencode/agents/` and
|
||||
`deploy/opencode-global-commands/start-nestquest-development.md` under
|
||||
`~/.config/opencode/commands/`. The old `nestquest-controller.service` remains static and
|
||||
inactive only for migration safety; foreground launch stops it.
|
||||
|
||||
Inside an already-open OpenCode TUI, `/start-development` (or the global
|
||||
`/start-nestquest-development`) switches to the same foreground
|
||||
`terminal-orchestrator` behavior immediately. The shell launcher is the
|
||||
preferred entry point when supplying a mission prompt file.
|
||||
|
||||
`templates/ORCHESTRATOR-PROMPT.template.md` and
|
||||
`templates/nestquest.profile.json` make the current CTXD cycle prompt
|
||||
adaptable to another project. See `templates/README.md` before publishing a
|
||||
rendered prompt for a new project.
|
||||
|
||||
The CTXD preflight reads the configured HTTP/MCP server's NestQuest prompt.
|
||||
It deliberately does not inspect `/home/overseer/.ctx/ctxd.db`, which belongs
|
||||
to a different local store and lacks this project.
|
||||
|
||||
All four agent credentials are currently readable by the same Unix account.
|
||||
The Gitea review and merge gates enforce normal workflow sequencing, but
|
||||
they are not a hard isolation boundary against a process running as that
|
||||
account. Strong isolation requires separate OS identities and protected
|
||||
credentials for the controller, reviewer, and approver.
|
||||
|
||||
The terminal runtime repeatedly executes CONTROLLER_TICK within the foreground
|
||||
session. Neither a feature merge nor a progress report ends the run while
|
||||
eligible work remains. A host-forced yield returns a continuation checkpoint
|
||||
that can be resumed in the same OpenCode session.
|
||||
|
||||
Do not put tokens in this repository or in unit files. Existing OpenCode MCP
|
||||
connections supply CTXD and Maestro. Rotate those MCP tokens as a coordinated
|
||||
operation across every consumer before revoking the old values.
|
||||
@@ -4,7 +4,7 @@ Home Assistant custom integration for managing chores, custody schedules, and ho
|
||||
|
||||
## Overview
|
||||
|
||||
NestQuest helps households coordinate daily life, chore tracking, custody schedules, and shared tasks directly within Home Assistant. Version 0.1.0 is the integration foundation: a configuration scaffold providing the package and manifest, shared constants, a single-instance config flow, setup/unload lifecycle handling with an options-reload listener, an options flow (horizon days, day rollover time, panel idle timeout), SQLite storage-path resolution, and HACS metadata. Sensors, calendar events, and service calls are forthcoming in later releases and are not yet part of the integration.
|
||||
NestQuest helps households coordinate daily life, chore tracking, custody schedules, and shared tasks directly within Home Assistant. Version 0.2.0 ships the integration foundation (a configuration scaffold providing the package and manifest, shared constants, a single-instance config flow, setup/unload lifecycle handling with an options-reload listener, an options flow for horizon days / day rollover time / panel idle timeout, and HACS metadata) plus its persistence layer: an integration-owned SQLite database (`nestquest.db`) with WAL mode and foreign keys, a versioned migration runner stamping schema version 1 across eight tables (children, admin_users, schedule_rules, task_definitions, presence_schedules, presence_overrides, task_instances, completion_events), typed DAO layers so no other feature writes raw SQL, an append-only completion-event log, and startup handling that creates a missing database, opens a valid one, and refuses — without touching a single byte — to start on a corrupt one. Sensors, calendar events, and service calls are forthcoming in later releases and are not yet part of the integration.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -13,14 +13,14 @@ NestQuest helps households coordinate daily life, chore tracking, custody schedu
|
||||
1. Ensure [HACS (Home Assistant Community Store)](https://hacs.xyz/) is installed.
|
||||
2. In Home Assistant, open **HACS** > **Integrations**.
|
||||
3. Select the three-dots menu in the top right and click **Custom repositories**.
|
||||
4. Enter the repository URL: `https://github.com/talon2king/NestQuest`
|
||||
4. Enter the repository URL: `https://github.com/Maniacs-Mansion/NestQuest`
|
||||
5. Select category **Integration** and click **Add**.
|
||||
6. Search for **NestQuest** and click **Download**.
|
||||
7. Restart Home Assistant.
|
||||
|
||||
> **Note:** Development happens in the private Gitea repository
|
||||
> (`https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest`). The GitHub
|
||||
> repository is a public mirror maintained for HACS distribution.
|
||||
> repository is a private mirror maintained for HACS distribution.
|
||||
|
||||
### Manual Installation
|
||||
|
||||
@@ -38,6 +38,278 @@ After installation and restart:
|
||||
2. Click **Add Integration** and search for **NestQuest**.
|
||||
3. Follow the on-screen configuration flow.
|
||||
|
||||
## Events
|
||||
|
||||
NestQuest fires Home Assistant bus events so automations can react to a
|
||||
child's progress. Each payload carries `child_id`, `child_name`,
|
||||
`instance_id`, `quest_title`, the instance's `window`, `due_date`,
|
||||
`due_time`, and `occurred_at` (a strict UTC ISO-8601 timestamp).
|
||||
|
||||
| Event | Fires when | Extra payload fields |
|
||||
|---|---|---|
|
||||
| `nestquest_quest_completed` | a quest is completed (an actual completion, never a re-complete no-op) | `was_on_time` (bool) |
|
||||
| `nestquest_quest_uncompleted` | an admin reverses a completion | — |
|
||||
| `nestquest_child_day_complete` | a completion clears the child's whole day (quests were owed and none remain; a zero-quest day never fires it) | `quests_due`, `quests_completed` |
|
||||
| `nestquest_quest_missed` | the nightly sweep marks a still-open past-due quest missed (integrated in a later release) | — |
|
||||
|
||||
Example automation trigger:
|
||||
|
||||
```yaml
|
||||
triggers:
|
||||
- trigger: event
|
||||
event_type: nestquest_child_day_complete
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ trigger.event.data.child_name == 'Declan' }}"
|
||||
actions:
|
||||
- action: notify.mobile_app_declans_phone
|
||||
data:
|
||||
message: "Every quest cleared — legendary!"
|
||||
```
|
||||
|
||||
## Automation Blueprints
|
||||
|
||||
NestQuest ships four 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.
|
||||
|
||||
### Wiring to the integration's notification settings
|
||||
|
||||
The integration's options flow records your notification choices —
|
||||
notify target, morning/afternoon/end-of-day times, and a toggle per
|
||||
automation. The blueprints take their own inputs at import time;
|
||||
fill them from the same values you stored in the options flow (the
|
||||
integration does not push its settings into imported automations):
|
||||
|
||||
| Blueprint input | Options-flow counterpart |
|
||||
|---|---|
|
||||
| `morning_time` (default 08:00) | `morning_summary_time` |
|
||||
| `afternoon_time` (default 15:00) | `afternoon_reminder_time` |
|
||||
| `report_time` (default 20:00) | `end_of_day_report_time` |
|
||||
| `send_summary` / `send_reminder` / `send_report` / `celebrate` | the four `*_enabled` toggles |
|
||||
| `notify_target` | `notify_target` (your notify service, e.g. `notify.mobile_app_your_phone`) |
|
||||
|
||||
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
|
||||
|
||||
1. **Morning summary** (`morning_summary.yaml`) — at the configured
|
||||
time, lists every child whose `binary_sensor.nestquest_<child>_present_today`
|
||||
is on and what they owe from `sensor.nestquest_<child>_quests_remaining_today`.
|
||||
Children not present today are omitted entirely.
|
||||
|
||||
2. **Afternoon reminder** (`afternoon_reminder.yaml`) — at the
|
||||
configured time, notifies only about present children who still
|
||||
have open quests. Sends nothing at all when every present child is
|
||||
clear.
|
||||
|
||||
3. **End-of-day report** (`end_of_day_report.yaml`) — one message per
|
||||
evening: each present child's completion count and the quests they
|
||||
still owe (the ones the midnight sweep will mark
|
||||
`nestquest_quest_missed`). Absent children are omitted. Sends
|
||||
nothing on an all-clear day unless `send_even_when_clear` is on.
|
||||
|
||||
4. **Day-complete celebration** (`day_complete_celebration.yaml`) —
|
||||
triggers on the `nestquest_child_day_complete` event (which only
|
||||
fires when quests were owed and all are complete, so a zero-quest
|
||||
day never celebrates). Create one `input_text` helper
|
||||
(`nestquest_last_celebrated`) for the dedupe: the automation
|
||||
records each celebrated child as `<child_id>:<today>` and
|
||||
celebrates each child at most once per day — a re-cleared day does
|
||||
not re-fire, but a second child clearing the same day still
|
||||
celebrates.
|
||||
|
||||
### Example package
|
||||
|
||||
```yaml
|
||||
# One automation per blueprint — Settings → Automations & Scenes →
|
||||
# Create → Blueprint, then pick each NestQuest blueprint.
|
||||
#
|
||||
# morning_summary: morning_time 08:00, notify_target notify.mobile_app_your_phone
|
||||
# afternoon_reminder: afternoon_time 15:00, same notify target
|
||||
# end_of_day_report: report_time 20:00, send_even_when_clear false, same target
|
||||
# day_complete_celebration: input_text.nestquest_last_celebrated,
|
||||
# celebration_message "Legendary! {{ trigger.event.data.child_name }} cleared every quest today!"
|
||||
#
|
||||
# Every blueprint has its own send/celebrate toggle input; turn an
|
||||
# automation off entirely from HA's automation menu.
|
||||
```
|
||||
|
||||
### Events the automations rely on
|
||||
|
||||
| Event | Payload |
|
||||
|---|---|
|
||||
| `nestquest_quest_completed` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at (UTC ISO-8601), was_on_time |
|
||||
| `nestquest_quest_uncompleted` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at |
|
||||
| `nestquest_child_day_complete` | child_id, child_name, quests_due, quests_completed, occurred_at |
|
||||
| `nestquest_quest_missed` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at (fired by the nightly sweep) |
|
||||
|
||||
Sensors the automations read: `sensor.nestquest_<child>_quests_due_today`
|
||||
(attributes: `child_id`, `child_name`, `present`, `instances`,
|
||||
`admin_instances`), `..._quests_remaining_today`,
|
||||
`..._quests_completed_today`, and
|
||||
`binary_sensor.nestquest_<child>_present_today`.
|
||||
|
||||
## TouchHub setup
|
||||
|
||||
Run these steps in order to display the NestQuest panel on a TouchHub
|
||||
wall device. The panel is an HA Lovelace dashboard, so the kiosk browser
|
||||
is simply a Home Assistant client. The path below is zero-config: the
|
||||
integration registers its panel dashboard itself on setup, so no YAML
|
||||
and no hand-built views are involved.
|
||||
|
||||
1. **Install the integration and add the config entry.** Install
|
||||
NestQuest via HACS or manually (see Installation above) and restart
|
||||
Home Assistant. Then, in *Settings → Devices & Services*, click
|
||||
**Add Integration**, search for **NestQuest**, and submit the flow —
|
||||
it has no fields; it just creates the single config entry. Then open
|
||||
the entry, click **Configure**, and fill the panel-plane API
|
||||
connection in the options flow:
|
||||
|
||||
- **NestQuest API base URL** — the full `http://host:port` URL of
|
||||
your NestQuest API service (default `http://127.0.0.1:8000`).
|
||||
- **Panel service token** — the token the panel presents to the API
|
||||
service as its Bearer credential. It may stay empty until you have
|
||||
one; a pasted token must be unpadded.
|
||||
|
||||
The form's remaining fields (panel idle timeout, coordinator update
|
||||
interval, last-good snapshot staleness) have defaults that serve the
|
||||
panel and need no change here.
|
||||
|
||||
2. **The panel dashboard appears automatically.** On setup the
|
||||
integration registers one storage-mode Lovelace dashboard: a sidebar
|
||||
entry titled **NestQuest** at the URL path `/nestquest`, whose stored
|
||||
config is nothing but the **`custom:nestquest-party` strategy**
|
||||
(`strategy: { type: custom:nestquest-party }`). The strategy
|
||||
generates the views at render time from the household roster:
|
||||
|
||||
- **The Party** — the party-board view at `/nestquest/party`; the
|
||||
dashboard root `/nestquest` shows it as the first view.
|
||||
- one quest-log view per child at `/nestquest/<child-slug>`
|
||||
("Ada's Quest Log", …), in roster order.
|
||||
|
||||
Nothing is hand-configured: no YAML, no manual views, no
|
||||
`child_order`, no `quest_log_path`/`board_path` — the board and the
|
||||
quest logs compute their navigation paths from the dashboard's own
|
||||
URL — and every generated view is already in **Panel** layout (see
|
||||
the two constraints below). If the Lovelace surfaces are unavailable
|
||||
(for example when Lovelace runs in YAML mode), setup logs a warning
|
||||
and skips the registration; create the dashboard manually with the
|
||||
same strategy as described in the manual alternative below.
|
||||
|
||||
3. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
|
||||
a new user for the wall device. Make it **non-admin**, and ensure it is
|
||||
not in the NestQuest admin allowlist. The allowlist blocks the NestQuest
|
||||
**admin** operations — `uncomplete_quest`, `create_quest_definition`,
|
||||
`update_quest_definition`, `set_quest_definition_active`,
|
||||
`set_presence_pattern`, `create_presence_override`,
|
||||
`delete_presence_override`, `export_history_csv`, and `manage_child`
|
||||
(plus `regenerate` flows) — it does not make the account view-only. The
|
||||
kiosk user *can* call `nestquest.complete_quest`, the one open NestQuest
|
||||
service, to complete quests from the panel, and as a non-admin Home
|
||||
Assistant user it may also reach other permitted Home Assistant entities
|
||||
and services.
|
||||
|
||||
4. **Open the dashboard and point TouchHub at it.** Open
|
||||
`http://<home-assistant>/nestquest` in a browser first and check the
|
||||
party board renders with your children's crests. Then, in the
|
||||
TouchHub launcher, add the **Home Assistant Lovelace** app to the
|
||||
dock and paste the dashboard URL from step 2 — the dashboard root,
|
||||
`/nestquest` (TouchHub loads that URL directly as the kiosk landing
|
||||
page). Set TouchHub **Auto-Return** to a value GREATER than the
|
||||
quest log's `idle_return_seconds` (default 40s) — recommend **60s or
|
||||
higher**. If Auto-Return is at or below 40s, TouchHub returns to its
|
||||
launcher before the quest log's idle return can navigate back to the
|
||||
party board.
|
||||
|
||||
5. **Log the kiosk session in.** Sign in on the device as the dedicated
|
||||
kiosk user from step 3, and leave that session logged in.
|
||||
|
||||
TouchHub loads the dashboard URL directly. The NestQuest cards are normal
|
||||
Lovelace cards, not an iframe, so no Home Assistant framing setting is
|
||||
needed.
|
||||
|
||||
### The two constraints
|
||||
|
||||
- **Panel layout.** Every view must be in **Panel** (single-card)
|
||||
layout: Home Assistant's default Masonry layout constrains the
|
||||
1080px-tall NestQuest card to a narrow column, while Panel renders the
|
||||
card full-screen at 1920×1080. The zero-config path already satisfies
|
||||
the requirement — the strategy generates every view as
|
||||
`type: panel` — while the manual alternative below requires setting
|
||||
the layout by hand on each view (Edit Dashboard → pencil on the view
|
||||
→ View type: Panel).
|
||||
|
||||
- **TouchHub Auto-Return.** TouchHub's **Auto-Return** must be greater
|
||||
than the quest log's `idle_return_seconds` (default 40s) — recommend
|
||||
**60s or higher** — so the launcher does not reclaim the wall device
|
||||
before the quest log's own idle return navigates back to the party
|
||||
board.
|
||||
|
||||
### Manual alternative: build the dashboard by hand
|
||||
|
||||
If you prefer not to use the zero-config dashboard above — or its
|
||||
registration was skipped — configure a Lovelace dashboard whose **URL
|
||||
slug is `nestquest`** (Settings → Dashboards → Edit → URL slug)
|
||||
yourself. Two options, in decreasing order of convenience:
|
||||
|
||||
- **Use the strategy.** Give the dashboard
|
||||
|
||||
```yaml
|
||||
strategy:
|
||||
type: custom:nestquest-party
|
||||
```
|
||||
|
||||
and no views of your own — the strategy generates the party-board and
|
||||
quest-log views (each already in Panel layout) from the household
|
||||
roster at render time. Optional `url_path` and `weather_entity` keys
|
||||
may sit beside `strategy:`.
|
||||
|
||||
- **Build the two views by hand.** A functional panel needs **two**
|
||||
views in that dashboard, each with its own card config from
|
||||
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
|
||||
paths (the path segment after the dashboard slug):
|
||||
|
||||
- **View 1 path `board`** (URL `/nestquest/board`) — party board
|
||||
(`type: custom:nestquest-party-board-card`): the household quest
|
||||
board. Set `weather_entity` (optional; the dock falls back to
|
||||
date/time), `quest_log_path: /nestquest/log` (the view the crest tap
|
||||
navigates to), and `child_order: [slug1, slug2, slug3]` listing each
|
||||
child's slug in display order.
|
||||
|
||||
- **View 2 path `log`** (URL `/nestquest/log`) — quest log
|
||||
(`type: custom:nestquest-quest-log-card`): the per-child quest screen
|
||||
the party board hops to. Set `board_path: /nestquest/board` (the
|
||||
party-board view to return to), `weather_entity`,
|
||||
`idle_return_seconds` (must be shorter than TouchHub Auto-Return —
|
||||
see the two constraints above), `confirm_timeout_seconds`, and
|
||||
`complete_screen_seconds`. The quest log reads the selected child's
|
||||
slug from the view's URL path, so the board → log hop is a plain
|
||||
Lovelace navigation.
|
||||
|
||||
The dashboard contains only the NestQuest panel card for each view —
|
||||
no other cards, no admin controls. Each view's layout MUST be set to
|
||||
**Panel** — see the two constraints above for why; the strategy
|
||||
generates it for you, hand-built views do not get it for free.
|
||||
|
||||
The `quest_log_path` and `board_path` values must match the dashboard
|
||||
slug and view paths above: `/<slug>/<view>`. If you chose `nestquest`
|
||||
as the dashboard slug with view paths `board` and `log`, the card
|
||||
configs are already correct. If you pick different names, substitute
|
||||
your actual paths in **both** card configs — e.g. a dashboard slug
|
||||
`chores` with view paths `main` and `history` needs
|
||||
`quest_log_path: /chores/history` and `board_path: /chores/main`.
|
||||
Navigation between the two views fails (the crest tap or back button
|
||||
lands on a 404) if the card config paths do not match the dashboard
|
||||
slug and view paths exactly.
|
||||
|
||||
See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML.
|
||||
|
||||
## Developer Setup and Testing
|
||||
|
||||
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
|
||||
@@ -55,6 +327,28 @@ To set up the development environment and install dependencies:
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Frontend cards
|
||||
|
||||
The Lovelace cards live in `frontend/` (TypeScript + Lit) and build to a
|
||||
single bundle at `custom_components/nestquest/www/nestquest-cards.js`. The
|
||||
integration registers that bundle as a Lovelace resource automatically on
|
||||
setup — do not add it by hand. Card YAML is documented in
|
||||
`design/ENTITIES-AND-SERVICES.md` §4.
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Kids panel layout
|
||||
|
||||
The Lovelace panel cards are designed for the wall display's confirmed
|
||||
resolution, 1920×1080 landscape, with no page scroll. TouchHub's bottom
|
||||
dock overlays the NestQuest weather dock strip until it auto-hides, so
|
||||
no tappable NestQuest control sits in that strip (the dock carries
|
||||
`pointer-events: none`). Keep it that way when editing the cards.
|
||||
|
||||
### Running Tests
|
||||
|
||||
Run the test suite with `pytest`:
|
||||
|
||||
+298
-1
@@ -1,5 +1,302 @@
|
||||
# NestQuest Release Notes
|
||||
|
||||
## Version 0.6.0 — 2026-09-23
|
||||
|
||||
### Scope
|
||||
|
||||
Sixth release of NestQuest, promoting the re-imagined architecture
|
||||
(Features 15, 16, 18 and 20) and the foreground development-orchestration
|
||||
tooling from `dev` onto 0.5.0. This is a major architectural change: the
|
||||
Home Assistant integration becomes a thin client of a standalone NestQuest
|
||||
API service that owns the database.
|
||||
|
||||
- **Feature 15 — Domain Core Extraction:** all domain code (schema,
|
||||
migrations, database access, the DAOs, the pure recurrence/presence
|
||||
engines, materialization, completion, the SSE-free event payload
|
||||
builders, the panel snapshot builder, the children/definitions/allowlist
|
||||
business layers, and an explicit settings object) now lives in a
|
||||
Home-Assistant-free package bundled inside the integration at
|
||||
`custom_components/nestquest/core/`. The SQLite schema and migration
|
||||
versions are unchanged, so an existing `nestquest.db` opens without
|
||||
migration. Covered by a no-Home-Assistant end-to-end smoke test.
|
||||
(Tasks d0b691d8, 510c1f78, 4dd8f870, 628473ad.)
|
||||
- **Feature 16 — NestQuest API Service:** a standalone FastAPI service
|
||||
(`api/`) that imports `core/` and is the only writer of the database.
|
||||
Panel plane (`/api/v1/panel/*`) is authenticated by a static service
|
||||
token: household snapshot and an idempotent complete route. Admin plane
|
||||
(`/api/v1/admin/*`): children, quest definitions, presence
|
||||
schedule/override, uncomplete, regenerate, history query + CSV export,
|
||||
and settings — authenticated by an Authentik OIDC JWT (issuer, audience,
|
||||
JWKS signature and expiry) plus membership in `nestquest-admins`, with
|
||||
the panel service token rejected outright. A server-sent-events stream
|
||||
publishes the quest-completed, quest-uncompleted, quest-missed and
|
||||
child-day-complete transitions; the missed sweep runs in the API with a
|
||||
cross-process-safe watermark. `/health` and the OpenAPI document are
|
||||
served. (Tasks 042dfc5c, ae7e90ae, 1860ef76, 4d7bae00, 44c93cfd,
|
||||
e70e6bfb, 0f2afb29, e4845baa, b586a2f1, e4a9d31b, da0226b3, 2d2dda53,
|
||||
2b3de7e5, 9047a97b, 9a118da9, 058c7b69, a232c4f5.)
|
||||
- **Feature 18 — HA Integration as API Client:** the integration no longer
|
||||
owns a database. It polls `GET /api/v1/panel/snapshot` and builds the
|
||||
existing sensors (entity ids and per-child instance payload shape
|
||||
unchanged), subscribes to the SSE stream and re-fires the four HA bus
|
||||
events, proxies `nestquest.complete_quest` to the panel complete route,
|
||||
and caches the last-good snapshot so a brief API outage does not blank
|
||||
the panel. The HA admin services and the service permission gate are
|
||||
removed; only the panel completion path remains. (Tasks 37d5e806,
|
||||
6e70d166, b1ed9c45, 47c7c90e, d25b875f, d04f5bf3, ad67fc44.)
|
||||
- **Feature 20 — Zero-Config Panel Dashboard:** the panel installs with no
|
||||
hand-written card YAML and no manual dashboard creation. The cards
|
||||
auto-discover children from a household entity attribute (ordered by the
|
||||
API's `sort_order`), a Lovelace dashboard strategy generates the
|
||||
party-board view and one quest-log view per child at render time with
|
||||
computed navigation paths, and the integration registers the dashboard
|
||||
on first setup. Navigation and the panel transitions match the design
|
||||
mockups. (Tasks 1b6d91d2, c859af23, c2d4dd8e, 107b8fbe, 50067c1f,
|
||||
fc69c940, 67775be9.)
|
||||
- **Development orchestration tooling:** durable OpenCode controller and
|
||||
role launchers, the foreground terminal orchestrator, the independent
|
||||
review/approval gates, and the automatic release-preparation policy.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Minimum Home Assistant version: **2024.6.0**.
|
||||
- A running NestQuest API service, reachable at the base URL and panel
|
||||
service token configured in the integration options. The API service
|
||||
source ships in `api/`; there is no container image, compose file or
|
||||
deployment runbook in this release, and Feature 17 — the deployment
|
||||
(TLS reverse proxy, Authentik, network separation, backups) — is **not**
|
||||
part of this release and must be completed before the integration is
|
||||
functional. See `docs/ARCHITECTURE.md` for the target design.
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- **The HA integration no longer owns a database.** It reads exclusively
|
||||
from the NestQuest API. The local `nestquest.db` is no longer opened,
|
||||
migrated or written by the integration (the file is left untouched on
|
||||
disk). Without a reachable API the entities become unavailable after the
|
||||
configured staleness threshold; the panel serves the last-good snapshot
|
||||
in the meantime.
|
||||
- **HA admin services are removed** (`uncomplete`, quest-definition
|
||||
create/update/set-active, presence pattern/override, CSV export,
|
||||
`manage_child`, `regenerate`) and the HA-side permission gate is gone.
|
||||
Admin operations move to the API, and — once Feature 19 lands — the
|
||||
admin PWA. Any automation that called an admin service must be
|
||||
repointed at the API.
|
||||
- **Logger namespaces moved** under `custom_components.nestquest.core.*`:
|
||||
the migrations runner now logs under
|
||||
`custom_components.nestquest.core.migrations` (previously
|
||||
`custom_components.nestquest.migrations`), and the children registry and
|
||||
admin allowlist log under `custom_components.nestquest.core.children` and
|
||||
`custom_components.nestquest.core.admin_allowlist`. The parent
|
||||
`custom_components.nestquest` logger still catches all three; only users
|
||||
who pinned a leaf logger name by its old spelling must update.
|
||||
- **The panel installs with no configuration.** `child_order`,
|
||||
`quest_log_path` and `board_path` are no longer required (an explicit
|
||||
value still wins). The integration registers a `custom:nestquest-party`
|
||||
dashboard on setup; point TouchHub at it.
|
||||
- **Schema is unchanged at version 7** — no migration runs on upgrade.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- **Feature 17 (API deployment and identity) is not in this release.** The
|
||||
API service source ships in `api/`, but no container image, compose
|
||||
file, TLS reverse proxy, Authentik application/provider/`nestquest-admins`
|
||||
group, proven network separation of the panel routes, or backup/restore
|
||||
is included. Feature 17's containerization groundwork was completed on
|
||||
its own feature branch, which is **not** merged into `dev` and therefore
|
||||
is not part of this release. Until that deployment is completed the
|
||||
integration has nothing to talk to.
|
||||
- **Feature 19 (admin PWA) is not in this release.** There is no admin UI
|
||||
in 0.6.0; admin operations are reachable only through the API directly.
|
||||
- Moving the household database from Home Assistant to the API box is a
|
||||
manual file copy (the schema is unchanged).
|
||||
- Single household; single API service.
|
||||
|
||||
### Rollback
|
||||
|
||||
Schema version is unchanged (7), so no migration is involved in either
|
||||
direction. Nevertheless **back up `nestquest.db` (plus `-wal`/`-shm`)
|
||||
before upgrading**: 0.6.0 never touches it, and a downgrade to 0.5.0
|
||||
resumes using exactly that file.
|
||||
|
||||
**HACS installs:**
|
||||
|
||||
1. Downgrade via HACS (HACS > Integrations > NestQuest > Redownload > 0.5.0).
|
||||
2. Restart Home Assistant.
|
||||
|
||||
**Manual installs:**
|
||||
|
||||
1. Replace `custom_components/nestquest/` with the 0.5.0 tree.
|
||||
2. Restart Home Assistant.
|
||||
|
||||
**Repository operators:** git-revert the 0.6.0 release merge on `main`.
|
||||
|
||||
## Version 0.5.0 — 2026-09-18
|
||||
|
||||
### Scope
|
||||
|
||||
Fifth release of NestQuest, promoting Features 06, 07, and 08 from `dev` onto 0.4.0 (quest model, children registry, presence engine):
|
||||
|
||||
- **Feature 06 — quest definitions and assignment:** business layer `quest_definitions.py` over the multi-assignee / multi-window model (D-008). Create/edit/deactivate definitions; assign/unassign children; query helpers for materialization. `ScheduleRule` ⇄ `schedule_rules` storage mapping in the DAO layer.
|
||||
- **Feature 07 — instance materialization:** `materialize.py` fans out one instance per (definition, child, window, firing date) when the recurrence rule fires and the child is present. Idempotent on `(definition_id, child_id, due_date, window)`; skips completed instances; never generates past dates. HA-local `today` and configured `horizon_days` threaded end-to-end. Regeneration on definition/assignee/presence changes; daily rollover listener; startup backfill; `nestquest.regenerate` service.
|
||||
- **Feature 08 — completion and event log:** business layer `completion.py` wrapping the existing append-only `CompletionEventsDao`. `complete_instance` / `uncomplete_instance` (reversal event, original row untouched); state derived from the latest event; HA-local `was_on_time`; derived `missed` (never stored). Actor shapes `'user'` / `'panel'` with `actor_child_id` (D-008). Permission gating of un-complete is Feature 09.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Minimum Home Assistant version: **2024.6.0**.
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- None at the schema layer. Schema remains **version 6** (advanced in 0.4.0). This release adds application code only.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- No permission decorator yet (Feature 09): `complete_instance` / `uncomplete_instance` are Python APIs, not gated HA services. The only HA service in this release is `nestquest.regenerate`.
|
||||
- No entity or event surface (Feature 10): no sensors, no `nestquest_quest_completed` bus events.
|
||||
- No panel or admin Lovelace cards (Features 12/13).
|
||||
- Un-complete is implemented but not admin-gated until Feature 09.
|
||||
- Single-instance only.
|
||||
|
||||
### Rollback
|
||||
|
||||
Schema version is unchanged from 0.4.0 (still 6). A code downgrade to 0.4.0 does not require a database restore for schema compatibility, but **back up `nestquest.db` (plus `-wal`/`-shm`) before upgrading** — generated instances and completion events written by 0.5.0 remain in the file after downgrade; 0.4.0 will not generate or complete them.
|
||||
|
||||
**HACS installs:**
|
||||
|
||||
1. Downgrade via HACS (HACS > Integrations > NestQuest > Redownload > 0.4.0).
|
||||
2. Restart Home Assistant.
|
||||
|
||||
**Manual installs:**
|
||||
|
||||
1. Replace `custom_components/nestquest/` with the 0.4.0 tree.
|
||||
2. Restart Home Assistant.
|
||||
|
||||
**Repository operators:** git-revert the 0.5.0 release merge on `main`.
|
||||
|
||||
## Version 0.4.0 — 2026-09-15
|
||||
|
||||
### Scope
|
||||
|
||||
Fourth release of NestQuest, shipping three completed features on top of the data layer and recurrence engine:
|
||||
|
||||
- **Feature 14 — quest domain model (BREAKING rename, D-007/D-008):** `task_definitions`/`task_instances` are now `quest_definitions`/`quest_instances` everywhere; definitions are multi-assignee (`quest_definition_assignees`, no single child column) and multi-window (`quest_definition_windows` — morning/afternoon/evening, clock ranges in `const.py`); the instance key widens to `(definition_id, child_id, due_date, window)` so twice-daily and shared quests materialise one instance per child per window per day; `completion_events` gains `actor_child_id` (the tapped panel profile; admin events stay NULL) with a second actor-pair CHECK. Schema version advances to **6**.
|
||||
- **Feature 03 — children registry & admin allowlist:** typed business layer for child profiles (create/edit with trimmed required names and duplicate-name warnings, whole-table reorder, deactivation as the only removal path — history is never touched); the admin allowlist with a fail-closed `is_admin` resolver (empty allowlist denies everyone; the last admin cannot be removed); automatic seeding on first setup (the config-flow user is persisted in entry data, with the options-flow list and HA owner accounts as fallbacks) so the owner is never locked out; the options flow gains a multi-select picker of existing Home Assistant users.
|
||||
- **Feature 05 — presence & custody engine:** a pure `PresenceSchedule`/`PresenceOverride` model with total validation and lossless round-trips; anchor-date cycle arithmetic (D-004 — never ISO week numbers or parity); `PresenceEngine.is_present` with the always-present default for schedule-free children; date overrides beat the repeating pattern; a `next_present_dates` preview for the admin schedule UI; a full-year regression guard walking 2020 (366 days, ISO week 53) and 2015 (365 days, ISO week 53) proving the rotation never inverts at a year boundary.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Minimum Home Assistant version: **2024.6.0**.
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- The database schema advances from version 1 to version 6. Upgrades are automatic and data-preserving (legacy `task_*` tables are renamed; the definitions table is rebuilt for multi-assignee; legacy instances pin to the morning window; panel events backfill `actor_child_id`). **Back up `nestquest.db` before upgrading** — see Rollback.
|
||||
- Dev installs from before 2026-09-15 migrate automatically on first start.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- Still no entities, services, calendar platforms, or panel cards: nothing is user-visible in Home Assistant yet (Features 06-10 land the business layer, materialisation, permission gate, and entity surface).
|
||||
- No materialisation yet: instances are not generated from definitions + presence yet (Feature 07).
|
||||
- Single-instance only.
|
||||
|
||||
### Rollback
|
||||
|
||||
**Back up `nestquest.db` (plus `-wal`/`-shm` sidecars) BEFORE upgrading.** This release migrates the database to schema version 6 with renamed tables; an older NestQuest build refuses a version-6 database ("newer than this integration understands") and cannot read renamed tables at all — a plain downgrade is NOT possible once the database has been migrated.
|
||||
|
||||
**Rollback procedure (after a migrated database):**
|
||||
|
||||
1. Stop Home Assistant.
|
||||
2. Restore the pre-upgrade `nestquest.db` backup (and sidecars) you made before upgrading.
|
||||
3. Downgrade the integration (HACS > Integrations > NestQuest > Redownload > 0.3.0) or replace `custom_components/nestquest/` manually.
|
||||
4. Restart Home Assistant.
|
||||
|
||||
Without a backup, the only path is staying on 0.4.0 (recommended) or deleting the integration and its database to start fresh (Settings > Devices & Services > NestQuest > Delete, then remove `nestquest.db`).
|
||||
|
||||
**Repository operators:** instead of uninstalling, git-revert the release merge on `main` and restore the pre-upgrade database.
|
||||
|
||||
## Version 0.3.0 — 2026-09-14
|
||||
|
||||
### Scope
|
||||
|
||||
Third release of NestQuest, shipping Feature 04 — the recurrence rule engine:
|
||||
|
||||
- `ScheduleRule`: an immutable, validated rule model (frozen dataclass) covering six recurrence shapes — daily, weekly (weekday sets), monthly by day-of-month, monthly by nth-weekday, yearly, and custom day-sets — with interval multipliers (every N days/weeks/months) and optional start/end dates.
|
||||
- `occurs_on(rule, date)`: pure calendar evaluation answering "does this rule fire on this date?"
|
||||
- `occurrences_between(rule, start, end)`: ordered, duplicate-free date lists, inclusive bounds, clamped to the rule's window.
|
||||
- Documented month-end policy: a rule for a day absent from a month (the 31st in April, the 30th/29th in February) fires on that month's LAST day — one firing per eligible month, interval anchored on nominal months elapsed.
|
||||
- Documented leap-day policy: a YEARLY February 29th rule fires February 28th in non-leap years (clamp, never skip).
|
||||
- Week offsets anchored at `start_date` by whole weeks elapsed — never ISO week numbers or parity (53-week years cannot silently invert schedules).
|
||||
- The engine is pure: no Home Assistant imports, no database access, no timezone logic.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Minimum Home Assistant version: **2024.6.0**.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- No entities, services, or calendar platforms yet — the integration does not expose sensors, calendar events, or service calls in this release (data layer + recurrence engine only).
|
||||
- No business logic yet: nothing materializes task instances (Feature 07 combines this engine with presence schedules) — storage and scheduling math only.
|
||||
- Single-instance only — one NestQuest config entry is permitted per Home Assistant instance.
|
||||
|
||||
### Rollback
|
||||
|
||||
Downgrading from 0.3.0 to 0.2.0 removes the recurrence engine's code but leaves the `nestquest.db` file in place (untouched by both releases; no code path deletes an existing database). The file's schema version stamp is unaffected — this release ships no schema migration.
|
||||
|
||||
**HACS installs:**
|
||||
|
||||
1. Downgrade via HACS (HACS > Integrations > NestQuest > Redownload > 0.2.0) or Remove entirely.
|
||||
2. Restart Home Assistant.
|
||||
3. If removing: delete the integration in Home Assistant (Settings > Devices & Services > NestQuest > Delete), then restart Home Assistant again.
|
||||
|
||||
**Manual installs:**
|
||||
|
||||
1. Replace or delete the `custom_components/nestquest/` directory in your Home Assistant configuration directory.
|
||||
2. Restart Home Assistant.
|
||||
|
||||
**Data:** the `nestquest.db` file (plus any `-wal`/`-shm` sidecars) is intentionally left untouched by both upgrade and rollback — back it up before upgrading.
|
||||
|
||||
**Repository operators:** instead of uninstalling, git-revert the release merge on `main`.
|
||||
|
||||
## Version 0.2.0 — 2026-09-14
|
||||
|
||||
### Scope
|
||||
|
||||
Second release of NestQuest, shipping Feature 02 — the persistence layer the integration owns outright:
|
||||
|
||||
- Integration-owned SQLite database (`nestquest.db`) in the Home Assistant config directory: WAL journal mode, foreign-key enforcement, executor-backed access so the event loop never blocks.
|
||||
- Schema v1 for all eight tables: `children`, `admin_users`, `schedule_rules`, `task_definitions`, `presence_schedules`, `presence_overrides`, `task_instances`, `completion_events` — validated by declarative CHECK constraints (no triggers).
|
||||
- Versioned migration runner: on first start the database is created at schema version 1; later starts are a verified no-op; a failed migration rolls back to its prior version. A database stamped with a future version refuses to start.
|
||||
- Typed DAO layers for every table so no other feature writes raw SQL. Assignment validation is atomic; `completion_events` is strictly append-only — the DAO exposes no update or delete method, and a package-wide guard keeps mutation SQL out of every module.
|
||||
- Startup handling for missing (created fresh), valid (opened), and corrupt (logged, retried, left byte-identical for backup restore) database files via a read-only `integrity_check` preflight that cannot write or create sidecars. An existing zero-byte file is treated as corrupt and left untouched, not silently initialized.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Minimum Home Assistant version: **2024.6.0**.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- No entities, services, or calendar platforms yet — the integration does not expose sensors, calendar events, or service calls in this release (data layer only).
|
||||
- No business logic yet: nothing materializes task instances and nothing can be completed — storage and schema only.
|
||||
- Single-instance only — one NestQuest config entry is permitted per Home Assistant instance.
|
||||
|
||||
### Rollback
|
||||
|
||||
Downgrading from 0.2.0 to 0.1.0 removes the data layer's code but leaves the `nestquest.db` file in place (0.1.0 never touches it, and no code path deletes an existing database). The file is compatible with this release's schema version stamp; a later reinstall of 0.2.0 or newer resumes from the stored version without re-migrating.
|
||||
|
||||
**HACS installs:**
|
||||
|
||||
1. Downgrade via HACS (HACS > Integrations > NestQuest > Redownload > 0.1.0) or Remove entirely.
|
||||
2. Restart Home Assistant.
|
||||
3. If removing: delete the integration in Home Assistant (Settings > Devices & Services > NestQuest > Delete), then restart Home Assistant again.
|
||||
|
||||
**Manual installs:**
|
||||
|
||||
1. Replace or delete the `custom_components/nestquest/` directory in your Home Assistant configuration directory.
|
||||
2. Restart Home Assistant.
|
||||
|
||||
**Data:** the `nestquest.db` file (plus any `-wal`/`-shm` sidecars) is intentionally left untouched by both upgrade and rollback — back it up before upgrading. Removing the integration via Home Assistant deletes the config entry but not the database file.
|
||||
|
||||
**Repository operators:** instead of uninstalling, git-revert the release merge on `main`. The database file is independent of the code and is never deleted by code.
|
||||
|
||||
## Version 0.1.0 — 2026-09-13
|
||||
|
||||
### Scope
|
||||
@@ -42,4 +339,4 @@ v0.1.0 is the first release — there is no previous NestQuest version to downgr
|
||||
|
||||
**Data:** the integration does not yet create its own database file, so there is no application data. However, Home Assistant persists the config entry and its selected options — removing the integration deletes that configuration, and the option values (`horizon_days`, `day_rollover_time`, `panel_idle_timeout`) will be lost on removal.
|
||||
|
||||
**Repository operators:** instead of uninstalling, you may git-revert the release merge on `main`, which removes the integration files entirely. End users should use the uninstall path above.
|
||||
**Repository operators:** instead of uninstalling, you may git-revert the release merge on `main`, which removes the integration files entirely. End users should use the uninstall path above.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# NestQuest Admin PWA — runtime configuration (Vite env).
|
||||
# Copy to .env (or .env.local) and fill in real values. NEVER commit .env.
|
||||
|
||||
# Authentik OIDC provider. Defaults to the issuer below when unset.
|
||||
VITE_AUTHENTIK_ISSUER=https://auth.cubecraftlabs.com/application/o/nestquest/
|
||||
|
||||
# REQUIRED. OAuth2/OIDC client id of the PUBLIC (PKCE) Authentik application.
|
||||
# No default: the app shows a configuration error screen when this is missing.
|
||||
# Never set a client_secret — this is a browser SPA using PKCE only.
|
||||
VITE_AUTHENTIK_CLIENT_ID=
|
||||
|
||||
# NestQuest API base URL.
|
||||
VITE_API_BASE_URL=https://nestquest.cubecraftlabs.com
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#3b3ab8" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<title>NestQuest Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3034
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "nestquest-admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.47.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"jsdom": "^30.1.1",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.5",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "NestQuest Admin",
|
||||
"short_name": "NestQuest",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f4f4f6",
|
||||
"theme_color": "#3b3ab8",
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/* NestQuest Admin service worker — hand-written, served from the site root.
|
||||
Precaches the app shell, serves static assets cache-first, and falls back
|
||||
to the cached shell for navigations when offline. Only same-origin GET
|
||||
requests are handled; cross-origin requests (Authentik, the API host) are
|
||||
never intercepted or cached.
|
||||
The build (vite-sw-plugin.ts) replaces PRECACHE_ASSETS with the hashed
|
||||
bundles referenced by index.html and the revision marker with a content
|
||||
hash, so each release installs a new worker with fresh caches. */
|
||||
|
||||
const REVISION = "__NQ_BUILD_REVISION__";
|
||||
const CACHE_PREFIX = "nestquest-admin-";
|
||||
const SHELL_CACHE = `${CACHE_PREFIX}shell-${REVISION}`;
|
||||
const RUNTIME_CACHE = `${CACHE_PREFIX}runtime-${REVISION}`;
|
||||
const CURRENT_CACHES = [SHELL_CACHE, RUNTIME_CACHE];
|
||||
|
||||
const PRECACHE_ASSETS = [];
|
||||
|
||||
const SHELL_URLS = [
|
||||
...new Set([
|
||||
"/",
|
||||
"/index.html",
|
||||
"/manifest.webmanifest",
|
||||
"/icons/icon-192.png",
|
||||
"/icons/icon-512.png",
|
||||
"/icons/icon-maskable-512.png",
|
||||
...PRECACHE_ASSETS,
|
||||
]),
|
||||
];
|
||||
|
||||
// Request destinations treated as static assets (cache-first). Anything else
|
||||
// same-origin (e.g. API calls) goes straight to the network.
|
||||
const STATIC_DESTINATIONS = ["script", "style", "image", "font", "manifest"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(SHELL_CACHE)
|
||||
.then((cache) => cache.addAll(SHELL_URLS))
|
||||
.then(() => self.skipWaiting()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((key) => key.startsWith(CACHE_PREFIX) && !CURRENT_CACHES.includes(key))
|
||||
.map((key) => caches.delete(key)),
|
||||
),
|
||||
)
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const request = event.request;
|
||||
if (request.method !== "GET") return;
|
||||
if (new URL(request.url).origin !== self.location.origin) return;
|
||||
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(request).catch(() =>
|
||||
caches.match("/index.html", { cacheName: SHELL_CACHE }).then(
|
||||
(cached) => cached || Response.error(),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (STATIC_DESTINATIONS.includes(request.destination)) {
|
||||
event.respondWith(cacheFirst(request));
|
||||
}
|
||||
});
|
||||
|
||||
async function cacheFirst(request) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
const response = await fetch(request);
|
||||
if (response.ok && response.type === "basic") {
|
||||
const cache = await caches.open(RUNTIME_CACHE);
|
||||
await cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Generates the committed PWA icons under public/icons/ as genuine PNGs.
|
||||
// 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 { 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 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, pixel) {
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(size, 0);
|
||||
ihdr.writeUInt32BE(size, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 2; // colour type: RGB
|
||||
const raw = Buffer.alloc(size * (size * 3 + 1));
|
||||
for (let y = 0; y < size; y++) {
|
||||
const row = y * (size * 3 + 1);
|
||||
raw[row] = 0; // filter: none
|
||||
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]),
|
||||
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;
|
||||
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 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],
|
||||
];
|
||||
for (const [name, size, scale] of icons) {
|
||||
const path = fileURLToPath(new URL(name, outDir));
|
||||
writeFileSync(path, encodePng(size, brandMark(size, scale)));
|
||||
console.log(`wrote ${path}`);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/* Shell layout — mobile-first (390x844 reference), plain CubeCraft styling. */
|
||||
|
||||
.admin-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background: var(--nq-a-surface);
|
||||
border-bottom: 1px solid var(--nq-a-border);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.admin-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.admin-subline {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.admin-panel {
|
||||
flex: 1;
|
||||
padding: 14px 16px 92px; /* bottom padding clears the 70px tab bar */
|
||||
}
|
||||
|
||||
/* A built screen supplies its own header and 92px-padded scroll column. */
|
||||
.admin-panel--screen {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.admin-tabbar {
|
||||
position: fixed;
|
||||
inset: auto 0 0;
|
||||
display: flex;
|
||||
min-height: calc(var(--nq-a-tabbar-height) + 12px);
|
||||
padding: 8px 4px calc(12px + env(safe-area-inset-bottom));
|
||||
background: var(--nq-a-surface);
|
||||
border-top: 1px solid var(--nq-a-border);
|
||||
}
|
||||
|
||||
.admin-tab {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
cursor: pointer;
|
||||
padding: 6px 4px;
|
||||
}
|
||||
|
||||
.admin-tab.active {
|
||||
font-weight: 700;
|
||||
color: var(--nq-brand-blue);
|
||||
}
|
||||
/* Auth screens (login, errors, refusal). */
|
||||
|
||||
.admin-login,
|
||||
.admin-error,
|
||||
.admin-refusal,
|
||||
.admin-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
min-height: 100%;
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-login-button {
|
||||
margin-top: 8px;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--nq-a-accent, #2563eb);
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-error,
|
||||
.admin-refusal {
|
||||
color: var(--nq-a-ink, inherit);
|
||||
}
|
||||
|
||||
.admin-refusal h1,
|
||||
.admin-error h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.admin-refusal p,
|
||||
.admin-error p {
|
||||
margin: 0;
|
||||
max-width: 34em;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import App from "./App";
|
||||
|
||||
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>(() => {})));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders the title and all four tab labels", () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "NestQuest Admin" })).toBeTruthy();
|
||||
for (const label of ["Today", "Tasks", "Schedule", "History"]) {
|
||||
expect(screen.getByRole("tab", { name: label })).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("starts on the Today screen and switches on tab click", () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "History" }));
|
||||
|
||||
expect(screen.getByRole("heading", { name: "History" })).toBeTruthy();
|
||||
expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder");
|
||||
expect(screen.getByRole("tab", { name: "History" }).getAttribute("aria-selected")).toBe(
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("the Tasks tab renders the definitions screen", () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Tasks" }));
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy();
|
||||
expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from "react";
|
||||
import "./App.css";
|
||||
import { TransitionsProvider } from "./api/useTransitions";
|
||||
import DefinitionsTab from "./definitions/DefinitionsTab";
|
||||
import HistoryTab from "./history/HistoryTab";
|
||||
import ScheduleTab from "./schedule/ScheduleTab";
|
||||
import TodayTab from "./today/TodayTab";
|
||||
|
||||
const TABS = [
|
||||
{ id: "today", label: "Today" },
|
||||
{ id: "tasks", label: "Tasks" },
|
||||
{ id: "schedule", label: "Schedule" },
|
||||
{ id: "history", label: "History" },
|
||||
] as const;
|
||||
|
||||
type TabId = (typeof TABS)[number]["id"];
|
||||
|
||||
export default function App() {
|
||||
const [active, setActive] = useState<TabId>("today");
|
||||
|
||||
return (
|
||||
<TransitionsProvider>
|
||||
<div className="admin-shell">
|
||||
{/* Every tab is a built screen that owns its header (ADMIN-SPEC §1); the
|
||||
generic shell header stays only as a visually hidden page heading. */}
|
||||
<header className="admin-header visually-hidden">
|
||||
<h1 className="admin-title">NestQuest Admin</h1>
|
||||
<p className="admin-subline">Admin console</p>
|
||||
</header>
|
||||
<main
|
||||
className="admin-panel admin-panel--screen"
|
||||
role="tabpanel"
|
||||
aria-labelledby={`tab-${active}`}
|
||||
>
|
||||
{active === "today" ? (
|
||||
<TodayTab />
|
||||
) : active === "tasks" ? (
|
||||
<DefinitionsTab />
|
||||
) : active === "schedule" ? (
|
||||
<ScheduleTab />
|
||||
) : (
|
||||
<HistoryTab />
|
||||
)}
|
||||
</main>
|
||||
<nav className="admin-tabbar" aria-label="Admin sections">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
id={`tab-${tab.id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab.id === active}
|
||||
className={tab.id === active ? "admin-tab active" : "admin-tab"}
|
||||
onClick={() => setActive(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</TransitionsProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import AppRoutes from "./AppRoutes";
|
||||
import { storeTokens } from "./auth/oidc";
|
||||
import { config } from "./config";
|
||||
|
||||
// The bundled config reads real env at import time; seed the shared module's
|
||||
// clientId directly so AppRoutes sees a configured app in these tests.
|
||||
(config as { clientId: string | undefined }).clientId = "test-client-id";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("AppRoutes auth gating", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
sessionStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
it("renders the login screen when unauthenticated at /", () => {
|
||||
render(<AppRoutes />);
|
||||
expect(screen.getByTestId("login-screen")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /sign in with authentik/i }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the four-tab shell after a successful authenticated probe", async () => {
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<AppRoutes />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "NestQuest Admin" })).toBeTruthy();
|
||||
});
|
||||
for (const label of ["Today", "Tasks", "Schedule", "History"]) {
|
||||
expect(screen.getByRole("tab", { name: label })).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the explicit refusal when the API answers 403", async () => {
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<AppRoutes />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("refusal-screen")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText(/not in the nestquest-admins group/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the configuration error screen when the client id is missing", () => {
|
||||
const original = config.clientId;
|
||||
(config as { clientId: string | undefined }).clientId = undefined;
|
||||
try {
|
||||
render(<AppRoutes />);
|
||||
expect(screen.getByTestId("config-error-screen")).toBeTruthy();
|
||||
} finally {
|
||||
(config as { clientId: string | undefined }).clientId = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Minimal path-based routing without react-router-dom: the app only needs two
|
||||
* paths (`/` shell and `/auth/callback`), so a tiny window.location switch is
|
||||
* enough and keeps the bundle small. Change screens listen to popstate.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { ConfigError, config } from "./config";
|
||||
import {
|
||||
AuthError,
|
||||
buildAuthorizeUrl,
|
||||
clearAuth,
|
||||
handleCallback,
|
||||
isAuthenticated,
|
||||
} from "./auth/oidc";
|
||||
import { ApiForbiddenError, apiFetch } from "./api/client";
|
||||
import AdminShell from "./App";
|
||||
|
||||
type Screen =
|
||||
| { kind: "loading" }
|
||||
| { kind: "login" }
|
||||
| { kind: "config-error"; message: string }
|
||||
| { kind: "auth-error"; message: string }
|
||||
| { kind: "refusal"; message: string }
|
||||
| { kind: "app" };
|
||||
|
||||
function currentPath(): string {
|
||||
return window.location.pathname.replace(/\/+$/, "") || "/";
|
||||
}
|
||||
|
||||
async function probeApi(setScreen: (screen: Screen) => void) {
|
||||
// A cheap authenticated probe so a 403 surfaces as an explicit refusal
|
||||
// right after login instead of a silent empty shell.
|
||||
try {
|
||||
await apiFetch("/api/v1/children", {}, config);
|
||||
setScreen({ kind: "app" });
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigError) {
|
||||
setScreen({ kind: "config-error", message: error.message });
|
||||
return;
|
||||
}
|
||||
if (error instanceof ApiForbiddenError) {
|
||||
setScreen({ kind: "refusal", message: error.message });
|
||||
return;
|
||||
}
|
||||
// Network or other failures still allow the shell to render; API screens
|
||||
// handle their own errors in later tasks.
|
||||
setScreen({ kind: "app" });
|
||||
}
|
||||
}
|
||||
|
||||
function LoginScreen({ onLogin }: { onLogin: () => void }) {
|
||||
return (
|
||||
<div className="admin-login" data-testid="login-screen">
|
||||
<h1 className="admin-title">NestQuest Admin</h1>
|
||||
<p className="admin-subline">Sign in to continue</p>
|
||||
<button type="button" className="admin-login-button" onClick={onLogin}>
|
||||
Sign in with Authentik
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppRoutes() {
|
||||
const [screen, setScreen] = useState<Screen>({ kind: "loading" });
|
||||
|
||||
useEffect(() => {
|
||||
// Re-evaluate on navigation (popstate) — routing is path based.
|
||||
const onPop = () => setScreen({ kind: "loading" });
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const path = currentPath();
|
||||
|
||||
if (!config.clientId) {
|
||||
setScreen({
|
||||
kind: "config-error",
|
||||
message:
|
||||
"Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/auth/callback") {
|
||||
(async () => {
|
||||
try {
|
||||
await handleCallback(window.location.search, config);
|
||||
if (cancelled) return;
|
||||
window.history.replaceState({}, "", "/");
|
||||
await probeApi((next) => !cancelled && setScreen(next));
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
const message =
|
||||
error instanceof AuthError || error instanceof Error
|
||||
? error.message
|
||||
: "Authentication failed.";
|
||||
setScreen({ kind: "auth-error", message });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
if (isAuthenticated()) {
|
||||
void probeApi((next) => !cancelled && setScreen(next));
|
||||
} else {
|
||||
setScreen({ kind: "login" });
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const startLogin = async () => {
|
||||
try {
|
||||
const url = await buildAuthorizeUrl(config);
|
||||
window.location.assign(url);
|
||||
} catch (error) {
|
||||
setScreen({
|
||||
kind: "config-error",
|
||||
message: error instanceof Error ? error.message : "Login failed.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
switch (screen.kind) {
|
||||
case "loading":
|
||||
return (
|
||||
<div className="admin-loading" role="status" data-testid="loading-screen">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
case "login":
|
||||
return <LoginScreen onLogin={startLogin} />;
|
||||
case "config-error":
|
||||
return (
|
||||
<div className="admin-error" role="alert" data-testid="config-error-screen">
|
||||
<h1>Configuration error</h1>
|
||||
<p>{screen.message}</p>
|
||||
</div>
|
||||
);
|
||||
case "auth-error":
|
||||
return (
|
||||
<div className="admin-error" role="alert" data-testid="auth-error-screen">
|
||||
<h1>Sign-in failed</h1>
|
||||
<p>{screen.message}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearAuth();
|
||||
window.history.replaceState({}, "", "/");
|
||||
setScreen({ kind: "loading" });
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
case "refusal":
|
||||
return (
|
||||
<div className="admin-refusal" role="alert" data-testid="refusal-screen">
|
||||
<h1>Access refused</h1>
|
||||
<p>{screen.message}</p>
|
||||
<button type="button" onClick={() => { clearAuth(); window.history.replaceState({}, "", "/"); setScreen({ kind: "login" }); }}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
case "app":
|
||||
return <AdminShell />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ApiForbiddenError,
|
||||
ApiUnauthorizedError,
|
||||
apiFetch,
|
||||
} from "./client";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
import type { AppConfig } from "../config";
|
||||
|
||||
const app: AppConfig = {
|
||||
issuer: "https://auth.example.com/application/o/nestquest",
|
||||
clientId: "test-client-id",
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
|
||||
tokenEndpoint: "https://auth.example.com/application/o/token/",
|
||||
userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
|
||||
endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
|
||||
redirectUri: "http://localhost:5173/auth/callback",
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("API client", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("attaches the Bearer token to API calls", async () => {
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
|
||||
|
||||
await apiFetch("/api/v1/children", {}, app, fetchMock as any);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://api.example.com/api/v1/children");
|
||||
expect(init.headers.Authorization).toBe("Bearer at-123");
|
||||
});
|
||||
|
||||
it("throws ApiForbiddenError on 403 (authenticated but refused)", async () => {
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 }));
|
||||
|
||||
await expect(
|
||||
apiFetch("/api/v1/children", {}, app, fetchMock as any),
|
||||
).rejects.toBeInstanceOf(ApiForbiddenError);
|
||||
});
|
||||
|
||||
it("triggers a login redirect on 401 with no refresh token", async () => {
|
||||
storeTokens({ access_token: "stale", expires_in: 600 });
|
||||
const assign = vi.fn();
|
||||
vi.stubGlobal("location", { ...window.location, assign });
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 401 }));
|
||||
|
||||
await expect(
|
||||
apiFetch("/api/v1/children", {}, app, fetchMock as any),
|
||||
).rejects.toBeInstanceOf(ApiUnauthorizedError);
|
||||
expect(assign).toHaveBeenCalledTimes(1);
|
||||
const url = new URL(assign.mock.calls[0][0] as string);
|
||||
expect(url.origin + url.pathname).toBe(
|
||||
"https://auth.example.com/application/o/authorize/",
|
||||
);
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
});
|
||||
|
||||
it("preserves non-401/403 statuses on the post-refresh retry (500 is not mislabeled)", async () => {
|
||||
storeTokens({ access_token: "stale", refresh_token: "rt-1", expires_in: 600 });
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response("", { status: 401 })) // initial API call
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ access_token: "fresh", expires_in: 600 }), // refresh grant
|
||||
)
|
||||
.mockResolvedValueOnce(new Response("", { status: 500 })); // retry
|
||||
|
||||
const response = await apiFetch("/api/v1/children", {}, app, fetchMock as any);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(fetchMock.mock.calls[1][0]).toBe(app.tokenEndpoint);
|
||||
expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe("Bearer fresh");
|
||||
// A server failure must surface as the real status, not ApiUnauthorizedError.
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Minimal fetch wrapper for the NestQuest API.
|
||||
*
|
||||
* - Attaches `Authorization: Bearer <access_token>` when a token is stored.
|
||||
* - 401: not authenticated (or token expired) -> trigger a new login.
|
||||
* - 403: authenticated but refused -> throws ApiForbiddenError so the UI can
|
||||
* render an explicit refusal message.
|
||||
*/
|
||||
import { config, type AppConfig } from "../config";
|
||||
import {
|
||||
buildAuthorizeUrl,
|
||||
getToken,
|
||||
isExpired,
|
||||
refreshAccessToken,
|
||||
} from "../auth/oidc";
|
||||
|
||||
export class ApiForbiddenError extends Error {
|
||||
constructor(message = "Your account is not in the nestquest-admins group.") {
|
||||
super(message);
|
||||
this.name = "ApiForbiddenError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiUnauthorizedError extends Error {
|
||||
constructor(message = "Not authenticated.") {
|
||||
super(message);
|
||||
this.name = "ApiUnauthorizedError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiRequestOptions extends Omit<RequestInit, "headers"> {
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect the browser to Authentik to start a new login.
|
||||
* Exported for testing injection.
|
||||
*/
|
||||
export async function triggerLogin(app: AppConfig = config): Promise<void> {
|
||||
const url = await buildAuthorizeUrl(app);
|
||||
window.location.assign(url);
|
||||
}
|
||||
|
||||
export async function apiFetch(
|
||||
path: string,
|
||||
options: ApiRequestOptions = {},
|
||||
app: AppConfig = config,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<Response> {
|
||||
if (isExpired() && (await refreshAccessToken(app, fetchImpl))) {
|
||||
// token renewed; fall through and use the fresh one
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
const headers = {
|
||||
...(options.headers ?? {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
|
||||
const response = await fetchImpl(`${app.apiBaseUrl}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
// Not authenticated: either no token or an unusable one.
|
||||
if (await refreshAccessToken(app, fetchImpl)) {
|
||||
const retryToken = getToken();
|
||||
const retryResponse = await fetchImpl(`${app.apiBaseUrl}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers ?? {}),
|
||||
...(retryToken ? { Authorization: `Bearer ${retryToken}` } : {}),
|
||||
},
|
||||
});
|
||||
if (retryResponse.status === 403) throw new ApiForbiddenError();
|
||||
if (retryResponse.ok) return retryResponse;
|
||||
// Only 401 means "unauthorized"; preserve every other status (e.g. a
|
||||
// 500 from the API) so callers can react to the real response.
|
||||
if (retryResponse.status === 401) throw new ApiUnauthorizedError();
|
||||
return retryResponse;
|
||||
}
|
||||
await triggerLogin(app);
|
||||
throw new ApiUnauthorizedError();
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
// Authenticated, but Authentik's token does not belong to nestquest-admins.
|
||||
throw new ApiForbiddenError();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Typed client for the admin quest-definition routes and the children list
|
||||
* the assignee picker reads (/api/v1/admin/quest-definitions, /children).
|
||||
*/
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export type WindowName = "morning" | "afternoon" | "evening";
|
||||
|
||||
/** Model rule names, as the admin plane serializes ScheduleRule.to_dict. */
|
||||
export type RuleType =
|
||||
| "daily"
|
||||
| "weekly"
|
||||
| "monthly_day"
|
||||
| "monthly_weekday"
|
||||
| "yearly"
|
||||
| "custom_days";
|
||||
|
||||
export interface DefinitionRule {
|
||||
rule_type: RuleType;
|
||||
interval: number;
|
||||
/** 0 = Monday .. 6 = Sunday. */
|
||||
weekday_set: number[] | null;
|
||||
day_of_month: number | null;
|
||||
nth_weekday: number | null;
|
||||
nth_weekday_weekday: number | null;
|
||||
month: number | null;
|
||||
start_date: string;
|
||||
end_date: string | null;
|
||||
}
|
||||
|
||||
export interface DefinitionAssignee {
|
||||
id: number;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export interface DefinitionWindow {
|
||||
window: WindowName;
|
||||
/** Local wall-clock "HH:MM", or null. */
|
||||
due_time: string | null;
|
||||
}
|
||||
|
||||
export interface QuestDefinition {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
is_active: boolean;
|
||||
skip_on_away: boolean;
|
||||
rule: DefinitionRule;
|
||||
assignees: DefinitionAssignee[];
|
||||
windows: DefinitionWindow[];
|
||||
}
|
||||
|
||||
export interface AdminChild {
|
||||
id: number;
|
||||
display_name: string;
|
||||
colour: string | null;
|
||||
avatar_ref: string | null;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
/** A window declaration as the create/edit bodies accept it. */
|
||||
export type WindowEntry = WindowName | [WindowName, string | null];
|
||||
|
||||
export interface DefinitionCreateBody {
|
||||
title: string;
|
||||
rule: DefinitionRule;
|
||||
assignee_child_ids: number[];
|
||||
windows: WindowEntry[];
|
||||
description?: string | null;
|
||||
icon?: string | null;
|
||||
skip_on_away?: boolean;
|
||||
}
|
||||
|
||||
export type DefinitionEditBody = Partial<DefinitionCreateBody>;
|
||||
|
||||
export const DEFINITIONS_PATH = "/api/v1/admin/quest-definitions";
|
||||
export const CHILDREN_PATH = "/api/v1/admin/children";
|
||||
|
||||
/** A non-2xx save response; `detail` is the API's message when it sent one. */
|
||||
export class ApiRequestError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly detail: string | null,
|
||||
) {
|
||||
super(detail ?? `Request failed (HTTP ${status}).`);
|
||||
this.name = "ApiRequestError";
|
||||
}
|
||||
}
|
||||
|
||||
/** FastAPI sends `detail` as a string (core errors) or a list of {msg} (body validation). */
|
||||
function readDetail(body: unknown): string | null {
|
||||
if (!body || typeof body !== "object" || !("detail" in body)) return null;
|
||||
const { detail } = body as { detail: unknown };
|
||||
if (typeof detail === "string") return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
const messages = detail
|
||||
.map((item) => (item && typeof item === "object" ? (item as { msg?: unknown }).msg : null))
|
||||
.filter((msg): msg is string => typeof msg === "string");
|
||||
return messages.length > 0 ? messages.join("; ") : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function readJson<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
// Non-JSON error body: report the status alone.
|
||||
}
|
||||
throw new ApiRequestError(response.status, readDetail(body));
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function fetchDefinitions(): Promise<QuestDefinition[]> {
|
||||
const body = await readJson<{ definitions: QuestDefinition[] }>(
|
||||
await apiFetch(DEFINITIONS_PATH),
|
||||
);
|
||||
return body.definitions;
|
||||
}
|
||||
|
||||
export async function fetchChildren(): Promise<AdminChild[]> {
|
||||
const body = await readJson<{ children: AdminChild[] }>(await apiFetch(CHILDREN_PATH));
|
||||
return body.children;
|
||||
}
|
||||
|
||||
function sendJson(path: string, method: "POST" | "PATCH", body: unknown): Promise<Response> {
|
||||
return apiFetch(path, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createDefinition(body: DefinitionCreateBody): Promise<QuestDefinition> {
|
||||
return readJson<QuestDefinition>(await sendJson(DEFINITIONS_PATH, "POST", body));
|
||||
}
|
||||
|
||||
export async function updateDefinition(
|
||||
id: number,
|
||||
body: DefinitionEditBody,
|
||||
): Promise<QuestDefinition> {
|
||||
return readJson<QuestDefinition>(await sendJson(`${DEFINITIONS_PATH}/${id}`, "PATCH", body));
|
||||
}
|
||||
|
||||
export const OCCURRENCES_PREVIEW_PATH = `${DEFINITIONS_PATH}/occurrences-preview`;
|
||||
|
||||
export interface OccurrencesPreviewOptions {
|
||||
/** "YYYY-MM-DD"; the API defaults to its host's today. */
|
||||
startDate?: string;
|
||||
/** 1..50; the API defaults to 10. */
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The next occurrence dates ("YYYY-MM-DD", ascending) for `rule`, computed by
|
||||
* the same backend recurrence engine the materializer uses.
|
||||
*/
|
||||
export async function previewOccurrences(
|
||||
rule: DefinitionRule,
|
||||
options: OccurrencesPreviewOptions = {},
|
||||
): Promise<string[]> {
|
||||
const body: { rule: DefinitionRule; start_date?: string; count?: number } = { rule };
|
||||
if (options.startDate !== undefined) body.start_date = options.startDate;
|
||||
if (options.count !== undefined) body.count = options.count;
|
||||
const response = await readJson<{ dates: string[] }>(
|
||||
await sendJson(OCCURRENCES_PREVIEW_PATH, "POST", body),
|
||||
);
|
||||
return response.dates;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { waitFor } from "@testing-library/react";
|
||||
import { EVENTS_PATH, SseParser, openTransitionStream, type TransitionEvent } from "./events";
|
||||
import { frame, testStream, type TestStream } from "./testStream";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
|
||||
const COMPLETED = {
|
||||
child_id: 1,
|
||||
child_name: "Declan",
|
||||
instance_id: 12,
|
||||
quest_title: "Make bed",
|
||||
window: "morning",
|
||||
due_date: "2026-09-23",
|
||||
due_time: null,
|
||||
occurred_at: "2026-09-23T11:04:00Z",
|
||||
};
|
||||
const DAY_COMPLETE = {
|
||||
child_id: 2,
|
||||
child_name: "Maeve",
|
||||
quests_due: 4,
|
||||
quests_completed: 4,
|
||||
occurred_at: "2026-09-23T12:00:00Z",
|
||||
};
|
||||
|
||||
describe("SseParser", () => {
|
||||
it("yields every frame from one chunk", () => {
|
||||
const parser = new SseParser();
|
||||
const events = parser.push(
|
||||
frame("nestquest_quest_completed", COMPLETED) +
|
||||
frame("nestquest_child_day_complete", DAY_COMPLETE),
|
||||
);
|
||||
expect(events).toEqual([
|
||||
{ type: "nestquest_quest_completed", payload: COMPLETED },
|
||||
{ type: "nestquest_child_day_complete", payload: DAY_COMPLETE },
|
||||
]);
|
||||
});
|
||||
|
||||
it("buffers a frame split across two chunks", () => {
|
||||
const parser = new SseParser();
|
||||
const text = frame("nestquest_quest_uncompleted", COMPLETED);
|
||||
const cut = text.indexOf("Make");
|
||||
expect(parser.push(text.slice(0, cut))).toEqual([]);
|
||||
expect(parser.push(text.slice(cut))).toEqual([
|
||||
{ type: "nestquest_quest_uncompleted", payload: COMPLETED },
|
||||
]);
|
||||
});
|
||||
|
||||
it("buffers a split inside the event name and the blank-line terminator", () => {
|
||||
const parser = new SseParser();
|
||||
const text = frame("nestquest_quest_missed", COMPLETED);
|
||||
expect(parser.push(text.slice(0, 12))).toEqual([]);
|
||||
expect(parser.push(text.slice(12, -1))).toEqual([]);
|
||||
expect(parser.push(text.slice(-1))).toEqual([
|
||||
{ type: "nestquest_quest_missed", payload: COMPLETED },
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a CRLF split across chunks as one line break", () => {
|
||||
const parser = new SseParser();
|
||||
const text = `event: nestquest_quest_completed\r\ndata: ${JSON.stringify(COMPLETED)}\r\n\r\n`;
|
||||
const cut = text.indexOf("\n"); // chunk ends on "\r"
|
||||
expect(parser.push(text.slice(0, cut))).toEqual([]);
|
||||
expect(parser.push(text.slice(cut))).toEqual([
|
||||
{ type: "nestquest_quest_completed", payload: COMPLETED },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops malformed, unknown and comment frames without throwing", () => {
|
||||
const parser = new SseParser();
|
||||
const events = parser.push(
|
||||
"event: nestquest_quest_completed\ndata: not-json\n\n" +
|
||||
"event: nestquest_quest_completed\ndata: [1,2]\n\n" +
|
||||
"event: something_else\ndata: {}\n\n" +
|
||||
": keep-alive\n\n" +
|
||||
"data: {}\n\n" +
|
||||
frame("nestquest_quest_completed", COMPLETED),
|
||||
);
|
||||
expect(events).toEqual([{ type: "nestquest_quest_completed", payload: COMPLETED }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openTransitionStream", () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
let streams: TestStream[];
|
||||
let stop: (() => void) | null;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
streams = [];
|
||||
stop = null;
|
||||
fetchMock = vi.fn(() => {
|
||||
const stream = testStream();
|
||||
streams.push(stream);
|
||||
return Promise.resolve(stream.response);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stop?.();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("sends the token in the Authorization header, never in the URL", async () => {
|
||||
stop = openTransitionStream({ onTransition: () => {} });
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toMatch(new RegExp(`${EVENTS_PATH}$`));
|
||||
expect(String(url)).not.toContain("at-123");
|
||||
expect(init.headers.Authorization).toBe("Bearer at-123");
|
||||
expect(init.headers.Accept).toBe("text/event-stream");
|
||||
});
|
||||
|
||||
it("delivers transitions from frames split across chunks", async () => {
|
||||
const seen: TransitionEvent[] = [];
|
||||
stop = openTransitionStream({ onTransition: (event) => seen.push(event) });
|
||||
await waitFor(() => expect(streams).toHaveLength(1));
|
||||
const text = frame("nestquest_quest_completed", COMPLETED);
|
||||
streams[0].push(text.slice(0, 20));
|
||||
streams[0].push(text.slice(20));
|
||||
await waitFor(() =>
|
||||
expect(seen).toEqual([{ type: "nestquest_quest_completed", payload: COMPLETED }]),
|
||||
);
|
||||
});
|
||||
|
||||
it("stop aborts the request and cancels the body", async () => {
|
||||
stop = openTransitionStream({ onTransition: () => {} });
|
||||
await waitFor(() => expect(streams).toHaveLength(1));
|
||||
const signal = fetchMock.mock.calls[0][1].signal as AbortSignal;
|
||||
stop();
|
||||
expect(signal.aborted).toBe(true);
|
||||
await waitFor(() => expect(streams[0].cancelled()).toBe(true));
|
||||
});
|
||||
|
||||
it("reconnects after the stream drops", async () => {
|
||||
const seen: TransitionEvent[] = [];
|
||||
stop = openTransitionStream({ onTransition: (event) => seen.push(event), minDelayMs: 5 });
|
||||
await waitFor(() => expect(streams).toHaveLength(1));
|
||||
streams[0].close();
|
||||
await waitFor(() => expect(streams).toHaveLength(2));
|
||||
streams[1].push(frame("nestquest_quest_missed", COMPLETED));
|
||||
await waitFor(() => expect(seen).toHaveLength(1));
|
||||
});
|
||||
|
||||
it("does not reconnect after stop, even with a reconnect pending", async () => {
|
||||
stop = openTransitionStream({ onTransition: () => {}, minDelayMs: 20 });
|
||||
await waitFor(() => expect(streams).toHaveLength(1));
|
||||
streams[0].close();
|
||||
stop();
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("gives up on a 403 refusal instead of retrying", async () => {
|
||||
fetchMock.mockImplementation(() => Promise.resolve(new Response("", { status: 403 })));
|
||||
stop = openTransitionStream({ onTransition: () => {}, minDelayMs: 5 });
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Live transition stream: GET /api/v1/admin/events (text/event-stream).
|
||||
*
|
||||
* The admin JWT travels in the Authorization header (via apiFetch), so the
|
||||
* stream is read with fetch + a ReadableStream reader — EventSource cannot
|
||||
* set headers, and the token must never go into a URL.
|
||||
*/
|
||||
import { ApiForbiddenError, ApiUnauthorizedError, apiFetch } from "./client";
|
||||
|
||||
export const EVENTS_PATH = "/api/v1/admin/events";
|
||||
|
||||
/** The four documented transitions, as the API names them on the wire. */
|
||||
export const TRANSITION_TYPES = [
|
||||
"nestquest_quest_completed",
|
||||
"nestquest_quest_uncompleted",
|
||||
"nestquest_quest_missed",
|
||||
"nestquest_child_day_complete",
|
||||
] as const;
|
||||
|
||||
export type TransitionType = (typeof TRANSITION_TYPES)[number];
|
||||
|
||||
/** Quest transitions carry the instance; a day-complete carries the child's counts. */
|
||||
export interface QuestTransitionPayload {
|
||||
child_id: number;
|
||||
child_name: string | null;
|
||||
instance_id: number;
|
||||
quest_title: string | null;
|
||||
window: string;
|
||||
due_date: string;
|
||||
due_time: string | null;
|
||||
occurred_at: string;
|
||||
}
|
||||
|
||||
export interface ChildDayCompletePayload {
|
||||
child_id: number;
|
||||
child_name: string | null;
|
||||
quests_due: number;
|
||||
quests_completed: number;
|
||||
occurred_at: string;
|
||||
}
|
||||
|
||||
export type TransitionEvent =
|
||||
| {
|
||||
type:
|
||||
| "nestquest_quest_completed"
|
||||
| "nestquest_quest_uncompleted"
|
||||
| "nestquest_quest_missed";
|
||||
payload: QuestTransitionPayload;
|
||||
}
|
||||
| { type: "nestquest_child_day_complete"; payload: ChildDayCompletePayload };
|
||||
|
||||
function isTransitionType(value: string): value is TransitionType {
|
||||
return (TRANSITION_TYPES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental SSE frame parser. Feed it decoded text in whatever chunks the
|
||||
* network delivers; it buffers a partial line or frame until it completes.
|
||||
* Comments (keep-alives), unknown event types and frames whose data is not a
|
||||
* JSON object are dropped, never thrown.
|
||||
*/
|
||||
export class SseParser {
|
||||
private buffer = "";
|
||||
private eventType = "";
|
||||
private data: string[] = [];
|
||||
/** A chunk ended on "\r": a leading "\n" in the next chunk is the same line break. */
|
||||
private pendingCr = false;
|
||||
|
||||
push(text: string): TransitionEvent[] {
|
||||
if (this.pendingCr && text.startsWith("\n")) text = text.slice(1);
|
||||
this.pendingCr = text.endsWith("\r");
|
||||
this.buffer += text;
|
||||
const lines = this.buffer.split(/\r\n|\r|\n/);
|
||||
this.buffer = lines.pop() ?? "";
|
||||
const events: TransitionEvent[] = [];
|
||||
for (const line of lines) {
|
||||
const event = this.line(line);
|
||||
if (event) events.push(event);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
private line(line: string): TransitionEvent | null {
|
||||
if (line === "") return this.dispatch();
|
||||
if (line.startsWith(":")) return null;
|
||||
const colon = line.indexOf(":");
|
||||
const field = colon === -1 ? line : line.slice(0, colon);
|
||||
let value = colon === -1 ? "" : line.slice(colon + 1);
|
||||
if (value.startsWith(" ")) value = value.slice(1);
|
||||
if (field === "event") this.eventType = value;
|
||||
else if (field === "data") this.data.push(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
private dispatch(): TransitionEvent | null {
|
||||
const type = this.eventType;
|
||||
const data = this.data.join("\n");
|
||||
this.eventType = "";
|
||||
this.data = [];
|
||||
if (!isTransitionType(type)) return null;
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
||||
return { type, payload } as TransitionEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export interface TransitionStreamOptions {
|
||||
onTransition: (event: TransitionEvent) => void;
|
||||
/** Reconnect delay after a drop: starts at `minDelayMs`, doubles up to `maxDelayMs`. */
|
||||
minDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
}
|
||||
|
||||
function wait(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(done, ms);
|
||||
signal.addEventListener("abort", done, { once: true });
|
||||
function done() {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener("abort", done);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Read one connection until the body ends (or the signal aborts). */
|
||||
async function readOnce(
|
||||
signal: AbortSignal,
|
||||
onTransition: (event: TransitionEvent) => void,
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(EVENTS_PATH, {
|
||||
signal,
|
||||
headers: { Accept: "text/event-stream" },
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Event stream request failed (HTTP ${response.status}).`);
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const parser = new SseParser();
|
||||
const cancel = () => void reader.cancel().catch(() => {});
|
||||
signal.addEventListener("abort", cancel, { once: true });
|
||||
try {
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done || signal.aborted) break;
|
||||
for (const event of parser.push(decoder.decode(value, { stream: true }))) {
|
||||
if (signal.aborted) break;
|
||||
onTransition(event);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener("abort", cancel);
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the admin transition stream, reconnecting with a bounded
|
||||
* backoff after a drop. Returns a stop function that aborts the open request
|
||||
* and any pending reconnect. A 401/403 stops the stream for good: apiFetch
|
||||
* has already started a login (401) or the account is refused (403).
|
||||
*/
|
||||
export function openTransitionStream({
|
||||
onTransition,
|
||||
minDelayMs = 1_000,
|
||||
maxDelayMs = 30_000,
|
||||
}: TransitionStreamOptions): () => void {
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
void (async () => {
|
||||
let delay = minDelayMs;
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
await readOnce(signal, onTransition);
|
||||
// A connection that was accepted and then ended starts the backoff over.
|
||||
delay = minDelayMs;
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) return;
|
||||
if (error instanceof ApiUnauthorizedError || error instanceof ApiForbiddenError) return;
|
||||
// Dropped or refused connection: fall through to the backoff.
|
||||
}
|
||||
if (signal.aborted) return;
|
||||
await wait(delay, signal);
|
||||
delay = Math.min(delay * 2, maxDelayMs);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Typed client for the admin history route (/api/v1/admin/history): the
|
||||
* append-only event log over a closed due-date range for one filter.
|
||||
*/
|
||||
import { apiFetch } from "./client";
|
||||
import { ApiRequestError, readJson } from "./definitions";
|
||||
|
||||
export type HistoryFilter = "all" | "reversals" | "missed";
|
||||
|
||||
export type HistoryEventType = "completed" | "uncompleted" | "missed";
|
||||
|
||||
export interface HistoryRow {
|
||||
/** UTC ISO stamp; null for a derived missed row (there is no event). */
|
||||
occurred_at: string | null;
|
||||
event_type: HistoryEventType;
|
||||
child_name: string;
|
||||
child_id: number;
|
||||
quest_title: string;
|
||||
instance_id: number;
|
||||
window: string;
|
||||
/** "YYYY-MM-DD", the day the task was owed. */
|
||||
due_date: string;
|
||||
/** Local wall-clock "HH:MM", or null. */
|
||||
due_time: string | null;
|
||||
/** e.g. "panel (Declan)", "joshua (admin)", "nightly sweep". */
|
||||
actor: string;
|
||||
was_on_time: boolean | null;
|
||||
}
|
||||
|
||||
export interface HistoryResponse {
|
||||
filter: HistoryFilter;
|
||||
start: string;
|
||||
end: string;
|
||||
count: number;
|
||||
rows: HistoryRow[];
|
||||
}
|
||||
|
||||
export const HISTORY_PATH = "/api/v1/admin/history";
|
||||
|
||||
/** `start`/`end` are inclusive "YYYY-MM-DD" due dates. */
|
||||
export async function fetchHistory(
|
||||
filter: HistoryFilter,
|
||||
start: string,
|
||||
end: string,
|
||||
): Promise<HistoryResponse> {
|
||||
const params = new URLSearchParams({ start, end, filter });
|
||||
return readJson<HistoryResponse>(await apiFetch(`${HISTORY_PATH}?${params.toString()}`));
|
||||
}
|
||||
|
||||
export const HISTORY_CSV_PATH = "/api/v1/admin/history.csv";
|
||||
|
||||
export interface HistoryCsv {
|
||||
blob: Blob;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
/** The `filename` from a Content-Disposition header, or null. */
|
||||
export function csvFilename(disposition: string | null): string | null {
|
||||
const match = disposition?.match(/filename="?([^";]+)"?/i);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The CSV export of the same range and filter as `fetchHistory`. Goes through
|
||||
* apiFetch so the admin token travels in the Authorization header.
|
||||
*/
|
||||
export async function exportHistoryCsv(
|
||||
filter: HistoryFilter,
|
||||
start: string,
|
||||
end: string,
|
||||
): Promise<HistoryCsv> {
|
||||
const params = new URLSearchParams({ start, end, filter });
|
||||
const response = await apiFetch(`${HISTORY_CSV_PATH}?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
// readJson raises ApiRequestError with the API's `detail` for a non-OK response.
|
||||
await readJson<never>(response);
|
||||
throw new ApiRequestError(response.status, null);
|
||||
}
|
||||
return {
|
||||
blob: await response.blob(),
|
||||
filename:
|
||||
csvFilename(response.headers.get("Content-Disposition")) ??
|
||||
`nestquest-history-${start}-to-${end}.csv`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Typed client for the admin instance mutation route
|
||||
* (POST /api/v1/admin/instances/{id}/uncomplete).
|
||||
*/
|
||||
import { apiFetch } from "./client";
|
||||
import { readJson } from "./definitions";
|
||||
|
||||
export interface UncompleteResult {
|
||||
instance_id: number;
|
||||
/** The derived state after the reversal: "open", or "missed" when past due. */
|
||||
state: string;
|
||||
/** False when the instance was already open (a no-op). */
|
||||
appended: boolean;
|
||||
}
|
||||
|
||||
export function uncompletePath(instanceId: number): string {
|
||||
return `/api/v1/admin/instances/${instanceId}/uncomplete`;
|
||||
}
|
||||
|
||||
/** Reverse one completion; raises ApiRequestError with the API's detail on failure. */
|
||||
export async function uncompleteInstance(instanceId: number): Promise<UncompleteResult> {
|
||||
return readJson<UncompleteResult>(await apiFetch(uncompletePath(instanceId), { method: "POST" }));
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Typed client for the admin presence routes: a child's repeating schedule
|
||||
* (/api/v1/admin/children/{id}/presence-schedule) and the household's
|
||||
* date-range overrides (/api/v1/admin/presence-overrides).
|
||||
*/
|
||||
import { apiFetch } from "./client";
|
||||
import { CHILDREN_PATH, readJson } from "./definitions";
|
||||
|
||||
/**
|
||||
* Cycle week index ("0".."n-1", JSON object keys) -> present weekdays
|
||||
* (0 = Monday .. 6 = Sunday). An empty list is away that whole week.
|
||||
*/
|
||||
export type PresencePattern = Record<string, number[]>;
|
||||
|
||||
export interface PresenceScheduleBody {
|
||||
cycle_length_weeks: number;
|
||||
/** "YYYY-MM-DD"; pins cycle week 0. */
|
||||
anchor_date: string;
|
||||
pattern: PresencePattern;
|
||||
}
|
||||
|
||||
export interface PresenceSchedule extends PresenceScheduleBody {
|
||||
child_id: number;
|
||||
}
|
||||
|
||||
export interface PresenceOverride {
|
||||
id: number;
|
||||
child_id: number;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
is_present: boolean;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface PresenceOverrideFilter {
|
||||
childId?: number;
|
||||
/** "YYYY-MM-DD"; keeps overrides overlapping [start, end]. */
|
||||
start?: string;
|
||||
end?: string;
|
||||
}
|
||||
|
||||
export const PRESENCE_OVERRIDES_PATH = "/api/v1/admin/presence-overrides";
|
||||
|
||||
export function presenceSchedulePath(childId: number): string {
|
||||
return `${CHILDREN_PATH}/${childId}/presence-schedule`;
|
||||
}
|
||||
|
||||
/** The child's schedule, or null when it has none (present every day). */
|
||||
export async function fetchPresenceSchedule(childId: number): Promise<PresenceSchedule | null> {
|
||||
const body = await readJson<{ schedule: PresenceSchedule | null }>(
|
||||
await apiFetch(presenceSchedulePath(childId)),
|
||||
);
|
||||
return body.schedule;
|
||||
}
|
||||
|
||||
/** Set (upsert) the child's schedule; resolves to the schedule as stored. */
|
||||
export async function savePresenceSchedule(
|
||||
childId: number,
|
||||
body: PresenceScheduleBody,
|
||||
): Promise<PresenceSchedule> {
|
||||
return readJson<PresenceSchedule>(
|
||||
await apiFetch(presenceSchedulePath(childId), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchPresenceOverrides(
|
||||
filter: PresenceOverrideFilter = {},
|
||||
): Promise<PresenceOverride[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (filter.childId !== undefined) params.set("child_id", String(filter.childId));
|
||||
if (filter.start !== undefined) params.set("start", filter.start);
|
||||
if (filter.end !== undefined) params.set("end", filter.end);
|
||||
const query = params.toString();
|
||||
const body = await readJson<{ overrides: PresenceOverride[] }>(
|
||||
await apiFetch(query ? `${PRESENCE_OVERRIDES_PATH}?${query}` : PRESENCE_OVERRIDES_PATH),
|
||||
);
|
||||
return body.overrides;
|
||||
}
|
||||
|
||||
export interface PresenceOverrideRange {
|
||||
child_id: number;
|
||||
/** "YYYY-MM-DD", inclusive. */
|
||||
start_date: string;
|
||||
/** "YYYY-MM-DD", inclusive; never before start_date. */
|
||||
end_date: string;
|
||||
is_present: boolean;
|
||||
}
|
||||
|
||||
export interface PresenceOverrideBody extends PresenceOverrideRange {
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export const PRESENCE_OVERRIDE_CONSEQUENCE_PATH = `${PRESENCE_OVERRIDES_PATH}/consequence`;
|
||||
|
||||
function postJson(path: string, body: unknown): Promise<Response> {
|
||||
return apiFetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* How many open upcoming tasks saving this override would remove. Computed
|
||||
* by the API before anything is saved; completed instances never count.
|
||||
*/
|
||||
export async function previewOverrideConsequence(range: PresenceOverrideRange): Promise<number> {
|
||||
const body = await readJson<{ removed: number }>(
|
||||
await postJson(PRESENCE_OVERRIDE_CONSEQUENCE_PATH, range),
|
||||
);
|
||||
return body.removed;
|
||||
}
|
||||
|
||||
export async function createPresenceOverride(body: PresenceOverrideBody): Promise<PresenceOverride> {
|
||||
return readJson<PresenceOverride>(await postJson(PRESENCE_OVERRIDES_PATH, body));
|
||||
}
|
||||
|
||||
export async function deletePresenceOverride(overrideId: number): Promise<void> {
|
||||
await readJson<{ status: string }>(
|
||||
await apiFetch(`${PRESENCE_OVERRIDES_PATH}/${overrideId}`, { method: "DELETE" }),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Typed client for GET /api/v1/admin/snapshot — today's household snapshot
|
||||
* that drives the admin Today tab.
|
||||
*/
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export type InstanceWindow = "morning" | "afternoon" | "evening";
|
||||
export type InstanceState = "open" | "completed" | "missed";
|
||||
|
||||
export interface SnapshotInstance {
|
||||
id: number;
|
||||
definition_id: number;
|
||||
child_id: number;
|
||||
title: string;
|
||||
icon: string | null;
|
||||
window: InstanceWindow;
|
||||
/** Local wall-clock "HH:MM", or null when the definition has no due time. */
|
||||
due_time: string | null;
|
||||
state: InstanceState;
|
||||
overdue: boolean;
|
||||
/** UTC ISO timestamp of the completion event, when completed. */
|
||||
completed_at: string | null;
|
||||
on_time: boolean | null;
|
||||
}
|
||||
|
||||
export interface SnapshotChild {
|
||||
child_id: number;
|
||||
child_name: string;
|
||||
present: boolean;
|
||||
/** "YYYY-MM-DD" of the next present day, or null. */
|
||||
next_present: string | null;
|
||||
due_today: number;
|
||||
completed_today: number;
|
||||
remaining_today: number;
|
||||
completion_pct: number;
|
||||
instances: SnapshotInstance[];
|
||||
}
|
||||
|
||||
export interface AdminSnapshot {
|
||||
/** "YYYY-MM-DD" in the household's local zone. */
|
||||
today_iso: string;
|
||||
/** 1-based day in the custody cycle. */
|
||||
cycle_day: number;
|
||||
children: SnapshotChild[];
|
||||
}
|
||||
|
||||
export const SNAPSHOT_PATH = "/api/v1/admin/snapshot";
|
||||
|
||||
export async function fetchSnapshot(): Promise<AdminSnapshot> {
|
||||
const response = await apiFetch(SNAPSHOT_PATH);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Snapshot request failed (HTTP ${response.status}).`);
|
||||
}
|
||||
return (await response.json()) as AdminSnapshot;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Test helper: a controllable text/event-stream Response whose body the test
|
||||
* feeds chunk by chunk (so a frame can be split across chunk boundaries).
|
||||
*/
|
||||
export interface TestStream {
|
||||
response: Response;
|
||||
push(text: string): void;
|
||||
close(): void;
|
||||
/** True once the reader cancelled the body (the stream was torn down). */
|
||||
cancelled(): boolean;
|
||||
}
|
||||
|
||||
export function testStream(): TestStream {
|
||||
const encoder = new TextEncoder();
|
||||
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
||||
let cancelled = false;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
controller = c;
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
}),
|
||||
push: (text) => controller.enqueue(encoder.encode(text)),
|
||||
close: () => controller.close(),
|
||||
cancelled: () => cancelled,
|
||||
};
|
||||
}
|
||||
|
||||
/** One SSE frame exactly as api/sse.py writes it. */
|
||||
export function frame(type: string, payload: unknown): string {
|
||||
return `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* One live transition stream per signed-in shell, fanned out to the screens.
|
||||
*
|
||||
* <TransitionsProvider> opens the stream on mount and aborts it on unmount;
|
||||
* useTransitions(handler) registers a screen's handler for as long as the
|
||||
* screen is mounted. Without a provider the hook does nothing, so a screen
|
||||
* rendered on its own (e.g. in a test) never opens a stream.
|
||||
*/
|
||||
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { openTransitionStream, type TransitionEvent } from "./events";
|
||||
|
||||
type Listener = (event: TransitionEvent) => void;
|
||||
|
||||
interface TransitionHub {
|
||||
subscribe(listener: Listener): () => void;
|
||||
}
|
||||
|
||||
const TransitionsContext = createContext<TransitionHub | null>(null);
|
||||
|
||||
export function TransitionsProvider({ children }: { children: ReactNode }) {
|
||||
const [hub] = useState(() => {
|
||||
const listeners = new Set<Listener>();
|
||||
return {
|
||||
listeners,
|
||||
subscribe(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
openTransitionStream({
|
||||
onTransition: (event) => {
|
||||
for (const listener of [...hub.listeners]) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {
|
||||
// One screen's handler must not tear down the shared stream.
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
[hub],
|
||||
);
|
||||
|
||||
return <TransitionsContext.Provider value={hub}>{children}</TransitionsContext.Provider>;
|
||||
}
|
||||
|
||||
/** Call `handler` for every transition while the calling component is mounted. */
|
||||
export function useTransitions(handler: Listener): void {
|
||||
const hub = useContext(TransitionsContext);
|
||||
const handlerRef = useRef(handler);
|
||||
useEffect(() => {
|
||||
handlerRef.current = handler;
|
||||
});
|
||||
useEffect(() => hub?.subscribe((event) => handlerRef.current(event)), [hub]);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildAuthorizeUrl,
|
||||
clearAuth,
|
||||
getToken,
|
||||
handleCallback,
|
||||
logout,
|
||||
storeTokens,
|
||||
} from "./oidc";
|
||||
import type { AppConfig } from "../config";
|
||||
|
||||
function testApp(): AppConfig {
|
||||
return {
|
||||
issuer: "https://auth.example.com/application/o/nestquest",
|
||||
clientId: "test-client-id",
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
|
||||
tokenEndpoint: "https://auth.example.com/application/o/token/",
|
||||
userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
|
||||
endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
|
||||
redirectUri: "http://localhost:5173/auth/callback",
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("OIDC flow", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("buildAuthorizeUrl carries all required params and stores state/verifier", async () => {
|
||||
const app = testApp();
|
||||
const url = new URL(await buildAuthorizeUrl(app));
|
||||
|
||||
expect(url.origin + url.pathname).toBe(app.authorizeEndpoint);
|
||||
expect(url.searchParams.get("response_type")).toBe("code");
|
||||
expect(url.searchParams.get("client_id")).toBe("test-client-id");
|
||||
expect(url.searchParams.get("redirect_uri")).toBe(app.redirectUri);
|
||||
expect(url.searchParams.get("scope")).toBe("openid email profile");
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(url.searchParams.get("state")).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
|
||||
expect(sessionStorage.getItem("nestquest.oidc_state")).toBe(
|
||||
url.searchParams.get("state"),
|
||||
);
|
||||
expect(sessionStorage.getItem("nestquest.code_verifier")).toMatch(/^[A-Za-z0-9-._~]{43,128}$/);
|
||||
});
|
||||
|
||||
it("handleCallback rejects a mismatched state", async () => {
|
||||
await buildAuthorizeUrl(testApp());
|
||||
sessionStorage.setItem("nestquest.oidc_state", "different-state");
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
await expect(
|
||||
handleCallback("?code=abc&state=tampered", testApp(), fetchMock as any),
|
||||
).rejects.toThrow(/state mismatch/i);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(getToken()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handleCallback exchanges the code with the correct form body and stores the token", async () => {
|
||||
await buildAuthorizeUrl(testApp());
|
||||
const verifier = sessionStorage.getItem("nestquest.code_verifier")!;
|
||||
const state = sessionStorage.getItem("nestquest.oidc_state")!;
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
access_token: "at-123",
|
||||
refresh_token: "rt-456",
|
||||
id_token: "id-789",
|
||||
expires_in: 300,
|
||||
}),
|
||||
);
|
||||
await handleCallback(`?code=abc&state=${state}`, testApp(), fetchMock as any);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://auth.example.com/application/o/token/");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
|
||||
|
||||
const body = new URLSearchParams(init.body);
|
||||
expect(body.get("grant_type")).toBe("authorization_code");
|
||||
expect(body.get("code")).toBe("abc");
|
||||
expect(body.get("redirect_uri")).toBe(testApp().redirectUri);
|
||||
expect(body.get("client_id")).toBe("test-client-id");
|
||||
expect(body.get("code_verifier")).toBe(verifier);
|
||||
expect(body.has("client_secret")).toBe(false);
|
||||
|
||||
expect(getToken()).toBe("at-123");
|
||||
// one-time values consumed
|
||||
expect(sessionStorage.getItem("nestquest.oidc_state")).toBeNull();
|
||||
expect(sessionStorage.getItem("nestquest.code_verifier")).toBeNull();
|
||||
});
|
||||
|
||||
it("handleCallback surfaces provider errors", async () => {
|
||||
await expect(
|
||||
handleCallback("?error=access_denied&error_description=nope", testApp(), vi.fn() as any),
|
||||
).rejects.toThrow(/nope|access_denied/);
|
||||
});
|
||||
|
||||
it("clearAuth removes all stored auth data", () => {
|
||||
storeTokens({ access_token: "a", refresh_token: "r", expires_in: 60 });
|
||||
clearAuth();
|
||||
expect(getToken()).toBeUndefined();
|
||||
expect(sessionStorage.length).toBe(0);
|
||||
});
|
||||
|
||||
it("logout clears auth and redirects to the end-session endpoint", async () => {
|
||||
storeTokens({ access_token: "a", id_token: "idt", expires_in: 60 });
|
||||
const assign = vi.fn();
|
||||
const original = window.location;
|
||||
vi.stubGlobal("location", { ...original, assign, origin: "http://localhost:5173" });
|
||||
|
||||
await logout(testApp());
|
||||
|
||||
expect(assign).toHaveBeenCalledTimes(1);
|
||||
const url = new URL(assign.mock.calls[0][0] as string);
|
||||
expect(url.toString()).toBe(
|
||||
"https://auth.example.com/application/o/nestquest/end-session/?id_token_hint=idt&client_id=test-client-id&post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A5173",
|
||||
);
|
||||
expect(getToken()).toBeUndefined();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("buildAuthorizeUrl resolves the authorize endpoint via discovery for a non-default issuer", async () => {
|
||||
const issuer = "https://sso.discovery.example/application/o/discovered";
|
||||
const discoveryDoc = {
|
||||
issuer,
|
||||
authorization_endpoint: "https://sso.discovery.example/application/o/authorize/",
|
||||
token_endpoint: "https://sso.discovery.example/application/o/token/",
|
||||
userinfo_endpoint: "https://sso.discovery.example/application/o/userinfo/",
|
||||
end_session_endpoint: `${issuer}/end-session/`,
|
||||
jwks_uri: `${issuer}/jwks/`,
|
||||
};
|
||||
const fetchMock = vi.fn().mockImplementation((url: string) =>
|
||||
Promise.resolve(
|
||||
String(url).endsWith("/.well-known/openid-configuration")
|
||||
? jsonResponse(discoveryDoc)
|
||||
: jsonResponse({}),
|
||||
),
|
||||
);
|
||||
const app: AppConfig = {
|
||||
issuer,
|
||||
clientId: "test-client-id",
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
redirectUri: "http://localhost:5173/auth/callback",
|
||||
};
|
||||
|
||||
const url = new URL(await buildAuthorizeUrl(app, fetchMock as any));
|
||||
|
||||
// The authorize endpoint comes from the discovery document — Authentik
|
||||
// serves it outside the app-specific issuer path, so it is NOT
|
||||
// `${issuer}/authorize/` and must never be a hardcoded production host.
|
||||
expect(url.origin + url.pathname).toBe(discoveryDoc.authorization_endpoint);
|
||||
expect(url.searchParams.get("client_id")).toBe("test-client-id");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe(
|
||||
`${issuer}/.well-known/openid-configuration`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* OIDC authorization-code + PKCE flow for a PUBLIC client.
|
||||
*
|
||||
* Never sends a client_secret anywhere. Tokens are stored in sessionStorage
|
||||
* (per-tab, cleared on tab close) — never localStorage.
|
||||
*/
|
||||
import { config, resolveEndpoints, type AppConfig, type ResolvedAppConfig } from "../config";
|
||||
import {
|
||||
computeCodeChallenge,
|
||||
generateCodeVerifier,
|
||||
generateState,
|
||||
} from "./pkce";
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
accessToken: "nestquest.access_token",
|
||||
refreshToken: "nestquest.refresh_token",
|
||||
expiresAt: "nestquest.expires_at",
|
||||
idToken: "nestquest.id_token",
|
||||
state: "nestquest.oidc_state",
|
||||
codeVerifier: "nestquest.code_verifier",
|
||||
} as const;
|
||||
|
||||
export class AuthError extends Error {}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
id_token?: string;
|
||||
expires_in?: number;
|
||||
token_type?: string;
|
||||
}
|
||||
|
||||
function makeScopes(): string {
|
||||
return "openid email profile";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Authentik authorization redirect URL, storing state + verifier in
|
||||
* sessionStorage so the callback can validate them. Endpoints are resolved
|
||||
* from the issuer's OIDC discovery document when not set on the config.
|
||||
*/
|
||||
export async function buildAuthorizeUrl(
|
||||
app: AppConfig = config,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<string> {
|
||||
if (!app.clientId) {
|
||||
throw new AuthError(
|
||||
"Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
|
||||
);
|
||||
}
|
||||
const resolved = await resolveEndpoints(app, fetchImpl);
|
||||
const state = generateState();
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = await computeCodeChallenge(codeVerifier);
|
||||
|
||||
sessionStorage.setItem(STORAGE_KEYS.state, state);
|
||||
sessionStorage.setItem(STORAGE_KEYS.codeVerifier, codeVerifier);
|
||||
|
||||
const url = new URL(resolved.authorizeEndpoint);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("client_id", app.clientId);
|
||||
url.searchParams.set("redirect_uri", app.redirectUri);
|
||||
url.searchParams.set("scope", makeScopes());
|
||||
url.searchParams.set("state", state);
|
||||
url.searchParams.set("code_challenge", codeChallenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the OAuth callback: validate state, exchange the code at the token
|
||||
* endpoint (PKCE, form-urlencoded, no client_secret), store tokens.
|
||||
*/
|
||||
export async function handleCallback(
|
||||
query: URLSearchParams | string = window.location.search,
|
||||
app: AppConfig = config,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const params =
|
||||
typeof query === "string" ? new URLSearchParams(query) : query;
|
||||
|
||||
const providerError = params.get("error");
|
||||
if (providerError) {
|
||||
throw new AuthError(
|
||||
params.get("error_description") || `Provider returned error: ${providerError}`,
|
||||
);
|
||||
}
|
||||
|
||||
const code = params.get("code");
|
||||
const state = params.get("state");
|
||||
if (!code || !state) {
|
||||
throw new AuthError("Callback is missing the code or state parameter.");
|
||||
}
|
||||
|
||||
const storedState = sessionStorage.getItem(STORAGE_KEYS.state);
|
||||
const codeVerifier = sessionStorage.getItem(STORAGE_KEYS.codeVerifier);
|
||||
if (!storedState || storedState !== state || !codeVerifier) {
|
||||
throw new AuthError("State mismatch — possible CSRF; refusing to log in.");
|
||||
}
|
||||
|
||||
if (!app.clientId) {
|
||||
throw new AuthError(
|
||||
"Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
|
||||
);
|
||||
}
|
||||
|
||||
const resolved: ResolvedAppConfig = await resolveEndpoints(app, fetchImpl);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: app.redirectUri,
|
||||
client_id: app.clientId,
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
|
||||
const response = await fetchImpl(resolved.tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new AuthError(`Token exchange failed with HTTP ${response.status}`);
|
||||
}
|
||||
const tokens = (await response.json()) as TokenResponse;
|
||||
if (!tokens.access_token) {
|
||||
throw new AuthError("Token response did not include an access token.");
|
||||
}
|
||||
|
||||
storeTokens(tokens);
|
||||
// One-time use values are consumed on success.
|
||||
sessionStorage.removeItem(STORAGE_KEYS.state);
|
||||
sessionStorage.removeItem(STORAGE_KEYS.codeVerifier);
|
||||
}
|
||||
|
||||
export function storeTokens(tokens: TokenResponse): void {
|
||||
sessionStorage.setItem(STORAGE_KEYS.accessToken, tokens.access_token!);
|
||||
if (tokens.refresh_token) {
|
||||
sessionStorage.setItem(STORAGE_KEYS.refreshToken, tokens.refresh_token);
|
||||
}
|
||||
if (tokens.id_token) {
|
||||
sessionStorage.setItem(STORAGE_KEYS.idToken, tokens.id_token);
|
||||
}
|
||||
if (typeof tokens.expires_in === "number") {
|
||||
sessionStorage.setItem(
|
||||
STORAGE_KEYS.expiresAt,
|
||||
String(Date.now() + tokens.expires_in * 1000),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken(): string | undefined {
|
||||
return sessionStorage.getItem(STORAGE_KEYS.accessToken) ?? undefined;
|
||||
}
|
||||
|
||||
export function getRefreshToken(): string | undefined {
|
||||
return sessionStorage.getItem(STORAGE_KEYS.refreshToken) ?? undefined;
|
||||
}
|
||||
|
||||
export function getIdToken(): string | undefined {
|
||||
return sessionStorage.getItem(STORAGE_KEYS.idToken) ?? undefined;
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return Boolean(getToken());
|
||||
}
|
||||
|
||||
/** True once the stored access token has passed its (approximate) expiry. */
|
||||
export function isExpired(): boolean {
|
||||
const expiresAt = sessionStorage.getItem(STORAGE_KEYS.expiresAt);
|
||||
if (!expiresAt) return false;
|
||||
return Date.now() >= Number(expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the access token with the refresh token grant. Returns false when
|
||||
* no refresh token is stored or the grant fails (caller should re-login).
|
||||
*/
|
||||
export async function refreshAccessToken(
|
||||
app: AppConfig = config,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<boolean> {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken || !app.clientId) return false;
|
||||
|
||||
const resolved = await resolveEndpoints(app, fetchImpl);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: app.clientId,
|
||||
});
|
||||
|
||||
const response = await fetchImpl(resolved.tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
const tokens = (await response.json()) as TokenResponse;
|
||||
if (!tokens.access_token) return false;
|
||||
storeTokens(tokens);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clearAuth(): void {
|
||||
for (const key of Object.values(STORAGE_KEYS)) {
|
||||
sessionStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sign out: clear local auth, then send the browser to the end-session endpoint. */
|
||||
export async function logout(
|
||||
app: AppConfig = config,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const resolved = await resolveEndpoints(app, fetchImpl);
|
||||
const idTokenHint = getIdToken();
|
||||
clearAuth();
|
||||
const params = new URLSearchParams();
|
||||
if (idTokenHint) params.set("id_token_hint", idTokenHint);
|
||||
if (app.clientId) params.set("client_id", app.clientId);
|
||||
params.set("post_logout_redirect_uri", window.location.origin);
|
||||
window.location.assign(`${resolved.endSessionEndpoint}?${params.toString()}`);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
base64UrlEncode,
|
||||
computeCodeChallenge,
|
||||
generateCodeVerifier,
|
||||
generateState,
|
||||
isLikelyValidVerifier,
|
||||
} from "./pkce";
|
||||
|
||||
describe("PKCE helpers", () => {
|
||||
it("base64UrlEncode has no padding and url-safe characters", () => {
|
||||
// bytes chosen to force "+" and "/" and padding in plain base64
|
||||
const bytes = new Uint8Array([0xfb, 0xff, 0xbf, 0xff]);
|
||||
const encoded = base64UrlEncode(bytes);
|
||||
expect(encoded).not.toMatch(/[+/=]/);
|
||||
});
|
||||
|
||||
it("computes the RFC 7636 appendix-B S256 vector", async () => {
|
||||
const verifier =
|
||||
"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
|
||||
const challenge = await computeCodeChallenge(verifier);
|
||||
expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
|
||||
});
|
||||
|
||||
it("generates verifiers with valid charset and length", () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const verifier = generateCodeVerifier();
|
||||
expect(isLikelyValidVerifier(verifier)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects verifiers outside 43-128 chars or with reserved chars", () => {
|
||||
expect(isLikelyValidVerifier("short")).toBe(false);
|
||||
expect(isLikelyValidVerifier("a".repeat(129))).toBe(false);
|
||||
expect(isLikelyValidVerifier("a".repeat(43) + "+")).toBe(false);
|
||||
});
|
||||
|
||||
it("generates distinct random states", () => {
|
||||
expect(generateState()).not.toBe(generateState());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* PKCE (RFC 7636) helpers for a public SPA client.
|
||||
*
|
||||
* Uses the Web Crypto API, which requires a SECURE CONTEXT: the app must be
|
||||
* served over https or http://localhost, otherwise crypto.subtle is
|
||||
* unavailable and login cannot work.
|
||||
*/
|
||||
|
||||
const VERIFIER_CHARSET =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
||||
|
||||
/** base64url-encode bytes with NO padding. */
|
||||
export function base64UrlEncode(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function randomBase64Url(byteLength: number): string {
|
||||
const bytes = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(bytes);
|
||||
return base64UrlEncode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Random code_verifier: 43-128 chars, unreserved charset only.
|
||||
* 32 random bytes -> 43 base64url chars, which is the minimum allowed length.
|
||||
*/
|
||||
export function generateCodeVerifier(): string {
|
||||
return randomBase64Url(32);
|
||||
}
|
||||
|
||||
/** S256 code_challenge = base64url(SHA-256(verifier)), no padding. */
|
||||
export async function computeCodeChallenge(verifier: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(verifier),
|
||||
);
|
||||
return base64UrlEncode(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
/** Random opaque state value for CSRF protection. */
|
||||
export function generateState(): string {
|
||||
return randomBase64Url(16);
|
||||
}
|
||||
|
||||
export function isLikelyValidVerifier(verifier: string): boolean {
|
||||
return (
|
||||
verifier.length >= 43 &&
|
||||
verifier.length <= 128 &&
|
||||
[...verifier].every((char) => VERIFIER_CHARSET.includes(char))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ConfigError,
|
||||
discoveryUrlForIssuer,
|
||||
readConfig,
|
||||
resolveEndpoints,
|
||||
resolveEndpointsFromDiscovery,
|
||||
type AppConfig,
|
||||
} from "./config";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Mirrors Authentik's layout: authorize/token/userinfo live OUTSIDE the
|
||||
// application-specific issuer path, end-session/jwks under it.
|
||||
function discoveryDocument(issuer: string) {
|
||||
const normalized = issuer.replace(/\/+$/, "");
|
||||
const origin = new URL(normalized).origin;
|
||||
return {
|
||||
issuer,
|
||||
authorization_endpoint: `${origin}/application/o/authorize/`,
|
||||
token_endpoint: `${origin}/application/o/token/`,
|
||||
userinfo_endpoint: `${origin}/application/o/userinfo/`,
|
||||
end_session_endpoint: `${normalized}/end-session/`,
|
||||
jwks_uri: `${normalized}/jwks/`,
|
||||
};
|
||||
}
|
||||
|
||||
describe("config", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("readConfig keeps the production issuer as the default", () => {
|
||||
const cfg = readConfig({});
|
||||
expect(cfg.issuer).toBe("https://auth.cubecraftlabs.com/application/o/nestquest");
|
||||
expect(cfg.clientId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("readConfig normalizes a trailing slash off a custom issuer", () => {
|
||||
expect(
|
||||
readConfig({ VITE_AUTHENTIK_ISSUER: "https://sso.custom.example/application/o/custom/" })
|
||||
.issuer,
|
||||
).toBe("https://sso.custom.example/application/o/custom");
|
||||
});
|
||||
|
||||
it("readConfig hardcodes no OIDC endpoints — they come from discovery", () => {
|
||||
const cfg = readConfig({});
|
||||
expect(cfg.authorizeEndpoint).toBeUndefined();
|
||||
expect(cfg.tokenEndpoint).toBeUndefined();
|
||||
expect(cfg.userinfoEndpoint).toBeUndefined();
|
||||
expect(cfg.endSessionEndpoint).toBeUndefined();
|
||||
expect(cfg.jwksUri).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds the discovery URL for issuers with and without a trailing slash", () => {
|
||||
expect(discoveryUrlForIssuer("https://sso.example.com/application/o/app/")).toBe(
|
||||
"https://sso.example.com/application/o/app/.well-known/openid-configuration",
|
||||
);
|
||||
expect(discoveryUrlForIssuer("https://sso.example.com/application/o/app")).toBe(
|
||||
"https://sso.example.com/application/o/app/.well-known/openid-configuration",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves every endpoint from the discovery document of a non-default issuer", async () => {
|
||||
const issuer = "https://sso.other-host.example/application/o/other-app";
|
||||
const doc = discoveryDocument(issuer);
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
|
||||
const app: AppConfig = {
|
||||
issuer,
|
||||
clientId: "client-id",
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
redirectUri: "http://localhost:5173/auth/callback",
|
||||
};
|
||||
|
||||
const resolved = await resolveEndpoints(app, fetchMock as unknown as typeof fetch);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://sso.other-host.example/application/o/other-app/.well-known/openid-configuration",
|
||||
);
|
||||
// Every endpoint comes verbatim from the (non-default) discovery document.
|
||||
expect(resolved.authorizeEndpoint).toBe(doc.authorization_endpoint);
|
||||
expect(resolved.tokenEndpoint).toBe(doc.token_endpoint);
|
||||
expect(resolved.userinfoEndpoint).toBe(doc.userinfo_endpoint);
|
||||
expect(resolved.endSessionEndpoint).toBe(doc.end_session_endpoint);
|
||||
expect(resolved.jwksUri).toBe(doc.jwks_uri);
|
||||
// Proves no hardcoded production host leaked into the resolved endpoints.
|
||||
for (const endpoint of [
|
||||
resolved.authorizeEndpoint,
|
||||
resolved.tokenEndpoint,
|
||||
resolved.userinfoEndpoint,
|
||||
resolved.endSessionEndpoint,
|
||||
resolved.jwksUri,
|
||||
]) {
|
||||
expect(endpoint).not.toMatch(/cubecraftlabs/);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves discovery for an issuer configured with a trailing slash", async () => {
|
||||
const issuer = "https://sso.slashy.example/application/o/slashy/";
|
||||
const doc = discoveryDocument(issuer);
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
|
||||
|
||||
const resolved = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://sso.slashy.example/application/o/slashy/.well-known/openid-configuration",
|
||||
);
|
||||
expect(resolved.authorizeEndpoint).toBe(doc.authorization_endpoint);
|
||||
expect(resolved.endSessionEndpoint).toBe(doc.end_session_endpoint);
|
||||
});
|
||||
|
||||
it("caches discovery per issuer for the session", async () => {
|
||||
const issuer = "https://sso.cached.example/application/o/cached";
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(discoveryDocument(issuer)));
|
||||
|
||||
const first = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
|
||||
const second = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("surfaces a clear configuration error when discovery returns an HTTP error", async () => {
|
||||
const issuer = "https://sso.http-error.example/application/o/broken";
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 500 }));
|
||||
|
||||
const error = await resolveEndpointsFromDiscovery(issuer, fetchMock as any).then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigError);
|
||||
expect((error as Error).message).toContain(issuer);
|
||||
expect((error as Error).message).toContain("HTTP 500");
|
||||
});
|
||||
|
||||
it("surfaces a clear configuration error when discovery is unreachable", async () => {
|
||||
const issuer = "https://sso.unreachable.example/application/o/unreachable";
|
||||
const fetchMock = vi.fn().mockRejectedValue(new TypeError("network down"));
|
||||
|
||||
await expect(
|
||||
resolveEndpointsFromDiscovery(issuer, fetchMock as any),
|
||||
).rejects.toBeInstanceOf(ConfigError);
|
||||
});
|
||||
|
||||
it("surfaces a clear configuration error when the discovery document is incomplete", async () => {
|
||||
const issuer = "https://sso.incomplete.example/application/o/incomplete";
|
||||
const doc = discoveryDocument(issuer) as Record<string, string>;
|
||||
delete doc.end_session_endpoint;
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
|
||||
|
||||
await expect(
|
||||
resolveEndpointsFromDiscovery(issuer, fetchMock as any),
|
||||
).rejects.toThrow(/end_session_endpoint/);
|
||||
});
|
||||
|
||||
it("rejects a discovery document advertising a different issuer", async () => {
|
||||
const issuer = "https://sso.mismatch.example/application/o/mismatch";
|
||||
const doc = {
|
||||
...discoveryDocument(issuer),
|
||||
issuer: "https://sso.elsewhere.example/application/o/elsewhere",
|
||||
};
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
|
||||
|
||||
await expect(
|
||||
resolveEndpointsFromDiscovery(issuer, fetchMock as any),
|
||||
).rejects.toThrow(/issuer mismatch/i);
|
||||
});
|
||||
|
||||
it("the bundled config resolves its endpoints from discovery at runtime", async () => {
|
||||
const issuer = "https://sso.bundled.example/application/o/bundled";
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(discoveryDocument(issuer)));
|
||||
const bundled = readConfig({ VITE_AUTHENTIK_ISSUER: `${issuer}/`, VITE_AUTHENTIK_CLIENT_ID: "bundled-client" });
|
||||
expect(bundled.authorizeEndpoint).toBeUndefined();
|
||||
|
||||
const resolved = await resolveEndpoints(bundled, fetchMock as any);
|
||||
|
||||
expect(resolved.authorizeEndpoint).toBe(
|
||||
"https://sso.bundled.example/application/o/authorize/",
|
||||
);
|
||||
expect(resolved.tokenEndpoint).toBe("https://sso.bundled.example/application/o/token/");
|
||||
expect(resolved.userinfoEndpoint).toBe(
|
||||
"https://sso.bundled.example/application/o/userinfo/",
|
||||
);
|
||||
expect(resolved.endSessionEndpoint).toBe(
|
||||
"https://sso.bundled.example/application/o/bundled/end-session/",
|
||||
);
|
||||
expect(resolved.jwksUri).toBe("https://sso.bundled.example/application/o/bundled/jwks/");
|
||||
});
|
||||
|
||||
it("does not re-fetch discovery when the config already carries endpoints", async () => {
|
||||
const app: AppConfig = {
|
||||
issuer: "https://auth.example.com/application/o/nestquest",
|
||||
clientId: "client-id",
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
|
||||
tokenEndpoint: "https://auth.example.com/application/o/token/",
|
||||
userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
|
||||
endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
|
||||
redirectUri: "http://localhost:5173/auth/callback",
|
||||
};
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
const resolved = await resolveEndpoints(app, fetchMock as any);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(resolved).toBe(app);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Runtime configuration, read from Vite env (import.meta.env).
|
||||
* See .env.example for the variables and their defaults.
|
||||
*
|
||||
* OIDC endpoints are NOT hardcoded here. They are resolved lazily from the
|
||||
* issuer's OIDC discovery document (see resolveEndpoints), because Authentik
|
||||
* serves authorize/token/userinfo OUTSIDE the application-specific issuer
|
||||
* path (issuer is /application/o/<slug>/, authorize is /application/o/authorize/).
|
||||
* Both hardcoding the production host and naively concatenating onto the
|
||||
* issuer would break non-default issuers; discovery is the only correct way.
|
||||
*/
|
||||
|
||||
export interface AppConfig {
|
||||
issuer: string;
|
||||
clientId: string | undefined;
|
||||
apiBaseUrl: string;
|
||||
/** Resolved from the issuer's OIDC discovery document — see resolveEndpoints(). */
|
||||
authorizeEndpoint?: string;
|
||||
tokenEndpoint?: string;
|
||||
userinfoEndpoint?: string;
|
||||
endSessionEndpoint?: string;
|
||||
jwksUri?: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
/** AppConfig whose OIDC endpoints have been filled in by resolveEndpoints(). */
|
||||
export type ResolvedAppConfig = AppConfig &
|
||||
Required<
|
||||
Pick<
|
||||
AppConfig,
|
||||
"authorizeEndpoint" | "tokenEndpoint" | "userinfoEndpoint" | "endSessionEndpoint"
|
||||
>
|
||||
>;
|
||||
|
||||
export interface DiscoveredEndpoints {
|
||||
issuer: string;
|
||||
authorizeEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
userinfoEndpoint: string;
|
||||
endSessionEndpoint: string;
|
||||
jwksUri: string;
|
||||
}
|
||||
|
||||
/** Raised when OIDC discovery fails or the configuration is unusable. */
|
||||
export class ConfigError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ConfigError";
|
||||
}
|
||||
}
|
||||
|
||||
const REQUIRED_ENDPOINT_NAMES = [
|
||||
"authorizeEndpoint",
|
||||
"tokenEndpoint",
|
||||
"userinfoEndpoint",
|
||||
"endSessionEndpoint",
|
||||
] as const;
|
||||
|
||||
export function readConfig(env: Record<string, string | undefined> = import.meta.env as any): AppConfig {
|
||||
const issuer = (
|
||||
env.VITE_AUTHENTIK_ISSUER || "https://auth.cubecraftlabs.com/application/o/nestquest/"
|
||||
).replace(/\/$/, "");
|
||||
const clientId = env.VITE_AUTHENTIK_CLIENT_ID;
|
||||
const apiBaseUrl = env.VITE_API_BASE_URL || "https://nestquest.cubecraftlabs.com";
|
||||
|
||||
return {
|
||||
issuer,
|
||||
// No default: a missing client id must surface as a configuration error
|
||||
// screen, not a broken redirect to Authentik.
|
||||
clientId: clientId || undefined,
|
||||
apiBaseUrl,
|
||||
// authorize/token/userinfo/end-session endpoints are resolved from the
|
||||
// issuer's discovery document at runtime — never hardcoded here.
|
||||
redirectUri: `${window.location.origin}/auth/callback`,
|
||||
};
|
||||
}
|
||||
|
||||
export const config: AppConfig = readConfig();
|
||||
|
||||
interface DiscoveryDocument {
|
||||
issuer?: unknown;
|
||||
authorization_endpoint?: unknown;
|
||||
token_endpoint?: unknown;
|
||||
userinfo_endpoint?: unknown;
|
||||
end_session_endpoint?: unknown;
|
||||
jwks_uri?: unknown;
|
||||
}
|
||||
|
||||
function normalizeIssuer(issuer: string): string {
|
||||
return issuer.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/** Discovery document location for an issuer, tolerant of a trailing slash. */
|
||||
export function discoveryUrlForIssuer(issuer: string): string {
|
||||
return `${normalizeIssuer(issuer)}/.well-known/openid-configuration`;
|
||||
}
|
||||
|
||||
async function fetchDiscoveredEndpoints(
|
||||
issuer: string,
|
||||
fetchImpl: typeof fetch,
|
||||
): Promise<DiscoveredEndpoints> {
|
||||
const normalizedIssuer = normalizeIssuer(issuer);
|
||||
const discoveryUrl = `${normalizedIssuer}/.well-known/openid-configuration`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(discoveryUrl);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new ConfigError(
|
||||
`OIDC discovery request to ${discoveryUrl} failed (${reason}). ` +
|
||||
"Check VITE_AUTHENTIK_ISSUER and network reachability.",
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ConfigError(
|
||||
`OIDC discovery failed: ${discoveryUrl} returned HTTP ${response.status}. ` +
|
||||
"Check VITE_AUTHENTIK_ISSUER.",
|
||||
);
|
||||
}
|
||||
|
||||
let doc: DiscoveryDocument;
|
||||
try {
|
||||
doc = (await response.json()) as DiscoveryDocument;
|
||||
} catch {
|
||||
throw new ConfigError(
|
||||
`OIDC discovery document at ${discoveryUrl} is not valid JSON. Check VITE_AUTHENTIK_ISSUER.`,
|
||||
);
|
||||
}
|
||||
|
||||
const authorizationEndpoint = nonEmptyString(doc.authorization_endpoint);
|
||||
const tokenEndpoint = nonEmptyString(doc.token_endpoint);
|
||||
const userinfoEndpoint = nonEmptyString(doc.userinfo_endpoint);
|
||||
const endSessionEndpoint = nonEmptyString(doc.end_session_endpoint);
|
||||
const jwksUri = nonEmptyString(doc.jwks_uri);
|
||||
const discoveredIssuer = nonEmptyString(doc.issuer);
|
||||
|
||||
const endpointFields: Array<[string, string | undefined]> = [
|
||||
["authorization_endpoint", authorizationEndpoint],
|
||||
["token_endpoint", tokenEndpoint],
|
||||
["userinfo_endpoint", userinfoEndpoint],
|
||||
["end_session_endpoint", endSessionEndpoint],
|
||||
["jwks_uri", jwksUri],
|
||||
];
|
||||
const missing = endpointFields.filter(([, value]) => !value).map(([name]) => name);
|
||||
if (missing.length > 0) {
|
||||
throw new ConfigError(
|
||||
`OIDC discovery document at ${discoveryUrl} is missing: ${missing.join(", ")}. ` +
|
||||
"The issuer must point at an OIDC provider that publishes these endpoints.",
|
||||
);
|
||||
}
|
||||
|
||||
if (discoveredIssuer && normalizeIssuer(discoveredIssuer) !== normalizedIssuer) {
|
||||
throw new ConfigError(
|
||||
`OIDC discovery issuer mismatch: ${discoveryUrl} advertises "${discoveredIssuer}" ` +
|
||||
`but VITE_AUTHENTIK_ISSUER is "${normalizedIssuer}".`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
issuer: normalizedIssuer,
|
||||
authorizeEndpoint: authorizationEndpoint!,
|
||||
tokenEndpoint: tokenEndpoint!,
|
||||
userinfoEndpoint: userinfoEndpoint!,
|
||||
endSessionEndpoint: endSessionEndpoint!,
|
||||
jwksUri: jwksUri!,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Session cache: one discovery fetch per issuer per SPA session. Failed
|
||||
* lookups are evicted so a transient network error can be retried instead of
|
||||
* poisoning the whole session.
|
||||
*/
|
||||
const discoveryCache = new Map<string, Promise<DiscoveredEndpoints>>();
|
||||
|
||||
export function resolveEndpointsFromDiscovery(
|
||||
issuer: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<DiscoveredEndpoints> {
|
||||
const normalizedIssuer = normalizeIssuer(issuer);
|
||||
const cached = discoveryCache.get(normalizedIssuer);
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = fetchDiscoveredEndpoints(normalizedIssuer, fetchImpl);
|
||||
discoveryCache.set(normalizedIssuer, pending);
|
||||
pending.catch(() => {
|
||||
discoveryCache.delete(normalizedIssuer);
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `app` with all OIDC endpoints filled in. When the config already
|
||||
* carries them (pre-resolved config, tests) this is a no-op; otherwise the
|
||||
* issuer's discovery document is fetched (cached for the session) and its
|
||||
* authorization/token/userinfo/end-session/jwks endpoints are used.
|
||||
*/
|
||||
export async function resolveEndpoints(
|
||||
app: AppConfig,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ResolvedAppConfig> {
|
||||
const missing = REQUIRED_ENDPOINT_NAMES.filter((name) => !app[name]);
|
||||
if (missing.length === 0) {
|
||||
return app as ResolvedAppConfig;
|
||||
}
|
||||
|
||||
const discovered = await resolveEndpointsFromDiscovery(app.issuer, fetchImpl);
|
||||
return {
|
||||
...app,
|
||||
authorizeEndpoint: discovered.authorizeEndpoint,
|
||||
tokenEndpoint: discovered.tokenEndpoint,
|
||||
userinfoEndpoint: discovered.userinfoEndpoint,
|
||||
endSessionEndpoint: discovered.endSessionEndpoint,
|
||||
jwksUri: discovered.jwksUri,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
/* Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2).
|
||||
Mobile-first at the 390x844 reference; the column is capped and centred wider. */
|
||||
|
||||
.defs-screen {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* §3.1 header */
|
||||
.defs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: var(--nq-a-surface);
|
||||
border-bottom: 1px solid var(--nq-a-border);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.defs-title {
|
||||
margin: 0;
|
||||
font-size: var(--nq-a-size-screen);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.defs-sub {
|
||||
margin: 2px 0 0;
|
||||
min-height: 1.4em;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-new {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-brand-gradient);
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-new:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
|
||||
.defs-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nq-a-density-gap);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
/* Filter chips */
|
||||
.defs-chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.defs-chip {
|
||||
flex: none;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-radius-pill);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-chip--active {
|
||||
border-color: #18181d;
|
||||
background: #18181d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Rows */
|
||||
.defs-card {
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.defs-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.defs-list > li + li {
|
||||
border-top: 1px solid var(--nq-a-divider);
|
||||
}
|
||||
|
||||
.defs-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: var(--nq-a-density-row-pad);
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-row-glyph {
|
||||
flex: none;
|
||||
display: flex;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-row-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.defs-row-title {
|
||||
font-size: var(--nq-a-size-row);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.defs-row-meta {
|
||||
font-size: var(--nq-a-size-meta);
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-row-chevron {
|
||||
flex: none;
|
||||
display: flex;
|
||||
color: var(--nq-a-ink-tertiary);
|
||||
}
|
||||
|
||||
/* An inactive definition renders the whole row tertiary (never hard-deleted). */
|
||||
.defs-row--inactive,
|
||||
.defs-row--inactive .defs-row-glyph,
|
||||
.defs-row--inactive .defs-row-meta {
|
||||
color: var(--nq-a-ink-tertiary);
|
||||
}
|
||||
|
||||
.defs-row--editing {
|
||||
background: var(--nq-a-selected);
|
||||
}
|
||||
|
||||
.defs-row--editing .defs-row-glyph {
|
||||
color: var(--nq-brand-blue);
|
||||
}
|
||||
|
||||
.defs-empty {
|
||||
padding: var(--nq-a-density-card-pad);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
/* Loading / error */
|
||||
.defs-status {
|
||||
padding: 24px 13px;
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
text-align: center;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-status--error {
|
||||
color: var(--nq-a-danger-strong);
|
||||
}
|
||||
|
||||
.defs-status--error p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.defs-retry {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* §3.2 edit sheet */
|
||||
.defs-sheet-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.defs-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(9, 9, 11, 0.4);
|
||||
animation: defs-fade var(--nq-dur-modal) var(--nq-ease-out);
|
||||
}
|
||||
|
||||
.defs-sheet {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
max-height: 92%;
|
||||
margin: 0 auto;
|
||||
overflow-y: auto;
|
||||
padding: 14px 18px 24px;
|
||||
border-radius: var(--nq-a-radius-sheet);
|
||||
background: var(--nq-a-surface);
|
||||
box-shadow: var(--nq-a-sheet-shadow);
|
||||
animation: defs-rise var(--nq-dur-modal) var(--nq-ease-out);
|
||||
}
|
||||
|
||||
@keyframes defs-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes defs-rise {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.defs-scrim,
|
||||
.defs-sheet {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.defs-grabber {
|
||||
width: 38px;
|
||||
height: 4px;
|
||||
margin: 0 auto 12px;
|
||||
border-radius: var(--nq-a-radius-pill);
|
||||
background: var(--nq-a-border);
|
||||
}
|
||||
|
||||
.defs-sheet-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.defs-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.defs-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.defs-label {
|
||||
font-size: var(--nq-a-size-label);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--nq-a-track-label);
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-input {
|
||||
width: 100%;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: 0 11px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.defs-select-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: 0 11px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-picker {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
}
|
||||
|
||||
.defs-picker > li + li {
|
||||
border-top: 1px solid var(--nq-a-divider);
|
||||
}
|
||||
|
||||
.defs-picker-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: 0 11px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-picker-check {
|
||||
display: flex;
|
||||
color: var(--nq-brand-blue);
|
||||
}
|
||||
|
||||
.defs-picker-option--locked {
|
||||
color: var(--nq-a-ink-secondary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.defs-picker-note {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.defs-repeats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.defs-interval {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-interval .defs-input {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.defs-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.defs-day,
|
||||
.defs-segment {
|
||||
min-height: var(--nq-a-tap-min);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-day--on,
|
||||
.defs-segment--on {
|
||||
border-color: transparent;
|
||||
background: var(--nq-brand-gradient);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.defs-segmented {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.defs-icon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(var(--nq-a-tap-min), 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.defs-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-icon--on {
|
||||
border-color: transparent;
|
||||
background: var(--nq-brand-gradient);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.defs-due-times {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.defs-due {
|
||||
display: grid;
|
||||
grid-template-columns: 84px 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.defs-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.defs-toggle-title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.defs-toggle-sub {
|
||||
font-size: var(--nq-a-size-meta);
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-switch {
|
||||
flex: none;
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--nq-a-radius-pill);
|
||||
background: var(--nq-a-border);
|
||||
cursor: pointer;
|
||||
transition: background var(--nq-dur-base) var(--nq-ease-out);
|
||||
}
|
||||
|
||||
.defs-switch--on {
|
||||
background: var(--nq-brand-gradient);
|
||||
}
|
||||
|
||||
.defs-switch-knob {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(9, 9, 11, 0.25);
|
||||
transition: transform var(--nq-dur-base) var(--nq-ease-out);
|
||||
}
|
||||
|
||||
.defs-switch--on .defs-switch-knob {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.defs-sheet-error {
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-danger-bg);
|
||||
color: var(--nq-a-danger-strong);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.defs-sheet-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.defs-confirm {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-danger-bg);
|
||||
color: var(--nq-a-danger-strong);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.defs-confirm p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.defs-confirm .defs-sheet-footer {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.defs-cancel,
|
||||
.defs-save {
|
||||
padding: 13px 0;
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-cancel {
|
||||
flex: 1;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
}
|
||||
|
||||
.defs-save {
|
||||
flex: 2;
|
||||
border: 0;
|
||||
background: var(--nq-brand-gradient);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.defs-cancel:disabled,
|
||||
.defs-save:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.defs-preview {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.defs-preview--error {
|
||||
color: var(--nq-a-ink-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,968 @@
|
||||
/**
|
||||
* Definitions ("Tasks") tab — list + edit sheet with icon picker
|
||||
* (design/ADMIN-SPEC.md §3.1–3.3).
|
||||
*/
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { ApiForbiddenError } from "../api/client";
|
||||
import {
|
||||
ApiRequestError,
|
||||
createDefinition,
|
||||
fetchChildren,
|
||||
fetchDefinitions,
|
||||
previewOccurrences,
|
||||
updateDefinition,
|
||||
type AdminChild,
|
||||
type DefinitionCreateBody,
|
||||
type DefinitionEditBody,
|
||||
type DefinitionRule,
|
||||
type QuestDefinition,
|
||||
type WindowEntry,
|
||||
type WindowName,
|
||||
} from "../api/definitions";
|
||||
import {
|
||||
MONTH_SHORT,
|
||||
WEEKDAY_SHORT,
|
||||
WINDOW_LABELS,
|
||||
WINDOW_ORDER,
|
||||
assigneeSummary,
|
||||
occurrenceLabel,
|
||||
recurrenceSummary,
|
||||
windowSummary,
|
||||
} from "./format";
|
||||
import {
|
||||
CheckGlyph,
|
||||
ChevronDownGlyph,
|
||||
ChevronRightGlyph,
|
||||
PlusGlyph,
|
||||
TaskGlyph,
|
||||
} from "./glyphs";
|
||||
import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons";
|
||||
import "./DefinitionsTab.css";
|
||||
|
||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||
export const SCROLL_BOTTOM_PADDING = "92px";
|
||||
|
||||
type Repeats = "daily" | "weekly" | "monthly" | "yearly" | "custom";
|
||||
|
||||
const REPEATS_OPTIONS: { value: Repeats; label: string }[] = [
|
||||
{ value: "daily", label: "Daily" },
|
||||
{ value: "weekly", label: "Weekly" },
|
||||
{ value: "monthly", label: "Monthly" },
|
||||
{ value: "yearly", label: "Yearly" },
|
||||
{ value: "custom", label: "Custom" },
|
||||
];
|
||||
|
||||
const REPEATS_BY_RULE: Record<DefinitionRule["rule_type"], Repeats> = {
|
||||
daily: "daily",
|
||||
weekly: "weekly",
|
||||
monthly_day: "monthly",
|
||||
monthly_weekday: "monthly",
|
||||
yearly: "yearly",
|
||||
custom_days: "custom",
|
||||
};
|
||||
|
||||
/** How many upcoming dates the edit sheet previews. */
|
||||
const PREVIEW_COUNT = 5;
|
||||
|
||||
/** Quiet period after the last rule edit before the preview is requested. */
|
||||
const PREVIEW_DEBOUNCE_MS = 300;
|
||||
|
||||
const WEEKDAY_LONG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
||||
|
||||
interface Draft {
|
||||
title: string;
|
||||
/** A Lucide name; a legacy value outside the subset is kept until replaced. */
|
||||
icon: string | null;
|
||||
assigneeIds: number[];
|
||||
repeats: Repeats;
|
||||
interval: string;
|
||||
weekdays: number[];
|
||||
dayOfMonth: string;
|
||||
month: string;
|
||||
/** An existing "nth weekday" monthly rule, kept while Repeats stays Monthly. */
|
||||
monthlyWeekday: { nth: number; weekday: number } | null;
|
||||
windows: WindowName[];
|
||||
dueTimes: Record<WindowName, string>;
|
||||
skipOnAway: boolean;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
}
|
||||
|
||||
type LoadState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; definitions: QuestDefinition[]; children: AdminChild[] };
|
||||
|
||||
/** `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();
|
||||
return {
|
||||
title: "",
|
||||
icon: null,
|
||||
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),
|
||||
monthlyWeekday: null,
|
||||
windows: [],
|
||||
dueTimes: emptyDueTimes(),
|
||||
// Mirrors the API's create default: no instance on an away day.
|
||||
skipOnAway: true,
|
||||
startDate: localIsoDate(today),
|
||||
endDate: null,
|
||||
};
|
||||
}
|
||||
|
||||
function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): Draft {
|
||||
const { rule } = definition;
|
||||
const today = new Date();
|
||||
const active = new Set(activeChildren.map((child) => child.id));
|
||||
const dueTimes = emptyDueTimes();
|
||||
for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? "";
|
||||
return {
|
||||
title: definition.title,
|
||||
icon: definition.icon,
|
||||
// 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],
|
||||
interval: String(rule.interval),
|
||||
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),
|
||||
monthlyWeekday:
|
||||
rule.rule_type === "monthly_weekday"
|
||||
? { nth: rule.nth_weekday ?? 1, weekday: rule.nth_weekday_weekday ?? 0 }
|
||||
: null,
|
||||
windows: WINDOW_ORDER.filter((w) => definition.windows.some((d) => d.window === w)),
|
||||
dueTimes,
|
||||
skipOnAway: definition.skip_on_away,
|
||||
startDate: rule.start_date,
|
||||
endDate: rule.end_date,
|
||||
};
|
||||
}
|
||||
|
||||
function parseWhole(value: string, min: number, max: number): number | null {
|
||||
if (!/^\d+$/.test(value.trim())) return null;
|
||||
const n = Number(value);
|
||||
return n >= min && n <= max ? n : null;
|
||||
}
|
||||
|
||||
/** Build the request rule from the draft, or return a message naming what to fix. */
|
||||
function buildRule(draft: Draft): DefinitionRule | string {
|
||||
const interval = parseWhole(draft.interval, 1, 999);
|
||||
if (interval === null) return "Interval must be a whole number of at least 1.";
|
||||
const rule: DefinitionRule = {
|
||||
rule_type: "daily",
|
||||
interval,
|
||||
weekday_set: null,
|
||||
day_of_month: null,
|
||||
nth_weekday: null,
|
||||
nth_weekday_weekday: null,
|
||||
month: null,
|
||||
start_date: draft.startDate,
|
||||
end_date: draft.endDate,
|
||||
};
|
||||
switch (draft.repeats) {
|
||||
case "daily":
|
||||
return rule;
|
||||
case "weekly":
|
||||
case "custom":
|
||||
if (draft.weekdays.length === 0) return "Pick at least one day.";
|
||||
return {
|
||||
...rule,
|
||||
rule_type: draft.repeats === "weekly" ? "weekly" : "custom_days",
|
||||
weekday_set: [...draft.weekdays].sort((a, b) => a - b),
|
||||
};
|
||||
case "monthly": {
|
||||
if (draft.monthlyWeekday) {
|
||||
return {
|
||||
...rule,
|
||||
rule_type: "monthly_weekday",
|
||||
nth_weekday: draft.monthlyWeekday.nth,
|
||||
nth_weekday_weekday: draft.monthlyWeekday.weekday,
|
||||
};
|
||||
}
|
||||
const day = parseWhole(draft.dayOfMonth, 1, 31);
|
||||
if (day === null) return "Day of month must be 1 to 31.";
|
||||
return { ...rule, rule_type: "monthly_day", day_of_month: day };
|
||||
}
|
||||
case "yearly": {
|
||||
const month = parseWhole(draft.month, 1, 12);
|
||||
const day = parseWhole(draft.dayOfMonth, 1, 31);
|
||||
if (month === null || day === null) return "Pick a month and a day (1 to 31).";
|
||||
return { ...rule, rule_type: "yearly", month, day_of_month: day };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `keepsAssignees`: an edit whose active selection is unchanged sends no
|
||||
* assignee list, so an empty selection is fine when inactive children remain.
|
||||
*/
|
||||
function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody | string {
|
||||
const title = draft.title.trim();
|
||||
if (!title) return "Give the task a title.";
|
||||
if (draft.assigneeIds.length === 0 && !keepsAssignees) return "Assign at least one child.";
|
||||
if (draft.windows.length === 0) return "Pick at least one window.";
|
||||
const rule = buildRule(draft);
|
||||
if (typeof rule === "string") return rule;
|
||||
const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map(
|
||||
(w) => [w, draft.dueTimes[w] || null],
|
||||
);
|
||||
return {
|
||||
title,
|
||||
icon: draft.icon,
|
||||
rule,
|
||||
assignee_child_ids: [...draft.assigneeIds],
|
||||
windows,
|
||||
skip_on_away: draft.skipOnAway,
|
||||
};
|
||||
}
|
||||
|
||||
function saveErrorMessage(error: unknown): string {
|
||||
if (error instanceof ApiForbiddenError) return error.message;
|
||||
if (error instanceof ApiRequestError && error.detail) return error.detail;
|
||||
return "The task could not be saved. Check your connection and try again.";
|
||||
}
|
||||
|
||||
function toggle<T>(list: T[], value: T): T[] {
|
||||
return list.includes(value) ? list.filter((item) => item !== value) : [...list, value];
|
||||
}
|
||||
|
||||
function sameIds(a: number[], b: number[]): boolean {
|
||||
return a.length === b.length && a.every((id) => b.includes(id));
|
||||
}
|
||||
|
||||
const FOCUSABLE =
|
||||
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
/* ── List ───────────────────────────────────────────────────────────── */
|
||||
|
||||
function FilterChips({
|
||||
options,
|
||||
filter,
|
||||
onChange,
|
||||
}: {
|
||||
options: AdminChild[];
|
||||
filter: number | "all";
|
||||
onChange: (filter: number | "all") => void;
|
||||
}) {
|
||||
const chips: { key: number | "all"; label: string }[] = [
|
||||
{ key: "all", label: "All" },
|
||||
...options.map((child) => ({ key: child.id, label: child.display_name })),
|
||||
];
|
||||
return (
|
||||
<div className="defs-chips" role="group" aria-label="Filter by child">
|
||||
{chips.map((chip) => (
|
||||
<button
|
||||
key={chip.key}
|
||||
type="button"
|
||||
className={chip.key === filter ? "defs-chip defs-chip--active" : "defs-chip"}
|
||||
aria-pressed={chip.key === filter}
|
||||
onClick={() => onChange(chip.key)}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DefinitionRow({
|
||||
definition,
|
||||
activeChildren,
|
||||
editing,
|
||||
onOpen,
|
||||
}: {
|
||||
definition: QuestDefinition;
|
||||
activeChildren: AdminChild[];
|
||||
editing: boolean;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
let meta = [
|
||||
assigneeSummary(definition.assignees, activeChildren),
|
||||
recurrenceSummary(definition.rule),
|
||||
windowSummary(definition.windows),
|
||||
].join(" · ");
|
||||
if (!definition.is_active) meta += " · inactive";
|
||||
let className = "defs-row";
|
||||
if (!definition.is_active) className += " defs-row--inactive";
|
||||
if (editing) className += " defs-row--editing";
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
data-testid={`definition-${definition.id}`}
|
||||
aria-expanded={editing}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<span className="defs-row-glyph">
|
||||
<DefinitionIcon name={definition.icon} />
|
||||
</span>
|
||||
<span className="defs-row-text">
|
||||
<span className="defs-row-title">{definition.title}</span>
|
||||
<span className="defs-row-meta">{editing ? "Editing" : meta}</span>
|
||||
</span>
|
||||
<span className="defs-row-chevron">
|
||||
{editing ? <ChevronDownGlyph /> : <ChevronRightGlyph />}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Edit sheet ─────────────────────────────────────────────────────── */
|
||||
|
||||
type PreviewState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "ready"; ruleKey: string; dates: string[] }
|
||||
| { kind: "error"; ruleKey: string; message: string };
|
||||
|
||||
/**
|
||||
* The next few dates the backend would materialize for the draft's rule.
|
||||
* `ruleKey` is the built rule serialized (null while the draft is invalid), so
|
||||
* the request only goes out when the rule itself changes — debounced, and a
|
||||
* superseded request's late response is dropped by its effect's cleanup.
|
||||
* A result is tagged with the rule it answers and only shown while that rule
|
||||
* is still the draft's, so a changed rule never shows the previous dates.
|
||||
*/
|
||||
function OccurrencePreview({ ruleKey }: { ruleKey: string | null }) {
|
||||
const [preview, setPreview] = useState<PreviewState>({ kind: "idle" });
|
||||
|
||||
useEffect(() => {
|
||||
if (ruleKey === null) {
|
||||
setPreview({ kind: "idle" });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
previewOccurrences(JSON.parse(ruleKey) as DefinitionRule, { count: PREVIEW_COUNT })
|
||||
.then((dates) => {
|
||||
if (!cancelled) setPreview({ kind: "ready", ruleKey, dates });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
const detail =
|
||||
error instanceof ApiForbiddenError ||
|
||||
(error instanceof ApiRequestError && error.detail)
|
||||
? `: ${error.message}`
|
||||
: ".";
|
||||
setPreview({
|
||||
kind: "error",
|
||||
ruleKey,
|
||||
message: `Upcoming dates unavailable${detail}`,
|
||||
});
|
||||
});
|
||||
}, PREVIEW_DEBOUNCE_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [ruleKey]);
|
||||
|
||||
if (preview.kind === "idle" || preview.ruleKey !== ruleKey) return null;
|
||||
if (preview.kind === "error") {
|
||||
return (
|
||||
<p className="defs-hint defs-preview defs-preview--error" data-testid="occurrence-preview">
|
||||
{preview.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
let text: string;
|
||||
if (preview.dates.length === 0) {
|
||||
text = "No upcoming dates in the next year.";
|
||||
} else {
|
||||
const labels = preview.dates.map(occurrenceLabel);
|
||||
// A full page means the rule keeps going past the last shown date.
|
||||
if (preview.dates.length >= PREVIEW_COUNT) labels.push("…");
|
||||
text = `Next: ${labels.join(" · ")}`;
|
||||
}
|
||||
return (
|
||||
<p className="defs-hint defs-preview" data-testid="occurrence-preview" aria-live="polite">
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="defs-field" role="group" aria-labelledby={id}>
|
||||
<span className="defs-label" id={id}>
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditSheet({
|
||||
target,
|
||||
activeChildren,
|
||||
onCancel,
|
||||
onSaved,
|
||||
}: {
|
||||
target: "new" | QuestDefinition;
|
||||
activeChildren: AdminChild[];
|
||||
onCancel: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Draft>(() =>
|
||||
target === "new" ? newDraft(activeChildren) : draftFrom(target, activeChildren),
|
||||
);
|
||||
// The active selection as loaded: while it is unchanged, the PATCH omits
|
||||
// assignee_child_ids, because the API replaces the whole set and inactive
|
||||
// children cannot be sent back.
|
||||
const [originalActiveIds] = useState(() => draft.assigneeIds);
|
||||
const inactiveAssignees =
|
||||
target === "new"
|
||||
? []
|
||||
: target.assignees.filter((a) => !activeChildren.some((child) => child.id === a.id));
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// A ref, not state: two taps in one frame must not both see `saving === false`.
|
||||
const inFlight = useRef(false);
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
const confirmRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Modal focus: move in on open, hand back to the trigger on close.
|
||||
useEffect(() => {
|
||||
const trigger = document.activeElement as HTMLElement | null;
|
||||
titleRef.current?.focus();
|
||||
return () => {
|
||||
if (trigger && trigger.isConnected) trigger.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (confirming) confirmRef.current?.focus();
|
||||
}, [confirming]);
|
||||
|
||||
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
if (!inFlight.current) onCancel();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab" || !sheetRef.current) return;
|
||||
const focusable = Array.from(sheetRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const current = document.activeElement;
|
||||
const outside = !focusable.includes(current as HTMLElement);
|
||||
if (event.shiftKey && (current === first || outside)) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && (current === last || outside)) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const update = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
|
||||
|
||||
const selectedAssignees = activeChildren
|
||||
.filter((child) => draft.assigneeIds.includes(child.id))
|
||||
.map((child) => ({ id: child.id, display_name: child.display_name }));
|
||||
const orderedWindows = WINDOW_ORDER.filter((w) => draft.windows.includes(w));
|
||||
const previewRule = buildRule(draft);
|
||||
const previewKey = typeof previewRule === "string" ? null : JSON.stringify(previewRule);
|
||||
|
||||
async function save(confirmed = false) {
|
||||
if (inFlight.current) return;
|
||||
const keepsAssignees = target !== "new" && sameIds(draft.assigneeIds, originalActiveIds);
|
||||
const body = buildBody(draft, keepsAssignees);
|
||||
if (typeof body === "string") {
|
||||
setError(body);
|
||||
setConfirming(false);
|
||||
return;
|
||||
}
|
||||
if (target !== "new" && !keepsAssignees && inactiveAssignees.length > 0 && !confirmed) {
|
||||
setError(null);
|
||||
setConfirming(true);
|
||||
return;
|
||||
}
|
||||
setConfirming(false);
|
||||
inFlight.current = true;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (target === "new") {
|
||||
await createDefinition(body);
|
||||
} else {
|
||||
const edit: DefinitionEditBody = { ...body };
|
||||
if (keepsAssignees) delete edit.assignee_child_ids;
|
||||
await updateDefinition(target.id, edit);
|
||||
}
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(saveErrorMessage(err));
|
||||
inFlight.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="defs-sheet-layer">
|
||||
<div
|
||||
className="defs-scrim"
|
||||
data-testid="sheet-scrim"
|
||||
onClick={() => {
|
||||
if (!inFlight.current) onCancel();
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={sheetRef}
|
||||
className="defs-sheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="defs-sheet-title"
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<div className="defs-grabber" aria-hidden="true" />
|
||||
<h3 className="defs-sheet-title" id="defs-sheet-title" ref={titleRef} tabIndex={-1}>
|
||||
{target === "new" ? "New task" : "Edit task"}
|
||||
</h3>
|
||||
<div className="defs-fields">
|
||||
<Field label="Title" id="defs-label-title">
|
||||
<input
|
||||
className="defs-input"
|
||||
aria-labelledby="defs-label-title"
|
||||
value={draft.title}
|
||||
onChange={(e) => update({ title: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Icon" id="defs-label-icon">
|
||||
<div className="defs-icon-grid" role="radiogroup" aria-labelledby="defs-label-icon">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={draft.icon === null}
|
||||
aria-label="No icon"
|
||||
className={draft.icon === null ? "defs-icon defs-icon--on" : "defs-icon"}
|
||||
onClick={() => update({ icon: null })}
|
||||
>
|
||||
<TaskGlyph size={18} />
|
||||
</button>
|
||||
{DEFINITION_ICONS.map(({ name, label, Icon }) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={draft.icon === name}
|
||||
aria-label={label}
|
||||
data-icon-option={name}
|
||||
className={draft.icon === name ? "defs-icon defs-icon--on" : "defs-icon"}
|
||||
onClick={() => update({ icon: name })}
|
||||
>
|
||||
<Icon size={18} aria-hidden="true" focusable="false" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label="Assigned children" id="defs-label-children">
|
||||
<button
|
||||
type="button"
|
||||
className="defs-select-row"
|
||||
aria-expanded={pickerOpen}
|
||||
aria-labelledby="defs-label-children defs-children-summary"
|
||||
onClick={() => setPickerOpen((open) => !open)}
|
||||
>
|
||||
<span id="defs-children-summary">
|
||||
{selectedAssignees.length > 0
|
||||
? assigneeSummary(selectedAssignees, activeChildren)
|
||||
: "Choose children"}
|
||||
</span>
|
||||
{pickerOpen ? <ChevronDownGlyph /> : <ChevronRightGlyph />}
|
||||
</button>
|
||||
{pickerOpen ? (
|
||||
<ul className="defs-picker" aria-label="Children">
|
||||
{activeChildren.map((child) => {
|
||||
const checked = draft.assigneeIds.includes(child.id);
|
||||
return (
|
||||
<li key={child.id}>
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
className="defs-picker-option"
|
||||
onClick={() => update({ assigneeIds: toggle(draft.assigneeIds, child.id) })}
|
||||
>
|
||||
<span>{child.display_name}</span>
|
||||
<span className="defs-picker-check">{checked ? <CheckGlyph /> : null}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{inactiveAssignees.map((assignee) => (
|
||||
<li
|
||||
key={assignee.id}
|
||||
className="defs-picker-option defs-picker-option--locked"
|
||||
aria-disabled="true"
|
||||
data-testid={`inactive-assignee-${assignee.id}`}
|
||||
>
|
||||
<span>{assignee.display_name}</span>
|
||||
<span className="defs-picker-note">Inactive</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{inactiveAssignees.length > 0 ? (
|
||||
<p className="defs-hint">
|
||||
Also assigned (inactive): {inactiveAssignees.map((a) => a.display_name).join(", ")}.
|
||||
Kept unless you change the selection.
|
||||
</p>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field label="Repeats" id="defs-label-repeats">
|
||||
<div className="defs-repeats">
|
||||
<select
|
||||
className="defs-input"
|
||||
aria-label="Repeats"
|
||||
value={draft.repeats}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
repeats: e.target.value as Repeats,
|
||||
// Leaving Monthly drops a carried nth-weekday rule for good.
|
||||
monthlyWeekday: e.target.value === "monthly" ? draft.monthlyWeekday : null,
|
||||
})
|
||||
}
|
||||
>
|
||||
{REPEATS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="defs-interval">
|
||||
<span>Every</span>
|
||||
<input
|
||||
className="defs-input"
|
||||
type="number"
|
||||
min={1}
|
||||
aria-label="Interval"
|
||||
value={draft.interval}
|
||||
onChange={(e) => update({ interval: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{draft.repeats === "weekly" || draft.repeats === "custom" ? (
|
||||
<div className="defs-weekdays" role="group" aria-label="Days">
|
||||
{WEEKDAY_SHORT.map((label, day) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
aria-pressed={draft.weekdays.includes(day)}
|
||||
aria-label={WEEKDAY_LONG[day]}
|
||||
className={
|
||||
draft.weekdays.includes(day) ? "defs-day defs-day--on" : "defs-day"
|
||||
}
|
||||
onClick={() => update({ weekdays: toggle(draft.weekdays, day) })}
|
||||
>
|
||||
{label.charAt(0)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{draft.repeats === "monthly" && draft.monthlyWeekday ? (
|
||||
<p className="defs-hint">
|
||||
{recurrenceSummary({
|
||||
rule_type: "monthly_weekday",
|
||||
interval: parseWhole(draft.interval, 1, 999) ?? 1,
|
||||
weekday_set: null,
|
||||
day_of_month: null,
|
||||
nth_weekday: draft.monthlyWeekday.nth,
|
||||
nth_weekday_weekday: draft.monthlyWeekday.weekday,
|
||||
month: null,
|
||||
start_date: draft.startDate,
|
||||
end_date: draft.endDate,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{draft.repeats === "yearly" ? (
|
||||
<select
|
||||
className="defs-input"
|
||||
aria-label="Month"
|
||||
value={draft.month}
|
||||
onChange={(e) => update({ month: e.target.value })}
|
||||
>
|
||||
{MONTH_SHORT.map((label, index) => (
|
||||
<option key={label} value={String(index + 1)}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
{(draft.repeats === "monthly" && !draft.monthlyWeekday) || draft.repeats === "yearly" ? (
|
||||
<input
|
||||
className="defs-input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={31}
|
||||
aria-label="Day of month"
|
||||
value={draft.dayOfMonth}
|
||||
onChange={(e) => update({ dayOfMonth: e.target.value })}
|
||||
/>
|
||||
) : null}
|
||||
<OccurrencePreview ruleKey={previewKey} />
|
||||
</Field>
|
||||
|
||||
<Field label="Due time" id="defs-label-due">
|
||||
{orderedWindows.length === 0 ? (
|
||||
<p className="defs-hint">Pick a window to set its due time.</p>
|
||||
) : (
|
||||
<div className="defs-due-times">
|
||||
{orderedWindows.map((w) => (
|
||||
<label key={w} className="defs-due">
|
||||
<span>{WINDOW_LABELS[w]}</span>
|
||||
<input
|
||||
className="defs-input"
|
||||
type="time"
|
||||
aria-label={`${WINDOW_LABELS[w]} due time`}
|
||||
value={draft.dueTimes[w]}
|
||||
onChange={(e) =>
|
||||
update({ dueTimes: { ...draft.dueTimes, [w]: e.target.value } })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Window" id="defs-label-window">
|
||||
<div className="defs-segmented">
|
||||
{WINDOW_ORDER.map((w) => (
|
||||
<button
|
||||
key={w}
|
||||
type="button"
|
||||
aria-pressed={draft.windows.includes(w)}
|
||||
className={
|
||||
draft.windows.includes(w) ? "defs-segment defs-segment--on" : "defs-segment"
|
||||
}
|
||||
onClick={() => update({ windows: toggle(draft.windows, w) })}
|
||||
>
|
||||
{WINDOW_LABELS[w]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="defs-toggle-row">
|
||||
<div>
|
||||
<div className="defs-toggle-title" id="defs-label-skip">
|
||||
Skip on away days
|
||||
</div>
|
||||
<div className="defs-toggle-sub">No instance when the child is absent</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={draft.skipOnAway}
|
||||
aria-labelledby="defs-label-skip"
|
||||
className={draft.skipOnAway ? "defs-switch defs-switch--on" : "defs-switch"}
|
||||
onClick={() => update({ skipOnAway: !draft.skipOnAway })}
|
||||
>
|
||||
<span className="defs-switch-knob" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="defs-sheet-error" role="alert" data-testid="sheet-error">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{confirming ? (
|
||||
<div
|
||||
className="defs-confirm"
|
||||
role="group"
|
||||
aria-labelledby="defs-confirm-text"
|
||||
data-testid="unassign-confirm"
|
||||
ref={confirmRef}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<p id="defs-confirm-text">
|
||||
Saving will unassign{" "}
|
||||
{inactiveAssignees.map((a) => a.display_name).join(", ")}. Inactive children can't be
|
||||
assigned again until they are reactivated.
|
||||
</p>
|
||||
<div className="defs-sheet-footer">
|
||||
<button type="button" className="defs-cancel" onClick={() => setConfirming(false)}>
|
||||
Go back
|
||||
</button>
|
||||
<button type="button" className="defs-save" onClick={() => save(true)}>
|
||||
Unassign and save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="defs-sheet-footer">
|
||||
<button type="button" className="defs-cancel" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="defs-save" onClick={() => save()} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Screen ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export default function DefinitionsTab() {
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [filter, setFilter] = useState<number | "all">("all");
|
||||
const [sheet, setSheet] = useState<SheetTarget>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// Keep the list on screen during a post-save re-fetch; spin only on first load.
|
||||
setState((current) => (current.kind === "ready" ? current : { kind: "loading" }));
|
||||
Promise.all([fetchDefinitions(), fetchChildren()])
|
||||
.then(([definitions, children]) => {
|
||||
if (!cancelled) setState({ kind: "ready", definitions, children });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
const message =
|
||||
error instanceof ApiForbiddenError
|
||||
? error.message
|
||||
: "Tasks could not be loaded. Check your connection and try again.";
|
||||
setState({ kind: "error", message });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [attempt]);
|
||||
|
||||
const activeChildren =
|
||||
state.kind === "ready"
|
||||
? [...state.children]
|
||||
.filter((child) => child.is_active)
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
: [];
|
||||
|
||||
let sub = "";
|
||||
let body;
|
||||
if (state.kind === "loading") {
|
||||
body = (
|
||||
<div className="defs-status" role="status" data-testid="definitions-loading">
|
||||
Loading tasks…
|
||||
</div>
|
||||
);
|
||||
} else if (state.kind === "error") {
|
||||
body = (
|
||||
<div className="defs-status defs-status--error" role="alert" data-testid="definitions-error">
|
||||
<p>{state.message}</p>
|
||||
<button type="button" className="defs-retry" onClick={() => setAttempt((n) => n + 1)}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const { definitions } = state;
|
||||
sub = `${definitions.length} definitions · ${activeChildren.length} children`;
|
||||
const shown =
|
||||
filter === "all"
|
||||
? definitions
|
||||
: definitions.filter((d) => d.assignees.some((a) => a.id === filter));
|
||||
const editingId = sheet !== null && sheet !== "new" ? sheet.id : null;
|
||||
body = (
|
||||
<>
|
||||
<FilterChips options={activeChildren} filter={filter} onChange={setFilter} />
|
||||
{shown.length > 0 ? (
|
||||
<ul className="defs-card defs-list" aria-label="Task definitions">
|
||||
{shown.map((definition) => (
|
||||
<DefinitionRow
|
||||
key={definition.id}
|
||||
definition={definition}
|
||||
activeChildren={activeChildren}
|
||||
editing={definition.id === editingId}
|
||||
onOpen={() => setSheet(definition)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="defs-card defs-empty">No tasks yet.</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="defs-screen">
|
||||
<header className="defs-header">
|
||||
<div>
|
||||
<h2 className="defs-title">Tasks</h2>
|
||||
<p className="defs-sub">{sub}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="defs-new"
|
||||
disabled={state.kind !== "ready"}
|
||||
onClick={() => setSheet("new")}
|
||||
>
|
||||
<PlusGlyph />
|
||||
New
|
||||
</button>
|
||||
</header>
|
||||
<div
|
||||
className="defs-scroll"
|
||||
data-testid="definitions-scroll"
|
||||
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
{sheet !== null && state.kind === "ready" ? (
|
||||
<EditSheet
|
||||
key={sheet === "new" ? "new" : sheet.id}
|
||||
target={sheet}
|
||||
activeChildren={activeChildren}
|
||||
onCancel={() => setSheet(null)}
|
||||
onSaved={() => {
|
||||
setSheet(null);
|
||||
setAttempt((n) => n + 1);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* The shipped Lucide subset a definition's `icon` may name (ADMIN-SPEC §3.3).
|
||||
* Only these named imports are bundled — no CDN, no runtime fetch. The stored
|
||||
* value is the Lucide kebab-case name, so it stays meaningful to any surface.
|
||||
*/
|
||||
import {
|
||||
Apple,
|
||||
Backpack,
|
||||
Bath,
|
||||
Bed,
|
||||
Bike,
|
||||
BookOpen,
|
||||
Brush,
|
||||
BrushCleaning,
|
||||
Calendar,
|
||||
Cat,
|
||||
Clock,
|
||||
CookingPot,
|
||||
Dog,
|
||||
Droplets,
|
||||
Fish,
|
||||
Gamepad2,
|
||||
Heart,
|
||||
House,
|
||||
ListChecks,
|
||||
Moon,
|
||||
Music,
|
||||
Pencil,
|
||||
Recycle,
|
||||
Shirt,
|
||||
ShowerHead,
|
||||
Smile,
|
||||
Sofa,
|
||||
SprayCan,
|
||||
Sprout,
|
||||
Star,
|
||||
Sun,
|
||||
Toilet,
|
||||
Trash2,
|
||||
Utensils,
|
||||
Volleyball,
|
||||
WashingMachine,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { TaskGlyph } from "./glyphs";
|
||||
|
||||
export interface DefinitionIconEntry {
|
||||
name: string;
|
||||
label: string;
|
||||
Icon: LucideIcon;
|
||||
}
|
||||
|
||||
export const DEFINITION_ICONS: readonly DefinitionIconEntry[] = [
|
||||
{ name: "bed", label: "Bed", Icon: Bed },
|
||||
{ name: "bath", label: "Bath", Icon: Bath },
|
||||
{ name: "shower-head", label: "Shower", Icon: ShowerHead },
|
||||
{ name: "toilet", label: "Toilet", Icon: Toilet },
|
||||
{ name: "brush", label: "Brush", Icon: Brush },
|
||||
{ name: "smile", label: "Smile", Icon: Smile },
|
||||
{ name: "shirt", label: "Clothes", Icon: Shirt },
|
||||
{ name: "washing-machine", label: "Laundry", Icon: WashingMachine },
|
||||
{ name: "utensils", label: "Meal", Icon: Utensils },
|
||||
{ name: "cooking-pot", label: "Cooking", Icon: CookingPot },
|
||||
{ name: "apple", label: "Snack", Icon: Apple },
|
||||
{ name: "brush-cleaning", label: "Sweep", Icon: BrushCleaning },
|
||||
{ name: "spray-can", label: "Clean", Icon: SprayCan },
|
||||
{ name: "trash-2", label: "Bins", Icon: Trash2 },
|
||||
{ name: "recycle", label: "Recycling", Icon: Recycle },
|
||||
{ name: "sofa", label: "Tidy", Icon: Sofa },
|
||||
{ name: "house", label: "Home", Icon: House },
|
||||
{ name: "backpack", label: "School bag", Icon: Backpack },
|
||||
{ name: "book-open", label: "Reading", Icon: BookOpen },
|
||||
{ name: "pencil", label: "Homework", Icon: Pencil },
|
||||
{ name: "music", label: "Music", Icon: Music },
|
||||
{ name: "dog", label: "Dog", Icon: Dog },
|
||||
{ name: "cat", label: "Cat", Icon: Cat },
|
||||
{ name: "fish", label: "Fish", Icon: Fish },
|
||||
{ name: "sprout", label: "Plants", Icon: Sprout },
|
||||
{ name: "droplets", label: "Water", Icon: Droplets },
|
||||
{ name: "bike", label: "Bike", Icon: Bike },
|
||||
{ name: "volleyball", label: "Sport", Icon: Volleyball },
|
||||
{ name: "gamepad-2", label: "Games", Icon: Gamepad2 },
|
||||
{ name: "star", label: "Star", Icon: Star },
|
||||
{ name: "heart", label: "Heart", Icon: Heart },
|
||||
{ name: "sun", label: "Morning", Icon: Sun },
|
||||
{ name: "moon", label: "Bedtime", Icon: Moon },
|
||||
{ name: "clock", label: "Clock", Icon: Clock },
|
||||
{ name: "calendar", label: "Calendar", Icon: Calendar },
|
||||
{ name: "list-checks", label: "Checklist", Icon: ListChecks },
|
||||
];
|
||||
|
||||
const BY_NAME = new Map(DEFINITION_ICONS.map((entry) => [entry.name, entry]));
|
||||
|
||||
export function findDefinitionIcon(name: string | null): DefinitionIconEntry | null {
|
||||
return name === null ? null : (BY_NAME.get(name) ?? null);
|
||||
}
|
||||
|
||||
/** A definition's glyph; no icon, or one outside the subset, gets the generic task glyph. */
|
||||
export function DefinitionIcon({ name, size = 17 }: { name: string | null; size?: number }) {
|
||||
const entry = findDefinitionIcon(name);
|
||||
if (!entry) return <TaskGlyph size={size} />;
|
||||
return <entry.Icon size={size} aria-hidden="true" focusable="false" data-icon={entry.name} />;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* List-row summaries for the Definitions tab (ADMIN-SPEC §3.1):
|
||||
* `<assignees> · <recurrence> · <windows>`.
|
||||
*/
|
||||
import type {
|
||||
AdminChild,
|
||||
DefinitionAssignee,
|
||||
DefinitionRule,
|
||||
DefinitionWindow,
|
||||
WindowName,
|
||||
} from "../api/definitions";
|
||||
|
||||
export const WINDOW_ORDER: WindowName[] = ["morning", "afternoon", "evening"];
|
||||
|
||||
export const WINDOW_LABELS: Record<WindowName, string> = {
|
||||
morning: "Morning",
|
||||
afternoon: "Afternoon",
|
||||
evening: "Evening",
|
||||
};
|
||||
|
||||
/** Index 0 = Monday .. 6 = Sunday, the core's weekday numbering. */
|
||||
export const WEEKDAY_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
export const MONTH_SHORT = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
|
||||
const COUNT_WORDS = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"];
|
||||
|
||||
/**
|
||||
* `Declan`, `Declan, Maeve`, or `All three` when every active child (two or
|
||||
* more) is assigned.
|
||||
*/
|
||||
export function assigneeSummary(
|
||||
assignees: Pick<DefinitionAssignee, "id" | "display_name">[],
|
||||
activeChildren: AdminChild[],
|
||||
): string {
|
||||
if (assignees.length === 0) return "Unassigned";
|
||||
const assigned = new Set(assignees.map((a) => a.id));
|
||||
const everyone =
|
||||
activeChildren.length > 1 &&
|
||||
assigned.size === activeChildren.length &&
|
||||
activeChildren.every((child) => assigned.has(child.id));
|
||||
if (everyone) {
|
||||
const word = COUNT_WORDS[activeChildren.length] ?? String(activeChildren.length);
|
||||
return `All ${word}`;
|
||||
}
|
||||
return assignees.map((a) => a.display_name).join(", ");
|
||||
}
|
||||
|
||||
function weekdayList(days: number[] | null): string {
|
||||
return [...(days ?? [])]
|
||||
.sort((a, b) => a - b)
|
||||
.map((day) => WEEKDAY_SHORT[day])
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function ordinal(n: number): string {
|
||||
if (n === -1) return "last";
|
||||
const suffix = n === 1 ? "st" : n === 2 ? "nd" : n === 3 ? "rd" : "th";
|
||||
return `${n}${suffix}`;
|
||||
}
|
||||
|
||||
function every(interval: number, one: string, unit: string): string {
|
||||
return interval === 1 ? one : `Every ${interval} ${unit}`;
|
||||
}
|
||||
|
||||
export function recurrenceSummary(rule: DefinitionRule): string {
|
||||
const n = rule.interval;
|
||||
switch (rule.rule_type) {
|
||||
case "daily":
|
||||
return every(n, "Daily", "days");
|
||||
case "weekly":
|
||||
return `${every(n, "Weekly", "weeks")} on ${weekdayList(rule.weekday_set)}`;
|
||||
case "monthly_day":
|
||||
return `${every(n, "Monthly", "months")} on day ${rule.day_of_month}`;
|
||||
case "monthly_weekday":
|
||||
return `${every(n, "Monthly", "months")} on the ${ordinal(rule.nth_weekday ?? 1)} ${
|
||||
WEEKDAY_SHORT[rule.nth_weekday_weekday ?? 0]
|
||||
}`;
|
||||
case "yearly": {
|
||||
const month = MONTH_SHORT[(rule.month ?? 1) - 1];
|
||||
const when = rule.day_of_month ? `on ${month} ${rule.day_of_month}` : `in ${month}`;
|
||||
return `${every(n, "Yearly", "years")} ${when}`;
|
||||
}
|
||||
case "custom_days":
|
||||
return `Custom${n === 1 ? "" : ` every ${n} weeks`} (${weekdayList(rule.weekday_set)})`;
|
||||
}
|
||||
}
|
||||
|
||||
export function windowSummary(windows: Pick<DefinitionWindow, "window">[]): string {
|
||||
const names = new Set(windows.map((w) => w.window));
|
||||
return WINDOW_ORDER.filter((w) => names.has(w))
|
||||
.map((w) => WINDOW_LABELS[w])
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* "2026-10-06" -> "Tue 6 Oct". The calendar date is read by its components
|
||||
* and its weekday taken in UTC (as the Today tab does), so the printed day
|
||||
* never shifts with the viewer's offset — `new Date("YYYY-MM-DD")` would.
|
||||
*/
|
||||
export function occurrenceLabel(iso: string): string {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
||||
if (!match) throw new Error(`Invalid calendar date: ${iso}`);
|
||||
const [, year, month, day] = match.map(Number);
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
// getUTCDay: 0 = Sunday; WEEKDAY_SHORT: 0 = Monday.
|
||||
return `${WEEKDAY_SHORT[(date.getUTCDay() + 6) % 7]} ${day} ${MONTH_SHORT[month - 1]}`;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Inline stroke glyphs for the Definitions tab (Lucide-style 24px grid),
|
||||
* CDN-free like the Today tab's. Definition icons arrive with the icon
|
||||
* picker task; until then every row carries the generic task glyph.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
function Glyph({ size, children }: { size: number; children: ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskGlyph({ size = 17 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" />
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||
<path d="m9 14 2 2 4-4" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlusGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronRightGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronDownGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/* History tab — compact density (design/ADMIN-SPEC.md §5). Mobile-first at
|
||||
the 390x844 reference; on wider viewports the column is capped and centred. */
|
||||
|
||||
.history-screen {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: var(--nq-a-surface);
|
||||
border-bottom: 1px solid var(--nq-a-border);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
margin: 0;
|
||||
font-size: var(--nq-a-size-screen);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.history-sub {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.history-csv {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.history-csv:disabled {
|
||||
cursor: progress;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Non-blocking CSV export failure; the tab stays usable. */
|
||||
.history-export-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: var(--nq-a-density-row-pad);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-danger-bg);
|
||||
color: var(--nq-a-danger-strong);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.history-export-dismiss {
|
||||
flex: none;
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
|
||||
.history-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nq-a-density-gap);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.history-card {
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
}
|
||||
|
||||
/* Stat row */
|
||||
.history-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.history-stat {
|
||||
padding: 12px;
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
}
|
||||
|
||||
.history-stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.history-stat-value--on-time {
|
||||
color: var(--nq-brand-blue);
|
||||
}
|
||||
|
||||
.history-stat-value--missed {
|
||||
color: var(--nq-a-danger);
|
||||
}
|
||||
|
||||
.history-stat-label {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
/* Filter chips */
|
||||
.history-chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.history-chip {
|
||||
flex: none;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-radius-pill);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.history-chip--active {
|
||||
border-color: #18181d;
|
||||
background: #18181d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Day-grouped event rows */
|
||||
.history-day {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.history-section-label {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.history-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.history-list > li + li {
|
||||
border-top: 1px solid var(--nq-a-divider);
|
||||
}
|
||||
|
||||
.history-event {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: var(--nq-a-density-row-pad);
|
||||
}
|
||||
|
||||
.history-dot {
|
||||
flex: none;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.history-dot--completed {
|
||||
background: var(--nq-a-success);
|
||||
}
|
||||
|
||||
.history-dot--uncompleted {
|
||||
background: var(--nq-a-reversal);
|
||||
}
|
||||
|
||||
.history-dot--missed {
|
||||
background: var(--nq-a-danger);
|
||||
}
|
||||
|
||||
.history-event-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.history-event-title {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-event-meta {
|
||||
margin-top: 1px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.history-event-time {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.history-empty {
|
||||
padding: var(--nq-a-density-card-pad);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
/* Append-only footer strip */
|
||||
.history-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-page);
|
||||
color: var(--nq-a-ink-secondary);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.history-note-glyph {
|
||||
flex: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Loading / error */
|
||||
.history-status {
|
||||
padding: 24px 13px;
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
text-align: center;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.history-status--error {
|
||||
color: var(--nq-a-danger-strong);
|
||||
}
|
||||
|
||||
.history-status--error p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.history-retry {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import HistoryTab, { FOOTER_NOTE, computeStats } from "./HistoryTab";
|
||||
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";
|
||||
|
||||
const TODAY = "2026-09-23";
|
||||
|
||||
function row(overrides: Partial<HistoryRow>): HistoryRow {
|
||||
return {
|
||||
occurred_at: null,
|
||||
event_type: "completed",
|
||||
child_name: "Declan",
|
||||
child_id: 1,
|
||||
quest_title: "Brush teeth",
|
||||
instance_id: 1,
|
||||
window: "morning",
|
||||
due_date: TODAY,
|
||||
due_time: null,
|
||||
actor: "panel (Declan)",
|
||||
was_on_time: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// America/Los_Angeles is UTC-7 in September.
|
||||
const BRUSH = row({
|
||||
instance_id: 1,
|
||||
occurred_at: "2026-09-23T14:04:00Z", // 7:04 AM
|
||||
due_time: "07:30",
|
||||
});
|
||||
const BED_DONE = row({
|
||||
instance_id: 2,
|
||||
quest_title: "Make bed",
|
||||
child_name: "Maeve",
|
||||
child_id: 2,
|
||||
occurred_at: "2026-09-23T14:50:00Z", // 7:50 AM
|
||||
actor: "panel (Maeve)",
|
||||
});
|
||||
const BED_UNDONE = row({
|
||||
instance_id: 2,
|
||||
event_type: "uncompleted",
|
||||
quest_title: "Make bed",
|
||||
child_name: "Maeve",
|
||||
child_id: 2,
|
||||
occurred_at: "2026-09-23T15:10:00Z", // 8:10 AM
|
||||
actor: "joshua (admin)",
|
||||
was_on_time: null,
|
||||
});
|
||||
const CAT_LATE = row({
|
||||
instance_id: 3,
|
||||
quest_title: "Feed cat",
|
||||
due_date: "2026-09-22",
|
||||
due_time: "07:00",
|
||||
occurred_at: "2026-09-22T14:22:00Z", // 7:22 AM, 22 min after due
|
||||
was_on_time: false,
|
||||
});
|
||||
// 7:30 PM local on Sep 22 — already Sep 23 in UTC.
|
||||
const HOMEWORK_LATE = row({
|
||||
instance_id: 4,
|
||||
quest_title: "Homework",
|
||||
child_name: "Rory",
|
||||
child_id: 3,
|
||||
due_date: "2026-09-22",
|
||||
due_time: null,
|
||||
occurred_at: "2026-09-23T02:30:00Z",
|
||||
actor: "panel (Rory)",
|
||||
was_on_time: false,
|
||||
});
|
||||
const PLANTS_MISSED = row({
|
||||
instance_id: 5,
|
||||
event_type: "missed",
|
||||
quest_title: "Water plants",
|
||||
child_name: "Rory",
|
||||
child_id: 3,
|
||||
due_date: "2026-09-21",
|
||||
due_time: "18:00",
|
||||
actor: "nightly sweep",
|
||||
was_on_time: null,
|
||||
});
|
||||
|
||||
// The API's order: due_date, then instance, then event id.
|
||||
const ROWS: Record<HistoryFilter, HistoryRow[]> = {
|
||||
all: [CAT_LATE, HOMEWORK_LATE, BRUSH, BED_DONE, BED_UNDONE],
|
||||
reversals: [BED_UNDONE],
|
||||
missed: [PLANTS_MISSED],
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const CSV_BODY = "occurred_at,event_type\n2026-09-23T14:04:00Z,completed\n";
|
||||
|
||||
function csvResponse(filter: string, start: string, end: string): Response {
|
||||
return new Response(`${filter}\n${CSV_BODY}`, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="nestquest-history-${start}-to-${end}.csv"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function routeFetch(url: string): Response {
|
||||
const parsed = new URL(url, "http://x");
|
||||
if (parsed.pathname.endsWith("/api/v1/admin/history.csv")) {
|
||||
return csvResponse(
|
||||
parsed.searchParams.get("filter") ?? "",
|
||||
parsed.searchParams.get("start") ?? "",
|
||||
parsed.searchParams.get("end") ?? "",
|
||||
);
|
||||
}
|
||||
if (!parsed.pathname.endsWith("/api/v1/admin/history")) {
|
||||
return jsonResponse({ detail: "unexpected" }, 500);
|
||||
}
|
||||
const filter = parsed.searchParams.get("filter") as HistoryFilter;
|
||||
const rows = ROWS[filter];
|
||||
return jsonResponse({
|
||||
filter,
|
||||
start: parsed.searchParams.get("start"),
|
||||
end: parsed.searchParams.get("end"),
|
||||
count: rows.length,
|
||||
rows,
|
||||
});
|
||||
}
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function historyCalls(): URL[] {
|
||||
return fetchMock.mock.calls.map(([url]) => new URL(String(url), "http://x"));
|
||||
}
|
||||
|
||||
function metas(section: HTMLElement): string[] {
|
||||
return within(section)
|
||||
.getAllByTestId("history-meta")
|
||||
.map((el) => el.textContent ?? "");
|
||||
}
|
||||
|
||||
async function renderReady() {
|
||||
render(<HistoryTab today={TODAY} />);
|
||||
await screen.findByTestId("stat-on-time");
|
||||
}
|
||||
|
||||
describe("HistoryTab", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("TZ", "America/Los_Angeles");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
fetchMock = vi.fn((url: string) => Promise.resolve(routeFetch(url)));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders the header and queries the last 14 days with the Bearer token", async () => {
|
||||
await renderReady();
|
||||
expect(screen.getByRole("heading", { name: "History" })).toBeTruthy();
|
||||
expect(screen.getByText("Append-only event log · last 14 days")).toBeTruthy();
|
||||
|
||||
const calls = historyCalls();
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
for (const url of calls) {
|
||||
expect(url.pathname).toBe("/api/v1/admin/history");
|
||||
expect(url.searchParams.get("start")).toBe("2026-09-10");
|
||||
expect(url.searchParams.get("end")).toBe(TODAY);
|
||||
}
|
||||
expect(calls.map((url) => url.searchParams.get("filter")).sort()).toEqual(["all", "missed"]);
|
||||
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe("Bearer at-123");
|
||||
});
|
||||
|
||||
it("stat row shows On time %, Completed and Missed for the period", async () => {
|
||||
await renderReady();
|
||||
// 4 completions (2 on time) in `all`; 1 derived missed row.
|
||||
expect(screen.getByTestId("stat-on-time").textContent).toBe("50%On time");
|
||||
expect(screen.getByTestId("stat-completed").textContent).toBe("4Completed");
|
||||
expect(screen.getByTestId("stat-missed").textContent).toBe("1Missed");
|
||||
});
|
||||
|
||||
it("On time shows — when the period has no completions", () => {
|
||||
expect(computeStats([BED_UNDONE], [])).toEqual({ onTimePct: null, completed: 0, missed: 0 });
|
||||
});
|
||||
|
||||
it("groups rows by local day, newest first, with event type, actor and on-time meta", async () => {
|
||||
await renderReady();
|
||||
|
||||
const today = screen.getByTestId(`day-${TODAY}`);
|
||||
expect(within(today).getByRole("heading").textContent).toBe("Today");
|
||||
expect(metas(today)).toEqual([
|
||||
"uncompleted · joshua (admin)",
|
||||
"completed · panel (Maeve) · on time",
|
||||
"completed · panel (Declan) · on time",
|
||||
]);
|
||||
expect(within(today).getAllByText("Make bed · Maeve")).toHaveLength(2);
|
||||
expect(within(today).getByText("8:10 AM")).toBeTruthy();
|
||||
|
||||
// The 7:30 PM completion is Sep 23 in UTC but Sep 22 locally.
|
||||
const yesterday = screen.getByTestId("day-2026-09-22");
|
||||
expect(within(yesterday).getByRole("heading").textContent).toBe("Yesterday");
|
||||
expect(metas(yesterday)).toEqual([
|
||||
"completed · panel (Rory) · late",
|
||||
"completed · panel (Declan) · late 22 min",
|
||||
]);
|
||||
expect(within(yesterday).getByText("7:30 PM")).toBeTruthy();
|
||||
|
||||
const dots = screen.getAllByTestId("history-dot").map((el) => el.className);
|
||||
expect(dots[0]).toContain("history-dot--uncompleted");
|
||||
expect(dots[1]).toContain("history-dot--completed");
|
||||
});
|
||||
|
||||
it("each filter chip re-queries the API with its filter and re-renders", async () => {
|
||||
await renderReady();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
|
||||
await waitFor(() =>
|
||||
expect(historyCalls().some((url) => url.searchParams.get("filter") === "reversals")).toBe(true),
|
||||
);
|
||||
await waitFor(() => expect(screen.getAllByTestId("history-row")).toHaveLength(1));
|
||||
expect(screen.getByTestId("history-meta").textContent).toBe("uncompleted · joshua (admin)");
|
||||
expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Missed" }));
|
||||
const missedDay = await screen.findByTestId("day-2026-09-21");
|
||||
expect(within(missedDay).getByRole("heading").textContent).toBe("Mon, Sep 21");
|
||||
expect(metas(missedDay)).toEqual(["missed · nightly sweep"]);
|
||||
expect(within(missedDay).getByTestId("history-dot").className).toContain("history-dot--missed");
|
||||
expect(within(missedDay).getByText("6:00 PM")).toBeTruthy();
|
||||
// Stats stay the period's numbers whichever chip is selected.
|
||||
expect(screen.getByTestId("stat-completed").textContent).toBe("4Completed");
|
||||
|
||||
fetchMock.mockClear();
|
||||
fireEvent.click(screen.getByRole("button", { name: "All events" }));
|
||||
await screen.findByTestId(`day-${TODAY}`);
|
||||
expect(historyCalls().map((url) => url.searchParams.get("filter"))).toContain("all");
|
||||
expect(screen.getAllByTestId("history-row")).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("shows the append-only footer", async () => {
|
||||
await renderReady();
|
||||
expect(FOOTER_NOTE).toBe("Events are never edited or deleted. A reversal is its own row.");
|
||||
expect(screen.getByTestId("history-footer").textContent).toBe(FOOTER_NOTE);
|
||||
});
|
||||
|
||||
describe("CSV export", () => {
|
||||
let createObjectURL: ReturnType<typeof vi.fn>;
|
||||
let revokeObjectURL: ReturnType<typeof vi.fn>;
|
||||
let clicked: { href: string; download: string; attached: boolean }[];
|
||||
|
||||
beforeEach(() => {
|
||||
createObjectURL = vi.fn(() => "blob:nestquest/csv-1");
|
||||
revokeObjectURL = vi.fn();
|
||||
// jsdom has no object URLs; install stubs and remove them afterwards.
|
||||
Object.assign(URL, { createObjectURL, revokeObjectURL });
|
||||
clicked = [];
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) {
|
||||
clicked.push({
|
||||
href: this.getAttribute("href") ?? "",
|
||||
download: this.download,
|
||||
attached: document.body.contains(this),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
const stubbed = URL as unknown as Record<string, unknown>;
|
||||
delete stubbed.createObjectURL;
|
||||
delete stubbed.revokeObjectURL;
|
||||
});
|
||||
|
||||
function csvCalls(): [URL, RequestInit][] {
|
||||
return fetchMock.mock.calls
|
||||
.map(([url, init]) => [new URL(String(url), "http://x"), init] as [URL, RequestInit])
|
||||
.filter(([url]) => url.pathname === "/api/v1/admin/history.csv");
|
||||
}
|
||||
|
||||
it("requests the CSV for the active filter and displayed range with the Bearer token", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByRole("button", { name: "CSV" }));
|
||||
await waitFor(() => expect(clicked).toHaveLength(1));
|
||||
|
||||
const calls = csvCalls();
|
||||
expect(calls).toHaveLength(1);
|
||||
const [url, init] = calls[0];
|
||||
expect(url.searchParams.get("filter")).toBe("all");
|
||||
expect(url.searchParams.get("start")).toBe("2026-09-10");
|
||||
expect(url.searchParams.get("end")).toBe(TODAY);
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer at-123");
|
||||
// The token never travels in the URL.
|
||||
expect(url.toString()).not.toContain("at-123");
|
||||
});
|
||||
|
||||
it("offers the returned CSV for download, then revokes the URL and removes the anchor", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByRole("button", { name: "CSV" }));
|
||||
await waitFor(() => expect(clicked).toHaveLength(1));
|
||||
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
||||
const blob = createObjectURL.mock.calls[0][0] as Blob;
|
||||
expect(await blob.text()).toBe(`all\n${CSV_BODY}`);
|
||||
expect(clicked[0]).toEqual({
|
||||
href: "blob:nestquest/csv-1",
|
||||
download: "nestquest-history-2026-09-10-to-2026-09-23.csv",
|
||||
attached: true,
|
||||
});
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:nestquest/csv-1");
|
||||
expect(document.querySelector("a[download]")).toBeNull();
|
||||
expect(screen.queryByTestId("history-export-error")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the range filename without a Content-Disposition header", async () => {
|
||||
fetchMock.mockImplementation((url: string) =>
|
||||
Promise.resolve(
|
||||
new URL(url, "http://x").pathname.endsWith(".csv")
|
||||
? new Response(CSV_BODY, { status: 200, headers: { "Content-Type": "text/csv" } })
|
||||
: routeFetch(url),
|
||||
),
|
||||
);
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByRole("button", { name: "CSV" }));
|
||||
await waitFor(() => expect(clicked).toHaveLength(1));
|
||||
expect(clicked[0].download).toBe("nestquest-history-2026-09-10-to-2026-09-23.csv");
|
||||
});
|
||||
|
||||
it("switching the filter chip then exporting uses the new filter", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
|
||||
await waitFor(() => expect(screen.getAllByTestId("history-row")).toHaveLength(1));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "CSV" }));
|
||||
await waitFor(() => expect(clicked).toHaveLength(1));
|
||||
const calls = csvCalls();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0][0].searchParams.get("filter")).toBe("reversals");
|
||||
expect(calls[0][0].searchParams.get("start")).toBe("2026-09-10");
|
||||
expect(calls[0][0].searchParams.get("end")).toBe(TODAY);
|
||||
});
|
||||
|
||||
it("a failed export shows a non-blocking error and does not download", async () => {
|
||||
fetchMock.mockImplementation((url: string) =>
|
||||
Promise.resolve(
|
||||
new URL(url, "http://x").pathname.endsWith(".csv")
|
||||
? jsonResponse({ detail: "boom" }, 500)
|
||||
: routeFetch(url),
|
||||
),
|
||||
);
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByRole("button", { name: "CSV" }));
|
||||
|
||||
const error = await screen.findByTestId("history-export-error");
|
||||
expect(error.textContent).toContain("The CSV export failed");
|
||||
expect(createObjectURL).not.toHaveBeenCalled();
|
||||
expect(clicked).toHaveLength(0);
|
||||
// The tab stays usable: rows remain, the button is re-enabled, chips still work.
|
||||
expect(screen.getAllByTestId("history-row")).toHaveLength(5);
|
||||
expect((screen.getByRole("button", { name: "CSV" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
|
||||
await waitFor(() => expect(screen.getAllByTestId("history-row")).toHaveLength(1));
|
||||
|
||||
fireEvent.click(within(error).getByRole("button", { name: "Dismiss" }));
|
||||
expect(screen.queryByTestId("history-export-error")).toBeNull();
|
||||
});
|
||||
|
||||
it("the button is disabled while the request is in flight (no double-submit)", async () => {
|
||||
let release: (response: Response) => void = () => {};
|
||||
fetchMock.mockImplementation((url: string) =>
|
||||
new URL(url, "http://x").pathname.endsWith(".csv")
|
||||
? new Promise<Response>((resolve) => {
|
||||
release = resolve;
|
||||
})
|
||||
: Promise.resolve(routeFetch(url)),
|
||||
);
|
||||
await renderReady();
|
||||
const csv = screen.getByRole("button", { name: "CSV" }) as HTMLButtonElement;
|
||||
fireEvent.click(csv);
|
||||
await waitFor(() => expect(csv.disabled).toBe(true));
|
||||
fireEvent.click(csv);
|
||||
expect(csvCalls()).toHaveLength(1);
|
||||
|
||||
release(csvResponse("all", "2026-09-10", TODAY));
|
||||
await waitFor(() => expect(clicked).toHaveLength(1));
|
||||
await waitFor(() => expect(csv.disabled).toBe(false));
|
||||
expect(csvCalls()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("rows are read-only: no row carries a control", async () => {
|
||||
await renderReady();
|
||||
for (const el of screen.getAllByTestId("history-row")) {
|
||||
expect(within(el).queryByRole("button")).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("a failed fetch shows the error state and Try again reloads", async () => {
|
||||
fetchMock.mockImplementation(() => Promise.resolve(jsonResponse({ detail: "boom" }, 500)));
|
||||
render(<HistoryTab today={TODAY} />);
|
||||
|
||||
const error = await screen.findByTestId("history-error");
|
||||
expect(error.textContent).toContain("The history could not be loaded");
|
||||
expect(screen.getByTestId("history-footer")).toBeTruthy();
|
||||
|
||||
fetchMock.mockImplementation((url: string) => Promise.resolve(routeFetch(url)));
|
||||
fireEvent.click(within(error).getByRole("button", { name: "Try again" }));
|
||||
await screen.findByTestId("stat-on-time");
|
||||
expect(screen.queryByTestId("history-error")).toBeNull();
|
||||
});
|
||||
|
||||
it("the scroll column ends with 92px bottom padding", async () => {
|
||||
await renderReady();
|
||||
expect(screen.getByTestId("history-scroll").style.paddingBottom).toBe("92px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HistoryTab live updates", () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
let streams: TestStream[];
|
||||
|
||||
function historyRequests(): URL[] {
|
||||
return fetchMock.mock.calls
|
||||
.map(([url]) => new URL(String(url), "http://x"))
|
||||
.filter((url) => url.pathname === "/api/v1/admin/history");
|
||||
}
|
||||
|
||||
const PAYLOAD = {
|
||||
child_id: 2,
|
||||
child_name: "Maeve",
|
||||
instance_id: 2,
|
||||
quest_title: "Make bed",
|
||||
window: "morning",
|
||||
due_date: TODAY,
|
||||
due_time: null,
|
||||
occurred_at: "2026-09-23T15:10:00Z",
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("TZ", "America/Los_Angeles");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
streams = [];
|
||||
fetchMock = vi.fn((url: string) => {
|
||||
if (new URL(url, "http://x").pathname.endsWith("/api/v1/admin/events")) {
|
||||
const stream = testStream();
|
||||
streams.push(stream);
|
||||
return Promise.resolve(stream.response);
|
||||
}
|
||||
return Promise.resolve(routeFetch(url));
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("a quest transition refetches the active filter; a day-complete does not", async () => {
|
||||
render(
|
||||
<TransitionsProvider>
|
||||
<HistoryTab today={TODAY} />
|
||||
</TransitionsProvider>,
|
||||
);
|
||||
await screen.findByTestId("stat-on-time");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
|
||||
await waitFor(() =>
|
||||
expect(historyRequests().some((u) => u.searchParams.get("filter") === "reversals")).toBe(true),
|
||||
);
|
||||
await screen.findByTestId("stat-on-time");
|
||||
await waitFor(() => expect(streams).toHaveLength(1));
|
||||
const before = historyRequests().length;
|
||||
|
||||
streams[0].push(
|
||||
frame("nestquest_child_day_complete", {
|
||||
child_id: 2,
|
||||
child_name: "Maeve",
|
||||
quests_due: 4,
|
||||
quests_completed: 4,
|
||||
occurred_at: "2026-09-23T15:00:00Z",
|
||||
}),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(historyRequests().length).toBe(before);
|
||||
|
||||
streams[0].push(frame("nestquest_quest_uncompleted", PAYLOAD));
|
||||
|
||||
await waitFor(() => expect(historyRequests().length).toBe(before + 3));
|
||||
const refetched = historyRequests().slice(before);
|
||||
// One refetch (reversals + the two stats queries), same range and filter.
|
||||
expect(refetched.map((u) => u.searchParams.get("filter")).sort()).toEqual([
|
||||
"all",
|
||||
"missed",
|
||||
"reversals",
|
||||
]);
|
||||
for (const url of refetched) {
|
||||
expect(url.searchParams.get("start")).toBe("2026-09-10");
|
||||
expect(url.searchParams.get("end")).toBe(TODAY);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(historyRequests().length).toBe(before + 3);
|
||||
expect(screen.queryByTestId("history-loading")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
|
||||
// Rows stay read-only (D-005): no row carries a control.
|
||||
for (const row of screen.getAllByTestId("history-row")) {
|
||||
expect(within(row).queryByRole("button")).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
describe("a filter change interleaved with a quest transition", () => {
|
||||
// Both land before the load effect runs, so one load serves both.
|
||||
async function interleave(order: "sse-first" | "chip-first") {
|
||||
await act(async () => {
|
||||
const click = () => fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
|
||||
if (order === "chip-first") click();
|
||||
streams[0].push(frame("nestquest_quest_uncompleted", PAYLOAD));
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
if (order === "sse-first") click();
|
||||
});
|
||||
}
|
||||
|
||||
async function renderLive() {
|
||||
render(
|
||||
<TransitionsProvider>
|
||||
<HistoryTab today={TODAY} />
|
||||
</TransitionsProvider>,
|
||||
);
|
||||
await screen.findByTestId("stat-on-time");
|
||||
await waitFor(() => expect(streams).toHaveLength(1));
|
||||
expect(screen.getAllByTestId("history-row")).toHaveLength(ROWS.all.length);
|
||||
}
|
||||
|
||||
function titles(): string[] {
|
||||
return screen.getAllByTestId("history-row").map((el) => el.textContent ?? "");
|
||||
}
|
||||
|
||||
for (const order of ["sse-first", "chip-first"] as const) {
|
||||
it(`${order}: shows loading, then only the active filter's rows`, async () => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const route = fetchMock.getMockImplementation()!;
|
||||
await renderLive();
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
const parsed = new URL(url, "http://x");
|
||||
if (parsed.searchParams.get("filter") === "reversals") {
|
||||
return gate.then(() => route(url));
|
||||
}
|
||||
return route(url);
|
||||
});
|
||||
|
||||
await interleave(order);
|
||||
expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
|
||||
// The previous filter's rows never show under the new chip.
|
||||
expect(screen.getByTestId("history-loading")).toBeTruthy();
|
||||
expect(screen.queryAllByTestId("history-row")).toHaveLength(0);
|
||||
|
||||
release();
|
||||
await screen.findByTestId("stat-on-time");
|
||||
expect(titles()).toHaveLength(1);
|
||||
expect(titles()[0]).toContain("Make bed · Maeve");
|
||||
expect(screen.getAllByTestId("history-dot")[0].className).toContain("history-dot--uncompleted");
|
||||
});
|
||||
|
||||
it(`${order}: a failed filter-change load shows the error state, not stale rows`, async () => {
|
||||
const route = fetchMock.getMockImplementation()!;
|
||||
await renderLive();
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
const parsed = new URL(url, "http://x");
|
||||
if (parsed.pathname === "/api/v1/admin/history") {
|
||||
return Promise.resolve(jsonResponse({ detail: "boom" }, 500));
|
||||
}
|
||||
return route(url);
|
||||
});
|
||||
|
||||
await interleave(order);
|
||||
const error = await screen.findByTestId("history-error");
|
||||
expect(error.textContent).toContain("The history could not be loaded");
|
||||
expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
|
||||
expect(screen.queryAllByTestId("history-row")).toHaveLength(0);
|
||||
expect(screen.queryByTestId("stat-on-time")).toBeNull();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("a failed transition refresh keeps the active filter's rows on screen", async () => {
|
||||
render(
|
||||
<TransitionsProvider>
|
||||
<HistoryTab today={TODAY} />
|
||||
</TransitionsProvider>,
|
||||
);
|
||||
await screen.findByTestId("stat-on-time");
|
||||
await waitFor(() => expect(streams).toHaveLength(1));
|
||||
const route = fetchMock.getMockImplementation()!;
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (new URL(url, "http://x").pathname === "/api/v1/admin/history") {
|
||||
return Promise.resolve(jsonResponse({ detail: "boom" }, 500));
|
||||
}
|
||||
return route(url);
|
||||
});
|
||||
const before = historyRequests().length;
|
||||
|
||||
streams[0].push(frame("nestquest_quest_uncompleted", PAYLOAD));
|
||||
// The "all" filter plus the missed stats query.
|
||||
await waitFor(() => expect(historyRequests().length).toBe(before + 2));
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(screen.queryByTestId("history-error")).toBeNull();
|
||||
expect(screen.queryByTestId("history-loading")).toBeNull();
|
||||
expect(screen.getAllByTestId("history-row")).toHaveLength(ROWS.all.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* History tab — the append-only event log (design/ADMIN-SPEC.md §5).
|
||||
*
|
||||
* Rows are read-only: events are never edited or deleted, and a reversal is
|
||||
* its own row (D-005).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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 "./HistoryTab.css";
|
||||
|
||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||
export const SCROLL_BOTTOM_PADDING = "92px";
|
||||
|
||||
export const RANGE_DAYS = 14;
|
||||
|
||||
export const FOOTER_NOTE = "Events are never edited or deleted. A reversal is its own row.";
|
||||
|
||||
export const FILTERS: { id: HistoryFilter; label: string }[] = [
|
||||
{ id: "all", label: "All events" },
|
||||
{ id: "reversals", label: "Reversals" },
|
||||
{ id: "missed", label: "Missed" },
|
||||
];
|
||||
|
||||
export interface HistoryStats {
|
||||
/** Whole percent, or null when the period has no completions. */
|
||||
onTimePct: number | null;
|
||||
completed: number;
|
||||
missed: number;
|
||||
}
|
||||
|
||||
/** Per-period stats from the `all` rows (completions) and the `missed` rows. */
|
||||
export function computeStats(allRows: HistoryRow[], missedRows: HistoryRow[]): HistoryStats {
|
||||
const completions = allRows.filter((row) => row.event_type === "completed");
|
||||
const onTime = completions.filter((row) => row.was_on_time === true).length;
|
||||
return {
|
||||
onTimePct: completions.length > 0 ? Math.round((onTime / completions.length) * 100) : null,
|
||||
completed: completions.length,
|
||||
missed: missedRows.filter((row) => row.event_type === "missed").length,
|
||||
};
|
||||
}
|
||||
|
||||
interface Loaded {
|
||||
rows: HistoryRow[];
|
||||
stats: HistoryStats;
|
||||
}
|
||||
|
||||
type LoadState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
/** `key` is the (filter, start, end) the rows were loaded for. */
|
||||
| { kind: "ready"; data: Loaded; key: string };
|
||||
|
||||
/**
|
||||
* The selected filter's rows plus the period stats. Stats always come from
|
||||
* the `all` and `missed` queries, whichever chip is selected, so switching
|
||||
* chips never changes the period's numbers.
|
||||
*/
|
||||
async function loadHistory(filter: HistoryFilter, start: string, end: string): Promise<Loaded> {
|
||||
const [selected, all, missed] = await Promise.all([
|
||||
fetchHistory(filter, start, end),
|
||||
filter === "all" ? null : fetchHistory("all", start, end),
|
||||
filter === "missed" ? null : fetchHistory("missed", start, end),
|
||||
]);
|
||||
return {
|
||||
rows: selected.rows,
|
||||
stats: computeStats((all ?? selected).rows, (missed ?? selected).rows),
|
||||
};
|
||||
}
|
||||
|
||||
/** Offer a Blob as a file download without navigating the page. */
|
||||
export function offerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.style.display = "none";
|
||||
document.body.appendChild(anchor);
|
||||
try {
|
||||
anchor.click();
|
||||
} finally {
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
function ScreenHeader({ exporting, onExport }: { exporting: boolean; onExport: () => void }) {
|
||||
return (
|
||||
<header className="history-header">
|
||||
<div>
|
||||
<h2 className="history-title">History</h2>
|
||||
<p className="history-sub">Append-only event log · last {RANGE_DAYS} days</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="history-csv"
|
||||
data-testid="history-csv"
|
||||
disabled={exporting}
|
||||
aria-busy={exporting}
|
||||
onClick={onExport}
|
||||
>
|
||||
<DownloadGlyph />
|
||||
CSV
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function StatRow({ stats }: { stats: HistoryStats }) {
|
||||
const items = [
|
||||
{ key: "on-time", label: "On time", value: stats.onTimePct === null ? "—" : `${stats.onTimePct}%` },
|
||||
{ key: "completed", label: "Completed", value: String(stats.completed) },
|
||||
{ key: "missed", label: "Missed", value: String(stats.missed) },
|
||||
];
|
||||
return (
|
||||
<div className="history-stats">
|
||||
{items.map((item) => (
|
||||
<div key={item.key} className="history-stat" data-testid={`stat-${item.key}`}>
|
||||
<div className={`history-stat-value history-stat-value--${item.key}`}>{item.value}</div>
|
||||
<div className="history-stat-label">{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({ row }: { row: HistoryRow }) {
|
||||
return (
|
||||
<li className="history-event" data-testid="history-row">
|
||||
<span
|
||||
className={`history-dot history-dot--${row.event_type}`}
|
||||
data-testid="history-dot"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="history-event-text">
|
||||
<div className="history-event-title">
|
||||
{row.quest_title} · {row.child_name}
|
||||
</div>
|
||||
<div className="history-event-meta" data-testid="history-meta">
|
||||
{rowMeta(row)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="history-event-time">{rowTime(row)}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterNote() {
|
||||
return (
|
||||
<div className="history-note" data-testid="history-footer">
|
||||
<span className="history-note-glyph">
|
||||
<LockGlyph />
|
||||
</span>
|
||||
<span>{FOOTER_NOTE}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HistoryTab({ today = localTodayIso() }: { today?: string }) {
|
||||
const [filter, setFilter] = useState<HistoryFilter>("all");
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const exportingRef = useRef(false);
|
||||
const start = shiftIso(today, -(RANGE_DAYS - 1));
|
||||
const key = `${filter}|${start}|${today}`;
|
||||
|
||||
// A quest transition is a new history row (or changes a derived missed row);
|
||||
// a day-complete adds none. The refetch keeps the active filter and range.
|
||||
useTransitions((event: TransitionEvent) => {
|
||||
if (event.type === "nestquest_child_day_complete") return;
|
||||
setAttempt((n) => n + 1);
|
||||
});
|
||||
|
||||
/** Download the CSV for the active filter over the displayed range. */
|
||||
async function exportCsv() {
|
||||
if (exportingRef.current) return;
|
||||
exportingRef.current = true;
|
||||
setExporting(true);
|
||||
setExportError(null);
|
||||
try {
|
||||
const { blob, filename } = await exportHistoryCsv(filter, start, today);
|
||||
offerDownload(blob, filename);
|
||||
} catch (error: unknown) {
|
||||
setExportError(
|
||||
error instanceof ApiForbiddenError
|
||||
? error.message
|
||||
: "The CSV export failed. Check your connection and try again.",
|
||||
);
|
||||
} finally {
|
||||
exportingRef.current = false;
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// Rows already loaded for this (filter, range) stay on screen while they
|
||||
// refresh; any other rows give way to the loading state.
|
||||
const isCurrent = (prev: LoadState) => prev.kind === "ready" && prev.key === key;
|
||||
setState((prev) => (isCurrent(prev) ? prev : { kind: "loading" }));
|
||||
loadHistory(filter, start, today)
|
||||
.then((data) => {
|
||||
if (!cancelled) setState({ kind: "ready", data, key });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
const message =
|
||||
error instanceof ApiForbiddenError
|
||||
? error.message
|
||||
: "The history could not be loaded. Check your connection and try again.";
|
||||
// A failed refresh keeps the rows already on screen.
|
||||
setState((prev) => (isCurrent(prev) ? prev : { kind: "error", message }));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filter, start, today, key, attempt]);
|
||||
|
||||
// Never render one filter's rows under another's chip, even for the render
|
||||
// before the load effect runs.
|
||||
const shown: LoadState = state.kind === "ready" && state.key !== key ? { kind: "loading" } : state;
|
||||
|
||||
let body;
|
||||
if (shown.kind === "loading") {
|
||||
body = (
|
||||
<div className="history-status" role="status" data-testid="history-loading">
|
||||
Loading history…
|
||||
</div>
|
||||
);
|
||||
} else if (shown.kind === "error") {
|
||||
body = (
|
||||
<div className="history-status history-status--error" role="alert" data-testid="history-error">
|
||||
<p>{shown.message}</p>
|
||||
<button type="button" className="history-retry" onClick={() => setAttempt((n) => n + 1)}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const groups = groupByDay(shown.data.rows);
|
||||
body =
|
||||
groups.length > 0 ? (
|
||||
groups.map((group) => (
|
||||
<section key={group.day} className="history-day" data-testid={`day-${group.day}`}>
|
||||
<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} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
) : (
|
||||
<div className="history-card history-empty">No events in the last {RANGE_DAYS} days.</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="history-screen">
|
||||
<ScreenHeader exporting={exporting} onExport={() => void exportCsv()} />
|
||||
<div
|
||||
className="history-scroll"
|
||||
data-testid="history-scroll"
|
||||
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
|
||||
>
|
||||
{exportError !== null ? (
|
||||
<div className="history-export-error" role="alert" data-testid="history-export-error">
|
||||
<span>{exportError}</span>
|
||||
<button type="button" className="history-export-dismiss" onClick={() => setExportError(null)}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{shown.kind === "ready" ? <StatRow stats={shown.data.stats} /> : null}
|
||||
<div className="history-chips" role="group" aria-label="Filter events">
|
||||
{FILTERS.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={item.id === filter ? "history-chip history-chip--active" : "history-chip"}
|
||||
aria-pressed={item.id === filter}
|
||||
onClick={() => setFilter(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{body}
|
||||
<FooterNote />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Day grouping and row wording for the History tab.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
import type { HistoryRow } from "../api/history";
|
||||
import { dayNumber, isoFromDayNumber, localTodayIso } from "../schedule/cycle";
|
||||
import { shortDate } from "../schedule/format";
|
||||
import { formatClockTime, formatDueTime } from "../today/format";
|
||||
|
||||
const MS_PER_MINUTE = 60_000;
|
||||
|
||||
/** `iso` shifted by `days` calendar days. */
|
||||
export function shiftIso(iso: string, days: number): string {
|
||||
return isoFromDayNumber(dayNumber(iso) + days);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** The row's due moment as local wall-clock time, or null without a due time. */
|
||||
function dueMoment(row: HistoryRow): Date | 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]));
|
||||
}
|
||||
|
||||
/** Newest first; a missed row sorts at its due moment (or the end of its day). */
|
||||
function sortKey(row: HistoryRow): 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();
|
||||
}
|
||||
|
||||
export interface DayGroup {
|
||||
day: string;
|
||||
rows: HistoryRow[];
|
||||
}
|
||||
|
||||
/** Rows grouped by day, newest day first and newest row first within a day. */
|
||||
export function groupByDay(rows: HistoryRow[]): DayGroup[] {
|
||||
const groups = new Map<string, HistoryRow[]>();
|
||||
for (const row of rows) {
|
||||
const day = rowDay(row);
|
||||
groups.set(day, [...(groups.get(day) ?? []), row]);
|
||||
}
|
||||
return [...groups.entries()]
|
||||
.sort(([a], [b]) => b.localeCompare(a))
|
||||
.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)),
|
||||
}));
|
||||
}
|
||||
|
||||
/** "Today", "Yesterday", else "Mon, Sep 21" (the CSS uppercases it). */
|
||||
export function dayLabel(day: string, today: string): string {
|
||||
if (day === today) return "Today";
|
||||
if (day === shiftIso(today, -1)) return "Yesterday";
|
||||
const weekday = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" }).format(
|
||||
new Date(dayNumber(day) * 86_400_000),
|
||||
);
|
||||
return `${weekday}, ${shortDate(day)}`;
|
||||
}
|
||||
|
||||
/** "on time", "late 22 min", or "late" when the lateness is not derivable. */
|
||||
function onTimeFlag(row: HistoryRow): 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);
|
||||
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);
|
||||
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);
|
||||
return row.due_time ? formatDueTime(row.due_time) : "";
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Inline stroke glyphs for the History tab (Lucide-style 24px grid),
|
||||
* CDN-free like the other tabs' glyphs.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
function Glyph({ size, children }: { size: number; children: ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadGlyph({ size = 15 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function LockGlyph({ size = 15 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/* NestQuest Admin — global tokens + base.
|
||||
Plain CubeCraft styling, mobile-first, 390x844 reference
|
||||
(design/ADMIN-SPEC.md). Values mirror design/tokens/nestquest-admin-tokens.css,
|
||||
re-scoped to :root for the standalone PWA. */
|
||||
|
||||
:root {
|
||||
--nq-brand-blue: #3b3ab8;
|
||||
--nq-brand-purple: #a035cc;
|
||||
--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));
|
||||
--nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple));
|
||||
|
||||
--nq-a-page: #f4f4f6;
|
||||
--nq-a-surface: #ffffff;
|
||||
--nq-a-selected: #ebebf8;
|
||||
--nq-a-border: #e4e4ea;
|
||||
--nq-a-divider: #ededf1;
|
||||
|
||||
--nq-a-ink: #18181d;
|
||||
--nq-a-ink-secondary: #52525e;
|
||||
--nq-a-ink-tertiary: #70707e;
|
||||
|
||||
--nq-a-success: #15803d;
|
||||
--nq-a-danger: #dc2626;
|
||||
--nq-a-danger-strong: #b91c1c;
|
||||
--nq-a-danger-bg: #fee2e2;
|
||||
--nq-a-warning-ink: #92400e;
|
||||
--nq-a-warning-ink-2: #78350f;
|
||||
--nq-a-warning-bg: #fef3c7;
|
||||
--nq-a-warning-border: rgba(217, 119, 6, 0.35);
|
||||
--nq-a-info-bg: #dbeafe;
|
||||
--nq-a-info-ink: #1e3a8a;
|
||||
--nq-a-info-icon: #1d4ed8;
|
||||
--nq-a-reversal: #7d28a0;
|
||||
|
||||
--nq-a-font: Nunito, system-ui, sans-serif;
|
||||
--nq-a-size-screen: 24px;
|
||||
--nq-a-size-stat: 22px;
|
||||
--nq-a-size-row: 13.5px;
|
||||
--nq-a-size-meta: 11px;
|
||||
--nq-a-size-label: 10px;
|
||||
--nq-a-track-label: 0.1em;
|
||||
|
||||
--nq-a-density-gap: 10px;
|
||||
--nq-a-density-card-pad: 13px;
|
||||
--nq-a-density-row-pad: 11px 13px;
|
||||
--nq-a-density-radius: 8px;
|
||||
|
||||
--nq-a-tabbar-height: 70px;
|
||||
--nq-a-tap-min: 44px;
|
||||
--nq-a-radius-pill: 9999px;
|
||||
--nq-a-radius-sheet: 20px 20px 0 0;
|
||||
--nq-a-sheet-shadow: 0 -8px 28px rgba(9, 9, 11, 0.18);
|
||||
--nq-a-progress-height: 6px;
|
||||
|
||||
--nq-ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--nq-dur-base: 200ms;
|
||||
--nq-dur-modal: 350ms;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--nq-a-font);
|
||||
background: var(--nq-a-page);
|
||||
color: var(--nq-a-ink);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import AppRoutes from "./AppRoutes";
|
||||
import "./index.css";
|
||||
import { registerServiceWorker } from "./pwa/register";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<AppRoutes />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
void registerServiceWorker();
|
||||
@@ -0,0 +1,396 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
REVISION_MARKER,
|
||||
STATIC_PRECACHE_FILES,
|
||||
injectServiceWorker,
|
||||
} from "../../vite-sw-plugin";
|
||||
import { registerServiceWorker } from "./register";
|
||||
|
||||
const adminRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const adminPath = (rel: string) => resolve(adminRoot, rel);
|
||||
const publicPath = (urlPath: string) => adminPath(`public${urlPath}`);
|
||||
|
||||
interface ManifestIcon {
|
||||
src: string;
|
||||
sizes: string;
|
||||
type: string;
|
||||
purpose?: string;
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(readFileSync(publicPath("/manifest.webmanifest"), "utf8"));
|
||||
const icons: ManifestIcon[] = manifest.icons;
|
||||
const swSource = readFileSync(publicPath("/sw.js"), "utf8");
|
||||
|
||||
const sizeOf = (icon: ManifestIcon) => Number(icon.sizes.split("x")[0]);
|
||||
const purposes = (icon: ManifestIcon) => (icon.purpose ?? "any").split(/\s+/);
|
||||
|
||||
describe("web app manifest", () => {
|
||||
it("meets the installability criteria", () => {
|
||||
expect(manifest.name).toBe("NestQuest Admin");
|
||||
expect(manifest.short_name).toBe("NestQuest");
|
||||
expect(manifest.start_url).toBe("/");
|
||||
expect(manifest.scope).toBe("/");
|
||||
expect(manifest.display).toBe("standalone");
|
||||
expect(manifest.background_color).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
expect(manifest.theme_color).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
|
||||
const any = icons.filter((i) => purposes(i).includes("any"));
|
||||
expect(any.some((i) => sizeOf(i) >= 192)).toBe(true);
|
||||
expect(any.some((i) => sizeOf(i) >= 512)).toBe(true);
|
||||
expect(icons.some((i) => purposes(i).includes("maskable"))).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the admin design token colours", () => {
|
||||
const css = readFileSync(adminPath("src/index.css"), "utf8");
|
||||
expect(css).toContain(`--nq-brand-blue: ${manifest.theme_color};`);
|
||||
expect(css).toContain(`--nq-a-page: ${manifest.background_color};`);
|
||||
});
|
||||
|
||||
it.each(icons.map((i) => [i.src, i] as const))(
|
||||
"icon %s is a real PNG of its declared size",
|
||||
(_src, icon) => {
|
||||
const path = publicPath(icon.src);
|
||||
expect(existsSync(path)).toBe(true);
|
||||
const png = readFileSync(path);
|
||||
expect([...png.subarray(0, 8)]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
expect(png.toString("ascii", 12, 16)).toBe("IHDR");
|
||||
const [w, h] = icon.sizes.split("x").map(Number);
|
||||
expect(png.readUInt32BE(16)).toBe(w);
|
||||
expect(png.readUInt32BE(20)).toBe(h);
|
||||
expect(icon.type).toBe("image/png");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("index.html and entry point", () => {
|
||||
const html = readFileSync(adminPath("index.html"), "utf8");
|
||||
|
||||
it("links the manifest, theme colour and apple touch icon", () => {
|
||||
expect(html).toContain('<link rel="manifest" href="/manifest.webmanifest" />');
|
||||
expect(html).toContain(`<meta name="theme-color" content="${manifest.theme_color}" />`);
|
||||
expect(html).toContain('<link rel="apple-touch-icon" href="/icons/icon-192.png" />');
|
||||
});
|
||||
|
||||
it("main.tsx registers the service worker", () => {
|
||||
const main = readFileSync(adminPath("src/main.tsx"), "utf8");
|
||||
expect(main).toMatch(/import \{ registerServiceWorker \} from "\.\/pwa\/register"/);
|
||||
expect(main).toMatch(/registerServiceWorker\(\)/);
|
||||
});
|
||||
});
|
||||
|
||||
// Minimal service-worker global scope: runs public/sw.js against in-memory
|
||||
// caches and a stubbed network, so handler behaviour is checked without I/O.
|
||||
function loadServiceWorker(
|
||||
network: (url: string) => Promise<FakeResponse>,
|
||||
source: string = swSource,
|
||||
) {
|
||||
const listeners: Record<string, (event: any) => void> = {};
|
||||
const stores = new Map<string, Map<string, FakeResponse>>();
|
||||
const openStore = (name: string) => {
|
||||
if (!stores.has(name)) stores.set(name, new Map());
|
||||
return stores.get(name)!;
|
||||
};
|
||||
const keyOf = (req: string | { url: string }) =>
|
||||
new URL(typeof req === "string" ? req : req.url, "https://admin.test").href;
|
||||
const fetchFn = vi.fn((req: string | { url: string }) => network(keyOf(req)));
|
||||
|
||||
const caches = {
|
||||
open: async (name: string) => {
|
||||
const store = openStore(name);
|
||||
return {
|
||||
addAll: async (urls: string[]) => {
|
||||
for (const u of urls) store.set(keyOf(u), await network(keyOf(u)));
|
||||
},
|
||||
put: async (req: { url: string }, res: FakeResponse) => void store.set(keyOf(req), res),
|
||||
};
|
||||
},
|
||||
keys: async () => [...stores.keys()],
|
||||
delete: async (name: string) => stores.delete(name),
|
||||
match: async (req: string | { url: string }, opts?: { cacheName?: string }) => {
|
||||
const names = opts?.cacheName ? [opts.cacheName] : [...stores.keys()];
|
||||
for (const n of names) {
|
||||
const hit = stores.get(n)?.get(keyOf(req));
|
||||
if (hit) return hit;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
const self = {
|
||||
location: new URL("https://admin.test/"),
|
||||
addEventListener: (type: string, fn: (event: any) => void) => (listeners[type] = fn),
|
||||
skipWaiting: vi.fn(async () => undefined),
|
||||
clients: { claim: vi.fn(async () => undefined) },
|
||||
};
|
||||
const ResponseStub = { error: () => new FakeResponse("", false, "error") };
|
||||
new Function("self", "caches", "fetch", "Response", source)(self, caches, fetchFn, ResponseStub);
|
||||
|
||||
const dispatch = async (type: string, init: object = {}) => {
|
||||
let pending: Promise<unknown> | undefined;
|
||||
const event = {
|
||||
...init,
|
||||
waitUntil: (p: Promise<unknown>) => (pending = p),
|
||||
respondWith: (p: Promise<unknown>) => (pending = p),
|
||||
};
|
||||
listeners[type](event);
|
||||
return { responded: pending !== undefined, result: await pending };
|
||||
};
|
||||
return { listeners, stores, self, fetchFn, dispatch, openStore };
|
||||
}
|
||||
|
||||
class FakeResponse {
|
||||
constructor(
|
||||
readonly body: string,
|
||||
readonly ok = true,
|
||||
readonly type = "basic",
|
||||
) {}
|
||||
clone() {
|
||||
return new FakeResponse(this.body, this.ok, this.type);
|
||||
}
|
||||
}
|
||||
|
||||
const request = (url: string, extra: object = {}) => ({
|
||||
url: new URL(url, "https://admin.test").href,
|
||||
method: "GET",
|
||||
mode: "cors",
|
||||
destination: "",
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe("service worker (public/sw.js)", () => {
|
||||
it("is a static asset served from the site root", () => {
|
||||
expect(existsSync(publicPath("/sw.js"))).toBe(true);
|
||||
expect(swSource).toMatch(/const SHELL_CACHE = /);
|
||||
expect(swSource).toMatch(/addEventListener\("install"/);
|
||||
expect(swSource).toMatch(/addEventListener\("activate"/);
|
||||
expect(swSource).toMatch(/addEventListener\("fetch"/);
|
||||
});
|
||||
|
||||
it("precaches the shell on install, prunes stale caches on activate", async () => {
|
||||
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
|
||||
sw.openStore("nestquest-admin-shell-v0");
|
||||
|
||||
await sw.dispatch("install");
|
||||
expect(sw.self.skipWaiting).toHaveBeenCalled();
|
||||
const precached = [...sw.stores.values()].flatMap((s) => [...s.keys()]);
|
||||
for (const path of ["/", "/index.html", "/manifest.webmanifest", ...icons.map((i) => i.src)]) {
|
||||
expect(precached).toContain(`https://admin.test${path}`);
|
||||
}
|
||||
|
||||
await sw.dispatch("activate");
|
||||
expect(sw.stores.has("nestquest-admin-shell-v0")).toBe(false);
|
||||
expect(sw.self.clients.claim).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves the cached shell for navigations when offline", async () => {
|
||||
let online = true;
|
||||
const sw = loadServiceWorker(async (url) => {
|
||||
if (!online) throw new TypeError("Failed to fetch");
|
||||
return new FakeResponse(`net:${url}`);
|
||||
});
|
||||
await sw.dispatch("install");
|
||||
online = false;
|
||||
|
||||
const { result } = await sw.dispatch("fetch", {
|
||||
request: request("/children/42", { mode: "navigate", destination: "document" }),
|
||||
});
|
||||
expect((result as FakeResponse).body).toBe("net:https://admin.test/index.html");
|
||||
});
|
||||
|
||||
it("serves static assets cache-first and fills the runtime cache", async () => {
|
||||
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
|
||||
const asset = request("/assets/index-abc123.js", { destination: "script" });
|
||||
|
||||
const first = await sw.dispatch("fetch", { request: asset });
|
||||
expect((first.result as FakeResponse).body).toBe("net:https://admin.test/assets/index-abc123.js");
|
||||
expect(sw.fetchFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
const second = await sw.dispatch("fetch", { request: asset });
|
||||
expect((second.result as FakeResponse).body).toBe("net:https://admin.test/assets/index-abc123.js");
|
||||
expect(sw.fetchFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("never handles cross-origin, non-GET or non-static requests", async () => {
|
||||
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
|
||||
const cases = [
|
||||
request("https://auth.example.com/logo.png", { destination: "image" }),
|
||||
request("/assets/app.js", { destination: "script", method: "POST" }),
|
||||
request("/api/admin/children"),
|
||||
];
|
||||
for (const req of cases) {
|
||||
const { responded } = await sw.dispatch("fetch", { request: req });
|
||||
expect(responded).toBe(false);
|
||||
}
|
||||
expect(sw.fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// A Vite-style built index.html referencing hashed bundles.
|
||||
const builtIndexHtml = (hash: string) => `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<script type="module" crossorigin src="/assets/index-${hash}.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-${hash}.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-${hash}.css">
|
||||
<link rel="preconnect" href="https://auth.example.com" />
|
||||
</head>
|
||||
<body><div id="root"></div></body>
|
||||
</html>`;
|
||||
|
||||
// Same-origin script/link URLs of an HTML document, parsed independently of
|
||||
// the build plugin so the regression check does not trust its own extractor.
|
||||
const referencedAssets = (html: string) =>
|
||||
[...new DOMParser().parseFromString(html, "text/html").querySelectorAll("script[src], link[href]")]
|
||||
.map((el) => new URL(el.getAttribute("src") ?? el.getAttribute("href")!, "https://admin.test/"))
|
||||
.filter((url) => url.origin === "https://admin.test")
|
||||
.map((url) => url.href);
|
||||
|
||||
// The unhashed files the build hashes into the revision, read from public/.
|
||||
const staticFiles = STATIC_PRECACHE_FILES.map((path) => ({
|
||||
path,
|
||||
bytes: readFileSync(publicPath(`/${path}`)),
|
||||
}));
|
||||
const revisionOf = (src: string) => /const REVISION = "([0-9a-f]+)";/.exec(src)?.[1];
|
||||
|
||||
describe("build-time service worker injection", () => {
|
||||
it("precaches every asset the cached index.html references on install (PWA-001)", async () => {
|
||||
const html = builtIndexHtml("Ab12Cd34");
|
||||
const built = injectServiceWorker(html, swSource, staticFiles);
|
||||
const sw = loadServiceWorker(async (url) =>
|
||||
new FakeResponse(url.endsWith("/index.html") ? html : `net:${url}`),
|
||||
built);
|
||||
|
||||
await sw.dispatch("install");
|
||||
const shell = [...sw.stores.entries()].find(([name]) => name.startsWith("nestquest-admin-shell-"));
|
||||
expect(shell).toBeDefined();
|
||||
const [, shellStore] = shell!;
|
||||
|
||||
const cachedHtml = shellStore.get("https://admin.test/index.html")!.body;
|
||||
const assets = referencedAssets(cachedHtml);
|
||||
expect(assets).toEqual(
|
||||
expect.arrayContaining([
|
||||
"https://admin.test/assets/index-Ab12Cd34.js",
|
||||
"https://admin.test/assets/vendor-Ab12Cd34.js",
|
||||
"https://admin.test/assets/index-Ab12Cd34.css",
|
||||
]),
|
||||
);
|
||||
for (const asset of assets) expect([...shellStore.keys()]).toContain(asset);
|
||||
expect([...shellStore.keys()]).not.toContain("https://auth.example.com/");
|
||||
});
|
||||
|
||||
it("derives a new revision for different index.html content (PWA-002)", () => {
|
||||
const a = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
|
||||
const b = injectServiceWorker(builtIndexHtml("bbbb2222"), swSource, staticFiles);
|
||||
|
||||
expect(a).not.toContain(REVISION_MARKER);
|
||||
expect(b).not.toContain(REVISION_MARKER);
|
||||
expect(revisionOf(a)).toMatch(/^[0-9a-f]{12}$/);
|
||||
expect(revisionOf(b)).toMatch(/^[0-9a-f]{12}$/);
|
||||
expect(revisionOf(a)).not.toBe(revisionOf(b));
|
||||
expect(injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles)).toBe(a);
|
||||
});
|
||||
|
||||
it("derives a new revision when only a precached static file changes (PWA-002)", () => {
|
||||
const html = builtIndexHtml("aaaa1111");
|
||||
const base = revisionOf(injectServiceWorker(html, swSource, staticFiles));
|
||||
const withChanged = (path: string) =>
|
||||
staticFiles.map((f) =>
|
||||
f.path === path ? { path, bytes: Buffer.concat([f.bytes, Buffer.from([0])]) } : f,
|
||||
);
|
||||
|
||||
expect(revisionOf(injectServiceWorker(html, swSource, [...staticFiles].reverse()))).toBe(base);
|
||||
for (const path of STATIC_PRECACHE_FILES) {
|
||||
const changed = revisionOf(injectServiceWorker(html, swSource, withChanged(path)));
|
||||
expect(changed, path).toMatch(/^[0-9a-f]{12}$/);
|
||||
expect(changed, path).not.toBe(base);
|
||||
}
|
||||
});
|
||||
|
||||
it("hashes every unhashed URL sw.js precaches (PWA-002)", () => {
|
||||
const shellUrls = [...swSource.matchAll(/^\s*"(\/[^"]*)",$/gm)].map((m) => m[1]);
|
||||
const fixed = shellUrls.filter((url) => url !== "/" && url !== "/index.html");
|
||||
expect(fixed.map((url) => url.slice(1)).sort()).toEqual([...STATIC_PRECACHE_FILES].sort());
|
||||
});
|
||||
|
||||
it("names both caches after the build revision (PWA-002)", async () => {
|
||||
const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
|
||||
const revision = /const REVISION = "([0-9a-f]+)";/.exec(built)![1];
|
||||
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built);
|
||||
|
||||
await sw.dispatch("install");
|
||||
await sw.dispatch("fetch", { request: request("/assets/late.js", { destination: "script" }) });
|
||||
expect([...sw.stores.keys()].sort()).toEqual([
|
||||
`nestquest-admin-runtime-${revision}`,
|
||||
`nestquest-admin-shell-${revision}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails fast when sw.js lacks the placeholders", () => {
|
||||
expect(() => injectServiceWorker(builtIndexHtml("x"), "const SHELL_URLS = [];", staticFiles)).toThrow();
|
||||
});
|
||||
|
||||
it("activation deletes only stale nestquest-admin caches (PWA-003)", async () => {
|
||||
const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
|
||||
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built);
|
||||
for (const name of [
|
||||
"nestquest-admin-shell-old",
|
||||
"nestquest-admin-runtime-old",
|
||||
"some-other-app-cache",
|
||||
]) {
|
||||
sw.openStore(name);
|
||||
}
|
||||
|
||||
await sw.dispatch("install");
|
||||
await sw.dispatch("activate");
|
||||
const names = [...sw.stores.keys()];
|
||||
expect(names).not.toContain("nestquest-admin-shell-old");
|
||||
expect(names).not.toContain("nestquest-admin-runtime-old");
|
||||
expect(names).toContain("some-other-app-cache");
|
||||
expect(names.some((n) => n.startsWith("nestquest-admin-shell-") && n !== "nestquest-admin-shell-old")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerServiceWorker", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const stubServiceWorker = (register: ReturnType<typeof vi.fn>) =>
|
||||
vi.stubGlobal("navigator", { ...navigator, serviceWorker: { register } });
|
||||
|
||||
it("does not register outside production builds", async () => {
|
||||
const register = vi.fn();
|
||||
stubServiceWorker(register);
|
||||
expect(import.meta.env.PROD).toBe(false);
|
||||
await registerServiceWorker();
|
||||
expect(register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers /sw.js in production", async () => {
|
||||
vi.stubEnv("PROD", true);
|
||||
const register = vi.fn().mockResolvedValue({});
|
||||
stubServiceWorker(register);
|
||||
await registerServiceWorker();
|
||||
expect(register).toHaveBeenCalledWith("/sw.js", { scope: "/" });
|
||||
});
|
||||
|
||||
it("logs instead of throwing when registration fails", async () => {
|
||||
vi.stubEnv("PROD", true);
|
||||
stubServiceWorker(vi.fn().mockRejectedValue(new Error("boom")));
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
await expect(registerServiceWorker()).resolves.toBeUndefined();
|
||||
expect(error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when the browser lacks service workers", async () => {
|
||||
vi.stubEnv("PROD", true);
|
||||
vi.stubGlobal("navigator", {});
|
||||
await expect(registerServiceWorker()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Service worker registration for the installable admin PWA.
|
||||
* Registers /sw.js only in production builds, so dev servers and tests never
|
||||
* run a worker. Failures are logged, never thrown — the app works without it.
|
||||
*/
|
||||
export async function registerServiceWorker(): Promise<void> {
|
||||
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
|
||||
try {
|
||||
await navigator.serviceWorker.register("/sw.js", { scope: "/" });
|
||||
} catch (error) {
|
||||
console.error("Service worker registration failed:", error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/* Presence override editor — roomy density pushed screen
|
||||
(design/ADMIN-SPEC.md §6). Covers the tab bar like a pushed route. */
|
||||
|
||||
.override-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
overflow-y: auto;
|
||||
background: var(--nq-a-page);
|
||||
}
|
||||
|
||||
.override-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
background: var(--nq-a-surface);
|
||||
border-bottom: 1px solid var(--nq-a-border);
|
||||
}
|
||||
|
||||
.override-back {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: var(--nq-a-tap-min);
|
||||
height: var(--nq-a-tap-min);
|
||||
margin: -6px 0 -6px -12px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--nq-a-ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.override-title {
|
||||
margin: 0;
|
||||
font-size: var(--nq-a-size-screen);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.override-sub {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.override-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 16px 24px;
|
||||
}
|
||||
|
||||
/* Form card: roomy density, 18px padding, 16px gaps. */
|
||||
.override-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 18px;
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.override-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.override-label {
|
||||
font-size: var(--nq-a-size-label);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--nq-a-track-label);
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.override-segments {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.override-segments--3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.override-segments--2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.override-segment {
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: 10px;
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.override-segment--gradient[aria-pressed="true"] {
|
||||
border-color: transparent;
|
||||
background: var(--nq-brand-gradient);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.override-segment--ink[aria-pressed="true"] {
|
||||
border-color: #18181d;
|
||||
background: #18181d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.override-dates {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.override-input {
|
||||
width: 100%;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: 13px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: 10px;
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.override-date {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Consequence warning */
|
||||
.override-warning {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--nq-a-warning-border);
|
||||
border-radius: 12px;
|
||||
background: var(--nq-a-warning-bg);
|
||||
}
|
||||
|
||||
.override-warning-glyph {
|
||||
flex: none;
|
||||
display: flex;
|
||||
color: var(--nq-a-warning-ink);
|
||||
}
|
||||
|
||||
.override-warning-headline {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
color: var(--nq-a-warning-ink);
|
||||
}
|
||||
|
||||
.override-warning-body {
|
||||
margin: 3px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-warning-ink-2);
|
||||
}
|
||||
|
||||
.override-error {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-danger-bg);
|
||||
color: var(--nq-a-danger-strong);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Footer: Cancel (flex 1) + Save override (flex 2), 15px vertical padding. */
|
||||
.override-footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.override-cancel,
|
||||
.override-save {
|
||||
padding: 15px 12px;
|
||||
border-radius: 12px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.override-cancel {
|
||||
flex: 1;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
}
|
||||
|
||||
.override-save {
|
||||
flex: 2;
|
||||
border: 0;
|
||||
background: var(--nq-brand-gradient);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.override-cancel:disabled,
|
||||
.override-save:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import App from "../App";
|
||||
import ScheduleTab from "./ScheduleTab";
|
||||
import { CONSEQUENCE_DEBOUNCE_MS, isIsoDate, isValidRange } from "./OverrideEditor";
|
||||
import type { AdminChild } from "../api/definitions";
|
||||
import type { PresenceOverride, PresenceSchedule } from "../api/presence";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
|
||||
function child(id: number, display_name: string, sort_order: number): AdminChild {
|
||||
return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active: true };
|
||||
}
|
||||
|
||||
const CHILDREN = [child(1, "Declan", 1), child(2, "Maeve", 2), child(3, "Rory", 3)];
|
||||
|
||||
const DECLAN: PresenceSchedule = {
|
||||
child_id: 1,
|
||||
cycle_length_weeks: 2,
|
||||
anchor_date: "2026-09-07",
|
||||
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] },
|
||||
};
|
||||
|
||||
const INITIAL_OVERRIDES: PresenceOverride[] = [
|
||||
{ id: 41, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: "Dentist trip" },
|
||||
];
|
||||
|
||||
const TODAY = "2026-09-23";
|
||||
const OVERRIDES_PATH = "/api/v1/admin/presence-overrides";
|
||||
const CONSEQUENCE_PATH = `${OVERRIDES_PATH}/consequence`;
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
interface Call {
|
||||
method: string;
|
||||
path: string;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
let overrides: PresenceOverride[];
|
||||
let consequence: (body: Record<string, unknown>) => Response | Promise<Response>;
|
||||
let createResponse: (body: Record<string, unknown>) => Response;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function calls(): Call[] {
|
||||
return fetchMock.mock.calls.map(([url, init]: [string, RequestInit | undefined]) => ({
|
||||
method: init?.method ?? "GET",
|
||||
path: new URL(String(url), "http://x").pathname,
|
||||
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function consequenceBodies(): unknown[] {
|
||||
return calls()
|
||||
.filter((c) => c.path === CONSEQUENCE_PATH)
|
||||
.map((c) => c.body);
|
||||
}
|
||||
|
||||
async function routeFetch(url: string, init: RequestInit = {}): Promise<Response> {
|
||||
const path = new URL(url, "http://x").pathname;
|
||||
const method = init.method ?? "GET";
|
||||
const body = init.body ? JSON.parse(String(init.body)) : undefined;
|
||||
if (path === CONSEQUENCE_PATH && method === "POST") return consequence(body);
|
||||
if (path === OVERRIDES_PATH && method === "POST") return createResponse(body);
|
||||
const deleteMatch = /\/presence-overrides\/(\d+)$/.exec(path);
|
||||
if (deleteMatch && method === "DELETE") {
|
||||
overrides = overrides.filter((o) => o.id !== Number(deleteMatch[1]));
|
||||
return jsonResponse({ status: "ok" });
|
||||
}
|
||||
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
|
||||
const scheduleMatch = /\/children\/(\d+)\/presence-schedule$/.exec(path);
|
||||
if (scheduleMatch) {
|
||||
return jsonResponse({ schedule: Number(scheduleMatch[1]) === 1 ? DECLAN : null });
|
||||
}
|
||||
if (path === OVERRIDES_PATH) return jsonResponse({ overrides });
|
||||
return jsonResponse({ detail: "unexpected" }, 500);
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve!: (response: Response) => void;
|
||||
const promise = new Promise<Response>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const pastDebounce = () => new Promise((r) => setTimeout(r, CONSEQUENCE_DEBOUNCE_MS + 150));
|
||||
|
||||
function headline(): string {
|
||||
return screen.getByTestId("override-consequence-headline").textContent ?? "";
|
||||
}
|
||||
|
||||
function warningBody(): string {
|
||||
return screen.getByTestId("override-consequence").querySelector(".override-warning-body")?.textContent ?? "";
|
||||
}
|
||||
|
||||
function saveButton(): HTMLButtonElement {
|
||||
return screen.getByRole("button", { name: "Save override" }) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
function setDate(label: "From" | "Through", value: string) {
|
||||
fireEvent.change(screen.getByLabelText(label), { target: { value } });
|
||||
}
|
||||
|
||||
async function openEditor() {
|
||||
render(<ScheduleTab today={TODAY} />);
|
||||
await screen.findByTestId("pattern-1");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||
await screen.findByRole("heading", { name: "Presence override" });
|
||||
}
|
||||
|
||||
describe("OverrideEditor", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
overrides = [...INITIAL_OVERRIDES];
|
||||
consequence = () => jsonResponse({ removed: 4 });
|
||||
createResponse = (body) => {
|
||||
const created: PresenceOverride = {
|
||||
id: 50,
|
||||
child_id: body.child_id as number,
|
||||
start_date: body.start_date as string,
|
||||
end_date: body.end_date as string,
|
||||
is_present: body.is_present as boolean,
|
||||
note: (body.note as string | undefined) ?? null,
|
||||
};
|
||||
overrides = [...overrides, created];
|
||||
return jsonResponse(created, 201);
|
||||
};
|
||||
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders the pushed screen per ADMIN-SPEC §6", async () => {
|
||||
await openEditor();
|
||||
expect(screen.getByText("Beats the custody pattern for these dates")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Back" })).toBeTruthy();
|
||||
const kids = within(screen.getByRole("group", { name: "Child" })).getAllByRole("button");
|
||||
expect(kids.map((b) => [b.textContent, b.getAttribute("aria-pressed")])).toEqual([
|
||||
["Declan", "true"],
|
||||
["Maeve", "false"],
|
||||
["Rory", "false"],
|
||||
]);
|
||||
const status = within(screen.getByRole("group", { name: "Status for these dates" }));
|
||||
expect(status.getByRole("button", { name: "Away" }).getAttribute("aria-pressed")).toBe("true");
|
||||
expect(status.getByRole("button", { name: "Home" }).getAttribute("aria-pressed")).toBe("false");
|
||||
expect((screen.getByLabelText("From") as HTMLInputElement).value).toBe(TODAY);
|
||||
expect((screen.getByLabelText("Through") as HTMLInputElement).value).toBe(TODAY);
|
||||
expect(screen.getByLabelText("Reason (optional)")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the live consequence count from the API and recounts on every change", async () => {
|
||||
await openEditor();
|
||||
// Save is off until the count is on screen: the warning precedes saving.
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
await waitFor(() => expect(headline()).toBe("This removes 4 upcoming tasks"));
|
||||
expect(consequenceBodies()).toEqual([
|
||||
{ child_id: 1, start_date: TODAY, end_date: TODAY, is_present: false },
|
||||
]);
|
||||
expect(warningBody()).toBe(
|
||||
"Declan's open tasks Sep 23 are removed while away. Completed days are untouched.",
|
||||
);
|
||||
expect(saveButton().disabled).toBe(false);
|
||||
|
||||
consequence = () => jsonResponse({ removed: 9 });
|
||||
setDate("Through", "2026-09-27");
|
||||
await waitFor(() => expect(headline()).toBe("This removes 9 upcoming tasks"));
|
||||
expect(consequenceBodies()[1]).toEqual({
|
||||
child_id: 1,
|
||||
start_date: TODAY,
|
||||
end_date: "2026-09-27",
|
||||
is_present: false,
|
||||
});
|
||||
expect(warningBody()).toBe(
|
||||
"Declan's open tasks Sep 23 – 27 are removed while away. Completed days are untouched.",
|
||||
);
|
||||
|
||||
consequence = () => jsonResponse({ removed: 2 });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
|
||||
await waitFor(() => expect(headline()).toBe("This removes 2 upcoming tasks"));
|
||||
expect(consequenceBodies()[2]).toEqual({
|
||||
child_id: 2,
|
||||
start_date: TODAY,
|
||||
end_date: "2026-09-27",
|
||||
is_present: false,
|
||||
});
|
||||
expect(warningBody()).toBe(
|
||||
"Maeve's open tasks Sep 23 – 27 are removed while away. Completed days are untouched.",
|
||||
);
|
||||
|
||||
// A Home override removes nothing by definition: no request, count 0.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Home" }));
|
||||
expect(headline()).toBe("This removes 0 upcoming tasks");
|
||||
expect(warningBody()).toBe(
|
||||
"Maeve is home Sep 23 – 27, so no tasks are removed. Completed days are untouched.",
|
||||
);
|
||||
await pastDebounce();
|
||||
expect(consequenceBodies()).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("renders the API's count verbatim — completed instances are excluded server-side", async () => {
|
||||
// The range holds 3 open instances and 1 already completed. The API
|
||||
// excludes the completed one (D-005; proven by
|
||||
// tests/test_api_admin_presence_consequence.py::test_completed_instance_in_range_is_not_counted
|
||||
// and ::test_count_matches_a_direct_materialise_comparison), so it answers 3,
|
||||
// and the editor shows exactly that — it never counts days on its own.
|
||||
consequence = () => jsonResponse({ removed: 3 });
|
||||
await openEditor();
|
||||
setDate("Through", "2026-09-25");
|
||||
await waitFor(() => expect(headline()).toBe("This removes 3 upcoming tasks"));
|
||||
expect(warningBody()).toBe(
|
||||
"Declan's open tasks Sep 23 – 25 are removed while away. Completed days are untouched.",
|
||||
);
|
||||
});
|
||||
|
||||
it("a stale consequence response never overwrites a newer one", async () => {
|
||||
const first = deferred();
|
||||
const second = deferred();
|
||||
const pending = [first, second];
|
||||
consequence = () => pending.shift()!.promise;
|
||||
await openEditor();
|
||||
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
|
||||
|
||||
setDate("Through", "2026-09-26");
|
||||
await waitFor(() => expect(consequenceBodies()).toHaveLength(2));
|
||||
|
||||
second.resolve(jsonResponse({ removed: 5 }));
|
||||
await waitFor(() => expect(headline()).toBe("This removes 5 upcoming tasks"));
|
||||
first.resolve(jsonResponse({ removed: 99 }));
|
||||
await pastDebounce();
|
||||
expect(headline()).toBe("This removes 5 upcoming tasks");
|
||||
});
|
||||
|
||||
it("debounces rapid edits into one request for the final range", async () => {
|
||||
await openEditor();
|
||||
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
|
||||
setDate("Through", "2026-09-24");
|
||||
setDate("Through", "2026-09-25");
|
||||
setDate("Through", "2026-09-26");
|
||||
await pastDebounce();
|
||||
expect(consequenceBodies().slice(1)).toEqual([
|
||||
{ child_id: 1, start_date: TODAY, end_date: "2026-09-26", is_present: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("an incomplete or reversed range sends no consequence request and cannot be saved", async () => {
|
||||
await openEditor();
|
||||
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
|
||||
|
||||
setDate("Through", "");
|
||||
expect(headline()).toBe("Choose a valid date range");
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
await pastDebounce();
|
||||
expect(consequenceBodies()).toHaveLength(1);
|
||||
|
||||
setDate("Through", "2026-09-20"); // before From
|
||||
expect(headline()).toBe("Choose a valid date range");
|
||||
await pastDebounce();
|
||||
expect(consequenceBodies()).toHaveLength(1);
|
||||
fireEvent.click(saveButton());
|
||||
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
|
||||
});
|
||||
|
||||
it("a malformed From date sends no consequence request and cannot be saved", async () => {
|
||||
await openEditor();
|
||||
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
|
||||
|
||||
setDate("From", "2026-02-30");
|
||||
expect(headline()).toBe("Choose a valid date range");
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
await pastDebounce();
|
||||
expect(consequenceBodies()).toHaveLength(1);
|
||||
fireEvent.click(saveButton());
|
||||
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
|
||||
});
|
||||
|
||||
it("Save sends the exact POST body, closes the editor and shows the new row", async () => {
|
||||
await openEditor();
|
||||
setDate("From", "2026-09-24");
|
||||
setDate("Through", "2026-09-26");
|
||||
fireEvent.change(screen.getByLabelText("Reason (optional)"), {
|
||||
target: { value: " Trip with grandparents " },
|
||||
});
|
||||
await waitFor(() => expect(saveButton().disabled).toBe(false));
|
||||
const before = calls().length;
|
||||
const schedulesBefore = calls().filter((c) => c.path.endsWith("/presence-schedule")).length;
|
||||
|
||||
fireEvent.click(saveButton());
|
||||
const row = await screen.findByTestId("override-50");
|
||||
expect(row.textContent).toContain("Declan away · Sep 24 – 26");
|
||||
expect(row.textContent).toContain("Trip with grandparents");
|
||||
expect(screen.queryByRole("heading", { name: "Presence override" })).toBeNull();
|
||||
|
||||
const after = calls().slice(before);
|
||||
const writes = after.filter((c) => c.method !== "GET");
|
||||
// D-005: saving sends ONLY the create request — never a delete of an
|
||||
// instance or an override. The API's regenerate path keeps completed
|
||||
// instances (tests/test_materialize.py::
|
||||
// test_regenerate_for_definition_rebuilds_horizon_and_preserves_completed).
|
||||
expect(writes).toEqual([
|
||||
{
|
||||
method: "POST",
|
||||
path: OVERRIDES_PATH,
|
||||
body: {
|
||||
child_id: 1,
|
||||
start_date: "2026-09-24",
|
||||
end_date: "2026-09-26",
|
||||
is_present: false,
|
||||
note: "Trip with grandparents",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(after.some((c) => c.method === "DELETE")).toBe(false);
|
||||
// The overrides and the presence schedules are refetched for the grid.
|
||||
expect(after.some((c) => c.method === "GET" && c.path === OVERRIDES_PATH)).toBe(true);
|
||||
expect(calls().filter((c) => c.path.endsWith("/presence-schedule")).length).toBeGreaterThan(
|
||||
schedulesBefore,
|
||||
);
|
||||
// The grid reflects the new override.
|
||||
expect(document.querySelector('[data-date="2026-09-25"]')?.getAttribute("data-state")).toBe(
|
||||
"override-away",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits an empty reason and saves a Home override", async () => {
|
||||
await openEditor();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Home" }));
|
||||
await waitFor(() => expect(saveButton().disabled).toBe(false));
|
||||
fireEvent.click(saveButton());
|
||||
await screen.findByTestId("override-50");
|
||||
const post = calls().find((c) => c.method === "POST" && c.path === OVERRIDES_PATH);
|
||||
expect(post?.body).toEqual({
|
||||
child_id: 1,
|
||||
start_date: TODAY,
|
||||
end_date: TODAY,
|
||||
is_present: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("a 422 shows the API detail and keeps the form", async () => {
|
||||
createResponse = () => jsonResponse({ detail: "overlaps an existing override" }, 422);
|
||||
await openEditor();
|
||||
setDate("Through", "2026-09-25");
|
||||
fireEvent.change(screen.getByLabelText("Reason (optional)"), { target: { value: "Camp" } });
|
||||
await waitFor(() => expect(saveButton().disabled).toBe(false));
|
||||
|
||||
fireEvent.click(saveButton());
|
||||
const alert = await screen.findByTestId("override-error");
|
||||
expect(alert.textContent).toBe("overlaps an existing override");
|
||||
expect(screen.getByRole("heading", { name: "Presence override" })).toBeTruthy();
|
||||
expect((screen.getByLabelText("Through") as HTMLInputElement).value).toBe("2026-09-25");
|
||||
expect((screen.getByLabelText("Reason (optional)") as HTMLInputElement).value).toBe("Camp");
|
||||
expect(saveButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("Cancel and Back close the editor without writing", async () => {
|
||||
await openEditor();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await screen.findByTestId("pattern-1");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Back" }));
|
||||
await screen.findByTestId("pattern-1");
|
||||
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
|
||||
});
|
||||
|
||||
it("Add opens the editor for the selected child", async () => {
|
||||
render(<ScheduleTab today={TODAY} />);
|
||||
await screen.findByTestId("pattern-1");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||
const kids = within(await screen.findByRole("group", { name: "Child" }));
|
||||
expect(kids.getByRole("button", { name: "Maeve" }).getAttribute("aria-pressed")).toBe("true");
|
||||
await waitFor(() =>
|
||||
expect(consequenceBodies()).toEqual([
|
||||
{ child_id: 2, start_date: TODAY, end_date: TODAY, is_present: false },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("the trash button deletes the override only after confirm", async () => {
|
||||
render(<ScheduleTab today={TODAY} />);
|
||||
const row = await screen.findByTestId("override-41");
|
||||
|
||||
fireEvent.click(within(row).getByRole("button", { name: "Delete override" }));
|
||||
fireEvent.click(within(row).getByRole("button", { name: "Keep" }));
|
||||
expect(calls().some((c) => c.method === "DELETE")).toBe(false);
|
||||
|
||||
fireEvent.click(within(row).getByRole("button", { name: "Delete override" }));
|
||||
const confirm = within(row).getByRole("group", { name: "Confirm delete" });
|
||||
fireEvent.click(within(confirm).getByRole("button", { name: "Delete" }));
|
||||
await waitFor(() => expect(screen.queryByTestId("override-41")).toBeNull());
|
||||
expect(calls().filter((c) => c.method === "DELETE")).toEqual([
|
||||
{ method: "DELETE", path: `${OVERRIDES_PATH}/41`, body: undefined },
|
||||
]);
|
||||
expect(screen.getByText("No overrides for Declan.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OverrideEditor focus", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
overrides = [...INITIAL_OVERRIDES];
|
||||
consequence = () => jsonResponse({ removed: 4 });
|
||||
createResponse = (body) => {
|
||||
const created = { id: 50, note: null, ...body } as PresenceOverride;
|
||||
overrides = [...overrides, created];
|
||||
return jsonResponse(created, 201);
|
||||
};
|
||||
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function dialog(): HTMLElement {
|
||||
return screen.getByRole("dialog", { name: "Presence override" });
|
||||
}
|
||||
|
||||
function addButton(): HTMLElement {
|
||||
return screen.getByRole("button", { name: "Add" });
|
||||
}
|
||||
|
||||
it("moves focus into the editor on open", async () => {
|
||||
await openEditor();
|
||||
expect(document.activeElement).toBe(screen.getByRole("heading", { name: "Presence override" }));
|
||||
expect(dialog().contains(document.activeElement)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps Tab and Shift+Tab inside the editor", async () => {
|
||||
await openEditor();
|
||||
await waitFor(() => expect(saveButton().disabled).toBe(false));
|
||||
const back = screen.getByRole("button", { name: "Back" });
|
||||
|
||||
saveButton().focus();
|
||||
fireEvent.keyDown(saveButton(), { key: "Tab" });
|
||||
expect(document.activeElement).toBe(back);
|
||||
|
||||
fireEvent.keyDown(back, { key: "Tab", shiftKey: true });
|
||||
expect(document.activeElement).toBe(saveButton());
|
||||
|
||||
// From the focused heading (not in the tab order) Shift+Tab wraps to the end.
|
||||
const title = screen.getByRole("heading", { name: "Presence override" });
|
||||
title.focus();
|
||||
fireEvent.keyDown(title, { key: "Tab", shiftKey: true });
|
||||
expect(document.activeElement).toBe(saveButton());
|
||||
});
|
||||
|
||||
it("makes the tab bar inert while open and restores it on close", async () => {
|
||||
render(<App />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Schedule" }));
|
||||
await screen.findByTestId("pattern-1");
|
||||
fireEvent.click(addButton());
|
||||
await screen.findByRole("heading", { name: "Presence override" });
|
||||
|
||||
const tabbar = document.querySelector(".admin-tabbar") as HTMLElement;
|
||||
expect(tabbar.hasAttribute("inert")).toBe(true);
|
||||
expect(tabbar.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(dialog().closest("[inert]")).toBeNull();
|
||||
|
||||
fireEvent.keyDown(dialog(), { key: "Escape" });
|
||||
await screen.findByTestId("pattern-1");
|
||||
expect(tabbar.hasAttribute("inert")).toBe(false);
|
||||
expect(tabbar.hasAttribute("aria-hidden")).toBe(false);
|
||||
});
|
||||
|
||||
it("Escape cancels without writing and returns focus to Add", async () => {
|
||||
await openEditor();
|
||||
fireEvent.keyDown(document.activeElement as HTMLElement, { key: "Escape" });
|
||||
await screen.findByTestId("pattern-1");
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(document.activeElement).toBe(addButton());
|
||||
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
|
||||
});
|
||||
|
||||
it("Escape does nothing while saving; focus returns to Add after the save", async () => {
|
||||
const gate = deferred();
|
||||
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
|
||||
const path = new URL(url, "http://x").pathname;
|
||||
if (path === OVERRIDES_PATH && init?.method === "POST") await gate.promise;
|
||||
return routeFetch(url, init);
|
||||
});
|
||||
await openEditor();
|
||||
await waitFor(() => expect(saveButton().disabled).toBe(false));
|
||||
fireEvent.click(saveButton());
|
||||
expect(screen.getByRole("button", { name: "Saving…" })).toBeTruthy();
|
||||
|
||||
fireEvent.keyDown(dialog(), { key: "Escape" });
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
|
||||
gate.resolve(new Response(null));
|
||||
await screen.findByTestId("override-50");
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(document.activeElement).toBe(addButton());
|
||||
});
|
||||
|
||||
it("Back and Cancel return focus to Add", async () => {
|
||||
await openEditor();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
||||
await screen.findByTestId("pattern-1");
|
||||
expect(document.activeElement).toBe(addButton());
|
||||
|
||||
fireEvent.click(addButton());
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
|
||||
await screen.findByTestId("pattern-1");
|
||||
expect(document.activeElement).toBe(addButton());
|
||||
});
|
||||
});
|
||||
|
||||
describe("range validation", () => {
|
||||
it("accepts only strict ISO calendar dates in order", () => {
|
||||
expect(isIsoDate("2026-09-23")).toBe(true);
|
||||
expect(isIsoDate("2026-02-30")).toBe(false);
|
||||
expect(isIsoDate("2026-9-3")).toBe(false);
|
||||
expect(isIsoDate("")).toBe(false);
|
||||
expect(isValidRange("2026-09-23", "2026-09-23")).toBe(true);
|
||||
expect(isValidRange("2026-09-23", "2026-09-22")).toBe(false);
|
||||
expect(isValidRange("2026-09-23", "")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Presence override editor — the pushed screen of design/ADMIN-SPEC.md §6.
|
||||
*
|
||||
* The consequence warning is counted by the API (POST
|
||||
* /presence-overrides/consequence) before anything is saved; Save stays off
|
||||
* until that count is on screen. Saving only ever sends the create request:
|
||||
* the API never removes an instance that already has a completion event (D-005).
|
||||
*/
|
||||
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { ApiForbiddenError } from "../api/client";
|
||||
import { ApiRequestError, type AdminChild } from "../api/definitions";
|
||||
import {
|
||||
createPresenceOverride,
|
||||
previewOverrideConsequence,
|
||||
type PresenceOverride,
|
||||
type PresenceOverrideBody,
|
||||
} from "../api/presence";
|
||||
import { dayNumber, isoFromDayNumber } from "./cycle";
|
||||
import { dateRange } from "./format";
|
||||
import { AlertCircleGlyph, ChevronLeftGlyph } from "./glyphs";
|
||||
import "./OverrideEditor.css";
|
||||
|
||||
/** How long the form must sit still before the consequence is recounted. */
|
||||
export const CONSEQUENCE_DEBOUNCE_MS = 300;
|
||||
|
||||
/** Strict "YYYY-MM-DD" naming a real calendar day (no 2026-02-30). */
|
||||
export function isIsoDate(value: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||
return isoFromDayNumber(dayNumber(value)) === value;
|
||||
}
|
||||
|
||||
export function isValidRange(start: string, end: string): boolean {
|
||||
return isIsoDate(start) && isIsoDate(end) && start <= end;
|
||||
}
|
||||
|
||||
type Consequence =
|
||||
| { kind: "invalid" }
|
||||
| { kind: "counting" }
|
||||
| { kind: "ready"; removed: number }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
function errorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof ApiForbiddenError) return error.message;
|
||||
if (error instanceof ApiRequestError && error.detail) return error.detail;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const FOCUSABLE =
|
||||
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
/**
|
||||
* Make everything outside `root` inert (siblings of each ancestor up to
|
||||
* <body>), so the tab bar behind the pushed screen cannot take focus.
|
||||
* Returns the undo.
|
||||
*/
|
||||
function inertOutside(root: HTMLElement): () => void {
|
||||
const made: HTMLElement[] = [];
|
||||
for (let node: HTMLElement = root; node.parentElement && node !== document.body; node = node.parentElement) {
|
||||
for (const sibling of Array.from(node.parentElement.children)) {
|
||||
if (sibling === node || !(sibling instanceof HTMLElement) || sibling.hasAttribute("inert")) continue;
|
||||
sibling.setAttribute("inert", "");
|
||||
sibling.setAttribute("aria-hidden", "true");
|
||||
made.push(sibling);
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
for (const el of made) {
|
||||
el.removeAttribute("inert");
|
||||
el.removeAttribute("aria-hidden");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function tasksPhrase(count: number): string {
|
||||
return `${count} upcoming ${count === 1 ? "task" : "tasks"}`;
|
||||
}
|
||||
|
||||
export interface OverrideEditorProps {
|
||||
/** Active children, in display order. */
|
||||
childOptions: AdminChild[];
|
||||
initialChildId: number;
|
||||
/** "YYYY-MM-DD"; the date fields start here. */
|
||||
today: string;
|
||||
onCancel: () => void;
|
||||
onSaved: (override: PresenceOverride) => void;
|
||||
}
|
||||
|
||||
export default function OverrideEditor({
|
||||
childOptions,
|
||||
initialChildId,
|
||||
today,
|
||||
onCancel,
|
||||
onSaved,
|
||||
}: OverrideEditorProps) {
|
||||
const [childId, setChildId] = useState(initialChildId);
|
||||
const [isPresent, setIsPresent] = useState(false);
|
||||
const [startDate, setStartDate] = useState(today);
|
||||
const [endDate, setEndDate] = useState(today);
|
||||
const [note, setNote] = useState("");
|
||||
const [consequence, setConsequence] = useState<Consequence>({ kind: "counting" });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Every form change bumps the sequence; only the latest request may render.
|
||||
const requestSeq = useRef(0);
|
||||
const screenRef = useRef<HTMLDivElement>(null);
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
|
||||
// Modal focus: move in on open and keep the background out of reach. The
|
||||
// opener hands focus back to its Add control once this screen closes.
|
||||
useEffect(() => {
|
||||
titleRef.current?.focus();
|
||||
return screenRef.current ? inertOutside(screenRef.current) : undefined;
|
||||
}, []);
|
||||
|
||||
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
if (!saving) onCancel();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab" || !screenRef.current) return;
|
||||
const focusable = Array.from(screenRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const current = document.activeElement;
|
||||
const outside = !focusable.includes(current as HTMLElement);
|
||||
if (event.shiftKey && (current === first || outside)) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && (current === last || outside)) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const child = childOptions.find((c) => c.id === childId) ?? childOptions[0];
|
||||
const rangeValid = isValidRange(startDate, endDate);
|
||||
|
||||
useEffect(() => {
|
||||
const seq = ++requestSeq.current;
|
||||
if (!rangeValid) {
|
||||
setConsequence({ kind: "invalid" });
|
||||
return;
|
||||
}
|
||||
if (isPresent) {
|
||||
// Keeping a child home removes nothing by definition.
|
||||
setConsequence({ kind: "ready", removed: 0 });
|
||||
return;
|
||||
}
|
||||
setConsequence({ kind: "counting" });
|
||||
const timer = setTimeout(() => {
|
||||
previewOverrideConsequence({
|
||||
child_id: childId,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
is_present: false,
|
||||
})
|
||||
.then((removed) => {
|
||||
if (seq === requestSeq.current) setConsequence({ kind: "ready", removed });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (seq !== requestSeq.current) return;
|
||||
setConsequence({
|
||||
kind: "error",
|
||||
message: errorMessage(err, "The upcoming tasks could not be counted. Try again."),
|
||||
});
|
||||
});
|
||||
}, CONSEQUENCE_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [childId, startDate, endDate, isPresent, rangeValid]);
|
||||
|
||||
const canSave = rangeValid && consequence.kind === "ready" && !saving;
|
||||
|
||||
function save() {
|
||||
if (!canSave) return;
|
||||
const body: PresenceOverrideBody = {
|
||||
child_id: childId,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
is_present: isPresent,
|
||||
};
|
||||
const reason = note.trim();
|
||||
if (reason) body.note = reason;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
createPresenceOverride(body)
|
||||
.then(onSaved)
|
||||
.catch((err: unknown) => {
|
||||
setError(errorMessage(err, "The override could not be saved. Check your connection and try again."));
|
||||
setSaving(false);
|
||||
});
|
||||
}
|
||||
|
||||
let headline: string;
|
||||
let detail: string;
|
||||
const range = rangeValid ? dateRange(startDate, endDate) : "";
|
||||
if (consequence.kind === "invalid") {
|
||||
headline = "Choose a valid date range";
|
||||
detail = "Through must be on or after From.";
|
||||
} else if (consequence.kind === "counting") {
|
||||
headline = "Counting upcoming tasks…";
|
||||
detail = `${child.display_name} away ${range}. Completed days are untouched.`;
|
||||
} else if (consequence.kind === "error") {
|
||||
headline = "Upcoming tasks not counted";
|
||||
detail = consequence.message;
|
||||
} else {
|
||||
headline = `This removes ${tasksPhrase(consequence.removed)}`;
|
||||
detail = isPresent
|
||||
? `${child.display_name} is home ${range}, so no tasks are removed. Completed days are untouched.`
|
||||
: `${child.display_name}'s open tasks ${range} are removed while away. Completed days are untouched.`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={screenRef}
|
||||
className="override-screen"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="override-title"
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<header className="override-header">
|
||||
<button type="button" className="override-back" aria-label="Back" onClick={onCancel}>
|
||||
<ChevronLeftGlyph size={20} />
|
||||
</button>
|
||||
<div>
|
||||
<h2 className="override-title" id="override-title" ref={titleRef} tabIndex={-1}>
|
||||
Presence override
|
||||
</h2>
|
||||
<p className="override-sub">Beats the custody pattern for these dates</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="override-scroll">
|
||||
<section className="override-card" aria-label="Override details">
|
||||
<div className="override-field">
|
||||
<span className="override-label" id="override-label-child">
|
||||
Child
|
||||
</span>
|
||||
<div className="override-segments override-segments--3" role="group" aria-labelledby="override-label-child">
|
||||
{childOptions.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
className="override-segment override-segment--gradient"
|
||||
aria-pressed={c.id === childId}
|
||||
onClick={() => setChildId(c.id)}
|
||||
>
|
||||
{c.display_name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="override-field">
|
||||
<span className="override-label" id="override-label-status">
|
||||
Status for these dates
|
||||
</span>
|
||||
<div className="override-segments override-segments--2" role="group" aria-labelledby="override-label-status">
|
||||
<button
|
||||
type="button"
|
||||
className="override-segment override-segment--ink"
|
||||
aria-pressed={!isPresent}
|
||||
onClick={() => setIsPresent(false)}
|
||||
>
|
||||
Away
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="override-segment override-segment--ink"
|
||||
aria-pressed={isPresent}
|
||||
onClick={() => setIsPresent(true)}
|
||||
>
|
||||
Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="override-dates">
|
||||
<label className="override-field">
|
||||
<span className="override-label">From</span>
|
||||
<input
|
||||
className="override-input override-date"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(event) => setStartDate(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="override-field">
|
||||
<span className="override-label">Through</span>
|
||||
<input
|
||||
className="override-input override-date"
|
||||
type="date"
|
||||
value={endDate}
|
||||
min={isIsoDate(startDate) ? startDate : undefined}
|
||||
onChange={(event) => setEndDate(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="override-field">
|
||||
<span className="override-label">Reason (optional)</span>
|
||||
<input
|
||||
className="override-input"
|
||||
type="text"
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="override-warning"
|
||||
aria-label="Consequence"
|
||||
aria-live="polite"
|
||||
data-testid="override-consequence"
|
||||
data-state={consequence.kind}
|
||||
>
|
||||
<span className="override-warning-glyph">
|
||||
<AlertCircleGlyph />
|
||||
</span>
|
||||
<div>
|
||||
<p className="override-warning-headline" data-testid="override-consequence-headline">
|
||||
{headline}
|
||||
</p>
|
||||
<p className="override-warning-body">{detail}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<p className="override-error" role="alert" data-testid="override-error">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="override-footer">
|
||||
<button type="button" className="override-cancel" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="override-save" onClick={save} disabled={!canSave}>
|
||||
{saving ? "Saving…" : "Save override"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
/* Schedule tab — compact density (design/ADMIN-SPEC.md §4). Mobile-first at
|
||||
the 390x844 reference; on wider viewports the column is capped and centred. */
|
||||
|
||||
.schedule-screen {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.schedule-header {
|
||||
background: var(--nq-a-surface);
|
||||
border-bottom: 1px solid var(--nq-a-border);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.schedule-title {
|
||||
margin: 0;
|
||||
font-size: var(--nq-a-size-screen);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.schedule-sub {
|
||||
margin: 2px 0 0;
|
||||
min-height: 1.4em;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
|
||||
.schedule-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nq-a-density-gap);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.schedule-card {
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
}
|
||||
|
||||
/* Child selector */
|
||||
.schedule-children {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.schedule-child-chip {
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-radius-pill);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.schedule-child-chip[aria-pressed="true"] {
|
||||
border-color: var(--nq-brand-blue);
|
||||
background: var(--nq-a-selected);
|
||||
color: var(--nq-brand-blue);
|
||||
}
|
||||
|
||||
/* Month grid card */
|
||||
.schedule-month {
|
||||
padding: var(--nq-a-density-card-pad);
|
||||
}
|
||||
|
||||
.schedule-month-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.schedule-month-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.schedule-chevrons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.schedule-chevron {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: 6px;
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.schedule-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.schedule-weekday {
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--nq-a-ink-tertiary);
|
||||
}
|
||||
|
||||
.schedule-day {
|
||||
aspect-ratio: 1;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.schedule-day--home {
|
||||
background: var(--nq-a-selected);
|
||||
color: #201f7a;
|
||||
}
|
||||
|
||||
.schedule-day--away {
|
||||
background: var(--nq-a-page);
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.schedule-day--override-away,
|
||||
.schedule-day--override-home {
|
||||
background: var(--nq-a-warning-bg);
|
||||
color: var(--nq-a-warning-ink);
|
||||
border-color: var(--nq-a-warning-border);
|
||||
}
|
||||
|
||||
/* An override that keeps the child home reads with a dashed border. */
|
||||
.schedule-day--override-home {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.schedule-day--today {
|
||||
background: var(--nq-brand-gradient);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.schedule-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
margin: 10px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.schedule-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.schedule-swatch {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Pattern editing controls */
|
||||
.schedule-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--nq-a-divider);
|
||||
}
|
||||
|
||||
.schedule-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
font-size: var(--nq-a-size-label);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.schedule-field input,
|
||||
.schedule-field select {
|
||||
min-height: 36px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: 6px;
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.schedule-save {
|
||||
min-height: 36px;
|
||||
margin-left: auto;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-brand-gradient);
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.schedule-save:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.schedule-save-status {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-success);
|
||||
}
|
||||
|
||||
.schedule-save-status--error {
|
||||
color: var(--nq-a-danger-strong);
|
||||
}
|
||||
|
||||
/* Presence pattern + overrides cards */
|
||||
.schedule-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.schedule-section-label {
|
||||
margin: 6px 0 -2px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.schedule-text-action {
|
||||
padding: 4px 0 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--nq-brand-blue);
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.schedule-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.schedule-list > li + li {
|
||||
border-top: 1px solid var(--nq-a-divider);
|
||||
}
|
||||
|
||||
.schedule-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: var(--nq-a-density-row-pad);
|
||||
}
|
||||
|
||||
.schedule-row-glyph {
|
||||
flex: none;
|
||||
display: flex;
|
||||
color: var(--nq-a-warning-ink);
|
||||
}
|
||||
|
||||
.schedule-row-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.schedule-row-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.schedule-row-meta {
|
||||
font-size: var(--nq-a-size-meta);
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.schedule-rule {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.schedule-icon-action {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: var(--nq-a-tap-min);
|
||||
height: var(--nq-a-tap-min);
|
||||
margin: -8px -10px -8px 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--nq-a-ink-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.schedule-empty {
|
||||
padding: var(--nq-a-density-card-pad);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
/* Loading / error */
|
||||
.schedule-status {
|
||||
padding: 24px 13px;
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
text-align: center;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.schedule-status--error {
|
||||
color: var(--nq-a-danger-strong);
|
||||
}
|
||||
|
||||
.schedule-status--error p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.schedule-retry {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Inline delete confirmation in an override row */
|
||||
.schedule-confirm {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.schedule-confirm-text {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.schedule-confirm-keep,
|
||||
.schedule-confirm-delete {
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.schedule-confirm-keep {
|
||||
border: 1px solid var(--nq-a-border);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
}
|
||||
|
||||
.schedule-confirm-delete {
|
||||
border: 0;
|
||||
background: var(--nq-a-danger);
|
||||
color: #fff;
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import ScheduleTab from "./ScheduleTab";
|
||||
import type { AdminChild } from "../api/definitions";
|
||||
import type { PresenceOverride, PresenceSchedule } from "../api/presence";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
|
||||
function child(id: number, display_name: string, sort_order: number, is_active = true): AdminChild {
|
||||
return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active };
|
||||
}
|
||||
|
||||
const CHILDREN = [
|
||||
child(3, "Rory", 3),
|
||||
child(1, "Declan", 1),
|
||||
child(2, "Maeve", 2),
|
||||
child(9, "Former", 4, false),
|
||||
];
|
||||
|
||||
// Declan: anchor Monday 2026-09-07; cycle week 0 home every day, week 1 Sat/Sun.
|
||||
// Keys arrive as JSON strings, exactly as the API serializes them.
|
||||
const DECLAN: PresenceSchedule = {
|
||||
child_id: 1,
|
||||
cycle_length_weeks: 2,
|
||||
anchor_date: "2026-09-07",
|
||||
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] },
|
||||
};
|
||||
|
||||
const MAEVE: PresenceSchedule = {
|
||||
child_id: 2,
|
||||
cycle_length_weeks: 4,
|
||||
anchor_date: "2026-09-07",
|
||||
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [], "2": [0, 1, 2, 3, 4, 5, 6], "3": [] },
|
||||
};
|
||||
|
||||
const OVERRIDES: PresenceOverride[] = [
|
||||
{ id: 41, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: "Dentist trip" },
|
||||
{ id: 42, child_id: 1, start_date: "2026-09-16", end_date: "2026-09-16", is_present: true, note: null },
|
||||
{ id: 43, child_id: 2, start_date: "2026-09-28", end_date: "2026-10-02", is_present: false, note: "Camp" },
|
||||
];
|
||||
|
||||
const TODAY = "2026-09-23";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
let putResponse: (body: unknown) => Response;
|
||||
|
||||
function routeFetch(url: string, init: RequestInit = {}): Response {
|
||||
const path = new URL(url, "http://x").pathname;
|
||||
if (init.method === "PUT") return putResponse(JSON.parse(String(init.body)));
|
||||
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
|
||||
const match = /\/children\/(\d+)\/presence-schedule$/.exec(path);
|
||||
if (match) {
|
||||
const id = Number(match[1]);
|
||||
return jsonResponse({ schedule: id === 1 ? DECLAN : id === 2 ? MAEVE : null });
|
||||
}
|
||||
if (path.endsWith("/presence-overrides")) return jsonResponse({ overrides: OVERRIDES });
|
||||
return jsonResponse({ detail: "unexpected" }, 500);
|
||||
}
|
||||
|
||||
function day(date: string): HTMLElement {
|
||||
const cell = document.querySelector<HTMLElement>(`[data-date="${date}"]`);
|
||||
if (!cell) throw new Error(`no cell for ${date}`);
|
||||
return cell;
|
||||
}
|
||||
|
||||
async function renderReady() {
|
||||
render(<ScheduleTab today={TODAY} />);
|
||||
await screen.findByTestId("pattern-1");
|
||||
}
|
||||
|
||||
describe("ScheduleTab", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
putResponse = (body) => jsonResponse({ child_id: 1, ...(body as object) });
|
||||
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("loads active children, their schedules and the overrides", async () => {
|
||||
await renderReady();
|
||||
const urls = fetchMock.mock.calls.map(([url]) => new URL(String(url), "http://x").pathname);
|
||||
expect(urls).toContain("/api/v1/admin/children");
|
||||
expect(urls).toContain("/api/v1/admin/children/1/presence-schedule");
|
||||
expect(urls).toContain("/api/v1/admin/children/2/presence-schedule");
|
||||
expect(urls).toContain("/api/v1/admin/children/3/presence-schedule");
|
||||
expect(urls).not.toContain("/api/v1/admin/children/9/presence-schedule");
|
||||
expect(urls).toContain("/api/v1/admin/presence-overrides");
|
||||
const chips = within(screen.getByRole("group", { name: "Child" })).getAllByRole("button");
|
||||
expect(chips.map((chip) => chip.textContent)).toEqual(["Declan", "Maeve", "Rory"]);
|
||||
expect(chips[0].getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("header shows the cycle length and anchor", async () => {
|
||||
await renderReady();
|
||||
expect(screen.getByRole("heading", { name: "Schedule" })).toBeTruthy();
|
||||
expect(screen.getByText("2-week cycle · anchor Monday, Sep 7")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("month grid renders home, away, override and today states from the anchor", async () => {
|
||||
await renderReady();
|
||||
expect(screen.getByTestId("month-label").textContent).toBe("September 2026");
|
||||
const states: Record<string, string> = {
|
||||
"2026-09-01": "away", // before the anchor: cycle week 1, Tuesday
|
||||
"2026-09-05": "home", // before the anchor: cycle week 1, Saturday
|
||||
"2026-09-07": "home", // anchor: week 0
|
||||
"2026-09-11": "override-away",
|
||||
"2026-09-15": "away", // week 1, Tuesday
|
||||
"2026-09-16": "override-home",
|
||||
"2026-09-19": "home", // week 1, Saturday
|
||||
"2026-09-22": "home", // wrap back to week 0
|
||||
"2026-09-29": "away", // week 1 again
|
||||
};
|
||||
for (const [date, state] of Object.entries(states)) {
|
||||
expect(day(date).dataset.state, date).toBe(state);
|
||||
expect(day(date).className).toContain(`schedule-day--${state}`);
|
||||
expect(day(date).className).not.toContain("schedule-day--today");
|
||||
}
|
||||
const today = day(TODAY);
|
||||
expect(today.className).toContain("schedule-day--today");
|
||||
expect(today.dataset.state).toBe("home");
|
||||
expect(today.getAttribute("aria-label")).toBe("Sep 23 (today): Home");
|
||||
// September 2026 starts on a Tuesday: one leading pad after the initials.
|
||||
const grid = screen.getByRole("group", { name: "Month grid" });
|
||||
expect(grid.children[7].tagName).toBe("SPAN");
|
||||
expect(grid.children[8].getAttribute("data-date")).toBe("2026-09-01");
|
||||
|
||||
const legend = screen.getByRole("list", { name: "Legend" });
|
||||
expect(within(legend).getAllByRole("listitem").map((li) => li.textContent)).toEqual([
|
||||
"Home",
|
||||
"Away",
|
||||
"Override · away",
|
||||
"Override · home",
|
||||
"Today",
|
||||
]);
|
||||
});
|
||||
|
||||
it("navigates months with the chevrons", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next month" }));
|
||||
expect(screen.getByTestId("month-label").textContent).toBe("October 2026");
|
||||
// Oct 1 is day 24 from the anchor: cycle week 1, a Thursday — away.
|
||||
expect(day("2026-10-01").dataset.state).toBe("away");
|
||||
expect(day("2026-10-03").dataset.state).toBe("home"); // week 1, Saturday
|
||||
expect(day("2026-10-05").dataset.state).toBe("home"); // week 0 again
|
||||
fireEvent.click(screen.getByRole("button", { name: "Previous month" }));
|
||||
expect(screen.getByTestId("month-label").textContent).toBe("September 2026");
|
||||
});
|
||||
|
||||
it("presence pattern card shows a rule per child", async () => {
|
||||
await renderReady();
|
||||
expect(within(screen.getByTestId("pattern-1")).getByText("Weeks 1 & 2 of cycle")).toBeTruthy();
|
||||
expect(within(screen.getByTestId("pattern-2")).getByText("Weeks 1 & 3 of cycle")).toBeTruthy();
|
||||
expect(within(screen.getByTestId("pattern-3")).getByText("Every day")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("overrides card lists the selected child's overrides with inert actions", async () => {
|
||||
await renderReady();
|
||||
const list = screen.getByRole("list", { name: "Overrides" });
|
||||
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
|
||||
const away = within(list).getByTestId("override-41");
|
||||
expect(within(away).getByText("Declan away · Sep 10 – 12")).toBeTruthy();
|
||||
expect(within(away).getByText("Dentist trip")).toBeTruthy();
|
||||
expect(within(away).getByRole("button", { name: "Delete override" })).toBeTruthy();
|
||||
expect(within(list).getByText("Declan home · Sep 16")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Add" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
|
||||
expect(screen.getByText("Maeve away · Sep 28 – Oct 2")).toBeTruthy();
|
||||
expect(screen.getByText("4-week cycle · anchor Monday, Sep 7")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a child with no schedule is home every day and defaults to a 2-week cycle", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Rory" }));
|
||||
expect(screen.getByText("2-week cycle · anchor Tuesday, Sep 1")).toBeTruthy();
|
||||
expect(day("2026-09-15").dataset.state).toBe("home");
|
||||
expect(screen.getByText("No overrides for Rory.")).toBeTruthy();
|
||||
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("editing a day and saving PUTs the pattern; the grid reflects the saved schedule", async () => {
|
||||
await renderReady();
|
||||
expect(day("2026-09-01").dataset.state).toBe("away");
|
||||
fireEvent.click(day("2026-09-01")); // cycle week 1 (pre-anchor), Tuesday
|
||||
|
||||
// The draft already shows the edit, and every other week-1 Tuesday follows.
|
||||
expect(day("2026-09-01").dataset.state).toBe("home");
|
||||
expect(day("2026-09-15").dataset.state).toBe("home");
|
||||
expect(day("2026-09-08").dataset.state).toBe("home");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
|
||||
expect(await screen.findByText("Pattern saved.")).toBeTruthy();
|
||||
|
||||
const put = fetchMock.mock.calls.find(([, init]) => init?.method === "PUT");
|
||||
expect(put).toBeTruthy();
|
||||
const [url, init] = put!;
|
||||
expect(new URL(String(url), "http://x").pathname).toBe("/api/v1/admin/children/1/presence-schedule");
|
||||
expect(init.headers.Authorization).toBe("Bearer at-123");
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
cycle_length_weeks: 2,
|
||||
anchor_date: "2026-09-07",
|
||||
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [1, 5, 6] },
|
||||
});
|
||||
|
||||
expect(day("2026-09-15").dataset.state).toBe("home");
|
||||
expect(day("2026-09-29").dataset.state).toBe("home");
|
||||
expect(day("2026-09-30").dataset.state).toBe("away");
|
||||
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("saves an edited anchor date and cycle length", async () => {
|
||||
await renderReady();
|
||||
fireEvent.change(screen.getByLabelText("Anchor date"), { target: { value: "2026-09-14" } });
|
||||
fireEvent.change(screen.getByLabelText("Cycle length"), { target: { value: "3" } });
|
||||
expect(screen.getByText("3-week cycle · anchor Monday, Sep 14")).toBeTruthy();
|
||||
// Anchor moved a week: Sep 7-13 is now the last week of the cycle (full, added).
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
|
||||
await screen.findByText("Pattern saved.");
|
||||
const [, init] = fetchMock.mock.calls.find(([, i]) => i?.method === "PUT")!;
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
cycle_length_weeks: 3,
|
||||
anchor_date: "2026-09-14",
|
||||
pattern: {
|
||||
"0": [0, 1, 2, 3, 4, 5, 6],
|
||||
"1": [5, 6],
|
||||
"2": [0, 1, 2, 3, 4, 5, 6],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("a 422 shows the API detail and keeps the draft", async () => {
|
||||
putResponse = () => jsonResponse({ detail: "anchor_date must be a Monday" }, 422);
|
||||
await renderReady();
|
||||
fireEvent.click(day("2026-09-01"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("anchor_date must be a Monday");
|
||||
expect(day("2026-09-01").dataset.state).toBe("home");
|
||||
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
|
||||
false,
|
||||
);
|
||||
// The saved rule is unchanged.
|
||||
expect(within(screen.getByTestId("pattern-1")).getByText("Weeks 1 & 2 of cycle")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("scroll column ends with 92px bottom padding", async () => {
|
||||
await renderReady();
|
||||
expect(screen.getByTestId("schedule-scroll").style.paddingBottom).toBe("92px");
|
||||
});
|
||||
|
||||
it("shows an error state on a failed load and retries", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("boom", { status: 500 }));
|
||||
render(<ScheduleTab today={TODAY} />);
|
||||
const error = await screen.findByTestId("schedule-error");
|
||||
expect(error.textContent).toContain("could not be loaded");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||
expect(await screen.findByTestId("pattern-1")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,557 @@
|
||||
/**
|
||||
* Schedule tab — the presence month grid (design/ADMIN-SPEC.md §4).
|
||||
*
|
||||
* Every day's cycle position comes from the anchor date (./cycle.ts), never
|
||||
* from ISO week numbers or week parity (D-004).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ApiForbiddenError } from "../api/client";
|
||||
import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions";
|
||||
import {
|
||||
deletePresenceOverride,
|
||||
fetchPresenceOverrides,
|
||||
fetchPresenceSchedule,
|
||||
savePresenceSchedule,
|
||||
type PresenceOverride,
|
||||
type PresenceSchedule,
|
||||
type PresenceScheduleBody,
|
||||
} from "../api/presence";
|
||||
import {
|
||||
cycleWeekIndex,
|
||||
dayState,
|
||||
fullPattern,
|
||||
localTodayIso,
|
||||
monthDates,
|
||||
monthStart,
|
||||
patternIncludes,
|
||||
presenceRule,
|
||||
resizePattern,
|
||||
togglePatternDay,
|
||||
weekdayOf,
|
||||
type DayState,
|
||||
} from "./cycle";
|
||||
import { anchorLabel, dateRange, monthLabel, shortDate } from "./format";
|
||||
import {
|
||||
CalendarCheckGlyph,
|
||||
CalendarXGlyph,
|
||||
ChevronLeftGlyph,
|
||||
ChevronRightGlyph,
|
||||
Trash2Glyph,
|
||||
} from "./glyphs";
|
||||
import OverrideEditor from "./OverrideEditor";
|
||||
import "./ScheduleTab.css";
|
||||
|
||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||
export const SCROLL_BOTTOM_PADDING = "92px";
|
||||
|
||||
export const DEFAULT_CYCLE_LENGTH_WEEKS = 2;
|
||||
const CYCLE_LENGTH_CHOICES = [1, 2, 3, 4];
|
||||
const WEEKDAY_INITIALS = ["M", "T", "W", "T", "F", "S", "S"];
|
||||
|
||||
export const STATE_LABELS: Record<DayState, string> = {
|
||||
home: "Home",
|
||||
away: "Away",
|
||||
"override-away": "Override · away",
|
||||
"override-home": "Override · home",
|
||||
};
|
||||
|
||||
interface Loaded {
|
||||
children: AdminChild[];
|
||||
schedules: Record<number, PresenceSchedule | null>;
|
||||
overrides: PresenceOverride[];
|
||||
}
|
||||
|
||||
type LoadState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; data: Loaded };
|
||||
|
||||
type DeleteState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "confirming"; id: number }
|
||||
| { kind: "deleting"; id: number }
|
||||
| { kind: "error"; id: number; message: string };
|
||||
|
||||
type SaveState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "saving" }
|
||||
| { kind: "saved" }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
async function loadSchedule(): Promise<Loaded> {
|
||||
const children = (await fetchChildren())
|
||||
.filter((child) => child.is_active)
|
||||
.sort((a, b) => a.sort_order - b.sort_order);
|
||||
const [schedules, overrides] = await Promise.all([
|
||||
Promise.all(children.map((child) => fetchPresenceSchedule(child.id))),
|
||||
fetchPresenceOverrides(),
|
||||
]);
|
||||
return {
|
||||
children,
|
||||
schedules: Object.fromEntries(children.map((child, i) => [child.id, schedules[i]])),
|
||||
overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function loadErrorMessage(error: unknown): string {
|
||||
return error instanceof ApiForbiddenError
|
||||
? error.message
|
||||
: "The schedule could not be loaded. Check your connection and try again.";
|
||||
}
|
||||
|
||||
function saveErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "The pattern could not be saved. Check your connection and try again.",
|
||||
): string {
|
||||
if (error instanceof ApiForbiddenError) return error.message;
|
||||
if (error instanceof ApiRequestError && error.detail) return error.detail;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function ScreenHeader({ sub }: { sub: string }) {
|
||||
return (
|
||||
<header className="schedule-header">
|
||||
<h2 className="schedule-title">Schedule</h2>
|
||||
<p className="schedule-sub">{sub}</p>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
interface MonthGridProps {
|
||||
month: string;
|
||||
today: string;
|
||||
schedule: PresenceScheduleBody;
|
||||
/** Null when the child has no saved schedule and no edits: home every day. */
|
||||
effective: PresenceScheduleBody | null;
|
||||
overrides: PresenceOverride[];
|
||||
onMonth: (delta: number) => void;
|
||||
onToggle: (date: string) => void;
|
||||
}
|
||||
|
||||
function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onToggle }: MonthGridProps) {
|
||||
const dates = monthDates(month);
|
||||
const leading = weekdayOf(month);
|
||||
return (
|
||||
<>
|
||||
<div className="schedule-month-head">
|
||||
<span className="schedule-month-label" data-testid="month-label">
|
||||
{monthLabel(month)}
|
||||
</span>
|
||||
<div className="schedule-chevrons">
|
||||
<button
|
||||
type="button"
|
||||
className="schedule-chevron"
|
||||
aria-label="Previous month"
|
||||
onClick={() => onMonth(-1)}
|
||||
>
|
||||
<ChevronLeftGlyph />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="schedule-chevron"
|
||||
aria-label="Next month"
|
||||
onClick={() => onMonth(1)}
|
||||
>
|
||||
<ChevronRightGlyph />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="schedule-grid" role="group" aria-label="Month grid">
|
||||
{WEEKDAY_INITIALS.map((initial, i) => (
|
||||
<span key={`wd-${i}`} className="schedule-weekday" aria-hidden="true">
|
||||
{initial}
|
||||
</span>
|
||||
))}
|
||||
{Array.from({ length: leading }, (_, i) => (
|
||||
<span key={`pad-${i}`} aria-hidden="true" />
|
||||
))}
|
||||
{dates.map((date) => {
|
||||
const state = dayState(effective, overrides, date);
|
||||
const isToday = date === today;
|
||||
let className = `schedule-day schedule-day--${state}`;
|
||||
if (isToday) className += " schedule-day--today";
|
||||
return (
|
||||
<button
|
||||
key={date}
|
||||
type="button"
|
||||
className={className}
|
||||
data-date={date}
|
||||
data-state={state}
|
||||
aria-pressed={patternIncludes(schedule, date)}
|
||||
aria-label={`${shortDate(date)}${isToday ? " (today)" : ""}: ${STATE_LABELS[state]}`}
|
||||
onClick={() => onToggle(date)}
|
||||
>
|
||||
{Number(date.slice(8))}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ul className="schedule-legend" aria-label="Legend">
|
||||
{(["home", "away", "override-away", "override-home"] as DayState[]).map((state) => (
|
||||
<li key={state} className="schedule-legend-item">
|
||||
<span className={`schedule-swatch schedule-day--${state}`} aria-hidden="true" />
|
||||
{STATE_LABELS[state]}
|
||||
</li>
|
||||
))}
|
||||
<li className="schedule-legend-item">
|
||||
<span className="schedule-swatch schedule-day--today" aria-hidden="true" />
|
||||
Today
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverrideRowProps {
|
||||
override: PresenceOverride;
|
||||
childName: string;
|
||||
deletion: DeleteState;
|
||||
onAskDelete: () => void;
|
||||
onKeep: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function OverrideRow({ override, childName, deletion, onAskDelete, onKeep, onDelete }: OverrideRowProps) {
|
||||
const mine =
|
||||
(deletion.kind === "confirming" || deletion.kind === "deleting") && deletion.id === override.id;
|
||||
return (
|
||||
<li className="schedule-row" data-testid={`override-${override.id}`}>
|
||||
<span className="schedule-row-glyph">
|
||||
{override.is_present ? <CalendarCheckGlyph /> : <CalendarXGlyph />}
|
||||
</span>
|
||||
<div className="schedule-row-text">
|
||||
<div className="schedule-row-title">
|
||||
{childName} {override.is_present ? "home" : "away"} ·{" "}
|
||||
{dateRange(override.start_date, override.end_date)}
|
||||
</div>
|
||||
<div className="schedule-row-meta">{override.note || "No reason given"}</div>
|
||||
</div>
|
||||
{mine ? (
|
||||
<div className="schedule-confirm" role="group" aria-label="Confirm delete">
|
||||
<span className="schedule-confirm-text">Delete override?</span>
|
||||
<button
|
||||
type="button"
|
||||
className="schedule-confirm-keep"
|
||||
onClick={onKeep}
|
||||
disabled={deletion.kind === "deleting"}
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="schedule-confirm-delete"
|
||||
onClick={onDelete}
|
||||
disabled={deletion.kind === "deleting"}
|
||||
>
|
||||
{deletion.kind === "deleting" ? "Deleting…" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="schedule-icon-action"
|
||||
aria-label="Delete override"
|
||||
onClick={onAskDelete}
|
||||
>
|
||||
<Trash2Glyph />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ScheduleTabProps {
|
||||
/** "YYYY-MM-DD"; defaults to the viewer's local date. */
|
||||
today?: string;
|
||||
}
|
||||
|
||||
export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProps) {
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [month, setMonth] = useState(() => monthStart(today));
|
||||
const [drafts, setDrafts] = useState<Record<number, PresenceScheduleBody>>({});
|
||||
const [save, setSave] = useState<SaveState>({ kind: "idle" });
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [deletion, setDeletion] = useState<DeleteState>({ kind: "idle" });
|
||||
const addRef = useRef<HTMLButtonElement>(null);
|
||||
const editorWasOpen = useRef(false);
|
||||
|
||||
// The editor replaces this screen, so Add is remounted when it closes;
|
||||
// hand focus back to it then.
|
||||
useEffect(() => {
|
||||
if (editorWasOpen.current && !editorOpen) addRef.current?.focus();
|
||||
editorWasOpen.current = editorOpen;
|
||||
}, [editorOpen]);
|
||||
|
||||
/** Reload children, schedules and overrides in place (no loading flash). */
|
||||
const refresh = () =>
|
||||
loadSchedule()
|
||||
.then((data) => setState({ kind: "ready", data }))
|
||||
.catch((error: unknown) => setState({ kind: "error", message: loadErrorMessage(error) }));
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ kind: "loading" });
|
||||
loadSchedule()
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setState({ kind: "ready", data });
|
||||
setSelectedId((current) => current ?? data.children[0]?.id ?? null);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
setState({ kind: "error", message: loadErrorMessage(error) });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [attempt]);
|
||||
|
||||
if (editorOpen && state.kind === "ready" && state.data.children.length > 0) {
|
||||
const { children } = state.data;
|
||||
return (
|
||||
<OverrideEditor
|
||||
childOptions={children}
|
||||
initialChildId={children.find((c) => c.id === selectedId)?.id ?? children[0].id}
|
||||
today={today}
|
||||
onCancel={() => setEditorOpen(false)}
|
||||
onSaved={(override) => {
|
||||
setEditorOpen(false);
|
||||
setSelectedId(override.child_id);
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let sub = "";
|
||||
let body;
|
||||
if (state.kind === "loading") {
|
||||
body = (
|
||||
<div className="schedule-status" role="status" data-testid="schedule-loading">
|
||||
Loading schedule…
|
||||
</div>
|
||||
);
|
||||
} else if (state.kind === "error") {
|
||||
body = (
|
||||
<div className="schedule-status schedule-status--error" role="alert" data-testid="schedule-error">
|
||||
<p>{state.message}</p>
|
||||
<button type="button" className="schedule-retry" onClick={() => setAttempt((n) => n + 1)}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else if (state.data.children.length === 0) {
|
||||
body = <div className="schedule-card schedule-empty">No children yet.</div>;
|
||||
} else {
|
||||
const { data } = state;
|
||||
const child = data.children.find((c) => c.id === selectedId) ?? data.children[0];
|
||||
const saved = data.schedules[child.id] ?? null;
|
||||
const draft = drafts[child.id];
|
||||
// Edits start from the saved schedule, or — with none — from "home every
|
||||
// day" on a 2-week cycle anchored at the displayed month's 1st.
|
||||
const schedule: PresenceScheduleBody = draft ??
|
||||
saved ?? {
|
||||
cycle_length_weeks: DEFAULT_CYCLE_LENGTH_WEEKS,
|
||||
anchor_date: month,
|
||||
pattern: fullPattern(DEFAULT_CYCLE_LENGTH_WEEKS),
|
||||
};
|
||||
const effective = draft ?? saved;
|
||||
const childOverrides = data.overrides.filter((o) => o.child_id === child.id);
|
||||
sub = `${schedule.cycle_length_weeks}-week cycle · anchor ${anchorLabel(schedule.anchor_date)}`;
|
||||
|
||||
const edit = (next: PresenceScheduleBody) => {
|
||||
setDrafts((all) => ({ ...all, [child.id]: next }));
|
||||
setSave({ kind: "idle" });
|
||||
};
|
||||
const toggle = (date: string) => {
|
||||
const week = cycleWeekIndex(schedule.anchor_date, date, schedule.cycle_length_weeks);
|
||||
edit({ ...schedule, pattern: togglePatternDay(schedule.pattern, week, weekdayOf(date)) });
|
||||
};
|
||||
const confirmDelete = (id: number) => {
|
||||
setDeletion({ kind: "deleting", id });
|
||||
deletePresenceOverride(id)
|
||||
.then(() => refresh())
|
||||
.then(() => setDeletion({ kind: "idle" }))
|
||||
.catch((error: unknown) =>
|
||||
setDeletion({
|
||||
kind: "error",
|
||||
id,
|
||||
message: saveErrorMessage(
|
||||
error,
|
||||
"The override could not be deleted. Check your connection and try again.",
|
||||
),
|
||||
}),
|
||||
);
|
||||
};
|
||||
const submit = () => {
|
||||
if (!draft) return;
|
||||
const childId = child.id;
|
||||
setSave({ kind: "saving" });
|
||||
const { cycle_length_weeks, anchor_date, pattern } = draft;
|
||||
savePresenceSchedule(childId, { cycle_length_weeks, anchor_date, pattern })
|
||||
.then((stored) => {
|
||||
setState((current) =>
|
||||
current.kind === "ready"
|
||||
? {
|
||||
kind: "ready",
|
||||
data: {
|
||||
...current.data,
|
||||
schedules: { ...current.data.schedules, [childId]: stored },
|
||||
},
|
||||
}
|
||||
: current,
|
||||
);
|
||||
setDrafts((all) => {
|
||||
const rest = { ...all };
|
||||
delete rest[childId];
|
||||
return rest;
|
||||
});
|
||||
setSave({ kind: "saved" });
|
||||
})
|
||||
.catch((error: unknown) => setSave({ kind: "error", message: saveErrorMessage(error) }));
|
||||
};
|
||||
|
||||
body = (
|
||||
<>
|
||||
<div className="schedule-children" role="group" aria-label="Child">
|
||||
{data.children.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
className="schedule-child-chip"
|
||||
aria-pressed={c.id === child.id}
|
||||
onClick={() => {
|
||||
setSelectedId(c.id);
|
||||
setSave({ kind: "idle" });
|
||||
}}
|
||||
>
|
||||
{c.display_name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="schedule-card schedule-month" aria-label="Month grid card">
|
||||
<MonthGrid
|
||||
month={month}
|
||||
today={today}
|
||||
schedule={schedule}
|
||||
effective={effective}
|
||||
overrides={childOverrides}
|
||||
onMonth={(delta) => setMonth((m) => monthStart(m, delta))}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<div className="schedule-controls">
|
||||
<label className="schedule-field">
|
||||
<span>Anchor date</span>
|
||||
<input
|
||||
type="date"
|
||||
value={schedule.anchor_date}
|
||||
onChange={(event) => {
|
||||
if (event.target.value) edit({ ...schedule, anchor_date: event.target.value });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="schedule-field">
|
||||
<span>Cycle length</span>
|
||||
<select
|
||||
value={schedule.cycle_length_weeks}
|
||||
onChange={(event) => {
|
||||
const weeks = Number(event.target.value);
|
||||
edit({
|
||||
...schedule,
|
||||
cycle_length_weeks: weeks,
|
||||
pattern: resizePattern(schedule.pattern, weeks),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{CYCLE_LENGTH_CHOICES.map((weeks) => (
|
||||
<option key={weeks} value={weeks}>
|
||||
{weeks} {weeks === 1 ? "week" : "weeks"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="schedule-save"
|
||||
disabled={!draft || save.kind === "saving"}
|
||||
onClick={submit}
|
||||
>
|
||||
{save.kind === "saving" ? "Saving…" : "Save pattern"}
|
||||
</button>
|
||||
</div>
|
||||
{save.kind === "error" ? (
|
||||
<p className="schedule-save-status schedule-save-status--error" role="alert">
|
||||
{save.message}
|
||||
</p>
|
||||
) : save.kind === "saved" ? (
|
||||
<p className="schedule-save-status" role="status">
|
||||
Pattern saved.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<h3 className="schedule-section-label">Presence pattern</h3>
|
||||
<ul className="schedule-card schedule-list" aria-label="Presence pattern">
|
||||
{data.children.map((c) => (
|
||||
<li key={c.id} className="schedule-row" data-testid={`pattern-${c.id}`}>
|
||||
<div className="schedule-row-text">
|
||||
<div className="schedule-row-title">{c.display_name}</div>
|
||||
</div>
|
||||
<span className="schedule-rule">{presenceRule(data.schedules[c.id] ?? null)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="schedule-section-head">
|
||||
<h3 className="schedule-section-label">Overrides</h3>
|
||||
<button
|
||||
ref={addRef}
|
||||
type="button"
|
||||
className="schedule-text-action"
|
||||
onClick={() => setEditorOpen(true)}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{childOverrides.length > 0 ? (
|
||||
<ul className="schedule-card schedule-list" aria-label="Overrides">
|
||||
{childOverrides.map((override) => (
|
||||
<OverrideRow
|
||||
key={override.id}
|
||||
override={override}
|
||||
childName={child.display_name}
|
||||
deletion={deletion}
|
||||
onAskDelete={() => setDeletion({ kind: "confirming", id: override.id })}
|
||||
onKeep={() => setDeletion({ kind: "idle" })}
|
||||
onDelete={() => confirmDelete(override.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="schedule-card schedule-empty">No overrides for {child.display_name}.</div>
|
||||
)}
|
||||
{deletion.kind === "error" ? (
|
||||
<p className="schedule-save-status schedule-save-status--error" role="alert">
|
||||
{deletion.message}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="schedule-screen">
|
||||
<ScreenHeader sub={sub} />
|
||||
<div
|
||||
className="schedule-scroll"
|
||||
data-testid="schedule-scroll"
|
||||
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
cycleWeekIndex,
|
||||
dayState,
|
||||
monthDates,
|
||||
monthStart,
|
||||
presenceRule,
|
||||
resizePattern,
|
||||
togglePatternDay,
|
||||
weekdayOf,
|
||||
} from "./cycle";
|
||||
import type { PresenceOverride, PresenceScheduleBody } from "../api/presence";
|
||||
|
||||
// Anchor Monday 2026-09-07; week 0 home every day, week 1 home Sat/Sun only.
|
||||
const SCHEDULE: PresenceScheduleBody = {
|
||||
cycle_length_weeks: 2,
|
||||
anchor_date: "2026-09-07",
|
||||
pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [5, 6] },
|
||||
};
|
||||
|
||||
describe("cycleWeekIndex", () => {
|
||||
it("follows the anchor across a month, wrapping every 2 weeks", () => {
|
||||
const expected: Record<string, number> = {};
|
||||
for (const date of monthDates("2026-09-01")) {
|
||||
const day = Number(date.slice(8));
|
||||
// Sep 7-13 week 0, 14-20 week 1, 21-27 week 0 (wrap), 28-30 week 1;
|
||||
// Sep 1-6 lie before the anchor and walk back into week 1.
|
||||
expected[date] = day < 7 ? 1 : Math.floor((day - 7) / 7) % 2;
|
||||
}
|
||||
for (const [date, week] of Object.entries(expected)) {
|
||||
expect(cycleWeekIndex(SCHEDULE.anchor_date, date, 2), date).toBe(week);
|
||||
}
|
||||
expect(cycleWeekIndex("2026-09-07", "2026-09-21", 2)).toBe(0);
|
||||
expect(cycleWeekIndex("2026-09-07", "2026-09-28", 2)).toBe(1);
|
||||
});
|
||||
|
||||
it("walks the cycle backwards before the anchor (floor division)", () => {
|
||||
expect(cycleWeekIndex("2026-09-07", "2026-09-06", 2)).toBe(1); // day -1
|
||||
expect(cycleWeekIndex("2026-09-07", "2026-08-31", 2)).toBe(1); // day -7
|
||||
expect(cycleWeekIndex("2026-09-07", "2026-08-30", 2)).toBe(0); // day -8
|
||||
expect(cycleWeekIndex("2026-09-07", "2026-08-24", 2)).toBe(0); // day -14
|
||||
expect(cycleWeekIndex("2026-09-07", "2026-08-23", 2)).toBe(1); // day -15
|
||||
expect(cycleWeekIndex("2026-09-07", "2026-09-06", 3)).toBe(2); // last week of a 3-week cycle
|
||||
});
|
||||
|
||||
it("ignores ISO week parity across a 53-week year", () => {
|
||||
// 2026-12-28 is ISO week 53 and 2027-01-04 ISO week 1 — both odd, so
|
||||
// parity would call them the same week. From the anchor they alternate.
|
||||
expect(cycleWeekIndex("2026-09-07", "2026-12-28", 2)).toBe(0);
|
||||
expect(cycleWeekIndex("2026-09-07", "2027-01-04", 2)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calendar helpers", () => {
|
||||
it("weekdayOf is Monday=0", () => {
|
||||
expect(weekdayOf("2026-09-07")).toBe(0);
|
||||
expect(weekdayOf("2026-09-01")).toBe(1);
|
||||
expect(weekdayOf("2026-09-06")).toBe(6);
|
||||
});
|
||||
|
||||
it("monthStart shifts across year ends", () => {
|
||||
expect(monthStart("2026-12-15", 1)).toBe("2027-01-01");
|
||||
expect(monthStart("2026-01-31", -1)).toBe("2025-12-01");
|
||||
expect(monthDates("2026-02-01")).toHaveLength(28);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dayState", () => {
|
||||
const overrides: PresenceOverride[] = [
|
||||
{ id: 1, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: null },
|
||||
{ id: 2, child_id: 1, start_date: "2026-09-16", end_date: "2026-09-16", is_present: true, note: null },
|
||||
];
|
||||
|
||||
it("applies overrides first, then the anchor-based pattern", () => {
|
||||
expect(dayState(SCHEDULE, overrides, "2026-09-08")).toBe("home");
|
||||
expect(dayState(SCHEDULE, overrides, "2026-09-11")).toBe("override-away");
|
||||
expect(dayState(SCHEDULE, overrides, "2026-09-15")).toBe("away");
|
||||
expect(dayState(SCHEDULE, overrides, "2026-09-16")).toBe("override-home");
|
||||
expect(dayState(SCHEDULE, overrides, "2026-09-19")).toBe("home");
|
||||
expect(dayState(SCHEDULE, overrides, "2026-09-01")).toBe("away");
|
||||
expect(dayState(SCHEDULE, overrides, "2026-09-05")).toBe("home");
|
||||
});
|
||||
|
||||
it("treats a child with no schedule as home every day", () => {
|
||||
expect(dayState(null, [], "2026-09-15")).toBe("home");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pattern editing and rule text", () => {
|
||||
it("toggles one weekday of one cycle week", () => {
|
||||
expect(togglePatternDay(SCHEDULE.pattern, 1, 1)).toEqual({
|
||||
0: [0, 1, 2, 3, 4, 5, 6],
|
||||
1: [1, 5, 6],
|
||||
});
|
||||
expect(togglePatternDay(SCHEDULE.pattern, 1, 5)[1]).toEqual([6]);
|
||||
});
|
||||
|
||||
it("resizes the pattern to the cycle length", () => {
|
||||
expect(resizePattern(SCHEDULE.pattern, 1)).toEqual({ 0: [0, 1, 2, 3, 4, 5, 6] });
|
||||
expect(resizePattern(SCHEDULE.pattern, 3)[2]).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
|
||||
it("describes the presence rule in plain language", () => {
|
||||
expect(presenceRule(null)).toBe("Every day");
|
||||
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [0, 1, 2, 3, 4, 5, 6] } })).toBe(
|
||||
"Every day",
|
||||
);
|
||||
expect(presenceRule(SCHEDULE)).toBe("Weeks 1 & 2 of cycle");
|
||||
expect(
|
||||
presenceRule({ cycle_length_weeks: 4, anchor_date: "2026-09-07", pattern: { 0: [0], 1: [], 2: [3], 3: [] } }),
|
||||
).toBe("Weeks 1 & 3 of cycle");
|
||||
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [1], 1: [] } })).toBe("Week 1 of cycle");
|
||||
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [], 1: [] } })).toBe("Never home");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Pure presence-cycle math for the Schedule tab.
|
||||
*
|
||||
* Cycle position comes from the anchor date ONLY, mirroring
|
||||
* core/presence.py `cycle_week_index`:
|
||||
* `((date - anchor).days // 7) % cycle_length_weeks` with floor division,
|
||||
* so dates before the anchor walk the cycle backwards. Never ISO week
|
||||
* numbers or week parity (D-004).
|
||||
*
|
||||
* Calendar dates are "YYYY-MM-DD" strings handled as whole UTC days, so no
|
||||
* viewer offset or DST change can shift a day.
|
||||
*/
|
||||
import type { PresenceOverride, PresencePattern, PresenceScheduleBody } from "../api/presence";
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
export const ALL_WEEKDAYS = [0, 1, 2, 3, 4, 5, 6];
|
||||
|
||||
/** Days since 1970-01-01 for a "YYYY-MM-DD" calendar date. */
|
||||
export function dayNumber(iso: string): number {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
||||
if (!match) throw new Error(`Invalid calendar date: ${iso}`);
|
||||
const [, year, month, day] = match;
|
||||
return Date.UTC(Number(year), Number(month) - 1, Number(day)) / MS_PER_DAY;
|
||||
}
|
||||
|
||||
export function isoFromDayNumber(days: number): string {
|
||||
return new Date(days * MS_PER_DAY).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Floor modulo: always in 0..divisor-1, like Python's `%`. */
|
||||
function mod(value: number, divisor: number): number {
|
||||
return ((value % divisor) + divisor) % divisor;
|
||||
}
|
||||
|
||||
/** 0 = Monday .. 6 = Sunday (1970-01-01 was a Thursday). */
|
||||
export function weekdayOf(iso: string): number {
|
||||
return mod(dayNumber(iso) + 3, 7);
|
||||
}
|
||||
|
||||
/** `((date - anchor).days // 7) % cycleLengthWeeks`, Python semantics. */
|
||||
export function cycleWeekIndex(anchorDate: string, date: string, cycleLengthWeeks: number): number {
|
||||
const days = dayNumber(date) - dayNumber(anchorDate);
|
||||
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);
|
||||
const date = new Date(Date.UTC(year, month - 1 + delta, 1));
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Every date of the month starting at `firstIso`. */
|
||||
export function monthDates(firstIso: string): string[] {
|
||||
const start = dayNumber(firstIso);
|
||||
const end = dayNumber(monthStart(firstIso, 1));
|
||||
const dates: string[] = [];
|
||||
for (let day = start; day < end; day += 1) dates.push(isoFromDayNumber(day));
|
||||
return dates;
|
||||
}
|
||||
|
||||
/** Present every day for every week of the cycle (the no-schedule case). */
|
||||
export function fullPattern(cycleLengthWeeks: number): PresencePattern {
|
||||
const pattern: PresencePattern = {};
|
||||
for (let week = 0; week < cycleLengthWeeks; week += 1) pattern[week] = [...ALL_WEEKDAYS];
|
||||
return pattern;
|
||||
}
|
||||
|
||||
export type DayState = "home" | "away" | "override-home" | "override-away";
|
||||
|
||||
export function patternIncludes(
|
||||
schedule: PresenceScheduleBody,
|
||||
date: string,
|
||||
): boolean {
|
||||
const week = cycleWeekIndex(schedule.anchor_date, date, schedule.cycle_length_weeks);
|
||||
return (schedule.pattern[week] ?? []).includes(weekdayOf(date));
|
||||
}
|
||||
|
||||
/**
|
||||
* A date's state for one child: an override covering the date wins;
|
||||
* otherwise the schedule's pattern decides (no schedule = home every day).
|
||||
*/
|
||||
export function dayState(
|
||||
schedule: PresenceScheduleBody | null,
|
||||
overrides: PresenceOverride[],
|
||||
date: string,
|
||||
): DayState {
|
||||
const override = overrides.find((o) => o.start_date <= date && date <= o.end_date);
|
||||
if (override) return override.is_present ? "override-home" : "override-away";
|
||||
if (!schedule) return "home";
|
||||
return patternIncludes(schedule, date) ? "home" : "away";
|
||||
}
|
||||
|
||||
/** Flip `weekday` in `week` of the pattern; returns a new pattern. */
|
||||
export function togglePatternDay(
|
||||
pattern: PresencePattern,
|
||||
week: number,
|
||||
weekday: number,
|
||||
): PresencePattern {
|
||||
const days = pattern[week] ?? [];
|
||||
const next = days.includes(weekday)
|
||||
? days.filter((d) => d !== weekday)
|
||||
: [...days, weekday].sort((a, b) => a - b);
|
||||
return { ...pattern, [week]: next };
|
||||
}
|
||||
|
||||
/** Fit the pattern to a new cycle length; added weeks start present every day. */
|
||||
export function resizePattern(pattern: PresencePattern, cycleLengthWeeks: number): PresencePattern {
|
||||
const next: PresencePattern = {};
|
||||
for (let week = 0; week < cycleLengthWeeks; week += 1) {
|
||||
next[week] = pattern[week] ? [...pattern[week]] : [...ALL_WEEKDAYS];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function joinWeeks(weeks: number[]): string {
|
||||
if (weeks.length === 1) return String(weeks[0]);
|
||||
return `${weeks.slice(0, -1).join(", ")} & ${weeks[weeks.length - 1]}`;
|
||||
}
|
||||
|
||||
/** Plain-language rule: `Every day`, `Weeks 1 & 3 of cycle`, `Never home`. */
|
||||
export function presenceRule(schedule: PresenceScheduleBody | null): string {
|
||||
if (!schedule) return "Every day";
|
||||
const weeks: number[] = [];
|
||||
let full = true;
|
||||
for (let week = 0; week < schedule.cycle_length_weeks; week += 1) {
|
||||
const days = schedule.pattern[week] ?? [];
|
||||
if (days.length > 0) weeks.push(week + 1);
|
||||
if (days.length < 7) full = false;
|
||||
}
|
||||
if (full) return "Every day";
|
||||
if (weeks.length === 0) return "Never home";
|
||||
return `${weeks.length === 1 ? "Week" : "Weeks"} ${joinWeeks(weeks)} of cycle`;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Date labels for the Schedule tab. Calendar dates are formatted in UTC from
|
||||
* their components, so the printed day never shifts with the viewer's offset.
|
||||
*/
|
||||
import { dayNumber } from "./cycle";
|
||||
|
||||
const LOCALE = "en-US";
|
||||
|
||||
function utcDate(iso: string): Date {
|
||||
return new Date(dayNumber(iso) * 86_400_000);
|
||||
}
|
||||
|
||||
function format(iso: string, options: Intl.DateTimeFormatOptions): string {
|
||||
return new Intl.DateTimeFormat(LOCALE, { ...options, timeZone: "UTC" }).format(utcDate(iso));
|
||||
}
|
||||
|
||||
/** "Tuesday, Sep 1" */
|
||||
export function anchorLabel(iso: string): string {
|
||||
return `${format(iso, { weekday: "long" })}, ${format(iso, { month: "short", day: "numeric" })}`;
|
||||
}
|
||||
|
||||
/** "September 2026" */
|
||||
export function monthLabel(iso: string): string {
|
||||
return format(iso, { month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
/** "Sep 4" */
|
||||
export function shortDate(iso: string): string {
|
||||
return format(iso, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
/** "Sep 4 – 7", "Sep 28 – Oct 2", or "Sep 4" for a single day. */
|
||||
export function dateRange(start: string, end: string): string {
|
||||
if (start === end) return shortDate(start);
|
||||
const sameMonth = start.slice(0, 7) === end.slice(0, 7);
|
||||
return `${shortDate(start)} – ${sameMonth ? Number(end.slice(8)) : shortDate(end)}`;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Inline stroke glyphs for the Schedule tab (Lucide-style 24px grid),
|
||||
* CDN-free like the Today and Tasks tabs' glyphs.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
function Glyph({ size, children }: { size: number; children: ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronLeftGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronRightGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarFrame() {
|
||||
return (
|
||||
<>
|
||||
<path d="M8 2v4M16 2v4" />
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M3 10h18" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarXGlyph({ size = 18 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<CalendarFrame />
|
||||
<path d="m14 14-4 4M10 14l4 4" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarCheckGlyph({ size = 18 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<CalendarFrame />
|
||||
<path d="m9 16 2 2 4-4" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function Trash2Glyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertCircleGlyph({ size = 18 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/* Today tab — compact density (design/ADMIN-SPEC.md §2). Mobile-first at the
|
||||
390x844 reference; on wider viewports the column is capped and centred. */
|
||||
|
||||
.today-screen {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* §2.1 header */
|
||||
.today-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: var(--nq-a-surface);
|
||||
border-bottom: 1px solid var(--nq-a-border);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.today-title {
|
||||
margin: 0;
|
||||
font-size: var(--nq-a-size-screen);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.today-sub {
|
||||
margin: 2px 0 0;
|
||||
min-height: 1.4em;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.today-icon-button {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
|
||||
.today-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nq-a-density-gap);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.today-card {
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
}
|
||||
|
||||
.today-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.today-list > li + li {
|
||||
border-top: 1px solid var(--nq-a-divider);
|
||||
}
|
||||
|
||||
/* §2.2 stat row */
|
||||
.today-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.today-stat {
|
||||
padding: 12px;
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
}
|
||||
|
||||
.today-stat-value {
|
||||
font-size: var(--nq-a-size-stat);
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.today-stat-value--done {
|
||||
color: var(--nq-brand-blue);
|
||||
}
|
||||
|
||||
.today-stat-value--remaining {
|
||||
color: var(--nq-a-ink);
|
||||
}
|
||||
|
||||
.today-stat-value--overdue {
|
||||
color: var(--nq-a-danger);
|
||||
}
|
||||
|
||||
.today-stat-label {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
/* §2.3 child rows */
|
||||
.today-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: var(--nq-a-density-row-pad);
|
||||
}
|
||||
|
||||
.today-avatar {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 7px;
|
||||
background: var(--nq-brand-gradient);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.today-child--away .today-avatar {
|
||||
background: #9898a8;
|
||||
}
|
||||
|
||||
.today-child-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.today-child-name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.today-child-meta {
|
||||
font-size: var(--nq-a-size-meta);
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.today-child-meta--done {
|
||||
color: var(--nq-a-success);
|
||||
}
|
||||
|
||||
.today-progress {
|
||||
flex: none;
|
||||
width: 82px;
|
||||
height: var(--nq-a-progress-height);
|
||||
border-radius: var(--nq-a-radius-pill);
|
||||
background: var(--nq-a-divider);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.today-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--nq-brand-gradient-h);
|
||||
}
|
||||
|
||||
/* §2.5 needs attention */
|
||||
.today-section-label {
|
||||
margin: 6px 0 -2px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.today-event {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: var(--nq-a-density-row-pad);
|
||||
}
|
||||
|
||||
.today-event-glyph {
|
||||
flex: none;
|
||||
display: flex;
|
||||
color: var(--nq-a-success);
|
||||
}
|
||||
|
||||
.today-event-glyph--overdue {
|
||||
color: var(--nq-a-danger);
|
||||
}
|
||||
|
||||
.today-event-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.today-event-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.today-event-meta {
|
||||
font-size: var(--nq-a-size-meta);
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.today-event-meta--overdue {
|
||||
color: var(--nq-a-danger);
|
||||
}
|
||||
|
||||
.today-action {
|
||||
flex: none;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: 0 4px;
|
||||
border: 0;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.today-action--done {
|
||||
color: var(--nq-brand-blue);
|
||||
}
|
||||
|
||||
.today-action--undo {
|
||||
color: var(--nq-brand-purple);
|
||||
}
|
||||
|
||||
.today-action:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Non-blocking Undo failure message. */
|
||||
.today-action-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: var(--nq-a-density-row-pad);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-danger-bg);
|
||||
color: var(--nq-a-danger-strong);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.today-action-dismiss {
|
||||
flex: none;
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.today-empty {
|
||||
padding: var(--nq-a-density-card-pad);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
/* §2.6 permission note */
|
||||
.today-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-info-bg);
|
||||
color: var(--nq-a-info-ink);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.today-note-glyph {
|
||||
flex: none;
|
||||
display: flex;
|
||||
color: var(--nq-a-info-icon);
|
||||
}
|
||||
|
||||
/* Loading / error */
|
||||
.today-status {
|
||||
padding: 24px 13px;
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
text-align: center;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.today-status--error {
|
||||
color: var(--nq-a-danger-strong);
|
||||
}
|
||||
|
||||
.today-status--error p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.today-retry {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import TodayTab, { PERMISSION_NOTE } from "./TodayTab";
|
||||
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";
|
||||
|
||||
function instance(overrides: Partial<SnapshotInstance>): SnapshotInstance {
|
||||
return {
|
||||
id: 0,
|
||||
definition_id: 1,
|
||||
child_id: 1,
|
||||
title: "Quest",
|
||||
icon: null,
|
||||
window: "morning",
|
||||
due_time: null,
|
||||
state: "open",
|
||||
overdue: false,
|
||||
completed_at: null,
|
||||
on_time: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const SNAPSHOT: AdminSnapshot = {
|
||||
today_iso: "2026-09-23",
|
||||
cycle_day: 5,
|
||||
children: [
|
||||
{
|
||||
child_id: 1,
|
||||
child_name: "Declan",
|
||||
present: true,
|
||||
next_present: null,
|
||||
due_today: 7,
|
||||
completed_today: 3,
|
||||
remaining_today: 4,
|
||||
completion_pct: 43,
|
||||
instances: [
|
||||
instance({ id: 11, title: "Brush teeth", due_time: "07:30", overdue: true }),
|
||||
instance({
|
||||
id: 12,
|
||||
title: "Make bed",
|
||||
state: "completed",
|
||||
completed_at: "2026-09-23T11:04:00Z",
|
||||
on_time: true,
|
||||
}),
|
||||
instance({ id: 13, title: "Homework", window: "afternoon", due_time: "16:00" }),
|
||||
// Overdue but already completed: not counted as overdue.
|
||||
instance({
|
||||
id: 14,
|
||||
title: "Pack bag",
|
||||
state: "completed",
|
||||
overdue: true,
|
||||
completed_at: "2026-09-23T10:00:00Z",
|
||||
on_time: false,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
child_id: 2,
|
||||
child_name: "Maeve",
|
||||
present: true,
|
||||
next_present: null,
|
||||
due_today: 4,
|
||||
completed_today: 4,
|
||||
remaining_today: 0,
|
||||
completion_pct: 100,
|
||||
instances: [],
|
||||
},
|
||||
{
|
||||
child_id: 3,
|
||||
child_name: "Rory",
|
||||
present: false,
|
||||
next_present: "2026-10-01",
|
||||
due_today: 0,
|
||||
completed_today: 0,
|
||||
remaining_today: 0,
|
||||
completion_pct: 0,
|
||||
instances: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
async function renderReady() {
|
||||
fetchMock.mockResolvedValue(jsonResponse(SNAPSHOT));
|
||||
render(<TodayTab />);
|
||||
await screen.findByText("Declan");
|
||||
}
|
||||
|
||||
describe("TodayTab", () => {
|
||||
beforeAll(() => {
|
||||
// A negative-offset zone: `new Date("2026-09-23")` would read as Sep 22 here.
|
||||
vi.stubEnv("TZ", "America/Los_Angeles");
|
||||
});
|
||||
|
||||
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("fetches the admin snapshot with the Bearer token", async () => {
|
||||
await renderReady();
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toMatch(/\/api\/v1\/admin\/snapshot$/);
|
||||
expect(init.headers.Authorization).toBe("Bearer at-123");
|
||||
});
|
||||
|
||||
it("header shows the weekday, date and cycle day", async () => {
|
||||
await renderReady();
|
||||
expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy();
|
||||
expect(screen.getByText("Wednesday, September 23 · cycle day 5 of 14")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Settings" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stat row shows done, remaining and overdue totals", async () => {
|
||||
await renderReady();
|
||||
expect(screen.getByTestId("stat-done").textContent).toBe("7Done");
|
||||
expect(screen.getByTestId("stat-remaining").textContent).toBe("4Remaining");
|
||||
expect(screen.getByTestId("stat-overdue").textContent).toBe("1Overdue");
|
||||
});
|
||||
|
||||
it("present child row shows progress meta and fill", async () => {
|
||||
await renderReady();
|
||||
const row = screen.getByTestId("child-1");
|
||||
expect(within(row).getByText("3 of 7 · 1 overdue")).toBeTruthy();
|
||||
const bar = within(row).getByRole("progressbar");
|
||||
expect(bar.getAttribute("aria-valuenow")).toBe("43");
|
||||
expect((bar.firstElementChild as HTMLElement).style.width).toBe("43%");
|
||||
expect(row.className).not.toContain("today-child--away");
|
||||
});
|
||||
|
||||
it("all-done child renders the success state", async () => {
|
||||
await renderReady();
|
||||
const meta = within(screen.getByTestId("child-2")).getByText("4 of 4 · all done");
|
||||
expect(meta.className).toContain("today-child-meta--done");
|
||||
});
|
||||
|
||||
it("away child shows the return date and the flat avatar", async () => {
|
||||
await renderReady();
|
||||
const row = screen.getByTestId("child-3");
|
||||
expect(within(row).getByText("Away · returns Oct 1")).toBeTruthy();
|
||||
expect(row.className).toContain("today-child--away");
|
||||
expect(within(row).getByTestId("child-avatar").textContent).toBe("R");
|
||||
});
|
||||
|
||||
it("needs-attention lists overdue and completed rows with their actions", async () => {
|
||||
await renderReady();
|
||||
const list = screen.getByRole("list", { name: "Needs attention" });
|
||||
const overdue = within(list).getByTestId("attention-11");
|
||||
expect(within(overdue).getByText("Brush teeth · Declan")).toBeTruthy();
|
||||
expect(within(overdue).getByText("Overdue since 7:30 AM")).toBeTruthy();
|
||||
expect(within(overdue).getByRole("button", { name: "Mark done" })).toBeTruthy();
|
||||
|
||||
const completed = within(list).getByTestId("attention-12");
|
||||
expect(within(completed).getByText("Make bed · Declan")).toBeTruthy();
|
||||
// 11:04Z is 4:04 AM in Los Angeles (PDT).
|
||||
expect(within(completed).getByText("Completed 4:04 AM")).toBeTruthy();
|
||||
expect(within(completed).getByRole("button", { name: "Undo" })).toBeTruthy();
|
||||
|
||||
// Open, not-overdue instances do not need attention.
|
||||
expect(within(list).queryByTestId("attention-13")).toBeNull();
|
||||
// No actor in the payload, so no "from panel".
|
||||
expect(list.textContent).not.toContain("from panel");
|
||||
});
|
||||
|
||||
it("permission note carries the exact copy", async () => {
|
||||
await renderReady();
|
||||
expect(PERMISSION_NOTE).toBe(
|
||||
"Undo is admin-only. A panel tap can never reverse a completion.",
|
||||
);
|
||||
expect(screen.getByTestId("permission-note").textContent).toBe(PERMISSION_NOTE);
|
||||
});
|
||||
|
||||
it("scroll column ends with 92px bottom padding", async () => {
|
||||
await renderReady();
|
||||
expect(screen.getByTestId("today-scroll").style.paddingBottom).toBe("92px");
|
||||
});
|
||||
|
||||
it("shows a loading state while the snapshot is in flight", () => {
|
||||
fetchMock.mockReturnValue(new Promise<Response>(() => {}));
|
||||
render(<TodayTab />);
|
||||
expect(screen.getByTestId("today-loading").textContent).toContain("Loading");
|
||||
expect(screen.getByTestId("today-scroll").style.paddingBottom).toBe("92px");
|
||||
});
|
||||
|
||||
it("shows an error state on a failed fetch and retries", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("boom", { status: 500 }));
|
||||
render(<TodayTab />);
|
||||
const error = await screen.findByTestId("today-error");
|
||||
expect(error.getAttribute("role")).toBe("alert");
|
||||
expect(error.textContent).toContain("could not be loaded");
|
||||
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(SNAPSHOT));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||
expect(await screen.findByText("Declan")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the refusal message when the API answers 403", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("", { status: 403 }));
|
||||
render(<TodayTab />);
|
||||
const error = await screen.findByTestId("today-error");
|
||||
expect(error.textContent).toContain("nestquest-admins");
|
||||
});
|
||||
});
|
||||
|
||||
/** SNAPSHOT after instance 12 ("Make bed") is reversed back to open. */
|
||||
const AFTER_UNDO: AdminSnapshot = {
|
||||
...SNAPSHOT,
|
||||
children: SNAPSHOT.children.map((child) =>
|
||||
child.child_id !== 1
|
||||
? child
|
||||
: {
|
||||
...child,
|
||||
completed_today: 2,
|
||||
remaining_today: 5,
|
||||
completion_pct: 29,
|
||||
instances: child.instances.map((i) =>
|
||||
i.id === 12 ? { ...i, state: "open" as const, completed_at: null, on_time: null } : i,
|
||||
),
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("TodayTab Undo and live updates", () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
let snapshots: AdminSnapshot[];
|
||||
let uncomplete: () => Promise<Response>;
|
||||
let streams: TestStream[];
|
||||
|
||||
function calls(pathSuffix: string, method = "GET") {
|
||||
return fetchMock.mock.calls.filter(
|
||||
([url, init]) =>
|
||||
new URL(String(url), "http://x").pathname.endsWith(pathSuffix) &&
|
||||
(init?.method ?? "GET") === method,
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("TZ", "America/Los_Angeles");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
snapshots = [SNAPSHOT, AFTER_UNDO];
|
||||
uncomplete = () =>
|
||||
Promise.resolve(jsonResponse({ instance_id: 12, state: "open", appended: true }));
|
||||
streams = [];
|
||||
fetchMock = vi.fn((url: string) => {
|
||||
const path = new URL(url, "http://x").pathname;
|
||||
if (path.endsWith("/api/v1/admin/snapshot")) {
|
||||
const next = snapshots.length > 1 ? snapshots.shift()! : snapshots[0];
|
||||
return Promise.resolve(jsonResponse(next));
|
||||
}
|
||||
if (path.endsWith("/api/v1/admin/instances/12/uncomplete")) return uncomplete();
|
||||
if (path.endsWith("/api/v1/admin/events")) {
|
||||
const stream = testStream();
|
||||
streams.push(stream);
|
||||
return Promise.resolve(stream.response);
|
||||
}
|
||||
return Promise.resolve(jsonResponse({ detail: "unexpected" }, 500));
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("Undo POSTs uncomplete for the completed instance and refetches the snapshot", async () => {
|
||||
render(<TodayTab />);
|
||||
const row = await screen.findByTestId("attention-12");
|
||||
fireEvent.click(within(row).getByRole("button", { name: "Undo" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId("attention-12")).toBeNull());
|
||||
const posts = calls("/api/v1/admin/instances/12/uncomplete", "POST");
|
||||
expect(posts).toHaveLength(1);
|
||||
expect(posts[0][1].headers.Authorization).toBe("Bearer at-123");
|
||||
expect(calls("/api/v1/admin/snapshot")).toHaveLength(2);
|
||||
// The reversed instance is open again: one fewer done, one more remaining.
|
||||
expect(screen.getByTestId("stat-done").textContent).toBe("6Done");
|
||||
expect(screen.getByTestId("stat-remaining").textContent).toBe("5Remaining");
|
||||
expect(screen.queryByTestId("today-undo-error")).toBeNull();
|
||||
});
|
||||
|
||||
it("disables Undo while the request is in flight (no double submit)", async () => {
|
||||
const pending = deferred<Response>();
|
||||
uncomplete = () => pending.promise;
|
||||
render(<TodayTab />);
|
||||
const row = await screen.findByTestId("attention-12");
|
||||
const button = within(row).getByRole("button", { name: "Undo" }) as HTMLButtonElement;
|
||||
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => expect(button.disabled).toBe(true));
|
||||
expect(button.textContent).toBe("Undoing…");
|
||||
// Every Undo is disabled while one reversal is in flight.
|
||||
const other = within(screen.getByTestId("attention-14")).getByRole("button", {
|
||||
name: "Undo",
|
||||
}) as HTMLButtonElement;
|
||||
expect(other.disabled).toBe(true);
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(other);
|
||||
expect(calls("/api/v1/admin/instances/12/uncomplete", "POST")).toHaveLength(1);
|
||||
expect(calls("/uncomplete", "POST")).toHaveLength(1);
|
||||
|
||||
pending.resolve(jsonResponse({ instance_id: 12, state: "open", appended: true }));
|
||||
await waitFor(() => expect(screen.queryByTestId("attention-12")).toBeNull());
|
||||
expect(other.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("a failed Undo shows a message and leaves the view intact", async () => {
|
||||
uncomplete = () => Promise.resolve(jsonResponse({ detail: "instance 12 not found" }, 404));
|
||||
render(<TodayTab />);
|
||||
const row = await screen.findByTestId("attention-12");
|
||||
fireEvent.click(within(row).getByRole("button", { name: "Undo" }));
|
||||
|
||||
const alert = await screen.findByTestId("today-undo-error");
|
||||
expect(alert.textContent).toContain("The completion could not be undone: instance 12 not found");
|
||||
// Still the original snapshot: nothing refetched, the row and its Undo remain usable.
|
||||
expect(calls("/api/v1/admin/snapshot")).toHaveLength(1);
|
||||
const button = within(screen.getByTestId("attention-12")).getByRole("button", {
|
||||
name: "Undo",
|
||||
}) as HTMLButtonElement;
|
||||
expect(button.disabled).toBe(false);
|
||||
expect(screen.getByTestId("stat-done").textContent).toBe("7Done");
|
||||
|
||||
fireEvent.click(within(alert).getByRole("button", { name: "Dismiss" }));
|
||||
expect(screen.queryByTestId("today-undo-error")).toBeNull();
|
||||
});
|
||||
|
||||
it("Mark done stays inert", async () => {
|
||||
render(<TodayTab />);
|
||||
const row = await screen.findByTestId("attention-11");
|
||||
fireEvent.click(within(row).getByRole("button", { name: "Mark done" }));
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("a transition on the live stream refetches the snapshot without a reload", async () => {
|
||||
const { unmount } = render(
|
||||
<TransitionsProvider>
|
||||
<TodayTab />
|
||||
</TransitionsProvider>,
|
||||
);
|
||||
await screen.findByTestId("attention-12");
|
||||
await waitFor(() => expect(streams).toHaveLength(1));
|
||||
const [, init] = calls("/api/v1/admin/events")[0];
|
||||
expect(init.headers.Authorization).toBe("Bearer at-123");
|
||||
|
||||
// An uncompletion made elsewhere, its frame split across two chunks.
|
||||
const text = frame("nestquest_quest_uncompleted", {
|
||||
child_id: 1,
|
||||
child_name: "Declan",
|
||||
instance_id: 12,
|
||||
quest_title: "Make bed",
|
||||
window: "morning",
|
||||
due_date: "2026-09-23",
|
||||
due_time: null,
|
||||
occurred_at: "2026-09-23T12:00:00Z",
|
||||
});
|
||||
streams[0].push(text.slice(0, 25));
|
||||
streams[0].push(text.slice(25));
|
||||
|
||||
await waitFor(() => expect(calls("/api/v1/admin/snapshot")).toHaveLength(2));
|
||||
await waitFor(() => expect(screen.queryByTestId("attention-12")).toBeNull());
|
||||
// Refreshed in place: the loading state never replaced the view.
|
||||
expect(screen.queryByTestId("today-loading")).toBeNull();
|
||||
|
||||
const signal = init.signal as AbortSignal;
|
||||
expect(signal.aborted).toBe(false);
|
||||
unmount();
|
||||
expect(signal.aborted).toBe(true);
|
||||
await waitFor(() => expect(streams[0].cancelled()).toBe(true));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Today tab — compact density (design/ADMIN-SPEC.md §2.1–2.3, §2.5–2.6).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
fetchSnapshot,
|
||||
type AdminSnapshot,
|
||||
type SnapshotChild,
|
||||
type SnapshotInstance,
|
||||
} from "../api/snapshot";
|
||||
import { ApiForbiddenError } from "../api/client";
|
||||
import { ApiRequestError } from "../api/definitions";
|
||||
import { uncompleteInstance } from "../api/instances";
|
||||
import { useTransitions } from "../api/useTransitions";
|
||||
import {
|
||||
formatClockTime,
|
||||
formatDueTime,
|
||||
formatLongDate,
|
||||
formatShortDate,
|
||||
} from "./format";
|
||||
import {
|
||||
AlertCircleGlyph,
|
||||
CheckCircleGlyph,
|
||||
InfoGlyph,
|
||||
SettingsGlyph,
|
||||
} from "./glyphs";
|
||||
import "./TodayTab.css";
|
||||
|
||||
const CYCLE_LENGTH_DAYS = 14;
|
||||
|
||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||
export const SCROLL_BOTTOM_PADDING = "92px";
|
||||
|
||||
export const PERMISSION_NOTE =
|
||||
"Undo is admin-only. A panel tap can never reverse a completion.";
|
||||
|
||||
type LoadState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; snapshot: AdminSnapshot };
|
||||
|
||||
function isOverdueOpen(instance: SnapshotInstance): boolean {
|
||||
return instance.overdue && instance.state === "open";
|
||||
}
|
||||
|
||||
function ScreenHeader({ sub }: { sub: string }) {
|
||||
return (
|
||||
<header className="today-header">
|
||||
<div>
|
||||
<h2 className="today-title">Today</h2>
|
||||
<p className="today-sub">{sub}</p>
|
||||
</div>
|
||||
{/* Settings screen is a later task; the control is not wired yet. */}
|
||||
<button type="button" className="today-icon-button" aria-label="Settings">
|
||||
<SettingsGlyph />
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function StatRow({ snapshot }: { snapshot: AdminSnapshot }) {
|
||||
const done = snapshot.children.reduce((sum, c) => sum + c.completed_today, 0);
|
||||
const remaining = snapshot.children.reduce((sum, c) => sum + c.remaining_today, 0);
|
||||
const overdue = snapshot.children.reduce(
|
||||
(sum, c) => sum + c.instances.filter(isOverdueOpen).length,
|
||||
0,
|
||||
);
|
||||
const stats = [
|
||||
{ key: "done", label: "Done", value: done },
|
||||
{ key: "remaining", label: "Remaining", value: remaining },
|
||||
{ key: "overdue", label: "Overdue", value: overdue },
|
||||
];
|
||||
return (
|
||||
<div className="today-stats">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.key} className="today-stat" data-testid={`stat-${stat.key}`}>
|
||||
<div className={`today-stat-value today-stat-value--${stat.key}`}>
|
||||
{stat.value}
|
||||
</div>
|
||||
<div className="today-stat-label">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChildRow({ child }: { child: SnapshotChild }) {
|
||||
const overdue = child.instances.filter(isOverdueOpen).length;
|
||||
const allDone = child.due_today > 0 && child.completed_today === child.due_today;
|
||||
let meta: string;
|
||||
let metaClass = "today-child-meta";
|
||||
if (!child.present) {
|
||||
meta = child.next_present
|
||||
? `Away · returns ${formatShortDate(child.next_present)}`
|
||||
: "Away";
|
||||
} else if (allDone) {
|
||||
meta = `${child.completed_today} of ${child.due_today} · all done`;
|
||||
metaClass += " today-child-meta--done";
|
||||
} else {
|
||||
meta = `${child.completed_today} of ${child.due_today} · ${overdue} overdue`;
|
||||
}
|
||||
const pct = Math.max(0, Math.min(100, child.completion_pct));
|
||||
// ADMIN-SPEC §2.3 also asks for a lowercase `override` pill when an absence
|
||||
// comes from a presence override. The snapshot carries no field saying
|
||||
// where an absence came from, so the pill is omitted rather than guessed.
|
||||
return (
|
||||
<li
|
||||
className={child.present ? "today-child" : "today-child today-child--away"}
|
||||
data-testid={`child-${child.child_id}`}
|
||||
>
|
||||
<span className="today-avatar" data-testid="child-avatar" aria-hidden="true">
|
||||
{child.child_name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<div className="today-child-text">
|
||||
<div className="today-child-name">{child.child_name}</div>
|
||||
<div className={metaClass}>{meta}</div>
|
||||
</div>
|
||||
<div
|
||||
className="today-progress"
|
||||
role="progressbar"
|
||||
aria-label={`${child.child_name} progress`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={pct}
|
||||
>
|
||||
<div className="today-progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
interface AttentionItem {
|
||||
instance: SnapshotInstance;
|
||||
childName: string;
|
||||
}
|
||||
|
||||
function attentionItems(snapshot: AdminSnapshot): AttentionItem[] {
|
||||
const all = snapshot.children.flatMap((child) =>
|
||||
child.instances.map((instance) => ({ instance, childName: child.child_name })),
|
||||
);
|
||||
const overdue = all.filter((item) => isOverdueOpen(item.instance));
|
||||
const completed = all
|
||||
.filter((item) => item.instance.state === "completed" && item.instance.completed_at)
|
||||
.sort((a, b) =>
|
||||
(b.instance.completed_at ?? "").localeCompare(a.instance.completed_at ?? ""),
|
||||
);
|
||||
return [...overdue, ...completed];
|
||||
}
|
||||
|
||||
interface UndoControl {
|
||||
/** The instance whose Undo is in flight, or null. */
|
||||
pendingId: number | null;
|
||||
onUndo: (instanceId: number) => void;
|
||||
}
|
||||
|
||||
function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }) {
|
||||
const { instance, childName } = item;
|
||||
const overdue = isOverdueOpen(instance);
|
||||
const undoing = undo.pendingId === instance.id;
|
||||
// TODO(e57facc2): wire "Mark done". The admin plane has no complete route
|
||||
// yet, so that action is inert here.
|
||||
return (
|
||||
<li className="today-event" data-testid={`attention-${instance.id}`}>
|
||||
<span
|
||||
className={overdue ? "today-event-glyph today-event-glyph--overdue" : "today-event-glyph"}
|
||||
>
|
||||
{overdue ? <AlertCircleGlyph /> : <CheckCircleGlyph />}
|
||||
</span>
|
||||
<div className="today-event-text">
|
||||
<div className="today-event-title">
|
||||
{instance.title} · {childName}
|
||||
</div>
|
||||
{overdue ? (
|
||||
<div className="today-event-meta today-event-meta--overdue">
|
||||
{instance.due_time
|
||||
? `Overdue since ${formatDueTime(instance.due_time)}`
|
||||
: "Overdue"}
|
||||
</div>
|
||||
) : (
|
||||
// The snapshot carries no completion actor, so "from panel" is not printed.
|
||||
<div className="today-event-meta">
|
||||
Completed {formatClockTime(instance.completed_at as string)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{overdue ? (
|
||||
<button type="button" className="today-action today-action--done">
|
||||
Mark done
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="today-action today-action--undo"
|
||||
disabled={undo.pendingId !== null}
|
||||
aria-busy={undoing}
|
||||
onClick={() => undo.onUndo(instance.id)}
|
||||
>
|
||||
{undoing ? "Undoing…" : "Undo"}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionNote() {
|
||||
return (
|
||||
<div className="today-note" data-testid="permission-note">
|
||||
<span className="today-note-glyph">
|
||||
<InfoGlyph />
|
||||
</span>
|
||||
<span>{PERMISSION_NOTE}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TodayTab() {
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
/** The next load is a refresh in place: keep the current view, no loading state. */
|
||||
const silentNext = useRef(false);
|
||||
const [undoingId, setUndoingId] = useState<number | null>(null);
|
||||
const undoingRef = useRef(false);
|
||||
const [undoError, setUndoError] = useState<string | null>(null);
|
||||
|
||||
function refresh() {
|
||||
silentNext.current = true;
|
||||
setAttempt((n) => n + 1);
|
||||
}
|
||||
|
||||
// Any transition (completed, uncompleted, missed, day complete) changes the snapshot.
|
||||
useTransitions(refresh);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const silent = silentNext.current;
|
||||
silentNext.current = false;
|
||||
if (!silent) setState({ kind: "loading" });
|
||||
fetchSnapshot()
|
||||
.then((snapshot) => {
|
||||
if (!cancelled) setState({ kind: "ready", snapshot });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
const message =
|
||||
error instanceof ApiForbiddenError
|
||||
? error.message
|
||||
: "Today's snapshot could not be loaded. Check your connection and try again.";
|
||||
// A failed refresh keeps the snapshot already on screen.
|
||||
setState((prev) => (silent && prev.kind === "ready" ? prev : { kind: "error", message }));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [attempt]);
|
||||
|
||||
/** Reverse one completion, then refetch the snapshot. One request at a time. */
|
||||
async function undo(instanceId: number) {
|
||||
if (undoingRef.current) return;
|
||||
undoingRef.current = true;
|
||||
setUndoingId(instanceId);
|
||||
setUndoError(null);
|
||||
try {
|
||||
await uncompleteInstance(instanceId);
|
||||
refresh();
|
||||
} catch (error: unknown) {
|
||||
setUndoError(
|
||||
error instanceof ApiForbiddenError
|
||||
? error.message
|
||||
: error instanceof ApiRequestError && error.detail
|
||||
? `The completion could not be undone: ${error.detail}`
|
||||
: "The completion could not be undone. Check your connection and try again.",
|
||||
);
|
||||
} finally {
|
||||
undoingRef.current = false;
|
||||
setUndoingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
let sub = "";
|
||||
let body;
|
||||
if (state.kind === "loading") {
|
||||
body = (
|
||||
<div className="today-status" role="status" data-testid="today-loading">
|
||||
Loading today…
|
||||
</div>
|
||||
);
|
||||
} else if (state.kind === "error") {
|
||||
body = (
|
||||
<div className="today-status today-status--error" role="alert" data-testid="today-error">
|
||||
<p>{state.message}</p>
|
||||
<button type="button" className="today-retry" onClick={() => setAttempt((n) => n + 1)}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const { snapshot } = state;
|
||||
sub = `${formatLongDate(snapshot.today_iso)} · cycle day ${snapshot.cycle_day} of ${CYCLE_LENGTH_DAYS}`;
|
||||
const attention = attentionItems(snapshot);
|
||||
body = (
|
||||
<>
|
||||
<StatRow snapshot={snapshot} />
|
||||
<ul className="today-card today-list" aria-label="Children">
|
||||
{snapshot.children.map((child) => (
|
||||
<ChildRow key={child.child_id} child={child} />
|
||||
))}
|
||||
</ul>
|
||||
<h3 className="today-section-label">Needs attention</h3>
|
||||
{attention.length > 0 ? (
|
||||
<ul className="today-card today-list" aria-label="Needs attention">
|
||||
{attention.map((item) => (
|
||||
<AttentionRow
|
||||
key={item.instance.id}
|
||||
item={item}
|
||||
undo={{ pendingId: undoingId, onUndo: (id) => void undo(id) }}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="today-card today-empty">Nothing needs attention.</div>
|
||||
)}
|
||||
<PermissionNote />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="today-screen">
|
||||
<ScreenHeader sub={sub} />
|
||||
<div
|
||||
className="today-scroll"
|
||||
data-testid="today-scroll"
|
||||
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
|
||||
>
|
||||
{undoError !== null ? (
|
||||
<div className="today-action-error" role="alert" data-testid="today-undo-error">
|
||||
<span>{undoError}</span>
|
||||
<button type="button" className="today-action-dismiss" onClick={() => setUndoError(null)}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Date/time formatting for the Today tab.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const LOCALE = "en-US";
|
||||
|
||||
function calendarDate(iso: string): Date {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
||||
if (!match) throw new Error(`Invalid calendar date: ${iso}`);
|
||||
const [, year, month, day] = match;
|
||||
return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
|
||||
}
|
||||
|
||||
function format(date: Date, options: Intl.DateTimeFormatOptions): string {
|
||||
// Newer ICU puts a narrow no-break space before AM/PM; normalise it.
|
||||
return new Intl.DateTimeFormat(LOCALE, options)
|
||||
.format(date)
|
||||
.replace(/ /g, " ");
|
||||
}
|
||||
|
||||
/** "Wednesday, September 23" */
|
||||
export function formatLongDate(iso: string): string {
|
||||
const date = calendarDate(iso);
|
||||
const weekday = format(date, { weekday: "long", timeZone: "UTC" });
|
||||
const monthDay = format(date, { month: "long", day: "numeric", timeZone: "UTC" });
|
||||
return `${weekday}, ${monthDay}`;
|
||||
}
|
||||
|
||||
/** "Sep 23" */
|
||||
export function formatShortDate(iso: string): string {
|
||||
return format(calendarDate(iso), { month: "short", day: "numeric", timeZone: "UTC" });
|
||||
}
|
||||
|
||||
/** "09:00" -> "9:00 AM" (a wall-clock time, no zone conversion). */
|
||||
export function formatDueTime(hhmm: string): string {
|
||||
const match = /^(\d{2}):(\d{2})/.exec(hhmm);
|
||||
if (!match) return hhmm;
|
||||
const date = new Date(Date.UTC(2000, 0, 1, Number(match[1]), Number(match[2])));
|
||||
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" });
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Inline stroke glyphs for the Today tab (Lucide-style 24px grid). The
|
||||
* shipped Lucide subset is task b50b048f; these keep this screen CDN-free
|
||||
* until then.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
function Glyph({ size, children }: { size: number; children: ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsGlyph({ size = 18 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1 7 17M17 7l2.1-2.1" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4M12 8h.01" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertCircleGlyph({ size = 18 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckCircleGlyph({ size = 18 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"useDefineForClassFields": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/* Build-time service worker injection. After Vite writes the bundle, the
|
||||
hashed asset URLs referenced by dist/index.html and a content-derived
|
||||
revision are written into dist/sw.js, so the offline shell is complete right
|
||||
after install and every release ships a new worker with fresh caches.
|
||||
Build tooling only — never imported by the app bundle. */
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
export const REVISION_MARKER = "__NQ_BUILD_REVISION__";
|
||||
const PRECACHE_PLACEHOLDER = "const PRECACHE_ASSETS = [];";
|
||||
// Unhashed files sw.js precaches by fixed URL, relative to the build output.
|
||||
export const STATIC_PRECACHE_FILES = [
|
||||
"manifest.webmanifest",
|
||||
"icons/icon-192.png",
|
||||
"icons/icon-512.png",
|
||||
"icons/icon-maskable-512.png",
|
||||
];
|
||||
|
||||
export interface PrecacheFile {
|
||||
path: string;
|
||||
bytes: string | Uint8Array;
|
||||
}
|
||||
|
||||
// Every same-origin URL an index.html loads via <script src> or <link href>,
|
||||
// normalised to an absolute path, in document order and without duplicates.
|
||||
export function extractShellAssets(indexHtml: string): string[] {
|
||||
const tags = indexHtml.match(/<(?:script|link)\b[^>]*>/gi) ?? [];
|
||||
const urls = tags.flatMap((tag) => {
|
||||
const attr = /\b(?:src|href)\s*=\s*["']([^"']+)["']/i.exec(tag);
|
||||
if (!attr) return [];
|
||||
const url = new URL(attr[1], "https://same-origin.invalid/");
|
||||
return url.origin === "https://same-origin.invalid" ? [url.pathname] : [];
|
||||
});
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
|
||||
// Hash of every precached file, sorted by path; each entry is length-prefixed
|
||||
// so no two different file sets hash the same concatenation.
|
||||
export function computeRevision(files: PrecacheFile[]): string {
|
||||
const hash = createHash("sha256");
|
||||
for (const { path, bytes } of [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
|
||||
const data = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : bytes;
|
||||
hash.update(`${path}\0${data.byteLength}\0`).update(data);
|
||||
}
|
||||
return hash.digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
// Returns swSource with the precache list and the build revision filled in.
|
||||
// The revision covers index.html (and so the hashed bundles it names), the
|
||||
// sw.js source and the bytes of every unhashed static file it precaches.
|
||||
export function injectServiceWorker(
|
||||
indexHtml: string,
|
||||
swSource: string,
|
||||
staticFiles: PrecacheFile[],
|
||||
): string {
|
||||
if (!swSource.includes(PRECACHE_PLACEHOLDER) || !swSource.includes(REVISION_MARKER)) {
|
||||
throw new Error("sw.js is missing the PRECACHE_ASSETS placeholder or revision marker");
|
||||
}
|
||||
const assets = extractShellAssets(indexHtml);
|
||||
const revision = computeRevision([
|
||||
{ path: "index.html", bytes: indexHtml },
|
||||
{ path: "sw.js", bytes: swSource },
|
||||
...staticFiles,
|
||||
]);
|
||||
return swSource
|
||||
.replace(PRECACHE_PLACEHOLDER, `const PRECACHE_ASSETS = ${JSON.stringify(assets)};`)
|
||||
.replaceAll(REVISION_MARKER, revision);
|
||||
}
|
||||
|
||||
export function serviceWorkerInjectPlugin(): Plugin {
|
||||
let outDir = "";
|
||||
return {
|
||||
name: "nestquest-sw-inject",
|
||||
apply: "build",
|
||||
configResolved(config) {
|
||||
outDir = resolve(config.root, config.build.outDir);
|
||||
},
|
||||
writeBundle() {
|
||||
const swPath = resolve(outDir, "sw.js");
|
||||
const indexHtml = readFileSync(resolve(outDir, "index.html"), "utf8");
|
||||
const staticFiles = STATIC_PRECACHE_FILES.map((path) => ({
|
||||
path,
|
||||
bytes: readFileSync(resolve(outDir, path)),
|
||||
}));
|
||||
writeFileSync(
|
||||
swPath,
|
||||
injectServiceWorker(indexHtml, readFileSync(swPath, "utf8"), staticFiles),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { serviceWorkerInjectPlugin } from "./vite-sw-plugin";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), serviceWorkerInjectPlugin()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
"""NestQuest API service.
|
||||
|
||||
A standalone FastAPI deployable that owns the NestQuest database and
|
||||
exposes it over HTTP to the Home Assistant integration (panel plane) and
|
||||
the admin PWA (admin plane). See Feature 16 (task cd03735b) in the
|
||||
project tracker for the full contract.
|
||||
|
||||
This package imports the NestQuest domain ``core`` WITHOUT executing
|
||||
``custom_components/nestquest/__init__.py`` — that parent package's
|
||||
``__init__.py`` imports ``homeassistant``, which is absent from the
|
||||
API's runtime. The coupling is encapsulated in
|
||||
:mod:`api.nestquest_core`, which loads ``core`` by file location with
|
||||
:mod:`importlib.util` and registers it under the distinct top-level name
|
||||
``nestquest_core`` (no ``sys.path`` mutation, no generic top-level
|
||||
names published). It is a TRANSITIONAL coupling that Feature 18 will
|
||||
revisit: once the integration becomes a thin API client and ``core``
|
||||
moves out of the integration bundle, this helper goes away.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
"""The NestQuest FastAPI application.
|
||||
|
||||
This module builds the ASGI app for the NestQuest API service (Feature
|
||||
16). The skeleton here wires the bundled ``core`` (Feature 15) and
|
||||
exposes the two endpoints every later task depends on:
|
||||
|
||||
- ``GET /health`` — returns ``200`` with a JSON status body once the
|
||||
app has imported core, opened the database, and applied migrations;
|
||||
a cheap ``SELECT 1`` confirms the connection is live without leaking
|
||||
the filesystem path on an unauthenticated route.
|
||||
- ``GET /api/v1/panel/snapshot`` — the panel plane's today snapshot
|
||||
(service-token protected; see :mod:`api.routes_panel`, whose router
|
||||
carries the shared :func:`api.dependencies.require_panel_token`
|
||||
check).
|
||||
- ``GET /api/v1/admin/ping`` — the admin plane's auth probe, protected
|
||||
by the Authentik OIDC JWT check (see :mod:`api.routes_admin`, whose
|
||||
router carries the shared :func:`api.auth.require_admin_jwt`
|
||||
dependency; the JWKS document it verifies against is cached on
|
||||
``app.state.jwks_cache``).
|
||||
- ``GET /docs`` — FastAPI's auto-served OpenAPI document (Swagger UI);
|
||||
``/openapi.json`` is the raw schema. No extra wiring is needed;
|
||||
FastAPI serves both by default.
|
||||
|
||||
The database is opened and migrated in a FastAPI lifespan context so a
|
||||
startup failure (e.g. an unwritable path) surfaces as a startup error
|
||||
rather than the first request. The connection is closed on shutdown.
|
||||
|
||||
This module exports ONLY the :func:`create_app` factory — there is no
|
||||
module-level app instance, because building one at import time would
|
||||
read ``NESTQUEST_DB_PATH`` at import (and fail-fast on a missing value)
|
||||
before any worker is ready to report it. Serve with::
|
||||
|
||||
uvicorn --factory api.app:create_app
|
||||
|
||||
so the factory runs on each worker startup and reads the environment
|
||||
then.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from api.auth import JwksCache
|
||||
from api.config import ApiConfig
|
||||
from api.database import DatabaseState, make_database
|
||||
from api.events import TransitionPublisher
|
||||
from api.routes_admin import router as admin_router
|
||||
from api.routes_panel import router as panel_router
|
||||
from api.scheduler import MissedSweepScheduler
|
||||
|
||||
|
||||
def _build_lifespan(db_path: str):
|
||||
"""Return a lifespan that opens+migrates then closes one database.
|
||||
|
||||
Captured as a closure so the app factory can hand the configured
|
||||
path in without threading it through FastAPI's lifespan signature.
|
||||
The lifespan also installs the app's ONE in-process transition
|
||||
publisher on ``app.state.publisher`` (see :mod:`api.events`) so
|
||||
every route and SSE subscriber shares that single instance, and
|
||||
starts the daily missed-sweep scheduler
|
||||
(:class:`api.scheduler.MissedSweepScheduler`) against the same
|
||||
database and publisher — cancelled before the connection closes on
|
||||
shutdown.
|
||||
"""
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
state = make_database(db_path)
|
||||
app.state.db = state
|
||||
app.state.publisher = TransitionPublisher()
|
||||
scheduler = MissedSweepScheduler(
|
||||
state.database, publisher=app.state.publisher
|
||||
)
|
||||
app.state.sweep_scheduler = scheduler
|
||||
try:
|
||||
await state.open_and_migrate()
|
||||
scheduler.start()
|
||||
yield
|
||||
finally:
|
||||
await scheduler.stop()
|
||||
await state.close()
|
||||
|
||||
return lifespan
|
||||
|
||||
|
||||
def create_app(config: ApiConfig | None = None) -> FastAPI:
|
||||
"""Build the NestQuest API FastAPI app.
|
||||
|
||||
``config`` defaults to one built from the process environment, so
|
||||
``uvicorn --factory api.app:create_app`` picks up
|
||||
``NESTQUEST_DB_PATH`` (and fails fast on startup if it is missing).
|
||||
Tests pass an explicit config so each test gets a hermetic temp
|
||||
SQLite file.
|
||||
"""
|
||||
cfg = config if config is not None else ApiConfig.from_env()
|
||||
|
||||
app = FastAPI(
|
||||
title="NestQuest API",
|
||||
description=(
|
||||
"Standalone HTTP service that owns the NestQuest database and "
|
||||
"exposes it to the Home Assistant integration (panel plane) and "
|
||||
"the admin PWA (admin plane)."
|
||||
),
|
||||
version="0.1.0",
|
||||
lifespan=_build_lifespan(cfg.db_path),
|
||||
)
|
||||
|
||||
# The resolved config rides on app.state so the reusable route
|
||||
# dependencies (the panel service-token check, the admin JWT check)
|
||||
# read the configured values from the app rather than module state.
|
||||
app.state.config = cfg
|
||||
# The admin plane's ONE per-app JWKS cache: fetched from the
|
||||
# configured provider on first use, then reused until its TTL
|
||||
# lapses (see api.auth.JwksCache). Tests replace it with a
|
||||
# stub-backed cache so no request ever leaves the process.
|
||||
app.state.jwks_cache = JwksCache(cfg.oidc_jwks_url)
|
||||
app.include_router(panel_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
@app.get("/health", summary="Service health")
|
||||
async def health() -> dict[str, str]:
|
||||
"""Return service health.
|
||||
|
||||
Returns ``200`` with ``{"status": "ok"}`` once the lifespan has
|
||||
opened and migrated the database. A cheap ``SELECT 1`` against
|
||||
the live connection confirms the database is reachable WITHOUT
|
||||
leaking the filesystem path on an unauthenticated route; if the
|
||||
connection is dead the query raises and the probe fails.
|
||||
"""
|
||||
state: DatabaseState = app.state.db
|
||||
await state.database.fetch_one("SELECT 1")
|
||||
return {"status": "ok"}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
__all__ = ["create_app"]
|
||||
+490
@@ -0,0 +1,490 @@
|
||||
"""Admin-plane authentication for the NestQuest API service.
|
||||
|
||||
The admin plane (``/api/v1/admin/*``, D-012) is authenticated by
|
||||
Authentik-issued OIDC access tokens: the admin PWA presents its JWT as
|
||||
a Bearer credential and :func:`require_admin_jwt` — the ONE
|
||||
implementation of that check — verifies it before any admin route runs.
|
||||
Verification enforces, without exception:
|
||||
|
||||
- the signature, against the RSA signing key published in the
|
||||
provider's JWKS document (fetched from the configured
|
||||
``NESTQUEST_OIDC_JWKS_URL`` and cached per app — never per request);
|
||||
- the issuer (``iss`` must equal the configured
|
||||
``NESTQUEST_OIDC_ISSUER``);
|
||||
- the audience (``aud`` must carry the configured
|
||||
``NESTQUEST_OIDC_AUDIENCE``);
|
||||
- expiry (``exp`` is required and enforced, and ``nbf`` is enforced
|
||||
when the token carries it).
|
||||
|
||||
Every rejection raises the SAME :class:`~fastapi.HTTPException` with
|
||||
the same detail — nothing in the response distinguishes which check
|
||||
failed. The check FAILS CLOSED: a missing app config, a malformed or
|
||||
missing ``Authorization`` header, a non-RS256 algorithm, an unknown
|
||||
``kid``, and any PyJWT verification error all yield the one 401.
|
||||
|
||||
The token's header (``alg``, ``kid``) is parsed and checked BEFORE the
|
||||
JWKS cache is consulted, so structurally invalid tokens can never
|
||||
amplify load on the provider. An unknown ``kid`` forces ONE
|
||||
TTL-bypassing refetch (throttled, see :class:`JwksCache`) so a rotated
|
||||
key is picked up without waiting out the TTL, and a JWKS entry that
|
||||
matches the ``kid`` but carries corrupt key material maps to the same
|
||||
401. A JWKS FETCH failure is deliberately NOT mapped to 401: the
|
||||
provider being unreachable is a server-side fault (an HTTP 500
|
||||
surfaces) and never poisons the cache, so the plane stays closed while
|
||||
the real cause is visible in the server log instead of disguised as a
|
||||
bad credential; a short backoff window after a failed fetch fails fast
|
||||
(re-raising the recorded fault) rather than fetching once per request.
|
||||
|
||||
Beyond authentication, :func:`require_admin` is the ONE dependency
|
||||
every admin route uses: it REJECTS the panel service token outright —
|
||||
that credential authenticates the panel plane only, so treating it as
|
||||
a JWT candidate would blur two credential types into one check — and
|
||||
then requires the verified JWT's ``groups`` claim to name the
|
||||
``nestquest-admins`` group (authorization, 403, distinct from the
|
||||
uniform authentication 401 so the two failure classes stay apart).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import binascii
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from fastapi import Header, HTTPException, Request
|
||||
|
||||
#: The authorization scheme the admin plane expects. Matched
|
||||
#: case-insensitively, as RFC 9110 requires for scheme names.
|
||||
_BEARER_SCHEME = "bearer"
|
||||
|
||||
#: The ONLY JWT signing algorithm accepted. Pinned to one asymmetric
|
||||
#: algorithm (Authentik's RS256 default) so neither the header's ``alg``
|
||||
#: nor any JWKS key can talk the verifier into a weaker or symmetric
|
||||
#: scheme; PyJWT re-enforces it at decode time.
|
||||
_ALLOWED_ALGORITHM = "RS256"
|
||||
|
||||
#: The 401 detail shared by every rejection — deliberately uniform so
|
||||
#: the response never reveals which part of the check failed.
|
||||
_REJECTION_DETAIL = "Missing or invalid admin credentials"
|
||||
|
||||
#: Challenge header returned with a 401, per the Bearer auth scheme.
|
||||
_WWW_AUTHENTICATE = {"WWW-Authenticate": "Bearer"}
|
||||
|
||||
#: The authorization group a verified admin JWT must name in its
|
||||
#: ``groups`` claim. A module constant (the value Authentik's
|
||||
#: nestquest-admins group maps to) so it is clearly named in one place.
|
||||
ADMIN_GROUP = "nestquest-admins"
|
||||
|
||||
#: Seconds a fetched JWKS document stays trusted before the next
|
||||
#: fetch. Five minutes bounds how long a rotated-away key keeps
|
||||
#: verifying while staying far above a per-request fetch.
|
||||
_JWKS_TTL_SECONDS = 300.0
|
||||
|
||||
#: Seconds a FAILED JWKS fetch blocks further fetch attempts. Within
|
||||
#: this window after a failure the cache fails fast — it re-raises the
|
||||
#: recorded fault instead of touching the provider — so an outage is
|
||||
#: never amplified into one fetch per request.
|
||||
_JWKS_BACKOFF_SECONDS = 5.0
|
||||
|
||||
#: Seconds that must pass before ANOTHER forced (TTL-bypassing) refetch
|
||||
#: may run. An unmatched ``kid`` forces one refetch to pick up rotated
|
||||
#: keys; this interval bounds it, so a flood of unknown-``kid`` tokens
|
||||
#: cannot turn each request into a provider fetch either.
|
||||
_JWKS_KID_REFETCH_SECONDS = 5.0
|
||||
|
||||
#: Seconds before the real JWKS fetch gives up on the provider.
|
||||
_JWKS_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
#: An async callable that fetches the JWKS document from a URL. The
|
||||
#: real one (:func:`_fetch_jwks`) uses httpx; tests inject a stub so
|
||||
#: the suite stays hermetic (no network access).
|
||||
JwksFetcher = Callable[[str], Awaitable[dict[str, object]]]
|
||||
|
||||
|
||||
async def _fetch_jwks(url: str) -> dict[str, object]:
|
||||
"""Fetch the provider's JWKS document over HTTP (the real fetch).
|
||||
|
||||
Uses httpx with a short timeout; a non-2xx response or a body that
|
||||
is not a ``{"keys": [...]}`` object raises — the cache never stores
|
||||
a failed or malformed fetch.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=_JWKS_TIMEOUT_SECONDS) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
document = response.json()
|
||||
if not isinstance(document, dict) or not isinstance(
|
||||
document.get("keys"), list
|
||||
):
|
||||
raise ValueError("JWKS document must be an object with a keys list")
|
||||
return document
|
||||
|
||||
|
||||
class JwksCache:
|
||||
"""One app's cache of the OIDC provider's JWKS document.
|
||||
|
||||
The document is fetched through ``fetcher`` (the real one uses
|
||||
httpx against the configured JWKS URL) and held for ``ttl_seconds``
|
||||
— the dependency NEVER fetches per request. A single fetch is
|
||||
shared between concurrent requests via an :class:`asyncio.Lock`
|
||||
(double-checked around it), and a failed fetch is never cached, so
|
||||
the next request retries once the backoff window lapses rather than
|
||||
being locked out until the TTL does.
|
||||
|
||||
Two bounds keep the provider out of an amplification loop:
|
||||
|
||||
- ``backoff_seconds``: after a FAILED fetch, :meth:`get` fails fast
|
||||
for this long — re-raising the recorded fault WITHOUT touching
|
||||
the fetcher or even the lock — instead of retrying per request.
|
||||
- ``kid_refetch_seconds``: forced (TTL-bypassing) refetches — the
|
||||
unmatched-``kid`` path — may run at most this often; a forced
|
||||
refetch that is bounded out (or one inside the backoff window)
|
||||
is served from the cached document as-is.
|
||||
|
||||
A successful fetch always clears the backoff; the document itself
|
||||
is still trusted only for ``ttl_seconds`` — nothing is cached
|
||||
indefinitely.
|
||||
|
||||
One instance lives on ``app.state.jwks_cache`` (installed by
|
||||
:func:`api.app.create_app`); tests replace it with one built around
|
||||
a stub fetcher, keeping the suite hermetic.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
ttl_seconds: float = _JWKS_TTL_SECONDS,
|
||||
backoff_seconds: float = _JWKS_BACKOFF_SECONDS,
|
||||
kid_refetch_seconds: float = _JWKS_KID_REFETCH_SECONDS,
|
||||
fetcher: JwksFetcher | None = None,
|
||||
) -> None:
|
||||
self._url = url
|
||||
self._ttl_seconds = ttl_seconds
|
||||
self._backoff_seconds = backoff_seconds
|
||||
self._kid_refetch_seconds = kid_refetch_seconds
|
||||
self._fetcher: JwksFetcher = (
|
||||
fetcher if fetcher is not None else _fetch_jwks
|
||||
)
|
||||
self._document: dict[str, object] | None = None
|
||||
self._fetched_at: float | None = None
|
||||
self._failed_until: float | None = None
|
||||
self._last_error: BaseException | None = None
|
||||
self._forced_at: float | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get(self, *, force: bool = False) -> dict[str, object]:
|
||||
"""Return the JWKS document, refetching it if the TTL lapsed.
|
||||
|
||||
With ``force=True`` the TTL is bypassed so an unmatched ``kid``
|
||||
can pick up a rotated key without waiting it out; the forced
|
||||
refetch is itself bounded (see the class docstring): inside the
|
||||
backoff window or the ``kid_refetch_seconds`` interval the
|
||||
cached document is served as-is instead. A failed fetch is
|
||||
re-raised to the caller (a provider outage is a server fault,
|
||||
never a 401) and starts the backoff window, during which every
|
||||
caller — including forced ones with nothing cached — fails fast
|
||||
on the recorded fault.
|
||||
"""
|
||||
if not force:
|
||||
fresh = self._fresh_document()
|
||||
if fresh is not None:
|
||||
return fresh
|
||||
error = self._backoff_error()
|
||||
if error is not None:
|
||||
# Fail fast OUTSIDE the lock: a request inside the backoff
|
||||
# window must not queue behind an in-flight fetch.
|
||||
if force:
|
||||
document = self._document
|
||||
if document is not None:
|
||||
return document
|
||||
raise error
|
||||
async with self._lock:
|
||||
if not force:
|
||||
fresh = self._fresh_document()
|
||||
if fresh is not None:
|
||||
return fresh
|
||||
error = self._backoff_error()
|
||||
if error is not None:
|
||||
if force:
|
||||
document = self._document
|
||||
if document is not None:
|
||||
return document
|
||||
raise error
|
||||
if force and self._refetch_throttled():
|
||||
document = self._document
|
||||
if document is not None:
|
||||
return document
|
||||
return await self._fetch_locked(force=force)
|
||||
|
||||
def _fresh_document(self) -> dict[str, object] | None:
|
||||
"""The cached document if one exists and is inside its TTL."""
|
||||
document = self._document
|
||||
fetched_at = self._fetched_at
|
||||
if (
|
||||
document is not None
|
||||
and fetched_at is not None
|
||||
and (time.monotonic() - fetched_at) < self._ttl_seconds
|
||||
):
|
||||
return document
|
||||
return None
|
||||
|
||||
def _backoff_error(self) -> BaseException | None:
|
||||
"""The recorded fetch fault while the backoff window is open."""
|
||||
failed_until = self._failed_until
|
||||
last_error = self._last_error
|
||||
if (
|
||||
failed_until is not None
|
||||
and last_error is not None
|
||||
and time.monotonic() < failed_until
|
||||
):
|
||||
return last_error
|
||||
return None
|
||||
|
||||
def _refetch_throttled(self) -> bool:
|
||||
"""Whether a forced refetch ran too recently to run another."""
|
||||
forced_at = self._forced_at
|
||||
return forced_at is not None and (
|
||||
time.monotonic() - forced_at
|
||||
) < self._kid_refetch_seconds
|
||||
|
||||
async def _fetch_locked(self, *, force: bool) -> dict[str, object]:
|
||||
"""Run ONE fetch attempt (caller holds the lock) and record it.
|
||||
|
||||
A failed fetch starts the ``backoff_seconds`` window and is
|
||||
re-raised; a successful one stores the document, restarts the
|
||||
TTL and clears the backoff. A forced attempt stamps
|
||||
``_forced_at`` so the ``kid_refetch_seconds`` interval bounds
|
||||
how often forced refetches may run back-to-back.
|
||||
"""
|
||||
if force:
|
||||
self._forced_at = time.monotonic()
|
||||
try:
|
||||
document = await self._fetcher(self._url)
|
||||
except Exception as error:
|
||||
self._failed_until = time.monotonic() + self._backoff_seconds
|
||||
self._last_error = error
|
||||
raise
|
||||
self._document = document
|
||||
self._fetched_at = time.monotonic()
|
||||
self._failed_until = None
|
||||
self._last_error = None
|
||||
return document
|
||||
|
||||
|
||||
#: 403 detail for the panel service token on an admin route. Deliberately
|
||||
#: non-leaking: it names the credential TYPE, not its value or where it came
|
||||
#: from. 403 (not 401) because this is a REFUSED credential type, not an
|
||||
#: absent/invalid one — challenging re-authentication would suggest the same
|
||||
#: credential could work with a retry, which it never can here.
|
||||
_PANEL_TOKEN_REJECTION_DETAIL = "This credential type is not accepted for admin access"
|
||||
|
||||
#: 403 detail for a valid JWT that is not a member of the admin group.
|
||||
#: Distinct from the uniform 401 on purpose: the token AUTHENTICATED, but
|
||||
#: the caller is not AUTHORIZED, so the response may say so.
|
||||
_FORBIDDEN_DETAIL = "Admin access requires membership in the nestquest-admins group"
|
||||
|
||||
|
||||
def _reject() -> HTTPException:
|
||||
"""Build the single 401 the dependency raises for every failure."""
|
||||
return HTTPException(
|
||||
status_code=401,
|
||||
detail=_REJECTION_DETAIL,
|
||||
headers=_WWW_AUTHENTICATE,
|
||||
)
|
||||
|
||||
|
||||
def _matching_key(jwks: dict[str, object], kid: str) -> object | None:
|
||||
"""Return the RSA signing key named ``kid`` from the JWKS.
|
||||
|
||||
Only ``kty: RSA`` entries are considered (the pinned RS256 scheme
|
||||
is asymmetric RSA); anything else — a missing ``keys`` list, no
|
||||
entry with that ``kid`` — returns ``None`` rather than falling back
|
||||
to "some" key. The key is built through PyJWT's JWKS decoder, and
|
||||
an entry that MATCHES the ``kid`` but carries corrupt key material
|
||||
(a malformed ``n``/``e`` raises ``ValueError`` — ``binascii.Error``
|
||||
is its subclass — or ``TypeError`` instead of a ``PyJWTError``) is
|
||||
mapped to the one 401 like every other rejection: the caller must
|
||||
never see a half-built key.
|
||||
"""
|
||||
keys = jwks.get("keys")
|
||||
if not isinstance(keys, list):
|
||||
raise _reject()
|
||||
for entry in keys:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("kid") != kid or entry.get("kty") != "RSA":
|
||||
continue
|
||||
try:
|
||||
return jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(entry))
|
||||
except (
|
||||
jwt.PyJWTError,
|
||||
binascii.Error,
|
||||
ValueError,
|
||||
TypeError,
|
||||
KeyError,
|
||||
):
|
||||
raise _reject() from None
|
||||
return None
|
||||
|
||||
|
||||
async def verify_admin_token(
|
||||
request: Request, token: str
|
||||
) -> dict[str, object]:
|
||||
"""Verify ONE admin bearer token and return its verified claims.
|
||||
|
||||
Reads the OIDC settings off ``app.state.config`` and the JWKS cache
|
||||
off ``app.state.jwks_cache`` (both installed by
|
||||
:func:`api.app.create_app`) so the check carries no module state.
|
||||
A missing config or cache, an EMPTY configured issuer/audience/JWKS
|
||||
URL, a non-RS256 ``alg`` header, an absent ``kid``, an unknown
|
||||
``kid``, and any PyJWT verification failure (bad signature,
|
||||
expired, wrong issuer, wrong audience, not-yet-valid, missing
|
||||
``exp``) raise the SAME 401 — the failure mode is never leaked.
|
||||
``exp`` is REQUIRED (a token that never expires is never accepted)
|
||||
and PyJWT enforces ``nbf`` when the token carries it.
|
||||
|
||||
The token's HEADER (``alg``, ``kid``) is parsed and enforced BEFORE
|
||||
the JWKS cache is consulted, so a structurally invalid token costs
|
||||
no fetch, no lock, no provider load. When the cached document has
|
||||
no entry for the ``kid``, ONE forced (TTL-bypassing, throttled)
|
||||
refetch is made and the match retried once — a rotated key is
|
||||
picked up without waiting out the TTL — before the 401.
|
||||
|
||||
A JWKS fetch failure propagates (see the module docstring): the
|
||||
provider being down is a server fault, not a bad credential.
|
||||
"""
|
||||
config = getattr(request.app.state, "config", None)
|
||||
if (
|
||||
config is None
|
||||
or not config.oidc_issuer
|
||||
or not config.oidc_audience
|
||||
or not config.oidc_jwks_url
|
||||
):
|
||||
raise _reject()
|
||||
cache = getattr(request.app.state, "jwks_cache", None)
|
||||
if cache is None:
|
||||
raise _reject()
|
||||
try:
|
||||
header = jwt.get_unverified_header(token)
|
||||
except jwt.PyJWTError:
|
||||
raise _reject() from None
|
||||
if header.get("alg") != _ALLOWED_ALGORITHM:
|
||||
raise _reject()
|
||||
kid = header.get("kid")
|
||||
if not isinstance(kid, str) or not kid:
|
||||
raise _reject()
|
||||
jwks = await cache.get()
|
||||
try:
|
||||
key = _matching_key(jwks, kid)
|
||||
if key is None:
|
||||
# The kid is not in the cached document: force ONE
|
||||
# TTL-bypassing refetch (bounded by the cache) and retry
|
||||
# the match once before rejecting.
|
||||
jwks = await cache.get(force=True)
|
||||
key = _matching_key(jwks, kid)
|
||||
if key is None:
|
||||
raise _reject()
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
key=key,
|
||||
algorithms=[_ALLOWED_ALGORITHM],
|
||||
issuer=config.oidc_issuer,
|
||||
audience=config.oidc_audience,
|
||||
options={"require": ["exp"]},
|
||||
)
|
||||
except jwt.PyJWTError:
|
||||
raise _reject() from None
|
||||
return claims
|
||||
|
||||
|
||||
async def require_admin_jwt(
|
||||
request: Request,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> dict[str, object]:
|
||||
"""Reject the request with 401 unless it carries a valid admin JWT.
|
||||
|
||||
Extracts the Bearer credential — a missing or malformed
|
||||
``Authorization`` header (wrong scheme, empty credential) raises
|
||||
the one 401 — and delegates the verification to
|
||||
:func:`verify_admin_token`, returning the verified claims for the
|
||||
route to use (the probe route surfaces the token's ``sub``).
|
||||
|
||||
The nestquest-admins GROUP check lives in :func:`require_admin`,
|
||||
which wraps this dependency; ``require_admin_jwt`` remains the plain
|
||||
JWT-validation contract — the 401 path stays exactly this uniform.
|
||||
"""
|
||||
if authorization is None:
|
||||
raise _reject()
|
||||
scheme, _, credential = authorization.partition(" ")
|
||||
if scheme.lower() != _BEARER_SCHEME or not credential.strip():
|
||||
raise _reject()
|
||||
return await verify_admin_token(request, credential.strip())
|
||||
|
||||
|
||||
async def require_admin(
|
||||
request: Request,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> dict[str, object]:
|
||||
"""The ONE admin-plane dependency: service-token refusal + group gate.
|
||||
|
||||
Order matters. FIRST the Bearer credential is compared against the
|
||||
configured panel service token (``app.state.config.panel_token``):
|
||||
a match is refused OUTRIGHT with 403 — BEFORE any JWT parsing — so
|
||||
the panel credential is never even treated as a JWT candidate, let
|
||||
alone a non-admin JWT. (403 rather than 401 because this is a
|
||||
refused credential TYPE, not an invalid one: challenging
|
||||
re-authentication would imply retrying could ever succeed; the
|
||||
detail names the type only, never the value.) A config with an
|
||||
EMPTY panel token never matches anything, so it cannot lock every
|
||||
Bearer request out.
|
||||
|
||||
Otherwise the credential goes through the existing JWT path
|
||||
(:func:`require_admin_jwt` → :func:`verify_admin_token`): a missing
|
||||
or malformed header and any invalid JWT still yield the SAME
|
||||
uniform 401 as before. A VALID JWT is then checked for
|
||||
authorization: its ``groups`` claim must be a list containing
|
||||
:data:`ADMIN_GROUP` — a missing, empty, or non-list ``groups`` or
|
||||
one without the group raises 403 (distinct from the 401 on
|
||||
purpose: the token authenticated, the caller did not authorize).
|
||||
|
||||
Returns the verified claims for the route.
|
||||
"""
|
||||
if authorization is not None:
|
||||
scheme, _, credential = authorization.partition(" ")
|
||||
if scheme.lower() == _BEARER_SCHEME and credential.strip():
|
||||
config = getattr(request.app.state, "config", None)
|
||||
if (
|
||||
config is not None
|
||||
and config.panel_token
|
||||
# compare_digest on bytes (as require_panel_token does)
|
||||
# so the refusal does not short-circuit on the first
|
||||
# mismatching byte — a constant-time comparison keeps a
|
||||
# timing side channel on the credential off the table.
|
||||
and secrets.compare_digest(
|
||||
credential.strip().encode("utf-8"),
|
||||
config.panel_token.encode("utf-8"),
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=_PANEL_TOKEN_REJECTION_DETAIL,
|
||||
)
|
||||
claims = await require_admin_jwt(request, authorization)
|
||||
groups = claims.get("groups")
|
||||
if not isinstance(groups, list) or ADMIN_GROUP not in groups:
|
||||
raise HTTPException(status_code=403, detail=_FORBIDDEN_DETAIL)
|
||||
return claims
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ADMIN_GROUP",
|
||||
"JwksCache",
|
||||
"JwksFetcher",
|
||||
"require_admin",
|
||||
"require_admin_jwt",
|
||||
"verify_admin_token",
|
||||
]
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
"""Configuration for the NestQuest API service.
|
||||
|
||||
The API reads its configuration knobs from environment variables:
|
||||
|
||||
- ``NESTQUEST_DB_PATH`` — the SQLite database file. The API opens this
|
||||
file on startup (creating it if absent) and applies migrations.
|
||||
- ``NESTQUEST_PANEL_TOKEN`` — the static panel-plane service token
|
||||
(D-012): the credential ``/api/v1/panel/*`` routes require via
|
||||
:func:`api.dependencies.require_panel_token`. It is stored outside
|
||||
the repo and handed to the service through the environment.
|
||||
- ``NESTQUEST_OIDC_ISSUER`` — the Authentik OIDC issuer URL whose
|
||||
``iss`` claim admin tokens must carry (D-012 admin plane).
|
||||
- ``NESTQUEST_OIDC_AUDIENCE`` — the OIDC client id the admin tokens
|
||||
must carry as their ``aud`` claim (the API service's client id).
|
||||
- ``NESTQUEST_OIDC_JWKS_URL`` — the Authentik endpoint serving the
|
||||
signing keys (the JWKS document) the admin tokens' signatures are
|
||||
verified against. Cached per app (see :class:`api.auth.JwksCache`).
|
||||
|
||||
Unlike a dev convenience default, a missing or empty value is a
|
||||
configuration ERROR, not a silent fallback: a default database path
|
||||
would silently drop data at reboot, a default (or empty) service token
|
||||
would either authenticate everyone or lock the panel plane out for
|
||||
good, and a default (or empty) OIDC setting would silently leave the
|
||||
admin plane trusting an issuer that was never configured. All five
|
||||
variables are therefore REQUIRED — ``ApiConfig.from_env`` RAISES when
|
||||
any of them is unset or empty. This mirrors the integration's
|
||||
"explicit settings object" discipline (Feature 15's ``core.settings``):
|
||||
the API never reads HA config; callers pass the resolved values into
|
||||
the wiring.
|
||||
|
||||
Serving: the app is built with the factory ``api.app:create_app`` — run
|
||||
it with ``uvicorn --factory api.app:create_app`` so the factory reads
|
||||
the environment on each worker startup rather than at import time.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
#: Environment variable holding the SQLite file path. The API opens
|
||||
#: this file on startup (creating it if absent) and applies migrations.
|
||||
#: REQUIRED — unset or empty raises :class:`ConfigError` from
|
||||
#: :meth:`ApiConfig.from_env`.
|
||||
DB_PATH_ENV = "NESTQUEST_DB_PATH"
|
||||
|
||||
#: Environment variable holding the static panel-plane service token
|
||||
#: (D-012), checked by :func:`api.dependencies.require_panel_token`.
|
||||
#: REQUIRED — unset or empty raises :class:`ConfigError` from
|
||||
#: :meth:`ApiConfig.from_env`; there is no silent default, because an
|
||||
#: empty configured token would either authenticate every caller or
|
||||
#: lock the panel plane out permanently.
|
||||
PANEL_TOKEN_ENV = "NESTQUEST_PANEL_TOKEN"
|
||||
|
||||
#: Environment variable holding the Authentik OIDC issuer URL (D-012
|
||||
#: admin plane): every admin JWT's ``iss`` claim must equal it. Checked
|
||||
#: by :func:`api.auth.require_admin_jwt`. REQUIRED — unset or empty
|
||||
#: raises :class:`ConfigError` from :meth:`ApiConfig.from_env`; a default
|
||||
#: issuer would silently make the admin plane trust tokens from an
|
||||
#: issuer nobody configured.
|
||||
OIDC_ISSUER_ENV = "NESTQUEST_OIDC_ISSUER"
|
||||
|
||||
#: Environment variable holding the OIDC audience (the API service's
|
||||
#: client id at Authentik): every admin JWT's ``aud`` claim must carry
|
||||
#: it. Checked by :func:`api.auth.require_admin_jwt`. REQUIRED — unset
|
||||
#: or empty raises :class:`ConfigError` from :meth:`ApiConfig.from_env`.
|
||||
OIDC_AUDIENCE_ENV = "NESTQUEST_OIDC_AUDIENCE"
|
||||
|
||||
#: Environment variable holding the Authentik JWKS URL: the endpoint
|
||||
#: serving the signing keys admin JWT signatures are verified against.
|
||||
#: Fetched and cached by :class:`api.auth.JwksCache`. REQUIRED — unset
|
||||
#: or empty raises :class:`ConfigError` from :meth:`ApiConfig.from_env`;
|
||||
#: without the provider's keys no token can be verified, so a silent
|
||||
#: default would only move the failure to request time.
|
||||
OIDC_JWKS_URL_ENV = "NESTQUEST_OIDC_JWKS_URL"
|
||||
|
||||
|
||||
class ConfigError(RuntimeError):
|
||||
"""Raised when required API configuration is missing or invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApiConfig:
|
||||
"""Resolved API configuration.
|
||||
|
||||
Every field is required (no defaults): ``from_env`` rejects an
|
||||
unset or empty value for each, and a caller building the dataclass
|
||||
directly must pass explicit values — the token check and the JWT
|
||||
checks themselves fail closed on empty configured values, but
|
||||
expecting a caller to discover that at request time is a wiring
|
||||
mistake, so every field is mandatory. Kept as a dataclass so later
|
||||
tasks (SSE, settings persistence) can extend it without touching
|
||||
the call sites that read ``db_path``.
|
||||
"""
|
||||
|
||||
db_path: str
|
||||
panel_token: str
|
||||
#: The Authentik OIDC issuer URL admin tokens' ``iss`` must equal.
|
||||
oidc_issuer: str
|
||||
#: The OIDC audience (the API service's client id) admin tokens'
|
||||
#: ``aud`` must carry.
|
||||
oidc_audience: str
|
||||
#: The Authentik JWKS URL the signing keys are fetched from.
|
||||
oidc_jwks_url: str
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, env: dict[str, str] | None = None) -> "ApiConfig":
|
||||
"""Build configuration from the process environment.
|
||||
|
||||
``env`` defaults to ``os.environ``; tests pass an explicit dict
|
||||
so they are hermetic (no monkeypatch of the global environ).
|
||||
|
||||
Raises :class:`ConfigError` when ANY of the five variables is
|
||||
unset or empty/whitespace-only — there is no silent default:
|
||||
|
||||
- ``NESTQUEST_DB_PATH``: a default in the temp directory would
|
||||
silently lose data at reboot;
|
||||
- ``NESTQUEST_PANEL_TOKEN``: a default token is never invented —
|
||||
the panel plane is closed until a real one is configured;
|
||||
- the three OIDC variables: an unconfigured issuer/audience/JWKS
|
||||
URL must not silently leave the admin plane open (a default
|
||||
JWKS URL or issuer would point at a provider nobody set up).
|
||||
"""
|
||||
source = env if env is not None else os.environ
|
||||
|
||||
def require(env_var: str, reason: str) -> str:
|
||||
"""Return the stripped env value or raise ConfigError."""
|
||||
value = source.get(env_var, "").strip()
|
||||
if not value:
|
||||
raise ConfigError(f"{env_var} {reason}")
|
||||
return value
|
||||
|
||||
return cls(
|
||||
db_path=require(
|
||||
DB_PATH_ENV,
|
||||
"must be set to a writable SQLite file path (it is unset "
|
||||
"or empty — the API will not fall back to a temp directory "
|
||||
"and silently lose data)",
|
||||
),
|
||||
panel_token=require(
|
||||
PANEL_TOKEN_ENV,
|
||||
"must be set to the panel service token (it is unset or "
|
||||
"empty — the API will not start without one, and no "
|
||||
"default token is invented)",
|
||||
),
|
||||
oidc_issuer=require(
|
||||
OIDC_ISSUER_ENV,
|
||||
"must be set to the Authentik OIDC issuer URL (it is "
|
||||
"unset or empty — the admin plane will not trust tokens "
|
||||
"from an issuer nobody configured)",
|
||||
),
|
||||
oidc_audience=require(
|
||||
OIDC_AUDIENCE_ENV,
|
||||
"must be set to the OIDC audience the admin tokens carry "
|
||||
"(it is unset or empty — the admin plane will not accept "
|
||||
"tokens without a configured audience)",
|
||||
),
|
||||
oidc_jwks_url=require(
|
||||
OIDC_JWKS_URL_ENV,
|
||||
"must be set to the Authentik JWKS URL (it is unset or "
|
||||
"empty — admin token signatures cannot be verified "
|
||||
"without the provider's signing keys)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ApiConfig",
|
||||
"ConfigError",
|
||||
"DB_PATH_ENV",
|
||||
"OIDC_AUDIENCE_ENV",
|
||||
"OIDC_ISSUER_ENV",
|
||||
"OIDC_JWKS_URL_ENV",
|
||||
"PANEL_TOKEN_ENV",
|
||||
]
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Database wiring for the NestQuest API service.
|
||||
|
||||
The API owns the database (it is the writer of record per Feature 16's
|
||||
contract). This module owns one application-lifetime
|
||||
:class:`core.db.NestQuestDatabase`: it opens the SQLite file at the
|
||||
configured path on startup, applies migrations (creating the schema on
|
||||
a fresh file), and closes the connection on shutdown.
|
||||
|
||||
The executor callable passed into ``NestQuestDatabase`` mirrors the
|
||||
contract of ``hass.async_add_executor_job``: schedule a synchronous
|
||||
callable on a worker and await its result. HA's own version lives on
|
||||
the event loop; the API is not HA, so it uses
|
||||
:func:`asyncio.to_thread` for the same effect — sqlite3 calls run on a
|
||||
worker thread and only plain data returns to the event loop.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from api.nestquest_core import core_db, core_migrations
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
#: The async SQLite wrapper. Re-exported so route handlers and later
|
||||
#: tasks reach it through the API package, not the bundled core.
|
||||
NestQuestDatabase = core_db.NestQuestDatabase
|
||||
|
||||
#: The migration runner. Re-exported for the same reason.
|
||||
apply_migrations = core_migrations.apply_migrations
|
||||
|
||||
|
||||
async def _executor(fn: Any, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Run a synchronous callable on a worker thread and await its result.
|
||||
|
||||
Mirrors the contract of ``hass.async_add_executor_job`` so the
|
||||
bundled ``NestQuestDatabase`` (written against that contract) works
|
||||
unchanged in the API process: sqlite3 calls never block the event
|
||||
loop, and only plain Python data (row tuples,
|
||||
:class:`core.db.ExecutionResult`) crosses back.
|
||||
"""
|
||||
return await asyncio.to_thread(fn, *args, **kwargs)
|
||||
|
||||
|
||||
class DatabaseState:
|
||||
"""Holds the live database wrapper and its resolved path.
|
||||
|
||||
A small container so the lifespan and the route handlers share one
|
||||
authoritative reference; FastAPI's lifespan stores one of these on
|
||||
``app.state.db`` and the health route reads it back.
|
||||
"""
|
||||
|
||||
__slots__ = ("database", "db_path")
|
||||
|
||||
def __init__(self, database: NestQuestDatabase, db_path: str) -> None:
|
||||
self.database = database
|
||||
self.db_path = db_path
|
||||
|
||||
async def open_and_migrate(self) -> None:
|
||||
"""Open the connection and apply migrations to the latest version."""
|
||||
LOGGER.info("Opening NestQuest database at %s", self.db_path)
|
||||
await self.database.open(self.db_path)
|
||||
version = await apply_migrations(self.database)
|
||||
LOGGER.info("NestQuest schema at version %d", version)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the connection; safe to call even if open failed."""
|
||||
await self.database.close()
|
||||
|
||||
|
||||
def make_database(db_path: str) -> DatabaseState:
|
||||
"""Construct a :class:`DatabaseState` for the given path (not yet open)."""
|
||||
return DatabaseState(NestQuestDatabase(_executor), db_path)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DatabaseState",
|
||||
"NestQuestDatabase",
|
||||
"apply_migrations",
|
||||
"make_database",
|
||||
]
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Reusable FastAPI dependencies for the NestQuest API routes.
|
||||
|
||||
The panel plane (``/api/v1/panel/*``) is authenticated by the static
|
||||
service token configured through ``NESTQUEST_PANEL_TOKEN`` (D-012): the
|
||||
HA integration presents it as a Bearer credential on every panel
|
||||
request. :func:`require_panel_token` is the ONE implementation of that
|
||||
check — every panel route (this task's snapshot route and each later
|
||||
panel route) declares it as a dependency rather than re-implementing
|
||||
token handling, so the credential, the failure behaviour, and the
|
||||
401 contract stay in exactly one place.
|
||||
|
||||
The dependency FAILS CLOSED: a missing ``Authorization`` header, a
|
||||
malformed one (wrong scheme or empty credential), a token that does not
|
||||
match the configured value, and an empty configured token all yield
|
||||
HTTP 401. The comparison is constant-time (:func:`secrets.compare_digest`)
|
||||
so the wall-clock difference between "wrong length" and "wrong value"
|
||||
cannot be used to probe the token byte by byte, and every rejection
|
||||
carries the same detail — nothing in the response distinguishes which
|
||||
part of the check failed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Header, HTTPException, Request
|
||||
|
||||
#: The authorization scheme the panel plane expects. Matched
|
||||
#: case-insensitively, as RFC 9110 requires for scheme names.
|
||||
_BEARER_SCHEME = "bearer"
|
||||
|
||||
#: The 401 detail shared by every rejection — deliberately uniform so
|
||||
#: the response never reveals which part of the check failed.
|
||||
_REJECTION_DETAIL = "Missing or invalid panel service token"
|
||||
|
||||
#: Challenge header returned with a 401, per the Bearer auth scheme.
|
||||
_WWW_AUTHENTICATE = {"WWW-Authenticate": "Bearer"}
|
||||
|
||||
|
||||
def _reject() -> HTTPException:
|
||||
"""Build the single 401 the dependency raises for every failure."""
|
||||
return HTTPException(
|
||||
status_code=401,
|
||||
detail=_REJECTION_DETAIL,
|
||||
headers=_WWW_AUTHENTICATE,
|
||||
)
|
||||
|
||||
|
||||
async def require_panel_token(
|
||||
request: Request,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> None:
|
||||
"""Reject the request with 401 unless it carries the panel token.
|
||||
|
||||
Reads the configured token off ``app.state.config`` (set by
|
||||
:func:`api.app.create_app`) so the dependency stays reusable across
|
||||
routes and carries no module state of its own. A missing or
|
||||
malformed ``Authorization`` header, a non-matching credential, and
|
||||
an EMPTY configured token (a wiring mistake) all raise the same
|
||||
``HTTPException(401)`` — the check is closed even when the service
|
||||
is misconfigured.
|
||||
|
||||
Returns ``None`` on success; FastAPI discards a ``None`` return,
|
||||
the route handler simply proceeds.
|
||||
"""
|
||||
config = getattr(request.app.state, "config", None)
|
||||
expected = config.panel_token if config is not None else ""
|
||||
if not expected or authorization is None:
|
||||
raise _reject()
|
||||
scheme, _, credential = authorization.partition(" ")
|
||||
if scheme.lower() != _BEARER_SCHEME or not credential.strip():
|
||||
raise _reject()
|
||||
# compare_digest on bytes so a non-ASCII token raises nothing and a
|
||||
# timing side channel on the str path is not available.
|
||||
if not secrets.compare_digest(
|
||||
credential.strip().encode("utf-8"), expected.encode("utf-8")
|
||||
):
|
||||
raise _reject()
|
||||
|
||||
|
||||
__all__ = ["require_panel_token"]
|
||||
@@ -0,0 +1,78 @@
|
||||
"""The ONE in-process transition-event publisher for the API service.
|
||||
|
||||
Route handlers call :meth:`TransitionPublisher.publish` synchronously
|
||||
(no ``await``, callable from any async handler); SSE subscribers stream
|
||||
those transitions through an async generator. The scope of "ONE" is
|
||||
deliberate: a single :class:`TransitionPublisher` instance is created
|
||||
in the app lifespan and stored on ``app.state.publisher`` — per-app,
|
||||
so tests build their own hermetic app without module-level state, and
|
||||
both the panel complete route and the SSE endpoint resolve the SAME
|
||||
instance off ``request.app.state`` (exactly one publisher exists per
|
||||
running app; nothing else may create one).
|
||||
|
||||
Design (small and dependency-free, asyncio only):
|
||||
|
||||
- Each subscriber gets its own ``asyncio.Queue(maxsize=64)``.
|
||||
- ``publish`` is synchronous and non-blocking: it puts the event on
|
||||
every live queue with ``put_nowait``. A slow subscriber that lets
|
||||
its queue fill gets the event DROPPED (oldest overflow) — never a
|
||||
blocked publisher, never unbounded growth.
|
||||
- A disconnected subscriber's queue is removed in the generator's
|
||||
``finally`` block, so teardown is clean and a closed consumer cannot
|
||||
leak its queue.
|
||||
|
||||
Fan-out is fire-and-forget by design: the SSE plane mirrors transition
|
||||
events for the panel UI; a dropped event costs the panel one UI
|
||||
refresh, and the documented recovery path is the next snapshot poll.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
#: Per-subscriber queue capacity. Bounded so a stalled consumer can
|
||||
#: never make the publisher or another subscriber wait; beyond this
|
||||
#: the slow subscriber drops the event (see module docstring).
|
||||
_QUEUE_MAXSIZE = 64
|
||||
|
||||
__all__ = ["TransitionPublisher"]
|
||||
|
||||
|
||||
class TransitionPublisher:
|
||||
"""A single in-process fan-out point for transition events."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Create the publisher with no subscribers."""
|
||||
self._subscribers: set[asyncio.Queue] = set()
|
||||
|
||||
def publish(self, event_type: str, payload: dict) -> None:
|
||||
"""Fan one transition event out to every current subscriber.
|
||||
|
||||
Synchronous and non-blocking: each subscriber's bounded queue
|
||||
is filled with ``put_nowait``, and a FULL queue silently drops
|
||||
the event for that (slow) subscriber rather than blocking the
|
||||
route handler or growing without bound.
|
||||
"""
|
||||
for queue in tuple(self._subscribers):
|
||||
try:
|
||||
queue.put_nowait((event_type, payload))
|
||||
except asyncio.QueueFull:
|
||||
# The subscriber is not keeping up; drop the event for
|
||||
# it and move on (the panel recovers by polling).
|
||||
pass
|
||||
|
||||
async def subscribe(self) -> AsyncIterator[tuple[str, dict]]:
|
||||
"""Yield ``(event_type, payload)`` tuples until the consumer breaks.
|
||||
|
||||
Registers ONE bounded queue for the duration of the iteration
|
||||
and removes it in the ``finally`` block, so a client disconnect
|
||||
(or any break out of the loop) tears the subscription down
|
||||
cleanly with no leaked queue.
|
||||
"""
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=_QUEUE_MAXSIZE)
|
||||
self._subscribers.add(queue)
|
||||
try:
|
||||
while True:
|
||||
yield await queue.get()
|
||||
finally:
|
||||
self._subscribers.discard(queue)
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Transitional coupling: import ``core`` without importing Home Assistant.
|
||||
|
||||
The NestQuest domain ``core`` package (Feature 15) is bundled INSIDE the
|
||||
Home Assistant integration at ``custom_components/nestquest/core/``.
|
||||
Importing it the obvious way — ``import custom_components.nestquest.core``
|
||||
— would execute ``custom_components/nestquest/__init__.py``, which
|
||||
imports ``homeassistant``. Home Assistant is NOT installed in the API
|
||||
service's runtime, so that import raises ``ModuleNotFoundError`` and the
|
||||
API cannot start.
|
||||
|
||||
``core`` itself is a clean, Home-Assistant-free package: its
|
||||
``__init__.py`` is a bare docstring and every internal import is
|
||||
relative (``from .db import ...``). It can therefore be imported by
|
||||
file location as a standalone package.
|
||||
|
||||
This module loads ``core`` with :func:`importlib.util.spec_from_file_location`
|
||||
— registering it under the distinct top-level name ``nestquest_core``
|
||||
with ``submodule_search_locations`` set to the bundled ``core``
|
||||
directory — so its relative imports resolve against that directory
|
||||
without touching ``sys.path``. No generic top-level name (``db``,
|
||||
``const``, ``schema``, ...) is published, so nothing here can silently
|
||||
shadow a future dependency, and the integration's HA-coupled
|
||||
``__init__.py`` is never executed: ``custom_components.nestquest`` is
|
||||
never imported.
|
||||
|
||||
Usage::
|
||||
|
||||
from api.nestquest_core import core_db, core_migrations
|
||||
# core_db.NestQuestDatabase, core_migrations.apply_migrations, ...
|
||||
|
||||
This is a TRANSITIONAL coupling. Feature 18 (task 430f7f98) converts
|
||||
the integration into a thin API client and moves ``core`` out of the
|
||||
integration bundle; once that lands, this bootstrap helper goes away and
|
||||
the API imports ``core`` as an ordinary installed dependency.
|
||||
|
||||
NOTE on the pytest process: the suite's ``conftest.py`` stubs the
|
||||
parent integration package and imports ``custom_components.nestquest.core.*``
|
||||
BEFORE any API test runs, so two copies of the core package coexist in
|
||||
the pytest process — the one registered here as ``nestquest_core.*``
|
||||
(loaded by file location) and the conftest-stubbed
|
||||
``custom_components.nestquest.core.*``. Later tasks must NOT compare
|
||||
class or exception identity across these two copies: a
|
||||
``nestquest_core.db.NestQuestDatabase`` is a DIFFERENT class object from
|
||||
``custom_components.nestquest.core.db.NestQuestDatabase`` even though
|
||||
they share source. Operate on a single copy within any one code path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
#: The repository root (this file is ``<root>/api/nestquest_core.py``).
|
||||
_REPO_ROOT: Path = Path(__file__).resolve().parent.parent
|
||||
|
||||
#: The bundled ``core`` package directory, inside the HA integration.
|
||||
_CORE_DIR: Path = _REPO_ROOT / "custom_components" / "nestquest" / "core"
|
||||
|
||||
#: The ``core`` package's ``__init__.py`` — a bare docstring, so loading
|
||||
#: it by file location executes no imports of its own.
|
||||
_CORE_INIT: Path = _CORE_DIR / "__init__.py"
|
||||
|
||||
#: The distinct top-level name under which the bundled ``core`` package
|
||||
#: is registered in ``sys.modules``. A distinctive name (rather than the
|
||||
#: generic ``core``) avoids publishing shadow-able top-level names and
|
||||
#: keeps the bundled copy separate from the conftest-stubbed
|
||||
#: ``custom_components.nestquest.core`` present in the pytest process.
|
||||
_CORE_MODULE_NAME = "nestquest_core"
|
||||
|
||||
|
||||
def _load_core() -> ModuleType:
|
||||
"""Load the bundled ``core`` package by file location and return it.
|
||||
|
||||
Registers the package under :data:`_CORE_MODULE_NAME` in
|
||||
``sys.modules`` with ``submodule_search_locations`` pointing at the
|
||||
bundled directory, so its relative imports (``from .db import ...``)
|
||||
resolve to ``nestquest_core.<submodule>`` against that directory
|
||||
without mutating ``sys.path``. Idempotent: a second call returns
|
||||
the already-registered module.
|
||||
"""
|
||||
existing = sys.modules.get(_CORE_MODULE_NAME)
|
||||
if existing is not None:
|
||||
return existing
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
_CORE_MODULE_NAME,
|
||||
_CORE_INIT,
|
||||
submodule_search_locations=[str(_CORE_DIR)],
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"could not load core from {_CORE_INIT}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[_CORE_MODULE_NAME] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
#: The bundled core package, loaded by file location and registered as
|
||||
#: ``nestquest_core`` in ``sys.modules``. Held here so callers import the
|
||||
#: submodules through it (``nestquest_core.db``, ``nestquest_core.migrations``)
|
||||
#: the same relative way the integration does.
|
||||
core: ModuleType = _load_core()
|
||||
|
||||
#: Re-exported for convenience; route handlers import these names from
|
||||
#: here rather than reaching into the core package directly.
|
||||
core_db = importlib.import_module(f"{_CORE_MODULE_NAME}.db")
|
||||
core_migrations = importlib.import_module(f"{_CORE_MODULE_NAME}.migrations")
|
||||
|
||||
#: The panel snapshot builder and its payload shaper
|
||||
#: (``core.snapshot.build_snapshot`` / ``instance_payload``) — the pure
|
||||
#: assembly the panel routes call.
|
||||
core_snapshot = importlib.import_module(f"{_CORE_MODULE_NAME}.snapshot")
|
||||
|
||||
#: The completion service (``core.completion.complete_instance`` /
|
||||
#: ``uncomplete_instance``) the panel's write routes call.
|
||||
core_completion = importlib.import_module(f"{_CORE_MODULE_NAME}.completion")
|
||||
|
||||
#: The read-only history layer (``core.history.query_history``,
|
||||
#: ``export_history_csv``, ``rows_to_csv``) the admin plane's history
|
||||
#: query and CSV export routes call.
|
||||
core_history = importlib.import_module(f"{_CORE_MODULE_NAME}.history")
|
||||
|
||||
#: The transition-event builders (``core.events.build_quest_completed_event``
|
||||
#: et al.) the panel's write routes build their SSE events with.
|
||||
core_events = importlib.import_module(f"{_CORE_MODULE_NAME}.events")
|
||||
|
||||
#: The explicit settings object (``core.settings.NestQuestSettings``)
|
||||
#: the snapshot builder's ``settings`` parameter takes.
|
||||
core_settings = importlib.import_module(f"{_CORE_MODULE_NAME}.settings")
|
||||
|
||||
#: The durable settings store (``core.settings_store.load_settings`` /
|
||||
#: ``update_settings``) the admin plane's settings routes read and
|
||||
#: write — the API's OWN settings source: a validated JSON document in
|
||||
#: the existing ``nestquest_meta_state`` table, resolved through the
|
||||
#: same core settings validators (the integration's HA config-entry
|
||||
#: options remain a temporary separate source until Feature 18 wires
|
||||
#: the client to the API).
|
||||
core_settings_store = importlib.import_module(
|
||||
f"{_CORE_MODULE_NAME}.settings_store"
|
||||
)
|
||||
|
||||
#: The children business layer (``core.children.create_child`` et al.)
|
||||
#: the admin plane's children routes call.
|
||||
core_children = importlib.import_module(f"{_CORE_MODULE_NAME}.children")
|
||||
|
||||
#: The quest-definition business layer (``core.quest_definitions`` —
|
||||
#: ``create_quest_definition``, ``edit_quest_definition``,
|
||||
#: ``set_quest_definition_active``) the admin plane's quest-definition
|
||||
#: routes call.
|
||||
core_quest_definitions = importlib.import_module(
|
||||
f"{_CORE_MODULE_NAME}.quest_definitions"
|
||||
)
|
||||
|
||||
#: The recurrence model (``core.recurrence.ScheduleRule`` and
|
||||
#: ``RuleValidationError``) the quest-definition routes build request
|
||||
#: rules with — the model validates at construction, the routes never
|
||||
#: re-implement rule validation.
|
||||
core_recurrence = importlib.import_module(f"{_CORE_MODULE_NAME}.recurrence")
|
||||
|
||||
#: The presence model (``core.presence.PresenceEngine``,
|
||||
#: ``PresenceSchedule``, ``PresenceOverride``) the admin plane's
|
||||
#: presence routes serialize and the tests resolve presence with.
|
||||
core_presence = importlib.import_module(f"{_CORE_MODULE_NAME}.presence")
|
||||
|
||||
#: The presence-write business layer
|
||||
#: (``core.presence_management.set_presence_schedule`` et al.) the
|
||||
#: admin plane's presence routes call — the ONE implementation the HA
|
||||
#: services share.
|
||||
core_presence_management = importlib.import_module(
|
||||
f"{_CORE_MODULE_NAME}.presence_management"
|
||||
)
|
||||
|
||||
#: The typed presence DAOs (``core.dao_presence``) the admin-plane tests
|
||||
#: read stored schedules and overrides back through.
|
||||
core_dao_presence = importlib.import_module(
|
||||
f"{_CORE_MODULE_NAME}.dao_presence"
|
||||
)
|
||||
|
||||
#: The materialization walk (``core.materialize.materialize``,
|
||||
#: ``regenerate_for_definition``, ``regenerate_for_child``) the admin
|
||||
#: plane's regenerate route calls — the same walk the integration's
|
||||
#: config/presence changes trigger.
|
||||
core_materialize = importlib.import_module(f"{_CORE_MODULE_NAME}.materialize")
|
||||
|
||||
#: The core constants module (``core.const.DEFAULT_HORIZON_DAYS``) the
|
||||
#: regenerate route sizes the household rolling horizon with.
|
||||
core_const = importlib.import_module(f"{_CORE_MODULE_NAME}.const")
|
||||
|
||||
#: The typed child DAO (``core.dao_children.ChildrenDao.get``) the
|
||||
#: regenerate route reads a child's existence with before delegating.
|
||||
core_dao_children = importlib.import_module(
|
||||
f"{_CORE_MODULE_NAME}.dao_children"
|
||||
)
|
||||
|
||||
#: The typed definition DAO (``core.dao_rules.QuestDefinitionsDao.get``)
|
||||
#: the regenerate route reads a definition's existence with before
|
||||
#: delegating.
|
||||
core_dao_rules = importlib.import_module(f"{_CORE_MODULE_NAME}.dao_rules")
|
||||
|
||||
#: The HA-free missed sweep (``core.sweep.run_missed_sweep`` +
|
||||
#: ``SWEEP_WATERMARK_KEY``) the admin plane's trigger route calls and
|
||||
#: the daily scheduler runs — the ONE sweep policy the integration's
|
||||
#: bus shim shares.
|
||||
core_sweep = importlib.import_module(f"{_CORE_MODULE_NAME}.sweep")
|
||||
|
||||
__all__ = [
|
||||
"core",
|
||||
"core_children",
|
||||
"core_completion",
|
||||
"core_const",
|
||||
"core_dao_children",
|
||||
"core_dao_presence",
|
||||
"core_dao_rules",
|
||||
"core_db",
|
||||
"core_events",
|
||||
"core_history",
|
||||
"core_materialize",
|
||||
"core_migrations",
|
||||
"core_presence",
|
||||
"core_presence_management",
|
||||
"core_quest_definitions",
|
||||
"core_recurrence",
|
||||
"core_snapshot",
|
||||
"core_settings",
|
||||
"core_settings_store",
|
||||
"core_sweep",
|
||||
]
|
||||
+2241
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,350 @@
|
||||
"""Panel-plane routes for the NestQuest API service.
|
||||
|
||||
The panel plane (``/api/v1/panel/*``, D-012) serves the wall panel's
|
||||
reads. Every route on :data:`router` requires the panel service token
|
||||
through the ONE reusable dependency :func:`api.dependencies.require_panel_token`
|
||||
(declared once at the router level, so each later panel route added to
|
||||
this router inherits the check).
|
||||
|
||||
The snapshot route is a thin adapter over the bundled core: it performs
|
||||
NO business logic. It reads the live database off ``app.state.db``,
|
||||
resolves ONE timezone-aware ``now`` (the API host's local time — the
|
||||
API is the household's "local" host now that the domain no longer lives
|
||||
inside Home Assistant), calls the pure
|
||||
:func:`nestquest_core.snapshot.build_snapshot`, shapes each child's
|
||||
instances through :func:`nestquest_core.snapshot.instance_payload` with
|
||||
``include_missed=False`` (D-009: missed instances are OMITTED from the
|
||||
panel payload), and returns the documented JSON shape:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"today_iso": "2026-09-22",
|
||||
"cycle_day": 2,
|
||||
"children": [
|
||||
{
|
||||
"child_id": 1,
|
||||
"child_name": "Ada",
|
||||
"present": false,
|
||||
"next_present": "2026-09-24",
|
||||
"due_today": 1,
|
||||
"completed_today": 0,
|
||||
"remaining_today": 1,
|
||||
"completion_pct": 0,
|
||||
"instances": [
|
||||
{"id": 7, "definition_id": 3, "child_id": 1,
|
||||
"title": "Pack bag", "icon": null, "window": "morning",
|
||||
"due_time": "10:00", "state": "open", "overdue": true,
|
||||
"completed_at": null, "on_time": null}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
``children`` follows the household's sort order (the core builder lists
|
||||
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).
|
||||
The same :class:`PanelSnapshotResponse` Pydantic model is the route's
|
||||
``response_model``, so the OpenAPI document carries the shape too.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import importlib
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from api.database import DatabaseState
|
||||
from api.dependencies import require_panel_token
|
||||
from api.nestquest_core import (
|
||||
core_completion,
|
||||
core_events,
|
||||
core_settings,
|
||||
core_snapshot,
|
||||
)
|
||||
from api import transitions as api_transitions
|
||||
from api.sse import transition_event_stream
|
||||
|
||||
#: The instance DAO, imported on the app's core copy for the
|
||||
#: child-mismatch guard on the complete route.
|
||||
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
||||
|
||||
#: All panel-plane routes share this router; the service-token check is
|
||||
#: attached HERE so every panel route (current and later) requires it.
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/panel",
|
||||
dependencies=[Depends(require_panel_token)],
|
||||
)
|
||||
|
||||
|
||||
class PanelInstancePayload(BaseModel):
|
||||
"""One instance in the documented panel payload shape (§1).
|
||||
|
||||
Mirrors :func:`nestquest_core.snapshot.instance_payload`:
|
||||
``state`` is ``open`` | ``completed`` (missed instances never reach
|
||||
the panel payload), ``on_time`` passes the completion event's flag
|
||||
through verbatim (``None`` for an open instance), and
|
||||
``completed_at`` is the event's UTC timestamp when completed.
|
||||
"""
|
||||
|
||||
id: int
|
||||
definition_id: int
|
||||
child_id: int
|
||||
title: str
|
||||
icon: str | None
|
||||
window: str
|
||||
due_time: str | None
|
||||
state: str
|
||||
overdue: bool
|
||||
completed_at: str | None
|
||||
on_time: bool | None
|
||||
|
||||
|
||||
class PanelChildPayload(BaseModel):
|
||||
"""One child's day: presence, rollup counts, and today's instances."""
|
||||
|
||||
child_id: int
|
||||
child_name: str
|
||||
present: bool
|
||||
#: ISO date of the child's next present day while they are away
|
||||
#: (the away plate renders ``Returns <weekday>, <Mon D>`` from it);
|
||||
#: ``None`` when the child is present today.
|
||||
next_present: str | None
|
||||
due_today: int
|
||||
completed_today: int
|
||||
remaining_today: int
|
||||
completion_pct: int
|
||||
instances: list[PanelInstancePayload]
|
||||
|
||||
|
||||
class PanelSnapshotResponse(BaseModel):
|
||||
"""The whole household snapshot the panel renders from.
|
||||
|
||||
``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.
|
||||
"""
|
||||
|
||||
today_iso: str
|
||||
cycle_day: int
|
||||
children: list[PanelChildPayload]
|
||||
|
||||
|
||||
class PanelCompleteRequest(BaseModel):
|
||||
"""The body of ``POST /api/v1/panel/instances/{id}/complete``.
|
||||
|
||||
Matches the panel's ``nestquest.complete_quest`` payload (§2 of
|
||||
design/ENTITIES-AND-SERVICES.md): ``actor_child_id`` is the tapped
|
||||
profile. A positive integer is required — the core completion
|
||||
layer rejects non-int (and bool) ids anyway, but rejecting them
|
||||
here with 422 keeps the contract visible in the OpenAPI document
|
||||
and fails before any database work.
|
||||
"""
|
||||
|
||||
actor_child_id: int
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"description": (
|
||||
"The tapped child profile the completion is "
|
||||
"attributed to (decision 9)."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@field_validator("actor_child_id")
|
||||
@classmethod
|
||||
def _positive(cls, value: int) -> int:
|
||||
"""Reject zero and negative ids with a validation error."""
|
||||
if value < 1:
|
||||
raise ValueError("actor_child_id must be a positive integer")
|
||||
return value
|
||||
|
||||
|
||||
def _local_now() -> datetime.datetime:
|
||||
"""Return the API host's local time as ONE timezone-aware clock read.
|
||||
|
||||
``now().astimezone()`` attaches the host's local timezone to a
|
||||
single clock read, so the calendar date and wall-clock time the
|
||||
builder derives can never disagree. Kept as a module function (not
|
||||
inlined) so tests pin the instant the same way the coordinator's
|
||||
``_local_now`` is pinned.
|
||||
"""
|
||||
return datetime.datetime.now().astimezone()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/snapshot",
|
||||
summary="Today's household snapshot",
|
||||
response_model=PanelSnapshotResponse,
|
||||
)
|
||||
async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
|
||||
"""Return today's full household snapshot for the panel.
|
||||
|
||||
Requires the panel service token (checked by the router's shared
|
||||
:func:`~api.dependencies.require_panel_token` dependency). Builds
|
||||
the snapshot through the pure core builder against the live
|
||||
database and shapes each child's instances with
|
||||
``instance_payload(include_missed=False)`` — missed instances are
|
||||
OMITTED from the panel payload (D-009) — then returns the shape
|
||||
documented in this module's docstring and
|
||||
:class:`PanelSnapshotResponse`.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
snapshot = await core_snapshot.build_snapshot(
|
||||
state.database, core_settings.NestQuestSettings(), _local_now()
|
||||
)
|
||||
return PanelSnapshotResponse(
|
||||
today_iso=snapshot.today_iso,
|
||||
cycle_day=snapshot.cycle_day,
|
||||
children=[
|
||||
PanelChildPayload(
|
||||
child_id=child.child_id,
|
||||
child_name=child.child_name,
|
||||
present=child.present,
|
||||
next_present=child.next_present,
|
||||
due_today=child.due_today,
|
||||
completed_today=child.completed_today,
|
||||
remaining_today=child.remaining_today,
|
||||
completion_pct=child.completion_pct,
|
||||
instances=[
|
||||
PanelInstancePayload.model_validate(instance)
|
||||
for instance in core_snapshot.instance_payload(
|
||||
child.instances, include_missed=False
|
||||
)
|
||||
],
|
||||
)
|
||||
for child in snapshot.children
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instances/{instance_id}/complete",
|
||||
summary="Complete one quest instance",
|
||||
status_code=200,
|
||||
)
|
||||
async def panel_complete_instance(
|
||||
instance_id: int, body: PanelCompleteRequest, request: Request
|
||||
) -> dict[str, str]:
|
||||
"""Complete one quest instance as the tapped child profile.
|
||||
|
||||
Requires the panel service token (checked by the router's shared
|
||||
:func:`~api.dependencies.require_panel_token` dependency — the ONE
|
||||
token check; this handler performs NO auth of its own).
|
||||
|
||||
Thin adapter only: resolves the live database off ``app.state.db``,
|
||||
reads ONE timezone-aware clock (:func:`_local_now`), and delegates
|
||||
to :func:`nestquest_core.completion.complete_instance` with
|
||||
``actor_source="panel"`` and the tapped ``actor_child_id`` — the
|
||||
append/idempotence logic, the lock, and the actor-shape validation
|
||||
all live in the core layer, never here.
|
||||
|
||||
Response and idempotence: ``complete_instance`` returns a
|
||||
:class:`~nestquest_core.completion.CompletionResult` whose
|
||||
``appended`` flag reports whether THIS call wrote an event; a
|
||||
repeat call for an already-completed instance is a no-op inside
|
||||
the core layer (no duplicate event) and this route still answers
|
||||
``{"status": "done"}`` either way, so a retried panel tap succeeds.
|
||||
|
||||
Error mapping (deliberately narrow): the ONLY ``ValueError`` mapped
|
||||
to 404 is the unknown-``instance_id`` one, identified by the
|
||||
``instance_id:`` prefix ``complete_instance`` names the failing
|
||||
field with. Every other ``ValueError`` (an invalid actor shape,
|
||||
which the body model and the panel actor contract already make
|
||||
unreachable) re-raises to the framework's 500 — nothing else is
|
||||
turned into a 404 or a 500 of our own making.
|
||||
|
||||
Child mismatch: ``complete_instance`` verifies the tapped
|
||||
``actor_child_id`` exists but does NOT check it is the child the
|
||||
instance belongs to. The route performs that check here (one
|
||||
``QuestInstancesDao.get_by_id`` read) and answers 404 when the
|
||||
instance's ``child_id`` differs — completing another child's quest
|
||||
from the wrong profile must not succeed, and 404 (not 403) keeps
|
||||
the instance's existence from leaking to a wrong-profile tap.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
database = state.database
|
||||
|
||||
# Child-mismatch guard (see docstring): the instance must belong to
|
||||
# the tapped profile before the completion layer is entered.
|
||||
instance = await _dao_instances.QuestInstancesDao(database).get_by_id(
|
||||
instance_id
|
||||
)
|
||||
if instance is not None and instance.child_id != body.actor_child_id:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Quest instance not found for this child",
|
||||
)
|
||||
|
||||
now = _local_now()
|
||||
try:
|
||||
result = await core_completion.complete_instance(
|
||||
database,
|
||||
instance_id,
|
||||
actor_source="panel",
|
||||
actor_child_id=body.actor_child_id,
|
||||
now=now,
|
||||
)
|
||||
except ValueError as error:
|
||||
message = str(error)
|
||||
if message.startswith("instance_id:"):
|
||||
# complete_instance names the field in its ValueError:
|
||||
# "instance_id: quest instance N does not exist".
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Quest instance not found",
|
||||
) from error
|
||||
raise
|
||||
# The derived state is "done" in both the appended and no-op cases;
|
||||
# ``result.appended`` distinguishes them for callers that care —
|
||||
# the panel route returns the same success either way.
|
||||
if result.appended:
|
||||
# An ACTUAL transition: publish the SSE transition event. The
|
||||
# payload is built by the core builder (never hand-built here)
|
||||
# and fan-out is fire-and-forget (see api/events.py).
|
||||
event_type, payload = await core_events.build_quest_completed_event(
|
||||
database, instance_id, was_on_time=result.was_on_time
|
||||
)
|
||||
request.app.state.publisher.publish(event_type, payload)
|
||||
# THEN evaluate the day-complete rule off the completed event's
|
||||
# payload (the core builder decides when the child's whole day
|
||||
# is cleared — a zero-quest day and a non-today instance never
|
||||
# fire it). ``today`` comes from the route's single clock read
|
||||
# so the calendar date can never disagree with the completion's.
|
||||
await api_transitions.publish_child_day_complete(
|
||||
database,
|
||||
payload,
|
||||
now.date(),
|
||||
request.app.state.publisher,
|
||||
)
|
||||
return {"status": str(result)}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events",
|
||||
summary="Stream panel transition events (SSE)",
|
||||
)
|
||||
async def panel_events(request: Request) -> StreamingResponse:
|
||||
"""Stream the app's transition events to the panel (Server-Sent
|
||||
Events).
|
||||
|
||||
Requires the panel service token (checked by the router's shared
|
||||
:func:`~api.dependencies.require_panel_token` dependency — the ONE
|
||||
token check; this handler performs NO auth of its own).
|
||||
|
||||
Returns the shared :func:`api.sse.transition_event_stream` response
|
||||
off the app's single in-process publisher (``app.state.publisher``,
|
||||
installed in the lifespan). The stream ends when the client
|
||||
disconnects, at which point the subscription is torn down (its
|
||||
queue removed) with nothing leaked.
|
||||
"""
|
||||
return transition_event_stream(request)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Daily missed-sweep background scheduler for the API service.
|
||||
|
||||
The API service has no Home Assistant, so nobody re-fires the
|
||||
integration's day-rollover listener for it: this module owns the API
|
||||
plane's own background job. One :class:`MissedSweepScheduler` per app
|
||||
(installed on ``app.state`` by the lifespan) sleeps until the
|
||||
configured ``day_rollover_time`` — read fresh from the settings store
|
||||
at the top of EVERY cycle, so an admin's settings change applies on
|
||||
the next scheduling decision without a restart — then runs the
|
||||
HA-free core's :func:`nestquest_core.sweep.run_missed_sweep` for the
|
||||
API host's local date and publishes each returned
|
||||
``nestquest_quest_missed`` event on the app's ONE transition publisher
|
||||
(the same SSE stream every route publishes through).
|
||||
|
||||
All sweep policy lives in the core (the watermark, the
|
||||
``[watermark, today)`` window, the no-completion-event rule): this
|
||||
scheduler only decides WHEN. A same-night rerun — e.g. two workers,
|
||||
or a settings change moving the rollover past itself — fires nothing:
|
||||
the core's watermark makes the run an empty no-op.
|
||||
|
||||
Testability: ``clock`` (an aware-now callable) and ``sleep`` (an
|
||||
awaitable delay) are injectable, so tests pin the time, fast-forward
|
||||
through the wait, and observe the computed delays without waiting a
|
||||
real day. Cancellation: :meth:`stop` cancels the loop task and waits
|
||||
for it, so the lifespan's shutdown is clean and a mid-run sweep is
|
||||
interrupted before the connection closes. A failed iteration (a
|
||||
database hiccup, a rejected setting) is logged and retried after a
|
||||
fixed backoff rather than silently killing the job.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from api.nestquest_core import core_settings_store, core_sweep
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
#: Backoff after a failed scheduling cycle (settings read or sweep
|
||||
#: error), so a persistent failure retries every minute instead of
|
||||
#: spinning or dying.
|
||||
_RETRY_BACKOFF_SECONDS = 60.0
|
||||
|
||||
|
||||
def _delay_seconds(target: datetime.time, now: datetime.datetime) -> float:
|
||||
"""Seconds from ``now`` until the next occurrence of ``target``.
|
||||
|
||||
``target`` is a plain ``HH:MM`` time of day: the next wall-clock
|
||||
occurrence strictly AFTER ``now`` (an equal time schedules for
|
||||
tomorrow, so the just-run sweep is never instantly re-run).
|
||||
"""
|
||||
scheduled = now.replace(
|
||||
hour=target.hour, minute=target.minute, second=0, microsecond=0
|
||||
)
|
||||
if scheduled <= now:
|
||||
scheduled += datetime.timedelta(days=1)
|
||||
return (scheduled - now).total_seconds()
|
||||
|
||||
|
||||
class MissedSweepScheduler:
|
||||
"""Run the missed sweep once per day at ``day_rollover_time``."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
database: object,
|
||||
*,
|
||||
publisher: object | None = None,
|
||||
clock: object | None = None,
|
||||
sleep: object | None = None,
|
||||
) -> None:
|
||||
"""Create the scheduler (not yet running).
|
||||
|
||||
``database`` is the app's live :class:`~api.database.NestQuestDatabase`.
|
||||
``publisher`` is the app's ONE
|
||||
:class:`~api.events.TransitionPublisher` (publishing is skipped
|
||||
when ``None``). ``clock`` defaults to the API host's local
|
||||
aware now (``datetime.datetime.now().astimezone()``) — the same
|
||||
single-clock discipline the routes' ``_local_now`` uses;
|
||||
``sleep`` defaults to :func:`asyncio.sleep`.
|
||||
"""
|
||||
self._database = database
|
||||
self._publisher = publisher
|
||||
self._clock = clock if clock is not None else (
|
||||
lambda: datetime.datetime.now().astimezone()
|
||||
)
|
||||
self._sleep = sleep if sleep is not None else asyncio.sleep
|
||||
self._task: asyncio.Task | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the background loop (idempotent: a running one stays)."""
|
||||
if self._task is None or self._task.done():
|
||||
self._task = asyncio.create_task(
|
||||
self._run_forever(), name="nestquest-missed-sweep"
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel the loop and wait for it to finish (safe if never started)."""
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
"""Whether the background loop task is alive."""
|
||||
return self._task is not None and not self._task.done()
|
||||
|
||||
async def _run_forever(self) -> None:
|
||||
"""Schedule → sweep → repeat, forever (until cancelled)."""
|
||||
while True:
|
||||
try:
|
||||
settings = await core_settings_store.load_settings(
|
||||
self._database
|
||||
)
|
||||
rollover = datetime.time.fromisoformat(
|
||||
settings.day_rollover_time
|
||||
)
|
||||
except Exception:
|
||||
# Not silent: the failure is logged and retried after
|
||||
# the backoff, so one bad settings document or a
|
||||
# transient database error never kills the scheduler.
|
||||
LOGGER.exception(
|
||||
"NestQuest missed-sweep scheduler failed to resolve "
|
||||
"the day rollover time; retrying in %gs",
|
||||
_RETRY_BACKOFF_SECONDS,
|
||||
)
|
||||
await self._sleep(_RETRY_BACKOFF_SECONDS)
|
||||
continue
|
||||
delay = _delay_seconds(rollover, self._clock())
|
||||
LOGGER.info(
|
||||
"NestQuest missed sweep scheduled for %s (%.0fs from now)",
|
||||
settings.day_rollover_time,
|
||||
delay,
|
||||
)
|
||||
await self._sleep(delay)
|
||||
try:
|
||||
await self._run_once(self._clock().date())
|
||||
except Exception:
|
||||
# Not silent either: the sweep error is logged and the
|
||||
# loop backs off before the next cycle, whose watermark
|
||||
# re-read makes an already-swept day a no-op.
|
||||
LOGGER.exception(
|
||||
"NestQuest scheduled missed sweep failed; retrying in "
|
||||
"%gs",
|
||||
_RETRY_BACKOFF_SECONDS,
|
||||
)
|
||||
await self._sleep(_RETRY_BACKOFF_SECONDS)
|
||||
|
||||
async def _run_once(self, today: datetime.date) -> None:
|
||||
"""Run ONE sweep for ``today`` and publish what it built.
|
||||
|
||||
A failure propagates to :meth:`_run_forever`, which logs it and
|
||||
backs off before the next cycle; the watermark makes a re-run
|
||||
of an already-swept day announce nothing twice.
|
||||
"""
|
||||
events = await core_sweep.run_missed_sweep(
|
||||
self._database, today=today
|
||||
)
|
||||
for event_type, payload in events:
|
||||
if self._publisher is not None:
|
||||
self._publisher.publish(event_type, payload)
|
||||
LOGGER.info(
|
||||
"NestQuest scheduled missed sweep for %s published %d event(s)",
|
||||
today.isoformat(),
|
||||
len(events),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["MissedSweepScheduler"]
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
"""The ONE Server-Sent Events response helper for the API service.
|
||||
|
||||
Both SSE planes — the panel plane (``/api/v1/panel/events``, panel
|
||||
service token, LAN-only) and the admin plane (``/api/v1/admin/events``,
|
||||
Authentik admin JWT) — stream the SAME transitions off the SAME
|
||||
in-process publisher (``app.state.publisher``, see :mod:`api.events`)
|
||||
with the SAME framing and teardown, so the stream lives here once and
|
||||
each plane's route only supplies its own router-level auth.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
__all__ = ["transition_event_stream"]
|
||||
|
||||
|
||||
async def _sse_stream(request: Request) -> AsyncIterator[str]:
|
||||
"""Yield the SSE frames for every published transition event.
|
||||
|
||||
One subscriber of the app's single publisher (resolved off
|
||||
``app.state.publisher``); each event becomes one
|
||||
``event: <type>\\ndata: <json>\\n\\n`` frame. Breaking out of the
|
||||
publisher's subscription (on client disconnect) tears the
|
||||
subscription down cleanly — no queue is leaked.
|
||||
"""
|
||||
publisher = request.app.state.publisher
|
||||
async for event_type, payload in publisher.subscribe():
|
||||
yield (
|
||||
f"event: {event_type}\n"
|
||||
f"data: {json.dumps(payload)}\n\n"
|
||||
)
|
||||
|
||||
|
||||
def transition_event_stream(request: Request) -> StreamingResponse:
|
||||
"""A ``text/event-stream`` response over the app's transition events.
|
||||
|
||||
Performs NO auth of its own: the calling route's router-level
|
||||
dependency is the ONE check for its plane. The stream ends when
|
||||
the client disconnects, at which point the subscription is torn
|
||||
down (its queue removed) with nothing leaked.
|
||||
"""
|
||||
return StreamingResponse(
|
||||
_sse_stream(request),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Reusable SSE transition publishers for the API service (task 44c93cfd).
|
||||
|
||||
Each helper wraps the ONE publisher on ``app.state.publisher`` with a
|
||||
single documented transition: it builds the payload through the core
|
||||
event builders (``nestquest_core.events``) — never hand-built here —
|
||||
and fans it out with :meth:`~api.events.TransitionPublisher.publish`.
|
||||
|
||||
The uncomplete route (follow-up task da0226b3) and the API-side missed
|
||||
sweep do not exist yet; these helpers are the functions they will call,
|
||||
and the SSE tests exercise them directly. Fan-out stays fire-and-forget
|
||||
(see api/events.py): a slow subscriber drops the event and recovers by
|
||||
polling the snapshot.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
from api.nestquest_core import core_events
|
||||
|
||||
|
||||
async def publish_quest_uncompleted(
|
||||
database: object, instance_id: int, publisher: object
|
||||
) -> None:
|
||||
"""Publish ONE ``nestquest_quest_uncompleted`` event for an actual
|
||||
reversal of ``instance_id``.
|
||||
|
||||
The payload is built by the core builder
|
||||
(:func:`nestquest_core.events.build_quest_uncompleted_events`);
|
||||
this helper only picks the single event out of the builder's list
|
||||
and publishes it.
|
||||
"""
|
||||
events = await core_events.build_quest_uncompleted_events(
|
||||
database, instance_id
|
||||
)
|
||||
for event_type, payload in events:
|
||||
publisher.publish(event_type, payload)
|
||||
|
||||
|
||||
async def publish_quest_missed(
|
||||
database: object, instance_id: int, publisher: object
|
||||
) -> None:
|
||||
"""Publish ONE ``nestquest_quest_missed`` event for ``instance_id``.
|
||||
|
||||
The payload is built by the core builder
|
||||
(:func:`nestquest_core.events.build_quest_missed_event`), which
|
||||
mirrors the documented sweep payload exactly (§3 of
|
||||
design/ENTITIES-AND-SERVICES.md).
|
||||
"""
|
||||
event_type, payload = await core_events.build_quest_missed_event(
|
||||
database, instance_id
|
||||
)
|
||||
publisher.publish(event_type, payload)
|
||||
|
||||
|
||||
async def publish_child_day_complete(
|
||||
database: object,
|
||||
completed_payload: dict,
|
||||
today: datetime.date,
|
||||
publisher: object,
|
||||
) -> bool:
|
||||
"""Publish ``nestquest_child_day_complete`` when a completion
|
||||
cleared the child's whole day; return whether one was published.
|
||||
|
||||
The day-complete rule lives entirely in the core builder
|
||||
(:func:`nestquest_core.events.build_child_day_complete_event`):
|
||||
it returns ``None`` when the day did not clear (a zero-quest day
|
||||
or a non-today instance) and this helper publishes nothing then.
|
||||
"""
|
||||
event = await core_events.build_child_day_complete_event(
|
||||
database, completed_payload, today=today
|
||||
)
|
||||
if event is None:
|
||||
return False
|
||||
event_type, payload = event
|
||||
publisher.publish(event_type, payload)
|
||||
return True
|
||||
|
||||
|
||||
__all__ = [
|
||||
"publish_child_day_complete",
|
||||
"publish_quest_missed",
|
||||
"publish_quest_uncompleted",
|
||||
]
|
||||
@@ -1,4 +1,14 @@
|
||||
"""The NestQuest integration."""
|
||||
"""The NestQuest integration (Feature 18: a thin client of the NestQuest API service).
|
||||
|
||||
The integration owns NO database: the API service (Feature 16) owns the
|
||||
SQLite file, the migrations, and every business rule. Setup builds the
|
||||
entry's ONE shared API client, the coordinator that polls the panel
|
||||
snapshot through it, and the SSE subscription that re-fires the API's
|
||||
transition frames on the HA bus, registers the single
|
||||
``nestquest.complete_quest`` proxy service, registers the zero-config
|
||||
panel dashboard (Feature 20), and forwards the sensor and
|
||||
binary_sensor platforms.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
@@ -8,8 +18,22 @@ from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .const import (
|
||||
CONF_SNAPSHOT_STALENESS,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_SNAPSHOT_STALENESS,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
|
||||
from .dashboard import async_register_dashboard
|
||||
from .frontend import async_register_frontend
|
||||
from .services import async_deregister_services, async_register_services
|
||||
from .sse import NestQuestEventStream
|
||||
|
||||
_LOGGER = LOGGER
|
||||
|
||||
@@ -19,17 +43,59 @@ class NestQuestRuntimeData:
|
||||
"""Runtime data stored on a NestQuest config entry."""
|
||||
|
||||
entry_id: str
|
||||
options: dict[str, Any]
|
||||
remove_update_listener: Callable[[], Any]
|
||||
coordinator: Any = None
|
||||
event_stream: NestQuestEventStream | None = None
|
||||
|
||||
|
||||
def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
|
||||
"""Return a live runtime record with a coordinator, or None.
|
||||
|
||||
The domain-global service (the panel completion proxy) has no
|
||||
database of its own; it resolves whichever config entry's runtime
|
||||
data is currently live at call time, so it cannot hold a stale
|
||||
handle to an unloaded entry's coordinator.
|
||||
"""
|
||||
for runtime_data in hass.data.get(DOMAIN, {}).values():
|
||||
if getattr(runtime_data, "coordinator", None) is not None:
|
||||
return runtime_data
|
||||
return None
|
||||
|
||||
|
||||
def _register_services(hass: HomeAssistant) -> None:
|
||||
"""Register the domain-global NestQuest service once.
|
||||
|
||||
The service is DOMAIN-global — registered exactly once (guarded by
|
||||
``has_service``) and NOT bound to any config entry — so the panel
|
||||
completion proxy resolves the currently-live runtime at call time
|
||||
and a name collision or duplicate setup is impossible. Removal is
|
||||
handled by the unload path only once the LAST entry is removed
|
||||
(see :func:`_deregister_services`).
|
||||
"""
|
||||
async_register_services(hass, find_runtime=_find_live_runtime_data)
|
||||
|
||||
|
||||
def _deregister_services(hass: HomeAssistant) -> None:
|
||||
"""Remove domain-global NestQuest services when the last entry unloads."""
|
||||
async_deregister_services(hass)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||
"""Set up the NestQuest integration. YAML configuration is not used, returns True."""
|
||||
await async_register_frontend(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up NestQuest from a config entry."""
|
||||
await async_register_frontend(hass)
|
||||
# The zero-config panel dashboard (Feature 20): register the
|
||||
# storage-mode strategy dashboard if no dashboard under the stable
|
||||
# url_path exists yet. Unavailability of the Lovelace dashboards
|
||||
# collection (YAML mode, lovelace not loaded) logs a warning and
|
||||
# NEVER fails setup; an existing dashboard — ours or the user's —
|
||||
# is left untouched, so a reload cannot duplicate it.
|
||||
await async_register_dashboard(hass)
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
async def _async_update_listener(
|
||||
@@ -38,12 +104,115 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Reload the entry when its options change."""
|
||||
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
|
||||
|
||||
runtime_data = NestQuestRuntimeData(
|
||||
entry_id=entry.entry_id,
|
||||
options=dict(entry.options),
|
||||
remove_update_listener=entry.add_update_listener(_async_update_listener),
|
||||
)
|
||||
entry.runtime_data = runtime_data
|
||||
remove_update_listener: Callable[[], Any] | None = None
|
||||
coordinator: Any = None
|
||||
event_stream: NestQuestEventStream | None = None
|
||||
try:
|
||||
remove_update_listener = entry.add_update_listener(_async_update_listener)
|
||||
_register_services(hass)
|
||||
# ONE API client for the entry, shared by the coordinator's
|
||||
# snapshot polling, the SSE subscription, and the completion
|
||||
# proxy (the client is stateless — one session, one connection
|
||||
# pool). An entry with no panel token yet gets the
|
||||
# typed-error stub (unavailable entities, never a setup crash).
|
||||
api_client = coordinator_client_from_entry(hass, entry)
|
||||
# The shared coordinator: one refresh cycle every entity reads
|
||||
# from (CONF_UPDATE_INTERVAL seconds, default five minutes).
|
||||
# It polls the API service's panel snapshot; a failed poll
|
||||
# keeps the entities rendering the last-good snapshot within
|
||||
# CONF_SNAPSHOT_STALENESS, unavailable past it.
|
||||
coordinator = NestQuestCoordinator(
|
||||
hass,
|
||||
entry_id=entry.entry_id,
|
||||
api_client=api_client,
|
||||
update_interval_seconds=entry.options.get(
|
||||
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
|
||||
),
|
||||
staleness_seconds=entry.options.get(
|
||||
CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS
|
||||
),
|
||||
)
|
||||
# The panel event subscription (the SSE task): a background
|
||||
# task consumes the API service's transition-event stream and
|
||||
# re-fires the four documented HA bus events. It reuses the
|
||||
# coordinator's client, so it only starts when that client
|
||||
# actually offers a stream — an unconfigured-token stub (or a
|
||||
# test snapshot client without ``stream_events``) leaves it
|
||||
# unstarted, and the typed-error path already covers
|
||||
# availability for those entries.
|
||||
if callable(getattr(api_client, "stream_events", None)):
|
||||
event_stream = NestQuestEventStream(hass, api_client)
|
||||
event_stream.start()
|
||||
runtime_data = NestQuestRuntimeData(
|
||||
entry_id=entry.entry_id,
|
||||
remove_update_listener=remove_update_listener,
|
||||
coordinator=coordinator,
|
||||
event_stream=event_stream,
|
||||
)
|
||||
# The runtime record is registered BEFORE the first refresh:
|
||||
# the service proxy resolves the entry's runtime state through
|
||||
# hass.data, and it must find the live coordinator there —
|
||||
# never a half-set-up entry. The platforms forward below reads
|
||||
# the coordinator off the entry (the HA runtime-data contract);
|
||||
# a forward failure below unwinds it through the except path.
|
||||
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
||||
entry.runtime_data = runtime_data
|
||||
# The first refresh polls the API snapshot; an API failure
|
||||
# (unconfigured token, API service down) does NOT crash setup:
|
||||
# the coordinator reports last_update_success=False, entities
|
||||
# come up unavailable, and HA's standard retry cycle applies
|
||||
# once the API is reachable. HA's real
|
||||
# ``async_config_entry_first_refresh`` wraps ANY failed update
|
||||
# pass in ConfigEntryNotReady — the original error survives
|
||||
# only as ``__cause__``, never as the raised exception — so the
|
||||
# catch keys on ConfigEntryNotReady, NOT on the typed API
|
||||
# error. Everything else (a platform-forward failure,
|
||||
# cancellation) still unwinds through the except block below.
|
||||
try:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
except ConfigEntryNotReady:
|
||||
pass
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
except BaseException:
|
||||
# A failure anywhere after the listener was registered must not
|
||||
# leak it (or the SSE task): unwind EVERYTHING acquired before
|
||||
# the failure, keeping the original error as the one raised.
|
||||
# Removal is best-effort — a failing step must not mask the
|
||||
# setup error that triggered it.
|
||||
if event_stream is not None:
|
||||
try:
|
||||
await event_stream.stop()
|
||||
except BaseException:
|
||||
pass
|
||||
if remove_update_listener is not None:
|
||||
try:
|
||||
remove_update_listener()
|
||||
except BaseException:
|
||||
pass
|
||||
# A coordinator created before the failure holds entity
|
||||
# listeners pointed at the never-registered runtime — shut it
|
||||
# down so no scheduled refresh outlives the failed setup.
|
||||
if coordinator is not None:
|
||||
try:
|
||||
await coordinator.async_shutdown()
|
||||
except BaseException:
|
||||
pass
|
||||
# A platform-forward failure reached this unwind AFTER
|
||||
# runtime_data was assigned: clear it so no later unload path
|
||||
# re-invokes the already-consumed update-listener remover
|
||||
# through the stale record.
|
||||
if getattr(entry, "runtime_data", None) is not None:
|
||||
entry.runtime_data = None
|
||||
# The runtime record was ALSO registered in hass.data before
|
||||
# the first refresh: remove it here on ANY failure, or a later
|
||||
# unload picks up the stale, already-torn-down record
|
||||
# (hass.data wins over entry.runtime_data there) and
|
||||
# re-invokes the already-consumed update-listener remover.
|
||||
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
|
||||
raise
|
||||
# The runtime record was already registered before the first
|
||||
# refresh; re-assert the (unchanged) record so the return path
|
||||
# stays readable as one registration site.
|
||||
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
||||
return True
|
||||
|
||||
@@ -52,16 +221,63 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
runtime_data = domain_data.pop(entry.entry_id, None)
|
||||
if not domain_data:
|
||||
is_last = not domain_data
|
||||
if is_last:
|
||||
hass.data.pop(DOMAIN, None)
|
||||
if runtime_data is None:
|
||||
runtime_data = getattr(entry, "runtime_data", None)
|
||||
# Platforms unload FIRST (mirroring HA conventions): entity
|
||||
# listeners must not outlive the coordinator the teardown below
|
||||
# shuts down.
|
||||
platforms_unloaded = await hass.config_entries.async_unload_platforms(
|
||||
entry, PLATFORMS
|
||||
)
|
||||
if platforms_unloaded is False and runtime_data is not None:
|
||||
# Home Assistant KEPT one or more platforms loaded — their
|
||||
# entities still live and must keep their coordinator. Restore
|
||||
# the runtime record popped above and report failure so HA
|
||||
# retries the unload later instead of leaving live entities
|
||||
# backed by a torn-down coordinator.
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime_data
|
||||
return False
|
||||
unload_error: BaseException | None = None
|
||||
if runtime_data is not None:
|
||||
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
|
||||
if remove_update_listener is not None:
|
||||
result = remove_update_listener()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
# The SSE subscription is stopped FIRST: its task must be gone
|
||||
# (no frame can fire) before anything else is torn down.
|
||||
event_stream = getattr(runtime_data, "event_stream", None)
|
||||
if event_stream is not None:
|
||||
stop_stream = getattr(event_stream, "stop", None)
|
||||
if stop_stream is not None:
|
||||
await stop_stream()
|
||||
remove_update_listener = getattr(
|
||||
runtime_data, "remove_update_listener", None
|
||||
)
|
||||
try:
|
||||
if remove_update_listener is not None:
|
||||
result = remove_update_listener()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
except BaseException as err:
|
||||
# A failing remover must not skip the coordinator shutdown
|
||||
# below; the first error is retained and re-raised only
|
||||
# after the teardown ran.
|
||||
if unload_error is None:
|
||||
unload_error = err
|
||||
finally:
|
||||
# The coordinator's entity listeners must stop here: a
|
||||
# scheduled refresh must never outlive the unload.
|
||||
coordinator = getattr(runtime_data, "coordinator", None)
|
||||
if coordinator is not None:
|
||||
shutdown = getattr(coordinator, "async_shutdown", None)
|
||||
if shutdown is not None:
|
||||
await shutdown()
|
||||
# Domain-global services are torn down only once the FINAL entry
|
||||
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
|
||||
# entry is still loaded.
|
||||
if is_last:
|
||||
_deregister_services(hass)
|
||||
if getattr(entry, "runtime_data", None) is not None:
|
||||
entry.runtime_data = None
|
||||
if unload_error is not None:
|
||||
raise unload_error
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
"""Panel-plane API client for the NestQuest integration (Feature 18).
|
||||
|
||||
The integration talks to the NestQuest API service (Feature 16) over
|
||||
HTTP instead of reading the database: this module is the ONE place that
|
||||
speaks the API's panel plane — ``GET /api/v1/panel/snapshot`` (today's
|
||||
household snapshot) and ``POST /api/v1/panel/instances/{id}/complete``
|
||||
(completion as the tapped child profile) — authenticated with the
|
||||
static panel service token as a Bearer credential on every request.
|
||||
|
||||
Transport-injected by construction: the constructor takes the base URL,
|
||||
the panel token, and the HTTP session to use. The session speaks the
|
||||
aiohttp ``ClientSession`` surface (``request(method, url, headers=...,
|
||||
json=...)`` returning an async context manager whose response carries
|
||||
``status`` and ``await text()``), so tests stub the transport with NO
|
||||
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.
|
||||
|
||||
Every failure is typed: a transport failure (connection refused, DNS
|
||||
failure, timeout — the exchange never produced a response) and every
|
||||
non-2xx HTTP response raise :class:`NestQuestApiError`. The error
|
||||
carries the HTTP status where there is one (so 401 and 404 are
|
||||
distinguishable at the call site) and ``None`` when there is not; the
|
||||
original transport error is chained as ``__cause__`` — nothing is
|
||||
swallowed. There are NO retries and the client logs nothing: refresh
|
||||
cadence is the coordinator's concern (the next task), and the panel
|
||||
token is a secret that must never reach a log line or an error
|
||||
message — the error texts name the method, path, and status only.
|
||||
|
||||
The panel plane's SSE event stream (``GET /api/v1/panel/events``) is
|
||||
exposed by :meth:`NestQuestApiClient.stream_events` — an async
|
||||
generator yielding ``(event_type, payload)`` per Server-Sent Events
|
||||
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).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .const import (
|
||||
CONF_API_BASE_URL,
|
||||
CONF_PANEL_TOKEN,
|
||||
DEFAULT_API_BASE_URL,
|
||||
DEFAULT_PANEL_TOKEN,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiohttp
|
||||
|
||||
__all__ = [
|
||||
"COMPLETE_INSTANCE_PATH",
|
||||
"DEFAULT_TIMEOUT_SECONDS",
|
||||
"EVENTS_PATH",
|
||||
"NestQuestApiError",
|
||||
"NestQuestApiClient",
|
||||
"SNAPSHOT_PATH",
|
||||
"client_from_entry",
|
||||
"is_valid_base_url",
|
||||
"resolve_api_config",
|
||||
]
|
||||
|
||||
#: The panel plane's snapshot route (Feature 16).
|
||||
SNAPSHOT_PATH = "/api/v1/panel/snapshot"
|
||||
|
||||
#: The panel plane's SSE event-stream route (Feature 16): each
|
||||
#: transition arrives as an ``event: <type>`` / ``data: <json>`` frame.
|
||||
EVENTS_PATH = "/api/v1/panel/events"
|
||||
|
||||
#: The panel plane's completion route template (Feature 16); the
|
||||
#: ``{instance_id}`` placeholder is filled per call.
|
||||
COMPLETE_INSTANCE_PATH = "/api/v1/panel/instances/{instance_id}/complete"
|
||||
|
||||
#: Request timeout, in seconds. One attempt, one timeout: a hung API
|
||||
#: call surfaces as a typed error for the coordinator to report, never
|
||||
#: a retry loop.
|
||||
DEFAULT_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
|
||||
class NestQuestApiError(Exception):
|
||||
"""A failed NestQuest API exchange, raised for every failure mode.
|
||||
|
||||
``status`` carries the HTTP status when a response arrived (a
|
||||
non-2xx response — 401 for a bad token, 404 for an unknown
|
||||
instance) and is ``None`` when the exchange failed at the transport
|
||||
level (connection refused, DNS failure, timeout) — so callers
|
||||
distinguish the two with ``is_transport_error``. The original
|
||||
transport error, where there is one, is chained as ``__cause__``.
|
||||
Messages carry the method, path, and status — never the request
|
||||
headers, so the panel token can never leak through an error.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, status: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
@property
|
||||
def is_transport_error(self) -> bool:
|
||||
"""True when the exchange failed before any response arrived."""
|
||||
return self.status is None
|
||||
|
||||
|
||||
def is_valid_base_url(value: object) -> bool:
|
||||
"""Return True when ``value`` is a usable API base URL.
|
||||
|
||||
A full ``http://`` or ``https://`` URL with a host — the exact
|
||||
shape the options flow validates (form ergonomics) and the client
|
||||
constructor enforces (fail fast, never build requests against a
|
||||
scheme-less or host-less string).
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
candidate = value.strip()
|
||||
if not candidate:
|
||||
return False
|
||||
parsed = urlparse(candidate)
|
||||
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
||||
|
||||
|
||||
def resolve_api_config(entry: Any) -> tuple[str, str]:
|
||||
"""Resolve ``(base_url, panel_token)`` off a config entry.
|
||||
|
||||
Precedence mirrors the options flow's stored-values resolution:
|
||||
``entry.options`` first (the live options-flow copy), then
|
||||
``entry.data`` (the config-flow-era copy), then the const defaults.
|
||||
The token is a secret: callers must never log the returned pair.
|
||||
"""
|
||||
options = getattr(entry, "options", None) or {}
|
||||
data = getattr(entry, "data", None) or {}
|
||||
|
||||
def _pick(key: str, default: str) -> str:
|
||||
if key in options:
|
||||
return options[key]
|
||||
if key in data:
|
||||
return data[key]
|
||||
return default
|
||||
|
||||
return (
|
||||
_pick(CONF_API_BASE_URL, DEFAULT_API_BASE_URL),
|
||||
_pick(CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN),
|
||||
)
|
||||
|
||||
|
||||
def client_from_entry(hass: Any, entry: Any) -> NestQuestApiClient:
|
||||
"""Build the production client for a config entry.
|
||||
|
||||
Reads the configured base URL and panel token off the entry
|
||||
(:func:`resolve_api_config`) and hands the client Home Assistant's
|
||||
SHARED aiohttp session (``homeassistant.helpers.aiohttp_client
|
||||
.async_get_clientsession``) so panel calls ride HA's one connection
|
||||
pool. The helper import is deferred into this function on purpose:
|
||||
the test harness is mock-only and the module must import (and the
|
||||
client must be stub-transport testable) without homeassistant or
|
||||
aiohttp installed.
|
||||
"""
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
base_url, panel_token = resolve_api_config(entry)
|
||||
return NestQuestApiClient(
|
||||
base_url, panel_token, async_get_clientsession(hass)
|
||||
)
|
||||
|
||||
|
||||
class NestQuestApiClient:
|
||||
"""Async client for the NestQuest API service's panel plane.
|
||||
|
||||
ONE instance per config entry (the coordinator task wires it into
|
||||
the refresh cycle). The client is deliberately thin: it adds the
|
||||
Bearer credential and the base URL, enforces one timeout per
|
||||
request, and turns every failure into :class:`NestQuestApiError` —
|
||||
no business logic, no retries, no caching.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
panel_token: str,
|
||||
session: aiohttp.ClientSession,
|
||||
*,
|
||||
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
"""Store the connection settings; the session is injected.
|
||||
|
||||
``session`` is an aiohttp ``ClientSession`` in production and a
|
||||
stub transport in tests. ``base_url`` must be a full http(s)
|
||||
URL and ``panel_token`` must be non-empty — a client built
|
||||
without either can only fail later at request time with a
|
||||
less-explicable error, so construction fails fast instead.
|
||||
"""
|
||||
if not is_valid_base_url(base_url):
|
||||
raise ValueError(
|
||||
f"base_url must be a full http(s) URL, got {base_url!r}"
|
||||
)
|
||||
if not isinstance(panel_token, str) or not panel_token.strip():
|
||||
raise ValueError("panel_token must be a non-empty string")
|
||||
if (
|
||||
not isinstance(timeout_seconds, (int, float))
|
||||
or isinstance(timeout_seconds, bool)
|
||||
or timeout_seconds <= 0
|
||||
):
|
||||
raise ValueError(
|
||||
f"timeout_seconds must be a positive number, got {timeout_seconds!r}"
|
||||
)
|
||||
self._base_url = base_url.strip().rstrip("/")
|
||||
self._panel_token = panel_token
|
||||
self._session = session
|
||||
self._timeout_seconds = float(timeout_seconds)
|
||||
|
||||
async def get_snapshot(self) -> dict[str, Any]:
|
||||
"""Fetch and parse today's household snapshot.
|
||||
|
||||
Returns the JSON the snapshot route returns (``today_iso``,
|
||||
``cycle_day``, and the per-child list — see
|
||||
``api/routes_panel.py`` for the documented shape). Raises
|
||||
:class:`NestQuestApiError` on transport failure, timeout, or a
|
||||
non-2xx response.
|
||||
"""
|
||||
return await self._request_json("GET", SNAPSHOT_PATH)
|
||||
|
||||
async def complete_instance(
|
||||
self, instance_id: int, actor_child_id: int
|
||||
) -> dict[str, Any]:
|
||||
"""Complete one quest instance as the tapped child profile.
|
||||
|
||||
POSTs ``{"actor_child_id": actor_child_id}`` to the complete
|
||||
route and returns the parsed response body (the route answers
|
||||
``{"status": ...}`` both for a first completion and for an
|
||||
idempotent repeat, so a retried panel tap succeeds). Raises
|
||||
:class:`NestQuestApiError` on transport failure, timeout, or a
|
||||
non-2xx response — 401 for a wrong token, 404 for an unknown
|
||||
instance.
|
||||
"""
|
||||
path = COMPLETE_INSTANCE_PATH.format(instance_id=instance_id)
|
||||
return await self._request_json(
|
||||
"POST", path, json_body={"actor_child_id": actor_child_id}
|
||||
)
|
||||
|
||||
async def stream_events(
|
||||
self,
|
||||
) -> AsyncIterator[tuple[str, dict[str, Any]]]:
|
||||
"""Yield ``(event_type, payload)`` for every panel SSE frame.
|
||||
|
||||
Opens ``GET /api/v1/panel/events`` with the Bearer credential
|
||||
and parses the Server-Sent Events framing the API service
|
||||
emits: ``event: <type>`` and ``data: <json>`` lines closed by a
|
||||
blank line (one frame per transition event; a frame's ``data``
|
||||
may span several ``data:`` lines, joined with newlines — the
|
||||
standard SSE rule). Line splits across chunk boundaries are
|
||||
buffered, so frames are parsed correctly whatever chunking the
|
||||
transport produces.
|
||||
|
||||
Opening the stream carries the SAME one-timeout, typed-error
|
||||
contract as every other call: a non-2xx response raises
|
||||
:class:`NestQuestApiError` with its status and a transport
|
||||
failure (connection refused, DNS, the open timing out) raises
|
||||
one with ``status=None`` and the original error chained. The
|
||||
reading loop itself has NO timeout — a healthy stream is
|
||||
long-lived — but any failure raised from the transport
|
||||
mid-stream raises typed as well, so the consumer can reconnect.
|
||||
A frame whose ``data`` is not valid JSON is a protocol failure
|
||||
and raises typed, carrying the response status it arrived
|
||||
with. Cancellation is never converted: closing the generator
|
||||
(``aclose``, i.e. the consumer's task being cancelled) stops
|
||||
cleanly and closes the underlying response in every path.
|
||||
"""
|
||||
url = f"{self._base_url}{EVENTS_PATH}"
|
||||
headers = {"Authorization": f"Bearer {self._panel_token}"}
|
||||
# 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.
|
||||
# The response context manager is entered manually (not via
|
||||
# ``async with``) because the block must span the yields below;
|
||||
# the ``finally`` closes it on EVERY exit path.
|
||||
try:
|
||||
async with asyncio.timeout(self._timeout_seconds):
|
||||
stream_cm = self._session.request(
|
||||
"GET", url, headers=headers, json=None
|
||||
)
|
||||
response = await stream_cm.__aenter__()
|
||||
except asyncio.CancelledError:
|
||||
# Cancellation is never converted (see ``_request_json``).
|
||||
raise
|
||||
except NestQuestApiError:
|
||||
raise
|
||||
except Exception as err:
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API GET {EVENTS_PATH} transport failure: {err}",
|
||||
status=None,
|
||||
) from err
|
||||
|
||||
status = response.status
|
||||
if not 200 <= status < 300:
|
||||
await self._close_stream(stream_cm)
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API GET {EVENTS_PATH} failed with HTTP {status}",
|
||||
status=status,
|
||||
)
|
||||
|
||||
try:
|
||||
event_type: str | None = None
|
||||
data_lines: list[str] = []
|
||||
buffer = ""
|
||||
async for chunk in response:
|
||||
if isinstance(chunk, (bytes, bytearray)):
|
||||
chunk = chunk.decode("utf-8")
|
||||
buffer += chunk
|
||||
while "\n" in buffer:
|
||||
line, buffer = buffer.split("\n", 1)
|
||||
if line.startswith("event:"):
|
||||
event_type = line[len("event:"):].strip()
|
||||
elif line.startswith("data:"):
|
||||
data_lines.append(line[len("data:"):].strip())
|
||||
elif not line and event_type is not None:
|
||||
# A blank line ends the frame. A trailing
|
||||
# incomplete frame (stream ends without its
|
||||
# closing blank line) is discarded, per the
|
||||
# SSE spec's treatment of incomplete events.
|
||||
try:
|
||||
payload = json.loads("\n".join(data_lines))
|
||||
except ValueError as err:
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API GET {EVENTS_PATH} "
|
||||
f"delivered a malformed SSE frame: {err}",
|
||||
status=status,
|
||||
) from err
|
||||
yield event_type, payload
|
||||
event_type = None
|
||||
data_lines = []
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except NestQuestApiError:
|
||||
raise
|
||||
except Exception as err:
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API GET {EVENTS_PATH} transport failure: {err}",
|
||||
status=None,
|
||||
) from err
|
||||
finally:
|
||||
await self._close_stream(stream_cm)
|
||||
|
||||
@staticmethod
|
||||
async def _close_stream(stream_cm: Any) -> None:
|
||||
"""Close an opened streaming response context manager.
|
||||
|
||||
Works for aiohttp's response context manager (whose
|
||||
``__aexit__`` is a coroutine) and for test stubs that return
|
||||
either a coroutine or a plain value.
|
||||
"""
|
||||
result = stream_cm.__aexit__(None, None, None)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
async def _request_json(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""Run ONE request and return the parsed JSON body.
|
||||
|
||||
The Bearer token rides the Authorization header only. A
|
||||
non-2xx response raises :class:`NestQuestApiError` with its
|
||||
status; anything the transport raises (connection, timeout) is
|
||||
wrapped as a transport-level :class:`NestQuestApiError` with
|
||||
the original error chained — never swallowed, never retried.
|
||||
A 2xx body that is not valid JSON is a protocol failure and
|
||||
raises typed too, carrying the status it arrived with.
|
||||
"""
|
||||
url = f"{self._base_url}{path}"
|
||||
headers = {"Authorization": f"Bearer {self._panel_token}"}
|
||||
try:
|
||||
async with asyncio.timeout(self._timeout_seconds):
|
||||
async with self._session.request(
|
||||
method, url, headers=headers, json=json_body
|
||||
) as response:
|
||||
status = response.status
|
||||
if not 200 <= status < 300:
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API {method} {path} failed with "
|
||||
f"HTTP {status}",
|
||||
status=status,
|
||||
)
|
||||
body_text = await response.text()
|
||||
except asyncio.CancelledError:
|
||||
# Cancellation is never converted: a shutting-down event
|
||||
# loop must stop, not read a synthesized API error.
|
||||
raise
|
||||
except NestQuestApiError:
|
||||
raise
|
||||
except Exception as err:
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API {method} {path} transport failure: {err}",
|
||||
status=None,
|
||||
) from err
|
||||
try:
|
||||
return json.loads(body_text)
|
||||
except ValueError as err:
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API {method} {path} returned malformed "
|
||||
f"JSON: {err}",
|
||||
status=status,
|
||||
) from err
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Per-child binary sensors (Feature 10, task 4).
|
||||
|
||||
Two binary sensors per active child, both reading only the coordinator
|
||||
snapshot (the same ChildDaySnapshot the day sensors read):
|
||||
|
||||
- ``binary_sensor.nestquest_<child>_all_done`` — ON exactly when the
|
||||
child owes quests today (due today > 0) AND owes none of them any
|
||||
more (remaining today == 0). A zero-quest day is OFF: the
|
||||
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
|
||||
(schedules and overrides, D-004 anchor-date arithmetic).
|
||||
|
||||
Naming and registry identity follow the day sensors' contract: names
|
||||
slug to the documented entity_id shape; unique_ids are built from the
|
||||
child's database id, never the display name, so a rename never
|
||||
orphans an entity's history.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import ChildDaySnapshot, NestQuestCoordinator
|
||||
|
||||
|
||||
class _NestQuestChildBinarySensor(CoordinatorEntity, BinarySensorEntity):
|
||||
"""Base for the per-child binary sensors.
|
||||
|
||||
Registry identity is built from the child's database id and never
|
||||
changes; the displayed name and device label re-derive from the
|
||||
child's CURRENT snapshot on every read, so a rename propagates on
|
||||
the next refresh without orphaning history. A child absent from
|
||||
the latest snapshot (a deactivation; removal is a later task's
|
||||
scope) yields ``None`` states rather than stale values.
|
||||
"""
|
||||
|
||||
_name_suffix: str = ""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._child_id = child.child_id
|
||||
self._fallback_name = child.child_name
|
||||
|
||||
@property
|
||||
def _child(self) -> ChildDaySnapshot | None:
|
||||
"""Return the child's current day snapshot, or None."""
|
||||
data = self.coordinator.data
|
||||
if data is None:
|
||||
return None
|
||||
for child in data.children:
|
||||
if child.child_id == self._child_id:
|
||||
return child
|
||||
return None
|
||||
|
||||
def _displayed_child_name(self) -> str:
|
||||
child = self._child
|
||||
return child.child_name if child is not None else self._fallback_name
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
return f"NestQuest {self._displayed_child_name()} {self._name_suffix}"
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, f"child_{self._child_id}")},
|
||||
name=f"NestQuest {self._displayed_child_name()}",
|
||||
manufacturer="NestQuest",
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
return {
|
||||
"child_id": child.child_id,
|
||||
"child_name": child.child_name,
|
||||
}
|
||||
|
||||
|
||||
class NestQuestAllDoneSensor(_NestQuestChildBinarySensor):
|
||||
"""ON when the child had quests today and owes none of them."""
|
||||
|
||||
_name_suffix = "all done"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator, child)
|
||||
self._attr_unique_id = f"{DOMAIN}_child_{child.child_id}_all_done"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
# Zero-quest days are OFF, not ON: an empty day must never fire
|
||||
# the celebration automation.
|
||||
return child.due_today > 0 and child.remaining_today == 0
|
||||
|
||||
|
||||
class NestQuestPresentTodaySensor(_NestQuestChildBinarySensor):
|
||||
"""ON when the child is at this house today (presence engine)."""
|
||||
|
||||
_name_suffix = "present today"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator, child)
|
||||
self._attr_unique_id = f"{DOMAIN}_child_{child.child_id}_present_today"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
return child.present
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Base attributes plus, for a child away today, the ISO date
|
||||
of their next present day (the panel's away plate renders
|
||||
``Returns <weekday>, <Mon D>`` from it)."""
|
||||
base = super().extra_state_attributes
|
||||
if base is None:
|
||||
return None
|
||||
child = self._child
|
||||
return {**base, "next_present": child.next_present if child else None}
|
||||
|
||||
|
||||
def _child_binary_sensors(
|
||||
coordinator: NestQuestCoordinator, child: ChildDaySnapshot
|
||||
) -> tuple[_NestQuestChildBinarySensor, ...]:
|
||||
"""Build the two documented binary sensors for one active child."""
|
||||
return (
|
||||
NestQuestAllDoneSensor(coordinator, child),
|
||||
NestQuestPresentTodaySensor(coordinator, child),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: Callable[..., None],
|
||||
) -> None:
|
||||
"""Create the two binary sensors per active child.
|
||||
|
||||
Entities are created for the snapshot's children at platform
|
||||
setup; a coordinator listener adds sensors for children that first
|
||||
appear on a later refresh (the day sensors' same pattern, so a new
|
||||
child's whole entity set lands together). Deactivated children
|
||||
are NOT removed here — removal is a later task's scope.
|
||||
"""
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
known_child_ids: set[int] = set()
|
||||
|
||||
def _add_new_children(children: Iterable[ChildDaySnapshot]) -> None:
|
||||
new_children = [
|
||||
child
|
||||
for child in children
|
||||
if child.child_id not in known_child_ids
|
||||
]
|
||||
if not new_children:
|
||||
return
|
||||
known_child_ids.update(child.child_id for child in new_children)
|
||||
async_add_entities(
|
||||
[
|
||||
sensor
|
||||
for child in new_children
|
||||
for sensor in _child_binary_sensors(coordinator, child)
|
||||
]
|
||||
)
|
||||
|
||||
data = coordinator.data
|
||||
if data is not None:
|
||||
_add_new_children(data.children)
|
||||
|
||||
def _handle_coordinator_update() -> None:
|
||||
data = coordinator.data
|
||||
if data is not None:
|
||||
_add_new_children(data.children)
|
||||
|
||||
coordinator.async_add_listener(_handle_coordinator_update)
|
||||
@@ -0,0 +1,90 @@
|
||||
blueprint:
|
||||
name: NestQuest afternoon reminder
|
||||
description: >-
|
||||
Sends an afternoon nudge listing ONLY the children who still have
|
||||
open quests today. Children who are present and all clear are
|
||||
omitted (the nudge is for stragglers, not a roll call), children
|
||||
who are not present today are omitted entirely, and the household
|
||||
rollup sensors are excluded by the same present guard. When every
|
||||
present child is already done the automation sends NOTHING at all:
|
||||
a template condition stops the run before the notification. The
|
||||
notify service is chosen by the household when importing this
|
||||
blueprint — NestQuest never hard-codes a personal notify target.
|
||||
domain: automation
|
||||
input:
|
||||
afternoon_time:
|
||||
name: Afternoon time
|
||||
description: Local time of day the reminder is sent.
|
||||
default: "15:00"
|
||||
selector:
|
||||
time: {}
|
||||
notify_target:
|
||||
name: Notify service
|
||||
description: >-
|
||||
The Home Assistant notify service to call for the reminder,
|
||||
for example notify.mobile_app_your_phone — a placeholder;
|
||||
pick the notify service for your own device.
|
||||
selector:
|
||||
text: {}
|
||||
send_reminder:
|
||||
name: Send the reminder
|
||||
description: >-
|
||||
Turn off to keep the automation installed but skip the
|
||||
notification entirely.
|
||||
default: true
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
mode: single
|
||||
|
||||
variables:
|
||||
send_reminder: !input send_reminder
|
||||
|
||||
triggers:
|
||||
- trigger: time
|
||||
at: !input afternoon_time
|
||||
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ send_reminder | bool }}"
|
||||
# Send nothing when every present child is already done: the guard is
|
||||
# false unless at least one present child has remaining quests today.
|
||||
- condition: template
|
||||
value_template: |
|
||||
{%- set ns = namespace(any_open=false) -%}
|
||||
{%- for due in states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
|
||||
{%- if due.attributes.get('present') -%}
|
||||
{%- set remaining_values = states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
|
||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||
| map(attribute='state')
|
||||
| list -%}
|
||||
{%- if remaining_values and (remaining_values | first | int) > 0 -%}
|
||||
{%- set ns.any_open = true -%}
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{- ns.any_open -}}
|
||||
|
||||
actions:
|
||||
- action: !input notify_target
|
||||
data:
|
||||
title: NestQuest afternoon reminder
|
||||
message: |
|
||||
{%- set ns = namespace(lines=[]) -%}
|
||||
{%- for due in states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
|
||||
{%- if due.attributes.get('present') -%}
|
||||
{%- set remaining_values = states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
|
||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||
| map(attribute='state')
|
||||
| list -%}
|
||||
{%- if remaining_values and (remaining_values | first | int) > 0 -%}
|
||||
{%- set line = due.attributes.child_name ~ ' still has ' ~ (remaining_values | first | int) ~ ' quest(s) open.' -%}
|
||||
{%- set ns.lines = ns.lines + [line] -%}
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{- ns.lines | join('\n') -}}
|
||||
@@ -0,0 +1,109 @@
|
||||
blueprint:
|
||||
name: NestQuest day complete celebration
|
||||
description: >-
|
||||
Celebrates the moment a child clears every quest for the day: the
|
||||
automation is driven by the nestquest_child_day_complete event,
|
||||
which NestQuest only fires when quests were owed and all of them
|
||||
are complete — a zero-quest day can never trigger this
|
||||
celebration. Once-per-child-per-day dedupe: point
|
||||
last_celebrated_helper at ONE input_text helper (create it under
|
||||
Settings, Devices and Services, Helpers — plain text). The
|
||||
automation records each celebrated child as
|
||||
"<child_id>:<today>" inside that helper, and a child who
|
||||
un-completes a quest and completes it again the same day is
|
||||
celebrated once, not twice — while a SECOND child clearing their
|
||||
day the same date is still celebrated in full. The helper is
|
||||
pruned to today's entries on every celebration, so it never
|
||||
grows past one entry per child per day. A helper that does not
|
||||
exist yet, or has never been set, lets the first celebration
|
||||
through. The notification message may use the event payload's
|
||||
child_name, quests_due and quests_completed. The notify service
|
||||
is chosen by the household when importing this blueprint —
|
||||
NestQuest never hard-codes a personal notify target.
|
||||
domain: automation
|
||||
input:
|
||||
notify_target:
|
||||
name: Notify service
|
||||
description: >-
|
||||
The Home Assistant notify service to call for the celebration,
|
||||
for example notify.mobile_app_your_phone — a placeholder;
|
||||
pick the notify service for your own device.
|
||||
selector:
|
||||
text: {}
|
||||
celebration_message:
|
||||
name: Celebration message
|
||||
description: >-
|
||||
The message sent when a child clears every quest today. May
|
||||
use the event payload: trigger.event.data.child_name,
|
||||
trigger.event.data.quests_due and trigger.event.data.quests_completed.
|
||||
default: "Legendary! {{ trigger.event.data.child_name }} cleared every quest today!"
|
||||
selector:
|
||||
text: {}
|
||||
celebrate:
|
||||
name: Send the celebration
|
||||
description: >-
|
||||
Turn off to keep the automation installed but skip the
|
||||
notification entirely.
|
||||
default: true
|
||||
selector:
|
||||
boolean: {}
|
||||
last_celebrated_helper:
|
||||
name: Last-celebrated helper
|
||||
description: >-
|
||||
A single input_text helper remembering which children were
|
||||
already celebrated today, as "<child_id>:<date>" entries
|
||||
separated by commas. The automation skips a child whose entry
|
||||
for today is already stored, and rewrites the helper with only
|
||||
today's entries plus the new one after each celebration.
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
- domain: input_text
|
||||
|
||||
mode: queued
|
||||
|
||||
variables:
|
||||
celebrate: !input celebrate
|
||||
last_celebrated_helper: !input last_celebrated_helper
|
||||
|
||||
triggers:
|
||||
- trigger: event
|
||||
event_type: nestquest_child_day_complete
|
||||
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ celebrate | bool }}"
|
||||
# Once-per-child-per-day dedupe: celebrate only when THIS child has
|
||||
# no equal entry for today in the helper. Entries compare as whole
|
||||
# comma-separated tokens — a substring check would wrongly suppress
|
||||
# child 1 after child 11 was celebrated. An unset or missing helper
|
||||
# reads as '' and celebrates.
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ (trigger.event.data.child_id ~ ':' ~ now().strftime('%Y-%m-%d'))
|
||||
not in ((states(last_celebrated_helper) or '')
|
||||
.split(',')
|
||||
| select('ne', '')
|
||||
| list) }}
|
||||
|
||||
actions:
|
||||
- action: !input notify_target
|
||||
data:
|
||||
title: NestQuest day complete
|
||||
message: !input celebration_message
|
||||
# Record this child's celebration, keeping only today's entries so the
|
||||
# helper never outgrows its text limit and stale entries cannot block
|
||||
# next week's celebrations.
|
||||
- action: input_text.set_value
|
||||
target:
|
||||
entity_id: !input last_celebrated_helper
|
||||
data:
|
||||
value: >-
|
||||
{%- set today = now().strftime('%Y-%m-%d') -%}
|
||||
{%- set ns = namespace(entries=[]) -%}
|
||||
{%- for entry in (states(last_celebrated_helper) or '').split(',') -%}
|
||||
{%- if entry and entry.endswith(':' ~ today) -%}
|
||||
{%- set ns.entries = ns.entries + [entry] -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{ (ns.entries + [trigger.event.data.child_id ~ ':' ~ today]) | join(',') }}
|
||||
@@ -0,0 +1,121 @@
|
||||
blueprint:
|
||||
name: NestQuest end-of-day report
|
||||
description: >-
|
||||
Sends the parent an end-of-day report on how the day went. It runs
|
||||
deliberately BEFORE NestQuest's midnight missed-quest sweep, so it
|
||||
reads the same day's data (what is still open at report time, and
|
||||
how much of the day was completed) rather than tomorrow-morning's
|
||||
hindsight. Every PRESENT child gets a completion line, so the
|
||||
parent always receives the day's summary; children who still owe
|
||||
quests get their open count called out on that line, and children
|
||||
who are not present today are omitted entirely. A child whose
|
||||
remaining-count sensor is unavailable is reported with the
|
||||
completion line only. By default the report is skipped when every
|
||||
present child is already done — turn on send-even-when-clear to
|
||||
always get the day's completion summary. The notify service is
|
||||
chosen by the household when importing this blueprint — NestQuest
|
||||
never hard-codes a personal notify target.
|
||||
domain: automation
|
||||
input:
|
||||
report_time:
|
||||
name: Report time
|
||||
description: >-
|
||||
Local time of day the report is sent — deliberately before the
|
||||
midnight missed-quest sweep, so the report reads the same
|
||||
day's data.
|
||||
default: "20:00"
|
||||
selector:
|
||||
time: {}
|
||||
notify_target:
|
||||
name: Notify service
|
||||
description: >-
|
||||
The Home Assistant notify service to call for the report,
|
||||
for example notify.mobile_app_your_phone — a placeholder;
|
||||
pick the notify service for your own device.
|
||||
selector:
|
||||
text: {}
|
||||
send_report:
|
||||
name: Send the report
|
||||
description: >-
|
||||
Turn off to keep the automation installed but skip the
|
||||
notification entirely.
|
||||
default: true
|
||||
selector:
|
||||
boolean: {}
|
||||
send_even_when_clear:
|
||||
name: Send even when clear
|
||||
description: >-
|
||||
Send the report even when every present child has already
|
||||
finished their quests today. When off (the default), an
|
||||
all-clear day sends nothing.
|
||||
default: false
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
mode: single
|
||||
|
||||
variables:
|
||||
send_report: !input send_report
|
||||
send_even_when_clear: !input send_even_when_clear
|
||||
|
||||
triggers:
|
||||
- trigger: time
|
||||
at: !input report_time
|
||||
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ send_report | bool }}"
|
||||
# Skip an all-clear day unless the household opted into
|
||||
# send-even-when-clear: the guard is false only when every present
|
||||
# child has a usable remaining count of zero. A MISSING or
|
||||
# non-numeric remaining sensor reads as -1 and counts as open —
|
||||
# a degraded state must reach the parent, not look like all-clear.
|
||||
- condition: template
|
||||
value_template: |
|
||||
{%- set ns = namespace(any_open=false) -%}
|
||||
{%- for due in states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
|
||||
{%- if due.attributes.get('present') -%}
|
||||
{%- set remaining_values = states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
|
||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||
| map(attribute='state')
|
||||
| list -%}
|
||||
{%- if not remaining_values or (remaining_values | first | int(-1)) != 0 -%}
|
||||
{%- set ns.any_open = true -%}
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{- send_even_when_clear | bool or ns.any_open -}}
|
||||
|
||||
actions:
|
||||
- action: !input notify_target
|
||||
data:
|
||||
title: NestQuest end-of-day report
|
||||
message: |
|
||||
{%- set ns = namespace(lines=[]) -%}
|
||||
{%- for due in states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
|
||||
{%- if due.attributes.get('present') -%}
|
||||
{%- set due_count = due.state | int -%}
|
||||
{%- set completed_values = states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_completed_today(_\\d+)?$')
|
||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||
| map(attribute='state')
|
||||
| list -%}
|
||||
{%- set remaining_values = states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
|
||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||
| map(attribute='state')
|
||||
| list -%}
|
||||
{%- set completed_count = (completed_values | first | int) if completed_values else 0 -%}
|
||||
{%- set remaining_count = (remaining_values | first | int) if remaining_values else none -%}
|
||||
{%- if remaining_count is not none and remaining_count > 0 -%}
|
||||
{%- set line = due.attributes.child_name ~ ': ' ~ remaining_count ~ ' still open (' ~ completed_count ~ '/' ~ due_count ~ ' done).' -%}
|
||||
{%- else -%}
|
||||
{%- set line = due.attributes.child_name ~ ': ' ~ completed_count ~ '/' ~ due_count ~ ' done.' -%}
|
||||
{%- endif -%}
|
||||
{%- set ns.lines = ns.lines + [line] -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{- ns.lines | join('\n') -}}
|
||||
@@ -0,0 +1,78 @@
|
||||
blueprint:
|
||||
name: NestQuest morning summary
|
||||
description: >-
|
||||
Sends a morning notification listing what each child still owes
|
||||
today. Only children whose NestQuest presence sensor is on are
|
||||
included; children who are not present today are omitted entirely.
|
||||
The notify service is chosen by the household when importing this
|
||||
blueprint — NestQuest never hard-codes a personal notify target.
|
||||
domain: automation
|
||||
input:
|
||||
morning_time:
|
||||
name: Morning time
|
||||
description: Local time of day the summary is sent.
|
||||
default: "08:00"
|
||||
selector:
|
||||
time: {}
|
||||
notify_target:
|
||||
name: Notify service
|
||||
description: >-
|
||||
The Home Assistant notify service to call for the summary,
|
||||
for example notify.mobile_app_your_phone — a placeholder;
|
||||
pick the notify service for your own device.
|
||||
selector:
|
||||
text: {}
|
||||
send_summary:
|
||||
name: Send the summary
|
||||
description: >-
|
||||
Turn off to keep the automation installed but skip the
|
||||
notification entirely.
|
||||
default: true
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
mode: single
|
||||
|
||||
variables:
|
||||
send_summary: !input send_summary
|
||||
|
||||
triggers:
|
||||
- trigger: time
|
||||
at: !input morning_time
|
||||
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ send_summary | bool }}"
|
||||
|
||||
actions:
|
||||
- action: !input notify_target
|
||||
data:
|
||||
title: NestQuest morning summary
|
||||
message: |
|
||||
{%- set ns = namespace(lines=[]) -%}
|
||||
{%- for due in states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
|
||||
{%- if due.attributes.get('present') -%}
|
||||
{%- set remaining_values = states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
|
||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||
| map(attribute='state')
|
||||
| list -%}
|
||||
{%- if remaining_values -%}
|
||||
{%- set remaining = remaining_values | first | int -%}
|
||||
{%- if remaining > 0 -%}
|
||||
{%- set line = due.attributes.child_name ~ ' owes ' ~ remaining ~ ' quest(s) today.' -%}
|
||||
{%- else -%}
|
||||
{%- set line = due.attributes.child_name ~ ' is all clear today (0 quests remaining).' -%}
|
||||
{%- endif -%}
|
||||
{%- else -%}
|
||||
{%- set line = due.attributes.child_name ~ "'s quest count is unavailable today." -%}
|
||||
{%- endif -%}
|
||||
{%- set ns.lines = ns.lines + [line] -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- if ns.lines -%}
|
||||
{{- ns.lines | join('\n') -}}
|
||||
{%- else -%}
|
||||
No children present today.
|
||||
{%- endif -%}
|
||||
@@ -28,7 +28,14 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the user step of the config flow."""
|
||||
"""Handle the user step of the config flow.
|
||||
|
||||
The integration needs no configuration to start (the API
|
||||
connection is set in the options flow), so the user input is
|
||||
stored as-is. The former admin-allowlist copy is gone: with
|
||||
the database removed the allowlist lives in the API service's
|
||||
own store, not on the entry.
|
||||
"""
|
||||
await self.async_set_unique_id(DOMAIN, raise_on_progress=False)
|
||||
if self._async_in_progress() or self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
@@ -36,4 +43,4 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="user")
|
||||
|
||||
return self.async_create_entry(title="NestQuest", data=user_input)
|
||||
return self.async_create_entry(title="NestQuest", data=dict(user_input))
|
||||
@@ -1,22 +1,10 @@
|
||||
"""Constants for the NestQuest integration."""
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.const` for the integration package.
|
||||
|
||||
The canonical constants live in :mod:`custom_components.nestquest.core.const` (the storage layer
|
||||
extracted in task d0b691d8); this module re-exports them so existing
|
||||
``from .const import ...`` references inside the integration keep
|
||||
working without touching every import line.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
DOMAIN = "nestquest"
|
||||
LOGGER_NAME = "custom_components.nestquest"
|
||||
LOGGER = logging.getLogger(LOGGER_NAME)
|
||||
|
||||
PLATFORMS: list[str] = []
|
||||
|
||||
SQLITE_DB_FILENAME = "nestquest.db"
|
||||
|
||||
# Configuration keys and default values
|
||||
CONF_HORIZON_DAYS = "horizon_days"
|
||||
DEFAULT_HORIZON_DAYS = 14
|
||||
|
||||
CONF_DAY_ROLLOVER_TIME = "day_rollover_time"
|
||||
DEFAULT_DAY_ROLLOVER_TIME = "00:00"
|
||||
|
||||
CONF_PANEL_IDLE_TIMEOUT = "panel_idle_timeout"
|
||||
DEFAULT_PANEL_IDLE_TIMEOUT = 300
|
||||
from .core.const import * # noqa: F401,F403
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
"""The shared NestQuest DataUpdateCoordinator (Feature 10, Feature 18).
|
||||
|
||||
ONE refresh pass builds the whole entity snapshot: active children,
|
||||
today's instances with their derived states (open / done / missed via
|
||||
:func:`~.completion.derive_state`), each child's presence today, and
|
||||
per-child rollup counts. Every Feature 10 entity reads
|
||||
``coordinator.data`` — no entity queries the database itself, so one
|
||||
refresh updates the whole board coherently.
|
||||
|
||||
Since Feature 18 the snapshot no longer comes from the local database:
|
||||
the coordinator polls the NestQuest API service's panel plane —
|
||||
``GET /api/v1/panel/snapshot`` through
|
||||
:class:`~.api_client.NestQuestApiClient` on its update interval — and
|
||||
RECONSTRUCTS the same core dataclasses the local builder produced
|
||||
(:class:`~.core.snapshot.NestQuestSnapshot`,
|
||||
:class:`~.core.snapshot.ChildDaySnapshot`,
|
||||
:class:`~.core.snapshot.QuestInstanceView`). ``sensor.py`` and
|
||||
``binary_sensor.py`` read ``coordinator.data`` and
|
||||
:func:`~.core.snapshot.instance_payload` exactly as before, so every
|
||||
entity_id and attribute payload shape is unchanged: the panel cards and
|
||||
the four blueprints keep working untouched.
|
||||
|
||||
One loss is deliberate and documented (D-009 revisited): the panel
|
||||
payload OMITS missed instances, so a snapshot rebuilt from the API can
|
||||
no longer carry them — ``admin_instances`` (``include_missed=True``)
|
||||
now equals ``instances`` (they can hold only open and completed rows).
|
||||
The admin surface for missed quests is the PWA, which reads the API
|
||||
service directly; the integration's entities never needed missed rows
|
||||
for their states or counts (the counts arrive precomputed).
|
||||
|
||||
Failure handling: constructing the client fails fast (ValueError) when
|
||||
the entry has no panel token configured, and a fetch failure raises the
|
||||
typed :class:`~.api_client.NestQuestApiError`. Neither crashes
|
||||
integration setup: the constructor logs a clear message and hands back
|
||||
a client-shaped stub whose refresh raises the same typed error, and
|
||||
``_async_update_data`` converts every typed error into
|
||||
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed` — the
|
||||
exception a HA ``DataUpdateCoordinator`` records as a failed pass
|
||||
(``last_update_success = False``, ``data = None``), so the entities
|
||||
come up unavailable. The typed error is never swallowed silently: it
|
||||
is logged with its message and chained into the UpdateFailed. (The
|
||||
conversion matters at setup: HA's real
|
||||
``async_config_entry_first_refresh`` wraps any failed pass in
|
||||
``ConfigEntryNotReady``, so a setup that keys on the original
|
||||
exception type would never match it.)
|
||||
|
||||
Last-good cache and staleness: every successful pass refreshes a cache
|
||||
of the rebuilt snapshot stamped with the :func:`time.monotonic`
|
||||
instant of the success. A typed failure is checked against that stamp
|
||||
BEFORE the pass is declared failed: within the configured staleness
|
||||
threshold (``CONF_SNAPSHOT_STALENESS``, seconds, default three update
|
||||
intervals) the pass SUCCEEDS with the cached snapshot — the panel
|
||||
keeps rendering last-good data and the entities stay available — and
|
||||
the outage is logged as a warning. Entities go unavailable only once
|
||||
the cache is older than the threshold (or before the first success);
|
||||
a later successful fetch refreshes the cache and the entities recover
|
||||
on their own, without a config-entry reload.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
DataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
from .api_client import NestQuestApiClient, NestQuestApiError, resolve_api_config
|
||||
from .const import (
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_SNAPSHOT_STALENESS,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
MIN_UPDATE_INTERVAL,
|
||||
)
|
||||
from .core.snapshot import (
|
||||
ChildDaySnapshot,
|
||||
NestQuestSnapshot,
|
||||
QuestInstanceView,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ChildDaySnapshot",
|
||||
"NestQuestCoordinator",
|
||||
"NestQuestSnapshot",
|
||||
"QuestInstanceView",
|
||||
]
|
||||
|
||||
|
||||
def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot:
|
||||
"""Reconstruct the core snapshot dataclasses from the API's JSON.
|
||||
|
||||
The panel snapshot route (``api/routes_panel.py``) returns today's
|
||||
household as plain JSON — ``today_iso``, ``cycle_day``, and the
|
||||
per-child list with precomputed counts and the instances in the
|
||||
documented payload shape. This function maps that shape BACK onto
|
||||
the dataclasses ``sensor.py`` / ``binary_sensor.py`` read, field by
|
||||
field:
|
||||
|
||||
- the API's ``state`` ``"completed"`` maps back to the core view's
|
||||
``"done"`` (so :func:`~.core.snapshot.instance_payload` renders
|
||||
``"completed"`` again — the round-trip is identity for open and
|
||||
completed rows);
|
||||
- ``overdue``, ``on_time`` (→ ``was_on_time``), ``completed_at``,
|
||||
``window``, ``due_time``, ``icon`` and the per-child counts pass
|
||||
through verbatim;
|
||||
- ``present`` and ``next_present`` pass through verbatim (the API
|
||||
resolved them through the same presence engine server-side);
|
||||
- the instance list is rebuilt IN ORDER, so the panel payload the
|
||||
sensors emit matches the route's order byte for byte.
|
||||
|
||||
Because the route omits missed instances (D-009), a snapshot built
|
||||
here cannot contain any ``missed`` view — see the module docstring.
|
||||
"""
|
||||
children: list[ChildDaySnapshot] = []
|
||||
for child in payload.get("children", []):
|
||||
views = tuple(
|
||||
QuestInstanceView(
|
||||
instance_id=instance["id"],
|
||||
definition_id=instance["definition_id"],
|
||||
child_id=instance["child_id"],
|
||||
title=instance["title"],
|
||||
icon=instance.get("icon"),
|
||||
window=instance["window"],
|
||||
# The API payload does not carry due_date: every row
|
||||
# is TODAY's instance (the route queries
|
||||
# [today, today]), so the view's due_date is the
|
||||
# snapshot's today_iso — the same value the local
|
||||
# builder stamped on every today row.
|
||||
due_date=payload["today_iso"],
|
||||
due_time=instance.get("due_time"),
|
||||
state=(
|
||||
"done" if instance["state"] == "completed" else
|
||||
instance["state"]
|
||||
),
|
||||
overdue=instance["overdue"],
|
||||
completed_at=instance.get("completed_at"),
|
||||
was_on_time=instance.get("on_time"),
|
||||
)
|
||||
for instance in child.get("instances", [])
|
||||
)
|
||||
children.append(
|
||||
ChildDaySnapshot(
|
||||
child_id=child["child_id"],
|
||||
child_name=child["child_name"],
|
||||
instances=views,
|
||||
present=child["present"],
|
||||
due_today=child["due_today"],
|
||||
completed_today=child["completed_today"],
|
||||
remaining_today=child["remaining_today"],
|
||||
completion_pct=child["completion_pct"],
|
||||
next_present=child.get("next_present"),
|
||||
)
|
||||
)
|
||||
return NestQuestSnapshot(
|
||||
today_iso=payload["today_iso"],
|
||||
children=tuple(children),
|
||||
cycle_day=payload["cycle_day"],
|
||||
)
|
||||
|
||||
|
||||
class _UnconfiguredApiClient:
|
||||
"""Client-shaped stand-in used when the entry has no panel token.
|
||||
|
||||
Constructing a real :class:`~.api_client.NestQuestApiClient` with an
|
||||
empty token raises ValueError by design (fail fast on a client that
|
||||
could only ever 401). An entry that simply has not been configured
|
||||
yet must not crash integration setup, so the coordinator swaps in
|
||||
this stub: every refresh raises the SAME typed
|
||||
:class:`~.api_client.NestQuestApiError` a transport failure would,
|
||||
carrying a clear "API not configured" message;
|
||||
:meth:`NestQuestCoordinator._async_update_data` converts it to
|
||||
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, so
|
||||
the coordinator marks the entities unavailable and the HA standard
|
||||
retry cycle applies. The error is logged, never silent.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
async def get_snapshot(self) -> dict[str, Any]:
|
||||
"""Always raise: there is no configured API to fetch from."""
|
||||
raise NestQuestApiError(
|
||||
"NestQuest API not configured: set the panel token in the "
|
||||
"integration options"
|
||||
)
|
||||
|
||||
|
||||
def _monotonic() -> float:
|
||||
"""The monotonic clock the staleness math reads.
|
||||
|
||||
A one-line indirection over :func:`time.monotonic` so the staleness
|
||||
tests can drive the clock deterministically (patch THIS function,
|
||||
never the stdlib clock). Monotonic is deliberate: wall-clock jumps
|
||||
(NTP corrections, suspends) must not shorten or extend the window.
|
||||
"""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
"""Shared refresh cycle for every NestQuest entity.
|
||||
|
||||
One refresh pass = one ``GET /api/v1/panel/snapshot`` through the
|
||||
API client, reconstructed into the core snapshot dataclasses every
|
||||
entity reads. A failed pass (unconfigured API, transport failure,
|
||||
non-2xx response) raises the typed error out of the client;
|
||||
``_async_update_data`` converts it to
|
||||
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, the
|
||||
exception HA's coordinator machinery records as a failed pass: the
|
||||
coordinator's ``data`` keeps its last good value while
|
||||
``last_update_success`` goes False — HA marks the entities
|
||||
unavailable until a later pass succeeds. An unexpected
|
||||
non-typed error still propagates out of ``_async_update_data``
|
||||
and is logged (with its traceback) by HA's machinery, the same way.
|
||||
|
||||
Forced refreshes (the service paths pushing an immediate update
|
||||
after a completion) are SERIALIZED per entry: overlapping
|
||||
complete/uncomplete calls must not interleave their snapshot
|
||||
passes, or an older pass could publish a stale snapshot AFTER a
|
||||
newer one and leave entities wrong until the next poll. Real
|
||||
HA's coordinator already serializes internally; the lock keeps
|
||||
the same guarantee on the stand-in and costs nothing upstream.
|
||||
|
||||
Last-good cache: a successful pass stamps the rebuilt snapshot
|
||||
with the :func:`~.coordinator._monotonic` instant of the success;
|
||||
a typed failure within the configured staleness threshold
|
||||
(``staleness_seconds``, from ``CONF_SNAPSHOT_STALENESS``) serves
|
||||
that cached snapshot instead of failing the pass, so a short API
|
||||
outage keeps the board up. Past the threshold — or before the
|
||||
first success — the pass fails as above, and a later successful
|
||||
pass refreshes the cache and recovers the entities on its own.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
entry_id: str,
|
||||
api_client: NestQuestApiClient,
|
||||
update_interval_seconds: int | None = None,
|
||||
staleness_seconds: int | None = None,
|
||||
) -> None:
|
||||
interval = update_interval_seconds
|
||||
if (
|
||||
not isinstance(interval, int)
|
||||
or isinstance(interval, bool)
|
||||
or interval < MIN_UPDATE_INTERVAL
|
||||
):
|
||||
interval = DEFAULT_UPDATE_INTERVAL
|
||||
staleness = staleness_seconds
|
||||
if (
|
||||
not isinstance(staleness, int)
|
||||
or isinstance(staleness, bool)
|
||||
or staleness < interval
|
||||
):
|
||||
# A missing or nonsensical threshold falls back to the
|
||||
# shipped default; a configured-but-too-short one (below
|
||||
# even one poll interval) falls back to whichever of
|
||||
# default/interval spans at least one failed poll, so the
|
||||
# cache is never dead on arrival. (The options flow
|
||||
# validates the same rule at the form.)
|
||||
staleness = max(DEFAULT_SNAPSHOT_STALENESS, interval)
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=datetime.timedelta(seconds=interval),
|
||||
)
|
||||
self.entry_id = entry_id
|
||||
self.api_client = api_client
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
#: The configured last-good window, in SECONDS (const.py:
|
||||
#: CONF_SNAPSHOT_STALENESS / DEFAULT_SNAPSHOT_STALENESS).
|
||||
self._staleness_seconds = staleness
|
||||
#: The last-good cache: the snapshot from the most recent
|
||||
#: SUCCESSFUL fetch and the :func:`time.monotonic` instant it
|
||||
#: landed. Both stay None until the first success.
|
||||
self._last_good_snapshot: NestQuestSnapshot | None = None
|
||||
self._last_good_monotonic: float | None = None
|
||||
|
||||
async def async_refresh(self) -> None:
|
||||
"""One refresh at a time; a queued refresh runs AFTER the
|
||||
in-flight one publishes, so the last snapshot always reflects
|
||||
the latest mutation."""
|
||||
async with self._refresh_lock:
|
||||
await super().async_refresh()
|
||||
|
||||
async def _async_update_data(self) -> NestQuestSnapshot:
|
||||
"""One refresh pass: fetch the panel snapshot and rebuild it.
|
||||
|
||||
A successful pass rebuilds the snapshot and refreshes the
|
||||
last-good cache (the snapshot plus the
|
||||
:func:`time.monotonic` instant of the success).
|
||||
|
||||
A typed API failure (unconfigured token stub, transport
|
||||
failure, non-2xx response) is first checked against that
|
||||
stamp: WITHIN the configured staleness threshold (elapsed time
|
||||
since the last successful fetch, inclusive of the threshold
|
||||
itself) the pass SUCCEEDS with the cached snapshot — the panel
|
||||
keeps rendering last-good data, the entities stay available —
|
||||
and the outage is logged as a clear warning (never silent).
|
||||
With no cache yet, or past the threshold, the typed error is
|
||||
converted to
|
||||
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`
|
||||
— the exception HA's coordinator machinery records as a failed
|
||||
pass (``last_update_success = False``, ``data`` unchanged) so
|
||||
entities go unavailable and the setup-time first refresh
|
||||
arrives as ``ConfigEntryNotReady`` — with the typed error
|
||||
logged and chained. Anything else still propagates.
|
||||
"""
|
||||
try:
|
||||
payload = await self.api_client.get_snapshot()
|
||||
except NestQuestApiError as err:
|
||||
now = _monotonic()
|
||||
cached = self._last_good_snapshot
|
||||
fetched_at = self._last_good_monotonic
|
||||
if (
|
||||
cached is not None
|
||||
and fetched_at is not None
|
||||
and now - fetched_at <= self._staleness_seconds
|
||||
):
|
||||
LOGGER.warning(
|
||||
"NestQuest API refresh failed (%s); serving the "
|
||||
"last-good snapshot fetched %.0f s ago, within the "
|
||||
"%d s staleness threshold",
|
||||
err,
|
||||
now - fetched_at,
|
||||
self._staleness_seconds,
|
||||
)
|
||||
return cached
|
||||
LOGGER.error(
|
||||
"NestQuest coordinator refresh failed: %s", err
|
||||
)
|
||||
raise UpdateFailed(str(err)) from err
|
||||
snapshot = snapshot_from_api_payload(payload)
|
||||
self._last_good_snapshot = snapshot
|
||||
self._last_good_monotonic = _monotonic()
|
||||
return snapshot
|
||||
|
||||
|
||||
def coordinator_client_from_entry(
|
||||
hass: HomeAssistant, entry: Any, *, session_factory: Any = None
|
||||
) -> Any:
|
||||
"""Build the refresh client for a config entry, tolerating no token.
|
||||
|
||||
Reads ``(base_url, panel_token)`` off the entry and constructs the
|
||||
real client. An entry with no panel token configured (the shipped
|
||||
default is the empty string) makes the constructor raise ValueError
|
||||
by design; integration setup must not crash on an unconfigured
|
||||
entry, so this factory swaps in the typed-error stub instead and
|
||||
logs ONE clear message. The ValueError itself is never re-raised
|
||||
past this point — it is converted, with the reason logged.
|
||||
|
||||
``session_factory`` overrides where the HTTP session comes from
|
||||
(production: HA's shared aiohttp session via
|
||||
:func:`~.api_client.client_from_entry`); it exists so tests can
|
||||
inject a stub transport without importing aiohttp — the mock-only
|
||||
test harness has no ``homeassistant.helpers.aiohttp_client``.
|
||||
"""
|
||||
base_url, panel_token = resolve_api_config(entry)
|
||||
if not isinstance(panel_token, str) or not panel_token.strip():
|
||||
LOGGER.warning(
|
||||
"NestQuest API panel token is not configured for entry %s: "
|
||||
"entities will stay unavailable until a token is set in the "
|
||||
"integration options",
|
||||
entry.entry_id,
|
||||
)
|
||||
return _UnconfiguredApiClient()
|
||||
if session_factory is not None:
|
||||
return NestQuestApiClient(
|
||||
base_url, panel_token, session_factory(hass)
|
||||
)
|
||||
from custom_components.nestquest.api_client import client_from_entry
|
||||
|
||||
return client_from_entry(hass, entry)
|
||||
|
||||
|
||||
#: Test seam: entry_id → client, consulted by the conftest's client
|
||||
#: factory BEFORE the production path. Always empty in production
|
||||
#: (nothing in the integration writes to it); tests register scripted
|
||||
#: clients through it and clear it between tests.
|
||||
_COORDINATOR_CLIENT_OVERRIDES: dict[str, Any] = {}
|
||||
@@ -0,0 +1,11 @@
|
||||
"""NestQuest storage layer.
|
||||
|
||||
A self-contained, Home-Assistant-free package holding the NestQuest
|
||||
SQLite storage layer: schema DDL, the versioned migration runner, the
|
||||
async connection wrapper, the path resolver, the domain constants and
|
||||
recurrence model, and the typed DAOs. Nothing in this package imports
|
||||
``homeassistant``; the integration wires it into HA at its own layer
|
||||
(constructing :class:`custom_components.nestquest.core.db.NestQuestDatabase`
|
||||
with an executor adapter backed by ``hass.async_add_executor_job``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Business layer for the admin allowlist (Feature 03).
|
||||
|
||||
Sits between the options flow / Feature 09 service gate and the typed
|
||||
:class:`~.dao_children.AdminUsersDao`. The allowlist stores Home
|
||||
Assistant user IDs — never usernames or display names (feature
|
||||
guardrail) — and an empty allowlist must fail closed: every function
|
||||
here refuses to write an empty allowlist, so the owner can never be
|
||||
accidentally locked out of admin operations by a bad save.
|
||||
|
||||
Shape policy: a user id is a non-empty trimmed string. Duplicate ids
|
||||
in one write are rejected up front — they would silently map to one
|
||||
row and hide a caller bug. Writes replace the allowlist atomically:
|
||||
the diff (additions and removals) is computed and applied inside one
|
||||
transaction under the connection-scoped lock shared with the DAOs, so
|
||||
a concurrent reader never observes a half-applied sync.
|
||||
|
||||
Seed policy (:func:`seed_setup_admin`): the allowlist is seeded only
|
||||
when it is EMPTY, so re-running setup never resurrects a deliberately
|
||||
narrowed list. The setup user (the HA user who completed the config
|
||||
flow, via the entry context) is added automatically so the owner is
|
||||
never locked out on first setup; a stored entry-data copy of the list
|
||||
re-seeds a lost database.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from .dao_children import AdminUsersDao, _connection_lock
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
"""UTC second-precision stamp, matching the registry's policy."""
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
|
||||
|
||||
def _validate_user_id(value: object) -> str:
|
||||
"""Return ``value`` trimmed; raise unless a non-empty string."""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(
|
||||
f"admin user id must be a string, got {value!r}"
|
||||
)
|
||||
trimmed = value.strip()
|
||||
if not trimmed:
|
||||
raise ValueError("admin user id must not be empty")
|
||||
return trimmed
|
||||
|
||||
|
||||
async def list_admin_ids(database: NestQuestDatabase) -> list[str]:
|
||||
"""Return the allowlisted HA user IDs in storage order."""
|
||||
return [row.ha_user_id for row in await AdminUsersDao(database).list()]
|
||||
|
||||
|
||||
async def set_admin_ids(
|
||||
database: NestQuestDatabase, user_ids: list[str]
|
||||
) -> None:
|
||||
"""Replace the allowlist with exactly ``user_ids`` (atomic sync).
|
||||
|
||||
Refuses an empty list — an empty allowlist must fail closed, and
|
||||
the only way to reach one through this API would be removing the
|
||||
last admin, so that removal is refused here with a clear error.
|
||||
Refuses non-string, empty, or duplicate ids up front. The diff
|
||||
against the current allowlist (additions and removals) applies
|
||||
inside one transaction under the connection lock.
|
||||
"""
|
||||
if not isinstance(user_ids, list):
|
||||
raise ValueError("user_ids must be a list of HA user id strings")
|
||||
normalized: list[str] = []
|
||||
for value in user_ids:
|
||||
normalized.append(_validate_user_id(value))
|
||||
if len(set(normalized)) != len(normalized):
|
||||
raise ValueError("user_ids must not contain duplicates")
|
||||
if not normalized:
|
||||
raise ValueError(
|
||||
"refusing to empty the admin allowlist: at least one admin "
|
||||
"must remain (an empty allowlist fails closed)"
|
||||
)
|
||||
dao = AdminUsersDao(database)
|
||||
async with _connection_lock(database):
|
||||
async with database.transaction():
|
||||
current = {row.ha_user_id for row in await dao.list()}
|
||||
target = set(normalized)
|
||||
for user_id in sorted(current - target):
|
||||
await dao.remove(user_id)
|
||||
for user_id in normalized:
|
||||
await dao.add(user_id, _now_stamp())
|
||||
|
||||
|
||||
async def is_admin(
|
||||
database: NestQuestDatabase, user_id: object
|
||||
) -> bool:
|
||||
"""Resolve an HA user ID against the allowlist — FAILS CLOSED.
|
||||
|
||||
True only for an allowlisted user id. Everything else is False:
|
||||
a non-allowlisted id, an unknown id, a None or non-string value
|
||||
(a service call with no user context), and an empty allowlist
|
||||
(nobody is admin rather than everybody). The Feature 09 gate
|
||||
wraps every admin-only service with this verdict; it never raises
|
||||
for malformed input — malformed means not admin.
|
||||
"""
|
||||
if not isinstance(user_id, str) or not user_id:
|
||||
return False
|
||||
return await AdminUsersDao(database).exists(user_id)
|
||||
|
||||
|
||||
async def seed_setup_admin(
|
||||
database: NestQuestDatabase,
|
||||
*,
|
||||
stored_admin_ids: list[str] | None = None,
|
||||
context_user_id: str | None = None,
|
||||
owner_ids: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Seed an empty allowlist so the owner is never locked out.
|
||||
|
||||
Only acts when the allowlist is EMPTY. Preference order: the
|
||||
stored persisted copy of the list (the options flow's current
|
||||
selection, then the config flow's original — disaster recovery for
|
||||
a lost database), then the HA user who completed the config flow
|
||||
(``context_user_id``), then — when no caller identity survived at
|
||||
all — every Home Assistant OWNER account (``owner_ids``), so a
|
||||
first setup whose flow context lost the user id still seeds a
|
||||
working admin. Returns the allowlist after seeding. Non-empty
|
||||
allowlists are returned untouched — re-running setup must never
|
||||
resurrect a deliberately narrowed list.
|
||||
"""
|
||||
dao = AdminUsersDao(database)
|
||||
async with _connection_lock(database):
|
||||
current = [row.ha_user_id for row in await dao.list()]
|
||||
if current:
|
||||
return current
|
||||
candidates: list[str] = []
|
||||
if stored_admin_ids is not None:
|
||||
if not isinstance(stored_admin_ids, list):
|
||||
raise ValueError(
|
||||
"stored_admin_ids must be a list of HA user id strings"
|
||||
)
|
||||
candidates = [_validate_user_id(value) for value in stored_admin_ids]
|
||||
elif context_user_id is not None:
|
||||
candidates = [_validate_user_id(context_user_id)]
|
||||
elif owner_ids:
|
||||
if not isinstance(owner_ids, list):
|
||||
raise ValueError(
|
||||
"owner_ids must be a list of HA user id strings"
|
||||
)
|
||||
candidates = [_validate_user_id(value) for value in owner_ids]
|
||||
for user_id in candidates:
|
||||
await dao.add(user_id, _now_stamp())
|
||||
return [row.ha_user_id for row in await dao.list()]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user