Compare commits
244
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d74efd495 | ||
|
|
a105b2b160 | ||
|
|
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 | ||
|
|
cdfb681f46 | ||
|
|
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 | ||
|
|
0f4b53fd4d | ||
|
|
22f8b5dc55 | ||
|
|
cb2be413bd |
@@ -4,3 +4,4 @@ __pycache__/
|
|||||||
.venv/
|
.venv/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
*.metadata.json
|
*.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.
|
||||||
@@ -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.
|
1. Ensure [HACS (Home Assistant Community Store)](https://hacs.xyz/) is installed.
|
||||||
2. In Home Assistant, open **HACS** > **Integrations**.
|
2. In Home Assistant, open **HACS** > **Integrations**.
|
||||||
3. Select the three-dots menu in the top right and click **Custom repositories**.
|
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**.
|
5. Select category **Integration** and click **Add**.
|
||||||
6. Search for **NestQuest** and click **Download**.
|
6. Search for **NestQuest** and click **Download**.
|
||||||
7. Restart Home Assistant.
|
7. Restart Home Assistant.
|
||||||
|
|
||||||
> **Note:** Development happens in the private Gitea repository
|
> **Note:** Development happens in the private Gitea repository
|
||||||
> (`https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest`). The GitHub
|
> (`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
|
### Manual Installation
|
||||||
|
|
||||||
@@ -38,6 +38,278 @@ After installation and restart:
|
|||||||
2. Click **Add Integration** and search for **NestQuest**.
|
2. Click **Add Integration** and search for **NestQuest**.
|
||||||
3. Follow the on-screen configuration flow.
|
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
|
## Developer Setup and Testing
|
||||||
|
|
||||||
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
|
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
|
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
|
### Running Tests
|
||||||
|
|
||||||
Run the test suite with `pytest`:
|
Run the test suite with `pytest`:
|
||||||
|
|||||||
+214
-1
@@ -1,5 +1,218 @@
|
|||||||
# NestQuest Release Notes
|
# 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
|
## Version 0.3.0 — 2026-09-14
|
||||||
|
|
||||||
### Scope
|
### Scope
|
||||||
@@ -126,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.
|
**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
+3024
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "nestquest-admin",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"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,134 @@
|
|||||||
|
/* 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-placeholder {
|
||||||
|
margin: 0;
|
||||||
|
padding: 13px;
|
||||||
|
background: var(--nq-a-surface);
|
||||||
|
border: 1px solid var(--nq-a-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nq-a-ink-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,48 @@
|
|||||||
|
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("tabpanel").textContent).toContain(
|
||||||
|
"History 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,60 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import "./App.css";
|
||||||
|
import DefinitionsTab from "./definitions/DefinitionsTab";
|
||||||
|
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");
|
||||||
|
// Built screens own their header (ADMIN-SPEC §1); the generic shell header
|
||||||
|
// stays for placeholder tabs and is visually hidden otherwise.
|
||||||
|
const hasScreen = active === "today" || active === "tasks";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-shell">
|
||||||
|
<header className={hasScreen ? "admin-header visually-hidden" : "admin-header"}>
|
||||||
|
<h1 className="admin-title">NestQuest Admin</h1>
|
||||||
|
<p className="admin-subline">Admin console</p>
|
||||||
|
</header>
|
||||||
|
<main
|
||||||
|
className={hasScreen ? "admin-panel admin-panel--screen" : "admin-panel"}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={`tab-${active}`}
|
||||||
|
>
|
||||||
|
{active === "today" ? (
|
||||||
|
<TodayTab />
|
||||||
|
) : active === "tasks" ? (
|
||||||
|
<DefinitionsTab />
|
||||||
|
) : (
|
||||||
|
<p className="admin-placeholder">
|
||||||
|
{TABS.find((tab) => tab.id === active)?.label} placeholder — screens
|
||||||
|
arrive in later tasks.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,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,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,561 @@
|
|||||||
|
/* 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-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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,920 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import DefinitionsTab from "./DefinitionsTab";
|
||||||
|
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
|
||||||
|
import { storeTokens } from "../auth/oidc";
|
||||||
|
|
||||||
|
function child(id: number, name: string, overrides: Partial<AdminChild> = {}): AdminChild {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
display_name: name,
|
||||||
|
colour: null,
|
||||||
|
avatar_ref: null,
|
||||||
|
sort_order: id,
|
||||||
|
is_active: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHILDREN: AdminChild[] = [
|
||||||
|
child(1, "Declan"),
|
||||||
|
child(2, "Maeve"),
|
||||||
|
child(3, "Rory"),
|
||||||
|
child(4, "Old", { is_active: false }),
|
||||||
|
];
|
||||||
|
|
||||||
|
function rule(overrides: Partial<DefinitionRule> = {}): DefinitionRule {
|
||||||
|
return {
|
||||||
|
rule_type: "daily",
|
||||||
|
interval: 1,
|
||||||
|
weekday_set: null,
|
||||||
|
day_of_month: null,
|
||||||
|
nth_weekday: null,
|
||||||
|
nth_weekday_weekday: null,
|
||||||
|
month: null,
|
||||||
|
start_date: "2026-01-01",
|
||||||
|
end_date: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const BRUSH: QuestDefinition = {
|
||||||
|
id: 10,
|
||||||
|
title: "Brush teeth",
|
||||||
|
description: null,
|
||||||
|
icon: "smile",
|
||||||
|
is_active: true,
|
||||||
|
skip_on_away: true,
|
||||||
|
rule: rule(),
|
||||||
|
assignees: [
|
||||||
|
{ id: 1, display_name: "Declan" },
|
||||||
|
{ id: 2, display_name: "Maeve" },
|
||||||
|
{ id: 3, display_name: "Rory" },
|
||||||
|
],
|
||||||
|
windows: [
|
||||||
|
{ window: "morning", due_time: "07:30" },
|
||||||
|
{ window: "evening", due_time: null },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const BINS: QuestDefinition = {
|
||||||
|
id: 11,
|
||||||
|
title: "Take out bins",
|
||||||
|
description: "Green bin",
|
||||||
|
icon: null,
|
||||||
|
is_active: true,
|
||||||
|
skip_on_away: false,
|
||||||
|
rule: rule({ rule_type: "weekly", interval: 2, weekday_set: [0, 3] }),
|
||||||
|
assignees: [
|
||||||
|
{ id: 1, display_name: "Declan" },
|
||||||
|
{ id: 4, display_name: "Old" },
|
||||||
|
],
|
||||||
|
windows: [{ window: "afternoon", due_time: "16:00" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const PIANO: QuestDefinition = {
|
||||||
|
id: 12,
|
||||||
|
title: "Piano practice",
|
||||||
|
description: null,
|
||||||
|
icon: null,
|
||||||
|
is_active: false,
|
||||||
|
skip_on_away: true,
|
||||||
|
rule: rule({ rule_type: "monthly_day", day_of_month: 15 }),
|
||||||
|
assignees: [{ id: 2, display_name: "Maeve" }],
|
||||||
|
windows: [{ window: "afternoon", due_time: null }],
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREVIEW_PATH = "/api/v1/admin/quest-definitions/occurrences-preview";
|
||||||
|
|
||||||
|
/** Answers an occurrence-preview request; the default serves no dates. */
|
||||||
|
type PreviewHandler = (body: { rule: DefinitionRule; count?: number }) => Promise<Response>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stub of the admin API: serves the list and children, records writes, and
|
||||||
|
* lets a test queue a response for the next write. Occurrence previews are
|
||||||
|
* reads (recorded, never counted as writes) answered by `setPreview`.
|
||||||
|
*/
|
||||||
|
function fakeApi(initial: QuestDefinition[]) {
|
||||||
|
let definitions = [...initial];
|
||||||
|
const calls: Call[] = [];
|
||||||
|
let nextWrite: Response | null = null;
|
||||||
|
let preview: PreviewHandler = async () => jsonResponse({ dates: [] });
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||||
|
const path = new URL(String(input), "http://localhost").pathname;
|
||||||
|
const method = init.method ?? "GET";
|
||||||
|
const body = typeof init.body === "string" ? JSON.parse(init.body) : null;
|
||||||
|
calls.push({ method, path, body });
|
||||||
|
if (method === "POST" && path === PREVIEW_PATH) return preview(body);
|
||||||
|
if (method === "GET" && path === "/api/v1/admin/quest-definitions") {
|
||||||
|
return jsonResponse({ definitions });
|
||||||
|
}
|
||||||
|
if (method === "GET" && path === "/api/v1/admin/children") {
|
||||||
|
return jsonResponse({ children: CHILDREN });
|
||||||
|
}
|
||||||
|
if (nextWrite) {
|
||||||
|
const response = nextWrite;
|
||||||
|
nextWrite = null;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
if (method === "POST" && path === "/api/v1/admin/quest-definitions") {
|
||||||
|
const created: QuestDefinition = {
|
||||||
|
id: 99,
|
||||||
|
title: body.title,
|
||||||
|
description: null,
|
||||||
|
icon: null,
|
||||||
|
is_active: true,
|
||||||
|
skip_on_away: body.skip_on_away,
|
||||||
|
rule: body.rule,
|
||||||
|
assignees: CHILDREN.filter((c) => body.assignee_child_ids.includes(c.id)).map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
display_name: c.display_name,
|
||||||
|
})),
|
||||||
|
windows: body.windows.map(([window, due_time]: [string, string | null]) => ({
|
||||||
|
window,
|
||||||
|
due_time,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
definitions = [...definitions, created];
|
||||||
|
return jsonResponse(created, 201);
|
||||||
|
}
|
||||||
|
const match = /^\/api\/v1\/admin\/quest-definitions\/(\d+)$/.exec(path);
|
||||||
|
if (method === "PATCH" && match) {
|
||||||
|
const id = Number(match[1]);
|
||||||
|
definitions = definitions.map((d) =>
|
||||||
|
d.id === id
|
||||||
|
? {
|
||||||
|
...d,
|
||||||
|
title: body.title ?? d.title,
|
||||||
|
skip_on_away: body.skip_on_away ?? d.skip_on_away,
|
||||||
|
rule: body.rule ?? d.rule,
|
||||||
|
// Like the API: a sent list replaces the whole set; an absent one keeps it.
|
||||||
|
assignees:
|
||||||
|
"assignee_child_ids" in body
|
||||||
|
? CHILDREN.filter((c) => body.assignee_child_ids.includes(c.id)).map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
display_name: c.display_name,
|
||||||
|
}))
|
||||||
|
: d.assignees,
|
||||||
|
}
|
||||||
|
: d,
|
||||||
|
);
|
||||||
|
return jsonResponse(definitions.find((d) => d.id === id));
|
||||||
|
}
|
||||||
|
return jsonResponse({ detail: "unexpected" }, 500);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
fetchMock,
|
||||||
|
calls,
|
||||||
|
writes: () => calls.filter((c) => c.method !== "GET" && c.path !== PREVIEW_PATH),
|
||||||
|
previews: () => calls.filter((c) => c.path === PREVIEW_PATH),
|
||||||
|
setPreview: (handler: PreviewHandler) => {
|
||||||
|
preview = handler;
|
||||||
|
},
|
||||||
|
listFetches: () =>
|
||||||
|
calls.filter((c) => c.method === "GET" && c.path === "/api/v1/admin/quest-definitions")
|
||||||
|
.length,
|
||||||
|
failNextWrite: (response: Response) => {
|
||||||
|
nextWrite = response;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let api: ReturnType<typeof fakeApi>;
|
||||||
|
|
||||||
|
async function renderReady(initial: QuestDefinition[] = [BRUSH, BINS, PIANO]) {
|
||||||
|
api = fakeApi(initial);
|
||||||
|
vi.stubGlobal("fetch", api.fetchMock);
|
||||||
|
render(<DefinitionsTab />);
|
||||||
|
await screen.findByText("Brush teeth");
|
||||||
|
}
|
||||||
|
|
||||||
|
function meta(row: HTMLElement): string {
|
||||||
|
return (row.querySelector(".defs-row-meta") as HTMLElement).textContent ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sheet(): HTMLElement {
|
||||||
|
return screen.getByRole("dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DefinitionsTab", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("header counts definitions and active children", async () => {
|
||||||
|
await renderReady();
|
||||||
|
expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy();
|
||||||
|
expect(screen.getByText("3 definitions · 3 children")).toBeTruthy();
|
||||||
|
const [url, init] = api.fetchMock.mock.calls[0];
|
||||||
|
expect(String(url)).toMatch(/\/api\/v1\/admin\/quest-definitions$/);
|
||||||
|
expect((init as RequestInit & { headers: Record<string, string> }).headers.Authorization).toBe(
|
||||||
|
"Bearer at-123",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rows show `<assignees> · <recurrence> · <windows>` meta", async () => {
|
||||||
|
await renderReady();
|
||||||
|
const brush = screen.getByTestId("definition-10");
|
||||||
|
expect(within(brush).getByText("Brush teeth")).toBeTruthy();
|
||||||
|
expect(meta(brush)).toBe("All three · Daily · Morning, Evening");
|
||||||
|
expect(meta(screen.getByTestId("definition-11"))).toBe(
|
||||||
|
"Declan, Old · Every 2 weeks on Mon, Thu · Afternoon",
|
||||||
|
);
|
||||||
|
expect(brush.className).not.toContain("defs-row--inactive");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an inactive definition is styled inactive with `· inactive`", async () => {
|
||||||
|
await renderReady();
|
||||||
|
const piano = screen.getByTestId("definition-12");
|
||||||
|
expect(meta(piano)).toBe("Maeve · Monthly on day 15 · Afternoon · inactive");
|
||||||
|
expect(piano.className).toContain("defs-row--inactive");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filter chips filter by child", async () => {
|
||||||
|
await renderReady();
|
||||||
|
const chips = screen.getByRole("group", { name: "Filter by child" });
|
||||||
|
expect(
|
||||||
|
within(chips)
|
||||||
|
.getAllByRole("button")
|
||||||
|
.map((b) => b.textContent),
|
||||||
|
).toEqual(["All", "Declan", "Maeve", "Rory"]);
|
||||||
|
expect(within(chips).getByRole("button", { name: "All" }).className).toContain(
|
||||||
|
"defs-chip--active",
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(within(chips).getByRole("button", { name: "Maeve" }));
|
||||||
|
expect(within(chips).getByRole("button", { name: "Maeve" }).getAttribute("aria-pressed")).toBe(
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId("definition-10")).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("definition-11")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("definition-12")).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(within(chips).getByRole("button", { name: "Rory" }));
|
||||||
|
expect(screen.queryByTestId("definition-10")).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("definition-12")).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(within(chips).getByRole("button", { name: "All" }));
|
||||||
|
expect(screen.getAllByRole("listitem")).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("New opens the sheet and saving POSTs the exact body, then re-fetches", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date"] });
|
||||||
|
vi.setSystemTime(new Date(2026, 8, 23, 9, 0)); // Wed 23 Sep 2026, local
|
||||||
|
await renderReady();
|
||||||
|
expect(api.listFetches()).toBe(1);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||||
|
const s = sheet();
|
||||||
|
expect(within(s).getByRole("heading", { name: "New task" })).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||||
|
target: { value: " Feed the cat " },
|
||||||
|
});
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||||
|
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
|
||||||
|
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
|
||||||
|
expect(within(s).getByRole("button", { name: /Assigned children/ }).textContent).toContain(
|
||||||
|
"Declan, Rory",
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), {
|
||||||
|
target: { value: "weekly" },
|
||||||
|
});
|
||||||
|
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
|
||||||
|
target: { value: "2" },
|
||||||
|
});
|
||||||
|
// Today (Wednesday) is preselected; add Saturday.
|
||||||
|
expect(within(s).getByRole("button", { name: "Wednesday" }).getAttribute("aria-pressed")).toBe(
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: "Saturday" }));
|
||||||
|
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: "Evening" }));
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
|
||||||
|
fireEvent.change(within(s).getByLabelText("Morning due time"), {
|
||||||
|
target: { value: "07:15" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = within(s).getByRole("switch", { name: "Skip on away days" });
|
||||||
|
expect(toggle.getAttribute("aria-checked")).toBe("true");
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
expect(toggle.getAttribute("aria-checked")).toBe("false");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(api.writes()).toEqual([
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/v1/admin/quest-definitions",
|
||||||
|
body: {
|
||||||
|
title: "Feed the cat",
|
||||||
|
rule: {
|
||||||
|
rule_type: "weekly",
|
||||||
|
interval: 2,
|
||||||
|
weekday_set: [2, 5],
|
||||||
|
day_of_month: null,
|
||||||
|
nth_weekday: null,
|
||||||
|
nth_weekday_weekday: null,
|
||||||
|
month: null,
|
||||||
|
start_date: "2026-09-23",
|
||||||
|
end_date: null,
|
||||||
|
},
|
||||||
|
assignee_child_ids: [1, 3],
|
||||||
|
windows: [
|
||||||
|
["morning", "07:15"],
|
||||||
|
["evening", null],
|
||||||
|
],
|
||||||
|
skip_on_away: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const created = await screen.findByTestId("definition-99");
|
||||||
|
expect(meta(created)).toBe("Declan, Rory · Every 2 weeks on Wed, Sat · Morning, Evening");
|
||||||
|
expect(api.listFetches()).toBe(2);
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
expect(screen.getByText("4 definitions · 3 children")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tapping a row opens the sheet in editing state and saving PATCHes", async () => {
|
||||||
|
await renderReady();
|
||||||
|
const row = screen.getByTestId("definition-11");
|
||||||
|
fireEvent.click(row);
|
||||||
|
|
||||||
|
// §3.1 editing state on the row.
|
||||||
|
expect(row.className).toContain("defs-row--editing");
|
||||||
|
expect(meta(row)).toBe("Editing");
|
||||||
|
expect(row.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
|
||||||
|
const s = sheet();
|
||||||
|
expect(within(s).getByRole("heading", { name: "Edit task" })).toBeTruthy();
|
||||||
|
expect((within(s).getByRole("textbox", { name: "Title" }) as HTMLInputElement).value).toBe(
|
||||||
|
"Take out bins",
|
||||||
|
);
|
||||||
|
expect((within(s).getByRole("combobox", { name: "Repeats" }) as HTMLSelectElement).value).toBe(
|
||||||
|
"weekly",
|
||||||
|
);
|
||||||
|
expect((within(s).getByLabelText("Afternoon due time") as HTMLInputElement).value).toBe(
|
||||||
|
"16:00",
|
||||||
|
);
|
||||||
|
// The inactive child "Old" is shown locked, not offered as a checkbox.
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||||
|
expect(within(s).queryByRole("checkbox", { name: "Old" })).toBeNull();
|
||||||
|
const locked = within(s).getByTestId("inactive-assignee-4");
|
||||||
|
expect(locked.textContent).toContain("Old");
|
||||||
|
expect(locked.textContent).toContain("Inactive");
|
||||||
|
expect(locked.getAttribute("aria-disabled")).toBe("true");
|
||||||
|
fireEvent.click(within(s).getByRole("checkbox", { name: "Maeve" }));
|
||||||
|
|
||||||
|
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||||
|
target: { value: "Take out the bins" },
|
||||||
|
});
|
||||||
|
fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), {
|
||||||
|
target: { value: "monthly" },
|
||||||
|
});
|
||||||
|
fireEvent.change(within(s).getByRole("spinbutton", { name: "Day of month" }), {
|
||||||
|
target: { value: "3" },
|
||||||
|
});
|
||||||
|
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
|
||||||
|
target: { value: "1" },
|
||||||
|
});
|
||||||
|
fireEvent.change(within(s).getByLabelText("Afternoon due time"), {
|
||||||
|
target: { value: "" },
|
||||||
|
});
|
||||||
|
fireEvent.click(within(s).getByRole("switch", { name: "Skip on away days" }));
|
||||||
|
|
||||||
|
// Changing the selection would drop "Old": confirm before sending.
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||||
|
expect(api.writes()).toHaveLength(0);
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: "Unassign and save" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(api.writes()).toEqual([
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
path: "/api/v1/admin/quest-definitions/11",
|
||||||
|
body: {
|
||||||
|
title: "Take out the bins",
|
||||||
|
rule: {
|
||||||
|
rule_type: "monthly_day",
|
||||||
|
interval: 1,
|
||||||
|
weekday_set: null,
|
||||||
|
day_of_month: 3,
|
||||||
|
nth_weekday: null,
|
||||||
|
nth_weekday_weekday: null,
|
||||||
|
month: null,
|
||||||
|
start_date: "2026-01-01",
|
||||||
|
end_date: null,
|
||||||
|
},
|
||||||
|
assignee_child_ids: [1, 2],
|
||||||
|
windows: [["afternoon", null]],
|
||||||
|
skip_on_away: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// The icon is never sent, so it is carried through unchanged.
|
||||||
|
expect(api.writes()[0].body).not.toHaveProperty("icon");
|
||||||
|
|
||||||
|
await screen.findByText("Take out the bins");
|
||||||
|
expect(api.listFetches()).toBe(2);
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("editing only the title omits assignee_child_ids and keeps an inactive assignee", async () => {
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByTestId("definition-11"));
|
||||||
|
const s = sheet();
|
||||||
|
expect(within(s).getByText(/Also assigned \(inactive\): Old\./)).toBeTruthy();
|
||||||
|
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||||
|
target: { value: "Bins out" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("unassign-confirm")).toBeNull();
|
||||||
|
expect(api.writes()).toHaveLength(1);
|
||||||
|
const body = api.writes()[0].body as Record<string, unknown>;
|
||||||
|
expect(api.writes()[0].method).toBe("PATCH");
|
||||||
|
expect(body.title).toBe("Bins out");
|
||||||
|
expect(body).not.toHaveProperty("assignee_child_ids");
|
||||||
|
|
||||||
|
await screen.findByText("Bins out");
|
||||||
|
expect(meta(screen.getByTestId("definition-11"))).toBe(
|
||||||
|
"Declan, Old · Every 2 weeks on Mon, Thu · Afternoon",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changing the active selection with an inactive assignee asks first", async () => {
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByTestId("definition-11"));
|
||||||
|
const s = sheet();
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||||
|
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
|
||||||
|
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||||
|
const confirm = within(s).getByTestId("unassign-confirm");
|
||||||
|
expect(confirm.textContent).toContain("Saving will unassign Old.");
|
||||||
|
expect(document.activeElement).toBe(confirm);
|
||||||
|
expect(api.writes()).toHaveLength(0);
|
||||||
|
|
||||||
|
// Going back sends nothing and restores the normal footer.
|
||||||
|
fireEvent.click(within(confirm).getByRole("button", { name: "Go back" }));
|
||||||
|
expect(within(s).queryByTestId("unassign-confirm")).toBeNull();
|
||||||
|
expect(api.writes()).toHaveLength(0);
|
||||||
|
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: "Unassign and save" }));
|
||||||
|
});
|
||||||
|
expect(api.writes()).toHaveLength(1);
|
||||||
|
expect((api.writes()[0].body as { assignee_child_ids: number[] }).assignee_child_ids).toEqual([
|
||||||
|
1, 3,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverting the selection to the original omits assignee_child_ids without asking", async () => {
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByTestId("definition-11"));
|
||||||
|
const s = sheet();
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||||
|
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
|
||||||
|
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId("unassign-confirm")).toBeNull();
|
||||||
|
expect(api.writes()).toHaveLength(1);
|
||||||
|
expect(api.writes()[0].body).not.toHaveProperty("assignee_child_ids");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focus moves into the sheet on open and returns to the row on Escape", async () => {
|
||||||
|
await renderReady();
|
||||||
|
const row = screen.getByTestId("definition-10");
|
||||||
|
row.focus();
|
||||||
|
fireEvent.click(row);
|
||||||
|
const s = sheet();
|
||||||
|
expect(document.activeElement).toBe(within(s).getByRole("heading", { name: "Edit task" }));
|
||||||
|
|
||||||
|
fireEvent.keyDown(s, { key: "Escape" });
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
expect(document.activeElement).toBe(row);
|
||||||
|
expect(api.writes()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Tab and Shift+Tab wrap within the sheet", async () => {
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByTestId("definition-10"));
|
||||||
|
const s = sheet();
|
||||||
|
const title = within(s).getByRole("textbox", { name: "Title" });
|
||||||
|
const save = within(s).getByRole("button", { name: "Save changes" });
|
||||||
|
|
||||||
|
save.focus();
|
||||||
|
fireEvent.keyDown(s, { key: "Tab" });
|
||||||
|
expect(document.activeElement).toBe(title);
|
||||||
|
|
||||||
|
fireEvent.keyDown(s, { key: "Tab", shiftKey: true });
|
||||||
|
expect(document.activeElement).toBe(save);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Escape does not close the sheet while saving", async () => {
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByTestId("definition-10"));
|
||||||
|
let release: (response: Response) => void = () => {};
|
||||||
|
api.fetchMock.mockImplementationOnce(
|
||||||
|
() => new Promise<Response>((resolve) => (release = resolve)),
|
||||||
|
);
|
||||||
|
const s = sheet();
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||||
|
});
|
||||||
|
fireEvent.keyDown(s, { key: "Escape" });
|
||||||
|
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
release(jsonResponse(BRUSH));
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the skip-on-away toggle reflects and persists a false value", async () => {
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByTestId("definition-11"));
|
||||||
|
const s = sheet();
|
||||||
|
const toggle = within(s).getByRole("switch", { name: "Skip on away days" });
|
||||||
|
expect(toggle.getAttribute("aria-checked")).toBe("false");
|
||||||
|
expect(toggle.className).not.toContain("defs-switch--on");
|
||||||
|
expect(within(s).getByText("No instance when the child is absent")).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||||
|
});
|
||||||
|
expect(api.writes()).toHaveLength(1);
|
||||||
|
expect((api.writes()[0].body as { skip_on_away: boolean }).skip_on_away).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a 422 from save shows the API's detail and keeps the sheet open", async () => {
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByTestId("definition-10"));
|
||||||
|
api.failNextWrite(jsonResponse({ detail: "interval must be >= 1, got 0" }, 422));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("sheet-error").textContent).toBe("interval must be >= 1, got 0");
|
||||||
|
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||||
|
expect(api.listFetches()).toBe(1);
|
||||||
|
// Save is usable again after the failure.
|
||||||
|
expect(
|
||||||
|
(within(sheet()).getByRole("button", { name: "Save changes" }) as HTMLButtonElement).disabled,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a 403 from save shows the admin-group refusal", async () => {
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByTestId("definition-10"));
|
||||||
|
api.failNextWrite(jsonResponse({ detail: "Forbidden" }, 403));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("sheet-error").textContent).toBe(
|
||||||
|
"Your account is not in the nestquest-admins group.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards against a double submit", async () => {
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByTestId("definition-10"));
|
||||||
|
const save = within(sheet()).getByRole("button", { name: "Save changes" });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(save);
|
||||||
|
fireEvent.click(save);
|
||||||
|
});
|
||||||
|
expect(api.writes()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an incomplete new task is not submitted", async () => {
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||||
|
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
|
||||||
|
expect(screen.getByTestId("sheet-error").textContent).toBe("Give the task a title.");
|
||||||
|
expect(api.writes()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Cancel closes the sheet without writing", async () => {
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByTestId("definition-10"));
|
||||||
|
fireEvent.click(within(sheet()).getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
expect(meta(screen.getByTestId("definition-10"))).toBe("All three · Daily · Morning, Evening");
|
||||||
|
expect(api.writes()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a 403 on load renders the refusal", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => jsonResponse({ detail: "Forbidden" }, 403)),
|
||||||
|
);
|
||||||
|
render(<DefinitionsTab />);
|
||||||
|
expect((await screen.findByTestId("definitions-error")).textContent).toContain(
|
||||||
|
"Your account is not in the nestquest-admins group.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scroll column carries the 92px bottom padding", async () => {
|
||||||
|
await renderReady();
|
||||||
|
expect(screen.getByTestId("definitions-scroll").style.paddingBottom).toBe("92px");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Occurrence preview ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** Past the sheet's 300 ms preview debounce. */
|
||||||
|
function settleDebounce(): Promise<void> {
|
||||||
|
return act(() => new Promise<void>((resolve) => setTimeout(resolve, 400)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewText(): string | null {
|
||||||
|
return screen.queryByTestId("occurrence-preview")?.textContent ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BINS' rule (every 2 weeks on Mon, Thu) from 2026-10-05. */
|
||||||
|
const BINS_DATES = ["2026-10-05", "2026-10-08", "2026-10-19", "2026-10-22", "2026-11-02"];
|
||||||
|
/** The same rule on Tuesdays only. */
|
||||||
|
const TUESDAY_DATES = ["2026-10-06", "2026-10-20", "2026-11-03", "2026-11-17", "2026-12-01"];
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: (response: Response) => void;
|
||||||
|
const promise = new Promise<Response>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DefinitionsTab occurrence preview", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("previews a weekly rule's next dates in plain language from the API", async () => {
|
||||||
|
await renderReady();
|
||||||
|
api.setPreview(async () => jsonResponse({ dates: BINS_DATES }));
|
||||||
|
fireEvent.click(screen.getByTestId("definition-11"));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(previewText()).toBe(
|
||||||
|
"Next: Mon 5 Oct · Thu 8 Oct · Mon 19 Oct · Thu 22 Oct · Mon 2 Nov · …",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(api.previews()).toEqual([
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/v1/admin/quest-definitions/occurrences-preview",
|
||||||
|
body: { rule: BINS.rule, count: 5 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-requests once per rule change, debounced, and not for non-rule edits", async () => {
|
||||||
|
await renderReady();
|
||||||
|
api.setPreview(async ({ rule }) =>
|
||||||
|
jsonResponse({ dates: rule.weekday_set?.includes(0) ? BINS_DATES : TUESDAY_DATES }),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId("definition-11"));
|
||||||
|
const s = sheet();
|
||||||
|
await waitFor(() => expect(previewText()).toContain("Mon 5 Oct"));
|
||||||
|
|
||||||
|
// A title edit leaves the rule alone: no new request.
|
||||||
|
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||||
|
target: { value: "Bins" },
|
||||||
|
});
|
||||||
|
// Three rule edits in quick succession collapse into one request.
|
||||||
|
const days = within(s).getByRole("group", { name: "Days" });
|
||||||
|
fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
|
||||||
|
fireEvent.click(within(days).getByRole("button", { name: "Thursday" }));
|
||||||
|
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(previewText()).toBe(
|
||||||
|
"Next: Tue 6 Oct · Tue 20 Oct · Tue 3 Nov · Tue 17 Nov · Tue 1 Dec · …",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await settleDebounce();
|
||||||
|
expect(api.previews()).toHaveLength(2);
|
||||||
|
expect(api.previews()[1].body).toEqual({
|
||||||
|
rule: { ...BINS.rule, weekday_set: [1] },
|
||||||
|
count: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
// An interval change is a rule change too.
|
||||||
|
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
|
||||||
|
target: { value: "1" },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(api.previews()).toHaveLength(3));
|
||||||
|
expect((api.previews()[2].body as { rule: DefinitionRule }).rule.interval).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("window and assignee edits never request, and a rule edit waits out the debounce", async () => {
|
||||||
|
await renderReady();
|
||||||
|
api.setPreview(async () => jsonResponse({ dates: BINS_DATES }));
|
||||||
|
fireEvent.click(screen.getByTestId("definition-11"));
|
||||||
|
const s = sheet();
|
||||||
|
await waitFor(() => expect(previewText()).toContain("Mon 5 Oct"));
|
||||||
|
expect(api.previews()).toHaveLength(1);
|
||||||
|
|
||||||
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||||
|
// Windows and assignees are not part of the rule.
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||||
|
fireEvent.click(within(s).getByRole("checkbox", { name: "Maeve" }));
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
});
|
||||||
|
expect(api.previews()).toHaveLength(1);
|
||||||
|
expect(previewText()).toContain("Mon 5 Oct");
|
||||||
|
|
||||||
|
// A rule edit is not requested until the 300 ms debounce elapses.
|
||||||
|
const days = within(s).getByRole("group", { name: "Days" });
|
||||||
|
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(299);
|
||||||
|
});
|
||||||
|
expect(api.previews()).toHaveLength(1);
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
});
|
||||||
|
expect(api.previews()).toHaveLength(2);
|
||||||
|
expect((api.previews()[1].body as { rule: DefinitionRule }).rule.weekday_set).toEqual([
|
||||||
|
0, 1, 3,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a rule edit hides the previous rule's dates until its own response arrives", async () => {
|
||||||
|
await renderReady();
|
||||||
|
const pending: ReturnType<typeof deferred>[] = [];
|
||||||
|
api.setPreview(() => {
|
||||||
|
const next = deferred();
|
||||||
|
pending.push(next);
|
||||||
|
return next.promise;
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTestId("definition-11"));
|
||||||
|
await waitFor(() => expect(pending).toHaveLength(1));
|
||||||
|
await act(async () => {
|
||||||
|
pending[0].resolve(jsonResponse({ dates: BINS_DATES }));
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(previewText()).toContain("Mon 5 Oct"));
|
||||||
|
|
||||||
|
const days = within(sheet()).getByRole("group", { name: "Days" });
|
||||||
|
fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
|
||||||
|
// Within the debounce: the old dates are already gone.
|
||||||
|
expect(previewText()).toBeNull();
|
||||||
|
// Request in flight: still nothing shown for the new rule.
|
||||||
|
await waitFor(() => expect(pending).toHaveLength(2));
|
||||||
|
expect(previewText()).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
pending[1].resolve(jsonResponse({ dates: ["2026-10-08", "2026-10-22"] }));
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(previewText()).toBe("Next: Thu 8 Oct · Thu 22 Oct"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a rule edit hides the previous rule's error until its own response arrives", async () => {
|
||||||
|
await renderReady();
|
||||||
|
const pending: ReturnType<typeof deferred>[] = [];
|
||||||
|
api.setPreview(() => {
|
||||||
|
const next = deferred();
|
||||||
|
pending.push(next);
|
||||||
|
return next.promise;
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTestId("definition-11"));
|
||||||
|
await waitFor(() => expect(pending).toHaveLength(1));
|
||||||
|
await act(async () => {
|
||||||
|
pending[0].resolve(jsonResponse({ detail: "rule rejected" }, 422));
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(previewText()).toBe("Upcoming dates unavailable: rule rejected"));
|
||||||
|
|
||||||
|
const days = within(sheet()).getByRole("group", { name: "Days" });
|
||||||
|
fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
|
||||||
|
expect(previewText()).toBeNull();
|
||||||
|
await waitFor(() => expect(pending).toHaveLength(2));
|
||||||
|
expect(previewText()).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
pending[1].resolve(jsonResponse({ dates: ["2026-10-08"] }));
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(previewText()).toBe("Next: Thu 8 Oct"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a stale response resolving after a newer one does not overwrite it", async () => {
|
||||||
|
await renderReady();
|
||||||
|
const pending: ReturnType<typeof deferred>[] = [];
|
||||||
|
api.setPreview(() => {
|
||||||
|
const next = deferred();
|
||||||
|
pending.push(next);
|
||||||
|
return next.promise;
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTestId("definition-11"));
|
||||||
|
await waitFor(() => expect(pending).toHaveLength(1));
|
||||||
|
|
||||||
|
const days = within(sheet()).getByRole("group", { name: "Days" });
|
||||||
|
fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
|
||||||
|
fireEvent.click(within(days).getByRole("button", { name: "Thursday" }));
|
||||||
|
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
|
||||||
|
await waitFor(() => expect(pending).toHaveLength(2));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
pending[1].resolve(jsonResponse({ dates: TUESDAY_DATES }));
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(previewText()).toContain("Tue 6 Oct"));
|
||||||
|
await act(async () => {
|
||||||
|
pending[0].resolve(jsonResponse({ dates: BINS_DATES }));
|
||||||
|
});
|
||||||
|
await settleDebounce();
|
||||||
|
expect(previewText()).toBe(
|
||||||
|
"Next: Tue 6 Oct · Tue 20 Oct · Tue 3 Nov · Tue 17 Nov · Tue 1 Dec · …",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failed preview shows a non-blocking message and the sheet still saves", async () => {
|
||||||
|
await renderReady();
|
||||||
|
api.setPreview(async () => jsonResponse({ detail: "rule rejected" }, 422));
|
||||||
|
fireEvent.click(screen.getByTestId("definition-10"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(previewText()).toBe("Upcoming dates unavailable: rule rejected"));
|
||||||
|
expect(screen.queryByTestId("sheet-error")).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
|
||||||
|
});
|
||||||
|
expect(api.writes()).toHaveLength(1);
|
||||||
|
expect(api.writes()[0].method).toBe("PATCH");
|
||||||
|
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an incomplete draft does not call the preview endpoint", async () => {
|
||||||
|
await renderReady();
|
||||||
|
api.setPreview(async () => jsonResponse({ dates: TUESDAY_DATES }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||||
|
const s = sheet();
|
||||||
|
fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), {
|
||||||
|
target: { value: "weekly" },
|
||||||
|
});
|
||||||
|
// Clear the default weekday: a weekly rule with no days is incomplete.
|
||||||
|
const days = within(s).getByRole("group", { name: "Days" });
|
||||||
|
for (const day of within(days).getAllByRole("button")) {
|
||||||
|
if (day.getAttribute("aria-pressed") === "true") fireEvent.click(day);
|
||||||
|
}
|
||||||
|
await settleDebounce();
|
||||||
|
expect(api.previews()).toHaveLength(0);
|
||||||
|
expect(previewText()).toBeNull();
|
||||||
|
|
||||||
|
// An unparseable interval is incomplete too.
|
||||||
|
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
|
||||||
|
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
|
||||||
|
target: { value: "" },
|
||||||
|
});
|
||||||
|
await settleDebounce();
|
||||||
|
expect(api.previews()).toHaveLength(0);
|
||||||
|
|
||||||
|
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
|
||||||
|
target: { value: "2" },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(previewText()).toContain("Tue 6 Oct"));
|
||||||
|
expect(api.previews()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,935 @@
|
|||||||
|
/**
|
||||||
|
* Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2).
|
||||||
|
*
|
||||||
|
* The icon picker (§3.3) is a separate task: an existing definition's icon is
|
||||||
|
* carried through unchanged by never sending it.
|
||||||
|
*/
|
||||||
|
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 "./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;
|
||||||
|
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: "",
|
||||||
|
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,
|
||||||
|
// 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,
|
||||||
|
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">
|
||||||
|
<TaskGlyph />
|
||||||
|
</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="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,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,71 @@
|
|||||||
|
/* 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-info-bg: #dbeafe;
|
||||||
|
--nq-a-info-ink: #1e3a8a;
|
||||||
|
--nq-a-info-icon: #1d4ed8;
|
||||||
|
|
||||||
|
--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,300 @@
|
|||||||
|
/* 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-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,223 @@
|
|||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import TodayTab, { PERMISSION_NOTE } from "./TodayTab";
|
||||||
|
import type { AdminSnapshot, SnapshotInstance } from "../api/snapshot";
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* Today tab — compact density (design/ADMIN-SPEC.md §2.1–2.3, §2.5–2.6).
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
fetchSnapshot,
|
||||||
|
type AdminSnapshot,
|
||||||
|
type SnapshotChild,
|
||||||
|
type SnapshotInstance,
|
||||||
|
} from "../api/snapshot";
|
||||||
|
import { ApiForbiddenError } from "../api/client";
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttentionRow({ item }: { item: AttentionItem }) {
|
||||||
|
const { instance, childName } = item;
|
||||||
|
const overdue = isOverdueOpen(instance);
|
||||||
|
// TODO(e57facc2): wire "Mark done" / "Undo" mutations and live updates.
|
||||||
|
// The admin plane has no complete route yet, so both actions are 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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={overdue ? "today-action today-action--done" : "today-action today-action--undo"}
|
||||||
|
>
|
||||||
|
{overdue ? "Mark done" : "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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
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.";
|
||||||
|
setState({ kind: "error", message });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [attempt]);
|
||||||
|
|
||||||
|
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} />
|
||||||
|
))}
|
||||||
|
</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 }}
|
||||||
|
>
|
||||||
|
{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,253 +1,102 @@
|
|||||||
"""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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import inspect
|
import inspect
|
||||||
import sqlite3
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
|
|
||||||
from .const import DOMAIN, LOGGER
|
from .const import (
|
||||||
from .db import NestQuestDatabase
|
CONF_SNAPSHOT_STALENESS,
|
||||||
from .migrations import apply_migrations
|
CONF_UPDATE_INTERVAL,
|
||||||
from .store import async_get_db_path
|
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
|
_LOGGER = LOGGER
|
||||||
|
|
||||||
#: SQLite reports "file is not a database" for corruption in every
|
|
||||||
#: sqlite3 version this integration targets; the version-specific
|
|
||||||
#: "encrypted or malformed" spelling (3.41+) maps to the same failure.
|
|
||||||
_CORRUPTION_MARKERS = (
|
|
||||||
"file is not a database",
|
|
||||||
"database disk image is malformed",
|
|
||||||
"encrypted or malformed",
|
|
||||||
"is not a database",
|
|
||||||
)
|
|
||||||
|
|
||||||
#: Primary sqlite error codes that mean "the file is corrupt" (SQLite
|
|
||||||
#: result codes, https://www.sqlite.org/c3ref/c_abort.html). Extended
|
|
||||||
#: codes carry the primary code in their low byte, so the match is on
|
|
||||||
#: errcode & 0xff.
|
|
||||||
_CORRUPT_PRIMARY_CODES = frozenset({11, 26}) # SQLITE_CORRUPT, SQLITE_NOTADB
|
|
||||||
|
|
||||||
|
|
||||||
def _is_corruption_error(error: BaseException) -> bool:
|
|
||||||
"""Return True when ``error`` indicates a corrupt database file.
|
|
||||||
|
|
||||||
Classification order: sqlite_errorcode (Python 3.11+) is primary
|
|
||||||
and locale-independent — SQLITE_CORRUPT and SQLITE_NOTADB, matched
|
|
||||||
on the primary byte so extended codes (SQLITE_CORRUPT_VTAB etc.)
|
|
||||||
classify correctly too. Message matching is the fallback for
|
|
||||||
older Pythons: the corruption spellings are the documented English
|
|
||||||
texts; a non-English locale could evade them, but the code check
|
|
||||||
already covers every locale on supported Pythons.
|
|
||||||
"""
|
|
||||||
if not isinstance(error, sqlite3.DatabaseError):
|
|
||||||
return False
|
|
||||||
errorcode = getattr(error, "sqlite_errorcode", None)
|
|
||||||
if errorcode is not None and (errorcode & 0xFF) in _CORRUPT_PRIMARY_CODES:
|
|
||||||
return True
|
|
||||||
message = str(error).lower()
|
|
||||||
return any(marker in message for marker in _CORRUPTION_MARKERS)
|
|
||||||
|
|
||||||
|
|
||||||
async def _preflight_existing_file(
|
|
||||||
hass: HomeAssistant, db_path: Path
|
|
||||||
) -> None:
|
|
||||||
"""Detect corruption in an EXISTING file before it is opened read-write.
|
|
||||||
|
|
||||||
Opening read-write persists WAL mode — for a rollback-journal file
|
|
||||||
that rewrites the header byte 18/19 and can create -wal/-shm
|
|
||||||
sidecars — which would modify a corrupt file before detection and
|
|
||||||
break the leave-untouched guarantee. This preflight therefore
|
|
||||||
validates the WHOLE file with a READ-ONLY connection (mode=ro +
|
|
||||||
immutable=1 URI, PRAGMA integrity_check, run on the executor like
|
|
||||||
every other DB call): corruption anywhere fails there, before
|
|
||||||
anything is written. ``immutable=1`` is SQLite's documented
|
|
||||||
guarantee that the connection performs NO writes and creates NO
|
|
||||||
sidecars — even a WAL-header file gets its -wal/-shm reads served
|
|
||||||
without creating the shared-memory file. Missing/empty files skip
|
|
||||||
the preflight (they are created fresh by the real open). All
|
|
||||||
filesystem stat/URI work runs inside the executor job with the
|
|
||||||
probe so the event loop never touches disk.
|
|
||||||
"""
|
|
||||||
def _probe() -> None:
|
|
||||||
# The file-state check and URI construction live inside the
|
|
||||||
# executor job: exists/stat/resolve are disk I/O.
|
|
||||||
if not db_path.exists():
|
|
||||||
return
|
|
||||||
# A zero-byte file is neither missing nor a valid database: it
|
|
||||||
# is a corrupt (truncated-to-nothing) existing file, likely an
|
|
||||||
# interrupted first write. The leave-untouched contract applies
|
|
||||||
# to it too: refuse rather than silently initialize over it.
|
|
||||||
if db_path.stat().st_size == 0:
|
|
||||||
raise sqlite3.DatabaseError(
|
|
||||||
"existing database file is empty (zero bytes): file is "
|
|
||||||
"not a database"
|
|
||||||
)
|
|
||||||
# Properly escape the path: '#'/'?'/'%' in a directory or file
|
|
||||||
# name would otherwise be parsed as URI fragments/parameters/
|
|
||||||
# escapes. immutable=1: SQLite guarantees no writes and no
|
|
||||||
# sidecar creation from this connection.
|
|
||||||
uri = db_path.resolve().as_uri() + "?mode=ro&immutable=1"
|
|
||||||
conn = sqlite3.connect(uri, uri=True)
|
|
||||||
try:
|
|
||||||
row = conn.execute("PRAGMA integrity_check").fetchone()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
verdict = row[0] if row else "missing"
|
|
||||||
if verdict != "ok":
|
|
||||||
# A non-ok integrity verdict IS corruption: raise with the
|
|
||||||
# verdict text (which matches the corruption markers) and
|
|
||||||
# the NOTADB code so classification is code-based.
|
|
||||||
raise sqlite3.DatabaseError(
|
|
||||||
f"integrity_check verdict {verdict!r} on read-only "
|
|
||||||
f"probe: file is not a database"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Every DB-touching call goes through the executor (the mock
|
|
||||||
# harness's async_add_executor_job is awaitable too).
|
|
||||||
await hass.async_add_executor_job(_probe)
|
|
||||||
except sqlite3.DatabaseError as err:
|
|
||||||
# Non-ok integrity verdicts are corruption, full stop: there is
|
|
||||||
# no benign reading of a failed whole-file integrity check, so
|
|
||||||
# ANY DatabaseError out of the probe that carries a corruption
|
|
||||||
# marker or code propagates as corruption. Only exotic probe
|
|
||||||
# failures that classify as non-corruption (e.g. a transient
|
|
||||||
# 'database is locked' on the read) are downgraded to a debug
|
|
||||||
# log, letting the real open path surface them.
|
|
||||||
if _is_corruption_error(err):
|
|
||||||
raise
|
|
||||||
raise sqlite3.DatabaseError(
|
|
||||||
f"read-only corruption probe of {db_path} failed: {err}"
|
|
||||||
) from err
|
|
||||||
|
|
||||||
async def _async_open_database(
|
|
||||||
hass: HomeAssistant, db_path: Path
|
|
||||||
) -> NestQuestDatabase:
|
|
||||||
"""Open the NestQuest database, mapping failure modes for setup.
|
|
||||||
|
|
||||||
Three startup cases (Feature 02 done-condition):
|
|
||||||
|
|
||||||
- file missing: SQLite creates it fresh on open — no special path,
|
|
||||||
migrations stamp version 1 on the next step.
|
|
||||||
- file present and valid: opens normally.
|
|
||||||
- file present but corrupt: log an ERROR with the path, close
|
|
||||||
anything opened, and raise ConfigEntryNotReady so HA retries —
|
|
||||||
the file is left byte-for-byte untouched so the owner can
|
|
||||||
restore a backup. No code path here deletes or overwrites an
|
|
||||||
existing database file.
|
|
||||||
|
|
||||||
Corruption can surface at EITHER stage: a truncated header fails
|
|
||||||
in the read-only preflight (before the file is ever opened
|
|
||||||
read-write), while damaged later pages fail during migrations —
|
|
||||||
the preflight guarantees no WAL sidecar or header write happens
|
|
||||||
on a corrupt file before it is detected. Both are classified the
|
|
||||||
same way.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
await _preflight_existing_file(hass, db_path)
|
|
||||||
database = await NestQuestDatabase(hass).open(db_path)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except sqlite3.DatabaseError as err:
|
|
||||||
if _is_corruption_error(err):
|
|
||||||
_LOGGER.error(
|
|
||||||
"NestQuest database at %s is corrupt: %s. Refusing to "
|
|
||||||
"start with a corrupt database; the file was left "
|
|
||||||
"untouched so a backup can be restored. Home Assistant "
|
|
||||||
"will retry setup.",
|
|
||||||
db_path,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
raise ConfigEntryNotReady(
|
|
||||||
f"NestQuest database corrupt: {db_path}"
|
|
||||||
) from err
|
|
||||||
# open() failed for a non-corruption reason (locked file,
|
|
||||||
# permission problem): retryable, but nothing was opened.
|
|
||||||
_LOGGER.error(
|
|
||||||
"NestQuest database at %s failed to open: %s. Home "
|
|
||||||
"Assistant will retry setup.",
|
|
||||||
db_path,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
raise ConfigEntryNotReady(
|
|
||||||
f"NestQuest database unavailable: {db_path}"
|
|
||||||
) from err
|
|
||||||
try:
|
|
||||||
await apply_migrations(database)
|
|
||||||
except (sqlite3.DatabaseError, RuntimeError) as err:
|
|
||||||
await database.close()
|
|
||||||
if _is_corruption_error(err):
|
|
||||||
_LOGGER.error(
|
|
||||||
"NestQuest database at %s is corrupt: %s. Refusing to "
|
|
||||||
"start with a corrupt database; the file was left "
|
|
||||||
"untouched so a backup can be restored. Home Assistant "
|
|
||||||
"will retry setup.",
|
|
||||||
db_path,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
raise ConfigEntryNotReady(
|
|
||||||
f"NestQuest database corrupt: {db_path}"
|
|
||||||
) from err
|
|
||||||
# A non-corruption failure (e.g. a locked file, a future schema
|
|
||||||
# version) is also a retryable setup problem, but without the
|
|
||||||
# corruption framing.
|
|
||||||
_LOGGER.error(
|
|
||||||
"NestQuest database at %s failed to open: %s. Home "
|
|
||||||
"Assistant will retry setup.",
|
|
||||||
db_path,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
raise ConfigEntryNotReady(
|
|
||||||
f"NestQuest database unavailable: {db_path}"
|
|
||||||
) from err
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
await database.close()
|
|
||||||
raise
|
|
||||||
return database
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class NestQuestRuntimeData:
|
class NestQuestRuntimeData:
|
||||||
"""Runtime data stored on a NestQuest config entry."""
|
"""Runtime data stored on a NestQuest config entry."""
|
||||||
|
|
||||||
entry_id: str
|
entry_id: str
|
||||||
options: dict[str, Any]
|
|
||||||
database: NestQuestDatabase
|
|
||||||
remove_update_listener: Callable[[], 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:
|
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||||
"""Set up the NestQuest integration. YAML configuration is not used, returns True."""
|
"""Set up the NestQuest integration. YAML configuration is not used, returns True."""
|
||||||
|
await async_register_frontend(hass)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up NestQuest from a config entry."""
|
"""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, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
existing = hass.data[DOMAIN].get(entry.entry_id)
|
|
||||||
if existing is not None:
|
|
||||||
db = getattr(existing, "database", None)
|
|
||||||
if db is None or not db.connected:
|
|
||||||
# Reopen through the SAME safety path as first setup: a
|
|
||||||
# record without a live connection (a previous setup's
|
|
||||||
# close-on-failure, or a hand-replaced file) must get the
|
|
||||||
# missing/valid/corrupt classification too, never a raw
|
|
||||||
# sqlite3 error and never skipped migrations.
|
|
||||||
db = await _async_open_database(
|
|
||||||
hass, await async_get_db_path(hass)
|
|
||||||
)
|
|
||||||
existing.database = db
|
|
||||||
entry.runtime_data = existing
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def _async_update_listener(
|
async def _async_update_listener(
|
||||||
listener_hass: HomeAssistant, listener_entry: ConfigEntry
|
listener_hass: HomeAssistant, listener_entry: ConfigEntry
|
||||||
@@ -255,19 +104,115 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
"""Reload the entry when its options change."""
|
"""Reload the entry when its options change."""
|
||||||
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
|
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
|
||||||
|
|
||||||
database = await _async_open_database(hass, await async_get_db_path(hass))
|
remove_update_listener: Callable[[], Any] | None = None
|
||||||
|
coordinator: Any = None
|
||||||
|
event_stream: NestQuestEventStream | None = None
|
||||||
try:
|
try:
|
||||||
remove_update_listener = entry.add_update_listener(_async_update_listener)
|
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:
|
except BaseException:
|
||||||
await database.close()
|
# 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
|
raise
|
||||||
runtime_data = NestQuestRuntimeData(
|
# The runtime record was already registered before the first
|
||||||
entry_id=entry.entry_id,
|
# refresh; re-assert the (unchanged) record so the return path
|
||||||
options=dict(entry.options),
|
# stays readable as one registration site.
|
||||||
database=database,
|
|
||||||
remove_update_listener=remove_update_listener,
|
|
||||||
)
|
|
||||||
entry.runtime_data = runtime_data
|
|
||||||
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -276,28 +221,63 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||||
runtime_data = domain_data.pop(entry.entry_id, None)
|
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)
|
hass.data.pop(DOMAIN, None)
|
||||||
if runtime_data is None:
|
if runtime_data is None:
|
||||||
runtime_data = getattr(entry, "runtime_data", 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:
|
if runtime_data is not None:
|
||||||
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
|
# The SSE subscription is stopped FIRST: its task must be gone
|
||||||
unload_error: BaseException | None = None
|
# (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:
|
try:
|
||||||
if remove_update_listener is not None:
|
if remove_update_listener is not None:
|
||||||
result = remove_update_listener()
|
result = remove_update_listener()
|
||||||
if inspect.isawaitable(result):
|
if inspect.isawaitable(result):
|
||||||
await result
|
await result
|
||||||
except BaseException as err:
|
except BaseException as err:
|
||||||
unload_error = 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:
|
finally:
|
||||||
database = getattr(runtime_data, "database", None)
|
# The coordinator's entity listeners must stop here: a
|
||||||
if database is not None:
|
# scheduled refresh must never outlive the unload.
|
||||||
await database.close()
|
coordinator = getattr(runtime_data, "coordinator", None)
|
||||||
if unload_error is not None:
|
if coordinator is not None:
|
||||||
if getattr(entry, "runtime_data", None) is not None:
|
shutdown = getattr(coordinator, "async_shutdown", None)
|
||||||
entry.runtime_data = None
|
if shutdown is not None:
|
||||||
raise unload_error
|
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:
|
if getattr(entry, "runtime_data", None) is not None:
|
||||||
entry.runtime_data = None
|
entry.runtime_data = None
|
||||||
|
if unload_error is not None:
|
||||||
|
raise unload_error
|
||||||
return True
|
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(
|
async def async_step_user(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> FlowResult:
|
) -> 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)
|
await self.async_set_unique_id(DOMAIN, raise_on_progress=False)
|
||||||
if self._async_in_progress() or self._async_current_entries():
|
if self._async_in_progress() or self._async_current_entries():
|
||||||
return self.async_abort(reason="single_instance_allowed")
|
return self.async_abort(reason="single_instance_allowed")
|
||||||
@@ -36,4 +43,4 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
if user_input is None:
|
if user_input is None:
|
||||||
return self.async_show_form(step_id="user")
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
from .core.const import * # noqa: F401,F403
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -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()]
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"""Business layer for the children registry (Feature 03).
|
||||||
|
|
||||||
|
Sits between the Feature 09 service gate and the typed
|
||||||
|
:class:`~.dao_children.ChildrenDao`: callers get validation, name
|
||||||
|
policies and the duplicate-name warning here, and never talk to the
|
||||||
|
DAO or SQL directly. Children are never hard-deleted — deactivation
|
||||||
|
is a separate explicit operation (:func:`set_child_active`), so
|
||||||
|
completion history keeps its references (feature guardrail).
|
||||||
|
|
||||||
|
Name policy: the display name is required and trimmed; an empty or
|
||||||
|
whitespace-only name is rejected with ValueError. A duplicate display
|
||||||
|
name is ALLOWED (two children may legitimately share a nickname) but
|
||||||
|
logged as a warning so an accidental double-add is visible in the HA
|
||||||
|
log. The duplicate check is case-insensitive and whitespace-normalised
|
||||||
|
on both sides, because "ada" and "Ada " are the same name to a parent.
|
||||||
|
|
||||||
|
Colour and avatar are optional free-form strings, trimmed when present;
|
||||||
|
``sort_order`` is an int with the DAO/schema default of 0. Clearing an
|
||||||
|
optional field back to NULL is deliberately rejected: the DAO's update
|
||||||
|
writes only provided fields (it cannot express NULL), so the business
|
||||||
|
layer refuses an explicit ``None`` instead of silently ignoring it.
|
||||||
|
Timestamps follow the module timestamp policy: the business layer
|
||||||
|
stamps UTC ISO-8601 at second precision so every row it creates is
|
||||||
|
uniform.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .dao_children import ChildRecord, ChildrenDao, _connection_lock
|
||||||
|
from .db import NestQuestDatabase
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
|
||||||
|
#: in :func:`edit_child`.
|
||||||
|
_UNSET = object()
|
||||||
|
|
||||||
|
|
||||||
|
def _now_stamp() -> str:
|
||||||
|
"""Return the module's strict UTC second-precision stamp."""
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||||
|
timespec="seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_text(
|
||||||
|
value: object, field: str, *, required: bool = False
|
||||||
|
) -> str | None:
|
||||||
|
"""Validate a free-text field and return it trimmed.
|
||||||
|
|
||||||
|
``None`` raises when ``required`` and otherwise returns None. A
|
||||||
|
non-string value is rejected; an empty result is rejected for
|
||||||
|
required fields (whitespace-only names are empty to a parent).
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
if required:
|
||||||
|
raise ValueError(f"{field} is required")
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(f"{field} must be a string, got {value!r}")
|
||||||
|
trimmed = value.strip()
|
||||||
|
if required and not trimmed:
|
||||||
|
raise ValueError(f"{field} is required")
|
||||||
|
return trimmed
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_sort_order(value: object) -> int:
|
||||||
|
"""Reject non-int sort orders (bools included) before the DAO."""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise ValueError(f"sort_order must be an integer, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_child_id(value: object) -> int:
|
||||||
|
"""Reject non-int child ids (bools included) before any lookup.
|
||||||
|
|
||||||
|
SQLite binds Python bools as integers, so ``True`` would silently
|
||||||
|
address child 1 and a float would round — malformed service input
|
||||||
|
must never mutate the wrong profile.
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise ValueError(f"child_id must be an integer, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
async def _warn_on_duplicate_name(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
display_name: str,
|
||||||
|
*,
|
||||||
|
exclude_child_id: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Log a warning when ``display_name`` duplicates an existing child.
|
||||||
|
|
||||||
|
Case-insensitive and whitespace-normalised on both sides. The
|
||||||
|
duplicate is still allowed — this only makes it visible; the log
|
||||||
|
line names both rows so an accidental double-add can be found.
|
||||||
|
``exclude_child_id`` removes the child being edited from the scan,
|
||||||
|
so an edit only warns when the result collides with a DIFFERENT
|
||||||
|
child's name — including case-only edits, where the self-comparison
|
||||||
|
would otherwise hide a real duplicate.
|
||||||
|
"""
|
||||||
|
lowered = display_name.casefold()
|
||||||
|
for child in await ChildrenDao(database).list_all():
|
||||||
|
if exclude_child_id is not None and child.id == exclude_child_id:
|
||||||
|
continue
|
||||||
|
if child.display_name.strip().casefold() == lowered:
|
||||||
|
LOGGER.warning(
|
||||||
|
"Child display name %r duplicates existing child %d "
|
||||||
|
"(%r); allowed, but check this was intentional",
|
||||||
|
display_name,
|
||||||
|
child.id,
|
||||||
|
child.display_name,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
async def create_child(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
display_name: str,
|
||||||
|
*,
|
||||||
|
colour: str | None = None,
|
||||||
|
avatar_ref: str | None = None,
|
||||||
|
sort_order: int = 0,
|
||||||
|
) -> ChildRecord:
|
||||||
|
"""Create a child profile and return the stored record.
|
||||||
|
|
||||||
|
The display name is required, trimmed, and rejected when empty.
|
||||||
|
A duplicate display name (case-insensitive) is allowed but logged
|
||||||
|
as a warning. The scan and the INSERT run inside the
|
||||||
|
connection-scoped lock shared with the DAOs, so a concurrent
|
||||||
|
create of the same normalised name queues behind the first insert
|
||||||
|
and sees it — the duplicate still warns; it can never slip through
|
||||||
|
unlogged.
|
||||||
|
"""
|
||||||
|
name = _validate_text(display_name, "display_name", required=True)
|
||||||
|
assert name is not None
|
||||||
|
colour_value = _validate_text(colour, "colour")
|
||||||
|
avatar_value = _validate_text(avatar_ref, "avatar_ref")
|
||||||
|
order = _validate_sort_order(sort_order)
|
||||||
|
async with _connection_lock(database):
|
||||||
|
await _warn_on_duplicate_name(database, name)
|
||||||
|
created = await ChildrenDao(database).create(
|
||||||
|
name,
|
||||||
|
_now_stamp(),
|
||||||
|
colour=colour_value,
|
||||||
|
avatar_ref=avatar_value,
|
||||||
|
sort_order=order,
|
||||||
|
)
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
async def edit_child(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
child_id: int,
|
||||||
|
*,
|
||||||
|
display_name: str | object = _UNSET,
|
||||||
|
colour: str | None | object = _UNSET,
|
||||||
|
avatar_ref: str | None | object = _UNSET,
|
||||||
|
sort_order: int | object = _UNSET,
|
||||||
|
) -> ChildRecord:
|
||||||
|
"""Edit a child profile and return the updated record.
|
||||||
|
|
||||||
|
Arguments default to the module sentinel ``_UNSET`` meaning "leave
|
||||||
|
this field alone". An explicitly passed ``None`` for an optional
|
||||||
|
field raises: the DAO's update writes only provided fields and
|
||||||
|
cannot express NULL, so a silent no-op would mask the caller's
|
||||||
|
intent (see the module docstring). A provided display name is
|
||||||
|
validated like create's; the duplicate scan excludes only the child
|
||||||
|
being edited, so a case-only edit colliding with another child's
|
||||||
|
normalised name still warns. Raises ValueError when the child does
|
||||||
|
not exist.
|
||||||
|
|
||||||
|
Atomicity: the existence check, the duplicate scan, the UPDATE and
|
||||||
|
the readback all run inside the connection-scoped lock shared with
|
||||||
|
the DAOs, so another edit cannot slip between this call's UPDATE
|
||||||
|
and its readback — the returned record is exactly the one this
|
||||||
|
edit produced, not a later writer's.
|
||||||
|
"""
|
||||||
|
_validate_child_id(child_id)
|
||||||
|
async with _connection_lock(database):
|
||||||
|
dao = ChildrenDao(database)
|
||||||
|
existing = await dao.get(child_id)
|
||||||
|
if existing is None:
|
||||||
|
raise ValueError(f"child {child_id} does not exist")
|
||||||
|
updates: dict[str, object] = {}
|
||||||
|
if display_name is not _UNSET:
|
||||||
|
name = _validate_text(
|
||||||
|
display_name, "display_name", required=True
|
||||||
|
)
|
||||||
|
assert name is not None
|
||||||
|
await _warn_on_duplicate_name(
|
||||||
|
database, name, exclude_child_id=child_id
|
||||||
|
)
|
||||||
|
updates["display_name"] = name
|
||||||
|
if colour is not _UNSET:
|
||||||
|
value = _validate_text(colour, "colour")
|
||||||
|
if value is None:
|
||||||
|
raise ValueError(
|
||||||
|
"clearing colour is not supported; pass a replacement "
|
||||||
|
"value or omit the field"
|
||||||
|
)
|
||||||
|
updates["colour"] = value
|
||||||
|
if avatar_ref is not _UNSET:
|
||||||
|
value = _validate_text(avatar_ref, "avatar_ref")
|
||||||
|
if value is None:
|
||||||
|
raise ValueError(
|
||||||
|
"clearing avatar_ref is not supported; pass a "
|
||||||
|
"replacement value or omit the field"
|
||||||
|
)
|
||||||
|
updates["avatar_ref"] = value
|
||||||
|
if sort_order is not _UNSET:
|
||||||
|
updates["sort_order"] = _validate_sort_order(sort_order)
|
||||||
|
if updates:
|
||||||
|
await dao.update(child_id, **updates)
|
||||||
|
edited = await dao.get(child_id)
|
||||||
|
assert edited is not None
|
||||||
|
return edited
|
||||||
|
|
||||||
|
|
||||||
|
async def set_child_active(
|
||||||
|
database: NestQuestDatabase, child_id: int, is_active: bool
|
||||||
|
) -> ChildRecord:
|
||||||
|
"""Deactivate or reactivate a child; returns the updated record.
|
||||||
|
|
||||||
|
Deactivation hides the child from the panel without deleting their
|
||||||
|
history (feature guardrail); this is the only removal path.
|
||||||
|
``is_active`` must be a real bool: the DAO's ``int()`` would
|
||||||
|
otherwise silently coerce strings and numerics ("1", 0) into a
|
||||||
|
state the caller never asked for. Raises ValueError when the
|
||||||
|
child does not exist.
|
||||||
|
|
||||||
|
Atomicity: existence check, UPDATE and readback run inside the
|
||||||
|
connection-scoped lock, so a concurrent opposite transition cannot
|
||||||
|
make this call report the other caller's value.
|
||||||
|
"""
|
||||||
|
_validate_child_id(child_id)
|
||||||
|
async with _connection_lock(database):
|
||||||
|
dao = ChildrenDao(database)
|
||||||
|
if await dao.get(child_id) is None:
|
||||||
|
raise ValueError(f"child {child_id} does not exist")
|
||||||
|
if not isinstance(is_active, bool):
|
||||||
|
raise ValueError(
|
||||||
|
f"is_active must be a real bool, got {is_active!r}"
|
||||||
|
)
|
||||||
|
await dao.set_active(child_id, is_active)
|
||||||
|
updated = await dao.get(child_id)
|
||||||
|
assert updated is not None
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
async def reorder_children(
|
||||||
|
database: NestQuestDatabase, ordered_ids: list[int]
|
||||||
|
) -> None:
|
||||||
|
"""Rewrite the children's panel display order in one transaction.
|
||||||
|
|
||||||
|
``ordered_ids`` must be a complete permutation of the children
|
||||||
|
table — every child id exactly once. A partial list (missing an
|
||||||
|
existing child), an unknown id, or a duplicate is rejected with
|
||||||
|
ValueError before any write, so the current order survives a
|
||||||
|
rejected call. The DAO enforces this inside its own transaction
|
||||||
|
(check and write are atomic under the connection lock); this
|
||||||
|
wrapper additionally rejects non-integer ids up front, since
|
||||||
|
``bool`` and floats would silently compare against stored integer
|
||||||
|
ids at the SQL layer.
|
||||||
|
"""
|
||||||
|
for child_id in ordered_ids:
|
||||||
|
_validate_child_id(child_id)
|
||||||
|
await ChildrenDao(database).reorder(ordered_ids)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_children(
|
||||||
|
database: NestQuestDatabase, *, active_only: bool = False
|
||||||
|
) -> list[ChildRecord]:
|
||||||
|
"""Return children in display order (sort_order, then id)."""
|
||||||
|
dao = ChildrenDao(database)
|
||||||
|
if active_only:
|
||||||
|
return await dao.list_active()
|
||||||
|
return await dao.list_all()
|
||||||
@@ -0,0 +1,549 @@
|
|||||||
|
"""Business layer for the completion event log (Feature 08).
|
||||||
|
|
||||||
|
Sits between the Feature 09 service gate and the typed
|
||||||
|
:class:`~.dao_instances.CompletionEventsDao`: callers get actor-shape
|
||||||
|
validation and the UTC occurred-at stamp here, and never talk to the
|
||||||
|
DAO or SQL directly. This module is the only Feature-08 caller of
|
||||||
|
:meth:`~.dao_instances.CompletionEventsDao.append`. Completing an
|
||||||
|
instance (no-op if already done) is :func:`complete_instance`;
|
||||||
|
un-completing (no-op if already open) is :func:`uncomplete_instance`.
|
||||||
|
Instance current state is derived from the latest event via
|
||||||
|
:func:`instance_state` (``open`` / ``done`` / ``missed``); there is
|
||||||
|
no status column. ``missed`` is derived for past-due open instances
|
||||||
|
(``due_date`` before HA-local ``today``) and is never stored as an
|
||||||
|
event_type.
|
||||||
|
|
||||||
|
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
|
||||||
|
``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'``
|
||||||
|
requires ``actor_user_id`` and forbids ``actor_child_id``; ``'panel'``
|
||||||
|
requires ``actor_child_id`` (the tapped profile) and forbids
|
||||||
|
``actor_user_id``. Inverted shapes raise a field-naming ValueError
|
||||||
|
before any write.
|
||||||
|
|
||||||
|
``occurred_at`` is stamped UTC ISO-8601 at second precision with an
|
||||||
|
explicit ``+00:00`` offset, matching the DAO's one accepted shape so
|
||||||
|
lexicographic range queries stay exact. ``was_on_time`` is required
|
||||||
|
(True/False) for completed events; un-completions may pass None. There
|
||||||
|
is no permission check here (that is Feature 09).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from .dao_children import _CONNECTION_LOCKS, _connection_lock
|
||||||
|
from .dao_instances import (
|
||||||
|
EVENT_COMPLETED,
|
||||||
|
EVENT_UNCOMPLETED,
|
||||||
|
CompletionEventRecord,
|
||||||
|
CompletionEventsDao,
|
||||||
|
QuestInstanceRecord,
|
||||||
|
QuestInstancesDao,
|
||||||
|
)
|
||||||
|
from .db import NestQuestDatabase
|
||||||
|
|
||||||
|
#: Timestamp policy for completion_events.occurred_at: strict UTC
|
||||||
|
#: ISO-8601 with explicit '+00:00' offset (module timestamp policy).
|
||||||
|
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def _now_stamp() -> str:
|
||||||
|
"""Return the module's strict UTC second-precision stamp."""
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||||
|
_UTC_TIMESTAMP_FORMAT
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stamp_occurred_at(now: datetime.datetime | None) -> str:
|
||||||
|
"""Return a DAO-accepted UTC stamp for ``now``, or the current time."""
|
||||||
|
if now is None:
|
||||||
|
return _now_stamp()
|
||||||
|
if not isinstance(now, datetime.datetime):
|
||||||
|
raise ValueError(f"now must be a datetime, got {now!r}")
|
||||||
|
if now.tzinfo is None:
|
||||||
|
raise ValueError("now must be timezone-aware")
|
||||||
|
return now.astimezone(datetime.timezone.utc).strftime(
|
||||||
|
_UTC_TIMESTAMP_FORMAT
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _completion_moment(
|
||||||
|
now: datetime.datetime | None,
|
||||||
|
today: datetime.date | None,
|
||||||
|
) -> tuple[datetime.date, datetime.time | None]:
|
||||||
|
"""Return the HA-local completion date and optional wall-clock time.
|
||||||
|
|
||||||
|
``today`` is the HA-local calendar date when threaded (Feature 07
|
||||||
|
style). ``now`` is a timezone-aware HA-local datetime; its wall
|
||||||
|
clock is used as-is and is never converted to UTC. When ``today``
|
||||||
|
is omitted the date comes from ``now``. When both are omitted the
|
||||||
|
host calendar date is the Feature 07 fallback — never the UTC
|
||||||
|
clock. Time-of-day is None unless ``now`` is supplied; comparing
|
||||||
|
against ``due_time`` requires ``now``.
|
||||||
|
"""
|
||||||
|
if now is not None:
|
||||||
|
if not isinstance(now, datetime.datetime):
|
||||||
|
raise ValueError(f"now must be a datetime, got {now!r}")
|
||||||
|
if now.tzinfo is None:
|
||||||
|
raise ValueError("now must be timezone-aware")
|
||||||
|
if today is not None:
|
||||||
|
completion_date = today
|
||||||
|
elif now is not None:
|
||||||
|
completion_date = now.date()
|
||||||
|
else:
|
||||||
|
completion_date = datetime.date.today()
|
||||||
|
if now is None:
|
||||||
|
return completion_date, None
|
||||||
|
return completion_date, now.time()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_today(today: datetime.date | None) -> datetime.date:
|
||||||
|
"""Return the threaded HA-local date, or the host calendar date."""
|
||||||
|
if today is None:
|
||||||
|
return datetime.date.today()
|
||||||
|
return today
|
||||||
|
|
||||||
|
|
||||||
|
def _derived_state(
|
||||||
|
instance: QuestInstanceRecord,
|
||||||
|
latest: CompletionEventRecord | None,
|
||||||
|
today: datetime.date,
|
||||||
|
) -> str:
|
||||||
|
"""Return ``open``, ``done``, or ``missed`` from event plus due date.
|
||||||
|
|
||||||
|
A latest completed event is ``done`` even when past due. No events
|
||||||
|
or a latest uncompleted event is ``open``, or ``missed`` when
|
||||||
|
``due_date`` is before ``today``. ``missed`` is never an event_type.
|
||||||
|
"""
|
||||||
|
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
|
||||||
|
due_date = datetime.date.fromisoformat(instance.due_date)
|
||||||
|
if due_date < today:
|
||||||
|
return "missed"
|
||||||
|
return "open"
|
||||||
|
if latest.event_type == EVENT_COMPLETED:
|
||||||
|
return "done"
|
||||||
|
raise ValueError(
|
||||||
|
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||||
|
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def derive_state(
|
||||||
|
instance: QuestInstanceRecord,
|
||||||
|
latest: CompletionEventRecord | None,
|
||||||
|
today: datetime.date,
|
||||||
|
) -> str:
|
||||||
|
"""Public state derivation for the Feature 10 coordinator.
|
||||||
|
|
||||||
|
Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or
|
||||||
|
``missed`` from the latest event plus the due date (``missed`` is
|
||||||
|
derived, never an event_type). ``today`` is the caller-resolved
|
||||||
|
HA-local date.
|
||||||
|
"""
|
||||||
|
return _derived_state(instance, latest, today)
|
||||||
|
|
||||||
|
|
||||||
|
def _was_on_time(
|
||||||
|
instance: QuestInstanceRecord,
|
||||||
|
now: datetime.datetime | None,
|
||||||
|
today: datetime.date | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether the completion moment is on time for ``instance``.
|
||||||
|
|
||||||
|
On ``due_date`` before ``due_time`` is on time; on ``due_date``
|
||||||
|
after ``due_time``, or any later date, is late. With no
|
||||||
|
``due_time``, on time if the completion date equals ``due_date``
|
||||||
|
(or is earlier); late if after ``due_date``. Same-day
|
||||||
|
``due_time`` comparison requires ``now``; UTC time-of-day is never
|
||||||
|
substituted.
|
||||||
|
"""
|
||||||
|
completion_date, completion_time = _completion_moment(now, today)
|
||||||
|
due_date = datetime.date.fromisoformat(instance.due_date)
|
||||||
|
if completion_date < due_date:
|
||||||
|
return True
|
||||||
|
if completion_date > due_date:
|
||||||
|
return False
|
||||||
|
if instance.due_time is None:
|
||||||
|
return True
|
||||||
|
if completion_time is None:
|
||||||
|
raise ValueError("now is required to compare due_time")
|
||||||
|
due_time = datetime.datetime.strptime(instance.due_time, "%H:%M").time()
|
||||||
|
return completion_time <= due_time
|
||||||
|
|
||||||
|
|
||||||
|
class _TaskReentrantLock:
|
||||||
|
"""Shared connection lock the owning task may re-enter."""
|
||||||
|
|
||||||
|
def __init__(self, inner: asyncio.Lock) -> None:
|
||||||
|
self._inner = inner
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
self._depth = 0
|
||||||
|
|
||||||
|
def locked(self) -> bool:
|
||||||
|
return self._inner.locked()
|
||||||
|
|
||||||
|
async def acquire(self) -> bool:
|
||||||
|
task = asyncio.current_task()
|
||||||
|
if self._task is task:
|
||||||
|
self._depth += 1
|
||||||
|
return True
|
||||||
|
await self._inner.acquire()
|
||||||
|
self._task = task
|
||||||
|
self._depth = 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
if self._task is not asyncio.current_task():
|
||||||
|
raise RuntimeError("lock released by a non-owner")
|
||||||
|
self._depth -= 1
|
||||||
|
if self._depth == 0:
|
||||||
|
self._task = None
|
||||||
|
self._inner.release()
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _TaskReentrantLock:
|
||||||
|
await self.acquire()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc: object) -> None:
|
||||||
|
self.release()
|
||||||
|
|
||||||
|
|
||||||
|
def _reentrant_connection_lock(database) -> _TaskReentrantLock:
|
||||||
|
"""Return the shared connection lock, made task-reentrant.
|
||||||
|
|
||||||
|
``CompletionEventsDao.append`` also acquires this lock, so
|
||||||
|
check+append in :func:`complete_instance` and
|
||||||
|
:func:`uncomplete_instance` must re-enter on the same task. The
|
||||||
|
wrapper is stored in the shared dict so the DAO sees the same
|
||||||
|
object.
|
||||||
|
"""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
key = (id(database), id(loop))
|
||||||
|
lock = _CONNECTION_LOCKS.get(key)
|
||||||
|
if isinstance(lock, _TaskReentrantLock):
|
||||||
|
return lock
|
||||||
|
wrapped = _TaskReentrantLock(
|
||||||
|
lock if lock is not None else asyncio.Lock()
|
||||||
|
)
|
||||||
|
_CONNECTION_LOCKS[key] = wrapped
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_int_id(value: object, field: str) -> int:
|
||||||
|
"""Reject non-int ids (bools included) before any lookup."""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise ValueError(f"{field} must be an integer, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_event_type(value: object) -> str:
|
||||||
|
"""Return a schema-legal event type, or raise naming the field."""
|
||||||
|
if value not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
|
||||||
|
raise ValueError(
|
||||||
|
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||||
|
f"'{EVENT_UNCOMPLETED}', got {value!r}"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_actor(
|
||||||
|
actor_source: object,
|
||||||
|
actor_user_id: object,
|
||||||
|
actor_child_id: object,
|
||||||
|
) -> tuple[str, str | None, int | None]:
|
||||||
|
"""Return a schema-legal actor triple, or raise naming the field.
|
||||||
|
|
||||||
|
``'user'`` requires a non-empty ``actor_user_id`` string and forbids
|
||||||
|
``actor_child_id``. ``'panel'`` requires an integer
|
||||||
|
``actor_child_id`` and forbids ``actor_user_id``. Any other
|
||||||
|
``actor_source`` (including ``'service'``) is rejected.
|
||||||
|
"""
|
||||||
|
if actor_source not in ("user", "panel"):
|
||||||
|
raise ValueError(
|
||||||
|
f"actor_source must be 'user' or 'panel', got {actor_source!r}"
|
||||||
|
)
|
||||||
|
if actor_source == "user":
|
||||||
|
if not isinstance(actor_user_id, str) or not actor_user_id.strip():
|
||||||
|
raise ValueError("actor_source 'user' requires actor_user_id")
|
||||||
|
if actor_child_id is not None:
|
||||||
|
raise ValueError(
|
||||||
|
"actor_source 'user' must not carry actor_child_id"
|
||||||
|
)
|
||||||
|
return "user", actor_user_id.strip(), None
|
||||||
|
if actor_user_id is not None:
|
||||||
|
raise ValueError("actor_source 'panel' must not carry actor_user_id")
|
||||||
|
if actor_child_id is None:
|
||||||
|
raise ValueError(
|
||||||
|
"actor_source 'panel' requires actor_child_id"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"panel",
|
||||||
|
None,
|
||||||
|
_validate_int_id(actor_child_id, "actor_child_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_was_on_time(
|
||||||
|
event_type: str, was_on_time: object
|
||||||
|
) -> bool | None:
|
||||||
|
"""Return a real bool or None; completed events require True/False."""
|
||||||
|
if was_on_time is not None and type(was_on_time) is not bool:
|
||||||
|
raise ValueError(
|
||||||
|
f"was_on_time must be True, False or None, got {was_on_time!r}"
|
||||||
|
)
|
||||||
|
if event_type == EVENT_COMPLETED and was_on_time is None:
|
||||||
|
raise ValueError(
|
||||||
|
"a completed event requires was_on_time (True/False)"
|
||||||
|
)
|
||||||
|
return was_on_time
|
||||||
|
|
||||||
|
|
||||||
|
async def append_event(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
instance_id: int,
|
||||||
|
child_id: int,
|
||||||
|
event_type: str,
|
||||||
|
*,
|
||||||
|
actor_source: str,
|
||||||
|
actor_user_id: str | None = None,
|
||||||
|
actor_child_id: int | None = None,
|
||||||
|
was_on_time: bool | None = None,
|
||||||
|
now: datetime.datetime | None = None,
|
||||||
|
) -> CompletionEventRecord:
|
||||||
|
"""Append one completion or un-completion event and return the row.
|
||||||
|
|
||||||
|
Validates actor shapes and the completed-event on-time requirement
|
||||||
|
before calling :meth:`CompletionEventsDao.append`. ``occurred_at``
|
||||||
|
is stamped here (UTC ``+00:00``); pass ``now`` to pin the clock.
|
||||||
|
"""
|
||||||
|
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||||
|
child_id = _validate_int_id(child_id, "child_id")
|
||||||
|
event_type = _validate_event_type(event_type)
|
||||||
|
actor_source, actor_user_id, actor_child_id = _validate_actor(
|
||||||
|
actor_source, actor_user_id, actor_child_id
|
||||||
|
)
|
||||||
|
was_on_time = _validate_was_on_time(event_type, was_on_time)
|
||||||
|
occurred_at = _stamp_occurred_at(now)
|
||||||
|
return await CompletionEventsDao(database).append(
|
||||||
|
instance_id,
|
||||||
|
child_id,
|
||||||
|
event_type,
|
||||||
|
actor_source,
|
||||||
|
occurred_at,
|
||||||
|
was_on_time,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
actor_child_id=actor_child_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def instance_state(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
instance_id: int,
|
||||||
|
*,
|
||||||
|
today: datetime.date | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Return ``open``, ``done``, or ``missed`` from the latest event.
|
||||||
|
|
||||||
|
No events, or a latest event of uncompleted, is ``open`` unless
|
||||||
|
``due_date`` is before ``today`` (HA-local, threaded), in which
|
||||||
|
case it is ``missed``. A latest event of completed is ``done``
|
||||||
|
even when past due. ``missed`` is never stored as an event_type.
|
||||||
|
Unknown ``instance_id`` raises ValueError naming the field.
|
||||||
|
Existence and the latest event are read inside the connection lock
|
||||||
|
so the answer is one snapshot.
|
||||||
|
"""
|
||||||
|
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||||
|
today_date = _resolve_today(today)
|
||||||
|
async with _connection_lock(database):
|
||||||
|
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||||
|
if instance is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"instance_id: quest instance {instance_id} does not exist"
|
||||||
|
)
|
||||||
|
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
||||||
|
instance_id
|
||||||
|
)
|
||||||
|
return _derived_state(instance, latest, today_date)
|
||||||
|
|
||||||
|
|
||||||
|
class CompletionResult(str):
|
||||||
|
"""The derived instance state, carrying the append verdict.
|
||||||
|
|
||||||
|
A ``str`` subclass so every existing ``result == "done"`` contract
|
||||||
|
keeps working; ``appended`` reports whether THIS call actually
|
||||||
|
appended an event (decided under the same lock as the append), so
|
||||||
|
a caller — the Feature 09 service path firing bus events —
|
||||||
|
announces a transition exactly once even under a concurrent
|
||||||
|
duplicate call where the loser's write is a no-op. A completion
|
||||||
|
also carries the ``was_on_time`` verdict computed under that same
|
||||||
|
lock, so the event payload never re-reads mutable latest state a
|
||||||
|
racing reversal could have replaced.
|
||||||
|
"""
|
||||||
|
|
||||||
|
appended: bool
|
||||||
|
was_on_time: bool | None
|
||||||
|
|
||||||
|
def __new__(
|
||||||
|
cls,
|
||||||
|
state: str,
|
||||||
|
*,
|
||||||
|
appended: bool,
|
||||||
|
was_on_time: bool | None = None,
|
||||||
|
) -> "CompletionResult":
|
||||||
|
obj = super().__new__(cls, state)
|
||||||
|
obj.appended = appended
|
||||||
|
obj.was_on_time = was_on_time
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
async def complete_instance(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
instance_id: int,
|
||||||
|
*,
|
||||||
|
actor_source: str,
|
||||||
|
actor_user_id: str | None = None,
|
||||||
|
actor_child_id: int | None = None,
|
||||||
|
now: datetime.datetime | None = None,
|
||||||
|
today: datetime.date | None = None,
|
||||||
|
) -> CompletionResult:
|
||||||
|
"""Append a completed event unless the instance is already done.
|
||||||
|
|
||||||
|
Returns the derived state ``done``; ``appended`` reports whether
|
||||||
|
THIS call appended (False on an already-done no-op). Unknown
|
||||||
|
``instance_id`` raises ValueError naming the field. Existence,
|
||||||
|
latest-event, and the append run under one connection lock so a
|
||||||
|
concurrent complete cannot double-append.
|
||||||
|
|
||||||
|
``was_on_time`` is computed from the HA-local completion moment
|
||||||
|
(threaded ``now`` / ``today``) against the instance ``due_date``
|
||||||
|
and optional ``due_time``. ``now`` is the timezone-aware HA-local
|
||||||
|
datetime; its wall clock is never converted to UTC. Same-day
|
||||||
|
``due_time`` comparison requires ``now``.
|
||||||
|
"""
|
||||||
|
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||||
|
actor_source, actor_user_id, actor_child_id = _validate_actor(
|
||||||
|
actor_source, actor_user_id, actor_child_id
|
||||||
|
)
|
||||||
|
async with _reentrant_connection_lock(database):
|
||||||
|
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||||
|
if instance is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"instance_id: quest instance {instance_id} does not exist"
|
||||||
|
)
|
||||||
|
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
||||||
|
instance_id
|
||||||
|
)
|
||||||
|
if latest is not None and latest.event_type == EVENT_COMPLETED:
|
||||||
|
return CompletionResult("done", appended=False)
|
||||||
|
if latest is not None and latest.event_type != EVENT_UNCOMPLETED:
|
||||||
|
raise ValueError(
|
||||||
|
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||||
|
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
|
||||||
|
)
|
||||||
|
# Computed under the completion lock and carried on the result:
|
||||||
|
# the bus event must report THIS completion's timing, never a
|
||||||
|
# re-read of the latest event a racing reversal could replace.
|
||||||
|
on_time = _was_on_time(instance, now, today)
|
||||||
|
await append_event(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
instance.child_id,
|
||||||
|
EVENT_COMPLETED,
|
||||||
|
actor_source=actor_source,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
actor_child_id=actor_child_id,
|
||||||
|
was_on_time=on_time,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
return CompletionResult("done", appended=True, was_on_time=on_time)
|
||||||
|
|
||||||
|
|
||||||
|
async def uncomplete_instance(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
instance_id: int,
|
||||||
|
*,
|
||||||
|
actor_source: str,
|
||||||
|
actor_user_id: str | None = None,
|
||||||
|
actor_child_id: int | None = None,
|
||||||
|
now: datetime.datetime | None = None,
|
||||||
|
today: datetime.date | None = None,
|
||||||
|
) -> CompletionResult:
|
||||||
|
"""Append an uncompleted event unless the instance is already open.
|
||||||
|
|
||||||
|
Returns the derived state ``open``, or ``missed`` when the instance
|
||||||
|
is past due (``due_date`` before HA-local ``today``); ``appended``
|
||||||
|
reports whether THIS call appended (False on an already-open
|
||||||
|
no-op). Unknown ``instance_id`` raises ValueError naming the
|
||||||
|
field. Existence, latest-event, and the append run under one
|
||||||
|
connection lock so a concurrent uncomplete cannot double-append.
|
||||||
|
The original completed row is not modified.
|
||||||
|
"""
|
||||||
|
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||||
|
actor_source, actor_user_id, actor_child_id = _validate_actor(
|
||||||
|
actor_source, actor_user_id, actor_child_id
|
||||||
|
)
|
||||||
|
today_date = _resolve_today(today)
|
||||||
|
async with _reentrant_connection_lock(database):
|
||||||
|
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||||
|
if instance is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"instance_id: quest instance {instance_id} does not exist"
|
||||||
|
)
|
||||||
|
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
||||||
|
instance_id
|
||||||
|
)
|
||||||
|
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
|
||||||
|
return CompletionResult(
|
||||||
|
_derived_state(instance, latest, today_date),
|
||||||
|
appended=False,
|
||||||
|
)
|
||||||
|
if latest.event_type != EVENT_COMPLETED:
|
||||||
|
raise ValueError(
|
||||||
|
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||||
|
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
|
||||||
|
)
|
||||||
|
latest = await append_event(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
instance.child_id,
|
||||||
|
EVENT_UNCOMPLETED,
|
||||||
|
actor_source=actor_source,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
actor_child_id=actor_child_id,
|
||||||
|
was_on_time=None,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
return CompletionResult(
|
||||||
|
_derived_state(instance, latest, today_date), appended=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_missed_for_child(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
child_id: int,
|
||||||
|
range_start: str,
|
||||||
|
range_end: str,
|
||||||
|
*,
|
||||||
|
today: datetime.date | None = None,
|
||||||
|
) -> list[QuestInstanceRecord]:
|
||||||
|
"""Return the child's missed instances due in the closed date range.
|
||||||
|
|
||||||
|
Missed means the latest event state is open and ``due_date`` is
|
||||||
|
before ``today`` (HA-local, threaded). Uses
|
||||||
|
:meth:`QuestInstancesDao.list_by_date_range` plus derived state.
|
||||||
|
Never appends a missed event. Instances and latest events are
|
||||||
|
read inside the connection lock so the list is one snapshot.
|
||||||
|
"""
|
||||||
|
child_id = _validate_int_id(child_id, "child_id")
|
||||||
|
today_date = _resolve_today(today)
|
||||||
|
async with _connection_lock(database):
|
||||||
|
instances = await QuestInstancesDao(database).list_by_date_range(
|
||||||
|
child_id, range_start, range_end
|
||||||
|
)
|
||||||
|
events = CompletionEventsDao(database)
|
||||||
|
missed: list[QuestInstanceRecord] = []
|
||||||
|
for instance in instances:
|
||||||
|
latest = await events.get_latest_for_instance(instance.id)
|
||||||
|
if _derived_state(instance, latest, today_date) == "missed":
|
||||||
|
missed.append(instance)
|
||||||
|
return missed
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""Constants for the NestQuest integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
DOMAIN = "nestquest"
|
||||||
|
LOGGER_NAME = "custom_components.nestquest"
|
||||||
|
LOGGER = logging.getLogger(LOGGER_NAME)
|
||||||
|
|
||||||
|
#: Strict 24-hour ``HH:MM`` pattern shared by the options flow and the
|
||||||
|
#: settings object's time validation, so a stored option and a freshly
|
||||||
|
#: validated form submit parse the same way. Defined here (HA-free) so
|
||||||
|
#: both the integration's options flow and the core settings module
|
||||||
|
#: import ONE pattern instead of re-copying it.
|
||||||
|
TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
|
||||||
|
|
||||||
|
# Platforms set up per config entry (Feature 10): the per-child day
|
||||||
|
# sensors and binary sensors.
|
||||||
|
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
|
||||||
|
|
||||||
|
# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The
|
||||||
|
# panel and admin cards listen for these; since Feature 18 the
|
||||||
|
# completed/uncompleted/day-complete transitions are published by the
|
||||||
|
# API service on its SSE stream and re-fired on the bus by the
|
||||||
|
# integration's subscription, and Feature 11's nightly sweep fires
|
||||||
|
# quest_missed.
|
||||||
|
EVENT_QUEST_COMPLETED = "nestquest_quest_completed"
|
||||||
|
EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted"
|
||||||
|
EVENT_QUEST_MISSED = "nestquest_quest_missed"
|
||||||
|
EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete"
|
||||||
|
|
||||||
|
#: Canonical domain-global service name (design/ENTITIES-AND-SERVICES.md §2).
|
||||||
|
#: Since Feature 18 only the panel completion service remains: the admin
|
||||||
|
#: surface is the PWA and the API service's admin routes, so the former
|
||||||
|
#: HA admin services (uncomplete, quest-definition and presence
|
||||||
|
#: management, CSV export, child management, regenerate) are gone.
|
||||||
|
SERVICE_COMPLETE_QUEST = "complete_quest"
|
||||||
|
|
||||||
|
DOMAIN_SERVICES: tuple[str, ...] = (
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
SQLITE_DB_FILENAME = "nestquest.db"
|
||||||
|
|
||||||
|
# Zero-config panel dashboard (Feature 20): ONE storage-mode Lovelace
|
||||||
|
# dashboard registered on setup whose config carries the bundle's
|
||||||
|
# ``custom:nestquest-party`` strategy (frontend/src/strategy.ts) — the
|
||||||
|
# views are generated at render time from the household roster. The
|
||||||
|
# url_path IS the domain: the stable key idempotency is keyed on (a
|
||||||
|
# reload or a second entry never duplicates it, and a dashboard already
|
||||||
|
# registered under it — ours or the user's — is left untouched).
|
||||||
|
DASHBOARD_URL_PATH = DOMAIN
|
||||||
|
DASHBOARD_TITLE = "NestQuest"
|
||||||
|
DASHBOARD_ICON = "mdi:party-popper"
|
||||||
|
DASHBOARD_STRATEGY_TYPE = "custom:nestquest-party"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Admin allowlist (Feature 03): HA user IDs permitted to call admin-only
|
||||||
|
# services. Stored as IDs, never usernames (feature guardrail). The
|
||||||
|
# database allowlist is authoritative; the config entry carries the same
|
||||||
|
# list as a disaster-recovery copy so a lost database can be re-seeded.
|
||||||
|
CONF_ADMIN_USER_IDS = "admin_user_ids"
|
||||||
|
|
||||||
|
# Shared coordinator refresh interval (Feature 10), in SECONDS. Every
|
||||||
|
# entity reads one snapshot per refresh; completions push an immediate
|
||||||
|
# refresh so this poll is only the fallback cadence.
|
||||||
|
CONF_UPDATE_INTERVAL = "update_interval"
|
||||||
|
DEFAULT_UPDATE_INTERVAL = 300
|
||||||
|
MIN_UPDATE_INTERVAL = 30
|
||||||
|
|
||||||
|
# Last-good snapshot staleness (Feature 18), in SECONDS: how long the
|
||||||
|
# coordinator keeps SERVING the last good snapshot after the API stops
|
||||||
|
# answering. A failed poll whose elapsed time since the last successful
|
||||||
|
# fetch is within the threshold returns the cached snapshot (the panel
|
||||||
|
# keeps rendering last-good data, entities stay available); past it the
|
||||||
|
# poll fails the HA way and the entities go unavailable. The default is
|
||||||
|
# 3x the default update interval (900 s = 15 minutes): three consecutive
|
||||||
|
# failed five-minute polls before the board is declared stale — enough
|
||||||
|
# to ride out an API service restart, short enough not to render a
|
||||||
|
# days-old board all day. Validation floor: the threshold must be at
|
||||||
|
# least the configured update interval, or the cache could never span
|
||||||
|
# even one failed poll.
|
||||||
|
CONF_SNAPSHOT_STALENESS = "snapshot_staleness"
|
||||||
|
DEFAULT_SNAPSHOT_STALENESS = 3 * DEFAULT_UPDATE_INTERVAL
|
||||||
|
|
||||||
|
# Notification automations (Feature 11). The notify target is a free-text
|
||||||
|
# Home Assistant service name (e.g. "notify.mobile_app_dads_phone") the
|
||||||
|
# household fills in — never a hard-coded personal target; the times are
|
||||||
|
# strict HH:MM local; each of the four automations has an independent
|
||||||
|
# enable toggle. Per the owner-settled resolution the end-of-day report
|
||||||
|
# defaults to 20:00 — deliberately BEFORE the midnight missed sweep —
|
||||||
|
# and never defaults to the rollover time.
|
||||||
|
CONF_NOTIFY_TARGET = "notify_target"
|
||||||
|
CONF_MORNING_SUMMARY_TIME = "morning_summary_time"
|
||||||
|
DEFAULT_MORNING_SUMMARY_TIME = "08:00"
|
||||||
|
CONF_AFTERNOON_REMINDER_TIME = "afternoon_reminder_time"
|
||||||
|
DEFAULT_AFTERNOON_REMINDER_TIME = "15:00"
|
||||||
|
CONF_END_OF_DAY_REPORT_TIME = "end_of_day_report_time"
|
||||||
|
DEFAULT_END_OF_DAY_REPORT_TIME = "20:00"
|
||||||
|
CONF_MORNING_SUMMARY_ENABLED = "morning_summary_enabled"
|
||||||
|
CONF_AFTERNOON_REMINDER_ENABLED = "afternoon_reminder_enabled"
|
||||||
|
CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
|
||||||
|
CONF_CELEBRATION_ENABLED = "celebration_enabled"
|
||||||
|
DEFAULT_AUTOMATION_ENABLED = True
|
||||||
|
|
||||||
|
# Panel-plane API client (Feature 18): the integration reaches the
|
||||||
|
# NestQuest API service (Feature 16) over HTTP instead of the database.
|
||||||
|
# The base URL points at the API service host serving ``/api/v1/panel/*``
|
||||||
|
# (uvicorn's default host/port is the sensible starting point); the panel
|
||||||
|
# service token is the static Bearer credential the panel plane requires
|
||||||
|
# (``NESTQUEST_PANEL_TOKEN`` on the API side). Both are stored on the
|
||||||
|
# entry like every other setting — options first, then the entry-data
|
||||||
|
# copy, then defaults. The TOKEN IS A SECRET: it is never logged, never
|
||||||
|
# echoed into error messages, and never committed. The shipped default
|
||||||
|
# token IS the empty string (DEFAULT_PANEL_TOKEN) — an entry with no
|
||||||
|
# configured token is therefore NOT a working client: the options flow
|
||||||
|
# accepts an empty token as "not configured yet" and only rejects
|
||||||
|
# malformed (padded/whitespace-only/non-string) values — the actual
|
||||||
|
# non-emptiness guard is enforced at client construction, where
|
||||||
|
# NestQuestApiClient fails fast on an empty/whitespace token
|
||||||
|
# (api_client.py). So a caller building the client from a bare
|
||||||
|
# entry must either ensure a token is configured first or handle the
|
||||||
|
# construction ValueError and surface an "API not configured" state —
|
||||||
|
# an unconfigured entry never yields a client that can only 401.
|
||||||
|
# This mirrors the API service's fail-closed stance (api/config.py: no
|
||||||
|
# default token exists).
|
||||||
|
CONF_API_BASE_URL = "api_base_url"
|
||||||
|
DEFAULT_API_BASE_URL = "http://127.0.0.1:8000"
|
||||||
|
CONF_PANEL_TOKEN = "panel_token"
|
||||||
|
DEFAULT_PANEL_TOKEN = ""
|
||||||
|
|
||||||
|
# Quest windows (D-008): a definition may declare several day windows;
|
||||||
|
# each produces its own instance per day and groups the panel's Quest
|
||||||
|
# Log into three columns. The clock ranges are stored here so they stay
|
||||||
|
# configurable later. Boundaries: a window's end equals the next
|
||||||
|
# window's start; classification (Feature 07 materialisation) treats a
|
||||||
|
# due time exactly on the boundary as belonging to the LATER window, so
|
||||||
|
# 12:00 is afternoon (not morning) and 17:00 is evening (not
|
||||||
|
# afternoon). Times after 21:00 belong to no window.
|
||||||
|
WINDOW_MORNING = "morning"
|
||||||
|
WINDOW_AFTERNOON = "afternoon"
|
||||||
|
WINDOW_EVENING = "evening"
|
||||||
|
|
||||||
|
QUEST_WINDOWS: tuple[str, ...] = (
|
||||||
|
WINDOW_MORNING,
|
||||||
|
WINDOW_AFTERNOON,
|
||||||
|
WINDOW_EVENING,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Inclusive "HH:MM" bounds per window, in 24-hour local time.
|
||||||
|
WINDOW_CLOCK_RANGES: dict[str, tuple[str, str]] = {
|
||||||
|
WINDOW_MORNING: ("00:00", "11:59"),
|
||||||
|
WINDOW_AFTERNOON: ("12:00", "17:00"),
|
||||||
|
WINDOW_EVENING: ("17:00", "21:00"),
|
||||||
|
}
|
||||||
+37
-7
@@ -191,13 +191,20 @@ class ChildrenDao:
|
|||||||
async def reorder(self, ordered_ids: list[int]) -> None:
|
async def reorder(self, ordered_ids: list[int]) -> None:
|
||||||
"""Persist a new sort order: ``ordered_ids[i]`` gets sort_order i.
|
"""Persist a new sort order: ``ordered_ids[i]`` gets sort_order i.
|
||||||
|
|
||||||
|
The list must be a COMPLETE permutation of the children table:
|
||||||
|
every existing child id appears exactly once. Partial lists
|
||||||
|
(missing an existing child) and unknown ids (naming a child
|
||||||
|
that does not exist) are rejected with ValueError BEFORE any
|
||||||
|
write, leaving the current order untouched — a silent partial
|
||||||
|
reorder would leave unlisted children with stale positions and
|
||||||
|
hide a caller bug. Duplicate ids are rejected up front: they
|
||||||
|
cannot all hold distinct positions, and silently assigning the
|
||||||
|
last occurrence's index would hide a caller bug.
|
||||||
|
|
||||||
Runs inside one transaction; positions are staged at offset
|
Runs inside one transaction; positions are staged at offset
|
||||||
-len (all negative, so any permutation of the same ids never
|
-len (all negative, so any permutation of the same ids never
|
||||||
collides mid-reorder under the wrapper's single-connection
|
collides mid-reorder under the wrapper's single-connection
|
||||||
serialization), then finalized 0..n-1. Duplicate ids are
|
serialization), then finalized 0..n-1.
|
||||||
rejected up front: they cannot all hold distinct positions, and
|
|
||||||
silently assigning the last occurrence's index would hide a
|
|
||||||
caller bug.
|
|
||||||
|
|
||||||
Concurrent reorder() calls on one connection are serialized by
|
Concurrent reorder() calls on one connection are serialized by
|
||||||
a connection-scoped asyncio lock (shared across every ChildrenDao
|
a connection-scoped asyncio lock (shared across every ChildrenDao
|
||||||
@@ -205,10 +212,10 @@ class ChildrenDao:
|
|||||||
transaction() rejects a second concurrent transaction with
|
transaction() rejects a second concurrent transaction with
|
||||||
RuntimeError, so racing callers would crash instead of
|
RuntimeError, so racing callers would crash instead of
|
||||||
queueing. The lock makes the second call wait, then run
|
queueing. The lock makes the second call wait, then run
|
||||||
against the first call's final state.
|
against the first call's final state — which also keeps the
|
||||||
|
completeness check honest: the re-read of the table happens
|
||||||
|
inside the same lock and transaction as the writes.
|
||||||
"""
|
"""
|
||||||
if not ordered_ids:
|
|
||||||
return
|
|
||||||
if len(set(ordered_ids)) != len(ordered_ids):
|
if len(set(ordered_ids)) != len(ordered_ids):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"reorder() received duplicate child ids; every id must "
|
"reorder() received duplicate child ids; every id must "
|
||||||
@@ -216,6 +223,29 @@ class ChildrenDao:
|
|||||||
)
|
)
|
||||||
async with _connection_lock(self._database):
|
async with _connection_lock(self._database):
|
||||||
async with self._database.transaction():
|
async with self._database.transaction():
|
||||||
|
rows = await self._database.fetch_all(
|
||||||
|
f"SELECT id FROM children"
|
||||||
|
)
|
||||||
|
existing = {row[0] for row in rows}
|
||||||
|
listed = set(ordered_ids)
|
||||||
|
missing = sorted(existing - listed)
|
||||||
|
unknown = sorted(listed - existing)
|
||||||
|
if missing or unknown:
|
||||||
|
details = []
|
||||||
|
if missing:
|
||||||
|
details.append(
|
||||||
|
f"missing child ids {missing} (a partial "
|
||||||
|
"reorder would leave them with stale "
|
||||||
|
"positions)"
|
||||||
|
)
|
||||||
|
if unknown:
|
||||||
|
details.append(
|
||||||
|
f"unknown child ids {unknown}"
|
||||||
|
)
|
||||||
|
raise ValueError(
|
||||||
|
"reorder() must list every child exactly once; "
|
||||||
|
+ "; ".join(details)
|
||||||
|
)
|
||||||
offset = len(ordered_ids)
|
offset = len(ordered_ids)
|
||||||
await self._database.execute_many(
|
await self._database.execute_many(
|
||||||
"UPDATE children SET sort_order = ? WHERE id = ?",
|
"UPDATE children SET sort_order = ? WHERE id = ?",
|
||||||
+353
-70
@@ -15,13 +15,13 @@ event. A package-wide guard test (bottom of the test module) keeps
|
|||||||
mutation SQL for this table out of every other module.
|
mutation SQL for this table out of every other module.
|
||||||
|
|
||||||
Upsert semantics: ``QuestInstancesDao.upsert`` is idempotent on
|
Upsert semantics: ``QuestInstancesDao.upsert`` is idempotent on
|
||||||
(definition_id, due_date) — re-running materialization for a date
|
(definition_id, child_id, due_date, window) — re-running
|
||||||
whose instance already exists updates nothing that matters
|
materialization for a tuple whose instance already exists refreshes
|
||||||
(child_id/due_time are refreshed only in the degenerate re-generation
|
only the generation-time snapshot columns and never duplicates a row.
|
||||||
case where they changed) and never duplicates a row. The schema's
|
The schema's UNIQUE(definition_id, child_id, due_date, window) is the
|
||||||
UNIQUE(definition_id, due_date) is the last line of defense. The
|
last line of defense. The upsert refuses a child that is not an
|
||||||
upsert refuses a child that is not an assignee of the definition
|
assignee of the definition and a window outside ``const.QUEST_WINDOWS``
|
||||||
(D-008 multi-assignee model).
|
(D-008 multi-assignee, multi-window model).
|
||||||
|
|
||||||
``delete_future_uncompleted`` implements the Feature 06/07 rule that
|
``delete_future_uncompleted`` implements the Feature 06/07 rule that
|
||||||
reassignment and schedule edits regenerate future instances: it
|
reassignment and schedule edits regenerate future instances: it
|
||||||
@@ -37,7 +37,7 @@ import datetime
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from .dao_children import _connection_lock
|
from .dao_children import _connection_lock
|
||||||
from .dao_rules import _validate_date
|
from .dao_rules import _validate_date, _validate_window
|
||||||
from .db import NestQuestDatabase
|
from .db import NestQuestDatabase
|
||||||
|
|
||||||
#: Timestamp policy for completion_events.occurred_at: strict UTC
|
#: Timestamp policy for completion_events.occurred_at: strict UTC
|
||||||
@@ -47,6 +47,46 @@ from .db import NestQuestDatabase
|
|||||||
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def _today() -> datetime.date:
|
||||||
|
"""Return the current local calendar date (the single date source).
|
||||||
|
|
||||||
|
Every "today" read in this module goes through this one function so
|
||||||
|
the delete clamp and the materialize walk's no-past guard read the
|
||||||
|
same clock, and a caller or test can pin/roll that clock
|
||||||
|
deterministically (e.g. a queued-across-midnight regression). Pure
|
||||||
|
clock read — no business rule lives here.
|
||||||
|
"""
|
||||||
|
return datetime.date.today()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_today(today: datetime.date | None) -> datetime.date:
|
||||||
|
"""Return the caller-resolved "today", or the host date when absent.
|
||||||
|
|
||||||
|
The no-past guard and the delete cutoffs must compare due dates
|
||||||
|
against the SAME calendar date the materialization horizon was built
|
||||||
|
from. Home Assistant's configured time zone can lag the host clock
|
||||||
|
around midnight, so when the caller has already resolved the local
|
||||||
|
date (``today``) that anchor is authoritative; ``None`` falls back to
|
||||||
|
the host clock (:func:`_today`), which is exactly the previous
|
||||||
|
behavior for callers with no HA-local date.
|
||||||
|
"""
|
||||||
|
return _today() if today is None else today
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_past(due_date: str, today_iso: str) -> None:
|
||||||
|
"""Raise unless ``due_date`` is on or after ``today_iso``.
|
||||||
|
|
||||||
|
The single no-past rule shared by :meth:`QuestInstancesDao.upsert`
|
||||||
|
and :meth:`QuestInstancesDao.upsert_if_valid`: instances are never
|
||||||
|
generated in the past, measured against the resolved "today".
|
||||||
|
"""
|
||||||
|
if due_date < today_iso:
|
||||||
|
raise ValueError(
|
||||||
|
f"instances are never generated in the past: due_date "
|
||||||
|
f"{due_date!r} is before today {today_iso!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_utc_timestamp(value: str, field: str) -> None:
|
def _validate_utc_timestamp(value: str, field: str) -> None:
|
||||||
"""Raise ValueError unless ``value`` is a strict UTC ISO-8601 stamp.
|
"""Raise ValueError unless ``value`` is a strict UTC ISO-8601 stamp.
|
||||||
|
|
||||||
@@ -90,6 +130,7 @@ class QuestInstanceRecord:
|
|||||||
id: int
|
id: int
|
||||||
definition_id: int
|
definition_id: int
|
||||||
child_id: int
|
child_id: int
|
||||||
|
window: str
|
||||||
due_date: str
|
due_date: str
|
||||||
due_time: str | None
|
due_time: str | None
|
||||||
generated_at: str
|
generated_at: str
|
||||||
@@ -105,16 +146,17 @@ class CompletionEventRecord:
|
|||||||
event_type: str
|
event_type: str
|
||||||
actor_source: str
|
actor_source: str
|
||||||
actor_user_id: str | None
|
actor_user_id: str | None
|
||||||
|
actor_child_id: int | None
|
||||||
occurred_at: str
|
occurred_at: str
|
||||||
was_on_time: bool | None
|
was_on_time: bool | None
|
||||||
|
|
||||||
|
|
||||||
_INSTANCE_COLUMNS = (
|
_INSTANCE_COLUMNS = (
|
||||||
"id, definition_id, child_id, due_date, due_time, generated_at"
|
"id, definition_id, child_id, window, due_date, due_time, generated_at"
|
||||||
)
|
)
|
||||||
_EVENT_COLUMNS = (
|
_EVENT_COLUMNS = (
|
||||||
"id, instance_id, child_id, event_type, actor_source, actor_user_id, "
|
"id, instance_id, child_id, event_type, actor_source, actor_user_id, "
|
||||||
"occurred_at, was_on_time"
|
"actor_child_id, occurred_at, was_on_time"
|
||||||
)
|
)
|
||||||
|
|
||||||
#: The only event types the append path accepts; the schema CHECK is
|
#: The only event types the append path accepts; the schema CHECK is
|
||||||
@@ -128,9 +170,10 @@ def _instance_from_row(row: tuple) -> QuestInstanceRecord:
|
|||||||
id=row[0],
|
id=row[0],
|
||||||
definition_id=row[1],
|
definition_id=row[1],
|
||||||
child_id=row[2],
|
child_id=row[2],
|
||||||
due_date=row[3],
|
window=row[3],
|
||||||
due_time=row[4],
|
due_date=row[4],
|
||||||
generated_at=row[5],
|
due_time=row[5],
|
||||||
|
generated_at=row[6],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -142,8 +185,9 @@ def _event_from_row(row: tuple) -> CompletionEventRecord:
|
|||||||
event_type=row[3],
|
event_type=row[3],
|
||||||
actor_source=row[4],
|
actor_source=row[4],
|
||||||
actor_user_id=row[5],
|
actor_user_id=row[5],
|
||||||
occurred_at=row[6],
|
actor_child_id=row[6],
|
||||||
was_on_time=None if row[7] is None else bool(row[7]),
|
occurred_at=row[7],
|
||||||
|
was_on_time=None if row[8] is None else bool(row[8]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -160,54 +204,64 @@ class QuestInstancesDao:
|
|||||||
due_date: str,
|
due_date: str,
|
||||||
generated_at: str,
|
generated_at: str,
|
||||||
*,
|
*,
|
||||||
|
window: str,
|
||||||
due_time: str | None = None,
|
due_time: str | None = None,
|
||||||
|
today: datetime.date | None = None,
|
||||||
) -> QuestInstanceRecord:
|
) -> QuestInstanceRecord:
|
||||||
"""Create the dated instance for (definition_id, due_date) idempotently.
|
"""Create the dated instance for the widened key idempotently.
|
||||||
|
|
||||||
Running materialization twice for the same definition/date
|
The instance key is (definition_id, child_id, due_date, window)
|
||||||
yields ONE row: the ON CONFLICT path refreshes the
|
(D-008): running materialization twice for the same tuple
|
||||||
generation-time snapshot columns (child_id, due_time) and
|
yields ONE row — the ON CONFLICT path refreshes the
|
||||||
regenerates the stamp, but can never create a duplicate — the
|
generation-time snapshot columns (due_time) and regenerates the
|
||||||
schema UNIQUE(definition_id, due_date) backs this up.
|
stamp, but can never create a duplicate. The schema's
|
||||||
|
UNIQUE(definition_id, child_id, due_date, window) backs this
|
||||||
|
up, so the twice-daily case (two windows, one child, one date)
|
||||||
|
yields two rows and the shared case (three assignees, one
|
||||||
|
window) yields three.
|
||||||
|
|
||||||
Guardrails enforced here:
|
Guardrails enforced here:
|
||||||
|
|
||||||
- ``due_date`` must be TODAY or later: instances are never
|
- ``due_date`` must be TODAY or later: instances are never
|
||||||
generated in the past (Feature 07). The caller's
|
generated in the past (Feature 07). The caller's
|
||||||
materialization horizon is future-dated by definition.
|
materialization horizon is future-dated by definition.
|
||||||
|
``today`` optionally pins the resolved HA-local date the
|
||||||
|
guard compares against (falling back to the host clock when
|
||||||
|
omitted) so a household time zone behind the host around
|
||||||
|
midnight is not misread as "past".
|
||||||
|
- ``window`` must be one of ``const.QUEST_WINDOWS``.
|
||||||
- An existing instance WITH a completion event is IMMUTABLE:
|
- An existing instance WITH a completion event is IMMUTABLE:
|
||||||
the conflict path must not rewrite its snapshot columns
|
the conflict path must not rewrite its snapshot columns
|
||||||
(Feature 07/08). The upsert refuses with ValueError in that
|
(Feature 07/08). The upsert refuses with ValueError in that
|
||||||
case — silently no-oping would mask a materialization bug.
|
case — silently no-oping would mask a materialization bug.
|
||||||
|
- The child must be an assignee of the definition (D-008
|
||||||
|
multi-assignee model).
|
||||||
|
|
||||||
The definition must exist (validated atomically under the
|
The definition must exist (validated atomically under the
|
||||||
connection lock so a concurrent definition deactivation or
|
connection lock so a concurrent definition deactivation or
|
||||||
child deletion cannot slip between check and insert).
|
child deletion cannot slip between check and insert).
|
||||||
"""
|
"""
|
||||||
_validate_date(due_date, "due_date")
|
_validate_date(due_date, "due_date")
|
||||||
|
_validate_window(window)
|
||||||
# Fail fast on obviously-past dates BEFORE queuing on the
|
# Fail fast on obviously-past dates BEFORE queuing on the
|
||||||
# connection lock (a nice early error); the AUTHORITATIVE
|
# connection lock (a nice early error); the AUTHORITATIVE
|
||||||
# check runs after the lock is acquired, because a caller can
|
# check runs after the lock is acquired, because a caller can
|
||||||
# wait on the lock across midnight — a date that was "today"
|
# wait on the lock across midnight — a date that was "today"
|
||||||
# at call time may be "yesterday" by the time the INSERT runs.
|
# at call time may be "yesterday" by the time the INSERT runs.
|
||||||
today = datetime.date.today().isoformat()
|
# A caller-supplied ``today`` (HA-local) is used for BOTH
|
||||||
if due_date < today:
|
# checks and never re-read, since it is already the anchor the
|
||||||
raise ValueError(
|
# batch runs against.
|
||||||
f"instances are never generated in the past: due_date "
|
today_date = _resolve_today(today)
|
||||||
f"{due_date!r} is before today {today!r}"
|
_reject_past(due_date, today_date.isoformat())
|
||||||
)
|
|
||||||
async with _connection_lock(self._database):
|
async with _connection_lock(self._database):
|
||||||
async with self._database.transaction():
|
async with self._database.transaction():
|
||||||
# Re-read "today" under the lock: this is the date the
|
# Re-read "today" under the lock: this is the date the
|
||||||
# insert actually executes on, so the no-past rule
|
# insert actually executes on, so the no-past rule
|
||||||
# holds across midnight rollovers.
|
# holds across midnight rollovers. A pinned HA-local
|
||||||
today = datetime.date.today().isoformat()
|
# anchor is exempt from the re-read (see above).
|
||||||
if due_date < today:
|
if today is None:
|
||||||
raise ValueError(
|
today_date = _today()
|
||||||
f"instances are never generated in the past: "
|
_reject_past(due_date, today_date.isoformat())
|
||||||
f"due_date {due_date!r} is before today "
|
|
||||||
f"{today!r}"
|
|
||||||
)
|
|
||||||
definition = await self._database.fetch_one(
|
definition = await self._database.fetch_one(
|
||||||
"SELECT 1 FROM quest_definitions WHERE id = ?",
|
"SELECT 1 FROM quest_definitions WHERE id = ?",
|
||||||
(definition_id,),
|
(definition_id,),
|
||||||
@@ -227,51 +281,173 @@ class QuestInstancesDao:
|
|||||||
f"assigned to child {child_id}; instance must "
|
f"assigned to child {child_id}; instance must "
|
||||||
"carry an assigned child"
|
"carry an assigned child"
|
||||||
)
|
)
|
||||||
child = await self._database.fetch_one(
|
|
||||||
"SELECT 1 FROM children WHERE id = ?", (child_id,)
|
|
||||||
)
|
|
||||||
if child is None:
|
|
||||||
raise ValueError(f"child {child_id} does not exist")
|
|
||||||
existing = await self._database.fetch_one(
|
existing = await self._database.fetch_one(
|
||||||
"SELECT 1 FROM completion_events "
|
"SELECT 1 FROM completion_events "
|
||||||
"WHERE instance_id = (SELECT id FROM quest_instances "
|
"WHERE instance_id = (SELECT id FROM quest_instances "
|
||||||
"WHERE definition_id = ? AND due_date = ?)",
|
"WHERE definition_id = ? AND child_id = ? "
|
||||||
(definition_id, due_date),
|
"AND due_date = ? AND window = ?)",
|
||||||
|
(definition_id, child_id, due_date, window),
|
||||||
)
|
)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"instance for (definition {definition_id}, "
|
f"instance for (definition {definition_id}, "
|
||||||
f"due_date {due_date!r}) already has a completion "
|
f"child {child_id}, due_date {due_date!r}, "
|
||||||
|
f"window {window!r}) already has a completion "
|
||||||
"event and is immutable; it cannot be regenerated"
|
"event and is immutable; it cannot be regenerated"
|
||||||
)
|
)
|
||||||
await self._database.execute(
|
await self._database.execute(
|
||||||
"INSERT INTO quest_instances (definition_id, child_id, "
|
"INSERT INTO quest_instances (definition_id, child_id, "
|
||||||
"due_date, due_time, generated_at) "
|
"window, due_date, due_time, generated_at) "
|
||||||
"VALUES (?, ?, ?, ?, ?) "
|
"VALUES (?, ?, ?, ?, ?, ?) "
|
||||||
"ON CONFLICT (definition_id, due_date) DO UPDATE SET "
|
"ON CONFLICT (definition_id, child_id, due_date, "
|
||||||
"child_id = excluded.child_id, "
|
"window) DO UPDATE SET "
|
||||||
"due_time = excluded.due_time, "
|
"due_time = excluded.due_time, "
|
||||||
"generated_at = excluded.generated_at",
|
"generated_at = excluded.generated_at",
|
||||||
(
|
(
|
||||||
definition_id,
|
definition_id,
|
||||||
child_id,
|
child_id,
|
||||||
|
window,
|
||||||
due_date,
|
due_date,
|
||||||
due_time,
|
due_time,
|
||||||
generated_at,
|
generated_at,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
instance = await self.get(definition_id, due_date)
|
instance = await self.get(
|
||||||
|
definition_id, child_id, due_date, window
|
||||||
|
)
|
||||||
|
assert instance is not None
|
||||||
|
return instance
|
||||||
|
|
||||||
|
async def upsert_if_valid(
|
||||||
|
self,
|
||||||
|
definition_id: int,
|
||||||
|
child_id: int,
|
||||||
|
due_date: str,
|
||||||
|
generated_at: str,
|
||||||
|
*,
|
||||||
|
window: str,
|
||||||
|
due_time: str | None = None,
|
||||||
|
today: datetime.date | None = None,
|
||||||
|
) -> QuestInstanceRecord | None:
|
||||||
|
"""Insert one dated instance iff its tuple is still materializable.
|
||||||
|
|
||||||
|
This is the materialization walk's single write path. Unlike the
|
||||||
|
generic :meth:`upsert`, which validates only existence and
|
||||||
|
assignment, this method re-validates EVERY materialization
|
||||||
|
precondition in the SAME connection-lock-held transaction as the
|
||||||
|
INSERT — closing the check-then-insert gap a concurrent config
|
||||||
|
write could otherwise slip through:
|
||||||
|
|
||||||
|
- the definition exists AND is active (a definition deactivated
|
||||||
|
after the input snapshot is skipped, never written);
|
||||||
|
- the child exists AND is active (a deactivated child is skipped);
|
||||||
|
- the (definition, child) assignment link still exists (a removed
|
||||||
|
assignment is skipped);
|
||||||
|
- the window is still declared on the definition (a removed window
|
||||||
|
is skipped);
|
||||||
|
- the window's live ``due_time`` still EQUALS the caller-supplied
|
||||||
|
``due_time`` (NULL-safe: NULL == NULL counts as equal; a window
|
||||||
|
with no due time matches a snapshot with no due time). A
|
||||||
|
window-time edit landed after the input snapshot makes the
|
||||||
|
caller's ``due_time`` stale, and writing it would overwrite the
|
||||||
|
newer due_time a concurrent regeneration already upserted — so
|
||||||
|
the tuple is skipped instead of written.
|
||||||
|
|
||||||
|
The due_time check closes the one check-then-insert gap this
|
||||||
|
method previously left open: it re-validated every OTHER
|
||||||
|
precondition but then wrote the snapshot's ``due_time``
|
||||||
|
unconditionally, so a stale materialization could clobber a
|
||||||
|
newer, concurrently-regenerated due_time.
|
||||||
|
|
||||||
|
Returns the stored instance, or ``None`` when a precondition
|
||||||
|
failed — the tuple no longer materializable, which the walk SKIPS
|
||||||
|
rather than raising, so the rest of the batch is unaffected. The
|
||||||
|
no-past rule (instances are never generated in the past) is
|
||||||
|
preserved from :meth:`upsert` and still raises ValueError; ``today``
|
||||||
|
optionally pins the HA-local anchor it compares against. An
|
||||||
|
instance that ALREADY has a completion event is also skipped
|
||||||
|
(returns ``None``) rather than raised: it is immutable, so the
|
||||||
|
walk leaves it untouched instead of failing the batch — the
|
||||||
|
generic :meth:`upsert` keeps its hard refusal for the
|
||||||
|
delete/regenerate path.
|
||||||
|
"""
|
||||||
|
_validate_date(due_date, "due_date")
|
||||||
|
_validate_window(window)
|
||||||
|
# Fail fast on obviously-past dates before queuing on the lock;
|
||||||
|
# the authoritative check re-runs under the lock (see upsert).
|
||||||
|
today_date = _resolve_today(today)
|
||||||
|
_reject_past(due_date, today_date.isoformat())
|
||||||
|
async with _connection_lock(self._database):
|
||||||
|
async with self._database.transaction():
|
||||||
|
if today is None:
|
||||||
|
today_date = _today()
|
||||||
|
_reject_past(due_date, today_date.isoformat())
|
||||||
|
still_valid = await self._database.fetch_one(
|
||||||
|
"SELECT 1 FROM quest_definitions d "
|
||||||
|
"JOIN quest_definition_assignees a "
|
||||||
|
"ON a.definition_id = d.id "
|
||||||
|
"JOIN children c ON c.id = a.child_id "
|
||||||
|
"JOIN quest_definition_windows w "
|
||||||
|
"ON w.definition_id = d.id "
|
||||||
|
"WHERE d.id = ? AND a.child_id = ? AND w.window = ? "
|
||||||
|
"AND d.is_active = 1 AND c.is_active = 1 "
|
||||||
|
"AND w.due_time IS ? LIMIT 1",
|
||||||
|
(definition_id, child_id, window, due_time),
|
||||||
|
)
|
||||||
|
if still_valid is None:
|
||||||
|
# A config change invalidated the tuple since the
|
||||||
|
# snapshot: skip it rather than abort the batch.
|
||||||
|
return None
|
||||||
|
existing = await self._database.fetch_one(
|
||||||
|
"SELECT 1 FROM completion_events "
|
||||||
|
"WHERE instance_id = (SELECT id FROM quest_instances "
|
||||||
|
"WHERE definition_id = ? AND child_id = ? "
|
||||||
|
"AND due_date = ? AND window = ?)",
|
||||||
|
(definition_id, child_id, due_date, window),
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
# The instance is already completed and therefore
|
||||||
|
# immutable: the walk SKIPS it (no-op) rather than
|
||||||
|
# rewriting its snapshot columns or failing the
|
||||||
|
# batch. This is the materialization walk's skip,
|
||||||
|
# distinct from the generic :meth:`upsert` refusal.
|
||||||
|
return None
|
||||||
|
await self._database.execute(
|
||||||
|
"INSERT INTO quest_instances (definition_id, child_id, "
|
||||||
|
"window, due_date, due_time, generated_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?) "
|
||||||
|
"ON CONFLICT (definition_id, child_id, due_date, "
|
||||||
|
"window) DO UPDATE SET "
|
||||||
|
"due_time = excluded.due_time, "
|
||||||
|
"generated_at = excluded.generated_at",
|
||||||
|
(
|
||||||
|
definition_id,
|
||||||
|
child_id,
|
||||||
|
window,
|
||||||
|
due_date,
|
||||||
|
due_time,
|
||||||
|
generated_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
instance = await self.get(
|
||||||
|
definition_id, child_id, due_date, window
|
||||||
|
)
|
||||||
assert instance is not None
|
assert instance is not None
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
async def get(
|
async def get(
|
||||||
self, definition_id: int, due_date: str
|
self,
|
||||||
|
definition_id: int,
|
||||||
|
child_id: int,
|
||||||
|
due_date: str,
|
||||||
|
window: str,
|
||||||
) -> QuestInstanceRecord | None:
|
) -> QuestInstanceRecord | None:
|
||||||
"""Return the instance for (definition_id, due_date), or None."""
|
"""Return the instance for the widened key tuple, or None."""
|
||||||
row = await self._database.fetch_one(
|
row = await self._database.fetch_one(
|
||||||
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
|
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
|
||||||
"WHERE definition_id = ? AND due_date = ?",
|
"WHERE definition_id = ? AND child_id = ? AND due_date = ? "
|
||||||
(definition_id, due_date),
|
"AND window = ?",
|
||||||
|
(definition_id, child_id, due_date, window),
|
||||||
)
|
)
|
||||||
return _instance_from_row(row) if row is not None else None
|
return _instance_from_row(row) if row is not None else None
|
||||||
|
|
||||||
@@ -317,8 +493,45 @@ class QuestInstancesDao:
|
|||||||
)
|
)
|
||||||
return [_instance_from_row(row) for row in rows]
|
return [_instance_from_row(row) for row in rows]
|
||||||
|
|
||||||
|
async def list_missed_unswept(
|
||||||
|
self, today_iso: str, watermark: str | None
|
||||||
|
) -> list[QuestInstanceRecord]:
|
||||||
|
"""Return past-due open instances the sweep has not announced.
|
||||||
|
|
||||||
|
Feature 11's missed-sweep query: ``due_date`` strictly before
|
||||||
|
``today_iso`` (past due), at or after ``watermark`` when one is
|
||||||
|
recorded, and with NO completion event ever (the Feature 08
|
||||||
|
append-only contract: an instance that was ever completed is
|
||||||
|
never reported missed, even when later reversed — history keeps
|
||||||
|
both rows and the sweep never mutates any). The watermark is
|
||||||
|
the HA-local date the sweep last ran, and the window is
|
||||||
|
``[watermark, today)``: the nightly run entering day D+1 sweeps
|
||||||
|
exactly day D's instances, so every day's quests are announced
|
||||||
|
once — at the midnight after their due date — and a same-night
|
||||||
|
rerun (watermark == today) sees an empty window. Read-only;
|
||||||
|
ordered deterministically by (due_date, id).
|
||||||
|
"""
|
||||||
|
_validate_date(today_iso, "today_iso")
|
||||||
|
if watermark is not None:
|
||||||
|
_validate_date(watermark, "watermark")
|
||||||
|
sql = (
|
||||||
|
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
|
||||||
|
"WHERE due_date < ? "
|
||||||
|
"AND NOT EXISTS ("
|
||||||
|
" SELECT 1 FROM completion_events "
|
||||||
|
" WHERE instance_id = quest_instances.id"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
parameters: tuple = (today_iso,)
|
||||||
|
if watermark is not None:
|
||||||
|
sql += " AND due_date >= ?"
|
||||||
|
parameters += (watermark,)
|
||||||
|
sql += " ORDER BY due_date, id"
|
||||||
|
rows = await self._database.fetch_all(sql, parameters)
|
||||||
|
return [_instance_from_row(row) for row in rows]
|
||||||
|
|
||||||
async def delete_future_uncompleted(
|
async def delete_future_uncompleted(
|
||||||
self, definition_id: int, cutoff_date: str
|
self, definition_id: int, cutoff_date: str, *, today: datetime.date | None = None
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Delete the definition's open instances at/after cutoff.
|
"""Delete the definition's open instances at/after cutoff.
|
||||||
|
|
||||||
@@ -330,9 +543,11 @@ class QuestInstancesDao:
|
|||||||
is measured against TODAY (materialization regenerates the
|
is measured against TODAY (materialization regenerates the
|
||||||
rolling horizon from now), so a caller cannot use this to
|
rolling horizon from now), so a caller cannot use this to
|
||||||
rewrite past open instances either — the day-rollover missed
|
rewrite past open instances either — the day-rollover missed
|
||||||
sweep (Feature 11) handles past-due instances instead. Any
|
sweep (Feature 11) handles past-due instances instead. ``today``
|
||||||
instance with a completion event is never touched, regardless
|
optionally pins the HA-local anchor the clamp compares against
|
||||||
of its date. Returns the number deleted.
|
(falling back to the host clock when omitted). Any instance with
|
||||||
|
a completion event is never touched, regardless of its date.
|
||||||
|
Returns the number deleted.
|
||||||
"""
|
"""
|
||||||
_validate_date(cutoff_date, "cutoff_date")
|
_validate_date(cutoff_date, "cutoff_date")
|
||||||
async with _connection_lock(self._database):
|
async with _connection_lock(self._database):
|
||||||
@@ -342,8 +557,8 @@ class QuestInstancesDao:
|
|||||||
# midnight cannot delete instances that became past
|
# midnight cannot delete instances that became past
|
||||||
# while it waited (backdating is clamped to the
|
# while it waited (backdating is clamped to the
|
||||||
# execution date, not the call date).
|
# execution date, not the call date).
|
||||||
today = datetime.date.today().isoformat()
|
today_date = _resolve_today(today)
|
||||||
effective_cutoff = max(cutoff_date, today)
|
effective_cutoff = max(cutoff_date, today_date.isoformat())
|
||||||
result = await self._database.execute(
|
result = await self._database.execute(
|
||||||
"DELETE FROM quest_instances WHERE definition_id = ? "
|
"DELETE FROM quest_instances WHERE definition_id = ? "
|
||||||
"AND due_date >= ? AND NOT EXISTS ("
|
"AND due_date >= ? AND NOT EXISTS ("
|
||||||
@@ -354,6 +569,51 @@ class QuestInstancesDao:
|
|||||||
)
|
)
|
||||||
return result.rowcount
|
return result.rowcount
|
||||||
|
|
||||||
|
async def delete_future_uncompleted_for_child(
|
||||||
|
self, child_id: int, cutoff_date: str, *, today: datetime.date | None = None
|
||||||
|
) -> tuple[int, str]:
|
||||||
|
"""Delete the child's open instances at/after cutoff, across every
|
||||||
|
definition, returning ``(deleted, effective_cutoff)``.
|
||||||
|
|
||||||
|
Child-scoped counterpart to :meth:`delete_future_uncompleted`,
|
||||||
|
used when a child's presence changes (Feature 09): only instances
|
||||||
|
at or after the cutoff with NO completion event are removed, inside
|
||||||
|
one transaction — an instance that gains a completion event
|
||||||
|
mid-flight cannot be deleted. The cutoff cannot be backdated below
|
||||||
|
today, for the same reason as the definition-scoped method (see its
|
||||||
|
docstring). ``today`` optionally pins the HA-local anchor the clamp
|
||||||
|
compares against (falling back to the host clock when omitted).
|
||||||
|
Any instance with a completion event is never touched, regardless
|
||||||
|
of its date.
|
||||||
|
|
||||||
|
Returns the number deleted and the effective cutoff that was
|
||||||
|
actually applied (``max(cutoff_date, today)``, read under the
|
||||||
|
lock). The caller (:func:`~.materialize.regenerate_for_child`)
|
||||||
|
passes that effective cutoff back as its re-materialization anchor,
|
||||||
|
so the delete and the rebuild share ONE execution-day even when the
|
||||||
|
call is queued across midnight — instead of the caller re-reading a
|
||||||
|
stale "today" and materializing a window the delete already
|
||||||
|
superseded.
|
||||||
|
"""
|
||||||
|
_validate_date(cutoff_date, "cutoff_date")
|
||||||
|
async with _connection_lock(self._database):
|
||||||
|
async with self._database.transaction():
|
||||||
|
# "today" is read under the lock, matching the
|
||||||
|
# definition-scoped method: the DELETE executes on this
|
||||||
|
# date, so a caller queued across midnight cannot delete
|
||||||
|
# instances that became past while it waited.
|
||||||
|
today_date = _resolve_today(today)
|
||||||
|
effective_cutoff = max(cutoff_date, today_date.isoformat())
|
||||||
|
result = await self._database.execute(
|
||||||
|
"DELETE FROM quest_instances WHERE child_id = ? "
|
||||||
|
"AND due_date >= ? AND NOT EXISTS ("
|
||||||
|
" SELECT 1 FROM completion_events "
|
||||||
|
" WHERE instance_id = quest_instances.id"
|
||||||
|
")",
|
||||||
|
(child_id, effective_cutoff),
|
||||||
|
)
|
||||||
|
return result.rowcount, effective_cutoff
|
||||||
|
|
||||||
|
|
||||||
class CompletionEventsDao:
|
class CompletionEventsDao:
|
||||||
"""Typed append-only access to the ``completion_events`` table.
|
"""Typed append-only access to the ``completion_events`` table.
|
||||||
@@ -380,19 +640,21 @@ class CompletionEventsDao:
|
|||||||
was_on_time: bool | None,
|
was_on_time: bool | None,
|
||||||
*,
|
*,
|
||||||
actor_user_id: str | None = None,
|
actor_user_id: str | None = None,
|
||||||
|
actor_child_id: int | None = None,
|
||||||
) -> CompletionEventRecord:
|
) -> CompletionEventRecord:
|
||||||
"""Append one completion or un-completion event.
|
"""Append one completion or un-completion event.
|
||||||
|
|
||||||
``actor_source`` is 'user' (with ``actor_user_id`` set) or
|
``actor_source`` is 'user' (an admin action: ``actor_user_id``
|
||||||
'panel' (no user id — the tapped child is ``child_id``); the
|
set, ``actor_child_id`` NULL) or 'panel' (a panel tap: no user
|
||||||
actor-pair CHECK enforces this shape at the schema level.
|
id, and ``actor_child_id`` carrying the tapped child profile,
|
||||||
``occurred_at`` must be the module's one strict UTC shape
|
D-008); the actor-pair CHECKs enforce this shape at the schema
|
||||||
(YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range queries
|
level. ``occurred_at`` must be the module's one strict UTC
|
||||||
stay exact. ``was_on_time`` is required (True/False) for
|
shape (YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range
|
||||||
completions; for un-completions it may be None or the reversed
|
queries stay exact. ``was_on_time`` is required (True/False)
|
||||||
completion's flag (Feature 08 still deciding). The instance
|
for completions; for un-completions it may be None or the
|
||||||
and child must exist — validated atomically under the
|
reversed completion's flag (Feature 08 still deciding). The
|
||||||
connection lock.
|
instance, child and — for panel events — the tapped child must
|
||||||
|
exist — validated atomically under the connection lock.
|
||||||
"""
|
"""
|
||||||
if event_type not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
|
if event_type not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -412,6 +674,15 @@ class CompletionEventsDao:
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
"actor_source 'panel' must not carry actor_user_id"
|
"actor_source 'panel' must not carry actor_user_id"
|
||||||
)
|
)
|
||||||
|
if actor_source == "panel" and actor_child_id is None:
|
||||||
|
raise ValueError(
|
||||||
|
"actor_source 'panel' requires actor_child_id "
|
||||||
|
"(the tapped child profile)"
|
||||||
|
)
|
||||||
|
if actor_source == "user" and actor_child_id is not None:
|
||||||
|
raise ValueError(
|
||||||
|
"actor_source 'user' must not carry actor_child_id"
|
||||||
|
)
|
||||||
if was_on_time is not None and type(was_on_time) is not bool:
|
if was_on_time is not None and type(was_on_time) is not bool:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"was_on_time must be True, False or None, got "
|
f"was_on_time must be True, False or None, got "
|
||||||
@@ -442,16 +713,28 @@ class CompletionEventsDao:
|
|||||||
)
|
)
|
||||||
if child is None:
|
if child is None:
|
||||||
raise ValueError(f"child {child_id} does not exist")
|
raise ValueError(f"child {child_id} does not exist")
|
||||||
|
if actor_child_id is not None:
|
||||||
|
tapped = await self._database.fetch_one(
|
||||||
|
"SELECT 1 FROM children WHERE id = ?",
|
||||||
|
(actor_child_id,),
|
||||||
|
)
|
||||||
|
if tapped is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"actor_child_id {actor_child_id} does not "
|
||||||
|
"reference an existing child"
|
||||||
|
)
|
||||||
result = await self._database.execute(
|
result = await self._database.execute(
|
||||||
"INSERT INTO completion_events (instance_id, child_id, "
|
"INSERT INTO completion_events (instance_id, child_id, "
|
||||||
"event_type, actor_source, actor_user_id, occurred_at, "
|
"event_type, actor_source, actor_user_id, "
|
||||||
"was_on_time) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
"actor_child_id, occurred_at, was_on_time) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
(
|
(
|
||||||
instance_id,
|
instance_id,
|
||||||
child_id,
|
child_id,
|
||||||
event_type,
|
event_type,
|
||||||
actor_source,
|
actor_source,
|
||||||
actor_user_id,
|
actor_user_id,
|
||||||
|
actor_child_id,
|
||||||
occurred_at,
|
occurred_at,
|
||||||
None if was_on_time is None else int(was_on_time),
|
None if was_on_time is None else int(was_on_time),
|
||||||
),
|
),
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Typed DAO for the integration-owned ``nestquest_meta_state`` table.
|
||||||
|
|
||||||
|
Generic persistent key/value bookkeeping that belongs to the
|
||||||
|
integration itself rather than any domain table — currently the
|
||||||
|
Feature 11 missed-sweep watermark. Values are plain strings; callers
|
||||||
|
interpret them. All SQL for this table lives here per the feature
|
||||||
|
guardrails, and every method runs through
|
||||||
|
:class:`~.db.NestQuestDatabase` so nothing blocks the event loop.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .dao_children import _connection_lock
|
||||||
|
from .db import NestQuestDatabase
|
||||||
|
|
||||||
|
_TABLE = "nestquest_meta_state"
|
||||||
|
|
||||||
|
|
||||||
|
class MetaStateDao:
|
||||||
|
"""Typed async access to the ``nestquest_meta_state`` table."""
|
||||||
|
|
||||||
|
def __init__(self, database: NestQuestDatabase) -> None:
|
||||||
|
self._database = database
|
||||||
|
|
||||||
|
async def get(self, key: str) -> str | None:
|
||||||
|
"""Return the stored value for ``key``, or None when absent."""
|
||||||
|
row = await self._database.fetch_one(
|
||||||
|
f"SELECT value FROM {_TABLE} WHERE key = ?", (key,)
|
||||||
|
)
|
||||||
|
return row[0] if row is not None else None
|
||||||
|
|
||||||
|
async def set(self, key: str, value: str) -> None:
|
||||||
|
"""Upsert one key/value row atomically.
|
||||||
|
|
||||||
|
The write runs inside one transaction under the shared
|
||||||
|
connection lock, so a racing reader either sees the old value
|
||||||
|
or the fully-written new one, never a half-applied state.
|
||||||
|
"""
|
||||||
|
async with _connection_lock(self._database):
|
||||||
|
async with self._database.transaction():
|
||||||
|
await self._database.execute(
|
||||||
|
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT (key) DO UPDATE SET value = excluded.value",
|
||||||
|
(key, value),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def claim(self, key: str, value: str) -> bool:
|
||||||
|
"""Atomically set ``key`` to ``value`` unless it already is.
|
||||||
|
|
||||||
|
The single upsert statement carries a ``WHERE`` guard on the
|
||||||
|
conflict path (``t.value IS NOT excluded.value``), so the
|
||||||
|
read-compare-write is ONE SQLite statement — atomic at the
|
||||||
|
DATABASE level, not just within one process: two connections
|
||||||
|
(two processes sharing one SQLite file) cannot both observe
|
||||||
|
"not yet set" and both write, because the second writer's
|
||||||
|
statement re-evaluates the guard against the first writer's
|
||||||
|
committed value and matches zero rows.
|
||||||
|
|
||||||
|
Returns True only when THIS call changed the row: the first
|
||||||
|
claimant wins and every later claimant of the same value
|
||||||
|
returns False. A DIFFERENT value always wins (an advance, e.g.
|
||||||
|
the sweep watermark moving to a new date) and re-arms the
|
||||||
|
same-value loser check for the new value.
|
||||||
|
|
||||||
|
Runs under the shared connection lock and inside one
|
||||||
|
transaction, like :meth:`set`; ``cursor.rowcount`` (1 for a
|
||||||
|
matched write, 0 for a guarded no-op) is captured by the
|
||||||
|
wrapper on the executor thread, so no sqlite3 object crosses
|
||||||
|
back to the event loop.
|
||||||
|
"""
|
||||||
|
async with _connection_lock(self._database):
|
||||||
|
async with self._database.transaction():
|
||||||
|
result = await self._database.execute(
|
||||||
|
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT (key) DO UPDATE SET value = excluded.value "
|
||||||
|
f"WHERE {_TABLE}.value IS NOT excluded.value",
|
||||||
|
(key, value),
|
||||||
|
)
|
||||||
|
return result.rowcount == 1
|
||||||
+148
-1
@@ -186,6 +186,15 @@ class PresenceOverridesDao:
|
|||||||
The child must exist (validated atomically under the
|
The child must exist (validated atomically under the
|
||||||
connection lock). Date shape is validated strictly, and the
|
connection lock). Date shape is validated strictly, and the
|
||||||
schema CHECK rejects end < start.
|
schema CHECK rejects end < start.
|
||||||
|
|
||||||
|
Overlap policy (Feature 05): an override for a child may not
|
||||||
|
overlap another override of the SAME child — two different
|
||||||
|
present/absent answers for one date are unresolvable. The
|
||||||
|
overlap check (inclusive boundaries) runs inside the same
|
||||||
|
transaction as the INSERT, so a racing create cannot slip a
|
||||||
|
conflicting override between the check and the write; a
|
||||||
|
conflict raises ValueError naming the conflicting override's
|
||||||
|
id and range.
|
||||||
"""
|
"""
|
||||||
_validate_date(start_date, "start_date")
|
_validate_date(start_date, "start_date")
|
||||||
_validate_date(end_date, "end_date")
|
_validate_date(end_date, "end_date")
|
||||||
@@ -196,6 +205,29 @@ class PresenceOverridesDao:
|
|||||||
)
|
)
|
||||||
if child is None:
|
if child is None:
|
||||||
raise ValueError(f"child {child_id} does not exist")
|
raise ValueError(f"child {child_id} does not exist")
|
||||||
|
# Only a well-ordered range has overlap semantics:
|
||||||
|
# inverted ranges fall through to the INSERT and hit
|
||||||
|
# the schema's end>=start CHECK, preserving the
|
||||||
|
# IntegrityError contract the matrix tests rely on.
|
||||||
|
if end_date >= start_date:
|
||||||
|
conflicting = await self._database.fetch_one(
|
||||||
|
f"SELECT {_OVERRIDE_COLUMNS} "
|
||||||
|
"FROM presence_overrides "
|
||||||
|
"WHERE child_id = ? AND end_date >= ? "
|
||||||
|
"AND start_date <= ? ORDER BY id LIMIT 1",
|
||||||
|
(child_id, start_date, end_date),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conflicting = None
|
||||||
|
if conflicting is not None:
|
||||||
|
conflict = _override_from_row(conflicting)
|
||||||
|
raise ValueError(
|
||||||
|
f"override for child {child_id} "
|
||||||
|
f"[{start_date}, {end_date}] overlaps existing "
|
||||||
|
f"override {conflict.id} "
|
||||||
|
f"[{conflict.start_date}, {conflict.end_date}] "
|
||||||
|
f"(is_present={conflict.is_present})"
|
||||||
|
)
|
||||||
result = await self._database.execute(
|
result = await self._database.execute(
|
||||||
"INSERT INTO presence_overrides (child_id, "
|
"INSERT INTO presence_overrides (child_id, "
|
||||||
"start_date, end_date, is_present, note) "
|
"start_date, end_date, is_present, note) "
|
||||||
@@ -238,10 +270,125 @@ class PresenceOverridesDao:
|
|||||||
)
|
)
|
||||||
return [_override_from_row(row) for row in rows]
|
return [_override_from_row(row) for row in rows]
|
||||||
|
|
||||||
|
async def list_filtered(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
child_id: int | None = None,
|
||||||
|
start: str | None = None,
|
||||||
|
end: str | None = None,
|
||||||
|
) -> list[PresenceOverrideRecord]:
|
||||||
|
"""Return the household's overrides, optionally narrowed.
|
||||||
|
|
||||||
|
Every filter is optional and they combine: ``child_id`` keeps
|
||||||
|
that child's overrides only (an unknown child raises ValueError
|
||||||
|
rather than reading as "no overrides"); ``start`` keeps
|
||||||
|
overrides ending on or after it and ``end`` keeps overrides
|
||||||
|
starting on or before it, so both together select the overrides
|
||||||
|
overlapping the closed range ``[start, end]`` (the
|
||||||
|
:meth:`list_by_child_and_range` boundary rule). Dates are
|
||||||
|
strict YYYY-MM-DD and ``end`` must not precede ``start``.
|
||||||
|
Ordered stably by start date, then child id, then id.
|
||||||
|
"""
|
||||||
|
if start is not None:
|
||||||
|
_validate_date(start, "start")
|
||||||
|
if end is not None:
|
||||||
|
_validate_date(end, "end")
|
||||||
|
if start is not None and end is not None and end < start:
|
||||||
|
raise ValueError(
|
||||||
|
f"end must be on or after start, got {end!r} < {start!r}"
|
||||||
|
)
|
||||||
|
clauses: list[str] = []
|
||||||
|
params: list[object] = []
|
||||||
|
if child_id is not None:
|
||||||
|
child = await self._database.fetch_one(
|
||||||
|
"SELECT 1 FROM children WHERE id = ?", (child_id,)
|
||||||
|
)
|
||||||
|
if child is None:
|
||||||
|
raise ValueError(f"child {child_id} does not exist")
|
||||||
|
clauses.append("child_id = ?")
|
||||||
|
params.append(child_id)
|
||||||
|
if start is not None:
|
||||||
|
clauses.append("end_date >= ?")
|
||||||
|
params.append(start)
|
||||||
|
if end is not None:
|
||||||
|
clauses.append("start_date <= ?")
|
||||||
|
params.append(end)
|
||||||
|
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||||
|
rows = await self._database.fetch_all(
|
||||||
|
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides{where} "
|
||||||
|
"ORDER BY start_date, child_id, id",
|
||||||
|
tuple(params),
|
||||||
|
)
|
||||||
|
return [_override_from_row(row) for row in rows]
|
||||||
|
|
||||||
|
async def get(self, override_id: int) -> PresenceOverrideRecord | None:
|
||||||
|
"""Return one override by id, or None when it does not exist."""
|
||||||
|
row = await self._database.fetch_one(
|
||||||
|
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
|
||||||
|
"WHERE id = ?",
|
||||||
|
(override_id,),
|
||||||
|
)
|
||||||
|
return _override_from_row(row) if row is not None else None
|
||||||
|
|
||||||
async def delete(self, override_id: int) -> bool:
|
async def delete(self, override_id: int) -> bool:
|
||||||
"""Remove one override by id; True if a row was removed."""
|
"""Remove one override by id; True if a row was removed."""
|
||||||
result = await self._database.execute(
|
result = await self._database.execute(
|
||||||
"DELETE FROM presence_overrides WHERE id = ?",
|
"DELETE FROM presence_overrides WHERE id = ?",
|
||||||
(override_id,),
|
(override_id,),
|
||||||
)
|
)
|
||||||
return result.rowcount > 0
|
return result.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def read_snapshot_unlocked(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
child_ids: list[int],
|
||||||
|
range_start: str,
|
||||||
|
range_end: str,
|
||||||
|
) -> tuple[
|
||||||
|
dict[int, PresenceScheduleRecord],
|
||||||
|
dict[int, list[PresenceOverrideRecord]],
|
||||||
|
]:
|
||||||
|
"""Read schedules and overrides for ``child_ids`` WITHOUT locking.
|
||||||
|
|
||||||
|
MUST be called inside the connection lock and an open transaction, so
|
||||||
|
the schedules and overrides returned here stay coherent with every
|
||||||
|
other table the caller reads inside that same transaction (the
|
||||||
|
materialization walk's single input snapshot does exactly that). The
|
||||||
|
date range comes already validated by the caller.
|
||||||
|
|
||||||
|
Returns ``(schedules, overrides)``:
|
||||||
|
|
||||||
|
- ``schedules`` maps ``child_id`` to its :class:`PresenceScheduleRecord`
|
||||||
|
for every child that HAS a schedule row. A child absent from the
|
||||||
|
mapping has no schedule, i.e. is present every day (Feature 05).
|
||||||
|
- ``overrides`` maps ``child_id`` to that child's overrides overlapping
|
||||||
|
``[range_start, range_end]`` (inclusive boundaries), ordered by start
|
||||||
|
date; a child with no matching override is absent from the mapping.
|
||||||
|
|
||||||
|
``child_ids`` must name existing children (the caller derives them
|
||||||
|
from a trusted snapshot, within the same transaction).
|
||||||
|
"""
|
||||||
|
children = sorted(set(child_ids))
|
||||||
|
if not children:
|
||||||
|
return {}, {}
|
||||||
|
placeholders = ",".join("?" for _ in children)
|
||||||
|
schedule_rows = await database.fetch_all(
|
||||||
|
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
|
||||||
|
f"WHERE child_id IN ({placeholders})",
|
||||||
|
tuple(children),
|
||||||
|
)
|
||||||
|
override_rows = await database.fetch_all(
|
||||||
|
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
|
||||||
|
f"WHERE child_id IN ({placeholders}) AND end_date >= ? "
|
||||||
|
"AND start_date <= ? ORDER BY start_date",
|
||||||
|
tuple(children) + (range_start, range_end),
|
||||||
|
)
|
||||||
|
schedules = {
|
||||||
|
record.child_id: record
|
||||||
|
for record in map(_schedule_from_row, schedule_rows)
|
||||||
|
}
|
||||||
|
overrides: dict[int, list[PresenceOverrideRecord]] = {}
|
||||||
|
for row in override_rows:
|
||||||
|
record = _override_from_row(row)
|
||||||
|
overrides.setdefault(record.child_id, []).append(record)
|
||||||
|
return schedules, overrides
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,13 +3,19 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from collections.abc import AsyncIterator, Awaitable, Sequence
|
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
#: Executor contract: schedule a synchronous callable on a worker
|
||||||
|
#: thread/futures pool and await its result. Mirrors the shape of
|
||||||
|
#: ``hass.async_add_executor_job`` so the integration wires it in with a
|
||||||
|
#: bare method reference, but this package never imports Home Assistant.
|
||||||
|
Executor = Callable[..., Awaitable[T]]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -28,10 +34,11 @@ class ExecutionResult:
|
|||||||
class NestQuestDatabase:
|
class NestQuestDatabase:
|
||||||
"""Async wrapper around a single SQLite connection.
|
"""Async wrapper around a single SQLite connection.
|
||||||
|
|
||||||
Every sqlite3 call is delegated to the Home Assistant executor through
|
Every sqlite3 call is delegated to a worker through the ``executor``
|
||||||
``hass.async_add_executor_job`` so the event loop is never blocked, and
|
callable passed at construction (mirroring the contract of
|
||||||
only plain Python data (row tuples and :class:`ExecutionResult`) is
|
``hass.async_add_executor_job``) so the event loop is never blocked,
|
||||||
returned to the event loop.
|
and only plain Python data (row tuples and
|
||||||
|
:class:`ExecutionResult`) is returned to the event loop.
|
||||||
|
|
||||||
Every operation is serialized through one asyncio.Lock per wrapper. A
|
Every operation is serialized through one asyncio.Lock per wrapper. A
|
||||||
``transaction()`` holds that lock for its whole BEGIN..COMMIT span, so
|
``transaction()`` holds that lock for its whole BEGIN..COMMIT span, so
|
||||||
@@ -50,9 +57,16 @@ class NestQuestDatabase:
|
|||||||
while the executor thread is mid-job.
|
while the executor thread is mid-job.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant) -> None:
|
def __init__(self, executor: Callable[..., Awaitable[T]]) -> None:
|
||||||
"""Initialize the wrapper around a Home Assistant instance."""
|
"""Initialize the wrapper around an executor callable.
|
||||||
self._hass = hass
|
|
||||||
|
``executor`` schedules a synchronous callable on a worker and
|
||||||
|
returns an awaitable of its result — the contract of
|
||||||
|
``hass.async_add_executor_job``. The integration passes that
|
||||||
|
method directly; tests pass a helper backed by
|
||||||
|
:func:`asyncio.to_thread` or an inline runner.
|
||||||
|
"""
|
||||||
|
self._executor: Callable[..., Awaitable[Any]] = executor
|
||||||
self._conn: sqlite3.Connection | None = None
|
self._conn: sqlite3.Connection | None = None
|
||||||
self._rowcount: int = -1
|
self._rowcount: int = -1
|
||||||
self._lastrowid: int | None = None
|
self._lastrowid: int | None = None
|
||||||
@@ -139,7 +153,7 @@ class NestQuestDatabase:
|
|||||||
return conn
|
return conn
|
||||||
|
|
||||||
settled, cancelled = await self._await_settled_state(
|
settled, cancelled = await self._await_settled_state(
|
||||||
self._hass.async_add_executor_job(_open)
|
self._executor(_open)
|
||||||
)
|
)
|
||||||
if cancelled:
|
if cancelled:
|
||||||
if isinstance(settled, sqlite3.Connection):
|
if isinstance(settled, sqlite3.Connection):
|
||||||
@@ -148,7 +162,7 @@ class NestQuestDatabase:
|
|||||||
settled.close()
|
settled.close()
|
||||||
|
|
||||||
await self._await_settled(
|
await self._await_settled(
|
||||||
self._hass.async_add_executor_job(_close_abandoned)
|
self._executor(_close_abandoned)
|
||||||
)
|
)
|
||||||
raise asyncio.CancelledError()
|
raise asyncio.CancelledError()
|
||||||
self._conn = settled
|
self._conn = settled
|
||||||
@@ -166,7 +180,7 @@ class NestQuestDatabase:
|
|||||||
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
|
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
|
||||||
|
|
||||||
result = await self._await_settled(
|
result = await self._await_settled(
|
||||||
self._hass.async_add_executor_job(_execute)
|
self._executor(_execute)
|
||||||
)
|
)
|
||||||
self._rowcount = result.rowcount
|
self._rowcount = result.rowcount
|
||||||
self._lastrowid = result.lastrowid
|
self._lastrowid = result.lastrowid
|
||||||
@@ -184,7 +198,7 @@ class NestQuestDatabase:
|
|||||||
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
|
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
|
||||||
|
|
||||||
result = await self._await_settled(
|
result = await self._await_settled(
|
||||||
self._hass.async_add_executor_job(_execute_many)
|
self._executor(_execute_many)
|
||||||
)
|
)
|
||||||
self._rowcount = result.rowcount
|
self._rowcount = result.rowcount
|
||||||
self._lastrowid = result.lastrowid
|
self._lastrowid = result.lastrowid
|
||||||
@@ -202,7 +216,7 @@ class NestQuestDatabase:
|
|||||||
return cursor.fetchone(), cursor.rowcount, cursor.lastrowid
|
return cursor.fetchone(), cursor.rowcount, cursor.lastrowid
|
||||||
|
|
||||||
row, rowcount, lastrowid = await self._await_settled(
|
row, rowcount, lastrowid = await self._await_settled(
|
||||||
self._hass.async_add_executor_job(_fetch_one)
|
self._executor(_fetch_one)
|
||||||
)
|
)
|
||||||
self._rowcount = rowcount
|
self._rowcount = rowcount
|
||||||
self._lastrowid = lastrowid
|
self._lastrowid = lastrowid
|
||||||
@@ -220,7 +234,7 @@ class NestQuestDatabase:
|
|||||||
return cursor.fetchall(), cursor.rowcount, cursor.lastrowid
|
return cursor.fetchall(), cursor.rowcount, cursor.lastrowid
|
||||||
|
|
||||||
rows, rowcount, lastrowid = await self._await_settled(
|
rows, rowcount, lastrowid = await self._await_settled(
|
||||||
self._hass.async_add_executor_job(_fetch_all)
|
self._executor(_fetch_all)
|
||||||
)
|
)
|
||||||
self._rowcount = rowcount
|
self._rowcount = rowcount
|
||||||
self._lastrowid = lastrowid
|
self._lastrowid = lastrowid
|
||||||
@@ -261,7 +275,7 @@ class NestQuestDatabase:
|
|||||||
|
|
||||||
await self._settle_lifecycle(
|
await self._settle_lifecycle(
|
||||||
conn,
|
conn,
|
||||||
self._hass.async_add_executor_job(_begin),
|
self._executor(_begin),
|
||||||
"BEGIN did not settle cleanly and the follow-up "
|
"BEGIN did not settle cleanly and the follow-up "
|
||||||
"cleanup also raised; the transaction may need manual "
|
"cleanup also raised; the transaction may need manual "
|
||||||
"cleanup",
|
"cleanup",
|
||||||
@@ -275,7 +289,7 @@ class NestQuestDatabase:
|
|||||||
|
|
||||||
await self._settle_lifecycle(
|
await self._settle_lifecycle(
|
||||||
conn,
|
conn,
|
||||||
self._hass.async_add_executor_job(_rollback),
|
self._executor(_rollback),
|
||||||
"ROLLBACK failed and the follow-up cleanup also "
|
"ROLLBACK failed and the follow-up cleanup also "
|
||||||
"raised; the transaction may need manual cleanup",
|
"raised; the transaction may need manual cleanup",
|
||||||
)
|
)
|
||||||
@@ -286,7 +300,7 @@ class NestQuestDatabase:
|
|||||||
|
|
||||||
await self._settle_lifecycle(
|
await self._settle_lifecycle(
|
||||||
conn,
|
conn,
|
||||||
self._hass.async_add_executor_job(_commit),
|
self._executor(_commit),
|
||||||
"COMMIT failed and the follow-up cleanup also raised; "
|
"COMMIT failed and the follow-up cleanup also raised; "
|
||||||
"the transaction may need manual cleanup",
|
"the transaction may need manual cleanup",
|
||||||
)
|
)
|
||||||
@@ -337,7 +351,7 @@ class NestQuestDatabase:
|
|||||||
return conn.in_transaction
|
return conn.in_transaction
|
||||||
|
|
||||||
stray = await self._await_settled(
|
stray = await self._await_settled(
|
||||||
self._hass.async_add_executor_job(_cleanup)
|
self._executor(_cleanup)
|
||||||
)
|
)
|
||||||
if stray:
|
if stray:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -412,7 +426,7 @@ class NestQuestDatabase:
|
|||||||
def _close() -> None:
|
def _close() -> None:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
await self._await_settled(self._hass.async_add_executor_job(_close))
|
await self._await_settled(self._executor(_close))
|
||||||
|
|
||||||
def _require_conn(self) -> sqlite3.Connection:
|
def _require_conn(self) -> sqlite3.Connection:
|
||||||
if self._conn is None:
|
if self._conn is None:
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""Transition-event payload construction for the NestQuest service paths (Feature 10).
|
||||||
|
|
||||||
|
This module is PAYLOAD CONSTRUCTION ONLY: it builds and RETURNS the
|
||||||
|
transition events (event type + payload dict) and never touches the
|
||||||
|
Home Assistant event bus or config. The caller decides how to fire
|
||||||
|
them (the integration's re-export shim wires the returned events to
|
||||||
|
``hass.bus.async_fire``).
|
||||||
|
|
||||||
|
The four documented events (design/ENTITIES-AND-SERVICES.md §3, names
|
||||||
|
in :mod:`~.const`): ``nestquest_quest_completed`` and
|
||||||
|
``nestquest_quest_uncompleted`` fire from the completion service
|
||||||
|
handlers on an actual transition (a no-op re-complete or re-uncomplete
|
||||||
|
fires nothing), and ``nestquest_child_day_complete`` fires when a
|
||||||
|
completion clears the child's whole day (quests were owed and none
|
||||||
|
remain — a zero-quest day can never fire it, matching the all-done
|
||||||
|
binary sensor's rule). ``nestquest_quest_missed`` is the documented
|
||||||
|
contract Feature 11's nightly sweep fires; :func:`build_quest_missed_event`
|
||||||
|
builds it with exactly the payload the sweep hand-builds (§3), so the
|
||||||
|
API plane can publish the same shape without re-deriving it.
|
||||||
|
|
||||||
|
The completed-event and day-complete-event builders are split so the
|
||||||
|
shim can fire ``nestquest_quest_completed`` BEFORE it evaluates the
|
||||||
|
day-complete rule (the pre-extraction implementation fired the
|
||||||
|
completed event on the bus first, then ran the day-complete DB reads;
|
||||||
|
a failure in those reads or the ZoneInfo lookup must not suppress the
|
||||||
|
completed event). :func:`build_quest_completed_event` builds the
|
||||||
|
completed event from the instance and its child/definition only;
|
||||||
|
:func:`build_child_day_complete_event` runs the day-complete DB reads
|
||||||
|
and returns the day-complete event (or ``None``). The shim fires the
|
||||||
|
completed event, then calls the day-complete builder and fires its
|
||||||
|
result. :func:`build_quest_uncompleted_events` builds the single
|
||||||
|
uncompleted event.
|
||||||
|
|
||||||
|
Payload policy: one dict carrying ``child_id``, ``child_name``,
|
||||||
|
``instance_id``, ``quest_title``, the instance's ``window``/
|
||||||
|
``due_date``/``due_time`` so automation authors can filter without a
|
||||||
|
database lookup, and ``occurred_at`` — the event's own strict UTC
|
||||||
|
ISO-8601 timestamp. Completion payloads additionally carry
|
||||||
|
``was_on_time``. No other personal data: names and titles are the
|
||||||
|
household's own records, and nothing beyond them leaks.
|
||||||
|
|
||||||
|
Timezone policy: the day-complete check needs the HA-local "today",
|
||||||
|
which the caller passes in as a plain :class:`datetime.date`. Nothing
|
||||||
|
here reads ``hass.config.time_zone`` — the integration owns HA
|
||||||
|
timezone, the core owns the calendar arithmetic.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from .completion import derive_state
|
||||||
|
from .const import (
|
||||||
|
EVENT_CHILD_DAY_COMPLETE,
|
||||||
|
EVENT_QUEST_COMPLETED,
|
||||||
|
EVENT_QUEST_MISSED,
|
||||||
|
EVENT_QUEST_UNCOMPLETED,
|
||||||
|
)
|
||||||
|
from .dao_children import ChildrenDao
|
||||||
|
from .dao_instances import CompletionEventsDao, QuestInstancesDao
|
||||||
|
from .dao_rules import QuestDefinitionsDao
|
||||||
|
from .db import NestQuestDatabase
|
||||||
|
|
||||||
|
#: Strict UTC ISO-8601 timestamp (the DAO layers' policy): one shape,
|
||||||
|
#: explicit offset, second precision.
|
||||||
|
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def _now_stamp() -> str:
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||||
|
_UTC_TIMESTAMP_FORMAT
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _instance_payload(
|
||||||
|
database: NestQuestDatabase, instance_id: int, now_stamp: str
|
||||||
|
) -> dict:
|
||||||
|
"""Build the shared event payload for one quest instance."""
|
||||||
|
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||||
|
if instance is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"instance_id: quest instance {instance_id} does not exist"
|
||||||
|
)
|
||||||
|
child = await ChildrenDao(database).get(instance.child_id)
|
||||||
|
definition = await QuestDefinitionsDao(database).get(
|
||||||
|
instance.definition_id
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"child_id": instance.child_id,
|
||||||
|
"child_name": child.display_name if child else None,
|
||||||
|
"instance_id": instance.id,
|
||||||
|
"quest_title": definition.title if definition else None,
|
||||||
|
"window": instance.window,
|
||||||
|
"due_date": instance.due_date,
|
||||||
|
"due_time": instance.due_time,
|
||||||
|
"occurred_at": now_stamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def build_quest_completed_event(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
instance_id: int,
|
||||||
|
*,
|
||||||
|
was_on_time: bool | None,
|
||||||
|
) -> tuple[str, dict]:
|
||||||
|
"""Build the ``nestquest_quest_completed`` event for an actual
|
||||||
|
completion.
|
||||||
|
|
||||||
|
Called by the complete_quest service handler AFTER an actual
|
||||||
|
completion transition, with the completion's ``was_on_time`` —
|
||||||
|
computed under the completion lock and carried on the result — so
|
||||||
|
the payload never re-reads mutable latest state a racing reversal
|
||||||
|
could have replaced. A no-op re-complete never reaches here.
|
||||||
|
|
||||||
|
Returns the ``(event_type, payload)`` tuple to fire. This builder
|
||||||
|
touches NO day-complete state: it only reads the instance, its
|
||||||
|
child, and its definition, so a failure in the day-complete
|
||||||
|
evaluation (DB reads, timezone lookup in the shim) cannot suppress
|
||||||
|
the completed event. The caller fires this BEFORE asking
|
||||||
|
:func:`build_child_day_complete_event` for the day-complete event.
|
||||||
|
"""
|
||||||
|
now_stamp = _now_stamp()
|
||||||
|
payload = await _instance_payload(database, instance_id, now_stamp)
|
||||||
|
payload["was_on_time"] = was_on_time
|
||||||
|
return (EVENT_QUEST_COMPLETED, payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def build_child_day_complete_event(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
completed_payload: dict,
|
||||||
|
*,
|
||||||
|
today: datetime.date,
|
||||||
|
) -> tuple[str, dict] | None:
|
||||||
|
"""Build the ``nestquest_child_day_complete`` event for a
|
||||||
|
completion that cleared the child's whole day, or ``None`` when no
|
||||||
|
day-complete event should fire.
|
||||||
|
|
||||||
|
Called by the complete_quest service handler AFTER the
|
||||||
|
``nestquest_quest_completed`` event has already been fired, with
|
||||||
|
that event's payload (so this builder reuses the same
|
||||||
|
``occurred_at`` stamp and the same ``child_id`` / ``child_name``
|
||||||
|
the completed event carried — one transition, one shared
|
||||||
|
timestamp, matching the pre-extraction implementation).
|
||||||
|
|
||||||
|
Day-complete rule (identical to the pre-extraction implementation
|
||||||
|
and to the all-done binary sensor): quests were owed today and
|
||||||
|
none remain — a zero-quest day can never fire it. Only a
|
||||||
|
completion of TODAY'S instance evaluates it: completing a
|
||||||
|
back-dated or future instance must not announce a cleared day.
|
||||||
|
|
||||||
|
``today`` is the HA-local calendar date the day-complete rule is
|
||||||
|
evaluated against; the integration reads it from
|
||||||
|
``hass.config.time_zone`` and passes it in, so this module never
|
||||||
|
touches HA config.
|
||||||
|
"""
|
||||||
|
# Only a completion of TODAY'S instance evaluates the day-complete
|
||||||
|
# rule: completing a back-dated or future instance must not
|
||||||
|
# announce a cleared day.
|
||||||
|
if completed_payload["due_date"] != today.isoformat():
|
||||||
|
return None
|
||||||
|
instances = await QuestInstancesDao(database).list_by_date_range(
|
||||||
|
completed_payload["child_id"], today.isoformat(), today.isoformat()
|
||||||
|
)
|
||||||
|
completion_events = CompletionEventsDao(database)
|
||||||
|
remaining = 0
|
||||||
|
for instance in instances:
|
||||||
|
latest_for_instance = await completion_events.get_latest_for_instance(
|
||||||
|
instance.id
|
||||||
|
)
|
||||||
|
if derive_state(instance, latest_for_instance, today) != "done":
|
||||||
|
remaining += 1
|
||||||
|
if not instances or remaining:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
EVENT_CHILD_DAY_COMPLETE,
|
||||||
|
{
|
||||||
|
"child_id": completed_payload["child_id"],
|
||||||
|
"child_name": completed_payload["child_name"],
|
||||||
|
"quests_due": len(instances),
|
||||||
|
"quests_completed": len(instances),
|
||||||
|
"occurred_at": completed_payload["occurred_at"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def build_quest_uncompleted_events(
|
||||||
|
database: NestQuestDatabase, instance_id: int
|
||||||
|
) -> list[tuple[str, dict]]:
|
||||||
|
"""Build the ``nestquest_quest_uncompleted`` event for an actual reversal.
|
||||||
|
|
||||||
|
Returns a one-element list of ``(event_type, payload)`` to fire.
|
||||||
|
"""
|
||||||
|
payload = await _instance_payload(database, instance_id, _now_stamp())
|
||||||
|
return [(EVENT_QUEST_UNCOMPLETED, payload)]
|
||||||
|
|
||||||
|
|
||||||
|
async def build_quest_missed_event(
|
||||||
|
database: NestQuestDatabase, instance_id: int
|
||||||
|
) -> tuple[str, dict]:
|
||||||
|
"""Build the ``nestquest_quest_missed`` event for one instance.
|
||||||
|
|
||||||
|
The payload mirrors the nightly sweep's hand-built payload exactly
|
||||||
|
(the shared :func:`_instance_payload` shape: ``child_id``,
|
||||||
|
``child_name``, ``instance_id``, ``quest_title``, ``window``,
|
||||||
|
``due_date``, ``due_time``, ``occurred_at``), so the API plane's
|
||||||
|
missed path publishes the same event shape the integration's sweep
|
||||||
|
fires. Like the sweep, this builder never mutates the instance or
|
||||||
|
writes a completion event — it reads only.
|
||||||
|
"""
|
||||||
|
payload = await _instance_payload(database, instance_id, _now_stamp())
|
||||||
|
return (EVENT_QUEST_MISSED, payload)
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
"""Read-only history layer for the admin plane (Admin spec §5, task 2d2dda53).
|
||||||
|
|
||||||
|
Assembles the History screen's event rows over the append-only
|
||||||
|
completion log: a query over a CLOSED date range ``[start_date,
|
||||||
|
end_date]`` for one of three filters —
|
||||||
|
|
||||||
|
- ``all`` — every completion event (completed AND uncompleted) whose
|
||||||
|
instance is due in the range;
|
||||||
|
- ``reversals`` — the uncompleted events only;
|
||||||
|
- ``missed`` — the DERIVED missed instances in the range.
|
||||||
|
|
||||||
|
Range semantics (Feature 13's rule, kept): the range scopes by the
|
||||||
|
INSTANCE's ``due_date`` — the day the task was owed — not the event's
|
||||||
|
wall-clock ``occurred_at``, because history reporting asks "what
|
||||||
|
happened to the tasks due that day". ``missed`` is derived, never
|
||||||
|
stored: rows come from :func:`~.completion.list_missed_for_child`
|
||||||
|
(past-due open instances, the same ``_derived_state`` rule the
|
||||||
|
coordinator uses), and nothing is ever appended for them.
|
||||||
|
|
||||||
|
Row shape: the Admin spec §5 event-row fields, fixed and documented in
|
||||||
|
:data:`CSV_COLUMNS` — ``occurred_at``, ``event_type``,
|
||||||
|
``child_name``, ``child_id``, ``quest_title``, ``instance_id``,
|
||||||
|
``window``, ``due_date``, ``due_time``, ``actor``, ``was_on_time``.
|
||||||
|
Derived missed rows carry ``event_type`` ``'missed'`` (never a stored
|
||||||
|
event_type), no ``occurred_at`` (there is no event), ``was_on_time``
|
||||||
|
``None`` and the spec's ``nightly sweep`` actor. A stored event's
|
||||||
|
``actor`` follows the spec's meta shape: the ``actor_user_id`` string
|
||||||
|
for a user event (``joshua``), ``panel (Child)`` naming the tapped
|
||||||
|
profile for a panel event.
|
||||||
|
|
||||||
|
Validation policy (module-level, shared by both the JSON query and the
|
||||||
|
CSV export): the filter must be one of :data:`FILTERS` and the range
|
||||||
|
must be two strict ``YYYY-MM-DD`` dates with ``end >= start``;
|
||||||
|
violations raise ``ValueError`` naming the offending field
|
||||||
|
(``filter`` / ``start`` / ``end``) BEFORE any read. The whole
|
||||||
|
household read runs under the task-reentrant connection lock so every
|
||||||
|
row of one answer is one snapshot — the same discipline
|
||||||
|
:mod:`.completion` gives its read-decide-write paths.
|
||||||
|
|
||||||
|
:func:`rows_to_csv` serializes rows with the :mod:`csv` module (proper
|
||||||
|
quoting, one stable header row) and :func:`export_history_csv` runs
|
||||||
|
query + serialization as ONE call, so the CSV export can never drift
|
||||||
|
from the JSON query's filtered range. No permission check lives here
|
||||||
|
(that is the admin router's single ``require_admin`` dependency).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import datetime
|
||||||
|
import io
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from .completion import _reentrant_connection_lock, list_missed_for_child
|
||||||
|
from .dao_children import ChildRecord, ChildrenDao
|
||||||
|
from .dao_instances import (
|
||||||
|
EVENT_UNCOMPLETED,
|
||||||
|
CompletionEventRecord,
|
||||||
|
CompletionEventsDao,
|
||||||
|
QuestInstanceRecord,
|
||||||
|
QuestInstancesDao,
|
||||||
|
)
|
||||||
|
from .dao_rules import QuestDefinitionsDao, _validate_date
|
||||||
|
from .db import NestQuestDatabase
|
||||||
|
|
||||||
|
#: The ``all`` filter: every completion event in the range.
|
||||||
|
FILTER_ALL = "all"
|
||||||
|
|
||||||
|
#: The ``reversals`` filter: uncompleted events only.
|
||||||
|
FILTER_REVERSALS = "reversals"
|
||||||
|
|
||||||
|
#: The ``missed`` filter: derived missed instances in the range.
|
||||||
|
FILTER_MISSED = "missed"
|
||||||
|
|
||||||
|
#: The filters the query accepts, in the Admin spec's chip order.
|
||||||
|
FILTERS = (FILTER_ALL, FILTER_REVERSALS, FILTER_MISSED)
|
||||||
|
|
||||||
|
#: The ``event_type`` a derived missed row reports. ``missed`` is a
|
||||||
|
#: DERIVED state, never a stored event_type (Feature 08).
|
||||||
|
EVENT_MISSED = "missed"
|
||||||
|
|
||||||
|
#: The actor a derived missed row reports (Admin spec §5 meta:
|
||||||
|
#: ``missed · nightly sweep``).
|
||||||
|
ACTOR_NIGHTLY_SWEEP = "nightly sweep"
|
||||||
|
|
||||||
|
#: The CSV column set, in header order — the Admin spec §5 event-row
|
||||||
|
#: fields. The header row is exactly this tuple, always, so a consumer
|
||||||
|
#: can key on column names.
|
||||||
|
CSV_COLUMNS = (
|
||||||
|
"occurred_at",
|
||||||
|
"event_type",
|
||||||
|
"child_name",
|
||||||
|
"child_id",
|
||||||
|
"quest_title",
|
||||||
|
"instance_id",
|
||||||
|
"window",
|
||||||
|
"due_date",
|
||||||
|
"due_time",
|
||||||
|
"actor",
|
||||||
|
"was_on_time",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HistoryRow:
|
||||||
|
"""One history row: a stored event or a derived missed instance.
|
||||||
|
|
||||||
|
The fields are :data:`CSV_COLUMNS` in order. For a derived missed
|
||||||
|
row ``occurred_at`` is ``None`` (no event exists) and ``was_on_time``
|
||||||
|
is ``None``; for a stored event ``event_type`` is
|
||||||
|
``'completed'``/``'uncompleted'`` and the fields come from the event
|
||||||
|
joined with its instance, child and definition.
|
||||||
|
"""
|
||||||
|
|
||||||
|
occurred_at: str | None
|
||||||
|
event_type: str
|
||||||
|
child_name: str
|
||||||
|
child_id: int
|
||||||
|
quest_title: str
|
||||||
|
instance_id: int
|
||||||
|
window: str
|
||||||
|
due_date: str
|
||||||
|
due_time: str | None
|
||||||
|
actor: str
|
||||||
|
was_on_time: bool | None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_filter(filter_name: str) -> str:
|
||||||
|
"""Return the filter, or raise ValueError naming the field."""
|
||||||
|
if filter_name not in FILTERS:
|
||||||
|
raise ValueError(
|
||||||
|
f"filter must be one of {', '.join(repr(f) for f in FILTERS)}, "
|
||||||
|
f"got {filter_name!r}"
|
||||||
|
)
|
||||||
|
return filter_name
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_range(start_date: str, end_date: str) -> tuple[str, str]:
|
||||||
|
"""Validate the closed range and return it, else raise naming the field.
|
||||||
|
|
||||||
|
Both bounds must be strict ``YYYY-MM-DD`` dates (the shared
|
||||||
|
:func:`~.dao_rules._validate_date`, so non-padded or re-ordered
|
||||||
|
shapes are rejected and a non-string raises naming the field) and
|
||||||
|
``end`` must be on or after ``start``.
|
||||||
|
"""
|
||||||
|
_validate_date(start_date, "start")
|
||||||
|
_validate_date(end_date, "end")
|
||||||
|
if end_date < start_date:
|
||||||
|
raise ValueError(
|
||||||
|
f"end must be on or after start, got {end_date!r} < "
|
||||||
|
f"{start_date!r}"
|
||||||
|
)
|
||||||
|
return start_date, end_date
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_today(today: datetime.date | None) -> datetime.date:
|
||||||
|
"""Return the threaded HA-local date, or the host calendar date."""
|
||||||
|
if today is None:
|
||||||
|
return datetime.date.today()
|
||||||
|
return today
|
||||||
|
|
||||||
|
|
||||||
|
def _event_actor(
|
||||||
|
event: CompletionEventRecord, actor_child: ChildRecord | None
|
||||||
|
) -> str:
|
||||||
|
"""Return the spec-meta actor string for one stored event.
|
||||||
|
|
||||||
|
A user event reports the ``actor_user_id`` string (the spec's
|
||||||
|
``uncompleted · joshua (admin)`` shape); a panel event reports
|
||||||
|
``panel (Child)`` naming the tapped profile. The tapped child is
|
||||||
|
never hard-deleted, so the lookup below is a belt-and-braces
|
||||||
|
fallback to the stored id, never a silent rewrite of policy.
|
||||||
|
"""
|
||||||
|
if event.actor_source == "user":
|
||||||
|
return event.actor_user_id or ""
|
||||||
|
if actor_child is not None:
|
||||||
|
return f"panel ({actor_child.display_name})"
|
||||||
|
return f"panel ({event.actor_child_id})"
|
||||||
|
|
||||||
|
|
||||||
|
def _event_row(
|
||||||
|
event: CompletionEventRecord,
|
||||||
|
instance: QuestInstanceRecord,
|
||||||
|
child: ChildRecord,
|
||||||
|
quest_title: str,
|
||||||
|
actor_child: ChildRecord | None,
|
||||||
|
) -> HistoryRow:
|
||||||
|
"""Join one stored event with its instance, child and definition."""
|
||||||
|
return HistoryRow(
|
||||||
|
occurred_at=event.occurred_at,
|
||||||
|
event_type=event.event_type,
|
||||||
|
child_name=child.display_name,
|
||||||
|
child_id=event.child_id,
|
||||||
|
quest_title=quest_title,
|
||||||
|
instance_id=event.instance_id,
|
||||||
|
window=instance.window,
|
||||||
|
due_date=instance.due_date,
|
||||||
|
due_time=instance.due_time,
|
||||||
|
actor=_event_actor(event, actor_child),
|
||||||
|
was_on_time=event.was_on_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _missed_row(
|
||||||
|
instance: QuestInstanceRecord,
|
||||||
|
child: ChildRecord,
|
||||||
|
quest_title: str,
|
||||||
|
) -> HistoryRow:
|
||||||
|
"""Build the derived row for one past-due open instance."""
|
||||||
|
return HistoryRow(
|
||||||
|
occurred_at=None,
|
||||||
|
event_type=EVENT_MISSED,
|
||||||
|
child_name=child.display_name,
|
||||||
|
child_id=instance.child_id,
|
||||||
|
quest_title=quest_title,
|
||||||
|
instance_id=instance.id,
|
||||||
|
window=instance.window,
|
||||||
|
due_date=instance.due_date,
|
||||||
|
due_time=instance.due_time,
|
||||||
|
actor=ACTOR_NIGHTLY_SWEEP,
|
||||||
|
was_on_time=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_values(row: HistoryRow) -> list[str]:
|
||||||
|
"""Return one CSV row's cell values in :data:`CSV_COLUMNS` order.
|
||||||
|
|
||||||
|
``None`` cells serialize as empty (a missed row has no
|
||||||
|
``occurred_at``); ``was_on_time`` serializes as ``true``/``false``
|
||||||
|
when the event carries the flag and empty when it does not.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
row.occurred_at or "",
|
||||||
|
row.event_type,
|
||||||
|
row.child_name,
|
||||||
|
str(row.child_id),
|
||||||
|
row.quest_title,
|
||||||
|
str(row.instance_id),
|
||||||
|
row.window,
|
||||||
|
row.due_date,
|
||||||
|
row.due_time or "",
|
||||||
|
row.actor,
|
||||||
|
"" if row.was_on_time is None else str(row.was_on_time).lower(),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def rows_to_csv(rows: list[HistoryRow]) -> str:
|
||||||
|
"""Serialize history rows to CSV text with the documented header.
|
||||||
|
|
||||||
|
The FIRST row is always :data:`CSV_COLUMNS`, so the header is
|
||||||
|
stable and deterministic; the :mod:`csv` module handles quoting
|
||||||
|
(titles or names containing commas or quotes are quoted per RFC
|
||||||
|
4180) and CRLF line endings.
|
||||||
|
"""
|
||||||
|
buffer = io.StringIO()
|
||||||
|
writer = csv.writer(buffer)
|
||||||
|
writer.writerow(CSV_COLUMNS)
|
||||||
|
for row in rows:
|
||||||
|
writer.writerow(_csv_values(row))
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
async def query_history(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
filter_name: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
*,
|
||||||
|
today: datetime.date | None = None,
|
||||||
|
) -> list[HistoryRow]:
|
||||||
|
"""Return the history rows for ``filter_name`` over the closed range.
|
||||||
|
|
||||||
|
Validates the filter and the strict ``YYYY-MM-DD`` range (raising
|
||||||
|
``ValueError`` naming ``filter`` / ``start`` / ``end``) BEFORE any
|
||||||
|
read, then builds the rows:
|
||||||
|
|
||||||
|
- ``all`` / ``reversals``: every child's events whose instances are
|
||||||
|
due in the range (:meth:`~.dao_instances.CompletionEventsDao.list_by_child_and_date_range`,
|
||||||
|
the Feature 13 due-date scope), ``reversals`` keeping only the
|
||||||
|
``uncompleted`` rows.
|
||||||
|
- ``missed``: :func:`~.completion.list_missed_for_child` per child —
|
||||||
|
the SAME derived-state rule the coordinator uses, never a new one,
|
||||||
|
and never a stored event.
|
||||||
|
|
||||||
|
Rows are ordered deterministically by ``due_date``, then instance,
|
||||||
|
then event id (the per-instance append order, so a completion and
|
||||||
|
its reversal keep their order). ``today`` pins the HA-local date
|
||||||
|
the missed derivation compares due dates against (the host calendar
|
||||||
|
date when omitted). The whole read runs under the task-reentrant
|
||||||
|
connection lock — :func:`list_missed_for_child` re-enters it — so
|
||||||
|
one answer is one snapshot.
|
||||||
|
"""
|
||||||
|
filter_name = _validate_filter(filter_name)
|
||||||
|
start_date, end_date = _validate_range(start_date, end_date)
|
||||||
|
today_date = _resolve_today(today)
|
||||||
|
|
||||||
|
keyed: list[tuple[tuple[str, int, int], HistoryRow]] = []
|
||||||
|
async with _reentrant_connection_lock(database):
|
||||||
|
children = await ChildrenDao(database).list_all()
|
||||||
|
children_by_id = {child.id: child for child in children}
|
||||||
|
instances = QuestInstancesDao(database)
|
||||||
|
definitions = QuestDefinitionsDao(database)
|
||||||
|
events = CompletionEventsDao(database)
|
||||||
|
titles: dict[int, str] = {}
|
||||||
|
|
||||||
|
if filter_name == FILTER_MISSED:
|
||||||
|
for child in children:
|
||||||
|
for instance in await list_missed_for_child(
|
||||||
|
database, child.id, start_date, end_date, today=today_date
|
||||||
|
):
|
||||||
|
if instance.definition_id not in titles:
|
||||||
|
definition = await definitions.get(
|
||||||
|
instance.definition_id
|
||||||
|
)
|
||||||
|
assert definition is not None, (
|
||||||
|
"a materialized instance's definition exists"
|
||||||
|
)
|
||||||
|
titles[instance.definition_id] = definition.title
|
||||||
|
keyed.append(
|
||||||
|
(
|
||||||
|
(instance.due_date, instance.id, 0),
|
||||||
|
_missed_row(
|
||||||
|
instance,
|
||||||
|
child,
|
||||||
|
titles[instance.definition_id],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
keyed.sort(key=lambda pair: pair[0])
|
||||||
|
return [row for _, row in keyed]
|
||||||
|
|
||||||
|
for child in children:
|
||||||
|
for event in await events.list_by_child_and_date_range(
|
||||||
|
child.id, start_date, end_date
|
||||||
|
):
|
||||||
|
if filter_name == FILTER_REVERSALS and (
|
||||||
|
event.event_type != EVENT_UNCOMPLETED
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
instance = await instances.get_by_id(event.instance_id)
|
||||||
|
assert instance is not None, (
|
||||||
|
"an event's instance is never deleted (append-only "
|
||||||
|
"history keeps its references)"
|
||||||
|
)
|
||||||
|
actor_child = (
|
||||||
|
children_by_id.get(event.actor_child_id)
|
||||||
|
if event.actor_child_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if instance.definition_id not in titles:
|
||||||
|
definition = await definitions.get(instance.definition_id)
|
||||||
|
assert definition is not None, (
|
||||||
|
"a materialized instance's definition exists"
|
||||||
|
)
|
||||||
|
titles[instance.definition_id] = definition.title
|
||||||
|
keyed.append(
|
||||||
|
(
|
||||||
|
(instance.due_date, instance.id, event.id),
|
||||||
|
_event_row(
|
||||||
|
event,
|
||||||
|
instance,
|
||||||
|
child,
|
||||||
|
titles[instance.definition_id],
|
||||||
|
actor_child,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
keyed.sort(key=lambda pair: pair[0])
|
||||||
|
return [row for _, row in keyed]
|
||||||
|
|
||||||
|
|
||||||
|
async def export_history_csv(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
filter_name: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
*,
|
||||||
|
today: datetime.date | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Return the filtered history as CSV text, header row included.
|
||||||
|
|
||||||
|
ONE query + serialization call so the CSV export carries exactly
|
||||||
|
the JSON query's filtered range and order — the same
|
||||||
|
:func:`query_history` validation, rows and :func:`rows_to_csv`
|
||||||
|
serializer, never a second implementation.
|
||||||
|
"""
|
||||||
|
rows = await query_history(
|
||||||
|
database, filter_name, start_date, end_date, today=today
|
||||||
|
)
|
||||||
|
return rows_to_csv(rows)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ACTOR_NIGHTLY_SWEEP",
|
||||||
|
"CSV_COLUMNS",
|
||||||
|
"EVENT_MISSED",
|
||||||
|
"EVENT_UNCOMPLETED",
|
||||||
|
"FILTERS",
|
||||||
|
"FILTER_ALL",
|
||||||
|
"FILTER_MISSED",
|
||||||
|
"FILTER_REVERSALS",
|
||||||
|
"HistoryRow",
|
||||||
|
"export_history_csv",
|
||||||
|
"query_history",
|
||||||
|
"rows_to_csv",
|
||||||
|
]
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
"""The materialization walk (Feature 07 core).
|
||||||
|
|
||||||
|
:func:`materialize` turns the schedule rules + custody presence of the
|
||||||
|
active quest definitions into concrete ``quest_instances`` rows over a
|
||||||
|
date range. It is the pure walk over definitions x dates x assignees x
|
||||||
|
windows; HA scheduling (day-rollover triggers) is a separate later
|
||||||
|
task and lives elsewhere.
|
||||||
|
|
||||||
|
The walk is deterministic and idempotent:
|
||||||
|
|
||||||
|
- All inputs — the active definitions with their rules, assignees and
|
||||||
|
windows, AND the assignee children's presence schedules and scoped
|
||||||
|
overrides — are read inside ONE locked transaction (see
|
||||||
|
:func:`~.dao_rules.load_materialization_input`), so the walk sees a
|
||||||
|
single coherent snapshot rather than a definition state from one
|
||||||
|
instant and a presence state from another.
|
||||||
|
- Presence is snapshotted into an immutable
|
||||||
|
:class:`~.presence.PresenceEngine` from that same coherent read.
|
||||||
|
- For each date in the range, each definition whose decoded rule
|
||||||
|
:func:`~.recurrence.occurs_on` on that date, each assignee who is
|
||||||
|
``is_present`` on that date (or every assignee, when the definition's
|
||||||
|
``skip_on_away`` is off) and each declared window, one instance is
|
||||||
|
written through :class:`~.dao_instances.QuestInstancesDao.upsert_if_valid`
|
||||||
|
with a ``generated_at`` stamp shared by the whole batch (one coherent
|
||||||
|
time per run).
|
||||||
|
|
||||||
|
A config change that lands AFTER the snapshot is read but BEFORE a
|
||||||
|
tuple's write (an assignment removed, a definition or child deactivated,
|
||||||
|
a window removed) makes that tuple SKIP rather than abort the batch:
|
||||||
|
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` re-validates
|
||||||
|
every materialization precondition (active definition, active child,
|
||||||
|
live assignment, declared window, and its CURRENT ``due_time``) inside
|
||||||
|
the SAME transaction as the INSERT, so the check and the write cannot be
|
||||||
|
split by a racing config edit.
|
||||||
|
|
||||||
|
The upsert is idempotent on (definition_id, child_id, due_date,
|
||||||
|
window), so re-running the materialization over the same range never
|
||||||
|
duplicates a row; a tuple whose instance is already completed is SKIPPED
|
||||||
|
(never rewritten, never raised) by
|
||||||
|
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`.
|
||||||
|
|
||||||
|
Regeneration: a config or presence change must rebuild future instances
|
||||||
|
so they track the new state. :func:`regenerate_for_definition` covers
|
||||||
|
definition edits / reassignment (Features 06/07); :func:`regenerate_for_child`
|
||||||
|
covers a single child's presence change (Feature 09). Both delete the
|
||||||
|
affected open instances at or after today — never a completed instance,
|
||||||
|
never the past — and re-run the walk over the rolling horizon
|
||||||
|
``[today, today + horizon_days]`` (``horizon_days`` defaults to
|
||||||
|
``DEFAULT_HORIZON_DAYS``).
|
||||||
|
|
||||||
|
HOOK (Feature 09 presence services): the presence business layer that
|
||||||
|
lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY
|
||||||
|
presence-state write so a child's future instances track its presence:
|
||||||
|
``set_presence_pattern`` (including clearing/setting an empty pattern),
|
||||||
|
deleting/clearing the presence schedule
|
||||||
|
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
|
||||||
|
``create_presence_override``, and ``delete_presence_override``. A
|
||||||
|
schedule delete changes presence state too (the child becomes
|
||||||
|
always-present), so it must regenerate just like the others. This hook
|
||||||
|
is documented, not yet wired: no presence business layer exists yet, and
|
||||||
|
the presence DAO (``dao_presence.py``) must stay free of business rules
|
||||||
|
— never call :func:`regenerate_for_child` from inside the DAO layer.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
from .const import DEFAULT_HORIZON_DAYS
|
||||||
|
from .dao_instances import (
|
||||||
|
CompletionEventsDao,
|
||||||
|
QuestInstancesDao,
|
||||||
|
_resolve_today,
|
||||||
|
)
|
||||||
|
from .dao_rules import (
|
||||||
|
_validate_date,
|
||||||
|
load_materialization_input,
|
||||||
|
schedule_rule_from_storage,
|
||||||
|
schedule_rule_storage_from_record,
|
||||||
|
)
|
||||||
|
from .db import NestQuestDatabase
|
||||||
|
from .presence import PresenceEngine, PresenceOverride, PresenceSchedule
|
||||||
|
from .recurrence import ScheduleRule, occurs_on
|
||||||
|
|
||||||
|
|
||||||
|
def _now_stamp() -> str:
|
||||||
|
"""Return the batch's shared UTC second-precision generation stamp."""
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||||
|
timespec="seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_horizon_days(horizon_days: int | None) -> int:
|
||||||
|
"""Validate a regeneration horizon and return the resolved day count.
|
||||||
|
|
||||||
|
``None`` resolves to :data:`~.const.DEFAULT_HORIZON_DAYS`. A bool
|
||||||
|
(SQLite would happily bind ``True``/``False`` onto an int), a non-int,
|
||||||
|
or a value below 1 raises ValueError naming the field — the validation
|
||||||
|
runs BEFORE any future-instance delete so a malformed horizon can never
|
||||||
|
delete rows it then fails to rebuild. A negative horizon would
|
||||||
|
otherwise delete first and only then be rejected by
|
||||||
|
:func:`materialize`'s inverted-range check; ``0``/``False`` would
|
||||||
|
silently shrink the window to a single day.
|
||||||
|
"""
|
||||||
|
if horizon_days is None:
|
||||||
|
return DEFAULT_HORIZON_DAYS
|
||||||
|
if (
|
||||||
|
isinstance(horizon_days, bool)
|
||||||
|
or not isinstance(horizon_days, int)
|
||||||
|
or horizon_days < 1
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"horizon_days must be a positive integer, got {horizon_days!r}"
|
||||||
|
)
|
||||||
|
return horizon_days
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_rule(snapshot) -> ScheduleRule:
|
||||||
|
"""Decode a snapshot's rule record back into a ScheduleRule.
|
||||||
|
|
||||||
|
The record carried by the snapshot is the one the definition
|
||||||
|
referenced at read time, so decoding it here (rather than re-reading
|
||||||
|
through ``ScheduleRulesDao``) preserves the coherence the snapshot
|
||||||
|
guarantees.
|
||||||
|
"""
|
||||||
|
return schedule_rule_from_storage(
|
||||||
|
schedule_rule_storage_from_record(snapshot.rule)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_engine(
|
||||||
|
schedules_records,
|
||||||
|
overrides_records,
|
||||||
|
*,
|
||||||
|
extra_overrides: tuple[PresenceOverride, ...] = (),
|
||||||
|
) -> PresenceEngine:
|
||||||
|
"""Convert raw presence records into an immutable PresenceEngine.
|
||||||
|
|
||||||
|
``extra_overrides`` layers already-validated, NOT-yet-stored
|
||||||
|
overrides on top of the stored ones — the consequence preview's
|
||||||
|
proposed override (:func:`count_removed_by_override`).
|
||||||
|
"""
|
||||||
|
schedules: dict[int, PresenceSchedule] = {}
|
||||||
|
for child_id, record in schedules_records.items():
|
||||||
|
schedules[child_id] = PresenceSchedule.decode(
|
||||||
|
child_id, record.anchor_date, record.pattern
|
||||||
|
)
|
||||||
|
overrides: dict[int, list[PresenceOverride]] = {}
|
||||||
|
for child_id, records in overrides_records.items():
|
||||||
|
overrides[child_id] = [
|
||||||
|
PresenceOverride(
|
||||||
|
record.child_id,
|
||||||
|
record.start_date,
|
||||||
|
record.end_date,
|
||||||
|
record.is_present,
|
||||||
|
record.note,
|
||||||
|
)
|
||||||
|
for record in records
|
||||||
|
]
|
||||||
|
for override in extra_overrides:
|
||||||
|
overrides.setdefault(override.child_id, []).append(override)
|
||||||
|
return PresenceEngine(schedules, overrides)
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratedTuple(NamedTuple):
|
||||||
|
"""One instance the walk WOULD write: its upsert key plus due time."""
|
||||||
|
|
||||||
|
definition_id: int
|
||||||
|
child_id: int
|
||||||
|
due_date: str
|
||||||
|
window: str
|
||||||
|
due_time: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def _generation_preview(
|
||||||
|
snapshots,
|
||||||
|
engine: PresenceEngine,
|
||||||
|
start: datetime.date,
|
||||||
|
end: datetime.date,
|
||||||
|
*,
|
||||||
|
child_filter: set[int] | None = None,
|
||||||
|
) -> list[GeneratedTuple]:
|
||||||
|
"""Return the tuples the walk would write over ``[start, end]``.
|
||||||
|
|
||||||
|
The pure, read-only half of :func:`materialize`: for each date, each
|
||||||
|
definition whose decoded rule :func:`~.recurrence.occurs_on` it,
|
||||||
|
each assignee in ``child_filter`` (every assignee when None) who is
|
||||||
|
``is_present`` on it — or every assignee when the definition's
|
||||||
|
``skip_on_away`` is off — and each declared window, one tuple, in
|
||||||
|
the walk's write order. :func:`materialize` writes exactly these
|
||||||
|
tuples and :func:`count_removed_by_override` compares two of these
|
||||||
|
previews, so the write and the consequence preview can never
|
||||||
|
diverge. No database access.
|
||||||
|
"""
|
||||||
|
definitions = [
|
||||||
|
(snapshot.definition.id, _decode_rule(snapshot),
|
||||||
|
snapshot.assignees, snapshot.windows,
|
||||||
|
snapshot.definition.skip_on_away)
|
||||||
|
for snapshot in snapshots
|
||||||
|
]
|
||||||
|
generated: list[GeneratedTuple] = []
|
||||||
|
cursor = start
|
||||||
|
while cursor <= end:
|
||||||
|
iso = cursor.isoformat()
|
||||||
|
for (
|
||||||
|
definition_id, rule, assignees, windows, skip_on_away
|
||||||
|
) in definitions:
|
||||||
|
if not occurs_on(rule, cursor):
|
||||||
|
continue
|
||||||
|
for child in assignees:
|
||||||
|
if child_filter is not None and child.id not in child_filter:
|
||||||
|
continue
|
||||||
|
if skip_on_away and not engine.is_present(child.id, cursor):
|
||||||
|
continue
|
||||||
|
for window in windows:
|
||||||
|
generated.append(
|
||||||
|
GeneratedTuple(
|
||||||
|
definition_id,
|
||||||
|
child.id,
|
||||||
|
iso,
|
||||||
|
window.window,
|
||||||
|
window.due_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cursor += datetime.timedelta(days=1)
|
||||||
|
return generated
|
||||||
|
|
||||||
|
|
||||||
|
async def materialize(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
*,
|
||||||
|
child_ids: list[int] | None = None,
|
||||||
|
today: datetime.date | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Generate quest instances for the closed range [start_date, end_date].
|
||||||
|
|
||||||
|
``start_date`` and ``end_date`` must be strict ISO calendar dates
|
||||||
|
(YYYY-MM-DD) with ``end_date`` on or after ``start_date``; anything
|
||||||
|
else raises ValueError naming the offending field. Returns the number
|
||||||
|
of instances upserted for the range (idempotent — a re-run refreshes
|
||||||
|
the same rows, never duplicates them).
|
||||||
|
|
||||||
|
``child_ids`` optionally scopes the walk to exactly those children:
|
||||||
|
assignees outside the list are never written, so a caller that only
|
||||||
|
wants one child's instances rebuilt (e.g. :func:`regenerate_for_child`
|
||||||
|
after a presence change) cannot touch any unrelated child's rows.
|
||||||
|
When omitted (or None) the walk covers every assignee as before.
|
||||||
|
|
||||||
|
``today`` optionally pins the resolved HA-local date the walk's
|
||||||
|
no-past guard compares against; it is threaded into every
|
||||||
|
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` write. When
|
||||||
|
omitted the host clock (``datetime.date.today``) is used, which is the
|
||||||
|
historical default. A caller that computed the horizon in Home
|
||||||
|
Assistant's configured time zone MUST pass that same date here so a
|
||||||
|
time zone behind the host around midnight is not rejected as "past".
|
||||||
|
|
||||||
|
``start_date`` is clamped up to the resolved "today" (the same
|
||||||
|
``today`` anchor threaded into the no-past guard), so a caller that
|
||||||
|
passes a ``start_date`` already in the past never yields a
|
||||||
|
past-dated instance: the walk simply begins on today and skips every
|
||||||
|
earlier date. A range entirely in the past therefore produces zero
|
||||||
|
instances rather than an error, and the DAO's no-past guard remains
|
||||||
|
the authoritative backstop under the lock.
|
||||||
|
|
||||||
|
All inputs are read in ONE locked transaction; each tuple's write then
|
||||||
|
goes through the atomic :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`,
|
||||||
|
which re-validates the tuple and its current ``due_time`` in the same
|
||||||
|
transaction as the INSERT, so a config change that arrives mid-batch
|
||||||
|
skips the affected tuples rather than aborting the batch or leaving a
|
||||||
|
partial result.
|
||||||
|
"""
|
||||||
|
_validate_date(start_date, "start_date")
|
||||||
|
_validate_date(end_date, "end_date")
|
||||||
|
if end_date < start_date:
|
||||||
|
raise ValueError(
|
||||||
|
f"end_date must be on or after start_date, got "
|
||||||
|
f"{end_date!r} < {start_date!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshots, schedules_records, overrides_records = await (
|
||||||
|
load_materialization_input(database, start_date, end_date)
|
||||||
|
)
|
||||||
|
engine = _build_engine(schedules_records, overrides_records)
|
||||||
|
child_filter = None if child_ids is None else set(child_ids)
|
||||||
|
|
||||||
|
generated_at = _now_stamp()
|
||||||
|
instances = QuestInstancesDao(database)
|
||||||
|
|
||||||
|
start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||||
|
end = datetime.datetime.strptime(end_date, "%Y-%m-%d").date()
|
||||||
|
|
||||||
|
# Clamp a past start up to today (the HA-local anchor when the caller
|
||||||
|
# pinned one, else the host clock) so the walk can never emit a
|
||||||
|
# past-dated instance; the DAO's no-past guard remains the backstop.
|
||||||
|
# A range entirely in the past therefore yields zero instances.
|
||||||
|
today_date = _resolve_today(today)
|
||||||
|
start = max(start, today_date)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for generated in _generation_preview(
|
||||||
|
snapshots, engine, start, end, child_filter=child_filter
|
||||||
|
):
|
||||||
|
written = await instances.upsert_if_valid(
|
||||||
|
generated.definition_id,
|
||||||
|
generated.child_id,
|
||||||
|
generated.due_date,
|
||||||
|
generated_at,
|
||||||
|
window=generated.window,
|
||||||
|
due_time=generated.due_time,
|
||||||
|
today=today,
|
||||||
|
)
|
||||||
|
if written is not None:
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
async def regenerate_for_definition(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
definition_id: int,
|
||||||
|
*,
|
||||||
|
today: datetime.date | None = None,
|
||||||
|
horizon_days: int | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Regenerate a definition's future instances after a config change.
|
||||||
|
|
||||||
|
The Feature 06 edit/assignment/activation paths call this after a
|
||||||
|
successful write so future instances track the new state: the
|
||||||
|
definition's open instances at or after today are deleted (never a
|
||||||
|
completed instance, never the past) via
|
||||||
|
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted`,
|
||||||
|
then the materialization walk re-runs over the rolling horizon
|
||||||
|
``[today, today + horizon_days]`` so the (changed) rule,
|
||||||
|
assignee set and windows re-materialize against today's state.
|
||||||
|
|
||||||
|
"today" is computed the same way the walk computes it —
|
||||||
|
:func:`~.dao_instances._today` — so the delete cutoff and the
|
||||||
|
re-materialization range share one anchor, and the walk's
|
||||||
|
``upsert_if_valid`` no-past guard never rejects the regenerated
|
||||||
|
range. ``today`` optionally pins a caller-resolved HA-local date
|
||||||
|
(threaded into both the delete cutoff and the re-materialization)
|
||||||
|
so a household time zone behind the host around midnight stays
|
||||||
|
consistent. ``horizon_days`` sizes the re-materialization window;
|
||||||
|
when omitted it falls back to :data:`~.const.DEFAULT_HORIZON_DAYS`.
|
||||||
|
A bool, non-int, or sub-1 value raises ValueError BEFORE any delete.
|
||||||
|
Returns the number of instances the re-materialization
|
||||||
|
upserted across the whole snapshot (idempotent — other active
|
||||||
|
definitions' tuples refresh in place, never duplicate).
|
||||||
|
"""
|
||||||
|
horizon = _resolve_horizon_days(horizon_days)
|
||||||
|
today_date = _resolve_today(today)
|
||||||
|
start_date = today_date.isoformat()
|
||||||
|
end_date = (
|
||||||
|
today_date + datetime.timedelta(days=horizon)
|
||||||
|
).isoformat()
|
||||||
|
|
||||||
|
instances = QuestInstancesDao(database)
|
||||||
|
await instances.delete_future_uncompleted(
|
||||||
|
definition_id, start_date, today=today
|
||||||
|
)
|
||||||
|
return await materialize(database, start_date, end_date, today=today)
|
||||||
|
|
||||||
|
|
||||||
|
# HOOK (Feature 09 presence services): ``regenerate_for_child`` is the
|
||||||
|
# child-presence counterpart to ``regenerate_for_definition``. The
|
||||||
|
# presence business layer that lands in Feature 09 MUST call
|
||||||
|
# ``regenerate_for_child`` after EVERY presence-state write, so future
|
||||||
|
# instances track a child's changed presence: set_presence_pattern
|
||||||
|
# (including clearing/setting an empty pattern), deleting/clearing the
|
||||||
|
# presence schedule (PresenceSchedulesDao.delete),
|
||||||
|
# create_presence_override, and delete_presence_override. A schedule
|
||||||
|
# delete changes presence state too (the child becomes always-present),
|
||||||
|
# so it must regenerate just like the others. It is deliberately NOT
|
||||||
|
# wired into ``dao_presence.py``: the DAO layer stays pure storage (no
|
||||||
|
# business rules), and no presence business layer exists yet to host the
|
||||||
|
# call — see the module docstring note above.
|
||||||
|
async def regenerate_for_child(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
child_id: int,
|
||||||
|
*,
|
||||||
|
today: datetime.date | None = None,
|
||||||
|
horizon_days: int | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Regenerate a child's future instances after a presence change.
|
||||||
|
|
||||||
|
The Feature 09 presence services call this after every
|
||||||
|
presence-state write — set_presence_pattern (including clearing or
|
||||||
|
setting an empty pattern), deleting/clearing the presence schedule
|
||||||
|
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
|
||||||
|
create_presence_override, and delete_presence_override — so a
|
||||||
|
child's future instances track its new presence: the child's open
|
||||||
|
instances at or after today are deleted across ALL its definitions
|
||||||
|
(never a completed instance, never the past) via
|
||||||
|
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`,
|
||||||
|
then the materialization walk re-runs over the rolling horizon
|
||||||
|
``[today, today + horizon_days]`` scoped to that child only,
|
||||||
|
so the child's now-absent/present dates re-materialize against
|
||||||
|
today's presence WITHOUT touching any other child's rows.
|
||||||
|
|
||||||
|
``child_id`` must be a plain int — bool and float are rejected
|
||||||
|
(SQLite binds a bool as 0/1 and a float would round, so a malformed
|
||||||
|
id must never mutate the wrong profile).
|
||||||
|
|
||||||
|
"today" is computed the same way the walk computes it —
|
||||||
|
:func:`~.dao_instances._today` — and the re-materialization range uses the
|
||||||
|
delete's returned effective cutoff (see
|
||||||
|
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`),
|
||||||
|
so the delete cutoff and the re-materialization window share ONE
|
||||||
|
execution-day anchor even when the call is queued across midnight.
|
||||||
|
``today`` optionally pins a caller-resolved HA-local date, threaded
|
||||||
|
into the delete cutoff and the re-materialization for the same
|
||||||
|
host-vs-HA time-zone consistency. ``horizon_days`` sizes the
|
||||||
|
re-materialization window; when omitted it falls back to
|
||||||
|
:data:`~.const.DEFAULT_HORIZON_DAYS`. A bool, non-int, or sub-1 value
|
||||||
|
raises ValueError BEFORE any delete. Returns the number of instances
|
||||||
|
the re-materialization upserted for the child across the whole
|
||||||
|
snapshot.
|
||||||
|
"""
|
||||||
|
if isinstance(child_id, bool) or not isinstance(child_id, int):
|
||||||
|
raise ValueError(f"child_id must be an integer, got {child_id!r}")
|
||||||
|
|
||||||
|
horizon = _resolve_horizon_days(horizon_days)
|
||||||
|
today_date = _resolve_today(today)
|
||||||
|
|
||||||
|
instances = QuestInstancesDao(database)
|
||||||
|
# Use the delete's returned effective cutoff as the materialize anchor,
|
||||||
|
# NOT the independently-computed "today" above. If the call is queued
|
||||||
|
# across midnight the delete clamps its cutoff to the execution-day;
|
||||||
|
# materializing from the stale "today" (now yesterday) would trip the
|
||||||
|
# walk's no-past guard and leave the child's future instances deleted
|
||||||
|
# but not rebuilt. One anchor for both keeps them consistent.
|
||||||
|
_, effective_cutoff = await instances.delete_future_uncompleted_for_child(
|
||||||
|
child_id, today_date.isoformat(), today=today
|
||||||
|
)
|
||||||
|
start = datetime.date.fromisoformat(effective_cutoff)
|
||||||
|
end_date = (start + datetime.timedelta(days=horizon)).isoformat()
|
||||||
|
return await materialize(
|
||||||
|
database, start.isoformat(), end_date, child_ids=[child_id], today=today
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def count_removed_by_override(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
override: PresenceOverride,
|
||||||
|
*,
|
||||||
|
today: datetime.date | None = None,
|
||||||
|
horizon_days: int | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Count the upcoming instances saving ``override`` would remove.
|
||||||
|
|
||||||
|
Read-only: nothing is written. The window is the part of the
|
||||||
|
override's range inside the rolling horizon ``[today, today +
|
||||||
|
horizon_days]`` that :func:`regenerate_for_child` rebuilds after the
|
||||||
|
override is saved (outside the range the two presence answers agree,
|
||||||
|
so nothing there can change). The materialization input is read
|
||||||
|
ONCE (:func:`~.dao_rules.load_materialization_input`) and
|
||||||
|
:func:`_generation_preview` runs twice over it for the override's
|
||||||
|
child — with the stored presence, and with ``override`` layered on
|
||||||
|
top (:func:`_build_engine`'s ``extra_overrides``). The count is the
|
||||||
|
tuples the first preview generates and the second does not, MINUS
|
||||||
|
every (definition, date, window) whose stored instance already has a
|
||||||
|
completion event (D-005: regeneration never deletes those). A
|
||||||
|
definition with ``skip_on_away`` off generates regardless of
|
||||||
|
presence, so it contributes nothing; a present override only ever
|
||||||
|
ADDS tuples, so it counts 0.
|
||||||
|
|
||||||
|
An override overlapping a stored one (which the create path refuses)
|
||||||
|
resolves through the engine's latest-start-wins rule. ``today`` and
|
||||||
|
``horizon_days`` resolve exactly as :func:`regenerate_for_child`
|
||||||
|
resolves them; a malformed horizon raises ValueError.
|
||||||
|
"""
|
||||||
|
horizon = _resolve_horizon_days(horizon_days)
|
||||||
|
today_date = _resolve_today(today)
|
||||||
|
start = max(today_date, override.start_date)
|
||||||
|
end = min(
|
||||||
|
today_date + datetime.timedelta(days=horizon), override.end_date
|
||||||
|
)
|
||||||
|
if end < start:
|
||||||
|
return 0
|
||||||
|
start_date, end_date = start.isoformat(), end.isoformat()
|
||||||
|
child_id = override.child_id
|
||||||
|
|
||||||
|
snapshots, schedules_records, overrides_records = await (
|
||||||
|
load_materialization_input(database, start_date, end_date)
|
||||||
|
)
|
||||||
|
current = _generation_preview(
|
||||||
|
snapshots,
|
||||||
|
_build_engine(schedules_records, overrides_records),
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
child_filter={child_id},
|
||||||
|
)
|
||||||
|
proposed = set(
|
||||||
|
_generation_preview(
|
||||||
|
snapshots,
|
||||||
|
_build_engine(
|
||||||
|
schedules_records,
|
||||||
|
overrides_records,
|
||||||
|
extra_overrides=(override,),
|
||||||
|
),
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
child_filter={child_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
removed = [generated for generated in current if generated not in proposed]
|
||||||
|
if not removed:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
stored = await QuestInstancesDao(database).list_by_date_range(
|
||||||
|
child_id, start_date, end_date
|
||||||
|
)
|
||||||
|
touched = {
|
||||||
|
event.instance_id
|
||||||
|
for event in await CompletionEventsDao(
|
||||||
|
database
|
||||||
|
).list_by_child_and_date_range(child_id, start_date, end_date)
|
||||||
|
}
|
||||||
|
completed = {
|
||||||
|
(instance.definition_id, instance.due_date, instance.window)
|
||||||
|
for instance in stored
|
||||||
|
if instance.id in touched
|
||||||
|
}
|
||||||
|
return sum(
|
||||||
|
1
|
||||||
|
for generated in removed
|
||||||
|
if (generated.definition_id, generated.due_date, generated.window)
|
||||||
|
not in completed
|
||||||
|
)
|
||||||
+197
-3
@@ -24,9 +24,17 @@ names before anything else assumes the new model. Migration 3 brings
|
|||||||
pre-D-008 databases to the multi-assignee model: it creates
|
pre-D-008 databases to the multi-assignee model: it creates
|
||||||
``quest_definition_assignees`` and, when ``quest_definitions`` still
|
``quest_definition_assignees`` and, when ``quest_definitions`` still
|
||||||
carries a single ``child_id`` column, rebuilds the table without it,
|
carries a single ``child_id`` column, rebuilds the table without it,
|
||||||
copying each definition's assignee across. Each callable migration
|
copying each definition's assignee across. Migration 4 adds the
|
||||||
runs inside its own transaction together with its version stamp, so a
|
windows table, migration 5 rebuilds ``quest_instances`` onto the
|
||||||
crash mid-step rolls the statements and the stamp back together.
|
widened (definition_id, child_id, due_date, window) key, and migration
|
||||||
|
6 rebuilds ``completion_events`` with the D-008 ``actor_child_id``
|
||||||
|
column. Migration 7 adds the generic ``nestquest_meta_state``
|
||||||
|
key/value table for integration-owned bookkeeping (Feature 11's
|
||||||
|
missed-sweep watermark). Migration 8 adds the ``skip_on_away``
|
||||||
|
column to ``quest_definitions`` (Feature 22), defaulting to 1. Each
|
||||||
|
callable migration runs inside its own transaction
|
||||||
|
together with its version stamp, so a crash mid-step rolls the
|
||||||
|
statements and the stamp back together.
|
||||||
|
|
||||||
Migration entries are either an ordered sequence of SQL statements or
|
Migration entries are either an ordered sequence of SQL statements or
|
||||||
an async callable taking ``(database, target_version)``. Callables
|
an async callable taking ``(database, target_version)``. Callables
|
||||||
@@ -70,9 +78,13 @@ from typing import Union
|
|||||||
|
|
||||||
from .db import NestQuestDatabase
|
from .db import NestQuestDatabase
|
||||||
from .schema import (
|
from .schema import (
|
||||||
|
COMPLETION_EVENTS_TABLE_SQL,
|
||||||
QUEST_DEFINITIONS_TABLE_SQL,
|
QUEST_DEFINITIONS_TABLE_SQL,
|
||||||
|
QUEST_INSTANCES_TABLE_SQL,
|
||||||
SCHEMA_V1_STATEMENTS,
|
SCHEMA_V1_STATEMENTS,
|
||||||
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
|
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
|
||||||
|
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
|
||||||
|
SCHEMA_V7_META_STATE_DDL,
|
||||||
)
|
)
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
@@ -146,6 +158,14 @@ async def _table_has_column(
|
|||||||
return any(row[1] == column for row in rows)
|
return any(row[1] == column for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
#: Window assigned to instances migrated from the pre-window instance
|
||||||
|
#: shape (D-008). Pre-window rows were generated for whole-day
|
||||||
|
#: obligations; the morning window is the documented default landing
|
||||||
|
#: spot, and future dates are regenerated with real window declarations
|
||||||
|
#: by materialization (delete_future_uncompleted + re-run).
|
||||||
|
LEGACY_MIGRATED_WINDOW = "morning"
|
||||||
|
|
||||||
|
|
||||||
async def _rename_legacy_task_tables(
|
async def _rename_legacy_task_tables(
|
||||||
database: NestQuestDatabase, target_version: int
|
database: NestQuestDatabase, target_version: int
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -249,6 +269,160 @@ async def _add_definition_assignees(
|
|||||||
await database.execute("PRAGMA foreign_keys = ON")
|
await database.execute("PRAGMA foreign_keys = ON")
|
||||||
|
|
||||||
|
|
||||||
|
async def _widen_instance_key(
|
||||||
|
database: NestQuestDatabase, target_version: int
|
||||||
|
) -> None:
|
||||||
|
"""Bring ``quest_instances`` to the D-008 instance key.
|
||||||
|
|
||||||
|
Databases whose instance table still carries the pre-window shape
|
||||||
|
(no ``window`` column, UNIQUE on (definition_id, due_date)) are
|
||||||
|
rebuilt via SQLite's canonical procedure into the widened shape —
|
||||||
|
UNIQUE (definition_id, child_id, due_date, window) — with every
|
||||||
|
migrated row landing in :data:`LEGACY_MIGRATED_WINDOW` so existing
|
||||||
|
ids (and the completion events referencing them) survive intact.
|
||||||
|
The old rows were unique per (definition, due_date), so pinning one
|
||||||
|
window cannot collide with the widened key. Fresh databases whose
|
||||||
|
table already matches the canonical shape only stamp.
|
||||||
|
|
||||||
|
The rebuild runs with ``foreign_keys = OFF`` (dropping the old
|
||||||
|
table under enforcement would fire the implicit DELETE and violate
|
||||||
|
the ``completion_events`` foreign key) and verifies
|
||||||
|
``PRAGMA foreign_key_check`` inside the transaction before the
|
||||||
|
stamp, so a corrupt copy aborts the whole migration.
|
||||||
|
"""
|
||||||
|
legacy_shape = not await _table_has_column(
|
||||||
|
database, "quest_instances", "window"
|
||||||
|
)
|
||||||
|
await database.execute("PRAGMA foreign_keys = OFF")
|
||||||
|
try:
|
||||||
|
async with database.transaction():
|
||||||
|
if legacy_shape:
|
||||||
|
await database.execute(
|
||||||
|
"CREATE TABLE quest_instances_rebuilt "
|
||||||
|
f"{QUEST_INSTANCES_TABLE_SQL}"
|
||||||
|
)
|
||||||
|
await database.execute(
|
||||||
|
"INSERT INTO quest_instances_rebuilt "
|
||||||
|
"(id, definition_id, child_id, window, due_date, "
|
||||||
|
"due_time, generated_at) "
|
||||||
|
"SELECT id, definition_id, child_id, ?, due_date, "
|
||||||
|
"due_time, generated_at "
|
||||||
|
"FROM quest_instances ORDER BY id",
|
||||||
|
(LEGACY_MIGRATED_WINDOW,),
|
||||||
|
)
|
||||||
|
await database.execute("DROP TABLE quest_instances")
|
||||||
|
await database.execute(
|
||||||
|
"ALTER TABLE quest_instances_rebuilt "
|
||||||
|
"RENAME TO quest_instances"
|
||||||
|
)
|
||||||
|
violations = await database.fetch_all(
|
||||||
|
"PRAGMA foreign_key_check"
|
||||||
|
)
|
||||||
|
if violations:
|
||||||
|
raise RuntimeError(
|
||||||
|
"instance rebuild produced foreign-key "
|
||||||
|
f"violations: {violations!r}"
|
||||||
|
)
|
||||||
|
LOGGER.info(
|
||||||
|
"Widened quest_instances key; migrated rows pinned "
|
||||||
|
"to the %r window",
|
||||||
|
LEGACY_MIGRATED_WINDOW,
|
||||||
|
)
|
||||||
|
await stamp_schema_version(database, target_version)
|
||||||
|
finally:
|
||||||
|
await database.execute("PRAGMA foreign_keys = ON")
|
||||||
|
|
||||||
|
|
||||||
|
async def _add_actor_child_id(
|
||||||
|
database: NestQuestDatabase, target_version: int
|
||||||
|
) -> None:
|
||||||
|
"""Bring ``completion_events`` to the D-008 actor-child shape.
|
||||||
|
|
||||||
|
The ``actor_child_id`` column records which panel profile was
|
||||||
|
tapped; it cannot be added with ``ALTER TABLE ADD COLUMN`` alone
|
||||||
|
because the canonical shape also carries an actor-pair coherence
|
||||||
|
CHECK (panel events require it, user events forbid it), and CHECK
|
||||||
|
constraints cannot be added to an existing table. Databases with
|
||||||
|
the legacy shape are therefore rebuilt via SQLite's canonical
|
||||||
|
procedure, backfilling each legacy panel row's ``actor_child_id``
|
||||||
|
to ``child_id`` — pre-D-008 panel completions were always recorded
|
||||||
|
against the tapped child themselves — while admin rows stay NULL.
|
||||||
|
Row ids are preserved so ``quest_instances`` references and the
|
||||||
|
append-only history stay intact. Fresh databases already match
|
||||||
|
the canonical shape and only stamp.
|
||||||
|
|
||||||
|
The rebuild runs with ``foreign_keys = OFF`` and verifies
|
||||||
|
``PRAGMA foreign_key_check`` inside the transaction before the
|
||||||
|
stamp. The copy is an INSERT..SELECT, not an UPDATE: the
|
||||||
|
completion_events table keeps its append-only guarantee.
|
||||||
|
"""
|
||||||
|
legacy_shape = not await _table_has_column(
|
||||||
|
database, "completion_events", "actor_child_id"
|
||||||
|
)
|
||||||
|
await database.execute("PRAGMA foreign_keys = OFF")
|
||||||
|
try:
|
||||||
|
async with database.transaction():
|
||||||
|
if legacy_shape:
|
||||||
|
await database.execute(
|
||||||
|
"CREATE TABLE completion_events_rebuilt "
|
||||||
|
f"{COMPLETION_EVENTS_TABLE_SQL}"
|
||||||
|
)
|
||||||
|
await database.execute(
|
||||||
|
"INSERT INTO completion_events_rebuilt "
|
||||||
|
"(id, instance_id, child_id, event_type, actor_source, "
|
||||||
|
"actor_user_id, actor_child_id, occurred_at, "
|
||||||
|
"was_on_time) "
|
||||||
|
"SELECT id, instance_id, child_id, event_type, "
|
||||||
|
"actor_source, actor_user_id, "
|
||||||
|
"CASE WHEN actor_source = 'panel' THEN child_id "
|
||||||
|
"ELSE NULL END, "
|
||||||
|
"occurred_at, was_on_time "
|
||||||
|
"FROM completion_events ORDER BY id"
|
||||||
|
)
|
||||||
|
await database.execute("DROP TABLE completion_events")
|
||||||
|
await database.execute(
|
||||||
|
"ALTER TABLE completion_events_rebuilt "
|
||||||
|
"RENAME TO completion_events"
|
||||||
|
)
|
||||||
|
violations = await database.fetch_all(
|
||||||
|
"PRAGMA foreign_key_check"
|
||||||
|
)
|
||||||
|
if violations:
|
||||||
|
raise RuntimeError(
|
||||||
|
"completion_events rebuild produced foreign-key "
|
||||||
|
f"violations: {violations!r}"
|
||||||
|
)
|
||||||
|
LOGGER.info(
|
||||||
|
"Added actor_child_id to completion_events; legacy "
|
||||||
|
"panel rows backfilled to their own child_id"
|
||||||
|
)
|
||||||
|
await stamp_schema_version(database, target_version)
|
||||||
|
finally:
|
||||||
|
await database.execute("PRAGMA foreign_keys = ON")
|
||||||
|
|
||||||
|
|
||||||
|
async def _add_skip_on_away(
|
||||||
|
database: NestQuestDatabase, target_version: int
|
||||||
|
) -> None:
|
||||||
|
"""Add the ``skip_on_away`` column to ``quest_definitions``.
|
||||||
|
|
||||||
|
Strictly additive: ``ALTER TABLE ... ADD COLUMN`` with a NOT NULL
|
||||||
|
DEFAULT 1, so existing rows read as 1 (skip absent dates — the
|
||||||
|
behaviour before the flag existed). Fresh databases already carry
|
||||||
|
the column from the canonical DDL, so the ALTER is guarded and the
|
||||||
|
step only stamps there.
|
||||||
|
"""
|
||||||
|
async with database.transaction():
|
||||||
|
if not await _table_has_column(
|
||||||
|
database, "quest_definitions", "skip_on_away"
|
||||||
|
):
|
||||||
|
await database.execute(
|
||||||
|
"ALTER TABLE quest_definitions ADD COLUMN "
|
||||||
|
"skip_on_away INTEGER NOT NULL DEFAULT 1"
|
||||||
|
)
|
||||||
|
await stamp_schema_version(database, target_version)
|
||||||
|
|
||||||
|
|
||||||
#: The ordered migration list. Append-only: never edit an applied
|
#: The ordered migration list. Append-only: never edit an applied
|
||||||
#: entry, add the next one instead. (Migration 1's content was
|
#: entry, add the next one instead. (Migration 1's content was
|
||||||
#: rewritten pre-release per D-007 — no version 1 database shipped.)
|
#: rewritten pre-release per D-007 — no version 1 database shipped.)
|
||||||
@@ -256,6 +430,26 @@ MIGRATIONS: Sequence[MigrationStep] = [
|
|||||||
MIGRATION_1_V1_DDL,
|
MIGRATION_1_V1_DDL,
|
||||||
_rename_legacy_task_tables,
|
_rename_legacy_task_tables,
|
||||||
_add_definition_assignees,
|
_add_definition_assignees,
|
||||||
|
# Migration 4 (D-008): the windows table is a plain new table — a
|
||||||
|
# CREATE IF NOT EXISTS suffices for both fresh databases (which
|
||||||
|
# already made it in migration 1) and pre-window databases.
|
||||||
|
list(SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL),
|
||||||
|
# Migration 5 (D-008): rebuild quest_instances onto the widened
|
||||||
|
# (definition_id, child_id, due_date, window) key.
|
||||||
|
_widen_instance_key,
|
||||||
|
# Migration 6 (D-008): rebuild completion_events with the
|
||||||
|
# actor_child_id column and its actor-pair coherence CHECK,
|
||||||
|
# backfilling legacy panel rows to their own child_id.
|
||||||
|
_add_actor_child_id,
|
||||||
|
# Migration 7 (Feature 11): the generic meta_state key/value table
|
||||||
|
# — a plain new table, so CREATE IF NOT EXISTS suffices for both
|
||||||
|
# fresh databases (which already made it in migration 1) and
|
||||||
|
# pre-v7 databases. Strictly additive; no existing table changes.
|
||||||
|
list(SCHEMA_V7_META_STATE_DDL),
|
||||||
|
# Migration 8 (Feature 22): the skip_on_away flag on
|
||||||
|
# quest_definitions — an additive column defaulting to 1, so every
|
||||||
|
# pre-existing definition keeps today's skip-when-absent behaviour.
|
||||||
|
_add_skip_on_away,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
"""The presence and custody model (Feature 05).
|
||||||
|
|
||||||
|
PURE module: no Home Assistant imports, no database access, no
|
||||||
|
timezone logic — it operates on plain calendar dates only (feature
|
||||||
|
guardrail). The later engine (cycle position, ``is_present``,
|
||||||
|
overrides, previews) builds directly on this model.
|
||||||
|
|
||||||
|
A :class:`PresenceSchedule` answers "on which weekdays of which cycle
|
||||||
|
week is this child at this house?" for an N-week repeating custody
|
||||||
|
pattern. Two of the household's three children follow a two-week
|
||||||
|
rotation; a child with NO schedule row is present every day (that
|
||||||
|
default lives in the engine, not here).
|
||||||
|
|
||||||
|
Pattern encoding: the model's canonical serialization is the SAME
|
||||||
|
pipe-separated CSV the database stores (see schema.py's
|
||||||
|
``presence_schedules.pattern``): segment ``i`` (week index ``i``) is a
|
||||||
|
comma-separated list of present weekdays, 0 = Monday .. 6 = Sunday,
|
||||||
|
e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in week 1. An
|
||||||
|
empty segment means absent every day of that week. Encoding sorts
|
||||||
|
each segment's weekdays and uses the exact segment order, so
|
||||||
|
``decode(encode(schedule))`` round-trips losslessly and two schedules
|
||||||
|
with equal patterns produce equal strings.
|
||||||
|
|
||||||
|
Anchor-date policy: ``anchor_date`` pins the cycle. Cycle position is
|
||||||
|
computed from anchor-date arithmetic ONLY — never ISO week numbers or
|
||||||
|
week parity (D-004): 53-week years silently invert parity on January
|
||||||
|
1st. The arithmetic lives in the engine task; this module validates
|
||||||
|
that the anchor is a strict calendar date.
|
||||||
|
|
||||||
|
Validation policy: construction is total — every reject happens in
|
||||||
|
``__init__`` so no invalid schedule can ever exist:
|
||||||
|
- ``cycle_length_weeks`` must be an integer 1..4. The 4 cap matches
|
||||||
|
the database CHECK (schema.py) and keeps anchor arithmetic and the
|
||||||
|
encoded pattern human-readable; revisit both together if a longer
|
||||||
|
cycle is ever needed.
|
||||||
|
- ``pattern`` must cover week indices 0..cycle_length_weeks-1 exactly
|
||||||
|
(no gaps, no extra weeks) with sets of integers 0..6 (bools
|
||||||
|
rejected: True would silently mean Monday).
|
||||||
|
- ``anchor_date`` must be a strict ISO YYYY-MM-DD calendar date (or a
|
||||||
|
``datetime.date``).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from types import MappingProxyType
|
||||||
|
|
||||||
|
#: The schema's documented cap for cycle_length_weeks (schema.py keeps
|
||||||
|
#: the pattern human-readable and the anchor arithmetic small).
|
||||||
|
MAX_CYCLE_LENGTH_WEEKS = 4
|
||||||
|
|
||||||
|
_DATE_FORMAT = "%Y-%m-%d"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value: object, field_name: str) -> datetime.date:
|
||||||
|
"""Accept a datetime.date or a strict YYYY-MM-DD string.
|
||||||
|
|
||||||
|
``datetime.datetime`` is rejected explicitly: it SUBCLASSES
|
||||||
|
datetime.date, so a bare isinstance check would store a value with
|
||||||
|
a time component and every later calendar-date comparison (e.g.
|
||||||
|
``PresenceOverride.covers``) would raise TypeError.
|
||||||
|
"""
|
||||||
|
if isinstance(value, datetime.datetime):
|
||||||
|
raise ValueError(
|
||||||
|
f"{field_name} must be a plain calendar date, not a "
|
||||||
|
f"datetime with a time component: {value!r}"
|
||||||
|
)
|
||||||
|
if isinstance(value, datetime.date):
|
||||||
|
return value
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(
|
||||||
|
f"{field_name} must be a datetime.date or an ISO date string, "
|
||||||
|
f"got {value!r}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError(
|
||||||
|
f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}"
|
||||||
|
) from None
|
||||||
|
if parsed.isoformat() != value:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}"
|
||||||
|
)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_week_index(week: object, cycle_length_weeks: int) -> int:
|
||||||
|
"""Reject non-int week indices (bools included) and out-of-cycle."""
|
||||||
|
if isinstance(week, bool) or not isinstance(week, int):
|
||||||
|
raise ValueError(
|
||||||
|
f"pattern week index must be an integer, got {week!r}"
|
||||||
|
)
|
||||||
|
if not 0 <= week < cycle_length_weeks:
|
||||||
|
raise ValueError(
|
||||||
|
f"pattern week index must be in 0..{cycle_length_weeks - 1}, "
|
||||||
|
f"got {week}"
|
||||||
|
)
|
||||||
|
return week
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_weekday_set(week: int, weekdays: object) -> frozenset[int]:
|
||||||
|
"""Validate one week's present-weekday set."""
|
||||||
|
if isinstance(weekdays, (str, bytes)) or weekdays is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"pattern[{week}] must be an iterable of weekday ints 0..6, "
|
||||||
|
f"got {weekdays!r}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
members = list(weekdays)
|
||||||
|
except TypeError:
|
||||||
|
raise ValueError(
|
||||||
|
f"pattern[{week}] must be an iterable of weekday ints 0..6, "
|
||||||
|
f"got {weekdays!r}"
|
||||||
|
) from None
|
||||||
|
for member in members:
|
||||||
|
if isinstance(member, bool) or not isinstance(member, int):
|
||||||
|
raise ValueError(
|
||||||
|
f"pattern[{week}] weekday must be an int 0..6, got {member!r}"
|
||||||
|
)
|
||||||
|
if not 0 <= member <= 6:
|
||||||
|
raise ValueError(
|
||||||
|
f"pattern[{week}] weekday must be in 0..6 (Monday=0), "
|
||||||
|
f"got {member}"
|
||||||
|
)
|
||||||
|
return frozenset(members)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_cycle_length(value: object) -> int:
|
||||||
|
"""Reject non-int and out-of-range cycle lengths (bools included)."""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise ValueError(
|
||||||
|
f"cycle_length_weeks must be an integer, got {value!r}"
|
||||||
|
)
|
||||||
|
if not 1 <= value <= MAX_CYCLE_LENGTH_WEEKS:
|
||||||
|
raise ValueError(
|
||||||
|
f"cycle_length_weeks must be between 1 and "
|
||||||
|
f"{MAX_CYCLE_LENGTH_WEEKS}, got {value}"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_child_id(value: object) -> int:
|
||||||
|
"""Reject non-int child ids (bools would silently address child 1)."""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise ValueError(f"child_id must be an integer, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, init=False)
|
||||||
|
class PresenceSchedule:
|
||||||
|
"""One child's N-week repeating presence pattern.
|
||||||
|
|
||||||
|
``pattern`` maps each week index of the cycle (``0`` .. ``n-1``)
|
||||||
|
to the set of present weekdays (ints, Monday=0). A week with an
|
||||||
|
empty set means the child is absent every day of that week — which
|
||||||
|
is distinct from having no schedule at all (the engine treats a
|
||||||
|
missing schedule as present every day).
|
||||||
|
"""
|
||||||
|
|
||||||
|
child_id: int
|
||||||
|
cycle_length_weeks: int
|
||||||
|
anchor_date: datetime.date
|
||||||
|
pattern: MappingProxyType # week index -> frozenset of weekdays
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
child_id: int,
|
||||||
|
cycle_length_weeks: int,
|
||||||
|
anchor_date: object,
|
||||||
|
pattern: dict[int, object],
|
||||||
|
) -> None:
|
||||||
|
object.__setattr__(self, "child_id", _validate_child_id(child_id))
|
||||||
|
cycle = _validate_cycle_length(cycle_length_weeks)
|
||||||
|
object.__setattr__(self, "cycle_length_weeks", cycle)
|
||||||
|
object.__setattr__(
|
||||||
|
self, "anchor_date", _parse_date(anchor_date, "anchor_date")
|
||||||
|
)
|
||||||
|
if not isinstance(pattern, dict):
|
||||||
|
raise ValueError(
|
||||||
|
"pattern must map week index to a set of present "
|
||||||
|
f"weekdays, got {pattern!r}"
|
||||||
|
)
|
||||||
|
normalised: dict[int, frozenset[int]] = {}
|
||||||
|
for week, weekdays in pattern.items():
|
||||||
|
index = _validate_week_index(week, cycle)
|
||||||
|
normalised[index] = _validate_weekday_set(index, weekdays)
|
||||||
|
missing = [
|
||||||
|
week for week in range(cycle) if week not in normalised
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
f"pattern must cover week indices 0..{cycle - 1}; "
|
||||||
|
f"missing {missing}"
|
||||||
|
)
|
||||||
|
# Frozen must mean frozen: a bare dict would let callers inject
|
||||||
|
# unvalidated weekdays (or drop required weeks) AFTER
|
||||||
|
# construction, bypassing total validation. The mapping
|
||||||
|
# proxy exposes the validated contents read-only; the
|
||||||
|
# per-week sets are already frozensets.
|
||||||
|
object.__setattr__(self, "pattern", MappingProxyType(normalised))
|
||||||
|
|
||||||
|
def encode(self) -> str:
|
||||||
|
"""Serialize to the database's pipe-separated CSV shape.
|
||||||
|
|
||||||
|
Segments appear in week order; each segment is the week's
|
||||||
|
present weekdays as a sorted comma-separated list (an empty set
|
||||||
|
encodes as an empty segment). Two schedules with equal
|
||||||
|
patterns encode identically.
|
||||||
|
"""
|
||||||
|
segments = [
|
||||||
|
",".join(str(day) for day in sorted(self.pattern[week]))
|
||||||
|
for week in range(self.cycle_length_weeks)
|
||||||
|
]
|
||||||
|
return "|".join(segments)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def decode(
|
||||||
|
cls,
|
||||||
|
child_id: int,
|
||||||
|
anchor_date: object,
|
||||||
|
encoded: str,
|
||||||
|
) -> "PresenceSchedule":
|
||||||
|
"""Build a schedule from its encoded pattern.
|
||||||
|
|
||||||
|
The cycle length is derived from the segment count, mirroring
|
||||||
|
the database's segment-count CHECK. Decoding is strict: wrong
|
||||||
|
shapes, non-numeric or out-of-range weekdays are rejected —
|
||||||
|
the database CHECKs make such rows unrepresentable, and this
|
||||||
|
is the same contract on the model side. A segment count above
|
||||||
|
the documented cycle cap is rejected by the ordinary
|
||||||
|
construction validation.
|
||||||
|
"""
|
||||||
|
if not isinstance(encoded, str):
|
||||||
|
raise ValueError(
|
||||||
|
f"encoded pattern must be a string, got {encoded!r}"
|
||||||
|
)
|
||||||
|
segments = encoded.split("|")
|
||||||
|
pattern: dict[int, set[int]] = {}
|
||||||
|
for week, segment in enumerate(segments):
|
||||||
|
weekdays: set[int] = set()
|
||||||
|
if segment:
|
||||||
|
for part in segment.split(","):
|
||||||
|
if len(part) != 1 or part not in "0123456":
|
||||||
|
raise ValueError(
|
||||||
|
f"encoded segment {week} carries an invalid "
|
||||||
|
f"weekday {part!r} (single digits 0-6 only)"
|
||||||
|
)
|
||||||
|
weekdays.add(int(part))
|
||||||
|
pattern[week] = weekdays
|
||||||
|
return cls(child_id, len(segments), anchor_date, pattern)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, init=False)
|
||||||
|
class PresenceOverride:
|
||||||
|
"""A date-specific present/absent flag beating any pattern.
|
||||||
|
|
||||||
|
Covers a single date (``end_date == start_date``) or an inclusive
|
||||||
|
range. A concrete range is the storage shape
|
||||||
|
(``presence_overrides.end_date`` is NOT NULL), so the model
|
||||||
|
requires both bounds and validates start <= end. Construction and
|
||||||
|
validation live here so the engine can trust every instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
child_id: int
|
||||||
|
start_date: datetime.date
|
||||||
|
end_date: datetime.date
|
||||||
|
is_present: bool
|
||||||
|
note: str | None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
child_id: int,
|
||||||
|
start_date: object,
|
||||||
|
end_date: object,
|
||||||
|
is_present: bool,
|
||||||
|
note: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
object.__setattr__(self, "child_id", _validate_child_id(child_id))
|
||||||
|
start = _parse_date(start_date, "start_date")
|
||||||
|
end = _parse_date(end_date, "end_date")
|
||||||
|
if end < start:
|
||||||
|
raise ValueError(
|
||||||
|
f"end_date must be on or after start_date, got "
|
||||||
|
f"{end.isoformat()} < {start.isoformat()}"
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "start_date", start)
|
||||||
|
object.__setattr__(self, "end_date", end)
|
||||||
|
if not isinstance(is_present, bool):
|
||||||
|
raise ValueError(
|
||||||
|
f"is_present must be a real bool, got {is_present!r}"
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "is_present", is_present)
|
||||||
|
if note is not None and not isinstance(note, str):
|
||||||
|
raise ValueError(f"note must be a string or None, got {note!r}")
|
||||||
|
object.__setattr__(self, "note", note)
|
||||||
|
|
||||||
|
def covers(self, date: datetime.date) -> bool:
|
||||||
|
"""Return True when the override applies to ``date``."""
|
||||||
|
return self.start_date <= date <= self.end_date
|
||||||
|
|
||||||
|
|
||||||
|
def cycle_week_index(
|
||||||
|
schedule: PresenceSchedule, target_date: object
|
||||||
|
) -> int:
|
||||||
|
"""Return the cycle week index ``target_date`` falls in.
|
||||||
|
|
||||||
|
Anchor-date arithmetic ONLY (D-004): ``((target_date -
|
||||||
|
anchor_date).days // 7) % cycle_length_weeks``. Python's floor
|
||||||
|
division makes dates BEFORE the anchor behave correctly — they
|
||||||
|
walk the cycle backwards (day -1 is the last week of the cycle,
|
||||||
|
day -8 wraps one further) instead of inverting, which is exactly
|
||||||
|
what ISO week parity does wrong in 53-week years. The modulo of
|
||||||
|
a negative in Python is non-negative, so the index is always a
|
||||||
|
valid week index.
|
||||||
|
"""
|
||||||
|
target = _parse_date(target_date, "target_date")
|
||||||
|
days = (target - schedule.anchor_date).days
|
||||||
|
return (days // 7) % schedule.cycle_length_weeks
|
||||||
|
|
||||||
|
|
||||||
|
#: Search cap for :meth:`PresenceEngine.next_present_dates`: two full
|
||||||
|
#: years of daily checks. A child absent every day (an all-absent
|
||||||
|
#: pattern with a blocking override) would otherwise loop forever; the
|
||||||
|
#: cap bounds the scan and surfaces the situation as an error instead.
|
||||||
|
MAX_PREVIEW_SCAN_DAYS = 366 * 2
|
||||||
|
|
||||||
|
|
||||||
|
class PresenceEngine:
|
||||||
|
"""Answers "is this child at this house on this date?".
|
||||||
|
|
||||||
|
Pure and side-effect-free: built from an immutable snapshot of the
|
||||||
|
children's schedules (``child_id -> PresenceSchedule``), it never
|
||||||
|
touches the database — callers build a snapshot from the DAO layer
|
||||||
|
and pass it in. A child with NO schedule entry is present every
|
||||||
|
day (the third household child's case); a child WITH a schedule is
|
||||||
|
present exactly when the date's weekday is in that schedule's
|
||||||
|
pattern for the date's cycle week index (anchor-date arithmetic —
|
||||||
|
:func:`cycle_week_index`, never ISO week parity).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
schedules: dict[int, PresenceSchedule],
|
||||||
|
overrides: dict[int, list[PresenceOverride]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(schedules, dict):
|
||||||
|
raise ValueError(
|
||||||
|
f"schedules must map child_id to PresenceSchedule, got "
|
||||||
|
f"{schedules!r}"
|
||||||
|
)
|
||||||
|
for child_id, schedule in schedules.items():
|
||||||
|
# Validate the KEY as a model child id too: True and 1.0
|
||||||
|
# compare equal to 1, so {True: schedule(1)} would be
|
||||||
|
# accepted and then behave differently on lookup.
|
||||||
|
_validate_child_id(child_id)
|
||||||
|
if not isinstance(schedule, PresenceSchedule):
|
||||||
|
raise ValueError(
|
||||||
|
f"schedules[{child_id!r}] must be a PresenceSchedule, "
|
||||||
|
f"got {schedule!r}"
|
||||||
|
)
|
||||||
|
if schedule.child_id != child_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"schedules is keyed {child_id!r} but holds the "
|
||||||
|
f"schedule of child {schedule.child_id}"
|
||||||
|
)
|
||||||
|
if overrides is None:
|
||||||
|
overrides = {}
|
||||||
|
if not isinstance(overrides, dict):
|
||||||
|
raise ValueError(
|
||||||
|
"overrides must map child_id to a list of "
|
||||||
|
f"PresenceOverride, got {overrides!r}"
|
||||||
|
)
|
||||||
|
for child_id, entries in overrides.items():
|
||||||
|
_validate_child_id(child_id)
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
raise ValueError(
|
||||||
|
f"overrides[{child_id!r}] must be a list of "
|
||||||
|
f"PresenceOverride, got {entries!r}"
|
||||||
|
)
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, PresenceOverride):
|
||||||
|
raise ValueError(
|
||||||
|
f"overrides[{child_id!r}] entries must be "
|
||||||
|
f"PresenceOverride, got {entry!r}"
|
||||||
|
)
|
||||||
|
if entry.child_id != child_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"overrides is keyed {child_id!r} but holds an "
|
||||||
|
f"override of child {entry.child_id}"
|
||||||
|
)
|
||||||
|
# Read-only snapshot: reassignment is refused and no mutator
|
||||||
|
# exists, so a built engine answers consistently for its
|
||||||
|
# lifetime. The overrides lists are copied to immutable
|
||||||
|
# tuples — a shallow mapping copy alone would leave caller-
|
||||||
|
# owned lists reachable through _overrides, and mutating one
|
||||||
|
# later would change is_present results.
|
||||||
|
object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules)))
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"_overrides",
|
||||||
|
MappingProxyType(
|
||||||
|
{child_id: tuple(entries) for child_id, entries in overrides.items()}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_present(self, child_id: object, date: object) -> bool:
|
||||||
|
"""Return whether ``child_id`` is present on ``date``.
|
||||||
|
|
||||||
|
Overrides take precedence over the repeating pattern: when an
|
||||||
|
override covers the date, its flag IS the answer — including
|
||||||
|
for a child with no schedule at all. The DAO layer guarantees
|
||||||
|
a child's overrides never overlap; if a snapshot still carries
|
||||||
|
two covering the same date, the one with the LATEST start date
|
||||||
|
wins (deterministic, and matches "the most specific swap wins").
|
||||||
|
"""
|
||||||
|
if isinstance(child_id, bool) or not isinstance(child_id, int):
|
||||||
|
raise ValueError(f"child_id must be an integer, got {child_id!r}")
|
||||||
|
target = _parse_date(date, "date")
|
||||||
|
entries = self._overrides.get(child_id) or []
|
||||||
|
covering = [
|
||||||
|
entry for entry in entries if entry.covers(target)
|
||||||
|
]
|
||||||
|
if covering:
|
||||||
|
winner = max(covering, key=lambda entry: entry.start_date)
|
||||||
|
return winner.is_present
|
||||||
|
schedule = self._schedules.get(child_id)
|
||||||
|
if schedule is None:
|
||||||
|
# No schedule at all: present every day (household default).
|
||||||
|
return True
|
||||||
|
week = cycle_week_index(schedule, target)
|
||||||
|
return target.weekday() in schedule.pattern[week]
|
||||||
|
|
||||||
|
def next_present_dates(
|
||||||
|
self,
|
||||||
|
child_id: object,
|
||||||
|
start_date: object,
|
||||||
|
count: int,
|
||||||
|
) -> list[datetime.date]:
|
||||||
|
"""Return the next ``count`` dates the child is present.
|
||||||
|
|
||||||
|
Walks forward from ``start_date`` (INCLUSIVE — a parent
|
||||||
|
previewing "starting Monday" expects Monday itself when the
|
||||||
|
child is present) checking :meth:`is_present` for every day,
|
||||||
|
so overrides and the pattern are honoured identically. The
|
||||||
|
admin Schedule tab renders this preview so a parent can confirm
|
||||||
|
a custody pattern is right before saving it.
|
||||||
|
|
||||||
|
A child absent every scanned day raises ValueError after the
|
||||||
|
two-year search cap — an always-absent child would otherwise
|
||||||
|
loop forever. ``count`` must be a positive integer.
|
||||||
|
"""
|
||||||
|
_validate_child_id(child_id)
|
||||||
|
start = _parse_date(start_date, "start_date")
|
||||||
|
if isinstance(count, bool) or not isinstance(count, int):
|
||||||
|
raise ValueError(f"count must be an integer, got {count!r}")
|
||||||
|
if count < 1:
|
||||||
|
raise ValueError(f"count must be at least 1, got {count}")
|
||||||
|
found: list[datetime.date] = []
|
||||||
|
day = start
|
||||||
|
for _ in range(MAX_PREVIEW_SCAN_DAYS):
|
||||||
|
if self.is_present(child_id, day):
|
||||||
|
found.append(day)
|
||||||
|
if len(found) == count:
|
||||||
|
return found
|
||||||
|
day += datetime.timedelta(days=1)
|
||||||
|
raise ValueError(
|
||||||
|
f"child {child_id} is not present on any of the "
|
||||||
|
f"{MAX_PREVIEW_SCAN_DAYS} days from {start.isoformat()}; "
|
||||||
|
f"{count} present day(s) cannot be previewed"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __setattr__(self, name: object, value: object) -> None:
|
||||||
|
raise AttributeError(
|
||||||
|
"PresenceEngine is an immutable snapshot; build a new engine "
|
||||||
|
"instead of mutating this one"
|
||||||
|
)
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"""Business layer for presence writes (Feature 09, API admin plane).
|
||||||
|
|
||||||
|
Sits between the write callers (the Feature 09 HA service handlers and
|
||||||
|
the API admin plane's presence routes) and the typed presence DAOs plus
|
||||||
|
the pure presence model: callers get the model validation, the upsert /
|
||||||
|
create / delete operations and the regeneration side effect here, and
|
||||||
|
never touch the DAO or SQL directly. This is the ONE implementation of
|
||||||
|
these business rules — every caller constructs the same
|
||||||
|
:class:`~.presence.PresenceSchedule` / :class:`~.presence.PresenceOverride`
|
||||||
|
models (whose constructors are total, so no re-validation happens in a
|
||||||
|
handler) and gets the same post-write
|
||||||
|
:func:`~.materialize.regenerate_for_child`, so a child's future quest
|
||||||
|
instances track its changed presence no matter which plane issued the
|
||||||
|
write. Like the model it is Home-Assistant-free: ``today`` is a plain
|
||||||
|
calendar date threaded through to the regeneration, defaulting to the
|
||||||
|
host clock there.
|
||||||
|
|
||||||
|
Upsert policy (Feature 05, unchanged): a child has AT MOST ONE presence
|
||||||
|
schedule — setting again REPLACES the existing row rather than
|
||||||
|
duplicating it. Overrides are deletable by design (children are not —
|
||||||
|
the children layer owns that guardrail). Deleting a nonexistent
|
||||||
|
override raises ValueError prefixed ``override_id:`` so a caller can
|
||||||
|
distinguish "unknown id" from a rejected argument the same way the
|
||||||
|
quest-definitions layer prefixes ``definition_id:``.
|
||||||
|
|
||||||
|
The admin plane's presence READS go through here too
|
||||||
|
(:func:`get_presence_schedule`, :func:`list_presence_overrides`), so a
|
||||||
|
reader never touches the DAO directly either.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from .dao_children import ChildrenDao
|
||||||
|
from .dao_presence import (
|
||||||
|
PresenceOverrideRecord,
|
||||||
|
PresenceOverridesDao,
|
||||||
|
PresenceSchedulesDao,
|
||||||
|
)
|
||||||
|
from .db import NestQuestDatabase
|
||||||
|
from .materialize import count_removed_by_override, regenerate_for_child
|
||||||
|
from .presence import PresenceOverride, PresenceSchedule
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_override_id(value: object) -> int:
|
||||||
|
"""Reject non-int override ids (bools would silently address id 1)."""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise ValueError(f"override_id must be an integer, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
async def set_presence_schedule(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
child_id: int,
|
||||||
|
cycle_length_weeks: int,
|
||||||
|
anchor_date: object,
|
||||||
|
pattern: dict[int, object],
|
||||||
|
*,
|
||||||
|
today: datetime.date | None = None,
|
||||||
|
horizon_days: int | None = None,
|
||||||
|
) -> PresenceSchedule:
|
||||||
|
"""Set (upsert) a child's repeating presence schedule.
|
||||||
|
|
||||||
|
The :class:`~.presence.PresenceSchedule` model validates at
|
||||||
|
construction — cycle length 1..4, a strict calendar anchor date,
|
||||||
|
and a pattern covering week indices 0..n-1 with weekday sets — so
|
||||||
|
no invalid schedule reaches the DAO. The encoded pattern is
|
||||||
|
UPSERTed by child (a second set REPLACES the child's existing
|
||||||
|
schedule; the schema's UNIQUE(child_id) makes duplication
|
||||||
|
unrepresentable), then :func:`~.materialize.regenerate_for_child`
|
||||||
|
re-materializes the child's future instances against the new
|
||||||
|
presence. ``today``/``horizon_days`` thread through to that
|
||||||
|
regeneration; raising ValueError names what to fix (an unknown
|
||||||
|
child reads ``child N does not exist``).
|
||||||
|
|
||||||
|
Returns the STORED schedule decoded back through the model — the
|
||||||
|
round-trip proves the encoded pattern the database now holds is
|
||||||
|
exactly the one the caller asked for.
|
||||||
|
"""
|
||||||
|
schedule = PresenceSchedule(
|
||||||
|
child_id, cycle_length_weeks, anchor_date, pattern
|
||||||
|
)
|
||||||
|
record = await PresenceSchedulesDao(database).upsert_by_child(
|
||||||
|
schedule.child_id,
|
||||||
|
schedule.cycle_length_weeks,
|
||||||
|
schedule.anchor_date.isoformat(),
|
||||||
|
schedule.encode(),
|
||||||
|
)
|
||||||
|
await regenerate_for_child(
|
||||||
|
database, schedule.child_id, today=today, horizon_days=horizon_days
|
||||||
|
)
|
||||||
|
return PresenceSchedule.decode(
|
||||||
|
record.child_id, record.anchor_date, record.pattern
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_presence_override(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
child_id: int,
|
||||||
|
start_date: object,
|
||||||
|
end_date: object,
|
||||||
|
is_present: bool,
|
||||||
|
note: str | None = None,
|
||||||
|
*,
|
||||||
|
today: datetime.date | None = None,
|
||||||
|
horizon_days: int | None = None,
|
||||||
|
) -> PresenceOverrideRecord:
|
||||||
|
"""Create one date-range presence override and return it as stored.
|
||||||
|
|
||||||
|
The :class:`~.presence.PresenceOverride` model validates at
|
||||||
|
construction — strict calendar dates, ``end_date`` on or after
|
||||||
|
``start_date``, a real ``is_present`` bool, a string-or-None note —
|
||||||
|
then the DAO insert applies the same-child no-overlap policy (a
|
||||||
|
conflicting range raises ValueError naming the existing override)
|
||||||
|
and :func:`~.materialize.regenerate_for_child` re-materializes the
|
||||||
|
child's future instances against the override's presence answer.
|
||||||
|
An unknown child reads ``child N does not exist``.
|
||||||
|
|
||||||
|
Returns the stored record WITH its ``id`` — the handle a later
|
||||||
|
:func:`delete_presence_override` takes.
|
||||||
|
"""
|
||||||
|
override = PresenceOverride(
|
||||||
|
child_id, start_date, end_date, is_present, note=note
|
||||||
|
)
|
||||||
|
record = await PresenceOverridesDao(database).create(
|
||||||
|
override.child_id,
|
||||||
|
override.start_date.isoformat(),
|
||||||
|
override.end_date.isoformat(),
|
||||||
|
override.is_present,
|
||||||
|
note=override.note,
|
||||||
|
)
|
||||||
|
await regenerate_for_child(
|
||||||
|
database, override.child_id, today=today, horizon_days=horizon_days
|
||||||
|
)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
async def preview_presence_override_consequence(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
child_id: int,
|
||||||
|
start_date: object,
|
||||||
|
end_date: object,
|
||||||
|
is_present: bool,
|
||||||
|
*,
|
||||||
|
today: datetime.date | None = None,
|
||||||
|
horizon_days: int | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Return how many upcoming instances saving this override removes.
|
||||||
|
|
||||||
|
The admin override editor's live consequence warning, computed
|
||||||
|
BEFORE anything is saved: the arguments build the SAME
|
||||||
|
:class:`~.presence.PresenceOverride` :func:`create_presence_override`
|
||||||
|
builds (so a malformed or inverted range, or a non-bool
|
||||||
|
``is_present``, raises the same ValueError), then
|
||||||
|
:func:`~.materialize.count_removed_by_override` compares what the
|
||||||
|
materialization walk generates for the child with and without it.
|
||||||
|
Nothing is written. An unknown child reads ``child N does not
|
||||||
|
exist``; an INACTIVE child has no instances to remove (the walk's
|
||||||
|
write path never materializes one), so it counts 0.
|
||||||
|
``today``/``horizon_days`` resolve as the post-save regeneration
|
||||||
|
resolves them.
|
||||||
|
"""
|
||||||
|
override = PresenceOverride(child_id, start_date, end_date, is_present)
|
||||||
|
child = await ChildrenDao(database).get(override.child_id)
|
||||||
|
if child is None:
|
||||||
|
raise ValueError(f"child {override.child_id} does not exist")
|
||||||
|
if not child.is_active:
|
||||||
|
return 0
|
||||||
|
return await count_removed_by_override(
|
||||||
|
database, override, today=today, horizon_days=horizon_days
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_presence_schedule(
|
||||||
|
database: NestQuestDatabase, child_id: int
|
||||||
|
) -> PresenceSchedule | None:
|
||||||
|
"""Return the child's presence schedule decoded, or None when unset.
|
||||||
|
|
||||||
|
None means the child exists and has no schedule row — present
|
||||||
|
every day (Feature 05). An unknown child raises ValueError
|
||||||
|
(``child N does not exist``) rather than reading as present.
|
||||||
|
"""
|
||||||
|
record = await PresenceSchedulesDao(database).get_by_child(child_id)
|
||||||
|
if record is None:
|
||||||
|
return None
|
||||||
|
return PresenceSchedule.decode(
|
||||||
|
record.child_id, record.anchor_date, record.pattern
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_presence_overrides(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
*,
|
||||||
|
child_id: int | None = None,
|
||||||
|
start: str | None = None,
|
||||||
|
end: str | None = None,
|
||||||
|
) -> list[PresenceOverrideRecord]:
|
||||||
|
"""Return the household's presence overrides, optionally filtered.
|
||||||
|
|
||||||
|
``child_id`` narrows to one child (unknown: ``child N does not
|
||||||
|
exist``); ``start``/``end`` keep the overrides overlapping that
|
||||||
|
closed range (strict YYYY-MM-DD, ``end >= start``). Ordered by
|
||||||
|
start date, then child id, then id.
|
||||||
|
"""
|
||||||
|
return await PresenceOverridesDao(database).list_filtered(
|
||||||
|
child_id=child_id, start=start, end=end
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_presence_override(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
override_id: int,
|
||||||
|
*,
|
||||||
|
today: datetime.date | None = None,
|
||||||
|
horizon_days: int | None = None,
|
||||||
|
) -> PresenceOverrideRecord:
|
||||||
|
"""Delete one presence override by id and return it as it was stored.
|
||||||
|
|
||||||
|
The override is looked up FIRST and an unknown id raises ValueError
|
||||||
|
prefixed ``override_id:`` (naming the id) BEFORE any delete, so a
|
||||||
|
mistyped id can never be silently swallowed by a no-op. The
|
||||||
|
deletion is followed by :func:`~.materialize.regenerate_for_child`
|
||||||
|
for the override's own child — the id decides the child, never a
|
||||||
|
caller-supplied one. ``today``/``horizon_days`` thread through to
|
||||||
|
that regeneration.
|
||||||
|
"""
|
||||||
|
_validate_override_id(override_id)
|
||||||
|
dao = PresenceOverridesDao(database)
|
||||||
|
override = await dao.get(override_id)
|
||||||
|
if override is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"override_id: presence override {override_id} does not exist"
|
||||||
|
)
|
||||||
|
await dao.delete(override_id)
|
||||||
|
await regenerate_for_child(
|
||||||
|
database, override.child_id, today=today, horizon_days=horizon_days
|
||||||
|
)
|
||||||
|
return override
|
||||||
@@ -0,0 +1,654 @@
|
|||||||
|
"""Business layer for creating and editing quest definitions (Feature 06).
|
||||||
|
|
||||||
|
Sits between the Feature 09 service gate and the typed
|
||||||
|
:class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the
|
||||||
|
all-or-nothing create/edit here, and never touch the DAO SQL directly.
|
||||||
|
:func:`create_quest_definition` is the create path,
|
||||||
|
:func:`edit_quest_definition` the edit path (which may also replace the
|
||||||
|
whole assignee set), :func:`assign_child` and
|
||||||
|
:func:`unassign_child` the single-child assignment paths, and
|
||||||
|
:func:`set_quest_definition_active` the deactivate/reactivate path.
|
||||||
|
There is no permission check here (that is Feature 09).
|
||||||
|
|
||||||
|
Validation policy mirrors :mod:`.children`:
|
||||||
|
|
||||||
|
- ``title`` is required and trimmed; empty or whitespace-only raises
|
||||||
|
ValueError.
|
||||||
|
- ``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule`
|
||||||
|
(its constructor is total — rule validation is NOT re-implemented
|
||||||
|
here).
|
||||||
|
- ``assignee_child_ids`` must be a non-empty list (strictly a ``list``);
|
||||||
|
every id is a plain int (bools and floats rejected, since SQLite would
|
||||||
|
bind them onto a real child) and must reference an existing ACTIVE
|
||||||
|
child (the DAO enforces the active/exists check inside the same
|
||||||
|
transaction). Duplicate ids are rejected here, before the composite
|
||||||
|
primary key could surface them as an :class:`sqlite3.IntegrityError`.
|
||||||
|
- ``windows`` must be a non-empty list (strictly a ``list``); each entry
|
||||||
|
is a window name from :data:`~.const.QUEST_WINDOWS` — either a bare
|
||||||
|
name (no due time) or a ``(name, due_time)`` pair whose due time is a
|
||||||
|
strict 24-hour HH:MM string. Duplicate window names are rejected
|
||||||
|
here, before the composite primary key could surface them as an
|
||||||
|
:class:`sqlite3.IntegrityError`.
|
||||||
|
|
||||||
|
The rule, definition, assignees and windows are persisted in ONE
|
||||||
|
transaction (the DAO's :meth:`~.dao_rules.QuestDefinitionsDao.create_with_rule_and_windows`),
|
||||||
|
so a rejected or failed create persists nothing. The definition-level
|
||||||
|
``due_time`` column is superseded by per-window due times (D-008) and is
|
||||||
|
never set here. ``created_at`` is stamped UTC ISO-8601 at second
|
||||||
|
precision, matching :mod:`.children`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from .dao_children import ChildRecord, _connection_lock
|
||||||
|
from .dao_rules import (
|
||||||
|
QuestDefinitionRecord,
|
||||||
|
QuestDefinitionSnapshot,
|
||||||
|
QuestDefinitionWindowRecord,
|
||||||
|
QuestDefinitionsDao,
|
||||||
|
ScheduleRuleStorage,
|
||||||
|
ScheduleRulesDao,
|
||||||
|
_UNSET,
|
||||||
|
_validate_date,
|
||||||
|
_validate_time,
|
||||||
|
_validate_window,
|
||||||
|
schedule_rule_from_storage,
|
||||||
|
schedule_rule_storage_from_record,
|
||||||
|
schedule_rule_to_storage,
|
||||||
|
)
|
||||||
|
from .db import NestQuestDatabase
|
||||||
|
from .materialize import regenerate_for_definition
|
||||||
|
from .recurrence import ScheduleRule, occurs_on
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CreatedQuestDefinition:
|
||||||
|
"""The persisted result of a create, edit or activation change.
|
||||||
|
|
||||||
|
``definition`` is the stored row; ``rule`` is the schedule rule
|
||||||
|
decoded back through the storage mapping; ``assignees`` and
|
||||||
|
``windows`` are the definition's linked child records and window
|
||||||
|
declarations, read through the DAO.
|
||||||
|
"""
|
||||||
|
|
||||||
|
definition: QuestDefinitionRecord
|
||||||
|
rule: ScheduleRule
|
||||||
|
assignees: list[ChildRecord]
|
||||||
|
windows: list[QuestDefinitionWindowRecord]
|
||||||
|
|
||||||
|
|
||||||
|
def _now_stamp() -> str:
|
||||||
|
"""Return the module's strict UTC second-precision stamp."""
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||||
|
timespec="seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_text(
|
||||||
|
value: object, field: str, *, required: bool = False
|
||||||
|
) -> str | None:
|
||||||
|
"""Validate a free-text field and return it trimmed.
|
||||||
|
|
||||||
|
``None`` raises when ``required`` and otherwise returns None. A
|
||||||
|
non-string value is rejected; an empty result is rejected for
|
||||||
|
required fields.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
if required:
|
||||||
|
raise ValueError(f"{field} is required")
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(f"{field} must be a string, got {value!r}")
|
||||||
|
trimmed = value.strip()
|
||||||
|
if required and not trimmed:
|
||||||
|
raise ValueError(f"{field} is required")
|
||||||
|
return trimmed
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_assignee_ids(value: object) -> list[int]:
|
||||||
|
"""Return the assignee ids as plain ints, or raise naming the field.
|
||||||
|
|
||||||
|
The argument must be a non-empty list — strictly a ``list``: tuples
|
||||||
|
and other sequences are rejected. Each entry must be a real int
|
||||||
|
(bools and floats rejected) so SQLite cannot bind ``True`` onto
|
||||||
|
child 1 or a float onto a neighbouring profile.
|
||||||
|
"""
|
||||||
|
if type(value) is not list:
|
||||||
|
raise ValueError(
|
||||||
|
f"assignee_child_ids must be a list, got {type(value).__name__}"
|
||||||
|
)
|
||||||
|
if not value:
|
||||||
|
raise ValueError("assignee_child_ids must not be empty")
|
||||||
|
ids: list[int] = []
|
||||||
|
for entry in value:
|
||||||
|
if isinstance(entry, bool) or not isinstance(entry, int):
|
||||||
|
raise ValueError(
|
||||||
|
"assignee_child_ids must contain integer child ids, "
|
||||||
|
f"got {entry!r}"
|
||||||
|
)
|
||||||
|
ids.append(entry)
|
||||||
|
if len(set(ids)) != len(ids):
|
||||||
|
raise ValueError(
|
||||||
|
"assignee_child_ids must not contain duplicate child ids"
|
||||||
|
)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_windows(
|
||||||
|
value: object,
|
||||||
|
) -> list[tuple[str, str | None]]:
|
||||||
|
"""Normalize the windows argument to ``(name, due_time)`` pairs.
|
||||||
|
|
||||||
|
The outer argument must be a non-empty list — strictly a ``list``:
|
||||||
|
tuples and other sequences are rejected, while each individual entry
|
||||||
|
may still be a ``(window, due_time)`` tuple. A bare window name
|
||||||
|
(due time None) is also accepted. The name must be a
|
||||||
|
:data:`~.const.QUEST_WINDOWS` spelling and a present due time must be
|
||||||
|
a strict 24-hour HH:MM, both validated up front so a malformed
|
||||||
|
window is rejected before any write.
|
||||||
|
"""
|
||||||
|
if type(value) is not list:
|
||||||
|
raise ValueError(
|
||||||
|
f"windows must be a list, got {type(value).__name__}"
|
||||||
|
)
|
||||||
|
if not value:
|
||||||
|
raise ValueError("windows must not be empty")
|
||||||
|
normalized: list[tuple[str, str | None]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for index, entry in enumerate(value):
|
||||||
|
if isinstance(entry, str):
|
||||||
|
name: object = entry
|
||||||
|
due_time: object = None
|
||||||
|
elif isinstance(entry, (tuple, list)) and len(entry) == 2:
|
||||||
|
name, due_time = entry[0], entry[1]
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"windows[{index}] must be a window name or a "
|
||||||
|
f"(name, due_time) pair, got {entry!r}"
|
||||||
|
)
|
||||||
|
_validate_window(name)
|
||||||
|
if due_time is not None:
|
||||||
|
_validate_time(due_time, "due_time")
|
||||||
|
if name in seen:
|
||||||
|
raise ValueError(
|
||||||
|
f"windows must not contain duplicate window {name!r}"
|
||||||
|
)
|
||||||
|
seen.add(name)
|
||||||
|
normalized.append((name, due_time))
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_definition_id(value: object) -> int:
|
||||||
|
"""Reject non-int definition ids (bools included) before any lookup.
|
||||||
|
|
||||||
|
SQLite binds Python bools as integers, so ``True`` would silently
|
||||||
|
address definition 1 and a float would round — malformed service
|
||||||
|
input must never mutate the wrong definition.
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise ValueError(f"definition_id must be an integer, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_skip_on_away(value: object) -> bool:
|
||||||
|
"""Reject a non-bool ``skip_on_away`` before any write.
|
||||||
|
|
||||||
|
The DAO's ``int()`` would otherwise silently coerce strings and
|
||||||
|
numerics ("0", 1) into a state the caller never asked for.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, bool):
|
||||||
|
raise ValueError(f"skip_on_away must be a real bool, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_child_id(value: object) -> int:
|
||||||
|
"""Reject non-int child ids (bools included) before any lookup.
|
||||||
|
|
||||||
|
SQLite binds Python bools as integers, so ``True`` would silently
|
||||||
|
address child 1 and a float would round — malformed service input
|
||||||
|
must never mutate the wrong profile.
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise ValueError(f"child_id must be an integer, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
async def create_quest_definition(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
title: str,
|
||||||
|
rule: ScheduleRule,
|
||||||
|
assignee_child_ids: list[int],
|
||||||
|
windows: list[str | tuple[str, str | None]],
|
||||||
|
*,
|
||||||
|
description: str | None = None,
|
||||||
|
icon: str | None = None,
|
||||||
|
skip_on_away: bool = True,
|
||||||
|
) -> CreatedQuestDefinition:
|
||||||
|
"""Create a quest definition (rule + assignees + windows) atomically.
|
||||||
|
|
||||||
|
Validates the arguments up front, then persists the schedule rule,
|
||||||
|
the definition, its assignees and its windows in one transaction via
|
||||||
|
the DAO. Returns the stored definition together with its decoded
|
||||||
|
rule, assignees and windows — all read back inside that same
|
||||||
|
transaction, so the returned bundle is a consistent creation
|
||||||
|
snapshot. Raises ValueError on any rejected argument; a rejected or
|
||||||
|
failed create persists nothing.
|
||||||
|
"""
|
||||||
|
name = _validate_text(title, "title", required=True)
|
||||||
|
assert name is not None
|
||||||
|
if not isinstance(rule, ScheduleRule):
|
||||||
|
raise ValueError(f"rule must be a ScheduleRule, got {rule!r}")
|
||||||
|
assignee_ids = _validate_assignee_ids(assignee_child_ids)
|
||||||
|
window_specs = _normalize_windows(windows)
|
||||||
|
description_value = _validate_text(description, "description")
|
||||||
|
icon_value = _validate_text(icon, "icon")
|
||||||
|
skip_on_away_value = _validate_skip_on_away(skip_on_away)
|
||||||
|
|
||||||
|
storage = schedule_rule_to_storage(rule)
|
||||||
|
dao = QuestDefinitionsDao(database)
|
||||||
|
try:
|
||||||
|
snapshot = await dao.create_with_rule_and_windows(
|
||||||
|
name,
|
||||||
|
storage,
|
||||||
|
_now_stamp(),
|
||||||
|
assignee_ids,
|
||||||
|
window_specs,
|
||||||
|
description=description_value,
|
||||||
|
icon=icon_value,
|
||||||
|
skip_on_away=skip_on_away_value,
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
# The DAO's assignee check names only the child; re-raise so the
|
||||||
|
# public contract names the field the caller actually passed.
|
||||||
|
message = str(error)
|
||||||
|
if message.startswith("child "):
|
||||||
|
raise ValueError(
|
||||||
|
f"assignee_child_ids: {message}"
|
||||||
|
) from error
|
||||||
|
raise
|
||||||
|
decoded_rule = schedule_rule_from_storage(storage)
|
||||||
|
return CreatedQuestDefinition(
|
||||||
|
definition=snapshot.definition,
|
||||||
|
rule=decoded_rule,
|
||||||
|
assignees=snapshot.assignees,
|
||||||
|
windows=snapshot.windows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def edit_quest_definition(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
definition_id: int,
|
||||||
|
*,
|
||||||
|
title: str | object = _UNSET,
|
||||||
|
description: str | None | object = _UNSET,
|
||||||
|
icon: str | None | object = _UNSET,
|
||||||
|
rule: ScheduleRule | object = _UNSET,
|
||||||
|
windows: list[str | tuple[str, str | None]] | object = _UNSET,
|
||||||
|
skip_on_away: bool | None = None,
|
||||||
|
assignee_child_ids: list[int] | object = _UNSET,
|
||||||
|
today: datetime.date | None = None,
|
||||||
|
horizon_days: int | None = None,
|
||||||
|
) -> CreatedQuestDefinition:
|
||||||
|
"""Edit a quest definition's metadata, rule, windows and assignees atomically.
|
||||||
|
|
||||||
|
Arguments default to the sentinel ``_UNSET`` meaning "leave this
|
||||||
|
field alone". A provided ``title`` is validated like create's
|
||||||
|
(required and trimmed); ``description`` and ``icon`` are optional
|
||||||
|
and may be cleared by passing ``None`` explicitly. A provided
|
||||||
|
``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule`
|
||||||
|
and REPLACES the definition's schedule rule IN PLACE — the existing
|
||||||
|
``schedule_rules`` row is updated, never orphaned or duplicated. A
|
||||||
|
provided ``windows`` list REPLACES the whole window set (each entry
|
||||||
|
a ``const.QUEST_WINDOWS`` name, optionally with a strict HH:MM due
|
||||||
|
time), matching create's non-empty contract. A provided
|
||||||
|
``skip_on_away`` (a real bool) replaces the stored flag; ``None``
|
||||||
|
leaves it unchanged. A provided ``assignee_child_ids`` REPLACES the
|
||||||
|
whole assignee set under create's contract (a non-empty list of
|
||||||
|
distinct plain ints, each an existing ACTIVE child — the DAO checks
|
||||||
|
that inside the edit's transaction): newly listed children are
|
||||||
|
assigned and unlisted ones unassigned. Raises ValueError when the
|
||||||
|
definition does not exist or any argument is rejected; a rejection
|
||||||
|
writes nothing, so the stored assignees (and every other field)
|
||||||
|
stay unchanged.
|
||||||
|
|
||||||
|
Returns the updated definition together with its decoded rule,
|
||||||
|
assignees and windows — all read back inside the DAO's transaction,
|
||||||
|
so the returned bundle is a consistent edit snapshot. Edits change
|
||||||
|
future instances only; no ``quest_instances`` or
|
||||||
|
``completion_events`` row is touched.
|
||||||
|
|
||||||
|
``today`` optionally pins the caller-resolved HA-local date the
|
||||||
|
regeneration's no-past guard and horizon are anchored to (threaded
|
||||||
|
into :func:`~.materialize.regenerate_for_definition`); it is a plain
|
||||||
|
``datetime.date`` with no HA import here — the Feature 09 service
|
||||||
|
supplies the hass-derived value. When omitted, regeneration falls
|
||||||
|
back to the host clock, same as before. ``horizon_days`` sizes the
|
||||||
|
regeneration's re-materialization window the same way (threaded into
|
||||||
|
:func:`~.materialize.regenerate_for_definition`); it defaults to
|
||||||
|
:data:`~.const.DEFAULT_HORIZON_DAYS` when omitted.
|
||||||
|
"""
|
||||||
|
_validate_definition_id(definition_id)
|
||||||
|
|
||||||
|
title_value: str | None | object = _UNSET
|
||||||
|
if title is not _UNSET:
|
||||||
|
name = _validate_text(title, "title", required=True)
|
||||||
|
assert name is not None
|
||||||
|
title_value = name
|
||||||
|
|
||||||
|
description_value: str | None | object = _UNSET
|
||||||
|
if description is not _UNSET:
|
||||||
|
description_value = _validate_text(description, "description")
|
||||||
|
|
||||||
|
icon_value: str | None | object = _UNSET
|
||||||
|
if icon is not _UNSET:
|
||||||
|
icon_value = _validate_text(icon, "icon")
|
||||||
|
|
||||||
|
rule_storage: ScheduleRuleStorage | object = _UNSET
|
||||||
|
if rule is not _UNSET:
|
||||||
|
if not isinstance(rule, ScheduleRule):
|
||||||
|
raise ValueError(f"rule must be a ScheduleRule, got {rule!r}")
|
||||||
|
rule_storage = schedule_rule_to_storage(rule)
|
||||||
|
|
||||||
|
window_specs: list[tuple[str, str | None]] | object = _UNSET
|
||||||
|
if windows is not _UNSET:
|
||||||
|
window_specs = _normalize_windows(windows)
|
||||||
|
|
||||||
|
assignee_ids: list[int] | object = _UNSET
|
||||||
|
if assignee_child_ids is not _UNSET:
|
||||||
|
assignee_ids = _validate_assignee_ids(assignee_child_ids)
|
||||||
|
|
||||||
|
skip_on_away_value: bool | object = _UNSET
|
||||||
|
if skip_on_away is not None:
|
||||||
|
skip_on_away_value = _validate_skip_on_away(skip_on_away)
|
||||||
|
|
||||||
|
dao = QuestDefinitionsDao(database)
|
||||||
|
try:
|
||||||
|
snapshot = await dao.edit_definition(
|
||||||
|
definition_id,
|
||||||
|
title=title_value,
|
||||||
|
description=description_value,
|
||||||
|
icon=icon_value,
|
||||||
|
rule=rule_storage,
|
||||||
|
windows=window_specs,
|
||||||
|
skip_on_away=skip_on_away_value,
|
||||||
|
assignee_child_ids=assignee_ids,
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
# The DAO's missing-definition and assignee errors name only the
|
||||||
|
# id; re-raise so the public contract names the field the caller
|
||||||
|
# passed (matching create/assign/unassign/deactivate).
|
||||||
|
message = str(error)
|
||||||
|
if message.startswith("quest definition "):
|
||||||
|
raise ValueError(f"definition_id: {message}") from error
|
||||||
|
if message.startswith("child "):
|
||||||
|
raise ValueError(f"assignee_child_ids: {message}") from error
|
||||||
|
raise
|
||||||
|
|
||||||
|
decoded_rule = schedule_rule_from_storage(
|
||||||
|
schedule_rule_storage_from_record(snapshot.rule)
|
||||||
|
)
|
||||||
|
|
||||||
|
await regenerate_for_definition(
|
||||||
|
database, definition_id, today=today, horizon_days=horizon_days
|
||||||
|
)
|
||||||
|
|
||||||
|
return CreatedQuestDefinition(
|
||||||
|
definition=snapshot.definition,
|
||||||
|
rule=decoded_rule,
|
||||||
|
assignees=snapshot.assignees,
|
||||||
|
windows=snapshot.windows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def assign_child(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
definition_id: int,
|
||||||
|
child_id: int,
|
||||||
|
*,
|
||||||
|
today: datetime.date | None = None,
|
||||||
|
horizon_days: int | None = None,
|
||||||
|
) -> list[ChildRecord]:
|
||||||
|
"""Assign ``child_id`` to the definition (idempotent).
|
||||||
|
|
||||||
|
Validates that both ids are plain ints (bools and floats rejected)
|
||||||
|
and that the child exists and is active — the DAO enforces the
|
||||||
|
active/exists check inside its transaction, and also confirms the
|
||||||
|
definition exists. Assigning an already-assigned child is a no-op
|
||||||
|
(no duplicate row). Returns the definition's assignees read back
|
||||||
|
inside the DAO's same transaction, a consistent post-assignment
|
||||||
|
roster (a concurrent assign/unassign cannot race it after the
|
||||||
|
write). Raises ValueError on any rejected argument. Only the
|
||||||
|
``quest_definition_assignees`` link is written; existing instances
|
||||||
|
and completion history are never touched.
|
||||||
|
|
||||||
|
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||||
|
into the assignment's regeneration (see :func:`edit_quest_definition`).
|
||||||
|
``horizon_days`` optionally sizes the regeneration's re-materialization
|
||||||
|
window the same way.
|
||||||
|
"""
|
||||||
|
_validate_definition_id(definition_id)
|
||||||
|
_validate_child_id(child_id)
|
||||||
|
dao = QuestDefinitionsDao(database)
|
||||||
|
try:
|
||||||
|
assignees = await dao.add_assignee(definition_id, child_id)
|
||||||
|
except ValueError as error:
|
||||||
|
# The DAO names only the child/definition; re-raise so the
|
||||||
|
# public contract names the field the caller actually passed.
|
||||||
|
message = str(error)
|
||||||
|
if message.startswith("child "):
|
||||||
|
raise ValueError(f"child_id: {message}") from error
|
||||||
|
if message.startswith("quest definition "):
|
||||||
|
raise ValueError(f"definition_id: {message}") from error
|
||||||
|
raise
|
||||||
|
await regenerate_for_definition(
|
||||||
|
database, definition_id, today=today, horizon_days=horizon_days
|
||||||
|
)
|
||||||
|
return assignees
|
||||||
|
|
||||||
|
|
||||||
|
async def unassign_child(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
definition_id: int,
|
||||||
|
child_id: int,
|
||||||
|
*,
|
||||||
|
today: datetime.date | None = None,
|
||||||
|
horizon_days: int | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Unassign ``child_id`` from the definition; True when removed.
|
||||||
|
|
||||||
|
Validates both ids are plain ints (bools and floats rejected) and
|
||||||
|
that the definition exists, then removes the (definition, child)
|
||||||
|
link. Returns True when a row was removed and False when the child
|
||||||
|
was not currently assigned. Raises ValueError on a rejected
|
||||||
|
argument. Only the ``quest_definition_assignees`` link is written;
|
||||||
|
existing instances and completion history are never touched.
|
||||||
|
|
||||||
|
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||||
|
into the unassignment's regeneration (see :func:`edit_quest_definition`).
|
||||||
|
``horizon_days`` optionally sizes the regeneration's re-materialization
|
||||||
|
window the same way.
|
||||||
|
"""
|
||||||
|
_validate_definition_id(definition_id)
|
||||||
|
_validate_child_id(child_id)
|
||||||
|
dao = QuestDefinitionsDao(database)
|
||||||
|
definition = await dao.get(definition_id)
|
||||||
|
if definition is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"definition_id: quest definition {definition_id} does not exist"
|
||||||
|
)
|
||||||
|
removed = await dao.remove_assignee(definition_id, child_id)
|
||||||
|
await regenerate_for_definition(
|
||||||
|
database, definition_id, today=today, horizon_days=horizon_days
|
||||||
|
)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
async def set_quest_definition_active(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
definition_id: int,
|
||||||
|
is_active: bool,
|
||||||
|
*,
|
||||||
|
today: datetime.date | None = None,
|
||||||
|
horizon_days: int | None = None,
|
||||||
|
) -> CreatedQuestDefinition:
|
||||||
|
"""Deactivate or reactivate a definition; returns the updated view.
|
||||||
|
|
||||||
|
Flipping the flag is the only removal path (Feature 06 guardrail):
|
||||||
|
a deactivated definition stops future instance generation but its
|
||||||
|
row, schedule rule, assignees and windows all survive, and existing
|
||||||
|
instances plus completion history are never touched. Reactivation
|
||||||
|
re-enables future generation; generating or removing instances is
|
||||||
|
Feature 07 and does not happen here.
|
||||||
|
|
||||||
|
``definition_id`` must be a plain int (bools and floats rejected,
|
||||||
|
since SQLite would bind ``True`` onto definition 1) and
|
||||||
|
``is_active`` must be a real bool — the DAO's ``int()`` would
|
||||||
|
otherwise silently coerce strings and numerics ("1", 0) into a
|
||||||
|
state the caller never asked for. Raises ValueError naming the
|
||||||
|
offending field when the id is malformed, ``is_active`` is not a
|
||||||
|
bool, or the definition does not exist.
|
||||||
|
|
||||||
|
Atomicity: the existence check, the UPDATE and the readback of the
|
||||||
|
definition, its rule, assignees and windows all run inside the
|
||||||
|
connection-scoped lock, so the returned bundle is a consistent
|
||||||
|
snapshot that a concurrent mutation cannot race.
|
||||||
|
|
||||||
|
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||||
|
into the activation change's regeneration (see
|
||||||
|
:func:`edit_quest_definition`). ``horizon_days`` optionally sizes the
|
||||||
|
regeneration's re-materialization window the same way.
|
||||||
|
"""
|
||||||
|
_validate_definition_id(definition_id)
|
||||||
|
if not isinstance(is_active, bool):
|
||||||
|
raise ValueError(f"is_active must be a real bool, got {is_active!r}")
|
||||||
|
async with _connection_lock(database):
|
||||||
|
dao = QuestDefinitionsDao(database)
|
||||||
|
if await dao.get(definition_id) is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"definition_id: quest definition {definition_id} "
|
||||||
|
"does not exist"
|
||||||
|
)
|
||||||
|
await dao.set_active(definition_id, is_active)
|
||||||
|
updated = await dao.get(definition_id)
|
||||||
|
assert updated is not None
|
||||||
|
rule_record = await ScheduleRulesDao(database).get(
|
||||||
|
updated.schedule_rule_id
|
||||||
|
)
|
||||||
|
assert rule_record is not None
|
||||||
|
assignees = await dao.list_assignees(definition_id)
|
||||||
|
windows = await dao.list_windows(definition_id)
|
||||||
|
rule = schedule_rule_from_storage(
|
||||||
|
schedule_rule_storage_from_record(rule_record)
|
||||||
|
)
|
||||||
|
await regenerate_for_definition(
|
||||||
|
database, definition_id, today=today, horizon_days=horizon_days
|
||||||
|
)
|
||||||
|
return CreatedQuestDefinition(
|
||||||
|
definition=updated,
|
||||||
|
rule=rule,
|
||||||
|
assignees=assignees,
|
||||||
|
windows=windows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bundle_snapshot(
|
||||||
|
snapshot: QuestDefinitionSnapshot,
|
||||||
|
) -> CreatedQuestDefinition:
|
||||||
|
"""Decode a DAO snapshot into the layer's rich view.
|
||||||
|
|
||||||
|
The rule record carried by the snapshot is the one the definition
|
||||||
|
referenced at read time, so decoding it here (rather than re-reading
|
||||||
|
through :class:`~.dao_rules.ScheduleRulesDao`) preserves the
|
||||||
|
coherence the DAO's locked snapshot guarantees: the rule, assignees
|
||||||
|
and windows all come from one atomic read, never three separate ones.
|
||||||
|
"""
|
||||||
|
rule = schedule_rule_from_storage(
|
||||||
|
schedule_rule_storage_from_record(snapshot.rule)
|
||||||
|
)
|
||||||
|
return CreatedQuestDefinition(
|
||||||
|
definition=snapshot.definition,
|
||||||
|
rule=rule,
|
||||||
|
assignees=snapshot.assignees,
|
||||||
|
windows=snapshot.windows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_active_definitions(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
) -> list[CreatedQuestDefinition]:
|
||||||
|
"""Return every ACTIVE definition with its decoded rule and links.
|
||||||
|
|
||||||
|
Ordered by rising definition id, matching the DAO's ``list_active``
|
||||||
|
order, so callers get a stable, repeatable sequence. Each entry is
|
||||||
|
the same rich :class:`CreatedQuestDefinition` snapshot as the rest of
|
||||||
|
the layer: the stored row plus its decoded
|
||||||
|
:class:`~.recurrence.ScheduleRule`, assignees and windows. The
|
||||||
|
definitions and their links are read by the DAO inside one locked
|
||||||
|
transaction, so each bundle is a coherent snapshot that a concurrent
|
||||||
|
edit cannot interleave.
|
||||||
|
"""
|
||||||
|
dao = QuestDefinitionsDao(database)
|
||||||
|
snapshots = await dao.list_snapshots_active()
|
||||||
|
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_all_definitions(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
) -> list[CreatedQuestDefinition]:
|
||||||
|
"""Return EVERY definition, active AND inactive (rich view).
|
||||||
|
|
||||||
|
Mirrors :func:`list_active_definitions` without the activity
|
||||||
|
filter: ordered by rising definition id, each entry the same
|
||||||
|
:class:`CreatedQuestDefinition` snapshot, read by the DAO inside one
|
||||||
|
locked transaction.
|
||||||
|
"""
|
||||||
|
dao = QuestDefinitionsDao(database)
|
||||||
|
snapshots = await dao.list_snapshots_all()
|
||||||
|
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_definitions_for_child(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
child_id: int,
|
||||||
|
) -> list[CreatedQuestDefinition]:
|
||||||
|
"""Return the definitions assigned to ``child_id`` (rich view).
|
||||||
|
|
||||||
|
``child_id`` must be a plain int (bools and floats rejected, since
|
||||||
|
SQLite would bind ``True`` onto child 1). Results are ordered by
|
||||||
|
rising definition id, matching ``list_active_definitions`` and
|
||||||
|
``list_definitions_firing_on``. Each entry is a
|
||||||
|
:class:`CreatedQuestDefinition` snapshot bundling the definition with
|
||||||
|
its decoded rule, assignees and windows, read atomically by the DAO.
|
||||||
|
"""
|
||||||
|
_validate_child_id(child_id)
|
||||||
|
dao = QuestDefinitionsDao(database)
|
||||||
|
snapshots = await dao.list_snapshots_by_child(child_id)
|
||||||
|
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
|
||||||
|
return sorted(bundles, key=lambda bundle: bundle.definition.id)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_definitions_firing_on(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
date: str,
|
||||||
|
) -> list[CreatedQuestDefinition]:
|
||||||
|
"""Return the ACTIVE definitions whose rule fires on ``date``.
|
||||||
|
|
||||||
|
``date`` must be a strict ISO calendar date (YYYY-MM-DD); a malformed
|
||||||
|
value raises ValueError naming the field. Each active definition's
|
||||||
|
stored rule is decoded through the task-1 storage mapping and handed
|
||||||
|
to the recurrence engine's :func:`~.recurrence.occurs_on`; only the
|
||||||
|
definitions whose decoded rule fires on the date are returned, in
|
||||||
|
rising definition-id order, each as the same rich
|
||||||
|
:class:`CreatedQuestDefinition` snapshot as the rest of the layer.
|
||||||
|
The definitions and their rules are read inside one locked
|
||||||
|
transaction, so a bundle can never filter on one rule while
|
||||||
|
presenting another state's windows.
|
||||||
|
"""
|
||||||
|
_validate_date(date, "date")
|
||||||
|
target = datetime.datetime.strptime(date, "%Y-%m-%d").date()
|
||||||
|
dao = QuestDefinitionsDao(database)
|
||||||
|
snapshots = await dao.list_snapshots_active()
|
||||||
|
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
|
||||||
|
return [bundle for bundle in bundles if occurs_on(bundle.rule, target)]
|
||||||
+122
-56
@@ -95,30 +95,45 @@ migration runner rebuilds a pre-D-008 table into this shape and copies
|
|||||||
its single-assignee column across. The composite primary key makes
|
its single-assignee column across. The composite primary key makes
|
||||||
duplicate assignment impossible at the storage layer.
|
duplicate assignment impossible at the storage layer.
|
||||||
|
|
||||||
|
Quest windows (D-008): ``quest_definition_windows`` declares which day
|
||||||
|
windows a definition spans — one row per (definition, window), the
|
||||||
|
composite primary key making a window idempotent per definition.
|
||||||
|
``window`` is CHECK-constrained to the three spellings ``const`` owns;
|
||||||
|
``due_time`` optionally pins a due time inside the window and is
|
||||||
|
caller-validated (strict HH:MM) by the DAO layer, matching the date
|
||||||
|
policy above. The window clock ranges live in ``const.py``, not here:
|
||||||
|
this layer stores what the caller declares and never interprets clocks.
|
||||||
|
|
||||||
Quest instances: ``quest_instances`` deliberately carries NO completion
|
Quest instances: ``quest_instances`` deliberately carries NO completion
|
||||||
status column. An instance's current state derives from the latest row
|
status column. An instance's current state derives from the latest row
|
||||||
in ``completion_events`` (Feature 08), so materialization (Feature 07)
|
in ``completion_events`` (Feature 08), so materialization (Feature 07)
|
||||||
can insert instances without knowing anything about completion and the
|
can insert instances without knowing anything about completion and the
|
||||||
append-only log stays the single source of truth. ``definition_id``,
|
append-only log stays the single source of truth. ``definition_id``,
|
||||||
``due_date`` and ``child_id`` snapshot the materialization-time facts
|
``child_id``, ``due_date`` and ``window`` snapshot the
|
||||||
("why this instance exists"): reassignment and rule edits change future
|
materialization-time facts ("why this instance exists"): assignment
|
||||||
instances only and never rewrite these rows. ``due_time`` snapshots
|
edits and rule edits change future instances only and never rewrite
|
||||||
the definition's optional due time at generation for the same reason —
|
these rows. ``window`` records which declared day window produced the
|
||||||
the instance keeps its generation-time value even if the definition is
|
instance (D-008), CHECK-constrained to the same three spellings
|
||||||
edited later. ``generated_at`` is a caller-set UTC ISO-8601 timestamp
|
``const`` owns. ``due_time`` snapshots the window's optional due time
|
||||||
per the module timestamp policy (no DB default, so a generation batch
|
at generation for the same reason — the instance keeps its
|
||||||
stamps one coherent time).
|
generation-time value even if the definition is edited later.
|
||||||
|
``generated_at`` is a caller-set UTC ISO-8601 timestamp per the module
|
||||||
|
timestamp policy (no DB default, so a generation batch stamps one
|
||||||
|
coherent time).
|
||||||
|
|
||||||
Uniqueness: the (definition_id, due_date) pair is declared as a
|
Uniqueness: the (definition_id, child_id, due_date, window) tuple is
|
||||||
table-level ``UNIQUE`` constraint rather than a separate ``CREATE
|
declared as a table-level ``UNIQUE`` constraint rather than a separate
|
||||||
UNIQUE INDEX`` statement. SQLite materializes it as an implicit unique
|
``CREATE UNIQUE INDEX`` statement. SQLite materializes it as an
|
||||||
index (``sqlite_autoindex_quest_instances_1``) — the required "unique
|
implicit unique index (``sqlite_autoindex_quest_instances_1``) — the
|
||||||
index prevents duplicate instances" — and the constraint form cannot be
|
required "unique index prevents duplicate instances" — and the
|
||||||
silently dropped the way a standalone index can, so the duplicate door
|
constraint form cannot be silently dropped the way a standalone index
|
||||||
stays shut. Idempotent materialization keys on exactly this pair. No
|
can, so the duplicate door stays shut. Idempotent materialization keys
|
||||||
secondary indexes beyond it are declared yet: history and board queries
|
on exactly this tuple: the twice-daily case (one definition, two
|
||||||
(Features 08/10/13) should justify their indexes as a later migration
|
windows, one child, one date) yields two rows and the shared case (one
|
||||||
once their query shapes exist.
|
definition, three assignees, one window) yields three. No secondary
|
||||||
|
indexes beyond it are declared yet: history and board queries (Features
|
||||||
|
08/10/13) should justify their indexes as a later migration once their
|
||||||
|
query shapes exist.
|
||||||
|
|
||||||
No ``ON DELETE`` clauses are declared on any foreign key in this
|
No ``ON DELETE`` clauses are declared on any foreign key in this
|
||||||
module: children and definitions are deactivated, never deleted, so a
|
module: children and definitions are deactivated, never deleted, so a
|
||||||
@@ -144,11 +159,12 @@ self-describing without joins.
|
|||||||
Actor policy: ``actor_source`` distinguishes an authenticated HA user
|
Actor policy: ``actor_source`` distinguishes an authenticated HA user
|
||||||
(``'user'``, with ``actor_user_id`` holding the HA user id) from a
|
(``'user'``, with ``actor_user_id`` holding the HA user id) from a
|
||||||
panel tap with no user context (``'panel'``, with ``actor_user_id``
|
panel tap with no user context (``'panel'``, with ``actor_user_id``
|
||||||
NULL — the tapped child profile is already ``child_id``). A coherence
|
NULL — the tapped child profile is ``actor_child_id``, D-008). Two
|
||||||
CHECK makes the pair shape non-negotiable: ``'user'`` requires
|
coherence CHECKs make both shapes non-negotiable: ``'user'`` requires
|
||||||
``actor_user_id``, ``'panel'`` forbids it. Other actor sources are
|
``actor_user_id`` and forbids ``actor_child_id``; ``'panel'`` requires
|
||||||
rejected; if an unattended system actor is ever needed, that is a
|
``actor_child_id`` (the tapped profile) and forbids ``actor_user_id``.
|
||||||
schema migration, not a silent widening.
|
Other actor sources are rejected; if an unattended system actor is
|
||||||
|
ever needed, that is a schema migration, not a silent widening.
|
||||||
|
|
||||||
On-time policy: ``was_on_time`` is required (integer 0 or 1) for
|
On-time policy: ``was_on_time`` is required (integer 0 or 1) for
|
||||||
``completed`` events — at completion time the due date is known and the
|
``completed`` events — at completion time the due date is known and the
|
||||||
@@ -241,7 +257,8 @@ QUEST_DEFINITIONS_TABLE_SQL = """(
|
|||||||
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
|
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
|
||||||
due_time TEXT,
|
due_time TEXT,
|
||||||
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
|
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL,
|
||||||
|
skip_on_away INTEGER NOT NULL DEFAULT 1 CHECK (skip_on_away IN (0, 1))
|
||||||
)"""
|
)"""
|
||||||
|
|
||||||
SCHEMA_V1_QUEST_DEFINITIONS_DDL: list[str] = [
|
SCHEMA_V1_QUEST_DEFINITIONS_DDL: list[str] = [
|
||||||
@@ -260,6 +277,17 @@ SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL: list[str] = [
|
|||||||
""",
|
""",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL: list[str] = [
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS quest_definition_windows (
|
||||||
|
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
|
||||||
|
window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')),
|
||||||
|
due_time TEXT,
|
||||||
|
PRIMARY KEY (definition_id, window)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
]
|
||||||
|
|
||||||
SCHEMA_V1_PRESENCE_SCHEDULES_DDL: list[str] = [
|
SCHEMA_V1_PRESENCE_SCHEDULES_DDL: list[str] = [
|
||||||
f"""
|
f"""
|
||||||
CREATE TABLE IF NOT EXISTS presence_schedules (
|
CREATE TABLE IF NOT EXISTS presence_schedules (
|
||||||
@@ -296,43 +324,79 @@ SCHEMA_V1_PRESENCE_OVERRIDES_DDL: list[str] = [
|
|||||||
""",
|
""",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
#: Column body of ``quest_instances``, shared between the v1 DDL
|
||||||
|
#: below and the instance-key rebuild migration, which must recreate
|
||||||
|
#: the table in exactly this shape (D-008: the instance key is
|
||||||
|
#: (definition_id, child_id, due_date, window) — one instance per
|
||||||
|
#: assigned child per declared window per firing date).
|
||||||
|
QUEST_INSTANCES_TABLE_SQL = """(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
|
||||||
|
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||||
|
window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')),
|
||||||
|
due_date TEXT NOT NULL,
|
||||||
|
due_time TEXT,
|
||||||
|
generated_at TEXT NOT NULL,
|
||||||
|
UNIQUE (definition_id, child_id, due_date, window)
|
||||||
|
)"""
|
||||||
|
|
||||||
SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [
|
SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [
|
||||||
"""
|
f"""
|
||||||
CREATE TABLE IF NOT EXISTS quest_instances (
|
CREATE TABLE IF NOT EXISTS quest_instances {QUEST_INSTANCES_TABLE_SQL}
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
|
|
||||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
|
||||||
due_date TEXT NOT NULL,
|
|
||||||
due_time TEXT,
|
|
||||||
generated_at TEXT NOT NULL,
|
|
||||||
UNIQUE (definition_id, due_date)
|
|
||||||
)
|
|
||||||
""",
|
""",
|
||||||
]
|
]
|
||||||
|
|
||||||
SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
|
#: Column body of ``completion_events``, shared between the v1 DDL
|
||||||
"""
|
#: below and the actor-child rebuild migration, which must recreate the
|
||||||
CREATE TABLE IF NOT EXISTS completion_events (
|
#: table in exactly this shape (D-008: ``actor_child_id`` records which
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
#: panel profile was tapped — set for panel events, NULL for admin
|
||||||
instance_id INTEGER NOT NULL REFERENCES quest_instances(id),
|
#: ones; legacy panel rows backfill to ``child_id``, the child they
|
||||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
#: always were).
|
||||||
event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')),
|
COMPLETION_EVENTS_TABLE_SQL = """(
|
||||||
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
actor_user_id TEXT,
|
instance_id INTEGER NOT NULL REFERENCES quest_instances(id),
|
||||||
occurred_at TEXT NOT NULL,
|
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||||
was_on_time INTEGER CHECK (
|
event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')),
|
||||||
was_on_time IS NULL
|
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
|
||||||
OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1))
|
actor_user_id TEXT,
|
||||||
),
|
actor_child_id INTEGER REFERENCES children(id),
|
||||||
CHECK (
|
occurred_at TEXT NOT NULL,
|
||||||
(actor_source = 'user' AND actor_user_id IS NOT NULL)
|
was_on_time INTEGER CHECK (
|
||||||
OR (actor_source = 'panel' AND actor_user_id IS NULL)
|
was_on_time IS NULL
|
||||||
),
|
OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1))
|
||||||
CHECK (
|
),
|
||||||
(event_type = 'completed' AND was_on_time IS NOT NULL)
|
CHECK (
|
||||||
OR (event_type = 'uncompleted')
|
(actor_source = 'user' AND actor_user_id IS NOT NULL)
|
||||||
)
|
OR (actor_source = 'panel' AND actor_user_id IS NULL)
|
||||||
|
),
|
||||||
|
CHECK (
|
||||||
|
(actor_source = 'user' AND actor_child_id IS NULL)
|
||||||
|
OR (actor_source = 'panel' AND actor_child_id IS NOT NULL)
|
||||||
|
),
|
||||||
|
CHECK (
|
||||||
|
(event_type = 'completed' AND was_on_time IS NOT NULL)
|
||||||
|
OR (event_type = 'uncompleted')
|
||||||
)
|
)
|
||||||
|
)"""
|
||||||
|
|
||||||
|
SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS completion_events {COMPLETION_EVENTS_TABLE_SQL}
|
||||||
|
""",
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Generic integration-owned key/value state (schema version 7,
|
||||||
|
#: Feature 11): persistent bookkeeping that belongs to the integration
|
||||||
|
#: rather than any domain table — currently the missed-sweep
|
||||||
|
#: watermark. ``value`` is a plain string; callers interpret it.
|
||||||
|
META_STATE_TABLE_SQL = """(
|
||||||
|
key TEXT NOT NULL PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
)"""
|
||||||
|
|
||||||
|
SCHEMA_V7_META_STATE_DDL: list[str] = [
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS nestquest_meta_state {META_STATE_TABLE_SQL}
|
||||||
""",
|
""",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -342,10 +406,12 @@ SCHEMA_V1_STATEMENTS: list[str] = [
|
|||||||
*SCHEMA_V1_SCHEDULE_RULES_DDL,
|
*SCHEMA_V1_SCHEDULE_RULES_DDL,
|
||||||
*SCHEMA_V1_QUEST_DEFINITIONS_DDL,
|
*SCHEMA_V1_QUEST_DEFINITIONS_DDL,
|
||||||
*SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
|
*SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
|
||||||
|
*SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
|
||||||
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
|
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
|
||||||
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
|
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
|
||||||
*SCHEMA_V1_QUEST_INSTANCES_DDL,
|
*SCHEMA_V1_QUEST_INSTANCES_DDL,
|
||||||
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
|
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
|
||||||
|
*SCHEMA_V7_META_STATE_DDL,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
"""Explicit, Home-Assistant-free settings object for NestQuest core.
|
||||||
|
|
||||||
|
The integration reads its configuration off a Home Assistant config
|
||||||
|
entry (``entry.options``); the core package must not. This module
|
||||||
|
bridges that split: it defines a frozen, validation-carrying
|
||||||
|
:class:`NestQuestSettings` dataclass that the integration constructs
|
||||||
|
from ``entry.options`` (via :meth:`NestQuestSettings.from_options`, or
|
||||||
|
:meth:`NestQuestSettings.from_options_resilient` for the per-field
|
||||||
|
fallback used at setup) and then passes into the core paths that
|
||||||
|
previously read HA config themselves — the rolling horizon
|
||||||
|
(``horizon_days``) and the daily rollover time (``day_rollover_time``),
|
||||||
|
plus the Feature 11 notification settings (notify target, the three
|
||||||
|
summary/reminder/report times, and the four independent enable toggles).
|
||||||
|
|
||||||
|
Nothing in this module imports ``homeassistant`` or touches a config
|
||||||
|
entry; ``from_options`` accepts any plain mapping keyed by the
|
||||||
|
:data:`~.const.CONF_*` names. The validation semantics match the
|
||||||
|
options flow's: ``horizon_days`` must be a real integer >= 1 (bool
|
||||||
|
rejected), and ``day_rollover_time`` plus the three notification times
|
||||||
|
must be strict ``HH:MM`` 24-hour strings. Construction therefore can
|
||||||
|
never yield a settings object whose horizon could shrink or explode the
|
||||||
|
materialized window, or whose rollover fires at an unparsable time.
|
||||||
|
|
||||||
|
The strict resolvers (:func:`_resolve_horizon_days`, :func:`_validate_hhmm`,
|
||||||
|
:func:`_resolve_bool`, :func:`_resolve_optional_str`) reject ``None``; the
|
||||||
|
``None``-as-absent mapping lives ONLY in :meth:`from_options` /
|
||||||
|
:meth:`from_options_resilient` (via :func:`_option_value`), so a hand-built
|
||||||
|
:class:`NestQuestSettings` cannot smuggle a ``None`` past
|
||||||
|
:meth:`__post_init__`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from types import MappingProxyType
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||||
|
CONF_AFTERNOON_REMINDER_TIME,
|
||||||
|
CONF_CELEBRATION_ENABLED,
|
||||||
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
|
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||||
|
CONF_END_OF_DAY_REPORT_TIME,
|
||||||
|
CONF_HORIZON_DAYS,
|
||||||
|
CONF_MORNING_SUMMARY_ENABLED,
|
||||||
|
CONF_MORNING_SUMMARY_TIME,
|
||||||
|
CONF_NOTIFY_TARGET,
|
||||||
|
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||||
|
DEFAULT_AUTOMATION_ENABLED,
|
||||||
|
DEFAULT_DAY_ROLLOVER_TIME,
|
||||||
|
DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||||
|
DEFAULT_HORIZON_DAYS,
|
||||||
|
DEFAULT_MORNING_SUMMARY_TIME,
|
||||||
|
LOGGER,
|
||||||
|
TIME_PATTERN,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _option_value(
|
||||||
|
opts: Mapping[str, Any], key: str, default: Any
|
||||||
|
) -> Any:
|
||||||
|
"""Return ``opts[key]`` when present and non-None, else ``default``.
|
||||||
|
|
||||||
|
This is the SOLE place the ``None``-as-absent mapping lives: a stored
|
||||||
|
``None`` is treated the same as an absent key — the historical
|
||||||
|
``horizon_days`` path mapped a missing-or-None value to the default,
|
||||||
|
and this keeps that forgiving semantics uniform across every field.
|
||||||
|
The per-field resolvers (:func:`_resolve_horizon_days`,
|
||||||
|
:func:`_validate_hhmm`, :func:`_resolve_bool`,
|
||||||
|
:func:`_resolve_optional_str`) are STRICT — they reject ``None`` —
|
||||||
|
so a partially-populated ``entry.options`` dict (or one migrated
|
||||||
|
across versions) never raises just because a key carries ``None``,
|
||||||
|
and a hand-built :class:`NestQuestSettings` cannot smuggle a
|
||||||
|
``None`` past :meth:`NestQuestSettings.__post_init__`.
|
||||||
|
"""
|
||||||
|
if key not in opts:
|
||||||
|
return default
|
||||||
|
value = opts[key]
|
||||||
|
return default if value is None else value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_hhmm(value: Any, *, field: str) -> str:
|
||||||
|
"""Validate a strict ``HH:MM`` string and return it; raise naming ``field``.
|
||||||
|
|
||||||
|
Uses the shared :data:`~.const.TIME_PATTERN` (the same compiled
|
||||||
|
regex the options flow applies), so a stored option and a freshly
|
||||||
|
validated form submit parse the same way. ``None`` (and any
|
||||||
|
non-string) is rejected; the ``None``-as-absent mapping lives ONLY
|
||||||
|
in :meth:`NestQuestSettings.from_options` (via :func:`_option_value`).
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str) or TIME_PATTERN.fullmatch(value) is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} must be a strict HH:MM string, got {value!r}"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_horizon_days(value: Any) -> int:
|
||||||
|
"""Validate a horizon value and return the resolved day count.
|
||||||
|
|
||||||
|
A bool is rejected (SQLite binds ``True``/``False`` onto an int), a
|
||||||
|
non-int is rejected, and a sub-1 value is rejected — so a malformed
|
||||||
|
option can never shrink or explode the materialized horizon.
|
||||||
|
``None`` is rejected here too; the ``None``-as-absent mapping lives
|
||||||
|
ONLY in :meth:`NestQuestSettings.from_options` (via
|
||||||
|
:func:`_option_value`), so a hand-built
|
||||||
|
:class:`NestQuestSettings(horizon_days=None)` raises in
|
||||||
|
:meth:`NestQuestSettings.__post_init__` rather than silently
|
||||||
|
constructing an object whose :meth:`NestQuestSettings.horizon_window`
|
||||||
|
would later raise ``TypeError`` on ``timedelta(days=None)``.
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"horizon_days must be a positive integer, got {value!r}"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_bool(value: Any) -> bool:
|
||||||
|
"""Return a real bool; a non-bool (including ``None``) is rejected.
|
||||||
|
|
||||||
|
voluptuous ``bool`` would coerce strings, so the options flow uses a
|
||||||
|
strict-bool validator and this mirrors it. The ``None``-as-absent
|
||||||
|
mapping lives ONLY in :meth:`NestQuestSettings.from_options` (via
|
||||||
|
:func:`_option_value`).
|
||||||
|
"""
|
||||||
|
if not isinstance(value, bool):
|
||||||
|
raise ValueError(f"expected a boolean, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_optional_str(value: Any) -> str:
|
||||||
|
"""Return a stripped notify target string; a non-str is rejected.
|
||||||
|
|
||||||
|
A padded or whitespace-only value is STRIPPED to its trim — the
|
||||||
|
shipped default is the empty string, meaning "no notifications are
|
||||||
|
configured yet". This is intentionally MORE LENIENT than the options
|
||||||
|
flow, which REJECTS a target that differs from its own trim (so a
|
||||||
|
padded value never persists): the integration tolerates a stored
|
||||||
|
padded value (e.g. one migrated from an older schema) by normalizing
|
||||||
|
it rather than crashing setup. The ``None``-as-absent mapping lives
|
||||||
|
ONLY in :meth:`NestQuestSettings.from_options` (via
|
||||||
|
:func:`_option_value`). :meth:`NestQuestSettings.__post_init__`
|
||||||
|
applies this SAME normalizer so a hand-built
|
||||||
|
``NestQuestSettings(notify_target=' x ')`` is stripped to ``'x'``
|
||||||
|
exactly like the options-flow path (no padded value can slip through
|
||||||
|
direct construction either).
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(f"notify_target must be a string, got {value!r}")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
#: The single field table the two builders share. Each row is
|
||||||
|
#: ``(field_name, CONF_* key, default, validator)``. ``from_options``
|
||||||
|
#: applies every validator (raising on the first invalid value) while
|
||||||
|
#: ``from_options_resilient`` wraps each in its own try/except so a single
|
||||||
|
#: bad sibling falls back to its default alone. Driving both builders
|
||||||
|
#: from this table avoids three-way drift between ``__post_init__``'s
|
||||||
|
#: inline checks and the two option builders' field lists: the only
|
||||||
|
#: places that enumerate the fields are this table and
|
||||||
|
#: ``__post_init__`` (which validates a hand-built instance without the
|
||||||
|
#: None->default mapping, so it stays inline).
|
||||||
|
_FIELDS: tuple[tuple[str, str, Any, Callable[[Any], Any]], ...] = (
|
||||||
|
("horizon_days", CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS, _resolve_horizon_days),
|
||||||
|
(
|
||||||
|
"day_rollover_time",
|
||||||
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
|
DEFAULT_DAY_ROLLOVER_TIME,
|
||||||
|
lambda v: _validate_hhmm(v, field="day_rollover_time"),
|
||||||
|
),
|
||||||
|
("notify_target", CONF_NOTIFY_TARGET, "", _resolve_optional_str),
|
||||||
|
(
|
||||||
|
"morning_summary_time",
|
||||||
|
CONF_MORNING_SUMMARY_TIME,
|
||||||
|
DEFAULT_MORNING_SUMMARY_TIME,
|
||||||
|
lambda v: _validate_hhmm(v, field="morning_summary_time"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"afternoon_reminder_time",
|
||||||
|
CONF_AFTERNOON_REMINDER_TIME,
|
||||||
|
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||||
|
lambda v: _validate_hhmm(v, field="afternoon_reminder_time"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"end_of_day_report_time",
|
||||||
|
CONF_END_OF_DAY_REPORT_TIME,
|
||||||
|
DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||||
|
lambda v: _validate_hhmm(v, field="end_of_day_report_time"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"morning_summary_enabled",
|
||||||
|
CONF_MORNING_SUMMARY_ENABLED,
|
||||||
|
DEFAULT_AUTOMATION_ENABLED,
|
||||||
|
_resolve_bool,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"afternoon_reminder_enabled",
|
||||||
|
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||||
|
DEFAULT_AUTOMATION_ENABLED,
|
||||||
|
_resolve_bool,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"end_of_day_report_enabled",
|
||||||
|
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||||
|
DEFAULT_AUTOMATION_ENABLED,
|
||||||
|
_resolve_bool,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"celebration_enabled",
|
||||||
|
CONF_CELEBRATION_ENABLED,
|
||||||
|
DEFAULT_AUTOMATION_ENABLED,
|
||||||
|
_resolve_bool,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NestQuestSettings:
|
||||||
|
"""Explicit, HA-free configuration carried into core paths.
|
||||||
|
|
||||||
|
The integration builds this once from ``entry.options`` (via
|
||||||
|
:meth:`from_options`, or :meth:`from_options_resilient` for the
|
||||||
|
per-field fallback used at setup) and threads it into the horizon
|
||||||
|
materialization and the daily-rollover scheduling. Core never
|
||||||
|
reads HA config itself; callers pass the resolved horizon and
|
||||||
|
rollover time in.
|
||||||
|
|
||||||
|
All fields are validated at construction; the dataclass is frozen so
|
||||||
|
a stale settings object cannot be mutated in place after the
|
||||||
|
integration hands it off.
|
||||||
|
"""
|
||||||
|
|
||||||
|
horizon_days: int = DEFAULT_HORIZON_DAYS
|
||||||
|
day_rollover_time: str = DEFAULT_DAY_ROLLOVER_TIME
|
||||||
|
notify_target: str = ""
|
||||||
|
morning_summary_time: str = DEFAULT_MORNING_SUMMARY_TIME
|
||||||
|
afternoon_reminder_time: str = DEFAULT_AFTERNOON_REMINDER_TIME
|
||||||
|
end_of_day_report_time: str = DEFAULT_END_OF_DAY_REPORT_TIME
|
||||||
|
morning_summary_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||||
|
afternoon_reminder_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||||
|
end_of_day_report_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||||
|
celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
# Validate STRICTLY at construction so a hand-built instance
|
||||||
|
# cannot carry an invalid value: the same rules from_options
|
||||||
|
# applies, but WITHOUT the None->default mapping (that lives
|
||||||
|
# only in from_options via _option_value). A None horizon, time,
|
||||||
|
# or toggle raises here rather than later blowing up
|
||||||
|
# horizon_window or the rollover listener.
|
||||||
|
_resolve_horizon_days(self.horizon_days)
|
||||||
|
_validate_hhmm(self.day_rollover_time, field="day_rollover_time")
|
||||||
|
# ``notify_target`` is normalised the SAME way as from_options
|
||||||
|
# (via _resolve_optional_str): a non-str is rejected and a
|
||||||
|
# padded/whitespace-only value is STRIPPED to its trim. This
|
||||||
|
# keeps a hand-built ``NestQuestSettings(notify_target=' x ')``
|
||||||
|
# behaving like the options flow (which strips on persist) so a
|
||||||
|
# padded value cannot slip through direct construction either.
|
||||||
|
# The dataclass is frozen, so the stripped value is written via
|
||||||
|
# object.__setattr__.
|
||||||
|
stripped_notify_target = _resolve_optional_str(self.notify_target)
|
||||||
|
object.__setattr__(self, "notify_target", stripped_notify_target)
|
||||||
|
_validate_hhmm(self.morning_summary_time, field="morning_summary_time")
|
||||||
|
_validate_hhmm(
|
||||||
|
self.afternoon_reminder_time, field="afternoon_reminder_time"
|
||||||
|
)
|
||||||
|
_validate_hhmm(
|
||||||
|
self.end_of_day_report_time, field="end_of_day_report_time"
|
||||||
|
)
|
||||||
|
for name in (
|
||||||
|
"morning_summary_enabled",
|
||||||
|
"afternoon_reminder_enabled",
|
||||||
|
"end_of_day_report_enabled",
|
||||||
|
"celebration_enabled",
|
||||||
|
):
|
||||||
|
value = getattr(self, name)
|
||||||
|
if not isinstance(value, bool):
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} must be a boolean, got {value!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def day_rollover_hour_minute(self) -> tuple[int, int]:
|
||||||
|
"""The (hour, minute) the daily rollover fires at."""
|
||||||
|
# The string is validated at construction, so a plain partition
|
||||||
|
# is safe and avoids re-running the regex on every access.
|
||||||
|
hour_str, _, minute_str = self.day_rollover_time.partition(":")
|
||||||
|
return int(hour_str), int(minute_str)
|
||||||
|
|
||||||
|
def horizon_window(
|
||||||
|
self, today: datetime.date
|
||||||
|
) -> tuple[datetime.date, datetime.date]:
|
||||||
|
"""Return the rolling horizon window ``[today, today + horizon_days]``.
|
||||||
|
|
||||||
|
``today`` is REQUIRED (there is no host-clock default): the
|
||||||
|
integration passes the HA-local ``hass.config.time_zone`` date so
|
||||||
|
the horizon tracks the household's own day, and a silent
|
||||||
|
host-clock fallback would be a wrong-day footgun (a test or a
|
||||||
|
caller that forgets to pass ``today`` would otherwise materialize
|
||||||
|
against the machine's calendar, not the household's). The
|
||||||
|
returned window is closed/inclusive — the same shape
|
||||||
|
:func:`~.materialize.materialize` walks.
|
||||||
|
"""
|
||||||
|
end = today + datetime.timedelta(days=self.horizon_days)
|
||||||
|
return today, end
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_options(
|
||||||
|
cls, options: Mapping[str, Any] | None = None
|
||||||
|
) -> "NestQuestSettings":
|
||||||
|
"""Build settings from a CONF_*-keyed mapping (e.g. ``entry.options``).
|
||||||
|
|
||||||
|
Reads the same keys the options flow persists, applying the
|
||||||
|
matching defaults when a key is absent OR ``None`` (via
|
||||||
|
:func:`_option_value`, the SOLE place the ``None``-as-absent
|
||||||
|
mapping lives) and validating STRICTLY: ``horizon_days`` is a
|
||||||
|
positive int (bool rejected), the four times are strict
|
||||||
|
``HH:MM``, the four toggles are real booleans, and
|
||||||
|
``notify_target`` is a string (stripped). A malformed value
|
||||||
|
RAISES — the integration uses :meth:`from_options_resilient` at
|
||||||
|
setup to fall back per field instead of crashing. A ``None``
|
||||||
|
mapping yields the all-defaults settings — useful for tests and
|
||||||
|
for a fresh install whose options dict is empty.
|
||||||
|
|
||||||
|
``options`` is consumed read-only via
|
||||||
|
:class:`types.MappingProxyType`, so this method never mutates
|
||||||
|
the caller's mapping.
|
||||||
|
"""
|
||||||
|
opts: Mapping[str, Any] = (
|
||||||
|
MappingProxyType({}) if options is None else MappingProxyType(options)
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
**{
|
||||||
|
field: validator(_option_value(opts, key, default))
|
||||||
|
for field, key, default, validator in _FIELDS
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_options_resilient(
|
||||||
|
cls, options: Mapping[str, Any] | None = None
|
||||||
|
) -> "NestQuestSettings":
|
||||||
|
"""Build settings, falling back PER FIELD on a malformed value.
|
||||||
|
|
||||||
|
Unlike :meth:`from_options` (which raises on the first invalid
|
||||||
|
field), this resolves EACH field in its own try/except: a
|
||||||
|
malformed value logs a :data:`~.const.LOGGER` warning naming the
|
||||||
|
field and falls back to that field's default. The integration
|
||||||
|
uses this at setup so a single bad sibling option (e.g. a
|
||||||
|
migrated ``morning_summary_time`` of ``'25:00'``) cannot
|
||||||
|
silently reset the user's ``horizon_days`` or
|
||||||
|
``day_rollover_time`` — the two fields core actually consumes.
|
||||||
|
|
||||||
|
An absent or ``None`` key maps to the default via
|
||||||
|
:func:`_option_value` WITHOUT a warning (that is the normal
|
||||||
|
missing-key path); only a present, non-``None``, invalid value
|
||||||
|
triggers a warning and a per-field fallback.
|
||||||
|
"""
|
||||||
|
opts: Mapping[str, Any] = (
|
||||||
|
MappingProxyType({}) if options is None else MappingProxyType(options)
|
||||||
|
)
|
||||||
|
resolved: dict[str, Any] = {}
|
||||||
|
for field, key, default, validator in _FIELDS:
|
||||||
|
raw = _option_value(opts, key, default)
|
||||||
|
try:
|
||||||
|
resolved[field] = validator(raw)
|
||||||
|
except ValueError as err:
|
||||||
|
LOGGER.warning(
|
||||||
|
"NestQuest option %s is invalid (%s); falling back "
|
||||||
|
"to the default %r",
|
||||||
|
field,
|
||||||
|
err,
|
||||||
|
default,
|
||||||
|
)
|
||||||
|
resolved[field] = default
|
||||||
|
return cls(**resolved)
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""Durable settings store for the NestQuest API service.
|
||||||
|
|
||||||
|
The API service has NO Home Assistant config entry, so it cannot build
|
||||||
|
:class:`~.settings.NestQuestSettings` from ``entry.options`` the way the
|
||||||
|
integration does. This module gives the API its own durable settings
|
||||||
|
source: a JSON document kept in the EXISTING ``nestquest_meta_state``
|
||||||
|
key/value table (:class:`~.dao_meta.MetaStateDao`) under the well-known
|
||||||
|
key :data:`SETTINGS_META_KEY` — no schema change, no new table —
|
||||||
|
validated through the SAME field table the settings object is built
|
||||||
|
from (:data:`~.settings._FIELDS`, the table driving
|
||||||
|
``from_options``/``from_options_resilient``). No validation rule is
|
||||||
|
re-implemented here.
|
||||||
|
|
||||||
|
OWNERSHIP: the API now owns settings in the database. The
|
||||||
|
integration's Home-Assistant config-entry options remain a temporary,
|
||||||
|
SEPARATE source until Feature 18 converts the integration into a thin
|
||||||
|
API client; the two are deliberately NOT unified here, and neither
|
||||||
|
side reads the other's values.
|
||||||
|
|
||||||
|
STORED SERIALIZATION — the document at ``SETTINGS_META_KEY`` is a JSON
|
||||||
|
object holding exactly the TEN settings fields, keyed by field name
|
||||||
|
(which equals the ``CONF_*`` option key for every field), complete
|
||||||
|
after the first update:
|
||||||
|
|
||||||
|
- ``horizon_days`` — int >= 1,
|
||||||
|
- ``day_rollover_time``, ``morning_summary_time``,
|
||||||
|
``afternoon_reminder_time``, ``end_of_day_report_time`` — strict
|
||||||
|
24-hour ``HH:MM`` strings,
|
||||||
|
- ``notify_target`` — a string (a padded value is stripped by the
|
||||||
|
core's normalizer),
|
||||||
|
- ``morning_summary_enabled``, ``afternoon_reminder_enabled``,
|
||||||
|
``end_of_day_report_enabled``, ``celebration_enabled`` — real
|
||||||
|
booleans.
|
||||||
|
|
||||||
|
Only values whose core validators ACCEPTED are ever written, so a
|
||||||
|
stored document always round-trips through the core's option builders.
|
||||||
|
An absent key (a fresh database) resolves to the all-defaults
|
||||||
|
settings, and a corrupt document degrades to defaults or per-field
|
||||||
|
defaults rather than crashing the service (see :func:`load_settings`).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .const import LOGGER
|
||||||
|
from .dao_meta import MetaStateDao
|
||||||
|
from .db import NestQuestDatabase
|
||||||
|
from .settings import NestQuestSettings, _FIELDS
|
||||||
|
|
||||||
|
#: The well-known ``nestquest_meta_state`` key holding the settings
|
||||||
|
#: document. One key, one JSON object — the same table the missed
|
||||||
|
#: sweep's watermark already uses, so no migration is needed.
|
||||||
|
SETTINGS_META_KEY = "settings"
|
||||||
|
|
||||||
|
#: The per-field resolution table keyed by field name: each row is the
|
||||||
|
#: ``(default, validator)`` pair the settings module's ``_FIELDS`` table
|
||||||
|
#: carries for that field. ``update_settings`` resolves each PROVIDED
|
||||||
|
#: field through its row's validator — the SAME core validators
|
||||||
|
#: ``from_options`` applies — and never re-implements one here.
|
||||||
|
#: (Field names equal the ``CONF_*`` option keys, so one dict serves
|
||||||
|
#: both the API's field names and the stored document's keys.)
|
||||||
|
_RESOLUTIONS: dict[str, tuple[Any, Any]] = {
|
||||||
|
key: (default, validator) for _field, key, default, validator in _FIELDS
|
||||||
|
}
|
||||||
|
|
||||||
|
#: The known field names, in the settings module's field order — the
|
||||||
|
#: allowed keys of an update body and the stored document's key set.
|
||||||
|
_FIELD_NAMES: tuple[str, ...] = tuple(
|
||||||
|
key for _field, key, _default, _validator in _FIELDS
|
||||||
|
)
|
||||||
|
|
||||||
|
#: One asyncio.Lock per (connection wrapper, running loop), the missed
|
||||||
|
#: sweep's pattern: the whole stored-document read → merge → write span
|
||||||
|
#: is one critical section, so two concurrent updates queue instead of
|
||||||
|
#: one losing the other's change — the loser re-reads the document
|
||||||
|
#: INSIDE the lock and merges onto the winner's settings.
|
||||||
|
_SETTINGS_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_lock(database: NestQuestDatabase) -> asyncio.Lock:
|
||||||
|
"""Return the settings lock bound to ``database``'s running loop."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
key = (id(database), id(loop))
|
||||||
|
lock = _SETTINGS_LOCKS.get(key)
|
||||||
|
if lock is None:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
_SETTINGS_LOCKS[key] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_document(database: NestQuestDatabase) -> dict[str, Any]:
|
||||||
|
"""Read and decode the stored settings document (empty when unusable).
|
||||||
|
|
||||||
|
An absent key — a fresh database that never updated settings — and
|
||||||
|
an unparseable value (invalid JSON, or JSON that is not an object)
|
||||||
|
return an empty mapping, which the option builders resolve to the
|
||||||
|
all-defaults settings; the corrupt cases log a warning first. A
|
||||||
|
parseable object is returned as stored (its per-field validity is
|
||||||
|
the builder's concern, not this reader's).
|
||||||
|
"""
|
||||||
|
raw = await MetaStateDao(database).get(SETTINGS_META_KEY)
|
||||||
|
if raw is None:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
document = json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
LOGGER.warning(
|
||||||
|
"NestQuest stored settings (meta_state key %r) are not valid "
|
||||||
|
"JSON; falling back to the default settings",
|
||||||
|
SETTINGS_META_KEY,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
if not isinstance(document, dict):
|
||||||
|
LOGGER.warning(
|
||||||
|
"NestQuest stored settings (meta_state key %r) are not a JSON "
|
||||||
|
"object; falling back to the default settings",
|
||||||
|
SETTINGS_META_KEY,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
def _named(field: str, error: ValueError) -> str:
|
||||||
|
"""Return a field-named message for a rejected validator value.
|
||||||
|
|
||||||
|
The horizon, time and notify-target validators already name their
|
||||||
|
field in the message; the boolean validator's message is generic
|
||||||
|
(``expected a boolean, got ...``), so it gets the field prefixed.
|
||||||
|
Every rejected update's error therefore names its field either way.
|
||||||
|
"""
|
||||||
|
message = str(error)
|
||||||
|
if message.startswith(f"{field} "):
|
||||||
|
return message
|
||||||
|
return f"{field}: {message}"
|
||||||
|
|
||||||
|
|
||||||
|
async def load_settings(database: NestQuestDatabase) -> NestQuestSettings:
|
||||||
|
"""Return the current effective settings stored for ``database``.
|
||||||
|
|
||||||
|
Reads :data:`SETTINGS_META_KEY` and resolves the stored document
|
||||||
|
through the core settings validators, with each absent field
|
||||||
|
falling back to its default. An absent key and an unparseable
|
||||||
|
document fall back to the all-defaults settings (via
|
||||||
|
:func:`_load_document`), and a parseable document with a single
|
||||||
|
invalid FIELD falls back PER FIELD — through
|
||||||
|
:meth:`NestQuestSettings.from_options_resilient`, the core's own
|
||||||
|
resilient builder — so a corrupt sibling never resets the stored
|
||||||
|
values around it. This never raises: the service must keep
|
||||||
|
serving reads even over a corrupt store.
|
||||||
|
"""
|
||||||
|
document = await _load_document(database)
|
||||||
|
return NestQuestSettings.from_options_resilient(document)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_settings(
|
||||||
|
database: NestQuestDatabase, changes: Mapping[str, Any]
|
||||||
|
) -> NestQuestSettings:
|
||||||
|
"""Validate ``changes`` and persist them as the effective settings.
|
||||||
|
|
||||||
|
``changes`` maps settings field names to new values. EVERY name is
|
||||||
|
checked against the known field table BEFORE anything is validated
|
||||||
|
or written: an unknown name raises a ``ValueError`` naming it (and
|
||||||
|
the known names), leaving the stored settings untouched. Each
|
||||||
|
PROVIDED field is then resolved from_options-style — its
|
||||||
|
:data:`~.settings._FIELDS` validator applied, with a JSON ``null``
|
||||||
|
mapping to the field's default (the settings module's uniform
|
||||||
|
None-as-absent rule) — and an invalid value raises a ``ValueError``
|
||||||
|
naming the field, also BEFORE anything is written, so a rejected
|
||||||
|
update can never leave a half-applied document behind.
|
||||||
|
|
||||||
|
The validated changes are merged onto the current effective
|
||||||
|
settings and the merged (complete, ten-field) document is persisted
|
||||||
|
in one atomic upsert; the updated settings are returned. The whole
|
||||||
|
read → merge → write span runs inside the per-database settings
|
||||||
|
lock, so concurrent updates queue instead of interleaving.
|
||||||
|
"""
|
||||||
|
if not isinstance(changes, Mapping):
|
||||||
|
raise ValueError(
|
||||||
|
"settings changes must be a JSON object mapping field names "
|
||||||
|
"to values"
|
||||||
|
)
|
||||||
|
unknown = sorted(
|
||||||
|
str(name) for name in changes if name not in _RESOLUTIONS
|
||||||
|
)
|
||||||
|
if unknown:
|
||||||
|
listed = ", ".join(repr(name) for name in unknown)
|
||||||
|
known = ", ".join(_FIELD_NAMES)
|
||||||
|
raise ValueError(
|
||||||
|
f"unknown settings field(s): {listed}; known fields: {known}"
|
||||||
|
)
|
||||||
|
async with _settings_lock(database):
|
||||||
|
current = await load_settings(database)
|
||||||
|
document: dict[str, Any] = {
|
||||||
|
field: getattr(current, field) for field in _FIELD_NAMES
|
||||||
|
}
|
||||||
|
for name, raw_value in changes.items():
|
||||||
|
default, validator = _RESOLUTIONS[name]
|
||||||
|
try:
|
||||||
|
document[name] = validator(
|
||||||
|
default if raw_value is None else raw_value
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError(_named(name, error)) from error
|
||||||
|
await MetaStateDao(database).set(
|
||||||
|
SETTINGS_META_KEY, json.dumps(document, sort_keys=True)
|
||||||
|
)
|
||||||
|
return NestQuestSettings(**document)
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""Pure, Home-Assistant-free panel snapshot builder.
|
||||||
|
|
||||||
|
The household snapshot the NestQuest panel renders — active children in
|
||||||
|
sort order, each child's today-instances in the documented payload shape
|
||||||
|
(missed instances OMITTED from the entity-facing attribute payload, but
|
||||||
|
retained on the snapshot for the count rollups), per-child counts and
|
||||||
|
presence, and the custody cycle day — used to be assembled inline inside
|
||||||
|
:mod:`custom_components.nestquest.coordinator`'s
|
||||||
|
``_async_update_data``. That assembly is HA-coupled only because its
|
||||||
|
host is a :class:`~homeassistant.helpers.update_coordinator.DataUpdateCoordinator`;
|
||||||
|
the assembly itself touches no HA surface.
|
||||||
|
|
||||||
|
This module holds the pure pieces — the snapshot dataclasses
|
||||||
|
(:class:`QuestInstanceView`, :class:`ChildDaySnapshot`,
|
||||||
|
:class:`NestQuestSnapshot`), the cycle-day helper (:func:`_cycle_day`),
|
||||||
|
the panel payload shaper (:func:`instance_payload`), and the async
|
||||||
|
:func:`build_snapshot` builder — so the HA coordinator and the API
|
||||||
|
service's panel snapshot route share ONE implementation of both the
|
||||||
|
snapshot assembly and the entity-facing attribute payload shape.
|
||||||
|
Nothing here imports :mod:`homeassistant`; the builder takes the
|
||||||
|
database, the settings object, and the HA-local ``now`` (a single
|
||||||
|
clock read the caller already resolved, from which it derives
|
||||||
|
``today``) and returns the snapshot, mirroring exactly what the
|
||||||
|
coordinator used to do inline.
|
||||||
|
|
||||||
|
``settings`` is accepted because the API route hands in the entry's
|
||||||
|
:class:`~.settings.NestQuestSettings`; it is CURRENTLY UNUSED — the
|
||||||
|
assembly reads nothing from it — and is retained so the builder's
|
||||||
|
contract stays stable if a future panel feature derives scan caps or
|
||||||
|
rollup behaviour from configuration. No speculative read is wired in.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .completion import derive_state
|
||||||
|
from .dao_children import ChildrenDao
|
||||||
|
from .dao_instances import CompletionEventsDao, QuestInstancesDao
|
||||||
|
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
||||||
|
from .dao_rules import QuestDefinitionsDao
|
||||||
|
from .db import NestQuestDatabase
|
||||||
|
from .presence import (
|
||||||
|
MAX_PREVIEW_SCAN_DAYS,
|
||||||
|
PresenceEngine,
|
||||||
|
PresenceOverride,
|
||||||
|
PresenceSchedule,
|
||||||
|
)
|
||||||
|
from .settings import NestQuestSettings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QuestInstanceView:
|
||||||
|
"""One instance plus everything the entities render from it."""
|
||||||
|
|
||||||
|
instance_id: int
|
||||||
|
definition_id: int
|
||||||
|
child_id: int
|
||||||
|
title: str
|
||||||
|
icon: str | None
|
||||||
|
window: str
|
||||||
|
due_date: str
|
||||||
|
due_time: str | None
|
||||||
|
state: str # open | done | missed
|
||||||
|
overdue: bool
|
||||||
|
completed_at: str | None
|
||||||
|
was_on_time: bool | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChildDaySnapshot:
|
||||||
|
"""One active child's day: instances, presence, and rollup counts."""
|
||||||
|
|
||||||
|
child_id: int
|
||||||
|
child_name: str
|
||||||
|
instances: tuple[QuestInstanceView, ...]
|
||||||
|
present: bool
|
||||||
|
due_today: int
|
||||||
|
completed_today: int
|
||||||
|
remaining_today: int
|
||||||
|
completion_pct: int
|
||||||
|
#: 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). ``None`` when the child is present today or no
|
||||||
|
#: present day exists within the presence engine's scan cap.
|
||||||
|
next_present: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NestQuestSnapshot:
|
||||||
|
"""The coordinator's whole-entity data payload."""
|
||||||
|
|
||||||
|
today_iso: str
|
||||||
|
children: tuple[ChildDaySnapshot, ...]
|
||||||
|
cycle_day: int
|
||||||
|
|
||||||
|
|
||||||
|
def _cycle_day(
|
||||||
|
schedules: dict[int, PresenceSchedule], children, today: datetime.date
|
||||||
|
) -> int:
|
||||||
|
"""Return today's 1-based day in the first scheduled child's cycle.
|
||||||
|
|
||||||
|
Anchor-date arithmetic ONLY (D-004): the day offset is computed from
|
||||||
|
the schedule's own anchor, never ISO week numbers or parity, so a
|
||||||
|
53-week year cannot silently invert the cycle.
|
||||||
|
"""
|
||||||
|
for child in children:
|
||||||
|
schedule = schedules.get(child.id)
|
||||||
|
if schedule is None:
|
||||||
|
continue
|
||||||
|
days = (today - schedule.anchor_date).days
|
||||||
|
cycle_length = schedule.cycle_length_weeks * 7
|
||||||
|
return (days % cycle_length) + 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def instance_payload(
|
||||||
|
instances: Iterable[QuestInstanceView], *, include_missed: bool
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Shape instance dicts per ENTITIES-AND-SERVICES.md §1.
|
||||||
|
|
||||||
|
The panel payload ``state`` is the documented ``open`` | ``completed``
|
||||||
|
spelling, so the builder's derived ``done`` maps to ``completed`` here;
|
||||||
|
``open`` passes through. ``missed`` instances are OMITTED from the
|
||||||
|
entity-facing attribute payload (decision 5) unless ``include_missed``
|
||||||
|
is set — the admin payload (D-009) keeps them with their ``missed``
|
||||||
|
state. ``on_time`` passes the builder's ``was_on_time`` through
|
||||||
|
verbatim (``True`` / ``False`` for a completed instance, ``None`` for
|
||||||
|
an open one). This is the SINGLE shared implementation both the HA
|
||||||
|
sensor layer and the API panel route read; it touches no Home
|
||||||
|
Assistant surface.
|
||||||
|
"""
|
||||||
|
payload: list[dict[str, Any]] = []
|
||||||
|
for view in instances:
|
||||||
|
if view.state == "missed" and not include_missed:
|
||||||
|
continue
|
||||||
|
payload.append(
|
||||||
|
{
|
||||||
|
"id": view.instance_id,
|
||||||
|
"definition_id": view.definition_id,
|
||||||
|
"child_id": view.child_id,
|
||||||
|
"title": view.title,
|
||||||
|
"icon": view.icon,
|
||||||
|
"window": view.window,
|
||||||
|
"due_time": view.due_time,
|
||||||
|
"state": (
|
||||||
|
"completed" if view.state == "done" else view.state
|
||||||
|
),
|
||||||
|
"overdue": view.overdue,
|
||||||
|
"completed_at": view.completed_at,
|
||||||
|
"on_time": view.was_on_time,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
async def build_snapshot(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
settings: NestQuestSettings,
|
||||||
|
now: datetime.datetime,
|
||||||
|
) -> NestQuestSnapshot:
|
||||||
|
"""Build the household panel snapshot for ``today`` at the ``now`` instant.
|
||||||
|
|
||||||
|
Mirrors the assembly that lived in the coordinator's
|
||||||
|
``_async_update_data``: active children in sort order, each child's
|
||||||
|
today-instances with derived state (open / done / missed via
|
||||||
|
:func:`~.completion.derive_state`) and the overdue flag, per-child
|
||||||
|
presence (resolved through :class:`~.presence.PresenceEngine`), the
|
||||||
|
per-child rollup counts, and the custody cycle day of the first
|
||||||
|
scheduled active child. Missed instances are retained on the
|
||||||
|
snapshot (the rollup counts and the panel's missed plate need them)
|
||||||
|
but the entity-facing attribute payload omits them at the
|
||||||
|
:func:`instance_payload` layer — this builder produces the shared
|
||||||
|
shape both consumers read.
|
||||||
|
|
||||||
|
``now`` is the HA-local timezone-aware datetime the caller already
|
||||||
|
resolved (a SINGLE clock read); the overdue check compares
|
||||||
|
``now.time()`` against the instance's ``due_time`` for today's open
|
||||||
|
instances. The builder performs NO internal clock read — it never
|
||||||
|
calls :func:`datetime.datetime.now` — so the instant is pinned in
|
||||||
|
tests. ``today`` is derived INSIDE the builder as ``now.date()``,
|
||||||
|
so the calendar date that anchors the due-date and cycle-day
|
||||||
|
arithmetic is always the SAME clock read as the wall-clock time;
|
||||||
|
the two can never disagree. ``settings`` is accepted but currently
|
||||||
|
unused (see the module docstring).
|
||||||
|
"""
|
||||||
|
today = now.date()
|
||||||
|
today_iso = today.isoformat()
|
||||||
|
|
||||||
|
children = await ChildrenDao(database).list_active()
|
||||||
|
|
||||||
|
schedules: dict[int, PresenceSchedule] = {}
|
||||||
|
overrides: dict[int, list[PresenceOverride]] = {}
|
||||||
|
for child in children:
|
||||||
|
schedule = await PresenceSchedulesDao(database).get_by_child(
|
||||||
|
child.id
|
||||||
|
)
|
||||||
|
if schedule is not None:
|
||||||
|
schedules[child.id] = PresenceSchedule.decode(
|
||||||
|
child.id, schedule.anchor_date, schedule.pattern
|
||||||
|
)
|
||||||
|
window_end = today + datetime.timedelta(
|
||||||
|
days=MAX_PREVIEW_SCAN_DAYS
|
||||||
|
)
|
||||||
|
window_end_iso = window_end.isoformat()
|
||||||
|
ranged = await PresenceOverridesDao(
|
||||||
|
database
|
||||||
|
).list_by_child_and_range(child.id, today_iso, window_end_iso)
|
||||||
|
if ranged:
|
||||||
|
# The engine validates MODEL objects, not DAO records;
|
||||||
|
# each record converts losslessly (the schema guarantees
|
||||||
|
# end >= start and a real is_present).
|
||||||
|
overrides[child.id] = [
|
||||||
|
PresenceOverride(
|
||||||
|
record.child_id,
|
||||||
|
record.start_date,
|
||||||
|
record.end_date,
|
||||||
|
record.is_present,
|
||||||
|
record.note,
|
||||||
|
)
|
||||||
|
for record in ranged
|
||||||
|
]
|
||||||
|
engine = PresenceEngine(schedules, overrides)
|
||||||
|
|
||||||
|
instances_dao = QuestInstancesDao(database)
|
||||||
|
events_dao = CompletionEventsDao(database)
|
||||||
|
definitions_dao = QuestDefinitionsDao(database)
|
||||||
|
|
||||||
|
snapshots: list[ChildDaySnapshot] = []
|
||||||
|
for child in children:
|
||||||
|
instances = await instances_dao.list_by_date_range(
|
||||||
|
child.id, today_iso, today_iso
|
||||||
|
)
|
||||||
|
views: list[QuestInstanceView] = []
|
||||||
|
for instance in instances:
|
||||||
|
latest = await events_dao.get_latest_for_instance(instance.id)
|
||||||
|
definition = await definitions_dao.get(instance.definition_id)
|
||||||
|
state = derive_state(instance, latest, today)
|
||||||
|
overdue = False
|
||||||
|
if state == "open" and instance.due_date == today_iso:
|
||||||
|
if instance.due_time is not None:
|
||||||
|
due_moment = datetime.datetime.strptime(
|
||||||
|
instance.due_time, "%H:%M"
|
||||||
|
).time()
|
||||||
|
overdue = now.time() > due_moment
|
||||||
|
views.append(
|
||||||
|
QuestInstanceView(
|
||||||
|
instance_id=instance.id,
|
||||||
|
definition_id=instance.definition_id,
|
||||||
|
child_id=instance.child_id,
|
||||||
|
title=definition.title if definition else "Quest",
|
||||||
|
icon=definition.icon if definition else None,
|
||||||
|
window=instance.window,
|
||||||
|
due_date=instance.due_date,
|
||||||
|
due_time=instance.due_time,
|
||||||
|
state=state,
|
||||||
|
overdue=overdue,
|
||||||
|
completed_at=(
|
||||||
|
latest.occurred_at
|
||||||
|
if latest is not None
|
||||||
|
and latest.event_type == "completed"
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
was_on_time=(
|
||||||
|
latest.was_on_time
|
||||||
|
if latest is not None
|
||||||
|
and latest.event_type == "completed"
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
due = len(views)
|
||||||
|
completed = sum(1 for view in views if view.state == "done")
|
||||||
|
remaining = due - completed
|
||||||
|
child_present = engine.is_present(child.id, today)
|
||||||
|
next_present: str | None = None
|
||||||
|
if not child_present:
|
||||||
|
try:
|
||||||
|
next_present = engine.next_present_dates(
|
||||||
|
child.id, today, 1
|
||||||
|
)[0].isoformat()
|
||||||
|
except ValueError:
|
||||||
|
next_present = None
|
||||||
|
snapshots.append(
|
||||||
|
ChildDaySnapshot(
|
||||||
|
child_id=child.id,
|
||||||
|
child_name=child.display_name,
|
||||||
|
instances=tuple(views),
|
||||||
|
present=child_present,
|
||||||
|
due_today=due,
|
||||||
|
completed_today=completed,
|
||||||
|
remaining_today=remaining,
|
||||||
|
completion_pct=(
|
||||||
|
round(completed * 100 / due) if due else 100
|
||||||
|
),
|
||||||
|
next_present=next_present,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return NestQuestSnapshot(
|
||||||
|
today_iso=today_iso,
|
||||||
|
children=tuple(snapshots),
|
||||||
|
cycle_day=_cycle_day(schedules, children, today),
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""SQLite storage path resolution for the NestQuest storage layer.
|
||||||
|
|
||||||
|
Home-Assistant-free: callers resolve the database file location from
|
||||||
|
their own config (HA resolves it from ``hass.config.path``) and pass
|
||||||
|
the full path here; this module ensures the parent directory exists
|
||||||
|
off-thread via :func:`asyncio.to_thread` and returns the absolute path.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_dir(path: Path) -> None:
|
||||||
|
"""Create the given directory and any missing parents."""
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_get_db_path(db_path: Path | str) -> Path:
|
||||||
|
"""Return the absolute path to the NestQuest SQLite database file.
|
||||||
|
|
||||||
|
``db_path`` is the full path to the database file (the integration
|
||||||
|
resolves it from ``hass.config.path(SQLITE_DB_FILENAME)``); the
|
||||||
|
parent directory is created off the event loop via
|
||||||
|
:func:`asyncio.to_thread` so this module never blocks and never
|
||||||
|
imports Home Assistant.
|
||||||
|
"""
|
||||||
|
path = Path(db_path).absolute()
|
||||||
|
await asyncio.to_thread(_ensure_dir, path.parent)
|
||||||
|
return path
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""The nightly missed-quest sweep, Home-Assistant-free (Feature 11).
|
||||||
|
|
||||||
|
This is the HA-free heart of the missed sweep, shared by the two
|
||||||
|
planes: the Home Assistant integration's :mod:`~..sweep` shim (which
|
||||||
|
fires the returned events on ``hass.bus``) and the API service (which
|
||||||
|
publishes them on the SSE stream and can also trigger the run on
|
||||||
|
demand). It owns the ONE sweep policy:
|
||||||
|
|
||||||
|
At the configured day-rollover time (or on an explicit trigger) this
|
||||||
|
job finds every instance whose ``due_date`` is before the
|
||||||
|
household-local date, that has NO completion event, and that falls
|
||||||
|
after the persisted sweep watermark — and RETURNS one
|
||||||
|
``nestquest_quest_missed`` event per instance with the documented
|
||||||
|
payload. Like every core module, it never touches the Home Assistant
|
||||||
|
event bus and never mutates an instance: ``missed`` is derived state,
|
||||||
|
never an event_type (Feature 08's contract), so the sweep is read-only
|
||||||
|
over the domain tables.
|
||||||
|
|
||||||
|
Idempotency is the watermark: after a run, ``nestquest_meta_state``
|
||||||
|
records the household-local date the sweep ran, and the next run only
|
||||||
|
sweeps instances due in ``[watermark, today)``. A second run the same
|
||||||
|
night therefore fires nothing new, and a run after days of downtime
|
||||||
|
sweeps exactly the accumulated window once — no per-instance journal,
|
||||||
|
no re-announcing old misses. An instance swept as missed that is
|
||||||
|
later completed simply gains its completion event; history keeps both
|
||||||
|
rows (append-only), and the sweep's later runs skip it via the
|
||||||
|
no-completion-event rule.
|
||||||
|
|
||||||
|
The whole watermark read → query → build span runs inside the
|
||||||
|
per-database sweep lock (one lock per connection wrapper and running
|
||||||
|
loop, the settings store's pattern): an overlapping startup sweep and
|
||||||
|
a rollover sweep of the SAME process queue instead of interleaving.
|
||||||
|
The announce decision itself is the DATABASE-level claim
|
||||||
|
(:meth:`MetaStateDao.claim`), so two PROCESSES sharing one SQLite file
|
||||||
|
(the integration's rollover sweep and the API service's scheduler)
|
||||||
|
cannot both announce the same missed batch either: both may read the
|
||||||
|
old watermark and build the events, but the single conditional upsert
|
||||||
|
lets exactly one of them win the claim — the loser returns [] and
|
||||||
|
announces nothing.
|
||||||
|
|
||||||
|
The caller supplies ``today`` as a plain :class:`datetime.date`: the
|
||||||
|
household-local calendar date, read from ``hass.config.time_zone`` by
|
||||||
|
the integration or from the API host's local clock by the API. This
|
||||||
|
module never reads a timezone database or config of its own.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .const import EVENT_QUEST_MISSED
|
||||||
|
from .dao_meta import MetaStateDao
|
||||||
|
from .dao_instances import QuestInstancesDao
|
||||||
|
from .db import NestQuestDatabase
|
||||||
|
# Intentional intra-package reuse of the shared payload builder: the
|
||||||
|
# missed event's payload is the documented §3 instance payload, and
|
||||||
|
# building it through the ONE builder keeps this sweep's payloads from
|
||||||
|
# drifting from the completion/uncompleted builders' shape.
|
||||||
|
from .events import _instance_payload
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: meta_state key holding the household-local date the sweep last ran.
|
||||||
|
SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date"
|
||||||
|
|
||||||
|
#: One asyncio.Lock per (connection wrapper, running loop), mirroring
|
||||||
|
#: the settings store's pattern: the watermark read → query → build
|
||||||
|
#: span is one critical section, so an overlapping startup sweep and
|
||||||
|
#: rollover sweep of the SAME process queue instead of interleaving.
|
||||||
|
#: Same-process callers are fully serialised by it; the cross-process
|
||||||
|
#: case is closed by the database-level claim, not by this lock.
|
||||||
|
_SWEEP_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _sweep_lock(database: NestQuestDatabase) -> asyncio.Lock:
|
||||||
|
"""Return the sweep lock bound to ``database``'s running loop."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
key = (id(database), id(loop))
|
||||||
|
lock = _SWEEP_LOCKS.get(key)
|
||||||
|
if lock is None:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
_SWEEP_LOCKS[key] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
|
#: Strict UTC ISO-8601 timestamp (the DAO layers' policy): one shape,
|
||||||
|
#: explicit offset, second precision.
|
||||||
|
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
async def run_missed_sweep(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
*,
|
||||||
|
today: datetime.date,
|
||||||
|
) -> list[tuple[str, dict]]:
|
||||||
|
"""Build one missed event per unswept past-due open instance.
|
||||||
|
|
||||||
|
``today`` is the household-local calendar date the sweep runs for
|
||||||
|
(the caller owns the clock: the integration reads it from
|
||||||
|
``hass.config.time_zone``, the API from its host's local time).
|
||||||
|
Returns the ``(event_type, payload)`` tuples to announce, EMPTY
|
||||||
|
when the watermark already covers ``today`` (a same-night rerun) —
|
||||||
|
the caller decides how to publish them and announces nothing on
|
||||||
|
its own.
|
||||||
|
|
||||||
|
The watermark read → query → build span runs inside the
|
||||||
|
per-database sweep lock, so overlapping callers of ONE process
|
||||||
|
queue instead of interleaving. The announce decision is the
|
||||||
|
DATABASE-level claim (:meth:`MetaStateDao.claim` — a single
|
||||||
|
conditional upsert on the watermark, atomic across processes):
|
||||||
|
every caller builds the events optimistically, then only the
|
||||||
|
caller whose claim actually advanced the watermark returns them;
|
||||||
|
a loser (another process already announced for ``today``) returns
|
||||||
|
[] and announces nothing twice. Every event in one run shares one
|
||||||
|
``occurred_at`` stamp (one run, one announcement moment), and the
|
||||||
|
payload comes from the shared
|
||||||
|
:func:`~.events._instance_payload` builder so the shape can never
|
||||||
|
drift from the documented §3 contract.
|
||||||
|
|
||||||
|
Failure ordering: the watermark is advanced ONLY by the claim,
|
||||||
|
AFTER the events are fully built — a failure before the claim
|
||||||
|
leaves the watermark unadvanced, so a later run re-sweeps the same
|
||||||
|
window and nothing is lost.
|
||||||
|
"""
|
||||||
|
today_iso = today.isoformat()
|
||||||
|
dao = MetaStateDao(database)
|
||||||
|
async with _sweep_lock(database):
|
||||||
|
watermark = await dao.get(SWEEP_WATERMARK_KEY)
|
||||||
|
if watermark == today_iso:
|
||||||
|
LOGGER.debug(
|
||||||
|
"NestQuest missed sweep already ran for %s", today_iso
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
instances_dao = QuestInstancesDao(database)
|
||||||
|
rows = await instances_dao.list_missed_unswept(today_iso, watermark)
|
||||||
|
now_stamp = datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||||
|
_UTC_TIMESTAMP_FORMAT
|
||||||
|
)
|
||||||
|
events: list[tuple[str, dict]] = []
|
||||||
|
for instance in rows:
|
||||||
|
events.append(
|
||||||
|
(
|
||||||
|
EVENT_QUEST_MISSED,
|
||||||
|
await _instance_payload(
|
||||||
|
database, instance.id, now_stamp
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# The database-level claim gates the announcement: only the
|
||||||
|
# caller that actually advanced the watermark may publish the
|
||||||
|
# built events, so two processes racing one SQLite file cannot
|
||||||
|
# both announce the same missed batch. A same-value loser's
|
||||||
|
# claim matches zero rows and this run announces nothing.
|
||||||
|
claimed = await dao.claim(SWEEP_WATERMARK_KEY, today_iso)
|
||||||
|
if not claimed:
|
||||||
|
LOGGER.info(
|
||||||
|
"NestQuest missed sweep for %s lost the watermark claim; "
|
||||||
|
"announcing nothing",
|
||||||
|
today_iso,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
LOGGER.info(
|
||||||
|
"NestQuest missed sweep for %s built %d event(s)",
|
||||||
|
today_iso,
|
||||||
|
len(events),
|
||||||
|
)
|
||||||
|
return events
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user