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 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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