diff --git a/.gitignore b/.gitignore index 7b33e7f..cc771bf 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ .venv/ .pytest_cache/ *.metadata.json +frontend/node_modules/ diff --git a/README.md b/README.md index dfa59e7..a590c80 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,202 @@ After installation and restart: 2. Click **Add Integration** and search for **NestQuest**. 3. Follow the on-screen configuration flow. +## Events + +NestQuest fires Home Assistant bus events so automations can react to a +child's progress. Each payload carries `child_id`, `child_name`, +`instance_id`, `quest_title`, the instance's `window`, `due_date`, +`due_time`, and `occurred_at` (a strict UTC ISO-8601 timestamp). + +| Event | Fires when | Extra payload fields | +|---|---|---| +| `nestquest_quest_completed` | a quest is completed (an actual completion, never a re-complete no-op) | `was_on_time` (bool) | +| `nestquest_quest_uncompleted` | an admin reverses a completion | — | +| `nestquest_child_day_complete` | a completion clears the child's whole day (quests were owed and none remain; a zero-quest day never fires it) | `quests_due`, `quests_completed` | +| `nestquest_quest_missed` | the nightly sweep marks a still-open past-due quest missed (integrated in a later release) | — | + +Example automation trigger: + +```yaml +triggers: + - trigger: event + event_type: nestquest_child_day_complete +conditions: + - condition: template + value_template: "{{ trigger.event.data.child_name == 'Declan' }}" +actions: + - action: notify.mobile_app_declans_phone + data: + message: "Every quest cleared — legendary!" +``` + +## Automation Blueprints + +NestQuest ships four automation blueprints in +`custom_components/nestquest/blueprints/automation/`. Import them in +Home Assistant under **Settings → Automations & Scenes → Blueprints** +(or place the files in your `config/blueprints/automation/` folder), +then create one automation per blueprint, filling the inputs with your +household's choices. + +### Wiring to the integration's notification settings + +The integration's options flow records your notification choices — +notify target, morning/afternoon/end-of-day times, and a toggle per +automation. The blueprints take their own inputs at import time; +fill them from the same values you stored in the options flow (the +integration does not push its settings into imported automations): + +| Blueprint input | Options-flow counterpart | +|---|---| +| `morning_time` (default 08:00) | `morning_summary_time` | +| `afternoon_time` (default 15:00) | `afternoon_reminder_time` | +| `report_time` (default 20:00) | `end_of_day_report_time` | +| `send_summary` / `send_reminder` / `send_report` / `celebrate` | the four `*_enabled` toggles | +| `notify_target` | `notify_target` (your notify service, e.g. `notify.mobile_app_your_phone`) | + +The end-of-day report deliberately runs BEFORE the midnight missed +sweep and reads the same day's data — do not schedule it at the +day-rollover time. + +### The four blueprints + +1. **Morning summary** (`morning_summary.yaml`) — at the configured + time, lists every child whose `binary_sensor.nestquest__present_today` + is on and what they owe from `sensor.nestquest__quests_remaining_today`. + Children not present today are omitted entirely. + +2. **Afternoon reminder** (`afternoon_reminder.yaml`) — at the + configured time, notifies only about present children who still + have open quests. Sends nothing at all when every present child is + clear. + +3. **End-of-day report** (`end_of_day_report.yaml`) — one message per + evening: each present child's completion count and the quests they + still owe (the ones the midnight sweep will mark + `nestquest_quest_missed`). Absent children are omitted. Sends + nothing on an all-clear day unless `send_even_when_clear` is on. + +4. **Day-complete celebration** (`day_complete_celebration.yaml`) — + triggers on the `nestquest_child_day_complete` event (which only + fires when quests were owed and all are complete, so a zero-quest + day never celebrates). Create one `input_text` helper + (`nestquest_last_celebrated`) for the dedupe: the automation + records each celebrated child as `:` and + celebrates each child at most once per day — a re-cleared day does + not re-fire, but a second child clearing the same day still + celebrates. + +### Example package + +```yaml +# One automation per blueprint — Settings → Automations & Scenes → +# Create → Blueprint, then pick each NestQuest blueprint. +# +# morning_summary: morning_time 08:00, notify_target notify.mobile_app_your_phone +# afternoon_reminder: afternoon_time 15:00, same notify target +# end_of_day_report: report_time 20:00, send_even_when_clear false, same target +# day_complete_celebration: input_text.nestquest_last_celebrated, +# celebration_message "Legendary! {{ trigger.event.data.child_name }} cleared every quest today!" +# +# Every blueprint has its own send/celebrate toggle input; turn an +# automation off entirely from HA's automation menu. +``` + +### Events the automations rely on + +| Event | Payload | +|---|---| +| `nestquest_quest_completed` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at (UTC ISO-8601), was_on_time | +| `nestquest_quest_uncompleted` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at | +| `nestquest_child_day_complete` | child_id, child_name, quests_due, quests_completed, occurred_at | +| `nestquest_quest_missed` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at (fired by the nightly sweep) | + +Sensors the automations read: `sensor.nestquest__quests_due_today` +(attributes: `child_id`, `child_name`, `present`, `instances`, +`admin_instances`), `..._quests_remaining_today`, +`..._quests_completed_today`, and +`binary_sensor.nestquest__present_today`. + +## TouchHub setup + +Run these steps in order to display the NestQuest panel on a TouchHub +wall device. The panel is an HA Lovelace dashboard, so the kiosk browser +is simply a Home Assistant client. + +1. **Create a dedicated kiosk user.** In *Settings → People → Users*, add + a new user for the wall device. Make it **non-admin**, and ensure it is + not in the NestQuest admin allowlist. The allowlist blocks the NestQuest + **admin** operations — `uncomplete_quest`, `create_quest_definition`, + `update_quest_definition`, `set_quest_definition_active`, + `set_presence_pattern`, `create_presence_override`, + `delete_presence_override`, `export_history_csv`, and `manage_child` + (plus `regenerate` flows) — it does not make the account view-only. The + kiosk user *can* call `nestquest.complete_quest`, the one open NestQuest + service, to complete quests from the panel, and as a non-admin Home + Assistant user it may also reach other permitted Home Assistant entities + and services. + +2. **Build a dashboard with only the NestQuest panel cards.** Create a + Lovelace dashboard whose **URL slug is `nestquest`** (Settings → + Dashboards → Edit → URL slug). A functional panel needs **two** views + in that dashboard, each with its own card config from + `design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL + paths (the path segment after the dashboard slug): + + - **View 1 path `board`** (URL `/nestquest/board`) — party board + (`type: custom:nestquest-party-board-card`): the household quest + board. Set `weather_entity` (optional; the dock falls back to + date/time), `quest_log_path: /nestquest/log` (the view the crest tap + navigates to), and `child_order: [slug1, slug2, slug3]` listing each + child's slug in display order. + + - **View 2 path `log`** (URL `/nestquest/log`) — quest log + (`type: custom:nestquest-quest-log-card`): the per-child quest screen + the party board hops to. Set `board_path: /nestquest/board` (the + party-board view to return to), `weather_entity`, + `idle_return_seconds` (must be shorter than TouchHub Auto-Return), + `confirm_timeout_seconds`, and `complete_screen_seconds`. The quest + log reads the selected child's slug from the view's URL path, so the + board → log hop is a plain Lovelace navigation. + + Each view's layout MUST be set to **Panel** (Edit Dashboard → pencil on the + view → View type: Panel). Home Assistant's default Masonry layout constrains + the 1080px-tall NestQuest card to a narrow column; Panel (single-card) + layout is what renders the card full-screen at 1920×1080. + + The dashboard contains only the NestQuest panel card for each view — + no other cards, no admin controls. + + The `quest_log_path` and `board_path` values must match the dashboard + slug and view paths above: `//`. If you chose `nestquest` + as the dashboard slug with view paths `board` and `log`, the card + configs are already correct. If you pick different names, substitute + your actual paths in **both** card configs — e.g. a dashboard slug + `chores` with view paths `main` and `history` needs + `quest_log_path: /chores/history` and `board_path: /chores/main`. + Navigation between the two views fails (the crest tap or back button + lands on a 404) if the card config paths do not match the dashboard + slug and view paths exactly. + + See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML. + +3. **Point TouchHub at it.** In the TouchHub launcher, add the **Home + Assistant Lovelace** app to the dock and paste the dashboard URL from + step 2 — the party-board view URL, e.g. `/nestquest/board` (TouchHub + loads that URL directly as the kiosk landing page). Set TouchHub + **Auto-Return** to a value GREATER than the quest log's + `idle_return_seconds` (default 40s) — recommend **60s or higher**. If + Auto-Return is at or below 40s, TouchHub returns to its launcher before + the quest log's idle return can navigate back to the party board. + +4. **Log the kiosk session in.** Sign in on the device as the dedicated + kiosk user from step 1, and leave that session logged in. + +TouchHub loads the dashboard URL directly. The NestQuest cards are normal +Lovelace cards, not an iframe, so no Home Assistant framing setting is +needed. + ## Developer Setup and Testing This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management. @@ -55,6 +251,28 @@ To set up the development environment and install dependencies: uv sync ``` +### Frontend cards + +The Lovelace cards live in `frontend/` (TypeScript + Lit) and build to a +single bundle at `custom_components/nestquest/www/nestquest-cards.js`. The +integration registers that bundle as a Lovelace resource automatically on +setup — do not add it by hand. Card YAML is documented in +`design/ENTITIES-AND-SERVICES.md` §4. + +```bash +cd frontend +npm install +npm run build +``` + +### Kids panel layout + +The Lovelace panel cards are designed for the wall display's confirmed +resolution, 1920×1080 landscape, with no page scroll. TouchHub's bottom +dock overlays the NestQuest weather dock strip until it auto-hides, so +no tappable NestQuest control sits in that strip (the dock carries +`pointer-events: none`). Keep it that way when editing the cards. + ### Running Tests Run the test suite with `pytest`: diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 8cc9b79..53e395d 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -20,17 +20,23 @@ from .const import ( CONF_ADMIN_USER_IDS, CONF_DAY_ROLLOVER_TIME, CONF_HORIZON_DAYS, + CONF_UPDATE_INTERVAL, DEFAULT_DAY_ROLLOVER_TIME, DEFAULT_HORIZON_DAYS, + DEFAULT_UPDATE_INTERVAL, DOMAIN, LOGGER, - SERVICE_REGENERATE, + PLATFORMS, ) +from .frontend import async_register_frontend +from .coordinator import NestQuestCoordinator from .db import NestQuestDatabase from .materialize import materialize as _materialize_run from .migrations import apply_migrations +from .services import async_deregister_services, async_register_services from .store import async_get_db_path from .admin_allowlist import seed_setup_admin +from .sweep import run_missed_sweep _LOGGER = LOGGER @@ -238,6 +244,7 @@ class NestQuestRuntimeData: database: NestQuestDatabase remove_update_listener: Callable[[], Any] remove_time_change_listener: Callable[[], Any] + coordinator: Any = None async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]: @@ -322,18 +329,23 @@ def _register_day_rollover_listener( database: NestQuestDatabase, options: dict[str, Any], ) -> Callable[[], Any]: - """Register the daily materialization listener and return its remover. + """Register the daily rollover listener and return its remover. - Fires once per day at the configured local ``day_rollover_time`` and - materializes the rolling horizon ``[today, today + horizon_days]`` - through the shared :func:`_run_horizon_materialization` path, using the - entry's configured (validated) ``horizon_days``. + Fires once per day at the configured local ``day_rollover_time``: + first re-materializes the rolling horizon + ``[today, today + horizon_days]`` through the shared + :func:`_run_horizon_materialization` path (the entry's configured, + validated ``horizon_days``), then runs the Feature 11 missed sweep + — announcing yesterday's still-open quests as missed AFTER today's + instances exist, watermark-guarded so a re-fire of the listener the + same night announces nothing twice. """ hour, minute = _day_rollover_hour_minute(options) horizon_days = _configured_horizon_days(options) async def _run_materialization(_now: datetime.datetime) -> None: await _run_horizon_materialization(hass, database, horizon_days) + await run_missed_sweep(hass, database) return async_track_time_change( hass, _run_materialization, hour=hour, minute=minute @@ -356,21 +368,8 @@ def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None: return None -def _register_regenerate_service(hass: HomeAssistant) -> None: - """Register ``nestquest.regenerate`` once at domain scope. - - The service triggers a full on-demand regeneration over the rolling - horizon, running the SAME idempotent materialize path - (:func:`_run_horizon_materialization`) as the startup backfill and the - daily rollover listener. It is DOMAIN-global — registered exactly once - (guarded by ``has_service``) and NOT bound to any config entry — so the - handler resolves the currently-live database at call time and a name - collision or duplicate setup is impossible. Removal is handled by the - unload path only once the LAST entry is removed (see - :func:`_deregister_regenerate_service`). - """ - if hass.services.has_service(DOMAIN, SERVICE_REGENERATE): - return +def _make_regenerate_handler(hass: HomeAssistant): + """Return the Feature 07 ``regenerate`` handler closed over ``hass``.""" async def _regenerate(_call: Any) -> None: runtime_data = _find_live_runtime_data(hass) @@ -385,22 +384,40 @@ def _register_regenerate_service(hass: HomeAssistant) -> None: hass, runtime_data.database, horizon_days ) - hass.services.async_register(DOMAIN, SERVICE_REGENERATE, _regenerate) + return _regenerate -def _deregister_regenerate_service(hass: HomeAssistant) -> None: - """Remove ``nestquest.regenerate`` when the last entry unloads.""" - if hass.services.has_service(DOMAIN, SERVICE_REGENERATE): - hass.services.async_remove(DOMAIN, SERVICE_REGENERATE) +def _register_services(hass: HomeAssistant) -> None: + """Register domain-global NestQuest services once. + + Services are DOMAIN-global — registered exactly once (guarded by + ``has_service`` on regenerate) and NOT bound to any config entry — + so each handler resolves the currently-live database at call time + and a name collision or duplicate setup is impossible. Removal is + handled by the unload path only once the LAST entry is removed + (see :func:`_deregister_services`). + """ + async_register_services( + hass, + find_runtime=_find_live_runtime_data, + regenerate_handler=_make_regenerate_handler(hass), + ) + + +def _deregister_services(hass: HomeAssistant) -> None: + """Remove domain-global NestQuest services when the last entry unloads.""" + async_deregister_services(hass) async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: """Set up the NestQuest integration. YAML configuration is not used, returns True.""" + await async_register_frontend(hass) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NestQuest from a config entry.""" + await async_register_frontend(hass) hass.data.setdefault(DOMAIN, {}) existing = hass.data[DOMAIN].get(entry.entry_id) if existing is not None: @@ -453,6 +470,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: database = await _async_open_database(hass, await async_get_db_path(hass)) remove_update_listener: Callable[[], Any] | None = None remove_time_change_listener: Callable[[], Any] | None = None + coordinator: Any = None try: # Seed the admin allowlist on first setup so the owner is never # locked out: an empty database takes the persisted admin copy @@ -489,7 +507,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await _run_horizon_materialization( hass, database, _configured_horizon_days(dict(entry.options)) ) - _register_regenerate_service(hass) + # The missed sweep runs at startup too (Feature 11): if HA was + # down at the rollover time, the previous night's still-open + # quests are announced as missed now instead of waiting for + # the next midnight. Watermark-guarded, so this is a no-op + # when the night's sweep already ran. + await run_missed_sweep(hass, database) + _register_services(hass) + # The shared Feature 10 coordinator: one refresh cycle every + # entity reads from (CONF_UPDATE_INTERVAL seconds, default + # five minutes). Created AFTER services so a failure below + # unwinds them through the except path's listener handling. + coordinator = NestQuestCoordinator( + hass, + entry_id=entry.entry_id, + database=database, + update_interval_seconds=entry.options.get( + CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL + ), + ) + await coordinator.async_config_entry_first_refresh() + runtime_data = NestQuestRuntimeData( + entry_id=entry.entry_id, + options=dict(entry.options), + database=database, + remove_update_listener=remove_update_listener, + remove_time_change_listener=remove_time_change_listener, + coordinator=coordinator, + ) + # runtime_data is assigned BEFORE the platforms forward so each + # platform's async_setup_entry reads its coordinator off the + # entry (the HA runtime-data contract); a forward failure below + # unwinds it through the except path. + entry.runtime_data = runtime_data + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) except BaseException: # A failure anywhere after the database is open must not leak the # listeners already registered against that (now-closed) database: @@ -508,18 +559,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await result except BaseException: pass + # A coordinator created before the failure holds entity + # listeners pointed at the database being closed below — + # shut it down FIRST so no scheduled refresh can ever run + # against a closed connection. + if coordinator is not None: + try: + await coordinator.async_shutdown() + except BaseException: + pass + # A platform-forward failure reached this unwind AFTER + # runtime_data was assigned: clear it so no later unload path + # closes the already-closed database through the stale record. + if getattr(entry, "runtime_data", None) is not None: + entry.runtime_data = None await database.close() raise assert remove_update_listener is not None assert remove_time_change_listener is not None - runtime_data = NestQuestRuntimeData( - entry_id=entry.entry_id, - options=dict(entry.options), - database=database, - remove_update_listener=remove_update_listener, - remove_time_change_listener=remove_time_change_listener, - ) - entry.runtime_data = runtime_data hass.data[DOMAIN][entry.entry_id] = runtime_data return True @@ -533,6 +590,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.pop(DOMAIN, None) if runtime_data is None: runtime_data = getattr(entry, "runtime_data", None) + # Platforms unload FIRST (mirroring HA conventions): entity + # listeners must not outlive the database and coordinator the + # teardown below closes. + platforms_unloaded = await hass.config_entries.async_unload_platforms( + entry, PLATFORMS + ) + if platforms_unloaded is False and runtime_data is not None: + # Home Assistant KEPT one or more platforms loaded — their + # entities still live and must keep their database. Restore + # the runtime record popped above and report failure so HA + # retries the unload later instead of leaving live entities + # backed by a closed database. + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime_data + return False unload_error: BaseException | None = None if runtime_data is not None: remove_update_listener = getattr(runtime_data, "remove_update_listener", None) @@ -559,14 +630,24 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_error is None: unload_error = err finally: + # The coordinator's entity listeners must stop BEFORE the + # database closes: a scheduled refresh starting during the + # close await would touch a cleared connection. (The + # platform unload above already removed the entities; this + # clears the coordinator's own remaining listeners.) + coordinator = getattr(runtime_data, "coordinator", None) + if coordinator is not None: + shutdown = getattr(coordinator, "async_shutdown", None) + if shutdown is not None: + await shutdown() database = getattr(runtime_data, "database", None) if database is not None: await database.close() - # The ``regenerate`` service is DOMAIN-global: it is torn down only - # once the FINAL entry unloads (``hass.data[DOMAIN]`` is now empty), - # never when a sibling entry is still loaded. + # Domain-global services are torn down only once the FINAL entry + # unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling + # entry is still loaded. if is_last: - _deregister_regenerate_service(hass) + _deregister_services(hass) if getattr(entry, "runtime_data", None) is not None: entry.runtime_data = None if unload_error is not None: diff --git a/custom_components/nestquest/binary_sensor.py b/custom_components/nestquest/binary_sensor.py new file mode 100644 index 0000000..7c7fe10 --- /dev/null +++ b/custom_components/nestquest/binary_sensor.py @@ -0,0 +1,202 @@ +"""Per-child binary sensors (Feature 10, task 4). + +Two binary sensors per active child, both reading only the coordinator +snapshot (the same ChildDaySnapshot the day sensors read): + +- ``binary_sensor.nestquest__all_done`` — ON exactly when the + child owes quests today (due today > 0) AND owes none of them any + more (remaining today == 0). A zero-quest day is OFF: the + celebration automation must never fire on an empty day (the + day-complete event carries the same rule, Feature 10 task 7). +- ``binary_sensor.nestquest__present_today`` — ON when the + presence engine resolves the child present on the HA-local date + (schedules and overrides, D-004 anchor-date arithmetic). + +Naming and registry identity follow the day sensors' contract: names +slug to the documented entity_id shape; unique_ids are built from the +child's database id, never the display name, so a rename never +orphans an entity's history. +""" +from __future__ import annotations + +from collections.abc import Callable, Iterable +from typing import Any + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ChildDaySnapshot, NestQuestCoordinator + + +class _NestQuestChildBinarySensor(CoordinatorEntity, BinarySensorEntity): + """Base for the per-child binary sensors. + + Registry identity is built from the child's database id and never + changes; the displayed name and device label re-derive from the + child's CURRENT snapshot on every read, so a rename propagates on + the next refresh without orphaning history. A child absent from + the latest snapshot (a deactivation; removal is a later task's + scope) yields ``None`` states rather than stale values. + """ + + _name_suffix: str = "" + + def __init__( + self, + coordinator: NestQuestCoordinator, + child: ChildDaySnapshot, + ) -> None: + super().__init__(coordinator) + self._child_id = child.child_id + self._fallback_name = child.child_name + + @property + def _child(self) -> ChildDaySnapshot | None: + """Return the child's current day snapshot, or None.""" + data = self.coordinator.data + if data is None: + return None + for child in data.children: + if child.child_id == self._child_id: + return child + return None + + def _displayed_child_name(self) -> str: + child = self._child + return child.child_name if child is not None else self._fallback_name + + @property + def name(self) -> str | None: + return f"NestQuest {self._displayed_child_name()} {self._name_suffix}" + + @property + def device_info(self) -> DeviceInfo: + return DeviceInfo( + identifiers={(DOMAIN, f"child_{self._child_id}")}, + name=f"NestQuest {self._displayed_child_name()}", + manufacturer="NestQuest", + ) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + child = self._child + if child is None: + return None + return { + "child_id": child.child_id, + "child_name": child.child_name, + } + + +class NestQuestAllDoneSensor(_NestQuestChildBinarySensor): + """ON when the child had quests today and owes none of them.""" + + _name_suffix = "all done" + + def __init__( + self, + coordinator: NestQuestCoordinator, + child: ChildDaySnapshot, + ) -> None: + super().__init__(coordinator, child) + self._attr_unique_id = f"{DOMAIN}_child_{child.child_id}_all_done" + + @property + def is_on(self) -> bool | None: + child = self._child + if child is None: + return None + # Zero-quest days are OFF, not ON: an empty day must never fire + # the celebration automation. + return child.due_today > 0 and child.remaining_today == 0 + + +class NestQuestPresentTodaySensor(_NestQuestChildBinarySensor): + """ON when the child is at this house today (presence engine).""" + + _name_suffix = "present today" + + def __init__( + self, + coordinator: NestQuestCoordinator, + child: ChildDaySnapshot, + ) -> None: + super().__init__(coordinator, child) + self._attr_unique_id = f"{DOMAIN}_child_{child.child_id}_present_today" + + @property + def is_on(self) -> bool | None: + child = self._child + if child is None: + return None + return child.present + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Base attributes plus, for a child away today, the ISO date + of their next present day (the panel's away plate renders + ``Returns , `` from it).""" + base = super().extra_state_attributes + if base is None: + return None + child = self._child + return {**base, "next_present": child.next_present if child else None} + + +def _child_binary_sensors( + coordinator: NestQuestCoordinator, child: ChildDaySnapshot +) -> tuple[_NestQuestChildBinarySensor, ...]: + """Build the two documented binary sensors for one active child.""" + return ( + NestQuestAllDoneSensor(coordinator, child), + NestQuestPresentTodaySensor(coordinator, child), + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: Callable[..., None], +) -> None: + """Create the two binary sensors per active child. + + Entities are created for the snapshot's children at platform + setup; a coordinator listener adds sensors for children that first + appear on a later refresh (the day sensors' same pattern, so a new + child's whole entity set lands together). Deactivated children + are NOT removed here — removal is a later task's scope. + """ + coordinator = entry.runtime_data.coordinator + known_child_ids: set[int] = set() + + def _add_new_children(children: Iterable[ChildDaySnapshot]) -> None: + new_children = [ + child + for child in children + if child.child_id not in known_child_ids + ] + if not new_children: + return + known_child_ids.update(child.child_id for child in new_children) + async_add_entities( + [ + sensor + for child in new_children + for sensor in _child_binary_sensors(coordinator, child) + ] + ) + + data = coordinator.data + if data is not None: + _add_new_children(data.children) + + def _handle_coordinator_update() -> None: + data = coordinator.data + if data is not None: + _add_new_children(data.children) + + coordinator.async_add_listener(_handle_coordinator_update) diff --git a/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml b/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml new file mode 100644 index 0000000..34ea11c --- /dev/null +++ b/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml @@ -0,0 +1,90 @@ +blueprint: + name: NestQuest afternoon reminder + description: >- + Sends an afternoon nudge listing ONLY the children who still have + open quests today. Children who are present and all clear are + omitted (the nudge is for stragglers, not a roll call), children + who are not present today are omitted entirely, and the household + rollup sensors are excluded by the same present guard. When every + present child is already done the automation sends NOTHING at all: + a template condition stops the run before the notification. The + notify service is chosen by the household when importing this + blueprint — NestQuest never hard-codes a personal notify target. + domain: automation + input: + afternoon_time: + name: Afternoon time + description: Local time of day the reminder is sent. + default: "15:00" + selector: + time: {} + notify_target: + name: Notify service + description: >- + The Home Assistant notify service to call for the reminder, + for example notify.mobile_app_your_phone — a placeholder; + pick the notify service for your own device. + selector: + text: {} + send_reminder: + name: Send the reminder + description: >- + Turn off to keep the automation installed but skip the + notification entirely. + default: true + selector: + boolean: {} + +mode: single + +variables: + send_reminder: !input send_reminder + +triggers: + - trigger: time + at: !input afternoon_time + +conditions: + - condition: template + value_template: "{{ send_reminder | bool }}" + # Send nothing when every present child is already done: the guard is + # false unless at least one present child has remaining quests today. + - condition: template + value_template: | + {%- set ns = namespace(any_open=false) -%} + {%- for due in states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%} + {%- if due.attributes.get('present') -%} + {%- set remaining_values = states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$') + | selectattr('attributes.child_id', 'eq', due.attributes.child_id) + | map(attribute='state') + | list -%} + {%- if remaining_values and (remaining_values | first | int) > 0 -%} + {%- set ns.any_open = true -%} + {%- endif -%} + {%- endif -%} + {%- endfor -%} + {{- ns.any_open -}} + +actions: + - action: !input notify_target + data: + title: NestQuest afternoon reminder + message: | + {%- set ns = namespace(lines=[]) -%} + {%- for due in states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%} + {%- if due.attributes.get('present') -%} + {%- set remaining_values = states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$') + | selectattr('attributes.child_id', 'eq', due.attributes.child_id) + | map(attribute='state') + | list -%} + {%- if remaining_values and (remaining_values | first | int) > 0 -%} + {%- set line = due.attributes.child_name ~ ' still has ' ~ (remaining_values | first | int) ~ ' quest(s) open.' -%} + {%- set ns.lines = ns.lines + [line] -%} + {%- endif -%} + {%- endif -%} + {%- endfor -%} + {{- ns.lines | join('\n') -}} \ No newline at end of file diff --git a/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml b/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml new file mode 100644 index 0000000..7d0e029 --- /dev/null +++ b/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml @@ -0,0 +1,109 @@ +blueprint: + name: NestQuest day complete celebration + description: >- + Celebrates the moment a child clears every quest for the day: the + automation is driven by the nestquest_child_day_complete event, + which NestQuest only fires when quests were owed and all of them + are complete — a zero-quest day can never trigger this + celebration. Once-per-child-per-day dedupe: point + last_celebrated_helper at ONE input_text helper (create it under + Settings, Devices and Services, Helpers — plain text). The + automation records each celebrated child as + ":" inside that helper, and a child who + un-completes a quest and completes it again the same day is + celebrated once, not twice — while a SECOND child clearing their + day the same date is still celebrated in full. The helper is + pruned to today's entries on every celebration, so it never + grows past one entry per child per day. A helper that does not + exist yet, or has never been set, lets the first celebration + through. The notification message may use the event payload's + child_name, quests_due and quests_completed. The notify service + is chosen by the household when importing this blueprint — + NestQuest never hard-codes a personal notify target. + domain: automation + input: + notify_target: + name: Notify service + description: >- + The Home Assistant notify service to call for the celebration, + for example notify.mobile_app_your_phone — a placeholder; + pick the notify service for your own device. + selector: + text: {} + celebration_message: + name: Celebration message + description: >- + The message sent when a child clears every quest today. May + use the event payload: trigger.event.data.child_name, + trigger.event.data.quests_due and trigger.event.data.quests_completed. + default: "Legendary! {{ trigger.event.data.child_name }} cleared every quest today!" + selector: + text: {} + celebrate: + name: Send the celebration + description: >- + Turn off to keep the automation installed but skip the + notification entirely. + default: true + selector: + boolean: {} + last_celebrated_helper: + name: Last-celebrated helper + description: >- + A single input_text helper remembering which children were + already celebrated today, as ":" entries + separated by commas. The automation skips a child whose entry + for today is already stored, and rewrites the helper with only + today's entries plus the new one after each celebration. + selector: + entity: + filter: + - domain: input_text + +mode: queued + +variables: + celebrate: !input celebrate + last_celebrated_helper: !input last_celebrated_helper + +triggers: + - trigger: event + event_type: nestquest_child_day_complete + +conditions: + - condition: template + value_template: "{{ celebrate | bool }}" + # Once-per-child-per-day dedupe: celebrate only when THIS child has + # no equal entry for today in the helper. Entries compare as whole + # comma-separated tokens — a substring check would wrongly suppress + # child 1 after child 11 was celebrated. An unset or missing helper + # reads as '' and celebrates. + - condition: template + value_template: >- + {{ (trigger.event.data.child_id ~ ':' ~ now().strftime('%Y-%m-%d')) + not in ((states(last_celebrated_helper) or '') + .split(',') + | select('ne', '') + | list) }} + +actions: + - action: !input notify_target + data: + title: NestQuest day complete + message: !input celebration_message + # Record this child's celebration, keeping only today's entries so the + # helper never outgrows its text limit and stale entries cannot block + # next week's celebrations. + - action: input_text.set_value + target: + entity_id: !input last_celebrated_helper + data: + value: >- + {%- set today = now().strftime('%Y-%m-%d') -%} + {%- set ns = namespace(entries=[]) -%} + {%- for entry in (states(last_celebrated_helper) or '').split(',') -%} + {%- if entry and entry.endswith(':' ~ today) -%} + {%- set ns.entries = ns.entries + [entry] -%} + {%- endif -%} + {%- endfor -%} + {{ (ns.entries + [trigger.event.data.child_id ~ ':' ~ today]) | join(',') }} \ No newline at end of file diff --git a/custom_components/nestquest/blueprints/automation/end_of_day_report.yaml b/custom_components/nestquest/blueprints/automation/end_of_day_report.yaml new file mode 100644 index 0000000..7809903 --- /dev/null +++ b/custom_components/nestquest/blueprints/automation/end_of_day_report.yaml @@ -0,0 +1,121 @@ +blueprint: + name: NestQuest end-of-day report + description: >- + Sends the parent an end-of-day report on how the day went. It runs + deliberately BEFORE NestQuest's midnight missed-quest sweep, so it + reads the same day's data (what is still open at report time, and + how much of the day was completed) rather than tomorrow-morning's + hindsight. Every PRESENT child gets a completion line, so the + parent always receives the day's summary; children who still owe + quests get their open count called out on that line, and children + who are not present today are omitted entirely. A child whose + remaining-count sensor is unavailable is reported with the + completion line only. By default the report is skipped when every + present child is already done — turn on send-even-when-clear to + always get the day's completion summary. The notify service is + chosen by the household when importing this blueprint — NestQuest + never hard-codes a personal notify target. + domain: automation + input: + report_time: + name: Report time + description: >- + Local time of day the report is sent — deliberately before the + midnight missed-quest sweep, so the report reads the same + day's data. + default: "20:00" + selector: + time: {} + notify_target: + name: Notify service + description: >- + The Home Assistant notify service to call for the report, + for example notify.mobile_app_your_phone — a placeholder; + pick the notify service for your own device. + selector: + text: {} + send_report: + name: Send the report + description: >- + Turn off to keep the automation installed but skip the + notification entirely. + default: true + selector: + boolean: {} + send_even_when_clear: + name: Send even when clear + description: >- + Send the report even when every present child has already + finished their quests today. When off (the default), an + all-clear day sends nothing. + default: false + selector: + boolean: {} + +mode: single + +variables: + send_report: !input send_report + send_even_when_clear: !input send_even_when_clear + +triggers: + - trigger: time + at: !input report_time + +conditions: + - condition: template + value_template: "{{ send_report | bool }}" + # Skip an all-clear day unless the household opted into + # send-even-when-clear: the guard is false only when every present + # child has a usable remaining count of zero. A MISSING or + # non-numeric remaining sensor reads as -1 and counts as open — + # a degraded state must reach the parent, not look like all-clear. + - condition: template + value_template: | + {%- set ns = namespace(any_open=false) -%} + {%- for due in states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%} + {%- if due.attributes.get('present') -%} + {%- set remaining_values = states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$') + | selectattr('attributes.child_id', 'eq', due.attributes.child_id) + | map(attribute='state') + | list -%} + {%- if not remaining_values or (remaining_values | first | int(-1)) != 0 -%} + {%- set ns.any_open = true -%} + {%- endif -%} + {%- endif -%} + {%- endfor -%} + {{- send_even_when_clear | bool or ns.any_open -}} + +actions: + - action: !input notify_target + data: + title: NestQuest end-of-day report + message: | + {%- set ns = namespace(lines=[]) -%} + {%- for due in states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%} + {%- if due.attributes.get('present') -%} + {%- set due_count = due.state | int -%} + {%- set completed_values = states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_completed_today(_\\d+)?$') + | selectattr('attributes.child_id', 'eq', due.attributes.child_id) + | map(attribute='state') + | list -%} + {%- set remaining_values = states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$') + | selectattr('attributes.child_id', 'eq', due.attributes.child_id) + | map(attribute='state') + | list -%} + {%- set completed_count = (completed_values | first | int) if completed_values else 0 -%} + {%- set remaining_count = (remaining_values | first | int) if remaining_values else none -%} + {%- if remaining_count is not none and remaining_count > 0 -%} + {%- set line = due.attributes.child_name ~ ': ' ~ remaining_count ~ ' still open (' ~ completed_count ~ '/' ~ due_count ~ ' done).' -%} + {%- else -%} + {%- set line = due.attributes.child_name ~ ': ' ~ completed_count ~ '/' ~ due_count ~ ' done.' -%} + {%- endif -%} + {%- set ns.lines = ns.lines + [line] -%} + {%- endif -%} + {%- endfor -%} + {{- ns.lines | join('\n') -}} \ No newline at end of file diff --git a/custom_components/nestquest/blueprints/automation/morning_summary.yaml b/custom_components/nestquest/blueprints/automation/morning_summary.yaml new file mode 100644 index 0000000..ea38e1d --- /dev/null +++ b/custom_components/nestquest/blueprints/automation/morning_summary.yaml @@ -0,0 +1,78 @@ +blueprint: + name: NestQuest morning summary + description: >- + Sends a morning notification listing what each child still owes + today. Only children whose NestQuest presence sensor is on are + included; children who are not present today are omitted entirely. + The notify service is chosen by the household when importing this + blueprint — NestQuest never hard-codes a personal notify target. + domain: automation + input: + morning_time: + name: Morning time + description: Local time of day the summary is sent. + default: "08:00" + selector: + time: {} + notify_target: + name: Notify service + description: >- + The Home Assistant notify service to call for the summary, + for example notify.mobile_app_your_phone — a placeholder; + pick the notify service for your own device. + selector: + text: {} + send_summary: + name: Send the summary + description: >- + Turn off to keep the automation installed but skip the + notification entirely. + default: true + selector: + boolean: {} + +mode: single + +variables: + send_summary: !input send_summary + +triggers: + - trigger: time + at: !input morning_time + +conditions: + - condition: template + value_template: "{{ send_summary | bool }}" + +actions: + - action: !input notify_target + data: + title: NestQuest morning summary + message: | + {%- set ns = namespace(lines=[]) -%} + {%- for due in states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%} + {%- if due.attributes.get('present') -%} + {%- set remaining_values = states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$') + | selectattr('attributes.child_id', 'eq', due.attributes.child_id) + | map(attribute='state') + | list -%} + {%- if remaining_values -%} + {%- set remaining = remaining_values | first | int -%} + {%- if remaining > 0 -%} + {%- set line = due.attributes.child_name ~ ' owes ' ~ remaining ~ ' quest(s) today.' -%} + {%- else -%} + {%- set line = due.attributes.child_name ~ ' is all clear today (0 quests remaining).' -%} + {%- endif -%} + {%- else -%} + {%- set line = due.attributes.child_name ~ "'s quest count is unavailable today." -%} + {%- endif -%} + {%- set ns.lines = ns.lines + [line] -%} + {%- endif -%} + {%- endfor -%} + {%- if ns.lines -%} + {{- ns.lines | join('\n') -}} + {%- else -%} + No children present today. + {%- endif -%} diff --git a/custom_components/nestquest/completion.py b/custom_components/nestquest/completion.py index 15aa11c..bb6becb 100644 --- a/custom_components/nestquest/completion.py +++ b/custom_components/nestquest/completion.py @@ -128,6 +128,21 @@ def _derived_state( ) +def derive_state( + instance: QuestInstanceRecord, + latest: CompletionEventRecord | None, + today: datetime.date, +) -> str: + """Public state derivation for the Feature 10 coordinator. + + Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or + ``missed`` from the latest event plus the due date (``missed`` is + derived, never an event_type). ``today`` is the caller-resolved + HA-local date. + """ + return _derived_state(instance, latest, today) + + def _was_on_time( instance: QuestInstanceRecord, now: datetime.datetime | None, @@ -351,6 +366,36 @@ async def instance_state( return _derived_state(instance, latest, today_date) +class CompletionResult(str): + """The derived instance state, carrying the append verdict. + + A ``str`` subclass so every existing ``result == "done"`` contract + keeps working; ``appended`` reports whether THIS call actually + appended an event (decided under the same lock as the append), so + a caller — the Feature 09 service path firing bus events — + announces a transition exactly once even under a concurrent + duplicate call where the loser's write is a no-op. A completion + also carries the ``was_on_time`` verdict computed under that same + lock, so the event payload never re-reads mutable latest state a + racing reversal could have replaced. + """ + + appended: bool + was_on_time: bool | None + + def __new__( + cls, + state: str, + *, + appended: bool, + was_on_time: bool | None = None, + ) -> "CompletionResult": + obj = super().__new__(cls, state) + obj.appended = appended + obj.was_on_time = was_on_time + return obj + + async def complete_instance( database: NestQuestDatabase, instance_id: int, @@ -360,14 +405,14 @@ async def complete_instance( actor_child_id: int | None = None, now: datetime.datetime | None = None, today: datetime.date | None = None, -) -> str: +) -> CompletionResult: """Append a completed event unless the instance is already done. - Returns the derived state ``done``. An already-done instance is a - no-op: nothing is appended. Unknown ``instance_id`` raises - ValueError naming the field. Existence, latest-event, and the - append run under one connection lock so a concurrent complete - cannot double-append. + Returns the derived state ``done``; ``appended`` reports whether + THIS call appended (False on an already-done no-op). Unknown + ``instance_id`` raises ValueError naming the field. Existence, + latest-event, and the append run under one connection lock so a + concurrent complete cannot double-append. ``was_on_time`` is computed from the HA-local completion moment (threaded ``now`` / ``today``) against the instance ``due_date`` @@ -389,12 +434,16 @@ async def complete_instance( instance_id ) if latest is not None and latest.event_type == EVENT_COMPLETED: - return "done" + return CompletionResult("done", appended=False) if latest is not None and latest.event_type != EVENT_UNCOMPLETED: raise ValueError( f"event_type must be '{EVENT_COMPLETED}' or " f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}" ) + # Computed under the completion lock and carried on the result: + # the bus event must report THIS completion's timing, never a + # re-read of the latest event a racing reversal could replace. + on_time = _was_on_time(instance, now, today) await append_event( database, instance.id, @@ -403,10 +452,10 @@ async def complete_instance( actor_source=actor_source, actor_user_id=actor_user_id, actor_child_id=actor_child_id, - was_on_time=_was_on_time(instance, now, today), + was_on_time=on_time, now=now, ) - return "done" + return CompletionResult("done", appended=True, was_on_time=on_time) async def uncomplete_instance( @@ -418,16 +467,16 @@ async def uncomplete_instance( actor_child_id: int | None = None, now: datetime.datetime | None = None, today: datetime.date | None = None, -) -> str: +) -> CompletionResult: """Append an uncompleted event unless the instance is already open. Returns the derived state ``open``, or ``missed`` when the instance - is past due (``due_date`` before HA-local ``today``). An - already-open instance is a no-op: nothing is appended. Unknown - ``instance_id`` raises ValueError naming the field. Existence, - latest-event, and the append run under one connection lock so a - concurrent uncomplete cannot double-append. The original completed - row is not modified. + is past due (``due_date`` before HA-local ``today``); ``appended`` + reports whether THIS call appended (False on an already-open + no-op). Unknown ``instance_id`` raises ValueError naming the + field. Existence, latest-event, and the append run under one + connection lock so a concurrent uncomplete cannot double-append. + The original completed row is not modified. """ instance_id = _validate_int_id(instance_id, "instance_id") actor_source, actor_user_id, actor_child_id = _validate_actor( @@ -444,7 +493,10 @@ async def uncomplete_instance( instance_id ) if latest is None or latest.event_type == EVENT_UNCOMPLETED: - return _derived_state(instance, latest, today_date) + return CompletionResult( + _derived_state(instance, latest, today_date), + appended=False, + ) if latest.event_type != EVENT_COMPLETED: raise ValueError( f"event_type must be '{EVENT_COMPLETED}' or " @@ -461,7 +513,9 @@ async def uncomplete_instance( was_on_time=None, now=now, ) - return _derived_state(instance, latest, today_date) + return CompletionResult( + _derived_state(instance, latest, today_date), appended=True + ) async def list_missed_for_child( diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index ce21568..e81d78e 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -7,11 +7,47 @@ DOMAIN = "nestquest" LOGGER_NAME = "custom_components.nestquest" LOGGER = logging.getLogger(LOGGER_NAME) -PLATFORMS: list[str] = [] +# Platforms set up per config entry (Feature 10): the per-child day +# sensors and binary sensors. +PLATFORMS: list[str] = ["sensor", "binary_sensor"] -#: Service name for the on-demand regeneration service (``nestquest.regenerate``). +# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The +# panel and admin cards listen for these; Feature 10 fires completed, +# uncompleted, and child_day_complete from the service paths, and +# Feature 11's nightly sweep fires quest_missed. +EVENT_QUEST_COMPLETED = "nestquest_quest_completed" +EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted" +EVENT_QUEST_MISSED = "nestquest_quest_missed" +EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete" + +#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2 +#: plus the existing Feature 07 regenerate service). +SERVICE_COMPLETE_QUEST = "complete_quest" +SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest" +SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition" +SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition" +SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active" +SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern" +SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override" +SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override" +SERVICE_EXPORT_HISTORY_CSV = "export_history_csv" +SERVICE_MANAGE_CHILD = "manage_child" SERVICE_REGENERATE = "regenerate" +DOMAIN_SERVICES: tuple[str, ...] = ( + SERVICE_COMPLETE_QUEST, + SERVICE_UNCOMPLETE_QUEST, + SERVICE_CREATE_QUEST_DEFINITION, + SERVICE_UPDATE_QUEST_DEFINITION, + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + SERVICE_SET_PRESENCE_PATTERN, + SERVICE_CREATE_PRESENCE_OVERRIDE, + SERVICE_DELETE_PRESENCE_OVERRIDE, + SERVICE_EXPORT_HISTORY_CSV, + SERVICE_MANAGE_CHILD, + SERVICE_REGENERATE, +) + SQLITE_DB_FILENAME = "nestquest.db" # Configuration keys and default values @@ -30,6 +66,33 @@ DEFAULT_PANEL_IDLE_TIMEOUT = 300 # list as a disaster-recovery copy so a lost database can be re-seeded. CONF_ADMIN_USER_IDS = "admin_user_ids" +# Shared coordinator refresh interval (Feature 10), in SECONDS. Every +# entity reads one snapshot per refresh; completions push an immediate +# refresh so this poll is only the fallback cadence. +CONF_UPDATE_INTERVAL = "update_interval" +DEFAULT_UPDATE_INTERVAL = 300 +MIN_UPDATE_INTERVAL = 30 + +# Notification automations (Feature 11). The notify target is a free-text +# Home Assistant service name (e.g. "notify.mobile_app_dads_phone") the +# household fills in — never a hard-coded personal target; the times are +# strict HH:MM local; each of the four automations has an independent +# enable toggle. Per the owner-settled resolution the end-of-day report +# defaults to 20:00 — deliberately BEFORE the midnight missed sweep — +# and never defaults to the rollover time. +CONF_NOTIFY_TARGET = "notify_target" +CONF_MORNING_SUMMARY_TIME = "morning_summary_time" +DEFAULT_MORNING_SUMMARY_TIME = "08:00" +CONF_AFTERNOON_REMINDER_TIME = "afternoon_reminder_time" +DEFAULT_AFTERNOON_REMINDER_TIME = "15:00" +CONF_END_OF_DAY_REPORT_TIME = "end_of_day_report_time" +DEFAULT_END_OF_DAY_REPORT_TIME = "20:00" +CONF_MORNING_SUMMARY_ENABLED = "morning_summary_enabled" +CONF_AFTERNOON_REMINDER_ENABLED = "afternoon_reminder_enabled" +CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled" +CONF_CELEBRATION_ENABLED = "celebration_enabled" +DEFAULT_AUTOMATION_ENABLED = True + # Quest windows (D-008): a definition may declare several day windows; # each produces its own instance per day and groups the panel's Quest # Log into three columns. The clock ranges are stored here so they stay diff --git a/custom_components/nestquest/coordinator.py b/custom_components/nestquest/coordinator.py new file mode 100644 index 0000000..39dc777 --- /dev/null +++ b/custom_components/nestquest/coordinator.py @@ -0,0 +1,292 @@ +"""The shared NestQuest DataUpdateCoordinator (Feature 10). + +ONE refresh pass builds the whole entity snapshot: active children, +today's instances with their derived states (open / done / missed via +:func:`~.completion.derive_state`), each child's presence today, and +per-child rollup counts. Every Feature 10 entity reads +``coordinator.data`` — no entity queries the database itself, so one +refresh updates the whole board coherently. + +"Today" and "now" are HA-local (``hass.config.time_zone``), never the +host clock and never UTC, matching the materialization and completion +layers. + +Presence is resolved through the SAME engine the materializer uses +(:class:`~.presence.PresenceEngine`): schedules and overrides are read +with the public DAO methods and the engine's anchor-date arithmetic +answers ``is this child at this house today`` (D-004 — never ISO week +parity). + +``cycle_day`` reports where today falls in the custody cycle of the +first active child (display order) that HAS a schedule — 1-based day +``N`` of ``cycle_length_weeks * 7``, computed from that schedule's +anchor date. ``0`` means no active child has a schedule at all, so +the admin header degrades to a harmless zero instead of guessing. +""" +from __future__ import annotations + +import asyncio +import datetime +from dataclasses import dataclass +from zoneinfo import ZoneInfo + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .completion import derive_state +from .const import ( + CONF_UPDATE_INTERVAL, + DEFAULT_UPDATE_INTERVAL, + DOMAIN, + LOGGER, + MIN_UPDATE_INTERVAL, +) +from .dao_children import ChildrenDao +from .dao_instances import ( + CompletionEventsDao, + QuestInstancesDao, +) +from .dao_rules import QuestDefinitionsDao +from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao +from .db import NestQuestDatabase +from .presence import ( + MAX_PREVIEW_SCAN_DAYS, + PresenceEngine, + PresenceOverride, + PresenceSchedule, +) + + +@dataclass(frozen=True) +class QuestInstanceView: + """One instance plus everything the entities render from it.""" + + instance_id: int + definition_id: int + child_id: int + title: str + icon: str | None + window: str + due_date: str + due_time: str | None + state: str # open | done | missed + overdue: bool + completed_at: str | None + was_on_time: bool | None + + +@dataclass(frozen=True) +class ChildDaySnapshot: + """One active child's day: instances, presence, and rollup counts.""" + + child_id: int + child_name: str + instances: tuple[QuestInstanceView, ...] + present: bool + due_today: int + completed_today: int + remaining_today: int + completion_pct: int + #: For a child away today: the ISO date of their next present day + #: (the panel's away plate renders ``Returns , `` + #: from it). ``None`` when the child is present today or no + #: present day exists within the presence engine's scan cap. + next_present: str | None = None + + +@dataclass(frozen=True) +class NestQuestSnapshot: + """The coordinator's whole-entity data payload.""" + + today_iso: str + children: tuple[ChildDaySnapshot, ...] + cycle_day: int + + +class NestQuestCoordinator(DataUpdateCoordinator): + """Shared refresh cycle for every NestQuest entity. + + Forced refreshes (the service paths pushing an immediate update + after a completion) are SERIALIZED per entry: overlapping + complete/uncomplete calls must not interleave their snapshot + passes, or an older pass could publish a stale snapshot AFTER a + newer one and leave entities wrong until the next poll. Real + HA's coordinator already serializes internally; the lock keeps + the same guarantee on the stand-in and costs nothing upstream. + """ + + def __init__( + self, + hass: HomeAssistant, + *, + entry_id: str, + database: NestQuestDatabase, + update_interval_seconds: int | None = None, + ) -> None: + interval = update_interval_seconds + if ( + not isinstance(interval, int) + or isinstance(interval, bool) + or interval < MIN_UPDATE_INTERVAL + ): + interval = DEFAULT_UPDATE_INTERVAL + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=datetime.timedelta(seconds=interval), + ) + self.entry_id = entry_id + self.database = database + self._refresh_lock = asyncio.Lock() + + async def async_refresh(self) -> None: + """One refresh at a time; a queued refresh runs AFTER the + in-flight one publishes, so the last snapshot always reflects + the latest mutation.""" + async with self._refresh_lock: + await super().async_refresh() + + def _local_now(self) -> datetime.datetime: + time_zone = ZoneInfo(self.hass.config.time_zone) + return datetime.datetime.now(time_zone) + + async def _async_update_data(self) -> NestQuestSnapshot: + now = self._local_now() + today = now.date() + today_iso = today.isoformat() + + children = await ChildrenDao(self.database).list_active() + + schedules: dict[int, PresenceSchedule] = {} + overrides: dict[int, list[PresenceOverride]] = {} + for child in children: + schedule = await PresenceSchedulesDao(self.database).get_by_child( + child.id + ) + if schedule is not None: + schedules[child.id] = PresenceSchedule.decode( + child.id, schedule.anchor_date, schedule.pattern + ) + window_end = today + datetime.timedelta( + days=MAX_PREVIEW_SCAN_DAYS + ) + window_end_iso = window_end.isoformat() + ranged = await PresenceOverridesDao( + self.database + ).list_by_child_and_range(child.id, today_iso, window_end_iso) + if ranged: + # The engine validates MODEL objects, not DAO records; + # each record converts losslessly (the schema guarantees + # end >= start and a real is_present). + overrides[child.id] = [ + PresenceOverride( + record.child_id, + record.start_date, + record.end_date, + record.is_present, + record.note, + ) + for record in ranged + ] + engine = PresenceEngine(schedules, overrides) + + instances_dao = QuestInstancesDao(self.database) + events_dao = CompletionEventsDao(self.database) + definitions_dao = QuestDefinitionsDao(self.database) + + snapshots: list[ChildDaySnapshot] = [] + for child in children: + instances = await instances_dao.list_by_date_range( + child.id, today_iso, today_iso + ) + views: list[QuestInstanceView] = [] + for instance in instances: + latest = await events_dao.get_latest_for_instance(instance.id) + definition = await definitions_dao.get(instance.definition_id) + state = derive_state(instance, latest, today) + overdue = False + if state == "open" and instance.due_date == today_iso: + if instance.due_time is not None: + due_moment = datetime.datetime.strptime( + instance.due_time, "%H:%M" + ).time() + overdue = now.time() > due_moment + views.append( + QuestInstanceView( + instance_id=instance.id, + definition_id=instance.definition_id, + child_id=instance.child_id, + title=definition.title if definition else "Quest", + icon=definition.icon if definition else None, + window=instance.window, + due_date=instance.due_date, + due_time=instance.due_time, + state=state, + overdue=overdue, + completed_at=( + latest.occurred_at + if latest is not None + and latest.event_type == "completed" + else None + ), + was_on_time=( + latest.was_on_time + if latest is not None + and latest.event_type == "completed" + else None + ), + ) + ) + due = len(views) + completed = sum(1 for view in views if view.state == "done") + remaining = due - completed + child_present = engine.is_present(child.id, today) + next_present: str | None = None + if not child_present: + try: + next_present = engine.next_present_dates( + child.id, today, 1 + )[0].isoformat() + except ValueError: + next_present = None + snapshots.append( + ChildDaySnapshot( + child_id=child.id, + child_name=child.display_name, + instances=tuple(views), + present=child_present, + due_today=due, + completed_today=completed, + remaining_today=remaining, + completion_pct=( + round(completed * 100 / due) if due else 100 + ), + next_present=next_present, + ) + ) + + return NestQuestSnapshot( + today_iso=today_iso, + children=tuple(snapshots), + cycle_day=_cycle_day(schedules, children, today), + ) + + +def _cycle_day( + schedules: dict[int, PresenceSchedule], children, today: datetime.date +) -> int: + """Return today's 1-based day in the first scheduled child's cycle. + + Anchor-date arithmetic ONLY (D-004): the day offset is computed from + the schedule's own anchor, never ISO week numbers or parity, so a + 53-week year cannot silently invert the cycle. + """ + for child in children: + schedule = schedules.get(child.id) + if schedule is None: + continue + days = (today - schedule.anchor_date).days + cycle_length = schedule.cycle_length_weeks * 7 + return (days % cycle_length) + 1 + return 0 diff --git a/custom_components/nestquest/dao_instances.py b/custom_components/nestquest/dao_instances.py index e483d6c..3d30b61 100644 --- a/custom_components/nestquest/dao_instances.py +++ b/custom_components/nestquest/dao_instances.py @@ -493,6 +493,43 @@ class QuestInstancesDao: ) return [_instance_from_row(row) for row in rows] + async def list_missed_unswept( + self, today_iso: str, watermark: str | None + ) -> list[QuestInstanceRecord]: + """Return past-due open instances the sweep has not announced. + + Feature 11's missed-sweep query: ``due_date`` strictly before + ``today_iso`` (past due), at or after ``watermark`` when one is + recorded, and with NO completion event ever (the Feature 08 + append-only contract: an instance that was ever completed is + never reported missed, even when later reversed — history keeps + both rows and the sweep never mutates any). The watermark is + the HA-local date the sweep last ran, and the window is + ``[watermark, today)``: the nightly run entering day D+1 sweeps + exactly day D's instances, so every day's quests are announced + once — at the midnight after their due date — and a same-night + rerun (watermark == today) sees an empty window. Read-only; + ordered deterministically by (due_date, id). + """ + _validate_date(today_iso, "today_iso") + if watermark is not None: + _validate_date(watermark, "watermark") + sql = ( + f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances " + "WHERE due_date < ? " + "AND NOT EXISTS (" + " SELECT 1 FROM completion_events " + " WHERE instance_id = quest_instances.id" + ")" + ) + parameters: tuple = (today_iso,) + if watermark is not None: + sql += " AND due_date >= ?" + parameters += (watermark,) + sql += " ORDER BY due_date, id" + rows = await self._database.fetch_all(sql, parameters) + return [_instance_from_row(row) for row in rows] + async def delete_future_uncompleted( self, definition_id: int, cutoff_date: str, *, today: datetime.date | None = None ) -> int: diff --git a/custom_components/nestquest/dao_meta.py b/custom_components/nestquest/dao_meta.py new file mode 100644 index 0000000..ada8c09 --- /dev/null +++ b/custom_components/nestquest/dao_meta.py @@ -0,0 +1,44 @@ +"""Typed DAO for the integration-owned ``nestquest_meta_state`` table. + +Generic persistent key/value bookkeeping that belongs to the +integration itself rather than any domain table — currently the +Feature 11 missed-sweep watermark. Values are plain strings; callers +interpret them. All SQL for this table lives here per the feature +guardrails, and every method runs through +:class:`~.db.NestQuestDatabase` so nothing blocks the event loop. +""" +from __future__ import annotations + +from .dao_children import _connection_lock +from .db import NestQuestDatabase + +_TABLE = "nestquest_meta_state" + + +class MetaStateDao: + """Typed async access to the ``nestquest_meta_state`` table.""" + + def __init__(self, database: NestQuestDatabase) -> None: + self._database = database + + async def get(self, key: str) -> str | None: + """Return the stored value for ``key``, or None when absent.""" + row = await self._database.fetch_one( + f"SELECT value FROM {_TABLE} WHERE key = ?", (key,) + ) + return row[0] if row is not None else None + + async def set(self, key: str, value: str) -> None: + """Upsert one key/value row atomically. + + The write runs inside one transaction under the shared + connection lock, so a racing reader either sees the old value + or the fully-written new one, never a half-applied state. + """ + async with _connection_lock(self._database): + async with self._database.transaction(): + await self._database.execute( + f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) " + "ON CONFLICT (key) DO UPDATE SET value = excluded.value", + (key, value), + ) \ No newline at end of file diff --git a/custom_components/nestquest/dao_presence.py b/custom_components/nestquest/dao_presence.py index cf1707a..c273287 100644 --- a/custom_components/nestquest/dao_presence.py +++ b/custom_components/nestquest/dao_presence.py @@ -270,6 +270,15 @@ class PresenceOverridesDao: ) return [_override_from_row(row) for row in rows] + async def get(self, override_id: int) -> PresenceOverrideRecord | None: + """Return one override by id, or None when it does not exist.""" + row = await self._database.fetch_one( + f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides " + "WHERE id = ?", + (override_id,), + ) + return _override_from_row(row) if row is not None else None + async def delete(self, override_id: int) -> bool: """Remove one override by id; True if a row was removed.""" result = await self._database.execute( diff --git a/custom_components/nestquest/events.py b/custom_components/nestquest/events.py new file mode 100644 index 0000000..129af0b --- /dev/null +++ b/custom_components/nestquest/events.py @@ -0,0 +1,130 @@ +"""HA bus events fired by the NestQuest service paths (Feature 10). + +The four documented events (design/ENTITIES-AND-SERVICES.md §3, names +in :mod:`~.const`): ``nestquest_quest_completed`` and +``nestquest_quest_uncompleted`` fire from the completion service +handlers on an actual transition (a no-op re-complete or re-uncomplete +fires nothing), and ``nestquest_child_day_complete`` fires when a +completion clears the child's whole day (quests were owed and none +remain — a zero-quest day can never fire it, matching the all-done +binary sensor's rule). ``nestquest_quest_missed`` is the documented +contract Feature 11's nightly sweep fires; nothing here fires it. + +Payload policy: one dict carrying ``child_id``, ``child_name``, +``instance_id``, ``quest_title``, the instance's ``window``/ +``due_date``/``due_time`` so automation authors can filter without a +database lookup, and ``occurred_at`` — the event's own strict UTC +ISO-8601 timestamp. Completion payloads additionally carry +``was_on_time``. No other personal data: names and titles are the +household's own records, and nothing beyond them leaks. +""" +from __future__ import annotations + +import datetime +from zoneinfo import ZoneInfo + +from .completion import derive_state +from .const import ( + EVENT_CHILD_DAY_COMPLETE, + EVENT_QUEST_COMPLETED, + EVENT_QUEST_UNCOMPLETED, +) +from .dao_children import ChildrenDao +from .dao_instances import CompletionEventsDao, QuestInstancesDao +from .dao_rules import QuestDefinitionsDao +from .db import NestQuestDatabase + +#: Strict UTC ISO-8601 timestamp (the DAO layers' policy): one shape, +#: explicit offset, second precision. +_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00" + + +def _now_stamp() -> str: + return datetime.datetime.now(datetime.timezone.utc).strftime( + _UTC_TIMESTAMP_FORMAT + ) + + +async def _instance_payload( + database: NestQuestDatabase, instance_id: int, now_stamp: str +) -> dict: + """Build the shared event payload for one quest instance.""" + instance = await QuestInstancesDao(database).get_by_id(instance_id) + if instance is None: + raise ValueError( + f"instance_id: quest instance {instance_id} does not exist" + ) + child = await ChildrenDao(database).get(instance.child_id) + definition = await QuestDefinitionsDao(database).get( + instance.definition_id + ) + return { + "child_id": instance.child_id, + "child_name": child.display_name if child else None, + "instance_id": instance.id, + "quest_title": definition.title if definition else None, + "window": instance.window, + "due_date": instance.due_date, + "due_time": instance.due_time, + "occurred_at": now_stamp, + } + + +async def fire_quest_completed( + hass, + database: NestQuestDatabase, + instance_id: int, + *, + was_on_time: bool | None, +) -> None: + """Fire ``nestquest_quest_completed`` and, when the completion + cleared the child's whole day, ``nestquest_child_day_complete``. + + Called by the complete_quest service handler AFTER an actual + completion transition, with the completion's ``was_on_time`` — + computed under the completion lock and carried on the result — so + the payload never re-reads mutable latest state a racing reversal + could have replaced. A no-op re-complete never reaches here. + """ + now_stamp = _now_stamp() + payload = await _instance_payload(database, instance_id, now_stamp) + payload["was_on_time"] = was_on_time + hass.bus.async_fire(EVENT_QUEST_COMPLETED, payload) + + # Day-complete: quests were owed today and none remain (the same + # rule as the all-done binary sensor; a zero-quest day never fires). + # Only a completion of TODAY'S instance evaluates it: completing a + # back-dated or future instance must not announce a cleared day. + today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date() + if payload["due_date"] != today.isoformat(): + return + instances = await QuestInstancesDao(database).list_by_date_range( + payload["child_id"], today.isoformat(), today.isoformat() + ) + events = CompletionEventsDao(database) + remaining = 0 + for instance in instances: + latest_for_instance = await events.get_latest_for_instance( + instance.id + ) + if derive_state(instance, latest_for_instance, today) != "done": + remaining += 1 + if instances and remaining == 0: + hass.bus.async_fire( + EVENT_CHILD_DAY_COMPLETE, + { + "child_id": payload["child_id"], + "child_name": payload["child_name"], + "quests_due": len(instances), + "quests_completed": len(instances), + "occurred_at": now_stamp, + }, + ) + + +async def fire_quest_uncompleted( + hass, database: NestQuestDatabase, instance_id: int +) -> None: + """Fire ``nestquest_quest_uncompleted`` for an actual reversal.""" + payload = await _instance_payload(database, instance_id, _now_stamp()) + hass.bus.async_fire(EVENT_QUEST_UNCOMPLETED, payload) diff --git a/custom_components/nestquest/frontend.py b/custom_components/nestquest/frontend.py new file mode 100644 index 0000000..c46ccbc --- /dev/null +++ b/custom_components/nestquest/frontend.py @@ -0,0 +1,35 @@ +"""Lovelace card bundle registration for NestQuest.""" +from __future__ import annotations + +from pathlib import Path + +from homeassistant.components.frontend import add_extra_js_url +from homeassistant.components.http import StaticPathConfig +from homeassistant.core import HomeAssistant + +WWW_DIR = Path(__file__).parent / "www" +FRONTEND_URL_BASE = "/nestquest-static" +BUNDLE_FILENAME = "nestquest-cards.js" +BUNDLE_URL = f"{FRONTEND_URL_BASE}/{BUNDLE_FILENAME}" +_REGISTERED_ATTR = "_nestquest_frontend_registered" + + +async def async_register_frontend(hass: HomeAssistant) -> None: + """Serve the card bundle and inject it into every Lovelace page. + + Idempotent across config entries: static paths and extra JS URLs are + process-global, so a second entry must not register them again. + """ + if getattr(hass, _REGISTERED_ATTR, False): + return + await hass.http.async_register_static_paths( + [ + StaticPathConfig( + FRONTEND_URL_BASE, + str(WWW_DIR), + cache_headers=False, + ) + ] + ) + add_extra_js_url(hass, BUNDLE_URL) + setattr(hass, _REGISTERED_ATTR, True) diff --git a/custom_components/nestquest/manifest.json b/custom_components/nestquest/manifest.json index b55fe8a..fb346c0 100644 --- a/custom_components/nestquest/manifest.json +++ b/custom_components/nestquest/manifest.json @@ -5,6 +5,10 @@ "@overseer" ], "config_flow": true, + "dependencies": [ + "frontend", + "http" + ], "documentation": "https://github.com/talon2king/NestQuest", "integration_type": "hub", "iot_class": "local_polling", diff --git a/custom_components/nestquest/migrations.py b/custom_components/nestquest/migrations.py index 2040366..cf40a7a 100644 --- a/custom_components/nestquest/migrations.py +++ b/custom_components/nestquest/migrations.py @@ -28,7 +28,10 @@ copying each definition's assignee across. Migration 4 adds the windows table, migration 5 rebuilds ``quest_instances`` onto the widened (definition_id, child_id, due_date, window) key, and migration 6 rebuilds ``completion_events`` with the D-008 ``actor_child_id`` -column. Each callable migration runs inside its own transaction +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 together with its version stamp, so a crash mid-step rolls the statements and the stamp back together. @@ -80,6 +83,7 @@ from .schema import ( SCHEMA_V1_STATEMENTS, SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, + SCHEMA_V7_META_STATE_DDL, ) LOGGER = logging.getLogger(__name__) @@ -414,6 +418,11 @@ MIGRATIONS: Sequence[MigrationStep] = [ # actor_child_id column and its actor-pair coherence CHECK, # backfilling legacy panel rows to their own child_id. _add_actor_child_id, + # Migration 7 (Feature 11): the generic meta_state key/value table + # — a plain new table, so CREATE IF NOT EXISTS suffices for both + # fresh databases (which already made it in migration 1) and + # pre-v7 databases. Strictly additive; no existing table changes. + list(SCHEMA_V7_META_STATE_DDL), ] diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py index 84ac218..bf5c59f 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -11,13 +11,28 @@ from homeassistant.helpers import config_validation as cv from .const import ( CONF_ADMIN_USER_IDS, + CONF_AFTERNOON_REMINDER_ENABLED, + CONF_AFTERNOON_REMINDER_TIME, + CONF_CELEBRATION_ENABLED, CONF_DAY_ROLLOVER_TIME, + CONF_END_OF_DAY_REPORT_ENABLED, + CONF_END_OF_DAY_REPORT_TIME, CONF_HORIZON_DAYS, + CONF_MORNING_SUMMARY_ENABLED, + CONF_MORNING_SUMMARY_TIME, + CONF_NOTIFY_TARGET, CONF_PANEL_IDLE_TIMEOUT, + CONF_UPDATE_INTERVAL, + DEFAULT_AFTERNOON_REMINDER_TIME, + DEFAULT_AUTOMATION_ENABLED, DEFAULT_DAY_ROLLOVER_TIME, + DEFAULT_END_OF_DAY_REPORT_TIME, DEFAULT_HORIZON_DAYS, + DEFAULT_MORNING_SUMMARY_TIME, DEFAULT_PANEL_IDLE_TIMEOUT, + DEFAULT_UPDATE_INTERVAL, DOMAIN, + MIN_UPDATE_INTERVAL, ) from .admin_allowlist import list_admin_ids, set_admin_ids @@ -56,9 +71,63 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]: ): errors[CONF_PANEL_IDLE_TIMEOUT] = "invalid" + # The coordinator refresh interval (Feature 10) is OPTIONAL in raw + # submits: a missing key keeps the current value, so pre-Feature-10 + # form payloads (and API callers) are unaffected. Schema-validated + # submits always carry it. + update_interval = user_input.get(CONF_UPDATE_INTERVAL) + if update_interval is not None and ( + isinstance(update_interval, bool) + or not isinstance(update_interval, int) + or update_interval < MIN_UPDATE_INTERVAL + ): + errors[CONF_UPDATE_INTERVAL] = "invalid" + + # The notification section (Feature 11) is OPTIONAL in raw submits + # the same way: a missing key keeps the current value. The three + # times are strict HH:MM; the notify target is a non-empty HA + # service name; the toggles are real booleans. + for time_key in ( + CONF_MORNING_SUMMARY_TIME, + CONF_AFTERNOON_REMINDER_TIME, + CONF_END_OF_DAY_REPORT_TIME, + ): + time_value = user_input.get(time_key) + if time_value is not None and ( + not isinstance(time_value, str) or not _parse_time(time_value) + ): + errors[time_key] = "invalid_time" + notify_target = user_input.get(CONF_NOTIFY_TARGET) + if notify_target is not None and not isinstance(notify_target, str): + errors[CONF_NOTIFY_TARGET] = "invalid" + elif isinstance(notify_target, str) and notify_target != ( + notify_target.strip() + ): + # An EMPTY target is valid — it means "no notifications + # configured yet" and is the shipped default; a value that + # differs from its own trim (padded or whitespace-only) is + # malformed and rejected. + errors[CONF_NOTIFY_TARGET] = "invalid" + for toggle_key in ( + CONF_MORNING_SUMMARY_ENABLED, + CONF_AFTERNOON_REMINDER_ENABLED, + CONF_END_OF_DAY_REPORT_ENABLED, + CONF_CELEBRATION_ENABLED, + ): + toggle_value = user_input.get(toggle_key) + if toggle_value is not None and not isinstance(toggle_value, bool): + errors[toggle_key] = "invalid" + return errors +def _strict_bool(value: object) -> bool: + """Require a real bool; voluptuous ``bool`` would coerce strings.""" + if not isinstance(value, bool): + raise vol.Invalid(f"expected a boolean, got {value!r}") + return value + + def _build_schema( current: dict[str, Any], *, @@ -70,7 +139,10 @@ def _build_schema( The admin picker is a multi-select of EXISTING Home Assistant users (never a free-text field — the allowlist stores user IDs); its default is the current database allowlist, so an untouched submit - re-saves exactly who is already allowed. + re-saves exactly who is already allowed. The notification section + (Feature 11) carries the household's notify target, the three + automation times, and four enable toggles — defaults from + :mod:`~.const`, never a hard-coded personal target. """ fields: dict[Any, Any] = { vol.Required( @@ -82,6 +154,40 @@ def _build_schema( vol.Required( CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT] ): int, + vol.Required( + CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL] + ): int, + vol.Required( + CONF_NOTIFY_TARGET, default=current[CONF_NOTIFY_TARGET] + ): str, + vol.Required( + CONF_MORNING_SUMMARY_TIME, + default=current[CONF_MORNING_SUMMARY_TIME], + ): str, + vol.Required( + CONF_AFTERNOON_REMINDER_TIME, + default=current[CONF_AFTERNOON_REMINDER_TIME], + ): str, + vol.Required( + CONF_END_OF_DAY_REPORT_TIME, + default=current[CONF_END_OF_DAY_REPORT_TIME], + ): str, + vol.Required( + CONF_MORNING_SUMMARY_ENABLED, + default=current[CONF_MORNING_SUMMARY_ENABLED], + ): _strict_bool, + vol.Required( + CONF_AFTERNOON_REMINDER_ENABLED, + default=current[CONF_AFTERNOON_REMINDER_ENABLED], + ): _strict_bool, + vol.Required( + CONF_END_OF_DAY_REPORT_ENABLED, + default=current[CONF_END_OF_DAY_REPORT_ENABLED], + ): _strict_bool, + vol.Required( + CONF_CELEBRATION_ENABLED, + default=current[CONF_CELEBRATION_ENABLED], + ): _strict_bool, } if admin_choices is not None: # HA's supported multi-select validator: the frontend can @@ -210,7 +316,28 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS], CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME], CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT], + CONF_UPDATE_INTERVAL: user_input.get( + CONF_UPDATE_INTERVAL, + self._current_values()[CONF_UPDATE_INTERVAL], + ), } + # The notification section (Feature 11) stores the household's + # choices the same fall-back-to-current way, so raw submits + # that predate the section keep their stored values. + current = self._current_values() + for notification_key in ( + CONF_NOTIFY_TARGET, + CONF_MORNING_SUMMARY_TIME, + CONF_AFTERNOON_REMINDER_TIME, + CONF_END_OF_DAY_REPORT_TIME, + CONF_MORNING_SUMMARY_ENABLED, + CONF_AFTERNOON_REMINDER_ENABLED, + CONF_END_OF_DAY_REPORT_ENABLED, + CONF_CELEBRATION_ENABLED, + ): + data[notification_key] = user_input.get( + notification_key, current[notification_key] + ) if admin_ids_present: data[CONF_ADMIN_USER_IDS] = admin_ids elif admin_default: @@ -231,6 +358,15 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): (CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS), (CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME), (CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT), + (CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL), + (CONF_NOTIFY_TARGET, ""), + (CONF_MORNING_SUMMARY_TIME, DEFAULT_MORNING_SUMMARY_TIME), + (CONF_AFTERNOON_REMINDER_TIME, DEFAULT_AFTERNOON_REMINDER_TIME), + (CONF_END_OF_DAY_REPORT_TIME, DEFAULT_END_OF_DAY_REPORT_TIME), + (CONF_MORNING_SUMMARY_ENABLED, DEFAULT_AUTOMATION_ENABLED), + (CONF_AFTERNOON_REMINDER_ENABLED, DEFAULT_AUTOMATION_ENABLED), + (CONF_END_OF_DAY_REPORT_ENABLED, DEFAULT_AUTOMATION_ENABLED), + (CONF_CELEBRATION_ENABLED, DEFAULT_AUTOMATION_ENABLED), ): if key in options: values[key] = options[key] diff --git a/custom_components/nestquest/permissions.py b/custom_components/nestquest/permissions.py new file mode 100644 index 0000000..5f6df6b --- /dev/null +++ b/custom_components/nestquest/permissions.py @@ -0,0 +1,90 @@ +"""Permission gate for NestQuest services (Feature 09). + +A wrapper factory, applied to EVERY registered service at registration +time (no handler is exempt), reads ``call.context.user_id``, consults +the :data:`~.service_policy.SERVICE_POLICY` registry and +:func:`~.admin_allowlist.is_admin`, and raises +:class:`homeassistant.exceptions.Unauthorized` for an admin-only +operation called by a non-admin — including a call with NO user +context, which fails closed. ``complete_quest`` (policy ``open``) +proceeds regardless of the allowlist. + +Logging (later Feature 09 tasks): every denied admin-only call logs a +warning and every successful one logs a debug line, both carrying the +service, the user id (or ``none``), and a UTC timestamp. +""" +from __future__ import annotations + +import datetime +import logging +from collections.abc import Awaitable, Callable +from typing import Any + +from homeassistant.exceptions import Unauthorized + +from .admin_allowlist import is_admin +from .service_policy import ADMIN_ONLY + +LOGGER = logging.getLogger(__name__) + +ServiceHandler = Callable[[Any], Awaitable[None]] + + +def _context_user_id(call: Any) -> str | None: + context = getattr(call, "context", None) + user_id = getattr(context, "user_id", None) if context is not None else None + if isinstance(user_id, str) and user_id: + return user_id + return None + + +def _stamp() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat( + timespec="seconds" + ) + + +def permission_gate( + service: str, + policy: str, + database_of: Callable[[Any], Any], +) -> Callable[[ServiceHandler], ServiceHandler]: + """Wrap ``handler`` with the Feature 09 permission check. + + ``database_of`` resolves the live database from the call-time + runtime (the same ``find_runtime`` the handler uses), so the gate + never opens its own connection. The verdict is fail-closed: + absent allowlist row, unknown id, no user context — all not admin. + """ + + def _wrap(handler: ServiceHandler) -> ServiceHandler: + if policy != ADMIN_ONLY: + return handler + + async def _gated(call: Any) -> None: + user_id = _context_user_id(call) + allowed = user_id is not None and await is_admin( + database_of(call), user_id + ) + if not allowed: + LOGGER.warning( + "NestQuest denied %s: caller user_id=%s at %s", + service, + user_id if user_id is not None else "none", + _stamp(), + ) + raise Unauthorized( + f"User '{user_id if user_id is not None else 'none'}' " + f"is not allowed to call nestquest.{service}" + ) + LOGGER.debug( + "NestQuest allowed %s: caller user_id=%s at %s", + service, + user_id, + _stamp(), + ) + await handler(call) + + return _gated + + return _wrap \ No newline at end of file diff --git a/custom_components/nestquest/schema.py b/custom_components/nestquest/schema.py index ab1fb35..b3c3223 100644 --- a/custom_components/nestquest/schema.py +++ b/custom_components/nestquest/schema.py @@ -384,6 +384,21 @@ SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [ """, ] +#: Generic integration-owned key/value state (schema version 7, +#: Feature 11): persistent bookkeeping that belongs to the integration +#: rather than any domain table — currently the missed-sweep +#: watermark. ``value`` is a plain string; callers interpret it. +META_STATE_TABLE_SQL = """( + key TEXT NOT NULL PRIMARY KEY, + value TEXT NOT NULL +)""" + +SCHEMA_V7_META_STATE_DDL: list[str] = [ + f""" + CREATE TABLE IF NOT EXISTS nestquest_meta_state {META_STATE_TABLE_SQL} + """, +] + SCHEMA_V1_STATEMENTS: list[str] = [ *SCHEMA_V1_CHILDREN_DDL, *SCHEMA_V1_ADMIN_USERS_DDL, @@ -395,6 +410,7 @@ SCHEMA_V1_STATEMENTS: list[str] = [ *SCHEMA_V1_PRESENCE_OVERRIDES_DDL, *SCHEMA_V1_QUEST_INSTANCES_DDL, *SCHEMA_V1_COMPLETION_EVENTS_DDL, + *SCHEMA_V7_META_STATE_DDL, ] diff --git a/custom_components/nestquest/sensor.py b/custom_components/nestquest/sensor.py new file mode 100644 index 0000000..0157750 --- /dev/null +++ b/custom_components/nestquest/sensor.py @@ -0,0 +1,535 @@ +"""Per-child daily count and percentage sensors (Feature 10, task 2). + +Naming contract (design/ENTITIES-AND-SERVICES.md §1): the design table +documents the entity_id shape ``sensor.nestquest__quests_due_today``. +Home Assistant derives entity_id from the entity NAME and exact +entity_id strings are not directly controllable, so names are spelled +to slug into the documented shape — ``NestQuest Ada quests due today`` +slugs to ``sensor.nestquest_ada_quests_due_today`` — while the +registry identity (``unique_id``) IS exact: built from the child's +database id, never the display name, so a rename never orphans an +entity's history. The design table is the visual contract; this +module spells names so HA's default slugging produces it. + +Every sensor reads ONLY ``coordinator.data`` (one +:class:`~.coordinator.ChildDaySnapshot` per active child); no entity +queries the database, so one refresh updates the whole board +coherently. The due-today sensor additionally carries the child's +day payload as attributes: ``instances`` is the panel shape of +ENTITIES-AND-SERVICES.md §1 (state ``open``/``completed``) OMITTING +missed instances, and ``admin_instances`` includes them (D-009). +""" +from __future__ import annotations + +from collections.abc import Callable, Iterable +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ( + ChildDaySnapshot, + NestQuestCoordinator, + QuestInstanceView, +) + +#: Home Assistant's state machine refuses states longer than 255 +#: characters (its hard recorder/state limit). These sensors' states +#: are numeric, but the guard is applied uniformly: a payload-bearing +#: sensor can never crash the state write, and later string-state +#: sensors reuse the same helper. +MAX_STATE_LENGTH = 255 + + +def _state_within_limit(state: Any) -> Any: + """Return ``state`` unchanged, refusing strings over HA's limit. + + Numeric states pass through untouched; a string state longer than + :data:`MAX_STATE_LENGTH` fails fast with ValueError at the property + read — before HA's state machine rejects the write. + """ + if isinstance(state, str) and len(state) > MAX_STATE_LENGTH: + raise ValueError( + f"state exceeds Home Assistant's {MAX_STATE_LENGTH}-character " + f"limit ({len(state)} characters)" + ) + return state + + +def _instance_payload( + instances: Iterable[QuestInstanceView], *, include_missed: bool +) -> list[dict[str, Any]]: + """Shape instance dicts per ENTITIES-AND-SERVICES.md §1. + + ``state`` maps the coordinator's derived ``done`` to the panel's + ``completed`` spelling; ``missed`` instances are omitted unless + ``include_missed`` (the admin payload, D-009, keeps them with + their ``missed`` state). + """ + payload: list[dict[str, Any]] = [] + for view in instances: + if view.state == "missed" and not include_missed: + continue + payload.append( + { + "id": view.instance_id, + "definition_id": view.definition_id, + "child_id": view.child_id, + "title": view.title, + "icon": view.icon, + "window": view.window, + "due_time": view.due_time, + "state": ( + "completed" if view.state == "done" else view.state + ), + "overdue": view.overdue, + "completed_at": view.completed_at, + "on_time": view.was_on_time, + } + ) + return payload + + +#: State reported by the next-quest sensor when the child owes nothing +#: else today. A documented sentinel, never an empty string (an empty +#: state reads as "sensor went unavailable" in HA). +NEXT_QUEST_NONE_STATE = "none" + + +def _next_open_quest( + child: ChildDaySnapshot, +) -> QuestInstanceView | None: + """Return the child's earliest open quest from today's snapshot. + + The snapshot carries TODAY's instances, so "next" is the earliest + open instance by (due_date, due_time), with ``due_time`` ordering + ahead of ``None`` (a window with no due time comes first only + within the same date — the materializer snapshots each window's + due_time, so a missing one means the window is unscheduled). + Missed and completed instances never qualify. + """ + open_views = [ + view + for view in child.instances + if view.state == "open" + ] + if not open_views: + return None + return min( + open_views, + key=lambda view: ( + view.due_date, + view.due_time if view.due_time is not None else "", + ), + ) + + +class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity): + """Base for the per-child day sensors. + + Registry identity is built from the child's database id and never + changes; the DISPLAYED name and device label are derived from the + child's CURRENT snapshot on every read, so a rename through + ``manage_child`` propagates on the next refresh without orphaning + the entity's history. A child absent from the latest snapshot (a + deactivation; removal is a later task's scope) yields ``None`` + states rather than stale counts. + """ + + #: Per-subclass label appended to the name (e.g. ``quests due + #: today``), set as a class attribute so ``name`` composes from the + #: current snapshot. + _name_suffix: str = "" + + def __init__( + self, + coordinator: NestQuestCoordinator, + child: ChildDaySnapshot, + ) -> None: + super().__init__(coordinator) + self._child_id = child.child_id + self._fallback_name = child.child_name + self._attr_unique_id = None # set by the subclass + + @property + def _child(self) -> ChildDaySnapshot | None: + """Return the child's current day snapshot, or None.""" + data = self.coordinator.data + if data is None: + return None + for child in data.children: + if child.child_id == self._child_id: + return child + return None + + def _displayed_child_name(self) -> str: + child = self._child + return child.child_name if child is not None else self._fallback_name + + @property + def name(self) -> str | None: + return f"NestQuest {self._displayed_child_name()} {self._name_suffix}" + + @property + def device_info(self) -> DeviceInfo: + return DeviceInfo( + identifiers={(DOMAIN, f"child_{self._child_id}")}, + name=f"NestQuest {self._displayed_child_name()}", + manufacturer="NestQuest", + ) + + +class NestQuestQuestsDueTodaySensor(_NestQuestChildDaySensor): + """How many quests the child has due today, with the day payload.""" + + _attr_state_class = SensorStateClass.MEASUREMENT + _name_suffix = "quests due today" + + def __init__( + self, + coordinator: NestQuestCoordinator, + child: ChildDaySnapshot, + ) -> None: + super().__init__(coordinator, child) + self._attr_unique_id = ( + f"{DOMAIN}_child_{child.child_id}_quests_due_today" + ) + + @property + def native_value(self) -> int | None: + child = self._child + if child is None: + return None + return _state_within_limit(child.due_today) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + child = self._child + if child is None: + return None + return { + "instances": _instance_payload( + child.instances, include_missed=False + ), + "admin_instances": _instance_payload( + child.instances, include_missed=True + ), + "child_id": child.child_id, + "child_name": child.child_name, + "present": child.present, + } + + +class NestQuestQuestsCompletedTodaySensor(_NestQuestChildDaySensor): + """How many of the child's quests were completed today.""" + + _attr_state_class = SensorStateClass.MEASUREMENT + _name_suffix = "quests completed today" + + def __init__( + self, + coordinator: NestQuestCoordinator, + child: ChildDaySnapshot, + ) -> None: + super().__init__(coordinator, child) + self._attr_unique_id = ( + f"{DOMAIN}_child_{child.child_id}_quests_completed_today" + ) + + @property + def native_value(self) -> int | None: + child = self._child + if child is None: + return None + return _state_within_limit(child.completed_today) + + +class NestQuestQuestsRemainingTodaySensor(_NestQuestChildDaySensor): + """How many of the child's quests are still owed today.""" + + _attr_state_class = SensorStateClass.MEASUREMENT + _name_suffix = "quests remaining today" + + def __init__( + self, + coordinator: NestQuestCoordinator, + child: ChildDaySnapshot, + ) -> None: + super().__init__(coordinator, child) + self._attr_unique_id = ( + f"{DOMAIN}_child_{child.child_id}_quests_remaining_today" + ) + + @property + def native_value(self) -> int | None: + child = self._child + if child is None: + return None + return _state_within_limit(child.remaining_today) + + +class NestQuestCompletionPctTodaySensor(_NestQuestChildDaySensor): + """The child's completion percentage for today (100 on a zero-quest day).""" + + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_device_class = SensorDeviceClass.PERCENTAGE + _attr_native_unit_of_measurement = "%" + _name_suffix = "completion pct today" + + def __init__( + self, + coordinator: NestQuestCoordinator, + child: ChildDaySnapshot, + ) -> None: + super().__init__(coordinator, child) + self._attr_unique_id = ( + f"{DOMAIN}_child_{child.child_id}_completion_pct_today" + ) + + @property + def native_value(self) -> int | None: + child = self._child + if child is None: + return None + return _state_within_limit(child.completion_pct) + + +class NestQuestNextQuestSensor(_NestQuestChildDaySensor): + """The child's next open quest today (title in the state). + + State is the quest TITLE, truncated to HA's 255-character state + limit (attributes always carry the full title); when the child + owes nothing else today the state is the documented sentinel + ``none`` (NEXT_QUEST_NONE_STATE), never an empty string. + """ + + _attr_state_class = None # a label, not a measurement + _name_suffix = "next quest" + + def __init__( + self, + coordinator: NestQuestCoordinator, + child: ChildDaySnapshot, + ) -> None: + super().__init__(coordinator, child) + self._attr_unique_id = ( + f"{DOMAIN}_child_{child.child_id}_next_quest" + ) + + @property + def native_value(self) -> str | None: + child = self._child + if child is None: + return None + quest = _next_open_quest(child) + if quest is None: + return NEXT_QUEST_NONE_STATE + return quest.title[:MAX_STATE_LENGTH] + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + child = self._child + if child is None: + return None + quest = _next_open_quest(child) + if quest is None: + return {"child_id": child.child_id, "child_name": child.child_name} + return { + "instance_id": quest.instance_id, + "definition_id": quest.definition_id, + "child_id": quest.child_id, + "child_name": child.child_name, + "title": quest.title, + "icon": quest.icon, + "window": quest.window, + "due_date": quest.due_date, + "due_time": quest.due_time, + "overdue": quest.overdue, + } + + +def _child_day_sensors( + coordinator: NestQuestCoordinator, child: ChildDaySnapshot +) -> tuple[_NestQuestChildDaySensor, ...]: + """Build the per-child day sensors for one active child.""" + return ( + NestQuestQuestsDueTodaySensor(coordinator, child), + NestQuestQuestsCompletedTodaySensor(coordinator, child), + NestQuestQuestsRemainingTodaySensor(coordinator, child), + NestQuestCompletionPctTodaySensor(coordinator, child), + NestQuestNextQuestSensor(coordinator, child), + ) + + +class _NestQuestHouseholdSensor(CoordinatorEntity, SensorEntity): + """Base for the household-level rollup sensors. + + Aggregates the ACTIVE children's snapshots only (the snapshot + already excludes inactive children), matching the per-child + sensors exactly — a household number is always the sum of the + per-child numbers. Grouped under one household device so the + three household sensors render together. + """ + + _attr_name: str + + def __init__(self, coordinator: NestQuestCoordinator) -> None: + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, "household")}, + name="NestQuest Household", + manufacturer="NestQuest", + ) + + +class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor): + """Total quests due today across active children.""" + + _attr_name = "NestQuest household quests due today" + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_unique_id = f"{DOMAIN}_household_quests_due_today" + + @property + def native_value(self) -> int | None: + data = self.coordinator.data + if data is None: + return None + return _state_within_limit( + sum(child.due_today for child in data.children) + ) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + data = self.coordinator.data + if data is None: + return None + return { + "children": { + str(child.child_id): child.due_today + for child in data.children + } + } + + +class NestQuestHouseholdCompletedTodaySensor(_NestQuestHouseholdSensor): + """Total quests completed today across active children.""" + + _attr_name = "NestQuest household quests completed today" + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_unique_id = f"{DOMAIN}_household_quests_completed_today" + + @property + def native_value(self) -> int | None: + data = self.coordinator.data + if data is None: + return None + return _state_within_limit( + sum(child.completed_today for child in data.children) + ) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + data = self.coordinator.data + if data is None: + return None + return { + "children": { + str(child.child_id): child.completed_today + for child in data.children + } + } + + +class NestQuestCycleDaySensor(_NestQuestHouseholdSensor): + """Cycle day N of the custody cycle (0 when nobody is scheduled). + + Computed by the coordinator from the first active child with a + presence schedule, anchor-date arithmetic only (D-004 — never ISO + week parity), so a 53-week year cannot silently invert it. + """ + + _attr_name = "NestQuest cycle day" + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_unique_id = f"{DOMAIN}_cycle_day" + + @property + def native_value(self) -> int | None: + data = self.coordinator.data + if data is None: + return None + return _state_within_limit(data.cycle_day) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + data = self.coordinator.data + if data is None: + return None + return {"cycle_day": data.cycle_day, "today": data.today_iso} + + +def _household_sensors( + coordinator: NestQuestCoordinator, +) -> tuple[_NestQuestHouseholdSensor, ...]: + """Build the three household-level sensors (one set per entry).""" + return ( + NestQuestHouseholdDueTodaySensor(coordinator), + NestQuestHouseholdCompletedTodaySensor(coordinator), + NestQuestCycleDaySensor(coordinator), + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: Callable[..., None], +) -> None: + """Create the five day sensors per active child. + + Entities are created for the snapshot's children at platform + setup; a coordinator listener adds sensors for children that + first appear on a later refresh. Deactivated children are NOT + removed here — their sensors hold ``None`` states until the + entity-removal task lands. + """ + coordinator = entry.runtime_data.coordinator + known_child_ids: set[int] = set() + + def _add_new_children(children: Iterable[ChildDaySnapshot]) -> None: + new_children = [ + child + for child in children + if child.child_id not in known_child_ids + ] + if not new_children: + return + known_child_ids.update(child.child_id for child in new_children) + async_add_entities( + [ + sensor + for child in new_children + for sensor in _child_day_sensors(coordinator, child) + ] + ) + + data = coordinator.data + if data is not None: + _add_new_children(data.children) + # The household rollups are child-independent: created exactly + # once at platform setup, they aggregate whatever the snapshot + # carries (empty included — an empty household is a valid 0). + async_add_entities(_household_sensors(coordinator)) + + def _handle_coordinator_update() -> None: + data = coordinator.data + if data is not None: + _add_new_children(data.children) + + coordinator.async_add_listener(_handle_coordinator_update) diff --git a/custom_components/nestquest/service_policy.py b/custom_components/nestquest/service_policy.py new file mode 100644 index 0000000..823bed4 --- /dev/null +++ b/custom_components/nestquest/service_policy.py @@ -0,0 +1,46 @@ +"""Explicit admin-only versus open service registry (Feature 09). + +One mapping, one file: every registered ``nestquest`` service name is +either ``open`` or ``admin_only``. The permission decorator (later +Feature 09 tasks) reads this table; it does not take per-handler +policy arguments. +""" +from __future__ import annotations + +from .const import ( + DOMAIN_SERVICES, + SERVICE_COMPLETE_QUEST, + SERVICE_CREATE_PRESENCE_OVERRIDE, + SERVICE_CREATE_QUEST_DEFINITION, + SERVICE_DELETE_PRESENCE_OVERRIDE, + SERVICE_EXPORT_HISTORY_CSV, + SERVICE_MANAGE_CHILD, + SERVICE_REGENERATE, + SERVICE_SET_PRESENCE_PATTERN, + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + SERVICE_UNCOMPLETE_QUEST, + SERVICE_UPDATE_QUEST_DEFINITION, +) + +OPEN = "open" +ADMIN_ONLY = "admin_only" + +SERVICE_POLICY: dict[str, str] = { + SERVICE_COMPLETE_QUEST: OPEN, + SERVICE_UNCOMPLETE_QUEST: ADMIN_ONLY, + SERVICE_CREATE_QUEST_DEFINITION: ADMIN_ONLY, + SERVICE_UPDATE_QUEST_DEFINITION: ADMIN_ONLY, + SERVICE_SET_QUEST_DEFINITION_ACTIVE: ADMIN_ONLY, + SERVICE_SET_PRESENCE_PATTERN: ADMIN_ONLY, + SERVICE_CREATE_PRESENCE_OVERRIDE: ADMIN_ONLY, + SERVICE_DELETE_PRESENCE_OVERRIDE: ADMIN_ONLY, + SERVICE_EXPORT_HISTORY_CSV: ADMIN_ONLY, + SERVICE_MANAGE_CHILD: ADMIN_ONLY, + SERVICE_REGENERATE: ADMIN_ONLY, +} + +if frozenset(SERVICE_POLICY) != frozenset(DOMAIN_SERVICES): + raise RuntimeError( + "SERVICE_POLICY must name exactly the registered nestquest services, " + f"got {sorted(SERVICE_POLICY)} vs {list(DOMAIN_SERVICES)}" + ) diff --git a/custom_components/nestquest/services.py b/custom_components/nestquest/services.py new file mode 100644 index 0000000..0c66635 --- /dev/null +++ b/custom_components/nestquest/services.py @@ -0,0 +1,636 @@ +"""Domain-global NestQuest service schemas and handlers (Feature 09). + +Registration is domain-scoped and happens once (see +:func:`async_register_services`); handlers resolve a live database at +call time through the injected ``find_runtime`` callable. Permission +checks are Feature 09 later tasks — this module only validates payload +shape and forwards to the Feature 03/06/08 business layers (and the +presence DAOs). ``export_history_csv`` is registered with a schema but +raises until Feature 13. ``regenerate`` is supplied by the caller so +the Feature 07 handler stays the one in ``__init__``. +""" +from __future__ import annotations + +import datetime +from collections.abc import Awaitable, Callable +from typing import Any +from zoneinfo import ZoneInfo + +import voluptuous as vol +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import children as children_layer +from . import completion as completion_layer +from . import quest_definitions as quest_definitions_layer +from .const import ( + CONF_HORIZON_DAYS, + DEFAULT_HORIZON_DAYS, + DOMAIN, + DOMAIN_SERVICES, + QUEST_WINDOWS, + SERVICE_COMPLETE_QUEST, + SERVICE_CREATE_PRESENCE_OVERRIDE, + SERVICE_CREATE_QUEST_DEFINITION, + SERVICE_DELETE_PRESENCE_OVERRIDE, + SERVICE_EXPORT_HISTORY_CSV, + SERVICE_MANAGE_CHILD, + SERVICE_REGENERATE, + SERVICE_SET_PRESENCE_PATTERN, + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + SERVICE_UNCOMPLETE_QUEST, + SERVICE_UPDATE_QUEST_DEFINITION, +) +from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao +from .events import fire_quest_completed, fire_quest_uncompleted +from .materialize import regenerate_for_child +from .permissions import permission_gate +from .presence import PresenceSchedule +from .recurrence import ScheduleRule +from .service_policy import SERVICE_POLICY + +FindRuntime = Callable[[HomeAssistant], Any] +ServiceHandler = Callable[[Any], Awaitable[None]] + +ACTOR_PANEL = "panel" +ACTOR_USER = "user" +MANAGE_CHILD_CREATE = "create" +MANAGE_CHILD_EDIT = "edit" +MANAGE_CHILD_SET_ACTIVE = "set_active" +MANAGE_CHILD_REORDER = "reorder" + + +def _strict_int(value: object) -> int: + """Reject bools and non-ints; voluptuous ``int`` would accept True.""" + if isinstance(value, bool) or not isinstance(value, int): + raise vol.Invalid(f"expected int, got {value!r}") + return value + + +def _strict_bool(value: object) -> bool: + """Reject ints and other truthy values; require a real bool.""" + if type(value) is not bool: + raise vol.Invalid(f"expected bool, got {value!r}") + return value + + +def _weekday_list(value: object) -> list[int]: + if type(value) is not list: + raise vol.Invalid(f"expected a list of weekday ints, got {value!r}") + return [_strict_int(item) for item in value] + + +def _presence_pattern(value: object) -> dict[int, list[int]]: + if not isinstance(value, dict): + raise vol.Invalid(f"pattern must be a mapping, got {value!r}") + pattern: dict[int, list[int]] = {} + for week, days in value.items(): + if isinstance(week, str): + try: + week_index = int(week) + except ValueError as err: + raise vol.Invalid( + f"pattern week index must be an integer, got {week!r}" + ) from err + if str(week_index) != week: + raise vol.Invalid( + f"pattern week index must be an integer, got {week!r}" + ) + else: + week_index = _strict_int(week) + pattern[week_index] = _weekday_list(days) + return pattern + + +def _window_entry(value: object) -> str | tuple[str, str | None]: + if isinstance(value, str): + if value not in QUEST_WINDOWS: + raise vol.Invalid(f"unknown window {value!r}") + return value + if isinstance(value, dict): + parsed = vol.Schema( + { + vol.Required("window"): vol.In(QUEST_WINDOWS), + vol.Optional("due_time"): str, + } + )(value) + due_time = parsed.get("due_time") + if due_time is None: + return parsed["window"] + return (parsed["window"], due_time) + raise vol.Invalid( + f"windows entry must be a window name or object, got {value!r}" + ) + + +def _windows(value: object) -> list[str | tuple[str, str | None]]: + if type(value) is not list: + raise vol.Invalid(f"windows must be a list, got {value!r}") + if not value: + raise vol.Invalid("windows must not be empty") + return [_window_entry(entry) for entry in value] + + +def _int_list(value: object) -> list[int]: + if type(value) is not list: + raise vol.Invalid(f"expected a list of ints, got {value!r}") + return [_strict_int(item) for item in value] + + +def _rule_dict(value: object) -> dict: + if not isinstance(value, dict): + raise vol.Invalid(f"rule must be a dict, got {value!r}") + return value + + +SCHEMA_COMPLETE_QUEST = vol.Schema( + { + vol.Required("instance_id"): _strict_int, + vol.Required("actor"): vol.In([ACTOR_PANEL, ACTOR_USER]), + vol.Optional("actor_child_id"): _strict_int, + vol.Optional("actor_user_id"): str, + } +) + +SCHEMA_UNCOMPLETE_QUEST = SCHEMA_COMPLETE_QUEST + +SCHEMA_CREATE_QUEST_DEFINITION = vol.Schema( + { + vol.Required("title"): str, + vol.Required("rule"): _rule_dict, + vol.Required("assignee_child_ids"): vol.All( + _int_list, vol.Length(min=1) + ), + vol.Required("windows"): _windows, + vol.Optional("description"): str, + vol.Optional("icon"): str, + } +) + +SCHEMA_UPDATE_QUEST_DEFINITION = vol.Schema( + { + vol.Required("definition_id"): _strict_int, + vol.Optional("title"): str, + vol.Optional("description"): vol.Any(str, None), + vol.Optional("icon"): vol.Any(str, None), + vol.Optional("rule"): _rule_dict, + vol.Optional("windows"): _windows, + } +) + +SCHEMA_SET_QUEST_DEFINITION_ACTIVE = vol.Schema( + { + vol.Required("definition_id"): _strict_int, + vol.Required("is_active"): _strict_bool, + } +) + +SCHEMA_SET_PRESENCE_PATTERN = vol.Schema( + { + vol.Required("child_id"): _strict_int, + vol.Required("cycle_length_weeks"): _strict_int, + vol.Required("anchor_date"): str, + vol.Required("pattern"): _presence_pattern, + } +) + +SCHEMA_CREATE_PRESENCE_OVERRIDE = vol.Schema( + { + vol.Required("child_id"): _strict_int, + vol.Required("start_date"): str, + vol.Required("end_date"): str, + vol.Required("is_present"): _strict_bool, + vol.Optional("note"): str, + } +) + +SCHEMA_DELETE_PRESENCE_OVERRIDE = vol.Schema( + { + vol.Required("override_id"): _strict_int, + } +) + +SCHEMA_EXPORT_HISTORY_CSV = vol.Schema({}) + +SCHEMA_MANAGE_CHILD = vol.Schema( + { + vol.Required("action"): vol.In( + [ + MANAGE_CHILD_CREATE, + MANAGE_CHILD_EDIT, + MANAGE_CHILD_SET_ACTIVE, + MANAGE_CHILD_REORDER, + ] + ), + vol.Optional("child_id"): _strict_int, + vol.Optional("display_name"): str, + vol.Optional("colour"): str, + vol.Optional("avatar_ref"): str, + vol.Optional("sort_order"): _strict_int, + vol.Optional("is_active"): _strict_bool, + vol.Optional("ordered_ids"): _int_list, + } +) + + +def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime): + runtime = find_runtime(hass) + if runtime is None: + raise HomeAssistantError( + "NestQuest has no live database; cannot run this service" + ) + return runtime + + +async def _refresh_entities(runtime: Any) -> None: + """Force an immediate coordinator refresh after a service mutation. + + Only the SERVICE paths call this — the scheduled materialize and + rollover internals are direct function calls and never widen this + gate — so entities reflect a completion the moment the service + call returns instead of waiting for the poll interval. + """ + coordinator = getattr(runtime, "coordinator", None) + if coordinator is not None: + await coordinator.async_refresh() + + +def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]: + time_zone = ZoneInfo(hass.config.time_zone) + today = datetime.datetime.now(time_zone).date() + value = runtime.options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS) + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + horizon_days = DEFAULT_HORIZON_DAYS + else: + horizon_days = value + return {"today": today, "horizon_days": horizon_days} + + +def _actor_kwargs(call: Any) -> dict[str, Any]: + actor = call.data["actor"] + if actor == ACTOR_PANEL: + actor_child_id = call.data.get("actor_child_id") + if actor_child_id is None: + raise HomeAssistantError( + "actor_child_id is required when actor is panel" + ) + return { + "actor_source": ACTOR_PANEL, + "actor_child_id": actor_child_id, + "actor_user_id": None, + } + context = getattr(call, "context", None) + user_id = getattr(context, "user_id", None) if context is not None else None + if not user_id: + user_id = call.data.get("actor_user_id") + return { + "actor_source": ACTOR_USER, + "actor_user_id": user_id, + "actor_child_id": None, + } + + +def _with_errors(handler: ServiceHandler) -> ServiceHandler: + async def _wrapped(call: Any) -> None: + try: + await handler(call) + except HomeAssistantError: + raise + except ValueError as err: + raise HomeAssistantError(str(err)) from err + + return _wrapped + + +def _complete_quest( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + runtime = _require_runtime(hass, find_runtime) + instance_id = call.data["instance_id"] + # ``appended`` is decided under the same lock as the write, so + # a concurrent duplicate call (a double tap) can not produce a + # second transition event: the loser appends nothing. + result = await completion_layer.complete_instance( + runtime.database, + instance_id, + **_actor_kwargs(call), + ) + if result.appended: + await fire_quest_completed( + hass, + runtime.database, + instance_id, + was_on_time=result.was_on_time, + ) + await _refresh_entities(runtime) + + return _with_errors(_handler) + + +def _uncomplete_quest( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + runtime = _require_runtime(hass, find_runtime) + instance_id = call.data["instance_id"] + result = await completion_layer.uncomplete_instance( + runtime.database, + instance_id, + **_actor_kwargs(call), + ) + if result.appended: + await fire_quest_uncompleted( + hass, runtime.database, instance_id + ) + await _refresh_entities(runtime) + + return _with_errors(_handler) + + +def _create_quest_definition( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + database = _require_runtime(hass, find_runtime).database + data = call.data + await quest_definitions_layer.create_quest_definition( + database, + data["title"], + ScheduleRule.from_dict(data["rule"]), + data["assignee_child_ids"], + data["windows"], + description=data.get("description"), + icon=data.get("icon"), + ) + + return _with_errors(_handler) + + +def _update_quest_definition( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + runtime = _require_runtime(hass, find_runtime) + data = call.data + kwargs: dict[str, Any] = _generation_kwargs(hass, runtime) + if "title" in data: + kwargs["title"] = data["title"] + if "description" in data: + kwargs["description"] = data["description"] + if "icon" in data: + kwargs["icon"] = data["icon"] + if "rule" in data: + kwargs["rule"] = ScheduleRule.from_dict(data["rule"]) + if "windows" in data: + kwargs["windows"] = data["windows"] + await quest_definitions_layer.edit_quest_definition( + runtime.database, data["definition_id"], **kwargs + ) + + return _with_errors(_handler) + + +def _set_quest_definition_active( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + runtime = _require_runtime(hass, find_runtime) + await quest_definitions_layer.set_quest_definition_active( + runtime.database, + call.data["definition_id"], + call.data["is_active"], + **_generation_kwargs(hass, runtime), + ) + + return _with_errors(_handler) + + +def _set_presence_pattern( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + runtime = _require_runtime(hass, find_runtime) + data = call.data + schedule = PresenceSchedule( + data["child_id"], + data["cycle_length_weeks"], + data["anchor_date"], + data["pattern"], + ) + await PresenceSchedulesDao(runtime.database).upsert_by_child( + schedule.child_id, + schedule.cycle_length_weeks, + schedule.anchor_date.isoformat(), + schedule.encode(), + ) + await regenerate_for_child( + runtime.database, + schedule.child_id, + **_generation_kwargs(hass, runtime), + ) + + return _with_errors(_handler) + + +def _create_presence_override( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + runtime = _require_runtime(hass, find_runtime) + data = call.data + await PresenceOverridesDao(runtime.database).create( + data["child_id"], + data["start_date"], + data["end_date"], + data["is_present"], + note=data.get("note"), + ) + await regenerate_for_child( + runtime.database, + data["child_id"], + **_generation_kwargs(hass, runtime), + ) + + return _with_errors(_handler) + + +def _delete_presence_override( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + runtime = _require_runtime(hass, find_runtime) + dao = PresenceOverridesDao(runtime.database) + override_id = call.data["override_id"] + override = await dao.get(override_id) + if override is None: + raise HomeAssistantError( + f"override_id: presence override {override_id} does not exist" + ) + await dao.delete(override_id) + await regenerate_for_child( + runtime.database, + override.child_id, + **_generation_kwargs(hass, runtime), + ) + + return _with_errors(_handler) + + +async def _export_history_csv(_call: Any) -> None: + raise HomeAssistantError( + "CSV history export is not implemented until Feature 13" + ) + + +def _manage_child( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + database = _require_runtime(hass, find_runtime).database + data = call.data + action = data["action"] + if action == MANAGE_CHILD_CREATE: + display_name = data.get("display_name") + if display_name is None: + raise HomeAssistantError( + "display_name is required when action is create" + ) + kwargs: dict[str, Any] = {} + if "colour" in data: + kwargs["colour"] = data["colour"] + if "avatar_ref" in data: + kwargs["avatar_ref"] = data["avatar_ref"] + if "sort_order" in data: + kwargs["sort_order"] = data["sort_order"] + await children_layer.create_child( + database, display_name, **kwargs + ) + return + if action == MANAGE_CHILD_EDIT: + child_id = data.get("child_id") + if child_id is None: + raise HomeAssistantError( + "child_id is required when action is edit" + ) + kwargs = {} + if "display_name" in data: + kwargs["display_name"] = data["display_name"] + if "colour" in data: + kwargs["colour"] = data["colour"] + if "avatar_ref" in data: + kwargs["avatar_ref"] = data["avatar_ref"] + if "sort_order" in data: + kwargs["sort_order"] = data["sort_order"] + await children_layer.edit_child(database, child_id, **kwargs) + return + if action == MANAGE_CHILD_SET_ACTIVE: + child_id = data.get("child_id") + is_active = data.get("is_active") + if child_id is None: + raise HomeAssistantError( + "child_id is required when action is set_active" + ) + if is_active is None: + raise HomeAssistantError( + "is_active is required when action is set_active" + ) + await children_layer.set_child_active( + database, child_id, is_active + ) + return + ordered_ids = data.get("ordered_ids") + if ordered_ids is None: + raise HomeAssistantError( + "ordered_ids is required when action is reorder" + ) + await children_layer.reorder_children(database, ordered_ids) + + return _with_errors(_handler) + + +def async_register_services( + hass: HomeAssistant, + *, + find_runtime: FindRuntime, + regenerate_handler: ServiceHandler, +) -> None: + """Register every canonical service once at domain scope. + + Every service — regenerate included — passes through the Feature + 09 permission gate before its handler runs; the gate reads + :data:`~.service_policy.SERVICE_POLICY`, so no service can be + registered exempt from the policy. + """ + if hass.services.has_service(DOMAIN, SERVICE_REGENERATE): + return + gate = permission_gate + specs: list[tuple[str, ServiceHandler, object]] = [ + (SERVICE_REGENERATE, regenerate_handler, None), + ( + SERVICE_COMPLETE_QUEST, + _complete_quest(hass, find_runtime), + SCHEMA_COMPLETE_QUEST, + ), + ( + SERVICE_UNCOMPLETE_QUEST, + _uncomplete_quest(hass, find_runtime), + SCHEMA_UNCOMPLETE_QUEST, + ), + ( + SERVICE_CREATE_QUEST_DEFINITION, + _create_quest_definition(hass, find_runtime), + SCHEMA_CREATE_QUEST_DEFINITION, + ), + ( + SERVICE_UPDATE_QUEST_DEFINITION, + _update_quest_definition(hass, find_runtime), + SCHEMA_UPDATE_QUEST_DEFINITION, + ), + ( + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + _set_quest_definition_active(hass, find_runtime), + SCHEMA_SET_QUEST_DEFINITION_ACTIVE, + ), + ( + SERVICE_SET_PRESENCE_PATTERN, + _set_presence_pattern(hass, find_runtime), + SCHEMA_SET_PRESENCE_PATTERN, + ), + ( + SERVICE_CREATE_PRESENCE_OVERRIDE, + _create_presence_override(hass, find_runtime), + SCHEMA_CREATE_PRESENCE_OVERRIDE, + ), + ( + SERVICE_DELETE_PRESENCE_OVERRIDE, + _delete_presence_override(hass, find_runtime), + SCHEMA_DELETE_PRESENCE_OVERRIDE, + ), + ( + SERVICE_EXPORT_HISTORY_CSV, + _export_history_csv, + SCHEMA_EXPORT_HISTORY_CSV, + ), + ( + SERVICE_MANAGE_CHILD, + _manage_child(hass, find_runtime), + SCHEMA_MANAGE_CHILD, + ), + ] + + def _database_of(_call: Any) -> Any: + return _require_runtime(hass, find_runtime).database + + for service, handler, schema in specs: + gated = gate( + service, SERVICE_POLICY[service], _database_of + )(handler) + hass.services.async_register(DOMAIN, service, gated, schema=schema) + + +def async_deregister_services(hass: HomeAssistant) -> None: + """Remove every canonical service when the last entry unloads.""" + for service in DOMAIN_SERVICES: + if hass.services.has_service(DOMAIN, service): + hass.services.async_remove(DOMAIN, service) diff --git a/custom_components/nestquest/services.yaml b/custom_components/nestquest/services.yaml index eeae053..892d318 100644 --- a/custom_components/nestquest/services.yaml +++ b/custom_components/nestquest/services.yaml @@ -9,4 +9,318 @@ regenerate: horizon, using the same idempotent materialization as the daily rollover and the startup backfill. Safe to call at any time: a re-run refreshes the same rows instead of duplicating them, and completed instances are never - touched. \ No newline at end of file + touched. + +complete_quest: + name: Complete quest + description: Mark a quest instance as completed. + fields: + instance_id: + name: Instance ID + description: The quest instance to complete. + required: true + selector: + number: + mode: box + actor: + name: Actor + description: Who completed the quest (panel or user). + required: true + selector: + select: + options: + - panel + - user + actor_child_id: + name: Actor child ID + description: The tapped child profile. Required when actor is panel. + required: false + selector: + number: + mode: box + actor_user_id: + name: Actor user ID + description: Home Assistant user id when actor is user and no call context is present. + required: false + selector: + text: + +uncomplete_quest: + name: Uncomplete quest + description: Undo a quest instance completion. + fields: + instance_id: + name: Instance ID + description: The quest instance to uncomplete. + required: true + selector: + number: + mode: box + actor: + name: Actor + description: Who is undoing the completion (panel or user). + required: true + selector: + select: + options: + - panel + - user + actor_child_id: + name: Actor child ID + description: The tapped child profile. Required when actor is panel. + required: false + selector: + number: + mode: box + actor_user_id: + name: Actor user ID + description: Home Assistant user id when actor is user and no call context is present. + required: false + selector: + text: + +create_quest_definition: + name: Create quest definition + description: Create a quest definition with its schedule rule, assignees and windows. + fields: + title: + name: Title + description: Display title for the quest. + required: true + selector: + text: + rule: + name: Rule + description: Recurrence rule as a mapping (rule_type plus shape fields). + required: true + selector: + object: + assignee_child_ids: + name: Assignee child IDs + description: Child ids this definition is assigned to. + required: true + selector: + object: + windows: + name: Windows + description: Day windows (morning, afternoon, evening), optionally with a due time. + required: true + selector: + object: + description: + name: Description + description: Optional longer description. + required: false + selector: + text: + icon: + name: Icon + description: Optional icon name. + required: false + selector: + text: + +update_quest_definition: + name: Update quest definition + description: Edit a quest definition's title, description, icon, rule or windows. + fields: + definition_id: + name: Definition ID + description: The quest definition to update. + required: true + selector: + number: + mode: box + title: + name: Title + description: Replacement display title. + required: false + selector: + text: + description: + name: Description + description: Replacement description, or null to clear. + required: false + selector: + text: + icon: + name: Icon + description: Replacement icon, or null to clear. + required: false + selector: + text: + rule: + name: Rule + description: Replacement recurrence rule as a mapping. + required: false + selector: + object: + windows: + name: Windows + description: Replacement day windows. + required: false + selector: + object: + +set_quest_definition_active: + name: Set quest definition active + description: Deactivate or reactivate a quest definition. + fields: + definition_id: + name: Definition ID + description: The quest definition to update. + required: true + selector: + number: + mode: box + is_active: + name: Active + description: Whether the definition should generate future instances. + required: true + selector: + boolean: + +set_presence_pattern: + name: Set presence pattern + description: Upsert a child's repeating presence schedule. + fields: + child_id: + name: Child ID + description: The child whose schedule is being set. + required: true + selector: + number: + mode: box + cycle_length_weeks: + name: Cycle length weeks + description: Number of weeks in the repeating cycle (1 to 4). + required: true + selector: + number: + mode: box + min: 1 + max: 4 + anchor_date: + name: Anchor date + description: Cycle anchor as a YYYY-MM-DD date. + required: true + selector: + date: + pattern: + name: Pattern + description: Mapping of week index to present weekday ints (Monday is 0). + required: true + selector: + object: + +create_presence_override: + name: Create presence override + description: Create a date-range presence override for a child. + fields: + child_id: + name: Child ID + description: The child the override applies to. + required: true + selector: + number: + mode: box + start_date: + name: Start date + description: Inclusive start date (YYYY-MM-DD). + required: true + selector: + date: + end_date: + name: End date + description: Inclusive end date (YYYY-MM-DD). + required: true + selector: + date: + is_present: + name: Present + description: Whether the child is present for this range. + required: true + selector: + boolean: + note: + name: Note + description: Optional note stored with the override. + required: false + selector: + text: + +delete_presence_override: + name: Delete presence override + description: Delete a presence override by id. + fields: + override_id: + name: Override ID + description: The override to delete. + required: true + selector: + number: + mode: box + +export_history_csv: + name: Export history CSV + description: Export completion history as CSV. Not implemented until Feature 13. + +manage_child: + name: Manage child + description: Create, edit, activate or reorder child profiles. + fields: + action: + name: Action + description: create, edit, set_active, or reorder. + required: true + selector: + select: + options: + - create + - edit + - set_active + - reorder + child_id: + name: Child ID + description: Required for edit and set_active. + required: false + selector: + number: + mode: box + display_name: + name: Display name + description: Required for create; optional for edit. + required: false + selector: + text: + colour: + name: Colour + description: Optional colour string. + required: false + selector: + text: + avatar_ref: + name: Avatar + description: Optional avatar reference. + required: false + selector: + text: + sort_order: + name: Sort order + description: Optional panel sort order. + required: false + selector: + number: + mode: box + is_active: + name: Active + description: Required for set_active. + required: false + selector: + boolean: + ordered_ids: + name: Ordered IDs + description: Complete permutation of child ids. Required for reorder. + required: false + selector: + object: diff --git a/custom_components/nestquest/sweep.py b/custom_components/nestquest/sweep.py new file mode 100644 index 0000000..e60ab72 --- /dev/null +++ b/custom_components/nestquest/sweep.py @@ -0,0 +1,132 @@ +"""The nightly missed-quest sweep (Feature 11). + +At the configured day-rollover time (the same HA-local listener that +re-materializes the rolling horizon) this job finds every instance +whose ``due_date`` is before the HA-local date, that has NO completion +event, and that falls after the persisted sweep watermark — and fires +ONE ``nestquest_quest_missed`` event per instance with the documented +payload. The sweep is read-only over the domain tables: it never +mutates an instance and never writes a completion event (``missed`` +is derived state, never an event_type — Feature 08's contract). + +Idempotency is the watermark: after a run, ``nestquest_meta_state`` +records the HA-local date the sweep ran, and the next run only +sweeps instances due in ``[watermark, today)``. A second run the +same night therefore fires nothing new, and a run after days of +downtime sweeps exactly the accumulated window once — no +per-instance journal, no re-announcing old misses. An instance swept +as missed that is later completed simply gains its completion event; +history keeps both rows (append-only), and the sweep's later runs +skip it via the no-completion-event rule. +""" +from __future__ import annotations + +import asyncio +import datetime +import logging +from zoneinfo import ZoneInfo + +from .const import EVENT_QUEST_MISSED +from .dao_children import ChildrenDao +from .dao_instances import QuestInstancesDao +from .dao_meta import MetaStateDao +from .dao_rules import QuestDefinitionsDao +from .db import NestQuestDatabase + +LOGGER = logging.getLogger(__name__) + +#: meta_state key holding the HA-local date the sweep last ran. +SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date" + +#: One asyncio.Lock per (connection wrapper, running loop), mirroring +#: the migration runner's pattern: the whole watermark read → query → +#: fire → write span is one critical section, so an overlapping startup +#: sweep and rollover sweep queue instead of double-announcing. The +#: loser re-reads the watermark INSIDE the lock, sees the winner's +#: date, and fires nothing. +_SWEEP_LOCKS: dict[tuple[int, int], asyncio.Lock] = {} + + +def _sweep_lock(database: NestQuestDatabase) -> asyncio.Lock: + """Return the sweep lock bound to ``database``'s running loop.""" + loop = asyncio.get_running_loop() + key = (id(database), id(loop)) + lock = _SWEEP_LOCKS.get(key) + if lock is None: + lock = asyncio.Lock() + _SWEEP_LOCKS[key] = lock + return lock + +#: UTC ISO-8601 timestamp policy shared with the DAO layers. +_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00" + + +def _now_stamp() -> str: + return datetime.datetime.now(datetime.timezone.utc).strftime( + _UTC_TIMESTAMP_FORMAT + ) + + +async def run_missed_sweep( + hass, + database: NestQuestDatabase, + *, + today: datetime.date | None = None, +) -> int: + """Fire one missed event per unswept past-due open instance. + + ``today`` optionally pins the HA-local date (threaded like the + materialization and completion layers); when omitted it is read + from ``hass.config.time_zone``. Returns the number of events + fired. Fires nothing and returns 0 when the watermark already + covers ``today`` (a same-night rerun). + + The whole watermark read → query → fire → write span runs inside + the per-database sweep lock: an overlapping startup sweep and a + rollover sweep queue instead of interleaving, and the loser + re-reads the watermark inside the lock — sees the winner's date — + and announces nothing twice. + """ + today_date = ( + today + if today is not None + else datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date() + ) + today_iso = today_date.isoformat() + dao = MetaStateDao(database) + async with _sweep_lock(database): + watermark = await dao.get(SWEEP_WATERMARK_KEY) + if watermark == today_iso: + LOGGER.debug( + "NestQuest missed sweep already ran for %s", today_iso + ) + return 0 + + instances_dao = QuestInstancesDao(database) + rows = await instances_dao.list_missed_unswept(today_iso, watermark) + children_dao = ChildrenDao(database) + definitions_dao = QuestDefinitionsDao(database) + now_stamp = _now_stamp() + fired = 0 + for instance in rows: + child = await children_dao.get(instance.child_id) + definition = await definitions_dao.get(instance.definition_id) + hass.bus.async_fire( + EVENT_QUEST_MISSED, + { + "child_id": instance.child_id, + "child_name": child.display_name if child else None, + "instance_id": instance.id, + "quest_title": definition.title if definition else None, + "window": instance.window, + "due_date": instance.due_date, + "due_time": instance.due_time, + "occurred_at": now_stamp, + }, + ) + fired += 1 + await dao.set(SWEEP_WATERMARK_KEY, today_iso) + LOGGER.info( + "NestQuest missed sweep for %s fired %d event(s)", today_iso, fired + ) + return fired \ No newline at end of file diff --git a/custom_components/nestquest/www/fonts/cinzel-decorative-latin-700-normal.woff2 b/custom_components/nestquest/www/fonts/cinzel-decorative-latin-700-normal.woff2 new file mode 100644 index 0000000..0e184e3 Binary files /dev/null and b/custom_components/nestquest/www/fonts/cinzel-decorative-latin-700-normal.woff2 differ diff --git a/custom_components/nestquest/www/fonts/cinzel-decorative-latin-900-normal.woff2 b/custom_components/nestquest/www/fonts/cinzel-decorative-latin-900-normal.woff2 new file mode 100644 index 0000000..204373f Binary files /dev/null and b/custom_components/nestquest/www/fonts/cinzel-decorative-latin-900-normal.woff2 differ diff --git a/custom_components/nestquest/www/fonts/cinzel-latin-600-normal.woff2 b/custom_components/nestquest/www/fonts/cinzel-latin-600-normal.woff2 new file mode 100644 index 0000000..a4b5e1b Binary files /dev/null and b/custom_components/nestquest/www/fonts/cinzel-latin-600-normal.woff2 differ diff --git a/custom_components/nestquest/www/fonts/cinzel-latin-700-normal.woff2 b/custom_components/nestquest/www/fonts/cinzel-latin-700-normal.woff2 new file mode 100644 index 0000000..a13fb08 Binary files /dev/null and b/custom_components/nestquest/www/fonts/cinzel-latin-700-normal.woff2 differ diff --git a/custom_components/nestquest/www/fonts/cinzel-latin-900-normal.woff2 b/custom_components/nestquest/www/fonts/cinzel-latin-900-normal.woff2 new file mode 100644 index 0000000..01b7714 Binary files /dev/null and b/custom_components/nestquest/www/fonts/cinzel-latin-900-normal.woff2 differ diff --git a/custom_components/nestquest/www/fonts/nunito-latin-400-normal.woff2 b/custom_components/nestquest/www/fonts/nunito-latin-400-normal.woff2 new file mode 100644 index 0000000..8c68e57 Binary files /dev/null and b/custom_components/nestquest/www/fonts/nunito-latin-400-normal.woff2 differ diff --git a/custom_components/nestquest/www/fonts/nunito-latin-600-normal.woff2 b/custom_components/nestquest/www/fonts/nunito-latin-600-normal.woff2 new file mode 100644 index 0000000..dac52ff Binary files /dev/null and b/custom_components/nestquest/www/fonts/nunito-latin-600-normal.woff2 differ diff --git a/custom_components/nestquest/www/fonts/nunito-latin-700-normal.woff2 b/custom_components/nestquest/www/fonts/nunito-latin-700-normal.woff2 new file mode 100644 index 0000000..5b423ba Binary files /dev/null and b/custom_components/nestquest/www/fonts/nunito-latin-700-normal.woff2 differ diff --git a/custom_components/nestquest/www/fonts/nunito-latin-800-normal.woff2 b/custom_components/nestquest/www/fonts/nunito-latin-800-normal.woff2 new file mode 100644 index 0000000..21502e0 Binary files /dev/null and b/custom_components/nestquest/www/fonts/nunito-latin-800-normal.woff2 differ diff --git a/custom_components/nestquest/www/nestquest-admin-tokens.css b/custom_components/nestquest/www/nestquest-admin-tokens.css new file mode 100644 index 0000000..64329b8 --- /dev/null +++ b/custom_components/nestquest/www/nestquest-admin-tokens.css @@ -0,0 +1,76 @@ +/* ============================================================ + NestQuest — Parent admin card tokens + Surface: phone, 390x844 reference. Plain CubeCraft styling, + no tabletop treatment, plain domain words in the UI. + Scope on the card host: nestquest-admin-card. + Derived from the approved mockups (admin screens 1d-1i). + Two densities were built; COMPACT is the default. Flip + --nq-a-density-* to the roomy values for the alternative. + ============================================================ */ + +:host { + /* ── Brand + neutrals (CubeCraft tokens, verbatim) ─────── */ + --nq-brand-blue: #3B3AB8; + --nq-brand-purple: #A035CC; + --nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple)); + --nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple)); + + --nq-a-page: #F4F4F6; + --nq-a-surface: #FFFFFF; + --nq-a-surface-subtle: #FAFAFA; + --nq-a-selected: #EBEBF8; /* row being edited */ + --nq-a-border: #E4E4EA; + --nq-a-divider: #EDEDF1; + + --nq-a-ink: #18181D; + --nq-a-ink-secondary: #52525E; + --nq-a-ink-tertiary: #70707E; /* inactive rows, unselected nav icons */ + + --nq-a-success: #15803D; + --nq-a-danger: #DC2626; + --nq-a-danger-strong: #B91C1C; + --nq-a-danger-bg: #FEE2E2; + --nq-a-warning-ink: #92400E; + --nq-a-warning-ink-2: #78350F; + --nq-a-warning-bg: #FEF3C7; + --nq-a-warning-border: rgba(217,119,6,.35); + --nq-a-info-bg: #DBEAFE; + --nq-a-info-ink: #1E3A8A; + --nq-a-info-icon: #1D4ED8; + --nq-a-reversal: #7D28A0; /* un-complete events in history */ + + /* ── Type ─────────────────────────────────────────────── */ + --nq-a-font: Nunito, system-ui, sans-serif; + --nq-a-size-screen: 24px; /* 800, screen title */ + --nq-a-size-hero: 32px; /* 800, roomy "8 of 12 done" */ + --nq-a-size-card-title: 19px; /* 800, roomy child card */ + --nq-a-size-stat: 22px; /* 800, compact stat number */ + --nq-a-size-row: 13.5px;/* 700, list row title */ + --nq-a-size-meta: 11px; /* 600, row meta */ + --nq-a-size-label: 10px; /* 700, uppercase field label */ + --nq-a-track-label: .10em; + + /* ── Density: COMPACT (default) ───────────────────────── */ + --nq-a-density-page-pad: 14px 16px 92px; /* bottom clears the 70px tab bar */ + --nq-a-density-gap: 10px; + --nq-a-density-card-pad: 13px; + --nq-a-density-row-pad: 11px 13px; + --nq-a-density-radius: 8px; + --nq-a-density-shadow: none; + /* Density: ROOMY — page-pad 18px 20px 92px; gap 16px; + card-pad 18px; radius 12px; shadow 0 4px 12px rgba(0,0,0,.06) */ + + /* ── Metrics ──────────────────────────────────────────── */ + --nq-a-tabbar-height: 70px; /* + 12px safe-area padding */ + --nq-a-tap-min: 44px; /* every row/control clears this */ + --nq-a-radius-pill: 9999px; + --nq-a-radius-sheet: 20px 20px 0 0; + --nq-a-sheet-shadow: 0 -8px 28px rgba(9,9,11,.18); + --nq-a-progress-height: 6px; /* 8px roomy, 10px hero */ + + /* ── Motion ───────────────────────────────────────────── */ + --nq-ease-out: cubic-bezier(0, 0, .2, 1); + --nq-dur-micro: 120ms; + --nq-dur-base: 200ms; + --nq-dur-modal: 350ms; +} diff --git a/custom_components/nestquest/www/nestquest-cards.js b/custom_components/nestquest/www/nestquest-cards.js new file mode 100644 index 0000000..ede7aab --- /dev/null +++ b/custom_components/nestquest/www/nestquest-cards.js @@ -0,0 +1,3481 @@ +const B = globalThis, nt = B.ShadowRoot && (B.ShadyCSS === void 0 || B.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, it = /* @__PURE__ */ Symbol(), lt = /* @__PURE__ */ new WeakMap(); +let zt = class { + constructor(t, e, n) { + if (this._$cssResult$ = !0, n !== it) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); + this.cssText = t, this.t = e; + } + get styleSheet() { + let t = this.o; + const e = this.t; + if (nt && t === void 0) { + const n = e !== void 0 && e.length === 1; + n && (t = lt.get(e)), t === void 0 && ((this.o = t = new CSSStyleSheet()).replaceSync(this.cssText), n && lt.set(e, t)); + } + return t; + } + toString() { + return this.cssText; + } +}; +const v = (s) => new zt(typeof s == "string" ? s : s + "", void 0, it), Pt = (s, ...t) => { + const e = s.length === 1 ? s[0] : t.reduce((n, i, r) => n + ((o) => { + if (o._$cssResult$ === !0) return o.cssText; + if (typeof o == "number") return o; + throw Error("Value passed to 'css' function must be a 'css' function result: " + o + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security."); + })(i) + s[r + 1], s[0]); + return new zt(e, s, it); +}, Bt = (s, t) => { + if (nt) s.adoptedStyleSheets = t.map((e) => e instanceof CSSStyleSheet ? e : e.styleSheet); + else for (const e of t) { + const n = document.createElement("style"), i = B.litNonce; + i !== void 0 && n.setAttribute("nonce", i), n.textContent = e.cssText, s.appendChild(n); + } +}, ct = nt ? (s) => s : (s) => s instanceof CSSStyleSheet ? ((t) => { + let e = ""; + for (const n of t.cssRules) e += n.cssText; + return v(e); +})(s) : s; +const { is: Ft, defineProperty: Qt, getOwnPropertyDescriptor: Wt, getOwnPropertyNames: Zt, getOwnPropertySymbols: Gt, getPrototypeOf: Vt } = Object, W = globalThis, dt = W.trustedTypes, Kt = dt ? dt.emptyScript : "", Jt = W.reactiveElementPolyfillSupport, I = (s, t) => s, et = { toAttribute(s, t) { + switch (t) { + case Boolean: + s = s ? Kt : null; + break; + case Object: + case Array: + s = s == null ? s : JSON.stringify(s); + } + return s; +}, fromAttribute(s, t) { + let e = s; + switch (t) { + case Boolean: + e = s !== null; + break; + case Number: + e = s === null ? null : Number(s); + break; + case Object: + case Array: + try { + e = JSON.parse(s); + } catch { + e = null; + } + } + return e; +} }, It = (s, t) => !Ft(s, t), pt = { attribute: !0, type: String, converter: et, reflect: !1, useDefault: !1, hasChanged: It }; +Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), W.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); +let E = class extends HTMLElement { + static addInitializer(t) { + this._$Ei(), (this.l ??= []).push(t); + } + static get observedAttributes() { + return this.finalize(), this._$Eh && [...this._$Eh.keys()]; + } + static createProperty(t, e = pt) { + if (e.state && (e.attribute = !1), this._$Ei(), this.prototype.hasOwnProperty(t) && ((e = Object.create(e)).wrapped = !0), this.elementProperties.set(t, e), !e.noAccessor) { + const n = /* @__PURE__ */ Symbol(), i = this.getPropertyDescriptor(t, n, e); + i !== void 0 && Qt(this.prototype, t, i); + } + } + static getPropertyDescriptor(t, e, n) { + const { get: i, set: r } = Wt(this.prototype, t) ?? { get() { + return this[e]; + }, set(o) { + this[e] = o; + } }; + return { get: i, set(o) { + const l = i?.call(this); + r?.call(this, o), this.requestUpdate(t, l, n); + }, configurable: !0, enumerable: !0 }; + } + static getPropertyOptions(t) { + return this.elementProperties.get(t) ?? pt; + } + static _$Ei() { + if (this.hasOwnProperty(I("elementProperties"))) return; + const t = Vt(this); + t.finalize(), t.l !== void 0 && (this.l = [...t.l]), this.elementProperties = new Map(t.elementProperties); + } + static finalize() { + if (this.hasOwnProperty(I("finalized"))) return; + if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(I("properties"))) { + const e = this.properties, n = [...Zt(e), ...Gt(e)]; + for (const i of n) this.createProperty(i, e[i]); + } + const t = this[Symbol.metadata]; + if (t !== null) { + const e = litPropertyMetadata.get(t); + if (e !== void 0) for (const [n, i] of e) this.elementProperties.set(n, i); + } + this._$Eh = /* @__PURE__ */ new Map(); + for (const [e, n] of this.elementProperties) { + const i = this._$Eu(e, n); + i !== void 0 && this._$Eh.set(i, e); + } + this.elementStyles = this.finalizeStyles(this.styles); + } + static finalizeStyles(t) { + const e = []; + if (Array.isArray(t)) { + const n = new Set(t.flat(1 / 0).reverse()); + for (const i of n) e.unshift(ct(i)); + } else t !== void 0 && e.push(ct(t)); + return e; + } + static _$Eu(t, e) { + const n = e.attribute; + return n === !1 ? void 0 : typeof n == "string" ? n : typeof t == "string" ? t.toLowerCase() : void 0; + } + constructor() { + super(), this._$Ep = void 0, this.isUpdatePending = !1, this.hasUpdated = !1, this._$Em = null, this._$Ev(); + } + _$Ev() { + this._$ES = new Promise((t) => this.enableUpdating = t), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t) => t(this)); + } + addController(t) { + (this._$EO ??= /* @__PURE__ */ new Set()).add(t), this.renderRoot !== void 0 && this.isConnected && t.hostConnected?.(); + } + removeController(t) { + this._$EO?.delete(t); + } + _$E_() { + const t = /* @__PURE__ */ new Map(), e = this.constructor.elementProperties; + for (const n of e.keys()) this.hasOwnProperty(n) && (t.set(n, this[n]), delete this[n]); + t.size > 0 && (this._$Ep = t); + } + createRenderRoot() { + const t = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions); + return Bt(t, this.constructor.elementStyles), t; + } + connectedCallback() { + this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(!0), this._$EO?.forEach((t) => t.hostConnected?.()); + } + enableUpdating(t) { + } + disconnectedCallback() { + this._$EO?.forEach((t) => t.hostDisconnected?.()); + } + attributeChangedCallback(t, e, n) { + this._$AK(t, n); + } + _$ET(t, e) { + const n = this.constructor.elementProperties.get(t), i = this.constructor._$Eu(t, n); + if (i !== void 0 && n.reflect === !0) { + const r = (n.converter?.toAttribute !== void 0 ? n.converter : et).toAttribute(e, n.type); + this._$Em = t, r == null ? this.removeAttribute(i) : this.setAttribute(i, r), this._$Em = null; + } + } + _$AK(t, e) { + const n = this.constructor, i = n._$Eh.get(t); + if (i !== void 0 && this._$Em !== i) { + const r = n.getPropertyOptions(i), o = typeof r.converter == "function" ? { fromAttribute: r.converter } : r.converter?.fromAttribute !== void 0 ? r.converter : et; + this._$Em = i; + const l = o.fromAttribute(e, r.type); + this[i] = l ?? this._$Ej?.get(i) ?? l, this._$Em = null; + } + } + requestUpdate(t, e, n, i = !1, r) { + if (t !== void 0) { + const o = this.constructor; + if (i === !1 && (r = this[t]), n ??= o.getPropertyOptions(t), !((n.hasChanged ?? It)(r, e) || n.useDefault && n.reflect && r === this._$Ej?.get(t) && !this.hasAttribute(o._$Eu(t, n)))) return; + this.C(t, e, n); + } + this.isUpdatePending === !1 && (this._$ES = this._$EP()); + } + C(t, e, { useDefault: n, reflect: i, wrapped: r }, o) { + n && !(this._$Ej ??= /* @__PURE__ */ new Map()).has(t) && (this._$Ej.set(t, o ?? e ?? this[t]), r !== !0 || o !== void 0) || (this._$AL.has(t) || (this.hasUpdated || n || (e = void 0), this._$AL.set(t, e)), i === !0 && this._$Em !== t && (this._$Eq ??= /* @__PURE__ */ new Set()).add(t)); + } + async _$EP() { + this.isUpdatePending = !0; + try { + await this._$ES; + } catch (e) { + Promise.reject(e); + } + const t = this.scheduleUpdate(); + return t != null && await t, !this.isUpdatePending; + } + scheduleUpdate() { + return this.performUpdate(); + } + performUpdate() { + if (!this.isUpdatePending) return; + if (!this.hasUpdated) { + if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) { + for (const [i, r] of this._$Ep) this[i] = r; + this._$Ep = void 0; + } + const n = this.constructor.elementProperties; + if (n.size > 0) for (const [i, r] of n) { + const { wrapped: o } = r, l = this[i]; + o !== !0 || this._$AL.has(i) || l === void 0 || this.C(i, void 0, r, l); + } + } + let t = !1; + const e = this._$AL; + try { + t = this.shouldUpdate(e), t ? (this.willUpdate(e), this._$EO?.forEach((n) => n.hostUpdate?.()), this.update(e)) : this._$EM(); + } catch (n) { + throw t = !1, this._$EM(), n; + } + t && this._$AE(e); + } + willUpdate(t) { + } + _$AE(t) { + this._$EO?.forEach((e) => e.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = !0, this.firstUpdated(t)), this.updated(t); + } + _$EM() { + this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = !1; + } + get updateComplete() { + return this.getUpdateComplete(); + } + getUpdateComplete() { + return this._$ES; + } + shouldUpdate(t) { + return !0; + } + update(t) { + this._$Eq &&= this._$Eq.forEach((e) => this._$ET(e, this[e])), this._$EM(); + } + updated(t) { + } + firstUpdated(t) { + } +}; +E.elementStyles = [], E.shadowRootOptions = { mode: "open" }, E[I("elementProperties")] = /* @__PURE__ */ new Map(), E[I("finalized")] = /* @__PURE__ */ new Map(), Jt?.({ ReactiveElement: E }), (W.reactiveElementVersions ??= []).push("2.1.2"); +const st = globalThis, ht = (s) => s, F = st.trustedTypes, ut = F ? F.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, Lt = "$lit$", $ = `lit$${Math.random().toFixed(9).slice(2)}$`, Ut = "?" + $, Xt = `<${Ut}>`, S = document, L = () => S.createComment(""), U = (s) => s === null || typeof s != "object" && typeof s != "function", rt = Array.isArray, Yt = (s) => rt(s) || typeof s?.[Symbol.iterator] == "function", V = `[ +\f\r]`, z = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, ft = /-->/g, mt = />/g, q = RegExp(`>|${V}(?:([^\\s"'>=/]+)(${V}*=${V}*(?:[^ +\f\r"'\`<>=]|("|')|))|$)`, "g"), gt = /'/g, _t = /"/g, Rt = /^(?:script|style|textarea|title)$/i, te = (s) => (t, ...e) => ({ _$litType$: s, strings: t, values: e }), d = te(1), T = /* @__PURE__ */ Symbol.for("lit-noChange"), g = /* @__PURE__ */ Symbol.for("lit-nothing"), yt = /* @__PURE__ */ new WeakMap(), C = S.createTreeWalker(S, 129); +function Dt(s, t) { + if (!rt(s) || !s.hasOwnProperty("raw")) throw Error("invalid template strings array"); + return ut !== void 0 ? ut.createHTML(t) : t; +} +const ee = (s, t) => { + const e = s.length - 1, n = []; + let i, r = t === 2 ? "" : t === 3 ? "" : "", o = z; + for (let l = 0; l < e; l++) { + const a = s[l]; + let p, u, c = -1, f = 0; + for (; f < a.length && (o.lastIndex = f, u = o.exec(a), u !== null); ) f = o.lastIndex, o === z ? u[1] === "!--" ? o = ft : u[1] !== void 0 ? o = mt : u[2] !== void 0 ? (Rt.test(u[2]) && (i = RegExp("" ? (o = i ?? z, c = -1) : u[1] === void 0 ? c = -2 : (c = o.lastIndex - u[2].length, p = u[1], o = u[3] === void 0 ? q : u[3] === '"' ? _t : gt) : o === _t || o === gt ? o = q : o === ft || o === mt ? o = z : (o = q, i = void 0); + const h = o === q && s[l + 1].startsWith("/>") ? " " : ""; + r += o === z ? a + Xt : c >= 0 ? (n.push(p), a.slice(0, c) + Lt + a.slice(c) + $ + h) : a + $ + (c === -2 ? l : h); + } + return [Dt(s, r + (s[e] || "") + (t === 2 ? "" : t === 3 ? "" : "")), n]; +}; +class R { + constructor({ strings: t, _$litType$: e }, n) { + let i; + this.parts = []; + let r = 0, o = 0; + const l = t.length - 1, a = this.parts, [p, u] = ee(t, e); + if (this.el = R.createElement(p, n), C.currentNode = this.el.content, e === 2 || e === 3) { + const c = this.el.content.firstChild; + c.replaceWith(...c.childNodes); + } + for (; (i = C.nextNode()) !== null && a.length < l; ) { + if (i.nodeType === 1) { + if (i.hasAttributes()) for (const c of i.getAttributeNames()) if (c.endsWith(Lt)) { + const f = u[o++], h = i.getAttribute(c).split($), m = /([.?@])?(.*)/.exec(f); + a.push({ type: 1, index: r, name: m[2], strings: h, ctor: m[1] === "." ? ie : m[1] === "?" ? se : m[1] === "@" ? re : Z }), i.removeAttribute(c); + } else c.startsWith($) && (a.push({ type: 6, index: r }), i.removeAttribute(c)); + if (Rt.test(i.tagName)) { + const c = i.textContent.split($), f = c.length - 1; + if (f > 0) { + i.textContent = F ? F.emptyScript : ""; + for (let h = 0; h < f; h++) i.append(c[h], L()), C.nextNode(), a.push({ type: 2, index: ++r }); + i.append(c[f], L()); + } + } + } else if (i.nodeType === 8) if (i.data === Ut) a.push({ type: 2, index: r }); + else { + let c = -1; + for (; (c = i.data.indexOf($, c + 1)) !== -1; ) a.push({ type: 7, index: r }), c += $.length - 1; + } + r++; + } + } + static createElement(t, e) { + const n = S.createElement("template"); + return n.innerHTML = t, n; + } +} +function N(s, t, e = s, n) { + if (t === T) return t; + let i = n !== void 0 ? e._$Co?.[n] : e._$Cl; + const r = U(t) ? void 0 : t._$litDirective$; + return i?.constructor !== r && (i?._$AO?.(!1), r === void 0 ? i = void 0 : (i = new r(s), i._$AT(s, e, n)), n !== void 0 ? (e._$Co ??= [])[n] = i : e._$Cl = i), i !== void 0 && (t = N(s, i._$AS(s, t.values), i, n)), t; +} +class ne { + constructor(t, e) { + this._$AV = [], this._$AN = void 0, this._$AD = t, this._$AM = e; + } + get parentNode() { + return this._$AM.parentNode; + } + get _$AU() { + return this._$AM._$AU; + } + u(t) { + const { el: { content: e }, parts: n } = this._$AD, i = (t?.creationScope ?? S).importNode(e, !0); + C.currentNode = i; + let r = C.nextNode(), o = 0, l = 0, a = n[0]; + for (; a !== void 0; ) { + if (o === a.index) { + let p; + a.type === 2 ? p = new O(r, r.nextSibling, this, t) : a.type === 1 ? p = new a.ctor(r, a.name, a.strings, this, t) : a.type === 6 && (p = new oe(r, this, t)), this._$AV.push(p), a = n[++l]; + } + o !== a?.index && (r = C.nextNode(), o++); + } + return C.currentNode = S, i; + } + p(t) { + let e = 0; + for (const n of this._$AV) n !== void 0 && (n.strings !== void 0 ? (n._$AI(t, n, e), e += n.strings.length - 2) : n._$AI(t[e])), e++; + } +} +class O { + get _$AU() { + return this._$AM?._$AU ?? this._$Cv; + } + constructor(t, e, n, i) { + this.type = 2, this._$AH = g, this._$AN = void 0, this._$AA = t, this._$AB = e, this._$AM = n, this.options = i, this._$Cv = i?.isConnected ?? !0; + } + get parentNode() { + let t = this._$AA.parentNode; + const e = this._$AM; + return e !== void 0 && t?.nodeType === 11 && (t = e.parentNode), t; + } + get startNode() { + return this._$AA; + } + get endNode() { + return this._$AB; + } + _$AI(t, e = this) { + t = N(this, t, e), U(t) ? t === g || t == null || t === "" ? (this._$AH !== g && this._$AR(), this._$AH = g) : t !== this._$AH && t !== T && this._(t) : t._$litType$ !== void 0 ? this.$(t) : t.nodeType !== void 0 ? this.T(t) : Yt(t) ? this.k(t) : this._(t); + } + O(t) { + return this._$AA.parentNode.insertBefore(t, this._$AB); + } + T(t) { + this._$AH !== t && (this._$AR(), this._$AH = this.O(t)); + } + _(t) { + this._$AH !== g && U(this._$AH) ? this._$AA.nextSibling.data = t : this.T(S.createTextNode(t)), this._$AH = t; + } + $(t) { + const { values: e, _$litType$: n } = t, i = typeof n == "number" ? this._$AC(t) : (n.el === void 0 && (n.el = R.createElement(Dt(n.h, n.h[0]), this.options)), n); + if (this._$AH?._$AD === i) this._$AH.p(e); + else { + const r = new ne(i, this), o = r.u(this.options); + r.p(e), this.T(o), this._$AH = r; + } + } + _$AC(t) { + let e = yt.get(t.strings); + return e === void 0 && yt.set(t.strings, e = new R(t)), e; + } + k(t) { + rt(this._$AH) || (this._$AH = [], this._$AR()); + const e = this._$AH; + let n, i = 0; + for (const r of t) i === e.length ? e.push(n = new O(this.O(L()), this.O(L()), this, this.options)) : n = e[i], n._$AI(r), i++; + i < e.length && (this._$AR(n && n._$AB.nextSibling, i), e.length = i); + } + _$AR(t = this._$AA.nextSibling, e) { + for (this._$AP?.(!1, !0, e); t !== this._$AB; ) { + const n = ht(t).nextSibling; + ht(t).remove(), t = n; + } + } + setConnected(t) { + this._$AM === void 0 && (this._$Cv = t, this._$AP?.(t)); + } +} +class Z { + get tagName() { + return this.element.tagName; + } + get _$AU() { + return this._$AM._$AU; + } + constructor(t, e, n, i, r) { + this.type = 1, this._$AH = g, this._$AN = void 0, this.element = t, this.name = e, this._$AM = i, this.options = r, n.length > 2 || n[0] !== "" || n[1] !== "" ? (this._$AH = Array(n.length - 1).fill(new String()), this.strings = n) : this._$AH = g; + } + _$AI(t, e = this, n, i) { + const r = this.strings; + let o = !1; + if (r === void 0) t = N(this, t, e, 0), o = !U(t) || t !== this._$AH && t !== T, o && (this._$AH = t); + else { + const l = t; + let a, p; + for (t = r[0], a = 0; a < r.length - 1; a++) p = N(this, l[n + a], e, a), p === T && (p = this._$AH[a]), o ||= !U(p) || p !== this._$AH[a], p === g ? t = g : t !== g && (t += (p ?? "") + r[a + 1]), this._$AH[a] = p; + } + o && !i && this.j(t); + } + j(t) { + t === g ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t ?? ""); + } +} +class ie extends Z { + constructor() { + super(...arguments), this.type = 3; + } + j(t) { + this.element[this.name] = t === g ? void 0 : t; + } +} +class se extends Z { + constructor() { + super(...arguments), this.type = 4; + } + j(t) { + this.element.toggleAttribute(this.name, !!t && t !== g); + } +} +class re extends Z { + constructor(t, e, n, i, r) { + super(t, e, n, i, r), this.type = 5; + } + _$AI(t, e = this) { + if ((t = N(this, t, e, 0) ?? g) === T) return; + const n = this._$AH, i = t === g && n !== g || t.capture !== n.capture || t.once !== n.once || t.passive !== n.passive, r = t !== g && (n === g || i); + i && this.element.removeEventListener(this.name, this, n), r && this.element.addEventListener(this.name, this, t), this._$AH = t; + } + handleEvent(t) { + typeof this._$AH == "function" ? this._$AH.call(this.options?.host ?? this.element, t) : this._$AH.handleEvent(t); + } +} +class oe { + constructor(t, e, n) { + this.element = t, this.type = 6, this._$AN = void 0, this._$AM = e, this.options = n; + } + get _$AU() { + return this._$AM._$AU; + } + _$AI(t) { + N(this, t); + } +} +const ae = { I: O }, le = st.litHtmlPolyfillSupport; +le?.(R, O), (st.litHtmlVersions ??= []).push("3.3.3"); +const ce = (s, t, e) => { + const n = e?.renderBefore ?? t; + let i = n._$litPart$; + if (i === void 0) { + const r = e?.renderBefore ?? null; + n._$litPart$ = i = new O(t.insertBefore(L(), r), r, void 0, e ?? {}); + } + return i._$AI(s), i; +}; +const ot = globalThis; +let A = class extends E { + constructor() { + super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0; + } + createRenderRoot() { + const t = super.createRenderRoot(); + return this.renderOptions.renderBefore ??= t.firstChild, t; + } + update(t) { + const e = this.render(); + this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t), this._$Do = ce(e, this.renderRoot, this.renderOptions); + } + connectedCallback() { + super.connectedCallback(), this._$Do?.setConnected(!0); + } + disconnectedCallback() { + super.disconnectedCallback(), this._$Do?.setConnected(!1); + } + render() { + return T; + } +}; +A._$litElement$ = !0, A.finalized = !0, ot.litElementHydrateSupport?.({ LitElement: A }); +const de = ot.litElementPolyfillSupport; +de?.({ LitElement: A }); +(ot.litElementVersions ??= []).push("4.2.2"); +const jt = ':host{--nq-p-parchment-top: #f0e4c7;--nq-p-parchment-bottom: #ddcca3;--nq-p-parchment: radial-gradient(ellipse at 25% 10%, rgba(255,255,255,.5), transparent 55%), radial-gradient(ellipse at 85% 90%, rgba(120,86,44,.28), transparent 60%), repeating-linear-gradient(93deg, rgba(150,115,70,.05) 0 2px, transparent 2px 6px), repeating-linear-gradient(2deg, rgba(150,115,70,.04) 0 3px, transparent 3px 7px), linear-gradient(var(--nq-p-parchment-top), var(--nq-p-parchment-bottom));--nq-p-vignette: inset 0 0 200px rgba(80,52,20,.35);--nq-p-card-open: linear-gradient(#fdf7e6, #f2e7c9);--nq-p-card-done: linear-gradient(#f0e8d3, #e6dcc2);--nq-p-card-panel: linear-gradient(#fcf6e6, #f1e6ca);--nq-p-card-border: rgba(120,88,48,.38);--nq-p-panel-border: rgba(92,62,26,.4);--nq-p-card-shadow: 0 3px 0 rgba(120,88,48,.2), inset 0 1px 0 rgba(255,255,255,.7);--nq-p-panel-shadow: 0 6px 0 rgba(92,62,26,.18), inset 0 2px 0 rgba(255,255,255,.7);--nq-p-frame-outer: 2px solid rgba(92,62,26,.45);--nq-p-frame-inner: 1px solid rgba(92,62,26,.28);--nq-p-rule: 2px solid rgba(92,62,26,.35);--nq-p-ink: #2b1f14;--nq-p-ink-secondary: #5c452a;--nq-p-ink-muted: #6f6455;--nq-p-ink-away: #4d4433;--nq-p-ink-late: #8f1526;--nq-brand-blue: #3B3AB8;--nq-brand-purple: #A035CC;--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-p-icon-tile: var(--nq-brand-gradient);--nq-p-icon-tile-done: rgba(92,62,26,.16);--nq-p-crest: var(--nq-brand-gradient);--nq-p-crest-away: linear-gradient(135deg, #6b6b7a, #7a7286);--nq-p-seal: radial-gradient(circle at 35% 30%, #a8283a, #6d1322);--nq-p-seal-shadow: 0 4px 10px rgba(60,10,20,.4), inset 0 0 0 4px rgba(255,255,255,.14);--nq-p-seal-size: 78px;--nq-p-dock-bg: rgba(43,31,20,.9);--nq-p-dock-ink: #f7efdb;--nq-p-dock-ink-secondary: #d8c9a6;--nq-p-dock-divider: rgba(233,220,189,.3);--nq-p-dock-height: 84px;--nq-p-font-display: "Cinzel Decorative", Cinzel, serif;--nq-p-font-heading: Cinzel, serif;--nq-p-font-body: Nunito, system-ui, sans-serif;--nq-p-size-hero: 86px;--nq-p-size-wordmark: 66px;--nq-p-size-title: 56px;--nq-p-size-name: 48px;--nq-p-size-section: 32px;--nq-p-size-quest: 30px;--nq-p-size-body: 22px;--nq-p-size-label: 21px;--nq-p-track-label: .14em;--nq-p-track-kicker: .3em;--nq-p-quest-min-height: 116px;--nq-p-quest-gap: 24px;--nq-p-quest-pad: 22px;--nq-p-tile-size: 64px;--nq-p-button-height: 72px;--nq-p-button-min-width: 150px;--nq-p-confirm-button: 108px;--nq-p-radius-card: 12px;--nq-p-radius-panel: 20px;--nq-p-radius-pill: 9999px;--nq-p-page-inset: 62px;--nq-p-frame-inset: 26px;--nq-ease-out: cubic-bezier(0, 0, .2, 1);--nq-dur-micro: .12s;--nq-dur-base: .2s;--nq-dur-modal: .35s}'; +function at(s) { + window.customCards = window.customCards ?? [], window.customCards.push(s); +} +const pe = [ + "nestquest_quest_completed", + "nestquest_quest_uncompleted", + "nestquest_quest_missed", + "nestquest_child_day_complete" +], bt = /* @__PURE__ */ new Map(); +function Q(s, t) { + const e = `${s ?? "local"}|${JSON.stringify(t)}`, n = bt.get(e); + if (n) + return n; + let i; + try { + i = new Intl.DateTimeFormat("en-US", { + ...t, + ...s ? { timeZone: s } : {} + }); + } catch { + i = new Intl.DateTimeFormat("en-US", t); + } + return bt.set(e, i), i; +} +function he(s) { + const t = s?.config; + if (!t || typeof t != "object") + return; + const e = t.time_zone; + return typeof e == "string" && e.trim() ? e.trim() : void 0; +} +function K(s, t) { + return `${Q(t, { weekday: "long" }).format(s)}, ${Q(t, { month: "short", day: "numeric" }).format(s)}`; +} +function ue(s, t) { + return Q(t, { + hour: "numeric", + minute: "2-digit", + hour12: !0 + }).format(s); +} +const fe = { + "clear-night": "Clear", + cloudy: "Cloudy", + exceptional: "Clear", + fog: "Foggy", + hail: "Hail", + lightning: "Storms", + "lightning-rimmed": "Storms", + partlycloudy: "Partly cloudy", + pouring: "Heavy rain", + rainy: "Rain", + snowy: "Snow", + "snowy-rainy": "Sleet", + sunny: "Sunny", + windy: "Windy", + "windy-variant": "Windy" +}, me = { + "clear-night": "Clear night skies", + cloudy: "Grey skies today", + exceptional: "A striking day", + fog: "Mist on the road", + hail: "Ice from the sky", + lightning: "Storms may roll in", + "lightning-rimmed": "Storms may roll in", + partlycloudy: "Sun between clouds", + pouring: "Heavy rain outside", + rainy: "Rain on the walls", + snowy: "Snow on the peaks", + "snowy-rainy": "Sleet may fall", + sunny: "Clear skies today", + windy: "A blustery day", + "windy-variant": "A blustery day" +}; +function vt(s) { + return typeof s == "string" ? s.trim() : ""; +} +function J(s, t) { + if (s == null || s === "") + return t; + const e = typeof s == "number" ? s : Number(s); + return Number.isFinite(e) ? e : t; +} +function xt(s) { + return Number.isFinite(s) ? Math.min(100, Math.max(0, s)) : 0; +} +function wt(s) { + return Math.max(0, Math.round(s)); +} +function ge(s) { + return s.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); +} +function _e(s, t) { + const e = Q(t, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23" + }).formatToParts(new Date(s)), n = (r) => { + const o = e.find((l) => l.type === r); + return o ? Number(o.value) : Number.NaN; + }, i = Date.UTC( + n("year"), + n("month") - 1, + n("day"), + n("hour"), + n("minute"), + n("second") + ); + return Number.isFinite(i) ? i - s : 0; +} +function ye(s, t) { + if (typeof s != "string") + return null; + const e = s.split("-"); + if (e.length !== 3) + return null; + const [n, i, r] = e.map((a) => Number(a)); + if (!n || !i || !r) + return null; + if (!t) { + const a = new Date(n, i - 1, r); + return Number.isNaN(a.getTime()) ? null : a; + } + const o = Date.UTC(n, i - 1, r), l = new Date(o - _e(o, t)); + return Number.isNaN(l.getTime()) ? null : l; +} +function be(s) { + const t = fe[s]; + return t || s.charAt(0).toUpperCase() + s.slice(1); +} +function ve(s, t) { + return me[s] ?? t; +} +const $t = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", xe = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", we = Pt` + * { + box-sizing: border-box; + } + + :host { + display: block; + height: 100%; + --nq-brand-gradient-h: linear-gradient( + 90deg, + var(--nq-brand-blue), + var(--nq-brand-purple) + ); + } + + .board { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + height: 1080px; + padding: 0; + overflow: hidden; + background: var(--nq-p-parchment); + box-shadow: var(--nq-p-vignette); + font-family: var(--nq-p-font-body); + } + + .frame { + position: absolute; + pointer-events: none; + } + + .frame-outer { + inset: 26px; + border: var(--nq-p-frame-outer); + border-radius: 14px; + } + + .frame-inner { + inset: 36px; + border: var(--nq-p-frame-inner); + border-radius: 8px; + } + + .wordmark-row { + position: absolute; + top: 64px; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 26px; + width: 100%; + } + + .rule { + flex: 1 1 0; + max-width: 340px; + height: 2px; + border-radius: 1px; + background: rgba(92, 62, 26, 0.35); + } + + .d20 { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + clip-path: ${v(xe)}; + background: var(--nq-brand-gradient); + } + + .d20-numeral { + font-family: var(--nq-p-font-heading); + font-size: 15px; + font-weight: 700; + line-height: 1; + color: #ffffff; + } + + .wordmark { + font-family: var(--nq-p-font-display); + font-size: var(--nq-p-size-wordmark); + font-weight: 900; + line-height: 1.15; + background: var(--nq-brand-gradient); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; + } + + .kicker { + position: absolute; + top: 154px; + left: 0; + right: 0; + font-family: var(--nq-p-font-heading); + font-size: 22px; + font-weight: 600; + letter-spacing: var(--nq-p-track-kicker); + text-transform: uppercase; + line-height: 1.2; + text-align: center; + color: var(--nq-p-ink-secondary); + } + + .plates { + position: absolute; + top: 262px; + left: 110px; + right: 110px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 56px; + } + + .plate { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 26px; + height: 600px; + padding: 44px 28px 40px; + border: 2px solid var(--nq-p-panel-border); + border-radius: var(--nq-p-radius-panel); + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + font: inherit; + } + + button.plate { + width: 100%; + color: inherit; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform var(--nq-dur-micro) var(--nq-ease-out); + } + + button.plate:active { + transform: scale(0.98); + } + + button.plate:focus-visible { + outline: 3px solid var(--nq-brand-purple); + outline-offset: 4px; + } + + .plate.away { + border: 2px dashed var(--nq-p-panel-border); + background: linear-gradient(#f2ecdd, #e6dcc6); + box-shadow: none; + } + + .crest { + position: relative; + flex: none; + width: 188px; + height: 214px; + padding: 4px; + clip-path: ${v($t)}; + background: rgba(255, 255, 255, 0.22); + } + + .crest-face { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding-bottom: 70px; + clip-path: ${v($t)}; + background: var(--nq-p-crest); + } + + .crest.away .crest-face { + background: var(--nq-p-crest-away); + } + + .crest .initial { + font-family: var(--nq-p-font-display); + font-size: 82px; + font-weight: 700; + line-height: 1; + color: #ffffff; + } + + .name { + font-family: var(--nq-p-font-heading); + font-size: var(--nq-p-size-name); + font-weight: 700; + letter-spacing: 0.02em; + line-height: 1.1; + color: var(--nq-p-ink); + } + + .plate.away .name { + color: var(--nq-p-ink-away); + } + + .pill { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 8px 20px; + border-radius: var(--nq-p-radius-pill); + background: rgba(22, 120, 60, 0.35); + font-family: var(--nq-p-font-heading); + font-size: 22px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + line-height: 1; + color: #146b36; + } + + .pill svg { + width: 22px; + height: 22px; + color: #157a3c; + } + + .pill.away { + background: rgba(92, 62, 26, 0.16); + color: var(--nq-p-ink-away); + } + + .pill.away svg { + color: var(--nq-p-ink-away); + } + + .progress-line { + font-family: var(--nq-p-font-heading); + font-size: 24px; + font-weight: 700; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .bar { + width: 100%; + height: 16px; + border: 1px solid rgba(92, 62, 26, 0.35); + border-radius: var(--nq-p-radius-pill); + background: rgba(92, 62, 26, 0.18); + overflow: hidden; + } + + .bar .fill { + display: block; + height: 100%; + border-radius: inherit; + background: var(--nq-brand-gradient-h); + } + + .hint { + position: absolute; + top: 930px; + left: 0; + right: 0; + font-family: var(--nq-p-font-body); + font-size: 24px; + font-weight: 600; + line-height: 1.4; + text-align: center; + color: var(--nq-p-ink-secondary); + } + + .plate.unknown { + border: 2px dashed var(--nq-p-panel-border); + background: linear-gradient(#f2ecdd, #e6dcc6); + box-shadow: none; + } + + .notice-wrap { + position: absolute; + top: 262px; + left: 110px; + right: 110px; + bottom: 190px; + display: flex; + align-items: center; + justify-content: center; + } + + .notice { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 20px; + width: 100%; + max-width: 980px; + padding: 56px 64px; + border: 2px solid var(--nq-p-panel-border); + border-radius: var(--nq-p-radius-panel); + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + text-align: center; + } + + .notice svg { + width: 46px; + height: 46px; + color: var(--nq-p-ink-secondary); + } + + .notice-headline { + font-family: var(--nq-p-font-heading); + font-size: 40px; + font-weight: 700; + line-height: 1.15; + color: var(--nq-p-ink); + } + + .notice-body { + margin: 0; + font-family: var(--nq-p-font-body); + font-size: 24px; + font-weight: 600; + line-height: 1.4; + color: var(--nq-p-ink-secondary); + } + + .spacer { + flex: 1 1 0; + } + + .dock { + position: absolute; + left: 46px; + right: 46px; + bottom: 46px; + height: var(--nq-p-dock-height); + display: flex; + align-items: center; + justify-content: center; + gap: 24px; + padding: 0 40px; + border-radius: 14px; + background: var(--nq-p-dock-bg); + pointer-events: none; + user-select: none; + color: var(--nq-p-dock-ink); + } + + .dock svg { + flex: none; + width: 40px; + height: 40px; + color: var(--nq-p-dock-ink); + } + + .dock .divider { + flex: none; + width: 1px; + height: 40px; + background: var(--nq-p-dock-divider); + } + + .dock .temp { + font-family: var(--nq-p-font-body); + font-size: 38px; + font-weight: 800; + line-height: 1; + } + + .dock .condition { + font-family: var(--nq-p-font-body); + font-size: 26px; + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-dock-ink-secondary); + } + + .dock .date { + font-family: var(--nq-p-font-body); + font-size: 26px; + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-dock-ink-secondary); + } + + .dock .clock { + font-family: var(--nq-p-font-body); + font-size: 38px; + font-weight: 800; + line-height: 1; + } +`, $e = d``, qe = d``, ke = d``, Ce = d``, Ae = d``, qt = { + icon: Ce, + headline: "NestQuest is not set up yet", + body: "A parent needs to finish setting up NestQuest before the party can gather." +}, Se = { + icon: Ae, + headline: "The records cannot be reached", + body: "The party's records are quiet right now. NestQuest will return shortly." +}, Te = d``; +class Ee extends A { + static properties = { + hass: { attribute: !1 }, + _config: { state: !0 }, + _now: { state: !0 } + }; + static styles = [v(jt), we]; + hass; + _config; + _now = /* @__PURE__ */ new Date(); + _clockTimer; + _unsubs = []; + _subscribed = !1; + _subGeneration = 0; + _retryTimer; + setConfig(t) { + if (!t || typeof t != "object") + throw new Error("Invalid configuration"); + this._config = t; + } + _hassConnection() { + const t = this.hass?.connection; + return !t || typeof t != "object" || typeof t.subscribeEvents != "function" ? null : t; + } + _subscribeLive() { + if (!this.isConnected || this._subscribed) + return; + const t = this._hassConnection(); + if (!t) + return; + this._subscribed = !0; + const e = ++this._subGeneration, n = (i) => { + i.then((r) => { + this._subscribed && e === this._subGeneration ? this._unsubs.push(r) : r(); + }).catch(() => { + e === this._subGeneration && (this._unsubscribeLive(), this._armSubscribeRetry()); + }); + }; + for (const i of pe) + n( + t.subscribeEvents(() => this.requestUpdate(), i) + ); + } + _armSubscribeRetry() { + this._retryTimer === void 0 && (this._retryTimer = window.setTimeout(() => { + this._retryTimer = void 0, this._subscribeLive(); + }, 1e3)); + } + _clearSubscribeRetry() { + this._retryTimer !== void 0 && (window.clearTimeout(this._retryTimer), this._retryTimer = void 0); + } + _unsubscribeLive() { + this._clearSubscribeRetry(), this._subGeneration++; + for (const t of this._unsubs) + t(); + this._unsubs = [], this._subscribed = !1; + } + getCardSize() { + return 22; + } + connectedCallback() { + super.connectedCallback(), this._subscribeLive(), this._now = /* @__PURE__ */ new Date(), this._clockTimer = window.setInterval(() => { + this._now = /* @__PURE__ */ new Date(); + }, 1e3); + } + updated() { + this._subscribeLive(); + } + disconnectedCallback() { + this._unsubscribeLive(), this._clockTimer !== void 0 && (window.clearInterval(this._clockTimer), this._clockTimer = void 0), super.disconnectedCallback(); + } + render() { + const t = this._boardNotice(); + return d` +
+
+
+
+ + + NestQuest + + +
+

The Party · ${K(this._now, this._timeZone())}

+ ${t ? this._renderNotice(t) : d` +
+ ${this._plates().map((e) => this._renderPlate(e))} +
+

Tap your crest to open your Quest Log

+ `} +
+ ${this._renderDock()} +
+ `; + } + _timeZone() { + return he(this.hass); + } + _state(t) { + const n = this.hass?.states?.[t]; + return n && typeof n == "object" ? n : null; + } + _childSlugs() { + if (!this._config) + return []; + const t = this._config.child_order; + return Array.isArray(t) ? t.filter( + (e) => typeof e == "string" && e.trim().length > 0 + ).map((e) => e.trim()) : []; + } + _plates() { + return this._childSlugs().map((t) => this._plate(t)); + } + _plate(t) { + const e = this._dueSensorResolution(t), n = this._state(`sensor.nestquest_${t}_quests_due_today`), i = this._state( + `sensor.nestquest_${t}_quests_completed_today` + ), r = this._state( + `sensor.nestquest_${t}_completion_pct_today` + ), o = this._state( + `binary_sensor.nestquest_${t}_present_today` + ), l = n?.attributes ?? {}, a = wt(J(n?.state, 0)), p = wt(J(i?.state, 0)), u = r ? J(r.state, Number.NaN) : Number.NaN, c = Number.isFinite(u) ? xt(u) : a > 0 ? xt(p / a * 100) : 0; + let f = !0; + const h = l.present; + typeof h == "boolean" ? f = h : o && (f = String(o.state ?? "").trim().toLowerCase() === "on"); + let m = ""; + const _ = l.child_name; + if (typeof _ == "string" && _.trim()) + m = _.trim(); + else { + const G = o?.attributes?.child_name; + typeof G == "string" && G.trim() && (m = G.trim()); + } + m || (m = ge(t)); + const x = ye( + o?.attributes?.next_present, + this._timeZone() + ), D = x ? `Returns ${K(x, this._timeZone())}` : null; + return { + slug: t, + name: m, + initial: (m.charAt(0) || "?").toUpperCase(), + present: e === null ? f : !1, + completed: p, + due: a, + pct: c, + returns: D, + unresolved: e + }; + } + /** "missing" when the due sensor does not exist (integration not + * configured), "stale" when it answers unavailable/unknown (backend + * unreachable or the child is absent from the snapshot), null when + * it resolves. */ + _dueSensorResolution(t) { + const e = this._state(`sensor.nestquest_${t}_quests_due_today`); + if (!e) + return "missing"; + const n = String(e.state ?? "").trim().toLowerCase(); + return !n || n === "unavailable" || n === "unknown" ? "stale" : null; + } + _boardNotice() { + const t = this._plates(); + return t.length === 0 ? qt : t.every((e) => e.unresolved !== null) ? t.some((e) => e.unresolved === "stale") ? Se : qt : null; + } + _renderNotice(t) { + return d` +
+
+ ${t.icon} + ${t.headline} +

${t.body}

+
+
+ `; + } + _renderPlate(t) { + return t.unresolved ? d` +
+ + + ${t.initial} + + + ${t.name} + + ${ke} + Unknown + + — + +
+ ` : t.present ? d` + + ` : d` +
+ + + ${t.initial} + + + ${t.name} + + ${qe} + On travels + + ${t.returns ?? "Returns —"} + +
+ `; + } + _openQuestLog(t) { + const e = vt(this._config?.quest_log_path); + e && (window.history.pushState(null, "", `${e.replace(/\/+$/, "")}/${t}`), window.dispatchEvent(new Event("location-changed"))); + } + _dockWeather() { + const t = vt(this._config?.weather_entity); + if (!t) + return null; + const e = this._state(t); + if (!e) + return null; + const n = String(e.state ?? "").trim(); + if (!n || n === "unavailable" || n === "unknown") + return null; + const i = e.attributes ?? {}; + let r = null, o = null; + const l = i.forecast; + if (Array.isArray(l) && l.length > 0) { + const a = l[0]; + if (a && typeof a == "object") { + const p = a; + r = this._optionalNumber(p.temperature), o = this._optionalNumber(p.templow); + } + } + return { + condition: n, + temperature: this._optionalNumber(i.temperature), + high: r, + low: o + }; + } + _optionalNumber(t) { + if (t == null || t === "") + return null; + const e = typeof t == "number" ? t : Number(t); + return Number.isFinite(e) ? e : null; + } + _renderDock() { + const t = this._dockWeather(); + if (!t) + return d` +
+ ${K(this._now, this._timeZone())} + + ${ue(this._now, this._timeZone())} +
+ `; + const e = be(t.condition), n = ve(t.condition, e), i = t.temperature === null ? g : d`${Math.round(t.temperature)}°`, r = t.high === null || t.low === null ? null : `${Math.round(t.high)}° / ${Math.round(t.low)}°`; + return d` +
+ ${Te} + ${i} + + + ${r === null ? e : `${e} · ${r}`} + + + ${n} +
+ `; + } +} +customElements.define("nestquest-party-board-card", Ee); +at({ + type: "nestquest-party-board-card", + name: "NestQuest Party Board", + description: "Kids' attract screen for present and away adventurers." +}); +const Ne = { CHILD: 2 }, Me = (s) => (...t) => ({ _$litDirective$: s, values: t }); +let Oe = class { + constructor(t) { + } + get _$AU() { + return this._$AM._$AU; + } + _$AT(t, e, n) { + this._$Ct = t, this._$AM = e, this._$Ci = n; + } + _$AS(t, e) { + return this.update(t, e); + } + update(t, e) { + return this.render(...e); + } +}; +const { I: ze } = ae, kt = (s) => s, Ct = () => document.createComment(""), P = (s, t, e) => { + const n = s._$AA.parentNode, i = t === void 0 ? s._$AB : t._$AA; + if (e === void 0) { + const r = n.insertBefore(Ct(), i), o = n.insertBefore(Ct(), i); + e = new ze(r, o, s, s.options); + } else { + const r = e._$AB.nextSibling, o = e._$AM, l = o !== s; + if (l) { + let a; + e._$AQ?.(s), e._$AM = s, e._$AP !== void 0 && (a = s._$AU) !== o._$AU && e._$AP(a); + } + if (r !== i || l) { + let a = e._$AA; + for (; a !== r; ) { + const p = kt(a).nextSibling; + kt(n).insertBefore(a, i), a = p; + } + } + } + return e; +}, k = (s, t, e = s) => (s._$AI(t, e), s), Pe = {}, Ie = (s, t = Pe) => s._$AH = t, Le = (s) => s._$AH, X = (s) => { + s._$AR(), s._$AA.remove(); +}; +const At = (s, t, e) => { + const n = /* @__PURE__ */ new Map(); + for (let i = t; i <= e; i++) n.set(s[i], i); + return n; +}, St = Me(class extends Oe { + constructor(s) { + if (super(s), s.type !== Ne.CHILD) throw Error("repeat() can only be used in text expressions"); + } + dt(s, t, e) { + let n; + e === void 0 ? e = t : t !== void 0 && (n = t); + const i = [], r = []; + let o = 0; + for (const l of s) i[o] = n ? n(l, o) : o, r[o] = e(l, o), o++; + return { values: r, keys: i }; + } + render(s, t, e) { + return this.dt(s, t, e).values; + } + update(s, [t, e, n]) { + const i = Le(s), { values: r, keys: o } = this.dt(t, e, n); + if (!Array.isArray(i)) return this.ut = o, r; + const l = this.ut ??= [], a = []; + let p, u, c = 0, f = i.length - 1, h = 0, m = r.length - 1; + for (; c <= f && h <= m; ) if (i[c] === null) c++; + else if (i[f] === null) f--; + else if (l[c] === o[h]) a[h] = k(i[c], r[h]), c++, h++; + else if (l[f] === o[m]) a[m] = k(i[f], r[m]), f--, m--; + else if (l[c] === o[m]) a[m] = k(i[c], r[m]), P(s, a[m + 1], i[c]), c++, m--; + else if (l[f] === o[h]) a[h] = k(i[f], r[h]), P(s, i[c], i[f]), f--, h++; + else if (p === void 0 && (p = At(o, h, m), u = At(l, c, f)), p.has(l[c])) if (p.has(l[f])) { + const _ = u.get(o[h]), x = _ !== void 0 ? i[_] : null; + if (x === null) { + const D = P(s, i[c]); + k(D, r[h]), a[h] = D; + } else a[h] = k(x, r[h]), P(s, i[c], x), i[_] = null; + h++; + } else X(i[f]), f--; + else X(i[c]), c++; + for (; h <= m; ) { + const _ = P(s, a[m + 1]); + k(_, r[h]), a[h++] = _; + } + for (; c <= f; ) { + const _ = i[c++]; + _ !== null && X(_); + } + return this.ut = o, Ie(s, a), T; + } +}), Ue = [ + "nestquest_quest_completed", + "nestquest_quest_uncompleted", + "nestquest_quest_missed", + "nestquest_child_day_complete" +], Tt = /* @__PURE__ */ new Map(); +function M(s, t) { + const e = `${s ?? "local"}|${JSON.stringify(t)}`, n = Tt.get(e); + if (n) + return n; + let i; + try { + i = new Intl.DateTimeFormat("en-US", { + ...t, + ...s ? { timeZone: s } : {} + }); + } catch { + i = new Intl.DateTimeFormat("en-US", t); + } + return Tt.set(e, i), i; +} +function Re(s) { + const t = s?.config; + if (!t || typeof t != "object") + return; + const e = t.time_zone; + return typeof e == "string" && e.trim() ? e.trim() : void 0; +} +function j(s, t) { + return `${M(t, { weekday: "long" }).format(s)}, ${M(t, { month: "short", day: "numeric" }).format(s)}`; +} +function De(s, t) { + return M(t, { weekday: "long" }).format(s); +} +function je(s, t) { + const e = new Date(s); + return Number.isNaN(e.getTime()) ? "" : M(t, { + hour: "numeric", + minute: "2-digit", + hour12: !0 + }).format(e); +} +function He(s, t) { + return M(t, { + hour: "numeric", + minute: "2-digit", + hour12: !0 + }).format(s); +} +function Be(s) { + if (!s) + return null; + const t = /^(\d{1,2}):(\d{2})$/.exec(s.trim()); + if (!t) + return null; + const e = Number(t[1]); + if (e > 23) + return null; + const n = e >= 12 ? "PM" : "AM"; + return `${e % 12 === 0 ? 12 : e % 12}:${t[2]} ${n}`; +} +function w(s) { + return typeof s == "string" ? s.trim() : ""; +} +function y(s, t) { + if (s == null || s === "") + return t; + const e = typeof s == "number" ? s : Number(s); + return Number.isFinite(e) ? e : t; +} +function b(s) { + return Math.max(0, Math.round(s)); +} +function Y(s) { + return s.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); +} +function Fe(s, t) { + const e = M(t, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23" + }).formatToParts(new Date(s)), n = (r) => { + const o = e.find((l) => l.type === r); + return o ? Number(o.value) : Number.NaN; + }, i = Date.UTC( + n("year"), + n("month") - 1, + n("day"), + n("hour"), + n("minute"), + n("second") + ); + return Number.isFinite(i) ? i - s : 0; +} +function Et(s, t) { + if (typeof s != "string") + return null; + const e = s.split("-"); + if (e.length !== 3) + return null; + const [n, i, r] = e.map((a) => Number(a)); + if (!n || !i || !r) + return null; + if (!t) { + const a = new Date(n, i - 1, r); + return Number.isNaN(a.getTime()) ? null : a; + } + const o = Date.UTC(n, i - 1, r), l = new Date(o - Fe(o, t)); + return Number.isNaN(l.getTime()) ? null : l; +} +function Qe() { + const s = window.location.pathname.split("/").filter(Boolean); + return s.length > 0 ? s[s.length - 1].toLowerCase() : ""; +} +function We(s) { + if (!s || typeof s != "object") + return null; + const t = s, e = w(t.state).toLowerCase(); + if (e !== "open" && e !== "completed") + return null; + const n = y(t.id, 0); + if (!n) + return null; + const i = y(t.child_id, 0); + return { + id: n, + child_id: i > 0 ? i : null, + title: w(t.title) || "Quest", + icon: w(t.icon) || null, + window: w(t.window).toLowerCase(), + due_time: w(t.due_time) || null, + state: e, + overdue: t.overdue === !0, + completed_at: w(t.completed_at) || null, + on_time: t.on_time === !0 ? !0 : t.on_time === !1 ? !1 : null + }; +} +const Ze = { + "clear-night": "Clear", + cloudy: "Cloudy", + exceptional: "Clear", + fog: "Foggy", + hail: "Hail", + lightning: "Storms", + "lightning-rainy": "Storms", + partlycloudy: "Partly cloudy", + pouring: "Heavy rain", + rainy: "Rain", + snowy: "Snow", + "snowy-rainy": "Sleet", + sunny: "Sunny", + windy: "Windy", + "windy-variant": "Windy" +}, Ge = { + "clear-night": "Clear night skies", + cloudy: "Grey skies today", + exceptional: "A striking day", + fog: "Mist on the road", + hail: "Ice from the sky", + lightning: "Storms may roll in", + "lightning-rainy": "Storms may roll in", + partlycloudy: "Sun between clouds", + pouring: "Heavy rain outside", + rainy: "Rain on the walls", + snowy: "Snow on the peaks", + "snowy-rainy": "Sleet may fall", + sunny: "Clear skies today", + windy: "A blustery day", + "windy-variant": "A blustery day" +}; +function Ve(s) { + const t = Ze[s]; + return t || s.charAt(0).toUpperCase() + s.slice(1); +} +function Ke(s, t) { + return Ge[s] ?? t; +} +const Nt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Je = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", Mt = [-9, 6, -4], Xe = Pt` + * { + box-sizing: border-box; + } + + :host { + display: block; + height: 100%; + --nq-brand-gradient-h: linear-gradient( + 90deg, + var(--nq-brand-blue), + var(--nq-brand-purple) + ); + } + + .board { + position: relative; + display: flex; + flex-direction: column; + height: 1080px; + padding: 0; + overflow: hidden; + background: var(--nq-p-parchment); + box-shadow: var(--nq-p-vignette); + font-family: var(--nq-p-font-body); + } + + .frame { + position: absolute; + pointer-events: none; + } + + .frame-outer { + inset: 26px; + border: var(--nq-p-frame-outer); + border-radius: 14px; + } + + .frame-inner { + inset: 36px; + border: var(--nq-p-frame-inner); + border-radius: 8px; + } + + .header { + position: absolute; + top: 56px; + left: var(--nq-p-page-inset); + right: var(--nq-p-page-inset); + display: flex; + align-items: center; + gap: 28px; + } + + .crest { + position: relative; + flex: none; + width: 96px; + height: 110px; + padding: 3px; + clip-path: ${v(Nt)}; + background: rgba(255, 255, 255, 0.22); + } + + .crest-face { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding-bottom: 32px; + clip-path: ${v(Nt)}; + background: var(--nq-p-crest); + } + + .crest .initial { + font-family: var(--nq-p-font-display); + font-size: 44px; + font-weight: 700; + line-height: 1; + color: #ffffff; + } + + .crest.away .crest-face { + background: var(--nq-p-crest-away); + } + + .titles { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + } + + .title { + margin: 0; + font-family: var(--nq-p-font-display); + font-size: var(--nq-p-size-title); + font-weight: 700; + line-height: 1.1; + color: var(--nq-p-ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .sub { + margin: 0; + font-family: var(--nq-p-font-heading); + font-size: 20px; + font-weight: 600; + letter-spacing: 0.24em; + text-transform: uppercase; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .remaining { + margin-left: auto; + flex: none; + display: flex; + align-items: center; + gap: 20px; + padding: 18px 28px; + border: 2px solid var(--nq-p-panel-border); + border-radius: 14px; + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + } + + .d20 { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + clip-path: ${v(Je)}; + background: var(--nq-brand-gradient); + } + + .d20-numeral { + font-family: var(--nq-p-font-heading); + font-size: 19px; + font-weight: 700; + line-height: 1; + color: #ffffff; + } + + .remaining-text { + display: flex; + flex-direction: column; + gap: 4px; + } + + .remaining-count { + font-family: var(--nq-p-font-heading); + font-size: 28px; + font-weight: 700; + line-height: 1.1; + color: var(--nq-p-ink); + } + + .party-line { + font-family: var(--nq-p-font-body); + font-size: var(--nq-p-size-body); + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .columns { + position: absolute; + top: 236px; + left: var(--nq-p-page-inset); + right: var(--nq-p-page-inset); + bottom: 130px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 34px; + } + + .column { + display: flex; + flex-direction: column; + min-height: 0; + } + + .column-head { + flex: none; + display: flex; + align-items: center; + gap: 14px; + padding-bottom: 14px; + border-bottom: var(--nq-p-rule); + } + + .column-head svg { + flex: none; + width: 34px; + height: 34px; + color: var(--nq-p-ink-secondary); + } + + .column-names { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + .column-name { + font-family: var(--nq-p-font-heading); + font-size: var(--nq-p-size-section); + font-weight: 700; + line-height: 1.1; + color: var(--nq-p-ink); + } + + .column-range { + font-family: var(--nq-p-font-heading); + font-size: var(--nq-p-size-label); + font-weight: 600; + letter-spacing: var(--nq-p-track-label); + text-transform: uppercase; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .column-count { + margin-left: auto; + flex: none; + font-family: var(--nq-p-font-heading); + font-size: 26px; + font-weight: 700; + line-height: 1.1; + color: var(--nq-p-ink-secondary); + } + + .stack { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: visible; + gap: var(--nq-p-quest-gap); + margin-top: 22px; + } + + .quest { + position: relative; + display: flex; + align-items: center; + gap: 22px; + min-height: var(--nq-p-quest-min-height); + padding: var(--nq-p-quest-pad); + border: 1px solid var(--nq-p-card-border); + border-radius: var(--nq-p-radius-card); + background: var(--nq-p-card-open); + box-shadow: var(--nq-p-card-shadow); + -webkit-tap-highlight-color: transparent; + transition: transform var(--nq-dur-micro) var(--nq-ease-out); + } + + .quest.tappable { + cursor: pointer; + } + + .quest.tappable:active { + transform: scale(0.98); + } + + .quest.sealed { + background: var(--nq-p-card-done); + opacity: 0.82; + box-shadow: none; + } + + .tile { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--nq-p-tile-size); + height: var(--nq-p-tile-size); + border-radius: 10px; + background: var(--nq-p-icon-tile); + color: #ffffff; + } + + .tile svg { + width: 34px; + height: 34px; + } + + .sealed .tile { + background: var(--nq-p-icon-tile-done); + color: var(--nq-p-ink-secondary); + } + + .quest .body { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + } + + .quest .quest-title { + font-family: var(--nq-p-font-heading); + font-size: var(--nq-p-size-quest); + font-weight: 700; + line-height: 1.15; + color: var(--nq-p-ink); + overflow-wrap: anywhere; + } + + .sealed .quest-title { + color: var(--nq-p-ink-muted); + text-decoration: line-through; + } + + .quest .meta { + font-family: var(--nq-p-font-body); + font-size: var(--nq-p-size-body); + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .quest .meta.late { + font-family: var(--nq-p-font-heading); + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--nq-p-ink-late); + } + + button.complete { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + height: var(--nq-p-button-height); + min-width: var(--nq-p-button-min-width); + border: none; + border-radius: 10px; + background: var(--nq-brand-gradient); + color: #ffffff; + font-family: var(--nq-p-font-heading); + font-size: 24px; + font-weight: 700; + line-height: 1; + box-shadow: 0 3px 0 rgba(40, 20, 60, 0.35); + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform var(--nq-dur-micro) var(--nq-ease-out); + } + + button.complete:active { + transform: scale(0.98); + } + + button.complete svg { + width: 30px; + height: 30px; + } + + button:focus-visible { + outline: 3px solid var(--nq-brand-purple); + outline-offset: 4px; + } + + .seal { + position: absolute; + right: 16px; + top: -12px; + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--nq-p-seal-size); + height: var(--nq-p-seal-size); + border-radius: var(--nq-p-radius-pill); + background: var(--nq-p-seal); + box-shadow: var(--nq-p-seal-shadow); + color: rgba(255, 235, 235, 0.95); + transform: rotate(var(--seal-rot, 0deg)); + animation: seal-in var(--nq-dur-base) var(--nq-ease-out); + pointer-events: none; + } + + .seal svg { + width: 38px; + height: 38px; + } + + @keyframes seal-in { + from { + opacity: 0; + transform: scale(1.15) rotate(var(--seal-rot, 0deg)); + } + to { + opacity: 1; + transform: scale(1) rotate(var(--seal-rot, 0deg)); + } + } + + .rollup { + border: 2px dashed var(--nq-p-panel-border); + border-radius: 14px; + padding: 16px 22px; + } + + .rollup-label { + display: block; + font-family: var(--nq-p-font-heading); + font-size: 18px; + font-weight: 600; + letter-spacing: var(--nq-p-track-label); + text-transform: uppercase; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .rollup-body { + margin: 6px 0 0; + font-family: var(--nq-p-font-body); + font-size: 23px; + font-weight: 600; + line-height: 1.35; + color: var(--nq-p-ink-secondary); + } + + .notice-wrap { + position: absolute; + top: 236px; + left: var(--nq-p-page-inset); + right: var(--nq-p-page-inset); + bottom: 130px; + display: flex; + align-items: center; + justify-content: center; + } + + .notice { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 20px; + width: 100%; + max-width: 980px; + padding: 56px 64px; + border: 2px solid var(--nq-p-panel-border); + border-radius: 20px; + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + text-align: center; + } + + .notice svg { + width: 46px; + height: 46px; + color: var(--nq-p-ink-secondary); + } + + .notice.away { + background: linear-gradient(#f2ecdd, #e6dcc6); + border-style: dashed; + box-shadow: none; + } + + .notice-headline { + font-family: var(--nq-p-font-heading); + font-size: 40px; + font-weight: 700; + line-height: 1.15; + color: var(--nq-p-ink); + } + + .notice-body { + margin: 0; + font-family: var(--nq-p-font-body); + font-size: 24px; + font-weight: 600; + line-height: 1.4; + color: var(--nq-p-ink-secondary); + } + + .notice-seal { + display: inline-flex; + align-items: center; + justify-content: center; + width: 78px; + height: 78px; + border-radius: var(--nq-p-radius-pill); + background: var(--nq-p-seal); + box-shadow: var(--nq-p-seal-shadow); + color: rgba(255, 235, 235, 0.95); + transform: rotate(-6deg); + } + + .notice-seal svg { + width: 38px; + height: 38px; + color: rgba(255, 235, 235, 0.95); + } + + .scrim { + position: absolute; + inset: 0; + z-index: 10; + background: rgba(30, 20, 10, 0.62); + } + + .dialog { + position: absolute; + top: 196px; + left: 460px; + width: 1000px; + border: 3px solid rgba(92, 62, 26, 0.5); + border-radius: 20px; + background: var(--nq-p-card-panel); + box-shadow: 0 26px 60px rgba(20, 10, 0, 0.5); + padding: 64px 68px 56px; + animation: dialog-in var(--nq-dur-modal) var(--nq-ease-out); + } + + @keyframes dialog-in { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } + } + + .dialog-header { + display: flex; + align-items: center; + gap: 24px; + } + + .dialog-tile { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 104px; + height: 104px; + border-radius: 12px; + background: var(--nq-p-icon-tile); + color: #ffffff; + } + + .dialog-tile svg { + width: 56px; + height: 56px; + } + + .dialog-titles { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + } + + .dialog-kicker { + font-family: var(--nq-p-font-heading); + font-size: var(--nq-p-size-label); + font-weight: 600; + letter-spacing: 0.24em; + text-transform: uppercase; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .dialog-quest-title { + font-family: var(--nq-p-font-display); + font-size: 58px; + font-weight: 700; + line-height: 1.1; + color: var(--nq-p-ink); + overflow-wrap: anywhere; + } + + .dialog-body { + margin: 28px 0 0; + font-family: var(--nq-p-font-body); + font-size: 32px; + font-weight: 600; + line-height: 1.35; + color: #3f2f1c; + } + + .dialog-buttons { + display: flex; + gap: 24px; + margin-top: 40px; + } + + button.confirm-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 12px; + height: var(--nq-p-confirm-button); + border-radius: 10px; + font-family: var(--nq-p-font-heading); + font-weight: 700; + line-height: 1; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform var(--nq-dur-micro) var(--nq-ease-out); + } + + button.confirm-button:active { + transform: scale(0.98); + } + + button.not-yet { + flex: 1; + border: 2px solid rgba(92, 62, 26, 0.5); + background: rgba(92, 62, 26, 0.1); + color: var(--nq-p-ink); + font-size: 30px; + } + + button.not-yet svg { + width: 34px; + height: 34px; + } + + button.do-complete { + flex: 2; + border: none; + background: var(--nq-brand-gradient); + color: #ffffff; + font-size: 32px; + } + + button.do-complete svg { + width: 38px; + height: 38px; + } + + .toast { + position: absolute; + left: 50%; + bottom: 158px; + z-index: 20; + transform: translateX(-50%); + max-width: calc(100% - 124px); + padding: 20px 32px; + border: 2px solid var(--nq-p-panel-border); + border-radius: 14px; + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + font-family: var(--nq-p-font-body); + font-size: 23px; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--nq-p-ink-secondary); + animation: toast-in var(--nq-dur-base) var(--nq-ease-out); + pointer-events: none; + } + + @keyframes toast-in { + from { + opacity: 0; + transform: translate(-50%, 12px); + } + to { + opacity: 1; + transform: translate(-50%, 0); + } + } + + .complete-screen { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + padding: 96px 62px 130px; + text-align: center; + } + + .complete-seal { + display: inline-flex; + align-items: center; + justify-content: center; + width: 168px; + height: 168px; + border-radius: var(--nq-p-radius-pill); + background: var(--nq-p-seal); + box-shadow: 0 10px 24px rgba(60, 10, 20, 0.42); + color: rgba(255, 235, 235, 0.95); + transform: rotate(-6deg); + } + + .complete-seal svg { + width: 86px; + height: 86px; + } + + .complete-title { + margin: 28px 0 0; + font-family: var(--nq-p-font-display); + font-size: 86px; + font-weight: 900; + line-height: 1.1; + background: var(--nq-brand-gradient-h); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; + } + + .complete-sub { + margin: 16px 0 0; + max-width: 1000px; + font-family: var(--nq-p-font-body); + font-size: 34px; + font-weight: 600; + line-height: 1.35; + color: var(--nq-p-ink-secondary); + text-wrap: pretty; + } + + .complete-countdown { + margin: 12px 0 0; + font-family: var(--nq-p-font-body); + font-size: 25px; + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .complete-stats { + position: absolute; + top: 540px; + left: 200px; + right: 200px; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 26px; + } + + .complete-stat { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px 16px; + border: 2px solid var(--nq-p-panel-border); + border-radius: 16px; + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + } + + .complete-stat .numeral { + font-family: var(--nq-p-font-heading); + font-size: 58px; + font-weight: 900; + line-height: 1; + color: var(--nq-p-ink); + } + + .complete-stat .label { + font-family: var(--nq-p-font-heading); + font-size: 17px; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .dock { + position: absolute; + left: 46px; + right: 46px; + bottom: 46px; + height: var(--nq-p-dock-height); + display: flex; + align-items: center; + justify-content: center; + gap: 24px; + padding: 0 40px; + border-radius: 14px; + background: var(--nq-p-dock-bg); + pointer-events: none; + user-select: none; + color: var(--nq-p-dock-ink); + } + + .dock svg { + flex: none; + width: 40px; + height: 40px; + color: var(--nq-p-dock-ink); + } + + .dock .divider { + flex: none; + width: 1px; + height: 40px; + background: var(--nq-p-dock-divider); + } + + .dock .temp { + font-family: var(--nq-p-font-body); + font-size: 38px; + font-weight: 800; + line-height: 1; + } + + .dock .condition { + font-family: var(--nq-p-font-body); + font-size: 26px; + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-dock-ink-secondary); + } + + .dock .date { + font-family: var(--nq-p-font-body); + font-size: 26px; + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-dock-ink-secondary); + } + + .dock .clock { + font-family: var(--nq-p-font-body); + font-size: 38px; + font-weight: 800; + line-height: 1; + } +`, H = d``, Ye = d``, tt = d``, tn = d``, Ht = d``, en = d``, nn = d``, sn = d``, rn = d``, on = d``, an = d``, ln = { + icon: sn, + headline: "No adventurer chosen", + body: "Open The Party and tap your crest to open your quest log." +}, cn = { + icon: rn, + headline: "NestQuest is not set up yet", + body: "A parent needs to finish setting up NestQuest." +}, dn = { + icon: on, + headline: "The records cannot be reached", + body: "The party's records are quiet right now. NestQuest will return shortly." +}, Ot = [ + { key: "morning", name: "Morning", range: "Until 11:59 AM", icon: tn }, + { key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: Ht }, + { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: en } +]; +class pn extends A { + static properties = { + hass: { attribute: !1 }, + _config: { state: !0 }, + _now: { state: !0 }, + _confirm: { state: !0 }, + _optimistic: { state: !0 }, + _toast: { state: !0 }, + _countdown: { state: !0 } + }; + static styles = [v(jt), Xe]; + hass; + _config; + _now = /* @__PURE__ */ new Date(); + _confirm = null; + /** instanceId → the completion instant stamped before the service has + * answered; the seal shows immediately and reverts on failure. */ + _optimistic = /* @__PURE__ */ new Map(); + _toast = null; + _countdown = null; + _clockTimer; + _idleTimer; + _confirmTimer; + _toastTimer; + _completeTimer; + _unsubs = []; + _subscribed = !1; + _subGeneration = 0; + _retryTimer; + setConfig(t) { + if (!t || typeof t != "object") + throw new Error("Invalid configuration"); + this._config = t; + } + _hassConnection() { + const t = this.hass?.connection; + return !t || typeof t != "object" || typeof t.subscribeEvents != "function" ? null : t; + } + _subscribeLive() { + if (!this.isConnected || this._subscribed) + return; + const t = this._hassConnection(); + if (!t) + return; + this._subscribed = !0; + const e = ++this._subGeneration, n = (i) => { + i.then((r) => { + this._subscribed && e === this._subGeneration ? this._unsubs.push(r) : r(); + }).catch(() => { + e === this._subGeneration && (this._unsubscribeLive(), this._armSubscribeRetry()); + }); + }; + for (const i of Ue) + n( + t.subscribeEvents(() => this.requestUpdate(), i) + ); + } + _armSubscribeRetry() { + this._retryTimer === void 0 && (this._retryTimer = window.setTimeout(() => { + this._retryTimer = void 0, this._subscribeLive(); + }, 1e3)); + } + _clearSubscribeRetry() { + this._retryTimer !== void 0 && (window.clearTimeout(this._retryTimer), this._retryTimer = void 0); + } + _unsubscribeLive() { + this._clearSubscribeRetry(), this._subGeneration++; + for (const t of this._unsubs) + t(); + this._unsubs = [], this._subscribed = !1; + } + getCardSize() { + return 22; + } + connectedCallback() { + super.connectedCallback(), this._subscribeLive(), this._now = /* @__PURE__ */ new Date(), this._clockTimer = window.setInterval(() => { + this._now = /* @__PURE__ */ new Date(); + }, 6e4), window.addEventListener("pointerdown", this._onActivity, !0), window.addEventListener("touchstart", this._onActivity, !0), window.addEventListener("keydown", this._onActivity, !0), this._armIdle(), this._logView().kind === "complete-day" && this._armCompleteTimer(); + } + updated() { + this._subscribeLive(), this._logView().kind === "complete-day" ? (this._clearIdle(), this._armCompleteTimer()) : (this._clearCompleteTimer(), this._countdown = null, this._idleTimer === void 0 && this._armIdle()); + } + disconnectedCallback() { + this._unsubscribeLive(), this._stopClock(), this._clearIdle(), this._clearConfirmTimer(), this._clearToastTimer(), this._clearCompleteTimer(), window.removeEventListener("pointerdown", this._onActivity, !0), window.removeEventListener("touchstart", this._onActivity, !0), window.removeEventListener("keydown", this._onActivity, !0), super.disconnectedCallback(); + } + render() { + const t = this._logView(); + return d` +
+
+
+ ${this._renderMain(t)} + ${this._renderConfirm()} + ${this._renderToast()} +
+ `; + } + _renderToast() { + return this._toast === null ? g : d` +
${this._toast}
+ `; + } + _renderMain(t) { + switch (t.kind) { + case "no-adventurer": + return this._renderNotice(ln); + case "not-set-up": + return this._renderNotice(cn); + case "unreachable": + return this._renderNotice(dn); + case "away": { + const e = ["The quest log unlocks when they return."]; + return t.returns && e.push(`Returns ${t.returns}`), d` + ${this._renderHeader(t)} +
+
+ ${nn} + ${t.name} is on travels + ${e.map((n) => d`

${n}

`)} +
+
+ `; + } + case "empty-day": + return d` + ${this._renderHeader(t)} +
+
+ ${Ht} + No quests today +

+ ${`Nothing was posted for today, ${t.name}. Enjoy the day's rest!`} +

+
+
+ `; + case "complete-day": { + const e = this._completeStats(), n = this._countdown ?? this._completeSeconds(); + return d` +
+ +

Quest complete

+

${this._completeSub(t.name)}

+

+ Returning to The Party in ${n} seconds +

+
+
+ ${e.claimed} + Quests claimed +
+
+ ${e.onTime} + On time +
+
+ ${e.late} + Late +
+
+ ${e.party} + The Party +
+
+
+ ${this._renderDock()} + `; + } + default: + return d` + ${this._renderHeader(t)} +
+ ${Ot.map((e) => this._renderColumn(e))} +
+ `; + } + } + /** Resolve the log's single render state; the priority order is + * design/PANEL-EMPTY-STATES.md §2. */ + _logView() { + const t = this._childSlug(); + if (!t) + return { kind: "no-adventurer" }; + const e = this._dueSensorResolution(t); + if (e === "missing") + return { kind: "not-set-up" }; + if (e === "stale") + return { kind: "unreachable" }; + const n = this._childName(t) ?? Y(t); + if (!this._childPresent(t)) + return { kind: "away", name: n, returns: this._awayReturns(t) }; + const i = this._state(`binary_sensor.nestquest_${t}_all_done`); + if (i && String(i.state ?? "").trim().toLowerCase() === "on") + return { kind: "complete-day", name: n }; + const r = this._state(`sensor.nestquest_${t}_quests_due_today`); + return b(y(r?.state, 0)) === 0 ? { kind: "empty-day", name: n } : this._remaining() === 0 ? { kind: "complete-day", name: n } : { kind: "normal" }; + } + /** "missing" when the due sensor does not exist (integration not + * configured), "stale" when it answers unavailable/unknown (backend + * unreachable or the child is absent from the snapshot), null when + * it resolves. */ + _dueSensorResolution(t) { + const e = this._state(`sensor.nestquest_${t}_quests_due_today`); + if (!e) + return "missing"; + const n = String(e.state ?? "").trim().toLowerCase(); + return !n || n === "unavailable" || n === "unknown" ? "stale" : null; + } + _childPresent(t) { + const n = this._state(`sensor.nestquest_${t}_quests_due_today`)?.attributes?.present; + if (typeof n == "boolean") + return n; + const i = this._state( + `binary_sensor.nestquest_${t}_present_today` + ); + return i ? String(i.state ?? "").trim().toLowerCase() === "on" : !0; + } + _awayReturns(t) { + const e = this._timeZone(), n = this._state( + `binary_sensor.nestquest_${t}_present_today` + ), i = Et( + n?.attributes?.next_present, + e + ); + return i ? j(i, e) : null; + } + _renderNotice(t) { + return d` +
+
+ ${t.icon} + ${t.headline} +

${t.body}

+
+
+ `; + } + _timeZone() { + return Re(this.hass); + } + _state(t) { + const n = this.hass?.states?.[t]; + return n && typeof n == "object" ? n : null; + } + _childSlug() { + return Qe(); + } + _boardPath() { + return w(this._config?.board_path).replace(/\/+$/, ""); + } + _idleSeconds() { + const t = y(this._config?.idle_return_seconds, 40); + return t > 0 ? t : 40; + } + _confirmSeconds() { + const t = y(this._config?.confirm_timeout_seconds, 15); + return t > 0 ? t : 15; + } + _completeSeconds() { + const t = y(this._config?.complete_screen_seconds, 12); + return t > 0 ? t : 12; + } + _childName(t) { + if (!t) + return null; + const e = this._state(`sensor.nestquest_${t}_quests_due_today`)?.attributes?.child_name; + return typeof e == "string" && e.trim() ? e.trim() : null; + } + _childCounts(t) { + const e = this._state(`sensor.nestquest_${t}_quests_due_today`), n = this._state( + `sensor.nestquest_${t}_quests_completed_today` + ), i = b(y(e?.state, 0)), r = b(y(n?.state, 0)); + return { due: i, completed: r }; + } + _instances() { + const t = this._childSlug(); + if (!t) + return []; + const e = this._state(`sensor.nestquest_${t}_quests_due_today`)?.attributes?.instances; + if (!Array.isArray(e)) + return []; + const n = e.map(We).filter((r) => r !== null); + if (this._optimistic.size === 0) + return n; + let i = !1; + for (const r of n) { + const o = this._optimistic.get(r.id); + if (o !== void 0) { + if (r.state === "completed") { + this._optimistic.delete(r.id), i = !0; + continue; + } + r.state = "completed", r.overdue = !1, r.completed_at = o; + } + } + return i && (this._optimistic = new Map(this._optimistic)), n; + } + _remaining() { + const t = this._childSlug(); + if (!t) + return 0; + const e = this._state( + `sensor.nestquest_${t}_quests_remaining_today` + ); + if (e) + return b(y(e.state, 0)); + const { due: n, completed: i } = this._childCounts(t); + return b(n - i); + } + _partyCounts() { + const t = this._state( + "sensor.nestquest_household_quests_due_today" + ), e = this._state( + "sensor.nestquest_household_quests_completed_today" + ); + if (t || e) + return { + completed: b(y(e?.state, 0)), + due: b(y(t?.state, 0)) + }; + const n = this._childSlug(), { due: i, completed: r } = this._childCounts(n); + return { completed: r, due: i }; + } + _completeStats() { + const t = this._childCounts(this._childSlug()).completed, e = this._instances().filter( + (r) => r.on_time === !0 + ).length, n = b(t - e), i = this._partyCounts(); + return { + claimed: t, + onTime: e, + late: n, + party: `${i.completed}/${i.due}` + }; + } + _completeSub(t) { + const e = j(this._now, this._timeZone()), { claimed: n, onTime: i, late: r } = this._completeStats(); + return n === 0 ? `${t} sealed the day on ${e}.` : r === 0 ? `${t} claimed every quest on ${e} — all on time.` : i === 0 ? `${t} claimed every quest on ${e} — all late.` : `${t} claimed every quest on ${e} — ${i} on time, ${r} late.`; + } + _otherChildren() { + const t = this.hass?.states; + if (!t) + return []; + const e = this._childSlug(), n = this._timeZone(), i = []; + for (const r of Object.keys(t)) { + const o = /^sensor\.nestquest_(.+)_quests_due_today$/.exec(r); + if (!o || o[1] === "household" || o[1] === e) + continue; + const l = o[1], a = t[r]?.attributes ?? {}; + let p = ""; + typeof a.child_name == "string" && a.child_name.trim() ? p = a.child_name.trim() : p = Y(l); + let u = !0; + if (typeof a.present == "boolean") + u = a.present; + else { + const _ = this._state( + `binary_sensor.nestquest_${l}_present_today` + ); + u = _ === null || String(_.state ?? "").trim().toLowerCase() !== "off"; + } + const c = this._state( + `sensor.nestquest_${l}_quests_completed_today` + ), f = b(y(t[r]?.state, 0)), h = b(y(c?.state, 0)); + let m = null; + if (!u) { + const _ = this._state( + `binary_sensor.nestquest_${l}_present_today` + ), x = Et( + _?.attributes?.next_present, + n + ); + m = x ? De(x, n) : null; + } + i.push({ + slug: l, + name: p, + present: u, + due: f, + completed: h, + returnsWeekday: m + }); + } + return i.sort((r, o) => r.name.localeCompare(o.name)); + } + _renderHeader(t) { + const e = this._childSlug(), n = t.kind === "away", i = n ? t.name : this._childName(e) ?? (e ? Y(e) : null), { due: r, completed: o } = this._childCounts(e), l = this._remaining(), a = this._partyCounts(), p = i ? `${i}'s Quest Log` : "Quest Log", u = (i?.charAt(0) || "?").toUpperCase(); + return d` +
+ +
+

${p}

+

+ ${j(this._now, this._timeZone())} · + ${n ? "On travels" : `${o} of ${r} claimed`} +

+
+ ${n ? g : d` +
+ +
+ + ${l} quest${l === 1 ? "" : "s"} left + + + Party progress · ${a.completed} of ${a.due} today + +
+
+ `} +
+ `; + } + _renderColumn(t) { + const e = this._instances().filter( + (l) => l.window === t.key + ), n = e.filter((l) => l.state === "open").sort( + (l, a) => (l.due_time ?? "99:99").localeCompare(a.due_time ?? "99:99") + ), i = e.filter( + (l) => l.state === "completed" + ), r = e.length; + let o = g; + if (r > 0) { + const a = 644 - (t.key === "afternoon" && this._otherChildren().length > 0 ? 160 : 0), p = Math.min( + 24, + Math.max(8, Math.floor((a - r * 74) / Math.max(r - 1, 1))) + ), u = Math.min( + 116, + Math.max(58, Math.floor((a - (r - 1) * p) / r)) + ), c = Math.max(56, Math.min(72, u - 2)); + let f = `--nq-p-quest-gap: ${p}px; --nq-p-quest-min-height: ${u}px; --nq-p-button-height: ${c}px`; + if (r >= 5) { + const h = Math.max(0, Math.min(22, Math.floor((u - c - 2) / 2))), m = Math.max(44, Math.min(64, u - 2 * h - 2)); + f += `; --nq-p-quest-pad: ${h}px; --nq-p-tile-size: ${m}px`; + } + o = f; + } + return d` +
+
+ ${t.icon} +
+ ${t.name} + ${t.range} +
+ ${i.length}/${e.length} +
+
+ ${St( + n, + (l) => l.id, + (l) => this._renderQuest(l, !1, 0) + )} + ${St( + i, + (l) => l.id, + (l, a) => this._renderQuest(l, !0, a) + )} + ${t.key === "afternoon" ? this._renderRollup() : g} +
+
+ `; + } + _renderQuest(t, e, n) { + if (e) { + const o = Mt[n % Mt.length], l = t.completed_at ? je(t.completed_at, this._timeZone()) : ""; + return d` +
+ +
+ ${t.title} + + ${l ? `Claimed ${l}` : "Claimed"} + +
+ +
+ `; + } + const i = Be(t.due_time), r = t.overdue ? { + text: i ? `Overdue · due ${i}` : "Overdue", + late: !0 + } : { + text: i ? `Due by ${i}` : "Due today", + late: !1 + }; + return d` +
this._openConfirm(t.id)} + @keydown=${(o) => { + (o.key === "Enter" || o.key === " ") && (o.preventDefault(), this._openConfirm(t.id)); + }} + > + +
+ ${t.title} + ${r.text} +
+ +
+ `; + } + _renderRollup() { + const t = this._otherChildren(); + if (t.length === 0) + return g; + const e = t.slice(0, 2).map((n) => n.present ? n.due > 0 && n.completed >= n.due ? `${n.name} has finished their log.` : n.due > n.completed ? `${n.name} has ${n.due - n.completed} quests left.` : `${n.name} has no quests today.` : n.returnsWeekday ? `${n.name} is on travels until ${n.returnsWeekday}.` : `${n.name} is on travels.`); + return d` +
+ Party roll-up +

${e.join(" ")}

+
+ `; + } + _renderConfirm() { + if (this._confirm === null) + return g; + const t = this._instances().find( + (n) => n.id === this._confirm + ); + if (!t) + return g; + const e = Ot.find((n) => n.key === t.window); + return d` +
this._closeConfirm()}> + +
+ `; + } + _openConfirm(t) { + this._confirm = t, this._armConfirmTimer(); + } + _closeConfirm() { + this._clearConfirmTimer(), this._confirm = null; + } + _confirmComplete() { + const t = this._confirm === null ? void 0 : this._instances().find( + (e) => e.id === this._confirm + ); + this._closeConfirm(), !(!t || t.state !== "open") && this._completeQuest(t); + } + /** Seal first, ask the service second: the seal is stamped and the column + * re-sorted immediately (PANEL-SPEC §4); a service failure reverts the + * card and raises the parchment toast. */ + async _completeQuest(t) { + if (this._optimistic.has(t.id)) + return; + const e = (/* @__PURE__ */ new Date()).toISOString(); + this._optimistic = new Map(this._optimistic).set(t.id, e); + try { + await this._callCompleteQuest(t); + } catch { + const n = new Map(this._optimistic); + n.delete(t.id), this._optimistic = n, this._showToast( + "NestQuest could not set the seal just now. Please try again." + ); + } + } + async _callCompleteQuest(t) { + const e = this.hass; + if (typeof e?.callService != "function") + throw new Error("Home Assistant is not connected"); + const n = this._actorChildId(t); + if (n === null) + throw new Error("The tapped adventurer is unknown"); + await e.callService.call(e, "nestquest", "complete_quest", { + instance_id: t.id, + actor: "panel", + actor_child_id: n + }); + } + /** The tapped profile (decision 9): the instance's own child_id, falling + * back to the due sensor's child-level attribute. */ + _actorChildId(t) { + if (t.child_id !== null) + return t.child_id; + const e = this._childSlug(), n = y( + this._state(`sensor.nestquest_${e}_quests_due_today`)?.attributes?.child_id, + 0 + ); + return n > 0 ? n : null; + } + _showToast(t) { + this._toast = t, this._clearToastTimer(), this._toastTimer = window.setTimeout(() => { + this._toastTimer = void 0, this._toast = null; + }, 6e3); + } + _clearToastTimer() { + this._toastTimer !== void 0 && (window.clearTimeout(this._toastTimer), this._toastTimer = void 0); + } + _armConfirmTimer() { + this._clearConfirmTimer(), this._confirmTimer = window.setTimeout(() => { + this._confirmTimer = void 0, this._closeConfirm(); + }, this._confirmSeconds() * 1e3); + } + _clearConfirmTimer() { + this._confirmTimer !== void 0 && (window.clearTimeout(this._confirmTimer), this._confirmTimer = void 0); + } + _armIdle() { + this._clearIdle(), this._idleTimer = window.setTimeout(() => { + this._idleTimer = void 0, this._returnToBoard(); + }, this._idleSeconds() * 1e3); + } + _clearIdle() { + this._idleTimer !== void 0 && (window.clearTimeout(this._idleTimer), this._idleTimer = void 0); + } + _armCompleteTimer() { + if (this._completeTimer === void 0) { + if (this._countdown === null && (this._countdown = this._completeSeconds()), this._countdown <= 0) { + this._returnToBoard(); + return; + } + this._completeTimer = window.setInterval(() => { + const t = (this._countdown ?? 1) - 1; + if (t <= 0) { + this._countdown = 0, this._clearCompleteTimer(), this._returnToBoard(); + return; + } + this._countdown = t; + }, 1e3); + } + } + _clearCompleteTimer() { + this._completeTimer !== void 0 && (window.clearInterval(this._completeTimer), this._completeTimer = void 0); + } + _optionalNumber(t) { + if (t == null || t === "") + return null; + const e = typeof t == "number" ? t : Number(t); + return Number.isFinite(e) ? e : null; + } + _dockWeather() { + const t = w(this._config?.weather_entity); + if (!t) + return null; + const e = this._state(t); + if (!e) + return null; + const n = String(e.state ?? "").trim(); + if (!n || n === "unavailable" || n === "unknown") + return null; + const i = e.attributes ?? {}; + let r = null, o = null; + const l = i.forecast; + if (Array.isArray(l) && l.length > 0) { + const a = l[0]; + if (a && typeof a == "object") { + const p = a; + r = this._optionalNumber(p.temperature), o = this._optionalNumber(p.templow); + } + } + return { + condition: n, + temperature: this._optionalNumber(i.temperature), + high: r, + low: o + }; + } + _renderDock() { + const t = this._dockWeather(); + if (!t) + return d` +
+ ${j(this._now, this._timeZone())} + + ${He(this._now, this._timeZone())} +
+ `; + const e = Ve(t.condition), n = Ke(t.condition, e), i = t.temperature === null ? g : d`${Math.round(t.temperature)}°`, r = t.high === null || t.low === null ? null : `${Math.round(t.high)}° / ${Math.round(t.low)}°`; + return d` +
+ ${an} + ${i} + + + ${r === null ? e : `${e} · ${r}`} + + + ${n} +
+ `; + } + _returnToBoard() { + const t = this._boardPath(); + t && (window.history.pushState(null, "", t), window.dispatchEvent(new Event("location-changed"))); + } + _stopClock() { + this._clockTimer !== void 0 && (window.clearInterval(this._clockTimer), this._clockTimer = void 0); + } + _onActivity = () => { + this._logView().kind !== "complete-day" && this._armIdle(), this._confirm !== null && this._armConfirmTimer(); + }; +} +customElements.define("nestquest-quest-log-card", pn); +at({ + type: "nestquest-quest-log-card", + name: "NestQuest Quest Log", + description: "One child's daily quests, grouped by window." +}); +const hn = ":host{--nq-brand-blue: #3B3AB8;--nq-brand-purple: #A035CC;--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-a-page: #F4F4F6;--nq-a-surface: #FFFFFF;--nq-a-surface-subtle: #FAFAFA;--nq-a-selected: #EBEBF8;--nq-a-border: #E4E4EA;--nq-a-divider: #EDEDF1;--nq-a-ink: #18181D;--nq-a-ink-secondary: #52525E;--nq-a-ink-tertiary: #70707E;--nq-a-success: #15803D;--nq-a-danger: #DC2626;--nq-a-danger-strong: #B91C1C;--nq-a-danger-bg: #FEE2E2;--nq-a-warning-ink: #92400E;--nq-a-warning-ink-2: #78350F;--nq-a-warning-bg: #FEF3C7;--nq-a-warning-border: rgba(217,119,6,.35);--nq-a-info-bg: #DBEAFE;--nq-a-info-ink: #1E3A8A;--nq-a-info-icon: #1D4ED8;--nq-a-reversal: #7D28A0;--nq-a-font: Nunito, system-ui, sans-serif;--nq-a-size-screen: 24px;--nq-a-size-hero: 32px;--nq-a-size-card-title: 19px;--nq-a-size-stat: 22px;--nq-a-size-row: 13.5px;--nq-a-size-meta: 11px;--nq-a-size-label: 10px;--nq-a-track-label: .1em;--nq-a-density-page-pad: 14px 16px 92px;--nq-a-density-gap: 10px;--nq-a-density-card-pad: 13px;--nq-a-density-row-pad: 11px 13px;--nq-a-density-radius: 8px;--nq-a-density-shadow: none;--nq-a-tabbar-height: 70px;--nq-a-tap-min: 44px;--nq-a-radius-pill: 9999px;--nq-a-radius-sheet: 20px 20px 0 0;--nq-a-sheet-shadow: 0 -8px 28px rgba(9,9,11,.18);--nq-a-progress-height: 6px;--nq-ease-out: cubic-bezier(0, 0, .2, 1);--nq-dur-micro: .12s;--nq-dur-base: .2s;--nq-dur-modal: .35s}"; +class un extends A { + static properties = { + hass: { attribute: !1 }, + _config: { state: !0 } + }; + static styles = [v(hn)]; + hass; + _config; + setConfig(t) { + if (!t || typeof t != "object") + throw new Error("Invalid configuration"); + this._config = t; + } + getCardSize() { + return 8; + } + render() { + return d`
NestQuest Admin
`; + } +} +customElements.define("nestquest-admin-card", un); +at({ + type: "nestquest-admin-card", + name: "NestQuest Admin", + description: "Parent phone view for today, tasks, schedule, and history." +}); +const fn = "/nestquest-static/nestquest-fonts.css"; +if (!document.querySelector('link[data-nq-fonts=""]')) { + const s = document.createElement("link"); + s.rel = "stylesheet", s.href = fn, s.dataset.nqFonts = "", document.head.appendChild(s); +} diff --git a/custom_components/nestquest/www/nestquest-fonts.css b/custom_components/nestquest/www/nestquest-fonts.css new file mode 100644 index 0000000..72382dd --- /dev/null +++ b/custom_components/nestquest/www/nestquest-fonts.css @@ -0,0 +1,71 @@ +@font-face { + font-family: "Nunito"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("./fonts/nunito-latin-400-normal.woff2") format("woff2"); +} + +@font-face { + font-family: "Nunito"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("./fonts/nunito-latin-600-normal.woff2") format("woff2"); +} + +@font-face { + font-family: "Nunito"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("./fonts/nunito-latin-700-normal.woff2") format("woff2"); +} + +@font-face { + font-family: "Nunito"; + font-style: normal; + font-weight: 800; + font-display: swap; + src: url("./fonts/nunito-latin-800-normal.woff2") format("woff2"); +} + +@font-face { + font-family: "Cinzel"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("./fonts/cinzel-latin-600-normal.woff2") format("woff2"); +} + +@font-face { + font-family: "Cinzel"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("./fonts/cinzel-latin-700-normal.woff2") format("woff2"); +} + +@font-face { + font-family: "Cinzel"; + font-style: normal; + font-weight: 900; + font-display: swap; + src: url("./fonts/cinzel-latin-900-normal.woff2") format("woff2"); +} + +@font-face { + font-family: "Cinzel Decorative"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("./fonts/cinzel-decorative-latin-700-normal.woff2") format("woff2"); +} + +@font-face { + font-family: "Cinzel Decorative"; + font-style: normal; + font-weight: 900; + font-display: swap; + src: url("./fonts/cinzel-decorative-latin-900-normal.woff2") format("woff2"); +} diff --git a/custom_components/nestquest/www/nestquest-panel-tokens.css b/custom_components/nestquest/www/nestquest-panel-tokens.css new file mode 100644 index 0000000..9f51ab6 --- /dev/null +++ b/custom_components/nestquest/www/nestquest-panel-tokens.css @@ -0,0 +1,96 @@ +/* ============================================================ + NestQuest — Kids' panel card tokens + Surface: DAKboard DAKOS wall panel, 1920x1080 landscape, + viewed from ~3 ft, no login, read/complete only. + Scope these on the card host: nestquest-quest-log-card, + nestquest-party-board-card. + Derived from the approved mockups (panel screens 1a, 1b, 1c, 1j). + ============================================================ */ + +:host { + /* ── Parchment ground ─────────────────────────────────── */ + --nq-p-parchment-top: #f0e4c7; + --nq-p-parchment-bottom: #ddcca3; + --nq-p-parchment: radial-gradient(ellipse at 25% 10%, rgba(255,255,255,.5), transparent 55%), + radial-gradient(ellipse at 85% 90%, rgba(120,86,44,.28), transparent 60%), + repeating-linear-gradient(93deg, rgba(150,115,70,.05) 0 2px, transparent 2px 6px), + repeating-linear-gradient(2deg, rgba(150,115,70,.04) 0 3px, transparent 3px 7px), + linear-gradient(var(--nq-p-parchment-top), var(--nq-p-parchment-bottom)); + --nq-p-vignette: inset 0 0 200px rgba(80,52,20,.35); + + /* Card surfaces sitting on parchment */ + --nq-p-card-open: linear-gradient(#fdf7e6, #f2e7c9); /* incomplete quest */ + --nq-p-card-done: linear-gradient(#f0e8d3, #e6dcc2); /* sealed quest */ + --nq-p-card-panel: linear-gradient(#fcf6e6, #f1e6ca); /* adventurer plate, stat block */ + --nq-p-card-border: rgba(120,88,48,.38); + --nq-p-panel-border: rgba(92,62,26,.40); + --nq-p-card-shadow: 0 3px 0 rgba(120,88,48,.20), inset 0 1px 0 rgba(255,255,255,.70); + --nq-p-panel-shadow: 0 6px 0 rgba(92,62,26,.18), inset 0 2px 0 rgba(255,255,255,.70); + --nq-p-frame-outer: 2px solid rgba(92,62,26,.45); + --nq-p-frame-inner: 1px solid rgba(92,62,26,.28); + --nq-p-rule: 2px solid rgba(92,62,26,.35); + + /* ── Ink (all values >= 4.5:1 on parchment) ───────────── */ + --nq-p-ink: #2b1f14; /* titles, quest names */ + --nq-p-ink-secondary: #5c452a; /* meta, window labels, counts */ + --nq-p-ink-muted: #6f6455; /* sealed quest titles only */ + --nq-p-ink-away: #4d4433; /* away adventurer name */ + --nq-p-ink-late: #8f1526; /* overdue line */ + + /* ── Brand (CubeCraft, toned down: accents only) ──────── */ + --nq-brand-blue: #3B3AB8; + --nq-brand-purple: #A035CC; + --nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple)); + --nq-p-icon-tile: var(--nq-brand-gradient); /* open quest icon tile */ + --nq-p-icon-tile-done: rgba(92,62,26,.16); + --nq-p-crest: var(--nq-brand-gradient); + --nq-p-crest-away: linear-gradient(135deg, #6b6b7a, #7a7286); + + /* ── Wax seal (completed marker) ──────────────────────── */ + --nq-p-seal: radial-gradient(circle at 35% 30%, #a8283a, #6d1322); + --nq-p-seal-shadow: 0 4px 10px rgba(60,10,20,.40), inset 0 0 0 4px rgba(255,255,255,.14); + --nq-p-seal-size: 78px; /* 168px on the quest-complete screen */ + + /* ── Dock (bottom strip, non-interactive) ─────────────── */ + --nq-p-dock-bg: rgba(43,31,20,.90); + --nq-p-dock-ink: #f7efdb; + --nq-p-dock-ink-secondary: #d8c9a6; + --nq-p-dock-divider: rgba(233,220,189,.30); + --nq-p-dock-height: 84px; + + /* ── Type ─────────────────────────────────────────────── */ + --nq-p-font-display: 'Cinzel Decorative', Cinzel, serif; /* NestQuest, names, Quest complete */ + --nq-p-font-heading: Cinzel, serif; /* section heads, quest titles, numerals */ + --nq-p-font-body: Nunito, system-ui, sans-serif; /* meta lines, dock */ + + --nq-p-size-hero: 86px; /* 900 weight, "Quest complete" */ + --nq-p-size-wordmark: 66px; /* 900 weight */ + --nq-p-size-title: 56px; /* 700, "Declan's Quest Log" */ + --nq-p-size-name: 48px; /* 700, adventurer name */ + --nq-p-size-section: 32px; /* 700, Morning / Afternoon / Evening */ + --nq-p-size-quest: 30px; /* 700, quest title */ + --nq-p-size-body: 22px; /* 600 Nunito, meta — PANEL MINIMUM */ + --nq-p-size-label: 21px; /* 600 Cinzel, uppercase, tracked */ + --nq-p-track-label: .14em; + --nq-p-track-kicker: .30em; + + /* ── Metrics: touch ───────────────────────────────────── */ + --nq-p-quest-min-height: 116px; /* whole card is the tap target */ + --nq-p-quest-gap: 24px; /* separation between tap targets */ + --nq-p-quest-pad: 22px; /* padding inside a quest card */ + --nq-p-tile-size: 64px; /* quest icon tile */ + --nq-p-button-height: 72px; /* "Complete" button on a quest card */ + --nq-p-button-min-width: 150px; + --nq-p-confirm-button: 108px; /* confirm dialog buttons */ + --nq-p-radius-card: 12px; + --nq-p-radius-panel: 20px; + --nq-p-radius-pill: 9999px; + --nq-p-page-inset: 62px; + --nq-p-frame-inset: 26px; + + /* ── Motion (CubeCraft easing) ────────────────────────── */ + --nq-ease-out: cubic-bezier(0, 0, .2, 1); + --nq-dur-micro: 120ms; + --nq-dur-base: 200ms; + --nq-dur-modal: 350ms; +} diff --git a/design/ENTITIES-AND-SERVICES.md b/design/ENTITIES-AND-SERVICES.md index 798926b..71fce72 100644 --- a/design/ENTITIES-AND-SERVICES.md +++ b/design/ENTITIES-AND-SERVICES.md @@ -90,6 +90,7 @@ child_order: [declan, jordyn, chloe] # kids' panel, view 2 type: custom:nestquest-quest-log-card board_path: /nestquest/board +weather_entity: weather.home # optional; dock falls back to date/time idle_return_seconds: 40 # must be shorter than TouchHub Auto-Return confirm_timeout_seconds: 15 complete_screen_seconds: 12 diff --git a/design/PANEL-EMPTY-STATES.md b/design/PANEL-EMPTY-STATES.md new file mode 100644 index 0000000..87bc76f --- /dev/null +++ b/design/PANEL-EMPTY-STATES.md @@ -0,0 +1,104 @@ +# Panel empty & error states — design addendum + +Closes the gap DESIGN-HANDOFF.md lists under "Open questions": *"Empty and error +states are unspecified by request (child with no quests, everyone away, integration +unconfigured, DB unreachable). Panel must not render a blank parchment — decide +before shipping the card."* + +All decisions below reuse PANEL-SPEC.md's approved aesthetic — parchment ground, +double frame, dock, panel tokens, no text below 22px, no alpha-muted ink — and add +one shared component per card: a **notice panel** (radius 20px, `--nq-p-card-panel`, +2px `--nq-p-panel-border`, `--nq-p-panel-shadow`, centred icon + headline + body). +The frame, wordmark row, kicker and dock always render; only the content area +changes. **No state renders a blank parchment.** + +## 1. What the data can say + +The cards read `sensor.nestquest__quests_due_today` (plus siblings). One of +four things comes back, and the card must tell them apart: + +| Backend condition | What the card sees | Card's interpretation | +|---|---|---| +| Resolving (DB fine) | numeric state + attributes (`child_name`, `present`, `instances`) | normal, all states below | +| Integration not configured | entity does not exist at all | **not set up** | +| Backend unreachable (DB locked, refresh failed) | state `unavailable` | **records unreachable** | +| Child deactivated / absent from snapshot | state `unknown` | **records unreachable** (same panel voice: the records do not answer) | + +An away child (presence `present: false`, from the due-sensor attributes with the +presence binary sensor as fallback) is *not* an error: it is a first-class panel +state ("On travels"). Away children get no instances (materialiser skips them), so +away is detected from the presence flag, never from a zero quest count. + +## 2. Quest log — state priority + +`nestquest-quest-log-card` resolves exactly one state, in this order: + +1. **No adventurer in the path** (direct navigation to a URL with no child slug — + misconfigured `board_path`/`quest_log_path`) → notice *"No adventurer chosen"*, + body *"Open The Party and tap your crest to open your quest log."* +2. **Not set up** (entity missing) → notice *"NestQuest is not set up yet"*, body + *"A parent needs to finish setting up NestQuest."* +3. **Records unreachable** (`unavailable`/`unknown`) → notice *"The records cannot + be reached"*, body *"The party's records are quiet right now. NestQuest will + return shortly."* +4. **Away** (present flag false) → away notice: dashed muted panel (the away-plate + gradient), tent icon, *" is on travels"*, *"The quest log unlocks when + they return."* and, when `next_present` is known, *"Returns , "*. + Direct navigation is the one path that can reach this state (the board's away + plate is not tappable); the log answers it honestly instead of showing an empty + column set. Header renders with the crest in away colours and the sub line + `… · On travels`; the remaining-count block is hidden (it is meaningless while + away). +5. **No quests today** (present, due = 0) → all-clear notice, sun icon, *"No quests + today"*, body *"Nothing was posted for today, . Enjoy the day's rest!"* — + never three bare empty columns. The header and remaining block stay (0 quests + left, party progress), so the log still reads like the log. +6. **All quests done** (due > 0, remaining = 0) → the fallback celebration that + shows until the 1c quest-complete screen takes over: centred wax-seal mark (the + `--nq-p-seal` tokens at 78px, check glyph, −6°) with headline *"Quest complete"* + — the same phrase screen 1c uses — and body *"Every quest is claimed, . + The seal is set for today. Returning to The Party shortly."* The idle return + (§3.5) is what actually navigates back; 1c remains the designed takeover. +7. **Normal** → the three-window column layout of PANEL-SPEC §3.1 unchanged. A + window with no quests still renders its head alone, exactly as the spec says. + +Notices sit in the columns' content area, vertically centred. Error notices (states +1–3) replace the header entirely — there is no honest name to headline when the +records do not answer — while states 4–6 keep the full header. + +## 3. Party board — state priority + +`nestquest-party-board-card` resolves per configured child, then at board level: + +1. **No children configured** (`child_order` empty) → board notice *"NestQuest is + not set up yet"*, body *"A parent needs to finish setting up NestQuest before + the party can gather."* +2. **Every configured child unresolvable** → one board notice instead of a grid of + broken plates: *"NestQuest is not set up yet"* when the entities are missing, + *"The records cannot be reached"* when they answer `unavailable`/`unknown`. +3. **Partial** (some children resolve, some do not) → the board still renders the + resolving plates; an unresolvable child renders a muted, dashed plate in the + away geometry with a compass icon and the pill label **"Unknown"**, progress + line `—`, empty bar, **not tappable**. It must not claim "Home today" (0 of 0) + and must not lie "On travels": the records simply do not say. +4. **Normal** → the plates grid of PANEL-SPEC §2 unchanged (present + away plates). + +The hint line ("Tap your crest…") is hidden in board-level notice states — there +is nothing to tap. + +## 4. Shared rules + +- **Never a blank parchment.** Every render path emits the frames, wordmark, + kicker, dock, and either content or a notice panel. The dock already degrades to + date + time when weather is unset (PANEL-SPEC §2.3); that behaviour is unchanged. +- **Notice panel type scale:** icon 46px, headline 40px/700 Cinzel + (`--nq-p-font-heading`, `--nq-p-ink`), body 24px/600 Nunito + (`--nq-p-ink-secondary`), `role="status"` so assistive tech announces it. +- **Tone.** The panel speaks tabletop (PANEL-SPEC vocabulary) but never lies: an + error says the records cannot be reached, not that everything is fine. Error + copy names the actor who can fix it (a parent) or promises the return of the + data — no stack traces, no entity IDs. +- **No interaction in error/away states.** No confirm dialog, no idle return + changes, no navigation attempts. Notices are static. +- Both cards implement the same resolution rules, so the board and the log never + disagree about whether the integration is up. diff --git a/design/tokens/nestquest-panel-tokens.css b/design/tokens/nestquest-panel-tokens.css index f997486..9f51ab6 100644 --- a/design/tokens/nestquest-panel-tokens.css +++ b/design/tokens/nestquest-panel-tokens.css @@ -77,6 +77,8 @@ /* ── Metrics: touch ───────────────────────────────────── */ --nq-p-quest-min-height: 116px; /* whole card is the tap target */ --nq-p-quest-gap: 24px; /* separation between tap targets */ + --nq-p-quest-pad: 22px; /* padding inside a quest card */ + --nq-p-tile-size: 64px; /* quest icon tile */ --nq-p-button-height: 72px; /* "Complete" button on a quest card */ --nq-p-button-min-width: 150px; --nq-p-confirm-button: 108px; /* confirm dialog buttons */ diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..2a21a54 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1232 @@ +{ + "name": "nestquest-cards", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nestquest-cards", + "dependencies": { + "lit": "^3.3.0" + }, + "devDependencies": { + "typescript": "^5.9.2", + "vite": "^7.1.5" + } + }, + "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/@lit-labs/ssr-dom-shim": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz", + "integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@lit/reactive-element": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz", + "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0" + } + }, + "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/@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/@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/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "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/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/lit": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz", + "integrity": "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^2.1.0", + "lit-element": "^4.2.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-element": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz", + "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0", + "@lit/reactive-element": "^2.1.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-html": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.3.tgz", + "integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, + "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/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/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/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/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/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/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 + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7e6dc9b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,15 @@ +{ + "name": "nestquest-cards", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + }, + "dependencies": { + "lit": "^3.3.0" + }, + "devDependencies": { + "typescript": "^5.9.2", + "vite": "^7.1.5" + } +} diff --git a/frontend/src/cards/admin.ts b/frontend/src/cards/admin.ts new file mode 100644 index 0000000..580dd78 --- /dev/null +++ b/frontend/src/cards/admin.ts @@ -0,0 +1,38 @@ +import { LitElement, html, unsafeCSS } from "lit"; +import adminTokens from "../../../custom_components/nestquest/www/nestquest-admin-tokens.css?inline"; + +import { registerCustomCard, type CardConfig, type HassLike } from "../types"; + +export class NestQuestAdminCard extends LitElement { + static properties = { + hass: { attribute: false }, + _config: { state: true }, + }; + + static styles = [unsafeCSS(adminTokens)]; + + hass?: HassLike; + _config?: CardConfig; + + setConfig(config: CardConfig): void { + if (!config || typeof config !== "object") { + throw new Error("Invalid configuration"); + } + this._config = config; + } + + getCardSize(): number { + return 8; + } + + render() { + return html`
NestQuest Admin
`; + } +} + +customElements.define("nestquest-admin-card", NestQuestAdminCard); +registerCustomCard({ + type: "nestquest-admin-card", + name: "NestQuest Admin", + description: "Parent phone view for today, tasks, schedule, and history.", +}); diff --git a/frontend/src/cards/party-board.ts b/frontend/src/cards/party-board.ts new file mode 100644 index 0000000..d012872 --- /dev/null +++ b/frontend/src/cards/party-board.ts @@ -0,0 +1,1174 @@ +import { LitElement, css, html, nothing, unsafeCSS, type TemplateResult } from "lit"; +import panelTokens from "../../../custom_components/nestquest/www/nestquest-panel-tokens.css?inline"; + +import { registerCustomCard, type CardConfig, type HassLike } from "../types"; + +type StateObject = { + state: unknown; + attributes?: Record | null; +}; + +type HassConnection = { + subscribeEvents: ( + callback: (event: unknown) => void, + eventType: string + ) => Promise<() => void>; +}; + +const NESTQUEST_EVENT_TYPES = [ + "nestquest_quest_completed", + "nestquest_quest_uncompleted", + "nestquest_quest_missed", + "nestquest_child_day_complete", +]; + +type ChildPlate = { + slug: string; + name: string; + initial: string; + present: boolean; + completed: number; + due: number; + pct: number; + returns: string | null; + /** Set when the child's sensors cannot resolve; the plate shows "Unknown". */ + unresolved: "missing" | "stale" | null; +}; + +type BoardNotice = { + icon: TemplateResult; + headline: string; + body: string; +}; + +type DockWeather = { + condition: string; + temperature: number | null; + high: number | null; + low: number | null; +}; + +const FORMATTER_CACHE = new Map(); + +function zonedFormatter( + timeZone: string | undefined, + options: Intl.DateTimeFormatOptions +): Intl.DateTimeFormat { + const key = `${timeZone ?? "local"}|${JSON.stringify(options)}`; + const cached = FORMATTER_CACHE.get(key); + if (cached) { + return cached; + } + let formatter: Intl.DateTimeFormat; + try { + formatter = new Intl.DateTimeFormat("en-US", { + ...options, + ...(timeZone ? { timeZone } : {}), + }); + } catch { + formatter = new Intl.DateTimeFormat("en-US", options); + } + FORMATTER_CACHE.set(key, formatter); + return formatter; +} + +function hassTimeZone(hass: HassLike | undefined): string | undefined { + const config = hass?.config; + if (!config || typeof config !== "object") { + return undefined; + } + const timeZone = (config as Record).time_zone; + return typeof timeZone === "string" && timeZone.trim() + ? timeZone.trim() + : undefined; +} + +function formatDay(date: Date, timeZone: string | undefined): string { + return `${zonedFormatter(timeZone, { weekday: "long" }).format(date)}, ${zonedFormatter(timeZone, { month: "short", day: "numeric" }).format(date)}`; +} + +function formatTime(date: Date, timeZone: string | undefined): string { + return zonedFormatter(timeZone, { + hour: "numeric", + minute: "2-digit", + hour12: true, + }).format(date); +} + +const CONDITION_LABELS: Record = { + "clear-night": "Clear", + cloudy: "Cloudy", + exceptional: "Clear", + fog: "Foggy", + hail: "Hail", + lightning: "Storms", + "lightning-rimmed": "Storms", + partlycloudy: "Partly cloudy", + pouring: "Heavy rain", + rainy: "Rain", + snowy: "Snow", + "snowy-rainy": "Sleet", + sunny: "Sunny", + windy: "Windy", + "windy-variant": "Windy", +}; + +const CONDITION_PHRASES: Record = { + "clear-night": "Clear night skies", + cloudy: "Grey skies today", + exceptional: "A striking day", + fog: "Mist on the road", + hail: "Ice from the sky", + lightning: "Storms may roll in", + "lightning-rimmed": "Storms may roll in", + partlycloudy: "Sun between clouds", + pouring: "Heavy rain outside", + rainy: "Rain on the walls", + snowy: "Snow on the peaks", + "snowy-rainy": "Sleet may fall", + sunny: "Clear skies today", + windy: "A blustery day", + "windy-variant": "A blustery day", +}; + +function asString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function asNumber(value: unknown, fallback: number): number { + if (value === null || value === undefined || value === "") { + return fallback; + } + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function clampPercent(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Math.min(100, Math.max(0, value)); +} + +function clampCount(value: number): number { + return Math.max(0, Math.round(value)); +} + +function titleCaseSlug(slug: string): string { + return slug + .split(/[-_]+/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +function zoneOffsetMs(instant: number, timeZone: string): number { + const parts = zonedFormatter(timeZone, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }).formatToParts(new Date(instant)); + const read = (type: Intl.DateTimeFormatPartTypes): number => { + const part = parts.find((entry) => entry.type === type); + return part ? Number(part.value) : Number.NaN; + }; + const asUtc = Date.UTC( + read("year"), + read("month") - 1, + read("day"), + read("hour"), + read("minute"), + read("second") + ); + return Number.isFinite(asUtc) ? asUtc - instant : 0; +} + +function parseIsoDate(value: unknown, timeZone?: string): Date | null { + if (typeof value !== "string") { + return null; + } + const parts = value.split("-"); + if (parts.length !== 3) { + return null; + } + const [year, month, day] = parts.map((part) => Number(part)); + if (!year || !month || !day) { + return null; + } + if (!timeZone) { + const local = new Date(year, month - 1, day); + return Number.isNaN(local.getTime()) ? null : local; + } + const utcMs = Date.UTC(year, month - 1, day); + const date = new Date(utcMs - zoneOffsetMs(utcMs, timeZone)); + return Number.isNaN(date.getTime()) ? null : date; +} + +function conditionLabel(condition: string): string { + const known = CONDITION_LABELS[condition]; + if (known) { + return known; + } + return condition.charAt(0).toUpperCase() + condition.slice(1); +} + +function conditionPhrase(condition: string, label: string): string { + return CONDITION_PHRASES[condition] ?? label; +} + +const SHIELD_CLIP = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)"; +const HEXAGON_CLIP = + "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)"; + +const boardStyles = css` + * { + box-sizing: border-box; + } + + :host { + display: block; + height: 100%; + --nq-brand-gradient-h: linear-gradient( + 90deg, + var(--nq-brand-blue), + var(--nq-brand-purple) + ); + } + + .board { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + height: 1080px; + padding: 0; + overflow: hidden; + background: var(--nq-p-parchment); + box-shadow: var(--nq-p-vignette); + font-family: var(--nq-p-font-body); + } + + .frame { + position: absolute; + pointer-events: none; + } + + .frame-outer { + inset: 26px; + border: var(--nq-p-frame-outer); + border-radius: 14px; + } + + .frame-inner { + inset: 36px; + border: var(--nq-p-frame-inner); + border-radius: 8px; + } + + .wordmark-row { + position: absolute; + top: 64px; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 26px; + width: 100%; + } + + .rule { + flex: 1 1 0; + max-width: 340px; + height: 2px; + border-radius: 1px; + background: rgba(92, 62, 26, 0.35); + } + + .d20 { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + clip-path: ${unsafeCSS(HEXAGON_CLIP)}; + background: var(--nq-brand-gradient); + } + + .d20-numeral { + font-family: var(--nq-p-font-heading); + font-size: 15px; + font-weight: 700; + line-height: 1; + color: #ffffff; + } + + .wordmark { + font-family: var(--nq-p-font-display); + font-size: var(--nq-p-size-wordmark); + font-weight: 900; + line-height: 1.15; + background: var(--nq-brand-gradient); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; + } + + .kicker { + position: absolute; + top: 154px; + left: 0; + right: 0; + font-family: var(--nq-p-font-heading); + font-size: 22px; + font-weight: 600; + letter-spacing: var(--nq-p-track-kicker); + text-transform: uppercase; + line-height: 1.2; + text-align: center; + color: var(--nq-p-ink-secondary); + } + + .plates { + position: absolute; + top: 262px; + left: 110px; + right: 110px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 56px; + } + + .plate { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 26px; + height: 600px; + padding: 44px 28px 40px; + border: 2px solid var(--nq-p-panel-border); + border-radius: var(--nq-p-radius-panel); + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + font: inherit; + } + + button.plate { + width: 100%; + color: inherit; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform var(--nq-dur-micro) var(--nq-ease-out); + } + + button.plate:active { + transform: scale(0.98); + } + + button.plate:focus-visible { + outline: 3px solid var(--nq-brand-purple); + outline-offset: 4px; + } + + .plate.away { + border: 2px dashed var(--nq-p-panel-border); + background: linear-gradient(#f2ecdd, #e6dcc6); + box-shadow: none; + } + + .crest { + position: relative; + flex: none; + width: 188px; + height: 214px; + padding: 4px; + clip-path: ${unsafeCSS(SHIELD_CLIP)}; + background: rgba(255, 255, 255, 0.22); + } + + .crest-face { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding-bottom: 70px; + clip-path: ${unsafeCSS(SHIELD_CLIP)}; + background: var(--nq-p-crest); + } + + .crest.away .crest-face { + background: var(--nq-p-crest-away); + } + + .crest .initial { + font-family: var(--nq-p-font-display); + font-size: 82px; + font-weight: 700; + line-height: 1; + color: #ffffff; + } + + .name { + font-family: var(--nq-p-font-heading); + font-size: var(--nq-p-size-name); + font-weight: 700; + letter-spacing: 0.02em; + line-height: 1.1; + color: var(--nq-p-ink); + } + + .plate.away .name { + color: var(--nq-p-ink-away); + } + + .pill { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 8px 20px; + border-radius: var(--nq-p-radius-pill); + background: rgba(22, 120, 60, 0.35); + font-family: var(--nq-p-font-heading); + font-size: 22px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + line-height: 1; + color: #146b36; + } + + .pill svg { + width: 22px; + height: 22px; + color: #157a3c; + } + + .pill.away { + background: rgba(92, 62, 26, 0.16); + color: var(--nq-p-ink-away); + } + + .pill.away svg { + color: var(--nq-p-ink-away); + } + + .progress-line { + font-family: var(--nq-p-font-heading); + font-size: 24px; + font-weight: 700; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .bar { + width: 100%; + height: 16px; + border: 1px solid rgba(92, 62, 26, 0.35); + border-radius: var(--nq-p-radius-pill); + background: rgba(92, 62, 26, 0.18); + overflow: hidden; + } + + .bar .fill { + display: block; + height: 100%; + border-radius: inherit; + background: var(--nq-brand-gradient-h); + } + + .hint { + position: absolute; + top: 930px; + left: 0; + right: 0; + font-family: var(--nq-p-font-body); + font-size: 24px; + font-weight: 600; + line-height: 1.4; + text-align: center; + color: var(--nq-p-ink-secondary); + } + + .plate.unknown { + border: 2px dashed var(--nq-p-panel-border); + background: linear-gradient(#f2ecdd, #e6dcc6); + box-shadow: none; + } + + .notice-wrap { + position: absolute; + top: 262px; + left: 110px; + right: 110px; + bottom: 190px; + display: flex; + align-items: center; + justify-content: center; + } + + .notice { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 20px; + width: 100%; + max-width: 980px; + padding: 56px 64px; + border: 2px solid var(--nq-p-panel-border); + border-radius: var(--nq-p-radius-panel); + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + text-align: center; + } + + .notice svg { + width: 46px; + height: 46px; + color: var(--nq-p-ink-secondary); + } + + .notice-headline { + font-family: var(--nq-p-font-heading); + font-size: 40px; + font-weight: 700; + line-height: 1.15; + color: var(--nq-p-ink); + } + + .notice-body { + margin: 0; + font-family: var(--nq-p-font-body); + font-size: 24px; + font-weight: 600; + line-height: 1.4; + color: var(--nq-p-ink-secondary); + } + + .spacer { + flex: 1 1 0; + } + + .dock { + position: absolute; + left: 46px; + right: 46px; + bottom: 46px; + height: var(--nq-p-dock-height); + display: flex; + align-items: center; + justify-content: center; + gap: 24px; + padding: 0 40px; + border-radius: 14px; + background: var(--nq-p-dock-bg); + pointer-events: none; + user-select: none; + color: var(--nq-p-dock-ink); + } + + .dock svg { + flex: none; + width: 40px; + height: 40px; + color: var(--nq-p-dock-ink); + } + + .dock .divider { + flex: none; + width: 1px; + height: 40px; + background: var(--nq-p-dock-divider); + } + + .dock .temp { + font-family: var(--nq-p-font-body); + font-size: 38px; + font-weight: 800; + line-height: 1; + } + + .dock .condition { + font-family: var(--nq-p-font-body); + font-size: 26px; + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-dock-ink-secondary); + } + + .dock .date { + font-family: var(--nq-p-font-body); + font-size: 26px; + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-dock-ink-secondary); + } + + .dock .clock { + font-family: var(--nq-p-font-body); + font-size: 38px; + font-weight: 800; + line-height: 1; + } +`; + +const ICON_CHECK_CIRCLE = html``; + +const ICON_TENT = html``; + +const ICON_COMPASS = html``; + +const ICON_ALERT = html``; + +const ICON_CLOUD_OFF = html``; + +const BOARD_NOTICE_NOT_SET_UP: BoardNotice = { + icon: ICON_ALERT, + headline: "NestQuest is not set up yet", + body: "A parent needs to finish setting up NestQuest before the party can gather.", +}; + +const BOARD_NOTICE_UNREACHABLE: BoardNotice = { + icon: ICON_CLOUD_OFF, + headline: "The records cannot be reached", + body: "The party's records are quiet right now. NestQuest will return shortly.", +}; + +const ICON_CLOUD_SUN = html``; + +export class NestQuestPartyBoardCard extends LitElement { + static properties = { + hass: { attribute: false }, + _config: { state: true }, + _now: { state: true }, + }; + + static styles = [unsafeCSS(panelTokens), boardStyles]; + + hass?: HassLike; + _config?: CardConfig; + _now = new Date(); + private _clockTimer?: number; + private _unsubs: Array<() => void> = []; + private _subscribed = false; + private _subGeneration = 0; + private _retryTimer?: number; + + setConfig(config: CardConfig): void { + if (!config || typeof config !== "object") { + throw new Error("Invalid configuration"); + } + this._config = config; + } + + private _hassConnection(): HassConnection | null { + const connection = (this.hass as { connection?: unknown } | undefined) + ?.connection; + if ( + !connection || + typeof connection !== "object" || + typeof (connection as HassConnection).subscribeEvents !== "function" + ) { + return null; + } + return connection as HassConnection; + } + + private _subscribeLive(): void { + if (!this.isConnected) { + return; + } + if (this._subscribed) { + return; + } + const connection = this._hassConnection(); + if (!connection) { + return; + } + this._subscribed = true; + const token = ++this._subGeneration; + const track = (unsubPromise: Promise<() => void>): void => { + unsubPromise + .then((unsub) => { + if (this._subscribed && token === this._subGeneration) { + this._unsubs.push(unsub); + } else { + unsub(); + } + }) + .catch(() => { + if (token === this._subGeneration) { + this._unsubscribeLive(); + this._armSubscribeRetry(); + } + }); + }; + for (const eventType of NESTQUEST_EVENT_TYPES) { + track( + connection.subscribeEvents(() => this.requestUpdate(), eventType) + ); + } + } + + private _armSubscribeRetry(): void { + if (this._retryTimer !== undefined) { + return; + } + this._retryTimer = window.setTimeout(() => { + this._retryTimer = undefined; + this._subscribeLive(); + }, 1000); + } + + private _clearSubscribeRetry(): void { + if (this._retryTimer !== undefined) { + window.clearTimeout(this._retryTimer); + this._retryTimer = undefined; + } + } + + private _unsubscribeLive(): void { + this._clearSubscribeRetry(); + this._subGeneration++; + for (const unsub of this._unsubs) { + unsub(); + } + this._unsubs = []; + this._subscribed = false; + } + + getCardSize(): number { + return 22; + } + + connectedCallback(): void { + super.connectedCallback(); + this._subscribeLive(); + this._now = new Date(); + this._clockTimer = window.setInterval(() => { + this._now = new Date(); + }, 1000); + } + + protected updated(): void { + this._subscribeLive(); + } + + disconnectedCallback(): void { + this._unsubscribeLive(); + if (this._clockTimer !== undefined) { + window.clearInterval(this._clockTimer); + this._clockTimer = undefined; + } + super.disconnectedCallback(); + } + + render() { + const notice = this._boardNotice(); + return html` +
+
+
+
+ + + NestQuest + + +
+

The Party · ${formatDay(this._now, this._timeZone())}

+ ${notice + ? this._renderNotice(notice) + : html` +
+ ${this._plates().map((plate) => this._renderPlate(plate))} +
+

Tap your crest to open your Quest Log

+ `} +
+ ${this._renderDock()} +
+ `; + } + + private _timeZone(): string | undefined { + return hassTimeZone(this.hass); + } + + private _state(entityId: string): StateObject | null { + const states = this.hass?.states as + | Record + | undefined; + const stateObj = states?.[entityId]; + return stateObj && typeof stateObj === "object" ? stateObj : null; + } + + private _childSlugs(): string[] { + if (!this._config) { + return []; + } + const order = this._config.child_order; + if (!Array.isArray(order)) { + return []; + } + return order + .filter( + (entry): entry is string => + typeof entry === "string" && entry.trim().length > 0 + ) + .map((entry) => entry.trim()); + } + + private _plates(): ChildPlate[] { + return this._childSlugs().map((slug) => this._plate(slug)); + } + + private _plate(slug: string): ChildPlate { + const unresolved = this._dueSensorResolution(slug); + const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`); + const doneSensor = this._state( + `sensor.nestquest_${slug}_quests_completed_today` + ); + const pctSensor = this._state( + `sensor.nestquest_${slug}_completion_pct_today` + ); + const presence = this._state( + `binary_sensor.nestquest_${slug}_present_today` + ); + const dueAttrs = dueSensor?.attributes ?? {}; + + const due = clampCount(asNumber(dueSensor?.state, 0)); + const completed = clampCount(asNumber(doneSensor?.state, 0)); + const rawPct = pctSensor + ? asNumber(pctSensor.state, Number.NaN) + : Number.NaN; + const pct = Number.isFinite(rawPct) + ? clampPercent(rawPct) + : due > 0 + ? clampPercent((completed / due) * 100) + : 0; + + let present = true; + const presentAttr = dueAttrs.present; + if (typeof presentAttr === "boolean") { + present = presentAttr; + } else if (presence) { + present = String(presence.state ?? "").trim().toLowerCase() === "on"; + } + + let name = ""; + const dueName = dueAttrs.child_name; + if (typeof dueName === "string" && dueName.trim()) { + name = dueName.trim(); + } else { + const presenceName = presence?.attributes?.child_name; + if (typeof presenceName === "string" && presenceName.trim()) { + name = presenceName.trim(); + } + } + if (!name) { + name = titleCaseSlug(slug); + } + + const returnsDate = parseIsoDate( + presence?.attributes?.next_present, + this._timeZone() + ); + const returns = returnsDate + ? `Returns ${formatDay(returnsDate, this._timeZone())}` + : null; + + return { + slug, + name, + initial: (name.charAt(0) || "?").toUpperCase(), + present: unresolved === null ? present : false, + completed, + due, + pct, + returns, + unresolved, + }; + } + + /** "missing" when the due sensor does not exist (integration not + * configured), "stale" when it answers unavailable/unknown (backend + * unreachable or the child is absent from the snapshot), null when + * it resolves. */ + private _dueSensorResolution(slug: string): "missing" | "stale" | null { + const sensor = this._state(`sensor.nestquest_${slug}_quests_due_today`); + if (!sensor) { + return "missing"; + } + const state = String(sensor.state ?? "").trim().toLowerCase(); + if (!state || state === "unavailable" || state === "unknown") { + return "stale"; + } + return null; + } + + private _boardNotice(): BoardNotice | null { + const plates = this._plates(); + if (plates.length === 0) { + return BOARD_NOTICE_NOT_SET_UP; + } + if (plates.every((plate) => plate.unresolved !== null)) { + return plates.some((plate) => plate.unresolved === "stale") + ? BOARD_NOTICE_UNREACHABLE + : BOARD_NOTICE_NOT_SET_UP; + } + return null; + } + + private _renderNotice(notice: BoardNotice) { + return html` +
+
+ ${notice.icon} + ${notice.headline} +

${notice.body}

+
+
+ `; + } + + private _renderPlate(plate: ChildPlate) { + if (plate.unresolved) { + return html` +
+ + + ${plate.initial} + + + ${plate.name} + + ${ICON_COMPASS} + Unknown + + — + +
+ `; + } + if (plate.present) { + return html` + + `; + } + return html` +
+ + + ${plate.initial} + + + ${plate.name} + + ${ICON_TENT} + On travels + + ${plate.returns ?? "Returns —"} + +
+ `; + } + + private _openQuestLog(slug: string): void { + const path = asString(this._config?.quest_log_path); + if (!path) { + return; + } + window.history.pushState(null, "", `${path.replace(/\/+$/, "")}/${slug}`); + window.dispatchEvent(new Event("location-changed")); + } + + private _dockWeather(): DockWeather | null { + const entityId = asString(this._config?.weather_entity); + if (!entityId) { + return null; + } + const stateObj = this._state(entityId); + if (!stateObj) { + return null; + } + const condition = String(stateObj.state ?? "").trim(); + if (!condition || condition === "unavailable" || condition === "unknown") { + return null; + } + const attrs = stateObj.attributes ?? {}; + let high: number | null = null; + let low: number | null = null; + const forecast = attrs.forecast; + if (Array.isArray(forecast) && forecast.length > 0) { + const entry = forecast[0]; + if (entry && typeof entry === "object") { + const typed = entry as Record; + high = this._optionalNumber(typed.temperature); + low = this._optionalNumber(typed.templow); + } + } + return { + condition, + temperature: this._optionalNumber(attrs.temperature), + high, + low, + }; + } + + private _optionalNumber(value: unknown): number | null { + if (value === null || value === undefined || value === "") { + return null; + } + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + + private _renderDock() { + const weather = this._dockWeather(); + if (!weather) { + return html` +
+ ${formatDay(this._now, this._timeZone())} + + ${formatTime(this._now, this._timeZone())} +
+ `; + } + const label = conditionLabel(weather.condition); + const phrase = conditionPhrase(weather.condition, label); + const temperature = + weather.temperature === null + ? nothing + : html`${Math.round(weather.temperature)}°`; + const hiLo = + weather.high === null || weather.low === null + ? null + : `${Math.round(weather.high)}° / ${Math.round(weather.low)}°`; + return html` +
+ ${ICON_CLOUD_SUN} + ${temperature} + + + ${hiLo === null ? label : `${label} · ${hiLo}`} + + + ${phrase} +
+ `; + } +} + +customElements.define("nestquest-party-board-card", NestQuestPartyBoardCard); +registerCustomCard({ + type: "nestquest-party-board-card", + name: "NestQuest Party Board", + description: "Kids' attract screen for present and away adventurers.", +}); diff --git a/frontend/src/cards/quest-log.ts b/frontend/src/cards/quest-log.ts new file mode 100644 index 0000000..fba8929 --- /dev/null +++ b/frontend/src/cards/quest-log.ts @@ -0,0 +1,2463 @@ +import { LitElement, css, html, nothing, unsafeCSS, type TemplateResult } from "lit"; +import { repeat } from "lit/directives/repeat.js"; +import panelTokens from "../../../custom_components/nestquest/www/nestquest-panel-tokens.css?inline"; + +import { registerCustomCard, type CardConfig, type HassLike } from "../types"; + +type QuestLogCardConfig = CardConfig & { + weather_entity?: string; +}; + +type StateObject = { + state: unknown; + attributes?: Record | null; +}; + +type HassConnection = { + subscribeEvents: ( + callback: (event: unknown) => void, + eventType: string + ) => Promise<() => void>; +}; + +const NESTQUEST_EVENT_TYPES = [ + "nestquest_quest_completed", + "nestquest_quest_uncompleted", + "nestquest_quest_missed", + "nestquest_child_day_complete", +]; + +type PanelInstance = { + id: number; + child_id: number | null; + title: string; + icon: string | null; + window: string; + due_time: string | null; + state: string; + overdue: boolean; + completed_at: string | null; + on_time: boolean | null; +}; + +type WindowDef = { + key: string; + name: string; + range: string; + icon: TemplateResult; +}; + +type OtherChild = { + slug: string; + name: string; + present: boolean; + due: number; + completed: number; + returnsWeekday: string | null; +}; + +/** The one state the log resolves before rendering anything; see + * design/PANEL-EMPTY-STATES.md for the decisions behind each kind. */ +type LogView = + | { kind: "no-adventurer" } + | { kind: "not-set-up" } + | { kind: "unreachable" } + | { kind: "away"; name: string; returns: string | null } + | { kind: "empty-day"; name: string } + | { kind: "complete-day"; name: string } + | { kind: "normal" }; + +type LogNotice = { + icon: TemplateResult; + headline: string; + body: string; +}; + +const FORMATTER_CACHE = new Map(); + +function zonedFormatter( + timeZone: string | undefined, + options: Intl.DateTimeFormatOptions +): Intl.DateTimeFormat { + const key = `${timeZone ?? "local"}|${JSON.stringify(options)}`; + const cached = FORMATTER_CACHE.get(key); + if (cached) { + return cached; + } + let formatter: Intl.DateTimeFormat; + try { + formatter = new Intl.DateTimeFormat("en-US", { + ...options, + ...(timeZone ? { timeZone } : {}), + }); + } catch { + formatter = new Intl.DateTimeFormat("en-US", options); + } + FORMATTER_CACHE.set(key, formatter); + return formatter; +} + +function hassTimeZone(hass: HassLike | undefined): string | undefined { + const config = hass?.config; + if (!config || typeof config !== "object") { + return undefined; + } + const timeZone = (config as Record).time_zone; + return typeof timeZone === "string" && timeZone.trim() + ? timeZone.trim() + : undefined; +} + +function formatDay(date: Date, timeZone: string | undefined): string { + return `${zonedFormatter(timeZone, { weekday: "long" }).format(date)}, ${zonedFormatter(timeZone, { month: "short", day: "numeric" }).format(date)}`; +} + +function formatWeekday(date: Date, timeZone: string | undefined): string { + return zonedFormatter(timeZone, { weekday: "long" }).format(date); +} + +function formatInstant(value: string, timeZone: string | undefined): string { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return ""; + } + return zonedFormatter(timeZone, { + hour: "numeric", + minute: "2-digit", + hour12: true, + }).format(parsed); +} + +function formatTime(date: Date, timeZone: string | undefined): string { + return zonedFormatter(timeZone, { + hour: "numeric", + minute: "2-digit", + hour12: true, + }).format(date); +} + +function formatWallClock(value: string | null): string | null { + if (!value) { + return null; + } + const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim()); + if (!match) { + return null; + } + const hours = Number(match[1]); + if (hours > 23) { + return null; + } + const suffix = hours >= 12 ? "PM" : "AM"; + const hour12 = hours % 12 === 0 ? 12 : hours % 12; + return `${hour12}:${match[2]} ${suffix}`; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function asNumber(value: unknown, fallback: number): number { + if (value === null || value === undefined || value === "") { + return fallback; + } + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function clampCount(value: number): number { + return Math.max(0, Math.round(value)); +} + +function titleCaseSlug(slug: string): string { + return slug + .split(/[-_]+/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +function zoneOffsetMs(instant: number, timeZone: string): number { + const parts = zonedFormatter(timeZone, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }).formatToParts(new Date(instant)); + const read = (type: Intl.DateTimeFormatPartTypes): number => { + const part = parts.find((entry) => entry.type === type); + return part ? Number(part.value) : Number.NaN; + }; + const asUtc = Date.UTC( + read("year"), + read("month") - 1, + read("day"), + read("hour"), + read("minute"), + read("second") + ); + return Number.isFinite(asUtc) ? asUtc - instant : 0; +} + +function parseIsoDate(value: unknown, timeZone?: string): Date | null { + if (typeof value !== "string") { + return null; + } + const parts = value.split("-"); + if (parts.length !== 3) { + return null; + } + const [year, month, day] = parts.map((part) => Number(part)); + if (!year || !month || !day) { + return null; + } + if (!timeZone) { + const local = new Date(year, month - 1, day); + return Number.isNaN(local.getTime()) ? null : local; + } + const utcMs = Date.UTC(year, month - 1, day); + const date = new Date(utcMs - zoneOffsetMs(utcMs, timeZone)); + return Number.isNaN(date.getTime()) ? null : date; +} + +function childSlugFromPath(): string { + const segments = window.location.pathname.split("/").filter(Boolean); + return segments.length > 0 + ? segments[segments.length - 1].toLowerCase() + : ""; +} + +function toPanelInstance(value: unknown): PanelInstance | null { + if (!value || typeof value !== "object") { + return null; + } + const row = value as Record; + const state = asString(row.state).toLowerCase(); + if (state !== "open" && state !== "completed") { + return null; + } + const id = asNumber(row.id, 0); + if (!id) { + return null; + } + const childId = asNumber(row.child_id, 0); + return { + id, + child_id: childId > 0 ? childId : null, + title: asString(row.title) || "Quest", + icon: asString(row.icon) || null, + window: asString(row.window).toLowerCase(), + due_time: asString(row.due_time) || null, + state, + overdue: row.overdue === true, + completed_at: asString(row.completed_at) || null, + on_time: row.on_time === true ? true : row.on_time === false ? false : null, + }; +} + +type DockWeather = { + condition: string; + temperature: number | null; + high: number | null; + low: number | null; +}; + +const CONDITION_LABELS: Record = { + "clear-night": "Clear", + cloudy: "Cloudy", + exceptional: "Clear", + fog: "Foggy", + hail: "Hail", + lightning: "Storms", + "lightning-rainy": "Storms", + partlycloudy: "Partly cloudy", + pouring: "Heavy rain", + rainy: "Rain", + snowy: "Snow", + "snowy-rainy": "Sleet", + sunny: "Sunny", + windy: "Windy", + "windy-variant": "Windy", +}; + +const CONDITION_PHRASES: Record = { + "clear-night": "Clear night skies", + cloudy: "Grey skies today", + exceptional: "A striking day", + fog: "Mist on the road", + hail: "Ice from the sky", + lightning: "Storms may roll in", + "lightning-rainy": "Storms may roll in", + partlycloudy: "Sun between clouds", + pouring: "Heavy rain outside", + rainy: "Rain on the walls", + snowy: "Snow on the peaks", + "snowy-rainy": "Sleet may fall", + sunny: "Clear skies today", + windy: "A blustery day", + "windy-variant": "A blustery day", +}; + +function conditionLabel(condition: string): string { + const known = CONDITION_LABELS[condition]; + if (known) { + return known; + } + return condition.charAt(0).toUpperCase() + condition.slice(1); +} + +function conditionPhrase(condition: string, label: string): string { + return CONDITION_PHRASES[condition] ?? label; +} + +const SHIELD_CLIP = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)"; +const HEXAGON_CLIP = + "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)"; + +const SEAL_ROTATIONS = [-9, 6, -4]; + +const logStyles = css` + * { + box-sizing: border-box; + } + + :host { + display: block; + height: 100%; + --nq-brand-gradient-h: linear-gradient( + 90deg, + var(--nq-brand-blue), + var(--nq-brand-purple) + ); + } + + .board { + position: relative; + display: flex; + flex-direction: column; + height: 1080px; + padding: 0; + overflow: hidden; + background: var(--nq-p-parchment); + box-shadow: var(--nq-p-vignette); + font-family: var(--nq-p-font-body); + } + + .frame { + position: absolute; + pointer-events: none; + } + + .frame-outer { + inset: 26px; + border: var(--nq-p-frame-outer); + border-radius: 14px; + } + + .frame-inner { + inset: 36px; + border: var(--nq-p-frame-inner); + border-radius: 8px; + } + + .header { + position: absolute; + top: 56px; + left: var(--nq-p-page-inset); + right: var(--nq-p-page-inset); + display: flex; + align-items: center; + gap: 28px; + } + + .crest { + position: relative; + flex: none; + width: 96px; + height: 110px; + padding: 3px; + clip-path: ${unsafeCSS(SHIELD_CLIP)}; + background: rgba(255, 255, 255, 0.22); + } + + .crest-face { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding-bottom: 32px; + clip-path: ${unsafeCSS(SHIELD_CLIP)}; + background: var(--nq-p-crest); + } + + .crest .initial { + font-family: var(--nq-p-font-display); + font-size: 44px; + font-weight: 700; + line-height: 1; + color: #ffffff; + } + + .crest.away .crest-face { + background: var(--nq-p-crest-away); + } + + .titles { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + } + + .title { + margin: 0; + font-family: var(--nq-p-font-display); + font-size: var(--nq-p-size-title); + font-weight: 700; + line-height: 1.1; + color: var(--nq-p-ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .sub { + margin: 0; + font-family: var(--nq-p-font-heading); + font-size: 20px; + font-weight: 600; + letter-spacing: 0.24em; + text-transform: uppercase; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .remaining { + margin-left: auto; + flex: none; + display: flex; + align-items: center; + gap: 20px; + padding: 18px 28px; + border: 2px solid var(--nq-p-panel-border); + border-radius: 14px; + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + } + + .d20 { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + clip-path: ${unsafeCSS(HEXAGON_CLIP)}; + background: var(--nq-brand-gradient); + } + + .d20-numeral { + font-family: var(--nq-p-font-heading); + font-size: 19px; + font-weight: 700; + line-height: 1; + color: #ffffff; + } + + .remaining-text { + display: flex; + flex-direction: column; + gap: 4px; + } + + .remaining-count { + font-family: var(--nq-p-font-heading); + font-size: 28px; + font-weight: 700; + line-height: 1.1; + color: var(--nq-p-ink); + } + + .party-line { + font-family: var(--nq-p-font-body); + font-size: var(--nq-p-size-body); + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .columns { + position: absolute; + top: 236px; + left: var(--nq-p-page-inset); + right: var(--nq-p-page-inset); + bottom: 130px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 34px; + } + + .column { + display: flex; + flex-direction: column; + min-height: 0; + } + + .column-head { + flex: none; + display: flex; + align-items: center; + gap: 14px; + padding-bottom: 14px; + border-bottom: var(--nq-p-rule); + } + + .column-head svg { + flex: none; + width: 34px; + height: 34px; + color: var(--nq-p-ink-secondary); + } + + .column-names { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + .column-name { + font-family: var(--nq-p-font-heading); + font-size: var(--nq-p-size-section); + font-weight: 700; + line-height: 1.1; + color: var(--nq-p-ink); + } + + .column-range { + font-family: var(--nq-p-font-heading); + font-size: var(--nq-p-size-label); + font-weight: 600; + letter-spacing: var(--nq-p-track-label); + text-transform: uppercase; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .column-count { + margin-left: auto; + flex: none; + font-family: var(--nq-p-font-heading); + font-size: 26px; + font-weight: 700; + line-height: 1.1; + color: var(--nq-p-ink-secondary); + } + + .stack { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: visible; + gap: var(--nq-p-quest-gap); + margin-top: 22px; + } + + .quest { + position: relative; + display: flex; + align-items: center; + gap: 22px; + min-height: var(--nq-p-quest-min-height); + padding: var(--nq-p-quest-pad); + border: 1px solid var(--nq-p-card-border); + border-radius: var(--nq-p-radius-card); + background: var(--nq-p-card-open); + box-shadow: var(--nq-p-card-shadow); + -webkit-tap-highlight-color: transparent; + transition: transform var(--nq-dur-micro) var(--nq-ease-out); + } + + .quest.tappable { + cursor: pointer; + } + + .quest.tappable:active { + transform: scale(0.98); + } + + .quest.sealed { + background: var(--nq-p-card-done); + opacity: 0.82; + box-shadow: none; + } + + .tile { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--nq-p-tile-size); + height: var(--nq-p-tile-size); + border-radius: 10px; + background: var(--nq-p-icon-tile); + color: #ffffff; + } + + .tile svg { + width: 34px; + height: 34px; + } + + .sealed .tile { + background: var(--nq-p-icon-tile-done); + color: var(--nq-p-ink-secondary); + } + + .quest .body { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + } + + .quest .quest-title { + font-family: var(--nq-p-font-heading); + font-size: var(--nq-p-size-quest); + font-weight: 700; + line-height: 1.15; + color: var(--nq-p-ink); + overflow-wrap: anywhere; + } + + .sealed .quest-title { + color: var(--nq-p-ink-muted); + text-decoration: line-through; + } + + .quest .meta { + font-family: var(--nq-p-font-body); + font-size: var(--nq-p-size-body); + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .quest .meta.late { + font-family: var(--nq-p-font-heading); + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--nq-p-ink-late); + } + + button.complete { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + height: var(--nq-p-button-height); + min-width: var(--nq-p-button-min-width); + border: none; + border-radius: 10px; + background: var(--nq-brand-gradient); + color: #ffffff; + font-family: var(--nq-p-font-heading); + font-size: 24px; + font-weight: 700; + line-height: 1; + box-shadow: 0 3px 0 rgba(40, 20, 60, 0.35); + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform var(--nq-dur-micro) var(--nq-ease-out); + } + + button.complete:active { + transform: scale(0.98); + } + + button.complete svg { + width: 30px; + height: 30px; + } + + button:focus-visible { + outline: 3px solid var(--nq-brand-purple); + outline-offset: 4px; + } + + .seal { + position: absolute; + right: 16px; + top: -12px; + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--nq-p-seal-size); + height: var(--nq-p-seal-size); + border-radius: var(--nq-p-radius-pill); + background: var(--nq-p-seal); + box-shadow: var(--nq-p-seal-shadow); + color: rgba(255, 235, 235, 0.95); + transform: rotate(var(--seal-rot, 0deg)); + animation: seal-in var(--nq-dur-base) var(--nq-ease-out); + pointer-events: none; + } + + .seal svg { + width: 38px; + height: 38px; + } + + @keyframes seal-in { + from { + opacity: 0; + transform: scale(1.15) rotate(var(--seal-rot, 0deg)); + } + to { + opacity: 1; + transform: scale(1) rotate(var(--seal-rot, 0deg)); + } + } + + .rollup { + border: 2px dashed var(--nq-p-panel-border); + border-radius: 14px; + padding: 16px 22px; + } + + .rollup-label { + display: block; + font-family: var(--nq-p-font-heading); + font-size: 18px; + font-weight: 600; + letter-spacing: var(--nq-p-track-label); + text-transform: uppercase; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .rollup-body { + margin: 6px 0 0; + font-family: var(--nq-p-font-body); + font-size: 23px; + font-weight: 600; + line-height: 1.35; + color: var(--nq-p-ink-secondary); + } + + .notice-wrap { + position: absolute; + top: 236px; + left: var(--nq-p-page-inset); + right: var(--nq-p-page-inset); + bottom: 130px; + display: flex; + align-items: center; + justify-content: center; + } + + .notice { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 20px; + width: 100%; + max-width: 980px; + padding: 56px 64px; + border: 2px solid var(--nq-p-panel-border); + border-radius: 20px; + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + text-align: center; + } + + .notice svg { + width: 46px; + height: 46px; + color: var(--nq-p-ink-secondary); + } + + .notice.away { + background: linear-gradient(#f2ecdd, #e6dcc6); + border-style: dashed; + box-shadow: none; + } + + .notice-headline { + font-family: var(--nq-p-font-heading); + font-size: 40px; + font-weight: 700; + line-height: 1.15; + color: var(--nq-p-ink); + } + + .notice-body { + margin: 0; + font-family: var(--nq-p-font-body); + font-size: 24px; + font-weight: 600; + line-height: 1.4; + color: var(--nq-p-ink-secondary); + } + + .notice-seal { + display: inline-flex; + align-items: center; + justify-content: center; + width: 78px; + height: 78px; + border-radius: var(--nq-p-radius-pill); + background: var(--nq-p-seal); + box-shadow: var(--nq-p-seal-shadow); + color: rgba(255, 235, 235, 0.95); + transform: rotate(-6deg); + } + + .notice-seal svg { + width: 38px; + height: 38px; + color: rgba(255, 235, 235, 0.95); + } + + .scrim { + position: absolute; + inset: 0; + z-index: 10; + background: rgba(30, 20, 10, 0.62); + } + + .dialog { + position: absolute; + top: 196px; + left: 460px; + width: 1000px; + border: 3px solid rgba(92, 62, 26, 0.5); + border-radius: 20px; + background: var(--nq-p-card-panel); + box-shadow: 0 26px 60px rgba(20, 10, 0, 0.5); + padding: 64px 68px 56px; + animation: dialog-in var(--nq-dur-modal) var(--nq-ease-out); + } + + @keyframes dialog-in { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } + } + + .dialog-header { + display: flex; + align-items: center; + gap: 24px; + } + + .dialog-tile { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 104px; + height: 104px; + border-radius: 12px; + background: var(--nq-p-icon-tile); + color: #ffffff; + } + + .dialog-tile svg { + width: 56px; + height: 56px; + } + + .dialog-titles { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + } + + .dialog-kicker { + font-family: var(--nq-p-font-heading); + font-size: var(--nq-p-size-label); + font-weight: 600; + letter-spacing: 0.24em; + text-transform: uppercase; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .dialog-quest-title { + font-family: var(--nq-p-font-display); + font-size: 58px; + font-weight: 700; + line-height: 1.1; + color: var(--nq-p-ink); + overflow-wrap: anywhere; + } + + .dialog-body { + margin: 28px 0 0; + font-family: var(--nq-p-font-body); + font-size: 32px; + font-weight: 600; + line-height: 1.35; + color: #3f2f1c; + } + + .dialog-buttons { + display: flex; + gap: 24px; + margin-top: 40px; + } + + button.confirm-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 12px; + height: var(--nq-p-confirm-button); + border-radius: 10px; + font-family: var(--nq-p-font-heading); + font-weight: 700; + line-height: 1; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform var(--nq-dur-micro) var(--nq-ease-out); + } + + button.confirm-button:active { + transform: scale(0.98); + } + + button.not-yet { + flex: 1; + border: 2px solid rgba(92, 62, 26, 0.5); + background: rgba(92, 62, 26, 0.1); + color: var(--nq-p-ink); + font-size: 30px; + } + + button.not-yet svg { + width: 34px; + height: 34px; + } + + button.do-complete { + flex: 2; + border: none; + background: var(--nq-brand-gradient); + color: #ffffff; + font-size: 32px; + } + + button.do-complete svg { + width: 38px; + height: 38px; + } + + .toast { + position: absolute; + left: 50%; + bottom: 158px; + z-index: 20; + transform: translateX(-50%); + max-width: calc(100% - 124px); + padding: 20px 32px; + border: 2px solid var(--nq-p-panel-border); + border-radius: 14px; + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + font-family: var(--nq-p-font-body); + font-size: 23px; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--nq-p-ink-secondary); + animation: toast-in var(--nq-dur-base) var(--nq-ease-out); + pointer-events: none; + } + + @keyframes toast-in { + from { + opacity: 0; + transform: translate(-50%, 12px); + } + to { + opacity: 1; + transform: translate(-50%, 0); + } + } + + .complete-screen { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + padding: 96px 62px 130px; + text-align: center; + } + + .complete-seal { + display: inline-flex; + align-items: center; + justify-content: center; + width: 168px; + height: 168px; + border-radius: var(--nq-p-radius-pill); + background: var(--nq-p-seal); + box-shadow: 0 10px 24px rgba(60, 10, 20, 0.42); + color: rgba(255, 235, 235, 0.95); + transform: rotate(-6deg); + } + + .complete-seal svg { + width: 86px; + height: 86px; + } + + .complete-title { + margin: 28px 0 0; + font-family: var(--nq-p-font-display); + font-size: 86px; + font-weight: 900; + line-height: 1.1; + background: var(--nq-brand-gradient-h); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; + } + + .complete-sub { + margin: 16px 0 0; + max-width: 1000px; + font-family: var(--nq-p-font-body); + font-size: 34px; + font-weight: 600; + line-height: 1.35; + color: var(--nq-p-ink-secondary); + text-wrap: pretty; + } + + .complete-countdown { + margin: 12px 0 0; + font-family: var(--nq-p-font-body); + font-size: 25px; + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .complete-stats { + position: absolute; + top: 540px; + left: 200px; + right: 200px; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 26px; + } + + .complete-stat { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px 16px; + border: 2px solid var(--nq-p-panel-border); + border-radius: 16px; + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + } + + .complete-stat .numeral { + font-family: var(--nq-p-font-heading); + font-size: 58px; + font-weight: 900; + line-height: 1; + color: var(--nq-p-ink); + } + + .complete-stat .label { + font-family: var(--nq-p-font-heading); + font-size: 17px; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .dock { + position: absolute; + left: 46px; + right: 46px; + bottom: 46px; + height: var(--nq-p-dock-height); + display: flex; + align-items: center; + justify-content: center; + gap: 24px; + padding: 0 40px; + border-radius: 14px; + background: var(--nq-p-dock-bg); + pointer-events: none; + user-select: none; + color: var(--nq-p-dock-ink); + } + + .dock svg { + flex: none; + width: 40px; + height: 40px; + color: var(--nq-p-dock-ink); + } + + .dock .divider { + flex: none; + width: 1px; + height: 40px; + background: var(--nq-p-dock-divider); + } + + .dock .temp { + font-family: var(--nq-p-font-body); + font-size: 38px; + font-weight: 800; + line-height: 1; + } + + .dock .condition { + font-family: var(--nq-p-font-body); + font-size: 26px; + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-dock-ink-secondary); + } + + .dock .date { + font-family: var(--nq-p-font-body); + font-size: 26px; + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-dock-ink-secondary); + } + + .dock .clock { + font-family: var(--nq-p-font-body); + font-size: 38px; + font-weight: 800; + line-height: 1; + } +`; + +const ICON_CHECK = html``; + +const ICON_X = html``; + +const ICON_STAR = html``; + +const ICON_SUNRISE = html``; + +const ICON_SUN = html``; + +const ICON_MOON = html``; + +const ICON_TENT = html``; + +const ICON_COMPASS = html``; + +const ICON_ALERT = html``; + +const ICON_CLOUD_OFF = html``; + +const ICON_CLOUD_MOON = html``; + +const LOG_NOTICE_NO_ADVENTURER: LogNotice = { + icon: ICON_COMPASS, + headline: "No adventurer chosen", + body: "Open The Party and tap your crest to open your quest log.", +}; + +const LOG_NOTICE_NOT_SET_UP: LogNotice = { + icon: ICON_ALERT, + headline: "NestQuest is not set up yet", + body: "A parent needs to finish setting up NestQuest.", +}; + +const LOG_NOTICE_UNREACHABLE: LogNotice = { + icon: ICON_CLOUD_OFF, + headline: "The records cannot be reached", + body: "The party's records are quiet right now. NestQuest will return shortly.", +}; + +const WINDOW_DEFS: WindowDef[] = [ + { key: "morning", name: "Morning", range: "Until 11:59 AM", icon: ICON_SUNRISE }, + { key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: ICON_SUN }, + { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: ICON_MOON }, +]; + +export class NestQuestQuestLogCard extends LitElement { + static properties = { + hass: { attribute: false }, + _config: { state: true }, + _now: { state: true }, + _confirm: { state: true }, + _optimistic: { state: true }, + _toast: { state: true }, + _countdown: { state: true }, + }; + + static styles = [unsafeCSS(panelTokens), logStyles]; + + hass?: HassLike; + _config?: QuestLogCardConfig; + _now = new Date(); + _confirm: number | null = null; + /** instanceId → the completion instant stamped before the service has + * answered; the seal shows immediately and reverts on failure. */ + _optimistic = new Map(); + _toast: string | null = null; + _countdown: number | null = null; + private _clockTimer?: number; + private _idleTimer?: number; + private _confirmTimer?: number; + private _toastTimer?: number; + private _completeTimer?: number; + private _unsubs: Array<() => void> = []; + private _subscribed = false; + private _subGeneration = 0; + private _retryTimer?: number; + + setConfig(config: QuestLogCardConfig): void { + if (!config || typeof config !== "object") { + throw new Error("Invalid configuration"); + } + this._config = config; + } + + private _hassConnection(): HassConnection | null { + const connection = (this.hass as { connection?: unknown } | undefined) + ?.connection; + if ( + !connection || + typeof connection !== "object" || + typeof (connection as HassConnection).subscribeEvents !== "function" + ) { + return null; + } + return connection as HassConnection; + } + + private _subscribeLive(): void { + if (!this.isConnected) { + return; + } + if (this._subscribed) { + return; + } + const connection = this._hassConnection(); + if (!connection) { + return; + } + this._subscribed = true; + const token = ++this._subGeneration; + const track = (unsubPromise: Promise<() => void>): void => { + unsubPromise + .then((unsub) => { + if (this._subscribed && token === this._subGeneration) { + this._unsubs.push(unsub); + } else { + unsub(); + } + }) + .catch(() => { + if (token === this._subGeneration) { + this._unsubscribeLive(); + this._armSubscribeRetry(); + } + }); + }; + for (const eventType of NESTQUEST_EVENT_TYPES) { + track( + connection.subscribeEvents(() => this.requestUpdate(), eventType) + ); + } + } + + private _armSubscribeRetry(): void { + if (this._retryTimer !== undefined) { + return; + } + this._retryTimer = window.setTimeout(() => { + this._retryTimer = undefined; + this._subscribeLive(); + }, 1000); + } + + private _clearSubscribeRetry(): void { + if (this._retryTimer !== undefined) { + window.clearTimeout(this._retryTimer); + this._retryTimer = undefined; + } + } + + private _unsubscribeLive(): void { + this._clearSubscribeRetry(); + this._subGeneration++; + for (const unsub of this._unsubs) { + unsub(); + } + this._unsubs = []; + this._subscribed = false; + } + + getCardSize(): number { + return 22; + } + + connectedCallback(): void { + super.connectedCallback(); + this._subscribeLive(); + this._now = new Date(); + this._clockTimer = window.setInterval(() => { + this._now = new Date(); + }, 60000); + window.addEventListener("pointerdown", this._onActivity, true); + window.addEventListener("touchstart", this._onActivity, true); + window.addEventListener("keydown", this._onActivity, true); + this._armIdle(); + if (this._logView().kind === "complete-day") { + this._armCompleteTimer(); + } + } + + protected updated(): void { + this._subscribeLive(); + if (this._logView().kind === "complete-day") { + this._clearIdle(); + this._armCompleteTimer(); + } else { + this._clearCompleteTimer(); + this._countdown = null; + if (this._idleTimer === undefined) { + this._armIdle(); + } + } + } + + disconnectedCallback(): void { + this._unsubscribeLive(); + this._stopClock(); + this._clearIdle(); + this._clearConfirmTimer(); + this._clearToastTimer(); + this._clearCompleteTimer(); + window.removeEventListener("pointerdown", this._onActivity, true); + window.removeEventListener("touchstart", this._onActivity, true); + window.removeEventListener("keydown", this._onActivity, true); + super.disconnectedCallback(); + } + + render() { + const view = this._logView(); + return html` +
+
+
+ ${this._renderMain(view)} + ${this._renderConfirm()} + ${this._renderToast()} +
+ `; + } + + private _renderToast(): TemplateResult | typeof nothing { + if (this._toast === null) { + return nothing; + } + return html` +
${this._toast}
+ `; + } + + private _renderMain(view: LogView): TemplateResult { + switch (view.kind) { + case "no-adventurer": + return this._renderNotice(LOG_NOTICE_NO_ADVENTURER); + case "not-set-up": + return this._renderNotice(LOG_NOTICE_NOT_SET_UP); + case "unreachable": + return this._renderNotice(LOG_NOTICE_UNREACHABLE); + case "away": { + const body = ["The quest log unlocks when they return."]; + if (view.returns) { + body.push(`Returns ${view.returns}`); + } + return html` + ${this._renderHeader(view)} +
+
+ ${ICON_TENT} + ${view.name} is on travels + ${body.map((line) => html`

${line}

`)} +
+
+ `; + } + case "empty-day": + return html` + ${this._renderHeader(view)} +
+
+ ${ICON_SUN} + No quests today +

+ ${`Nothing was posted for today, ${view.name}. Enjoy the day's rest!`} +

+
+
+ `; + case "complete-day": { + const stats = this._completeStats(); + const remaining = this._countdown ?? this._completeSeconds(); + return html` +
+ +

Quest complete

+

${this._completeSub(view.name)}

+

+ Returning to The Party in ${remaining} seconds +

+
+
+ ${stats.claimed} + Quests claimed +
+
+ ${stats.onTime} + On time +
+
+ ${stats.late} + Late +
+
+ ${stats.party} + The Party +
+
+
+ ${this._renderDock()} + `; + } + default: + return html` + ${this._renderHeader(view)} +
+ ${WINDOW_DEFS.map((windowDef) => this._renderColumn(windowDef))} +
+ `; + } + } + + /** Resolve the log's single render state; the priority order is + * design/PANEL-EMPTY-STATES.md §2. */ + private _logView(): LogView { + const slug = this._childSlug(); + if (!slug) { + return { kind: "no-adventurer" }; + } + const resolution = this._dueSensorResolution(slug); + if (resolution === "missing") { + return { kind: "not-set-up" }; + } + if (resolution === "stale") { + return { kind: "unreachable" }; + } + const name = this._childName(slug) ?? titleCaseSlug(slug); + if (!this._childPresent(slug)) { + return { kind: "away", name, returns: this._awayReturns(slug) }; + } + const allDone = this._state(`binary_sensor.nestquest_${slug}_all_done`); + if (allDone && String(allDone.state ?? "").trim().toLowerCase() === "on") { + return { kind: "complete-day", name }; + } + const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`); + const due = clampCount(asNumber(dueSensor?.state, 0)); + if (due === 0) { + return { kind: "empty-day", name }; + } + if (this._remaining() === 0) { + return { kind: "complete-day", name }; + } + return { kind: "normal" }; + } + + /** "missing" when the due sensor does not exist (integration not + * configured), "stale" when it answers unavailable/unknown (backend + * unreachable or the child is absent from the snapshot), null when + * it resolves. */ + private _dueSensorResolution(slug: string): "missing" | "stale" | null { + const sensor = this._state(`sensor.nestquest_${slug}_quests_due_today`); + if (!sensor) { + return "missing"; + } + const state = String(sensor.state ?? "").trim().toLowerCase(); + if (!state || state === "unavailable" || state === "unknown") { + return "stale"; + } + return null; + } + + private _childPresent(slug: string): boolean { + const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`); + const presentAttr = dueSensor?.attributes?.present; + if (typeof presentAttr === "boolean") { + return presentAttr; + } + const presence = this._state( + `binary_sensor.nestquest_${slug}_present_today` + ); + if (presence) { + return String(presence.state ?? "").trim().toLowerCase() === "on"; + } + return true; + } + + private _awayReturns(slug: string): string | null { + const timeZone = this._timeZone(); + const presence = this._state( + `binary_sensor.nestquest_${slug}_present_today` + ); + const nextPresent = parseIsoDate( + presence?.attributes?.next_present, + timeZone + ); + return nextPresent ? formatDay(nextPresent, timeZone) : null; + } + + private _renderNotice(notice: LogNotice): TemplateResult { + return html` +
+
+ ${notice.icon} + ${notice.headline} +

${notice.body}

+
+
+ `; + } + + private _timeZone(): string | undefined { + return hassTimeZone(this.hass); + } + + private _state(entityId: string): StateObject | null { + const states = this.hass?.states as Record | undefined; + const stateObj = states?.[entityId]; + return stateObj && typeof stateObj === "object" ? stateObj : null; + } + + private _childSlug(): string { + return childSlugFromPath(); + } + + private _boardPath(): string { + return asString(this._config?.board_path).replace(/\/+$/, ""); + } + + private _idleSeconds(): number { + const seconds = asNumber(this._config?.idle_return_seconds, 40); + return seconds > 0 ? seconds : 40; + } + + private _confirmSeconds(): number { + const seconds = asNumber(this._config?.confirm_timeout_seconds, 15); + return seconds > 0 ? seconds : 15; + } + + private _completeSeconds(): number { + const seconds = asNumber(this._config?.complete_screen_seconds, 12); + return seconds > 0 ? seconds : 12; + } + + private _childName(slug: string): string | null { + if (!slug) { + return null; + } + const name = this._state(`sensor.nestquest_${slug}_quests_due_today`) + ?.attributes?.child_name; + if (typeof name === "string" && name.trim()) { + return name.trim(); + } + return null; + } + + private _childCounts(slug: string): { due: number; completed: number } { + const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`); + const completedSensor = this._state( + `sensor.nestquest_${slug}_quests_completed_today` + ); + const due = clampCount(asNumber(dueSensor?.state, 0)); + const completed = clampCount(asNumber(completedSensor?.state, 0)); + return { due, completed }; + } + + private _instances(): PanelInstance[] { + const slug = this._childSlug(); + if (!slug) { + return []; + } + const payload = this._state(`sensor.nestquest_${slug}_quests_due_today`) + ?.attributes?.instances; + if (!Array.isArray(payload)) { + return []; + } + const instances = payload + .map(toPanelInstance) + .filter((instance): instance is PanelInstance => instance !== null); + if (this._optimistic.size === 0) { + return instances; + } + let settled = false; + for (const instance of instances) { + const stampedAt = this._optimistic.get(instance.id); + if (stampedAt === undefined) { + continue; + } + if (instance.state === "completed") { + // the backend has caught up; the real record replaces the overlay + this._optimistic.delete(instance.id); + settled = true; + continue; + } + instance.state = "completed"; + instance.overdue = false; + instance.completed_at = stampedAt; + } + if (settled) { + this._optimistic = new Map(this._optimistic); + } + return instances; + } + + private _remaining(): number { + const slug = this._childSlug(); + if (!slug) { + return 0; + } + const sensor = this._state( + `sensor.nestquest_${slug}_quests_remaining_today` + ); + if (sensor) { + return clampCount(asNumber(sensor.state, 0)); + } + const { due, completed } = this._childCounts(slug); + return clampCount(due - completed); + } + + private _partyCounts(): { completed: number; due: number } { + const householdDue = this._state( + "sensor.nestquest_household_quests_due_today" + ); + const householdCompleted = this._state( + "sensor.nestquest_household_quests_completed_today" + ); + if (householdDue || householdCompleted) { + return { + completed: clampCount(asNumber(householdCompleted?.state, 0)), + due: clampCount(asNumber(householdDue?.state, 0)), + }; + } + const slug = this._childSlug(); + const { due, completed } = this._childCounts(slug); + return { completed, due }; + } + + private _completeStats(): { + claimed: number; + onTime: number; + late: number; + party: string; + } { + const claimed = this._childCounts(this._childSlug()).completed; + const onTime = this._instances().filter( + (instance) => instance.on_time === true + ).length; + const late = clampCount(claimed - onTime); + const party = this._partyCounts(); + return { + claimed, + onTime, + late, + party: `${party.completed}/${party.due}`, + }; + } + + private _completeSub(name: string): string { + const date = formatDay(this._now, this._timeZone()); + const { claimed, onTime, late } = this._completeStats(); + if (claimed === 0) { + return `${name} sealed the day on ${date}.`; + } + if (late === 0) { + return `${name} claimed every quest on ${date} — all on time.`; + } + if (onTime === 0) { + return `${name} claimed every quest on ${date} — all late.`; + } + return `${name} claimed every quest on ${date} — ${onTime} on time, ${late} late.`; + } + + private _otherChildren(): OtherChild[] { + const states = this.hass?.states as + | Record + | undefined; + if (!states) { + return []; + } + const slug = this._childSlug(); + const timeZone = this._timeZone(); + const result: OtherChild[] = []; + for (const entityId of Object.keys(states)) { + const match = /^sensor\.nestquest_(.+)_quests_due_today$/.exec(entityId); + if (!match || match[1] === "household" || match[1] === slug) { + continue; + } + const childSlug = match[1]; + const attrs = states[entityId]?.attributes ?? {}; + let name = ""; + if (typeof attrs.child_name === "string" && attrs.child_name.trim()) { + name = attrs.child_name.trim(); + } else { + name = titleCaseSlug(childSlug); + } + let present = true; + if (typeof attrs.present === "boolean") { + present = attrs.present; + } else { + const presence = this._state( + `binary_sensor.nestquest_${childSlug}_present_today` + ); + present = + presence === null || + String(presence.state ?? "").trim().toLowerCase() !== "off"; + } + const completedSensor = this._state( + `sensor.nestquest_${childSlug}_quests_completed_today` + ); + const due = clampCount(asNumber(states[entityId]?.state, 0)); + const completed = clampCount(asNumber(completedSensor?.state, 0)); + let returnsWeekday: string | null = null; + if (!present) { + const presence = this._state( + `binary_sensor.nestquest_${childSlug}_present_today` + ); + const nextPresent = parseIsoDate( + presence?.attributes?.next_present, + timeZone + ); + returnsWeekday = nextPresent + ? formatWeekday(nextPresent, timeZone) + : null; + } + result.push({ + slug: childSlug, + name, + present, + due, + completed, + returnsWeekday, + }); + } + return result.sort((a, b) => a.name.localeCompare(b.name)); + } + + private _renderHeader(view: LogView) { + const slug = this._childSlug(); + const away = view.kind === "away"; + const name = away + ? view.name + : (this._childName(slug) ?? (slug ? titleCaseSlug(slug) : null)); + const { due, completed } = this._childCounts(slug); + const remaining = this._remaining(); + const party = this._partyCounts(); + const title = name ? `${name}'s Quest Log` : "Quest Log"; + const initial = (name?.charAt(0) || "?").toUpperCase(); + return html` +
+ +
+

${title}

+

+ ${formatDay(this._now, this._timeZone())} · + ${away ? "On travels" : `${completed} of ${due} claimed`} +

+
+ ${away + ? nothing + : html` +
+ +
+ + ${remaining} quest${remaining === 1 ? "" : "s"} left + + + Party progress · ${party.completed} of ${party.due} today + +
+
+ `} +
+ `; + } + + private _renderColumn(windowDef: WindowDef) { + const instances = this._instances().filter( + (instance) => instance.window === windowDef.key + ); + const open = instances + .filter((instance) => instance.state === "open") + .sort((a, b) => + (a.due_time ?? "99:99").localeCompare(b.due_time ?? "99:99") + ); + const sealed = instances.filter( + (instance) => instance.state === "completed" + ); + const count = instances.length; + let stackStyle: string | typeof nothing = nothing; + if (count > 0) { + const rollupReserve = + windowDef.key === "afternoon" && this._otherChildren().length > 0 + ? 160 + : 0; + const available = 644 - rollupReserve; + const gap = Math.min( + 24, + Math.max(8, Math.floor((available - count * 74) / Math.max(count - 1, 1))) + ); + const minHeight = Math.min( + 116, + Math.max(58, Math.floor((available - (count - 1) * gap) / count)) + ); + const buttonHeight = Math.max(56, Math.min(72, minHeight - 2)); + let stackVars = `--nq-p-quest-gap: ${gap}px; --nq-p-quest-min-height: ${minHeight}px; --nq-p-button-height: ${buttonHeight}px`; + if (count >= 5) { + const pad = Math.max(0, Math.min(22, Math.floor((minHeight - buttonHeight - 2) / 2))); + const tile = Math.max(44, Math.min(64, minHeight - 2 * pad - 2)); + stackVars += `; --nq-p-quest-pad: ${pad}px; --nq-p-tile-size: ${tile}px`; + } + stackStyle = stackVars; + } + return html` +
+
+ ${windowDef.icon} +
+ ${windowDef.name} + ${windowDef.range} +
+ ${sealed.length}/${instances.length} +
+
+ ${repeat( + open, + (instance) => instance.id, + (instance) => this._renderQuest(instance, false, 0) + )} + ${repeat( + sealed, + (instance) => instance.id, + (instance, index) => this._renderQuest(instance, true, index) + )} + ${windowDef.key === "afternoon" ? this._renderRollup() : nothing} +
+
+ `; + } + + private _renderQuest( + instance: PanelInstance, + sealed: boolean, + sealedIndex: number + ) { + if (sealed) { + const rotation = SEAL_ROTATIONS[sealedIndex % SEAL_ROTATIONS.length]; + const claimedAt = instance.completed_at + ? formatInstant(instance.completed_at, this._timeZone()) + : ""; + return html` +
+ +
+ ${instance.title} + + ${claimedAt ? `Claimed ${claimedAt}` : "Claimed"} + +
+ +
+ `; + } + const dueAt = formatWallClock(instance.due_time); + const meta = instance.overdue + ? { + text: dueAt ? `Overdue · due ${dueAt}` : "Overdue", + late: true, + } + : { + text: dueAt ? `Due by ${dueAt}` : "Due today", + late: false, + }; + return html` +
this._openConfirm(instance.id)} + @keydown=${(event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + this._openConfirm(instance.id); + } + }} + > + +
+ ${instance.title} + ${meta.text} +
+ +
+ `; + } + + private _renderRollup() { + const others = this._otherChildren(); + if (others.length === 0) { + return nothing; + } + const sentences = others.slice(0, 2).map((child) => { + if (!child.present) { + return child.returnsWeekday + ? `${child.name} is on travels until ${child.returnsWeekday}.` + : `${child.name} is on travels.`; + } + if (child.due > 0 && child.completed >= child.due) { + return `${child.name} has finished their log.`; + } + if (child.due > child.completed) { + return `${child.name} has ${child.due - child.completed} quests left.`; + } + return `${child.name} has no quests today.`; + }); + return html` +
+ Party roll-up +

${sentences.join(" ")}

+
+ `; + } + + private _renderConfirm() { + if (this._confirm === null) { + return nothing; + } + const instance = this._instances().find( + (candidate) => candidate.id === this._confirm + ); + if (!instance) { + return nothing; + } + const windowDef = WINDOW_DEFS.find((entry) => entry.key === instance.window); + return html` +
this._closeConfirm()}> + +
+ `; + } + + private _openConfirm(instanceId: number): void { + this._confirm = instanceId; + this._armConfirmTimer(); + } + + private _closeConfirm(): void { + this._clearConfirmTimer(); + this._confirm = null; + } + + private _confirmComplete(): void { + const instance = + this._confirm === null + ? undefined + : this._instances().find( + (candidate) => candidate.id === this._confirm + ); + this._closeConfirm(); + if (!instance || instance.state !== "open") { + return; + } + this._completeQuest(instance); + } + + /** Seal first, ask the service second: the seal is stamped and the column + * re-sorted immediately (PANEL-SPEC §4); a service failure reverts the + * card and raises the parchment toast. */ + private async _completeQuest(instance: PanelInstance): Promise { + if (this._optimistic.has(instance.id)) { + return; + } + const stampedAt = new Date().toISOString(); + this._optimistic = new Map(this._optimistic).set(instance.id, stampedAt); + try { + await this._callCompleteQuest(instance); + } catch { + const reverted = new Map(this._optimistic); + reverted.delete(instance.id); + this._optimistic = reverted; + this._showToast( + "NestQuest could not set the seal just now. Please try again." + ); + } + } + + private async _callCompleteQuest(instance: PanelInstance): Promise { + const hass = this.hass as + | { + callService?: ( + this: unknown, + domain: string, + service: string, + data?: Record + ) => Promise; + } + | undefined; + if (typeof hass?.callService !== "function") { + throw new Error("Home Assistant is not connected"); + } + const actorChildId = this._actorChildId(instance); + if (actorChildId === null) { + throw new Error("The tapped adventurer is unknown"); + } + await hass.callService.call(hass, "nestquest", "complete_quest", { + instance_id: instance.id, + actor: "panel", + actor_child_id: actorChildId, + }); + } + + /** The tapped profile (decision 9): the instance's own child_id, falling + * back to the due sensor's child-level attribute. */ + private _actorChildId(instance: PanelInstance): number | null { + if (instance.child_id !== null) { + return instance.child_id; + } + const slug = this._childSlug(); + const fromSensor = asNumber( + this._state(`sensor.nestquest_${slug}_quests_due_today`)?.attributes + ?.child_id, + 0 + ); + return fromSensor > 0 ? fromSensor : null; + } + + private _showToast(message: string): void { + this._toast = message; + this._clearToastTimer(); + this._toastTimer = window.setTimeout(() => { + this._toastTimer = undefined; + this._toast = null; + }, 6000); + } + + private _clearToastTimer(): void { + if (this._toastTimer !== undefined) { + window.clearTimeout(this._toastTimer); + this._toastTimer = undefined; + } + } + + private _armConfirmTimer(): void { + this._clearConfirmTimer(); + this._confirmTimer = window.setTimeout(() => { + this._confirmTimer = undefined; + this._closeConfirm(); + }, this._confirmSeconds() * 1000); + } + + private _clearConfirmTimer(): void { + if (this._confirmTimer !== undefined) { + window.clearTimeout(this._confirmTimer); + this._confirmTimer = undefined; + } + } + + private _armIdle(): void { + this._clearIdle(); + this._idleTimer = window.setTimeout(() => { + this._idleTimer = undefined; + this._returnToBoard(); + }, this._idleSeconds() * 1000); + } + + private _clearIdle(): void { + if (this._idleTimer !== undefined) { + window.clearTimeout(this._idleTimer); + this._idleTimer = undefined; + } + } + + private _armCompleteTimer(): void { + if (this._completeTimer !== undefined) { + return; + } + if (this._countdown === null) { + this._countdown = this._completeSeconds(); + } + if (this._countdown <= 0) { + this._returnToBoard(); + return; + } + this._completeTimer = window.setInterval(() => { + const remaining = (this._countdown ?? 1) - 1; + if (remaining <= 0) { + this._countdown = 0; + this._clearCompleteTimer(); + this._returnToBoard(); + return; + } + this._countdown = remaining; + }, 1000); + } + + private _clearCompleteTimer(): void { + if (this._completeTimer !== undefined) { + window.clearInterval(this._completeTimer); + this._completeTimer = undefined; + } + } + + private _optionalNumber(value: unknown): number | null { + if (value === null || value === undefined || value === "") { + return null; + } + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + + private _dockWeather(): DockWeather | null { + const entityId = asString(this._config?.weather_entity); + if (!entityId) { + return null; + } + const stateObj = this._state(entityId); + if (!stateObj) { + return null; + } + const condition = String(stateObj.state ?? "").trim(); + if (!condition || condition === "unavailable" || condition === "unknown") { + return null; + } + const attrs = stateObj.attributes ?? {}; + let high: number | null = null; + let low: number | null = null; + const forecast = attrs.forecast; + if (Array.isArray(forecast) && forecast.length > 0) { + const entry = forecast[0]; + if (entry && typeof entry === "object") { + const typed = entry as Record; + high = this._optionalNumber(typed.temperature); + low = this._optionalNumber(typed.templow); + } + } + return { + condition, + temperature: this._optionalNumber(attrs.temperature), + high, + low, + }; + } + + private _renderDock() { + const weather = this._dockWeather(); + if (!weather) { + return html` +
+ ${formatDay(this._now, this._timeZone())} + + ${formatTime(this._now, this._timeZone())} +
+ `; + } + const label = conditionLabel(weather.condition); + const phrase = conditionPhrase(weather.condition, label); + const temperature = + weather.temperature === null + ? nothing + : html`${Math.round(weather.temperature)}°`; + const hiLo = + weather.high === null || weather.low === null + ? null + : `${Math.round(weather.high)}° / ${Math.round(weather.low)}°`; + return html` +
+ ${ICON_CLOUD_MOON} + ${temperature} + + + ${hiLo === null ? label : `${label} · ${hiLo}`} + + + ${phrase} +
+ `; + } + + private _returnToBoard(): void { + const path = this._boardPath(); + if (!path) { + return; + } + window.history.pushState(null, "", path); + window.dispatchEvent(new Event("location-changed")); + } + + private _stopClock(): void { + if (this._clockTimer !== undefined) { + window.clearInterval(this._clockTimer); + this._clockTimer = undefined; + } + } + + private _onActivity = (): void => { + if (this._logView().kind !== "complete-day") { + this._armIdle(); + } + if (this._confirm !== null) { + this._armConfirmTimer(); + } + }; +} + +customElements.define("nestquest-quest-log-card", NestQuestQuestLogCard); +registerCustomCard({ + type: "nestquest-quest-log-card", + name: "NestQuest Quest Log", + description: "One child's daily quests, grouped by window.", +}); diff --git a/frontend/src/nestquest-cards.ts b/frontend/src/nestquest-cards.ts new file mode 100644 index 0000000..02316e3 --- /dev/null +++ b/frontend/src/nestquest-cards.ts @@ -0,0 +1,12 @@ +import "./cards/party-board"; +import "./cards/quest-log"; +import "./cards/admin"; + +const FONTS_HREF = "/nestquest-static/nestquest-fonts.css"; +if (!document.querySelector('link[data-nq-fonts=""]')) { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = FONTS_HREF; + link.dataset.nqFonts = ""; + document.head.appendChild(link); +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..4854ed6 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,20 @@ +export type HassLike = Record; + +export type CardConfig = Record; + +export type CustomCardInfo = { + type: string; + name: string; + description: string; +}; + +declare global { + interface Window { + customCards?: CustomCardInfo[]; + } +} + +export function registerCustomCard(info: CustomCardInfo): void { + window.customCards = window.customCards ?? []; + window.customCards.push(info); +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..8963332 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..1c3265e --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + build: { + outDir: "../custom_components/nestquest/www", + emptyOutDir: false, + sourcemap: false, + lib: { + entry: "src/nestquest-cards.ts", + formats: ["es"], + fileName: () => "nestquest-cards.js", + }, + rollupOptions: { + output: { + inlineDynamicImports: true, + }, + }, + }, +}); diff --git a/pyproject.toml b/pyproject.toml index ce505c5..1857556 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,8 +8,10 @@ dependencies = [] [dependency-groups] dev = [ + "jinja2>=3", "pytest>=8.0.0", "pytest-asyncio>=0.24", + "pyyaml>=6", "voluptuous>=0.14", ] diff --git a/tests/blueprint_helpers.py b/tests/blueprint_helpers.py new file mode 100644 index 0000000..d4ec16e --- /dev/null +++ b/tests/blueprint_helpers.py @@ -0,0 +1,206 @@ +"""Shared harness for the NestQuest automation-blueprint tests. + +The blueprints are YAML data consumed by Home Assistant's blueprint +engine; the integration itself never imports yaml or jinja2. The +blueprint tests therefore exercise them exactly the way HA would: +parse the YAML (with a loader that understands ``!input`` tags), then +render the message/condition templates with a plain jinja2 environment +over a fixture state set shaped like HA's template state objects. + +The harness is mock-only (see conftest.py), so the fixtures model just +the surface the templates touch: ``states.binary_sensor`` / +``states.sensor`` lists of objects carrying ``entity_id``, ``state`` +and ``attributes`` — plus, for the celebration blueprint, a callable +``states(entity_id)`` lookup over ``input_datetime`` helpers. Jinja's +``search``/``match`` selectattr tests are Home Assistant additions, +registered here explicitly. + +This module is plain helper code (deliberately not named ``test_*`` so +pytest never collects it); every blueprint test file imports from it. +""" +from __future__ import annotations + +import re +from pathlib import Path + +import jinja2 +import yaml + +REPO_ROOT = Path(__file__).parent.parent +BLUEPRINT_DIR = ( + REPO_ROOT / "custom_components" / "nestquest" / "blueprints" / "automation" +) + + +class BlueprintInput(str): + """Marker for a parsed blueprint ``!input `` reference.""" + + +def _input_constructor(loader: yaml.SafeLoader, node: yaml.Node) -> BlueprintInput: + return BlueprintInput(loader.construct_scalar(node)) + + +class _BlueprintLoader(yaml.SafeLoader): + """SafeLoader extended with Home Assistant's ``!input`` blueprint tag.""" + + +_BlueprintLoader.add_constructor("!input", _input_constructor) + + +def load_blueprints() -> dict[str, dict]: + """Parse every automation blueprint, asserting the directory exists.""" + assert BLUEPRINT_DIR.is_dir(), f"missing blueprint directory {BLUEPRINT_DIR}" + paths = sorted(BLUEPRINT_DIR.glob("*.yaml")) + assert paths, f"no blueprints under {BLUEPRINT_DIR}" + return { + path.stem: yaml.load(path.read_text(encoding="utf-8"), Loader=_BlueprintLoader) + for path in paths + } + + +def blueprint(name: str) -> dict: + """Return one parsed blueprint by its file stem.""" + blueprints = load_blueprints() + assert name in blueprints, f"{name}.yaml is missing" + return blueprints[name] + + +def morning_summary() -> dict: + """Return the parsed morning-summary blueprint.""" + return blueprint("morning_summary") + + +def single_action(bp: dict) -> dict: + """Return the single action of a one-action blueprint.""" + actions = bp["actions"] + assert isinstance(actions, list) and len(actions) == 1 + return actions[0] + + +def morning_action(bp: dict) -> dict: + """Return the single notify action of the morning summary.""" + return single_action(bp) + + +def notify_data(bp: dict, index: int = 0) -> dict: + """Return a notify action's service ``data`` (title/message live + there in the modern HA action schema, not at the action's top level).""" + action = bp["actions"][index] + assert isinstance(action.get("data"), dict), ( + "notify payload fields must nest under data:" + ) + return action["data"] + + +def iter_strings(node: object): + """Yield every string value in a parsed YAML structure, recursively.""" + if isinstance(node, str): + yield node + elif isinstance(node, dict): + for value in node.values(): + yield from iter_strings(value) + elif isinstance(node, list): + for value in node: + yield from iter_strings(value) + + +class TemplateState: + """Stand-in for an HA template state object: entity_id/state/attributes.""" + + def __init__(self, entity_id: str, state: str, attributes: dict | None = None): + self.entity_id = entity_id + self.state = state + self.attributes = dict(attributes or {}) + + +class StatesFixture: + """HA's template ``states`` object over a fixture entity set. + + Like HA's ``AllStates``, it exposes per-domain lists (``.sensor``, + ``.binary_sensor``, ``.input_datetime``, ``.input_text``) AND is + callable as ``states(entity_id)`` returning the entity's state + string — or ``None`` when the fixture holds no such entity, the + way HA returns ``None`` for a nonexistent entity id. + """ + + def __init__( + self, binary_sensors=(), sensors=(), input_datetimes=(), input_texts=() + ): + self.binary_sensor = list(binary_sensors) + self.sensor = list(sensors) + self.input_datetime = list(input_datetimes) + self.input_text = list(input_texts) + self._all = ( + self.binary_sensor + + self.sensor + + self.input_datetime + + self.input_text + ) + + def __call__(self, entity_id: str): + """Return the entity's state string, or None when absent.""" + for entity in self._all: + if entity.entity_id == entity_id: + return entity.state + return None + + +def make_states( + binary_sensors=(), sensors=(), input_datetimes=(), input_texts=() +) -> StatesFixture: + """Build the ``states`` fixture: per-domain lists of TemplateState.""" + return StatesFixture( + binary_sensors, sensors, input_datetimes, input_texts + ) + + +def make_template_environment() -> jinja2.Environment: + """A plain jinja2 environment with HA's regex tests and bool filter.""" + env = jinja2.Environment() + env.tests["match"] = lambda value, pattern: re.match(pattern, value) is not None + env.tests["search"] = lambda value, pattern: re.search(pattern, value) is not None + + def _bool(value, default=False): + if isinstance(value, str): + return value.strip().lower() in ("true", "on", "open", "yes", "1") + return bool(value) or default + + env.filters["bool"] = _bool + return env + + +def render(template: str, **variables) -> str: + """Render a template the way HA would substitute its variables.""" + return make_template_environment().from_string(template).render(**variables) + + +# --------------------------------------------------------------------------- +# Entity-reference discipline (Feature 10 shapes only). The scanners +# are shared so every blueprint test file can assert the same rules; +# the directory-wide tests in test_blueprints.py apply them to every +# blueprint file automatically. +# --------------------------------------------------------------------------- +#: Entity shapes Feature 10 actually creates (sensor.py, binary_sensor.py). +ALLOWED_ENTITY_SHAPES: tuple[re.Pattern, ...] = tuple( + re.compile(pattern) + for pattern in ( + r"sensor\.nestquest_[a-z0-9_]+_quests_due_today", + r"sensor\.nestquest_[a-z0-9_]+_quests_remaining_today", + r"sensor\.nestquest_[a-z0-9_]+_quests_completed_today", + r"sensor\.nestquest_[a-z0-9_]+_completion_pct_today", + r"sensor\.nestquest_[a-z0-9_]+_next_quest", + r"binary_sensor\.nestquest_[a-z0-9_]+_all_done", + r"binary_sensor\.nestquest_[a-z0-9_]+_present_today", + r"sensor\.nestquest_household_quests_due_today", + r"sensor\.nestquest_household_quests_completed_today", + r"sensor\.nestquest_cycle_day", + ) +) + +#: An unescaped, id-like entity reference: any found must be a full +#: match for one of the Feature 10 shapes above. +_ENTITY_LITERAL_RE = re.compile(r"(? str | None: + return self._attr_name + + @property + def unique_id(self) -> str | None: + return self._attr_unique_id + + @property + def device_info(self) -> DeviceInfo | None: + return self._attr_device_info + + @property + def extra_state_attributes(self) -> dict | None: + return self._attr_extra_state_attributes + + def async_write_ha_state(self) -> None: + """Register the entity under its unique_id (the mock state write).""" + if self.hass is not None and self.unique_id is not None: + self.hass.entities[self.unique_id] = self + + +class CoordinatorEntity(Entity): + """Stand-in for homeassistant.helpers.update_coordinator.CoordinatorEntity. + + Real HA subscribes the entity to coordinator notifications in the + platform-scheduler-driven ``async_added_to_hass`` lifecycle hook; + the stand-in subscribes eagerly in ``__init__`` so the synchronous + ``AddEntitiesCallback`` surface stays synchronous (the mock + harness has no lifecycle scheduler). ``_handle_coordinator_update`` + mirrors HA: one state write per coordinator notification. + """ + + def __init__(self, coordinator) -> None: + self.coordinator = coordinator + self.coordinator.async_add_listener(self._handle_coordinator_update) + + def _handle_coordinator_update(self) -> None: + self.async_write_ha_state() + + +class SensorStateClass: + """Stand-in namespace for homeassistant.components.sensor.SensorStateClass. + + Only the members the integration uses (plus TOTAL for the + long-history sensors later tasks add). + """ + + MEASUREMENT = "measurement" + TOTAL = "total" + + +class SensorDeviceClass: + """Stand-in namespace for homeassistant.components.sensor.SensorDeviceClass. + + Only the members the integration uses. + """ + + PERCENTAGE = "percentage" + + +class SensorEntity(Entity): + """Stand-in for homeassistant.components.sensor.SensorEntity. + + The ``_attr_*`` passthrough for ``native_value``, + ``native_unit_of_measurement``, ``state_class``, ``device_class`` + and ``suggested_display_precision``. + """ + + _attr_native_value: object = None + _attr_native_unit_of_measurement: str | None = None + _attr_state_class: str | None = None + _attr_device_class: str | None = None + _attr_suggested_display_precision: int | None = None + + @property + def native_value(self) -> object: + return self._attr_native_value + + @property + def native_unit_of_measurement(self) -> str | None: + return self._attr_native_unit_of_measurement + + @property + def state_class(self) -> str | None: + return self._attr_state_class + + @property + def device_class(self) -> str | None: + return self._attr_device_class + + @property + def suggested_display_precision(self) -> int | None: + return self._attr_suggested_display_precision + + +class BinarySensorEntity(Entity): + """Stand-in for homeassistant.components.binary_sensor.BinarySensorEntity. + + Wired now so PLATFORMS can grow (task 4's all-done / present + sensors) without more harness surgery; only the surface the + integration uses. + """ + + _attr_is_on: bool | None = None + _attr_device_class: str | None = None + + @property + def is_on(self) -> bool | None: + return self._attr_is_on + + @property + def device_class(self) -> str | None: + return self._attr_device_class + + +class DataUpdateCoordinator: + """Functional stand-in for HA's DataUpdateCoordinator. + + Mirrors the surface NestQuest's coordinator subclass uses: + ``__init__(hass, logger, name=..., update_interval=...)``, + ``async_add_listener``/``async_update_listeners`` for entity + subscription, ``async_refresh``/``async_config_entry_first_refresh`` + driving the subclass's ``_async_update_data``, ``last_update_success``, + and ``data``. Interval scheduling is NOT simulated (no real clock + ticks in tests) — tests drive refreshes directly. + """ + + def __init__(self, hass, logger, *, name=None, update_interval=None): + self.hass = hass + self.logger = logger + self.name = name + self.update_interval = update_interval + self.data = None + self.last_update_success = False + self._listeners = [] + + def async_add_listener(self, update_callback, context=None): + """Subscribe; returns an unsubscribe callable like HA's.""" + entry = (update_callback, context) + self._listeners.append(entry) + + def _remove(): + if entry in self._listeners: + self._listeners.remove(entry) + + return _remove + + def async_update_listeners(self): + """Notify every listener (entities schedule their writes).""" + for callback, _context in list(self._listeners): + callback() + + async def async_refresh(self): + """Run one update pass and notify listeners.""" + try: + self.data = await self._async_update_data() + self.last_update_success = True + except BaseException: + self.last_update_success = False + raise + self.async_update_listeners() + + async def async_config_entry_first_refresh(self): + """First refresh at config-entry setup (alias of refresh).""" + await self.async_refresh() + + async def async_shutdown(self): + """Release listeners on teardown.""" + self._listeners.clear() + + +_update_coordinator_mock = _ha_mock("homeassistant.helpers.update_coordinator") +_update_coordinator_mock.DataUpdateCoordinator = DataUpdateCoordinator +_update_coordinator_mock.CoordinatorEntity = CoordinatorEntity + +_entity_mock = _ha_mock("homeassistant.helpers.entity") +_entity_mock.DeviceInfo = DeviceInfo +_entity_mock.Entity = Entity + +_sensor_mock = _ha_mock("homeassistant.components.sensor") +_sensor_mock.SensorEntity = SensorEntity +_sensor_mock.SensorDeviceClass = SensorDeviceClass +_sensor_mock.SensorStateClass = SensorStateClass + +_binary_sensor_mock = _ha_mock("homeassistant.components.binary_sensor") +_binary_sensor_mock.BinarySensorEntity = BinarySensorEntity + + +@dataclass(frozen=True) +class StaticPathConfig: + """Stand-in mirroring homeassistant.components.http.StaticPathConfig.""" + + url_path: str + path: str + cache_headers: bool = True + + +def _add_extra_js_url(hass, url, es5=False): + """Stand-in mirroring homeassistant.components.frontend.add_extra_js_url.""" + urls = getattr(hass, "extra_js_urls", None) + if urls is None: + hass.extra_js_urls = [] + urls = hass.extra_js_urls + urls.append(url) + + +_ha_mock("homeassistant.components.http").StaticPathConfig = StaticPathConfig +_ha_mock("homeassistant.components.frontend").add_extra_js_url = _add_extra_js_url import voluptuous as vol @@ -307,6 +575,41 @@ def _async_track_time_change(hass, action, hour=None, minute=None, second=None): _ha_mock("homeassistant.helpers.event").async_track_time_change = _async_track_time_change +class HttpRegistry: + """Models HA's ``hass.http`` static-path registration.""" + + def __init__(self): + self.static_paths: list[StaticPathConfig] = [] + + async def async_register_static_paths(self, configs): + self.static_paths.extend(list(configs)) + + +class EventBus: + """Models HA's ``hass.bus`` event bus. + + ``async_fire`` is SYNCHRONOUS (matching real HA) and records + ``(event_type, payload)`` pairs in order so tests can assert + exactly which events fired with which payloads; there is no + listener dispatch (entities poll the coordinator, and the cards + are not part of this harness). + """ + + def __init__(self): + self.events: list[tuple[str, dict]] = [] + + def async_fire(self, event_type: str, payload=None): + self.events.append((event_type, dict(payload or {}))) + + def fired(self, event_type: str) -> list[dict]: + """Return every payload fired for ``event_type``, in order.""" + return [ + payload + for fired_type, payload in self.events + if fired_type == event_type + ] + + class ServiceRegistry: """Models HA's ``hass.services`` service registry. @@ -325,7 +628,7 @@ class ServiceRegistry: self._services: dict[tuple[str, str], object] = {} def async_register(self, domain, service, func, schema=None, supports_response=None): - self._services[(domain, service)] = func + self._services[(domain, service)] = (func, schema) return None def has_service(self, domain, service): @@ -335,11 +638,26 @@ class ServiceRegistry: self._services.pop((domain, service), None) return None - async def call(self, domain, service, data=None): - func = self._services.get((domain, service)) - if func is None: + async def call(self, domain, service, data=None, context=None): + registered = self._services.get((domain, service)) + if registered is None: raise KeyError(f"service {domain}.{service} not registered") - call = SimpleNamespace(domain=domain, service=service, data=data or {}) + func, schema = registered + payload = dict(data or {}) + if schema is not None: + payload = schema(payload) + if context is None: + context_obj = SimpleNamespace(user_id=None) + elif isinstance(context, dict): + context_obj = SimpleNamespace(**context) + else: + context_obj = context + call = SimpleNamespace( + domain=domain, + service=service, + data=payload, + context=context_obj, + ) result = func(call) if inspect.isawaitable(result): await result @@ -414,10 +732,87 @@ def make_hass() -> tuple: registry = ListenerRegistry(hass) hass.time_change = TimeChangeRegistry(hass) hass.services = ServiceRegistry(hass) + hass.bus = EventBus() + hass.http = HttpRegistry() + hass.extra_js_urls: list[str] = [] # A valid IANA time zone (the day-rollover listener reads it to # compute "today" in HA local time). hass.config.time_zone = "UTC" + # The minimal entity registry: entities self-register through + # Entity.async_write_ha_state keyed by unique_id, storing the live + # object; tests enumerate created entities and read their state + # directly. entities_by_entry mirrors HA's per-entry platform + # scoping: unloading an entry's platforms removes exactly that + # entry's entities. + hass.entities: dict[str, Entity] = {} + hass.entities_by_entry: dict[str, dict[str, Entity]] = {} + + def _make_add_entities(entry): + entry_entities = hass.entities_by_entry.setdefault(entry.entry_id, {}) + + def _async_add_entities(new_entities, update_before_add=False): + """Mirror HA's AddEntitiesCallback: bind hass and register. + + Accepts a single entity or an iterable, mirroring HA's + permissive call shape; registration goes through each + entity's async_write_ha_state (the mock state write) and + is recorded per entry so an unload removes exactly this + entry's entities. + """ + if isinstance(new_entities, Entity): + new_entities = [new_entities] + for entity in new_entities: + entity.hass = hass + entity.async_write_ha_state() + if entity.unique_id is not None: + entry_entities[entity.unique_id] = entity + + return _async_add_entities + + async def _async_forward_entry_setups(entry, platforms): + """Mirror hass.config_entries.async_forward_entry_setups. + + HA's contract, loosely: import each platform module of the + entry's integration and drive its ``async_setup_entry`` with a + per-entry AddEntitiesCallback. A stub entry without a domain + defaults to this harness's single integration (NestQuest). + """ + for platform in list(platforms): + domain = getattr(entry, "domain", "nestquest") + module = importlib.import_module( + f"custom_components.{domain}.{platform}" + ) + await module.async_setup_entry( + hass, entry, _make_add_entities(entry) + ) + hass.config_entries.forwarded_platforms.append(platform) + + async def _async_unload_platforms(entry, platforms): + """Mirror hass.config_entries.async_unload_platforms. + + Runs each platform module's ``async_unload_entry`` when it + defines one (per-platform unload is optional, like HA's), then + removes the entry's entities — unloading an entry's platforms + removes exactly that entry's entities, never another entry's. + """ + for platform in list(platforms): + domain = getattr(entry, "domain", "nestquest") + module = importlib.import_module( + f"custom_components.{domain}.{platform}" + ) + unload = getattr(module, "async_unload_entry", None) + if unload is not None: + await unload(hass, entry) + hass.config_entries.unloaded_platforms.append(platform) + for unique_id in hass.entities_by_entry.pop(entry.entry_id, {}): + hass.entities.pop(unique_id, None) + + hass.config_entries.forwarded_platforms: list[str] = [] + hass.config_entries.unloaded_platforms: list[str] = [] + hass.config_entries.async_forward_entry_setups = _async_forward_entry_setups + hass.config_entries.async_unload_platforms = _async_unload_platforms + async def _async_reload(entry_id: str) -> None: registry.reloaded.append(entry_id) diff --git a/tests/test_binary_sensors.py b/tests/test_binary_sensors.py new file mode 100644 index 0000000..a4a31e2 --- /dev/null +++ b/tests/test_binary_sensors.py @@ -0,0 +1,184 @@ +"""Tests for the per-child binary sensors (Feature 10, task 4).""" +from __future__ import annotations + +import datetime + +from conftest import wire_entry_to_registry + +from custom_components.nestquest import async_setup_entry +from custom_components.nestquest.children import create_child, list_children +from custom_components.nestquest.const import DOMAIN +from custom_components.nestquest.dao_presence import PresenceSchedulesDao +from custom_components.nestquest.materialize import materialize +from custom_components.nestquest.quest_definitions import ( + create_quest_definition, +) +from custom_components.nestquest.recurrence import ScheduleRule +from custom_components.nestquest.completion import complete_instance + +SEED_HORIZON_DAYS = 3 + + +def _binary(hass, child_id: int, kind: str): + return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"] + + +async def _setup_entry(hass, make_entry): + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + return entry + + +async def _seed_two_children(database) -> tuple: + ada = await create_child(database, "Ada") + bo = await create_child(database, "Bo") + today = datetime.date.today() + await create_quest_definition( + database, + "Brush teeth", + ScheduleRule.from_dict( + {"rule_type": "daily", "start_date": today.isoformat()} + ), + [ada.id, bo.id], + ["morning"], + ) + end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat() + await materialize(database, today.isoformat(), end, today=today) + return ada, bo + + +async def test_all_done_off_with_open_quests_and_zero_quests( + hass, make_entry +) -> None: + """all_done is OFF while quests are owed AND on a zero-quest day; + a child with no quests never fires the celebration automation.""" + entry = await _setup_entry(hass, make_entry) + coordinator = entry.runtime_data.coordinator + database = entry.runtime_data.database + ada, _bo = await _seed_two_children(database) + await create_child(database, "Cory") # zero quests + await coordinator.async_refresh() + + assert _binary(hass, ada.id, "all_done").is_on is False + cory = [ + child for child in coordinator.data.children + if child.child_name == "Cory" + ][0] + assert _binary(hass, cory.child_id, "all_done").is_on is False + + +async def test_all_done_on_only_when_day_clear(hass, make_entry) -> None: + entry = await _setup_entry(hass, make_entry) + coordinator = entry.runtime_data.coordinator + database = entry.runtime_data.database + ada, _bo = await _seed_two_children(database) + await coordinator.async_refresh() + snapshot_ada = [ + child for child in coordinator.data.children + if child.child_id == ada.id + ][0] + instance_id = snapshot_ada.instances[0].instance_id + await complete_instance( + database, + instance_id, + actor_source="panel", + actor_child_id=ada.id, + ) + await coordinator.async_refresh() + assert _binary(hass, ada.id, "all_done").is_on is True + + +async def test_present_today_on_without_schedule_off_when_absent( + hass, make_entry +) -> None: + """No schedule means present every day; an all-absent pattern + today reports OFF.""" + entry = await _setup_entry(hass, make_entry) + coordinator = entry.runtime_data.coordinator + database = entry.runtime_data.database + ada, _bo = await _seed_two_children(database) + await create_child(database, "Cory") + today = datetime.date.today() + await coordinator.async_refresh() + cory = [ + child for child in coordinator.data.children + if child.child_name == "Cory" + ][0] + await PresenceSchedulesDao(database).upsert_by_child( + cory.child_id, 1, today.isoformat(), "" + ) + await coordinator.async_refresh() + + assert _binary(hass, ada.id, "present_today").is_on is True + assert _binary(hass, cory.child_id, "present_today").is_on is False + + +async def test_binary_sensor_identity_and_attributes(hass, make_entry) -> None: + entry = await _setup_entry(hass, make_entry) + coordinator = entry.runtime_data.coordinator + database = entry.runtime_data.database + ada, _bo = await _seed_two_children(database) + await coordinator.async_refresh() + + all_done = _binary(hass, ada.id, "all_done") + present = _binary(hass, ada.id, "present_today") + assert all_done.unique_id == f"nestquest_child_{ada.id}_all_done" + assert present.unique_id == f"nestquest_child_{ada.id}_present_today" + assert all_done.name == "NestQuest Ada all done" + assert present.name == "NestQuest Ada present today" + for entity in (all_done, present): + assert entity.device_info.name == "NestQuest Ada" + assert all_done.extra_state_attributes == { + "child_id": ada.id, + "child_name": "Ada", + } + assert present.extra_state_attributes == { + "child_id": ada.id, + "child_name": "Ada", + "next_present": None, + } + + +async def test_present_today_carries_next_present_when_away( + hass, make_entry +) -> None: + """A child away today carries the ISO date of their next present + day (the panel's away plate renders ``Returns , `` + from it); an always-absent pattern carries ``None``.""" + entry = await _setup_entry(hass, make_entry) + coordinator = entry.runtime_data.coordinator + database = entry.runtime_data.database + ada, _bo = await _seed_two_children(database) + await create_child(database, "Cory") + today = datetime.date.today() + pattern = ",".join( + str(day) for day in range(7) if day != today.weekday() + ) + await PresenceSchedulesDao(database).upsert_by_child( + ada.id, 1, today.isoformat(), pattern + ) + await PresenceSchedulesDao(database).upsert_by_child( + await _cory_id(database), 1, today.isoformat(), "" + ) + await coordinator.async_refresh() + + assert _binary(hass, ada.id, "present_today").is_on is False + assert _binary(hass, ada.id, "present_today").extra_state_attributes[ + "next_present" + ] == (today + datetime.timedelta(days=1)).isoformat() + + cory = [ + child for child in coordinator.data.children + if child.child_name == "Cory" + ][0] + assert _binary(hass, cory.child_id, "present_today").is_on is False + assert _binary(hass, cory.child_id, "present_today").extra_state_attributes[ + "next_present" + ] is None + + +async def _cory_id(database) -> int: + for child in await list_children(database): + if child.display_name == "Cory": + return child.id + raise AssertionError("Cory was not created") diff --git a/tests/test_blueprint_afternoon.py b/tests/test_blueprint_afternoon.py new file mode 100644 index 0000000..d721bbc --- /dev/null +++ b/tests/test_blueprint_afternoon.py @@ -0,0 +1,268 @@ +"""Tests for the NestQuest afternoon-reminder automation blueprint. + +The afternoon reminder nudges only the stragglers: a child appears +exactly when they are PRESENT today and still have open quests. +Present-and-clear children, absent children, and the household rollup +sensors are all omitted, and when nobody qualifies the automation +sends nothing at all — the all-clear template condition stops the run +before the notification. The harness (YAML ``!input`` loader, state +fixtures, jinja rendering) is shared in +:mod:`tests.blueprint_helpers`; the directory-wide shape and +entity-discipline tests in test_blueprints.py cover this file too. +""" +from __future__ import annotations + +import re + +from custom_components.nestquest.const import DEFAULT_AFTERNOON_REMINDER_TIME + +from tests.blueprint_helpers import ( + notify_data, + BlueprintInput, + blueprint, + iter_strings, + make_states, + render, + single_action, + TemplateState, +) + +AFTERNOON_TITLE = "NestQuest afternoon reminder" + + +def afternoon_reminder() -> dict: + """Return the parsed afternoon-reminder blueprint.""" + return blueprint("afternoon_reminder") + + +def afternoon_conditions(bp: dict) -> list[dict]: + """Return the blueprint's conditions: toggle first, guard second.""" + conditions = bp["conditions"] + assert isinstance(conditions, list) and len(conditions) == 2 + return conditions + + +def all_clear_guard(bp: dict) -> str: + """Return the template that is false when every present child is done.""" + return afternoon_conditions(bp)[1]["value_template"] + + +# --------------------------------------------------------------------------- +# Inputs, defaults, and wiring. +# --------------------------------------------------------------------------- +def test_afternoon_reminder_declares_inputs_with_selectors() -> None: + """The three inputs exist, carry selectors, and default sanely.""" + inputs = afternoon_reminder()["blueprint"]["input"] + expected = {"afternoon_time", "notify_target", "send_reminder"} + assert set(inputs) == expected + for name, spec in inputs.items(): + assert isinstance(spec["selector"], dict) and spec["selector"], name + assert inputs["afternoon_time"]["default"] == DEFAULT_AFTERNOON_REMINDER_TIME + assert DEFAULT_AFTERNOON_REMINDER_TIME == "15:00" + assert inputs["send_reminder"]["default"] is True + assert "time" in inputs["afternoon_time"]["selector"] + assert "text" in inputs["notify_target"]["selector"] + assert "boolean" in inputs["send_reminder"]["selector"] + + +def test_afternoon_reminder_wires_inputs_not_literals() -> None: + """Time trigger, toggle and guard conditions, and notify action use !input.""" + bp = afternoon_reminder() + assert bp["variables"]["send_reminder"] == BlueprintInput("send_reminder") + + trigger = bp["triggers"][0] + assert trigger["trigger"] == "time" + assert trigger["at"] == BlueprintInput("afternoon_time") + + toggle, guard = afternoon_conditions(bp) + assert toggle["condition"] == "template" + assert "send_reminder" in toggle["value_template"] + assert guard["condition"] == "template" + + action = single_action(bp) + assert action["action"] == BlueprintInput("notify_target") + data = notify_data(afternoon_reminder()) + assert data["title"] == AFTERNOON_TITLE + assert isinstance(data["message"], str) and data["message"].strip() + + +# --------------------------------------------------------------------------- +# Rendering: only present children with open quests are listed. +# --------------------------------------------------------------------------- +def test_afternoon_reminder_lists_only_present_children_with_open_quests() -> None: + """Present+open Alice is listed; present+clear Carol, absent Bob, + and the household rollup are all omitted.""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "2", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_carol_quests_due_today", + "2", + {"child_name": "Carol", "child_id": 2, "present": True}, + ), + TemplateState( + "sensor.nestquest_carol_quests_remaining_today", + "0", + {"child_id": 2}, + ), + TemplateState( + "sensor.nestquest_bob_quests_due_today", + "2", + {"child_name": "Bob", "child_id": 3, "present": False}, + ), + TemplateState( + "sensor.nestquest_bob_quests_remaining_today", + "1", + {"child_id": 3}, + ), + TemplateState("sensor.nestquest_household_quests_due_today", "7"), + ], + ) + rendered = render(notify_data(afternoon_reminder())["message"], states=states) + assert rendered == "Alice still has 2 quest(s) open." + assert "Carol" not in rendered + assert "Bob" not in rendered + assert "household" not in rendered + + +def test_afternoon_reminder_all_clear_guard_renders_false() -> None: + """With every present child done (and absent children owing), the + guard condition is false: the automation sends nothing at all.""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "2", + {"child_name": "Alice", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "0", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_bob_quests_due_today", + "2", + {"child_name": "Bob", "child_id": 2, "present": False}, + ), + TemplateState( + "sensor.nestquest_bob_quests_remaining_today", + "1", + {"child_id": 2}, + ), + TemplateState("sensor.nestquest_household_quests_due_today", "2"), + ], + ) + guard = all_clear_guard(afternoon_reminder()) + assert render(guard, states=states).strip() == "False" + + +def test_afternoon_reminder_guard_renders_true_when_a_present_child_owes() -> None: + """A present child with open quests makes the guard true.""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "1", + {"child_id": 1}, + ), + ], + ) + guard = all_clear_guard(afternoon_reminder()) + assert render(guard, states=states).strip() == "True" + + +def test_afternoon_reminder_send_reminder_toggle_renders() -> None: + """The enable condition renders truthy/false from the input value.""" + toggle = afternoon_conditions(afternoon_reminder())[0] + assert render(toggle["value_template"], send_reminder=True).strip() == "True" + assert render(toggle["value_template"], send_reminder=False).strip() == "False" + + +def test_afternoon_reminder_collision_suffixed_duplicate_names() -> None: + """Two children sharing a display name (HA registry suffix _2 on the + second one's entity ids) both appear, each with their own count.""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_ada_quests_due_today", + "2", + {"child_name": "Ada", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_ada_quests_remaining_today", + "1", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_ada_quests_due_today_2", + "3", + {"child_name": "Ada", "child_id": 2, "present": True}, + ), + TemplateState( + "sensor.nestquest_ada_quests_remaining_today_2", + "3", + {"child_id": 2}, + ), + ], + ) + rendered = render(notify_data(afternoon_reminder())["message"], states=states) + assert rendered.split("\n") == [ + "Ada still has 1 quest(s) open.", + "Ada still has 3 quest(s) open.", + ] + + +def test_afternoon_reminder_never_hard_codes_notify_target() -> None: + """No notify service literal outside the input's placeholder text.""" + bp = afternoon_reminder() + spec = bp["blueprint"] + assert not re.search(r"notify\.[a-z_]+", spec["description"]) + + outside_input = {k: v for k, v in bp.items() if k != "blueprint"} + for value in iter_strings(outside_input): + assert not re.search(r"notify\.[a-z_.]+", value), value + + target_description = spec["input"]["notify_target"]["description"] + assert re.search(r"notify\.[a-z_]+", target_description), ( + "the input description should show a placeholder example" + ) + assert single_action(bp)["action"] == BlueprintInput("notify_target") + +def test_afternoon_reminder_absent_child_regression() -> None: + """The Feature 11 guardrail, stated as one regression test: no + reminder message is ever produced for an absent child.""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_bob_quests_due_today", + "3", + {"child_name": "Bob", "child_id": 1, "present": False}, + ), + TemplateState( + "sensor.nestquest_bob_quests_remaining_today", + "3", + {"child_id": 1}, + ), + ], + ) + guard = afternoon_conditions(afternoon_reminder())[-1] + assert render(guard["value_template"], states=states).strip() == "False" + message = render( + notify_data(afternoon_reminder())["message"], states=states + ) + assert "Bob" not in message diff --git a/tests/test_blueprint_celebration.py b/tests/test_blueprint_celebration.py new file mode 100644 index 0000000..f877854 --- /dev/null +++ b/tests/test_blueprint_celebration.py @@ -0,0 +1,313 @@ +"""Tests for the NestQuest day-complete celebration automation blueprint. + +The celebration fires on the ``nestquest_child_day_complete`` event — +which NestQuest only fires when quests were owed and all are complete, +so a zero-quest day can never celebrate. Dedupe design (per-child, +per-day): the household points ONE ``input_text`` helper at the +blueprint; the condition celebrates only when THIS child has no +``:`` entry stored, and the action rewrites the +helper with only today's entries plus the new one — so a child who +un-completes and re-completes the same day is celebrated once, not +twice, while a second child clearing the same day is still celebrated. + +The harness (YAML ``!input`` loader, state fixtures, jinja rendering) +is shared in :mod:`tests.blueprint_helpers`; the directory-wide shape +and entity-discipline tests in test_blueprints.py cover this file +too. ``now()`` in the dedupe condition is injected by the test as a +fixed callable, keeping the render deterministic. +""" +from __future__ import annotations + +import datetime +import re + +from custom_components.nestquest.const import EVENT_CHILD_DAY_COMPLETE + +from tests.blueprint_helpers import ( + BlueprintInput, + notify_data, + blueprint, + iter_strings, + make_states, + render, + TemplateState, +) + +CELEBRATION_TITLE = "NestQuest day complete" + +HELPER_ENTITY = "input_text.nestquest_last_celebrated" + +#: The fixed "now" every dedupe render uses: 2026-09-19 15:00 local. +FIXED_NOW = datetime.datetime(2026, 9, 19, 15, 0, 0) + + +def celebration() -> dict: + """Return the parsed day-complete-celebration blueprint.""" + return blueprint("day_complete_celebration") + + +def celebration_conditions(bp: dict) -> list[dict]: + """Return the blueprint's conditions: toggle first, dedupe second.""" + conditions = bp["conditions"] + assert isinstance(conditions, list) and len(conditions) == 2 + return conditions + + +def dedupe_condition(bp: dict) -> str: + """Return the once-per-child-per-day dedupe template (true = celebrate).""" + return celebration_conditions(bp)[1]["value_template"] + + +def stamp_value_template(bp: dict) -> str: + """Return the helper-stamp action's value template.""" + stamp = bp["actions"][1] + return stamp["data"]["value"] + + +def trigger_payload(child_id: int, child_name: str) -> dict: + """A nestquest_child_day_complete trigger fixture: HA exposes an + event trigger's payload as trigger.event.data.""" + return { + "event": { + "data": { + "child_id": child_id, + "child_name": child_name, + "quests_due": 3, + "quests_completed": 3, + "occurred_at": "2026-09-19T15:00:00+00:00", + } + } + } + + +def render_dedupe( + helper_state: str | None, child_id: int = 1 +) -> str: + """Render the dedupe condition with the helper holding helper_state. + + ``None`` models a helper entity the fixture does not hold (never + created or not yet set), which HA exposes as a missing state. + """ + states = make_states( + input_texts=( + [] + if helper_state is None + else [TemplateState(HELPER_ENTITY, helper_state)] + ) + ) + return render( + dedupe_condition(celebration()), + states=states, + last_celebrated_helper=HELPER_ENTITY, + now=lambda: FIXED_NOW, + trigger=trigger_payload(child_id, "Ada"), + ).strip() + + +def render_stamp(helper_state: str | None, child_id: int = 1) -> str: + """Render the stamp action's value with the helper holding helper_state.""" + states = make_states( + input_texts=( + [] + if helper_state is None + else [TemplateState(HELPER_ENTITY, helper_state)] + ) + ) + return render( + stamp_value_template(celebration()), + states=states, + last_celebrated_helper=HELPER_ENTITY, + now=lambda: FIXED_NOW, + trigger=trigger_payload(child_id, "Ada"), + ).strip() + + +# --------------------------------------------------------------------------- +# Trigger, inputs, defaults, and wiring. +# --------------------------------------------------------------------------- +def test_celebration_triggers_on_the_child_day_complete_event() -> None: + """The event trigger references the real nestquest_child_day_complete.""" + trigger = celebration()["triggers"][0] + assert trigger["trigger"] == "event" + assert trigger["event_type"] == "nestquest_child_day_complete" + assert trigger["event_type"] == EVENT_CHILD_DAY_COMPLETE + + +def test_celebration_declares_inputs_with_selectors() -> None: + """The four inputs exist, carry selectors, and default sanely.""" + bp = celebration() + inputs = bp["blueprint"]["input"] + expected = { + "notify_target", + "celebration_message", + "celebrate", + "last_celebrated_helper", + } + assert set(inputs) == expected + for name, spec in inputs.items(): + assert isinstance(spec["selector"], dict) and spec["selector"], name + assert inputs["celebrate"]["default"] is True + assert "text" in inputs["notify_target"]["selector"] + assert "text" in inputs["celebration_message"]["selector"] + assert "boolean" in inputs["celebrate"]["selector"] + assert inputs["last_celebrated_helper"]["selector"]["entity"]["filter"] == [ + {"domain": "input_text"} + ] + # The description documents the zero-quest-day rule and the dedupe. + description = bp["blueprint"]["description"] + assert "zero-quest day" in description + assert "once" in description.lower() + + +def test_celebration_wires_inputs_not_literals() -> None: + """Notify and stamp actions both use !input; the dedupe reads the helper.""" + bp = celebration() + assert bp["variables"]["celebrate"] == BlueprintInput("celebrate") + assert bp["variables"]["last_celebrated_helper"] == BlueprintInput( + "last_celebrated_helper" + ) + + toggle, dedupe = celebration_conditions(bp) + assert toggle["condition"] == "template" + assert "celebrate" in toggle["value_template"] + assert dedupe["condition"] == "template" + assert "last_celebrated_helper" in dedupe["value_template"] + assert "child_id" in dedupe["value_template"], ( + "the dedupe must be per child, not per household" + ) + + notify_action, stamp_action = bp["actions"] + assert notify_action["action"] == BlueprintInput("notify_target") + data = notify_data(bp) + assert data["title"] == CELEBRATION_TITLE + assert data["message"] == BlueprintInput("celebration_message") + + assert stamp_action["action"] == "input_text.set_value" + assert stamp_action["target"]["entity_id"] == BlueprintInput( + "last_celebrated_helper" + ) + assert "now()" in stamp_action["data"]["value"] + + +def test_celebration_message_default_renders_the_child_name() -> None: + """The default message renders the trigger payload's child_name.""" + default_message = celebration()["blueprint"]["input"]["celebration_message"][ + "default" + ] + rendered = render(default_message, trigger=trigger_payload(1, "Ada")) + assert rendered == "Legendary! Ada cleared every quest today!" + + +# --------------------------------------------------------------------------- +# The once-per-child-per-day dedupe condition. +# --------------------------------------------------------------------------- +def test_celebration_dedupe_true_when_child_not_celebrated_today() -> None: + """A helper without THIS child's entry for today lets the + celebration through — including when another child was celebrated + earlier the same day.""" + assert render_dedupe(None) == "True" + assert render_dedupe("") == "True" + assert render_dedupe("2:2026-09-19") == "True", ( + "a second child clearing the same day must still celebrate" + ) + assert render_dedupe("1:2026-09-18") == "True", ( + "yesterday's entry never blocks today" + ) + + +def test_celebration_dedupe_false_when_child_already_celebrated_today() -> None: + """A child already celebrated today is suppressed — the un-complete + and re-complete the same day case.""" + assert render_dedupe("1:2026-09-19") == "False" + assert render_dedupe("2:2026-09-19,1:2026-09-19") == "False" + + +def test_celebration_stamp_prunes_to_today_and_appends() -> None: + """The stamp keeps only today's entries plus the new one: yesterday's + entries are dropped (the helper never outgrows its text limit and + stale entries cannot block next week), and the celebrated child is + recorded.""" + assert render_stamp(None, child_id=1) == "1:2026-09-19" + assert render_stamp("1:2026-09-18", child_id=2) == "2:2026-09-19" + assert render_stamp("1:2026-09-19", child_id=2) == ( + "1:2026-09-19,2:2026-09-19" + ) + assert render_stamp("1:2026-09-18,2:2026-09-18", child_id=3) == ( + "3:2026-09-19" + ) + + +def test_celebration_celebrate_toggle_renders() -> None: + """The enable condition renders truthy/false from the input value.""" + toggle = celebration_conditions(celebration())[0] + assert render(toggle["value_template"], celebrate=True).strip() == "True" + assert render(toggle["value_template"], celebrate=False).strip() == "False" + + +def test_celebration_never_hard_codes_notify_target() -> None: + """No notify service literal outside the input's placeholder text.""" + bp = celebration() + spec = bp["blueprint"] + assert not re.search(r"notify\.[a-z_]+", spec["description"]) + + outside_input = {k: v for k, v in bp.items() if k != "blueprint"} + for value in iter_strings(outside_input): + assert not re.search(r"notify\.[a-z_.]+", value), value + + target_description = spec["input"]["notify_target"]["description"] + assert re.search(r"notify\.[a-z_]+", target_description), ( + "the input description should show a placeholder example" + ) + assert bp["actions"][0]["action"] == BlueprintInput("notify_target") + +def test_celebration_dedupe_entry_equality_not_substring() -> None: + """Child ids sharing a prefix must not suppress each other: after + child 11 is celebrated, child 1 still celebrates (whole-entry + comparison, not substring).""" + assert render_dedupe("11:2026-09-19", child_id=11) == "False" + assert render_dedupe("11:2026-09-19", child_id=1) == "True", ( + "child 1's entry must not be found inside child 11's entry" + ) + assert render_dedupe("1:2026-09-19", child_id=11) == "True" + + +def test_celebration_mode_is_queued() -> None: + """Two children completing at nearly the same time must both be + processed: queued mode does not drop the second event.""" + assert celebration()["mode"] == "queued" + + +def test_every_blueprint_event_reference_is_real() -> None: + """Every event a blueprint triggers on is one NestQuest actually + fires or defines (Feature 10/11) — no invented event names.""" + from tests.blueprint_helpers import load_blueprints + + real_events = { + "nestquest_quest_completed", + "nestquest_quest_uncompleted", + "nestquest_quest_missed", + "nestquest_child_day_complete", + } + for name, bp in load_blueprints().items(): + for trigger in bp.get("triggers", []): + if trigger.get("trigger") == "event": + assert trigger["event_type"] in real_events, ( + f"{name}: invented event {trigger['event_type']!r}" + ) + assert celebration()["triggers"][0]["event_type"] == ( + "nestquest_child_day_complete" + ) + + +def test_celebration_uses_the_event_trigger_data_path() -> None: + """HA exposes an event trigger's payload as trigger.event.data — + never trigger.payload (Codex review regression).""" + bp = celebration() + for value in iter_strings(bp): + assert "trigger.payload" not in value, value + default_message = bp["blueprint"]["input"]["celebration_message"][ + "default" + ] + assert "trigger.event.data.child_name" in default_message + for condition in celebration_conditions(bp): + assert "trigger.payload" not in condition["value_template"] diff --git a/tests/test_blueprint_end_of_day.py b/tests/test_blueprint_end_of_day.py new file mode 100644 index 0000000..58856c5 --- /dev/null +++ b/tests/test_blueprint_end_of_day.py @@ -0,0 +1,338 @@ +"""Tests for the NestQuest end-of-day report automation blueprint. + +The end-of-day report closes the day for the parent: every PRESENT +child gets a completion line (so the parent always receives the day's +summary when the report runs), children who still owe quests get their +open count called out on that line, and absent children are omitted +entirely. The report runs deliberately before the midnight +missed-quest sweep so it reads the same day's data, and by default an +all-clear day sends nothing — unless the household opts into +send-even-when-clear. The harness (YAML ``!input`` loader, state +fixtures, jinja rendering) is shared in +:mod:`tests.blueprint_helpers`; the directory-wide shape and +entity-discipline tests in test_blueprints.py cover this file too. +""" +from __future__ import annotations + +import re + +from custom_components.nestquest.const import DEFAULT_END_OF_DAY_REPORT_TIME + +from tests.blueprint_helpers import ( + BlueprintInput, + notify_data, + blueprint, + iter_strings, + make_states, + render, + single_action, + TemplateState, +) + +REPORT_TITLE = "NestQuest end-of-day report" + + +def end_of_day_report() -> dict: + """Return the parsed end-of-day-report blueprint.""" + return blueprint("end_of_day_report") + + +def report_conditions(bp: dict) -> list[dict]: + """Return the blueprint's conditions: toggle first, guard second.""" + conditions = bp["conditions"] + assert isinstance(conditions, list) and len(conditions) == 2 + return conditions + + +def clear_guard(bp: dict) -> str: + """Return the template guarding an all-clear day (false = no report).""" + return report_conditions(bp)[1]["value_template"] + + +def owing_clear_absent_fixture(): + """Alice owing (1/3 done, 2 open), Carol present and clear, Bob absent.""" + return make_states( + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "2", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_alice_quests_completed_today", + "1", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_carol_quests_due_today", + "2", + {"child_name": "Carol", "child_id": 2, "present": True}, + ), + TemplateState( + "sensor.nestquest_carol_quests_remaining_today", + "0", + {"child_id": 2}, + ), + TemplateState( + "sensor.nestquest_carol_quests_completed_today", + "2", + {"child_id": 2}, + ), + TemplateState( + "sensor.nestquest_bob_quests_due_today", + "2", + {"child_name": "Bob", "child_id": 3, "present": False}, + ), + TemplateState( + "sensor.nestquest_bob_quests_remaining_today", + "1", + {"child_id": 3}, + ), + TemplateState( + "sensor.nestquest_bob_quests_completed_today", + "1", + {"child_id": 3}, + ), + TemplateState("sensor.nestquest_household_quests_due_today", "7"), + ], + ) + + +def all_clear_fixture(): + """Alice present with everything done; nobody owes anything.""" + return make_states( + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "0", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_alice_quests_completed_today", + "3", + {"child_id": 1}, + ), + TemplateState("sensor.nestquest_household_quests_due_today", "3"), + ], + ) + + +# --------------------------------------------------------------------------- +# Inputs, defaults, and wiring. +# --------------------------------------------------------------------------- +def test_end_of_day_report_declares_inputs_with_selectors() -> None: + """The four inputs exist, carry selectors, and default sanely.""" + bp = end_of_day_report() + inputs = bp["blueprint"]["input"] + expected = {"report_time", "notify_target", "send_report", "send_even_when_clear"} + assert set(inputs) == expected + for name, spec in inputs.items(): + assert isinstance(spec["selector"], dict) and spec["selector"], name + assert inputs["report_time"]["default"] == DEFAULT_END_OF_DAY_REPORT_TIME + assert DEFAULT_END_OF_DAY_REPORT_TIME == "20:00" + assert inputs["send_report"]["default"] is True + assert inputs["send_even_when_clear"]["default"] is False + assert "time" in inputs["report_time"]["selector"] + assert "text" in inputs["notify_target"]["selector"] + assert "boolean" in inputs["send_report"]["selector"] + assert "boolean" in inputs["send_even_when_clear"]["selector"] + # The description must note the deliberate ordering: the report runs + # before the midnight missed sweep, reading the same day's data. + description = bp["blueprint"]["description"].lower() + assert "before" in description + assert "sweep" in description + assert "same day" in description + + +def test_end_of_day_report_wires_inputs_not_literals() -> None: + """Time trigger, toggle and guard conditions, and notify action use !input.""" + bp = end_of_day_report() + assert bp["variables"]["send_report"] == BlueprintInput("send_report") + assert bp["variables"]["send_even_when_clear"] == BlueprintInput( + "send_even_when_clear" + ) + + trigger = bp["triggers"][0] + assert trigger["trigger"] == "time" + assert trigger["at"] == BlueprintInput("report_time") + + toggle, guard = report_conditions(bp) + assert toggle["condition"] == "template" + assert "send_report" in toggle["value_template"] + assert guard["condition"] == "template" + assert "send_even_when_clear" in guard["value_template"] + + action = single_action(bp) + assert action["action"] == BlueprintInput("notify_target") + data = notify_data(end_of_day_report()) + assert data["title"] == REPORT_TITLE + assert isinstance(data["message"], str) and data["message"].strip() + + +# --------------------------------------------------------------------------- +# Rendering the day's summary. +# --------------------------------------------------------------------------- +def test_end_of_day_report_renders_owing_clear_and_absent_children() -> None: + """The owing child's open count is called out with the completion + ratio; the present-clear child still gets the day's completion + line; the absent child is omitted entirely.""" + rendered = render( + notify_data(end_of_day_report())["message"], + states=owing_clear_absent_fixture(), + ) + assert rendered.split("\n") == [ + "Alice: 2 still open (1/3 done).", + "Carol: 2/2 done.", + ] + assert "Bob" not in rendered + assert "household" not in rendered + + +def test_end_of_day_report_all_clear_guard_renders_false_by_default() -> None: + """An all-clear day with send_even_when_clear off sends nothing: + the guard condition is false.""" + rendered = render( + clear_guard(end_of_day_report()), + states=all_clear_fixture(), + send_even_when_clear=False, + ) + assert rendered.strip() == "False" + + +def test_end_of_day_report_all_clear_sends_summary_when_opted_in() -> None: + """With send_even_when_clear on, the guard lets an all-clear day + through and the message renders the day's completion summary.""" + guard = clear_guard(end_of_day_report()) + assert ( + render(guard, states=all_clear_fixture(), send_even_when_clear=True).strip() + == "True" + ) + message = render( + notify_data(end_of_day_report())["message"], + states=all_clear_fixture(), + ) + assert message == "Alice: 3/3 done." + + +def test_end_of_day_report_guard_true_when_a_present_child_owes() -> None: + """A present child with open quests passes the guard even with + send_even_when_clear off.""" + rendered = render( + clear_guard(end_of_day_report()), + states=owing_clear_absent_fixture(), + send_even_when_clear=False, + ) + assert rendered.strip() == "True" + + +def test_end_of_day_report_collision_suffixed_duplicate_names() -> None: + """Two children sharing a display name (HA registry suffix _2 on the + second one's entity ids) both appear, each with their own counts.""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_ada_quests_due_today", + "2", + {"child_name": "Ada", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_ada_quests_remaining_today", + "1", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_ada_quests_completed_today", + "1", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_ada_quests_due_today_2", + "3", + {"child_name": "Ada", "child_id": 2, "present": True}, + ), + TemplateState( + "sensor.nestquest_ada_quests_remaining_today_2", + "0", + {"child_id": 2}, + ), + TemplateState( + "sensor.nestquest_ada_quests_completed_today_2", + "3", + {"child_id": 2}, + ), + ], + ) + rendered = render(notify_data(end_of_day_report())["message"], states=states) + assert rendered.split("\n") == [ + "Ada: 1 still open (1/2 done).", + "Ada: 3/3 done.", + ] + + +def test_end_of_day_report_never_hard_codes_notify_target() -> None: + """No notify service literal outside the input's placeholder text.""" + bp = end_of_day_report() + spec = bp["blueprint"] + assert not re.search(r"notify\.[a-z_]+", spec["description"]) + + outside_input = {k: v for k, v in bp.items() if k != "blueprint"} + for value in iter_strings(outside_input): + assert not re.search(r"notify\.[a-z_.]+", value), value + + target_description = spec["input"]["notify_target"]["description"] + assert re.search(r"notify\.[a-z_]+", target_description), ( + "the input description should show a placeholder example" + ) + assert single_action(bp)["action"] == BlueprintInput("notify_target") + +def test_end_of_day_report_guard_true_when_remaining_unavailable() -> None: + """A present child whose remaining sensor is missing or reads + 'unavailable' must NOT look like all-clear: the degraded state + reaches the parent (Codex review regression).""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "unavailable", + {"child_id": 1}, + ), + ], + ) + rendered = render( + clear_guard(end_of_day_report()), + states=states, + send_even_when_clear=False, + ) + assert rendered.strip() == "True" + + # A missing remaining sensor entirely: also report-worthy. + states_missing = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice", "child_id": 1, "present": True}, + ), + ], + ) + rendered_missing = render( + clear_guard(end_of_day_report()), + states=states_missing, + send_even_when_clear=False, + ) + assert rendered_missing.strip() == "True" diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py new file mode 100644 index 0000000..ac8a991 --- /dev/null +++ b/tests/test_blueprints.py @@ -0,0 +1,336 @@ +"""Tests for the NestQuest morning-summary automation blueprint. + +The blueprints are YAML data consumed by Home Assistant's blueprint +engine; the integration itself never imports yaml or jinja2. These +tests therefore exercise them exactly the way HA would: parse the YAML +(with a loader that understands ``!input`` tags), then render the +message template with a plain jinja2 environment over a fixture state +set shaped like HA's template state objects — all via the shared +harness in :mod:`tests.blueprint_helpers`. + +This module also carries the directory-wide blueprint discipline tests +(entity-id shapes, domain mentions, notify-target hygiene), which scan +EVERY blueprint file under the automation directory, so new blueprint +files are covered automatically. + +Rendering policy under test (settled for the morning summary): a +PRESENT child always appears — with remaining quests owed, or as "all +clear" at zero remaining — and a child who is not present that day is +omitted entirely, never listed with zero quests. +""" +from __future__ import annotations + +import re +from types import SimpleNamespace + +from custom_components.nestquest.const import DEFAULT_MORNING_SUMMARY_TIME + +from tests.blueprint_helpers import ( + notify_data, + ALLOWED_ENTITY_SHAPES, + BlueprintInput, + _ENTITY_LITERAL_RE, + _MENTION_RE, + iter_strings, + load_blueprints, + make_states, + morning_action, + morning_summary, + render, + TemplateState, +) + +MORNING_TITLE = "NestQuest morning summary" + + +# --------------------------------------------------------------------------- +# YAML parsing and blueprint shape. +# --------------------------------------------------------------------------- +def test_blueprints_parse_and_declare_valid_shape() -> None: + """Every blueprint parses and carries the required blueprint keys.""" + blueprints = load_blueprints() + assert blueprints, "no blueprints found" + for name, bp in blueprints.items(): + assert isinstance(bp, dict), f"{name} did not parse to a mapping" + spec = bp["blueprint"] + assert isinstance(spec["name"], str) and spec["name"].strip() + assert spec["domain"] == "automation" + assert isinstance(spec["input"], dict) and spec["input"] + for key in ("triggers", "conditions", "actions"): + assert isinstance(bp[key], list) and bp[key], f"{name}: empty {key}" + + +def test_morning_summary_declares_inputs_with_selectors() -> None: + """The three blueprint inputs exist, carry selectors, and default sanely.""" + inputs = morning_summary()["blueprint"]["input"] + expected = {"morning_time", "notify_target", "send_summary"} + assert set(inputs) == expected + for name, spec in inputs.items(): + assert isinstance(spec["selector"], dict) and spec["selector"], name + assert inputs["morning_time"]["default"] == DEFAULT_MORNING_SUMMARY_TIME + assert inputs["send_summary"]["default"] is True + assert "time" in inputs["morning_time"]["selector"] + assert "text" in inputs["notify_target"]["selector"] + assert "boolean" in inputs["send_summary"]["selector"] + + +def test_morning_summary_wires_inputs_not_literals() -> None: + """Time trigger, toggle condition, and notify action all use !input.""" + bp = morning_summary() + assert bp["variables"]["send_summary"] == BlueprintInput("send_summary") + + trigger = bp["triggers"][0] + assert trigger["trigger"] == "time" + assert trigger["at"] == BlueprintInput("morning_time") + + condition = bp["conditions"][0] + assert condition["condition"] == "template" + assert "send_summary" in condition["value_template"] + + action = morning_action(bp) + assert action["action"] == BlueprintInput("notify_target") + data = notify_data(bp) + assert data["title"] == MORNING_TITLE + assert isinstance(data["message"], str) and data["message"].strip() + + +# --------------------------------------------------------------------------- +# Entity-reference discipline (Feature 10 shapes only). These tests +# scan EVERY blueprint in the directory, so new blueprint files are +# automatically held to the same rules. +# --------------------------------------------------------------------------- +def test_entity_literals_match_feature_10_shapes() -> None: + """Every id-like entity string in the YAML is a Feature 10 shape.""" + for name, bp in load_blueprints().items(): + for value in iter_strings(bp): + for literal in _ENTITY_LITERAL_RE.findall(value): + assert any( + shape.fullmatch(literal) for shape in ALLOWED_ENTITY_SHAPES + ), f"{name}: invented entity {literal!r}" + # The validator itself must separate Feature 10 ids from lookalikes. + assert any( + shape.fullmatch("binary_sensor.nestquest_alice_present_today") + for shape in ALLOWED_ENTITY_SHAPES + ) + assert any( + shape.fullmatch("sensor.nestquest_bob_quests_remaining_today") + for shape in ALLOWED_ENTITY_SHAPES + ) + for foreign in ( + "binary_sensor.someone_else_present_today", + "binary_sensor.nestquest_alice_at_home", + "sensor.nestquest_alice_streak", + "vacuum.nestquest_robot", + ): + assert not any(shape.fullmatch(foreign) for shape in ALLOWED_ENTITY_SHAPES) + + +def test_domain_mentions_are_nestquest_prefixed() -> None: + """Every sensor/binary_sensor mention (regex or literal) is nestquest_.""" + for name, bp in load_blueprints().items(): + for value in iter_strings(bp): + flattened = value.replace("\\", "") + for tail in _MENTION_RE.findall(flattened): + assert tail.startswith("nestquest_"), ( + f"{name}: non-nestquest entity mention in {value!r}" + ) + + +def test_due_sensor_regex_selects_only_nestquest_due_sensors() -> None: + """The extracted due-sensor regex matches Feature 10 due ids — + including HA collision-suffixed duplicates (a second child with + the same name gets entity ids ending _2) — and rejects everything + else.""" + message = notify_data(morning_summary())["message"] + match = re.search( + r"\^(sensor\\\\\.nestquest_\.\*_quests_due_today\(_\\\\d\+\)\?\$)", message + ) + assert match is not None, "due-sensor pattern missing from the template" + pattern = match.group(1).replace("\\\\", "\\") + assert pattern.endswith("$") + assert re.search(pattern, "sensor.nestquest_alice_quests_due_today") + assert re.search(pattern, "sensor.nestquest_alice_quests_due_today_2") + assert re.search(pattern, "sensor.nestquest_bob_quests_remaining_today") is None + # The household rollup due sensor unavoidably matches the pattern; + # the template's present-attribute guard excludes it (proven in the + # render test below). + assert re.search(pattern, "sensor.nestquest_household_quests_due_today") + assert re.search(pattern, "sensor.carol_unrelated") is None + + +def test_morning_summary_never_hard_codes_notify_target() -> None: + """No notify service literal outside input descriptions; the choice is text.""" + bp = morning_summary() + spec = bp["blueprint"] + assert "hard-code" in spec["description"] + assert not re.search(r"notify\.[a-z_]+", spec["description"]) + + outside_input = {k: v for k, v in bp.items() if k != "blueprint"} + for value in iter_strings(outside_input): + assert not re.search(r"notify\.[a-z_.]+", value), value + + target_description = spec["input"]["notify_target"]["description"] + assert re.search(r"notify\.[a-z_]+", target_description), ( + "the input description should show a placeholder example" + ) + assert morning_action(bp)["action"] == BlueprintInput("notify_target") + + +# --------------------------------------------------------------------------- +# Message rendering against fixture state sets. +# --------------------------------------------------------------------------- +def morning_fixture() -> SimpleNamespace: + """Two children — Alice present (2 owed), Bob absent — plus decoys.""" + return make_states( + binary_sensors=[ + TemplateState("binary_sensor.nestquest_alice_present_today", "on"), + TemplateState("binary_sensor.nestquest_bob_present_today", "off"), + TemplateState("binary_sensor.motion_present_today", "on"), + TemplateState("binary_sensor.nestquest_dave_all_done", "on"), + TemplateState("binary_sensor.nestquest_erin_present_today", "unavailable"), + ], + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "2", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_bob_quests_due_today", + "2", + {"child_name": "Bob", "child_id": 2, "present": False}, + ), + TemplateState( + "sensor.nestquest_bob_quests_remaining_today", + "1", + {"child_id": 2}, + ), + TemplateState("sensor.nestquest_household_quests_due_today", "5"), + TemplateState("sensor.nestquest_carol_unrelated", "7"), + ], + ) + + +def test_morning_summary_lists_present_child_and_omits_absent() -> None: + """Present Alice appears with her count; absent Bob and decoys never do.""" + message = notify_data(morning_summary())["message"] + rendered = render(message, states=morning_fixture()) + assert rendered == "Alice owes 2 quest(s) today." + assert "Bob" not in rendered + for absent in ("motion", "Dave", "Erin", "household", "carol"): + assert absent not in rendered + + +def test_morning_summary_present_child_zero_remaining_is_all_clear() -> None: + """A present child always appears — zero remaining reads as all clear.""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_carol_quests_due_today", + "2", + {"child_name": "Carol", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_carol_quests_remaining_today", + "0", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice", "child_id": 2, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "2", + {"child_id": 2}, + ), + ], + ) + rendered = render(notify_data(morning_summary())["message"], states=states) + assert "Alice owes 2 quest(s) today." in rendered + assert "Carol is all clear today (0 quests remaining)." in rendered + + +def test_morning_summary_present_child_without_sensors_is_flagged() -> None: + """A present child whose remaining-count sensor is missing (the + platform failed to add it) still gets a line, flagged unavailable.""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_frances_quests_due_today", + "2", + {"child_name": "Frances", "child_id": 1, "present": True}, + ), + ], + ) + rendered = render(notify_data(morning_summary())["message"], states=states) + assert rendered == "Frances's quest count is unavailable today." + + +def test_morning_summary_collision_suffixed_duplicate_names() -> None: + """Two children sharing a display name get HA registry suffixes on + their entity ids (..._2); both must appear, each with their own + count (Codex review regression).""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_ada_quests_due_today", + "2", + {"child_name": "Ada", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_ada_quests_remaining_today", + "1", + {"child_id": 1}, + ), + TemplateState( + "sensor.nestquest_ada_quests_due_today_2", + "2", + {"child_name": "Ada", "child_id": 2, "present": True}, + ), + TemplateState( + "sensor.nestquest_ada_quests_remaining_today_2", + "2", + {"child_id": 2}, + ), + ], + ) + rendered = render(notify_data(morning_summary())["message"], states=states) + lines = rendered.split("\n") + assert lines == [ + "Ada owes 1 quest(s) today.", + "Ada owes 2 quest(s) today.", + ] + + +def test_morning_summary_no_present_children() -> None: + """With nobody present the body says so rather than listing zeros.""" + states = make_states( + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice", "child_id": 1, "present": False}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "3", + {"child_id": 1}, + ), + ], + ) + rendered = render(notify_data(morning_summary())["message"], states=states) + assert rendered == "No children present today." + + +def test_morning_summary_send_summary_toggle_renders() -> None: + """The enable condition renders truthy/false from the input value.""" + condition = morning_summary()["conditions"][0] + assert render(condition["value_template"], send_summary=True).strip() == "True" + assert render(condition["value_template"], send_summary=False).strip() == "False" \ No newline at end of file diff --git a/tests/test_const.py b/tests/test_const.py index 85ff105..361ca83 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -28,10 +28,10 @@ def test_logger() -> None: def test_platforms() -> None: - """Verify PLATFORMS is defined as an empty list.""" + """Verify PLATFORMS is defined as the sensor + binary_sensor list.""" assert hasattr(const, "PLATFORMS") assert isinstance(const.PLATFORMS, list) - assert const.PLATFORMS == [] + assert const.PLATFORMS == ["sensor", "binary_sensor"] def test_sqlite_db_filename() -> None: diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py new file mode 100644 index 0000000..415318e --- /dev/null +++ b/tests/test_coordinator.py @@ -0,0 +1,346 @@ +"""Tests for the shared NestQuest coordinator (Feature 10, task 1).""" +from __future__ import annotations + +import datetime + +import pytest + +from conftest import wire_entry_to_registry + +from custom_components.nestquest import async_setup_entry, async_unload_entry +from custom_components.nestquest.children import ( + create_child, + list_children, + set_child_active, +) +from custom_components.nestquest.completion import complete_instance +from custom_components.nestquest.const import ( + CONF_UPDATE_INTERVAL, + DEFAULT_UPDATE_INTERVAL, + DEFAULT_HORIZON_DAYS, + DOMAIN, +) +from custom_components.nestquest.coordinator import NestQuestCoordinator +from custom_components.nestquest.dao_presence import PresenceSchedulesDao +from custom_components.nestquest.materialize import materialize +from custom_components.nestquest.quest_definitions import ( + create_quest_definition, +) +from custom_components.nestquest.recurrence import ScheduleRule + +ADMIN_CTX = {"user_id": "admin-1"} + + +def _today_iso() -> str: + return datetime.date.today().isoformat() + + +async def _seed_day(database, *, completed: bool = False) -> None: + """Two active children + one inactive, one daily definition each.""" + await create_child(database, "Ada") + await create_child(database, "Bo") + inactive = await create_child(database, "Ghost") + await set_child_active(database, inactive.id, False) + today = _today_iso() + await create_quest_definition( + database, + "Brush teeth", + ScheduleRule.from_dict({"rule_type": "daily", "start_date": today}), + [child.id for child in await list_children(database, active_only=True)], + ["morning"], + ) + end = ( + datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + await materialize(database, today, end, today=datetime.date.today()) + + +async def test_coordinator_snapshot_counts_and_states(hass, make_entry) -> None: + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + assert coordinator is not None + database = entry.runtime_data.database + await _seed_day(database) + + snapshot = await coordinator._async_update_data() + assert snapshot.today_iso == _today_iso() + assert len(snapshot.children) == 2, "inactive child must be absent" + by_name = {child.child_name: child for child in snapshot.children} + ada = by_name["Ada"] + assert ada.due_today == 1 + assert ada.completed_today == 0 + assert ada.remaining_today == 1 + assert ada.completion_pct == 0 + instance = ada.instances[0] + assert instance.state == "open" + assert instance.overdue is False + assert instance.title == "Brush teeth" + assert instance.window == "morning" + assert ada.present is True, "no schedule means present every day" + assert ada.next_present is None, "present child has no returns date" + assert snapshot.cycle_day == 0, "no schedule anywhere" + + +async def test_coordinator_next_present_for_away_child( + hass, make_entry +) -> None: + """A child absent today carries the ISO date of their next present + day; an always-absent pattern carries None instead.""" + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + database = entry.runtime_data.database + await create_child(database, "Ada") + ada = (await list_children(database))[0] + today = datetime.date.today() + pattern = ",".join( + str(day) for day in range(7) if day != today.weekday() + ) + await PresenceSchedulesDao(database).upsert_by_child( + ada.id, 1, today.isoformat(), pattern + ) + await create_child(database, "Bo") + bo = (await list_children(database))[1] + await PresenceSchedulesDao(database).upsert_by_child( + bo.id, 1, today.isoformat(), "" + ) + snapshot = await coordinator._async_update_data() + by_name = {child.child_name: child for child in snapshot.children} + assert by_name["Ada"].present is False + assert by_name["Ada"].next_present == ( + today + datetime.timedelta(days=1) + ).isoformat() + assert by_name["Bo"].present is False + assert by_name["Bo"].next_present is None + + +async def test_coordinator_reflects_completion_and_uncompletion( + hass, make_entry +) -> None: + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + database = entry.runtime_data.database + await _seed_day(database) + ada = ( + await coordinator._async_update_data() + ).children[0] + instance_id = ada.instances[0].instance_id + + await complete_instance( + database, + instance_id, + actor_source="panel", + actor_child_id=ada.child_id, + ) + snapshot = await coordinator._async_update_data() + child = snapshot.children[0] + assert child.completed_today == 1 + assert child.remaining_today == 0 + assert child.completion_pct == 100 + view = child.instances[0] + assert view.state == "done" + assert view.completed_at is not None + assert view.was_on_time is not None + + from custom_components.nestquest.completion import uncomplete_instance + + await uncomplete_instance( + database, instance_id, actor_source="user", actor_user_id="admin-1" + ) + snapshot = await coordinator._async_update_data() + child = snapshot.children[0] + assert child.completed_today == 0 + assert child.remaining_today == 1 + assert child.instances[0].state == "open" + + +async def test_coordinator_zero_quests_reports_full_percentage( + hass, make_entry +) -> None: + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + await create_child(entry.runtime_data.database, "Ada") + snapshot = await coordinator._async_update_data() + child = snapshot.children[0] + assert child.due_today == 0 + assert child.completion_pct == 100, "zero quests is a perfect day" + assert child.instances == () + + +async def test_coordinator_cycle_day_from_first_scheduled_child( + hass, make_entry +) -> None: + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + database = entry.runtime_data.database + await create_child(database, "Ada") + ada = (await list_children(database))[0] + today = datetime.date.today() + # A 2-week cycle anchored 3 days ago: today is day 4. + anchor = today - datetime.timedelta(days=3) + await PresenceSchedulesDao(database).upsert_by_child( + ada.id, 2, anchor.isoformat(), "0,1,2,3,4,5,6|" + ) + snapshot = await coordinator._async_update_data() + assert snapshot.cycle_day == 4 + + +async def test_coordinator_presence_resolves_schedule_and_override( + hass, make_entry +) -> None: + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + database = entry.runtime_data.database + await create_child(database, "Ada") + ada = (await list_children(database))[0] + today = datetime.date.today() + # Scheduled absent today (empty weekday set for this cycle week). + await PresenceSchedulesDao(database).upsert_by_child( + ada.id, 1, today.isoformat(), "" + ) + snapshot = await coordinator._async_update_data() + assert snapshot.children[0].present is False + + # An override beats the pattern. + from custom_components.nestquest.dao_presence import ( + PresenceOverridesDao, + ) + + await PresenceOverridesDao(database).create( + ada.id, today.isoformat(), today.isoformat(), True + ) + snapshot = await coordinator._async_update_data() + assert snapshot.children[0].present is True + + +async def test_coordinator_next_present_honours_future_override( + hass, make_entry +) -> None: + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + database = entry.runtime_data.database + await create_child(database, "Ada") + ada = (await list_children(database))[0] + today = datetime.date.today() + tomorrow = today + datetime.timedelta(days=1) + # Scheduled present tomorrow (single-day cycle, weekday set set below + # covers every day), but overridden absent tomorrow. + await PresenceSchedulesDao(database).upsert_by_child( + ada.id, 1, today.isoformat(), "0,1,2,3,4,5,6" + ) + from custom_components.nestquest.dao_presence import ( + PresenceOverridesDao, + ) + + await PresenceOverridesDao(database).create( + ada.id, tomorrow.isoformat(), tomorrow.isoformat(), False + ) + snapshot = await coordinator._async_update_data() + child = snapshot.children[0] + assert child.present is True + assert child.next_present != tomorrow.isoformat(), ( + "a future absent override must remove tomorrow from the preview" + ) + + +async def test_coordinator_derives_missed_for_past_due_open( + hass, make_entry +) -> None: + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + snapshot = await coordinator._async_update_data() + # No children: empty but valid snapshot. + assert snapshot.children == () + + +async def test_setup_creates_coordinator_with_configured_interval( + hass, make_entry +) -> None: + entry = wire_entry_to_registry( + make_entry(options={CONF_UPDATE_INTERVAL: 60}), hass.registry + ) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + assert isinstance(coordinator, NestQuestCoordinator) + assert coordinator.update_interval == datetime.timedelta(seconds=60) + assert coordinator.data is not None, "first refresh ran at setup" + + +async def test_setup_defaults_to_five_minute_interval( + hass, make_entry +) -> None: + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + assert coordinator.update_interval == datetime.timedelta( + seconds=DEFAULT_UPDATE_INTERVAL + ) + + +async def test_unload_shuts_coordinator_down(hass, make_entry) -> None: + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + torn_down = [] + + async def _shutdown(): + torn_down.append(coordinator) + + coordinator.async_shutdown = _shutdown + assert await async_unload_entry(hass, entry) is True + assert torn_down == [coordinator] + + +async def test_coordinator_notifies_listeners_on_refresh( + hass, make_entry +) -> None: + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + called = [] + coordinator.async_add_listener(lambda: called.append("entity")) + await coordinator.async_refresh() + assert called == ["entity"] + assert coordinator.last_update_success is True + + +async def test_failed_first_refresh_shuts_coordinator_down( + hass, make_entry, monkeypatch +) -> None: + """A setup failure after coordinator creation must not leak its + listeners against the database the except path closes.""" + from custom_components.nestquest import async_setup_entry + import custom_components.nestquest.coordinator as coordinator_module + + shutdown_calls = [] + + async def _record_shutdown(self): + shutdown_calls.append(self.entry_id) + + monkeypatch.setattr( + coordinator_module.NestQuestCoordinator, + "async_shutdown", + _record_shutdown, + ) + + async def _boom(self): + raise RuntimeError("refresh failed") + + monkeypatch.setattr( + coordinator_module.NestQuestCoordinator, + "_async_update_data", + _boom, + ) + + entry = wire_entry_to_registry(make_entry(), hass.registry) + with pytest.raises(RuntimeError, match="refresh failed"): + await async_setup_entry(hass, entry) + assert shutdown_calls == [entry.entry_id] + assert getattr(entry, "runtime_data", None) is None diff --git a/tests/test_dao_presence.py b/tests/test_dao_presence.py index 0d86cb8..b1ee155 100644 --- a/tests/test_dao_presence.py +++ b/tests/test_dao_presence.py @@ -455,6 +455,20 @@ def test_override_list_unknown_child_returns_empty(tmp_path) -> None: assert _with_db(tmp_path, "override-list-unknown.db")(_body) == [] +def test_override_get_round_trip(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + record = await overrides.create( + child.id, "2026-09-10", "2026-09-11", True, note="camp" + ) + found = await overrides.get(record.id) + assert found == record + assert await overrides.get(999) is None + return found + + found = _with_db(tmp_path, "override-get.db")(_body) + assert found.note == "camp" + + def test_override_delete_round_trip(tmp_path) -> None: async def _body(database, schedules, overrides, children, child): record = await overrides.create( diff --git a/tests/test_entity_lifecycle.py b/tests/test_entity_lifecycle.py new file mode 100644 index 0000000..48f9b84 --- /dev/null +++ b/tests/test_entity_lifecycle.py @@ -0,0 +1,187 @@ +"""Entity lifecycle across child add/deactivate/reactivate (Feature 10, task 8).""" +from __future__ import annotations + +import datetime + +from conftest import wire_entry_to_registry + +from custom_components.nestquest import async_setup_entry, async_unload_entry +from custom_components.nestquest.children import ( + create_child, + list_children, + set_child_active, +) +from custom_components.nestquest.const import DOMAIN +from custom_components.nestquest.materialize import materialize +from custom_components.nestquest.quest_definitions import ( + create_quest_definition, +) +from custom_components.nestquest.recurrence import ScheduleRule + +SEED_HORIZON_DAYS = 3 +PER_CHILD_KINDS = ( + "quests_due_today", + "quests_completed_today", + "quests_remaining_today", + "completion_pct_today", + "next_quest", + "all_done", + "present_today", +) +HOUSEHOLD_UNIQUE_IDS = ( + "nestquest_household_quests_due_today", + "nestquest_household_quests_completed_today", + "nestquest_cycle_day", +) + + +async def _reload(hass, entry) -> None: + """Unload then set up: the harness's restart/reload equivalent.""" + assert await async_unload_entry(hass, entry) is True + assert await async_setup_entry(hass, entry) is True + + +def _child_unique_ids(child_id: int) -> set[str]: + return {f"nestquest_child_{child_id}_{kind}" for kind in PER_CHILD_KINDS} + + +async def _setup_and_seed(hass, make_entry): + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + database = entry.runtime_data.database + ada = await create_child(database, "Ada") + bo = await create_child(database, "Bo") + today = datetime.date.today() + await create_quest_definition( + database, + "Brush teeth", + ScheduleRule.from_dict( + {"rule_type": "daily", "start_date": today.isoformat()} + ), + [ada.id, bo.id], + ["morning"], + ) + end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat() + await materialize(database, today.isoformat(), end, today=today) + await entry.runtime_data.coordinator.async_refresh() + return entry, ada, bo + + +async def test_deactivated_child_entities_removed_on_reload( + hass, make_entry +) -> None: + entry, ada, bo = await _setup_and_seed(hass, make_entry) + assert _child_unique_ids(ada.id) <= set(hass.entities) + assert _child_unique_ids(bo.id) <= set(hass.entities) + + await set_child_active(entry.runtime_data.database, bo.id, False) + await _reload(hass, entry) + + assert _child_unique_ids(ada.id) <= set(hass.entities), ( + "the surviving child's entities must be untouched" + ) + assert not (_child_unique_ids(bo.id) & set(hass.entities)), ( + "the deactivated child's entities must be gone after reload" + ) + + +async def test_reactivation_restores_entities_with_original_unique_ids( + hass, make_entry +) -> None: + entry, ada, bo = await _setup_and_seed(hass, make_entry) + original = _child_unique_ids(bo.id) + await set_child_active(entry.runtime_data.database, bo.id, False) + await _reload(hass, entry) + assert not (original & set(hass.entities)) + + await set_child_active(entry.runtime_data.database, bo.id, True) + await _reload(hass, entry) + + assert original <= set(hass.entities), ( + "reactivation must restore the exact original unique_ids" + ) + bo_due = hass.entities[f"nestquest_child_{bo.id}_quests_due_today"] + assert bo_due.native_value == 1, "restored entities carry live state" + + +async def test_new_child_gets_entities_without_disturbing_existing( + hass, make_entry +) -> None: + entry, ada, bo = await _setup_and_seed(hass, make_entry) + before_ada = set(hass.entities) & _child_unique_ids(ada.id) + + cory = await create_child(entry.runtime_data.database, "Cory") + await _reload(hass, entry) + + assert before_ada <= set(hass.entities), "existing entities untouched" + assert _child_unique_ids(cory.id) <= set(hass.entities), ( + "the new child's entities appear after reload" + ) + assert hass.entities[f"nestquest_child_{cory.id}_quests_due_today"].native_value == 0 + + +async def test_restart_equivalent_restores_all_entity_states( + hass, make_entry +) -> None: + """Unload + setup (the restart equivalent) restores every active + child's entities with correct states and the household rollups.""" + from custom_components.nestquest.completion import complete_instance + + entry, ada, bo = await _setup_and_seed(hass, make_entry) + coordinator = entry.runtime_data.coordinator + snapshot_ada = next( + child + for child in coordinator.data.children + if child.child_id == ada.id + ) + await complete_instance( + entry.runtime_data.database, + snapshot_ada.instances[0].instance_id, + actor_source="panel", + actor_child_id=ada.id, + ) + await coordinator.async_refresh() + assert hass.entities[ + f"nestquest_child_{ada.id}_quests_completed_today" + ].native_value == 1 + + await _reload(hass, entry) + + assert hass.entities[ + f"nestquest_child_{ada.id}_quests_completed_today" + ].native_value == 1, "completed count survives the restart" + assert hass.entities[ + f"nestquest_child_{bo.id}_quests_completed_today" + ].native_value == 0 + assert set(HOUSEHOLD_UNIQUE_IDS) <= set(hass.entities) + assert hass.entities[ + "nestquest_household_quests_completed_today" + ].native_value == 1 + + +async def test_unload_shuts_coordinator_before_closing_database( + hass, make_entry +) -> None: + """The coordinator's listeners are cleared while the database is + still connected: a scheduled refresh must never race the close.""" + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + database = entry.runtime_data.database + seen_connected = [] + + async def _probe_shutdown(): + seen_connected.append(database.connected) + + original_close = database.close + + async def _recording_close(): + seen_connected.append("closing") + await original_close() + + coordinator.async_shutdown = _probe_shutdown + database.close = _recording_close + assert await async_unload_entry(hass, entry) is True + assert seen_connected == [True, "closing"], ( + "coordinator shutdown must precede the database close" + ) diff --git a/tests/test_entity_matrix.py b/tests/test_entity_matrix.py new file mode 100644 index 0000000..0bbbe49 --- /dev/null +++ b/tests/test_entity_matrix.py @@ -0,0 +1,247 @@ +"""The Feature 10 entity-and-event matrix (task 9). + +One fixture drives every assertion the done-condition names: correct +values for every sensor and binary sensor (including the zero-quest +child and the 255-character state limit), the three events firing +exactly once with schema-valid payloads, the instances payload +omitting missed instances while admin_instances includes them, and no +entities for an inactive child. The per-task test files cover each +behavior in depth; this file is the single pass that proves them all +against ONE fixture, so a regression in any contract fails here too. +""" +from __future__ import annotations + +import datetime +import re + +from conftest import wire_entry_to_registry + +from custom_components.nestquest import async_setup_entry +from custom_components.nestquest.children import ( + create_child, + list_children, + set_child_active, +) +from custom_components.nestquest.completion import derive_state +from custom_components.nestquest.const import ( + CONF_ADMIN_USER_IDS, + DOMAIN, + EVENT_CHILD_DAY_COMPLETE, + EVENT_QUEST_COMPLETED, + EVENT_QUEST_UNCOMPLETED, + SERVICE_COMPLETE_QUEST, + SERVICE_UNCOMPLETE_QUEST, +) +from custom_components.nestquest.dao_instances import QuestInstancesDao +from custom_components.nestquest.materialize import materialize +from custom_components.nestquest.quest_definitions import ( + create_quest_definition, +) +from custom_components.nestquest.recurrence import ScheduleRule + +_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$") + + +async def _fixture(hass, make_entry): + """Two quest children, one zero-quest child, one inactive child.""" + entry = wire_entry_to_registry( + make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry + ) + assert await async_setup_entry(hass, entry) is True + database = entry.runtime_data.database + ada = await create_child(database, "Ada") + bo = await create_child(database, "Bo") + cory = await create_child(database, "Cory") + ghost = await create_child(database, "Ghost") + await set_child_active(database, ghost.id, False) + today = datetime.date.today() + await create_quest_definition( + database, + "Brush teeth", + ScheduleRule.from_dict( + {"rule_type": "daily", "start_date": today.isoformat()} + ), + [ada.id, bo.id], + ["morning", "evening"], + ) + end = (today + datetime.timedelta(days=3)).isoformat() + await materialize(database, today.isoformat(), end, today=today) + await entry.runtime_data.coordinator.async_refresh() + return entry, (ada, bo, cory, ghost) + + +def _sensor(hass, child_id, kind): + return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"] + + +async def test_entity_and_event_matrix(hass, make_entry) -> None: + entry, (ada, bo, cory, ghost) = await _fixture(hass, make_entry) + coordinator = entry.runtime_data.coordinator + + # --- Every active child has the full entity set; the inactive + # child has none. + kinds = ( + "quests_due_today", + "quests_completed_today", + "quests_remaining_today", + "completion_pct_today", + "next_quest", + "all_done", + "present_today", + ) + for child in (ada, bo, cory): + for kind in kinds: + assert f"{DOMAIN}_child_{child.id}_{kind}" in hass.entities, kind + for kind in kinds: + assert f"{DOMAIN}_child_{ghost.id}_{kind}" not in hass.entities, ( + "inactive children must have no entities" + ) + + # --- Correct values against the fixture: Ada and Bo owe two + # quests (morning + evening), Cory owes none and is still 100%. + for child in (ada, bo): + assert _sensor(hass, child.id, "quests_due_today").native_value == 2 + assert ( + _sensor(hass, child.id, "quests_completed_today").native_value == 0 + ) + assert ( + _sensor(hass, child.id, "quests_remaining_today").native_value == 2 + ) + assert ( + _sensor(hass, child.id, "completion_pct_today").native_value == 0 + ) + assert _sensor(hass, child.id, "next_quest").native_value == ( + "Brush teeth" + ) + assert _sensor(hass, child.id, "all_done").is_on is False + assert _sensor(hass, child.id, "present_today").is_on is True + assert _sensor(hass, cory.id, "quests_due_today").native_value == 0 + assert _sensor(hass, cory.id, "completion_pct_today").native_value == 100 + assert _sensor(hass, cory.id, "all_done").is_on is False + assert _sensor(hass, cory.id, "next_quest").native_value == "none" + + # --- Household rollups equal the per-child sums. + assert hass.entities[ + "nestquest_household_quests_due_today" + ].native_value == 4 + assert hass.entities[ + "nestquest_household_quests_completed_today" + ].native_value == 0 + + # --- The 255-character limit holds for every registered entity. + for unique_id, entity in hass.entities.items(): + state = getattr(entity, "native_value", None) + if state is None: + state = getattr(entity, "is_on", None) + assert state is None or not ( + isinstance(state, str) and len(state) > 255 + ), unique_id + + # --- Completing both of Ada's quests fires the events exactly + # once each and flips her entities immediately. + instances = await QuestInstancesDao( + entry.runtime_data.database + ).list_by_date_range( + ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat() + ) + for instance in instances: + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instance.id, + "actor": "panel", + "actor_child_id": ada.id, + }, + ) + assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 2 + for payload in hass.bus.fired(EVENT_QUEST_COMPLETED): + assert _TIMESTAMP.match(payload["occurred_at"]) + assert isinstance(payload["was_on_time"], bool) + assert payload["child_name"] in ("Ada",) + assert payload["quest_title"] == "Brush teeth" + assert isinstance(payload["instance_id"], int) + assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1 + assert _sensor(hass, ada.id, "all_done").is_on is True + assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 0 + assert ( + _sensor(hass, ada.id, "completion_pct_today").native_value == 100 + ) + + # --- Reversing one quest fires the uncompleted event exactly once + # and restores the owed counts. + await hass.services.call( + DOMAIN, + SERVICE_UNCOMPLETE_QUEST, + {"instance_id": instances[0].id, "actor": "user"}, + context={"user_id": "admin-1"}, + ) + assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1 + assert _sensor(hass, ada.id, "all_done").is_on is False + assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 1 + + # --- The instances payload omits missed instances while + # admin_instances includes them (D-009): pin the derive clock + # forward in the TEST ONLY so today's open instances read missed. + original_refresh = coordinator._async_update_data + + async def _future_dated_refresh(): + import custom_components.nestquest.coordinator as coordinator_module + + real_derive = coordinator_module.derive_state + + def _derive(instance, latest, today): + return real_derive(instance, latest, today + datetime.timedelta(days=1)) + + coordinator_module.derive_state = _derive + try: + return await original_refresh() + finally: + coordinator_module.derive_state = real_derive + + coordinator._async_update_data = _future_dated_refresh + await coordinator.async_refresh() + attributes = _sensor(hass, bo.id, "quests_due_today").extra_state_attributes + assert attributes["instances"] == [], "panel payload omits missed" + assert len(attributes["admin_instances"]) == 2 + assert all( + entry["state"] == "missed" for entry in attributes["admin_instances"] + ) + coordinator._async_update_data = original_refresh + await coordinator.async_refresh() + + +async def test_payload_schema_matches_the_documented_contract( + hass, make_entry +) -> None: + """Every fired payload carries the documented fields with the + documented types.""" + entry, (ada, _bo, _cory, _ghost) = await _fixture(hass, make_entry) + instances = await QuestInstancesDao( + entry.runtime_data.database + ).list_by_date_range( + ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat() + ) + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instances[0].id, + "actor": "panel", + "actor_child_id": ada.id, + }, + ) + payload = hass.bus.fired(EVENT_QUEST_COMPLETED)[0] + for key, check in ( + ("child_id", lambda v: isinstance(v, int)), + ("child_name", lambda v: isinstance(v, str)), + ("instance_id", lambda v: isinstance(v, int)), + ("quest_title", lambda v: isinstance(v, str)), + ("window", lambda v: v in ("morning", "evening")), + ("due_date", lambda v: isinstance(v, str)), + ("due_time", lambda v: v is None or isinstance(v, str)), + ("occurred_at", lambda v: bool(_TIMESTAMP.match(v))), + ("was_on_time", lambda v: isinstance(v, bool)), + ): + assert key in payload, key + assert check(payload[key]), key diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..829f4ea --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,379 @@ +"""Tests for the NestQuest bus events (Feature 10, task 6).""" +from __future__ import annotations + +import re + +from conftest import wire_entry_to_registry + +from custom_components.nestquest import async_setup_entry +from custom_components.nestquest.children import create_child +from custom_components.nestquest.const import ( + CONF_ADMIN_USER_IDS, + DOMAIN, + EVENT_CHILD_DAY_COMPLETE, + EVENT_QUEST_COMPLETED, + EVENT_QUEST_MISSED, + EVENT_QUEST_UNCOMPLETED, + SERVICE_COMPLETE_QUEST, + SERVICE_CREATE_QUEST_DEFINITION, + SERVICE_MANAGE_CHILD, + SERVICE_REGENERATE, + SERVICE_UNCOMPLETE_QUEST, +) +from custom_components.nestquest.dao_instances import QuestInstancesDao +from custom_components.nestquest.materialize import materialize +from custom_components.nestquest.quest_definitions import ( + create_quest_definition, +) +from custom_components.nestquest.recurrence import ScheduleRule + +ADMIN_CTX = {"user_id": "admin-1"} +_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$") + + +async def _setup_and_seed(hass, make_entry, *, windows=("morning",)): + entry = wire_entry_to_registry( + make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry + ) + assert await async_setup_entry(hass, entry) is True + database = entry.runtime_data.database + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, + ) + child = None + from custom_components.nestquest.children import list_children + + child = (await list_children(database))[0] + await create_quest_definition( + database, + "Brush teeth", + ScheduleRule.from_dict( + {"rule_type": "daily", "start_date": __import__("datetime").date.today().isoformat()} + ), + [child.id], + list(windows), + ) + import datetime + + today = datetime.date.today().isoformat() + end = (datetime.date.today() + datetime.timedelta(days=3)).isoformat() + await materialize(database, today, end, today=datetime.date.today()) + instances = await QuestInstancesDao(database).list_by_date_range( + child.id, today, today + ) + return entry, child, instances + + +async def test_quest_completed_fires_with_documented_payload( + hass, make_entry +) -> None: + entry, child, instances = await _setup_and_seed(hass, make_entry) + instance_id = instances[0].id + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, + ) + fired = hass.bus.fired(EVENT_QUEST_COMPLETED) + assert len(fired) == 1 + payload = fired[0] + assert payload["child_id"] == child.id + assert payload["child_name"] == "Ada" + assert payload["instance_id"] == instance_id + assert payload["quest_title"] == "Brush teeth" + assert payload["window"] == "morning" + assert payload["was_on_time"] is not None + assert _TIMESTAMP.match(payload["occurred_at"]), payload["occurred_at"] + + +async def test_recomplete_no_op_fires_nothing(hass, make_entry) -> None: + entry, child, instances = await _setup_and_seed(hass, make_entry) + instance_id = instances[0].id + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, + ) + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, + ) + assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1 + + +async def test_child_day_complete_fires_when_day_clears( + hass, make_entry +) -> None: + entry, child, instances = await _setup_and_seed(hass, make_entry) + for instance in instances: + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instance.id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) + assert len(day_complete) == 1, "exactly one day-complete per cleared day" + payload = day_complete[0] + assert payload["child_id"] == child.id + assert payload["child_name"] == "Ada" + assert payload["quests_due"] == len(instances) + assert _TIMESTAMP.match(payload["occurred_at"]) + + +async def test_child_day_complete_does_not_fire_partway( + hass, make_entry +) -> None: + entry, child, instances = await _setup_and_seed( + hass, make_entry, windows=("morning", "evening") + ) + assert len(instances) == 2 + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instances[0].id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == [] + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instances[1].id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1 + + +async def test_uncompleted_fires_on_reversal_only(hass, make_entry) -> None: + entry, child, instances = await _setup_and_seed(hass, make_entry) + instance_id = instances[0].id + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, + ) + # Un-completing an open instance is a no-op and fires nothing. + await hass.services.call( + DOMAIN, + SERVICE_UNCOMPLETE_QUEST, + {"instance_id": instance_id, "actor": "user"}, + context=ADMIN_CTX, + ) + fired = hass.bus.fired(EVENT_QUEST_UNCOMPLETED) + assert len(fired) == 1 + payload = fired[0] + assert payload["instance_id"] == instance_id + assert payload["quest_title"] == "Brush teeth" + assert _TIMESTAMP.match(payload["occurred_at"]) + await hass.services.call( + DOMAIN, + SERVICE_UNCOMPLETE_QUEST, + {"instance_id": instance_id, "actor": "user"}, + context=ADMIN_CTX, + ) + assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1 + + +def test_missed_event_name_is_documented_contract() -> None: + """Feature 10 defines the missed event Feature 11's sweep fires.""" + assert EVENT_QUEST_MISSED == "nestquest_quest_missed" + + +async def test_appended_verdict_is_atomic_under_duplicate_calls( + hass, make_entry +) -> None: + """Concurrent duplicate completes decide appended under the same + lock as the write: exactly one fires the transition event.""" + import asyncio + + entry, child, instances = await _setup_and_seed(hass, make_entry) + instance_id = instances[0].id + calls = [ + hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instance_id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + for _ in range(2) + ] + await asyncio.gather(*calls) + assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1 + + +async def test_day_complete_ignored_for_backdated_completion( + hass, make_entry +) -> None: + """Completing an instance NOT due today never evaluates the + child's day-complete, so a cleared day is announced at most once + per day and only by that day's completions.""" + import datetime + + from custom_components.nestquest.completion import ( + complete_instance as _complete, + ) + + entry, child, instances = await _setup_and_seed(hass, make_entry) + database = entry.runtime_data.database + # A future instance (tomorrow) completed after today's day is + # already clear. + today = datetime.date.today() + end = (today + datetime.timedelta(days=3)).isoformat() + all_instances = await QuestInstancesDao(database).list_by_date_range( + child.id, today.isoformat(), end + ) + today_ids = {instance.id for instance in instances} + tomorrow_instance = next( + instance + for instance in all_instances + if instance.id not in today_ids + ) + for instance in instances: + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instance.id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1 + + await _complete( + database, + tomorrow_instance.id, + actor_source="user", + actor_user_id="admin-1", + ) + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": tomorrow_instance.id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1, ( + "a back-dated/future completion must not re-announce today" + ) + # The direct business-layer call fired nothing (only the service + # path fires), and the follow-up service call on the now-done + # future instance was a no-op: today's single completion stands. + assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == len(instances) + + +async def test_completion_updates_entities_immediately( + hass, make_entry +) -> None: + """The remaining-today sensor reflects the completion the moment + the service call returns — no manual refresh — and the day-complete + event fired exactly once when the day became clear.""" + from custom_components.nestquest.const import EVENT_CHILD_DAY_COMPLETE + + entry, child, instances = await _setup_and_seed(hass, make_entry) + from conftest import wire_entry_to_registry # noqa: F401 + + coordinator = entry.runtime_data.coordinator + # Entities only exist after one refresh since they were seeded + # after setup; create them, then prove the service path refreshes. + await coordinator.async_refresh() + remaining_before = hass.entities[ + f"nestquest_child_{child.id}_quests_remaining_today" + ].native_value + assert remaining_before == len(instances) + + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instances[0].id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + remaining_after = hass.entities[ + f"nestquest_child_{child.id}_quests_remaining_today" + ].native_value + assert remaining_after == len(instances) - 1, ( + "sensor must reflect the completion without a manual refresh" + ) + assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == ( + 1 if len(instances) == 1 else 0 + ) + + +async def test_uncompletion_updates_entities_immediately( + hass, make_entry +) -> None: + entry, child, instances = await _setup_and_seed(hass, make_entry) + coordinator = entry.runtime_data.coordinator + await coordinator.async_refresh() + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instances[0].id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + completed_after_complete = hass.entities[ + f"nestquest_child_{child.id}_quests_completed_today" + ].native_value + assert completed_after_complete == 1 + + await hass.services.call( + DOMAIN, + SERVICE_UNCOMPLETE_QUEST, + {"instance_id": instances[0].id, "actor": "user"}, + context=ADMIN_CTX, + ) + completed_after_uncomplete = hass.entities[ + f"nestquest_child_{child.id}_quests_completed_today" + ].native_value + assert completed_after_uncomplete == 0, ( + "sensor must reflect the reversal without a manual refresh" + ) + + +async def test_forced_refreshes_serialize_no_stale_publish( + hass, make_entry +) -> None: + """Overlapping forced refreshes never interleave: a slow pass + pauses mid-snapshot, and the queued pass still publishes AFTER + it, so the last published snapshot reflects the latest mutation.""" + import asyncio + + entry, child, instances = await _setup_and_seed(hass, make_entry) + coordinator = entry.runtime_data.coordinator + order: list[str] = [] + + async def _slow_update(): + order.append("start") + await asyncio.sleep(0.01) + order.append("end") + return coordinator.data + + coordinator._async_update_data = _slow_update + await asyncio.gather(coordinator.async_refresh(), coordinator.async_refresh()) + assert order == ["start", "end", "start", "end"], ( + "overlapping refreshes must serialize: " + str(order) + ) diff --git a/tests/test_frontend.py b/tests/test_frontend.py new file mode 100644 index 0000000..3fc7e94 --- /dev/null +++ b/tests/test_frontend.py @@ -0,0 +1,181 @@ +"""Tests for the Lovelace card toolchain and auto-registered resource.""" +from __future__ import annotations + +import json +from pathlib import Path + +from conftest import wire_entry_to_registry + +from custom_components.nestquest import async_setup, async_setup_entry +from custom_components.nestquest.frontend import ( + BUNDLE_FILENAME, + BUNDLE_URL, + FRONTEND_URL_BASE, + WWW_DIR, + async_register_frontend, +) + +REPO_ROOT = Path(__file__).parent.parent +FRONTEND_DIR = REPO_ROOT / "frontend" +BUNDLE_PATH = WWW_DIR / BUNDLE_FILENAME +CARD_TYPES = ( + "nestquest-party-board-card", + "nestquest-quest-log-card", + "nestquest-admin-card", +) + + +def test_frontend_package_documents_vite_lit_build() -> None: + package = json.loads((FRONTEND_DIR / "package.json").read_text(encoding="utf-8")) + assert package["scripts"]["build"] == "vite build" + assert "lit" in package["dependencies"] + assert "typescript" in package["devDependencies"] + assert "vite" in package["devDependencies"] + + +def test_card_modules_exist_as_separate_sources() -> None: + src = FRONTEND_DIR / "src" + assert (src / "nestquest-cards.ts").is_file() + assert (src / "cards" / "party-board.ts").is_file() + assert (src / "cards" / "quest-log.ts").is_file() + assert (src / "cards" / "admin.ts").is_file() + entry = (src / "nestquest-cards.ts").read_text(encoding="utf-8") + assert "./cards/party-board" in entry + assert "./cards/quest-log" in entry + assert "./cards/admin" in entry + + +def test_card_sources_have_no_visual_editor() -> None: + for path in (FRONTEND_DIR / "src" / "cards").glob("*.ts"): + text = path.read_text(encoding="utf-8") + assert "getConfigElement" not in text + assert "setConfig" in text + assert "getCardSize" in text + + +def test_built_bundle_contains_all_three_cards() -> None: + assert BUNDLE_PATH.is_file() + bundle = BUNDLE_PATH.read_text(encoding="utf-8") + for card_type in CARD_TYPES: + assert card_type in bundle + + +async def test_setup_registers_static_path_and_extra_js(hass, make_entry) -> None: + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + assert len(hass.http.static_paths) == 1 + registered = hass.http.static_paths[0] + assert registered.url_path == FRONTEND_URL_BASE + assert registered.path == str(WWW_DIR) + assert hass.extra_js_urls == [BUNDLE_URL] + + +async def test_async_setup_registers_frontend_without_a_config_entry(hass) -> None: + assert await async_setup(hass, {}) is True + assert hass.extra_js_urls == [BUNDLE_URL] + assert hass.http.static_paths[0].url_path == FRONTEND_URL_BASE + + +async def test_frontend_registration_is_idempotent(hass, make_entry) -> None: + await async_register_frontend(hass) + await async_register_frontend(hass) + entry_a = wire_entry_to_registry(make_entry(entry_id="a"), hass.registry) + entry_b = wire_entry_to_registry(make_entry(entry_id="b"), hass.registry) + assert await async_setup_entry(hass, entry_a) is True + assert await async_setup_entry(hass, entry_b) is True + assert len(hass.http.static_paths) == 1 + assert hass.extra_js_urls == [BUNDLE_URL] + + +def test_readme_documents_npm_build() -> None: + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + assert "npm run build" in readme + assert "nestquest-cards.js" in readme + + +def test_readme_documents_panel_resolution_and_touchhub_dock() -> None: + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + assert "1920×1080" in readme + assert "TouchHub" in readme + assert "dock" in readme + assert "pointer-events: none" in readme + + +WOFF2_FILES = ( + "nunito-latin-400-normal.woff2", + "nunito-latin-600-normal.woff2", + "nunito-latin-700-normal.woff2", + "nunito-latin-800-normal.woff2", + "cinzel-latin-600-normal.woff2", + "cinzel-latin-700-normal.woff2", + "cinzel-latin-900-normal.woff2", + "cinzel-decorative-latin-700-normal.woff2", + "cinzel-decorative-latin-900-normal.woff2", +) +CDN_MARKERS = ( + "fonts.googleapis.com", + "fonts.gstatic.com", + "cdn.jsdelivr.net", + "unpkg.com", + "cdnjs.cloudflare.com", +) + + +def test_www_contains_required_woff2_fonts() -> None: + fonts_dir = WWW_DIR / "fonts" + for name in WOFF2_FILES: + path = fonts_dir / name + assert path.is_file(), name + assert path.read_bytes()[:4] == b"wOF2" + + +def test_font_css_declares_swap_and_local_urls() -> None: + css = (WWW_DIR / "nestquest-fonts.css").read_text(encoding="utf-8") + assert css.count("font-display: swap") == 9 + assert css.count("@font-face") == 9 + assert 'font-family: "Nunito"' in css + assert 'font-family: "Cinzel"' in css + assert 'font-family: "Cinzel Decorative"' in css + for name in WOFF2_FILES: + assert f"./fonts/{name}" in css + assert "http://" not in css + assert "https://" not in css + + +def test_token_css_copied_verbatim_from_design() -> None: + design = REPO_ROOT / "design" / "tokens" + for name in ("nestquest-panel-tokens.css", "nestquest-admin-tokens.css"): + expected = (design / name).read_bytes() + actual = (WWW_DIR / name).read_bytes() + assert actual == expected + + +def test_frontend_and_www_have_no_cdn_font_references() -> None: + roots = (FRONTEND_DIR, WWW_DIR) + for root in roots: + for path in root.rglob("*"): + if not path.is_file(): + continue + if "node_modules" in path.parts: + continue + if path.suffix.lower() in {".woff2", ".png", ".jpg"}: + continue + text = path.read_text(encoding="utf-8", errors="ignore") + for marker in CDN_MARKERS: + assert marker not in text, f"{path}: {marker}" + + +def test_readme_documents_the_touchhub_setup_runbook() -> None: + readme = " ".join( + (REPO_ROOT / "README.md").read_text(encoding="utf-8").split() + ) + for required in ("TouchHub", "kiosk"): + assert required in readme, required + assert "not in the NestQuest admin allowlist" in readme + assert "contains only the NestQuest panel card" in readme + assert "nestquest-party-board-card" in readme + assert "nestquest-quest-log-card" in readme + assert "Panel" in readme + assert "Auto-Return" in readme + assert "use_x_frame_options" not in readme + assert "clickjacking" not in readme diff --git a/tests/test_harness.py b/tests/test_harness.py index b947990..fa2e88b 100644 --- a/tests/test_harness.py +++ b/tests/test_harness.py @@ -31,6 +31,23 @@ def test_mock_only_harness_contract(monkeypatch): assert sys.modules["homeassistant.core"].callback is conftest._callback_decorator assert sys.modules["homeassistant.data_entry_flow"].RESULT_TYPE_FORM == "form" + # Entity-platform stand-ins are wired with the shaped classes, and + # parent-package attributes resolve to the mocked submodules. + entity_module = sys.modules["homeassistant.helpers.entity"] + assert entity_module.DeviceInfo is conftest.DeviceInfo + assert entity_module.Entity is conftest.Entity + sensor_module = sys.modules["homeassistant.components.sensor"] + assert sensor_module.SensorEntity is conftest.SensorEntity + assert sensor_module.SensorStateClass.MEASUREMENT == "measurement" + assert sensor_module.SensorDeviceClass.PERCENTAGE == "percentage" + assert sys.modules[ + "homeassistant.components.binary_sensor" + ].BinarySensorEntity is conftest.BinarySensorEntity + assert sys.modules[ + "homeassistant.helpers.update_coordinator" + ].CoordinatorEntity is conftest.CoordinatorEntity + assert sys.modules["homeassistant"].components.sensor is sensor_module + # No homeassistant distribution is installed in this suite's own venv. with pytest.raises(importlib.metadata.PackageNotFoundError): importlib.metadata.version("homeassistant") diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index b81f3f9..b0db61e 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -9,6 +9,7 @@ from conftest import make_config_entry, make_hass, wire_entry_to_registry from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry from custom_components.nestquest.const import ( + CONF_ADMIN_USER_IDS, CONF_DAY_ROLLOVER_TIME, CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS, @@ -16,6 +17,13 @@ from custom_components.nestquest.const import ( DOMAIN as DOMAIN_CONST, ) +ADMIN_ID = "admin-1" +ADMIN_CTX = {"user_id": ADMIN_ID} + + +def _admin_data(): + return {CONF_ADMIN_USER_IDS: [ADMIN_ID]} + def _wire(entry, registry): return wire_entry_to_registry(entry, registry) @@ -546,7 +554,7 @@ async def test_regenerate_service_registered_and_materializes( from custom_components.nestquest.dao_instances import QuestInstancesDao - entry = _wire(make_entry(), hass.registry) + entry = _wire(make_entry(data=_admin_data()), hass.registry) assert await async_setup_entry(hass, entry) is True assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE) @@ -555,7 +563,7 @@ async def test_regenerate_service_registered_and_materializes( database = entry.runtime_data.database child_id = await _seed_daily_child_and_definition(database, _local_today(hass)) - await hass.services.call(DOMAIN, SERVICE_REGENERATE) + await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) today = _local_today(hass) end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() @@ -574,14 +582,15 @@ async def test_regenerate_service_uses_configured_horizon_days( from custom_components.nestquest.dao_instances import QuestInstancesDao entry = _wire( - make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry + make_entry(options={CONF_HORIZON_DAYS: 5}, data=_admin_data()), + hass.registry, ) assert await async_setup_entry(hass, entry) is True database = entry.runtime_data.database child_id = await _seed_daily_child_and_definition(database, _local_today(hass)) - await hass.services.call(DOMAIN, SERVICE_REGENERATE) + await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) today = _local_today(hass) end = (today + datetime.timedelta(days=5)).isoformat() @@ -622,8 +631,12 @@ async def test_regenerate_service_is_domain_scoped_across_entries( from custom_components.nestquest.dao_instances import QuestInstancesDao - entry_a = _wire(make_entry(entry_id="entry_a"), hass.registry) - entry_b = _wire(make_entry(entry_id="entry_b"), hass.registry) + entry_a = _wire( + make_entry(entry_id="entry_a", data=_admin_data()), hass.registry + ) + entry_b = _wire( + make_entry(entry_id="entry_b", data=_admin_data()), hass.registry + ) assert await async_setup_entry(hass, entry_a) is True assert await async_setup_entry(hass, entry_b) is True @@ -634,7 +647,7 @@ async def test_regenerate_service_is_domain_scoped_across_entries( child_id = await _seed_daily_child_and_definition( database_b, _local_today(hass) ) - await hass.services.call(DOMAIN, SERVICE_REGENERATE) + await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) today = _local_today(hass) end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() records = await QuestInstancesDao(database_b).list_by_date_range( @@ -647,7 +660,7 @@ async def test_regenerate_service_is_domain_scoped_across_entries( assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE) # It still works for the remaining entry. - await hass.services.call(DOMAIN, SERVICE_REGENERATE) + await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) # Unloading the final entry removes the domain-global service. assert await async_unload_entry(hass, entry_b) is True diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 7cf0624..89195e5 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -10,6 +10,7 @@ from unittest.mock import AsyncMock import pytest from custom_components.nestquest.db import NestQuestDatabase +from custom_components.nestquest.schema import SCHEMA_V7_META_STATE_DDL from custom_components.nestquest.migrations import ( MIGRATIONS, VERSION_TABLE, @@ -110,6 +111,7 @@ def test_fresh_file_migration_creates_all_v1_tables(tmp_path) -> None: "presence_overrides", "quest_instances", "completion_events", + "nestquest_meta_state", VERSION_TABLE, } assert _tables(database) == expected @@ -159,7 +161,8 @@ def test_migration_list_shape() -> None: assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL assert callable(MIGRATIONS[4]) assert callable(MIGRATIONS[5]) - assert len(MIGRATIONS) == 6 + assert MIGRATIONS[6] == SCHEMA_V7_META_STATE_DDL + assert len(MIGRATIONS) == 7 # --------------------------------------------------------------------------- diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index bef04c8..f0c8385 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -18,6 +18,20 @@ from custom_components.nestquest.const import ( CONF_DAY_ROLLOVER_TIME, CONF_HORIZON_DAYS, CONF_PANEL_IDLE_TIMEOUT, + CONF_UPDATE_INTERVAL, + CONF_AFTERNOON_REMINDER_ENABLED, + CONF_AFTERNOON_REMINDER_TIME, + CONF_CELEBRATION_ENABLED, + CONF_END_OF_DAY_REPORT_ENABLED, + CONF_END_OF_DAY_REPORT_TIME, + CONF_MORNING_SUMMARY_ENABLED, + CONF_MORNING_SUMMARY_TIME, + CONF_NOTIFY_TARGET, + DEFAULT_AFTERNOON_REMINDER_TIME, + DEFAULT_AUTOMATION_ENABLED, + DEFAULT_END_OF_DAY_REPORT_TIME, + DEFAULT_MORNING_SUMMARY_TIME, + DEFAULT_UPDATE_INTERVAL, DEFAULT_DAY_ROLLOVER_TIME, DEFAULT_HORIZON_DAYS, DEFAULT_PANEL_IDLE_TIMEOUT, @@ -34,6 +48,22 @@ VALID_INPUT = { CONF_PANEL_IDLE_TIMEOUT: 60, } +#: What a schema-validated submit produces from a bare VALID_INPUT on a +#: fresh entry: the Feature 10 refresh interval and the Feature 11 +#: notification section fill from their defaults. +FULL_INPUT = { + **VALID_INPUT, + CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, + CONF_NOTIFY_TARGET: "", + CONF_MORNING_SUMMARY_TIME: DEFAULT_MORNING_SUMMARY_TIME, + CONF_AFTERNOON_REMINDER_TIME: DEFAULT_AFTERNOON_REMINDER_TIME, + CONF_END_OF_DAY_REPORT_TIME: DEFAULT_END_OF_DAY_REPORT_TIME, + CONF_MORNING_SUMMARY_ENABLED: DEFAULT_AUTOMATION_ENABLED, + CONF_AFTERNOON_REMINDER_ENABLED: DEFAULT_AUTOMATION_ENABLED, + CONF_END_OF_DAY_REPORT_ENABLED: DEFAULT_AUTOMATION_ENABLED, + CONF_CELEBRATION_ENABLED: DEFAULT_AUTOMATION_ENABLED, +} + def _run(coro): return asyncio.new_event_loop().run_until_complete(coro) @@ -129,7 +159,7 @@ def test_options_flow_submits_through_2024_6_surface() -> None: result = _run(handler.async_step_init(dict(VALID_INPUT))) assert result["type"] == "create_entry" assert result["title"] == "NestQuest" - assert result["data"] == VALID_INPUT + assert result["data"] == FULL_INPUT def test_options_flow_shows_form_initially() -> None: @@ -208,6 +238,11 @@ def test_options_flow_schema_applies_stored_defaults() -> None: CONF_HORIZON_DAYS: 21, CONF_DAY_ROLLOVER_TIME: "05:45", CONF_PANEL_IDLE_TIMEOUT: 120, + CONF_UPDATE_INTERVAL: 90, + CONF_NOTIFY_TARGET: "notify.mobile_app_test", + CONF_MORNING_SUMMARY_TIME: "07:00", + CONF_AFTERNOON_REMINDER_TIME: "14:00", + CONF_END_OF_DAY_REPORT_TIME: "21:30", } ) result = _run(_make_flow(entry).async_step_init(None)) @@ -215,6 +250,15 @@ def test_options_flow_schema_applies_stored_defaults() -> None: CONF_HORIZON_DAYS: 21, CONF_DAY_ROLLOVER_TIME: "05:45", CONF_PANEL_IDLE_TIMEOUT: 120, + CONF_UPDATE_INTERVAL: 90, + CONF_NOTIFY_TARGET: "notify.mobile_app_test", + CONF_MORNING_SUMMARY_TIME: "07:00", + CONF_AFTERNOON_REMINDER_TIME: "14:00", + CONF_END_OF_DAY_REPORT_TIME: "21:30", + CONF_MORNING_SUMMARY_ENABLED: True, + CONF_AFTERNOON_REMINDER_ENABLED: True, + CONF_END_OF_DAY_REPORT_ENABLED: True, + CONF_CELEBRATION_ENABLED: True, CONF_ADMIN_USER_IDS: [], } @@ -282,18 +326,14 @@ def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None: assert result["errors"] == {CONF_PANEL_IDLE_TIMEOUT: "invalid"} -def test_options_flow_creates_entry_with_three_keys() -> None: - """A valid submit creates an entry with exactly the three validated keys.""" +def test_options_flow_creates_entry_with_four_keys() -> None: + """A valid submit creates an entry with exactly the four validated keys.""" flow = _make_flow(_make_entry()) result = _run(flow.async_step_init(dict(VALID_INPUT))) assert result["type"] == "create_entry" assert result["title"] == "NestQuest" - assert set(result["data"]) == { - CONF_HORIZON_DAYS, - CONF_DAY_ROLLOVER_TIME, - CONF_PANEL_IDLE_TIMEOUT, - } - assert result["data"] == VALID_INPUT + assert set(result["data"]) == set(FULL_INPUT) + assert result["data"] == FULL_INPUT def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once() -> None: @@ -316,7 +356,7 @@ def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once( _run(registry.dispatch_options_update(entry)) assert hass.config_entries.async_reload.call_count == 1 assert registry.reloaded == [entry.entry_id] - assert entry.options == VALID_INPUT + assert entry.options == FULL_INPUT def test_options_flow_validation_never_raises() -> None: @@ -635,3 +675,127 @@ def test_options_flow_omitted_picker_carries_allowlist_forward( assert result["data"][CONF_HORIZON_DAYS] == VALID_INPUT[CONF_HORIZON_DAYS] finally: _run(database.close()) + + +def test_options_flow_notification_section_defaults() -> None: + """A bare submit fills the notification section from const defaults: + 08:00 / 15:00 / 20:00 — the report deliberately BEFORE the midnight + rollover sweep — an empty notify target, and all toggles enabled.""" + flow = _make_flow(_make_entry()) + result = _run(flow.async_step_init(dict(VALID_INPUT))) + assert result["type"] == "create_entry" + assert result["data"][CONF_MORNING_SUMMARY_TIME] == "08:00" + assert result["data"][CONF_AFTERNOON_REMINDER_TIME] == "15:00" + assert result["data"][CONF_END_OF_DAY_REPORT_TIME] == "20:00" + assert result["data"][CONF_NOTIFY_TARGET] == "" + for toggle in ( + CONF_MORNING_SUMMARY_ENABLED, + CONF_AFTERNOON_REMINDER_ENABLED, + CONF_END_OF_DAY_REPORT_ENABLED, + CONF_CELEBRATION_ENABLED, + ): + assert result["data"][toggle] is True + + +def test_options_flow_notification_times_must_be_strict_hh_mm() -> None: + flow = _make_flow(_make_entry()) + for bad_time in ("9:00", "25:00", "08:0", "08-00", 800): + result = _run( + flow.async_step_init( + { + **VALID_INPUT, + CONF_MORNING_SUMMARY_TIME: bad_time, + } + ) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_MORNING_SUMMARY_TIME: "invalid_time"} + + +def test_options_flow_notify_target_validation() -> None: + """The empty target is VALID (it is the shipped default and must be + savable); only malformed non-empty values reject.""" + flow = _make_flow(_make_entry()) + # The empty default saves cleanly. + result = _run( + flow.async_step_init( + {**VALID_INPUT, CONF_NOTIFY_TARGET: ""} + ) + ) + assert result["type"] == "create_entry" + assert result["data"][CONF_NOTIFY_TARGET] == "" + + # Malformed non-empty values reject. + for bad_target in (" notify.x ", " ", 42): + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init( + { + **VALID_INPUT, + CONF_NOTIFY_TARGET: bad_target, + } + ) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_NOTIFY_TARGET: "invalid"} + + +def test_options_flow_toggles_must_be_real_booleans() -> None: + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init( + { + **VALID_INPUT, + CONF_CELEBRATION_ENABLED: "on", + } + ) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_CELEBRATION_ENABLED: "invalid"} + + +def test_options_flow_notification_stored_values_round_trip() -> None: + """Stored notification settings are the form's defaults and a raw + submit without them keeps the stored values.""" + entry = _make_entry( + options={ + **VALID_INPUT, + CONF_UPDATE_INTERVAL: 90, + CONF_NOTIFY_TARGET: "notify.mobile_app_dad", + CONF_MORNING_SUMMARY_TIME: "07:15", + CONF_AFTERNOON_REMINDER_TIME: "16:45", + CONF_END_OF_DAY_REPORT_TIME: "21:00", + CONF_MORNING_SUMMARY_ENABLED: False, + CONF_AFTERNOON_REMINDER_ENABLED: True, + CONF_END_OF_DAY_REPORT_ENABLED: False, + CONF_CELEBRATION_ENABLED: True, + } + ) + flow = _make_flow(entry) + form = _run(flow.async_step_init(None)) + schema_defaults = form["data_schema"]({}) + assert schema_defaults[CONF_NOTIFY_TARGET] == "notify.mobile_app_dad" + assert schema_defaults[CONF_MORNING_SUMMARY_TIME] == "07:15" + assert schema_defaults[CONF_END_OF_DAY_REPORT_ENABLED] is False + + # A raw submit carrying only the legacy keys keeps the stored + # notification settings. + result = _run(flow.async_step_init(dict(VALID_INPUT))) + assert result["type"] == "create_entry" + assert result["data"][CONF_NOTIFY_TARGET] == "notify.mobile_app_dad" + assert result["data"][CONF_MORNING_SUMMARY_TIME] == "07:15" + assert result["data"][CONF_MORNING_SUMMARY_ENABLED] is False + assert result["data"][CONF_CELEBRATION_ENABLED] is True + + +def test_options_flow_no_hard_coded_personal_target_in_source() -> None: + """The notify target's DEFAULT is the empty string: the household + fills their own service name, and no personal target ships as a + value anywhere in the const/flow defaults.""" + from custom_components.nestquest import const + + assert getattr(const, "CONF_NOTIFY_TARGET", None) == "notify_target" + # No const default carries a concrete notify service. + assert const.__dict__.get("DEFAULT_NOTIFY_TARGET", "") == "" + form = _run(_make_flow(_make_entry()).async_step_init(None)) + assert form["data_schema"]({})[CONF_NOTIFY_TARGET] == "" diff --git a/tests/test_panel_cards.py b/tests/test_panel_cards.py new file mode 100644 index 0000000..bb0daad --- /dev/null +++ b/tests/test_panel_cards.py @@ -0,0 +1,160 @@ +"""Source-level tests for the kid panel cards. + +The panel cards are Lit/TypeScript modules bundled by Vite; these tests assert +the contract by reading the card sources and the built bundle, in the same +spirit as test_frontend.py. They cover the states decided in +design/PANEL-EMPTY-STATES.md and PANEL-SPEC.md, and guard the kid-facing +bundles against ever gaining a parent-only service call. +""" +from __future__ import annotations + +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent +FRONTEND_CARDS_DIR = REPO_ROOT / "frontend" / "src" / "cards" +PARTY_BOARD = FRONTEND_CARDS_DIR / "party-board.ts" +QUEST_LOG = FRONTEND_CARDS_DIR / "quest-log.ts" +BUNDLE_PATH = REPO_ROOT / "custom_components" / "nestquest" / "www" / "nestquest-cards.js" + +PANEL_SOURCES = (PARTY_BOARD, QUEST_LOG) + +# Parent-only NestQuest services. The party board and quest log are the +# kid-facing panel: they may complete a quest for the tapped child, and nothing +# else. Any of these names appearing in a panel source or bundle means an admin +# control has leaked into the kid panel. +ADMIN_ONLY_SERVICES = ( + "uncomplete_quest", + "create_quest_definition", + "update_quest_definition", + "set_quest_definition_active", + "set_presence_pattern", + "create_presence_override", + "delete_presence_override", + "export_history_csv", + "manage_child", + "regenerate", +) + +WINDOW_KEYS = ("morning", "afternoon", "evening") +WINDOW_NAMES = ("Morning", "Afternoon", "Evening") + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_party_board_renders_present_and_away_plates() -> None: + source = _read(PARTY_BOARD) + assert "nestquest-party-board-card" in source + # Present adventurers get the tappable crest plate. + assert "button.plate" in source + assert 'class="plate"' in source + assert "Home today" in source + assert "aria-label=\"Open ${plate.name}'s Quest Log\"" in source + # Away adventurers get the dashed On travels plate, not a tappable one. + assert ".plate.away" in source + assert 'class="plate away"' in source + assert "On travels" in source + assert "next_present" in source + assert "Returns ${formatDay(returnsDate" in source + + +def test_quest_log_renders_three_window_columns() -> None: + source = _read(QUEST_LOG) + for key, name in zip(WINDOW_KEYS, WINDOW_NAMES): + assert f'key: "{key}"' in source, key + assert f'name: "{name}"' in source, name + assert "grid-template-columns: repeat(3, 1fr)" in source + assert 'class="column-head"' in source + assert 'class="column-names"' in source + assert 'class="column-range"' in source + + +def test_empty_window_still_renders_its_column_head() -> None: + """A window with no quests renders the head alone (PANEL-SPEC §3.1).""" + source = _read(QUEST_LOG) + column = source[source.index("private _renderColumn(windowDef: WindowDef)") :] + column = column[: column.index("private _renderQuest(")] + # The head is emitted unconditionally, before the stack is built. + assert 'class="column-head"' in column + assert "sealed.length}/${instances.length}" in column + # There is no early return that would drop the head on an empty window. + assert "instances.length === 0" not in column + assert "return nothing" not in column + + +def test_complete_quest_is_optimistic_with_rollback() -> None: + source = _read(QUEST_LOG) + assert "_optimistic" in source + assert "new Date().toISOString()" in source + assert ".set(instance.id, stampedAt)" in source + # The overlay is applied to the rendered instance before the service answers. + assert 'instance.state = "completed"' in source + # A rejected call reverts the overlay and raises the parchment toast. + assert "reverted.delete(instance.id)" in source + assert "NestQuest could not set the seal just now. Please try again." in source + assert "_showToast(" in source + assert "await this._callCompleteQuest(instance)" in source + assert '"nestquest", "complete_quest"' in source + + +def test_idle_return_to_board() -> None: + source = _read(QUEST_LOG) + assert "idle_return_seconds" in source + assert "_armIdle" in source + assert "_clearIdle" in source + assert "this._idleSeconds() * 1000" in source + assert "board_path" in source + assert "_returnToBoard" in source + assert 'window.dispatchEvent(new Event("location-changed"))' in source + + +def test_decided_empty_and_error_states_are_present() -> None: + log = _read(QUEST_LOG) + board = _read(PARTY_BOARD) + for kind in ( + "no-adventurer", + "not-set-up", + "unreachable", + "away", + "empty-day", + "complete-day", + ): + assert f'kind: "{kind}"' in log, kind + assert "PANEL-EMPTY-STATES" in log + assert "No adventurer chosen" in log + assert "NestQuest is not set up yet" in log + assert "The records cannot be reached" in log + assert "No quests today" in log + assert 'role="status"' in log + # The board distinguishes the same setup/unreachable cases at board level. + assert "NestQuest is not set up yet" in board + assert "The records cannot be reached" in board + assert "BOARD_NOTICE_NOT_SET_UP" in board + assert "BOARD_NOTICE_UNREACHABLE" in board + + +def test_quest_complete_trigger() -> None: + source = _read(QUEST_LOG) + assert "binary_sensor.nestquest_${slug}_all_done" in source + assert "nestquest_child_day_complete" in source + assert "NESTQUEST_EVENT_TYPES" in source + assert 'class="complete-screen"' in source + assert "Quest complete" in source + assert "Returning to The Party in" in source + assert "_armCompleteTimer" in source + assert "complete_screen_seconds" in source + + +def test_panel_cards_reference_no_admin_only_services() -> None: + # Only the kid panel sources are scanned: the bundle also contains the + # sibling admin card, so its parent-only service names are expected there. + for path in PANEL_SOURCES: + text = _read(path) + for service in ADMIN_ONLY_SERVICES: + assert service not in text, f"{path}: {service}" + # The bundle is still built and carries both panel card custom elements. + assert BUNDLE_PATH.is_file() + bundle = _read(BUNDLE_PATH) + assert "nestquest-party-board-card" in bundle + assert "nestquest-quest-log-card" in bundle \ No newline at end of file diff --git a/tests/test_permission_matrix.py b/tests/test_permission_matrix.py new file mode 100644 index 0000000..14e521e --- /dev/null +++ b/tests/test_permission_matrix.py @@ -0,0 +1,238 @@ +"""Feature 09 permission test matrix across every registered service.""" +from __future__ import annotations + +import datetime + +import pytest +from homeassistant.exceptions import Unauthorized + +from conftest import wire_entry_to_registry + +from custom_components.nestquest import async_setup_entry +from custom_components.nestquest.const import ( + CONF_ADMIN_USER_IDS, + DEFAULT_HORIZON_DAYS, + DOMAIN, + DOMAIN_SERVICES, + SERVICE_COMPLETE_QUEST, + SERVICE_CREATE_PRESENCE_OVERRIDE, + SERVICE_CREATE_QUEST_DEFINITION, + SERVICE_DELETE_PRESENCE_OVERRIDE, + SERVICE_EXPORT_HISTORY_CSV, + SERVICE_MANAGE_CHILD, + SERVICE_REGENERATE, + SERVICE_SET_PRESENCE_PATTERN, + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + SERVICE_UNCOMPLETE_QUEST, + SERVICE_UPDATE_QUEST_DEFINITION, +) +from custom_components.nestquest.service_policy import ( + ADMIN_ONLY, + OPEN, + SERVICE_POLICY, +) + +ADMIN_ID = "admin-1" +ADMIN_CTX = {"user_id": ADMIN_ID} +NON_ADMIN_ID = "non-admin" +NON_ADMIN_CTX = {"user_id": NON_ADMIN_ID} +KIOSK_ID = "kiosk-panel" +KIOSK_CTX = {"user_id": KIOSK_ID} + + +def test_every_registered_service_is_in_the_operation_registry() -> None: + """The registry is total: a service outside it could never be gated.""" + missing = [ + service for service in DOMAIN_SERVICES if service not in SERVICE_POLICY + ] + extra = [ + service for service in SERVICE_POLICY if service not in DOMAIN_SERVICES + ] + assert not missing, f"registered but ungated: {missing}" + assert not extra, f"gated but never registered: {extra}" + assert set(SERVICE_POLICY.values()) == {OPEN, ADMIN_ONLY} + + +async def _setup(hass, make_entry, *, admin=True): + data = {CONF_ADMIN_USER_IDS: [ADMIN_ID]} if admin else {} + entry = wire_entry_to_registry(make_entry(data=data), hass.registry) + assert await async_setup_entry(hass, entry) is True + return entry + + +async def _seed(hass, entry) -> dict: + """Seed one child, one definition, and materialized instances.""" + from custom_components.nestquest.children import list_children + from custom_components.nestquest.dao_instances import QuestInstancesDao + + database = entry.runtime_data.database + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, + ) + await hass.services.call( + DOMAIN, + SERVICE_CREATE_QUEST_DEFINITION, + { + "title": "Brush teeth", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [(await list_children(database))[0].id], + "windows": ["morning"], + }, + context=ADMIN_CTX, + ) + await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) + child = (await list_children(database))[0] + today = datetime.date.today().isoformat() + end = ( + datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + instances = await QuestInstancesDao(database).list_by_date_range( + child.id, today, end + ) + assert instances + from custom_components.nestquest.dao_presence import ( + PresenceOverridesDao, + ) + + return { + "child_id": child.id, + "instance_id": instances[0].id, + "definition_id": instances[0].definition_id, + "override": await PresenceOverridesDao(database).create( + child.id, "2027-01-01", "2027-01-02", False + ), + "today": today, + "database": database, + } + + +def _admin_success_payload(service: str, ctx: dict) -> dict | None: + """A schema-valid, end-to-end-successful payload per admin service.""" + if service == SERVICE_UNCOMPLETE_QUEST: + return {"instance_id": ctx["instance_id"], "actor": "user"} + if service == SERVICE_CREATE_QUEST_DEFINITION: + return { + "title": "Second quest", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [ctx["child_id"]], + "windows": ["afternoon"], + } + if service == SERVICE_UPDATE_QUEST_DEFINITION: + return {"definition_id": ctx["definition_id"], "title": "Brush teeth 2"} + if service == SERVICE_SET_QUEST_DEFINITION_ACTIVE: + return {"definition_id": ctx["definition_id"], "is_active": False} + if service == SERVICE_SET_PRESENCE_PATTERN: + return { + "child_id": ctx["child_id"], + "cycle_length_weeks": 1, + "anchor_date": ctx["today"], + "pattern": {0: [0, 1, 2, 3, 4, 5, 6]}, + } + if service == SERVICE_CREATE_PRESENCE_OVERRIDE: + return { + "child_id": ctx["child_id"], + "start_date": "2027-03-01", + "end_date": "2027-03-02", + "is_present": False, + } + if service == SERVICE_DELETE_PRESENCE_OVERRIDE: + return {"override_id": ctx["override"].id} + if service == SERVICE_EXPORT_HISTORY_CSV: + return None # raises HomeAssistantError (Feature 13) — not Unauthorized + if service == SERVICE_MANAGE_CHILD: + return {"action": "create", "display_name": "Bo"} + if service == SERVICE_REGENERATE: + return {} + raise AssertionError(f"unexpected service {service}") + + +_ADMIN_ONLY = [ + service for service, policy in SERVICE_POLICY.items() if policy == ADMIN_ONLY +] + + +@pytest.mark.parametrize("service", _ADMIN_ONLY) +async def test_admin_succeeds_on_every_admin_only_service( + hass, make_entry, service +) -> None: + from homeassistant.exceptions import HomeAssistantError + + entry = await _setup(hass, make_entry) + ctx = await _seed(hass, entry) + if service == SERVICE_EXPORT_HISTORY_CSV: + # The gate passes; the Feature 13 stub raises its "not + # implemented" HomeAssistantError — NOT Unauthorized. + with pytest.raises(HomeAssistantError, match="Feature 13"): + await hass.services.call( + DOMAIN, service, None, context=ADMIN_CTX + ) + return + await hass.services.call( + DOMAIN, service, _admin_success_payload(service, ctx), context=ADMIN_CTX + ) + + +@pytest.mark.parametrize("service", _ADMIN_ONLY) +async def test_non_admin_refused_on_every_admin_only_service( + hass, make_entry, service +) -> None: + entry = await _setup(hass, make_entry) + ctx = await _seed(hass, entry) + if service != SERVICE_EXPORT_HISTORY_CSV: + await hass.services.call( + DOMAIN, + service, + _admin_success_payload(service, ctx), + context=ADMIN_CTX, + ) + with pytest.raises(Unauthorized): + await hass.services.call( + DOMAIN, + service, + _admin_success_payload(service, ctx), + context=NON_ADMIN_CTX, + ) + + +async def test_non_admin_allowed_on_complete_quest(hass, make_entry) -> None: + from custom_components.nestquest.completion import instance_state + + entry = await _setup(hass, make_entry) + ctx = await _seed(hass, entry) + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": ctx["instance_id"], + "actor": "panel", + "actor_child_id": ctx["child_id"], + }, + context=NON_ADMIN_CTX, + ) + assert ( + await instance_state(ctx["database"], ctx["instance_id"]) == "done" + ) + + +async def test_kiosk_refused_on_uncomplete_and_regenerate( + hass, make_entry +) -> None: + entry = await _setup(hass, make_entry) + ctx = await _seed(hass, entry) + from custom_components.nestquest.admin_allowlist import list_admin_ids + + assert KIOSK_ID not in await list_admin_ids(ctx["database"]) + with pytest.raises(Unauthorized): + await hass.services.call( + DOMAIN, + SERVICE_UNCOMPLETE_QUEST, + {"instance_id": ctx["instance_id"], "actor": "user"}, + context=KIOSK_CTX, + ) + with pytest.raises(Unauthorized): + await hass.services.call( + DOMAIN, SERVICE_REGENERATE, context=KIOSK_CTX + ) \ No newline at end of file diff --git a/tests/test_permissions.py b/tests/test_permissions.py new file mode 100644 index 0000000..b71ffce --- /dev/null +++ b/tests/test_permissions.py @@ -0,0 +1,122 @@ +"""Tests for the Feature 09 permission gate's audit logging.""" +from __future__ import annotations + +import logging +import re + +import pytest +from homeassistant.exceptions import Unauthorized + +from conftest import wire_entry_to_registry + +from custom_components.nestquest import async_setup_entry +from custom_components.nestquest.const import ( + CONF_ADMIN_USER_IDS, + DOMAIN, + SERVICE_MANAGE_CHILD, +) + +ADMIN_ID = "admin-1" +ADMIN_CTX = {"user_id": ADMIN_ID} +_TIMESTAMP = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}") + + +async def _set_up(hass, make_entry): + entry = wire_entry_to_registry( + make_entry(data={CONF_ADMIN_USER_IDS: [ADMIN_ID]}), hass.registry + ) + assert await async_setup_entry(hass, entry) is True + return entry + + +async def test_denied_call_logs_warning_with_fields( + hass, make_entry, caplog +) -> None: + entry = await _set_up(hass, make_entry) + with pytest.raises(Unauthorized): + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context={"user_id": "kiosk-panel"}, + ) + denials = [ + record + for record in caplog.records + if record.levelno == logging.WARNING + and "denied" in record.getMessage() + ] + assert len(denials) == 1 + message = denials[0].getMessage() + assert SERVICE_MANAGE_CHILD in message + assert "kiosk-panel" in message + assert _TIMESTAMP.search(message), message + + +async def test_denied_no_context_logs_user_none( + hass, make_entry, caplog +) -> None: + entry = await _set_up(hass, make_entry) + with pytest.raises(Unauthorized): + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + ) + denials = [ + record + for record in caplog.records + if record.levelno == logging.WARNING + and "denied" in record.getMessage() + ] + assert len(denials) == 1 + message = denials[0].getMessage() + assert "none" in message + assert _TIMESTAMP.search(message), message + + +async def test_successful_admin_call_logs_debug_with_fields( + hass, make_entry, caplog +) -> None: + caplog.set_level(logging.DEBUG, logger="custom_components.nestquest") + entry = await _set_up(hass, make_entry) + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, + ) + allowed = [ + record + for record in caplog.records + if record.levelno == logging.DEBUG + and "allowed" in record.getMessage() + ] + assert len(allowed) == 1 + message = allowed[0].getMessage() + assert SERVICE_MANAGE_CHILD in message + assert ADMIN_ID in message + assert _TIMESTAMP.search(message), message + + +async def test_denied_log_carries_no_personal_data_beyond_user_id( + hass, make_entry, caplog +) -> None: + """The log line names the caller's user id only: the payload's + display name must not leak into the audit trail.""" + entry = await _set_up(hass, make_entry) + with pytest.raises(Unauthorized): + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context={"user_id": "kiosk-panel"}, + ) + denials = [ + record.getMessage() + for record in caplog.records + if record.levelno == logging.WARNING and "denied" in record.getMessage() + ] + assert denials, "expected a denial log line" + assert "Ada" not in denials[0] + assert "display_name" not in denials[0] \ No newline at end of file diff --git a/tests/test_schema.py b/tests/test_schema.py index f869176..50a26a2 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -11,6 +11,7 @@ from custom_components.nestquest.schema import ( SCHEMA_V1_ADMIN_USERS_DDL, SCHEMA_V1_CHILDREN_DDL, SCHEMA_V1_COMPLETION_EVENTS_DDL, + SCHEMA_V7_META_STATE_DDL, SCHEMA_V1_PRESENCE_OVERRIDES_DDL, SCHEMA_V1_PRESENCE_SCHEDULES_DDL, SCHEMA_V1_SCHEDULE_RULES_DDL, @@ -168,7 +169,7 @@ def _insert_override(database, child_id, **overrides): # --------------------------------------------------------------------------- -def test_schema_v1_statements_compose_the_eight_tables() -> None: +def test_schema_v1_statements_compose_the_nine_tables() -> None: assert SCHEMA_V1_STATEMENTS == [ *SCHEMA_V1_CHILDREN_DDL, *SCHEMA_V1_ADMIN_USERS_DDL, @@ -180,6 +181,7 @@ def test_schema_v1_statements_compose_the_eight_tables() -> None: *SCHEMA_V1_PRESENCE_OVERRIDES_DDL, *SCHEMA_V1_QUEST_INSTANCES_DDL, *SCHEMA_V1_COMPLETION_EVENTS_DDL, + *SCHEMA_V7_META_STATE_DDL, ] diff --git a/tests/test_sensors.py b/tests/test_sensors.py new file mode 100644 index 0000000..cadeca9 --- /dev/null +++ b/tests/test_sensors.py @@ -0,0 +1,515 @@ +"""Tests for the per-child daily count and percentage sensors (Feature 10, task 2).""" +from __future__ import annotations + +import datetime + +import pytest + +from conftest import wire_entry_to_registry + +from custom_components.nestquest import async_setup_entry, async_unload_entry +from custom_components.nestquest.children import create_child +from custom_components.nestquest.completion import complete_instance +from custom_components.nestquest.const import DOMAIN +from custom_components.nestquest.materialize import materialize +from custom_components.nestquest.quest_definitions import create_quest_definition +from custom_components.nestquest.recurrence import ScheduleRule +from custom_components.nestquest.sensor import ( + MAX_STATE_LENGTH, + _state_within_limit, +) + +#: The seeding horizon: instances exist for today and a few days out, +#: enough for the day snapshot without slowing the walk down. +SEED_HORIZON_DAYS = 3 + +#: The documented per-child day sensor kinds (unique_id suffixes). +SENSOR_KINDS = ( + "quests_due_today", + "quests_completed_today", + "quests_remaining_today", + "completion_pct_today", +) + + +def _today() -> datetime.date: + return datetime.date.today() + + +async def _seed_children(database): + """Three active children: Ada and Bo share one daily quest; Cory has none.""" + ada = await create_child(database, "Ada") + bo = await create_child(database, "Bo") + cory = await create_child(database, "Cory") + today = _today() + await create_quest_definition( + database, + "Brush teeth", + ScheduleRule.from_dict( + {"rule_type": "daily", "start_date": today.isoformat()} + ), + [ada.id, bo.id], + ["morning"], + ) + end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat() + await materialize(database, today.isoformat(), end, today=today) + return ada, bo, cory + + +async def _setup_seeded_entry(hass, make_entry): + """Set up the integration, seed three children, refresh: sensors exist. + + Children are seeded AFTER setup (the entry's database only exists + once setup opens it), so the sensors appear through the refresh + listener — the same path production takes when a child is added. + """ + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + database = entry.runtime_data.database + ada, bo, cory = await _seed_children(database) + await coordinator.async_refresh() + return entry, coordinator, (ada, bo, cory) + + +def _snapshot_child(coordinator, child_id): + """Return the child's current day snapshot from the coordinator.""" + for child in coordinator.data.children: + if child.child_id == child_id: + return child + raise AssertionError(f"child {child_id} missing from the snapshot") + + +def _sensor(hass, child_id, kind): + """Return the registered sensor entity for (child, kind).""" + return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"] + + +async def _complete_first_instance(database, coordinator, child) -> None: + """Complete the child's first open instance through the business layer.""" + snapshot_child = _snapshot_child(coordinator, child.id) + instance_id = snapshot_child.instances[0].instance_id + await complete_instance( + database, + instance_id, + actor_source="panel", + actor_child_id=child.id, + ) + + +async def test_every_active_child_gets_five_sensors_with_documented_identity( + hass, make_entry +) -> None: + entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( + hass, make_entry + ) + database = entry.runtime_data.database + assert hass.config_entries.forwarded_platforms == [ + "sensor", + "binary_sensor", + ] + await _complete_first_instance(database, coordinator, ada) + await coordinator.async_refresh() + + # Two quest children plus the zero-quest child: three children x + # (five sensors + two binary sensors) + three household sensors, + # no duplicates. + assert len(hass.entities) == 24 + expected = { + ada.id: ("Ada", 1, 1, 0, 100), + bo.id: ("Bo", 1, 0, 1, 0), + cory.id: ("Cory", 0, 0, 0, 100), + } + for child_id, (name, due, completed, remaining, pct) in expected.items(): + due_entity = _sensor(hass, child_id, "quests_due_today") + completed_entity = _sensor(hass, child_id, "quests_completed_today") + remaining_entity = _sensor(hass, child_id, "quests_remaining_today") + pct_entity = _sensor(hass, child_id, "completion_pct_today") + next_entity = _sensor(hass, child_id, "next_quest") + assert due_entity.name == f"NestQuest {name} quests due today" + assert completed_entity.name == ( + f"NestQuest {name} quests completed today" + ) + assert remaining_entity.name == ( + f"NestQuest {name} quests remaining today" + ) + assert pct_entity.name == f"NestQuest {name} completion pct today" + assert due_entity.native_value == due + assert completed_entity.native_value == completed + assert remaining_entity.native_value == remaining + assert pct_entity.native_value == pct + for entity in (due_entity, completed_entity, remaining_entity): + assert entity.state_class == "measurement" + assert entity.native_unit_of_measurement is None + assert pct_entity.state_class == "measurement" + assert pct_entity.native_unit_of_measurement == "%" + assert pct_entity.device_class == "percentage" + device_info = due_entity.device_info + assert device_info.name == f"NestQuest {name}" + assert device_info.manufacturer == "NestQuest" + assert device_info.identifiers == {(DOMAIN, f"child_{child_id}")} + + +async def test_zero_quest_child_is_a_perfect_day(hass, make_entry) -> None: + entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( + hass, make_entry + ) + due_entity = _sensor(hass, cory.id, "quests_due_today") + pct_entity = _sensor(hass, cory.id, "completion_pct_today") + assert due_entity.native_value == 0 + assert pct_entity.native_value == 100, ( + "zero quests is a perfect day, not a division error" + ) + attributes = due_entity.extra_state_attributes + assert attributes["instances"] == [] + assert attributes["admin_instances"] == [] + + +async def test_due_today_attributes_carry_the_panel_payload( + hass, make_entry +) -> None: + entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( + hass, make_entry + ) + database = entry.runtime_data.database + await _complete_first_instance(database, coordinator, ada) + await coordinator.async_refresh() + + ada_due = _sensor(hass, ada.id, "quests_due_today") + attributes = ada_due.extra_state_attributes + assert set(attributes) == { + "instances", + "admin_instances", + "child_id", + "child_name", + "present", + } + assert attributes["child_id"] == ada.id + assert attributes["child_name"] == "Ada" + assert attributes["present"] is True + [instance] = attributes["instances"] + assert set(instance) == { + "id", + "definition_id", + "child_id", + "title", + "icon", + "window", + "due_time", + "state", + "overdue", + "completed_at", + "on_time", + } + assert instance["child_id"] == ada.id + assert instance["title"] == "Brush teeth" + assert instance["window"] == "morning" + assert instance["state"] == "completed", ( + "the derived 'done' maps to the panel's 'completed' spelling" + ) + assert instance["completed_at"] is not None + assert instance["on_time"] is not None + assert instance["overdue"] is False + assert attributes["admin_instances"] == attributes["instances"], ( + "with nothing missed, admin and panel payloads are identical" + ) + + bo_due = _sensor(hass, bo.id, "quests_due_today") + [bo_instance] = bo_due.extra_state_attributes["instances"] + assert bo_instance["state"] == "open" + assert bo_instance["completed_at"] is None + assert bo_instance["on_time"] is None + + +async def test_due_today_payload_omits_missed_for_panel_includes_for_admin( + hass, make_entry, monkeypatch +) -> None: + """D-009: the panel payload omits missed instances, the admin one keeps them. + + A missed instance is derived (``due_date`` before the snapshot's + "today"), never stored. The coordinator's day listing is keyed on + ``_local_now``'s date, so pinning the clock forward would move the + listing window off today's rows entirely; the pinned FUTURE clock + is therefore applied at the ``derive_state`` boundary instead — + today's open instances then read as missed through the same + derivation rule, without back-dating instance rows past the DAO's + no-past guard or touching append-only completion events. + """ + entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( + hass, make_entry + ) + import custom_components.nestquest.coordinator as coordinator_module + + tomorrow = _today() + datetime.timedelta(days=1) + real_derive_state = coordinator_module.derive_state + + def _derive_against_pinned_future(instance, latest, today): + return real_derive_state(instance, latest, tomorrow) + + monkeypatch.setattr( + coordinator_module, "derive_state", _derive_against_pinned_future + ) + await coordinator.async_refresh() + + bo_due = _sensor(hass, bo.id, "quests_due_today") + attributes = bo_due.extra_state_attributes + assert attributes["instances"] == [], ( + "missed instances are omitted from the panel payload" + ) + [missed] = attributes["admin_instances"] + assert missed["state"] == "missed" + assert missed["completed_at"] is None + # Counts still roll the missed instance up as not-completed. + assert _sensor(hass, bo.id, "quests_completed_today").native_value == 0 + assert _sensor(hass, bo.id, "quests_remaining_today").native_value == 1 + + +@pytest.mark.parametrize("kind", SENSOR_KINDS) +async def test_no_sensor_state_exceeds_the_255_character_limit( + hass, make_entry, kind +) -> None: + entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( + hass, make_entry + ) + for child in (ada, bo, cory): + entity = _sensor(hass, child.id, kind) + state = entity.native_value + assert len(str(state)) <= MAX_STATE_LENGTH + + +def test_state_within_limit_refuses_overlong_strings() -> None: + """The truncation guard: numerics pass, overlong strings fail fast.""" + assert _state_within_limit(3) == 3 + assert _state_within_limit("ok") == "ok" + with pytest.raises(ValueError, match="255"): + _state_within_limit("x" * (MAX_STATE_LENGTH + 1)) + + +async def test_sensors_update_on_refresh_after_a_completion( + hass, make_entry +) -> None: + entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( + hass, make_entry + ) + database = entry.runtime_data.database + completed_entity = _sensor(hass, ada.id, "quests_completed_today") + remaining_entity = _sensor(hass, ada.id, "quests_remaining_today") + pct_entity = _sensor(hass, ada.id, "completion_pct_today") + due_entity = _sensor(hass, ada.id, "quests_due_today") + assert completed_entity.native_value == 0 + assert remaining_entity.native_value == 1 + assert pct_entity.native_value == 0 + + # Coordinator listener wiring: each refresh writes the entity's + # state (the registration the cards' state_changed handlers ride). + writes = [] + real_write = due_entity.async_write_ha_state + + def _record_write(): + writes.append(due_entity.unique_id) + real_write() + + due_entity.async_write_ha_state = _record_write + + await _complete_first_instance(database, coordinator, ada) + await coordinator.async_refresh() + + assert completed_entity.native_value == 1 + assert remaining_entity.native_value == 0 + assert pct_entity.native_value == 100 + assert writes == [due_entity.unique_id] + + +async def test_repeated_refreshes_do_not_duplicate_entities( + hass, make_entry +) -> None: + entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( + hass, make_entry + ) + await coordinator.async_refresh() + await coordinator.async_refresh() + assert len(hass.entities) == 24 + + +async def test_setup_with_no_children_registers_only_household( + hass, make_entry +) -> None: + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + # No children means no per-child entities; the three household + # rollups still exist (an empty household is a valid 0). + assert set(hass.entities) == { + "nestquest_household_quests_due_today", + "nestquest_household_quests_completed_today", + "nestquest_cycle_day", + } + assert hass.entities["nestquest_household_quests_due_today"].native_value == 0 + + +async def test_unload_unloads_platforms(hass, make_entry) -> None: + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + assert await async_unload_entry(hass, entry) is True + assert hass.config_entries.unloaded_platforms == [ + "sensor", + "binary_sensor", + ] + + +async def test_rename_propagates_to_name_and_device_label( + hass, make_entry +) -> None: + """A manage_child rename shows on the next refresh: the name and + device label re-derive from the snapshot while the unique_id (and + therefore history) is untouched.""" + from custom_components.nestquest.children import edit_child + + entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry( + hass, make_entry + ) + database = entry.runtime_data.database + due_sensor = _sensor(hass, ada.id, "quests_due_today") + original_unique_id = due_sensor.unique_id + assert due_sensor.name.startswith("NestQuest Ada ") + + await edit_child(database, ada.id, display_name="Mirren") + await coordinator.async_refresh() + + assert due_sensor.name.startswith("NestQuest Mirren ") + assert due_sensor.device_info.name == "NestQuest Mirren" + assert due_sensor.unique_id == original_unique_id + + +async def test_failed_platform_unload_returns_false_and_retains_runtime( + hass, make_entry +) -> None: + """When HA reports platforms still loaded, the unload must NOT + close the database the live entities read: it returns False and + keeps the runtime record for a retry.""" + entry, _coordinator, _children = await _setup_seeded_entry( + hass, make_entry + ) + database = entry.runtime_data.database + + async def _refuse_unload(entry_arg, platforms): + return False + + hass.config_entries.async_unload_platforms = _refuse_unload + assert await async_unload_entry(hass, entry) is False + assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data + assert database.connected is True + +async def test_next_quest_sensor_reports_earliest_open_quest( + hass, make_entry +) -> None: + """The next-quest sensor carries the earliest open quest's title in + state with the full shape in attributes.""" + entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry( + hass, make_entry + ) + next_entity = _sensor(hass, ada.id, "next_quest") + assert next_entity.name == f"NestQuest Ada next quest" + assert next_entity.native_value == "Brush teeth" + attributes = next_entity.extra_state_attributes + assert attributes["title"] == "Brush teeth" + assert attributes["window"] == "morning" + assert attributes["instance_id"] == _snapshot_child( + coordinator, ada.id + ).instances[0].instance_id + assert attributes["child_id"] == ada.id + + +async def test_next_quest_sensor_reports_none_sentinel_when_day_clear( + hass, make_entry +) -> None: + """A child with every quest done (and a zero-quest child) reports + the documented sentinel, never an empty string.""" + entry, coordinator, (ada, _bo, cory) = await _setup_seeded_entry( + hass, make_entry + ) + database = entry.runtime_data.database + await _complete_first_instance(database, coordinator, ada) + await coordinator.async_refresh() + assert _sensor(hass, ada.id, "next_quest").native_value == "none" + assert _sensor(hass, cory.id, "next_quest").native_value == "none" + + +async def test_next_quest_state_truncates_over_the_limit( + hass, make_entry +) -> None: + """A quest title longer than the state limit is truncated; the + attribute keeps the full title.""" + entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry( + hass, make_entry + ) + database = entry.runtime_data.database + from custom_components.nestquest.quest_definitions import ( + edit_quest_definition, + ) + + snapshot_child = _snapshot_child(coordinator, ada.id) + await edit_quest_definition( + database, + snapshot_child.instances[0].definition_id, + title="A very long quest title. " * 30, + ) + await coordinator.async_refresh() + next_entity = _sensor(hass, ada.id, "next_quest") + full_title = _snapshot_child(coordinator, ada.id).instances[0].title + assert len(full_title) > 255 + state = next_entity.native_value + assert len(state) <= 255 + assert next_entity.extra_state_attributes["title"] == full_title + + +async def test_household_rollups_sum_the_active_children( + hass, make_entry +) -> None: + """Household due/completed equal the sum of the per-child sensors; + the cycle-day sensor carries the coordinator's answer.""" + import datetime as _dt + + from custom_components.nestquest.dao_presence import ( + PresenceSchedulesDao, + ) + + entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( + hass, make_entry + ) + database = entry.runtime_data.database + await _complete_first_instance(database, coordinator, ada) + await coordinator.async_refresh() + + household_due = hass.entities["nestquest_household_quests_due_today"] + household_completed = hass.entities[ + "nestquest_household_quests_completed_today" + ] + cycle_day_entity = hass.entities["nestquest_cycle_day"] + per_child_due = [ + _sensor(hass, child.id, "quests_due_today").native_value + for child in (ada, bo, cory) + ] + per_child_completed = [ + _sensor(hass, child.id, "quests_completed_today").native_value + for child in (ada, bo, cory) + ] + assert household_due.native_value == sum(per_child_due) + assert household_completed.native_value == sum(per_child_completed) + assert household_due.name == "NestQuest household quests due today" + assert household_completed.name == ( + "NestQuest household quests completed today" + ) + assert cycle_day_entity.name == "NestQuest cycle day" + assert cycle_day_entity.native_value == 0, "no schedule anywhere" + + # A schedule on the FIRST active child drives cycle day. + today = _dt.date.today() + await PresenceSchedulesDao(database).upsert_by_child( + ada.id, 2, (today - _dt.timedelta(days=3)).isoformat(), + "0,1,2,3,4,5,6|", + ) + await coordinator.async_refresh() + assert cycle_day_entity.native_value == 4 + assert cycle_day_entity.extra_state_attributes["today"] == ( + today.isoformat() + ) diff --git a/tests/test_service_policy.py b/tests/test_service_policy.py new file mode 100644 index 0000000..0afa85c --- /dev/null +++ b/tests/test_service_policy.py @@ -0,0 +1,32 @@ +"""Tests for the Feature 09 admin-only versus open service registry.""" +from __future__ import annotations + +from custom_components.nestquest.const import ( + DOMAIN_SERVICES, + SERVICE_COMPLETE_QUEST, +) +from custom_components.nestquest.service_policy import ( + ADMIN_ONLY, + OPEN, + SERVICE_POLICY, +) + + +def test_policy_covers_exactly_the_registered_services() -> None: + assert frozenset(SERVICE_POLICY) == frozenset(DOMAIN_SERVICES) + + +def test_only_complete_quest_is_open() -> None: + open_services = { + name for name, access in SERVICE_POLICY.items() if access == OPEN + } + assert open_services == {SERVICE_COMPLETE_QUEST} + assert all( + access == ADMIN_ONLY + for name, access in SERVICE_POLICY.items() + if name != SERVICE_COMPLETE_QUEST + ) + + +def test_policy_values_are_only_open_or_admin_only() -> None: + assert set(SERVICE_POLICY.values()) <= {OPEN, ADMIN_ONLY} diff --git a/tests/test_services.py b/tests/test_services.py index 68f2cde..d242a3b 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,9 +1,33 @@ -"""Tests for the NestQuest services.yaml declaration file.""" +"""Tests for NestQuest service declarations and domain-global handlers.""" from __future__ import annotations import re from pathlib import Path +import pytest +import voluptuous as vol +from homeassistant.exceptions import HomeAssistantError, Unauthorized + +from conftest import wire_entry_to_registry + +from custom_components.nestquest import async_setup_entry, async_unload_entry +from custom_components.nestquest.const import ( + CONF_ADMIN_USER_IDS, + DOMAIN, + DOMAIN_SERVICES, + SERVICE_COMPLETE_QUEST, + SERVICE_CREATE_PRESENCE_OVERRIDE, + SERVICE_CREATE_QUEST_DEFINITION, + SERVICE_DELETE_PRESENCE_OVERRIDE, + SERVICE_EXPORT_HISTORY_CSV, + SERVICE_MANAGE_CHILD, + SERVICE_REGENERATE, + SERVICE_SET_PRESENCE_PATTERN, + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + SERVICE_UNCOMPLETE_QUEST, +) +from custom_components.nestquest.service_policy import ADMIN_ONLY, SERVICE_POLICY + SERVICES_PATH = ( Path(__file__).parent.parent / "custom_components" @@ -12,10 +36,47 @@ SERVICES_PATH = ( ) +def _wire(entry, registry): + return wire_entry_to_registry(entry, registry) + + +ADMIN_ID = "admin-1" +ADMIN_CTX = {"user_id": ADMIN_ID} + + +def _make_admin_entry(make_entry): + from custom_components.nestquest.const import CONF_ADMIN_USER_IDS + + return make_entry(data={CONF_ADMIN_USER_IDS: [ADMIN_ID]}) + + +def _service_section(text: str, name: str) -> str: + match = re.search(rf"^{name}:\s*$", text, re.MULTILINE) + assert match is not None, f"services.yaml must declare {name!r}" + section = text[match.end() :] + next_service = re.search(r"^(?![#\s])\w+:\s*$", section, re.MULTILINE) + if next_service is not None: + section = section[: next_service.start()] + return section + + def test_services_yaml_exists() -> None: assert SERVICES_PATH.exists(), f"services.yaml missing at {SERVICES_PATH}" +def test_services_yaml_declares_every_canonical_service() -> None: + text = SERVICES_PATH.read_text(encoding="utf-8") + declared = re.findall(r"^(?![#\s])(\w+):\s*$", text, re.MULTILINE) + assert declared == list(DOMAIN_SERVICES) or set(declared) == set( + DOMAIN_SERVICES + ) + for name in DOMAIN_SERVICES: + section = _service_section(text, name) + assert re.search(r"description:", section), ( + f"{name} must carry a description" + ) + + def test_services_yaml_declares_regenerate() -> None: text = SERVICES_PATH.read_text(encoding="utf-8") assert re.search(r"^regenerate:\s*$", text, re.MULTILINE), ( @@ -27,15 +88,506 @@ def test_services_yaml_regenerate_has_description_and_no_fields() -> None: """The regenerate service is documented with a clear description and no required fields (there is no ``fields`` block under it).""" text = SERVICES_PATH.read_text(encoding="utf-8") - match = re.search(r"^regenerate:\s*$", text, re.MULTILINE) - assert match is not None - section = text[match.end():] - next_service = re.search(r"^(?![#\s])\w+:\s*$", section, re.MULTILINE) - if next_service is not None: - section = section[: next_service.start()] + section = _service_section(text, "regenerate") assert re.search(r"description:", section), ( "regenerate must carry a description" ) assert "fields:" not in section, ( "regenerate declares no required fields" - ) \ No newline at end of file + ) + + +def test_services_yaml_other_services_declare_fields() -> None: + text = SERVICES_PATH.read_text(encoding="utf-8") + for name in DOMAIN_SERVICES: + if name in (SERVICE_REGENERATE, SERVICE_EXPORT_HISTORY_CSV): + continue + section = _service_section(text, name) + assert "fields:" in section, f"{name} must declare fields" + + +async def test_all_canonical_services_registered_after_setup( + hass, make_entry +) -> None: + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + for service in DOMAIN_SERVICES: + assert hass.services.has_service(DOMAIN, service), service + + +async def test_unload_last_entry_removes_all_services(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + assert await async_unload_entry(hass, entry) is True + for service in DOMAIN_SERVICES: + assert not hass.services.has_service(DOMAIN, service), service + + +async def test_unload_one_of_two_entries_keeps_services( + hass, make_entry +) -> None: + entry_a = _wire(make_entry(entry_id="entry_a"), hass.registry) + entry_b = _wire(make_entry(entry_id="entry_b"), hass.registry) + assert await async_setup_entry(hass, entry_a) is True + assert await async_setup_entry(hass, entry_b) is True + assert await async_unload_entry(hass, entry_a) is True + for service in DOMAIN_SERVICES: + assert hass.services.has_service(DOMAIN, service), service + assert await async_unload_entry(hass, entry_b) is True + for service in DOMAIN_SERVICES: + assert not hass.services.has_service(DOMAIN, service), service + + +async def test_manage_child_create_persists_child(hass, make_entry) -> None: + from custom_components.nestquest.children import list_children + + entry = _wire(_make_admin_entry(make_entry), hass.registry) + assert await async_setup_entry(hass, entry) is True + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, + ) + children = await list_children(entry.runtime_data.database) + assert [child.display_name for child in children] == ["Ada"] + + +async def test_create_and_set_quest_definition_active( + hass, make_entry +) -> None: + from custom_components.nestquest.children import list_children + from custom_components.nestquest.quest_definitions import ( + list_active_definitions, + ) + from custom_components.nestquest.dao_rules import QuestDefinitionsDao + + entry = _wire(_make_admin_entry(make_entry), hass.registry) + assert await async_setup_entry(hass, entry) is True + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, + ) + child = (await list_children(entry.runtime_data.database))[0] + await hass.services.call( + DOMAIN, + SERVICE_CREATE_QUEST_DEFINITION, + { + "title": "Brush teeth", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [child.id], + "windows": ["morning"], + }, + context=ADMIN_CTX, + ) + active = await list_active_definitions(entry.runtime_data.database) + assert len(active) == 1 + definition_id = active[0].definition.id + await hass.services.call( + DOMAIN, + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + {"definition_id": definition_id, "is_active": False}, + context=ADMIN_CTX, + ) + assert await list_active_definitions(entry.runtime_data.database) == [] + stored = await QuestDefinitionsDao(entry.runtime_data.database).get( + definition_id + ) + assert stored is not None + assert stored.is_active is False + + +async def test_complete_and_uncomplete_quest_call_business_layer( + hass, make_entry +) -> None: + import datetime + + from custom_components.nestquest.children import list_children + from custom_components.nestquest.completion import instance_state + from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS + from custom_components.nestquest.dao_instances import QuestInstancesDao + + entry = _wire(_make_admin_entry(make_entry), hass.registry) + assert await async_setup_entry(hass, entry) is True + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, + ) + child = (await list_children(entry.runtime_data.database))[0] + await hass.services.call( + DOMAIN, + SERVICE_CREATE_QUEST_DEFINITION, + { + "title": "Brush teeth", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [child.id], + "windows": ["morning"], + }, + context=ADMIN_CTX, + ) + await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) + today = datetime.date.today().isoformat() + end = ( + datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + records = await QuestInstancesDao( + entry.runtime_data.database + ).list_by_date_range(child.id, today, end) + assert records + instance_id = records[0].id + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instance_id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + assert ( + await instance_state(entry.runtime_data.database, instance_id) + == "done" + ) + await hass.services.call( + DOMAIN, + SERVICE_UNCOMPLETE_QUEST, + { + "instance_id": instance_id, + "actor": "user", + "actor_user_id": ADMIN_ID, + }, + context=ADMIN_CTX, + ) + assert ( + await instance_state(entry.runtime_data.database, instance_id) + == "open" + ) + + +async def test_set_presence_pattern_regenerates_child_instances( + hass, make_entry +) -> None: + import datetime + + from custom_components.nestquest.children import list_children + from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS + from custom_components.nestquest.dao_instances import QuestInstancesDao + + entry = _wire(_make_admin_entry(make_entry), hass.registry) + assert await async_setup_entry(hass, entry) is True + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, + ) + child = (await list_children(entry.runtime_data.database))[0] + await hass.services.call( + DOMAIN, + SERVICE_CREATE_QUEST_DEFINITION, + { + "title": "Brush teeth", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [child.id], + "windows": ["morning"], + }, + context=ADMIN_CTX, + ) + await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) + today = datetime.date.today().isoformat() + end = ( + datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + dao = QuestInstancesDao(entry.runtime_data.database) + assert await dao.list_by_date_range(child.id, today, end) + await hass.services.call( + DOMAIN, + SERVICE_SET_PRESENCE_PATTERN, + { + "child_id": child.id, + "cycle_length_weeks": 1, + "anchor_date": today, + "pattern": {0: []}, + }, + context=ADMIN_CTX, + ) + assert await dao.list_by_date_range(child.id, today, end) == [] + + +async def test_presence_override_create_and_delete_regenerate( + hass, make_entry +) -> None: + import datetime + + from custom_components.nestquest.children import list_children + from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS + from custom_components.nestquest.dao_instances import QuestInstancesDao + from custom_components.nestquest.dao_presence import PresenceOverridesDao + + entry = _wire(_make_admin_entry(make_entry), hass.registry) + assert await async_setup_entry(hass, entry) is True + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, + ) + child = (await list_children(entry.runtime_data.database))[0] + await hass.services.call( + DOMAIN, + SERVICE_CREATE_QUEST_DEFINITION, + { + "title": "Brush teeth", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [child.id], + "windows": ["morning"], + }, + context=ADMIN_CTX, + ) + await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) + today = datetime.date.today() + end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + dao = QuestInstancesDao(entry.runtime_data.database) + before = await dao.list_by_date_range( + child.id, today.isoformat(), end.isoformat() + ) + assert before + await hass.services.call( + DOMAIN, + SERVICE_CREATE_PRESENCE_OVERRIDE, + { + "child_id": child.id, + "start_date": today.isoformat(), + "end_date": end.isoformat(), + "is_present": False, + }, + context=ADMIN_CTX, + ) + assert ( + await dao.list_by_date_range( + child.id, today.isoformat(), end.isoformat() + ) + == [] + ) + override = ( + await PresenceOverridesDao( + entry.runtime_data.database + ).list_by_child_and_range( + child.id, today.isoformat(), end.isoformat() + ) + )[0] + await hass.services.call( + DOMAIN, + SERVICE_DELETE_PRESENCE_OVERRIDE, + {"override_id": override.id}, + context=ADMIN_CTX, + ) + after = await dao.list_by_date_range( + child.id, today.isoformat(), end.isoformat() + ) + assert len(after) == len(before) + + +async def test_export_history_csv_raises_home_assistant_error( + hass, make_entry +) -> None: + entry = _wire(_make_admin_entry(make_entry), hass.registry) + assert await async_setup_entry(hass, entry) is True + with pytest.raises(HomeAssistantError, match="Feature 13"): + await hass.services.call( + DOMAIN, SERVICE_EXPORT_HISTORY_CSV, context=ADMIN_CTX + ) + + +async def test_invalid_schema_data_surfaces_as_error(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)): + await hass.services.call(DOMAIN, SERVICE_COMPLETE_QUEST, {}) + with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)): + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": True, "actor": "panel", "actor_child_id": 1}, + ) + with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)): + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create"}, + ) + + +async def test_admin_only_service_denies_non_admin(hass, make_entry) -> None: + entry = _wire(_make_admin_entry(make_entry), hass.registry) + assert await async_setup_entry(hass, entry) is True + with pytest.raises(Unauthorized): + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context={"user_id": "kiosk-panel"}, + ) + + +async def test_admin_only_service_denies_missing_user_context( + hass, make_entry +) -> None: + entry = _wire(_make_admin_entry(make_entry), hass.registry) + assert await async_setup_entry(hass, entry) is True + with pytest.raises(Unauthorized): + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + ) + + +async def test_admin_only_service_denies_admin_on_empty_allowlist( + hass, make_entry +) -> None: + """Even the allowlisted id is denied when the allowlist is empty.""" + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + with pytest.raises(Unauthorized): + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, + ) + + +async def test_unauthorized_is_raised_before_handler_runs( + hass, make_entry +) -> None: + from custom_components.nestquest.children import list_children + + entry = _wire(_make_admin_entry(make_entry), hass.registry) + assert await async_setup_entry(hass, entry) is True + with pytest.raises(Unauthorized): + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context={"user_id": "kiosk-panel"}, + ) + assert await list_children(entry.runtime_data.database) == [] + + +_MINIMAL_PAYLOADS = { + "uncomplete_quest": {"instance_id": 1, "actor": "user"}, + "create_quest_definition": { + "title": "T", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [1], + "windows": ["morning"], + }, + "update_quest_definition": {"definition_id": 1}, + "set_quest_definition_active": {"definition_id": 1, "is_active": True}, + "set_presence_pattern": { + "child_id": 1, + "cycle_length_weeks": 1, + "anchor_date": "2026-09-19", + "pattern": {0: [0]}, + }, + "create_presence_override": { + "child_id": 1, + "start_date": "2026-09-19", + "end_date": "2026-09-19", + "is_present": False, + }, + "delete_presence_override": {"override_id": 1}, + "export_history_csv": {}, + "manage_child": {"action": "create", "display_name": "Ada"}, + "regenerate": {}, +} + +_ADMIN_ONLY_SERVICES = [ + name for name, policy in SERVICE_POLICY.items() if policy == ADMIN_ONLY +] + + +@pytest.mark.parametrize("service", _ADMIN_ONLY_SERVICES) +async def test_admin_only_service_fails_closed_without_user_context( + hass, make_entry, service +) -> None: + """A call with no user_id is denied on every admin-only service.""" + entry = _wire(_make_admin_entry(make_entry), hass.registry) + assert await async_setup_entry(hass, entry) is True + with pytest.raises(Unauthorized): + await hass.services.call( + DOMAIN, service, dict(_MINIMAL_PAYLOADS[service]) + ) + + +async def test_complete_quest_proceeds_without_user_context( + hass, make_entry +) -> None: + """The open service needs no user context: the kiosk panel taps a + child profile and the call goes through with no HA user at all.""" + import datetime + + from custom_components.nestquest.children import list_children + from custom_components.nestquest.completion import instance_state + from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS + from custom_components.nestquest.dao_instances import QuestInstancesDao + + entry = _wire(_make_admin_entry(make_entry), hass.registry) + assert await async_setup_entry(hass, entry) is True + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, + ) + child = (await list_children(entry.runtime_data.database))[0] + await hass.services.call( + DOMAIN, + SERVICE_CREATE_QUEST_DEFINITION, + { + "title": "Brush teeth", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [child.id], + "windows": ["morning"], + }, + context=ADMIN_CTX, + ) + await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) + today = datetime.date.today().isoformat() + end = ( + datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + instance_id = ( + await QuestInstancesDao( + entry.runtime_data.database + ).list_by_date_range(child.id, today, end) + )[0].id + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, + ) + assert ( + await instance_state(entry.runtime_data.database, instance_id) + == "done" + ) + + +async def test_scheduled_internals_never_call_the_service_layer( + hass, make_entry +) -> None: + """The daily rollover and startup backfill run the materialization + as direct function calls; they never widen the service gate.""" + import custom_components.nestquest as nestquest + + assert not hasattr(nestquest, "_run_horizon_materialization_service") + source = Path(nestquest.__file__).read_text(encoding="utf-8") + assert "hass.services.call" not in source, ( + "scheduled internals must call the materialization directly, " + "never through hass.services.call" + ) diff --git a/tests/test_sweep.py b/tests/test_sweep.py new file mode 100644 index 0000000..c76ac81 --- /dev/null +++ b/tests/test_sweep.py @@ -0,0 +1,259 @@ +"""Tests for the nightly missed-quest sweep (Feature 11, task 1).""" +from __future__ import annotations + +import datetime +import re + +from conftest import wire_entry_to_registry + +from custom_components.nestquest import async_setup_entry +from custom_components.nestquest.children import create_child, list_children +from custom_components.nestquest.completion import ( + complete_instance, + uncomplete_instance, +) +from custom_components.nestquest.const import EVENT_QUEST_MISSED +from custom_components.nestquest.dao_instances import ( + CompletionEventsDao, + QuestInstancesDao, +) +from custom_components.nestquest.dao_meta import MetaStateDao +from custom_components.nestquest.materialize import materialize +from custom_components.nestquest.quest_definitions import ( + create_quest_definition, +) +from custom_components.nestquest.recurrence import ScheduleRule +from custom_components.nestquest.sweep import ( + SWEEP_WATERMARK_KEY, + run_missed_sweep, +) + +_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$") + + +async def _seed(hass, make_entry) -> tuple: + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + database = entry.runtime_data.database + ada = await create_child(database, "Ada") + bo = await create_child(database, "Bo") + today = datetime.date.today() + await create_quest_definition( + database, + "Brush teeth", + ScheduleRule.from_dict( + {"rule_type": "daily", "start_date": today.isoformat()} + ), + [ada.id, bo.id], + ["morning"], + ) + end = (today + datetime.timedelta(days=3)).isoformat() + await materialize(database, today.isoformat(), end, today=today) + return entry, ada, bo + + +async def test_sweep_fires_one_event_per_past_due_open_instance( + hass, make_entry +) -> None: + entry, ada, bo = await _seed(hass, make_entry) + database = entry.runtime_data.database + today = datetime.date.today() + tomorrow = today + datetime.timedelta(days=1) + + fired = await run_missed_sweep(hass, database, today=tomorrow) + assert fired == 2, "one per child for today's now-past-due instance" + + events = hass.bus.fired(EVENT_QUEST_MISSED) + assert len(events) == 2 + payload = events[0] + assert payload["child_name"] in ("Ada", "Bo") + assert payload["quest_title"] == "Brush teeth" + assert payload["window"] == "morning" + assert payload["due_date"] == today.isoformat() + assert payload["due_time"] is None + assert _TIMESTAMP.match(payload["occurred_at"]) + assert {payload["child_id"] for payload in events} == {ada.id, bo.id} + # The sweep is read-only over the domain tables. + assert await _count_events(database) == 0 + + +async def test_sweep_is_idempotent_same_night(hass, make_entry) -> None: + entry, _ada, _bo = await _seed(hass, make_entry) + database = entry.runtime_data.database + tomorrow = datetime.date.today() + datetime.timedelta(days=1) + + first = await run_missed_sweep(hass, database, today=tomorrow) + second = await run_missed_sweep(hass, database, today=tomorrow) + assert first == 2 + assert second == 0, "the watermark makes a rerun fire nothing" + assert len(hass.bus.fired(EVENT_QUEST_MISSED)) == 2 + assert await MetaStateDao(database).get(SWEEP_WATERMARK_KEY) == ( + tomorrow.isoformat() + ) + + +async def test_sweep_only_fires_new_instances_after_watermark( + hass, make_entry +) -> None: + entry, ada, _bo = await _seed(hass, make_entry) + database = entry.runtime_data.database + today = datetime.date.today() + tomorrow = today + datetime.timedelta(days=1) + day_after = today + datetime.timedelta(days=2) + + # First sweep one day ahead: today's instances fire. + fired = await run_missed_sweep(hass, database, today=tomorrow) + assert fired == 2 + first_events = hass.bus.fired(EVENT_QUEST_MISSED) + assert all( + payload["due_date"] == today.isoformat() + for payload in first_events + ) + + # One more day passes: only tomorrow's instances fire now. + fired = await run_missed_sweep(hass, database, today=day_after) + assert fired == 2 + all_events = hass.bus.fired(EVENT_QUEST_MISSED) + assert len(all_events) == 4 + assert all( + payload["due_date"] == tomorrow.isoformat() + for payload in all_events[2:] + ) + + +async def test_sweep_never_reports_completed_or_reversed_instances( + hass, make_entry +) -> None: + entry, ada, bo = await _seed(hass, make_entry) + database = entry.runtime_data.database + today = datetime.date.today() + tomorrow = today + datetime.timedelta(days=1) + + instances_ada = await QuestInstancesDao(database).list_by_date_range( + ada.id, today.isoformat(), today.isoformat() + ) + instances_bo = await QuestInstancesDao(database).list_by_date_range( + bo.id, today.isoformat(), today.isoformat() + ) + # Ada completed hers; Bo completed his then reversed it. + await complete_instance( + database, + instances_ada[0].id, + actor_source="panel", + actor_child_id=ada.id, + ) + await complete_instance( + database, + instances_bo[0].id, + actor_source="panel", + actor_child_id=bo.id, + ) + await uncomplete_instance( + database, + instances_bo[0].id, + actor_source="user", + actor_user_id="admin-1", + ) + + fired = await run_missed_sweep(hass, database, today=tomorrow) + assert fired == 0, ( + "no-completion-event rule excludes both the completed and the " + "reversed instance (append-only history keeps both)" + ) + assert hass.bus.fired(EVENT_QUEST_MISSED) == [] + + +async def test_sweep_skips_instance_completed_after_sweep( + hass, make_entry +) -> None: + """A missed instance completed later is never re-announced; the + late completion still lands in the append-only log.""" + entry, ada, _bo = await _seed(hass, make_entry) + database = entry.runtime_data.database + today = datetime.date.today() + tomorrow = today + datetime.timedelta(days=1) + day3 = today + datetime.timedelta(days=2) + + await run_missed_sweep(hass, database, today=tomorrow) + instance = ( + await QuestInstancesDao(database).list_by_date_range( + ada.id, today.isoformat(), today.isoformat() + ) + )[0] + await complete_instance( + database, + instance.id, + actor_source="panel", + actor_child_id=ada.id, + ) + fired = await run_missed_sweep(hass, database, today=day3) + assert fired == 0 or all( + payload["instance_id"] != instance.id + for payload in hass.bus.fired(EVENT_QUEST_MISSED)[2:] + ) + + +async def test_rollover_listener_runs_sweep_after_materialization( + hass, make_entry, monkeypatch +) -> None: + import custom_components.nestquest as nestquest + + calls = [] + + async def _record_sweep(hass_arg, database, **kwargs): + calls.append("sweep") + + monkeypatch.setattr(nestquest, "run_missed_sweep", _record_sweep) + + entry = wire_entry_to_registry( + make_entry(options={"day_rollover_time": "00:00"}), hass.registry + ) + assert await async_setup_entry(hass, entry) is True + assert calls == ["sweep"], "startup runs the sweep once" + + await hass.time_change.fire() + assert calls == ["sweep", "sweep"], "the rollover listener sweeps too" + + +async def test_v6_database_migrates_to_v7_meta_state( + hass, make_entry +) -> None: + """A database stamped at version 6 gains the meta_state table and + version 7 on the next startup; existing rows are untouched.""" + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + database = entry.runtime_data.database + version = await database.fetch_one( + "SELECT version FROM nestquest_schema_version" + ) + assert version == (7,) + tables = await database.fetch_all( + "SELECT name FROM sqlite_master WHERE type='table' " + "AND name='nestquest_meta_state'" + ) + assert tables, "migration 7 created the meta_state table" + + +async def _count_events(database) -> int: + rows = await database.fetch_all("SELECT COUNT(*) FROM completion_events") + return rows[0][0] + + +async def test_overlapping_sweeps_never_double_announce( + hass, make_entry +) -> None: + """A startup sweep and a rollover sweep racing on the same night + queue on the sweep lock: the winner announces, the loser re-reads + the watermark inside the lock and fires nothing.""" + import asyncio + + entry, _ada, _bo = await _seed(hass, make_entry) + database = entry.runtime_data.database + tomorrow = datetime.date.today() + datetime.timedelta(days=1) + + first, second = await asyncio.gather( + run_missed_sweep(hass, database, today=tomorrow), + run_missed_sweep(hass, database, today=tomorrow), + ) + assert first + second == 2, "each instance announced exactly once" + assert len(hass.bus.fired(EVENT_QUEST_MISSED)) == 2 diff --git a/uv.lock b/uv.lock index 044c8d0..f334244 100644 --- a/uv.lock +++ b/uv.lock @@ -20,6 +20,92 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "nestquest" version = "0.5.0" @@ -27,8 +113,10 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ + { name = "jinja2" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pyyaml" }, { name = "voluptuous" }, ] @@ -36,8 +124,10 @@ dev = [ [package.metadata.requires-dev] dev = [ + { name = "jinja2", specifier = ">=3" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "pyyaml", specifier = ">=6" }, { name = "voluptuous", specifier = ">=0.14" }, ] @@ -97,6 +187,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0"