From 10f365ef9f04a8d9084ccd41067808cb3d6bdc90 Mon Sep 17 00:00:00 2001 From: Joshua Date: Mon, 21 Sep 2026 13:10:57 -0400 Subject: [PATCH 01/11] Add the re-imagined architecture document --- docs/ARCHITECTURE.md | 203 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 docs/ARCHITECTURE.md diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..054b20e --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,203 @@ +# NestQuest Architecture + +Status: approved direction (2026-09-21). This document supersedes the +single-process design implied by Features 01–14, in which the Home Assistant +integration owned the database and the parent admin surface was planned as a +Lovelace card inside Home Assistant. + +## 1. Why this changed + +The original design put everything inside the Home Assistant integration: the +SQLite database, the business logic, the kid panel cards, and (planned) the +parent admin card. Three problems surfaced: + +1. **The panel needed hand configuration.** Standing up the kid panel required + hand-writing card YAML (`child_order`, `quest_log_path`, `board_path`) and + hand-creating a Lovelace dashboard. A fresh install rendered nothing. +2. **The admin surface fought Home Assistant.** The admin design + (`design/ADMIN-SPEC.md`) is drawn at 390×844 — it is a phone app. Trapping it + inside a Lovelace card meant fighting HA's UI constraints for a mobile-first + experience. +3. **Data was locked to one process.** A standalone admin web app cannot reach a + SQLite file inside Home Assistant's config directory. + +The re-imagined design keeps the panel in Home Assistant (TouchHub loads an HA +dashboard), moves data ownership to a standalone API service, and promotes the +admin surface to a standalone mobile-first PWA. + +## 2. Target architecture + +``` + DAKboard TouchHub + │ (loads an HA dashboard URL) + ▼ + HA box API box + ┌───────────────────────────────┐ ┌───────────────────────────────────┐ + │ Lovelace panel dashboard │ │ reverse proxy (TLS) │ + │ ├ nestquest-party-board-card │ service │ ├ /api/v1/panel/* LAN-only │ + │ └ nestquest-quest-log-card │ token │ └ /api/v1/admin/* public │ + │ │ ─────────▶ │ FastAPI (imports core/) │ + │ NestQuest integration │ │ └ SQLite (owned here) │ + │ (thin client, no database) │ ◀───────── │ Authentik (OIDC) │ + │ sensors + HA bus events │ SSE └───────────────────────────────────┘ + └───────────────────────────────┘ ▲ + PWA ───────────┘ + https://nestquest.cubecraftlabs.com +``` + +One owner of the data (the API). Two clients (the HA integration, the PWA). + +## 3. Data ownership + +**The API service is the only writer.** The HA integration never opens the +database; it reads a snapshot and posts completions. This removes the +two-writer problem entirely. + +- **Storage:** SQLite on the API box. Single household, trivial concurrency, + and the existing schema, migrations, and DAO tests are already SQLite. Postgres + buys nothing at this scale. +- **Schema:** unchanged. The cutover from the current HA-hosted database is a + file copy — same schema, same migration version. +- **Backups:** continuous replication (e.g. Litestream) plus a proven restore. + A configured-but-untested backup is not a backup. + +## 4. Authentication: two planes + +The kid panel and the admin PWA have opposite trust levels, so they use +separate credentials and separate network exposure. + +### Panel plane (machine) + +- **Credential:** a static service token. +- **Exposure:** `/api/v1/panel/*` is reachable **only from the LAN**. +- **Grants:** read the snapshot, and the single open write — complete a quest as + `actor: panel` with the tapped `actor_child_id`. +- **Refused:** admin routes reject the service token outright, not merely as a + non-admin. The internet cannot reach these routes at all. + +This preserves the original fail-closed boundary: the kiosk can complete a +quest and nothing else. + +### Admin plane (human) + +- **Credential:** an Authentik OIDC JWT (authorization code + PKCE). +- **Exposure:** `/api/v1/admin/*` is internet-facing behind Authentik. +- **Grants:** everything a parent can do — definitions, assignment, presence, + children, un-complete, history, export, settings, regenerate. +- **Gate:** the API validates the JWT (issuer, audience, JWKS signature, expiry) + and requires membership in the `nestquest-admins` group. The Authentik group + is the single source of truth for "who is a parent", replacing the + integration-side admin allowlist. + +## 5. Components + +### `core/` — domain library + +The extracted domain layer: schema, migrations, database access, DAOs, the pure +recurrence and presence engines, materialization, completion, event payload +construction, and the children/definitions/allowlist business layers. **No +Home Assistant imports.** Consumed by both the API and (during transition) the +integration. This is where the ~1500 existing tests live after extraction. + +### `api/` — NestQuest API + +A FastAPI service importing `core/`. Owns the database. Serves the panel and +admin planes, validates Authentik tokens, and publishes a transition event +stream (SSE) that the integration consumes. No business logic in route handlers. + +### `custom_components/nestquest/` — HA integration (client) + +A thin client. Polls the snapshot and builds the existing sensors; subscribes to +the SSE stream and re-fires the four HA bus events; proxies the panel completion. +Holds a last-good snapshot cache so a brief API outage does not blank the panel. +Owns no database and no business logic. + +### `panel/` — Lovelace cards and dashboard strategy + +The two kid-facing Lit cards plus a dashboard strategy that generates the panel +views at render time. Cards auto-discover children, so no `child_order` is +required; the strategy computes navigation paths, so `quest_log_path` and +`board_path` are not required. + +### `admin/` — Admin PWA + +A React progressive web app implementing `design/ADMIN-SPEC.md`, authenticated +via Authentik OIDC, installable on a phone. The only admin surface. + +## 6. Zero-configuration panel + +A fresh install should show the panel with no YAML: + +1. **Auto-discovery.** The party-board card reads an ordered child list from a + household entity attribute instead of a configured `child_order`. +2. **Dashboard strategy.** A Lovelace strategy generates a party-board view and + one quest-log view per child, wiring navigation between them, so paths are + computed rather than configured. Explicit configuration still wins if a user + supplies it. +3. **Auto-registration.** The integration registers the dashboard on first + setup, so there is no manual dashboard step. + +## 7. Home Assistant surface preserved + +The re-imagining must not break what already works: + +- **Sensors:** the per-child and household sensor entity_ids and the per-child + instance attribute payload shape are unchanged. The panel cards and the four + blueprints depend on them. +- **Events:** the four HA bus events (`nestquest_quest_completed`, + `nestquest_quest_uncompleted`, `nestquest_quest_missed`, + `nestquest_child_day_complete`) keep firing with their documented payloads. + They are now driven by the API's transition stream rather than by local + service handlers. +- **Blueprints:** the morning summary, afternoon reminder, end-of-day report, and + day-complete celebration continue to work with no change. + +## 8. Events without an HA token on the API box + +The API publishes transitions on an SSE stream; the integration subscribes and +re-fires the HA bus events locally. This deliberately avoids storing a Home +Assistant long-lived token on the API box, which would grant full HA access. + +## 9. Migration phases + +Mapped to the Maestro features: + +| Phase | Feature | Outcome | +|---|---|---| +| 1 | 15 — Domain Core Extraction | `core/` runs with no HA imports; one implementation of the rules | +| 2 | 16 — NestQuest API Service | Panel and admin planes served with two-plane auth | +| 3 | 17 — API Deployment and Identity | TLS, Authentik, backups, network separation | +| 4 | 18 — HA Integration as API Client | Integration becomes a client; entity/event surface preserved | +| 5 | 19 — Admin PWA | Mobile-first React PWA replaces the admin card | +| 6 | 20 — Zero-Config Panel Dashboard | Panel installs itself; navigation and animations match the mockups | + +Cutover: copy the existing SQLite file to the API box (same schema). + +## 10. Locked decisions + +- **API on its own box** — separate from Home Assistant. +- **Authentik** (`https://auth.cubecraftlabs.com`) for admin identity; a + `nestquest-admins` group is the admin source of truth. +- **Single household** — no tenant column, no multi-household auth. +- **React** for the admin PWA. +- **Drop the HA admin services** — the PWA is the only admin surface. The panel + completion path stays. +- **SQLite on the API box** initially; Postgres only if multi-household ever + becomes real. + +## 11. Preserved vs. changed + +**Preserved:** the tested Python domain layer, the panel card designs and +tokens, the sensor and event contract, the completion model, the append-only +event log, the panel's fail-closed "complete only" boundary. + +**Changed:** data ownership moves to the API; the integration becomes a client; +the admin card becomes a PWA; panel cards become config-free with a +strategy-generated dashboard. + +**New:** the API service, Authentik-gated admin auth, the PWA, and a +last-good-snapshot cache in the integration. + +**Cost:** two new deployables (the API and the PWA) and one new box. Accepted +knowingly in exchange for a self-installing panel, an admin surface that is not +fighting HA's UI, and a data layer with real auth in front of it. -- 2.55.0 From 415ababc8e7e093332c26705686772963ce8755a Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 13:02:44 -0400 Subject: [PATCH 02/11] Automatically prepare releases for completed features (#182) Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> --- orchestration_tests/test_cycle_prompt.py | 30 +++++++ orchestration_tests/test_nq_orchestrate.py | 12 +++ scripts/nq-orchestrate | 12 +++ templates/ORCHESTRATOR-PROMPT.template.md | 100 ++++++++++++++++----- 4 files changed, 133 insertions(+), 21 deletions(-) diff --git a/orchestration_tests/test_cycle_prompt.py b/orchestration_tests/test_cycle_prompt.py index 2e26afc..3a21f97 100644 --- a/orchestration_tests/test_cycle_prompt.py +++ b/orchestration_tests/test_cycle_prompt.py @@ -51,6 +51,36 @@ class CyclePromptTests(unittest.TestCase): self.assertNotIn("The persistent user service runs", output) self.assertNotIn("---\n\n---\n\n# 27", output) + def test_task_complete_feature_triggers_one_guarded_promotion(self): + output = RENDER(TEMPLATE, PROFILE) + self.assertIn( + "feature becomes task-complete and its reviewed merge into `dev` is verified", + output, + ) + self.assertIn("Every required feature task is `done`", output) + self.assertIn("No task review, remediation, approval, or merge remains pending", output) + self.assertIn("feature acceptance criteria and integrated checks pass", output) + self.assertIn("Independent approval applies to the exact feature head", output) + self.assertIn("approver's merge of that head into `dev` is verified", output) + self.assertIn("reflects the completed feature and exact repository references", output) + self.assertIn("Task completion inside an unmerged feature branch does not trigger", output) + self.assertIn("Do not create a duplicate promotion", output) + self.assertIn("Allow only one active promotion pull request to `main`", output) + self.assertIn("if it was closed without merge, recalculate", output) + self.assertIn("sole active orchestrator for the project", output) + self.assertIn("including controller exclusivity, reconciliation", output) + self.assertIn("RELEASE_PREPARATION_POLICY = EXCLUDED", output) + self.assertIn("Active promotion branch and pull request", output) + self.assertIn("An active promotion that has not reached `RELEASE_READY`", output) + self.assertIn("RELEASE_PREPARATION_POLICY:\n", output) + self.assertIn("A ready promotion is a local checkpoint", output) + self.assertIn("continue that work while the ready promotion waits", output) + self.assertLess( + output.index("Begin an automatically triggered, requested, or scheduled release"), + output.index("Advance to the next eligible feature"), + ) + self.assertIn("Only Joshua may merge into `main`", output) + if __name__ == "__main__": unittest.main() diff --git a/orchestration_tests/test_nq_orchestrate.py b/orchestration_tests/test_nq_orchestrate.py index 830b8be..8b54742 100644 --- a/orchestration_tests/test_nq_orchestrate.py +++ b/orchestration_tests/test_nq_orchestrate.py @@ -1,4 +1,5 @@ from pathlib import Path +import fcntl import os import subprocess import tempfile @@ -47,6 +48,7 @@ class ForegroundLauncherTests(unittest.TestCase): "NQ_PROJECT_ROOT": str(project), "OPENCODE_BIN": str(opencode), "SYSTEMCTL_BIN": str(systemctl), + "NQ_STATE_DIR": str(root / "state"), }) printed = subprocess.run([str(SCRIPT), "--print-prompt"], env=env, text=True, capture_output=True) @@ -67,6 +69,16 @@ class ForegroundLauncherTests(unittest.TestCase): ["--check-interactive", "--project", str(project)]) output.unlink() + lock_path = root / "state/controller.lock" + with lock_path.open("w") as held_lock: + fcntl.flock(held_lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + locked = subprocess.run([str(SCRIPT)], env=env, text=True, + capture_output=True) + self.assertEqual(locked.returncode, 75) + self.assertIn("Another NestQuest orchestrator or controller", + locked.stderr) + self.assertFalse(output.exists()) + open_marker.touch() refused = subprocess.run([str(SCRIPT)], env=env, text=True, capture_output=True) diff --git a/scripts/nq-orchestrate b/scripts/nq-orchestrate index 979e0fd..0ba1adc 100755 --- a/scripts/nq-orchestrate +++ b/scripts/nq-orchestrate @@ -11,6 +11,7 @@ control_home=${NQ_CONTROL_HOME:-/home/overseer/.local/share/nestquest-controller project=${NQ_PROJECT_ROOT:-/mnt/ai-storage/projects/NestQuest} opencode_bin=${OPENCODE_BIN:-/home/overseer/.opencode/bin/opencode} systemctl_bin=${SYSTEMCTL_BIN:-systemctl} +state_dir=${NQ_STATE_DIR:-/home/overseer/.local/state/nestquest-controller} renderer="$control_home/scripts/render-cycle-prompt" profile="$control_home/templates/nestquest.profile.json" @@ -41,6 +42,17 @@ fi # Foreground mode owns orchestration. A dormant service must never race it. "$systemctl_bin" --user stop nestquest-controller.service +# Share the controller's lock and hold it across the final exec. This closes +# the launch-time race between two foreground sessions as well as the race with +# the background controller. +mkdir -p "$state_dir" +chmod 700 "$state_dir" +exec 9>"$state_dir/controller.lock" +if ! flock -n 9; then + echo "Another NestQuest orchestrator or controller is running." >&2 + exit 75 +fi + session_status=$("$control_home/scripts/nq-controller" --check-interactive --project "$project") if [[ "$session_status" == "interactive NestQuest session open" ]]; then echo "Another interactive NestQuest OpenCode session is open." >&2 diff --git a/templates/ORCHESTRATOR-PROMPT.template.md b/templates/ORCHESTRATOR-PROMPT.template.md index f17e098..610d613 100644 --- a/templates/ORCHESTRATOR-PROMPT.template.md +++ b/templates/ORCHESTRATOR-PROMPT.template.md @@ -150,6 +150,15 @@ Possible scopes: - `PROJECT_QUEUE` - `RELEASE_PREPARATION` +Also set `RELEASE_PREPARATION_POLICY` at startup: + +- `AUTO` by default. +- `EXCLUDED` only when the user's launch request explicitly says not to prepare + a release or `{{INTEGRATION_BRANCH}}`-to-`{{RELEASE_BRANCH}}` promotion. + +Record both `MISSION_SCOPE` and `RELEASE_PREPARATION_POLICY` in durable run +state. Do not infer exclusion merely from `SINGLE_TASK` or `SINGLE_FEATURE`. + If the user explicitly names one task or feature, respect that scope. If the user launches the orchestrator for general/autonomous {{PROJECT_NAME}} development without limiting it to one feature, use: @@ -171,6 +180,7 @@ Maintain a durable orchestration checkpoint using {{WORK_TRACKER}} wherever its Track at minimum: - `MISSION_SCOPE` +- `RELEASE_PREPARATION_POLICY` - Current feature - Current controller phase - Active development agents @@ -185,6 +195,9 @@ Track at minimum: - Blocked items - Tests/checks status - Last verified repository state +- Active promotion branch and pull request +- Triggering feature and exact `{{INTEGRATION_BRANCH}}` commit for each automatic promotion +- Completed features pending the next promotion - `NEXT_ACTION` After every material state transition: @@ -254,8 +267,8 @@ Choose the highest-priority actionable transition in this order: 8. Remediate feature-level review findings. 9. Obtain feature approval. 10. Merge an approved feature into `{{INTEGRATION_BRANCH}}`. -11. Advance to the next eligible feature when mission scope permits. -12. Begin a requested/scheduled release when appropriate. +11. Begin an automatically triggered, requested, or scheduled release when appropriate. The automatic post-feature trigger is in scope for both `SINGLE_FEATURE` and `PROJECT_QUEUE` unless `RELEASE_PREPARATION_POLICY = EXCLUDED`. +12. Advance to the next eligible feature when mission scope permits. 13. Continue unrelated work around locally blocked items. 14. Escalate only when the defined escalation rules require it. @@ -588,9 +601,10 @@ Immediately transition to `FEATURE_INTEGRATION`. A merged feature is a transition. -It is terminal ONLY when: - -`MISSION_SCOPE = SINGLE_FEATURE` +It is terminal only when `MISSION_SCOPE = SINGLE_FEATURE` and no authorized +post-feature release transition is pending. Preparing the automatic promotion +for that completed feature is part of `SINGLE_FEATURE` scope when +`RELEASE_PREPARATION_POLICY = AUTO`. Otherwise select the next eligible work. @@ -637,13 +651,19 @@ The orchestrator itself does not perform the merge. When `MISSION_SCOPE = PROJECT_QUEUE` and a feature is successfully merged into `{{INTEGRATION_BRANCH}}`: 1. Reconcile {{WORK_TRACKER}}. -2. Check for a requested/scheduled release. +2. Evaluate the automatic, requested, and scheduled release triggers. 3. Identify remaining incomplete features. 4. Determine which features are eligible based on dependencies and priority. 5. Select the next eligible feature. 6. Initialize it. 7. Continue the development loop. +If an automatic promotion becomes `RELEASE_READY` while another eligible +feature remains, keep that promotion waiting for {{RELEASE_OWNER_NAME}} and +continue the project queue. A ready promotion is a local checkpoint in this +case, not a global terminal condition. Features completed while it remains open +are recorded as pending for the next promotion under Section 18. + Do not return to the user simply because one feature completed. If several features are available, choose using: @@ -658,27 +678,52 @@ If several features are available, choose using: # 18. RELEASE RULES -Completed features accumulate in `{{INTEGRATION_BRANCH}}`. +Completed features accumulate in `{{INTEGRATION_BRANCH}}`. With no active +promotion, each verified feature completion starts release preparation. While +one promotion is active, later completed features accumulate for the next +promotion. -Prepare a release only when: +Prepare a release when any of these conditions is true: - {{RELEASE_OWNER_NAME}} requests it; OR -- {{WORK_TRACKER}} schedules it. +- {{WORK_TRACKER}} schedules it; OR +- `RELEASE_PREPARATION_POLICY = AUTO`, and a feature becomes task-complete and its reviewed merge into `{{INTEGRATION_BRANCH}}` is verified. + +For the automatic trigger, a feature is task-complete only when: + +- Every required feature task is `done`, or deliberately `canceled` with recorded evidence. +- No task review, remediation, approval, or merge remains pending. +- The feature acceptance criteria and integrated checks pass. +- Independent approval applies to the exact feature head. +- The approver's merge of that head into `{{INTEGRATION_BRANCH}}` is verified. +- {{WORK_TRACKER}} reflects the completed feature and exact repository references. + +Task completion inside an unmerged feature branch does not trigger a release. +The verified feature-to-`{{INTEGRATION_BRANCH}}` merge is the triggering transition. + +Before creating a release branch or pull request: + +1. Verify this session is the sole active orchestrator for the project. `{{TERMINAL_LAUNCHER}}` enforces that exclusivity by holding the shared controller lock for the full foreground session. If that lock is not held by this session, do not create a release branch or pull request; escalate instead. +2. Reconcile open release records, release branches, and promotion pull requests. +3. Do not create a duplicate promotion for an already-covered `{{INTEGRATION_BRANCH}}` commit. +4. Allow only one active promotion pull request to `{{RELEASE_BRANCH}}`. If an earlier promotion is still open and does not contain the newly completed feature, record the feature as pending for the next automatic promotion. Resume it after the active promotion is merged or closed; if it was closed without merge, recalculate the next promotion from the current validated `{{INTEGRATION_BRANCH}}` head. +5. Record the triggering feature and exact `{{INTEGRATION_BRANCH}}` commit so the automatic trigger runs once for that merge. Release process: -1. Reconcile release scope. -2. Confirm included features are complete. -3. Create release branch from exact validated `{{INTEGRATION_BRANCH}}`. -4. Run complete release checks. -5. Open release PR → `{{RELEASE_BRANCH}}`. -6. Obtain fresh independent {{REVIEWER_VENDOR}} review. -7. Remediate findings through development agents. -8. Incorporate every release correction back into `{{INTEGRATION_BRANCH}}`. -9. Revalidate. -10. Assign final release PR to `{{HOST_ACCOUNT}}`. -11. Update {{WORK_TRACKER}}. -12. Stop before {{RELEASE_BRANCH}} merge. +1. Enforce every pre-creation requirement above, including controller exclusivity, reconciliation, duplicate and active-promotion checks, and trigger recording. +2. Reconcile release scope. +3. Confirm included features are complete. +4. Create release branch from exact validated `{{INTEGRATION_BRANCH}}`. +5. Run complete release checks. +6. Open release PR → `{{RELEASE_BRANCH}}`. +7. Obtain fresh independent {{REVIEWER_VENDOR}} review. +8. Remediate findings through development agents. +9. Incorporate every release correction back into `{{INTEGRATION_BRANCH}}`. +10. Revalidate. +11. Assign final release PR to `{{HOST_ACCOUNT}}`. +12. Update {{WORK_TRACKER}}. +13. Stop before {{RELEASE_BRANCH}} merge. Only {{RELEASE_OWNER_NAME}} may merge into `{{RELEASE_BRANCH}}`. @@ -724,13 +769,23 @@ For `PROJECT_QUEUE`, this additionally requires: - No eligible incomplete feature remains. - No task/review/remediation/merge is pending. +- No release preparation, release review, or release remediation is pending. +- Every in-scope automatic trigger is either incorporated into the active + `RELEASE_READY` promotion or durably recorded for the next promotion. - No development agent is active. - No approver is active. +An active promotion that has not reached `RELEASE_READY` makes +`MISSION_COMPLETE` false. + ## `RELEASE_READY` Release PR has passed required gates and is assigned to `{{HOST_ACCOUNT}}`, awaiting {{RELEASE_OWNER_NAME}}'s {{RELEASE_BRANCH}} merge. +`RELEASE_READY` is globally terminal only when no other in-scope engineering +action is executable. In `PROJECT_QUEUE`, if another eligible incomplete +feature remains, continue that work while the ready promotion waits. + ## `GLOBAL_ESCALATION` All remaining in-scope work is blocked by decisions/access that require {{RELEASE_OWNER_NAME}}. @@ -778,9 +833,12 @@ Output a machine-readable continuation checkpoint: ```text ORCHESTRATOR_STATE: CONTINUE MISSION_SCOPE: +RELEASE_PREPARATION_POLICY: CONTROLLER_PHASE: CURRENT_FEATURE: ACTIVE_TASKS: +ACTIVE_PROMOTION: +PENDING_PROMOTION_FEATURES: BLOCKED_ITEMS: LAST_VERIFIED_STATE: NEXT_ACTION: -- 2.55.0 From 70ee4ec111547d577740f9a93cac7e2abea32bdc Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 13:06:40 -0400 Subject: [PATCH 03/11] Make the missed-sweep watermark claim safe across processes (#184) Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> --- custom_components/nestquest/core/dao_meta.py | 36 ++- custom_components/nestquest/core/sweep.py | 57 +++- tests/test_core_sweep.py | 295 +++++++++++++++++++ 3 files changed, 372 insertions(+), 16 deletions(-) create mode 100644 tests/test_core_sweep.py diff --git a/custom_components/nestquest/core/dao_meta.py b/custom_components/nestquest/core/dao_meta.py index ada8c09..4c04e4c 100644 --- a/custom_components/nestquest/core/dao_meta.py +++ b/custom_components/nestquest/core/dao_meta.py @@ -41,4 +41,38 @@ class MetaStateDao: f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) " "ON CONFLICT (key) DO UPDATE SET value = excluded.value", (key, value), - ) \ No newline at end of file + ) + + 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 diff --git a/custom_components/nestquest/core/sweep.py b/custom_components/nestquest/core/sweep.py index 346893a..79f1977 100644 --- a/custom_components/nestquest/core/sweep.py +++ b/custom_components/nestquest/core/sweep.py @@ -26,12 +26,17 @@ 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 → write span runs inside the +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 queue instead of interleaving, and the loser re-reads -the watermark INSIDE the lock — sees the winner's date — and announces -nothing twice. +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 @@ -60,11 +65,11 @@ LOGGER = logging.getLogger(__name__) SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date" #: One asyncio.Lock per (connection wrapper, running loop), mirroring -#: the settings store's pattern: the whole watermark read → query → -#: build → write span is one critical section, so an overlapping -#: startup sweep and rollover sweep queue instead of double-announcing. -#: The loser re-reads the watermark INSIDE the lock, sees the winner's -#: date, and fires nothing. +#: 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] = {} @@ -99,15 +104,24 @@ async def run_missed_sweep( the caller decides how to publish them and announces nothing on its own. - The whole watermark read → query → build → write span runs inside - the per-database sweep lock: an overlapping startup sweep and a - rollover sweep queue instead of interleaving, and the loser - re-reads the watermark inside the lock — sees the winner's date — - and announces nothing twice. Every event in one run shares one + 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) @@ -134,10 +148,23 @@ async def run_missed_sweep( ), ) ) - await dao.set(SWEEP_WATERMARK_KEY, today_iso) + # 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 + diff --git a/tests/test_core_sweep.py b/tests/test_core_sweep.py new file mode 100644 index 0000000..4a4215a --- /dev/null +++ b/tests/test_core_sweep.py @@ -0,0 +1,295 @@ +"""Cross-process safety of the missed-sweep watermark claim. + +Feature 11's sweep guards its announcement with the watermark; the +original guard was a read-then-set inside a per-PROCESS asyncio lock, +so two PROCESSES sharing one SQLite file (the HA integration's +rollover sweep and the API service's scheduler) could both read the +old watermark and both announce the same missed batch. These tests +pin the fix: + +- :meth:`MetaStateDao.claim` is the atomic, database-level primitive: + one conditional upsert whose ``rowcount`` says whether THIS caller + changed the row. The first claimant of a value wins; a second + claimant of the same value — even on an INDEPENDENT connection to + the same file, the shape two processes produce — loses. +- :func:`run_missed_sweep` gates the announcement on that claim: two + independent ``NestQuestDatabase`` wrappers (two processes' shape) + racing one seeded database yields exactly one announcer. +- The documented sweep semantics are unchanged: a same-night rerun + announces nothing, and a run after days of downtime announces the + accumulated ``[watermark, today)`` window exactly once. + +Everything here runs against the bundled HA-free core copy +(``custom_components.nestquest.core``) with a plain +``asyncio.to_thread`` executor — no Home Assistant, no API server. +""" +from __future__ import annotations + +import asyncio +import datetime +from types import SimpleNamespace + +import pytest + +from custom_components.nestquest.core.dao_instances import ( + QuestInstancesDao, +) +from custom_components.nestquest.core.dao_meta import MetaStateDao +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.migrations import apply_migrations +from custom_components.nestquest.core.children import create_child +from custom_components.nestquest.core.quest_definitions import ( + create_quest_definition, +) +from custom_components.nestquest.core.materialize import materialize +from custom_components.nestquest.core.recurrence import ScheduleRule +from custom_components.nestquest.core.sweep import ( + SWEEP_WATERMARK_KEY, + run_missed_sweep, +) + +#: One missed event per unswept past-due instance — the sweep's only +#: event type. +EVENT_QUEST_MISSED = "nestquest_quest_missed" + + +async def _executor(fn, *args, **kwargs): + """Run a synchronous callable on a worker thread and await it. + + The :class:`NestQuestDatabase` executor contract (the shape of + ``hass.async_add_executor_job``), so sqlite3 calls never block the + event loop. + """ + return await asyncio.to_thread(fn, *args, **kwargs) + + +async def _open_database(db_path: str) -> NestQuestDatabase: + """Open (without migrating) one independent connection wrapper.""" + database = NestQuestDatabase(_executor) + await database.open(db_path) + return database + + +async def _seed_missed_household( + db_path: str, today: datetime.date +) -> SimpleNamespace: + """Seed a household with two past-due open instances on ``db_path``. + + One child, two quests whose ONLY instances are due YESTERDAY + (materialized with ``today`` pinned to yesterday, ``end_date`` + closing the rules so the later today-walk cannot duplicate them). + Seeding uses its OWN connection, opened and closed here, so the + connections the tests race with start fresh. + """ + database = await _open_database(db_path) + try: + await apply_migrations(database) + today_iso = today.isoformat() + yesterday = today - datetime.timedelta(days=1) + yesterday_iso = yesterday.isoformat() + rule = ScheduleRule.from_dict( + { + "rule_type": "daily", + "start_date": yesterday_iso, + "end_date": yesterday_iso, + } + ) + ada = await create_child(database, "Ada", sort_order=0) + await create_quest_definition( + database, "Stale chore", rule, [ada.id], [("morning", "09:00")] + ) + await create_quest_definition( + database, "Old chore", rule, [ada.id], [("evening", "18:30")] + ) + await materialize(database, yesterday_iso, yesterday_iso, today=yesterday) + # A quest due TODAY (created after the yesterday walk so it has + # no past-due instance): the post-downtime catch-up run's + # target — inside [watermark=today, later_today) but not + # inside the day-one sweep's window. + await create_quest_definition( + database, + "Recoverable chore", + ScheduleRule.from_dict( + {"rule_type": "daily", "start_date": today_iso} + ), + [ada.id], + [("afternoon", "17:00")], + ) + await materialize(database, today_iso, today_iso, today=today) + instances = QuestInstancesDao(database) + rows = await instances.list_by_child_and_date(ada.id, yesterday_iso) + assert len(rows) == 2 + today_rows = await instances.list_by_child_and_date( + ada.id, today_iso + ) + assert len(today_rows) == 1 + return SimpleNamespace( + child=ada, + instances=rows, + recoverable_instance=today_rows[0], + yesterday_iso=yesterday_iso, + today_iso=today_iso, + ) + finally: + await database.close() + + +# --- the claim primitive: atomic at the database level ------------------ + + +async def test_claim_first_caller_wins_and_second_same_value_loses( + temp_db_path: str, +) -> None: + """claim() is a conditional upsert: first True, same-value second False. + + A different value always wins (an advance re-arms the check), and + the stored value follows the winner. + """ + database = await _open_database(temp_db_path) + try: + await apply_migrations(database) + dao = MetaStateDao(database) + assert await dao.get(SWEEP_WATERMARK_KEY) is None + + assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-01") is True + # Same value again: the row is already there — nothing changed. + assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-01") is False + assert await dao.get(SWEEP_WATERMARK_KEY) == "2026-09-01" + # A DIFFERENT value is an advance, not a re-claim: it wins. + assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-02") is True + assert await dao.get(SWEEP_WATERMARK_KEY) == "2026-09-02" + assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-02") is False + finally: + await database.close() + + +async def test_claim_is_atomic_across_independent_connections( + temp_db_path: str, +) -> None: + """Two connections to one file cannot both claim the same value. + + This is the two-process shape: independent sqlite3 connections (one + per ``NestQuestDatabase`` wrapper), racing the SAME key on the SAME + event loop with no shared lock. The single conditional upsert is + serialized by SQLite itself, so exactly one caller's rowcount is 1. + """ + first = await _open_database(temp_db_path) + second = await _open_database(temp_db_path) + try: + await apply_migrations(first) + won = await asyncio.gather( + MetaStateDao(first).claim(SWEEP_WATERMARK_KEY, "2026-09-01"), + MetaStateDao(second).claim(SWEEP_WATERMARK_KEY, "2026-09-01"), + ) + assert sorted(won) == [False, True] + # One stored row, holding the winner's value. + assert await MetaStateDao(first).get(SWEEP_WATERMARK_KEY) == ( + "2026-09-01" + ) + assert await MetaStateDao(second).get(SWEEP_WATERMARK_KEY) == ( + "2026-09-01" + ) + finally: + await first.close() + await second.close() + + +# --- the sweep: the announcement is gated by the atomic claim ----------- + + +async def test_two_process_shape_race_exactly_one_announces( + temp_db_path: str, +) -> None: + """Two independent connections racing the sweep: ONE announcer. + + Each wrapper carries its own connection AND its own per-process + sweep lock, so nothing in-process serialises the two runs — the + only guard left is the database-level claim. Both read the unset + watermark and both build the events, but exactly one claim wins: + one run returns the missed events, the loser returns []. + """ + today = datetime.datetime.now().astimezone().date() + seed = await _seed_missed_household(temp_db_path, today) + first = await _open_database(temp_db_path) + second = await _open_database(temp_db_path) + try: + results = await asyncio.gather( + run_missed_sweep(first, today=today), + run_missed_sweep(second, today=today), + ) + announcers = [r for r in results if r] + assert len(announcers) == 1 + event_type, payload = announcers[0][0] + assert event_type == EVENT_QUEST_MISSED + assert payload["due_date"] == seed.yesterday_iso + # Both instances of the seeded window, one event each. + assert len(announcers[0]) == 2 + losers = [r for r in results if not r] + assert losers == [[]] + # The watermark holds the run's date for whoever looks next. + assert ( + await MetaStateDao(first).get(SWEEP_WATERMARK_KEY) + == seed.today_iso + ) + finally: + await first.close() + await second.close() + + +async def test_same_night_rerun_announces_nothing(temp_db_path: str) -> None: + """A second sweep for the same date is an empty no-op (the watermark).""" + today = datetime.datetime.now().astimezone().date() + await _seed_missed_household(temp_db_path, today) + database = await _open_database(temp_db_path) + try: + first = await run_missed_sweep(database, today=today) + assert len(first) == 2 + second = await run_missed_sweep(database, today=today) + assert second == [] + finally: + await database.close() + + +async def test_post_downtime_run_announces_accumulated_window_once( + temp_db_path: str, +) -> None: + """After days of downtime the catch-up run sweeps the window once. + + The first run sweeps yesterday's instances (watermark := today); + ``today`` then jumps two days and the catch-up run — on a + DIFFERENT connection, the two-process shape — announces the + accumulated ``[watermark, later_today)`` window exactly once, and + a rerun on the original connection is silent again. + """ + today = datetime.datetime.now().astimezone().date() + later_today = today + datetime.timedelta(days=2) + seed = await _seed_missed_household(temp_db_path, today) + first = await _open_database(temp_db_path) + second = await _open_database(temp_db_path) + try: + # Day one: yesterday's two instances fire once. + day_one = await run_missed_sweep(first, today=today) + assert len(day_one) == 2 + assert all( + payload["due_date"] == seed.yesterday_iso + for _, payload in day_one + ) + + # The downtime catch-up: the window is [watermark, later_today). + catchup = await run_missed_sweep(second, today=later_today) + assert len(catchup) == 1 + event_type, payload = catchup[0] + assert event_type == EVENT_QUEST_MISSED + assert payload["due_date"] == seed.today_iso + assert payload["instance_id"] == seed.recoverable_instance.id + assert payload["child_id"] == seed.child.id + + # A further rerun announces nothing. + assert await run_missed_sweep(first, today=later_today) == [] + assert ( + await MetaStateDao(first).get(SWEEP_WATERMARK_KEY) + == later_today.isoformat() + ) + finally: + await first.close() + await second.close() -- 2.55.0 From 0ed35ab85a62b4001577eead363b497158fc6599 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 23 Sep 2026 14:29:12 -0400 Subject: [PATCH 04/11] release: advance version to 0.6.0 and add the re-imagined architecture release notes --- RELEASE_NOTES.md | 146 ++++++++++++++++++---- custom_components/nestquest/manifest.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 128 insertions(+), 24 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5bd20da..3c82296 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,27 +1,131 @@ # NestQuest Release Notes -## Unreleased +## Version 0.6.0 — 2026-09-23 -- **Storage layer extraction (task d0b691d8):** the SQLite storage layer - (schema, migrations, db, store, const, recurrence, DAOs) is bundled - under `custom_components/nestquest/core/`, with the integration's - original module names preserved as re-export shims. -- **Logger namespace change (breaking for logging configuration):** the - migrations runner now logs under - `custom_components.nestquest.core.migrations` instead of - `custom_components.nestquest.migrations`. Users who tuned the old - logger name (e.g. in `logger.yaml` or `configuration.yaml`) must - update the entry to keep that level, as the old name no longer - receives migration log output. -- **Additional logger namespace moves:** the children registry and the - admin allowlist business layers now log under - `custom_components.nestquest.core.children` and - `custom_components.nestquest.core.admin_allowlist` respectively - (moved with the engines/business-layer extraction, task 510c1f78). - The parent `custom_components.nestquest` logger still catches both, - so users who tune the parent rather than the leaf names are - unaffected; only users who pinned a leaf logger name by its old - spelling need to update. +### 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 (see `api/README.md` + and `docs/ARCHITECTURE.md`). 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. + +### 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 is containerized (`Dockerfile`, `deploy/compose.yaml`, + `api/README.md`) but there is no deployed TLS reverse proxy, no Authentik + application/provider/`nestquest-admins` group, no proven network + separation of the panel routes, and no backup/restore. 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 diff --git a/custom_components/nestquest/manifest.json b/custom_components/nestquest/manifest.json index c4fe61d..4f5d553 100644 --- a/custom_components/nestquest/manifest.json +++ b/custom_components/nestquest/manifest.json @@ -15,5 +15,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues", "requirements": [], - "version": "0.5.0" + "version": "0.6.0" } diff --git a/pyproject.toml b/pyproject.toml index f1779bc..88eaee7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nestquest" -version = "0.5.0" +version = "0.6.0" description = "NestQuest Home Assistant Integration" readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 711e68e..f3a2c88 100644 --- a/uv.lock +++ b/uv.lock @@ -373,7 +373,7 @@ wheels = [ [[package]] name = "nestquest" -version = "0.5.0" +version = "0.6.0" source = { virtual = "." } [package.dev-dependencies] -- 2.55.0 From 8676bdc6ad3f3c626bff364bea2cb602588be92b Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 23 Sep 2026 14:35:01 -0400 Subject: [PATCH 05/11] release: correct the 0.6.0 notes - no containerization artifacts ship in this release --- RELEASE_NOTES.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 3c82296..6504e5f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -61,10 +61,12 @@ API service that owns the database. - 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 (see `api/README.md` - and `docs/ARCHITECTURE.md`). 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. + 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 @@ -97,11 +99,13 @@ API service that owns the database. ### Known Limitations - **Feature 17 (API deployment and identity) is not in this release.** The - API is containerized (`Dockerfile`, `deploy/compose.yaml`, - `api/README.md`) but there is no deployed TLS reverse proxy, no Authentik - application/provider/`nestquest-admins` group, no proven network - separation of the panel routes, and no backup/restore. Until that - deployment is completed the integration has nothing to talk to. + 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 -- 2.55.0 From a808f899930bf363bf4d8281186cc1d7d6ce8078 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 20:01:25 -0400 Subject: [PATCH 06/11] Feature 21: Admin Today Snapshot API (#190) (#191) Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> --- api/routes_admin.py | 117 +++++++++++ tests/test_api_admin_snapshot.py | 328 +++++++++++++++++++++++++++++++ 2 files changed, 445 insertions(+) create mode 100644 tests/test_api_admin_snapshot.py diff --git a/api/routes_admin.py b/api/routes_admin.py index 21d3e4c..93f9037 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -116,6 +116,22 @@ owns every rule so the two routes cannot drift: a ``Content-Disposition`` attachment filename and the documented, stable header row (Admin spec §5 event-row fields). +The snapshot route (task ef3f4877) is the admin-plane twin of the +panel's ``GET /api/v1/panel/snapshot`` for the admin PWA's Today tab — +the PWA is internet-facing and never holds the panel service token +(D-012), so it reads the same snapshot here: + +- ``GET /snapshot`` threads ONE clock read (:func:`_local_now`) into + :func:`nestquest_core.snapshot.build_snapshot` and shapes each + child's instances through + :func:`nestquest_core.snapshot.instance_payload` with + ``include_missed=True`` — admin payloads KEEP missed instances with + their ``missed`` state (D-009), where the panel omits them. The + body is the panel's documented shape (``today_iso``, ``cycle_day``, + ``children`` with the per-child presence, rollup counts and + ``instances``); the builder, the state derivation and the shaping + all live in the core. + The settings routes (task 2b3de7e5) follow the same pattern over :mod:`nestquest_core.settings_store` — the API's OWN durable settings store. The API now OWNS settings in the database (a validated JSON @@ -254,6 +270,7 @@ from api.nestquest_core import ( core_recurrence, core_settings, core_settings_store, + core_snapshot, core_sweep, ) @@ -683,6 +700,59 @@ class AdminMissedSweepResponse(BaseModel): fired: int +class AdminInstancePayload(BaseModel): + """One instance in the admin snapshot payload. + + Mirrors :func:`nestquest_core.snapshot.instance_payload` with + ``include_missed=True``: ``state`` is ``open`` | ``completed`` | + ``missed`` (admin payloads keep missed instances, D-009), + ``on_time`` passes the completion event's flag through verbatim + (``None`` unless completed), 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 AdminChildSnapshotPayload(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; + #: ``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[AdminInstancePayload] + + +class AdminSnapshotResponse(BaseModel): + """Today's whole household snapshot for the admin Today tab. + + The panel snapshot's shape (``today_iso``, ``cycle_day``, + ``children`` in the household's sort order), with missed instances + kept in each child's ``instances``. + """ + + today_iso: str + cycle_day: int + children: list[AdminChildSnapshotPayload] + + #: 404 detail for a child id the core layer reports as non-existent. _CHILD_NOT_FOUND_DETAIL = "Child not found" @@ -1729,4 +1799,51 @@ async def admin_run_missed_sweep( return AdminMissedSweepResponse(fired=len(events)) +@router.get( + "/snapshot", + summary="Today's household snapshot", + response_model=AdminSnapshotResponse, +) +async def admin_snapshot(request: Request) -> AdminSnapshotResponse: + """Return today's full household snapshot for the admin Today tab. + + Requires a valid admin JWT (the router's shared + :func:`~api.auth.require_admin` dependency — the ONE check; this + handler performs NO auth of its own). Thin adapter: ONE clock read + (:func:`_local_now`) goes into + :func:`nestquest_core.snapshot.build_snapshot` against the live + database, and each child's instances are shaped with + ``instance_payload(include_missed=True)`` — missed instances are + KEPT in the admin payload (D-009) — into + :class:`AdminSnapshotResponse`. + """ + state: DatabaseState = request.app.state.db + snapshot = await core_snapshot.build_snapshot( + state.database, core_settings.NestQuestSettings(), _local_now() + ) + return AdminSnapshotResponse( + today_iso=snapshot.today_iso, + cycle_day=snapshot.cycle_day, + children=[ + AdminChildSnapshotPayload( + 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=[ + AdminInstancePayload.model_validate(instance) + for instance in core_snapshot.instance_payload( + child.instances, include_missed=True + ) + ], + ) + for child in snapshot.children + ], + ) + + __all__ = ["router"] diff --git a/tests/test_api_admin_snapshot.py b/tests/test_api_admin_snapshot.py new file mode 100644 index 0000000..52652ac --- /dev/null +++ b/tests/test_api_admin_snapshot.py @@ -0,0 +1,328 @@ +"""Admin-plane snapshot route tests (task ef3f4877). + +These tests exercise ``GET /api/v1/admin/snapshot`` against the ASGI +app with httpx, reusing the shared local-RSA-key/stubbed-JWKS harness +(:mod:`tests.admin_jwt_harness`) so an admin JWT genuinely +authenticates with NO network access. The household is seeded with +:func:`tests.test_api_panel._seed_household` through the SAME core +copy the app uses (``nestquest_core.*``), and both routes' single +clock read (``_local_now``) is pinned to noon so nothing straddles +midnight. + +Proven per the done-condition: + +- An admin JWT returns 200 with the documented shape: ``today_iso``, + ``cycle_day`` and, per child, the presence fields, the rollup counts + and ``instances`` in the core ``instance_payload`` shape. +- A MISSED instance is KEPT in the admin payload (D-009) and still + OMITTED from the panel payload. The core builder lists only + instances due on ``now.date()``, and ``derive_state`` calls an + instance missed only when its due date is BEFORE that day — so a + real snapshot never carries a missed view. The test therefore + wraps the real builder to add the seeded past-due instance as a + view whose state the core's own ``derive_state`` computes + (``missed``), and reads it through both routes. +- Auth runs through the router's ONE ``require_admin`` dependency: no + credential is 401, the panel service token is refused outright + (403, the credential-type detail), and a non-admin JWT is 403. +- The handler performs exactly ONE clock read per request. +""" +from __future__ import annotations + +import dataclasses +import datetime +import importlib +from types import SimpleNamespace +from zoneinfo import ZoneInfo + +import pytest + +from api import routes_admin, routes_panel +from api.nestquest_core import core_snapshot +from tests.admin_jwt_harness import ( + ADMIN_KEY, + KID, + AdminRunner, + jwks_for, + make_token, +) +from tests.test_api_panel import _seed_household + +# The completion layer through the SAME core copy the app uses — the +# missed test derives its view's state with the core's own rule. +_completion = importlib.import_module("nestquest_core.completion") + +#: The panel service token the harness app is configured with. +HARNESS_PANEL_TOKEN = "admin-test-panel-token" + +#: The 403 detail for the panel service token on an admin route. +PANEL_TOKEN_REJECTION_DETAIL = ( + "This credential type is not accepted for admin access" +) + +#: The 403 detail for a valid JWT without the admin group. +FORBIDDEN_DETAIL = ( + "Admin access requires membership in the nestquest-admins group" +) + + +def _admin_headers() -> dict[str, str]: + """The Authorization header of a valid nestquest-admins JWT.""" + return {"Authorization": f"Bearer {make_token()}"} + + +def _panel_headers() -> dict[str, str]: + """The Authorization header carrying the harness panel token.""" + return {"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"} + + +@pytest.fixture +async def snapshot_household( + temp_db_path: str, monkeypatch +) -> SimpleNamespace: + """A seeded household served by the admin-plane app, clock pinned. + + ``today`` comes from one host clock read; the pinned instant is + 12:00 UTC that day (past Ada's 10:00 due, before Bo's 17:00). Both + the admin and the panel route's ``_local_now`` return it, and the + admin one counts its calls. + """ + today = datetime.datetime.now().astimezone().date() + pinned_now = datetime.datetime( + today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC") + ) + seed = await _seed_household(temp_db_path, today, pinned_now) + + clock_reads: list[datetime.datetime] = [] + + def _admin_now() -> datetime.datetime: + clock_reads.append(pinned_now) + return pinned_now + + monkeypatch.setattr(routes_admin, "_local_now", _admin_now) + monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now) + runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) + async with runner as client: + yield SimpleNamespace( + client=client, + seed=seed, + today=today, + pinned_now=pinned_now, + pinned_utc_iso=pinned_now.astimezone( + datetime.timezone.utc + ).isoformat(), + clock_reads=clock_reads, + ) + + +# --- authentication: the ONE require_admin dependency ---------------------- + + +async def test_snapshot_without_credential_is_401( + snapshot_household: SimpleNamespace, +) -> None: + """No Authorization header is the uniform 401.""" + response = await snapshot_household.client.get("/api/v1/admin/snapshot") + assert response.status_code == 401 + + +async def test_snapshot_refuses_panel_service_token_outright( + snapshot_household: SimpleNamespace, +) -> None: + """The panel service token is refused as a credential type (403). + + 403 with the credential-type detail — not the uniform 401 and not + the non-admin group detail — proves the token was refused outright, + never tried as a JWT. + """ + response = await snapshot_household.client.get( + "/api/v1/admin/snapshot", headers=_panel_headers() + ) + assert response.status_code == 403 + assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL + + +async def test_snapshot_refuses_non_admin_jwt( + snapshot_household: SimpleNamespace, +) -> None: + """A valid JWT without the nestquest-admins group is 403.""" + non_admin = make_token(groups=("some-other-group",)) + response = await snapshot_household.client.get( + "/api/v1/admin/snapshot", + headers={"Authorization": f"Bearer {non_admin}"}, + ) + assert response.status_code == 403 + assert response.json()["detail"] == FORBIDDEN_DETAIL + + +# --- the payload ----------------------------------------------------------- + + +async def test_admin_jwt_returns_documented_snapshot( + snapshot_household: SimpleNamespace, +) -> None: + """An admin JWT returns 200 with the documented snapshot shape. + + Every field is asserted as an explicit literal, with ONE clock + read for the request. With no missed view in the real snapshot, + the admin body equals the panel body for the same household. + """ + client = snapshot_household.client + seed = snapshot_household.seed + + response = await client.get( + "/api/v1/admin/snapshot", headers=_admin_headers() + ) + assert response.status_code == 200 + assert len(snapshot_household.clock_reads) == 1 + body = response.json() + + assert body == { + "today_iso": snapshot_household.today.isoformat(), + # Ada's 2-week cycle anchored yesterday => today is cycle day 2. + "cycle_day": 2, + "children": [ + { + "child_id": seed.ada.id, + "child_name": "Ada", + "present": False, + "next_present": seed.returns_date.isoformat(), + "due_today": 1, + "completed_today": 0, + "remaining_today": 1, + "completion_pct": 0, + "instances": [ + { + "id": seed.pack_bag_instance.id, + "definition_id": seed.pack_bag_instance.definition_id, + "child_id": seed.ada.id, + "title": "Pack bag", + "icon": None, + "window": "morning", + "due_time": "10:00", + "state": "open", + "overdue": True, + "completed_at": None, + "on_time": None, + } + ], + }, + { + "child_id": seed.bo.id, + "child_name": "Bo", + "present": True, + "next_present": None, + "due_today": 1, + "completed_today": 1, + "remaining_today": 0, + "completion_pct": 100, + "instances": [ + { + "id": seed.brush_teeth_instance.id, + "definition_id": ( + seed.brush_teeth_instance.definition_id + ), + "child_id": seed.bo.id, + "title": "Brush teeth", + "icon": None, + "window": "morning", + "due_time": "17:00", + "state": "completed", + "overdue": False, + "completed_at": snapshot_household.pinned_utc_iso, + "on_time": True, + } + ], + }, + ], + } + + panel = await client.get( + "/api/v1/panel/snapshot", headers=_panel_headers() + ) + assert panel.status_code == 200 + assert panel.json() == body + + +async def test_admin_keeps_missed_instance_panel_omits_it( + snapshot_household: SimpleNamespace, monkeypatch +) -> None: + """A missed view is KEPT by the admin route, OMITTED by the panel's. + + The real builder is wrapped to add the seeded past-due Stale chore + instance to Ada's views, its state derived by the core's own + ``derive_state`` (no events, due yesterday => ``missed``). Both + routes read the SAME wrapped snapshot, so the only difference is + each route's ``include_missed`` flag. + """ + client = snapshot_household.client + seed = snapshot_household.seed + stale = seed.stale_instance + missed_state = _completion.derive_state( + stale, None, snapshot_household.today + ) + assert missed_state == "missed" + missed_view = core_snapshot.QuestInstanceView( + instance_id=stale.id, + definition_id=stale.definition_id, + child_id=stale.child_id, + title="Stale chore", + icon=None, + window=stale.window, + due_date=stale.due_date, + due_time=stale.due_time, + state=missed_state, + overdue=False, + completed_at=None, + was_on_time=None, + ) + real_build_snapshot = core_snapshot.build_snapshot + + async def _with_missed_view(database, settings, now): + snapshot = await real_build_snapshot(database, settings, now) + return dataclasses.replace( + snapshot, + children=tuple( + dataclasses.replace( + child, instances=(*child.instances, missed_view) + ) + if child.child_id == seed.ada.id + else child + for child in snapshot.children + ), + ) + + monkeypatch.setattr(core_snapshot, "build_snapshot", _with_missed_view) + + admin = await client.get( + "/api/v1/admin/snapshot", headers=_admin_headers() + ) + assert admin.status_code == 200 + admin_ada = admin.json()["children"][0] + assert admin_ada["child_id"] == seed.ada.id + assert [instance["id"] for instance in admin_ada["instances"]] == [ + seed.pack_bag_instance.id, + stale.id, + ] + assert admin_ada["instances"][1] == { + "id": stale.id, + "definition_id": stale.definition_id, + "child_id": seed.ada.id, + "title": "Stale chore", + "icon": None, + "window": "morning", + "due_time": "09:00", + "state": "missed", + "overdue": False, + "completed_at": None, + "on_time": None, + } + + panel = await client.get( + "/api/v1/panel/snapshot", headers=_panel_headers() + ) + assert panel.status_code == 200 + panel_ada = panel.json()["children"][0] + assert [instance["id"] for instance in panel_ada["instances"]] == [ + seed.pack_bag_instance.id + ], "the panel payload must still omit missed instances" -- 2.55.0 From 42ea5710d7262a87c8f0f8efae87b4ad333d92e6 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 20:52:06 -0400 Subject: [PATCH 07/11] Feature 22: Admin Definitions Read and skip_on_away (#193, #194) (#195) Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> --- api/routes_admin.py | 47 ++- custom_components/nestquest/core/dao_rules.py | 79 ++++- .../nestquest/core/materialize.py | 12 +- .../nestquest/core/migrations.py | 31 +- .../nestquest/core/quest_definitions.py | 39 ++- custom_components/nestquest/core/schema.py | 3 +- tests/test_api_admin_questdef.py | 123 +++++++ tests/test_api_admin_questdef_list.py | 299 ++++++++++++++++++ tests/test_dao_rules.py | 67 ++++ tests/test_materialize.py | 127 ++++++++ tests/test_migrations.py | 95 +++++- tests/test_schema.py | 25 ++ 12 files changed, 925 insertions(+), 22 deletions(-) create mode 100644 tests/test_api_admin_questdef_list.py diff --git a/api/routes_admin.py b/api/routes_admin.py index 93f9037..6fc3009 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -251,7 +251,7 @@ import datetime from typing import Annotated, Literal, NoReturn from fastapi import APIRouter, Depends, HTTPException, Request, Response -from pydantic import BaseModel, field_validator, model_validator +from pydantic import BaseModel, StrictBool, field_validator, model_validator from api import transitions as api_transitions from api.auth import require_admin @@ -363,11 +363,18 @@ class AdminQuestDefinitionResponse(BaseModel): description: str | None icon: str | None is_active: bool + skip_on_away: bool rule: AdminRuleResponse assignees: list[AdminAssigneeResponse] windows: list[AdminDefinitionWindowResponse] +class AdminQuestDefinitionListResponse(BaseModel): + """The definitions list: every definition, active AND inactive, by id.""" + + definitions: list[AdminQuestDefinitionResponse] + + class AdminChildCreateRequest(BaseModel): """The body of ``POST /api/v1/admin/children``. @@ -444,8 +451,9 @@ class AdminQuestDefinitionCreateRequest(BaseModel): ``title``, ``rule``, ``assignee_child_ids`` and ``windows`` are required (the core rejects an empty title, a non-list or empty assignee/window list, and unknown window names); ``description`` - and ``icon`` are optional and default to NULL through the core. - ``windows`` entries are a window name (``morning``) or a two-item + and ``icon`` are optional and default to NULL through the core, and + ``skip_on_away`` is optional and defaults to true (no instance on a + date the child is absent). ``windows`` entries are a window name (``morning``) or a two-item ``[window, due_time]`` pair whose due time is a strict 24-hour HH:MM (or ``null`` for none) — both forms the core normalizes. """ @@ -456,6 +464,7 @@ class AdminQuestDefinitionCreateRequest(BaseModel): windows: list[str | tuple[str, str | None]] description: str | None = None icon: str | None = None + skip_on_away: StrictBool = True class AdminQuestDefinitionEditRequest(BaseModel): @@ -470,7 +479,8 @@ class AdminQuestDefinitionEditRequest(BaseModel): ``rule`` or ``windows`` is passed and rejected by the core. A supplied ``windows`` list REPLACES the whole window set, and ``assignees`` are deliberately absent — assignment is not settable - through this route. + through this route. An omitted (or ``null``) ``skip_on_away`` + keeps the stored value. """ title: str | None = None @@ -478,6 +488,7 @@ class AdminQuestDefinitionEditRequest(BaseModel): icon: str | None = None rule: AdminRuleRequest | None = None windows: list[str | tuple[str, str | None]] | None = None + skip_on_away: StrictBool | None = None class AdminQuestDefinitionActiveRequest(BaseModel): @@ -957,6 +968,7 @@ def _quest_definition_response( description=bundle.definition.description, icon=bundle.definition.icon, is_active=bundle.definition.is_active, + skip_on_away=bundle.definition.skip_on_away, rule=_rule_response(bundle.rule), assignees=[ AdminAssigneeResponse(id=child.id, display_name=child.display_name) @@ -1168,6 +1180,32 @@ async def admin_reorder_children( return {"status": "ok"} +@router.get( + "/quest-definitions", + summary="List quest definitions", + response_model=AdminQuestDefinitionListResponse, +) +async def admin_list_quest_definitions( + request: Request, +) -> AdminQuestDefinitionListResponse: + """Return every quest definition in rising id order. + + Requires a valid admin JWT (the router's shared + :func:`~api.auth.require_admin` dependency — the ONE check; this + handler performs NO auth of its own). Thin adapter: one + :func:`nestquest_core.quest_definitions.list_all_definitions` call, + active AND inactive definitions included (the admin screen manages + both), each serialized exactly as the create/edit responses are. + """ + state: DatabaseState = request.app.state.db + bundles = await core_quest_definitions.list_all_definitions( + state.database + ) + return AdminQuestDefinitionListResponse( + definitions=[_quest_definition_response(bundle) for bundle in bundles] + ) + + @router.post( "/quest-definitions", summary="Create a quest definition", @@ -1202,6 +1240,7 @@ async def admin_create_quest_definition( body.windows, description=body.description, icon=body.icon, + skip_on_away=body.skip_on_away, ) except ValueError as error: _raise_quest_definition_error(error) diff --git a/custom_components/nestquest/core/dao_rules.py b/custom_components/nestquest/core/dao_rules.py index ec3f6d9..669569d 100644 --- a/custom_components/nestquest/core/dao_rules.py +++ b/custom_components/nestquest/core/dao_rules.py @@ -105,6 +105,19 @@ def _validate_window(window: str) -> None: ) +def _validate_skip_on_away(value: object) -> int: + """Return ``value`` as 0/1, raising ValueError for anything else. + + The fresh-schema CHECK constraint enforces this in SQLite, but a + migrated database gained the column through ``ADD COLUMN`` without + it, so every DAO write path validates here instead. Accepts a bool + or the ints 0/1; floats, strings and other ints are rejected. + """ + if not isinstance(value, int) or value not in (0, 1): + raise ValueError(f"skip_on_away must be 0 or 1, got {value!r}") + return int(value) + + @dataclass(frozen=True) class ScheduleRuleRecord: """One row of ``schedule_rules``.""" @@ -152,6 +165,7 @@ class QuestDefinitionRecord: due_time: str | None is_active: bool created_at: str + skip_on_away: bool @dataclass(frozen=True) @@ -187,7 +201,7 @@ _RULE_COLUMNS = ( ) _DEFINITION_COLUMNS = ( "id, title, description, icon, schedule_rule_id, due_time, " - "is_active, created_at" + "is_active, created_at, skip_on_away" ) _WINDOW_COLUMNS = "definition_id, window, due_time" @@ -216,6 +230,7 @@ def _definition_from_row(row: tuple) -> QuestDefinitionRecord: due_time=row[5], is_active=bool(row[6]), created_at=row[7], + skip_on_away=bool(row[8]), ) @@ -632,6 +647,7 @@ class QuestDefinitionsDao: due_time: str | None = None, is_active: bool = True, assignee_child_ids: list[int] | None = None, + skip_on_away: bool = True, ) -> QuestDefinitionRecord: """Insert one definition and return the record as stored. @@ -644,6 +660,7 @@ class QuestDefinitionsDao: empty list or None creates an unassigned definition, which callers may fill via :meth:`add_assignee`. """ + skip_on_away_value = _validate_skip_on_away(skip_on_away) async with _connection_lock(self._database): async with self._database.transaction(): await self._validate_rule_exists(schedule_rule_id) @@ -652,7 +669,8 @@ class QuestDefinitionsDao: result = await self._database.execute( "INSERT INTO quest_definitions (title, description, " "icon, schedule_rule_id, due_time, is_active, " - "created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + "created_at, skip_on_away) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ( title, description, @@ -661,6 +679,7 @@ class QuestDefinitionsDao: due_time, int(is_active), created_at, + skip_on_away_value, ), ) definition_id = result.lastrowid @@ -684,6 +703,7 @@ class QuestDefinitionsDao: *, description: str | None = None, icon: str | None = None, + skip_on_away: bool = True, ) -> QuestDefinitionSnapshot: """Insert a rule, definition, assignees and windows atomically. @@ -713,6 +733,7 @@ class QuestDefinitionsDao: _validate_date(schedule_rule.start_date, "start_date") if schedule_rule.end_date is not None: _validate_date(schedule_rule.end_date, "end_date") + skip_on_away_value = _validate_skip_on_away(skip_on_away) async with _connection_lock(self._database): async with self._database.transaction(): for child_id in assignee_child_ids: @@ -721,8 +742,18 @@ class QuestDefinitionsDao: result = await self._database.execute( "INSERT INTO quest_definitions (title, description, " "icon, schedule_rule_id, due_time, is_active, " - "created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", - (title, description, icon, rule_id, None, 1, created_at), + "created_at, skip_on_away) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + title, + description, + icon, + rule_id, + None, + 1, + created_at, + skip_on_away_value, + ), ) definition_id = result.lastrowid for child_id in assignee_child_ids: @@ -764,6 +795,7 @@ class QuestDefinitionsDao: icon: str | None | object = _UNSET, rule: ScheduleRuleStorage | object = _UNSET, windows: list[tuple[str, str | None]] | object = _UNSET, + skip_on_away: bool | object = _UNSET, ) -> QuestDefinitionSnapshot: """Edit a definition's metadata, rule and windows atomically. @@ -774,9 +806,10 @@ class QuestDefinitionsDao: ``_UNSET`` meaning "leave this field alone"; ``None`` writes SQL NULL so optional metadata (description, icon) can be cleared. - - ``title``/``description``/``icon`` update the definition row - in place; the definition-level ``due_time`` column is never - written (per-window due times supersede it, D-008). + - ``title``/``description``/``icon``/``skip_on_away`` update the + definition row in place; the definition-level ``due_time`` + column is never written (per-window due times supersede it, + D-008). - ``rule`` (a pre-validated :class:`ScheduleRuleStorage`) overwrites the referenced ``schedule_rules`` row in place: the definition keeps its ``schedule_rule_id``, so no new or @@ -792,6 +825,8 @@ class QuestDefinitionsDao: does not exist. Edits change future instances only: no ``quest_instances`` or ``completion_events`` row is touched. """ + if skip_on_away is not _UNSET: + skip_on_away = _validate_skip_on_away(skip_on_away) async with _connection_lock(self._database): async with self._database.transaction(): definition = await self.get(definition_id) @@ -809,6 +844,9 @@ class QuestDefinitionsDao: if value is not _UNSET: assignments.append(f"{column} = ?") parameters.append(value) + if skip_on_away is not _UNSET: + assignments.append("skip_on_away = ?") + parameters.append(skip_on_away) if assignments: parameters.append(definition_id) await self._database.execute( @@ -907,6 +945,13 @@ class QuestDefinitionsDao: ) return [_definition_from_row(row) for row in rows] + async def list_all(self) -> list[QuestDefinitionRecord]: + """Return every definition, active AND inactive, oldest first.""" + rows = await self._database.fetch_all( + f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions ORDER BY id" + ) + return [_definition_from_row(row) for row in rows] + async def _snapshots_for( self, definitions: list[QuestDefinitionRecord] ) -> list[QuestDefinitionSnapshot]: @@ -951,6 +996,18 @@ class QuestDefinitionsDao: definitions = await self.list_active() return await self._snapshots_for(definitions) + async def list_snapshots_all(self) -> list[QuestDefinitionSnapshot]: + """Return every definition (active AND inactive) with its rule, + assignees and windows, all read inside one locked transaction. + + Mirrors :meth:`list_snapshots_active` over :meth:`list_all` + (oldest first), with the same coherence guarantee. + """ + async with _connection_lock(self._database): + async with self._database.transaction(): + definitions = await self.list_all() + return await self._snapshots_for(definitions) + async def read_snapshots_active(self) -> list[QuestDefinitionSnapshot]: """Return every active definition's snapshot WITHOUT locking. @@ -990,13 +1047,16 @@ class QuestDefinitionsDao: description: str | None | object = _UNSET, icon: str | None | object = _UNSET, due_time: str | None | object = _UNSET, + skip_on_away: bool | None = None, ) -> int: """Update the given fields; returns rows updated (0 if absent). Arguments default to the module sentinel ``_UNSET`` meaning "leave this column alone"; passing ``None`` explicitly writes SQL NULL, so callers can remove optional metadata - (description, icon, due_time). Assignment and activation are + (description, icon, due_time). ``skip_on_away`` is NOT NULL, + so its ``None`` default leaves it unchanged; a supplied value + must be 0/1 (a bool). Assignment and activation are deliberately NOT settable here: they have their own explicit operations (:meth:`add_assignee`, :meth:`remove_assignee`, :meth:`set_active`) so they stay separately loggable and @@ -1013,6 +1073,9 @@ class QuestDefinitionsDao: if value is not _UNSET: assignments.append(f"{column} = ?") parameters.append(value) + if skip_on_away is not None: + assignments.append("skip_on_away = ?") + parameters.append(_validate_skip_on_away(skip_on_away)) if not assignments: return 0 parameters.append(definition_id) diff --git a/custom_components/nestquest/core/materialize.py b/custom_components/nestquest/core/materialize.py index c32d3d9..fee785d 100644 --- a/custom_components/nestquest/core/materialize.py +++ b/custom_components/nestquest/core/materialize.py @@ -18,7 +18,8 @@ The walk is deterministic and idempotent: :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 and each declared window, one instance 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). @@ -208,7 +209,8 @@ async def materialize( definitions = [ (snapshot.definition.id, _decode_rule(snapshot), - snapshot.assignees, snapshot.windows) + snapshot.assignees, snapshot.windows, + snapshot.definition.skip_on_away) for snapshot in snapshots ] @@ -229,13 +231,15 @@ async def materialize( cursor = start while cursor <= end: iso = cursor.isoformat() - for definition_id, rule, assignees, windows in definitions: + 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 not engine.is_present(child.id, cursor): + if skip_on_away and not engine.is_present(child.id, cursor): continue for window in windows: written = await instances.upsert_if_valid( diff --git a/custom_components/nestquest/core/migrations.py b/custom_components/nestquest/core/migrations.py index cf40a7a..fba7c50 100644 --- a/custom_components/nestquest/core/migrations.py +++ b/custom_components/nestquest/core/migrations.py @@ -30,8 +30,9 @@ 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). Each callable migration runs inside its own -transaction +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. @@ -400,6 +401,28 @@ async def _add_actor_child_id( 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 #: entry, add the next one instead. (Migration 1's content was #: rewritten pre-release per D-007 — no version 1 database shipped.) @@ -423,6 +446,10 @@ MIGRATIONS: Sequence[MigrationStep] = [ # 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, ] diff --git a/custom_components/nestquest/core/quest_definitions.py b/custom_components/nestquest/core/quest_definitions.py index d301aa4..9e347e3 100644 --- a/custom_components/nestquest/core/quest_definitions.py +++ b/custom_components/nestquest/core/quest_definitions.py @@ -191,6 +191,17 @@ def _validate_definition_id(value: object) -> int: 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. @@ -212,6 +223,7 @@ async def create_quest_definition( *, description: str | None = None, icon: str | None = None, + skip_on_away: bool = True, ) -> CreatedQuestDefinition: """Create a quest definition (rule + assignees + windows) atomically. @@ -231,6 +243,7 @@ async def create_quest_definition( 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) @@ -243,6 +256,7 @@ async def create_quest_definition( 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 @@ -271,6 +285,7 @@ async def edit_quest_definition( icon: str | None | object = _UNSET, rule: ScheduleRule | object = _UNSET, windows: list[str | tuple[str, str | None]] | object = _UNSET, + skip_on_away: bool | None = None, today: datetime.date | None = None, horizon_days: int | None = None, ) -> CreatedQuestDefinition: @@ -285,7 +300,9 @@ async def edit_quest_definition( ``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. Assignment is NOT + time), matching create's non-empty contract. A provided + ``skip_on_away`` (a real bool) replaces the stored flag; ``None`` + leaves it unchanged. Assignment is NOT settable here. Raises ValueError when the definition does not exist or any argument is rejected. @@ -331,6 +348,10 @@ async def edit_quest_definition( if windows is not _UNSET: window_specs = _normalize_windows(windows) + 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( @@ -340,6 +361,7 @@ async def edit_quest_definition( icon=icon_value, rule=rule_storage, windows=window_specs, + skip_on_away=skip_on_away_value, ) except ValueError as error: # The DAO's missing-definition error names only the id; re-raise @@ -558,6 +580,21 @@ async def list_active_definitions( 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, diff --git a/custom_components/nestquest/core/schema.py b/custom_components/nestquest/core/schema.py index b3c3223..880ae36 100644 --- a/custom_components/nestquest/core/schema.py +++ b/custom_components/nestquest/core/schema.py @@ -257,7 +257,8 @@ QUEST_DEFINITIONS_TABLE_SQL = """( schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id), due_time TEXT, 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] = [ diff --git a/tests/test_api_admin_questdef.py b/tests/test_api_admin_questdef.py index 0e378fd..9111fb2 100644 --- a/tests/test_api_admin_questdef.py +++ b/tests/test_api_admin_questdef.py @@ -215,6 +215,7 @@ async def test_create_persists_definition_with_assignees_and_windows( "description", "icon", "is_active", + "skip_on_away", "rule", "assignees", "windows", @@ -223,6 +224,7 @@ async def test_create_persists_definition_with_assignees_and_windows( assert payload["description"] == "Put everything back where it belongs." assert payload["icon"] == "toy-box" assert payload["is_active"] is True + assert payload["skip_on_away"] is True assert payload["rule"] == _weekly_rule_dict() assert {a["id"]: a["display_name"] for a in payload["assignees"]} == { ada: "Ada", @@ -521,9 +523,130 @@ async def test_edit_rejections_are_422_and_change_nothing( } +@pytest.mark.parametrize("coercible", [0, 1, "true", "false"]) +async def test_create_rejects_coercible_skip_on_away_and_persists_nothing( + admin_questdefs: SimpleNamespace, coercible: object +) -> None: + """A numeric or string skip_on_away is 422, not coerced to a bool.""" + client = admin_questdefs.client + dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database) + ada = (await _create_children(client, ("Ada",)))[0] + existing = await client.post( + "/api/v1/admin/quest-definitions", + json={ + "title": "Feed the cat", + "rule": _weekly_rule_body(), + "assignee_child_ids": [ada], + "windows": ["morning"], + "skip_on_away": False, + }, + headers=_admin_headers(), + ) + assert existing.status_code == 201 + existing_id = existing.json()["id"] + + response = await client.post( + "/api/v1/admin/quest-definitions", + json={ + "title": "Tidy the den", + "rule": _weekly_rule_body(), + "assignee_child_ids": [ada], + "windows": ["morning"], + "skip_on_away": coercible, + }, + headers=_admin_headers(), + ) + assert response.status_code == 422 + assert set(await _definitions_by_id(admin_questdefs.database)) == { + existing_id + } + assert (await dao.get(existing_id)).skip_on_away is False + + +@pytest.mark.parametrize("coercible", [0, 1, "true", "false"]) +async def test_edit_rejects_coercible_skip_on_away_and_changes_nothing( + admin_questdefs: SimpleNamespace, coercible: object +) -> None: + """A numeric or string skip_on_away edit is 422; the flag is kept.""" + client = admin_questdefs.client + dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database) + ada = (await _create_children(client, ("Ada",)))[0] + definition_id = (await _create_definition(client, [ada])).json()["id"] + # Start from each stored value so a coerced write would be visible. + for stored in (True, False): + set_flag = await client.patch( + f"/api/v1/admin/quest-definitions/{definition_id}", + json={"skip_on_away": stored}, + headers=_admin_headers(), + ) + assert set_flag.status_code == 200 + + response = await client.patch( + f"/api/v1/admin/quest-definitions/{definition_id}", + json={"skip_on_away": coercible, "title": "Renamed"}, + headers=_admin_headers(), + ) + assert response.status_code == 422 + stored_definition = await dao.get(definition_id) + assert stored_definition.skip_on_away is stored + assert stored_definition.title == "Tidy the den" + + # --- set_active ------------------------------------------------------------- +async def test_edit_toggles_skip_on_away_and_omission_keeps_it( + admin_questdefs: SimpleNamespace, +) -> None: + """A supplied skip_on_away persists; an edit omitting it keeps it.""" + client = admin_questdefs.client + dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database) + ada = (await _create_children(client, ("Ada",)))[0] + created = await _create_definition(client, [ada]) + assert created.status_code == 201 + definition_id = created.json()["id"] + assert created.json()["skip_on_away"] is True + + toggled_off = await client.patch( + f"/api/v1/admin/quest-definitions/{definition_id}", + json={"skip_on_away": False}, + headers=_admin_headers(), + ) + assert toggled_off.status_code == 200 + assert toggled_off.json()["skip_on_away"] is False + assert (await dao.get(definition_id)).skip_on_away is False + + # An edit that omits the flag leaves the stored False untouched. + renamed = await client.patch( + f"/api/v1/admin/quest-definitions/{definition_id}", + json={"title": "Tidy the den properly"}, + headers=_admin_headers(), + ) + assert renamed.status_code == 200 + assert renamed.json()["skip_on_away"] is False + stored = await dao.get(definition_id) + assert stored.title == "Tidy the den properly" + assert stored.skip_on_away is False + + toggled_on = await client.patch( + f"/api/v1/admin/quest-definitions/{definition_id}", + json={"skip_on_away": True}, + headers=_admin_headers(), + ) + assert toggled_on.status_code == 200 + assert toggled_on.json()["skip_on_away"] is True + assert (await dao.get(definition_id)).skip_on_away is True + + # A non-boolean value is a malformed body: 422, nothing changes. + rejected = await client.patch( + f"/api/v1/admin/quest-definitions/{definition_id}", + json={"skip_on_away": "sometimes"}, + headers=_admin_headers(), + ) + assert rejected.status_code == 422 + assert (await dao.get(definition_id)).skip_on_away is True + + async def test_set_active_toggles_the_flag_and_the_definition_survives( admin_questdefs: SimpleNamespace, ) -> None: diff --git a/tests/test_api_admin_questdef_list.py b/tests/test_api_admin_questdef_list.py new file mode 100644 index 0000000..0584695 --- /dev/null +++ b/tests/test_api_admin_questdef_list.py @@ -0,0 +1,299 @@ +"""Admin-plane quest-definition LIST route tests (task d4667573). + +``GET /api/v1/admin/quest-definitions`` against the ASGI app with httpx, +reusing the shared local-RSA-key/stubbed-JWKS harness +(:mod:`tests.admin_jwt_harness`) so an admin JWT genuinely authenticates +with NO network access. + +- An admin JWT lists EVERY definition — active AND inactive — in rising + id order, each in the same documented shape as the create/edit + responses (id, title, description, icon, is_active, rule, assignees, + windows). +- An empty household lists nothing: 200 with an empty list. +- Authentication runs through the router's ONE ``require_admin`` + dependency: an absent credential is 401, the panel service token is + refused outright (403, the credential-type detail), and a JWT without + the nestquest-admins group is 403. +""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from tests.admin_jwt_harness import ( + ADMIN_KEY, + KID, + AdminRunner, + jwks_for, + make_token, +) + +#: The admin list route under test. +LIST_PATH = "/api/v1/admin/quest-definitions" + +#: The panel service token the harness app is configured with. +HARNESS_PANEL_TOKEN = "admin-test-panel-token" + +#: The 403 detail for the panel service token on an admin route. +PANEL_TOKEN_REJECTION_DETAIL = ( + "This credential type is not accepted for admin access" +) + +#: The 403 detail for a valid JWT without the admin group. +FORBIDDEN_DETAIL = ( + "Admin access requires membership in the nestquest-admins group" +) + + +def _admin_headers() -> dict[str, str]: + """The Authorization header of a valid nestquest-admins JWT.""" + return {"Authorization": f"Bearer {make_token()}"} + + +@pytest.fixture +async def admin_client(temp_db_path: str) -> SimpleNamespace: + """An admin-plane app client over a fresh (empty) household.""" + runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) + async with runner as client: + yield client + + +async def _seed_active_and_inactive(client: object) -> tuple[int, int, int]: + """Seed one child, an ACTIVE definition and an INACTIVE one. + + Returns ``(child_id, active_id, inactive_id)``. Seeded through the + admin routes so the rows are exactly what the admin plane writes. + """ + child = await client.post( + "/api/v1/admin/children", + json={"display_name": "Ada"}, + headers=_admin_headers(), + ) + assert child.status_code == 201 + child_id = child.json()["id"] + + active = await client.post( + LIST_PATH, + json={ + "title": "Tidy the den", + "rule": { + "rule_type": "weekly", + "interval": 1, + "weekday_set": [0, 2], + "start_date": "2026-03-02", + }, + "assignee_child_ids": [child_id], + "windows": ["morning", ["evening", "19:30"]], + "description": "Put everything back where it belongs.", + "icon": "toy-box", + }, + headers=_admin_headers(), + ) + assert active.status_code == 201 + + inactive = await client.post( + LIST_PATH, + json={ + "title": "Water the plants", + "rule": { + "rule_type": "daily", + "interval": 1, + "start_date": "2026-03-01", + }, + "assignee_child_ids": [child_id], + "windows": ["afternoon"], + }, + headers=_admin_headers(), + ) + assert inactive.status_code == 201 + inactive_id = inactive.json()["id"] + deactivated = await client.patch( + f"{LIST_PATH}/{inactive_id}/active", + json={"is_active": False}, + headers=_admin_headers(), + ) + assert deactivated.status_code == 200 + + return child_id, active.json()["id"], inactive_id + + +async def test_admin_lists_active_and_inactive_definitions_with_full_shape( + admin_client: object, +) -> None: + """Both definitions come back, by rising id, in the documented shape.""" + child_id, active_id, inactive_id = await _seed_active_and_inactive( + admin_client + ) + + response = await admin_client.get(LIST_PATH, headers=_admin_headers()) + + assert response.status_code == 200 + assert response.json() == { + "definitions": [ + { + "id": active_id, + "title": "Tidy the den", + "description": "Put everything back where it belongs.", + "icon": "toy-box", + "is_active": True, + "skip_on_away": True, + "rule": { + "rule_type": "weekly", + "interval": 1, + "weekday_set": [0, 2], + "day_of_month": None, + "nth_weekday": None, + "nth_weekday_weekday": None, + "month": None, + "start_date": "2026-03-02", + "end_date": None, + }, + "assignees": [{"id": child_id, "display_name": "Ada"}], + "windows": [ + {"window": "morning", "due_time": None}, + {"window": "evening", "due_time": "19:30"}, + ], + }, + { + "id": inactive_id, + "title": "Water the plants", + "description": None, + "icon": None, + "is_active": False, + "skip_on_away": True, + "rule": { + "rule_type": "daily", + "interval": 1, + "weekday_set": None, + "day_of_month": None, + "nth_weekday": None, + "nth_weekday_weekday": None, + "month": None, + "start_date": "2026-03-01", + "end_date": None, + }, + "assignees": [{"id": child_id, "display_name": "Ada"}], + "windows": [{"window": "afternoon", "due_time": None}], + }, + ] + } + + +async def test_list_items_match_the_create_response_shape( + admin_client: object, +) -> None: + """Each listed item is exactly what the create route returned.""" + child = await admin_client.post( + "/api/v1/admin/children", + json={"display_name": "Ben"}, + headers=_admin_headers(), + ) + created = await admin_client.post( + LIST_PATH, + json={ + "title": "Feed the cat", + "rule": { + "rule_type": "daily", + "interval": 1, + "start_date": "2026-03-01", + }, + "assignee_child_ids": [child.json()["id"]], + "windows": ["morning"], + }, + headers=_admin_headers(), + ) + assert created.status_code == 201 + + response = await admin_client.get(LIST_PATH, headers=_admin_headers()) + + assert response.status_code == 200 + assert response.json() == {"definitions": [created.json()]} + + +async def test_skip_on_away_round_trips_true_and_false_through_list( + admin_client: object, +) -> None: + """skip_on_away posted true, false, or omitted lists back as stored.""" + child = await admin_client.post( + "/api/v1/admin/children", + json={"display_name": "Cleo"}, + headers=_admin_headers(), + ) + child_id = child.json()["id"] + + async def create(title: str, extra: dict[str, object]) -> dict: + response = await admin_client.post( + LIST_PATH, + json={ + "title": title, + "rule": { + "rule_type": "daily", + "interval": 1, + "start_date": "2026-03-01", + }, + "assignee_child_ids": [child_id], + "windows": ["morning"], + **extra, + }, + headers=_admin_headers(), + ) + assert response.status_code == 201 + return response.json() + + skipping = await create("Skip when away", {"skip_on_away": True}) + keeping = await create("Keep when away", {"skip_on_away": False}) + defaulted = await create("Default", {}) + assert skipping["skip_on_away"] is True + assert keeping["skip_on_away"] is False + assert defaulted["skip_on_away"] is True + + response = await admin_client.get(LIST_PATH, headers=_admin_headers()) + + assert response.status_code == 200 + listed = {item["id"]: item for item in response.json()["definitions"]} + assert listed[skipping["id"]]["skip_on_away"] is True + assert listed[keeping["id"]]["skip_on_away"] is False + assert listed[defaulted["id"]]["skip_on_away"] is True + assert response.json() == {"definitions": [skipping, keeping, defaulted]} + + +async def test_empty_household_lists_no_definitions( + admin_client: object, +) -> None: + """With no definitions stored the list is 200 and empty.""" + response = await admin_client.get(LIST_PATH, headers=_admin_headers()) + + assert response.status_code == 200 + assert response.json() == {"definitions": []} + + +async def test_list_without_credentials_is_401(admin_client: object) -> None: + """An absent credential is the uniform 401.""" + response = await admin_client.get(LIST_PATH) + + assert response.status_code == 401 + + +async def test_list_with_panel_service_token_is_403( + admin_client: object, +) -> None: + """The panel service token is refused outright, never a JWT.""" + response = await admin_client.get( + LIST_PATH, + headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}, + ) + + assert response.status_code == 403 + assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL + + +async def test_list_with_non_admin_jwt_is_403(admin_client: object) -> None: + """A valid JWT without the nestquest-admins group is 403.""" + non_admin = make_token(groups=("some-other-group",)) + response = await admin_client.get( + LIST_PATH, headers={"Authorization": f"Bearer {non_admin}"} + ) + + assert response.status_code == 403 + assert response.json()["detail"] == FORBIDDEN_DETAIL diff --git a/tests/test_dao_rules.py b/tests/test_dao_rules.py index 7047d86..5986dbf 100644 --- a/tests/test_dao_rules.py +++ b/tests/test_dao_rules.py @@ -847,6 +847,7 @@ def test_definition_update_cannot_change_assignee_or_active( "description", "icon", "due_time", + "skip_on_away", } assert await definitions.update( definition.id, title="A2" @@ -862,6 +863,72 @@ def test_definition_update_cannot_change_assignee_or_active( _with_db(tmp_path, "definition-update-shape.db")(_body) +def test_definition_update_toggles_skip_on_away_and_omission_keeps_it( + tmp_path, +) -> None: + async def _body(database, rules, definitions, children, child): + rule = await rules.create("daily", "2026-09-14") + definition = await definitions.create("A", rule.id, NOW) + assert definition.skip_on_away is True + assert await definitions.update( + definition.id, skip_on_away=False + ) == 1 + assert (await definitions.get(definition.id)).skip_on_away is False + # Omitting the flag (None) leaves the stored False untouched. + assert await definitions.update(definition.id, title="A2") == 1 + kept = await definitions.get(definition.id) + assert kept.title == "A2" + assert kept.skip_on_away is False + assert await definitions.update(definition.id) == 0 + assert await definitions.update( + definition.id, skip_on_away=True + ) == 1 + assert (await definitions.get(definition.id)).skip_on_away is True + + _with_db(tmp_path, "definition-update-skip-on-away.db")(_body) + + +@pytest.mark.parametrize("bad", [2, -1, 0.0, 1.5, "1", "true", b"\x01"]) +def test_skip_on_away_rejected_unless_zero_or_one_at_every_write( + tmp_path, bad +) -> None: + """Every DAO entry point taking the flag rejects non-0/1 values. + + This is the only guard on migrated databases, whose column was + added without the fresh-schema CHECK constraint. + """ + async def _body(database, rules, definitions, children, child): + rule = await rules.create("daily", "2026-09-14") + definition = await definitions.create("A", rule.id, NOW) + with pytest.raises(ValueError, match="skip_on_away must be 0 or 1"): + await definitions.create("B", rule.id, NOW, skip_on_away=bad) + with pytest.raises(ValueError, match="skip_on_away must be 0 or 1"): + await definitions.create_with_rule_and_windows( + "C", + schedule_rule_to_storage( + ScheduleRule( + rule_type=RuleType.DAILY, start_date="2026-09-14" + ) + ), + NOW, + [child.id], + [("morning", None)], + skip_on_away=bad, + ) + with pytest.raises(ValueError, match="skip_on_away must be 0 or 1"): + await definitions.edit_definition( + definition.id, title="A2", skip_on_away=bad + ) + with pytest.raises(ValueError, match="skip_on_away must be 0 or 1"): + await definitions.update( + definition.id, title="A2", skip_on_away=bad + ) + stored = await definitions.list_all() + assert [(d.title, d.skip_on_away) for d in stored] == [("A", True)] + + _with_db(tmp_path, "definition-skip-on-away-invalid.db")(_body) + + def test_definition_update_no_fields_returns_zero(tmp_path) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") diff --git a/tests/test_materialize.py b/tests/test_materialize.py index e9df73a..39a5667 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -657,6 +657,133 @@ def test_materialize_honours_overrides(tmp_path) -> None: _with_db(tmp_path, "materialize-override.db")(_body) +async def _absent_midweek_setup(database, skip_on_away: bool): + """One child absent on days 2..4 of a future week, one daily quest. + + Returns ``(child_id, definition_id, start, end_iso, absent_dates)``. + """ + from custom_components.nestquest.core.dao_presence import ( + PresenceOverridesDao, + ) + + child = await ChildrenDao(database).create("Ada", NOW) + start = _future_monday() + end_iso = (start + datetime.timedelta(days=6)).isoformat() + absent = { + (start + datetime.timedelta(days=offset)).isoformat() + for offset in (2, 3, 4) + } + await PresenceOverridesDao(database).create( + child.id, min(absent), max(absent), False, note="away" + ) + created = await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=start.isoformat()), + [child.id], + ["morning"], + skip_on_away=skip_on_away, + ) + assert created.definition.skip_on_away is skip_on_away + return child.id, created.definition.id, start, end_iso, absent + + +def test_materialize_skip_on_away_true_skips_absent_dates(tmp_path) -> None: + async def _body(database): + child_id, _, start, end_iso, absent = await _absent_midweek_setup( + database, skip_on_away=True + ) + count = await materialize(database, start.isoformat(), end_iso) + records = await QuestInstancesDao(database).list_by_date_range( + child_id, start.isoformat(), end_iso + ) + due_dates = {record.due_date for record in records} + assert count == 4 + assert due_dates.isdisjoint(absent) + assert len(due_dates) == 4 + + _with_db(tmp_path, "skip-on-away-true.db")(_body) + + +def test_materialize_skip_on_away_false_generates_absent_dates( + tmp_path, +) -> None: + async def _body(database): + child_id, _, start, end_iso, absent = await _absent_midweek_setup( + database, skip_on_away=False + ) + count = await materialize(database, start.isoformat(), end_iso) + records = await QuestInstancesDao(database).list_by_date_range( + child_id, start.isoformat(), end_iso + ) + due_dates = {record.due_date for record in records} + assert count == 7 + assert absent <= due_dates + assert len(due_dates) == 7 + + _with_db(tmp_path, "skip-on-away-false.db")(_body) + + +def test_edit_skip_on_away_regenerates_absent_dates(tmp_path) -> None: + """Toggling the flag through the edit path rebuilds future instances.""" + async def _body(database): + child_id, definition_id, start, end_iso, absent = ( + await _absent_midweek_setup(database, skip_on_away=True) + ) + await materialize(database, start.isoformat(), end_iso) + dao = QuestInstancesDao(database) + + async def due_dates() -> set[str]: + records = await dao.list_by_date_range( + child_id, start.isoformat(), end_iso + ) + return {record.due_date for record in records} + + assert (await due_dates()).isdisjoint(absent) + + edited = await edit_quest_definition( + database, definition_id, skip_on_away=False + ) + assert edited.definition.skip_on_away is False + assert absent <= await due_dates() + + # None leaves the stored flag unchanged. + unchanged = await edit_quest_definition( + database, definition_id, title="Daily chore (renamed)" + ) + assert unchanged.definition.skip_on_away is False + + edited = await edit_quest_definition( + database, definition_id, skip_on_away=True + ) + assert edited.definition.skip_on_away is True + assert (await due_dates()).isdisjoint(absent) + + _with_db(tmp_path, "skip-on-away-edit.db")(_body) + + +def test_skip_on_away_rejects_non_bool(tmp_path) -> None: + async def _body(database): + child = await ChildrenDao(database).create("Ada", NOW) + rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-01-01") + with pytest.raises(ValueError, match="skip_on_away must be a real bool"): + await create_quest_definition( + database, "Chore", rule, [child.id], ["morning"], + skip_on_away=1, + ) + created = await create_quest_definition( + database, "Chore", rule, [child.id], ["morning"] + ) + with pytest.raises(ValueError, match="skip_on_away must be a real bool"): + await edit_quest_definition( + database, created.definition.id, skip_on_away="false" + ) + stored = await QuestDefinitionsDao(database).get(created.definition.id) + assert stored.skip_on_away is True + + _with_db(tmp_path, "skip-on-away-validation.db")(_body) + + def test_materialize_rejects_malformed_or_inverted_bounds(tmp_path) -> None: async def _body(database): start = (_future_monday()).isoformat() diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 0bfca68..855164a 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -153,7 +153,9 @@ def test_migration_list_shape() -> None: migration 3 brings pre-D-008 definitions to the multi-assignee model, migration 4 adds the windows table, migration 5 rebuilds quest_instances onto the widened (definition, child, date, window) - key, and migration 6 adds actor_child_id to completion_events. + key, migration 6 adds actor_child_id to completion_events, + migration 7 adds the meta_state table and migration 8 adds + skip_on_away to quest_definitions. """ assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS assert callable(MIGRATIONS[1]) @@ -162,7 +164,8 @@ def test_migration_list_shape() -> None: assert callable(MIGRATIONS[4]) assert callable(MIGRATIONS[5]) assert MIGRATIONS[6] == SCHEMA_V7_META_STATE_DDL - assert len(MIGRATIONS) == 7 + assert callable(MIGRATIONS[7]) + assert len(MIGRATIONS) == 8 # --------------------------------------------------------------------------- @@ -564,6 +567,13 @@ def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None: database.fetch_all("PRAGMA table_info(quest_definitions)") ) assert "child_id" not in {c[1] for c in columns} + # The rebuilt row reads the pre-flag behaviour: skip on away. + skip = _run( + database.fetch_one( + "SELECT skip_on_away FROM quest_definitions WHERE id = 1" + ) + ) + assert skip == (1,) # The single assignee moved into the assignees table. assignees = _run( database.fetch_all( @@ -637,6 +647,87 @@ def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None: _run(database.close()) +# --------------------------------------------------------------------------- +# Migration 8: skip_on_away on quest_definitions (Feature 22) +# --------------------------------------------------------------------------- + + +def _seed_v7_database(database) -> None: + """Build a genuine version-7 database holding one definition. + + Migrations 1..7 run first; the fresh v1 DDL already carries + ``skip_on_away`` (canonical shape), so the column is dropped to + reproduce the exact pre-migration-8 table a shipped v7 file holds, + and a definition row is written through that old column list. + """ + assert _migrate(database, MIGRATIONS[:7]) == 7 + _run( + database.execute( + "ALTER TABLE quest_definitions DROP COLUMN skip_on_away" + ) + ) + columns = _run(database.fetch_all("PRAGMA table_info(quest_definitions)")) + assert "skip_on_away" not in {c[1] for c in columns} + _run( + database.execute( + "INSERT INTO schedule_rules (id, rule_type, interval, " + "start_date) VALUES (1, 'daily', 1, '2026-09-01')" + ) + ) + _run( + database.execute( + "INSERT INTO quest_definitions (id, title, schedule_rule_id, " + "is_active, created_at) VALUES (1, 'Dishes', 1, 1, " + "'2026-09-01T00:00:00+00:00')" + ) + ) + + +def test_v7_definitions_gain_skip_on_away_defaulting_true(tmp_path) -> None: + from custom_components.nestquest.core.dao_rules import ( + QuestDefinitionsDao, + ) + + database = _open_db(tmp_path / "v7-upgrade.db") + try: + _seed_v7_database(database) + before = _version(database) + assert before == 7 + + final = _migrate(database) + + assert final == before + 1 + assert _version(database) == before + 1 + columns = { + c[1]: c + for c in _run( + database.fetch_all("PRAGMA table_info(quest_definitions)") + ) + } + assert "skip_on_away" in columns + # (cid, name, type, notnull, dflt_value, pk) + assert columns["skip_on_away"][2:5] == ("INTEGER", 1, "1") + record = _run(QuestDefinitionsDao(database).get(1)) + assert record is not None + assert record.title == "Dishes" + assert record.skip_on_away is True + finally: + _run(database.close()) + + +def test_skip_on_away_migration_is_noop_on_fresh_schema(tmp_path) -> None: + """A fresh file already has the column; step 8 only stamps.""" + database = _open_db(tmp_path / "fresh-v8.db") + try: + assert _migrate(database) == len(MIGRATIONS) + columns = _run( + database.fetch_all("PRAGMA table_info(quest_definitions)") + ) + assert [c[1] for c in columns].count("skip_on_away") == 1 + finally: + _run(database.close()) + + # --------------------------------------------------------------------------- # Idempotence: second start is a no-op # --------------------------------------------------------------------------- diff --git a/tests/test_schema.py b/tests/test_schema.py index 4a526de..86c6eaf 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -322,6 +322,7 @@ def test_quest_definitions_columns_types_and_constraints(tmp_path) -> None: ("due_time", "TEXT", 0, None, 0), ("is_active", "INTEGER", 1, "1", 0), ("created_at", "TEXT", 1, None, 0), + ("skip_on_away", "INTEGER", 1, "1", 0), ] finally: _run(database.close()) @@ -633,6 +634,30 @@ def test_schedule_rules_rule_type_check_enforced(tmp_path) -> None: _run(database.close()) +@pytest.mark.parametrize("bad_flag", [2, -1]) +def test_quest_definitions_skip_on_away_check_enforced( + tmp_path, bad_flag +) -> None: + database = _open_db(tmp_path / f"skip-on-away-{bad_flag}.db") + try: + _apply(database) + _child(database) + _insert_rule(database) + with pytest.raises(sqlite3.IntegrityError, match="CHECK"): + _insert_definition(database, skip_on_away=bad_flag) + definition_id = _insert_definition(database, skip_on_away=0) + with pytest.raises(sqlite3.IntegrityError, match="CHECK"): + _run( + database.execute( + "UPDATE quest_definitions SET skip_on_away = ? " + "WHERE id = ?", + (bad_flag, definition_id), + ) + ) + finally: + _run(database.close()) + + def test_schedule_rules_interval_zero_fails(tmp_path) -> None: database = _open_db(tmp_path / "interval-zero.db") try: -- 2.55.0 From 4b39af4b6753298d977bbff7ecc445e73e5d900f Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 21:10:39 -0400 Subject: [PATCH 08/11] Feature 22 follow-up: Editable definition assignees (#196) (#197) Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> --- api/routes_admin.py | 48 +- custom_components/nestquest/core/dao_rules.py | 52 ++- .../nestquest/core/quest_definitions.py | 32 +- tests/test_api_admin_questdef.py | 7 +- tests/test_api_admin_questdef_assignees.py | 431 ++++++++++++++++++ 5 files changed, 536 insertions(+), 34 deletions(-) create mode 100644 tests/test_api_admin_questdef_assignees.py diff --git a/api/routes_admin.py b/api/routes_admin.py index 6fc3009..1c4de6b 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -30,10 +30,10 @@ hand-rolled here), and the call goes to ``set_quest_definition_active``. The title policy, the window-name and due-time policy, the assignee checks, the whole-set window replacement and the never-hard-delete policy (deactivation is the only -removal path) all live in the core. Assignment is NOT settable -through the API: the core's edit path takes no assignees. The edit -route forwards only SUPPLIED fields (``exclude_unset``), like the -children edit. +removal path) all live in the core. The edit route forwards only +SUPPLIED fields (``exclude_unset``), like the children edit; a supplied +``assignee_child_ids`` list REPLACES the whole assignee set inside the +core edit's one transaction (and its one regeneration). The presence routes follow the same pattern over :mod:`nestquest_core.presence_management` — the ONE presence write @@ -182,8 +182,9 @@ deliberately narrow): set_active on an unknown id) is mapped to 404. - A core ``ValueError`` reporting a NON-EXISTENT DEFINITION (edit or set_active on an unknown id — the core prefixes that error with - ``definition_id:``) is mapped to 404. Create's rejected-assignee - error names a child from the BODY, not a path id, so it maps to 422. + ``definition_id:``) is mapped to 404. Create's and edit's + rejected-assignee error names a child from the BODY, not a path id, + so it maps to 422. - Every OTHER core ``ValueError`` — an empty display name, an explicit ``null`` colour/avatar_ref (clearing is not supported), a reorder list that is not a complete permutation of the children (missing, @@ -251,7 +252,13 @@ import datetime from typing import Annotated, Literal, NoReturn from fastapi import APIRouter, Depends, HTTPException, Request, Response -from pydantic import BaseModel, StrictBool, field_validator, model_validator +from pydantic import ( + BaseModel, + StrictBool, + StrictInt, + field_validator, + model_validator, +) from api import transitions as api_transitions from api.auth import require_admin @@ -456,11 +463,13 @@ class AdminQuestDefinitionCreateRequest(BaseModel): date the child is absent). ``windows`` entries are a window name (``morning``) or a two-item ``[window, due_time]`` pair whose due time is a strict 24-hour HH:MM (or ``null`` for none) — both forms the core normalizes. + Assignee ids are strict JSON integers: a boolean, float or numeric + string is 422, never coerced to a child id. """ title: str rule: AdminRuleRequest - assignee_child_ids: list[int] + assignee_child_ids: list[StrictInt] windows: list[str | tuple[str, str | None]] description: str | None = None icon: str | None = None @@ -476,11 +485,13 @@ class AdminQuestDefinitionEditRequest(BaseModel): field stays unchanged. An explicit ``null`` ``description``/ ``icon`` IS passed and CLEARS the stored value (the core supports clearing here, unlike the children edit); an explicit ``null`` - ``rule`` or ``windows`` is passed and rejected by the core. A - supplied ``windows`` list REPLACES the whole window set, and - ``assignees`` are deliberately absent — assignment is not settable - through this route. An omitted (or ``null``) ``skip_on_away`` - keeps the stored value. + ``rule``, ``windows`` or ``assignee_child_ids`` is passed and + rejected by the core. A supplied ``windows`` list REPLACES the + whole window set, and a supplied ``assignee_child_ids`` list + REPLACES the whole assignee set (non-empty, every id an existing + active child — create's contract; a rejection changes nothing). An + omitted (or ``null``) ``skip_on_away`` keeps the stored value. + Assignee ids are strict JSON integers, as on create. """ title: str | None = None @@ -489,6 +500,7 @@ class AdminQuestDefinitionEditRequest(BaseModel): rule: AdminRuleRequest | None = None windows: list[str | tuple[str, str | None]] | None = None skip_on_away: StrictBool | None = None + assignee_child_ids: list[StrictInt] | None = None class AdminQuestDefinitionActiveRequest(BaseModel): @@ -1266,10 +1278,12 @@ async def admin_edit_quest_definition( field is never passed to :func:`nestquest_core.quest_definitions.edit_quest_definition` and stays unchanged; a supplied ``windows`` list replaces the WHOLE - window set in the core, and a supplied ``rule`` is rebuilt into a - ScheduleRule (:func:`_rule_from_request`, validated by the model). - An explicit ``null`` ``rule``/``windows`` reaches the core as None - and is rejected there (422), while an explicit ``null`` + window set in the core, a supplied ``assignee_child_ids`` list + replaces the WHOLE assignee set in the same core transaction, and a + supplied ``rule`` is rebuilt into a ScheduleRule + (:func:`_rule_from_request`, validated by the model). An explicit + ``null`` ``rule``/``windows``/``assignee_child_ids`` reaches the + core as None and is rejected there (422), while an explicit ``null`` ``description``/``icon`` clears the stored value — the core edit path supports clearing, unlike the children edit. An unknown definition id is mapped to 404 and any other core ``ValueError`` diff --git a/custom_components/nestquest/core/dao_rules.py b/custom_components/nestquest/core/dao_rules.py index 669569d..4f005d9 100644 --- a/custom_components/nestquest/core/dao_rules.py +++ b/custom_components/nestquest/core/dao_rules.py @@ -796,8 +796,9 @@ class QuestDefinitionsDao: rule: ScheduleRuleStorage | object = _UNSET, windows: list[tuple[str, str | None]] | object = _UNSET, skip_on_away: bool | object = _UNSET, + assignee_child_ids: list[int] | object = _UNSET, ) -> QuestDefinitionSnapshot: - """Edit a definition's metadata, rule and windows atomically. + """Edit a definition's metadata, rule, windows and assignees atomically. Mirrors :meth:`create_with_rule_and_windows`: everything runs inside ONE transaction under the connection lock, and the @@ -818,11 +819,15 @@ class QuestDefinitionsDao: are deleted and the given set re-inserted, so missing windows are added, changed due times updated and removed windows dropped. + - ``assignee_child_ids`` REPLACES the whole assignee set: every + id is validated as existing-and-active BEFORE any write (the + same check :meth:`add_assignee` applies), then links for + children no longer listed are removed and links for newly + listed children inserted; unchanged links are left alone. - Assignment is deliberately not settable here (multi-assignee is - managed by :meth:`add_assignee`/:meth:`remove_assignee`), and - activation is untouched. Raises ValueError when the definition - does not exist. Edits change future instances only: no + Activation is untouched. Raises ValueError when the definition + does not exist or an assignee is rejected; either way the + transaction rolls back and nothing is written. Edits change future instances only: no ``quest_instances`` or ``completion_events`` row is touched. """ if skip_on_away is not _UNSET: @@ -834,6 +839,9 @@ class QuestDefinitionsDao: raise ValueError( f"quest definition {definition_id} does not exist" ) + if assignee_child_ids is not _UNSET: + for child_id in assignee_child_ids: + await self._validate_assignable_child(child_id) assignments: list[str] = [] parameters: list[object] = [] for column, value in ( @@ -882,6 +890,10 @@ class QuestDefinitionsDao: "VALUES (?, ?, ?)", (definition_id, window, due_time), ) + if assignee_child_ids is not _UNSET: + await self._replace_assignees( + definition_id, assignee_child_ids + ) updated = await self.get(definition_id) assignees = await self.list_assignees(definition_id) window_records = await self.list_windows(definition_id) @@ -897,6 +909,36 @@ class QuestDefinitionsDao: windows=window_records, ) + async def _replace_assignees( + self, definition_id: int, child_ids: list[int] + ) -> None: + """Make the definition's assignee links exactly ``child_ids``. + + Removes the links for children not in ``child_ids`` and inserts + the missing ones. MUST be called inside the connection lock and + an open transaction, after the ids were validated as assignable. + """ + rows = await self._database.fetch_all( + "SELECT child_id FROM quest_definition_assignees " + "WHERE definition_id = ?", + (definition_id,), + ) + current = {row[0] for row in rows} + wanted = set(child_ids) + for child_id in sorted(current - wanted): + await self._database.execute( + "DELETE FROM quest_definition_assignees " + "WHERE definition_id = ? AND child_id = ?", + (definition_id, child_id), + ) + for child_id in child_ids: + if child_id not in current: + await self._database.execute( + "INSERT INTO quest_definition_assignees " + "(definition_id, child_id) VALUES (?, ?)", + (definition_id, child_id), + ) + async def _validate_rule_exists(self, schedule_rule_id: int) -> None: """Raise ValueError unless the rule exists. diff --git a/custom_components/nestquest/core/quest_definitions.py b/custom_components/nestquest/core/quest_definitions.py index 9e347e3..9918311 100644 --- a/custom_components/nestquest/core/quest_definitions.py +++ b/custom_components/nestquest/core/quest_definitions.py @@ -4,8 +4,9 @@ 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, :func:`assign_child` and -:func:`unassign_child` the assignment paths, and +: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). @@ -286,10 +287,11 @@ async def edit_quest_definition( 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 and windows atomically. + """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 @@ -302,9 +304,14 @@ async def edit_quest_definition( 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. Assignment is NOT - settable here. Raises ValueError when the definition does not - exist or any argument is rejected. + 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, @@ -348,6 +355,10 @@ async def edit_quest_definition( 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) @@ -362,14 +373,17 @@ async def edit_quest_definition( 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 error names only the id; re-raise - # so the public contract names the field the caller passed (matching - # assign/unassign/deactivate). + # 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( diff --git a/tests/test_api_admin_questdef.py b/tests/test_api_admin_questdef.py index 9111fb2..3a0f398 100644 --- a/tests/test_api_admin_questdef.py +++ b/tests/test_api_admin_questdef.py @@ -18,8 +18,9 @@ caveat in api/nestquest_core.py): and windows exactly as posted. - ``PATCH /api/v1/admin/quest-definitions/{id}``: an edit PERSISTS the supplied fields and leaves omitted fields untouched (exclude_unset - semantics); a supplied ``windows`` list replaces the whole set; - assignment is not settable through the edit. + semantics); a supplied ``windows`` list replaces the whole set; an + omitted ``assignee_child_ids`` leaves the assignees untouched (the + supplied-list cases live in test_api_admin_questdef_assignees.py). - ``PATCH /api/v1/admin/quest-definitions/{id}/active`` with ``is_active: false`` sets the flag FALSE and the definition STILL EXISTS with its rule, assignees and windows (never hard-deleted); @@ -397,7 +398,7 @@ async def test_edit_persists_supplied_fields_and_leaves_omitted_untouched( ) assert stored.definition.icon == "toy-box" assert stored.rule.to_dict() == _weekly_rule_dict() - # Assignment is not settable through the edit route: untouched. + # assignee_child_ids was omitted: the assignees are untouched. assert sorted(child.id for child in stored.assignees) == [ada, bo] # A second edit touching only the rule leaves the metadata and the diff --git a/tests/test_api_admin_questdef_assignees.py b/tests/test_api_admin_questdef_assignees.py new file mode 100644 index 0000000..40c4b86 --- /dev/null +++ b/tests/test_api_admin_questdef_assignees.py @@ -0,0 +1,431 @@ +"""Admin-plane editable assignees on the definition edit route (task 1c8096f6). + +``PATCH /api/v1/admin/quest-definitions/{definition_id}`` accepts an +optional ``assignee_child_ids`` list that REPLACES the definition's +assignee set inside the core edit's ONE transaction +(:func:`nestquest_core.quest_definitions.edit_quest_definition`): + +- newly listed children are assigned and unlisted ones unassigned; the + response and the database both carry the resulting set; +- an empty list, a duplicate, an unknown child or an inactive child is + 422 and NOTHING is written — the stored assignees (and any other + field supplied in the same body) are unchanged; +- a JSON boolean, float or numeric string id is 422 on create and edit + (never coerced to an int) and nothing is written; +- an omitted ``assignee_child_ids`` leaves the assignees unchanged; +- an assignment change regenerates the definition's future instances + through the existing core path; +- the route stays behind the router's ONE ``require_admin`` dependency + (absent credential 401, panel service token 403, non-admin JWT 403, + admin JWT 200). + +Every case is proven against the DATABASE, read through the SAME core +copy the app uses (``nestquest_core.*``; see api/nestquest_core.py). +""" +from __future__ import annotations + +import datetime +import importlib +from types import SimpleNamespace + +import pytest + +from tests.admin_jwt_harness import ( + ADMIN_KEY, + KID, + AdminRunner, + jwks_for, + make_token, +) + +_dao_rules = importlib.import_module("nestquest_core.dao_rules") +_dao_instances = importlib.import_module("nestquest_core.dao_instances") +_materialize = importlib.import_module("nestquest_core.materialize") +_quest_definitions = importlib.import_module( + "nestquest_core.quest_definitions" +) + +#: The panel service token the harness app is configured with. +HARNESS_PANEL_TOKEN = "admin-test-panel-token" + + +def _admin_headers() -> dict[str, str]: + """The Authorization header of a valid nestquest-admins JWT.""" + return {"Authorization": f"Bearer {make_token()}"} + + +def _edit_path(definition_id: int) -> str: + """The edit route for one definition.""" + return f"/api/v1/admin/quest-definitions/{definition_id}" + + +@pytest.fixture +async def admin_assignees(temp_db_path: str) -> SimpleNamespace: + """An admin-plane app client plus its live database.""" + runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) + async with runner as client: + yield SimpleNamespace( + client=client, + database=runner.app.state.db.database, + ) + + +async def _create_children( + client: object, names: tuple[str, ...] +) -> list[int]: + """Create ACTIVE children through the admin children route.""" + ids = [] + for name in names: + created = await client.post( + "/api/v1/admin/children", + json={"display_name": name}, + headers=_admin_headers(), + ) + assert created.status_code == 201 + ids.append(created.json()["id"]) + return ids + + +async def _create_definition(client: object, assignee_ids: list[int]) -> int: + """POST a daily morning definition for the given assignees.""" + created = await client.post( + "/api/v1/admin/quest-definitions", + json={ + "title": "Brush teeth", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": assignee_ids, + "windows": ["morning"], + }, + headers=_admin_headers(), + ) + assert created.status_code == 201 + return created.json()["id"] + + +async def _stored_assignee_ids( + database: object, definition_id: int +) -> set[int]: + """The definition's assignee ids as stored.""" + dao = _dao_rules.QuestDefinitionsDao(database) + return {child.id for child in await dao.list_assignees(definition_id)} + + +def _response_assignee_ids(response: object) -> set[int]: + """The assignee ids a definition payload reports.""" + return {assignee["id"] for assignee in response.json()["assignees"]} + + +# --- add / remove / replace ------------------------------------------------- + + +@pytest.mark.parametrize( + ("initial", "requested"), + [ + (("Ada",), ("Ada", "Bo")), # add a child + (("Ada", "Bo"), ("Ada",)), # remove a child + (("Ada", "Bo"), ("Cy",)), # replace the whole set + ], +) +async def test_edit_replaces_the_assignee_set( + admin_assignees: SimpleNamespace, + initial: tuple[str, ...], + requested: tuple[str, ...], +) -> None: + """A supplied list becomes exactly the stored and returned set.""" + client = admin_assignees.client + names = ("Ada", "Bo", "Cy") + ids = dict(zip(names, await _create_children(client, names))) + definition_id = await _create_definition( + client, [ids[name] for name in initial] + ) + wanted = {ids[name] for name in requested} + + response = await client.patch( + _edit_path(definition_id), + json={"assignee_child_ids": [ids[name] for name in requested]}, + headers=_admin_headers(), + ) + + assert response.status_code == 200 + assert _response_assignee_ids(response) == wanted + assert {a["display_name"] for a in response.json()["assignees"]} == set( + requested + ) + assert await _stored_assignee_ids( + admin_assignees.database, definition_id + ) == wanted + # The rest of the definition is untouched by an assignee-only edit. + assert response.json()["title"] == "Brush teeth" + assert [w["window"] for w in response.json()["windows"]] == ["morning"] + + +async def test_edit_with_assignees_and_other_fields_applies_both( + admin_assignees: SimpleNamespace, +) -> None: + """Assignees and metadata supplied together land in one edit.""" + client = admin_assignees.client + ada, bo = await _create_children(client, ("Ada", "Bo")) + definition_id = await _create_definition(client, [ada]) + + response = await client.patch( + _edit_path(definition_id), + json={"title": "Floss", "assignee_child_ids": [bo]}, + headers=_admin_headers(), + ) + + assert response.status_code == 200 + assert response.json()["title"] == "Floss" + assert _response_assignee_ids(response) == {bo} + dao = _dao_rules.QuestDefinitionsDao(admin_assignees.database) + assert (await dao.get(definition_id)).title == "Floss" + assert await _stored_assignee_ids( + admin_assignees.database, definition_id + ) == {bo} + + +# --- rejections leave the stored state unchanged ---------------------------- + + +@pytest.mark.parametrize( + ("case", "expected_detail"), + [ + ("empty", "assignee_child_ids must not be empty"), + ("unknown", "assignee_child_ids: child 999 does not exist"), + ("inactive", "assignee_child_ids: child {cy} is inactive"), + ("duplicate", "assignee_child_ids must not contain duplicate"), + ("null", "assignee_child_ids must be a list"), + ("mixed_unknown", "assignee_child_ids: child 999 does not exist"), + ("wrong_type", None), # the body model's own 422 + ], +) +async def test_rejected_assignee_list_is_422_and_changes_nothing( + admin_assignees: SimpleNamespace, + case: str, + expected_detail: str | None, +) -> None: + """Empty/unknown/inactive/malformed lists are 422 with no mutation. + + The same body also renames the definition, proving the whole edit + is all-or-nothing: neither the assignees nor the title change. + """ + client = admin_assignees.client + ada, bo, cy, di = await _create_children( + client, ("Ada", "Bo", "Cy", "Di") + ) + definition_id = await _create_definition(client, [ada, bo]) + deactivated = await client.patch( + f"/api/v1/admin/children/{cy}/active", + json={"is_active": False}, + headers=_admin_headers(), + ) + assert deactivated.status_code == 200 + + requested: object = { + "empty": [], + "unknown": [999], + "inactive": [ada, cy], + "duplicate": [ada, ada], + "null": None, + # A valid new child alongside an unknown one: di must NOT be + # assigned (all-or-nothing), and ada/bo must NOT be removed. + "mixed_unknown": [di, 999], + "wrong_type": ["Ada"], + }[case] + + response = await client.patch( + _edit_path(definition_id), + json={"title": "Renamed", "assignee_child_ids": requested}, + headers=_admin_headers(), + ) + + assert response.status_code == 422, case + if expected_detail is not None: + assert response.json()["detail"].startswith( + expected_detail.format(cy=cy) + ), response.json() + assert await _stored_assignee_ids( + admin_assignees.database, definition_id + ) == {ada, bo} + dao = _dao_rules.QuestDefinitionsDao(admin_assignees.database) + assert (await dao.get(definition_id)).title == "Brush teeth" + + +#: JSON ids the lax ``int`` parser would coerce (``true`` -> 1). +_COERCIBLE_IDS = [True, 1.0, "1"] + + +@pytest.mark.parametrize("coercible", _COERCIBLE_IDS) +async def test_edit_rejects_coercible_assignee_ids_and_changes_nothing( + admin_assignees: SimpleNamespace, coercible: object +) -> None: + """A bool, float or numeric-string id edit is 422; nothing changes.""" + client = admin_assignees.client + ada, bo = await _create_children(client, ("Ada", "Bo")) + # Assign bo only, so a coerced id 1 (ada) would be a visible change. + definition_id = await _create_definition(client, [bo]) + assert ada == 1 + + response = await client.patch( + _edit_path(definition_id), + json={"title": "Renamed", "assignee_child_ids": [coercible]}, + headers=_admin_headers(), + ) + + assert response.status_code == 422 + assert await _stored_assignee_ids( + admin_assignees.database, definition_id + ) == {bo} + dao = _dao_rules.QuestDefinitionsDao(admin_assignees.database) + assert (await dao.get(definition_id)).title == "Brush teeth" + + +@pytest.mark.parametrize("coercible", _COERCIBLE_IDS) +async def test_create_rejects_coercible_assignee_ids_and_persists_nothing( + admin_assignees: SimpleNamespace, coercible: object +) -> None: + """A bool, float or numeric-string id create is 422; nothing is written.""" + client = admin_assignees.client + ada, bo = await _create_children(client, ("Ada", "Bo")) + assert ada == 1 + existing_id = await _create_definition(client, [bo]) + + response = await client.post( + "/api/v1/admin/quest-definitions", + json={ + "title": "Tidy the den", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [coercible], + "windows": ["morning"], + }, + headers=_admin_headers(), + ) + + assert response.status_code == 422 + bundles = await _quest_definitions.list_active_definitions( + admin_assignees.database + ) + assert [bundle.definition.id for bundle in bundles] == [existing_id] + assert await _stored_assignee_ids( + admin_assignees.database, existing_id + ) == {bo} + + +async def test_rejected_assignees_on_unknown_definition_is_404( + admin_assignees: SimpleNamespace, +) -> None: + """An unknown definition id is still 404 when assignees are supplied.""" + client = admin_assignees.client + (ada,) = await _create_children(client, ("Ada",)) + response = await client.patch( + _edit_path(999), + json={"assignee_child_ids": [ada]}, + headers=_admin_headers(), + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Quest definition not found" + + +# --- omitted keeps ---------------------------------------------------------- + + +async def test_omitted_assignees_are_unchanged( + admin_assignees: SimpleNamespace, +) -> None: + """An edit without assignee_child_ids never touches the assignees.""" + client = admin_assignees.client + ada, bo = await _create_children(client, ("Ada", "Bo")) + definition_id = await _create_definition(client, [ada, bo]) + + response = await client.patch( + _edit_path(definition_id), + json={"title": "Floss"}, + headers=_admin_headers(), + ) + + assert response.status_code == 200 + assert _response_assignee_ids(response) == {ada, bo} + assert await _stored_assignee_ids( + admin_assignees.database, definition_id + ) == {ada, bo} + + +# --- regeneration ----------------------------------------------------------- + + +async def test_assignment_change_regenerates_future_instances( + admin_assignees: SimpleNamespace, +) -> None: + """Swapping the assignee moves the future open instances with it. + + The regeneration lives in the core edit path the route calls: the + definition's open instances at or after today are deleted and the + horizon re-materialized for the NEW assignee set. + """ + client = admin_assignees.client + database = admin_assignees.database + ada, bo = await _create_children(client, ("Ada", "Bo")) + definition_id = await _create_definition(client, [ada]) + + today = datetime.date.today() + start = today.isoformat() + end = (today + datetime.timedelta(days=7)).isoformat() + await _materialize.materialize(database, start, end, today=today) + instances = _dao_instances.QuestInstancesDao(database) + assert await instances.list_by_date_range(ada, start, end) + assert await instances.list_by_date_range(bo, start, end) == [] + + response = await client.patch( + _edit_path(definition_id), + json={"assignee_child_ids": [bo]}, + headers=_admin_headers(), + ) + + assert response.status_code == 200 + assert await instances.list_by_date_range(ada, start, end) == [] + regenerated = await instances.list_by_date_range(bo, start, end) + assert regenerated + assert {i.definition_id for i in regenerated} == {definition_id} + + +# --- authentication --------------------------------------------------------- + + +async def test_assignee_edit_auth_outcomes( + admin_assignees: SimpleNamespace, +) -> None: + """401 absent, 403 panel token, 403 non-admin, 200 admin.""" + client = admin_assignees.client + ada, bo = await _create_children(client, ("Ada", "Bo")) + definition_id = await _create_definition(client, [ada]) + body = {"assignee_child_ids": [bo]} + + absent = await client.patch(_edit_path(definition_id), json=body) + assert absent.status_code == 401 + + panel = await client.patch( + _edit_path(definition_id), + json=body, + headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}, + ) + assert panel.status_code == 403 + + non_admin_token = make_token(groups=("some-other-group",)) + non_admin = await client.patch( + _edit_path(definition_id), + json=body, + headers={"Authorization": f"Bearer {non_admin_token}"}, + ) + assert non_admin.status_code == 403 + + # None of the refused calls reached the handler. + assert await _stored_assignee_ids( + admin_assignees.database, definition_id + ) == {ada} + + admin = await client.patch( + _edit_path(definition_id), json=body, headers=_admin_headers() + ) + assert admin.status_code == 200 + assert await _stored_assignee_ids( + admin_assignees.database, definition_id + ) == {bo} -- 2.55.0 From b8532bacdedd015a814b67f60707f6614f223794 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 22:16:47 -0400 Subject: [PATCH 09/11] Feature 23: Admin API Completion - preview, presence, live (#199-#202) (#203) Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> --- api/routes_admin.py | 339 +++++++++++++ api/routes_panel.py | 37 +- api/sse.py | 50 ++ .../nestquest/core/dao_presence.py | 51 ++ .../nestquest/core/materialize.py | 219 +++++++-- .../nestquest/core/presence_management.py | 79 ++- tests/test_api_admin_occurrences.py | 431 +++++++++++++++++ tests/test_api_admin_presence_consequence.py | 448 ++++++++++++++++++ tests/test_api_admin_presence_reads.py | 345 ++++++++++++++ tests/test_api_admin_sse.py | 283 +++++++++++ tests/test_api_sse.py | 15 +- 11 files changed, 2226 insertions(+), 71 deletions(-) create mode 100644 api/sse.py create mode 100644 tests/test_api_admin_occurrences.py create mode 100644 tests/test_api_admin_presence_consequence.py create mode 100644 tests/test_api_admin_presence_reads.py create mode 100644 tests/test_api_admin_sse.py diff --git a/api/routes_admin.py b/api/routes_admin.py index 1c4de6b..2ad7874 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -58,6 +58,24 @@ layer the HA services share, so the two planes cannot drift: (children are never hard-deleted; overrides are deletable by design) and regenerates that override's child. It answers ``{"status": "ok"}``. +- ``POST /presence-overrides/consequence`` previews an override + BEFORE it is saved: ONE ``preview_presence_override_consequence`` + call answers ``{removed: N}``, the number of the child's open + upcoming instances the materialization walk would stop generating + (instances with a completion event are never counted, D-005). + Nothing is written; an unknown child is 404, a malformed or inverted + range 422. +- ``GET /children/{child_id}/presence-schedule`` reads the child's + schedule through ONE ``get_presence_schedule`` call, wrapped as + ``{"schedule": ...}`` in the PUT response's shape — ``null`` when the + child has none (present every day), never a 404; an unknown child is + 404. +- ``GET /presence-overrides`` lists the household's overrides through + ONE ``list_presence_overrides`` call, wrapped as + ``{"overrides": [...]}`` in the POST response's shape, ordered by + start date, child id, id; the optional ``child_id``, ``start`` and + ``end`` query filters (strict dates, ``end >= start``) are the + core's to validate. The uncomplete and regenerate routes (task da0226b3) follow the same pattern over :mod:`nestquest_core.completion` and @@ -172,6 +190,33 @@ integration's rollover listener shares: read-only-over-the-domain-tables guarantee all live in the core; the response reports how many transitions were published. +The occurrence-preview route (task 5f843564) lets the admin PWA's +Definitions edit sheet show the next dates a rule fires on WITHOUT +reimplementing the recurrence rules client-side: + +- ``POST /quest-definitions/occurrences-preview`` takes the SAME + ``rule`` object create/edit take (:class:`AdminRuleRequest`, built + through the SAME :func:`_rule_from_request` / core ``from_dict`` + validation, so a rejected rule is 422 exactly as on create), an + optional ``start_date`` (default: the API host's local ``today``, + ONE :func:`_local_now` clock read) and an optional ``count`` (1..50, + default 10). The dates come from ONE + :func:`nestquest_core.recurrence.occurrences_between` call — the + same engine the materializer walks — so the preview can only agree + with what would be generated. The scan is BOUNDED server-side: the + window is ``[start_date, start_date + 366 days]`` inclusive, + whatever the caller sends, so every rule shape (a yearly rule + included) shows at least its next occurrence, and a caller can never + ask for an unbounded walk. Any strict ``start_date`` is accepted + rather than clamped or range-limited — the fixed window already + bounds the work — EXCEPT one whose window would reach 9999-12-31, + which is 422 (the core's day walk cannot step past the calendar's + last date). ``start_date`` strictness + (``YYYY-MM-DD``) is the core's own date validator; a non-strict or + unparseable value is 422. A ``count`` outside 1..50 is the body + model's 422 before any handler code runs. Nothing is read or + written: the route touches no database. + Error mapping (every children, quest-definition and presence route, deliberately narrow): @@ -245,6 +290,16 @@ an omitted field is never passed to :func:`nestquest_core.children.edit_child` and stays unchanged — while an explicit ``null`` IS passed and is rejected by the core rather than silently ignored. + +The EVENTS route (``GET /events``) is the admin plane's live +transition stream for the Admin PWA: the SAME four transitions +(quest-completed, quest-uncompleted, quest-missed, child-day-complete) +off the SAME in-process publisher as the panel plane, framed by the +shared :func:`api.sse.transition_event_stream` helper. It inherits the +router's ONE :func:`~api.auth.require_admin` check (the Authorization +header — the PWA reads it with a fetch-based reader because +``EventSource`` cannot set headers), so the panel service token is +refused here exactly as on every other admin route. """ from __future__ import annotations @@ -252,6 +307,7 @@ import datetime from typing import Annotated, Literal, NoReturn from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi.responses import StreamingResponse from pydantic import ( BaseModel, StrictBool, @@ -280,6 +336,7 @@ from api.nestquest_core import ( core_snapshot, core_sweep, ) +from api.sse import transition_event_stream #: All admin-plane routes share this router; the ONE admin dependency #: (service-token refusal + JWT verification + nestquest-admins group @@ -452,6 +509,45 @@ class AdminRuleRequest(BaseModel): end_date: str | None = None +#: Upper bound on ``count`` for the occurrence-preview route. +_PREVIEW_MAX_COUNT = 50 + +#: Days after ``start_date`` the occurrence-preview window spans +#: (inclusive), so a yearly rule always shows its next occurrence. +_PREVIEW_WINDOW_DAYS = 366 + + +class AdminOccurrencesPreviewRequest(BaseModel): + """The body of ``POST /api/v1/admin/quest-definitions/occurrences-preview``. + + ``rule`` is the SAME object create/edit take. ``start_date`` is an + optional strict ``YYYY-MM-DD`` (validated by the core; default the + API host's today). ``count`` is a strict JSON integer in + 1..``_PREVIEW_MAX_COUNT`` — anything else is 422 before any handler + code runs. + """ + + rule: AdminRuleRequest + start_date: str | None = None + count: StrictInt = 10 + + @field_validator("count") + @classmethod + def _count_in_range(cls, value: int) -> int: + """Reject a count outside 1.._PREVIEW_MAX_COUNT.""" + if not 1 <= value <= _PREVIEW_MAX_COUNT: + raise ValueError( + f"count must be between 1 and {_PREVIEW_MAX_COUNT}" + ) + return value + + +class AdminOccurrencesPreviewResponse(BaseModel): + """The next occurrence dates (``YYYY-MM-DD``, ascending).""" + + dates: list[str] + + class AdminQuestDefinitionCreateRequest(BaseModel): """The body of ``POST /api/v1/admin/quest-definitions``. @@ -573,6 +669,50 @@ class AdminPresenceOverrideResponse(BaseModel): note: str | None +class AdminPresenceScheduleReadResponse(BaseModel): + """The ``GET /children/{child_id}/presence-schedule`` payload. + + ``schedule`` is ``None`` when the child has no schedule — the child + is present every day (Feature 05); absence is state, not an error. + """ + + schedule: AdminPresenceScheduleResponse | None + + +class AdminPresenceOverrideListResponse(BaseModel): + """The ``GET /presence-overrides`` payload: the matching overrides.""" + + overrides: list[AdminPresenceOverrideResponse] + + +class AdminPresenceOverrideConsequenceRequest(BaseModel): + """The body of ``POST /api/v1/admin/presence-overrides/consequence``. + + The create body's shape minus the note (which cannot change what is + generated): the strict dates, their order and the real + ``is_present`` bool are enforced by the SAME + :class:`~nestquest_core.presence.PresenceOverride` constructor the + create route's core path builds; this model only guards the SHAPE. + Strict fields: a JSON ``true`` is not child id 1, nor ``1`` a bool. + """ + + child_id: StrictInt + start_date: str + end_date: str + is_present: StrictBool + + +class AdminPresenceOverrideConsequenceResponse(BaseModel): + """The consequence preview: ``removed`` upcoming instances. + + The number of open (no completion event) upcoming quest instances + of the child that saving the override would remove — the "This + removes N upcoming tasks" warning. Always 0 for a present override. + """ + + removed: int + + class AdminUncompleteResponse(BaseModel): """The result of ``POST /api/v1/admin/instances/{id}/uncomplete``. @@ -938,6 +1078,40 @@ def _raise_sweep_error(error: ValueError) -> NoReturn: raise HTTPException(status_code=422, detail=str(error)) from error +def _raise_preview_error(error: ValueError) -> NoReturn: + """Map a core recurrence ``ValueError`` from the preview route to 422. + + The preview route has no path ids, so it has no 404 case: a + rejected rule (:class:`~nestquest_core.recurrence.RuleValidationError`) + or a non-strict ``start_date`` is 422 with the core's message as + the detail. Only ``ValueError`` is caught, so an unexpected + failure re-raises rather than becoming a 4xx. + """ + raise HTTPException(status_code=422, detail=str(error)) from error + + +def _preview_window_end(start_date: str) -> str: + """Return the preview window's inclusive end for ``start_date``. + + ``start_date`` + :data:`_PREVIEW_WINDOW_DAYS`. Parsing here only + locates the window; the STRICT ``YYYY-MM-DD`` check stays the + core's — :func:`~nestquest_core.recurrence.occurrences_between` + re-validates the same string and rejects any lenient form this + parse accepted. Raises ``ValueError`` for an unparseable value and + for a start whose window would reach the calendar's last date (the + core's day walk steps one day PAST its end, which overflows there). + """ + start = datetime.date.fromisoformat(start_date) + if datetime.date.max - start <= datetime.timedelta( + days=_PREVIEW_WINDOW_DAYS + ): + raise ValueError( + f"start_date {start_date!r} is too close to the end of the " + "calendar for a preview window" + ) + return (start + datetime.timedelta(days=_PREVIEW_WINDOW_DAYS)).isoformat() + + def _local_now() -> datetime.datetime: """Return the API host's local time as ONE timezone-aware clock read. @@ -1259,6 +1433,42 @@ async def admin_create_quest_definition( return _quest_definition_response(bundle) +@router.post( + "/quest-definitions/occurrences-preview", + summary="Preview the next occurrence dates of a schedule rule", + response_model=AdminOccurrencesPreviewResponse, +) +async def admin_preview_occurrences( + body: AdminOccurrencesPreviewRequest, +) -> AdminOccurrencesPreviewResponse: + """Return the first ``count`` dates the supplied rule fires on. + + Requires a valid admin JWT (the router's shared + :func:`~api.auth.require_admin` dependency — the ONE check; this + handler performs NO auth of its own). Thin adapter: the ``rule`` + becomes ONE :class:`~nestquest_core.recurrence.ScheduleRule` + (:func:`_rule_from_request`, the create/edit path), and the dates + come from ONE :func:`nestquest_core.recurrence.occurrences_between` + call over the bounded window ``[start_date, start_date + 366 + days]`` — ``start_date`` defaulting to ONE :func:`_local_now` clock + read — truncated to ``count``. Every core ``ValueError`` is 422 + (:func:`_raise_preview_error`). No database access. + """ + start_date = ( + body.start_date + if body.start_date is not None + else _local_now().date().isoformat() + ) + try: + rule = _rule_from_request(body.rule) + dates = core_recurrence.occurrences_between( + rule, start_date, _preview_window_end(start_date) + ) + except ValueError as error: + _raise_preview_error(error) + return AdminOccurrencesPreviewResponse(dates=dates[: body.count]) + + @router.patch( "/quest-definitions/{definition_id}", summary="Edit a quest definition", @@ -1445,6 +1655,114 @@ async def admin_delete_presence_override( return {"status": "ok"} +@router.post( + "/presence-overrides/consequence", + summary="Count the upcoming instances a presence override would remove", + response_model=AdminPresenceOverrideConsequenceResponse, +) +async def admin_presence_override_consequence( + body: AdminPresenceOverrideConsequenceRequest, request: Request +) -> AdminPresenceOverrideConsequenceResponse: + """Return how many upcoming instances saving the override removes. + + Requires a valid admin JWT (the router's shared + :func:`~api.auth.require_admin` dependency — the ONE check; this + handler performs NO auth of its own). Thin adapter: ONE + :func:`nestquest_core.presence_management.preview_presence_override_consequence` + call, anchored on ONE :func:`_local_now` clock read, which compares + the materialization walk's output for the child with and without + the proposed override and excludes instances that already have a + completion event (D-005). Nothing is written. + + Errors are mapped by :func:`_raise_child_error`: an unknown child + is 404; a malformed or inverted range is 422. + """ + state: DatabaseState = request.app.state.db + try: + removed = await ( + core_presence_management.preview_presence_override_consequence( + state.database, + body.child_id, + body.start_date, + body.end_date, + body.is_present, + today=_local_now().date(), + ) + ) + except ValueError as error: + _raise_child_error(error) + return AdminPresenceOverrideConsequenceResponse(removed=removed) + + +@router.get( + "/children/{child_id}/presence-schedule", + summary="Read a child's repeating presence schedule", + response_model=AdminPresenceScheduleReadResponse, +) +async def admin_get_presence_schedule( + child_id: int, request: Request +) -> AdminPresenceScheduleReadResponse: + """Return the child's presence schedule, or ``schedule: null``. + + Requires a valid admin JWT (the router's shared + :func:`~api.auth.require_admin` dependency — the ONE check; this + handler performs NO auth of its own). Thin adapter: one + :func:`nestquest_core.presence_management.get_presence_schedule` + call; a stored schedule is serialized exactly as the PUT response + is, and a child without one answers ``{"schedule": null}`` (present + every day). Errors are mapped by :func:`_raise_child_error`: an + unknown child is 404. + """ + state: DatabaseState = request.app.state.db + try: + schedule = await core_presence_management.get_presence_schedule( + state.database, child_id + ) + except ValueError as error: + _raise_child_error(error) + return AdminPresenceScheduleReadResponse( + schedule=( + None if schedule is None else _presence_schedule_response(schedule) + ) + ) + + +@router.get( + "/presence-overrides", + summary="List presence overrides", + response_model=AdminPresenceOverrideListResponse, +) +async def admin_list_presence_overrides( + request: Request, + child_id: int | None = None, + start: str | None = None, + end: str | None = None, +) -> AdminPresenceOverrideListResponse: + """Return the household's presence overrides, optionally filtered. + + Requires a valid admin JWT (the router's shared + :func:`~api.auth.require_admin` dependency — the ONE check; this + handler performs NO auth of its own). Thin adapter: the optional + ``child_id``, ``start`` and ``end`` query parameters go straight to + :func:`nestquest_core.presence_management.list_presence_overrides` + — the date policy (strict ``YYYY-MM-DD``, ``end >= start``) and the + overlap rule live in the core — and each record is serialized + exactly as the POST response is, ordered by start date, child id, + id. Errors are mapped by :func:`_raise_child_error`: an unknown + ``child_id`` is 404, a rejected date or range is 422. + """ + state: DatabaseState = request.app.state.db + try: + records = await core_presence_management.list_presence_overrides( + state.database, child_id=child_id, start=start, end=end + ) + except ValueError as error: + _raise_child_error(error) + return AdminPresenceOverrideListResponse( + overrides=[_presence_override_response(record) for record in records] + ) + + @router.post( "/instances/{instance_id}/uncomplete", summary="Reverse one completion (append a reversal event)", @@ -1899,4 +2217,25 @@ async def admin_snapshot(request: Request) -> AdminSnapshotResponse: ) + +@router.get( + "/events", + summary="Stream admin transition events (SSE)", +) +async def admin_events(request: Request) -> StreamingResponse: + """Stream the app's transition events to an admin client + (Server-Sent Events). + + Requires a valid admin JWT (the router's shared + :func:`~api.auth.require_admin` dependency — the ONE check; this + handler performs NO auth of its own), so an unauthenticated or + panel-token request is refused before any stream is established. + + Returns the shared :func:`api.sse.transition_event_stream` response + off the app's single in-process publisher (``app.state.publisher``) + — the same publisher, framing and teardown as the panel stream. + """ + return transition_event_stream(request) + + __all__ = ["router"] diff --git a/api/routes_panel.py b/api/routes_panel.py index 462458c..6405384 100644 --- a/api/routes_panel.py +++ b/api/routes_panel.py @@ -53,9 +53,6 @@ from __future__ import annotations import datetime import importlib -import json - -from collections.abc import AsyncIterator from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import StreamingResponse @@ -70,6 +67,7 @@ from api.nestquest_core import ( 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. @@ -328,23 +326,6 @@ async def panel_complete_instance( return {"status": str(result)} -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: \\ndata: \\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" - ) - - @router.get( "/events", summary="Stream panel transition events (SSE)", @@ -357,17 +338,13 @@ async def panel_events(request: Request) -> StreamingResponse: :func:`~api.dependencies.require_panel_token` dependency — the ONE token check; this handler performs NO auth of its own). - Returns a ``text/event-stream`` response whose body is produced by - :func:`_sse_stream` 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. + 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 StreamingResponse( - _sse_stream(request), - media_type="text/event-stream", - headers={"Cache-Control": "no-cache"}, - ) + return transition_event_stream(request) __all__ = ["router"] diff --git a/api/sse.py b/api/sse.py new file mode 100644 index 0000000..495a6c7 --- /dev/null +++ b/api/sse.py @@ -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: \\ndata: \\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"}, + ) diff --git a/custom_components/nestquest/core/dao_presence.py b/custom_components/nestquest/core/dao_presence.py index c273287..8aa5020 100644 --- a/custom_components/nestquest/core/dao_presence.py +++ b/custom_components/nestquest/core/dao_presence.py @@ -270,6 +270,57 @@ class PresenceOverridesDao: ) 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( diff --git a/custom_components/nestquest/core/materialize.py b/custom_components/nestquest/core/materialize.py index fee785d..ffeeefa 100644 --- a/custom_components/nestquest/core/materialize.py +++ b/custom_components/nestquest/core/materialize.py @@ -64,9 +64,14 @@ the presence DAO (``dao_presence.py``) must stay free of business rules from __future__ import annotations import datetime +from typing import NamedTuple from .const import DEFAULT_HORIZON_DAYS -from .dao_instances import QuestInstancesDao, _resolve_today +from .dao_instances import ( + CompletionEventsDao, + QuestInstancesDao, + _resolve_today, +) from .dao_rules import ( _validate_date, load_materialization_input, @@ -126,8 +131,15 @@ def _decode_rule(snapshot) -> ScheduleRule: def _build_engine( schedules_records, overrides_records, + *, + extra_overrides: tuple[PresenceOverride, ...] = (), ) -> PresenceEngine: - """Convert raw presence records into an immutable 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( @@ -145,9 +157,75 @@ def _build_engine( ) 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, @@ -207,13 +285,6 @@ async def materialize( engine = _build_engine(schedules_records, overrides_records) child_filter = None if child_ids is None else set(child_ids) - definitions = [ - (snapshot.definition.id, _decode_rule(snapshot), - snapshot.assignees, snapshot.windows, - snapshot.definition.skip_on_away) - for snapshot in snapshots - ] - generated_at = _now_stamp() instances = QuestInstancesDao(database) @@ -228,32 +299,20 @@ async def materialize( start = max(start, today_date) count = 0 - 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: - written = await instances.upsert_if_valid( - definition_id, - child.id, - iso, - generated_at, - window=window.window, - due_time=window.due_time, - today=today, - ) - if written is not None: - count += 1 - cursor += datetime.timedelta(days=1) + 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 @@ -379,3 +438,93 @@ async def regenerate_for_child( 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 + ) diff --git a/custom_components/nestquest/core/presence_management.py b/custom_components/nestquest/core/presence_management.py index 2b48edb..fea6189 100644 --- a/custom_components/nestquest/core/presence_management.py +++ b/custom_components/nestquest/core/presence_management.py @@ -22,18 +22,23 @@ 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 regenerate_for_child +from .materialize import count_removed_by_override, regenerate_for_child from .presence import PresenceOverride, PresenceSchedule @@ -130,6 +135,78 @@ async def create_presence_override( 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, diff --git a/tests/test_api_admin_occurrences.py b/tests/test_api_admin_occurrences.py new file mode 100644 index 0000000..58cef8b --- /dev/null +++ b/tests/test_api_admin_occurrences.py @@ -0,0 +1,431 @@ +"""Admin-plane occurrence-preview route tests (task 5f843564). + +These tests exercise ``POST /api/v1/admin/quest-definitions/occurrences-preview`` +against the ASGI app with httpx, reusing the shared local-RSA-key/ +stubbed-JWKS harness (:mod:`tests.admin_jwt_harness`) so an admin JWT +genuinely authenticates with NO network access. + +- Authentication runs through the router's ONE ``require_admin`` + dependency: no credential is 401, the panel service token is 403 + (refused as a credential type), a JWT without the admin group is + 403, an admin JWT is 200. +- The returned dates are asserted EXACTLY for a weekly rule with an + interval and a start_date, a daily rule with an interval, a rule + with an end_date (the list stops at it), a month-end-clamped monthly + rule, and the default start (the pinned host clock's today); one + case also asserts parity with the core's own ``occurrences_between`` + over the documented window. +- A rejected rule (the same core ``from_dict`` validation create/edit + use), a non-strict ``start_date`` and a ``count`` outside 1..50 are + 422. +""" +from __future__ import annotations + +import datetime +import importlib +from types import SimpleNamespace +from zoneinfo import ZoneInfo + +import pytest + +from api import routes_admin +from tests.admin_jwt_harness import ( + ADMIN_KEY, + KID, + AdminRunner, + jwks_for, + make_token, +) + +# The recurrence engine through the SAME core copy the app uses +# (nestquest_core.*, NOT the conftest-stubbed custom_components copy). +_recurrence = importlib.import_module("nestquest_core.recurrence") + +PREVIEW_URL = "/api/v1/admin/quest-definitions/occurrences-preview" + +#: The panel service token the harness app is configured with. +HARNESS_PANEL_TOKEN = "admin-test-panel-token" + +#: The 403 detail for the panel service token on an admin route. +PANEL_TOKEN_REJECTION_DETAIL = ( + "This credential type is not accepted for admin access" +) + +#: The 403 detail for a valid JWT without the admin group. +FORBIDDEN_DETAIL = ( + "Admin access requires membership in the nestquest-admins group" +) + +#: The pinned host instant: Wednesday 2026-09-23, noon UTC. +PINNED_NOW = datetime.datetime(2026, 9, 23, 12, 0, 0, tzinfo=ZoneInfo("UTC")) + + +def _admin_headers() -> dict[str, str]: + """The Authorization header of a valid nestquest-admins JWT.""" + return {"Authorization": f"Bearer {make_token()}"} + + +def _weekly_rule() -> dict[str, object]: + """Every OTHER week's Tuesday and Friday, anchored on Tue 2026-03-03.""" + return { + "rule_type": "weekly", + "interval": 2, + "weekday_set": [1, 4], + "start_date": "2026-03-03", + } + + +@pytest.fixture +async def preview(temp_db_path: str, monkeypatch) -> SimpleNamespace: + """An admin-plane app client with the host clock pinned (and counted).""" + clock_reads: list[datetime.datetime] = [] + + def _admin_now() -> datetime.datetime: + clock_reads.append(PINNED_NOW) + return PINNED_NOW + + monkeypatch.setattr(routes_admin, "_local_now", _admin_now) + runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) + async with runner as client: + yield SimpleNamespace(client=client, clock_reads=clock_reads) + + +async def _post(client: object, body: dict[str, object]) -> object: + """POST a preview body with an admin JWT.""" + return await client.post(PREVIEW_URL, json=body, headers=_admin_headers()) + + +# --- authentication: the ONE require_admin dependency ---------------------- + + +async def test_preview_without_credential_is_401( + preview: SimpleNamespace, +) -> None: + """No Authorization header is the uniform 401.""" + response = await preview.client.post( + PREVIEW_URL, json={"rule": _weekly_rule()} + ) + assert response.status_code == 401 + + +async def test_preview_refuses_panel_service_token( + preview: SimpleNamespace, +) -> None: + """The panel service token is refused as a credential type (403).""" + response = await preview.client.post( + PREVIEW_URL, + json={"rule": _weekly_rule()}, + headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}, + ) + assert response.status_code == 403 + assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL + + +async def test_preview_refuses_non_admin_jwt( + preview: SimpleNamespace, +) -> None: + """A valid JWT without the nestquest-admins group is 403.""" + non_admin = make_token(groups=("some-other-group",)) + response = await preview.client.post( + PREVIEW_URL, + json={"rule": _weekly_rule()}, + headers={"Authorization": f"Bearer {non_admin}"}, + ) + assert response.status_code == 403 + assert response.json()["detail"] == FORBIDDEN_DETAIL + + +async def test_preview_admin_jwt_is_200(preview: SimpleNamespace) -> None: + """An admin JWT is 200 with the documented ``dates`` shape.""" + response = await _post( + preview.client, {"rule": _weekly_rule(), "start_date": "2026-03-01"} + ) + assert response.status_code == 200 + assert set(response.json()) == {"dates"} + + +# --- the returned dates ---------------------------------------------------- + + +async def test_weekly_interval_rule_from_before_its_start( + preview: SimpleNamespace, +) -> None: + """Every other week's Tue/Fri, starting at the rule's own start_date. + + The preview starts on Sun 2026-03-01, before the rule's anchor, so + the first date is the anchor itself; the off weeks (Mar 10-16, + Mar 24-30) are skipped. + """ + response = await _post( + preview.client, + {"rule": _weekly_rule(), "start_date": "2026-03-01", "count": 6}, + ) + assert response.status_code == 200 + assert response.json()["dates"] == [ + "2026-03-03", + "2026-03-06", + "2026-03-17", + "2026-03-20", + "2026-03-31", + "2026-04-03", + ] + + +async def test_weekly_interval_rule_from_mid_cadence( + preview: SimpleNamespace, +) -> None: + """A start inside an on-week keeps the rule's anchor cadence.""" + response = await _post( + preview.client, + {"rule": _weekly_rule(), "start_date": "2026-03-18", "count": 4}, + ) + assert response.status_code == 200 + assert response.json()["dates"] == [ + "2026-03-20", + "2026-03-31", + "2026-04-03", + "2026-04-14", + ] + + +async def test_daily_interval_rule(preview: SimpleNamespace) -> None: + """Every third day from 2026-01-01, previewed from 2026-01-05.""" + response = await _post( + preview.client, + { + "rule": { + "rule_type": "daily", + "interval": 3, + "start_date": "2026-01-01", + }, + "start_date": "2026-01-05", + "count": 4, + }, + ) + assert response.status_code == 200 + assert response.json()["dates"] == [ + "2026-01-07", + "2026-01-10", + "2026-01-13", + "2026-01-16", + ] + + +async def test_rule_with_end_date_stops_at_it( + preview: SimpleNamespace, +) -> None: + """A daily rule ending 2026-05-04 yields only its four dates.""" + response = await _post( + preview.client, + { + "rule": { + "rule_type": "daily", + "start_date": "2026-05-01", + "end_date": "2026-05-04", + }, + "start_date": "2026-04-28", + "count": 10, + }, + ) + assert response.status_code == 200 + assert response.json()["dates"] == [ + "2026-05-01", + "2026-05-02", + "2026-05-03", + "2026-05-04", + ] + + +async def test_monthly_day_rule_clamps_to_month_end( + preview: SimpleNamespace, +) -> None: + """The core's month-end policy: the 31st fires on Feb 28th.""" + response = await _post( + preview.client, + { + "rule": { + "rule_type": "monthly_day", + "day_of_month": 31, + "start_date": "2026-01-31", + }, + "start_date": "2026-01-01", + "count": 3, + }, + ) + assert response.status_code == 200 + assert response.json()["dates"] == [ + "2026-01-31", + "2026-02-28", + "2026-03-31", + ] + + +async def test_default_start_is_host_today_with_default_count( + preview: SimpleNamespace, +) -> None: + """No start_date: ONE clock read's date; no count: ten dates.""" + response = await _post(preview.client, {"rule": {"rule_type": "daily"}}) + assert response.status_code == 200 + expected = [ + (datetime.date(2026, 9, 23) + datetime.timedelta(days=n)).isoformat() + for n in range(10) + ] + assert response.json()["dates"] == expected + assert preview.clock_reads == [PINNED_NOW] + + +async def test_supplied_start_date_reads_no_clock( + preview: SimpleNamespace, +) -> None: + """A supplied start_date is used as-is: the host clock is not read.""" + response = await _post( + preview.client, {"rule": _weekly_rule(), "start_date": "2026-03-01"} + ) + assert response.status_code == 200 + assert preview.clock_reads == [] + + +async def test_dates_match_core_occurrences_between_over_window( + preview: SimpleNamespace, +) -> None: + """The route's dates ARE the core's walk over the 366-day window.""" + rule_body = { + "rule_type": "monthly_weekday", + "nth_weekday": -1, + "nth_weekday_weekday": 4, + "start_date": "2026-01-01", + } + response = await _post( + preview.client, + {"rule": rule_body, "start_date": "2026-02-10", "count": 50}, + ) + assert response.status_code == 200 + core_dates = _recurrence.occurrences_between( + _recurrence.ScheduleRule.from_dict(rule_body), + "2026-02-10", + "2027-02-11", + ) + assert response.json()["dates"] == core_dates + assert len(core_dates) == 12 + + +async def test_yearly_rule_shows_next_occurrence_within_window( + preview: SimpleNamespace, +) -> None: + """The 366-day window always reaches a yearly rule's next date.""" + response = await _post( + preview.client, + { + "rule": { + "rule_type": "yearly", + "month": 9, + "day_of_month": 22, + "start_date": "2020-09-22", + }, + "start_date": "2026-09-23", + }, + ) + assert response.status_code == 200 + assert response.json()["dates"] == ["2027-09-22"] + + +async def test_start_near_calendar_end_is_422( + preview: SimpleNamespace, +) -> None: + """A window that would reach 9999-12-31 is rejected, not clamped.""" + response = await _post( + preview.client, + {"rule": {"rule_type": "daily"}, "start_date": "9999-12-30"}, + ) + assert response.status_code == 422 + assert "too close to the end of the calendar" in response.json()["detail"] + + +async def test_latest_accepted_start_date(preview: SimpleNamespace) -> None: + """The last start whose window ends before 9999-12-31 is accepted.""" + response = await _post( + preview.client, + { + "rule": {"rule_type": "daily"}, + "start_date": "9998-12-29", + "count": 1, + }, + ) + assert response.status_code == 200 + assert response.json()["dates"] == ["9998-12-29"] + + +# --- rejections: 422 ------------------------------------------------------- + + +@pytest.mark.parametrize( + "rule", + [ + {"rule_type": "weekly"}, # weekly without weekday_set + {"rule_type": "fortnightly"}, # unknown rule_type + {"rule_type": "daily", "interval": 0}, + {"rule_type": "daily", "weekday_set": [1]}, # forbidden field + {"rule_type": "daily", "start_date": "2026-3-1"}, + { + "rule_type": "daily", + "start_date": "2026-05-04", + "end_date": "2026-05-01", + }, + ], +) +async def test_malformed_rule_is_422( + preview: SimpleNamespace, rule: dict[str, object] +) -> None: + """A rule the core's from_dict rejects is 422, as on create/edit.""" + response = await _post( + preview.client, {"rule": rule, "start_date": "2026-03-01"} + ) + assert response.status_code == 422 + + +async def test_missing_rule_is_422(preview: SimpleNamespace) -> None: + """The rule is required: the body model's 422.""" + response = await _post(preview.client, {"start_date": "2026-03-01"}) + assert response.status_code == 422 + + +@pytest.mark.parametrize( + "start_date", ["2026-3-1", "20260301", "tomorrow", "2026-02-30", ""] +) +async def test_non_strict_start_date_is_422( + preview: SimpleNamespace, start_date: str +) -> None: + """start_date must be a strict YYYY-MM-DD date (the core's check).""" + response = await _post( + preview.client, {"rule": _weekly_rule(), "start_date": start_date} + ) + assert response.status_code == 422 + + +@pytest.mark.parametrize("count", [0, -1, 51, 1000, True, "5", 2.5]) +async def test_count_out_of_range_is_422( + preview: SimpleNamespace, count: object +) -> None: + """count must be a strict JSON integer in 1..50.""" + response = await _post( + preview.client, + {"rule": _weekly_rule(), "start_date": "2026-03-01", "count": count}, + ) + assert response.status_code == 422 + + +@pytest.mark.parametrize("count", [1, 50]) +async def test_count_bounds_are_inclusive( + preview: SimpleNamespace, count: int +) -> None: + """count 1 and 50 are accepted and truncate the list exactly.""" + response = await _post( + preview.client, + { + "rule": {"rule_type": "daily"}, + "start_date": "2026-01-01", + "count": count, + }, + ) + assert response.status_code == 200 + assert len(response.json()["dates"]) == count diff --git a/tests/test_api_admin_presence_consequence.py b/tests/test_api_admin_presence_consequence.py new file mode 100644 index 0000000..4bdaac4 --- /dev/null +++ b/tests/test_api_admin_presence_consequence.py @@ -0,0 +1,448 @@ +"""Admin override consequence-count route tests (task 7616e3eb). + +``POST /api/v1/admin/presence-overrides/consequence`` answers, BEFORE an +override is saved, how many of the child's upcoming open quest +instances saving it would remove (``{"removed": N}``) — the ADMIN-SPEC +§6 "This removes N upcoming tasks" warning. These tests exercise it +against the ASGI app with the shared stubbed-JWKS harness +(:mod:`tests.admin_jwt_harness`): + +- a two-week custody pattern (present the whole first week of the + cycle, absent the whole second) where an absent override over a + known span removes an EXACT number of instances; +- ranges that remove nothing (already-absent days, a present + override) count 0; +- an instance with a completion event inside the range is NOT counted + (D-005 — regeneration never deletes it); +- a ``skip_on_away = false`` definition contributes nothing; +- the count MATCHES a direct materialise comparison: the override is + then actually created (which regenerates the child) and the removed + open future instances are counted from the database; +- the preview writes nothing; +- the four auth outcomes, an unknown child 404 and a malformed range + 422. + +The cycle is anchored on the REAL host "today" (the create route's +regeneration reads the host clock, so the comparison test cannot pin +it): days today..today+6 are cycle week 0 (present every day), days +today+7..today+13 week 1 (absent every day), today+14 week 0 again. +""" +from __future__ import annotations + +import datetime +import importlib +from types import SimpleNamespace + +import pytest + +from tests.admin_jwt_harness import ( + ADMIN_KEY, + KID, + AdminRunner, + jwks_for, + make_token, +) + +# The DAOs and materializer through the SAME core copy the app uses +# (nestquest_core.*, NOT the conftest-stubbed custom_components copy). +_dao_instances = importlib.import_module("nestquest_core.dao_instances") +_dao_presence = importlib.import_module("nestquest_core.dao_presence") +_materialize = importlib.import_module("nestquest_core.materialize") + +CONSEQUENCE_URL = "/api/v1/admin/presence-overrides/consequence" +OVERRIDES_URL = "/api/v1/admin/presence-overrides" + +#: The panel service token the harness app is configured with. +HARNESS_PANEL_TOKEN = "admin-test-panel-token" + +#: The 403 detail for the panel service token on an admin route. +PANEL_TOKEN_REJECTION_DETAIL = ( + "This credential type is not accepted for admin access" +) + +#: The 403 detail for a valid JWT without the admin group. +FORBIDDEN_DETAIL = ( + "Admin access requires membership in the nestquest-admins group" +) + +#: The two-week custody pattern: week 0 every weekday, week 1 none. +CUSTODY_PATTERN = {"0": [0, 1, 2, 3, 4, 5, 6], "1": []} + + +def _admin_headers() -> dict[str, str]: + """The Authorization header of a valid nestquest-admins JWT.""" + return {"Authorization": f"Bearer {make_token()}"} + + +def _day(offset: int) -> str: + """The ISO date ``offset`` days after the host's today.""" + return (datetime.date.today() + datetime.timedelta(days=offset)).isoformat() + + +def _body( + child_id: int, start: int, end: int, *, is_present: bool = False +) -> dict[str, object]: + """A consequence body over ``[today+start, today+end]``.""" + return { + "child_id": child_id, + "start_date": _day(start), + "end_date": _day(end), + "is_present": is_present, + } + + +async def _create_child(client: object, name: str = "Ada") -> int: + created = await client.post( + "/api/v1/admin/children", + json={"display_name": name}, + headers=_admin_headers(), + ) + assert created.status_code == 201 + return created.json()["id"] + + +async def _create_daily_definition( + client: object, + child_id: int, + title: str, + windows: list[str], + *, + skip_on_away: bool = True, +) -> int: + created = await client.post( + "/api/v1/admin/quest-definitions", + json={ + "title": title, + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [child_id], + "windows": windows, + "skip_on_away": skip_on_away, + }, + headers=_admin_headers(), + ) + assert created.status_code == 201 + return created.json()["id"] + + +async def _set_custody_schedule(client: object, child_id: int) -> None: + """Anchor the two-week custody cycle on today (week 0 starts today).""" + response = await client.put( + f"/api/v1/admin/children/{child_id}/presence-schedule", + json={ + "cycle_length_weeks": 2, + "anchor_date": _day(0), + "pattern": CUSTODY_PATTERN, + }, + headers=_admin_headers(), + ) + assert response.status_code == 200 + + +async def _materialize_horizon(database: object) -> None: + """Materialize the household horizon so the stored state is current.""" + today = datetime.date.today() + await _materialize.materialize( + database, _day(0), _day(14), today=today + ) + + +async def _open_future_keys(database: object, child_id: int) -> set[tuple]: + """The child's stored open (no completion event) future instance keys.""" + instances = await _dao_instances.QuestInstancesDao( + database + ).list_by_date_range(child_id, _day(0), _day(60)) + events = _dao_instances.CompletionEventsDao(database) + keys = set() + for instance in instances: + if not await events.list_by_instance(instance.id): + keys.add( + (instance.definition_id, instance.due_date, instance.window) + ) + return keys + + +async def _complete( + database: object, child_id: int, definition_id: int, due_date: str, + window: str, +) -> None: + """Append a completion event to the stored instance.""" + instance = await _dao_instances.QuestInstancesDao(database).get( + definition_id, child_id, due_date, window + ) + assert instance is not None + await _dao_instances.CompletionEventsDao(database).append( + instance.id, + child_id, + "completed", + "user", + datetime.datetime.now(datetime.timezone.utc).isoformat( + timespec="seconds" + ), + True, + actor_user_id="admin-user-1", + ) + + +async def _consequence(client: object, body: dict[str, object]) -> int: + response = await client.post( + CONSEQUENCE_URL, json=body, headers=_admin_headers() + ) + assert response.status_code == 200, response.text + return response.json()["removed"] + + +@pytest.fixture +async def custody(temp_db_path: str) -> SimpleNamespace: + """A child on the two-week custody cycle with a two-window chore. + + Also assigns a ``skip_on_away = false`` daily chore (generated + regardless of presence) and materializes the horizon, so the stored + instances are the walk's current output. + """ + runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) + async with runner as client: + database = runner.app.state.db.database + child_id = await _create_child(client) + chore_id = await _create_daily_definition( + client, child_id, "Brush teeth", ["morning", "evening"] + ) + always_id = await _create_daily_definition( + client, child_id, "Feed the fish", ["afternoon"], + skip_on_away=False, + ) + await _set_custody_schedule(client, child_id) + await _materialize_horizon(database) + yield SimpleNamespace( + client=client, + database=database, + child_id=child_id, + chore_id=chore_id, + always_id=always_id, + ) + + +# --- the count --------------------------------------------------------------- + + +async def test_custody_override_removes_a_known_number( + custody: SimpleNamespace, +) -> None: + """Absent over today+2..today+8 removes days +2..+6 x two windows. + + Days +7 and +8 are already absent (cycle week 1), and the + skip_on_away=false chore is generated regardless of presence, so + exactly 5 present days x 2 windows = 10 instances would go. + """ + removed = await _consequence( + custody.client, _body(custody.child_id, 2, 8) + ) + assert removed == 10 + + +async def test_already_absent_range_removes_nothing( + custody: SimpleNamespace, +) -> None: + """An absent override over cycle week 1 changes nothing: 0.""" + assert await _consequence( + custody.client, _body(custody.child_id, 7, 13) + ) == 0 + + +async def test_present_override_removes_nothing( + custody: SimpleNamespace, +) -> None: + """A present override only ever adds instances: 0.""" + assert await _consequence( + custody.client, _body(custody.child_id, 2, 8, is_present=True) + ) == 0 + + +async def test_past_range_removes_nothing(custody: SimpleNamespace) -> None: + """A range entirely before today touches no upcoming instance: 0.""" + assert await _consequence( + custody.client, _body(custody.child_id, -20, -1) + ) == 0 + + +async def test_completed_instance_in_range_is_not_counted( + custody: SimpleNamespace, +) -> None: + """D-005: an instance with a completion event is never removed.""" + await _complete( + custody.database, custody.child_id, custody.chore_id, _day(3), + "morning", + ) + removed = await _consequence( + custody.client, _body(custody.child_id, 2, 8) + ) + assert removed == 9 + + +async def test_skip_on_away_false_definition_contributes_nothing( + temp_db_path: str, +) -> None: + """A child whose only chore ignores presence: absent removes 0.""" + runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) + async with runner as client: + database = runner.app.state.db.database + child_id = await _create_child(client) + await _create_daily_definition( + client, child_id, "Feed the fish", ["morning"], + skip_on_away=False, + ) + await _set_custody_schedule(client, child_id) + await _materialize_horizon(database) + assert await _consequence(client, _body(child_id, 0, 14)) == 0 + + +async def test_count_matches_a_direct_materialise_comparison( + custody: SimpleNamespace, +) -> None: + """Saving the override removes EXACTLY the previewed instances. + + With a completed instance inside the range, the preview is taken, + then the override is really created (its core path regenerates the + child) and the open future instances that disappeared are counted + from the database. The completed instance survives the regeneration. + """ + client, database = custody.client, custody.database + await _complete( + database, custody.child_id, custody.chore_id, _day(4), "evening" + ) + body = _body(custody.child_id, 1, 9) + removed = await _consequence(client, body) + + before = await _open_future_keys(database, custody.child_id) + created = await client.post( + OVERRIDES_URL, json=body, headers=_admin_headers() + ) + assert created.status_code == 201 + after = await _open_future_keys(database, custody.child_id) + + assert after <= before + assert len(before - after) == removed + # Days +1..+6 present x two windows, minus the completed one. + assert removed == 11 + completed = await _dao_instances.QuestInstancesDao(database).get( + custody.chore_id, custody.child_id, _day(4), "evening" + ) + assert completed is not None + + +async def test_preview_writes_nothing(custody: SimpleNamespace) -> None: + """No override is stored and no instance is removed by the preview.""" + client, database = custody.client, custody.database + before = await _open_future_keys(database, custody.child_id) + assert await _consequence(client, _body(custody.child_id, 0, 14)) > 0 + assert await _open_future_keys(database, custody.child_id) == before + overrides = await _dao_presence.PresenceOverridesDao( + database + ).list_by_child_and_range(custody.child_id, "2000-01-01", "2100-01-01") + assert overrides == [] + + +# --- errors ------------------------------------------------------------------ + + +async def test_unknown_child_is_404(custody: SimpleNamespace) -> None: + response = await custody.client.post( + CONSEQUENCE_URL, json=_body(9999, 1, 2), headers=_admin_headers() + ) + assert response.status_code == 404 + + +@pytest.mark.parametrize( + "overrides", + [ + {"start_date": "2026-02-30"}, + {"end_date": "2026-1-5"}, + {"start_date": "2026-03-10", "end_date": "2026-03-01"}, + {"is_present": "sometimes"}, + {"child_id": "one"}, + ], +) +async def test_malformed_range_or_status_is_422( + custody: SimpleNamespace, overrides: dict[str, object] +) -> None: + body = {**_body(custody.child_id, 1, 2), **overrides} + response = await custody.client.post( + CONSEQUENCE_URL, json=body, headers=_admin_headers() + ) + assert response.status_code == 422 + + +@pytest.mark.parametrize( + "overrides", + [ + {"child_id": True}, + {"child_id": False}, + {"child_id": "1"}, + {"child_id": 1.0}, + {"is_present": 1}, + {"is_present": 0}, + {"is_present": "true"}, + {"is_present": "false"}, + ], +) +async def test_coercible_child_id_or_status_is_422( + custody: SimpleNamespace, overrides: dict[str, object] +) -> None: + """Strict fields: a bool id or a numeric/string status is rejected. + + The fixture child's id is 1, so a coerced ``true`` would otherwise + target it and return 200. Nothing is stored and nothing removed. + """ + assert custody.child_id == 1 + database = custody.database + before = await _open_future_keys(database, custody.child_id) + body = {**_body(custody.child_id, 0, 14), **overrides} + response = await custody.client.post( + CONSEQUENCE_URL, json=body, headers=_admin_headers() + ) + assert response.status_code == 422 + assert await _open_future_keys(database, custody.child_id) == before + overrides_stored = await _dao_presence.PresenceOverridesDao( + database + ).list_by_child_and_range(custody.child_id, "2000-01-01", "2100-01-01") + assert overrides_stored == [] + + +# --- authentication: the router's ONE require_admin -------------------------- + + +async def test_no_credential_is_401(custody: SimpleNamespace) -> None: + response = await custody.client.post( + CONSEQUENCE_URL, json=_body(custody.child_id, 1, 2) + ) + assert response.status_code == 401 + + +async def test_panel_service_token_is_403(custody: SimpleNamespace) -> None: + response = await custody.client.post( + CONSEQUENCE_URL, + json=_body(custody.child_id, 1, 2), + headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}, + ) + assert response.status_code == 403 + assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL + + +async def test_non_admin_jwt_is_403(custody: SimpleNamespace) -> None: + non_admin = make_token(groups=("some-other-group",)) + response = await custody.client.post( + CONSEQUENCE_URL, + json=_body(custody.child_id, 1, 2), + headers={"Authorization": f"Bearer {non_admin}"}, + ) + assert response.status_code == 403 + assert response.json()["detail"] == FORBIDDEN_DETAIL + + +async def test_admin_jwt_is_200(custody: SimpleNamespace) -> None: + response = await custody.client.post( + CONSEQUENCE_URL, + json=_body(custody.child_id, 1, 2), + headers=_admin_headers(), + ) + assert response.status_code == 200 + assert response.json() == {"removed": 4} diff --git a/tests/test_api_admin_presence_reads.py b/tests/test_api_admin_presence_reads.py new file mode 100644 index 0000000..9ab1e88 --- /dev/null +++ b/tests/test_api_admin_presence_reads.py @@ -0,0 +1,345 @@ +"""Admin-plane presence READ route tests (task 07fde892). + +``GET /api/v1/admin/children/{child_id}/presence-schedule`` and +``GET /api/v1/admin/presence-overrides`` against the ASGI app with +httpx, through the shared stubbed-JWKS harness +(:mod:`tests.admin_jwt_harness`) — no network access. + +- The schedule read answers ``{"schedule": {...}}`` in the PUT + response's shape for a seeded schedule, ``{"schedule": null}`` for a + child with none (present every day — NOT a 404), and 404 for an + unknown child. +- The overrides read answers ``{"overrides": [...]}`` in the POST + response's shape, ordered by start date, child id, id; ``child_id`` + and the ``start``/``end`` range each narrow the list; a malformed or + inverted range is 422 and an unknown ``child_id`` is 404. +- Both routes inherit the router's ONE ``require_admin``: no + credential 401, the panel service token 403, a non-admin JWT 403, an + admin JWT 200. + +Seeding goes through the admin write routes, so the reads are proven +against rows the real write path stored. +""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from tests.admin_jwt_harness import ADMIN_KEY, KID, AdminRunner, jwks_for, make_token + +OVERRIDES_URL = "/api/v1/admin/presence-overrides" + +#: The panel service token the harness app is configured with. +HARNESS_PANEL_TOKEN = "admin-test-panel-token" + +#: The 403 detail for the panel service token on an admin route. +PANEL_TOKEN_REJECTION_DETAIL = ( + "This credential type is not accepted for admin access" +) + +#: The 403 detail for a valid JWT without the admin group. +FORBIDDEN_DETAIL = ( + "Admin access requires membership in the nestquest-admins group" +) + + +def _schedule_url(child_id: int) -> str: + return f"/api/v1/admin/children/{child_id}/presence-schedule" + + +def _admin_headers() -> dict[str, str]: + """The Authorization header of a valid nestquest-admins JWT.""" + return {"Authorization": f"Bearer {make_token()}"} + + +async def _create_child(client: object, name: str) -> int: + """Create one child through the admin children route; return the id.""" + created = await client.post( + "/api/v1/admin/children", + json={"display_name": name}, + headers=_admin_headers(), + ) + assert created.status_code == 201 + return created.json()["id"] + + +async def _create_override( + client: object, + child_id: int, + start_date: str, + end_date: str, + *, + is_present: bool = False, + note: str | None = None, +) -> dict[str, object]: + """Create one override through the admin write route; return it.""" + created = await client.post( + OVERRIDES_URL, + json={ + "child_id": child_id, + "start_date": start_date, + "end_date": end_date, + "is_present": is_present, + "note": note, + }, + headers=_admin_headers(), + ) + assert created.status_code == 201, created.text + return created.json() + + +@pytest.fixture +async def reads(temp_db_path: str) -> SimpleNamespace: + """An admin-plane client over a fresh database.""" + runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) + async with runner as client: + yield SimpleNamespace(client=client) + + +@pytest.fixture +async def seeded(reads: SimpleNamespace) -> SimpleNamespace: + """Two children with interleaved overrides (Ada has a schedule).""" + client = reads.client + ada = await _create_child(client, "Ada") + ben = await _create_child(client, "Ben") + put = await client.put( + _schedule_url(ada), + json={ + "cycle_length_weeks": 2, + "anchor_date": "2026-01-05", + "pattern": {"0": [4, 0, 2], "1": []}, + }, + headers=_admin_headers(), + ) + assert put.status_code == 200 + ada_march = await _create_override( + client, ada, "2026-03-01", "2026-03-07", note="grandparents" + ) + ben_january = await _create_override( + client, ben, "2026-01-10", "2026-01-12", is_present=True + ) + ben_march = await _create_override(client, ben, "2026-03-01", "2026-03-01") + ada_may = await _create_override(client, ada, "2026-05-20", "2026-05-20") + return SimpleNamespace( + client=client, + ada=ada, + ben=ben, + ada_march=ada_march, + ben_january=ben_january, + ben_march=ben_march, + ada_may=ada_may, + ) + + +# --- presence schedule read ------------------------------------------------ + + +async def test_schedule_read_returns_the_stored_schedule( + seeded: SimpleNamespace, +) -> None: + """A seeded schedule reads back in the PUT response's shape.""" + response = await seeded.client.get( + _schedule_url(seeded.ada), headers=_admin_headers() + ) + assert response.status_code == 200 + assert response.json() == { + "schedule": { + "child_id": seeded.ada, + "cycle_length_weeks": 2, + "anchor_date": "2026-01-05", + "pattern": {"0": [0, 2, 4], "1": []}, + } + } + + +async def test_schedule_read_without_schedule_is_null_not_404( + seeded: SimpleNamespace, +) -> None: + """A child with no schedule (present every day) is schedule: null.""" + response = await seeded.client.get( + _schedule_url(seeded.ben), headers=_admin_headers() + ) + assert response.status_code == 200 + assert response.json() == {"schedule": None} + + +async def test_schedule_read_unknown_child_is_404( + reads: SimpleNamespace, +) -> None: + """An unknown child is 404 with the children routes' detail.""" + response = await reads.client.get( + _schedule_url(999), headers=_admin_headers() + ) + assert response.status_code == 404 + assert response.json() == {"detail": "Child not found"} + + +# --- presence overrides read ----------------------------------------------- + + +async def test_overrides_read_lists_all_in_stable_order( + seeded: SimpleNamespace, +) -> None: + """Unfiltered: every override, by start date, then child id, then id.""" + response = await seeded.client.get( + OVERRIDES_URL, headers=_admin_headers() + ) + assert response.status_code == 200 + assert response.json() == { + "overrides": [ + seeded.ben_january, + seeded.ada_march, + seeded.ben_march, + seeded.ada_may, + ] + } + assert seeded.ada_march == { + "id": seeded.ada_march["id"], + "child_id": seeded.ada, + "start_date": "2026-03-01", + "end_date": "2026-03-07", + "is_present": False, + "note": "grandparents", + } + + +async def test_overrides_read_empty_household(reads: SimpleNamespace) -> None: + """No overrides at all is an empty list.""" + response = await reads.client.get(OVERRIDES_URL, headers=_admin_headers()) + assert response.status_code == 200 + assert response.json() == {"overrides": []} + + +async def test_overrides_read_filters_by_child( + seeded: SimpleNamespace, +) -> None: + """child_id keeps only that child's overrides.""" + response = await seeded.client.get( + OVERRIDES_URL, + params={"child_id": seeded.ada}, + headers=_admin_headers(), + ) + assert response.status_code == 200 + assert response.json() == { + "overrides": [seeded.ada_march, seeded.ada_may] + } + + +async def test_overrides_read_filters_by_overlapping_range( + seeded: SimpleNamespace, +) -> None: + """start/end keep overrides overlapping the closed range (inclusive).""" + # 2026-03-07 is Ada's March override's LAST day: the boundary counts. + response = await seeded.client.get( + OVERRIDES_URL, + params={"start": "2026-03-07", "end": "2026-05-20"}, + headers=_admin_headers(), + ) + assert response.status_code == 200 + assert response.json() == { + "overrides": [seeded.ada_march, seeded.ada_may] + } + + +async def test_overrides_read_open_ended_range_filters( + seeded: SimpleNamespace, +) -> None: + """start alone keeps later-ending, end alone keeps earlier-starting.""" + from_april = await seeded.client.get( + OVERRIDES_URL, params={"start": "2026-04-01"}, headers=_admin_headers() + ) + assert from_april.json() == {"overrides": [seeded.ada_may]} + until_january = await seeded.client.get( + OVERRIDES_URL, params={"end": "2026-01-31"}, headers=_admin_headers() + ) + assert until_january.json() == {"overrides": [seeded.ben_january]} + + +async def test_overrides_read_combines_child_and_range( + seeded: SimpleNamespace, +) -> None: + """child_id and the range narrow together.""" + response = await seeded.client.get( + OVERRIDES_URL, + params={"child_id": seeded.ben, "start": "2026-02-01", "end": "2026-12-31"}, + headers=_admin_headers(), + ) + assert response.status_code == 200 + assert response.json() == {"overrides": [seeded.ben_march]} + + +@pytest.mark.parametrize( + "params", + [ + {"start": "2026-03-10", "end": "2026-03-01"}, + {"start": "2026-3-1"}, + {"end": "not-a-date"}, + {"start": "2026-02-30"}, + ], +) +async def test_overrides_read_invalid_range_is_422( + seeded: SimpleNamespace, params: dict[str, str] +) -> None: + """A malformed date or an inverted range is the core's 422.""" + response = await seeded.client.get( + OVERRIDES_URL, params=params, headers=_admin_headers() + ) + assert response.status_code == 422 + + +async def test_overrides_read_unknown_child_is_404( + reads: SimpleNamespace, +) -> None: + """An unknown child_id filter is 404, never an empty list.""" + response = await reads.client.get( + OVERRIDES_URL, params={"child_id": 999}, headers=_admin_headers() + ) + assert response.status_code == 404 + assert response.json() == {"detail": "Child not found"} + + +# --- authentication: the router's ONE require_admin -------------------------- + + +@pytest.fixture(params=["schedule", "overrides"]) +async def read_url(request: pytest.FixtureRequest, reads: SimpleNamespace) -> str: + """Each read route's URL (the schedule route for a real child).""" + if request.param == "overrides": + return OVERRIDES_URL + return _schedule_url(await _create_child(reads.client, "Ada")) + + +async def test_read_without_credential_is_401( + reads: SimpleNamespace, read_url: str +) -> None: + response = await reads.client.get(read_url) + assert response.status_code == 401 + + +async def test_read_refuses_panel_service_token( + reads: SimpleNamespace, read_url: str +) -> None: + response = await reads.client.get( + read_url, headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"} + ) + assert response.status_code == 403 + assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL + + +async def test_read_refuses_non_admin_jwt( + reads: SimpleNamespace, read_url: str +) -> None: + non_admin = make_token(groups=("some-other-group",)) + response = await reads.client.get( + read_url, headers={"Authorization": f"Bearer {non_admin}"} + ) + assert response.status_code == 403 + assert response.json()["detail"] == FORBIDDEN_DETAIL + + +async def test_read_admin_jwt_is_200( + reads: SimpleNamespace, read_url: str +) -> None: + response = await reads.client.get(read_url, headers=_admin_headers()) + assert response.status_code == 200 diff --git a/tests/test_api_admin_sse.py b/tests/test_api_admin_sse.py new file mode 100644 index 0000000..6156345 --- /dev/null +++ b/tests/test_api_admin_sse.py @@ -0,0 +1,283 @@ +"""Admin-plane SSE tests: the admin transition stream (task 92f75680). + +These tests exercise ``GET /api/v1/admin/events`` — the admin plane's +Server-Sent Events stream for the Admin PWA — against a REAL server: + +- The stream inherits the admin router's ONE ``require_admin`` + dependency: an absent credential is 401, the panel service token and + a non-admin JWT are 403, all before any stream is established. +- An admin JWT subscribes; each of the four documented transitions + (quest-completed, quest-uncompleted, quest-missed, child-day-complete) + arrives as an ``event: `` / ``data: `` frame carrying its + documented payload fields (design/ENTITIES-AND-SERVICES.md §3). +- The admin and panel streams are fed by the SAME in-process publisher: + one transition arrives, identically, on both. + +Streaming approach: :mod:`tests.test_api_sse`'s live-uvicorn reader +(httpx's ASGITransport buffers SSE bodies), on the admin-enabled +:class:`tests.test_api_admin_uncomplete._AdminServer` whose JWKS cache +is stubbed, so a real admin JWT verifies with NO network access. +""" +from __future__ import annotations + +import datetime +from types import SimpleNamespace +from zoneinfo import ZoneInfo + +import httpx +import pytest + +from api import routes_admin, routes_panel +from tests.admin_jwt_harness import make_token +from tests.test_api_admin_uncomplete import _AdminServer +from tests.test_api_panel import PANEL_TOKEN, _seed_household +from tests.test_api_sse import ( + EVENT_CHILD_DAY_COMPLETE, + EVENT_QUEST_COMPLETED, + EVENT_QUEST_MISSED, + EVENT_QUEST_UNCOMPLETED, + READ_TIMEOUT, + _stream, +) + +EVENTS_PATH = "/api/v1/admin/events" + + +@pytest.fixture +def temp_db_path(tmp_path) -> str: + """A fresh per-test SQLite path under pytest's tmp_path.""" + return str(tmp_path / "nestquest.db") + + +@pytest.fixture +async def admin_live(temp_db_path: str) -> SimpleNamespace: + """A seeded household served by a live admin-enabled uvicorn app. + + Both planes' clocks are pinned to 12:00 UTC (past Ada's 10:00 due, + before Bo's 17:00) BEFORE the server starts — the app is created + inside uvicorn's factory thread. + """ + today = datetime.datetime.now().astimezone().date() + pinned_now = datetime.datetime( + today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC") + ) + seed = await _seed_household(temp_db_path, today, pinned_now) + original_panel = routes_panel._local_now + original_admin = routes_admin._local_now + routes_panel._local_now = lambda: pinned_now + routes_admin._local_now = lambda: pinned_now + try: + with _AdminServer(temp_db_path) as server: + client = httpx.AsyncClient( + base_url=server.url(""), timeout=READ_TIMEOUT + ) + async with client: + yield SimpleNamespace(client=client, seed=seed, server=server) + finally: + routes_panel._local_now = original_panel + routes_admin._local_now = original_admin + + +async def _panel_complete(live: SimpleNamespace, instance_id: int, child_id: int): + """Complete ``instance_id`` through the panel route (a real transition).""" + response = await live.client.post( + f"/api/v1/panel/instances/{instance_id}/complete", + headers={"Authorization": f"Bearer {PANEL_TOKEN}"}, + json={"actor_child_id": child_id}, + ) + assert response.status_code == 200 + + +# --- auth: the ONE require_admin dependency, no stream on refusal ---------- + + +async def test_admin_events_refuses_absent_panel_and_non_admin( + admin_live: SimpleNamespace, +) -> None: + """No credential is 401; the panel token and a non-admin JWT are 403. + + Each refusal is a complete (non-streaming) JSON error response, so + no stream is established for any of them. + """ + client = admin_live.client + + absent = await client.get(EVENTS_PATH) + assert absent.status_code == 401 + assert not absent.headers["content-type"].startswith("text/event-stream") + + panel = await client.get( + EVENTS_PATH, headers={"Authorization": f"Bearer {PANEL_TOKEN}"} + ) + assert panel.status_code == 403 + assert panel.json()["detail"] == ( + "This credential type is not accepted for admin access" + ) + + non_admin = await client.get( + EVENTS_PATH, + headers={ + "Authorization": f"Bearer {make_token(groups=('other-group',))}" + }, + ) + assert non_admin.status_code == 403 + assert non_admin.json()["detail"] == ( + "Admin access requires membership in the nestquest-admins group" + ) + + +async def test_admin_jwt_is_not_accepted_on_panel_events( + admin_live: SimpleNamespace, +) -> None: + """The panel plane is unchanged: an admin JWT does not open it.""" + response = await admin_live.client.get( + "/api/v1/panel/events", + headers={"Authorization": f"Bearer {make_token()}"}, + ) + assert response.status_code == 401 + + +async def test_admin_events_sets_no_cache(admin_live: SimpleNamespace) -> None: + """The admin stream is ``text/event-stream`` with ``no-cache``.""" + async with admin_live.client.stream( + "GET", + EVENTS_PATH, + headers={"Authorization": f"Bearer {make_token()}"}, + ) as response: + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + assert response.headers["cache-control"] == "no-cache" + + +# --- the four documented transitions on the admin stream ------------------- + + +async def test_admin_stream_carries_completed_and_day_complete( + admin_live: SimpleNamespace, +) -> None: + """A panel completion streams quest-completed then child-day-complete. + + Ada's only quest today is Pack bag, so completing it also clears her + day; both frames arrive on the ADMIN stream with their documented + payload fields. + """ + seed = admin_live.seed + pack_bag = seed.pack_bag_instance + stream = _stream(admin_live.server.url(EVENTS_PATH), make_token()) + async with stream: + await _panel_complete(admin_live, pack_bag.id, seed.ada.id) + + event_type, payload = await stream.next_event() + assert event_type == EVENT_QUEST_COMPLETED == "nestquest_quest_completed" + assert payload["child_id"] == seed.ada.id + assert payload["child_name"] == "Ada" + assert payload["instance_id"] == pack_bag.id + assert payload["quest_title"] == "Pack bag" + assert payload["window"] == "morning" + assert payload["due_date"] == pack_bag.due_date + assert payload["due_time"] == "10:00" + assert payload["occurred_at"] + assert payload["was_on_time"] is False # 10:00 due, 12:00 now + completed_at = payload["occurred_at"] + + event_type, payload = await stream.next_event() + assert event_type == EVENT_CHILD_DAY_COMPLETE + assert event_type == "nestquest_child_day_complete" + assert payload["child_id"] == seed.ada.id + assert payload["child_name"] == "Ada" + assert payload["quests_due"] == 1 + assert payload["quests_completed"] == 1 + assert payload["occurred_at"] == completed_at + + +async def test_admin_stream_carries_uncompleted( + admin_live: SimpleNamespace, +) -> None: + """The admin uncomplete route's reversal streams quest-uncompleted.""" + seed = admin_live.seed + brush = seed.brush_teeth_instance + admin_headers = {"Authorization": f"Bearer {make_token()}"} + stream = _stream(admin_live.server.url(EVENTS_PATH), make_token()) + async with stream: + response = await admin_live.client.post( + f"/api/v1/admin/instances/{brush.id}/uncomplete", + headers=admin_headers, + ) + assert response.status_code == 200 + assert response.json()["appended"] is True + + event_type, payload = await stream.next_event() + assert event_type == EVENT_QUEST_UNCOMPLETED + assert event_type == "nestquest_quest_uncompleted" + assert payload["child_id"] == seed.bo.id + assert payload["child_name"] == "Bo" + assert payload["instance_id"] == brush.id + assert payload["quest_title"] == "Brush teeth" + assert payload["window"] == "morning" + assert payload["due_date"] == brush.due_date + assert payload["due_time"] == "17:00" + assert payload["occurred_at"] + assert "was_on_time" not in payload + + +async def test_admin_stream_carries_missed(admin_live: SimpleNamespace) -> None: + """The missed-sweep publisher's transition streams to the admin client. + + Drives the API-side helper the sweep calls, on the SERVER's loop, + against the app's own database and its ONE publisher. + """ + from api import transitions + + seed = admin_live.seed + stale = seed.stale_instance + app = admin_live.server.app + stream = _stream(admin_live.server.url(EVENTS_PATH), make_token()) + async with stream: + admin_live.server.run_on_loop( + transitions.publish_quest_missed( + app.state.db.database, stale.id, app.state.publisher + ) + ) + event_type, payload = await stream.next_event() + assert event_type == EVENT_QUEST_MISSED == "nestquest_quest_missed" + assert payload["child_id"] == seed.ada.id + assert payload["child_name"] == "Ada" + assert payload["instance_id"] == stale.id + assert payload["quest_title"] == "Stale chore" + assert payload["window"] == "morning" + assert payload["due_date"] == stale.due_date + assert payload["due_time"] == "09:00" + assert payload["occurred_at"] + + +# --- one publisher feeds both planes --------------------------------------- + + +async def test_admin_and_panel_streams_share_one_publisher( + admin_live: SimpleNamespace, +) -> None: + """One transition arrives, identically, on the admin AND panel streams. + + The route publishes once to ``app.state.publisher``; both planes' + subscribers receive the same frames (same ``occurred_at``), which a + second, admin-only publisher could never produce. + """ + seed = admin_live.seed + pack_bag = seed.pack_bag_instance + admin_stream = _stream(admin_live.server.url(EVENTS_PATH), make_token()) + panel_stream = _stream(admin_live.server.url("/api/v1/panel/events")) + async with admin_stream, panel_stream: + await _panel_complete(admin_live, pack_bag.id, seed.ada.id) + + admin_events = [ + await admin_stream.next_event(), + await admin_stream.next_event(), + ] + panel_events = [ + await panel_stream.next_event(), + await panel_stream.next_event(), + ] + assert [event_type for event_type, _ in admin_events] == [ + EVENT_QUEST_COMPLETED, + EVENT_CHILD_DAY_COMPLETE, + ] + assert admin_events == panel_events diff --git a/tests/test_api_sse.py b/tests/test_api_sse.py index aea8034..98a6600 100644 --- a/tests/test_api_sse.py +++ b/tests/test_api_sse.py @@ -158,7 +158,8 @@ class _EventStream: parsed frame with a timeout. """ - def __init__(self) -> None: + def __init__(self, token: str = PANEL_TOKEN) -> None: + self.token = token self.queue: asyncio.Queue = asyncio.Queue() self._task: asyncio.Task | None = None self._client: httpx.AsyncClient | None = None @@ -170,7 +171,7 @@ class _EventStream: self._stream_cm = self._client.stream( "GET", self.url, - headers={"Authorization": f"Bearer {PANEL_TOKEN}"}, + headers={"Authorization": f"Bearer {self.token}"}, ) self._response = await self._stream_cm.__aenter__() assert self._response.status_code == 200 @@ -263,9 +264,13 @@ async def seeded_panel(temp_db_path: str) -> SimpleNamespace: routes_panel._local_now = original -def _stream(url: str) -> _EventStream: - """A subscribed SSE reader for ``url``.""" - return _EventStream()._set_url(url) +def _stream(url: str, token: str = PANEL_TOKEN) -> _EventStream: + """A subscribed SSE reader for ``url``, sending ``token`` as Bearer. + + Defaults to the panel service token; the admin-plane stream tests + pass an admin JWT instead. + """ + return _EventStream(token)._set_url(url) # --- auth and content type ---------------------------------------------- -- 2.55.0 From fe44740450336e604724ec49a59935af90cd6968 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Thu, 24 Sep 2026 00:08:58 -0400 Subject: [PATCH 10/11] Feature 19: Admin PWA (#187-#210) (#211) Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> --- admin/.env.example | 13 + admin/.gitignore | 2 + admin/index.html | 15 + admin/package-lock.json | 3034 +++++++++++++++++ admin/package.json | 28 + admin/public/icons/icon-192.png | Bin 0 -> 2580 bytes admin/public/icons/icon-512.png | Bin 0 -> 8815 bytes admin/public/icons/icon-maskable-512.png | Bin 0 -> 8371 bytes admin/public/manifest.webmanifest | 14 + admin/public/sw.js | 88 + admin/scripts/generate-icons.mjs | 80 + admin/src/App.css | 123 + admin/src/App.test.tsx | 47 + admin/src/App.tsx | 63 + admin/src/AppRoutes.test.tsx | 193 ++ admin/src/AppRoutes.tsx | 211 ++ admin/src/api/client.test.ts | 94 + admin/src/api/client.ts | 93 + admin/src/api/definitions.ts | 174 + admin/src/api/events.test.ts | 162 + admin/src/api/events.ts | 196 ++ admin/src/api/history.ts | 85 + admin/src/api/instances.ts | 23 + admin/src/api/presence.ts | 126 + admin/src/api/snapshot.ts | 55 + admin/src/api/testStream.ts | 39 + admin/src/api/useTransitions.tsx | 61 + admin/src/auth/oidc.test.ts | 173 + admin/src/auth/oidc.ts | 225 ++ admin/src/auth/pkce.test.ts | 41 + admin/src/auth/pkce.ts | 55 + admin/src/config.test.ts | 215 ++ admin/src/config.ts | 221 ++ admin/src/definitions/DefinitionsTab.css | 585 ++++ admin/src/definitions/DefinitionsTab.test.tsx | 1087 ++++++ admin/src/definitions/DefinitionsTab.tsx | 968 ++++++ admin/src/definitions/definitionIcons.tsx | 103 + admin/src/definitions/format.ts | 111 + admin/src/definitions/glyphs.tsx | 67 + admin/src/history/HistoryTab.css | 296 ++ admin/src/history/HistoryTab.test.tsx | 631 ++++ admin/src/history/HistoryTab.tsx | 300 ++ admin/src/history/format.ts | 94 + admin/src/history/glyphs.tsx | 41 + admin/src/index.css | 76 + admin/src/main.tsx | 13 + admin/src/pwa/pwa.test.ts | 396 +++ admin/src/pwa/register.ts | 13 + admin/src/schedule/OverrideEditor.css | 220 ++ admin/src/schedule/OverrideEditor.test.tsx | 533 +++ admin/src/schedule/OverrideEditor.tsx | 348 ++ admin/src/schedule/ScheduleTab.css | 416 +++ admin/src/schedule/ScheduleTab.test.tsx | 276 ++ admin/src/schedule/ScheduleTab.tsx | 557 +++ admin/src/schedule/cycle.test.ts | 115 + admin/src/schedule/cycle.ts | 142 + admin/src/schedule/format.ts | 37 + admin/src/schedule/glyphs.tsx | 85 + admin/src/today/TodayTab.css | 331 ++ admin/src/today/TodayTab.test.tsx | 408 +++ admin/src/today/TodayTab.tsx | 347 ++ admin/src/today/format.ts | 50 + admin/src/today/glyphs.tsx | 61 + admin/tsconfig.json | 16 + admin/vite-sw-plugin.ts | 93 + admin/vite.config.ts | 10 + 66 files changed, 14775 insertions(+) create mode 100644 admin/.env.example create mode 100644 admin/.gitignore create mode 100644 admin/index.html create mode 100644 admin/package-lock.json create mode 100644 admin/package.json create mode 100644 admin/public/icons/icon-192.png create mode 100644 admin/public/icons/icon-512.png create mode 100644 admin/public/icons/icon-maskable-512.png create mode 100644 admin/public/manifest.webmanifest create mode 100644 admin/public/sw.js create mode 100644 admin/scripts/generate-icons.mjs create mode 100644 admin/src/App.css create mode 100644 admin/src/App.test.tsx create mode 100644 admin/src/App.tsx create mode 100644 admin/src/AppRoutes.test.tsx create mode 100644 admin/src/AppRoutes.tsx create mode 100644 admin/src/api/client.test.ts create mode 100644 admin/src/api/client.ts create mode 100644 admin/src/api/definitions.ts create mode 100644 admin/src/api/events.test.ts create mode 100644 admin/src/api/events.ts create mode 100644 admin/src/api/history.ts create mode 100644 admin/src/api/instances.ts create mode 100644 admin/src/api/presence.ts create mode 100644 admin/src/api/snapshot.ts create mode 100644 admin/src/api/testStream.ts create mode 100644 admin/src/api/useTransitions.tsx create mode 100644 admin/src/auth/oidc.test.ts create mode 100644 admin/src/auth/oidc.ts create mode 100644 admin/src/auth/pkce.test.ts create mode 100644 admin/src/auth/pkce.ts create mode 100644 admin/src/config.test.ts create mode 100644 admin/src/config.ts create mode 100644 admin/src/definitions/DefinitionsTab.css create mode 100644 admin/src/definitions/DefinitionsTab.test.tsx create mode 100644 admin/src/definitions/DefinitionsTab.tsx create mode 100644 admin/src/definitions/definitionIcons.tsx create mode 100644 admin/src/definitions/format.ts create mode 100644 admin/src/definitions/glyphs.tsx create mode 100644 admin/src/history/HistoryTab.css create mode 100644 admin/src/history/HistoryTab.test.tsx create mode 100644 admin/src/history/HistoryTab.tsx create mode 100644 admin/src/history/format.ts create mode 100644 admin/src/history/glyphs.tsx create mode 100644 admin/src/index.css create mode 100644 admin/src/main.tsx create mode 100644 admin/src/pwa/pwa.test.ts create mode 100644 admin/src/pwa/register.ts create mode 100644 admin/src/schedule/OverrideEditor.css create mode 100644 admin/src/schedule/OverrideEditor.test.tsx create mode 100644 admin/src/schedule/OverrideEditor.tsx create mode 100644 admin/src/schedule/ScheduleTab.css create mode 100644 admin/src/schedule/ScheduleTab.test.tsx create mode 100644 admin/src/schedule/ScheduleTab.tsx create mode 100644 admin/src/schedule/cycle.test.ts create mode 100644 admin/src/schedule/cycle.ts create mode 100644 admin/src/schedule/format.ts create mode 100644 admin/src/schedule/glyphs.tsx create mode 100644 admin/src/today/TodayTab.css create mode 100644 admin/src/today/TodayTab.test.tsx create mode 100644 admin/src/today/TodayTab.tsx create mode 100644 admin/src/today/format.ts create mode 100644 admin/src/today/glyphs.tsx create mode 100644 admin/tsconfig.json create mode 100644 admin/vite-sw-plugin.ts create mode 100644 admin/vite.config.ts diff --git a/admin/.env.example b/admin/.env.example new file mode 100644 index 0000000..706fd43 --- /dev/null +++ b/admin/.env.example @@ -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 diff --git a/admin/.gitignore b/admin/.gitignore new file mode 100644 index 0000000..04c01ba --- /dev/null +++ b/admin/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ \ No newline at end of file diff --git a/admin/index.html b/admin/index.html new file mode 100644 index 0000000..8f95fb8 --- /dev/null +++ b/admin/index.html @@ -0,0 +1,15 @@ + + + + + + + + + NestQuest Admin + + +
+ + + \ No newline at end of file diff --git a/admin/package-lock.json b/admin/package-lock.json new file mode 100644 index 0000000..7da0990 --- /dev/null +++ b/admin/package-lock.json @@ -0,0 +1,3034 @@ +{ + "name": "nestquest-admin", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nestquest-admin", + "version": "0.0.0", + "dependencies": { + "lucide-react": "^1.47.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "jsdom": "^30.1.1", + "typescript": "^5.9.2", + "vite": "^7.1.5", + "vitest": "^3.2.4" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.1.tgz", + "integrity": "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.0", + "@csstools/css-color-parser": "^4.2.3", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz", + "integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz", + "integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz", + "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz", + "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz", + "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz", + "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz", + "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz", + "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz", + "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz", + "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz", + "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz", + "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz", + "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz", + "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz", + "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz", + "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz", + "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz", + "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz", + "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz", + "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz", + "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz", + "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz", + "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz", + "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz", + "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz", + "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz", + "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", + "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/browserslist": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.438", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.438.tgz", + "integrity": "sha512-AN9xMU1hJiT65LkCUPL1DZm5TCbOPb2Qsm5pYwFLEXp6/qj9SdT4yMiqs7Qqstwvkn1zF7l36SRGt+s6XcJ0FA==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz", + "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.2.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.10.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.47.0.tgz", + "integrity": "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz", + "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.4", + "@rollup/rollup-android-arm64": "4.63.4", + "@rollup/rollup-darwin-arm64": "4.63.4", + "@rollup/rollup-darwin-x64": "4.63.4", + "@rollup/rollup-freebsd-arm64": "4.63.4", + "@rollup/rollup-freebsd-x64": "4.63.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", + "@rollup/rollup-linux-arm-musleabihf": "4.63.4", + "@rollup/rollup-linux-arm64-gnu": "4.63.4", + "@rollup/rollup-linux-arm64-musl": "4.63.4", + "@rollup/rollup-linux-loong64-gnu": "4.63.4", + "@rollup/rollup-linux-loong64-musl": "4.63.4", + "@rollup/rollup-linux-ppc64-gnu": "4.63.4", + "@rollup/rollup-linux-ppc64-musl": "4.63.4", + "@rollup/rollup-linux-riscv64-gnu": "4.63.4", + "@rollup/rollup-linux-riscv64-musl": "4.63.4", + "@rollup/rollup-linux-s390x-gnu": "4.63.4", + "@rollup/rollup-linux-x64-gnu": "4.63.4", + "@rollup/rollup-linux-x64-musl": "4.63.4", + "@rollup/rollup-openbsd-x64": "4.63.4", + "@rollup/rollup-openharmony-arm64": "4.63.4", + "@rollup/rollup-win32-arm64-msvc": "4.63.4", + "@rollup/rollup-win32-ia32-msvc": "4.63.4", + "@rollup/rollup-win32-x64-gnu": "4.63.4", + "@rollup/rollup-win32-x64-msvc": "4.63.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.15", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.15.tgz", + "integrity": "sha512-SJVBeHOxDbNoq14CvpAoA2mLEWdbldGK8nR+yumpjY5nrlZxOflcA7p1Qj1gbTGmbu9DY9qLj/bENSWhBzjxRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.15" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.15", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.15.tgz", + "integrity": "sha512-ERuv0p98XgSzmlSLJr8vDNxX+uATGInlN97V3R+JpYWaSqn5IG3ewWgCwczk4bkOpgth/o8Nt5FOi4B4w0n/1A==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.0.tgz", + "integrity": "sha512-AdzuGcAkzhbha3GgCSoUBx2P0quwym6qdFNCQz/fDGZc8w9XJ741wHUbt2kTmfPz+ZIOMkySFkiY7RXmiDDC8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz", + "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/admin/package.json b/admin/package.json new file mode 100644 index 0000000..cb86399 --- /dev/null +++ b/admin/package.json @@ -0,0 +1,28 @@ +{ + "name": "nestquest-admin", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "lucide-react": "^1.47.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "jsdom": "^30.1.1", + "typescript": "^5.9.2", + "vite": "^7.1.5", + "vitest": "^3.2.4" + } +} diff --git a/admin/public/icons/icon-192.png b/admin/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..4435870c8891af50b9eff2d5fd05a1aed156abe4 GIT binary patch literal 2580 zcmaJ@X;@QN6TV4+u*i;n%5sf}1u3>gS;Z??K@2{UfQk4KzzrpU0u@l;qBT-hMG_Ss z1@(e}1Q0=FX%TQ&Hf@4zhRrG>iEKrZAXwl#;i*69kMBovo;!19=AC!WoH?nUwC$Se zhUyRmX}VKgcY*bh;-^Xk|9ua>dP0y|kGty@W=!{J-_(iS+YD6xn%vdo5nn%5T9~lc z;KISj@%y3jsU-i{C9bzz(^Bf%j-8Jj&s;I5T2s+7;y83ohCHfa&w3MU;vV#s_&U^w zy^|jID&7ikfJu57SjNOx&allg0p$}~VVzFqP0M-wZV+!fNm5zTdQW`Ix6)pHu zQ*2%@ty`>K>^QcS6c3A=+6ETl41Y8Eu*l=TOhk)J+!dBPZ<|93Kf*}iBBi6l{Wt$$ zNh;y5xqfCi_BqKg3-=yb;=v`^=pc7yR>b2_VHw6E@f)xgoEbjfHg<+T)Y1C9fQ_*x z`E<+_l??~q-Z(4VKw#G3)!mF*yvsNzOIBI$Ao~OeX?Rk^q!SBGkx)AQI7Xz1%LBki zSsYp=4kky2w1U*QuNaBAl8*GvhC}{z=#V$v=}Ul*V3oEiHez(IsDTiVTc&#;$!@1@ zg^U*KLXZny9on@yll78kjfBUbzji#eU0OE%vOwy9lG;~C^S8F~lo5hZM;g@Mcwx@U zO=>LXVDnv1c1_hJ5H5EOKr&x$Vh`tFQuDAoB-_cYs?FESF8>{eR561vlKKkE!l4v$ zJAWGfI`t;jIot=)(vgbax(3=achHHdm`%9p2$caczh9ew>XX(0F~39s{Jo}?RoONl!9_ANKx6AXrjZF1Ezm^! zs^8Mm+FoJ93N9WVmqfEIS5uUc08|)I?3DJLmyYM#=q}Q$wW%daU`_S3c%Gp#Es<*%w7XBprE4}JmwX+wz696!) z_qnKAsE*7vRyns4)FkeZ+US~FC%>GPMkSfe#HDaPNR`kC-_Sb}U1hVbxr|wAE=G$% z?V?Th@XhU>^}lr?o84?67`D5OdJj@7oxSkwG(oE9p^+mJg+8BWQK4tObFQ(lBN85m zZpBhKmK__cf#?}A#UDiHCld=_^gsDRjY`%fxQpGP@?jCGl0Ut!NQd%b4pyhELI}JB z*fa-|MJ2`bWZ%nnr3sv19uKJG)9@*Dr0+l!Is$6yh@4}h$8E++wNlER(p0Hi=^%o( zrq1Ni1G;G4t4IS7ok&qb&P5JcaAM3EGD7*(vOwq5>v%K*q8Ol6m8Z&-b@oIr*W{(Z zWGYSIriJ=3CT>C2b^=|<9WYZqe3Nvrj^U-G6CS=wna9H+>Q-%C-mC}d_DXqY%ZT}f zwn?09mY+;ancci+3+h*&=!)tsV7ul3S@)}yu)ua6I2^U`MoNEe{tPF^iBYlAjYfye zZUQDkeFuDLrD}~S_;Q_Ed`f}KienY-=+zc^l>}%|pbA`1kuOs+LG?o4x`LE8QPv{Y zf|C+9+Jv-6UX^PD9#+2G8{#Uv3$3qRz1>4wnO6wxn1W^9;np|j-~(t9=);Sc9smZ4 z+nsIsIx>N@kPB6tOeq?>%aRHSO_FIL>0;Y}IU_i!WW4c0|J_4Apjvm>!@aT1-+PMJ zRhA^HQdx8*>Q3$dP>%T0+N*2JA-&fd2xdiWBTb%tz9_SJeq9j{*8<0vYfL$S^fV@= z*^Dg{g>YlJaws=Uh~=&yYK4@g|Ar4?<87CgvD`vzCihM=p}BP2dP2V zCFpgpRg*1Q71=}B)u|6j!mAE%P(lW!CqEJ{cc;FTjpgt&U~=J~ZlV388pZKqpO zGtSwOfT2@dVIH_jEOXhvrK}RSwoTTMnVwwFQ2M@yYy6cxbrCI3A0(oKF3ig@Cop$# zgT6Af12xux;nDo*LfGlzD9io__>Y??8khm7&3!?Y8~R-4fVxS=mChbxt9|Pl_U7SG zV?6BPGWTEcco9o*1?HI`afuo^-p(6u6rFlK0QrEz*|4q~N~1VOB@_GZx}=>#VBR*w zZ0Hq+%xhO0k4_ zXXwMr!GaS=-%0xUsE{swsH2pBZM-OjrY%h)$lU1mZv!r+2Top3$HN1Eu%B(yrzeYk zuE_1oECx{8oso&Cs}GEBc#saul+fAE$d02ojSUlL{R*TfhY6ZH=-Z5{Mu(4aR7!`k zG_B^@hl494qAmt_sg&};ohrPP*RU{3+b8>8KGc+=^lF%_ishL5Dh5Ojn}rZ`fG`Ok zM=0Vm_$I{!0ecj;HY%tD`2*n+#5o2Pl8{)%yf}j&{bsWA|C+=uGBHtD8Y8g6djX!! zF0}y;Mc*8%DYU^LJs@0x{W76=aeM~Nxv=LS3}C`2Akbd<1ur~au4mPJUb)VpS?#Gf S(*eBpAop!F*V3)~j||)2Btx;W$71>c_RGKaxYI9OJk_W2Z+)&3pIO!`nMP zeY5tp?f1?+pI`M{?SZdv&N%(d%*;N!M|b@6K#!#to_g`Ak5l?Y&kX&}?@!*mg%z)r zRcw^szvA;2TZ+#Qb-yst8!>U_sKV3-dUES#dU`|H%S|5}el6Uql)Fl{Ox+NaK_Z{cKGgwjQE!HL&_C*>c$P?*s~_ft%PfhmMJaQ9ZID;HF<+L z>a6RH2jb?Yx3tQ{(TP4_)j>v@ zwzR31n*!V1v4q4W1mMAFCEw`M@H1N&hxdemO8EW=xr{%o4y7VKuONf&IxOn$da)Cd z(zQ<<@=^$C!(}niRE`9@^M`%D7W0ZU6 zXLiDlDQ^BLC@+M|>$fyz@=EX#L7)=*`*IrB&HycV4+}=RaLgAkjhIOna>**@sVYzK z{ThSf5i>O%$5mMV*7;M|Vwy`<&@H>Ak~N?Af%Z-(%Ryn55>chXidA(?O)*Vfy88>A zTt>d~P)y{@7e_}0V52je@|3A>qktv-~R?EHJ`yX(TPamq!HNaNwH9u5)F8XX<1%F;=}M=A5V z&Z|$Y%b6&bR0zKV3dHq&$C}l>l7YtDTfH57UQ}B2k>p!n$?jES^iui}7962=6I9Q1 zlb6HTt_cJ1z}V1r=HeK$dd$O6^U1&cc0Fu< zkWHd{AkLh_8@u~;=8Ce%a1@1!cHILcDqE+{j>Nz>WmjtCH|0>hsGaM=PQ<%gnG}8#|v#`oNRbqBcrgj;#M^yIeC&rjfI?c z)W?EsxumR=O%MA#yWkJdB2!C)xej0|AKp&SBejf=jZ=AL#oZr&+^Uw8v%8kO*U+Oj zA$8flRM}OXlaGvuDP#7LM5#;&Qf%|T(n)-g`VnU*?d73wCdHCc^ipRliE6#B5X)T(`WuCO1HS1CEjvu;WcK=u<~se_Xfjdhs}kYY+5RjBGtht%*jWw<6#Xg&1R8)IQ?Z7gpcJNU69uN5kbOv za~1Ii${9v-MXp`RDPfF4oDO~sWzvn3aBtlsDD+NSVM9WW4_gP(pHdisShvySbj>40 zkT27uCe?CFVX<30sO~lS(f1zXy{76!oMg^((1+?o5J6Myfmu55O3(n zZiOVp`wf!K>7Y=;74;@nW{j^?>K;kznHA%FrA@!0V6@rD{X5gUa&nSH=1>NJxx!r1 zJQ?vF=c9C(>eG7x`24gaPSd<-uo(pNw+GY^3p@1&gR(Izw9DilDbi{*6bBWj}|hAe=4XFZ9* zET7SCzQVB7Pm?3Kxt>m{Y*ORBfplq+on)O%Y6t{lh<}~Uxec@$CmnpE;qOer+&tk@ z3F>WfN_M3;!mYcweHHv->gV24a8J!B>WXnUcyJTn8|RgDgwId{IP|fx#J0gNb}{Kn z&#EftXMn!4$PAow|B*#q)d#W)fPVAJ-1H2V05J0_EimP%Ea1;0iPbMwdC3TODLq7K`Cxb`m zV`q_%V&fK*@wy?$NZbTnZXSv8Q`H^n)1k5RNmuxN26v^-FdS0Wec}u533JfRkRiGu zS!C*m`FX+~m)|FqLO1H4&A;UcTLXGJ$R{V0P>&TpD$OcFdiQklv7ia7E=vmTZ|(@Z zShuRP0fKgMvq+I{$YHX8`_S!r7w;U?eIg0Ib@%*YV<@+ZxZB0a`V)0z<*5ww9Nd1^ zG-h9~&^-&t0$px4Zig8jviGqUom0*qBG)K-rFX7XHPyF+C)VoklEy3DaO95mk+YOI zKg2ElTOe@#;%-V+aO6UYC>j==G1B8w4f=f*uw=g+vjvuX5b#Wys03iCH;mp9WENfG zWNuC!dHn)6&rV+8dim0OfydsCoUdH;x9&uy_pE@Wzg6sSqD= zn>U~{^a!4SC<{BeXUa_6>GqK$0o#FDC>L`xg%;iA9FbccYTiPPnGa(iwnp$fFozg} zE(q1<$ zOXMCr%XL69Gst1z^}vCjV{L15aTwgL}pMZK!(r-H3z*bkb2$^_U_bO zK8D(*LJAIrok16t_;r097r`_eN=kp9FnTG#XVXdPz1jkne)_h#051;LO|uGCUN@XF z#k&84!hb;XS*oe|N1Ey^#iD@FR%o#ZuEf2GB&86Yp(FrWU{%mxEJ*;9$B=LJdtM_| zp|P*wx7fMl6SxI6z~TCEuD;zs3#bGcy@EUHF^q;&#cF+JTp3wELceDbI>C;0WWfah z$i%(u2Pr!>lg9Xjf`s;$VqQXS(z7#BV-(wVXMJT)rdzc5wCV%T;3M+Un zyhIs9G29>hz>uRfU67~8hZn2w`Nhs43BUh^FT7IcN`(h(x{Z|7nMrBt?m+OVpFPC< zJ~ci{AxC-uuRz+PfS>Fpz1ua7TUiJ6t(1Ax`86Xu7LR_JB!P7Ar+D$+GX zGYjnP7dwq?>71J<6i^N^f%)3-sd>@t)S!8DNr)dhy{A*j+{I)es}gX> z?R#&KAfl9f8gzV*n|Fhch7VH%UI4MLqW*XD{mh%?9q*!Ws%UuQ`17*I^7A89a4EVW zdEnuF6=X@@K7U5(J(^yUySaH4zn^DSH|oMMwK-fyn< zFH1quO@p_(kNFqG@xp63G@tnT-%63uQjFsOC)9>JP=kc|#Pb=$3q7UH>dgsFf%Y6? zKi_o}0!EvKDd;;Fx%pEqnVBS87M{NA6<(;P&F}K!cC-|C&4m4Q44{={$JBeUau;z)Ugs$%tPx2&tIbDdzh1-52L%`7L4gY^+HXSz)B?R3H8e*ep<~W{c409J8z%K z%4uJ^dNAcAE$i4IWVEs^(0YnZ2+{(m9<&Ccg@je-Mkk8L&B(`#xQb62-x$v7u;q1z zW)xGwcxj2k_j-uBt@X9dT4*6ArjlRHxy2>0$&#(9POk5UMTt5$x<`*`F_O|MpQM%vuh7v3x`#`s zQc-loLA+KzN0ZB)X}4h{QFOpSj;P)Ai8t|*n9G`g#pG4omNwxBx={$015=>L5!DI% zMkqDsX>=+iv8c!j&zNxY_K^9FYGv7ozUYZ@&|rvsT@LJg0p+NX?32?`=H<7z7SXX! zP7fB`k7Z%R?HXR0APf2Me0n{RnyWgn0AUn}5?t#D!hDwS4J?XfWDzPXv@taStdo|E z1q7uUe+2n2nM|aB8HDx;xdBL&Mt6x!pr^a#CUg-svJpCr^9j$zF=@_n0ox2W_|4q$ zP$(T{CvRb28l3`=ooWPKD-%z!M(v0?U3)yrI?nxaSbNQ|op@Jk)avWWwdN|z_dhT{ z_map*@E^2@@JlR4s3|tl)yE;&!z3emYWQfQM7x_s^_Dz_g?cvyeo{HQ)a~@NLXfVP z4KQ49kZNX|Wex?ckfMz8zq>iW?R7P-9K}ia^Osm1tXPT{pCH#L;I%=bYchGD?VCYV zys=7o?0@&a0^9~f#59$*_2`q)f)8bw7P=_p1OnkbE;KOE8#8H-AciZJi9*YnBb|#7 zZQ-gMuT(mKOqveppvH5>^avwrOZ2|*sOy~S>6n&&w1gU*v$ZjdvN3lp3mB?xHr zdC?9w<&%dqm`dcHWP3d9FzF_F(Le?t621!Pw1WE8RCjd>sd1AypawGy*CZivw%0A5 zQ~kxU5}!uX8$%L{_wY{+VS=(Mb&*>bO>1mOvQ?X#9r!>nRG8uxUZI^2aYz_DgnX5* zV$cx8dgsBo+2Sy{#4u$tYZLVzo6c!Fp-xJXEwMBv(0^5=sl!pX_hdFUQCnhg$88^; zuTEMhk7E@n&h)mwu>IMcxnk+sOze4^wag{uBrRZd_id4K%U|il&((m8C~=zfr_WVN zO}p%9PpSTSs3MB1NBEk`vN3F=%D>}tnlIiSF1L6b7)+w?7yT9Q0lp}o4vzUxX)wh8 ziua4_-?ooIw;DzB98-`n7cg=yagfc(`VZC6yU3ftZUIp(%n}2}qxH1QrHFWM_{+af z;u$yrdE2traOW@}AzGR6KdHiPgrIM^Nso~%#>o`!Z~2@Y4)VT1naEY7iB{?;)~C41 z-)?S8W`|8gy~rtgk6@&G#W3d30H08llvop*LucAN8s@?RvZZ=07_X8!^C2Ptt1uS> zy!j8+@Jao)FPX1upr?031kH{=zv*~oK> z_o}(-K&qVMWK)qm=0wUk#saZZ(MI^Mn$Ft!#SpNH#t#dK!u_}=8p0Te@RMk*%?aZnhCRxyJ}FZfo&5o_;AX(b&^(3Z&lo?W1G2QGo;> zq2?G#iieziYV`+`fUa02kPgob(}=V5PD;(aoqU6~%*L|7^{SSyF`LfauEq`cW5OiZ zF;5nnK=DRWQ_s8NZNt9&)d^rCQ`gYN-jaA`=Z`}3gJRpYrpw8o>}}eNFkvn!?OgDD zeFFWIvY34;APX=mZGNx+Vf?b8Kpe=BpAnX4IaQ>K9EE@t)UOv4356cD_1?_c9Y^7_GB|TP6NUub)(6}HcPfl z=6`mX&!Kas*5zzA{ASBr8R@cO$?_UyChdS8Z8KG3j##q|2wQY(h$nn}&8Rqs&jHNWU zFQOc!2`sC3J&B{gLh_zG{IfxIJ<;oZ6%40I&v&DBV`m!4WLBGL7R@AoY;*g4iPSbJ z?tgrN#8sks-sxZ;Ay~|wr}tiKK+66%hFN-2pp+hEX~{yl%V%iQ1{}z^h582k*E;Am zV*X8LK6x0_I)SIoj$v8F#AHeA!xM`EtK>AG_%nWE&gS!Jsi5ig|9>z=Wuv(R{961k h1DF)UO+@U)|e`|4713pZaR_@yScK{tuRSZ{q*} literal 0 HcmV?d00001 diff --git a/admin/public/icons/icon-maskable-512.png b/admin/public/icons/icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..ae98c76043bc1c19dde093220bf2e8289683a0be GIT binary patch literal 8371 zcmc&)dt6l2_CF&CCemp5el(OBCfVepuuRYRL{mUb1tpz)T}Ul56H>H8X<>2&6N}7j zc#4WRmKq4*>7fzVYr{0)h)Sd3V;;#|<&2YOI%ogZ+K2J>&+mS&-p}p+GtBI>&wA~( zzU#4he@1L{Xy?A28HNd+I%VQ4hUoxbJ20K_PkP@8-y*YDTf9u-X zMHhAio^aH7KdY>pJMght#RnFj+VSbA*i~CzfKTtT;>5s{-D|x^)&^C7y*TLb;m6Oe zKEC?6H(icDwDFp>=<9ci+`s?8#@$+wUTH|U5cfoD>8-1M%Xi|JvBxTV zP4>O?McmL%_}33l)>KvY%c?$BnP}O)@aGj-)isr)BTA!%QdV-r zkxioQnFoV2Wct%Ek<4W5ahzx|$ZG{#kbn7k660TP5;Prjbb}DLmwd+58|bU)jof2Q zJIQR1tq+4Fgi_8Xn*79VHS!2mJ~HLRqL)o>&~)^3iYBl4m-h*_K>uA`&$$CGPW)~ag7bZw@$NKlj6_UYzlFx}L0e+oI$fN${ zm7>~DF`|LA-uP}{yHextnr+OzvgP9Ziqtsy?tVpTMRym!4N|0Qe&O8<$xi=)jIfjM zAMtDQh?wISz*Iz@_Ak#CQ|>J9Azl5{$4g$n;caE|G`|hf4v-)H)O}~Tv3@G89)tft z*p0Gbr~lnQ)0s~HFPzEt$vvz3tlxNVi2R!0FLhJ=nJxHttj+nB`+cI1V7L>be`4tF zzGrKj?gYAyMEV`;T0Pg_?|XeE?VT-CNt@r_avh}U4g#$I6TNxYJsLr$9wayNm-v_65%`2}8&bduXB6Im=8FOPKLY-#mTf7u38g2h!#?%~6UYoyIVQ>mUDj+}j|U@PN4W zQn&t>@D&S@71Z-3!*f2K<}*^;psD<31X9SB&!b#9MeNcRFa!Cx9Rk@20r0vn7ayj3 zqL&Bv0X}Y4$%bAtxF?ar&@+RRHPM`C;|g)-gDd*GRNa1($*oZZH8NBwAh0ko(>EqGh&tSbu>iunL35v&G$F#~fw#5TL_jUc-%SJr=wN*Tv<T6^)EoQAT=Y6W3;0V4sw1#U$6sxh}s^^P4G8zV1XF zQpS^<_rL^(N#xPKI+E%!pgp)4!9wPFxa*W=jJ>_)FbgSVDj{VOp{b(Wu6c$L*iG^` z{dAPB)Edih(1=h7p4s zNKu5Xn3a+|dTm2qFaQO$J|KpLF8~M4miaM|-Iq8uLl_}vA9n=Sj>|>p{6flnauHl& zq*MX?6Ql{if1F3%529@-Q?!`9Z7&T8UZ7+Y2A} zWKw+tsErc2RX`}%OqR@~=dSL-h?zg3$JCnH@eDdkQhIMlDLH#cF9VBb7)KU*^hU8) z52?t(gPao_nkV7B7mznWy$uSy3Q%HI*Q(tem&0>AAHfJ(BjI>Uj*}j?yrH&cd?^^uqE!&A!kjD^&3I#e1K&E0kybd+dRBVgoD`ZIL!w;ZV?8{2 zXh|9cquizu^H%B-B-;Tin6QCNGZAk$NOpJ-+NO+)Vsr>2h1fArdRh3z%r*9mCcw6KEyVogK^VCh?eBWjy%}PPt79fw z@Vp?sLbszBh^q?Vi%*kn(l#jh-Sk#GCUPl4pE5xoJ z!z}XfA3V&A^Kh=;zUu@G`(Roj!$oVTZmAR&7=BRh2EP}Jh8X`#N-;K`OG@_0^LDP!m)#feb;@ea38RQutrWCG26D(sCV zC7@$I=%^bhjR+3UmgfL^6w(m%S-1x>GQ`qQ-C)UfP>53@dvIPbNxWJ&U0NL!{)yZO z+d)UU5wy#Ikm)*??7EHWRMAr{rG|+N3jacG>d^HCvITxG!gS6Jlk|JLgUgh5Mu*x5 zk(y`4$jobC|HABd86ubu@bpE^6neY~5UHE7QlrGY+#e%5k0m&G(abVa+~*&^228SAgHV(+ePvXJ!Deq>wt2aII4=OkN=_5UZ?lNToa#9Hq|8Z=tE{EBRA5)&Z~s z5JfIwHAset@u9k8$#=i*HK_=kIk*u^La>FaV|bN3AQ)V!0JcqVA*gC*<5}+|<+&E? zIvQ|bn-DwZWcCR7CJ3`qwYiAfMuQXT3NS@k6i6>Y{t?uBsjAaSoHazZUdjy&-$DI( zyU4ADM3L73PWeeaUy9g+czy_T9DJ$*x;{_F_5}b!!vOZs&*%`HPD&32r-=k(&!K~g zIfAiAMia*CttPwebGfW>3q&WXIbW<@d*9{*Ty<#jG-8?q28(oZPzf0GxlPOoE58f8 zpM41wPnNW>{aJL!FtOrn##JGwCtylFHvr(t!nbMyMiz64q4{J4a9t*ln{8zEM6yNX z6ST}}2j{yi#D;_>&!osPjE3)acxF@$b#m9<>@67=kWnlt0B|C&q0UzJ+%&NKKqx;< zN+$8=u21UOmx4a=^)smMC=E*7r%M!TU)H%Of|S`YM@Bj|#gzPSg6HtNg;*3!!cmR! zAXsr^IaeMe6i|B>%IToma528i*UOJOb>!PV$yX|>C!Rso0K5?^_mF8b1L{ALI}We^ zo7=Nty5s^a8|f@5uYr{854BGx)p@B4H0FM_Y8%vxGpY5VRRIV)<~%P3dKF!J8inIcK15HOK>8Y%e(1Msg)L7!w;fDHJL3R)Rp7NSvw!I2eIVX6%}A zWEFhsNO&l%m}+G1D+svn?3%}c_&72Oi1(LFz;%kqufKWm!@xY`ze0c(Jm_cSJZ>Bb7mo8N5-#$u-@MqkO(FL-O_qZpx);Jt7Wp{}c+%WvSV6xr>J}Oa+~broGo zY)5p5*G~q$xHSB*e0t1qF|7#4r0xmC`s0_&|M9xh_DgOd7l-pw_^dde(bPrQ&lRaR z%ex&s3ru23YQU3qx;!yTO2)z%!Jwzdov(7arjq*~43;1{0|o%wSNlG>>+LTEcgth- z{4|=&%3b7Ko#Xn#zONk%EVtV%5m=~W%=_v|YFcQ5eyQa19dQ8UVnr4wde3E)4cohy ztJP@1&cp<5H&`*vmT@Y6GI>Pvs8nsmLfX5Z+aEN&waH_;easAlORP0hq!h!aKi8dO zpv5;i5#UDH5jvY;;DSbIvl~^}FmAX2ds*qJuVGT+18XnzSn&-lc=ZMs-GSo41XgT1 zd(Cc`OFbz^?t9U0m`7Lx_)U|LrxGk|Id%DRxi7Ri5e?|<5mwsN;6l<*w2LCGw||=g zkIl3l=hCV&^QTW@rPWUCK1}<_x)ad8fj@!yOb2+3f@T+OwnPWNi+lM8XNYH^e zNpJaJhb*|`ly1qJ+GSs;8JyGINVIe~;U1b@4!$0x4A`_T^8M}bemmcS;m0r!y$#@| zOUuHCOAuErE;NsW6pK*5JD`&X{J{AOS7Z7?7b9xGG;!t1{Xn{h@131x0Y+@DG8fS3 zxjutqA@s-rr8|P5&newwBJ^M@Xn#~n4%^Aq+uJtu#x<9jHl^gKT^o=Iy}Q6aE%DDH zd#*-|{KnGktIhe@Kp(^^vlVqINxZhv*HRBmA%B8laU_*@>mgTCGu(S*(FV@}MQK*e z?&`oWFEH7zO#2}n*xcvu$owZ>`H@%u7cR{s$}30pCtl2G?)g}`r(h&Qqo}(WcgKYa z6~x0QVG7Dohj>D=-tiL)(HMNU{&|6QcTTK~0Kq4{bZVhE^Z+G)QE84V^HqG{A9SOq z!}yE6JJ9&URW7}Epg~e|*LSL0@7aa9mSZV|Em`%~_bM#AV&I< { + 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; +} diff --git a/admin/scripts/generate-icons.mjs b/admin/scripts/generate-icons.mjs new file mode 100644 index 0000000..89cd01f --- /dev/null +++ b/admin/scripts/generate-icons.mjs @@ -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}`); +} diff --git a/admin/src/App.css b/admin/src/App.css new file mode 100644 index 0000000..4f8fbcc --- /dev/null +++ b/admin/src/App.css @@ -0,0 +1,123 @@ +/* Shell layout — mobile-first (390x844 reference), plain CubeCraft styling. */ + +.admin-shell { + display: flex; + flex-direction: column; + min-height: 100%; +} + +.admin-header { + background: var(--nq-a-surface); + border-bottom: 1px solid var(--nq-a-border); + padding: 14px 16px; +} + +.admin-title { + margin: 0; + font-size: 24px; + font-weight: 800; + letter-spacing: -0.02em; +} + +.admin-subline { + margin: 2px 0 0; + font-size: 12px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.admin-panel { + flex: 1; + padding: 14px 16px 92px; /* bottom padding clears the 70px tab bar */ +} + +/* A built screen supplies its own header and 92px-padded scroll column. */ +.admin-panel--screen { + padding: 0; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.admin-tabbar { + position: fixed; + inset: auto 0 0; + display: flex; + min-height: calc(var(--nq-a-tabbar-height) + 12px); + padding: 8px 4px calc(12px + env(safe-area-inset-bottom)); + background: var(--nq-a-surface); + border-top: 1px solid var(--nq-a-border); +} + +.admin-tab { + flex: 1; + border: 0; + background: none; + font-family: inherit; + font-size: 12px; + font-weight: 600; + color: var(--nq-a-ink-secondary); + cursor: pointer; + padding: 6px 4px; +} + +.admin-tab.active { + font-weight: 700; + color: var(--nq-brand-blue); +} +/* Auth screens (login, errors, refusal). */ + +.admin-login, +.admin-error, +.admin-refusal, +.admin-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 100%; + padding: 32px 16px; + text-align: center; +} + +.admin-login-button { + margin-top: 8px; + padding: 12px 24px; + border: none; + border-radius: 8px; + background: var(--nq-a-accent, #2563eb); + color: #fff; + font-size: 15px; + font-weight: 700; + cursor: pointer; +} + +.admin-error, +.admin-refusal { + color: var(--nq-a-ink, inherit); +} + +.admin-refusal h1, +.admin-error h1 { + margin: 0; + font-size: 20px; +} + +.admin-refusal p, +.admin-error p { + margin: 0; + max-width: 34em; + font-size: 14px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} diff --git a/admin/src/App.test.tsx b/admin/src/App.test.tsx new file mode 100644 index 0000000..7a38e6a --- /dev/null +++ b/admin/src/App.test.tsx @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import App from "./App"; + +describe("NestQuest Admin shell", () => { + beforeEach(() => { + // The Today tab fetches the admin snapshot; keep the shell tests offline. + vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("renders the title and all four tab labels", () => { + render(); + + 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(); + + expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("tab", { name: "History" })); + + expect(screen.getByRole("heading", { name: "History" })).toBeTruthy(); + expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder"); + expect(screen.getByRole("tab", { name: "History" }).getAttribute("aria-selected")).toBe( + "true", + ); + }); + + it("the Tasks tab renders the definitions screen", () => { + render(); + + fireEvent.click(screen.getByRole("tab", { name: "Tasks" })); + + expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy(); + expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder"); + }); +}); \ No newline at end of file diff --git a/admin/src/App.tsx b/admin/src/App.tsx new file mode 100644 index 0000000..5f44fad --- /dev/null +++ b/admin/src/App.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import "./App.css"; +import { TransitionsProvider } from "./api/useTransitions"; +import DefinitionsTab from "./definitions/DefinitionsTab"; +import HistoryTab from "./history/HistoryTab"; +import ScheduleTab from "./schedule/ScheduleTab"; +import TodayTab from "./today/TodayTab"; + +const TABS = [ + { id: "today", label: "Today" }, + { id: "tasks", label: "Tasks" }, + { id: "schedule", label: "Schedule" }, + { id: "history", label: "History" }, +] as const; + +type TabId = (typeof TABS)[number]["id"]; + +export default function App() { + const [active, setActive] = useState("today"); + + return ( + +
+ {/* Every tab is a built screen that owns its header (ADMIN-SPEC §1); the + generic shell header stays only as a visually hidden page heading. */} +
+

NestQuest Admin

+

Admin console

+
+
+ {active === "today" ? ( + + ) : active === "tasks" ? ( + + ) : active === "schedule" ? ( + + ) : ( + + )} +
+ +
+
+ ); +} \ No newline at end of file diff --git a/admin/src/AppRoutes.test.tsx b/admin/src/AppRoutes.test.tsx new file mode 100644 index 0000000..5ba0287 --- /dev/null +++ b/admin/src/AppRoutes.test.tsx @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, 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(); + expect(screen.getByTestId("login-screen")).toBeTruthy(); + expect( + screen.getByRole("button", { name: /sign in with authentik/i }), + ).toBeTruthy(); + }); + + function probeCalls(fetchMock: ReturnType): string[] { + return fetchMock.mock.calls + .map(([url]) => String(url)) + .filter((url) => url.includes("/api/")); + } + + it("probes exactly GET /api/v1/admin/ping and no non-admin path", async () => { + storeTokens({ access_token: "at-123", expires_in: 600 }); + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 500 })); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("probe-error-screen")).toBeTruthy(); + }); + const calls = probeCalls(fetchMock); + expect(calls).toEqual([`${config.apiBaseUrl}/api/v1/admin/ping`]); + const [, init] = fetchMock.mock.calls[0]; + expect(init.method ?? "GET").toBe("GET"); + expect(init.headers.Authorization).toBe("Bearer at-123"); + for (const url of calls) { + expect(new URL(url, "http://localhost").pathname.startsWith("/api/v1/admin/")).toBe(true); + } + }); + + 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(); + + 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(); + } + expect(String(fetchMock.mock.calls[0][0])).toBe( + `${config.apiBaseUrl}/api/v1/admin/ping`, + ); + }); + + 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(); + + await waitFor(() => { + expect(screen.getByTestId("refusal-screen")).toBeTruthy(); + }); + expect(screen.getByText(/not in the nestquest-admins group/i)).toBeTruthy(); + expect(screen.queryByRole("tab", { name: "Today" })).toBeNull(); + }); + + it("keeps the login flow when the probe answers 401", async () => { + storeTokens({ access_token: "stale", expires_in: 600 }); + const assign = vi.fn(); + vi.stubGlobal("location", { ...window.location, assign }); + const fetchMock = vi.fn().mockImplementation(async (url: string) => + String(url).includes("/api/v1/admin/ping") + ? new Response("", { status: 401 }) + : jsonResponse({ + issuer: config.issuer, + authorization_endpoint: "https://auth.example.com/authorize/", + token_endpoint: "https://auth.example.com/token/", + userinfo_endpoint: "https://auth.example.com/userinfo/", + end_session_endpoint: "https://auth.example.com/end-session/", + jwks_uri: "https://auth.example.com/jwks/", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("login-screen")).toBeTruthy(); + }); + expect(assign).toHaveBeenCalledTimes(1); + const redirect = new URL(assign.mock.calls[0][0] as string); + expect(redirect.origin + redirect.pathname).toBe("https://auth.example.com/authorize/"); + expect(probeCalls(fetchMock)).toEqual([`${config.apiBaseUrl}/api/v1/admin/ping`]); + expect(screen.queryByRole("tab", { name: "Today" })).toBeNull(); + expect(screen.queryByTestId("probe-error-screen")).toBeNull(); + }); + + for (const status of [404, 500]) { + it(`shows the error screen, not the shell, when the probe answers ${status}`, async () => { + storeTokens({ access_token: "at-123", expires_in: 600 }); + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status })); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("probe-error-screen")).toBeTruthy(); + }); + expect(screen.getByText(new RegExp(`answered ${status}`))).toBeTruthy(); + expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Sign out" })).toBeTruthy(); + expect(screen.queryByRole("tab", { name: "Today" })).toBeNull(); + expect(screen.queryByTestId("refusal-screen")).toBeNull(); + }); + } + + it("shows the error screen, not the shell, when the probe request fails", async () => { + storeTokens({ access_token: "at-123", expires_in: 600 }); + const fetchMock = vi.fn().mockRejectedValue(new TypeError("Failed to fetch")); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("probe-error-screen")).toBeTruthy(); + }); + expect(screen.getByText(/could not be reached/i)).toBeTruthy(); + expect(screen.queryByRole("tab", { name: "Today" })).toBeNull(); + }); + + it("re-probes on retry and enters the shell once the probe succeeds", async () => { + storeTokens({ access_token: "at-123", expires_in: 600 }); + // After the retry the ping succeeds; the shell's own tab loads stay pending. + let pings = 0; + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (!String(url).endsWith("/api/v1/admin/ping")) return new Promise(() => {}); + pings += 1; + return Promise.resolve(new Response("", { status: pings === 1 ? 500 : 200 })); + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("probe-error-screen")).toBeTruthy(); + }); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + await waitFor(() => { + expect(screen.getByRole("tab", { name: "Today" })).toBeTruthy(); + }); + expect(String(fetchMock.mock.calls[1][0])).toBe( + `${config.apiBaseUrl}/api/v1/admin/ping`, + ); + }); + + 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(); + expect(screen.getByTestId("config-error-screen")).toBeTruthy(); + } finally { + (config as { clientId: string | undefined }).clientId = original; + } + }); +}); diff --git a/admin/src/AppRoutes.tsx b/admin/src/AppRoutes.tsx new file mode 100644 index 0000000..9959c19 --- /dev/null +++ b/admin/src/AppRoutes.tsx @@ -0,0 +1,211 @@ +/** + * 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, ApiUnauthorizedError, 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: "probe-error"; message: string } + | { kind: "app" }; + +function currentPath(): string { + return window.location.pathname.replace(/\/+$/, "") || "/"; +} + +const ADMIN_PROBE_PATH = "/api/v1/admin/ping"; + +async function probeApi(setScreen: (screen: Screen) => void) { + // The admin plane's authorization probe: only a 2xx enters the shell, so a + // non-admin (403) sees the explicit refusal and any other failure an error. + try { + const response = await apiFetch(ADMIN_PROBE_PATH, {}, config); + if (response.ok) { + setScreen({ kind: "app" }); + return; + } + setScreen({ + kind: "probe-error", + message: `The admin API answered ${response.status}. The admin console cannot load.`, + }); + } 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; + } + if (error instanceof ApiUnauthorizedError) { + // apiFetch has already started the login redirect. + setScreen({ kind: "login" }); + return; + } + setScreen({ + kind: "probe-error", + message: "The admin API could not be reached. Check your connection and try again.", + }); + } +} + +function LoginScreen({ onLogin }: { onLogin: () => void }) { + return ( +
+

NestQuest Admin

+

Sign in to continue

+ +
+ ); +} + +export default function AppRoutes() { + const [screen, setScreen] = useState({ 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 ( +
+ Loading… +
+ ); + case "login": + return ; + case "config-error": + return ( +
+

Configuration error

+

{screen.message}

+
+ ); + case "auth-error": + return ( +
+

Sign-in failed

+

{screen.message}

+ +
+ ); + case "refusal": + return ( +
+

Access refused

+

{screen.message}

+ +
+ ); + case "probe-error": + return ( +
+

Admin console unavailable

+

{screen.message}

+ + +
+ ); + case "app": + return ; + } +} diff --git a/admin/src/api/client.test.ts b/admin/src/api/client.test.ts new file mode 100644 index 0000000..9cd828a --- /dev/null +++ b/admin/src/api/client.test.ts @@ -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); + }); +}); diff --git a/admin/src/api/client.ts b/admin/src/api/client.ts new file mode 100644 index 0000000..4ad468b --- /dev/null +++ b/admin/src/api/client.ts @@ -0,0 +1,93 @@ +/** + * Minimal fetch wrapper for the NestQuest API. + * + * - Attaches `Authorization: Bearer ` 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 { + headers?: Record; +} + +/** + * Redirect the browser to Authentik to start a new login. + * Exported for testing injection. + */ +export async function triggerLogin(app: AppConfig = config): Promise { + 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 { + 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; +} diff --git a/admin/src/api/definitions.ts b/admin/src/api/definitions.ts new file mode 100644 index 0000000..b38f83b --- /dev/null +++ b/admin/src/api/definitions.ts @@ -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; + +export const DEFINITIONS_PATH = "/api/v1/admin/quest-definitions"; +export const CHILDREN_PATH = "/api/v1/admin/children"; + +/** A non-2xx save response; `detail` is the API's message when it sent one. */ +export class ApiRequestError extends Error { + constructor( + readonly status: number, + readonly detail: string | null, + ) { + super(detail ?? `Request failed (HTTP ${status}).`); + this.name = "ApiRequestError"; + } +} + +/** FastAPI sends `detail` as a string (core errors) or a list of {msg} (body validation). */ +function readDetail(body: unknown): string | null { + if (!body || typeof body !== "object" || !("detail" in body)) return null; + const { detail } = body as { detail: unknown }; + if (typeof detail === "string") return detail; + if (Array.isArray(detail)) { + const messages = detail + .map((item) => (item && typeof item === "object" ? (item as { msg?: unknown }).msg : null)) + .filter((msg): msg is string => typeof msg === "string"); + return messages.length > 0 ? messages.join("; ") : null; + } + return null; +} + +export async function readJson(response: Response): Promise { + 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 { + const body = await readJson<{ definitions: QuestDefinition[] }>( + await apiFetch(DEFINITIONS_PATH), + ); + return body.definitions; +} + +export async function fetchChildren(): Promise { + const body = await readJson<{ children: AdminChild[] }>(await apiFetch(CHILDREN_PATH)); + return body.children; +} + +function sendJson(path: string, method: "POST" | "PATCH", body: unknown): Promise { + return apiFetch(path, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +export async function createDefinition(body: DefinitionCreateBody): Promise { + return readJson(await sendJson(DEFINITIONS_PATH, "POST", body)); +} + +export async function updateDefinition( + id: number, + body: DefinitionEditBody, +): Promise { + return readJson(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 { + 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; +} diff --git a/admin/src/api/events.test.ts b/admin/src/api/events.test.ts new file mode 100644 index 0000000..33a24fd --- /dev/null +++ b/admin/src/api/events.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { waitFor } from "@testing-library/react"; +import { EVENTS_PATH, SseParser, openTransitionStream, type TransitionEvent } from "./events"; +import { frame, testStream, type TestStream } from "./testStream"; +import { storeTokens } from "../auth/oidc"; + +const COMPLETED = { + child_id: 1, + child_name: "Declan", + instance_id: 12, + quest_title: "Make bed", + window: "morning", + due_date: "2026-09-23", + due_time: null, + occurred_at: "2026-09-23T11:04:00Z", +}; +const DAY_COMPLETE = { + child_id: 2, + child_name: "Maeve", + quests_due: 4, + quests_completed: 4, + occurred_at: "2026-09-23T12:00:00Z", +}; + +describe("SseParser", () => { + it("yields every frame from one chunk", () => { + const parser = new SseParser(); + const events = parser.push( + frame("nestquest_quest_completed", COMPLETED) + + frame("nestquest_child_day_complete", DAY_COMPLETE), + ); + expect(events).toEqual([ + { type: "nestquest_quest_completed", payload: COMPLETED }, + { type: "nestquest_child_day_complete", payload: DAY_COMPLETE }, + ]); + }); + + it("buffers a frame split across two chunks", () => { + const parser = new SseParser(); + const text = frame("nestquest_quest_uncompleted", COMPLETED); + const cut = text.indexOf("Make"); + expect(parser.push(text.slice(0, cut))).toEqual([]); + expect(parser.push(text.slice(cut))).toEqual([ + { type: "nestquest_quest_uncompleted", payload: COMPLETED }, + ]); + }); + + it("buffers a split inside the event name and the blank-line terminator", () => { + const parser = new SseParser(); + const text = frame("nestquest_quest_missed", COMPLETED); + expect(parser.push(text.slice(0, 12))).toEqual([]); + expect(parser.push(text.slice(12, -1))).toEqual([]); + expect(parser.push(text.slice(-1))).toEqual([ + { type: "nestquest_quest_missed", payload: COMPLETED }, + ]); + }); + + it("treats a CRLF split across chunks as one line break", () => { + const parser = new SseParser(); + const text = `event: nestquest_quest_completed\r\ndata: ${JSON.stringify(COMPLETED)}\r\n\r\n`; + const cut = text.indexOf("\n"); // chunk ends on "\r" + expect(parser.push(text.slice(0, cut))).toEqual([]); + expect(parser.push(text.slice(cut))).toEqual([ + { type: "nestquest_quest_completed", payload: COMPLETED }, + ]); + }); + + it("drops malformed, unknown and comment frames without throwing", () => { + const parser = new SseParser(); + const events = parser.push( + "event: nestquest_quest_completed\ndata: not-json\n\n" + + "event: nestquest_quest_completed\ndata: [1,2]\n\n" + + "event: something_else\ndata: {}\n\n" + + ": keep-alive\n\n" + + "data: {}\n\n" + + frame("nestquest_quest_completed", COMPLETED), + ); + expect(events).toEqual([{ type: "nestquest_quest_completed", payload: COMPLETED }]); + }); +}); + +describe("openTransitionStream", () => { + let fetchMock: ReturnType; + let streams: TestStream[]; + let stop: (() => void) | null; + + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + streams = []; + stop = null; + fetchMock = vi.fn(() => { + const stream = testStream(); + streams.push(stream); + return Promise.resolve(stream.response); + }); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + stop?.(); + vi.unstubAllGlobals(); + }); + + it("sends the token in the Authorization header, never in the URL", async () => { + stop = openTransitionStream({ onTransition: () => {} }); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toMatch(new RegExp(`${EVENTS_PATH}$`)); + expect(String(url)).not.toContain("at-123"); + expect(init.headers.Authorization).toBe("Bearer at-123"); + expect(init.headers.Accept).toBe("text/event-stream"); + }); + + it("delivers transitions from frames split across chunks", async () => { + const seen: TransitionEvent[] = []; + stop = openTransitionStream({ onTransition: (event) => seen.push(event) }); + await waitFor(() => expect(streams).toHaveLength(1)); + const text = frame("nestquest_quest_completed", COMPLETED); + streams[0].push(text.slice(0, 20)); + streams[0].push(text.slice(20)); + await waitFor(() => + expect(seen).toEqual([{ type: "nestquest_quest_completed", payload: COMPLETED }]), + ); + }); + + it("stop aborts the request and cancels the body", async () => { + stop = openTransitionStream({ onTransition: () => {} }); + await waitFor(() => expect(streams).toHaveLength(1)); + const signal = fetchMock.mock.calls[0][1].signal as AbortSignal; + stop(); + expect(signal.aborted).toBe(true); + await waitFor(() => expect(streams[0].cancelled()).toBe(true)); + }); + + it("reconnects after the stream drops", async () => { + const seen: TransitionEvent[] = []; + stop = openTransitionStream({ onTransition: (event) => seen.push(event), minDelayMs: 5 }); + await waitFor(() => expect(streams).toHaveLength(1)); + streams[0].close(); + await waitFor(() => expect(streams).toHaveLength(2)); + streams[1].push(frame("nestquest_quest_missed", COMPLETED)); + await waitFor(() => expect(seen).toHaveLength(1)); + }); + + it("does not reconnect after stop, even with a reconnect pending", async () => { + stop = openTransitionStream({ onTransition: () => {}, minDelayMs: 20 }); + await waitFor(() => expect(streams).toHaveLength(1)); + streams[0].close(); + stop(); + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("gives up on a 403 refusal instead of retrying", async () => { + fetchMock.mockImplementation(() => Promise.resolve(new Response("", { status: 403 }))); + stop = openTransitionStream({ onTransition: () => {}, minDelayMs: 5 }); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setTimeout(resolve, 40)); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/admin/src/api/events.ts b/admin/src/api/events.ts new file mode 100644 index 0000000..bf6b572 --- /dev/null +++ b/admin/src/api/events.ts @@ -0,0 +1,196 @@ +/** + * Live transition stream: GET /api/v1/admin/events (text/event-stream). + * + * The admin JWT travels in the Authorization header (via apiFetch), so the + * stream is read with fetch + a ReadableStream reader — EventSource cannot + * set headers, and the token must never go into a URL. + */ +import { ApiForbiddenError, ApiUnauthorizedError, apiFetch } from "./client"; + +export const EVENTS_PATH = "/api/v1/admin/events"; + +/** The four documented transitions, as the API names them on the wire. */ +export const TRANSITION_TYPES = [ + "nestquest_quest_completed", + "nestquest_quest_uncompleted", + "nestquest_quest_missed", + "nestquest_child_day_complete", +] as const; + +export type TransitionType = (typeof TRANSITION_TYPES)[number]; + +/** Quest transitions carry the instance; a day-complete carries the child's counts. */ +export interface QuestTransitionPayload { + child_id: number; + child_name: string | null; + instance_id: number; + quest_title: string | null; + window: string; + due_date: string; + due_time: string | null; + occurred_at: string; +} + +export interface ChildDayCompletePayload { + child_id: number; + child_name: string | null; + quests_due: number; + quests_completed: number; + occurred_at: string; +} + +export type TransitionEvent = + | { + type: + | "nestquest_quest_completed" + | "nestquest_quest_uncompleted" + | "nestquest_quest_missed"; + payload: QuestTransitionPayload; + } + | { type: "nestquest_child_day_complete"; payload: ChildDayCompletePayload }; + +function isTransitionType(value: string): value is TransitionType { + return (TRANSITION_TYPES as readonly string[]).includes(value); +} + +/** + * Incremental SSE frame parser. Feed it decoded text in whatever chunks the + * network delivers; it buffers a partial line or frame until it completes. + * Comments (keep-alives), unknown event types and frames whose data is not a + * JSON object are dropped, never thrown. + */ +export class SseParser { + private buffer = ""; + private eventType = ""; + private data: string[] = []; + /** A chunk ended on "\r": a leading "\n" in the next chunk is the same line break. */ + private pendingCr = false; + + push(text: string): TransitionEvent[] { + if (this.pendingCr && text.startsWith("\n")) text = text.slice(1); + this.pendingCr = text.endsWith("\r"); + this.buffer += text; + const lines = this.buffer.split(/\r\n|\r|\n/); + this.buffer = lines.pop() ?? ""; + const events: TransitionEvent[] = []; + for (const line of lines) { + const event = this.line(line); + if (event) events.push(event); + } + return events; + } + + private line(line: string): TransitionEvent | null { + if (line === "") return this.dispatch(); + if (line.startsWith(":")) return null; + const colon = line.indexOf(":"); + const field = colon === -1 ? line : line.slice(0, colon); + let value = colon === -1 ? "" : line.slice(colon + 1); + if (value.startsWith(" ")) value = value.slice(1); + if (field === "event") this.eventType = value; + else if (field === "data") this.data.push(value); + return null; + } + + private dispatch(): TransitionEvent | null { + const type = this.eventType; + const data = this.data.join("\n"); + this.eventType = ""; + this.data = []; + if (!isTransitionType(type)) return null; + let payload: unknown; + try { + payload = JSON.parse(data); + } catch { + return null; + } + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + return { type, payload } as TransitionEvent; + } +} + +export interface TransitionStreamOptions { + onTransition: (event: TransitionEvent) => void; + /** Reconnect delay after a drop: starts at `minDelayMs`, doubles up to `maxDelayMs`. */ + minDelayMs?: number; + maxDelayMs?: number; +} + +function wait(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const timer = setTimeout(done, ms); + signal.addEventListener("abort", done, { once: true }); + function done() { + clearTimeout(timer); + signal.removeEventListener("abort", done); + resolve(); + } + }); +} + +/** Read one connection until the body ends (or the signal aborts). */ +async function readOnce( + signal: AbortSignal, + onTransition: (event: TransitionEvent) => void, +): Promise { + const response = await apiFetch(EVENTS_PATH, { + signal, + headers: { Accept: "text/event-stream" }, + }); + if (!response.ok || !response.body) { + throw new Error(`Event stream request failed (HTTP ${response.status}).`); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const parser = new SseParser(); + const cancel = () => void reader.cancel().catch(() => {}); + signal.addEventListener("abort", cancel, { once: true }); + try { + for (;;) { + const { value, done } = await reader.read(); + if (done || signal.aborted) break; + for (const event of parser.push(decoder.decode(value, { stream: true }))) { + if (signal.aborted) break; + onTransition(event); + } + } + } finally { + signal.removeEventListener("abort", cancel); + reader.releaseLock(); + } +} + +/** + * Subscribe to the admin transition stream, reconnecting with a bounded + * backoff after a drop. Returns a stop function that aborts the open request + * and any pending reconnect. A 401/403 stops the stream for good: apiFetch + * has already started a login (401) or the account is refused (403). + */ +export function openTransitionStream({ + onTransition, + minDelayMs = 1_000, + maxDelayMs = 30_000, +}: TransitionStreamOptions): () => void { + const controller = new AbortController(); + const { signal } = controller; + + void (async () => { + let delay = minDelayMs; + while (!signal.aborted) { + try { + await readOnce(signal, onTransition); + // A connection that was accepted and then ended starts the backoff over. + delay = minDelayMs; + } catch (error: unknown) { + if (signal.aborted) return; + if (error instanceof ApiUnauthorizedError || error instanceof ApiForbiddenError) return; + // Dropped or refused connection: fall through to the backoff. + } + if (signal.aborted) return; + await wait(delay, signal); + delay = Math.min(delay * 2, maxDelayMs); + } + })(); + + return () => controller.abort(); +} diff --git a/admin/src/api/history.ts b/admin/src/api/history.ts new file mode 100644 index 0000000..eeecda8 --- /dev/null +++ b/admin/src/api/history.ts @@ -0,0 +1,85 @@ +/** + * Typed client for the admin history route (/api/v1/admin/history): the + * append-only event log over a closed due-date range for one filter. + */ +import { apiFetch } from "./client"; +import { ApiRequestError, readJson } from "./definitions"; + +export type HistoryFilter = "all" | "reversals" | "missed"; + +export type HistoryEventType = "completed" | "uncompleted" | "missed"; + +export interface HistoryRow { + /** UTC ISO stamp; null for a derived missed row (there is no event). */ + occurred_at: string | null; + event_type: HistoryEventType; + child_name: string; + child_id: number; + quest_title: string; + instance_id: number; + window: string; + /** "YYYY-MM-DD", the day the task was owed. */ + due_date: string; + /** Local wall-clock "HH:MM", or null. */ + due_time: string | null; + /** e.g. "panel (Declan)", "joshua (admin)", "nightly sweep". */ + actor: string; + was_on_time: boolean | null; +} + +export interface HistoryResponse { + filter: HistoryFilter; + start: string; + end: string; + count: number; + rows: HistoryRow[]; +} + +export const HISTORY_PATH = "/api/v1/admin/history"; + +/** `start`/`end` are inclusive "YYYY-MM-DD" due dates. */ +export async function fetchHistory( + filter: HistoryFilter, + start: string, + end: string, +): Promise { + const params = new URLSearchParams({ start, end, filter }); + return readJson(await apiFetch(`${HISTORY_PATH}?${params.toString()}`)); +} + +export const HISTORY_CSV_PATH = "/api/v1/admin/history.csv"; + +export interface HistoryCsv { + blob: Blob; + filename: string; +} + +/** The `filename` from a Content-Disposition header, or null. */ +export function csvFilename(disposition: string | null): string | null { + const match = disposition?.match(/filename="?([^";]+)"?/i); + return match ? match[1].trim() : null; +} + +/** + * The CSV export of the same range and filter as `fetchHistory`. Goes through + * apiFetch so the admin token travels in the Authorization header. + */ +export async function exportHistoryCsv( + filter: HistoryFilter, + start: string, + end: string, +): Promise { + const params = new URLSearchParams({ start, end, filter }); + const response = await apiFetch(`${HISTORY_CSV_PATH}?${params.toString()}`); + if (!response.ok) { + // readJson raises ApiRequestError with the API's `detail` for a non-OK response. + await readJson(response); + throw new ApiRequestError(response.status, null); + } + return { + blob: await response.blob(), + filename: + csvFilename(response.headers.get("Content-Disposition")) ?? + `nestquest-history-${start}-to-${end}.csv`, + }; +} diff --git a/admin/src/api/instances.ts b/admin/src/api/instances.ts new file mode 100644 index 0000000..bcc3c33 --- /dev/null +++ b/admin/src/api/instances.ts @@ -0,0 +1,23 @@ +/** + * Typed client for the admin instance mutation route + * (POST /api/v1/admin/instances/{id}/uncomplete). + */ +import { apiFetch } from "./client"; +import { readJson } from "./definitions"; + +export interface UncompleteResult { + instance_id: number; + /** The derived state after the reversal: "open", or "missed" when past due. */ + state: string; + /** False when the instance was already open (a no-op). */ + appended: boolean; +} + +export function uncompletePath(instanceId: number): string { + return `/api/v1/admin/instances/${instanceId}/uncomplete`; +} + +/** Reverse one completion; raises ApiRequestError with the API's detail on failure. */ +export async function uncompleteInstance(instanceId: number): Promise { + return readJson(await apiFetch(uncompletePath(instanceId), { method: "POST" })); +} diff --git a/admin/src/api/presence.ts b/admin/src/api/presence.ts new file mode 100644 index 0000000..a04461e --- /dev/null +++ b/admin/src/api/presence.ts @@ -0,0 +1,126 @@ +/** + * Typed client for the admin presence routes: a child's repeating schedule + * (/api/v1/admin/children/{id}/presence-schedule) and the household's + * date-range overrides (/api/v1/admin/presence-overrides). + */ +import { apiFetch } from "./client"; +import { CHILDREN_PATH, readJson } from "./definitions"; + +/** + * Cycle week index ("0".."n-1", JSON object keys) -> present weekdays + * (0 = Monday .. 6 = Sunday). An empty list is away that whole week. + */ +export type PresencePattern = Record; + +export interface PresenceScheduleBody { + cycle_length_weeks: number; + /** "YYYY-MM-DD"; pins cycle week 0. */ + anchor_date: string; + pattern: PresencePattern; +} + +export interface PresenceSchedule extends PresenceScheduleBody { + child_id: number; +} + +export interface PresenceOverride { + id: number; + child_id: number; + start_date: string; + end_date: string; + is_present: boolean; + note: string | null; +} + +export interface PresenceOverrideFilter { + childId?: number; + /** "YYYY-MM-DD"; keeps overrides overlapping [start, end]. */ + start?: string; + end?: string; +} + +export const PRESENCE_OVERRIDES_PATH = "/api/v1/admin/presence-overrides"; + +export function presenceSchedulePath(childId: number): string { + return `${CHILDREN_PATH}/${childId}/presence-schedule`; +} + +/** The child's schedule, or null when it has none (present every day). */ +export async function fetchPresenceSchedule(childId: number): Promise { + const body = await readJson<{ schedule: PresenceSchedule | null }>( + await apiFetch(presenceSchedulePath(childId)), + ); + return body.schedule; +} + +/** Set (upsert) the child's schedule; resolves to the schedule as stored. */ +export async function savePresenceSchedule( + childId: number, + body: PresenceScheduleBody, +): Promise { + return readJson( + await apiFetch(presenceSchedulePath(childId), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +export async function fetchPresenceOverrides( + filter: PresenceOverrideFilter = {}, +): Promise { + const params = new URLSearchParams(); + if (filter.childId !== undefined) params.set("child_id", String(filter.childId)); + if (filter.start !== undefined) params.set("start", filter.start); + if (filter.end !== undefined) params.set("end", filter.end); + const query = params.toString(); + const body = await readJson<{ overrides: PresenceOverride[] }>( + await apiFetch(query ? `${PRESENCE_OVERRIDES_PATH}?${query}` : PRESENCE_OVERRIDES_PATH), + ); + return body.overrides; +} + +export interface PresenceOverrideRange { + child_id: number; + /** "YYYY-MM-DD", inclusive. */ + start_date: string; + /** "YYYY-MM-DD", inclusive; never before start_date. */ + end_date: string; + is_present: boolean; +} + +export interface PresenceOverrideBody extends PresenceOverrideRange { + note?: string; +} + +export const PRESENCE_OVERRIDE_CONSEQUENCE_PATH = `${PRESENCE_OVERRIDES_PATH}/consequence`; + +function postJson(path: string, body: unknown): Promise { + return apiFetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** + * How many open upcoming tasks saving this override would remove. Computed + * by the API before anything is saved; completed instances never count. + */ +export async function previewOverrideConsequence(range: PresenceOverrideRange): Promise { + const body = await readJson<{ removed: number }>( + await postJson(PRESENCE_OVERRIDE_CONSEQUENCE_PATH, range), + ); + return body.removed; +} + +export async function createPresenceOverride(body: PresenceOverrideBody): Promise { + return readJson(await postJson(PRESENCE_OVERRIDES_PATH, body)); +} + +export async function deletePresenceOverride(overrideId: number): Promise { + await readJson<{ status: string }>( + await apiFetch(`${PRESENCE_OVERRIDES_PATH}/${overrideId}`, { method: "DELETE" }), + ); +} diff --git a/admin/src/api/snapshot.ts b/admin/src/api/snapshot.ts new file mode 100644 index 0000000..9fa0186 --- /dev/null +++ b/admin/src/api/snapshot.ts @@ -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 { + const response = await apiFetch(SNAPSHOT_PATH); + if (!response.ok) { + throw new Error(`Snapshot request failed (HTTP ${response.status}).`); + } + return (await response.json()) as AdminSnapshot; +} diff --git a/admin/src/api/testStream.ts b/admin/src/api/testStream.ts new file mode 100644 index 0000000..18c469e --- /dev/null +++ b/admin/src/api/testStream.ts @@ -0,0 +1,39 @@ +/** + * Test helper: a controllable text/event-stream Response whose body the test + * feeds chunk by chunk (so a frame can be split across chunk boundaries). + */ +export interface TestStream { + response: Response; + push(text: string): void; + close(): void; + /** True once the reader cancelled the body (the stream was torn down). */ + cancelled(): boolean; +} + +export function testStream(): TestStream { + const encoder = new TextEncoder(); + let controller!: ReadableStreamDefaultController; + let cancelled = false; + const body = new ReadableStream({ + start(c) { + controller = c; + }, + cancel() { + cancelled = true; + }, + }); + return { + response: new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + push: (text) => controller.enqueue(encoder.encode(text)), + close: () => controller.close(), + cancelled: () => cancelled, + }; +} + +/** One SSE frame exactly as api/sse.py writes it. */ +export function frame(type: string, payload: unknown): string { + return `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`; +} diff --git a/admin/src/api/useTransitions.tsx b/admin/src/api/useTransitions.tsx new file mode 100644 index 0000000..e021d79 --- /dev/null +++ b/admin/src/api/useTransitions.tsx @@ -0,0 +1,61 @@ +/** + * One live transition stream per signed-in shell, fanned out to the screens. + * + * opens the stream on mount and aborts it on unmount; + * useTransitions(handler) registers a screen's handler for as long as the + * screen is mounted. Without a provider the hook does nothing, so a screen + * rendered on its own (e.g. in a test) never opens a stream. + */ +import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react"; +import { openTransitionStream, type TransitionEvent } from "./events"; + +type Listener = (event: TransitionEvent) => void; + +interface TransitionHub { + subscribe(listener: Listener): () => void; +} + +const TransitionsContext = createContext(null); + +export function TransitionsProvider({ children }: { children: ReactNode }) { + const [hub] = useState(() => { + const listeners = new Set(); + return { + listeners, + subscribe(listener: Listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; + }); + + useEffect( + () => + openTransitionStream({ + onTransition: (event) => { + for (const listener of [...hub.listeners]) { + try { + listener(event); + } catch { + // One screen's handler must not tear down the shared stream. + } + } + }, + }), + [hub], + ); + + return {children}; +} + +/** Call `handler` for every transition while the calling component is mounted. */ +export function useTransitions(handler: Listener): void { + const hub = useContext(TransitionsContext); + const handlerRef = useRef(handler); + useEffect(() => { + handlerRef.current = handler; + }); + useEffect(() => hub?.subscribe((event) => handlerRef.current(event)), [hub]); +} diff --git a/admin/src/auth/oidc.test.ts b/admin/src/auth/oidc.test.ts new file mode 100644 index 0000000..cd63b02 --- /dev/null +++ b/admin/src/auth/oidc.test.ts @@ -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`, + ); + }); +}); diff --git a/admin/src/auth/oidc.ts b/admin/src/auth/oidc.ts new file mode 100644 index 0000000..f2fe723 --- /dev/null +++ b/admin/src/auth/oidc.ts @@ -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 { + 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 { + 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 { + 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 { + 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()}`); +} diff --git a/admin/src/auth/pkce.test.ts b/admin/src/auth/pkce.test.ts new file mode 100644 index 0000000..7b7f686 --- /dev/null +++ b/admin/src/auth/pkce.test.ts @@ -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()); + }); +}); diff --git a/admin/src/auth/pkce.ts b/admin/src/auth/pkce.ts new file mode 100644 index 0000000..de04c5f --- /dev/null +++ b/admin/src/auth/pkce.ts @@ -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 { + 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)) + ); +} diff --git a/admin/src/config.test.ts b/admin/src/config.test.ts new file mode 100644 index 0000000..9fcf9bf --- /dev/null +++ b/admin/src/config.test.ts @@ -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; + 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); + }); +}); diff --git a/admin/src/config.ts b/admin/src/config.ts new file mode 100644 index 0000000..9880be9 --- /dev/null +++ b/admin/src/config.ts @@ -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//, 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 = 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 { + 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>(); + +export function resolveEndpointsFromDiscovery( + issuer: string, + fetchImpl: typeof fetch = fetch, +): Promise { + 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 { + 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, + }; +} diff --git a/admin/src/definitions/DefinitionsTab.css b/admin/src/definitions/DefinitionsTab.css new file mode 100644 index 0000000..a857716 --- /dev/null +++ b/admin/src/definitions/DefinitionsTab.css @@ -0,0 +1,585 @@ +/* Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2). + Mobile-first at the 390x844 reference; the column is capped and centred wider. */ + +.defs-screen { + width: 100%; + max-width: 640px; + margin: 0 auto; +} + +/* §3.1 header */ +.defs-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + background: var(--nq-a-surface); + border-bottom: 1px solid var(--nq-a-border); + padding: 14px 16px; +} + +.defs-title { + margin: 0; + font-size: var(--nq-a-size-screen); + font-weight: 800; + letter-spacing: -0.02em; +} + +.defs-sub { + margin: 2px 0 0; + min-height: 1.4em; + font-size: 12px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.defs-new { + flex: none; + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 36px; + padding: 0 14px; + border: 0; + border-radius: var(--nq-a-density-radius); + background: var(--nq-brand-gradient); + color: #fff; + font-family: inherit; + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.defs-new:disabled { + opacity: 0.5; + cursor: default; +} + +/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */ +.defs-scroll { + display: flex; + flex-direction: column; + gap: var(--nq-a-density-gap); + padding: 14px 16px; +} + +/* Filter chips */ +.defs-chips { + display: flex; + gap: 6px; + overflow-x: auto; +} + +.defs-chip { + flex: none; + min-height: 32px; + padding: 0 12px; + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-radius-pill); + background: var(--nq-a-surface); + color: var(--nq-a-ink); + font-family: inherit; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.defs-chip--active { + border-color: #18181d; + background: #18181d; + color: #fff; +} + +/* Rows */ +.defs-card { + background: var(--nq-a-surface); + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); + overflow: hidden; +} + +.defs-list { + margin: 0; + padding: 0; + list-style: none; +} + +.defs-list > li + li { + border-top: 1px solid var(--nq-a-divider); +} + +.defs-row { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: var(--nq-a-tap-min); + padding: var(--nq-a-density-row-pad); + border: 0; + background: none; + color: var(--nq-a-ink); + font-family: inherit; + text-align: left; + cursor: pointer; +} + +.defs-row-glyph { + flex: none; + display: flex; + color: var(--nq-a-ink-secondary); +} + +.defs-row-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.defs-row-title { + font-size: var(--nq-a-size-row); + font-weight: 700; +} + +.defs-row-meta { + font-size: var(--nq-a-size-meta); + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.defs-row-chevron { + flex: none; + display: flex; + color: var(--nq-a-ink-tertiary); +} + +/* An inactive definition renders the whole row tertiary (never hard-deleted). */ +.defs-row--inactive, +.defs-row--inactive .defs-row-glyph, +.defs-row--inactive .defs-row-meta { + color: var(--nq-a-ink-tertiary); +} + +.defs-row--editing { + background: var(--nq-a-selected); +} + +.defs-row--editing .defs-row-glyph { + color: var(--nq-brand-blue); +} + +.defs-empty { + padding: var(--nq-a-density-card-pad); + font-size: 13px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +/* Loading / error */ +.defs-status { + padding: 24px 13px; + background: var(--nq-a-surface); + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); + text-align: center; + font-size: 13.5px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.defs-status--error { + color: var(--nq-a-danger-strong); +} + +.defs-status--error p { + margin: 0 0 10px; +} + +.defs-retry { + padding: 8px 16px; + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-surface); + color: var(--nq-a-ink); + font-family: inherit; + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +/* §3.2 edit sheet */ +.defs-sheet-layer { + position: fixed; + inset: 0; + z-index: 20; + display: flex; + flex-direction: column; + justify-content: flex-end; +} + +.defs-scrim { + position: absolute; + inset: 0; + background: rgba(9, 9, 11, 0.4); + animation: defs-fade var(--nq-dur-modal) var(--nq-ease-out); +} + +.defs-sheet { + position: relative; + width: 100%; + max-width: 640px; + max-height: 92%; + margin: 0 auto; + overflow-y: auto; + padding: 14px 18px 24px; + border-radius: var(--nq-a-radius-sheet); + background: var(--nq-a-surface); + box-shadow: var(--nq-a-sheet-shadow); + animation: defs-rise var(--nq-dur-modal) var(--nq-ease-out); +} + +@keyframes defs-fade { + from { + opacity: 0; + } +} + +@keyframes defs-rise { + from { + transform: translateY(100%); + } +} + +@media (prefers-reduced-motion: reduce) { + .defs-scrim, + .defs-sheet { + animation: none; + } +} + +.defs-grabber { + width: 38px; + height: 4px; + margin: 0 auto 12px; + border-radius: var(--nq-a-radius-pill); + background: var(--nq-a-border); +} + +.defs-sheet-title { + margin: 0 0 12px; + font-size: 19px; + font-weight: 800; +} + +.defs-fields { + display: flex; + flex-direction: column; + gap: 11px; +} + +.defs-field { + display: flex; + flex-direction: column; + gap: 5px; +} + +.defs-label { + font-size: var(--nq-a-size-label); + font-weight: 700; + text-transform: uppercase; + letter-spacing: var(--nq-a-track-label); + color: var(--nq-a-ink-secondary); +} + +.defs-input { + width: 100%; + min-height: var(--nq-a-tap-min); + padding: 0 11px; + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-surface); + color: var(--nq-a-ink); + font-family: inherit; + font-size: 14px; + font-weight: 600; +} + +.defs-select-row { + display: flex; + align-items: center; + justify-content: space-between; + min-height: var(--nq-a-tap-min); + padding: 0 11px; + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-surface); + color: var(--nq-a-ink); + font-family: inherit; + font-size: 14px; + font-weight: 700; + cursor: pointer; +} + +.defs-picker { + margin: 0; + padding: 0; + list-style: none; + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); +} + +.defs-picker > li + li { + border-top: 1px solid var(--nq-a-divider); +} + +.defs-picker-option { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-height: var(--nq-a-tap-min); + padding: 0 11px; + border: 0; + background: none; + color: var(--nq-a-ink); + font-family: inherit; + font-size: 13.5px; + font-weight: 700; + cursor: pointer; +} + +.defs-picker-check { + display: flex; + color: var(--nq-brand-blue); +} + +.defs-picker-option--locked { + color: var(--nq-a-ink-secondary); + cursor: default; +} + +.defs-picker-note { + font-size: 12px; + font-weight: 600; +} + +.defs-repeats { + display: flex; + gap: 8px; +} + +.defs-interval { + flex: none; + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 700; + color: var(--nq-a-ink-secondary); +} + +.defs-interval .defs-input { + width: 64px; +} + +.defs-weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; +} + +.defs-day, +.defs-segment { + min-height: var(--nq-a-tap-min); + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-surface); + color: var(--nq-a-ink); + font-family: inherit; + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.defs-day--on, +.defs-segment--on { + border-color: transparent; + background: var(--nq-brand-gradient); + color: #fff; +} + +.defs-segmented { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; +} + +.defs-icon-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--nq-a-tap-min), 1fr)); + gap: 6px; +} + +.defs-icon { + display: flex; + align-items: center; + justify-content: center; + min-height: var(--nq-a-tap-min); + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-surface); + color: var(--nq-a-ink); + cursor: pointer; +} + +.defs-icon--on { + border-color: transparent; + background: var(--nq-brand-gradient); + color: #fff; +} + +.defs-due-times { + display: flex; + flex-direction: column; + gap: 6px; +} + +.defs-due { + display: grid; + grid-template-columns: 84px 1fr; + align-items: center; + gap: 8px; + font-size: 12.5px; + font-weight: 700; +} + +.defs-hint { + margin: 0; + font-size: 12px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.defs-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.defs-toggle-title { + font-size: 13.5px; + font-weight: 700; +} + +.defs-toggle-sub { + font-size: var(--nq-a-size-meta); + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.defs-switch { + flex: none; + position: relative; + width: 44px; + height: 26px; + padding: 0; + border: 0; + border-radius: var(--nq-a-radius-pill); + background: var(--nq-a-border); + cursor: pointer; + transition: background var(--nq-dur-base) var(--nq-ease-out); +} + +.defs-switch--on { + background: var(--nq-brand-gradient); +} + +.defs-switch-knob { + position: absolute; + top: 3px; + left: 3px; + width: 20px; + height: 20px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgba(9, 9, 11, 0.25); + transition: transform var(--nq-dur-base) var(--nq-ease-out); +} + +.defs-switch--on .defs-switch-knob { + transform: translateX(18px); +} + +.defs-sheet-error { + margin: 12px 0 0; + padding: 10px 12px; + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-danger-bg); + color: var(--nq-a-danger-strong); + font-size: 12.5px; + font-weight: 700; +} + +.defs-sheet-footer { + display: flex; + gap: 8px; + margin-top: 16px; +} + +.defs-confirm { + margin-top: 12px; + padding: 10px 12px; + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-danger-bg); + color: var(--nq-a-danger-strong); + font-size: 12.5px; + font-weight: 700; +} + +.defs-confirm p { + margin: 0; +} + +.defs-confirm .defs-sheet-footer { + margin-top: 10px; +} + +.defs-cancel, +.defs-save { + padding: 13px 0; + border-radius: var(--nq-a-density-radius); + font-family: inherit; + font-size: 14px; + font-weight: 700; + cursor: pointer; +} + +.defs-cancel { + flex: 1; + border: 1px solid var(--nq-a-border); + background: var(--nq-a-surface); + color: var(--nq-a-ink); +} + +.defs-save { + flex: 2; + border: 0; + background: var(--nq-brand-gradient); + color: #fff; +} + +.defs-cancel:disabled, +.defs-save:disabled { + opacity: 0.6; + cursor: default; +} + +.defs-preview { + margin-top: 8px; +} + +.defs-preview--error { + color: var(--nq-a-ink-secondary); + font-style: italic; +} diff --git a/admin/src/definitions/DefinitionsTab.test.tsx b/admin/src/definitions/DefinitionsTab.test.tsx new file mode 100644 index 0000000..038429b --- /dev/null +++ b/admin/src/definitions/DefinitionsTab.test.tsx @@ -0,0 +1,1087 @@ +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 { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons"; +import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions"; +import { storeTokens } from "../auth/oidc"; + +function child(id: number, name: string, overrides: Partial = {}): 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 { + 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; + +/** + * 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: body.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, + icon: "icon" in body ? body.icon : d.icon, + 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; + +async function renderReady(initial: QuestDefinition[] = [BRUSH, BINS, PIANO]) { + api = fakeApi(initial); + vi.stubGlobal("fetch", api.fetchMock); + render(); + 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 }).headers.Authorization).toBe( + "Bearer at-123", + ); + }); + + it("rows show ` · · ` 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", + icon: null, + 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", + icon: null, + 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, + }, + }, + ]); + 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; + 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((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(); + 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 { + return act(() => new Promise((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((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[] = []; + 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[] = []; + 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[] = []; + 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); + }); +}); + +describe("DefinitionsTab icon picker", () => { + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + /** The Lucide name a row renders, or null for the generic fallback glyph. */ + function rowIcon(row: HTMLElement): string | null { + const svg = row.querySelector(".defs-row-glyph svg"); + expect(svg).not.toBeNull(); + return svg!.getAttribute("data-icon"); + } + + function iconRadio(s: HTMLElement, label: string): HTMLElement { + return within(within(s).getByRole("radiogroup", { name: "Icon" })).getByRole("radio", { + name: label, + }); + } + + it("ships the curated subset, and every entry renders an SVG", () => { + expect(DEFINITION_ICONS.map((entry) => entry.name)).toEqual([ + "bed", + "bath", + "shower-head", + "toilet", + "brush", + "smile", + "shirt", + "washing-machine", + "utensils", + "cooking-pot", + "apple", + "brush-cleaning", + "spray-can", + "trash-2", + "recycle", + "sofa", + "house", + "backpack", + "book-open", + "pencil", + "music", + "dog", + "cat", + "fish", + "sprout", + "droplets", + "bike", + "volleyball", + "gamepad-2", + "star", + "heart", + "sun", + "moon", + "clock", + "calendar", + "list-checks", + ]); + expect(DEFINITION_ICONS.length).toBeGreaterThanOrEqual(24); + expect(DEFINITION_ICONS.length).toBeLessThanOrEqual(40); + for (const { name } of DEFINITION_ICONS) { + const { container, unmount } = render(); + const svg = container.querySelector("svg"); + expect(svg, name).not.toBeNull(); + expect(svg!.getAttribute("data-icon")).toBe(name); + expect(svg!.querySelectorAll("path, circle, rect, line, polyline, ellipse").length).toBeGreaterThan(0); + unmount(); + } + }); + + it("a chosen icon is POSTed for a new task and renders on its row", async () => { + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "New" })); + const s = sheet(); + expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("true"); + + fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), { + target: { value: "Make the bed" }, + }); + fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ })); + fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" })); + fireEvent.click(within(s).getByRole("button", { name: "Morning" })); + fireEvent.click(iconRadio(s, "Bed")); + expect(iconRadio(s, "Bed").getAttribute("aria-checked")).toBe("true"); + expect(iconRadio(s, "Bed").className).toContain("defs-icon--on"); + expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("false"); + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + + expect(api.writes()).toHaveLength(1); + expect(api.writes()[0].method).toBe("POST"); + expect((api.writes()[0].body as { icon: string }).icon).toBe("bed"); + expect(rowIcon(await screen.findByTestId("definition-99"))).toBe("bed"); + }); + + it("a chosen icon is PATCHed for an existing task without breaking the assignee omit", async () => { + await renderReady(); + expect(rowIcon(screen.getByTestId("definition-11"))).toBeNull(); + fireEvent.click(screen.getByTestId("definition-11")); + const s = sheet(); + expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("true"); + fireEvent.click(iconRadio(s, "Bins")); + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + + expect(screen.queryByTestId("unassign-confirm")).toBeNull(); + expect(api.writes()).toHaveLength(1); + const { method, path, body } = api.writes()[0]; + expect(method).toBe("PATCH"); + expect(path).toBe("/api/v1/admin/quest-definitions/11"); + expect((body as { icon: string }).icon).toBe("trash-2"); + // Inactive "Old" stays assigned: the unchanged selection is still omitted. + expect(body).not.toHaveProperty("assignee_child_ids"); + await waitFor(() => expect(rowIcon(screen.getByTestId("definition-11"))).toBe("trash-2")); + }); + + it("an unchanged icon is still sent on edit and shows as selected", async () => { + await renderReady(); + expect(rowIcon(screen.getByTestId("definition-10"))).toBe("smile"); + fireEvent.click(screen.getByTestId("definition-10")); + const s = sheet(); + expect(iconRadio(s, "Smile").getAttribute("aria-checked")).toBe("true"); + fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), { + target: { value: "Brush teeth well" }, + }); + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + + expect((api.writes()[0].body as { icon: string }).icon).toBe("smile"); + }); + + it("an unknown legacy icon falls back to the generic glyph and is kept on save", async () => { + const legacy: QuestDefinition = { ...BRUSH, id: 20, icon: "rocket-ship-9000" }; + await renderReady([legacy]); + const row = screen.getByTestId("definition-20"); + expect(rowIcon(row)).toBeNull(); + + fireEvent.click(row); + const s = sheet(); + const group = within(s).getByRole("radiogroup", { name: "Icon" }); + for (const radio of within(group).getAllByRole("radio")) { + expect(radio.getAttribute("aria-checked")).toBe("false"); + } + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + + expect((api.writes()[0].body as { icon: string }).icon).toBe("rocket-ship-9000"); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(rowIcon(screen.getByTestId("definition-20"))).toBeNull(); + }); +}); diff --git a/admin/src/definitions/DefinitionsTab.tsx b/admin/src/definitions/DefinitionsTab.tsx new file mode 100644 index 0000000..b4803b5 --- /dev/null +++ b/admin/src/definitions/DefinitionsTab.tsx @@ -0,0 +1,968 @@ +/** + * Definitions ("Tasks") tab — list + edit sheet with icon picker + * (design/ADMIN-SPEC.md §3.1–3.3). + */ +import { + useEffect, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, +} from "react"; +import { ApiForbiddenError } from "../api/client"; +import { + ApiRequestError, + createDefinition, + fetchChildren, + fetchDefinitions, + previewOccurrences, + updateDefinition, + type AdminChild, + type DefinitionCreateBody, + type DefinitionEditBody, + type DefinitionRule, + type QuestDefinition, + type WindowEntry, + type WindowName, +} from "../api/definitions"; +import { + MONTH_SHORT, + WEEKDAY_SHORT, + WINDOW_LABELS, + WINDOW_ORDER, + assigneeSummary, + occurrenceLabel, + recurrenceSummary, + windowSummary, +} from "./format"; +import { + CheckGlyph, + ChevronDownGlyph, + ChevronRightGlyph, + PlusGlyph, + TaskGlyph, +} from "./glyphs"; +import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons"; +import "./DefinitionsTab.css"; + +/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */ +export const SCROLL_BOTTOM_PADDING = "92px"; + +type Repeats = "daily" | "weekly" | "monthly" | "yearly" | "custom"; + +const REPEATS_OPTIONS: { value: Repeats; label: string }[] = [ + { value: "daily", label: "Daily" }, + { value: "weekly", label: "Weekly" }, + { value: "monthly", label: "Monthly" }, + { value: "yearly", label: "Yearly" }, + { value: "custom", label: "Custom" }, +]; + +const REPEATS_BY_RULE: Record = { + daily: "daily", + weekly: "weekly", + monthly_day: "monthly", + monthly_weekday: "monthly", + yearly: "yearly", + custom_days: "custom", +}; + +/** How many upcoming dates the edit sheet previews. */ +const PREVIEW_COUNT = 5; + +/** Quiet period after the last rule edit before the preview is requested. */ +const PREVIEW_DEBOUNCE_MS = 300; + +const WEEKDAY_LONG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; + +interface Draft { + title: string; + /** A Lucide name; a legacy value outside the subset is kept until replaced. */ + icon: string | null; + assigneeIds: number[]; + repeats: Repeats; + interval: string; + weekdays: number[]; + dayOfMonth: string; + month: string; + /** An existing "nth weekday" monthly rule, kept while Repeats stays Monthly. */ + monthlyWeekday: { nth: number; weekday: number } | null; + windows: WindowName[]; + dueTimes: Record; + 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 { + return { morning: "", afternoon: "", evening: "" }; +} + +function newDraft(activeChildren: AdminChild[]): Draft { + const today = new Date(); + return { + title: "", + icon: null, + assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [], + repeats: "daily", + interval: "1", + weekdays: [(today.getDay() + 6) % 7], + dayOfMonth: String(today.getDate()), + month: String(today.getMonth() + 1), + monthlyWeekday: null, + windows: [], + dueTimes: emptyDueTimes(), + // Mirrors the API's create default: no instance on an away day. + skipOnAway: true, + startDate: localIsoDate(today), + endDate: null, + }; +} + +function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): Draft { + const { rule } = definition; + const today = new Date(); + const active = new Set(activeChildren.map((child) => child.id)); + const dueTimes = emptyDueTimes(); + for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? ""; + return { + title: definition.title, + icon: definition.icon, + // Inactive children cannot be submitted (the API rejects them with 422). + assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)), + repeats: REPEATS_BY_RULE[rule.rule_type], + interval: String(rule.interval), + weekdays: + rule.rule_type === "weekly" || rule.rule_type === "custom_days" + ? [...(rule.weekday_set ?? [])] + : [(today.getDay() + 6) % 7], + dayOfMonth: String(rule.day_of_month ?? today.getDate()), + month: String(rule.month ?? today.getMonth() + 1), + monthlyWeekday: + rule.rule_type === "monthly_weekday" + ? { nth: rule.nth_weekday ?? 1, weekday: rule.nth_weekday_weekday ?? 0 } + : null, + windows: WINDOW_ORDER.filter((w) => definition.windows.some((d) => d.window === w)), + dueTimes, + skipOnAway: definition.skip_on_away, + startDate: rule.start_date, + endDate: rule.end_date, + }; +} + +function parseWhole(value: string, min: number, max: number): number | null { + if (!/^\d+$/.test(value.trim())) return null; + const n = Number(value); + return n >= min && n <= max ? n : null; +} + +/** Build the request rule from the draft, or return a message naming what to fix. */ +function buildRule(draft: Draft): DefinitionRule | string { + const interval = parseWhole(draft.interval, 1, 999); + if (interval === null) return "Interval must be a whole number of at least 1."; + const rule: DefinitionRule = { + rule_type: "daily", + interval, + weekday_set: null, + day_of_month: null, + nth_weekday: null, + nth_weekday_weekday: null, + month: null, + start_date: draft.startDate, + end_date: draft.endDate, + }; + switch (draft.repeats) { + case "daily": + return rule; + case "weekly": + case "custom": + if (draft.weekdays.length === 0) return "Pick at least one day."; + return { + ...rule, + rule_type: draft.repeats === "weekly" ? "weekly" : "custom_days", + weekday_set: [...draft.weekdays].sort((a, b) => a - b), + }; + case "monthly": { + if (draft.monthlyWeekday) { + return { + ...rule, + rule_type: "monthly_weekday", + nth_weekday: draft.monthlyWeekday.nth, + nth_weekday_weekday: draft.monthlyWeekday.weekday, + }; + } + const day = parseWhole(draft.dayOfMonth, 1, 31); + if (day === null) return "Day of month must be 1 to 31."; + return { ...rule, rule_type: "monthly_day", day_of_month: day }; + } + case "yearly": { + const month = parseWhole(draft.month, 1, 12); + const day = parseWhole(draft.dayOfMonth, 1, 31); + if (month === null || day === null) return "Pick a month and a day (1 to 31)."; + return { ...rule, rule_type: "yearly", month, day_of_month: day }; + } + } +} + +/** + * `keepsAssignees`: an edit whose active selection is unchanged sends no + * assignee list, so an empty selection is fine when inactive children remain. + */ +function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody | string { + const title = draft.title.trim(); + if (!title) return "Give the task a title."; + if (draft.assigneeIds.length === 0 && !keepsAssignees) return "Assign at least one child."; + if (draft.windows.length === 0) return "Pick at least one window."; + const rule = buildRule(draft); + if (typeof rule === "string") return rule; + const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map( + (w) => [w, draft.dueTimes[w] || null], + ); + return { + title, + icon: draft.icon, + rule, + assignee_child_ids: [...draft.assigneeIds], + windows, + skip_on_away: draft.skipOnAway, + }; +} + +function saveErrorMessage(error: unknown): string { + if (error instanceof ApiForbiddenError) return error.message; + if (error instanceof ApiRequestError && error.detail) return error.detail; + return "The task could not be saved. Check your connection and try again."; +} + +function toggle(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 ( +
+ {chips.map((chip) => ( + + ))} +
+ ); +} + +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 ( +
  • + +
  • + ); +} + +/* ── 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({ 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 ( +

    + {preview.message} +

    + ); + } + 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 ( +

    + {text} +

    + ); +} + +function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) { + return ( +
    + + {label} + + {children} +
    + ); +} + +function EditSheet({ + target, + activeChildren, + onCancel, + onSaved, +}: { + target: "new" | QuestDefinition; + activeChildren: AdminChild[]; + onCancel: () => void; + onSaved: () => void; +}) { + const [draft, setDraft] = useState(() => + 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(null); + // A ref, not state: two taps in one frame must not both see `saving === false`. + const inFlight = useRef(false); + const sheetRef = useRef(null); + const titleRef = useRef(null); + const confirmRef = useRef(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) { + 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(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) => 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 ( +
    +
    { + if (!inFlight.current) onCancel(); + }} + /> +
    + +
    + ); +} + +/* ── Screen ─────────────────────────────────────────────────────────── */ + +export default function DefinitionsTab() { + const [state, setState] = useState({ kind: "loading" }); + const [attempt, setAttempt] = useState(0); + const [filter, setFilter] = useState("all"); + const [sheet, setSheet] = useState(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 = ( +
    + Loading tasks… +
    + ); + } else if (state.kind === "error") { + body = ( +
    +

    {state.message}

    + +
    + ); + } 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 = ( + <> + + {shown.length > 0 ? ( +
      + {shown.map((definition) => ( + setSheet(definition)} + /> + ))} +
    + ) : ( +
    No tasks yet.
    + )} + + ); + } + + return ( +
    +
    +
    +

    Tasks

    +

    {sub}

    +
    + +
    +
    + {body} +
    + {sheet !== null && state.kind === "ready" ? ( + setSheet(null)} + onSaved={() => { + setSheet(null); + setAttempt((n) => n + 1); + }} + /> + ) : null} +
    + ); +} diff --git a/admin/src/definitions/definitionIcons.tsx b/admin/src/definitions/definitionIcons.tsx new file mode 100644 index 0000000..dbf0a3d --- /dev/null +++ b/admin/src/definitions/definitionIcons.tsx @@ -0,0 +1,103 @@ +/** + * The shipped Lucide subset a definition's `icon` may name (ADMIN-SPEC §3.3). + * Only these named imports are bundled — no CDN, no runtime fetch. The stored + * value is the Lucide kebab-case name, so it stays meaningful to any surface. + */ +import { + Apple, + Backpack, + Bath, + Bed, + Bike, + BookOpen, + Brush, + BrushCleaning, + Calendar, + Cat, + Clock, + CookingPot, + Dog, + Droplets, + Fish, + Gamepad2, + Heart, + House, + ListChecks, + Moon, + Music, + Pencil, + Recycle, + Shirt, + ShowerHead, + Smile, + Sofa, + SprayCan, + Sprout, + Star, + Sun, + Toilet, + Trash2, + Utensils, + Volleyball, + WashingMachine, + type LucideIcon, +} from "lucide-react"; +import { TaskGlyph } from "./glyphs"; + +export interface DefinitionIconEntry { + name: string; + label: string; + Icon: LucideIcon; +} + +export const DEFINITION_ICONS: readonly DefinitionIconEntry[] = [ + { name: "bed", label: "Bed", Icon: Bed }, + { name: "bath", label: "Bath", Icon: Bath }, + { name: "shower-head", label: "Shower", Icon: ShowerHead }, + { name: "toilet", label: "Toilet", Icon: Toilet }, + { name: "brush", label: "Brush", Icon: Brush }, + { name: "smile", label: "Smile", Icon: Smile }, + { name: "shirt", label: "Clothes", Icon: Shirt }, + { name: "washing-machine", label: "Laundry", Icon: WashingMachine }, + { name: "utensils", label: "Meal", Icon: Utensils }, + { name: "cooking-pot", label: "Cooking", Icon: CookingPot }, + { name: "apple", label: "Snack", Icon: Apple }, + { name: "brush-cleaning", label: "Sweep", Icon: BrushCleaning }, + { name: "spray-can", label: "Clean", Icon: SprayCan }, + { name: "trash-2", label: "Bins", Icon: Trash2 }, + { name: "recycle", label: "Recycling", Icon: Recycle }, + { name: "sofa", label: "Tidy", Icon: Sofa }, + { name: "house", label: "Home", Icon: House }, + { name: "backpack", label: "School bag", Icon: Backpack }, + { name: "book-open", label: "Reading", Icon: BookOpen }, + { name: "pencil", label: "Homework", Icon: Pencil }, + { name: "music", label: "Music", Icon: Music }, + { name: "dog", label: "Dog", Icon: Dog }, + { name: "cat", label: "Cat", Icon: Cat }, + { name: "fish", label: "Fish", Icon: Fish }, + { name: "sprout", label: "Plants", Icon: Sprout }, + { name: "droplets", label: "Water", Icon: Droplets }, + { name: "bike", label: "Bike", Icon: Bike }, + { name: "volleyball", label: "Sport", Icon: Volleyball }, + { name: "gamepad-2", label: "Games", Icon: Gamepad2 }, + { name: "star", label: "Star", Icon: Star }, + { name: "heart", label: "Heart", Icon: Heart }, + { name: "sun", label: "Morning", Icon: Sun }, + { name: "moon", label: "Bedtime", Icon: Moon }, + { name: "clock", label: "Clock", Icon: Clock }, + { name: "calendar", label: "Calendar", Icon: Calendar }, + { name: "list-checks", label: "Checklist", Icon: ListChecks }, +]; + +const BY_NAME = new Map(DEFINITION_ICONS.map((entry) => [entry.name, entry])); + +export function findDefinitionIcon(name: string | null): DefinitionIconEntry | null { + return name === null ? null : (BY_NAME.get(name) ?? null); +} + +/** A definition's glyph; no icon, or one outside the subset, gets the generic task glyph. */ +export function DefinitionIcon({ name, size = 17 }: { name: string | null; size?: number }) { + const entry = findDefinitionIcon(name); + if (!entry) return ; + return