From 2d30f0ce772679f208a721255a2899d54113223e Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Fri, 18 Sep 2026 15:59:02 -0400 Subject: [PATCH 01/46] Register canonical NestQuest HA services with schemas Domain-global handlers for the ENTITIES-AND-SERVICES inventory plus regenerate. No permission decorator yet. --- custom_components/nestquest/__init__.py | 55 ++- custom_components/nestquest/const.py | 27 +- custom_components/nestquest/services.py | 557 ++++++++++++++++++++++ custom_components/nestquest/services.yaml | 316 +++++++++++- tests/conftest.py | 32 +- tests/test_services.py | 249 +++++++++- 6 files changed, 1194 insertions(+), 42 deletions(-) create mode 100644 custom_components/nestquest/services.py diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 8cc9b79..b0edf5b 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -24,11 +24,11 @@ from .const import ( DEFAULT_HORIZON_DAYS, DOMAIN, LOGGER, - SERVICE_REGENERATE, ) 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 @@ -356,21 +356,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,13 +372,29 @@ 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: @@ -489,7 +492,7 @@ 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) + _register_services(hass) except BaseException: # A failure anywhere after the database is open must not leak the # listeners already registered against that (now-closed) database: @@ -562,11 +565,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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/const.py b/custom_components/nestquest/const.py index ce21568..6238252 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -9,9 +9,34 @@ LOGGER = logging.getLogger(LOGGER_NAME) PLATFORMS: list[str] = [] -#: Service name for the on-demand regeneration service (``nestquest.regenerate``). +#: 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 diff --git a/custom_components/nestquest/services.py b/custom_components/nestquest/services.py new file mode 100644 index 0000000..c6d9fb2 --- /dev/null +++ b/custom_components/nestquest/services.py @@ -0,0 +1,557 @@ +"""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 + +from collections.abc import Awaitable, Callable +from typing import Any + +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 ( + 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 .presence import PresenceSchedule +from .recurrence import ScheduleRule + +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_database(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.database + + +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: + database = _require_database(hass, find_runtime) + await completion_layer.complete_instance( + database, + call.data["instance_id"], + **_actor_kwargs(call), + ) + + return _with_errors(_handler) + + +def _uncomplete_quest( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + database = _require_database(hass, find_runtime) + await completion_layer.uncomplete_instance( + database, + call.data["instance_id"], + **_actor_kwargs(call), + ) + + return _with_errors(_handler) + + +def _create_quest_definition( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + database = _require_database(hass, find_runtime) + 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: + database = _require_database(hass, find_runtime) + data = call.data + kwargs: dict[str, Any] = {} + 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( + 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: + database = _require_database(hass, find_runtime) + await quest_definitions_layer.set_quest_definition_active( + database, + call.data["definition_id"], + call.data["is_active"], + ) + + return _with_errors(_handler) + + +def _set_presence_pattern( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + database = _require_database(hass, find_runtime) + data = call.data + schedule = PresenceSchedule( + data["child_id"], + data["cycle_length_weeks"], + data["anchor_date"], + data["pattern"], + ) + await PresenceSchedulesDao(database).upsert_by_child( + schedule.child_id, + schedule.cycle_length_weeks, + schedule.anchor_date.isoformat(), + schedule.encode(), + ) + + return _with_errors(_handler) + + +def _create_presence_override( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + database = _require_database(hass, find_runtime) + data = call.data + await PresenceOverridesDao(database).create( + data["child_id"], + data["start_date"], + data["end_date"], + data["is_present"], + note=data.get("note"), + ) + + return _with_errors(_handler) + + +def _delete_presence_override( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + database = _require_database(hass, find_runtime) + await PresenceOverridesDao(database).delete(call.data["override_id"]) + + 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_database(hass, find_runtime) + 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.""" + if hass.services.has_service(DOMAIN, SERVICE_REGENERATE): + return + hass.services.async_register( + DOMAIN, SERVICE_REGENERATE, regenerate_handler + ) + hass.services.async_register( + DOMAIN, + SERVICE_COMPLETE_QUEST, + _complete_quest(hass, find_runtime), + schema=SCHEMA_COMPLETE_QUEST, + ) + hass.services.async_register( + DOMAIN, + SERVICE_UNCOMPLETE_QUEST, + _uncomplete_quest(hass, find_runtime), + schema=SCHEMA_UNCOMPLETE_QUEST, + ) + hass.services.async_register( + DOMAIN, + SERVICE_CREATE_QUEST_DEFINITION, + _create_quest_definition(hass, find_runtime), + schema=SCHEMA_CREATE_QUEST_DEFINITION, + ) + hass.services.async_register( + DOMAIN, + SERVICE_UPDATE_QUEST_DEFINITION, + _update_quest_definition(hass, find_runtime), + schema=SCHEMA_UPDATE_QUEST_DEFINITION, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + _set_quest_definition_active(hass, find_runtime), + schema=SCHEMA_SET_QUEST_DEFINITION_ACTIVE, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_PRESENCE_PATTERN, + _set_presence_pattern(hass, find_runtime), + schema=SCHEMA_SET_PRESENCE_PATTERN, + ) + hass.services.async_register( + DOMAIN, + SERVICE_CREATE_PRESENCE_OVERRIDE, + _create_presence_override(hass, find_runtime), + schema=SCHEMA_CREATE_PRESENCE_OVERRIDE, + ) + hass.services.async_register( + DOMAIN, + SERVICE_DELETE_PRESENCE_OVERRIDE, + _delete_presence_override(hass, find_runtime), + schema=SCHEMA_DELETE_PRESENCE_OVERRIDE, + ) + hass.services.async_register( + DOMAIN, + SERVICE_EXPORT_HISTORY_CSV, + _export_history_csv, + schema=SCHEMA_EXPORT_HISTORY_CSV, + ) + hass.services.async_register( + DOMAIN, + SERVICE_MANAGE_CHILD, + _manage_child(hass, find_runtime), + schema=SCHEMA_MANAGE_CHILD, + ) + + +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/tests/conftest.py b/tests/conftest.py index b6603e8..3fa8637 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -177,7 +177,11 @@ _data_entry_flow_mock.RESULT_TYPE_ABORT = "abort" _data_entry_flow_mock.FlowResult = dict -class ConfigEntryNotReady(Exception): +class HomeAssistantError(Exception): + """Stand-in mirroring homeassistant.exceptions.HomeAssistantError.""" + + +class ConfigEntryNotReady(HomeAssistantError): """Stand-in mirroring homeassistant.exceptions.ConfigEntryNotReady. HA retries setup when this is raised; the tests assert the corrupt @@ -185,6 +189,7 @@ class ConfigEntryNotReady(Exception): """ +_exceptions_mock.HomeAssistantError = HomeAssistantError _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady import voluptuous as vol @@ -325,7 +330,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 +340,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 diff --git a/tests/test_services.py b/tests/test_services.py index 68f2cde..1f2a2dd 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,9 +1,28 @@ -"""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 + +from conftest import wire_entry_to_registry + +from custom_components.nestquest import async_setup_entry, async_unload_entry +from custom_components.nestquest.const import ( + DOMAIN, + DOMAIN_SERVICES, + SERVICE_COMPLETE_QUEST, + SERVICE_CREATE_QUEST_DEFINITION, + SERVICE_EXPORT_HISTORY_CSV, + SERVICE_MANAGE_CHILD, + SERVICE_REGENERATE, + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + SERVICE_UNCOMPLETE_QUEST, +) + SERVICES_PATH = ( Path(__file__).parent.parent / "custom_components" @@ -12,10 +31,37 @@ SERVICES_PATH = ( ) +def _wire(entry, registry): + return wire_entry_to_registry(entry, registry) + + +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 +73,202 @@ 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_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"}, + ) + 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_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"}, + ) + 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"], + }, + ) + 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}, + ) + 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_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"}, + ) + 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"], + }, + ) + await hass.services.call(DOMAIN, SERVICE_REGENERATE) + 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": "parent-1", + }, + ) + assert ( + await instance_state(entry.runtime_data.database, instance_id) + == "open" + ) + + +async def test_export_history_csv_raises_home_assistant_error( + hass, make_entry +) -> None: + entry = _wire(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) + + +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"}, + ) From b1c42f10d02cb77fba9eaa3786053fdff2f174e0 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Fri, 18 Sep 2026 16:03:42 -0400 Subject: [PATCH 02/46] Regenerate child instances after presence service writes Presence pattern and override handlers now call regenerate_for_child. Definition edit/activate pass HA-local today and configured horizon. --- custom_components/nestquest/dao_presence.py | 9 ++ custom_components/nestquest/services.py | 73 +++++++++--- tests/test_dao_presence.py | 14 +++ tests/test_services.py | 120 ++++++++++++++++++++ 4 files changed, 199 insertions(+), 17 deletions(-) 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/services.py b/custom_components/nestquest/services.py index c6d9fb2..0e7ee18 100644 --- a/custom_components/nestquest/services.py +++ b/custom_components/nestquest/services.py @@ -11,8 +11,10 @@ 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 @@ -22,6 +24,8 @@ 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, @@ -38,6 +42,7 @@ from .const import ( SERVICE_UPDATE_QUEST_DEFINITION, ) from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao +from .materialize import regenerate_for_child from .presence import PresenceSchedule from .recurrence import ScheduleRule @@ -225,13 +230,24 @@ SCHEMA_MANAGE_CHILD = vol.Schema( ) -def _require_database(hass: HomeAssistant, find_runtime: FindRuntime): +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.database + return runtime + + +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]: @@ -274,7 +290,7 @@ def _complete_quest( hass: HomeAssistant, find_runtime: FindRuntime ) -> ServiceHandler: async def _handler(call: Any) -> None: - database = _require_database(hass, find_runtime) + database = _require_runtime(hass, find_runtime).database await completion_layer.complete_instance( database, call.data["instance_id"], @@ -288,7 +304,7 @@ def _uncomplete_quest( hass: HomeAssistant, find_runtime: FindRuntime ) -> ServiceHandler: async def _handler(call: Any) -> None: - database = _require_database(hass, find_runtime) + database = _require_runtime(hass, find_runtime).database await completion_layer.uncomplete_instance( database, call.data["instance_id"], @@ -302,7 +318,7 @@ def _create_quest_definition( hass: HomeAssistant, find_runtime: FindRuntime ) -> ServiceHandler: async def _handler(call: Any) -> None: - database = _require_database(hass, find_runtime) + database = _require_runtime(hass, find_runtime).database data = call.data await quest_definitions_layer.create_quest_definition( database, @@ -321,9 +337,9 @@ def _update_quest_definition( hass: HomeAssistant, find_runtime: FindRuntime ) -> ServiceHandler: async def _handler(call: Any) -> None: - database = _require_database(hass, find_runtime) + runtime = _require_runtime(hass, find_runtime) data = call.data - kwargs: dict[str, Any] = {} + kwargs: dict[str, Any] = _generation_kwargs(hass, runtime) if "title" in data: kwargs["title"] = data["title"] if "description" in data: @@ -335,7 +351,7 @@ def _update_quest_definition( if "windows" in data: kwargs["windows"] = data["windows"] await quest_definitions_layer.edit_quest_definition( - database, data["definition_id"], **kwargs + runtime.database, data["definition_id"], **kwargs ) return _with_errors(_handler) @@ -345,11 +361,12 @@ def _set_quest_definition_active( hass: HomeAssistant, find_runtime: FindRuntime ) -> ServiceHandler: async def _handler(call: Any) -> None: - database = _require_database(hass, find_runtime) + runtime = _require_runtime(hass, find_runtime) await quest_definitions_layer.set_quest_definition_active( - database, + runtime.database, call.data["definition_id"], call.data["is_active"], + **_generation_kwargs(hass, runtime), ) return _with_errors(_handler) @@ -359,7 +376,7 @@ def _set_presence_pattern( hass: HomeAssistant, find_runtime: FindRuntime ) -> ServiceHandler: async def _handler(call: Any) -> None: - database = _require_database(hass, find_runtime) + runtime = _require_runtime(hass, find_runtime) data = call.data schedule = PresenceSchedule( data["child_id"], @@ -367,12 +384,17 @@ def _set_presence_pattern( data["anchor_date"], data["pattern"], ) - await PresenceSchedulesDao(database).upsert_by_child( + 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) @@ -381,15 +403,20 @@ def _create_presence_override( hass: HomeAssistant, find_runtime: FindRuntime ) -> ServiceHandler: async def _handler(call: Any) -> None: - database = _require_database(hass, find_runtime) + runtime = _require_runtime(hass, find_runtime) data = call.data - await PresenceOverridesDao(database).create( + 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) @@ -398,8 +425,20 @@ def _delete_presence_override( hass: HomeAssistant, find_runtime: FindRuntime ) -> ServiceHandler: async def _handler(call: Any) -> None: - database = _require_database(hass, find_runtime) - await PresenceOverridesDao(database).delete(call.data["override_id"]) + 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) @@ -414,7 +453,7 @@ def _manage_child( hass: HomeAssistant, find_runtime: FindRuntime ) -> ServiceHandler: async def _handler(call: Any) -> None: - database = _require_database(hass, find_runtime) + database = _require_runtime(hass, find_runtime).database data = call.data action = data["action"] if action == MANAGE_CHILD_CREATE: 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_services.py b/tests/test_services.py index 1f2a2dd..3c63194 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -15,10 +15,13 @@ from custom_components.nestquest.const import ( 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, ) @@ -246,6 +249,123 @@ async def test_complete_and_uncomplete_quest_call_business_layer( ) +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_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"}, + ) + 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"], + }, + ) + await hass.services.call(DOMAIN, SERVICE_REGENERATE) + 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: []}, + }, + ) + 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_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"}, + ) + 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"], + }, + ) + await hass.services.call(DOMAIN, SERVICE_REGENERATE) + 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, + }, + ) + 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}, + ) + 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: From 4281b9d01cec5b4d4ff8f6928cf21102a5e579de Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Fri, 18 Sep 2026 16:05:12 -0400 Subject: [PATCH 03/46] Add admin-only versus open service policy registry One mapping in service_policy.py: complete_quest is open, every other registered nestquest service is admin_only. --- custom_components/nestquest/service_policy.py | 46 +++++++++++++++++++ tests/test_service_policy.py | 32 +++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 custom_components/nestquest/service_policy.py create mode 100644 tests/test_service_policy.py 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/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} From 37d7b7e6c3eeee8b1687f2cbd14999da612c4d3a Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 08:36:14 -0400 Subject: [PATCH 04/46] Gate every NestQuest service behind the admin permission check permission_gate wraps all registered services at registration time: admin-only policy reads call.context.user_id against admin_allowlist is_admin (fail closed on no context); complete_quest stays open. --- custom_components/nestquest/permissions.py | 90 ++++++++++++++ custom_components/nestquest/services.py | 136 +++++++++++---------- tests/conftest.py | 5 + tests/test_lifecycle.py | 29 +++-- tests/test_services.py | 108 ++++++++++++++-- 5 files changed, 284 insertions(+), 84 deletions(-) create mode 100644 custom_components/nestquest/permissions.py 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/services.py b/custom_components/nestquest/services.py index 0e7ee18..9c6d765 100644 --- a/custom_components/nestquest/services.py +++ b/custom_components/nestquest/services.py @@ -43,8 +43,10 @@ from .const import ( ) from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao 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]] @@ -521,72 +523,78 @@ def async_register_services( find_runtime: FindRuntime, regenerate_handler: ServiceHandler, ) -> None: - """Register every canonical service once at domain scope.""" + """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 - hass.services.async_register( - DOMAIN, SERVICE_REGENERATE, regenerate_handler - ) - hass.services.async_register( - DOMAIN, - SERVICE_COMPLETE_QUEST, - _complete_quest(hass, find_runtime), - schema=SCHEMA_COMPLETE_QUEST, - ) - hass.services.async_register( - DOMAIN, - SERVICE_UNCOMPLETE_QUEST, - _uncomplete_quest(hass, find_runtime), - schema=SCHEMA_UNCOMPLETE_QUEST, - ) - hass.services.async_register( - DOMAIN, - SERVICE_CREATE_QUEST_DEFINITION, - _create_quest_definition(hass, find_runtime), - schema=SCHEMA_CREATE_QUEST_DEFINITION, - ) - hass.services.async_register( - DOMAIN, - SERVICE_UPDATE_QUEST_DEFINITION, - _update_quest_definition(hass, find_runtime), - schema=SCHEMA_UPDATE_QUEST_DEFINITION, - ) - hass.services.async_register( - DOMAIN, - SERVICE_SET_QUEST_DEFINITION_ACTIVE, - _set_quest_definition_active(hass, find_runtime), - schema=SCHEMA_SET_QUEST_DEFINITION_ACTIVE, - ) - hass.services.async_register( - DOMAIN, - SERVICE_SET_PRESENCE_PATTERN, - _set_presence_pattern(hass, find_runtime), - schema=SCHEMA_SET_PRESENCE_PATTERN, - ) - hass.services.async_register( - DOMAIN, - SERVICE_CREATE_PRESENCE_OVERRIDE, - _create_presence_override(hass, find_runtime), - schema=SCHEMA_CREATE_PRESENCE_OVERRIDE, - ) - hass.services.async_register( - DOMAIN, - SERVICE_DELETE_PRESENCE_OVERRIDE, - _delete_presence_override(hass, find_runtime), - schema=SCHEMA_DELETE_PRESENCE_OVERRIDE, - ) - hass.services.async_register( - DOMAIN, - SERVICE_EXPORT_HISTORY_CSV, - _export_history_csv, - schema=SCHEMA_EXPORT_HISTORY_CSV, - ) - hass.services.async_register( - DOMAIN, - SERVICE_MANAGE_CHILD, - _manage_child(hass, find_runtime), - schema=SCHEMA_MANAGE_CHILD, - ) + 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: diff --git a/tests/conftest.py b/tests/conftest.py index 3fa8637..f9e31bc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -189,8 +189,13 @@ class ConfigEntryNotReady(HomeAssistantError): """ +class Unauthorized(HomeAssistantError): + """Stand-in mirroring homeassistant.exceptions.Unauthorized.""" + + _exceptions_mock.HomeAssistantError = HomeAssistantError _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady +_exceptions_mock.Unauthorized = Unauthorized import voluptuous as vol 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_services.py b/tests/test_services.py index 3c63194..c77b13e 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -6,12 +6,13 @@ from pathlib import Path import pytest import voluptuous as vol -from homeassistant.exceptions import HomeAssistantError +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, @@ -38,6 +39,16 @@ 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}" @@ -129,12 +140,13 @@ async def test_unload_one_of_two_entries_keeps_services( async def test_manage_child_create_persists_child(hass, make_entry) -> None: from custom_components.nestquest.children import list_children - entry = _wire(make_entry(), hass.registry) + 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"] @@ -149,12 +161,13 @@ async def test_create_and_set_quest_definition_active( ) from custom_components.nestquest.dao_rules import QuestDefinitionsDao - entry = _wire(make_entry(), hass.registry) + 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( @@ -166,6 +179,7 @@ async def test_create_and_set_quest_definition_active( "assignee_child_ids": [child.id], "windows": ["morning"], }, + context=ADMIN_CTX, ) active = await list_active_definitions(entry.runtime_data.database) assert len(active) == 1 @@ -174,6 +188,7 @@ async def test_create_and_set_quest_definition_active( 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( @@ -193,12 +208,13 @@ async def test_complete_and_uncomplete_quest_call_business_layer( from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS from custom_components.nestquest.dao_instances import QuestInstancesDao - entry = _wire(make_entry(), hass.registry) + 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( @@ -210,8 +226,9 @@ async def test_complete_and_uncomplete_quest_call_business_layer( "assignee_child_ids": [child.id], "windows": ["morning"], }, + context=ADMIN_CTX, ) - await hass.services.call(DOMAIN, SERVICE_REGENERATE) + 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) @@ -240,8 +257,9 @@ async def test_complete_and_uncomplete_quest_call_business_layer( { "instance_id": instance_id, "actor": "user", - "actor_user_id": "parent-1", + "actor_user_id": ADMIN_ID, }, + context=ADMIN_CTX, ) assert ( await instance_state(entry.runtime_data.database, instance_id) @@ -258,12 +276,13 @@ async def test_set_presence_pattern_regenerates_child_instances( from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS from custom_components.nestquest.dao_instances import QuestInstancesDao - entry = _wire(make_entry(), hass.registry) + 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( @@ -275,8 +294,9 @@ async def test_set_presence_pattern_regenerates_child_instances( "assignee_child_ids": [child.id], "windows": ["morning"], }, + context=ADMIN_CTX, ) - await hass.services.call(DOMAIN, SERVICE_REGENERATE) + 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) @@ -292,6 +312,7 @@ async def test_set_presence_pattern_regenerates_child_instances( "anchor_date": today, "pattern": {0: []}, }, + context=ADMIN_CTX, ) assert await dao.list_by_date_range(child.id, today, end) == [] @@ -306,12 +327,13 @@ async def test_presence_override_create_and_delete_regenerate( from custom_components.nestquest.dao_instances import QuestInstancesDao from custom_components.nestquest.dao_presence import PresenceOverridesDao - entry = _wire(make_entry(), hass.registry) + 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( @@ -323,8 +345,9 @@ async def test_presence_override_create_and_delete_regenerate( "assignee_child_ids": [child.id], "windows": ["morning"], }, + context=ADMIN_CTX, ) - await hass.services.call(DOMAIN, SERVICE_REGENERATE) + 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) @@ -341,6 +364,7 @@ async def test_presence_override_create_and_delete_regenerate( "end_date": end.isoformat(), "is_present": False, }, + context=ADMIN_CTX, ) assert ( await dao.list_by_date_range( @@ -359,6 +383,7 @@ async def test_presence_override_create_and_delete_regenerate( 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() @@ -369,10 +394,12 @@ async def test_presence_override_create_and_delete_regenerate( async def test_export_history_csv_raises_home_assistant_error( hass, make_entry ) -> None: - entry = _wire(make_entry(), hass.registry) + 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) + 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: @@ -392,3 +419,60 @@ async def test_invalid_schema_data_surfaces_as_error(hass, make_entry) -> None: 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) == [] From d0f49b44b5af0495f0ae19d5b5d0ea13810033d6 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 08:38:24 -0400 Subject: [PATCH 05/46] Cover fail-closed no-context behavior across admin-only services Parametrised test denies no-context calls on every admin-only service; complete_quest proceeds with no user; scheduled internals stay direct function calls. --- tests/test_services.py | 115 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/tests/test_services.py b/tests/test_services.py index c77b13e..d242a3b 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -26,6 +26,7 @@ from custom_components.nestquest.const import ( 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 @@ -476,3 +477,117 @@ async def test_unauthorized_is_raised_before_handler_runs( 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" + ) From b5058a3bed643214d2fa2837b91b09e49d6c0e5d Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 08:39:02 -0400 Subject: [PATCH 06/46] Test the permission gate's audit logging Warning on denial and debug on success carry the service, the caller user id (or none), and a UTC stamp; nothing beyond the user id leaks. --- tests/test_permissions.py | 122 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 tests/test_permissions.py 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 From c1efd39ce3e52621e0338569b12ae2d882d3a2d9 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 08:40:20 -0400 Subject: [PATCH 07/46] Add the permission test matrix across every registered service Admin succeeds on all ten admin-only services; a non-admin and a no-context call are refused on each; complete_quest stays open; a kiosk-style id is refused on uncomplete_quest and regenerate; the registry coverage assertion fails the suite on any gap. --- tests/test_permission_matrix.py | 238 ++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 tests/test_permission_matrix.py 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 From 979eafc5a20d13cd9c6aa733023518d9edebf218 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 11:41:12 -0400 Subject: [PATCH 08/46] Add the shared NestQuest data update coordinator One refresh pass builds the whole entity snapshot: active children, today's instances with derived open/done/missed states, per-child presence via the PresenceEngine (records converted to model objects), rollup counts, and the custody cycle day. Interval configurable via the new update_interval option, default five minutes. --- custom_components/nestquest/__init__.py | 25 ++ custom_components/nestquest/completion.py | 15 ++ custom_components/nestquest/const.py | 7 + custom_components/nestquest/coordinator.py | 254 ++++++++++++++++++++ custom_components/nestquest/options_flow.py | 23 ++ tests/conftest.py | 62 +++++ tests/test_coordinator.py | 244 +++++++++++++++++++ tests/test_options_flow.py | 24 +- 8 files changed, 649 insertions(+), 5 deletions(-) create mode 100644 custom_components/nestquest/coordinator.py create mode 100644 tests/test_coordinator.py diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index b0edf5b..2cfea93 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -20,11 +20,14 @@ 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, ) +from .coordinator import NestQuestCoordinator from .db import NestQuestDatabase from .materialize import materialize as _materialize_run from .migrations import apply_migrations @@ -238,6 +241,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]: @@ -493,6 +497,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, database, _configured_horizon_days(dict(entry.options)) ) _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() except BaseException: # A failure anywhere after the database is open must not leak the # listeners already registered against that (now-closed) database: @@ -521,6 +538,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: database=database, remove_update_listener=remove_update_listener, remove_time_change_listener=remove_time_change_listener, + coordinator=coordinator, ) entry.runtime_data = runtime_data hass.data[DOMAIN][entry.entry_id] = runtime_data @@ -565,6 +583,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: database = getattr(runtime_data, "database", None) if database is not None: await database.close() + # Tear the shared coordinator down after the DB close: no + # listener may outlive the connection it reads. + coordinator = getattr(runtime_data, "coordinator", None) + if coordinator is not None: + shutdown = getattr(coordinator, "async_shutdown", None) + if shutdown is not None: + await shutdown() # Domain-global services are torn down only once the FINAL entry # unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling # entry is still loaded. diff --git a/custom_components/nestquest/completion.py b/custom_components/nestquest/completion.py index 15aa11c..034741d 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, diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index 6238252..69b55ae 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -55,6 +55,13 @@ 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 + # 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..7d8e79b --- /dev/null +++ b/custom_components/nestquest/coordinator.py @@ -0,0 +1,254 @@ +"""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 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 ( + 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 + + +@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.""" + + 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 + + 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 + ) + todays = await PresenceOverridesDao( + self.database + ).list_by_child_and_range(child.id, today_iso, today_iso) + if todays: + # 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 todays + ] + 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 + snapshots.append( + ChildDaySnapshot( + child_id=child.id, + child_name=child.display_name, + instances=tuple(views), + present=engine.is_present(child.id, today), + due_today=due, + completed_today=completed, + remaining_today=remaining, + completion_pct=( + round(completed * 100 / due) if due else 100 + ), + ) + ) + + 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/options_flow.py b/custom_components/nestquest/options_flow.py index 84ac218..32ddd3b 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -14,10 +14,13 @@ from .const import ( CONF_DAY_ROLLOVER_TIME, CONF_HORIZON_DAYS, CONF_PANEL_IDLE_TIMEOUT, + CONF_UPDATE_INTERVAL, DEFAULT_DAY_ROLLOVER_TIME, DEFAULT_HORIZON_DAYS, DEFAULT_PANEL_IDLE_TIMEOUT, + DEFAULT_UPDATE_INTERVAL, DOMAIN, + MIN_UPDATE_INTERVAL, ) from .admin_allowlist import list_admin_ids, set_admin_ids @@ -56,6 +59,18 @@ 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" + return errors @@ -82,6 +97,9 @@ 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, } if admin_choices is not None: # HA's supported multi-select validator: the frontend can @@ -210,6 +228,10 @@ 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], + ), } if admin_ids_present: data[CONF_ADMIN_USER_IDS] = admin_ids @@ -231,6 +253,7 @@ 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), ): if key in options: values[key] = options[key] diff --git a/tests/conftest.py b/tests/conftest.py index f9e31bc..cbc5c24 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,6 +45,7 @@ _HA_MODULES = ( "homeassistant.helpers", "homeassistant.helpers.config_validation", "homeassistant.helpers.event", + "homeassistant.helpers.update_coordinator", ) def _find_spec(name: str): @@ -85,6 +86,7 @@ for _parent, _child in ( ("homeassistant", "helpers"), ("homeassistant.helpers", "config_validation"), ("homeassistant.helpers", "event"), + ("homeassistant.helpers", "update_coordinator"), ): setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"]) @@ -197,6 +199,66 @@ _exceptions_mock.HomeAssistantError = HomeAssistantError _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady _exceptions_mock.Unauthorized = Unauthorized + +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 + import voluptuous as vol diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py new file mode 100644 index 0000000..414273a --- /dev/null +++ b/tests/test_coordinator.py @@ -0,0 +1,244 @@ +"""Tests for the shared NestQuest coordinator (Feature 10, task 1).""" +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.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 snapshot.cycle_day == 0, "no schedule anywhere" + + +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_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 diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index bef04c8..af949b6 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -18,6 +18,8 @@ from custom_components.nestquest.const import ( CONF_DAY_ROLLOVER_TIME, CONF_HORIZON_DAYS, CONF_PANEL_IDLE_TIMEOUT, + CONF_UPDATE_INTERVAL, + DEFAULT_UPDATE_INTERVAL, DEFAULT_DAY_ROLLOVER_TIME, DEFAULT_HORIZON_DAYS, DEFAULT_PANEL_IDLE_TIMEOUT, @@ -129,7 +131,10 @@ 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"] == { + **VALID_INPUT, + CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, + } def test_options_flow_shows_form_initially() -> None: @@ -208,6 +213,7 @@ 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, } ) result = _run(_make_flow(entry).async_step_init(None)) @@ -215,6 +221,7 @@ 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_ADMIN_USER_IDS: [], } @@ -282,8 +289,8 @@ 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" @@ -292,8 +299,12 @@ def test_options_flow_creates_entry_with_three_keys() -> None: CONF_HORIZON_DAYS, CONF_DAY_ROLLOVER_TIME, CONF_PANEL_IDLE_TIMEOUT, + CONF_UPDATE_INTERVAL, + } + assert result["data"] == { + **VALID_INPUT, + CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, } - assert result["data"] == VALID_INPUT def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once() -> None: @@ -316,7 +327,10 @@ 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 == { + **VALID_INPUT, + CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, + } def test_options_flow_validation_never_raises() -> None: From 5c99987e7f44b0a9e8a2a32b7aa3bb992dd5f63c Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 11:44:11 -0400 Subject: [PATCH 09/46] Shut the coordinator down when first refresh fails setup The setup except path closes the database; the coordinator created above it now shuts down first so no listener can fire against the closed connection. Regression test included. --- custom_components/nestquest/__init__.py | 10 +++++++ tests/test_coordinator.py | 37 +++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 2cfea93..78441f0 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -460,6 +460,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 @@ -528,6 +529,15 @@ 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 await database.close() raise assert remove_update_listener is not None diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 414273a..745c53a 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -3,6 +3,8 @@ 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 @@ -242,3 +244,38 @@ async def test_coordinator_notifies_listeners_on_refresh( 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 From 00409a76db7be0936e84411ed59c3655d48e109b Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 11:50:18 -0400 Subject: [PATCH 10/46] Add per-child daily count and percentage sensors Four sensors per active child read the coordinator snapshot: quests due today (carrying the panel payload that omits missed instances plus the admin payload that includes them, D-009), completed, remaining, and completion percentage (100 on a zero-quest day). Platform forwarding wired through the harness's entity registry stand-ins. --- custom_components/nestquest/__init__.py | 33 ++- custom_components/nestquest/const.py | 4 +- custom_components/nestquest/sensor.py | 312 ++++++++++++++++++++++ tests/conftest.py | 226 ++++++++++++++++ tests/test_const.py | 4 +- tests/test_harness.py | 17 ++ tests/test_sensors.py | 341 ++++++++++++++++++++++++ 7 files changed, 925 insertions(+), 12 deletions(-) create mode 100644 custom_components/nestquest/sensor.py create mode 100644 tests/test_sensors.py diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 78441f0..84e1074 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -26,6 +26,7 @@ from .const import ( DEFAULT_UPDATE_INTERVAL, DOMAIN, LOGGER, + PLATFORMS, ) from .coordinator import NestQuestCoordinator from .db import NestQuestDatabase @@ -511,6 +512,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ), ) 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: @@ -538,19 +553,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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, - coordinator=coordinator, - ) - entry.runtime_data = runtime_data hass.data[DOMAIN][entry.entry_id] = runtime_data return True @@ -564,6 +575,10 @@ 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. + await hass.config_entries.async_unload_platforms(entry, PLATFORMS) unload_error: BaseException | None = None if runtime_data is not None: remove_update_listener = getattr(runtime_data, "remove_update_listener", None) diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index 69b55ae..82ef273 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -7,7 +7,9 @@ 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. binary_sensor joins when task 4's entities land. +PLATFORMS: list[str] = ["sensor"] #: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2 #: plus the existing Feature 07 regenerate service). diff --git a/custom_components/nestquest/sensor.py b/custom_components/nestquest/sensor.py new file mode 100644 index 0000000..8f05fa1 --- /dev/null +++ b/custom_components/nestquest/sensor.py @@ -0,0 +1,312 @@ +"""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 + + +class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity): + """Base for the per-child day sensors. + + Registry identity and the child's NestQuest device grouping are + built from the child's database id; the day's numbers are read + from the child's CURRENT snapshot so every refresh lands + coherently. A child absent from the latest snapshot (a + deactivation; removal is a later task's scope) yields ``None`` + states rather than stale counts. + """ + + def __init__( + self, + coordinator: NestQuestCoordinator, + child: ChildDaySnapshot, + ) -> None: + super().__init__(coordinator) + self._child_id = child.child_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"child_{child.child_id}")}, + name=f"NestQuest {child.child_name}", + manufacturer="NestQuest", + ) + + @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 + + +class NestQuestQuestsDueTodaySensor(_NestQuestChildDaySensor): + """How many quests the child has due today, with the day payload.""" + + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, + coordinator: NestQuestCoordinator, + child: ChildDaySnapshot, + ) -> None: + super().__init__(coordinator, child) + self._attr_name = f"NestQuest {child.child_name} quests due today" + 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 + + def __init__( + self, + coordinator: NestQuestCoordinator, + child: ChildDaySnapshot, + ) -> None: + super().__init__(coordinator, child) + self._attr_name = ( + f"NestQuest {child.child_name} quests completed today" + ) + 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 + + def __init__( + self, + coordinator: NestQuestCoordinator, + child: ChildDaySnapshot, + ) -> None: + super().__init__(coordinator, child) + self._attr_name = ( + f"NestQuest {child.child_name} quests remaining today" + ) + 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 = "%" + + def __init__( + self, + coordinator: NestQuestCoordinator, + child: ChildDaySnapshot, + ) -> None: + super().__init__(coordinator, child) + self._attr_name = ( + f"NestQuest {child.child_name} completion pct today" + ) + 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) + + +def _child_day_sensors( + coordinator: NestQuestCoordinator, child: ChildDaySnapshot +) -> tuple[_NestQuestChildDaySensor, ...]: + """Build the four documented day sensors for one active child.""" + return ( + NestQuestQuestsDueTodaySensor(coordinator, child), + NestQuestQuestsCompletedTodaySensor(coordinator, child), + NestQuestQuestsRemainingTodaySensor(coordinator, child), + NestQuestCompletionPctTodaySensor(coordinator, child), + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: Callable[..., None], +) -> None: + """Create the four 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) + + 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/tests/conftest.py b/tests/conftest.py index cbc5c24..d753166 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,6 +27,7 @@ import inspect import sys import tempfile import weakref +from dataclasses import dataclass from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock @@ -44,8 +45,12 @@ _HA_MODULES = ( "homeassistant.exceptions", "homeassistant.helpers", "homeassistant.helpers.config_validation", + "homeassistant.helpers.entity", "homeassistant.helpers.event", "homeassistant.helpers.update_coordinator", + "homeassistant.components", + "homeassistant.components.sensor", + "homeassistant.components.binary_sensor", ) def _find_spec(name: str): @@ -85,8 +90,12 @@ for _parent, _child in ( ("homeassistant", "exceptions"), ("homeassistant", "helpers"), ("homeassistant.helpers", "config_validation"), + ("homeassistant.helpers", "entity"), ("homeassistant.helpers", "event"), ("homeassistant.helpers", "update_coordinator"), + ("homeassistant", "components"), + ("homeassistant.components", "sensor"), + ("homeassistant.components", "binary_sensor"), ): setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"]) @@ -200,6 +209,154 @@ _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady _exceptions_mock.Unauthorized = Unauthorized +@dataclass +class DeviceInfo: + """Stand-in for homeassistant.helpers.entity.DeviceInfo. + + The kwargs-only shape the integration builds per-child devices + with: ``identifiers``, ``name``, ``manufacturer``, ``model``. + """ + + identifiers: object = None + name: str | None = None + manufacturer: str | None = None + model: str | None = None + + +class Entity: + """Stand-in for homeassistant.helpers.entity.Entity. + + Mirrors the ``_attr_*`` attribute contract through the ``name``, + ``unique_id``, ``device_info`` and ``extra_state_attributes`` + properties, and ``async_write_ha_state`` registering the entity + into the hass entity registry (``hass.entities``, a dict keyed by + ``unique_id`` storing the live object) — the harness's stand-in + for HA's state machine, so tests enumerate entities and read + their state directly off the registered object. + """ + + hass = None + _attr_name: str | None = None + _attr_unique_id: str | None = None + _attr_device_info: DeviceInfo | None = None + _attr_extra_state_attributes: dict | None = None + + @property + def name(self) -> 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. @@ -258,6 +415,19 @@ class DataUpdateCoordinator: _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 import voluptuous as vol @@ -505,6 +675,62 @@ def make_hass() -> tuple: # 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. + hass.entities: dict[str, Entity] = {} + + def _async_add_entities(new_entities, update_before_add=False): + """Mirror HA's AddEntitiesCallback: bind hass and register entities. + + 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). + """ + if isinstance(new_entities, Entity): + new_entities = [new_entities] + for entity in new_entities: + entity.hass = hass + entity.async_write_ha_state() + + 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 + the AddEntitiesCallback above. 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, _async_add_entities) + 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). + """ + 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) + + 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_const.py b/tests/test_const.py index 85ff105..dc9e255 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 platform list.""" assert hasattr(const, "PLATFORMS") assert isinstance(const.PLATFORMS, list) - assert const.PLATFORMS == [] + assert const.PLATFORMS == ["sensor"] def test_sqlite_db_filename() -> None: 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_sensors.py b/tests/test_sensors.py new file mode 100644 index 0000000..a8d712f --- /dev/null +++ b/tests/test_sensors.py @@ -0,0 +1,341 @@ +"""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_four_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"] + await _complete_first_instance(database, coordinator, ada) + await coordinator.async_refresh() + + # Two quest children plus the zero-quest child: three children x + # four sensors, no duplicates. + assert len(hass.entities) == 12 + 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") + 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) == 12 + + +async def test_setup_with_no_children_registers_no_entities( + hass, make_entry +) -> None: + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + assert hass.entities == {} + + +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"] From 7a0c8d1885c9dafba6fd111631c9affebf47cd2a Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 11:53:27 -0400 Subject: [PATCH 11/46] Honor failed platform unload; derive labels from the snapshot A False platform unload now returns False and retains the runtime record so live entities keep their database. Sensor names and device labels re-derive from the current snapshot so child renames propagate; unique_ids stay child-id based. --- custom_components/nestquest/__init__.py | 12 +++++- custom_components/nestquest/sensor.py | 51 ++++++++++++++++--------- tests/test_sensors.py | 44 +++++++++++++++++++++ 3 files changed, 87 insertions(+), 20 deletions(-) diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 84e1074..33f1449 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -578,7 +578,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Platforms unload FIRST (mirroring HA conventions): entity # listeners must not outlive the database and coordinator the # teardown below closes. - await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + 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) diff --git a/custom_components/nestquest/sensor.py b/custom_components/nestquest/sensor.py index 8f05fa1..08fbd0c 100644 --- a/custom_components/nestquest/sensor.py +++ b/custom_components/nestquest/sensor.py @@ -101,14 +101,20 @@ def _instance_payload( class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity): """Base for the per-child day sensors. - Registry identity and the child's NestQuest device grouping are - built from the child's database id; the day's numbers are read - from the child's CURRENT snapshot so every refresh lands - coherently. A child absent from the latest snapshot (a + 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, @@ -116,11 +122,8 @@ class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity): ) -> None: super().__init__(coordinator) self._child_id = child.child_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"child_{child.child_id}")}, - name=f"NestQuest {child.child_name}", - manufacturer="NestQuest", - ) + self._fallback_name = child.child_name + self._attr_unique_id = None # set by the subclass @property def _child(self) -> ChildDaySnapshot | None: @@ -133,11 +136,28 @@ class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity): 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, @@ -145,7 +165,6 @@ class NestQuestQuestsDueTodaySensor(_NestQuestChildDaySensor): child: ChildDaySnapshot, ) -> None: super().__init__(coordinator, child) - self._attr_name = f"NestQuest {child.child_name} quests due today" self._attr_unique_id = ( f"{DOMAIN}_child_{child.child_id}_quests_due_today" ) @@ -179,6 +198,7 @@ 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, @@ -186,9 +206,6 @@ class NestQuestQuestsCompletedTodaySensor(_NestQuestChildDaySensor): child: ChildDaySnapshot, ) -> None: super().__init__(coordinator, child) - self._attr_name = ( - f"NestQuest {child.child_name} quests completed today" - ) self._attr_unique_id = ( f"{DOMAIN}_child_{child.child_id}_quests_completed_today" ) @@ -205,6 +222,7 @@ 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, @@ -212,9 +230,6 @@ class NestQuestQuestsRemainingTodaySensor(_NestQuestChildDaySensor): child: ChildDaySnapshot, ) -> None: super().__init__(coordinator, child) - self._attr_name = ( - f"NestQuest {child.child_name} quests remaining today" - ) self._attr_unique_id = ( f"{DOMAIN}_child_{child.child_id}_quests_remaining_today" ) @@ -233,6 +248,7 @@ class NestQuestCompletionPctTodaySensor(_NestQuestChildDaySensor): _attr_state_class = SensorStateClass.MEASUREMENT _attr_device_class = SensorDeviceClass.PERCENTAGE _attr_native_unit_of_measurement = "%" + _name_suffix = "completion pct today" def __init__( self, @@ -240,9 +256,6 @@ class NestQuestCompletionPctTodaySensor(_NestQuestChildDaySensor): child: ChildDaySnapshot, ) -> None: super().__init__(coordinator, child) - self._attr_name = ( - f"NestQuest {child.child_name} completion pct today" - ) self._attr_unique_id = ( f"{DOMAIN}_child_{child.child_id}_completion_pct_today" ) diff --git a/tests/test_sensors.py b/tests/test_sensors.py index a8d712f..c054740 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -339,3 +339,47 @@ async def test_unload_unloads_platforms(hass, make_entry) -> None: 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"] + + +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 \ No newline at end of file From 1f7a7ba46cd0674f465e33ccc54f70400e5cc3e6 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 11:54:19 -0400 Subject: [PATCH 12/46] Add the per-child next-quest sensor State carries the earliest open quest's title truncated under the 255-character limit with the full shape in attributes; the documented "none" sentinel is reported when the day is clear. --- custom_components/nestquest/sensor.py | 93 ++++++++++++++++++++++++++- tests/test_sensors.py | 73 +++++++++++++++++++-- 2 files changed, 159 insertions(+), 7 deletions(-) diff --git a/custom_components/nestquest/sensor.py b/custom_components/nestquest/sensor.py index 08fbd0c..d3ae546 100644 --- a/custom_components/nestquest/sensor.py +++ b/custom_components/nestquest/sensor.py @@ -98,6 +98,40 @@ def _instance_payload( 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. @@ -268,15 +302,70 @@ class NestQuestCompletionPctTodaySensor(_NestQuestChildDaySensor): 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 four documented day sensors for one active child.""" + """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), ) @@ -285,7 +374,7 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: Callable[..., None], ) -> None: - """Create the four day sensors per active child. + """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 diff --git a/tests/test_sensors.py b/tests/test_sensors.py index c054740..cf0478b 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -97,7 +97,7 @@ async def _complete_first_instance(database, coordinator, child) -> None: ) -async def test_every_active_child_gets_four_sensors_with_documented_identity( +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( @@ -109,8 +109,8 @@ async def test_every_active_child_gets_four_sensors_with_documented_identity( await coordinator.async_refresh() # Two quest children plus the zero-quest child: three children x - # four sensors, no duplicates. - assert len(hass.entities) == 12 + # five sensors, no duplicates. + assert len(hass.entities) == 15 expected = { ada.id: ("Ada", 1, 1, 0, 100), bo.id: ("Bo", 1, 0, 1, 0), @@ -121,6 +121,7 @@ async def test_every_active_child_gets_four_sensors_with_documented_identity( 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" @@ -323,7 +324,7 @@ async def test_repeated_refreshes_do_not_duplicate_entities( ) await coordinator.async_refresh() await coordinator.async_refresh() - assert len(hass.entities) == 12 + assert len(hass.entities) == 15 async def test_setup_with_no_children_registers_no_entities( @@ -382,4 +383,66 @@ async def test_failed_platform_unload_returns_false_and_retains_runtime( 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 \ No newline at end of file + 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 From df4c9eb21a9de8601d6653e263f3a1b2ff954e27 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 11:56:10 -0400 Subject: [PATCH 13/46] Add per-child all-done and present-today binary sensors all_done is on exactly when the child owed quests today and owes none remaining (a zero-quest day stays off so celebrations never fire on an empty day); present_today mirrors the presence engine's answer for the HA-local date. --- custom_components/nestquest/binary_sensor.py | 191 +++++++++++++++++++ custom_components/nestquest/const.py | 4 +- tests/test_binary_sensors.py | 134 +++++++++++++ tests/test_const.py | 4 +- tests/test_sensors.py | 16 +- 5 files changed, 340 insertions(+), 9 deletions(-) create mode 100644 custom_components/nestquest/binary_sensor.py create mode 100644 tests/test_binary_sensors.py diff --git a/custom_components/nestquest/binary_sensor.py b/custom_components/nestquest/binary_sensor.py new file mode 100644 index 0000000..7cd45b3 --- /dev/null +++ b/custom_components/nestquest/binary_sensor.py @@ -0,0 +1,191 @@ +"""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 + + +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/const.py b/custom_components/nestquest/const.py index 82ef273..3d1d89a 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -8,8 +8,8 @@ LOGGER_NAME = "custom_components.nestquest" LOGGER = logging.getLogger(LOGGER_NAME) # Platforms set up per config entry (Feature 10): the per-child day -# sensors. binary_sensor joins when task 4's entities land. -PLATFORMS: list[str] = ["sensor"] +# sensors and binary sensors. +PLATFORMS: list[str] = ["sensor", "binary_sensor"] #: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2 #: plus the existing Feature 07 regenerate service). diff --git a/tests/test_binary_sensors.py b/tests/test_binary_sensors.py new file mode 100644 index 0000000..2c31abf --- /dev/null +++ b/tests/test_binary_sensors.py @@ -0,0 +1,134 @@ +"""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 +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 entity.extra_state_attributes == { + "child_id": ada.id, + "child_name": "Ada", + } diff --git a/tests/test_const.py b/tests/test_const.py index dc9e255..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 the sensor platform list.""" + """Verify PLATFORMS is defined as the sensor + binary_sensor list.""" assert hasattr(const, "PLATFORMS") assert isinstance(const.PLATFORMS, list) - assert const.PLATFORMS == ["sensor"] + assert const.PLATFORMS == ["sensor", "binary_sensor"] def test_sqlite_db_filename() -> None: diff --git a/tests/test_sensors.py b/tests/test_sensors.py index cf0478b..14cd27b 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -104,13 +104,16 @@ async def test_every_active_child_gets_five_sensors_with_documented_identity( hass, make_entry ) database = entry.runtime_data.database - assert hass.config_entries.forwarded_platforms == ["sensor"] + 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, no duplicates. - assert len(hass.entities) == 15 + # (five sensors + two binary sensors), no duplicates. + assert len(hass.entities) == 21 expected = { ada.id: ("Ada", 1, 1, 0, 100), bo.id: ("Bo", 1, 0, 1, 0), @@ -324,7 +327,7 @@ async def test_repeated_refreshes_do_not_duplicate_entities( ) await coordinator.async_refresh() await coordinator.async_refresh() - assert len(hass.entities) == 15 + assert len(hass.entities) == 21 async def test_setup_with_no_children_registers_no_entities( @@ -339,7 +342,10 @@ 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"] + assert hass.config_entries.unloaded_platforms == [ + "sensor", + "binary_sensor", + ] async def test_rename_propagates_to_name_and_device_label( From 5bca24910a47d0b858a8c9a78345b4ee14ffbe13 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 11:58:12 -0400 Subject: [PATCH 14/46] Add household rollup and cycle-day sensors Household quests due/completed today sum the active children's snapshots; cycle day reports the first scheduled child's cycle position (0 when nobody is scheduled), anchor-date arithmetic only. --- custom_components/nestquest/sensor.py | 121 ++++++++++++++++++++++++++ tests/test_sensors.py | 71 +++++++++++++-- 2 files changed, 187 insertions(+), 5 deletions(-) diff --git a/custom_components/nestquest/sensor.py b/custom_components/nestquest/sensor.py index d3ae546..0157750 100644 --- a/custom_components/nestquest/sensor.py +++ b/custom_components/nestquest/sensor.py @@ -369,6 +369,123 @@ def _child_day_sensors( ) +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, @@ -405,6 +522,10 @@ async def async_setup_entry( 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 diff --git a/tests/test_sensors.py b/tests/test_sensors.py index 14cd27b..cadeca9 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -112,8 +112,9 @@ async def test_every_active_child_gets_five_sensors_with_documented_identity( await coordinator.async_refresh() # Two quest children plus the zero-quest child: three children x - # (five sensors + two binary sensors), no duplicates. - assert len(hass.entities) == 21 + # (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), @@ -327,15 +328,22 @@ async def test_repeated_refreshes_do_not_duplicate_entities( ) await coordinator.async_refresh() await coordinator.async_refresh() - assert len(hass.entities) == 21 + assert len(hass.entities) == 24 -async def test_setup_with_no_children_registers_no_entities( +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 - assert hass.entities == {} + # 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: @@ -452,3 +460,56 @@ async def test_next_quest_state_truncates_over_the_limit( 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() + ) From fe38e5040ed5707be373e9bdb811c65ab8f21aaa Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 12:01:05 -0400 Subject: [PATCH 15/46] Fire the documented bus events from the completion service paths quest_completed and quest_uncompleted fire on actual transitions with documented payloads; child_day_complete fires when a completion clears the child's day; quest_missed is defined as Feature 11's contract. Payloads documented in the README for automation authors. --- README.md | 29 ++++ custom_components/nestquest/const.py | 9 ++ custom_components/nestquest/events.py | 124 +++++++++++++++ custom_components/nestquest/services.py | 19 ++- tests/conftest.py | 26 ++++ tests/test_events.py | 192 ++++++++++++++++++++++++ 6 files changed, 397 insertions(+), 2 deletions(-) create mode 100644 custom_components/nestquest/events.py create mode 100644 tests/test_events.py diff --git a/README.md b/README.md index dfa59e7..441ad74 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,35 @@ 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.payload.child_name == 'Declan' }}" +actions: + - action: notify.mobile_app_declans_phone + data: + message: "Every quest cleared — legendary!" +``` + ## Developer Setup and Testing This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management. diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index 3d1d89a..924898f 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -11,6 +11,15 @@ LOGGER = logging.getLogger(LOGGER_NAME) # sensors and binary sensors. PLATFORMS: list[str] = ["sensor", "binary_sensor"] +# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The +# panel and admin cards listen for these; 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" diff --git a/custom_components/nestquest/events.py b/custom_components/nestquest/events.py new file mode 100644 index 0000000..d8394c6 --- /dev/null +++ b/custom_components/nestquest/events.py @@ -0,0 +1,124 @@ +"""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 +) -> 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; a no-op re-complete never reaches here. + """ + now_stamp = _now_stamp() + payload = await _instance_payload(database, instance_id, now_stamp) + latest = await CompletionEventsDao(database).get_latest_for_instance( + instance_id + ) + payload["was_on_time"] = ( + latest.was_on_time if latest is not None else None + ) + 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). + today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date() + 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/services.py b/custom_components/nestquest/services.py index 9c6d765..3ee9903 100644 --- a/custom_components/nestquest/services.py +++ b/custom_components/nestquest/services.py @@ -42,6 +42,7 @@ from .const import ( 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 @@ -293,11 +294,19 @@ def _complete_quest( ) -> ServiceHandler: async def _handler(call: Any) -> None: database = _require_runtime(hass, find_runtime).database + instance_id = call.data["instance_id"] + # Events fire on an ACTUAL transition only: an already-done + # instance is a no-op below and must not re-announce itself. + state_before = await completion_layer.instance_state( + database, instance_id + ) await completion_layer.complete_instance( database, - call.data["instance_id"], + instance_id, **_actor_kwargs(call), ) + if state_before != "done": + await fire_quest_completed(hass, database, instance_id) return _with_errors(_handler) @@ -307,11 +316,17 @@ def _uncomplete_quest( ) -> ServiceHandler: async def _handler(call: Any) -> None: database = _require_runtime(hass, find_runtime).database + instance_id = call.data["instance_id"] + state_before = await completion_layer.instance_state( + database, instance_id + ) await completion_layer.uncomplete_instance( database, - call.data["instance_id"], + instance_id, **_actor_kwargs(call), ) + if state_before == "done": + await fire_quest_uncompleted(hass, database, instance_id) return _with_errors(_handler) diff --git a/tests/conftest.py b/tests/conftest.py index d753166..8873d63 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -549,6 +549,31 @@ 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 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. @@ -671,6 +696,7 @@ def make_hass() -> tuple: registry = ListenerRegistry(hass) hass.time_change = TimeChangeRegistry(hass) hass.services = ServiceRegistry(hass) + hass.bus = EventBus() # A valid IANA time zone (the day-rollover listener reads it to # compute "today" in HA local time). hass.config.time_zone = "UTC" diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..ee3d79b --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,192 @@ +"""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" From 2a4eec658fa4669156543db99c667cf075181563 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 12:03:42 -0400 Subject: [PATCH 16/46] Decide event transitions under the completion lock complete/uncomplete return a CompletionResult str subclass carrying appended (decided with the write under the lock), so duplicate concurrent calls fire one transition event; day-complete only evaluates for an instance due today. --- custom_components/nestquest/completion.py | 58 ++++++++++----- custom_components/nestquest/events.py | 4 ++ custom_components/nestquest/services.py | 19 ++--- tests/test_events.py | 88 +++++++++++++++++++++++ 4 files changed, 140 insertions(+), 29 deletions(-) diff --git a/custom_components/nestquest/completion.py b/custom_components/nestquest/completion.py index 034741d..7d163b5 100644 --- a/custom_components/nestquest/completion.py +++ b/custom_components/nestquest/completion.py @@ -366,6 +366,25 @@ 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. + """ + + appended: bool + + def __new__(cls, state: str, *, appended: bool) -> "CompletionResult": + obj = super().__new__(cls, state) + obj.appended = appended + return obj + + async def complete_instance( database: NestQuestDatabase, instance_id: int, @@ -375,14 +394,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`` @@ -404,7 +423,7 @@ 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 " @@ -421,7 +440,7 @@ async def complete_instance( was_on_time=_was_on_time(instance, now, today), now=now, ) - return "done" + return CompletionResult("done", appended=True) async def uncomplete_instance( @@ -433,16 +452,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( @@ -459,7 +478,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 " @@ -476,7 +498,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/events.py b/custom_components/nestquest/events.py index d8394c6..9798340 100644 --- a/custom_components/nestquest/events.py +++ b/custom_components/nestquest/events.py @@ -91,7 +91,11 @@ async def fire_quest_completed( # 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() ) diff --git a/custom_components/nestquest/services.py b/custom_components/nestquest/services.py index 3ee9903..c03150d 100644 --- a/custom_components/nestquest/services.py +++ b/custom_components/nestquest/services.py @@ -295,17 +295,15 @@ def _complete_quest( async def _handler(call: Any) -> None: database = _require_runtime(hass, find_runtime).database instance_id = call.data["instance_id"] - # Events fire on an ACTUAL transition only: an already-done - # instance is a no-op below and must not re-announce itself. - state_before = await completion_layer.instance_state( - database, instance_id - ) - await completion_layer.complete_instance( + # ``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( database, instance_id, **_actor_kwargs(call), ) - if state_before != "done": + if result.appended: await fire_quest_completed(hass, database, instance_id) return _with_errors(_handler) @@ -317,15 +315,12 @@ def _uncomplete_quest( async def _handler(call: Any) -> None: database = _require_runtime(hass, find_runtime).database instance_id = call.data["instance_id"] - state_before = await completion_layer.instance_state( - database, instance_id - ) - await completion_layer.uncomplete_instance( + result = await completion_layer.uncomplete_instance( database, instance_id, **_actor_kwargs(call), ) - if state_before == "done": + if result.appended: await fire_quest_uncompleted(hass, database, instance_id) return _with_errors(_handler) diff --git a/tests/test_events.py b/tests/test_events.py index ee3d79b..ad019be 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -190,3 +190,91 @@ async def test_uncompleted_fires_on_reversal_only(hass, make_entry) -> None: 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) From ad56d13c6e35b708ec378d51442592a2eccf1c0b Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 13:16:12 -0400 Subject: [PATCH 17/46] Refresh entities immediately after completion service calls complete_quest and uncomplete_quest force a coordinator refresh when the call appended, so entity states land the moment the service call returns; scheduled internals are unaffected. --- custom_components/nestquest/services.py | 29 ++++++++-- tests/test_events.py | 74 +++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/custom_components/nestquest/services.py b/custom_components/nestquest/services.py index c03150d..f8c322b 100644 --- a/custom_components/nestquest/services.py +++ b/custom_components/nestquest/services.py @@ -242,6 +242,19 @@ def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime): 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() @@ -293,18 +306,19 @@ def _complete_quest( hass: HomeAssistant, find_runtime: FindRuntime ) -> ServiceHandler: async def _handler(call: Any) -> None: - database = _require_runtime(hass, find_runtime).database + 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( - database, + runtime.database, instance_id, **_actor_kwargs(call), ) if result.appended: - await fire_quest_completed(hass, database, instance_id) + await fire_quest_completed(hass, runtime.database, instance_id) + await _refresh_entities(runtime) return _with_errors(_handler) @@ -313,15 +327,18 @@ def _uncomplete_quest( hass: HomeAssistant, find_runtime: FindRuntime ) -> ServiceHandler: async def _handler(call: Any) -> None: - database = _require_runtime(hass, find_runtime).database + runtime = _require_runtime(hass, find_runtime) instance_id = call.data["instance_id"] result = await completion_layer.uncomplete_instance( - database, + runtime.database, instance_id, **_actor_kwargs(call), ) if result.appended: - await fire_quest_uncompleted(hass, database, instance_id) + await fire_quest_uncompleted( + hass, runtime.database, instance_id + ) + await _refresh_entities(runtime) return _with_errors(_handler) diff --git a/tests/test_events.py b/tests/test_events.py index ad019be..b15c50a 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -278,3 +278,77 @@ async def test_day_complete_ignored_for_backdated_completion( # 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" + ) From a5dbae5d50aa10407cef60ff8a0abf0ef4d15c9c Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 13:21:54 -0400 Subject: [PATCH 18/46] Serialize forced coordinator refreshes per entry NestQuestCoordinator.async_refresh holds a per-entry lock so overlapping service-path refreshes never interleave; the queued pass always publishes after the in-flight one. --- custom_components/nestquest/coordinator.py | 20 ++++++++++++++++- tests/test_events.py | 25 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/custom_components/nestquest/coordinator.py b/custom_components/nestquest/coordinator.py index 7d8e79b..08f9ad9 100644 --- a/custom_components/nestquest/coordinator.py +++ b/custom_components/nestquest/coordinator.py @@ -25,6 +25,7 @@ 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 @@ -97,7 +98,16 @@ class NestQuestSnapshot: class NestQuestCoordinator(DataUpdateCoordinator): - """Shared refresh cycle for every NestQuest entity.""" + """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, @@ -122,6 +132,14 @@ class NestQuestCoordinator(DataUpdateCoordinator): ) 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) diff --git a/tests/test_events.py b/tests/test_events.py index b15c50a..829f4ea 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -352,3 +352,28 @@ async def test_uncompletion_updates_entities_immediately( 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) + ) From a9d8f1d225bae33de447c7a6bc17e2ccee044064 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 13:23:35 -0400 Subject: [PATCH 19/46] Handle entity lifecycle for child add, deactivate, reactivate The harness tracks entities per entry so a platform unload removes exactly that entry's entities; lifecycle tests prove deactivated children drop out on reload, reactivation restores the original unique_ids, new children appear without disturbing existing ones, and the restart equivalent restores live states. --- tests/conftest.py | 50 +++++++---- tests/test_entity_lifecycle.py | 159 +++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 16 deletions(-) create mode 100644 tests/test_entity_lifecycle.py diff --git a/tests/conftest.py b/tests/conftest.py index 8873d63..e2dfd3d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -704,28 +704,40 @@ def make_hass() -> tuple: # 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. + # 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 _async_add_entities(new_entities, update_before_add=False): - """Mirror HA's AddEntitiesCallback: bind hass and register entities. + def _make_add_entities(entry): + entry_entities = hass.entities_by_entry.setdefault(entry.entry_id, {}) - 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). - """ - if isinstance(new_entities, Entity): - new_entities = [new_entities] - for entity in new_entities: - entity.hass = hass - entity.async_write_ha_state() + 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 - the AddEntitiesCallback above. A stub entry without a domain + 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): @@ -733,14 +745,18 @@ def make_hass() -> tuple: module = importlib.import_module( f"custom_components.{domain}.{platform}" ) - await module.async_setup_entry(hass, entry, _async_add_entities) + 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). + 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") @@ -751,6 +767,8 @@ def make_hass() -> tuple: 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] = [] diff --git a/tests/test_entity_lifecycle.py b/tests/test_entity_lifecycle.py new file mode 100644 index 0000000..a85c265 --- /dev/null +++ b/tests/test_entity_lifecycle.py @@ -0,0 +1,159 @@ +"""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 From 36263d3f1952f18c4b97ca040ea636bbbff1082a Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 13:26:51 -0400 Subject: [PATCH 20/46] Add the entity-and-event matrix test One fixture drives every Feature 10 contract: all per-child sensors and binary sensors, household rollups, the 255-character limit, the three events exactly once with schema-valid payloads, the missed-instance omission rule, and no entities for inactive children. --- tests/test_entity_matrix.py | 247 ++++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 tests/test_entity_matrix.py 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 From 35e66bd9e65951392fe70514768baee47a7ef3cd Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 13:29:25 -0400 Subject: [PATCH 21/46] Carry completion timing under the lock; shut down before close The completed event's was_on_time travels on the CompletionResult (decided with the write) instead of re-reading latest state a racing reversal could replace; unload clears coordinator listeners before the database close. --- custom_components/nestquest/__init__.py | 13 +++++++---- custom_components/nestquest/completion.py | 23 +++++++++++++++---- custom_components/nestquest/events.py | 18 ++++++++------- custom_components/nestquest/services.py | 7 +++++- tests/test_entity_lifecycle.py | 28 +++++++++++++++++++++++ 5 files changed, 71 insertions(+), 18 deletions(-) diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 33f1449..5c66d8c 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -615,16 +615,19 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_error is None: unload_error = err finally: - database = getattr(runtime_data, "database", None) - if database is not None: - await database.close() - # Tear the shared coordinator down after the DB close: no - # listener may outlive the connection it reads. + # 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() # 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. diff --git a/custom_components/nestquest/completion.py b/custom_components/nestquest/completion.py index 7d163b5..bb6becb 100644 --- a/custom_components/nestquest/completion.py +++ b/custom_components/nestquest/completion.py @@ -374,14 +374,25 @@ class CompletionResult(str): 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. + 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) -> "CompletionResult": + 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 @@ -429,6 +440,10 @@ async def complete_instance( 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, @@ -437,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 CompletionResult("done", appended=True) + return CompletionResult("done", appended=True, was_on_time=on_time) async def uncomplete_instance( diff --git a/custom_components/nestquest/events.py b/custom_components/nestquest/events.py index 9798340..129af0b 100644 --- a/custom_components/nestquest/events.py +++ b/custom_components/nestquest/events.py @@ -71,22 +71,24 @@ async def _instance_payload( async def fire_quest_completed( - hass, database: NestQuestDatabase, instance_id: int + 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; a no-op re-complete never reaches here. + 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) - latest = await CompletionEventsDao(database).get_latest_for_instance( - instance_id - ) - payload["was_on_time"] = ( - latest.was_on_time if latest is not None else None - ) + 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 diff --git a/custom_components/nestquest/services.py b/custom_components/nestquest/services.py index f8c322b..0c66635 100644 --- a/custom_components/nestquest/services.py +++ b/custom_components/nestquest/services.py @@ -317,7 +317,12 @@ def _complete_quest( **_actor_kwargs(call), ) if result.appended: - await fire_quest_completed(hass, runtime.database, instance_id) + await fire_quest_completed( + hass, + runtime.database, + instance_id, + was_on_time=result.was_on_time, + ) await _refresh_entities(runtime) return _with_errors(_handler) diff --git a/tests/test_entity_lifecycle.py b/tests/test_entity_lifecycle.py index a85c265..48f9b84 100644 --- a/tests/test_entity_lifecycle.py +++ b/tests/test_entity_lifecycle.py @@ -157,3 +157,31 @@ async def test_restart_equivalent_restores_all_entity_states( 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" + ) From 3de9f4b970c49e131cddafcd6b3600626e1f5a66 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:15:21 -0400 Subject: [PATCH 22/46] Add the nightly missed-quest sweep Fires one nestquest_quest_missed event per past-due open instance at the configured rollover time and at startup, watermark-guarded in the new nestquest_meta_state table (migration 7, strictly additive). Read-only over the domain tables; never reports an instance that was ever completed. --- custom_components/nestquest/__init__.py | 22 +- custom_components/nestquest/dao_instances.py | 37 +++ custom_components/nestquest/dao_meta.py | 44 ++++ custom_components/nestquest/migrations.py | 11 +- custom_components/nestquest/schema.py | 16 ++ custom_components/nestquest/sweep.py | 103 ++++++++ tests/test_migrations.py | 5 +- tests/test_schema.py | 4 +- tests/test_sweep.py | 239 +++++++++++++++++++ 9 files changed, 473 insertions(+), 8 deletions(-) create mode 100644 custom_components/nestquest/dao_meta.py create mode 100644 custom_components/nestquest/sweep.py create mode 100644 tests/test_sweep.py diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 5c66d8c..143e9e0 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -35,6 +35,7 @@ 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 @@ -327,18 +328,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 @@ -498,6 +504,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await _run_horizon_materialization( hass, database, _configured_horizon_days(dict(entry.options)) ) + # 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 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/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/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/sweep.py b/custom_components/nestquest/sweep.py new file mode 100644 index 0000000..a36cdf4 --- /dev/null +++ b/custom_components/nestquest/sweep.py @@ -0,0 +1,103 @@ +"""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 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" + +#: 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). + """ + 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) + 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/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_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_sweep.py b/tests/test_sweep.py new file mode 100644 index 0000000..17ee530 --- /dev/null +++ b/tests/test_sweep.py @@ -0,0 +1,239 @@ +"""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] From 8cd43141a9cbd74b7d930fd75db087f7be8f5da1 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:16:50 -0400 Subject: [PATCH 23/46] Serialize the missed sweep's watermark claim The whole watermark read-query-fire-write span runs under a per-database sweep lock; a racing second sweep re-reads the watermark inside the lock and announces nothing. --- custom_components/nestquest/sweep.py | 85 +++++++++++++++++++--------- tests/test_sweep.py | 20 +++++++ 2 files changed, 77 insertions(+), 28 deletions(-) diff --git a/custom_components/nestquest/sweep.py b/custom_components/nestquest/sweep.py index a36cdf4..e60ab72 100644 --- a/custom_components/nestquest/sweep.py +++ b/custom_components/nestquest/sweep.py @@ -21,6 +21,7 @@ skip it via the no-completion-event rule. """ from __future__ import annotations +import asyncio import datetime import logging from zoneinfo import ZoneInfo @@ -37,6 +38,25 @@ 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" @@ -60,6 +80,12 @@ async def run_missed_sweep( 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 @@ -68,35 +94,38 @@ async def run_missed_sweep( ) today_iso = today_date.isoformat() dao = MetaStateDao(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 + 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) + 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 ) diff --git a/tests/test_sweep.py b/tests/test_sweep.py index 17ee530..c76ac81 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -237,3 +237,23 @@ async def test_v6_database_migrates_to_v7_meta_state( 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 From 3c832c1ceecf0d70dd4cf790d8730924022e1510 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:18:18 -0400 Subject: [PATCH 24/46] Add the notification section to the options flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notify target (empty default, household fills their own service), three strict HH:MM automation times (08:00/15:00/20:00 — the report deliberately before the midnight sweep), and four independent enable toggles, all leniently stored and round-tripped. --- custom_components/nestquest/const.py | 20 +++ custom_components/nestquest/options_flow.py | 111 ++++++++++++- tests/test_options_flow.py | 173 ++++++++++++++++++-- 3 files changed, 285 insertions(+), 19 deletions(-) diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index 924898f..e81d78e 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -73,6 +73,26 @@ 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/options_flow.py b/custom_components/nestquest/options_flow.py index 32ddd3b..201d128 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -11,12 +11,24 @@ 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, @@ -71,9 +83,47 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]: ): 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) + or not notify_target.strip() + or notify_target != notify_target.strip() + ): + 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], *, @@ -85,7 +135,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( @@ -100,6 +153,37 @@ def _build_schema( 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 @@ -233,6 +317,23 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): 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: @@ -254,6 +355,14 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): (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/tests/test_options_flow.py b/tests/test_options_flow.py index af949b6..ba4dcc5 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -19,6 +19,18 @@ from custom_components.nestquest.const import ( 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, @@ -36,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) @@ -131,10 +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, - CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, - } + assert result["data"] == FULL_INPUT def test_options_flow_shows_form_initially() -> None: @@ -214,6 +239,10 @@ def test_options_flow_schema_applies_stored_defaults() -> None: 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)) @@ -222,6 +251,14 @@ def test_options_flow_schema_applies_stored_defaults() -> None: 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: [], } @@ -295,16 +332,8 @@ def test_options_flow_creates_entry_with_four_keys() -> None: 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, - CONF_UPDATE_INTERVAL, - } - assert result["data"] == { - **VALID_INPUT, - CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, - } + 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: @@ -327,10 +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, - CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, - } + assert entry.options == FULL_INPUT def test_options_flow_validation_never_raises() -> None: @@ -649,3 +675,114 @@ 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_must_be_clean_nonempty_string() -> None: + flow = _make_flow(_make_entry()) + for bad_target in ("", " notify.x ", 42): + 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] == "" From fbde3eeaa12f063136725c0b5b9e2effc6daf370 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:20:13 -0400 Subject: [PATCH 25/46] Allow saving the empty default notify target An empty target means notifications are not configured yet and is the shipped default; only malformed strings (padded or whitespace-only) reject. --- custom_components/nestquest/options_flow.py | 12 ++++++++---- tests/test_options_flow.py | 17 +++++++++++++++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py index 201d128..bf5c59f 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -98,11 +98,15 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]: ): 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) - or not notify_target.strip() - or notify_target != notify_target.strip() + 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, diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index ba4dcc5..f0c8385 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -712,9 +712,22 @@ def test_options_flow_notification_times_must_be_strict_hh_mm() -> None: assert result["errors"] == {CONF_MORNING_SUMMARY_TIME: "invalid_time"} -def test_options_flow_notify_target_must_be_clean_nonempty_string() -> None: +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()) - for bad_target in ("", " notify.x ", 42): + # 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( { From dc7a82f6e253249afef27a2a10cf8cde80ead611 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:24:50 -0400 Subject: [PATCH 26/46] Add the morning summary blueprint Iterates present children from the Feature 10 presence sensors, lists what each owes today from the remaining sensor, omits absent children entirely. Blueprint inputs (time, notify service, send toggle); the notify target is the household's choice, never hard-coded. PyYAML and Jinja2 join the dev-only dependencies for blueprint template tests. --- .../automation/morning_summary.yaml | 80 ++++ pyproject.toml | 2 + tests/test_blueprints.py | 382 ++++++++++++++++++ uv.lock | 145 +++++++ 4 files changed, 609 insertions(+) create mode 100644 custom_components/nestquest/blueprints/automation/morning_summary.yaml create mode 100644 tests/test_blueprints.py 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..8efda4a --- /dev/null +++ b/custom_components/nestquest/blueprints/automation/morning_summary.yaml @@ -0,0 +1,80 @@ +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 + title: NestQuest morning summary + message: | + {%- set ns = namespace(lines=[]) -%} + {%- for presence in states.binary_sensor + | selectattr('entity_id', 'search', '^binary_sensor\\.nestquest_.*_present_today$') + | selectattr('state', 'eq', 'on') -%} + {%- set child_id = presence.entity_id.split('.')[1].removeprefix('nestquest_').removesuffix('_present_today') -%} + {%- set remaining_values = states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_' ~ child_id ~ '_quests_remaining_today$') + | map(attribute='state') + | list -%} + {%- set due_sensors = states.sensor + | selectattr('entity_id', 'search', '^sensor\\.nestquest_' ~ child_id ~ '_quests_due_today$') + | list -%} + {%- set child_name = due_sensors[0].attributes['child_name'] if due_sensors else (child_id | capitalize) -%} + {%- if remaining_values -%} + {%- set remaining = remaining_values | first | int -%} + {%- if remaining > 0 -%} + {%- set line = child_name ~ ' owes ' ~ remaining ~ ' quest(s) today.' -%} + {%- else -%} + {%- set line = child_name ~ ' is all clear today (0 quests remaining).' -%} + {%- endif -%} + {%- else -%} + {%- set line = child_name ~ "'s quest count is unavailable today." -%} + {%- endif -%} + {%- set ns.lines = ns.lines + [line] -%} + {%- endfor -%} + {%- if ns.lines -%} + {{- ns.lines | join('\n') -}} + {%- else -%} + No children present today. + {%- endif -%} diff --git a/pyproject.toml b/pyproject.toml index cd7c2c2..3a8a289 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/test_blueprints.py b/tests/test_blueprints.py new file mode 100644 index 0000000..977bf3e --- /dev/null +++ b/tests/test_blueprints.py @@ -0,0 +1,382 @@ +"""Tests for the NestQuest automation blueprints. + +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. + +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``. Jinja's ``search``/``match`` selectattr tests are +Home Assistant additions, registered here explicitly. + +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 pathlib import Path +from types import SimpleNamespace + +import jinja2 +import yaml + +from custom_components.nestquest.const import DEFAULT_MORNING_SUMMARY_TIME + +REPO_ROOT = Path(__file__).parent.parent +BLUEPRINT_DIR = ( + REPO_ROOT / "custom_components" / "nestquest" / "blueprints" / "automation" +) + +MORNING_TITLE = "NestQuest morning summary" + + +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 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) + + +def morning_summary() -> dict: + """Return the parsed morning-summary blueprint.""" + blueprints = load_blueprints() + assert "morning_summary" in blueprints, "morning_summary.yaml is missing" + return blueprints["morning_summary"] + + +def morning_action(bp: dict) -> dict: + """Return the single notify action of a summary blueprint.""" + actions = bp["actions"] + assert isinstance(actions, list) and len(actions) == 1 + return actions[0] + + +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 {}) + + +def make_states(binary_sensors=(), sensors=()) -> SimpleNamespace: + """Build the ``states`` fixture: per-domain lists of TemplateState.""" + return SimpleNamespace( + binary_sensor=list(binary_sensors), + sensor=list(sensors), + ) + + +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) + + +# --------------------------------------------------------------------------- +# 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") + assert action["title"] == MORNING_TITLE + assert isinstance(action["message"], str) and action["message"].strip() + + +# --------------------------------------------------------------------------- +# Entity-reference discipline (Feature 10 shapes only). +# --------------------------------------------------------------------------- +#: 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"(? 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_presence_regex_selects_only_nestquest_present_sensors() -> None: + """The extracted presence regex matches only Feature 10 presence ids.""" + message = morning_action(morning_summary())["message"] + match = re.search( + r"\^(binary_sensor\\\\\.nestquest_\.\*_present_today\$)", message + ) + assert match is not None, "presence pattern missing from the template" + pattern = match.group(1).replace("\\\\", "\\") + assert pattern.endswith("$") + assert re.search(pattern, "binary_sensor.nestquest_alice_present_today") + assert re.search(pattern, "binary_sensor.nestquest_dave_all_done") is None + assert re.search(pattern, "binary_sensor.motion_present_today") is None + assert re.search(pattern, "binary_sensor.nestquest_erin_present_today_2") 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"}, + ), + TemplateState("sensor.nestquest_alice_quests_remaining_today", "2"), + TemplateState( + "sensor.nestquest_bob_quests_due_today", + "2", + {"child_name": "Bob"}, + ), + TemplateState("sensor.nestquest_bob_quests_remaining_today", "1"), + 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 = morning_action(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( + binary_sensors=[ + TemplateState("binary_sensor.nestquest_carol_present_today", "on"), + TemplateState("binary_sensor.nestquest_alice_present_today", "on"), + ], + sensors=[ + TemplateState( + "sensor.nestquest_carol_quests_due_today", + "2", + {"child_name": "Carol"}, + ), + TemplateState("sensor.nestquest_carol_quests_remaining_today", "0"), + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice"}, + ), + TemplateState("sensor.nestquest_alice_quests_remaining_today", "2"), + ], + ) + rendered = render(morning_action(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 with no Feature 10 sensors still gets a line.""" + states = make_states( + binary_sensors=[ + TemplateState("binary_sensor.nestquest_frances_present_today", "on") + ], + sensors=[], + ) + rendered = render(morning_action(morning_summary())["message"], states=states) + assert rendered == "Frances's quest count is unavailable today." + + +def test_morning_summary_no_present_children() -> None: + """With nobody present the body says so rather than listing zeros.""" + states = make_states( + binary_sensors=[ + TemplateState("binary_sensor.nestquest_alice_present_today", "off") + ], + sensors=[ + TemplateState( + "sensor.nestquest_alice_quests_due_today", + "3", + {"child_name": "Alice"}, + ), + TemplateState("sensor.nestquest_alice_quests_remaining_today", "3"), + ], + ) + rendered = render(morning_action(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" diff --git a/uv.lock b/uv.lock index 13bd8ec..dd4e390 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.3.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" From f31bda2714cdbf6b38b4786e10db01ed4d754630 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:31:00 -0400 Subject: [PATCH 27/46] Include collision-suffixed entity ids in the morning summary Duplicate child names get HA registry suffixes (_2); the template now iterates the due sensors (which carry present/child_id/child_name as attributes) and pairs counts by child_id, so suffixed duplicates are listed and the household rollup is excluded by the presence guard. --- .../automation/morning_summary.yaml | 37 +++--- tests/test_blueprints.py | 118 +++++++++++++----- 2 files changed, 106 insertions(+), 49 deletions(-) diff --git a/custom_components/nestquest/blueprints/automation/morning_summary.yaml b/custom_components/nestquest/blueprints/automation/morning_summary.yaml index 8efda4a..20f74b4 100644 --- a/custom_components/nestquest/blueprints/automation/morning_summary.yaml +++ b/custom_components/nestquest/blueprints/automation/morning_summary.yaml @@ -49,29 +49,26 @@ actions: title: NestQuest morning summary message: | {%- set ns = namespace(lines=[]) -%} - {%- for presence in states.binary_sensor - | selectattr('entity_id', 'search', '^binary_sensor\\.nestquest_.*_present_today$') - | selectattr('state', 'eq', 'on') -%} - {%- set child_id = presence.entity_id.split('.')[1].removeprefix('nestquest_').removesuffix('_present_today') -%} - {%- set remaining_values = states.sensor - | selectattr('entity_id', 'search', '^sensor\\.nestquest_' ~ child_id ~ '_quests_remaining_today$') - | map(attribute='state') - | list -%} - {%- set due_sensors = states.sensor - | selectattr('entity_id', 'search', '^sensor\\.nestquest_' ~ child_id ~ '_quests_due_today$') - | list -%} - {%- set child_name = due_sensors[0].attributes['child_name'] if due_sensors else (child_id | capitalize) -%} - {%- if remaining_values -%} - {%- set remaining = remaining_values | first | int -%} - {%- if remaining > 0 -%} - {%- set line = child_name ~ ' owes ' ~ remaining ~ ' quest(s) today.' -%} + {%- 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 = child_name ~ ' is all clear today (0 quests remaining).' -%} + {%- set line = due.attributes.child_name ~ "'s quest count is unavailable today." -%} {%- endif -%} - {%- else -%} - {%- set line = child_name ~ "'s quest count is unavailable today." -%} + {%- set ns.lines = ns.lines + [line] -%} {%- endif -%} - {%- set ns.lines = ns.lines + [line] -%} {%- endfor -%} {%- if ns.lines -%} {{- ns.lines | join('\n') -}} diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 977bf3e..dda97b7 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -242,19 +242,26 @@ def test_domain_mentions_are_nestquest_prefixed() -> None: ) -def test_presence_regex_selects_only_nestquest_present_sensors() -> None: - """The extracted presence regex matches only Feature 10 presence ids.""" +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 = morning_action(morning_summary())["message"] match = re.search( - r"\^(binary_sensor\\\\\.nestquest_\.\*_present_today\$)", message + r"\^(sensor\\\\\.nestquest_\.\*_quests_due_today\(_\\\\d\+\)\?\$)", message ) - assert match is not None, "presence pattern missing from the template" + assert match is not None, "due-sensor pattern missing from the template" pattern = match.group(1).replace("\\\\", "\\") assert pattern.endswith("$") - assert re.search(pattern, "binary_sensor.nestquest_alice_present_today") - assert re.search(pattern, "binary_sensor.nestquest_dave_all_done") is None - assert re.search(pattern, "binary_sensor.motion_present_today") is None - assert re.search(pattern, "binary_sensor.nestquest_erin_present_today_2") is None + 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: @@ -292,15 +299,23 @@ def morning_fixture() -> SimpleNamespace: TemplateState( "sensor.nestquest_alice_quests_due_today", "3", - {"child_name": "Alice"}, + {"child_name": "Alice", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "2", + {"child_id": 1}, ), - TemplateState("sensor.nestquest_alice_quests_remaining_today", "2"), TemplateState( "sensor.nestquest_bob_quests_due_today", "2", - {"child_name": "Bob"}, + {"child_name": "Bob", "child_id": 2, "present": False}, + ), + TemplateState( + "sensor.nestquest_bob_quests_remaining_today", + "1", + {"child_id": 2}, ), - TemplateState("sensor.nestquest_bob_quests_remaining_today", "1"), TemplateState("sensor.nestquest_household_quests_due_today", "5"), TemplateState("sensor.nestquest_carol_unrelated", "7"), ], @@ -320,23 +335,27 @@ def test_morning_summary_lists_present_child_and_omits_absent() -> None: 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( - binary_sensors=[ - TemplateState("binary_sensor.nestquest_carol_present_today", "on"), - TemplateState("binary_sensor.nestquest_alice_present_today", "on"), - ], sensors=[ TemplateState( "sensor.nestquest_carol_quests_due_today", "2", - {"child_name": "Carol"}, + {"child_name": "Carol", "child_id": 1, "present": True}, + ), + TemplateState( + "sensor.nestquest_carol_quests_remaining_today", + "0", + {"child_id": 1}, ), - TemplateState("sensor.nestquest_carol_quests_remaining_today", "0"), TemplateState( "sensor.nestquest_alice_quests_due_today", "3", - {"child_name": "Alice"}, + {"child_name": "Alice", "child_id": 2, "present": True}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "2", + {"child_id": 2}, ), - TemplateState("sensor.nestquest_alice_quests_remaining_today", "2"), ], ) rendered = render(morning_action(morning_summary())["message"], states=states) @@ -345,30 +364,71 @@ def test_morning_summary_present_child_zero_remaining_is_all_clear() -> None: def test_morning_summary_present_child_without_sensors_is_flagged() -> None: - """A present child with no Feature 10 sensors still gets a line.""" + """A present child whose remaining-count sensor is missing (the + platform failed to add it) still gets a line, flagged unavailable.""" states = make_states( - binary_sensors=[ - TemplateState("binary_sensor.nestquest_frances_present_today", "on") + sensors=[ + TemplateState( + "sensor.nestquest_frances_quests_due_today", + "2", + {"child_name": "Frances", "child_id": 1, "present": True}, + ), ], - sensors=[], ) rendered = render(morning_action(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(morning_action(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( - binary_sensors=[ - TemplateState("binary_sensor.nestquest_alice_present_today", "off") - ], sensors=[ TemplateState( "sensor.nestquest_alice_quests_due_today", "3", - {"child_name": "Alice"}, + {"child_name": "Alice", "child_id": 1, "present": False}, + ), + TemplateState( + "sensor.nestquest_alice_quests_remaining_today", + "3", + {"child_id": 1}, ), - TemplateState("sensor.nestquest_alice_quests_remaining_today", "3"), ], ) rendered = render(morning_action(morning_summary())["message"], states=states) From ad6f1768d72157e745c9d2e942f65a24032c16c3 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:42:50 -0400 Subject: [PATCH 28/46] Add the afternoon open-quests reminder blueprint Lists only present children who still owe quests; sends nothing at all when every present child is clear (a false template condition stops the action). Blueprint test helpers move to a shared module. --- .../automation/afternoon_reminder.yaml | 89 +++++++ tests/blueprint_helpers.py | 196 ++++++++++++++ tests/test_blueprint_afternoon.py | 242 ++++++++++++++++++ tests/test_blueprints.py | 154 ++--------- 4 files changed, 550 insertions(+), 131 deletions(-) create mode 100644 custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml create mode 100644 tests/blueprint_helpers.py create mode 100644 tests/test_blueprint_afternoon.py 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..ffe0b73 --- /dev/null +++ b/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml @@ -0,0 +1,89 @@ +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 + 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/tests/blueprint_helpers.py b/tests/blueprint_helpers.py new file mode 100644 index 0000000..5752fbb --- /dev/null +++ b/tests/blueprint_helpers.py @@ -0,0 +1,196 @@ +"""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 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"(? 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") + assert action["title"] == AFTERNOON_TITLE + assert isinstance(action["message"], str) and action["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(single_action(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(single_action(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") \ No newline at end of file diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index dda97b7..fbd49c3 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -1,17 +1,17 @@ -"""Tests for the NestQuest automation blueprints. +"""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. +set shaped like HA's template state objects — all via the shared +harness in :mod:`tests.blueprint_helpers`. -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``. Jinja's ``search``/``match`` selectattr tests are -Home Assistant additions, registered here explicitly. +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 @@ -21,111 +21,27 @@ omitted entirely, never listed with zero quests. from __future__ import annotations import re -from pathlib import Path from types import SimpleNamespace -import jinja2 -import yaml - from custom_components.nestquest.const import DEFAULT_MORNING_SUMMARY_TIME -REPO_ROOT = Path(__file__).parent.parent -BLUEPRINT_DIR = ( - REPO_ROOT / "custom_components" / "nestquest" / "blueprints" / "automation" +from tests.blueprint_helpers import ( + 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" -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 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) - - -def morning_summary() -> dict: - """Return the parsed morning-summary blueprint.""" - blueprints = load_blueprints() - assert "morning_summary" in blueprints, "morning_summary.yaml is missing" - return blueprints["morning_summary"] - - -def morning_action(bp: dict) -> dict: - """Return the single notify action of a summary blueprint.""" - actions = bp["actions"] - assert isinstance(actions, list) and len(actions) == 1 - return actions[0] - - -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 {}) - - -def make_states(binary_sensors=(), sensors=()) -> SimpleNamespace: - """Build the ``states`` fixture: per-domain lists of TemplateState.""" - return SimpleNamespace( - binary_sensor=list(binary_sensors), - sensor=list(sensors), - ) - - -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) - - # --------------------------------------------------------------------------- # YAML parsing and blueprint shape. # --------------------------------------------------------------------------- @@ -177,34 +93,10 @@ def test_morning_summary_wires_inputs_not_literals() -> None: # --------------------------------------------------------------------------- -# Entity-reference discipline (Feature 10 shapes only). +# 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. # --------------------------------------------------------------------------- -#: 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"(? None: """Every id-like entity string in the YAML is a Feature 10 shape.""" for name, bp in load_blueprints().items(): @@ -439,4 +331,4 @@ 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" + assert render(condition["value_template"], send_summary=False).strip() == "False" \ No newline at end of file From 715aac994a9ab0badb771ba80f3cecc63dda1150 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:45:50 -0400 Subject: [PATCH 29/46] Nest notify payload fields under the action's data key The modern HA action schema passes title and message to the notify service only under data; fix the morning summary and afternoon reminder blueprints and the test accessors accordingly. --- .../automation/afternoon_reminder.yaml | 35 ++++++------- .../automation/morning_summary.yaml | 51 ++++++++++--------- tests/blueprint_helpers.py | 10 ++++ tests/test_blueprint_afternoon.py | 10 ++-- tests/test_blueprints.py | 18 ++++--- 5 files changed, 70 insertions(+), 54 deletions(-) diff --git a/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml b/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml index ffe0b73..34ea11c 100644 --- a/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml +++ b/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml @@ -69,21 +69,22 @@ conditions: actions: - action: !input notify_target - 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] -%} + 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 -%} - {%- endif -%} - {%- endfor -%} - {{- ns.lines | join('\n') -}} \ No newline at end of file + {%- 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 index 20f74b4..ea38e1d 100644 --- a/custom_components/nestquest/blueprints/automation/morning_summary.yaml +++ b/custom_components/nestquest/blueprints/automation/morning_summary.yaml @@ -46,32 +46,33 @@ conditions: actions: - action: !input notify_target - 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.' -%} + 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 ~ ' is all clear today (0 quests remaining).' -%} + {%- set line = due.attributes.child_name ~ "'s quest count is unavailable today." -%} {%- endif -%} - {%- else -%} - {%- set line = due.attributes.child_name ~ "'s quest count is unavailable today." -%} + {%- set ns.lines = ns.lines + [line] -%} {%- endif -%} - {%- set ns.lines = ns.lines + [line] -%} + {%- endfor -%} + {%- if ns.lines -%} + {{- ns.lines | join('\n') -}} + {%- else -%} + No children present today. {%- endif -%} - {%- endfor -%} - {%- if ns.lines -%} - {{- ns.lines | join('\n') -}} - {%- else -%} - No children present today. - {%- endif -%} diff --git a/tests/blueprint_helpers.py b/tests/blueprint_helpers.py index 5752fbb..d4ec16e 100644 --- a/tests/blueprint_helpers.py +++ b/tests/blueprint_helpers.py @@ -82,6 +82,16 @@ def morning_action(bp: dict) -> dict: 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): diff --git a/tests/test_blueprint_afternoon.py b/tests/test_blueprint_afternoon.py index 1fe5458..7c13c77 100644 --- a/tests/test_blueprint_afternoon.py +++ b/tests/test_blueprint_afternoon.py @@ -17,6 +17,7 @@ import re from custom_components.nestquest.const import DEFAULT_AFTERNOON_REMINDER_TIME from tests.blueprint_helpers import ( + notify_data, BlueprintInput, blueprint, iter_strings, @@ -80,8 +81,9 @@ def test_afternoon_reminder_wires_inputs_not_literals() -> None: action = single_action(bp) assert action["action"] == BlueprintInput("notify_target") - assert action["title"] == AFTERNOON_TITLE - assert isinstance(action["message"], str) and action["message"].strip() + data = notify_data(afternoon_reminder()) + assert data["title"] == AFTERNOON_TITLE + assert isinstance(data["message"], str) and data["message"].strip() # --------------------------------------------------------------------------- @@ -125,7 +127,7 @@ def test_afternoon_reminder_lists_only_present_children_with_open_quests() -> No TemplateState("sensor.nestquest_household_quests_due_today", "7"), ], ) - rendered = render(single_action(afternoon_reminder())["message"], states=states) + 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 @@ -218,7 +220,7 @@ def test_afternoon_reminder_collision_suffixed_duplicate_names() -> None: ), ], ) - rendered = render(single_action(afternoon_reminder())["message"], states=states) + 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.", diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index fbd49c3..ac8a991 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -26,6 +26,7 @@ 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, @@ -88,8 +89,9 @@ def test_morning_summary_wires_inputs_not_literals() -> None: action = morning_action(bp) assert action["action"] == BlueprintInput("notify_target") - assert action["title"] == MORNING_TITLE - assert isinstance(action["message"], str) and action["message"].strip() + data = notify_data(bp) + assert data["title"] == MORNING_TITLE + assert isinstance(data["message"], str) and data["message"].strip() # --------------------------------------------------------------------------- @@ -139,7 +141,7 @@ def test_due_sensor_regex_selects_only_nestquest_due_sensors() -> None: including HA collision-suffixed duplicates (a second child with the same name gets entity ids ending _2) — and rejects everything else.""" - message = morning_action(morning_summary())["message"] + message = notify_data(morning_summary())["message"] match = re.search( r"\^(sensor\\\\\.nestquest_\.\*_quests_due_today\(_\\\\d\+\)\?\$)", message ) @@ -216,7 +218,7 @@ def morning_fixture() -> SimpleNamespace: def test_morning_summary_lists_present_child_and_omits_absent() -> None: """Present Alice appears with her count; absent Bob and decoys never do.""" - message = morning_action(morning_summary())["message"] + 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 @@ -250,7 +252,7 @@ def test_morning_summary_present_child_zero_remaining_is_all_clear() -> None: ), ], ) - rendered = render(morning_action(morning_summary())["message"], states=states) + 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 @@ -267,7 +269,7 @@ def test_morning_summary_present_child_without_sensors_is_flagged() -> None: ), ], ) - rendered = render(morning_action(morning_summary())["message"], states=states) + rendered = render(notify_data(morning_summary())["message"], states=states) assert rendered == "Frances's quest count is unavailable today." @@ -299,7 +301,7 @@ def test_morning_summary_collision_suffixed_duplicate_names() -> None: ), ], ) - rendered = render(morning_action(morning_summary())["message"], states=states) + rendered = render(notify_data(morning_summary())["message"], states=states) lines = rendered.split("\n") assert lines == [ "Ada owes 1 quest(s) today.", @@ -323,7 +325,7 @@ def test_morning_summary_no_present_children() -> None: ), ], ) - rendered = render(morning_action(morning_summary())["message"], states=states) + rendered = render(notify_data(morning_summary())["message"], states=states) assert rendered == "No children present today." From d2f9c50930d168933655c6c5c808a8cd274ac977 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:46:13 -0400 Subject: [PATCH 30/46] Add the end-of-day parent report blueprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defaults to 20:00 local — deliberately before the midnight missed sweep, reading the same day's data. Every present child gets a completion line; owed lines name still-open quests; absent children are omitted; nothing sends when all clear unless opted in. --- .../automation/end_of_day_report.yaml | 119 +++++++ tests/test_blueprint_end_of_day.py | 296 ++++++++++++++++++ 2 files changed, 415 insertions(+) create mode 100644 custom_components/nestquest/blueprints/automation/end_of_day_report.yaml create mode 100644 tests/test_blueprint_end_of_day.py 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..e1ab3f0 --- /dev/null +++ b/custom_components/nestquest/blueprints/automation/end_of_day_report.yaml @@ -0,0 +1,119 @@ +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 when no present child + # has any quests remaining 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 -%} + {{- 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/tests/test_blueprint_end_of_day.py b/tests/test_blueprint_end_of_day.py new file mode 100644 index 0000000..82b49ee --- /dev/null +++ b/tests/test_blueprint_end_of_day.py @@ -0,0 +1,296 @@ +"""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") \ No newline at end of file From b9137720072ad271aa513fb96467fb64fa473cb6 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:47:37 -0400 Subject: [PATCH 31/46] Report when remaining counts are unavailable, not all-clear A missing or non-numeric remaining sensor reads as -1 in the all-clear guard, so a degraded day reaches the parent instead of looking like a clear day. --- .../automation/end_of_day_report.yaml | 8 ++-- tests/test_blueprint_end_of_day.py | 44 ++++++++++++++++++- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/custom_components/nestquest/blueprints/automation/end_of_day_report.yaml b/custom_components/nestquest/blueprints/automation/end_of_day_report.yaml index e1ab3f0..7809903 100644 --- a/custom_components/nestquest/blueprints/automation/end_of_day_report.yaml +++ b/custom_components/nestquest/blueprints/automation/end_of_day_report.yaml @@ -66,8 +66,10 @@ 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 when no present child - # has any quests remaining today. + # 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) -%} @@ -79,7 +81,7 @@ conditions: | selectattr('attributes.child_id', 'eq', due.attributes.child_id) | map(attribute='state') | list -%} - {%- if remaining_values and (remaining_values | first | int) > 0 -%} + {%- if not remaining_values or (remaining_values | first | int(-1)) != 0 -%} {%- set ns.any_open = true -%} {%- endif -%} {%- endif -%} diff --git a/tests/test_blueprint_end_of_day.py b/tests/test_blueprint_end_of_day.py index 82b49ee..58856c5 100644 --- a/tests/test_blueprint_end_of_day.py +++ b/tests/test_blueprint_end_of_day.py @@ -293,4 +293,46 @@ def test_end_of_day_report_never_hard_codes_notify_target() -> None: 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") \ No newline at end of file + 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" From bf84793a1c6919128008302efefed594348ae5d7 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:47:52 -0400 Subject: [PATCH 32/46] Add the day-complete celebration blueprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triggers on nestquest_child_day_complete (never a zero-quest day), notifies with the payload's child name, and dedupes per child per day via an input_text helper holding : entries pruned to today — a re-cleared day does not re-fire, but a second child clearing the same day still celebrates. --- .../automation/day_complete_celebration.yaml | 104 +++++++ tests/test_blueprint_celebration.py | 258 ++++++++++++++++++ 2 files changed, 362 insertions(+) create mode 100644 custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml create mode 100644 tests/test_blueprint_celebration.py 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..9165a84 --- /dev/null +++ b/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml @@ -0,0 +1,104 @@ +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.payload.child_name, + trigger.payload.quests_due and trigger.payload.quests_completed. + default: "Legendary! {{ trigger.payload.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: single + +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 entry for today in the helper. An unset or missing helper reads + # as '' and celebrates. + - condition: template + value_template: >- + {{ (trigger.payload.child_id ~ ':' ~ now().strftime('%Y-%m-%d')) + not in (states(last_celebrated_helper) or '') }} + +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.payload.child_id ~ ':' ~ today]) | join(',') }} \ No newline at end of file diff --git a/tests/test_blueprint_celebration.py b/tests/test_blueprint_celebration.py new file mode 100644 index 0000000..e86eca4 --- /dev/null +++ b/tests/test_blueprint_celebration.py @@ -0,0 +1,258 @@ +"""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 payload fixture.""" + return { + "payload": { + "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") \ No newline at end of file From f6166f25d95434c00bc9fbbc8ea3beb8e6cf1cc0 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:50:59 -0400 Subject: [PATCH 33/46] Compare celebration stamps as whole entries; queue events Child ids sharing a prefix no longer suppress each other (split and compare entries for equality), and queued mode keeps a second child's day-complete event from being dropped while the first notify runs. --- .../automation/day_complete_celebration.yaml | 13 +++++++++---- tests/test_blueprint_celebration.py | 18 +++++++++++++++++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml b/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml index 9165a84..3672adc 100644 --- a/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml +++ b/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml @@ -60,7 +60,7 @@ blueprint: filter: - domain: input_text -mode: single +mode: queued variables: celebrate: !input celebrate @@ -74,12 +74,17 @@ conditions: - condition: template value_template: "{{ celebrate | bool }}" # Once-per-child-per-day dedupe: celebrate only when THIS child has - # no entry for today in the helper. An unset or missing helper reads - # as '' and celebrates. + # 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.payload.child_id ~ ':' ~ now().strftime('%Y-%m-%d')) - not in (states(last_celebrated_helper) or '') }} + not in ((states(last_celebrated_helper) or '') + .split(',') + | select('ne', '') + | list) }} actions: - action: !input notify_target diff --git a/tests/test_blueprint_celebration.py b/tests/test_blueprint_celebration.py index e86eca4..9d61277 100644 --- a/tests/test_blueprint_celebration.py +++ b/tests/test_blueprint_celebration.py @@ -255,4 +255,20 @@ def test_celebration_never_hard_codes_notify_target() -> None: 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") \ No newline at end of file + 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" From 3a2e6faa6942b6f4fc925e3a3870d7c0a7694904 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:52:12 -0400 Subject: [PATCH 34/46] Document the automation package and test event validity README gains the blueprint section: all four blueprints, their inputs, the wiring table to the options-flow notification settings, the celebration helper setup, event/sensor payload reference, and a copy-pasteable example package. Tests assert every event trigger references a real NestQuest event and no reminder is produced for an absent child. --- README.md | 88 +++++++++++++++++++++++++++++ tests/test_blueprint_afternoon.py | 26 ++++++++- tests/test_blueprint_celebration.py | 22 ++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 441ad74..0ed4e2b 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,94 @@ actions: 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.payload.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`. + ## Developer Setup and Testing This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management. diff --git a/tests/test_blueprint_afternoon.py b/tests/test_blueprint_afternoon.py index 7c13c77..d721bbc 100644 --- a/tests/test_blueprint_afternoon.py +++ b/tests/test_blueprint_afternoon.py @@ -241,4 +241,28 @@ def test_afternoon_reminder_never_hard_codes_notify_target() -> None: 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") \ No newline at end of file + 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 index 9d61277..2e20b81 100644 --- a/tests/test_blueprint_celebration.py +++ b/tests/test_blueprint_celebration.py @@ -272,3 +272,25 @@ 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" + ) From ca931767680c71915db86fb89e8e5d87cf9ca3e9 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 14:54:03 -0400 Subject: [PATCH 35/46] Read the celebration payload from trigger.event.data HA event triggers expose the payload as trigger.event.data, not trigger.payload; fix the blueprint, its tests, and the README examples, with a regression guard. --- README.md | 4 +-- .../automation/day_complete_celebration.yaml | 10 +++--- tests/test_blueprint_celebration.py | 31 ++++++++++++++----- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 0ed4e2b..5f54fdd 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ triggers: event_type: nestquest_child_day_complete conditions: - condition: template - value_template: "{{ trigger.payload.child_name == 'Declan' }}" + value_template: "{{ trigger.event.data.child_name == 'Declan' }}" actions: - action: notify.mobile_app_declans_phone data: @@ -134,7 +134,7 @@ day-rollover time. # 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.payload.child_name }} cleared every quest today!" +# 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. diff --git a/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml b/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml index 3672adc..7d0e029 100644 --- a/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml +++ b/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml @@ -34,9 +34,9 @@ blueprint: name: Celebration message description: >- The message sent when a child clears every quest today. May - use the event payload: trigger.payload.child_name, - trigger.payload.quests_due and trigger.payload.quests_completed. - default: "Legendary! {{ trigger.payload.child_name }} cleared every quest today!" + 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: @@ -80,7 +80,7 @@ conditions: # reads as '' and celebrates. - condition: template value_template: >- - {{ (trigger.payload.child_id ~ ':' ~ now().strftime('%Y-%m-%d')) + {{ (trigger.event.data.child_id ~ ':' ~ now().strftime('%Y-%m-%d')) not in ((states(last_celebrated_helper) or '') .split(',') | select('ne', '') @@ -106,4 +106,4 @@ actions: {%- set ns.entries = ns.entries + [entry] -%} {%- endif -%} {%- endfor -%} - {{ (ns.entries + [trigger.payload.child_id ~ ':' ~ today]) | join(',') }} \ No newline at end of file + {{ (ns.entries + [trigger.event.data.child_id ~ ':' ~ today]) | join(',') }} \ No newline at end of file diff --git a/tests/test_blueprint_celebration.py b/tests/test_blueprint_celebration.py index 2e20b81..f877854 100644 --- a/tests/test_blueprint_celebration.py +++ b/tests/test_blueprint_celebration.py @@ -65,14 +65,17 @@ def stamp_value_template(bp: dict) -> str: def trigger_payload(child_id: int, child_name: str) -> dict: - """A nestquest_child_day_complete trigger payload fixture.""" + """A nestquest_child_day_complete trigger fixture: HA exposes an + event trigger's payload as trigger.event.data.""" return { - "payload": { - "child_id": child_id, - "child_name": child_name, - "quests_due": 3, - "quests_completed": 3, - "occurred_at": "2026-09-19T15:00:00+00:00", + "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", + } } } @@ -294,3 +297,17 @@ def test_every_blueprint_event_reference_is_real() -> None: 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"] From edca25affa6cab1dc08b3574433995cf6bd38aea Mon Sep 17 00:00:00 2001 From: Joshua Date: Sun, 20 Sep 2026 10:08:38 -0400 Subject: [PATCH 36/46] Add the Lit card toolchain and auto-register the Lovelace bundle --- .gitignore | 1 + README.md | 14 + custom_components/nestquest/__init__.py | 3 + custom_components/nestquest/frontend.py | 35 + custom_components/nestquest/manifest.json | 4 + .../nestquest/www/nestquest-cards.js | 572 ++++++++ frontend/package-lock.json | 1232 +++++++++++++++++ frontend/package.json | 15 + frontend/src/cards/admin.ts | 35 + frontend/src/cards/party-board.ts | 35 + frontend/src/cards/quest-log.ts | 35 + frontend/src/nestquest-cards.ts | 3 + frontend/src/types.ts | 20 + frontend/tsconfig.json | 13 + frontend/vite.config.ts | 19 + tests/conftest.py | 38 + tests/test_frontend.py | 93 ++ 17 files changed, 2167 insertions(+) create mode 100644 custom_components/nestquest/frontend.py create mode 100644 custom_components/nestquest/www/nestquest-cards.js create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/cards/admin.ts create mode 100644 frontend/src/cards/party-board.ts create mode 100644 frontend/src/cards/quest-log.ts create mode 100644 frontend/src/nestquest-cards.ts create mode 100644 frontend/src/types.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 tests/test_frontend.py 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 5f54fdd..7887abe 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,20 @@ 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 +``` + ### Running Tests Run the test suite with `pytest`: diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 143e9e0..53e395d 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -28,6 +28,7 @@ from .const import ( LOGGER, PLATFORMS, ) +from .frontend import async_register_frontend from .coordinator import NestQuestCoordinator from .db import NestQuestDatabase from .materialize import materialize as _materialize_run @@ -410,11 +411,13 @@ def _deregister_services(hass: HomeAssistant) -> None: 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: 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 ffef4b2..a85ae11 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/www/nestquest-cards.js b/custom_components/nestquest/www/nestquest-cards.js new file mode 100644 index 0000000..8f85055 --- /dev/null +++ b/custom_components/nestquest/www/nestquest-cards.js @@ -0,0 +1,572 @@ +const U = globalThis, k = U.ShadowRoot && (U.ShadyCSS === void 0 || U.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, Y = /* @__PURE__ */ Symbol(), I = /* @__PURE__ */ new WeakMap(); +let nt = class { + constructor(t, e, s) { + if (this._$cssResult$ = !0, s !== Y) 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 (k && t === void 0) { + const s = e !== void 0 && e.length === 1; + s && (t = I.get(e)), t === void 0 && ((this.o = t = new CSSStyleSheet()).replaceSync(this.cssText), s && I.set(e, t)); + } + return t; + } + toString() { + return this.cssText; + } +}; +const ot = (r) => new nt(typeof r == "string" ? r : r + "", void 0, Y), ht = (r, t) => { + if (k) r.adoptedStyleSheets = t.map((e) => e instanceof CSSStyleSheet ? e : e.styleSheet); + else for (const e of t) { + const s = document.createElement("style"), i = U.litNonce; + i !== void 0 && s.setAttribute("nonce", i), s.textContent = e.cssText, r.appendChild(s); + } +}, q = k ? (r) => r : (r) => r instanceof CSSStyleSheet ? ((t) => { + let e = ""; + for (const s of t.cssRules) e += s.cssText; + return ot(e); +})(r) : r; +const { is: at, defineProperty: lt, getOwnPropertyDescriptor: ct, getOwnPropertyNames: dt, getOwnPropertySymbols: pt, getPrototypeOf: ut } = Object, H = globalThis, Q = H.trustedTypes, $t = Q ? Q.emptyScript : "", _t = H.reactiveElementPolyfillSupport, b = (r, t) => r, R = { toAttribute(r, t) { + switch (t) { + case Boolean: + r = r ? $t : null; + break; + case Object: + case Array: + r = r == null ? r : JSON.stringify(r); + } + return r; +}, fromAttribute(r, t) { + let e = r; + switch (t) { + case Boolean: + e = r !== null; + break; + case Number: + e = r === null ? null : Number(r); + break; + case Object: + case Array: + try { + e = JSON.parse(r); + } catch { + e = null; + } + } + return e; +} }, tt = (r, t) => !at(r, t), W = { attribute: !0, type: String, converter: R, reflect: !1, useDefault: !1, hasChanged: tt }; +Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), H.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); +let y = 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 = W) { + 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 s = /* @__PURE__ */ Symbol(), i = this.getPropertyDescriptor(t, s, e); + i !== void 0 && lt(this.prototype, t, i); + } + } + static getPropertyDescriptor(t, e, s) { + const { get: i, set: n } = ct(this.prototype, t) ?? { get() { + return this[e]; + }, set(o) { + this[e] = o; + } }; + return { get: i, set(o) { + const l = i?.call(this); + n?.call(this, o), this.requestUpdate(t, l, s); + }, configurable: !0, enumerable: !0 }; + } + static getPropertyOptions(t) { + return this.elementProperties.get(t) ?? W; + } + static _$Ei() { + if (this.hasOwnProperty(b("elementProperties"))) return; + const t = ut(this); + t.finalize(), t.l !== void 0 && (this.l = [...t.l]), this.elementProperties = new Map(t.elementProperties); + } + static finalize() { + if (this.hasOwnProperty(b("finalized"))) return; + if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(b("properties"))) { + const e = this.properties, s = [...dt(e), ...pt(e)]; + for (const i of s) this.createProperty(i, e[i]); + } + const t = this[Symbol.metadata]; + if (t !== null) { + const e = litPropertyMetadata.get(t); + if (e !== void 0) for (const [s, i] of e) this.elementProperties.set(s, i); + } + this._$Eh = /* @__PURE__ */ new Map(); + for (const [e, s] of this.elementProperties) { + const i = this._$Eu(e, s); + i !== void 0 && this._$Eh.set(i, e); + } + this.elementStyles = this.finalizeStyles(this.styles); + } + static finalizeStyles(t) { + const e = []; + if (Array.isArray(t)) { + const s = new Set(t.flat(1 / 0).reverse()); + for (const i of s) e.unshift(q(i)); + } else t !== void 0 && e.push(q(t)); + return e; + } + static _$Eu(t, e) { + const s = e.attribute; + return s === !1 ? void 0 : typeof s == "string" ? s : 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 s of e.keys()) this.hasOwnProperty(s) && (t.set(s, this[s]), delete this[s]); + t.size > 0 && (this._$Ep = t); + } + createRenderRoot() { + const t = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions); + return ht(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, s) { + this._$AK(t, s); + } + _$ET(t, e) { + const s = this.constructor.elementProperties.get(t), i = this.constructor._$Eu(t, s); + if (i !== void 0 && s.reflect === !0) { + const n = (s.converter?.toAttribute !== void 0 ? s.converter : R).toAttribute(e, s.type); + this._$Em = t, n == null ? this.removeAttribute(i) : this.setAttribute(i, n), this._$Em = null; + } + } + _$AK(t, e) { + const s = this.constructor, i = s._$Eh.get(t); + if (i !== void 0 && this._$Em !== i) { + const n = s.getPropertyOptions(i), o = typeof n.converter == "function" ? { fromAttribute: n.converter } : n.converter?.fromAttribute !== void 0 ? n.converter : R; + this._$Em = i; + const l = o.fromAttribute(e, n.type); + this[i] = l ?? this._$Ej?.get(i) ?? l, this._$Em = null; + } + } + requestUpdate(t, e, s, i = !1, n) { + if (t !== void 0) { + const o = this.constructor; + if (i === !1 && (n = this[t]), s ??= o.getPropertyOptions(t), !((s.hasChanged ?? tt)(n, e) || s.useDefault && s.reflect && n === this._$Ej?.get(t) && !this.hasAttribute(o._$Eu(t, s)))) return; + this.C(t, e, s); + } + this.isUpdatePending === !1 && (this._$ES = this._$EP()); + } + C(t, e, { useDefault: s, reflect: i, wrapped: n }, o) { + s && !(this._$Ej ??= /* @__PURE__ */ new Map()).has(t) && (this._$Ej.set(t, o ?? e ?? this[t]), n !== !0 || o !== void 0) || (this._$AL.has(t) || (this.hasUpdated || s || (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, n] of this._$Ep) this[i] = n; + this._$Ep = void 0; + } + const s = this.constructor.elementProperties; + if (s.size > 0) for (const [i, n] of s) { + const { wrapped: o } = n, l = this[i]; + o !== !0 || this._$AL.has(i) || l === void 0 || this.C(i, void 0, n, l); + } + } + let t = !1; + const e = this._$AL; + try { + t = this.shouldUpdate(e), t ? (this.willUpdate(e), this._$EO?.forEach((s) => s.hostUpdate?.()), this.update(e)) : this._$EM(); + } catch (s) { + throw t = !1, this._$EM(), s; + } + 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) { + } +}; +y.elementStyles = [], y.shadowRootOptions = { mode: "open" }, y[b("elementProperties")] = /* @__PURE__ */ new Map(), y[b("finalized")] = /* @__PURE__ */ new Map(), _t?.({ ReactiveElement: y }), (H.reactiveElementVersions ??= []).push("2.1.2"); +const L = globalThis, V = (r) => r, O = L.trustedTypes, K = O ? O.createPolicy("lit-html", { createHTML: (r) => r }) : void 0, et = "$lit$", _ = `lit$${Math.random().toFixed(9).slice(2)}$`, st = "?" + _, ft = `<${st}>`, g = document, w = () => g.createComment(""), C = (r) => r === null || typeof r != "object" && typeof r != "function", z = Array.isArray, At = (r) => z(r) || typeof r?.[Symbol.iterator] == "function", T = `[ +\f\r]`, S = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, Z = /-->/g, J = />/g, f = RegExp(`>|${T}(?:([^\\s"'>=/]+)(${T}*=${T}*(?:[^ +\f\r"'\`<>=]|("|')|))|$)`, "g"), F = /'/g, G = /"/g, it = /^(?:script|style|textarea|title)$/i, mt = (r) => (t, ...e) => ({ _$litType$: r, strings: t, values: e }), j = mt(1), v = /* @__PURE__ */ Symbol.for("lit-noChange"), d = /* @__PURE__ */ Symbol.for("lit-nothing"), X = /* @__PURE__ */ new WeakMap(), A = g.createTreeWalker(g, 129); +function rt(r, t) { + if (!z(r) || !r.hasOwnProperty("raw")) throw Error("invalid template strings array"); + return K !== void 0 ? K.createHTML(t) : t; +} +const gt = (r, t) => { + const e = r.length - 1, s = []; + let i, n = t === 2 ? "" : t === 3 ? "" : "", o = S; + for (let l = 0; l < e; l++) { + const h = r[l]; + let c, p, a = -1, u = 0; + for (; u < h.length && (o.lastIndex = u, p = o.exec(h), p !== null); ) u = o.lastIndex, o === S ? p[1] === "!--" ? o = Z : p[1] !== void 0 ? o = J : p[2] !== void 0 ? (it.test(p[2]) && (i = RegExp("" ? (o = i ?? S, a = -1) : p[1] === void 0 ? a = -2 : (a = o.lastIndex - p[2].length, c = p[1], o = p[3] === void 0 ? f : p[3] === '"' ? G : F) : o === G || o === F ? o = f : o === Z || o === J ? o = S : (o = f, i = void 0); + const $ = o === f && r[l + 1].startsWith("/>") ? " " : ""; + n += o === S ? h + ft : a >= 0 ? (s.push(c), h.slice(0, a) + et + h.slice(a) + _ + $) : h + _ + (a === -2 ? l : $); + } + return [rt(r, n + (r[e] || "") + (t === 2 ? "" : t === 3 ? "" : "")), s]; +}; +class P { + constructor({ strings: t, _$litType$: e }, s) { + let i; + this.parts = []; + let n = 0, o = 0; + const l = t.length - 1, h = this.parts, [c, p] = gt(t, e); + if (this.el = P.createElement(c, s), A.currentNode = this.el.content, e === 2 || e === 3) { + const a = this.el.content.firstChild; + a.replaceWith(...a.childNodes); + } + for (; (i = A.nextNode()) !== null && h.length < l; ) { + if (i.nodeType === 1) { + if (i.hasAttributes()) for (const a of i.getAttributeNames()) if (a.endsWith(et)) { + const u = p[o++], $ = i.getAttribute(a).split(_), N = /([.?@])?(.*)/.exec(u); + h.push({ type: 1, index: n, name: N[2], strings: $, ctor: N[1] === "." ? vt : N[1] === "?" ? Et : N[1] === "@" ? St : M }), i.removeAttribute(a); + } else a.startsWith(_) && (h.push({ type: 6, index: n }), i.removeAttribute(a)); + if (it.test(i.tagName)) { + const a = i.textContent.split(_), u = a.length - 1; + if (u > 0) { + i.textContent = O ? O.emptyScript : ""; + for (let $ = 0; $ < u; $++) i.append(a[$], w()), A.nextNode(), h.push({ type: 2, index: ++n }); + i.append(a[u], w()); + } + } + } else if (i.nodeType === 8) if (i.data === st) h.push({ type: 2, index: n }); + else { + let a = -1; + for (; (a = i.data.indexOf(_, a + 1)) !== -1; ) h.push({ type: 7, index: n }), a += _.length - 1; + } + n++; + } + } + static createElement(t, e) { + const s = g.createElement("template"); + return s.innerHTML = t, s; + } +} +function E(r, t, e = r, s) { + if (t === v) return t; + let i = s !== void 0 ? e._$Co?.[s] : e._$Cl; + const n = C(t) ? void 0 : t._$litDirective$; + return i?.constructor !== n && (i?._$AO?.(!1), n === void 0 ? i = void 0 : (i = new n(r), i._$AT(r, e, s)), s !== void 0 ? (e._$Co ??= [])[s] = i : e._$Cl = i), i !== void 0 && (t = E(r, i._$AS(r, t.values), i, s)), t; +} +class yt { + 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: s } = this._$AD, i = (t?.creationScope ?? g).importNode(e, !0); + A.currentNode = i; + let n = A.nextNode(), o = 0, l = 0, h = s[0]; + for (; h !== void 0; ) { + if (o === h.index) { + let c; + h.type === 2 ? c = new x(n, n.nextSibling, this, t) : h.type === 1 ? c = new h.ctor(n, h.name, h.strings, this, t) : h.type === 6 && (c = new bt(n, this, t)), this._$AV.push(c), h = s[++l]; + } + o !== h?.index && (n = A.nextNode(), o++); + } + return A.currentNode = g, i; + } + p(t) { + let e = 0; + for (const s of this._$AV) s !== void 0 && (s.strings !== void 0 ? (s._$AI(t, s, e), e += s.strings.length - 2) : s._$AI(t[e])), e++; + } +} +class x { + get _$AU() { + return this._$AM?._$AU ?? this._$Cv; + } + constructor(t, e, s, i) { + this.type = 2, this._$AH = d, this._$AN = void 0, this._$AA = t, this._$AB = e, this._$AM = s, 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 = E(this, t, e), C(t) ? t === d || t == null || t === "" ? (this._$AH !== d && this._$AR(), this._$AH = d) : t !== this._$AH && t !== v && this._(t) : t._$litType$ !== void 0 ? this.$(t) : t.nodeType !== void 0 ? this.T(t) : At(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 !== d && C(this._$AH) ? this._$AA.nextSibling.data = t : this.T(g.createTextNode(t)), this._$AH = t; + } + $(t) { + const { values: e, _$litType$: s } = t, i = typeof s == "number" ? this._$AC(t) : (s.el === void 0 && (s.el = P.createElement(rt(s.h, s.h[0]), this.options)), s); + if (this._$AH?._$AD === i) this._$AH.p(e); + else { + const n = new yt(i, this), o = n.u(this.options); + n.p(e), this.T(o), this._$AH = n; + } + } + _$AC(t) { + let e = X.get(t.strings); + return e === void 0 && X.set(t.strings, e = new P(t)), e; + } + k(t) { + z(this._$AH) || (this._$AH = [], this._$AR()); + const e = this._$AH; + let s, i = 0; + for (const n of t) i === e.length ? e.push(s = new x(this.O(w()), this.O(w()), this, this.options)) : s = e[i], s._$AI(n), i++; + i < e.length && (this._$AR(s && s._$AB.nextSibling, i), e.length = i); + } + _$AR(t = this._$AA.nextSibling, e) { + for (this._$AP?.(!1, !0, e); t !== this._$AB; ) { + const s = V(t).nextSibling; + V(t).remove(), t = s; + } + } + setConnected(t) { + this._$AM === void 0 && (this._$Cv = t, this._$AP?.(t)); + } +} +class M { + get tagName() { + return this.element.tagName; + } + get _$AU() { + return this._$AM._$AU; + } + constructor(t, e, s, i, n) { + this.type = 1, this._$AH = d, this._$AN = void 0, this.element = t, this.name = e, this._$AM = i, this.options = n, s.length > 2 || s[0] !== "" || s[1] !== "" ? (this._$AH = Array(s.length - 1).fill(new String()), this.strings = s) : this._$AH = d; + } + _$AI(t, e = this, s, i) { + const n = this.strings; + let o = !1; + if (n === void 0) t = E(this, t, e, 0), o = !C(t) || t !== this._$AH && t !== v, o && (this._$AH = t); + else { + const l = t; + let h, c; + for (t = n[0], h = 0; h < n.length - 1; h++) c = E(this, l[s + h], e, h), c === v && (c = this._$AH[h]), o ||= !C(c) || c !== this._$AH[h], c === d ? t = d : t !== d && (t += (c ?? "") + n[h + 1]), this._$AH[h] = c; + } + o && !i && this.j(t); + } + j(t) { + t === d ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t ?? ""); + } +} +class vt extends M { + constructor() { + super(...arguments), this.type = 3; + } + j(t) { + this.element[this.name] = t === d ? void 0 : t; + } +} +class Et extends M { + constructor() { + super(...arguments), this.type = 4; + } + j(t) { + this.element.toggleAttribute(this.name, !!t && t !== d); + } +} +class St extends M { + constructor(t, e, s, i, n) { + super(t, e, s, i, n), this.type = 5; + } + _$AI(t, e = this) { + if ((t = E(this, t, e, 0) ?? d) === v) return; + const s = this._$AH, i = t === d && s !== d || t.capture !== s.capture || t.once !== s.once || t.passive !== s.passive, n = t !== d && (s === d || i); + i && this.element.removeEventListener(this.name, this, s), n && 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 bt { + constructor(t, e, s) { + this.element = t, this.type = 6, this._$AN = void 0, this._$AM = e, this.options = s; + } + get _$AU() { + return this._$AM._$AU; + } + _$AI(t) { + E(this, t); + } +} +const wt = L.litHtmlPolyfillSupport; +wt?.(P, x), (L.litHtmlVersions ??= []).push("3.3.3"); +const Ct = (r, t, e) => { + const s = e?.renderBefore ?? t; + let i = s._$litPart$; + if (i === void 0) { + const n = e?.renderBefore ?? null; + s._$litPart$ = i = new x(t.insertBefore(w(), n), n, void 0, e ?? {}); + } + return i._$AI(r), i; +}; +const D = globalThis; +class m extends y { + 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 = Ct(e, this.renderRoot, this.renderOptions); + } + connectedCallback() { + super.connectedCallback(), this._$Do?.setConnected(!0); + } + disconnectedCallback() { + super.disconnectedCallback(), this._$Do?.setConnected(!1); + } + render() { + return v; + } +} +m._$litElement$ = !0, m.finalized = !0, D.litElementHydrateSupport?.({ LitElement: m }); +const Pt = D.litElementPolyfillSupport; +Pt?.({ LitElement: m }); +(D.litElementVersions ??= []).push("4.2.2"); +function B(r) { + window.customCards = window.customCards ?? [], window.customCards.push(r); +} +class xt extends m { + static properties = { + hass: { attribute: !1 }, + _config: { state: !0 } + }; + hass; + _config; + setConfig(t) { + if (!t || typeof t != "object") + throw new Error("Invalid configuration"); + this._config = t; + } + getCardSize() { + return 6; + } + render() { + return j`
NestQuest Party Board
`; + } +} +customElements.define("nestquest-party-board-card", xt); +B({ + type: "nestquest-party-board-card", + name: "NestQuest Party Board", + description: "Kids' attract screen for present and away adventurers." +}); +class Nt extends m { + static properties = { + hass: { attribute: !1 }, + _config: { state: !0 } + }; + hass; + _config; + setConfig(t) { + if (!t || typeof t != "object") + throw new Error("Invalid configuration"); + this._config = t; + } + getCardSize() { + return 8; + } + render() { + return j`
NestQuest Quest Log
`; + } +} +customElements.define("nestquest-quest-log-card", Nt); +B({ + type: "nestquest-quest-log-card", + name: "NestQuest Quest Log", + description: "One child's daily quests, grouped by window." +}); +class Ut extends m { + static properties = { + hass: { attribute: !1 }, + _config: { state: !0 } + }; + hass; + _config; + setConfig(t) { + if (!t || typeof t != "object") + throw new Error("Invalid configuration"); + this._config = t; + } + getCardSize() { + return 8; + } + render() { + return j`
NestQuest Admin
`; + } +} +customElements.define("nestquest-admin-card", Ut); +B({ + type: "nestquest-admin-card", + name: "NestQuest Admin", + description: "Parent phone view for today, tasks, schedule, and history." +}); 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..b15993b --- /dev/null +++ b/frontend/src/cards/admin.ts @@ -0,0 +1,35 @@ +import { LitElement, html } from "lit"; + +import { registerCustomCard, type CardConfig, type HassLike } from "../types"; + +export class NestQuestAdminCard extends LitElement { + static properties = { + hass: { attribute: false }, + _config: { state: true }, + }; + + 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..d19bf8b --- /dev/null +++ b/frontend/src/cards/party-board.ts @@ -0,0 +1,35 @@ +import { LitElement, html } from "lit"; + +import { registerCustomCard, type CardConfig, type HassLike } from "../types"; + +export class NestQuestPartyBoardCard extends LitElement { + static properties = { + hass: { attribute: false }, + _config: { state: true }, + }; + + hass?: HassLike; + _config?: CardConfig; + + setConfig(config: CardConfig): void { + if (!config || typeof config !== "object") { + throw new Error("Invalid configuration"); + } + this._config = config; + } + + getCardSize(): number { + return 6; + } + + render() { + return html`
NestQuest Party Board
`; + } +} + +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..d56a788 --- /dev/null +++ b/frontend/src/cards/quest-log.ts @@ -0,0 +1,35 @@ +import { LitElement, html } from "lit"; + +import { registerCustomCard, type CardConfig, type HassLike } from "../types"; + +export class NestQuestQuestLogCard extends LitElement { + static properties = { + hass: { attribute: false }, + _config: { state: true }, + }; + + 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 Quest Log
`; + } +} + +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..3d08e0e --- /dev/null +++ b/frontend/src/nestquest-cards.ts @@ -0,0 +1,3 @@ +import "./cards/party-board"; +import "./cards/quest-log"; +import "./cards/admin"; 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/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/tests/conftest.py b/tests/conftest.py index e2dfd3d..6d6c9c4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,6 +51,8 @@ _HA_MODULES = ( "homeassistant.components", "homeassistant.components.sensor", "homeassistant.components.binary_sensor", + "homeassistant.components.http", + "homeassistant.components.frontend", ) def _find_spec(name: str): @@ -96,6 +98,8 @@ for _parent, _child in ( ("homeassistant", "components"), ("homeassistant.components", "sensor"), ("homeassistant.components", "binary_sensor"), + ("homeassistant.components", "http"), + ("homeassistant.components", "frontend"), ): setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"]) @@ -429,6 +433,28 @@ _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 @@ -549,6 +575,16 @@ 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. @@ -697,6 +733,8 @@ def make_hass() -> tuple: 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" diff --git a/tests/test_frontend.py b/tests/test_frontend.py new file mode 100644 index 0000000..74e51a0 --- /dev/null +++ b/tests/test_frontend.py @@ -0,0 +1,93 @@ +"""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 From 89f517b3ad3d7e67b2a8143a52b4e4c083c2cff6 Mon Sep 17 00:00:00 2001 From: grok <24+grok@code.cubecraftcreations.com> Date: Sun, 20 Sep 2026 11:16:17 -0400 Subject: [PATCH 37/46] Add bundled fonts, token CSS, and local font-face loading Add bundled fonts, token CSS, and local font-face loading Co-authored-by: grok <24+grok@code.cubecraftcreations.com> Co-committed-by: grok <24+grok@code.cubecraftcreations.com> --- .../cinzel-decorative-latin-700-normal.woff2 | Bin 0 -> 15488 bytes .../cinzel-decorative-latin-900-normal.woff2 | Bin 0 -> 14568 bytes .../www/fonts/cinzel-latin-600-normal.woff2 | Bin 0 -> 15204 bytes .../www/fonts/cinzel-latin-700-normal.woff2 | Bin 0 -> 15184 bytes .../www/fonts/cinzel-latin-900-normal.woff2 | Bin 0 -> 14804 bytes .../www/fonts/nunito-latin-400-normal.woff2 | Bin 0 -> 16316 bytes .../www/fonts/nunito-latin-600-normal.woff2 | Bin 0 -> 16556 bytes .../www/fonts/nunito-latin-700-normal.woff2 | Bin 0 -> 16228 bytes .../www/fonts/nunito-latin-800-normal.woff2 | Bin 0 -> 16520 bytes .../nestquest/www/nestquest-admin-tokens.css | 76 ++++ .../nestquest/www/nestquest-cards.js | 376 +++++++++--------- .../nestquest/www/nestquest-fonts.css | 71 ++++ .../nestquest/www/nestquest-panel-tokens.css | 94 +++++ frontend/src/cards/admin.ts | 5 +- frontend/src/cards/party-board.ts | 5 +- frontend/src/cards/quest-log.ts | 5 +- frontend/src/nestquest-cards.ts | 9 + frontend/src/vite-env.d.ts | 1 + tests/test_frontend.py | 64 +++ 19 files changed, 520 insertions(+), 186 deletions(-) create mode 100644 custom_components/nestquest/www/fonts/cinzel-decorative-latin-700-normal.woff2 create mode 100644 custom_components/nestquest/www/fonts/cinzel-decorative-latin-900-normal.woff2 create mode 100644 custom_components/nestquest/www/fonts/cinzel-latin-600-normal.woff2 create mode 100644 custom_components/nestquest/www/fonts/cinzel-latin-700-normal.woff2 create mode 100644 custom_components/nestquest/www/fonts/cinzel-latin-900-normal.woff2 create mode 100644 custom_components/nestquest/www/fonts/nunito-latin-400-normal.woff2 create mode 100644 custom_components/nestquest/www/fonts/nunito-latin-600-normal.woff2 create mode 100644 custom_components/nestquest/www/fonts/nunito-latin-700-normal.woff2 create mode 100644 custom_components/nestquest/www/fonts/nunito-latin-800-normal.woff2 create mode 100644 custom_components/nestquest/www/nestquest-admin-tokens.css create mode 100644 custom_components/nestquest/www/nestquest-fonts.css create mode 100644 custom_components/nestquest/www/nestquest-panel-tokens.css create mode 100644 frontend/src/vite-env.d.ts 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 0000000000000000000000000000000000000000..0e184e30c458aa9b5f5258938722281be3e7f7cb GIT binary patch literal 15488 zcmV-`Jb%M?Pew8T0RR9106c&I4*&oF0FE3006ZoD0RR9100000000000000000000 z0000Q78}8O94-c60D(La2nvD0Oo5?03xh5I0X7081BO@xAO(d02ZLS=2OCp+CG6OX z0?q?O#8AI8in7U?E{YOL8ZrC-Z%Gdkg*5@~Z}cR&X;i5TWCMb#*}}Eu=45TF?Fy32 z+g8Nq-!SQtE&~IP_d2=iSz02>cHyDuUnu!GVZ4CJtme!G&w$ zoT+JNnz_ocDy__>?#e9NX4|yVE^jpE4V%`!ZY317w!Ihigj6U!4pFuD(cann4`4x+ z00{}eOt9ogpsAr5HexAa8~V?mI^UOAk)FV$kQ|WB3eX3jW$W#|y~Ey-#-Tf41N
    Y0}e$g>j|L#gGI%$;2Xe7@@u>*D#w3i(C7dt>pk|lq|aU59o z=CTJ!R+1%34w7Te9uQVUMRmWsz65TuXp7_F<8a!mecpLwT}osA_B_uD!z{2uh&%uc zgi~j+CBYl0f}JqON_nP-bhDAg~LI@H>eDUX7F|($TvpPoyzzR_D z-{~3H03mR>Le;)jgaPtr#c4T!{B?3^J|IH_ zpku5y|FJk9-~it14;mx@jr|K4WYjBw2E@Fgw1F3E!19CwA>gKCTIsIdG#-pss+n2S z9Ww2csV`^%3EjCOsko)Y4k83Mo^w+o<_+`q^bsHGUbgs`pl-p%fJcQ3foJ-a_S3vV z5`X)UApuV&!PbM}Xsw4O;N~Z$!01zH$LPV}A1Kj#p9;M@K=%r`9SH+Xsx>YMtbvk5`3;T1Q*5mZKN|6Mj2o^JEKTg>Cl%h`sEJO{`+QYWTleVdaTk}By(J?T_=J! z*3ybKp_SAr#{gHi&V|)&&;NfxZI!XRh_Si18-7avGOVAKlTMaPuB+61GotK{pvN+Z zNrhodoIqUSbOxTfTAlL0sviab?pIk_-D1>7tOSv)7|CAR%>^vfXPY99YQeGIVoE19u=^%YxrW;$OXoS;yE-p+c-%2^e=! zcVFOM4_)O_9{z{K_W&XslinLtyHQ?$=B+)GKWsZ!yc>`lXj~{sHj+=LymUPj*MiIirdTaf<22 z83ATK#1{i8=1&~^66(~JTXbXeZE+a8LtrsG9{I7TR^_n*6+BPn&yAUg4s!~nlhV#C zhihHhT$FrE`LTk_D!DM^Ye{H!B!FF45di$JBWUBVz3WH%} zZFDvlfcZ`CcIMyde=COE(FV#pOm@;YN2cLgEp@t{>~5@zV^@XN57jNEbY8cjLM0pM zl=wkn$WU)BgdARLn3x*DPG9lRryseb5eYPnmCT|PQ1IO7>exo*OrDde7K;@3^$DlN zXyq4l{nfl}wxA<~Jyq%8{E2y^z37+iFBtNeFR6)=45uxnuoVno>$+xAWX;gu9`FQq z3BFXj3}~g$i{#*nH)iAN+MJ4*U@F2{W5*%?pR>Llh=m|YiyJy8fAvhbDZ@dpB~i2X z`H8^A%9bb=CUQ&ibYQV5NG}5tT2=~wuGF5kR$!LUJ)Pn)M<5phYg1&?VdlIKL29_Y z4Fx6u8wpl=bkbfFnGK)?8wwT~uJH#+5*=|C!n?|?3-78wLw7^A{#>48A94&B%@{s2O<>p9?Hr6!}T4}jl;N`Wkajt)>nXbHsHx-6=4MSB#F=e=Cs z^5yeUrHZc_U%lz|%{$k8_t8(kee&ObU$u1n?mC=3(?f)afLbd9Jrk+KkTKO=*^tRt z3XC|y7Z-dBLS{aG`sR^Rs?<8T8A!dr2{~ev`FV3p3%Au{g*m;En?PtK=LBr=!mdBu zQ9wY*&d`UjRkCJWM?dZD1=HcfWaD0|I)7hh36 zdf~|FM_y0{@VRZWoePH%Dh@_TgrJ!78}9d_tXo3>O$4vP)aw^Fva|b>-X; zm_L=<1WsM5wyUTSO$iCIW|$WWRO?K$5*H%)9XfLHn!V0k$2D`w<#9P*rkC-B)>yp_ z-?rbHZq?_-d3C=1K_?lFl;uCd*hk0PsXSceWH-D4Xe?6Apt4nXX$ai;}nq(&_ zZn0he&_kXD<{x=DmuCDK%;4<4(0IJY_UoPAhq#}Wy!lxk`RS@${}d&VGvj=s-nN&B zi8|XY0um$GquvMF?IT)ri-O8q4QS{vmFeRe^?y+b;&9=u+0wGlg)RY4G+w+IlaXWa z5y^y-5-py+6scU)XduvJE0GQzWV&o8*Q=Mme*FRr7^E<4T9{K#NpaqJsV=xA&AfS8 zZdsHK0i{nMp#miIF9@gzXh6gW3jr@S2K#0i2L?0*h@jEI#Dc*Kj%6@d(C8yVrqhpD z^m#Ec*cZ#pU?4)L)7w~dcriBEGt0~%Alk1qAld~wI9UClpv#tf1rnX!GuUPjD9{k? zDOUgD4aOf#=zS*)hJt>x+|Q7p05tT!T&|WFy!y@a{*&aWxVUfNz6Ao#y@uJ#-V$i- z$TfKi6e&@sAsRdUv}o0?Lyx{VtfHB=*s4XVHtqHpHe%G6aT5+ZV#aJA)wiy7`ov(z zy`}xb^7R`HDO%hcVNbm4{mH*z`8bAj?7Pu>+wZhxM9>%9>5wrOj4rZIME8YQRB?Ty z22(AjO_=I1ZN^lOsR7eo=JqkSpSc6f9c1nhbA!yCujWEE2#EGULq!7(rmM;^wZ2)btj|cA=3-lV86;g|4~_U)B|;?`9`AL}Ufz?@ZQdk1sP zuv($6rF=ivj1@i`b{sg7^Wn=+0EIw7RKhiBw#8O0TD57n$FLEj#*CYA*by^kuaOY# z9+pNrs?q?x{{@SMNAG^8-DuDWbOAbrjv0U!h( z-giA*ULOGRoexm^ce*|2XaMv$Q2;IOS(0(~!}~<|a{gb_z7LUsfQk7?2eou>0o>=5 zJ_MWqpwBr6kOH|VH%35#KsTDfs&wg-3&lZhi94&jSbOe(oUFM3RFS!2+V{$oW^$_d$0HdTSTR5A>$ z(iDkQ<9a*t%6T-AV#yITBZQ^mVVBfuYt(vmbnGTcGCw$!IMB^b(KsQnE-qY1xvp1wBiW^-JyKJu0(BFTH)XW-ETg8^2?-gb%v9ag>~~Ia_3E zvgk+5)rztiXtT_3tGo5CaPG0pD?q}Jg$(fMyWOxg+fw0c+1&8-3d~TA8YU$}&+p5G z0PVL3#uJf=!rN zK28J5TwqZ8ftXosyt<;RUO@zdV;(Z{AkM>tl9>;4!(4?aAhm%0*n)8>g~em6A=lPm zg3P(SM3aCZWUX=Es!9d#3aV9V;1Z9#YYcgjIFcGZ3=s!tArryXg?EFN^PV%OLs|H z1(3?|vfvttE&mQhZVg$!&8P(=1Q0+FK@2g_#$axnS!`_*sL3ImfyK6krNxrf-CW@f zXMg{;JpbCL?(t`~SpbqRb1oQ#rZ0xtX#Uq#?b&+D@anG=QyeX z4=jcuT!49x2NNBV06#+Kt+ZYs4!Ss3N-Y~Pw@xtP47Bo9m^6_GAy(lWIn=}Nd08Yg zwym01Scj;`#c)4=m7;Sbvj+ z2yPR)l{o2$L};=L4?w)`;R#q950i0aVDb|f z0(4xRgbRkGCh9tKW4DhkYin#{KFiWz-eGECCs`H(SX#_-f6ti%#Cl7BTp_hf?gEZRX>a} z;>MowYNoU(IUZ&e5paS`26TbyqD}V!d(h5~SO(lCo!SK3Xz9-c;->QtIT-AAGdUxr z*2a%H1nFe=typ@@H9iY51osz5I*LBWA5TBw_hjX)Rv*Hrq$U?8-xX{Ny}_R=MY)_X zQKA%1CoP2i+w?VF?lA3=X9`{cukFxq&rOZ`M(?vS=M$`Urf^>(GVUTrv@h1bFeZf9&K`>H(Lsc8Jz& zFyy;FwFr~zAM4h^h_{tSc9kAnc>CZK0F7b4_h> zK)b_1OCPqXZu=m=*07sEi}f7zvK7!K>F((`)3f74x1^LCq!!2lCx&E)@{PK4YS1u2 zAFZuhFCE{YO|H1*@i7`_;uMV0Fa=XRkIxNR)#lbB*&!266S1;~Ky5pB06_$(5N}yV zp@d?zMtCQi`08J#(j3?e?yi5B8*~qro)vEN+tlb$%UI9Bl0-ao31FfO@h5s}@3%rQ>pQK#DXF+;nbz5dWYU8c*o~tRbBs0Bs;_xAZHAzkeQdx(0xxh^m$(=hF^mkY z%}AF5_2%{&f{h$DWD)#F>4Zqwp#x#hN^_ADL(Z}=m*=siTzv^Ej^g|)f^;H|Lb)q2NYri4Rqy#? zr02V|jT;U@1e*9pEwEtfuoJl_<#*?yN(z(COovp*VL?qx?kaXa(&Uk@HDFJqwX@$^ zaCxm0P`~Lg-n}e{g%MG`6g`l|DB&_SK)d8SA>2A#l;j>UjcxIHAPY*H4uQ*hdEEuT zijg7*zp!D(*EhLC z^o_wVw_g^wfeoon$JYmJLDMIuQMl=2{BzxOy<6GET|J9C;A5&y)7T@Xy_?xPYZ63k z&yh}an;%uhx>;Fosq_6iTD2%n{2hW8Kg4?^_A%4ioaA6V@8 zpd!-Q=^5bSW8Cx6e!X#gkH2hmzRQkJQrmhSQZK$h-TY9TNv5cwAe z#K~D2J-o|%k1Mqb6M^1WvCfBf1hdLOi?IoRMx4t^f=n1hzVrj&SGWd=?Y%c!XV}T( zRdsV_-O1_`^ZZ0zEP+KH@&F`=H$dlH>X{s z3uB^?{ffCLeqz7(k(qq*{rhdb4PN*&6KND50kyUtYZ-z5mKj6}Kr-zKsa^ZHb1ABc zB$d#~V_b3J`aT`&fvC!_)UK%Lxy=g*7Q>&WL~p0xb4dlWOv&fXIW|KLS{5C00K^u= zoGDbe=OfgBQuMjt>VI(Mye|lC;P-r%&agRCv0mE82h0#o!Q{qZUQ7{?n?Z?Efew{j zb_Zqx<|Z82*AwoenX+rMv?jd~wck6n_8H8-g+>vj2@0lXdC;khM=L}tJ?=1Dd8c<<5uPJP#W8m6C> zId|b8F0h0X48UcghU)ifCzz%lJ1vnR-E~W9f;jc(XZ?*0XE&Z!n|iX>m~&#>1@{)h z0CNu}2+%$qlJ+sDf`3(R?N4cSoGh5n-tk-!yYOB*_uy)>Rn_obl0OL8xnYf_F7ZZr z+}~%pH(i>{unG7E`cgI-H}2|K>XN|bUUw$wL?-8H_ZZYRp-9=$gviq_9$g|e;Jz-; z!&tNH#5q<=90e){5y{(^3AmJ%Cep?hZDScp0qO-#oSF<;~0i8n6fuhU%aIhw56H|RzTWvOBCAR zjilmSQ{z>{VMa#f0JRXAM=lg3aaCD>!FJ7=fT`G2wWe^DEzG)<_#;%T_2%007%uS< zW?xF`sheA0YMvf1(Z5BInx~WR$%wXDCtD4Dn`Xr9JeKX+uvvPePoN*)xXbESt%c_L zwDU`@F6jFxw?=^|Rz<#x?qYbayY__D&|FiJ+6JL@^8_=Iz0jD%!m$UAOTwJ!4{XBb zo`R<6HSH_3CbGC(!TJ%_b3B-zn0>+Xzy9(>%AQXWimVt(eDPJRGZqE_i%~LW_CQ~w zXijFWj$x>(R-3o`ewt6xy!BBXNw(TvZnYig54^S-3sV;sPERq)(vSa=V~jS8(I+i- z1t4jTV=JS;vmzGAM0S87j>G`-0hksCrj6P&=Q8xg1FJ6Qnl`PMD~L5UQ7*tv#?+ti zQj<7U#hh2{Hi^Er8F=BEzW`sL10t0pJ$_86_TMeZCu~e*lwk4oujLi6BVd1sTx@^+ z5wA`Yil;ncH~pXVx{_`7Po^+54ihaV|2yk92Y4SP&<4bt(hk}h*;v=GH`nSmY?o5E zN~3*hA-hj=7GV@AUyNYIOjc`X!vyaDYld7a!y2xzuhxa%wy>Bo4Dt&@E29MGD2>kI z3fabczgaLSWtb|b-Sbl>?49Mip?FL!w&%OzUltQqUfP#!rq!aBUPl%vH7$PN@ZCNG zo^UG3>QhKc-Z78+SH?`&m4QQX?s(LYF{k~CzAhyzCKuZc)%3~0NpjkLzQ4$%4g;)Y~ z!ekkDf##D{4@rRKq)ENYz?%OyP2vea8_4-!R>>acv&MBKJ8iCe^m**`81(>V-weWn zgxN$ibu(4O!MCE{pNOLN>|5Ue=*^ZgPG;H`n*VBV_UpDQUykkP)X7#r?mc%()&iPEcuKl80;%V&>WCbD`SB)#gZo(Zl#m@N> zck4j&lN)aHGRL<`h^5zp;6s?Ofd-O8gdn+42RpiZvH{H?7xM5q*~{E zb$tlOm={1yH3+vfv{Gng0RQ0ZX#3p#t$Ejc1<}0Rpju9-opY#DsK|-8Xzg9C?-ke* z{ci1R-UcC4m2(N6qU@0L!M3XO^2&YX0jUl$l}mV(eNmHHjmN>Vs+TJwyF`yY{)Qp? z#h!O!vit;3RpJMGHd=>Be`*2@_I|Q})sw2{GMlJ)L#epF?#^<3fz%~YE7Fe@d>x#6 z5pvy^71>7Fd2iR9hB~P@&M->C=j8C#fg5qTYgcEuf&CMlpvB{!q7A=BY1&K&TP&O+ zM%{Wuh&r_p%$c@{(gLSL1QX+e@l=|q-%wzgw#1_y6Ut~#!Uwu8>H!I^5HQ$rJ7{5A6`xS2-TJIpfwTihDg7~s>I)EQYF1*FokYGr3bU}yE1ObXU{-7~&$ zO^XciVLis*8#M1RE=+6b!Pc-IMGNQ_m3AbkL@&9y*ChE~szu)LVc7lGr%-cD%q>Nv z+C{ZdCUWujj|qbL?aFEvuryS9x2LU-P*`!~S502O@&Xa=#~k8oIKiHNQYuYDBcR?T zw7@4Ng!@87Lm_4ASh1&6z~8VNi-W

    3aV9VEh#0gR{oud&?&o_h`L<ZzGWL;ZqOQ*->+l+sp;X)tO6ZOpZ+>~ZQ$|2e>KOQbD!gKqJz=&!f*{ z#GZRhylP8FMcwUo+*?iL;YdysgyLRtpRn_s6R=1VbO|~S=PiH*q4TWslwX$m>{}9C zzmZcK8*0iBN6IDW#rzv~>y#|H^{409|I3^tsu~tZgH5tCQ*F9OM*Y`Ohm>o)6GRt> z#d#X|dG+oJDf4Q6o>QlYtF0})mE@5v)9LjTqrZk4-eq`Q61KO z+;dZYN=dQ=j#@T+KV^xAW7ueQQpPqb)nZhhtGt}7+kqJ?@iRle|3#Y!e=q2cLP+8> zlY{L|WlKrk5Hliji-%Z*C|m;ewT;%X>`l)S(}!=qo|CWMxDwE4bR98<)2j4QeD0ZG z?3`*uc2At$hsA9^ z1nIx@FggLUg^B(A`Vt!EZFCrcVUM|h*)cY=IuTxh|I)m(1@1?$(Mpmu8%vEM;?47p z|FN()C-czxn9AU7#y=y*aMYNlxTLb4<3^p6CBhh||9UAq^hq!c)}rk|4*+q6Z+yuZ7T?MVVBPu++|B5l9(EN-Q>_M-jWU0sG+ZJzwPb5sevWe4$3#C{(WWErThI#i;g=8XAOc-Qw_?dLBYb+? z*R0}w8MmqrBYh)SPco|}!_XfPS~_rPMJptxd>|(o4V;p;W`4^EFWX4iZ^4SC%JpM8 zGojo^^C8R#m`InxTzFG|03g_$->M@s>HS<@2@?Nr3|o_!&X<}?u9um7gfarCjc!41 z!5LT#obQwy6&4-8`=zCP8;#61~k|`j8jYFOvIUgMoWeaYwpM zy`WN1*Vu9IYah*?rVHD^I&^!w9o8_9w!^}@S5V+q-Lh@w{$6MW>daMLnmtai7T)6c zZYTf*%;?GKy`gS?N#VAIURH&zK11&p>OL+EZfKb~|9Z}N(Vg9OAo5-7kxwLWpJe@XSATZY)Z`eB$e%puQkLNxE};`(^Y z$TVubwL^VXI*6;8PnYi<9TDjyS*{iQ1|t!O@@W67i%j3RT2UfNSnFH3Yjjr**VjHg zMK5=3<6fZpq9J6;D#<&CDip>NQPX~>KwA9e4~u&O-RcP`@%6fq1d2z_1O8TRKU6Nm zSVfTm>C&juJSy<7X8WY>y2b@}a~X_4q0tzm!=Q+r>=UGLRrt`umK#A+K~|8g`|OUa z^>+JVI5*Q9lT@G9e&s(0u~4S)ED7VL0T6GnQR)|PG0H5vTg8cI^OdV3MQTqq3~4Cx zli~%zkdV*?p_D>VARp^W@jkPD^M@EAhZ*G`u+FBvH=Acz5J?Yn^9QsaHJ!PKt>S(; zDkjt&h6&b@ON{}#&wkO~yMJ78ip!R(Z$;+i!1cFA;u5?L=a6n9$w))|(BEP<(E%#z z^3NY7v2tup5Lv6^es#a;RlP7+}t_yf{}!(pP{iJfln^-FvY$MFVA&8LtnPk?o<8>!o5|Dg( zFE2Dq^sMp=u`G5UO^orCMWN9!DLXx=)4AX84oz-g^nc-g#OJtI$hL;N>6ieoj)4T1 za4lyUD#x(IBFiR^|2%^$2UsY-`QuVLp8y~J!ynH8UfezjRDbGc%MP=r6-HyJx|2$@ zl~|0um=4OLdEem-vzyswFuQhbyj96ME2~~*20!XgmVa; zzM1|mTN2v5ZE~wJZA8J13_tuIM___=%`kg2%(t~KcPp%?)t$Mm7cjJAqioI03Os9~ z`hU$ntZ3VefZcaBrpq%u3LNa@TKd~5htyNb!m-;|f^J`)bPh)b(UN|Mh+JPgZe2E# z7o^xe(?=Pk9lk(7g6I#^p*7ieMLMO)h??g!=>( zk4HeX6;TY@%(#=)TOEg}W_G zf_adZb|u3+`>a$6U~t4dgHfri1=l}j!&;Bf8p!E?%m1OU8bvK|&^bo4$DyUIeCXTL z)?f8l$yVbr+X~Vx!O0t^?_CZ~LGfF7xIlgIL?nLVVa@n9wV*Rc>6c$K&IL)kO_Z@l z!&IMA*dOuZR-e28#(E>E-~M1DS!HAQty=|5} zZi}rP)Aue9*Ka+sf$?So_f@Ptax=GPe8i5xv)u}b@Y%~|qAC9e z0Gx9xSz<}H@K0n0Dcr-mtYkUkwgXA9I=qPQYUonhG}Zt!tYuE;90{RfUt1X1#rZym zusFFoJwod5W#=AbHBs!z3*#k}rNz4hL($*s>ZlNk0&OTpwsOu)RkTA|PDf#dte82b z2EAPYkW@{kLzC(&8kZui3Gm^ceDREK@JNlQuMDtLAJf)*MQ*6-Yj4;y)bhh-3TXN% z_tEH*Q8k@a3R1Ri3Nyhw;M1hGZtx9^l~XygTS4`wK=98%xak-~vmP6w!{iIU_q@L( z3k|O}!=gL(_*6J0k*m+uhwg$g2oirlTkm!KN<4I$NQD1_HC>0UP)-@)LW~c)ZkVmS z5UrPWMQ;^+ZE0+)Kl0z4Gs#Av>IaV|Cl3qx>x$ruaYRX>zh(q0f0}LpOr%W3==rsw zx>3=3S;r{^con$a#=Aq>x!FNQ+kX@#gzNgk>^Tk|elh@35GflJw|_BU{dw-Vh=WQL znW-U;*h31ur}>}DFsG4fWQDJppLQC>b_#Eb+5L!R@TI*tQ@i~9zy3Pj1;{J}LChTv zyr)l{_ErhW;n8{~(Ig>AnJN?%0?H$T!~R*ASp6}eE}xsfRcO4^nD zayDV9R!ZL+*o}V{(Fg?r~YpCX7?zNb=_m9M@sVt5g*MSwPB5FOLl_E z$ez|OqpdqIsv1mK7`CMu%c#iGG`kf6!osxDF>vj!Oh+XqycnahFufUxh$>t3DFfZ& z4BN}UIeP|Zhl#eqyfJ^149jgSt~e|Mh7Fts5XJHDlthB{SJJGz$|%QTJ&ZzcO5x`p z`XfS{dqV2~qNdz^59pgcv3F`zJ`%7m_N?seu{~2a!ydX1a2LXLO?PBx_onKO96_S} zMmg562R8qh!>y0Qk8#IlVem^mJHeeG1T=9AYVLA7xVm{nvyM!OPY@)yYUjs12yu$; zI$-a#o~ga`Bm0kdqae;dLCiaLEfy`>F~$;>(yzcXK%ds)8@=x~>*BJh#vbbnZb4+T zjk#wE@(2aGbX97|>Cj^o`RGAOp{+?Vsv7<@WD&BiMOG*w?{uFm4cQ*;pp7~fD~m4Et3Kzd_r5UBb= zLt{b%jenx>%yW-l!lN~cG|=>aie5J2HMUt1UZ+M_CV&BU%9V!b(uxSNbxP!7Y0T{V zb%5O^!HzouxMa~Ba4tbCzWp8J*Q}H&WJ*UxlhpXf+SM*3iGIw-We| zGMjZJGJ-NZKV`5mRdpCaL@%i?4XRE)O>3*}0Lmt>-rKi(y@qFIav)P`QWDkFA}I;b zZT`8gOlf{AXo5_a&2)a^I{SUM1t;e*MMM^nfVqMpR(@j(GkJyS7(mTQj5(MTvn$Ex ze2P+fZgesRgEH| z{jhJ1`d%#N90sd<#2F`_!7$A>K`HH#+Lm~8mtu1)VoJkc7M6szR&|ZeY%|} zGn#?E{8R&hBuivdI8Ut6uT;@kv=7OmkH#>NV1A0A)%yJ=H_}`oam*6)_7_nC5vZ$wcO% zHTf=_&h<-`i0zoL@0T*hh#Etwf0RUe>-$e#L-Z?h?l42YyfK7r6^|8k*MQk8rBiWVCG zk`aLaBhTV+FThyNIRG-d^vPQWLO!uOsC!eA6s+eB^Da5SFnIxm?&`65G5(eFs-1l- z$k^&TkcwVj?E~v~XNWo5zz&j9(gT1N8%llu{cVEJW8W;&L?~1lsuTLz3LxpOJx!#^ zyHTxmTspk+rPq{RLQ)(vVQnv-BPW)E$%10uvFrn*lw763ZoFIUv7v5?dPj6@Q4wUn zwpB)T+&V@T0+>3i$8%wZaKzCljz2}qDIs97<~Di_%tuE(@E`~El6RACNc5BI*q$a; z#wmD-eA<6Yjmh6uj;b-NLpjVe;%BklZ?MJpS-&aUTNxI-{^%>~@@_(JUq-LERg|G6 zUBVr-PM4kT)RFlM%VKI1o{l%R8Fn;|0*NmoG~z(OMD@&Hxa=8ei5SJL&4F>>S|G!}=E0~?k*H7rr?4^2d&VOE|3QuZegY0)1!JyL$oOHB0Ng)iM#y<0e?^Dhxs0BC<(cowOWE-o@YYdlLV6Ajo(bvWk1) zxJdrZ>cTq!1q5%^z1zN4>qQ==AOH+MfpIo1HD1wLrnx$3IEW`E){Ve0elB+QAg9Lm zooQIHhFv*5X_IcfluUgUa!Ytv{{ZZC`(4-8Z-U~~{MNM>a=4}KT|5`w93srl_>>zT z;Reu45&hO$Qb}%6bi=sPXAkwKHWvj*ODTa0lh5%EyyyM6B{D?!vMh{L`mS@VsjqL_ zkob2YjKpY}9PP-i_~b&Xf0boQV8+ zn|(%{Ym;ht;E_y^HlD;BwTVE-8+#2qxZ*ql78n4FP1-lOC|iux#m}2`II8^tk%ML< z=A&k#=3j;cU4rR}537LVWWumF-sv{An?Ncos8mmN{qg;iU~8cKS;F_}e!D~Hzg#oE zEsw3(DvEtlGp=JD0*q4RYJ{x5a^_=3>_4GNY^Nk`_}8R9H&jm;786H?7~EC8=Z%*> zB*vfF#PRH@p}Cu7&k7#M&^GRCJqK&syx8UFh*h}YS@^#Oa`tFN1r}s-Z#d1wwmMXy zj33u}w$mxoDhxVGS3v+-9S$LSZ{K8 za<0PHQQzAXkUoXrd;21Ly`@=bL}wSX&pW^J%6iZE~qdPEkK zawT%82yhFKw!1(R7uuo*&>Sp~7s3_%&9nN>qV_6Y+m_nAH}FcnbK;Zp#uZ(KM>yPf z>sWS%IC+ziTgp2#rd+e=0JB%LHx;j1aI`rVtHs4So7CT_ z7P^*t-__ba-;Y2?zW;;!=lcLO;5O*{pXyFodW_tKkMFqUhM$I?lS~78*}Gw`4}W4n zlXi{qz)s)Pjik`hSBdtlyqBr^8|Mbv%B75|8LK)NV!mC=PIibcsnaY=qWoXO<1Vyq2`CM8x&|@E zHfWzF$|n~Wg(*d$QC=vtwiopzDz;J8h%}tGC23^r0E-7ZmX%t8+7rM3aqYH0{$OwR ze}RgAwVWAd^GUojj|^yZ&f&H%L=>4(-{*W5aH>9oG0d3H9PBL4kvnH*E!^s&hV0#m zM8uDrdA~ZP5zao&{w%TT$J8gCB_M-|#sjo5CAduuKu*Hg?kK8Ew5HFyY+%>-Bk&Xn z(;*-unfF7SvVh;x$dZ~{4F;%WhOlrX%$aC9n68)sKa91nGraTWVYhBiWx7J0_gcPL zTswzy9{|9@(ZH6&H>%r-&jOqUt!l%2w`;RMcRHDYL9v?c(Z}6r zqBW4`I^D{%WvQhaaX6Vbqki1_+{DW{Gs^w_#mW+1ZH_SK(YiaTEc8T+Opuy!<|Erw zs){qYQ>|+HMsHHjx5(9egIC%y>2$8>;8f;EX{kko+_x0lkzf()dE~`tZ%_HIS1%ndcT-y`19+6Oe-@e0A!Fb(9^=nScyeq0O1=nScu}Ov!Y&K9li|A_O*`1Q}75XM$d_1ae_eE}Y1u&H9&?iFD z(aP?gf$1~9rc$f0C+BKJcsjGO#kn)N4Er@aOvSuk5|{<{HqV=Q^%;-@r@YfxHf2^a zFC#-Uzs&wW>!&tzIHKnAyuxuQ_S2(fzW=E)Gn0z|)Vb?X`fxbSnaG)p!xZH_JI$ z=3LA5FlVprDL`j7?X!cxb#pb&Q+K}T+0HDY)=t}nnl9U67Jtb6}c~VLw?dW8%A!hxJUEh3qbGG z#O+#gw32fo3N_5{@SY2>L7(*iSH~5ec%Wt9PHI`P(YmNr%$-Z%5&~UcZIWosL#B9#Y&Q+^O-&|i4K!OmTMd)%-%)>X2sfBgIjlH>b z#_c9cx|wD9(~~z_oHtCbE^e-l%UtTQp;T#X38;az3bi2_(4zW#KW1nI)3^flvw7F`LQMb>^Hx~nP=uTy9MhWVdrB1$W)VcL*0b#&K{QO9A|Dk zqq&8Xxh_vbck?%vGsbe~x_6Vy-TjPm5pVlZ@&GRdXgSlvb!C>t{;{)tl6zHUS1;0x zH~Uo3eKSY`i*T^#GeBc_Z1Ie@BUWf8=e%qE0up!IVGGc%uVxXFyR zfN9$6saLbgE4)b9WhbI@VwPX}$1ap!{6%KUre~@r0TGZoCu`1I#zO>d31BEsr4joW z6e@|P_7o*!gL%vk{XSpdM1>=0j%ZK1>yOKIn}0)ZM?} znAg}VvS>ZVv%*a3m=N5~yC6T3UWW*DuwoAp} zeSxC%1Vxa6!5GkVB4A^PNDkzX{5m5*=1L=ZQI3qq0QorLB}%oyS-Cou>}5VKIK$a7V&q)uAM+=;q}XH^|BHudQ~1*4+H?` zllx&l0@nD?M}iZ{d=#QV&PT)Z^L&kB`ygRHwctpDBMg{Px-Kl_#wM~vieg!jmdedn zLUVob0>v`e&~#F163$mCO;mO&DWRz+E0v{88fy{-a;1uprZ~e?3*=+6!)MEuJ$J!W zn4azk&1`9ut}JTy@WSocabVAhT>s3AY^JX8%T~Am zkpj8%!_I0sDm}D9>%~J^Bu|i!b7(%28EZ_O+~ITuMBbPxOUa#5 zCNE99DE#wq9!tC~EDw~*RAZ<#B@s&5g~sG9Ei6?6bHlX07UY@5a0p=66w|K#52DTc zqwHny4-qsgzTl4$K%ME4>7k&~M?+`8kP+iQPO$zTkAN1FTjH|9V~x*75Y2&O<^Ofy z!j*sO062>P0RR9100000000000000000000 z0000Q78|`x94-c60D(La2nvD5D1oA63xh5I0X7081BO@xAO(bK2ZLS=2OGF~CEOTi zw}TK!O5YMuluy!V#s7awpks&>X9}W!AP@wp0~1wZZOb`vN@vu9!grAVsF-)(4$-?)}fKS9_Cs@)Z5+c)QsK(qRqD5gPj>7yilpK2MVW7qZ*N zr0^E9Kq*jk0a*hY0jz+8W7+YoLvRS=`3_Ip{3oS%f}ntbf(W807FZJt1O)}`LJ=z$ zR&CZc-R4|dv2rd-*MHN$%9N*n4j=NqnZM^UOu9#TY*Bou@fBpZa6goGJo@!2rda>$|{Ni>WxA!_$;yt?!Kv{Go?ZGGej zo(RSmBZLsb2rqqph1)V_R(-v-yA?!ErV>FQZNEJRKr7p=YtaD7RT^jj@K{>_Irjs3 zI3Rsll%5Sp-=&n~0TL7dqGK1kKQGDyS^(<0fdT=*R^I>^#E9=%4KV6^lK{Ng0E;X` zWhKS{;?y|5Z@{49j7G87fcBhb3Rf-FkbLbkG$1|n%Op7j5T5j7En@Ao5fX`6tOhB$ zao{-@>!Zj5=%vsm7~#A$QkmY(AD=SXBCjsW=<2P?V4To5(5>M^MPRFw5`@e=^>Gwl z+x01n(y=ZS_+$|S^bDScj8_>`Pef#m(Y|U21haZ=xR_A`n?PKbhh^B5R@2~|>>3%Y zdZb4(%qn?Y!SA*~qKeRST)I!xH20=As1SxJ9qp^qV$X5aP$MF2fiDaOj1(mma3d&L z!CAb2GB8fEE~OU?nda^J92xaNWZTrt?v|pnWgjFoTaZ)jF~B2MSv8{3j@(QPyW^{9 ztb1rt*?i)AEM$c|z?_8&os#xv>P6rH)ehP{I#;KGqCGa1hoM+qm*7RGu&h$oQ%?x_ zl<6N^!^W9FP#-LQISz9atd>PHKPd~TpkF03Tt>mZH91K@o2A z+pF-H@7L)m zlJ;?;$T&OSj@nOGP9DVGy(iR_r=3nLE$2!!Jz?8uZPaqsG4j{|F_3ypxvjN5j6mkD z?ODm>OzD6h<;6E+Gupx7J`1o%qASt)8+@Ha?MkPcXCoP0>`471R%5U)XO98kmy(&S zpZZ8*tJu{nN^S6^s8Ol}a53txzpzX{ndLTWjQQcYNSPnoaAAyymEb|KS7bAF2^XY2 z@UqTllEScJqBAt1`D=MAk#^{%mkKy@vU&g#3 zupyFE2UJU!a-YvU`xyj-JKr<Ow$7gTb69Jz@!L`*& zib&NsXOXpTNp{Lvtsc6l%kwI&(Ym9-zwsOAKZzlUlNh^V?84*-*jygp))tGW_?FTs zk*ZG48bo!H-+k#vhD0g@P=z(Ilukp;S&dlIwdWV5 z5U>h`h+r+@GpiwD*&&vJ!HYd(*dcV1gd=5&C?_Nwuo2~+GOO*3#n7yZ!JbzS=(hBD zN9F3`6;m~#%!bd$`8H3eB~qC*^}FAjEN3S12_HyjQ!INT4)$GuxV{eLl(5?N1# zo7&bT;%Nb|S!~1WRd)4?LVU`bbYxMoTz8V=rkge0zE^XbZdLF)?$!5rtJw5Jf!58_ z@Qg8OiIuElh;v9Im7%>y;$l z2nT}#rv@{|5XB*}8%U2?`QDYz#1HP_W@3VPF?JZCRV3c}9uQJeF-8SjUL-0ligNB-a54?7$wl;0Vs(3lflmKgd81fe-{? z5CKbJ8MHtvv_U&`z;f6GBQOeMFb)&26}G{4n1&wH?$!Jv2Zg zG+DNc>V-b&hXELbp@oMpm8`J^z zc%lK8WGBUJ$2%MUKtECAu_QxIVaP2GkfB7N)?eD{bU3S=R!l-T?Ebm|ZaiGD*VLJ_Jfxh_r;MZXG16_Xw zApZn<)lXXJ(LBEK^$=&2jFy0iS=lJmYQL#&qHru*mH3FIFdJ#^Qd|{FCFQ>@{RLX}5^@#S9^Hr+-K2)ZTU`Q`pT%OGbOB#2GXbJ!k z;$IgnJO)=ebax+ZXuIwD(yhOOyRu8xr@BBu0TgrZ;GA&miwC=0J~uB6Y-4Kt8)qk5Spih09`fE)1> z8sGJJ=PS8w9BB)CS=-#XxipZN5r|_q8q@@M7ALJGRia9cf*lK*I(t;Lj6ie&Ov^Nd zn>_Yu=qFHZp^p>r3Q|juS%d%XjN21zjMgfcNfxE;d~~7pPV2jfV8B(#s)D!*6GF~z zhktQG$}K62PwC-4P1BlW2Io6x%|Kg6TsKq22JQgH)1^gV{S^*0;9_p zg&Bugmc-6$p_BQhvyN6f%{66mwvhYUr3W@2`|PA>;0P&UzH&I7A+X9`#FDV>{0Lgm zO~8AT!{2O+6dtc?qG^P4esWWZ9Gq&9tZ;i(zT4dXGGo5k)Tj2Sd)Wtu)vcH?TPE&mb!2Sc^ zAgqNhu)p)vAD9O`bgpNJ%C)kWnJ@9E}d^$B=h~KiJi^z;L zu)I5$VFlgAYoNThAxqgzn_@{{TX675mR@;+s%`36c!Mz5m4~Nnh}mu53w+zaTumuF zOf>L=*oLYT5}g-)BOEV|Fl$meK{D#BFsl7{Z|@xAl6w?E;1Hyn=b)M@ zKogb%w~4ug(PQv?c}FwkAA(0Y63=V|E0zqcK9b4t8W!1iJBAWZ^Ez08{~}Cf|HdwM zMHJ_&2JQV+3dHLf&;_!P5u`*c9{{G*pGLSRcx-*Z(U;tX{CJ!EXY=(&_uABATG?{| z_qwb`6X@^b%Z`)>A+v5Ta7`1rxO5o70YhL*o?CjDdN*IW1Y%*_Ikw42;tR1}umV-# z;rw07gJWtBjfBV%yM+M;$pc4TnzW1gkV9*eXAC`WWeEVw^}U!O-lV(?;%t#-9LDn8 zv9EUbZMnsivzOEGTf<6+PmUFZ6CEC7X!YHi@jI9bo9Qyzfp&D3qoIxY0&*5#r}Ki$ z12_lEk`NKWb~(t_8m<<)wL>kEmWS9 zx^_@a)p?Eg0_xI;dzSnMQzQj;%(k;Me_EX+M1qWmm%F@ zJ>bEvfmK!2ePtc{m?4Zadb@UC=B0Kz7$Gjt07r+?yeQ=c*!7DLY^!H<6FebHi>?6T zb7Nqw4+}P_Z^~oBc*zAwmI3Ow9^X{&dNCVXFPOF1Kqf%|x~&)0goO?MrTn*_6*Se+ zzJ-?NB3Zaps`k;A&ho+_@TsCx!<1YH99XSb0;)*+9OZAe`SFW`a@ zX16$1jz(}#2vCsnH996Py~uMClJ!G!0GbO<3}R4gwOWni>2b6wRCTP!NBnqiV>-wt zIK_i*+_%H~_t;Z43_0}yCLBxXg!+d%#&meYyOb{;MeKmr`t_ub!eb1XfeY#njbQE% zm7nguaw%!FA9Xq&*CHAnqXkXM zhHirV>z!m_Eeg%@#qxgMjMwg+O+)r+F7?+o z4t@JXg2);4R$2IwVa>VnNb8oUt;HF+>L)%t#uLRRf=TE9X4?9xB^|6k1ur&?Dw)7; zcXvAOv))jQ@Li+m<{3on>5*`=C$;8BoZRcvwYE#iYYcyOCxkfF*wUEjV8%=MML*5i zIa%StKi$v4MI0*s1J3i?gqBI7c51P;py@Tmp)0!+*If3;z(&oYe zFVvYoJdA#GP#mV@HuhZcB9n z(`xzN`{s3BHaTaxJB_ETGqcbFY{UIfl!+n;x)Vr@PQh~#Up25P$~rpouCT|;l!XtC zI(v2%SB~42drEdsu-%nKyT<6Q-GyH=Wl@wW03}=pUIj34GPcBmtZB0{6(ZGU;*7fG zVg}fX1od_!hP7My5vQ9r_N5a=Nz6qa;LcJB<4a+Oiqd6ambCB5;_><3_7)-Mfq9K% zW9hM+KER!VF(czW_q28srFJ<@CGb0QP7)#Wf(o`^E59$oBKB=rZn?ALQ~tU zgk`jrvM}rSwH}Bvb|nE=tv6p^Y&o^%et0eRKPQR~k=AE-se%PGfqzO&m&0JnK0T=w z(QqiGENSX#A(n7rrgV$MCo3_=34XZb*wTGA4RI*v-@#jh7lM8aF!Nzz!j=lFd2JtY zhzNyFit zG}hbg?hS?&n&YM?E#?Gu*cn7lWo)=jqOG3X-F_OW_x$3s&X#9aQ{yQ{U@DSKV4qXX zO@bq%|Bu*C=$rU|+d3bpb51YqV=3u4X_)QMtyBBjB4q`l@kEj8*h5n$s`86F*m5OY z3X|m{Baq*-E}l9t)YYw-xlCv${i4mzukey2a+MF=}1v z^1$S*#`N>$WN$m+*}8E2fa}6EJuH19>J*ocOV7j^>W*gAd!K>9M3JyhpI#B1<`ymr z*T^>2mDpZfRT1w?cMaPns9my>1qTgHdtzxa z!+qbxPmJX;D=o9aurPrEMwn5tqEr|cK-TWvyH>81G~PuG|9DBOhErqOYnF$i;wJ_0 zQLmQ5F{?KZg}S|!UQxQy=R(9>=3;-$td!UkkwIfVFBmx1Gy2HEFO7D#_&ShYB7+c3 z$u}Nw7mv3PG||QP4=R0Lf&XF}28oJGNu7<>XG!YKv^GT1)1LH9K8~<95QTBlPoX+R z^;$>{pjk^9r4Y`)$)sNlKf)sH*oUWHgHs<)Hg)+5q3>Dj{gP@f<=+;vLD=u`d4)(- z26n(1*NlTtsRp`{O}c6mbW8K*QqPiszCQ}G-8Ff$kjwf;c-JQ@?fX^<51m6#3v zozr6{S%MPi0{k5@2;;O{jKv$h+p6__e!?enNPR8Y1=zL za4x(LGm!1^Xw_N>N=XW!>ZV6)e`dKr_r36Y@c-@@Fa|8{M$A8F&rwzv^LXl`O+T~& zIS8%Gxb_1aE&Wslp_~cwIkT8%wm!QRm29|N$csk z1I_^paCG>pa+UxiSCT+Yf^6#62)2v>gR^O?TIR4mYHghvZq#kA+G&N))ov+259&;cLN@=it|xet~}O&1@>|G#8Gu~YNO!OL2> z{Swqu)Y3Hk*@jc%&mgOI{@GM3G%tQUc#5AZcFm5^KrnM}oUyRJ@ZBf5_|&nOHi`Vx zV>-hRPBE4*@FIy=-?RH95kBb)l@q~JZn-tf#m0salXmK_-c&MOqLTsS4t3kbd)(rv zGWQgD1T!f$nbOFzj{FcfU3&9~wQrG~f3dG6$netMdCL6k5ff4!=-tTRaDa@W`LRsh8h<8gFqF=@Ebni8SAb*VpJ)RzmGr37ur zjgBELjan93+}#_R8I`x27_~_TOk-Vlkj35+bvPL!^TINFyF-h6mPSRBz?pa-+Nc$i zCDoE@O3kHAs~#auf(yp7?n=!`_PAh$LOYl#AWKS1!sLXDg?zJ|c7Z)Jj4Ffi+PmHNgg1L0_{y{eWm(-ck z;_u&{x+<+W(W&*^L4(Btd#BbP;HJU6{!QBUQ+P6*4w2|TvvqFKW=`QrP&y1G;mcVY zYpi8qZnh7!XB2G`pJ^K(yOpqrpao&Fi>1LqvXn4a0pB4&td4_y#(=(ZQDaPKLLh6y zg?L>XNso7gebS<%so^XmG)C6s5}q$f3+0@uF+w7J;^IAhTBj@}C$d7dAb7zO+BNMwPyud z8KI~mD4goYsoR@^=Pr>@8bhnC3|(qrt8+X<;Iy2)Ro6vm+7q5$An}z+yV(J^steT# zhwLp}VvH5GGe1inx7eF4(nJYsYF-QYJ%867CD<7(&j2waRFi4g_=R4zg^4gigB&>K z5VQt&-!TuQ{+BVFw{q4w4bK)Sg6iWW|1ZnGv@$8cF+kj2;_(U>(pec;8p^5gbjhuz z6k7^y{Y9R&!aeGmQ&f<9oU56t{7q^KGvc3sb)ihTX)KxvFRW}L{R}T)+25XHbldGa zdkX?YlYY*mwmrRVheUK*%vNU$naF;e#A$`oO3BIt?!Yod*eP7OtqlT8+_81G1{^Ep z<^$g;m2oNSU~W-Hi9<+fMO*}55EA0<9KPgjqkvE|V9E(!fBv}79;ZJ1`v-&-eoN(3 zeA&vKE6Yq3B(J{(b)m_vgC{?XFk#n*J}SBSGiyj#{Mj+7ggrmwO=M`|Pr z;=SFg9katYU$~>y&Cz)q>K|22+^ENdb#<7c_1|GM@xw%9$nHNMpm3#v5K6XaM&al>W@I}U<4?i7O{62MKO|~sG4LKgzv$(#G zzHFivxqNWnMl2Q_E{AaxG32g>lWxp(5wnM~t6m(sv@hWDK4Y!|fP~CVl*~;Dat$y_ z5kuDa&fjDltl;?I>d(Pi?m|&7)1dXkAqNz!52Ni5J!r%2_0Lbz4~JRL8)4w;{hbm{ z!ND0F1+8I`vGAa&RQoA~7hGw8`$Pfm4p$6|l=zjU%&168^5IvfrSDIjD9qCTc^jI6 z3=-PnPuFf=5$D>PA+MJ59hRbpjqq?VOfc+5_wm(^m4!^m=bKTC1wcT?V(1TcUTH7q z2Ipw^;_N%&Ale#|tqac%5^;6wm{Z%qKkREa`^?>9k0jPI_@A!m^I{Dj6C>uM7kXG& z1%v+}{>us@5n+f}B+?{=iYa$d94CG)*G}RgM{FrWZYf8s86JGY5)4u=yrMPvw4T02Az2UA_+>ndvRJBV&Oyz|W@ zu=2<-^btadwv3&7BFtI4p;V;_+~|#!zsfj?A+{VPtdT?WH(cyXulCvcE8mb0}59>x$&{r6}Pg^Z_J@63k^{Gm^nm-Kkc0A?>@AUHj zV!0#SD9GH;d(bVUWaP)`b-&LHE*&}6tRK_jFp|2tdgf~N)|K(Rl`#^Y(4tLbF$VIZ zAUE_8!!Yz)%afGgcoSb+Nw(5RwnP$AVEKDcbM<99Hzv(9V&oqedlcgbiH+|s9|+-E zGxFvb=fYEl;Jg_Bse24rN(nb^=X(CJd9$Cw!;iHq|{VZV=yvQ z>2E3!Hjg-k8Ks~7d1gz%f;yeFls3a{L_aSP_OudTe^rL7OErpLlMOC>cj3o|nOkAE zym1s%xX12rkJw#Zd|De;g%Y5YY~u{qtV~fJH_av`U`_=Sgq;;tkSt2Pz{Z;rPQe<) zFA~(l=ek(9X|>!@YzLY~IjrQckb1`rH;Qc;MIFXbPO>K0RV>OVbXyq0O7i1M;l8AD zX>o?cbvnZ*9SS7%!LAXaCW$S}O5^>7O5uU5N6aMvdGMqc|2ZXi-c+Lf^xO)~kR9g= zQbl(7FRYgM;DUf{36B7uxnAcY90SL7Eh*KgA7~NpU%m*5tR=tJC%{oK!Ss2ocC}hv z7Nwz~5tfywtgetm8^&4yTT!aSHO`pNgD>Bq57!Ku{f}~Y%6Ec1I-faf!R0VS4ls1@ zh}^Y-Dh49t_z1rap4AaQ+#i3z3x2wDfBF9|!e&Lit>FOdqirt9{OLYxbmz5^7MuY? zwDo9$dk%ggqvm98QnW#2ke3W_kH9+cDi}vT5krctJ#|bce_ab@6)e{}QH<&Q9y)EV z8A8*6J7KK;R37yrfys}~0#QS-%Nonl$k=s> zb~c*H@Wr%vMZ<#Ss-NZ$U+7^W8@hl0B&(e>Zb;&k0ug)^c=@ii7&L83E3|Z!#@IN+8QiHXE)1Z(W9k#* z!1|)I%>p>fvCcOE_){UiaUrI6ZQt4$kjMMN1FW|y-&$t^N&0IbUJc$u!aRxku?BLB z_*upG;-ADje*1cMJnTQvlH&?2zy|s7`Ar(Hzb!q)Oua&mR3`_gjVdoW3WF7!y@S1= zBzy?~xJaA^U?di1^q+{J`H0JJl9n40nqS_}DjH5llu3xjU0Mda{ma*Z7taa6f#aZHmBWaJD>Sn~4qU(kOZA5g32-osH(G-pboJ|G$%p_2 zQAE&R+@BF$BXcj49Q;=b5Od0Lnm|@#dD3A5tb_=J7E=~;^fy?SL8=%O(-r~&x|41N zlUk3c8YpPiaTbcLkrx7|$w?!g7Am0I7P>bYVgBGcp=j0gTWbqDVmL$(3yz+tJrJQB zC>+0gq~Xqy$Y!=`aIcHKy*6O#ot=3k=aEGE7k2g^Zy`wVK_Y)QrVtX#?dI#ywKq&L z!a|kHqzim_ASgsjVx=};PH&YLhe%vwtXCWXw`U^n968q6WuM4PN|YOf;VP{|s7($} z!aY(2E-D|NJM7MX&VW9}C)p2eSqv`gwf50~>XO?R;L5s+AhwyZ?R?ul)xobYfq&vP zMnFJd>a(5bDsXV~36P*3FIyf~X?`#boKiqZNxE3Nz@NCdPqq#EsGutXPE(`wfqH!y z)IShe>;#UtK;+bwJ49j+%@yaaO0`W70_i0Ij-8VP@+)Lr^@imgQ#)JhYV_f91pQm6 zs?4O)iR3(GE9#aL{SbWL2{}Fe#VUZ~&xKWy@dmN_5PTna9h=l*?){rOYHDDU$1>4^ zpG|HQbz`7!9iTYYsr(>Aif)*v4g}ud;bt9C!Lj5gP|UNUGQL*R)I1p(l79xDLkZ z@5&;_EdAF0EryhJf{cZK0tcuYz`7?C3hHS0&rh~lr#l!TxmlM<4)CG)GEmfvHa0V} zH$I9JU6^T?N7dz`PzdJ^219`L(;Kdax)lnp$Qd^k@PjXSIE@p8D1>VsLwcjc0<4wZhi|KSShvh5uBXhDjZia7*L|p#2a%MNkpOcAYBAKI1Q-o*y4glMuwzU1& z-8z5?dw>ZK!bHHG7_;ICy!p|TgVBwjalRP+W!&%%1SawlCft)bvjGFHffImlaHSAy zY`@9OLLf2BfMM5Md}4`7sDS{3aV6p(_$8X;*tf%oKnGtZwdPWqjJjL02`oydUF;)o z+0}K@X_hW!a-v|+b7Z%9ImUcPWxa6+#Rn%h?fO6bj{9coxd@_pcUIQg#@(Gg+Z8<3 zy0eMADwO7C-sU5U{%k$k$LrHX)$)|p=M^5UOh^5NhZ^SVc=O$kFAUVfhDl=A{Nn zBn@l%7N_Q$KO&j#%eTXs0NkHKmm{Mo2gX(OkiG)MvW` zmTTZE!mcw&#zNVlM4_;pJpRoJhMb|$lQ;WNANQW^M-U17LUZ(78<7YY=sLhD+AtE~ zhCskj5)2~{-n33LV7}rfLR%%Y&rlStRKy(cwgwn!oS1TZ1rPOw0Rj`$M$Df zOWbHdvU(ZOM~qcO!c#yw{Ng*JCtj7(Q+Cu znpC$ZFt)!!A}^`L+Wo3`H@*R}eAsNl?_YK-|9@Tp?S^@R(Z&I+neI(v6#Bywy?5vm zECCCM`Ee0D8roajS-ad#jeRpiQ?AfGjDU7x8(X38Zy4xRSYi_uD6oCq6rn7=f#Ky? zNcCkK4Mk3gC0s#4w1o5NR6+Qq_jgYHbI##dbrQy6p0d6PVt%>ccsNbW1B@n)i*r4O zkvU_&P^bqnB;|3bPk<13(a4u}ujXj;eW^I}77|c{b|^~7^-u9|3RH`$vh*?FjGLm% zTG-Vr!Gj(`v7Xa`+^~itI+?v7MK%+OnvGx{tCsUz+wT2Z6UL(b=3Ey&NC|N_-3+^q zOT_{I&Q;jPzGeBG%Y_^e=!*;%mGx}myDY^^(Z$LBED5xe`3ty#9mJ zZ^Zkk%~8iAHb+>+zcg?)u*t!Z`bnoDg87p`iLtDpenH+30vb!B2Vgh-_=tBfDIFIl z?9eZ}u-kBS1-#ckZ^1fdF$9t3kQ&Hko6&67?U~!qsL?$&11;h_d=~~g{X@lx zR}Yx1AO9uIz=)PMDhqXy1K6FWSz7m(yMo(3&2JIoFfuKjp5^NcaTikMT<1(6OMeZ- zsbM4wr#h1%8*M5i20EfBVf_~x)@@qX>>YyPZbiM^b-WOy;g9G`M8&M%O$i5kOXjxu zpyz@86u~5C+Jvm-(fv{I6u8Xgp2WoZs z0?)Q*b1AzfWey=-@#NdzGLOg{ph@;$Y_NlO+@76?y~iS9#&eBn@770L`5eI?e@-K4#7!h*2n94ct6%StAgG+KT^UdesM#hz}_goqzxZQwDHTdHW|EJ zH3EY!TR06(#iL<+yqLL`=xmvxmfWf629C;7{u<$`yMYtEvJfK@*D^zRyh&G zd-=|Lbxmdb6}tB$Y(1HqdY$6(Wy@?IN7eeH-WLz@0{JtS(sLAA^^?6BqMu!$h1|I2 zbGyM}0|3C7GP@i5HzbZ_oL|thS z9h&Z2Tls3L;AIvDYR_v90;YecOhidB|L#0DoK}Ic=3%=A2e69o8fVz$KGOe8Zi2Jd zICu~d8SK%@h>FDAP3;NX<~Tnu1uWbXvi=M0sll#4L(PvVb zMWHuy-f`a6#s7>7JB7P;yw?m|%n~0fcWk-RIdPX!d2-5~t$bYq=ljmyjwBsAFMR(i zM6C*&-`N?j&OF^f;#c9A2~4L}0YwGEdWdKncjN1{i({(0TzT^r@TyC@PNj~fI<&An z#TN(D*L!Va>|gRpN_ZNTrY#2(PV4RW^6ljr*vRu_YPEhW*}saM*(tu$IDqfsT5{Mc z=L1##G_vw&%Tk>A;d{p-9?bk_mw_{Nf{`P}N=z0Vse1`*+w1Ry;_jq{1MHHa6ekhK z@Qs1c2Go$l4oSD&SCsHrj-A}LaEJG>cLvZ_^8L`10hMWr4Y8;f6qS2T`RRA%QGWp1 zV@U&JC!dx!aSFs*?fuO(~phb~eIvEs_<>TMv9~e>r zInw;1=Q_ZyqHR~ejJB(&(+@uXJqT$o6X|DIT%yV>XZ&XdojWRvi(U^fAFt1ni_~^k zq+JYI6VZraDm3#PFhC%cU+7MjH~82P$sx*@5R8zlqH z)EuXEFv_MXq?fK)TPDn^}wvCA?(Bean-~ zF+d`EK!ZR8qr7xPYDTG7B#&K)pOZ9Sa2`p z*A&+5OAc$6QRmEUc)Ic&l29#Yavl{9NwC5miJ^&#MYgl*IRNuA7q>S5Yt7+s$m6mA&`HGKKcfLz{l+_<&I=6Ek>i^F%Ho1T~kH?y)_~9DJ(#&o@M4q}gENw=o zyJ0ZVQ{B6_i+*hH?ji<`C?*bn)IId_x<@ryQoJ9CMpf|MJJVLX(p1uEtYMEU@qit2-(1yEQ-UU9?IdF3*Q8AEBR{9?fdKQ`zW)B z!zL1HLQ+k_3?=T)Aw$oz=w#rAVZCEKDw7D1v@;h_$-#G#=uPu8hx&Tx+x)*9z>Op6 z<8(E-wGXfd!G$hL%uUw{#oS%F0(hy|vbx~kQD+WM!@Y_3azCz?7J#@%mY#c7+nKlM z+Hwk8)V;Y^*p7vFYn{vV?Jo)GF1wW;=^U>q7m$u|(U~)sXLqP0rYtJNJa=t){8bFp zb2?2cUARS8nNvJO1!#OjoXnG4&MM0RL&nKFhx`294gDW`&aS1~avr2m7xL)uVPp@m z1CC}gP1pAD68_Z#xc;jSkZ&Qu#e(}f_VU86>zvDjJ)Km5tKKeKam9Cg=I+~?>*zJs zJl?EokH?uCIDdAvW$XcVfbC_Q>9&P_b5kTVpG2|kY&+Wy?O-yEl@fEgQYRb*&0NB6 zwYkg+9ysDN_B2cairdNZt7U0-AP`{g!9*gF+qie*Gxosx13E*^vmod4DhTegVvj=* zZ9D`ykEuXC(e$jkvsH*o{XMM}Ml`{f2C8i+UNvNymHJ|jK3-SjY1LgAIPad=u9&>A zyW&R11i5F%IWUY5z`=|RW)^JT-1i*AKJp#7C>j6^_5V)(IzI{T@pmn4m08JnOfB{@V>8|^T>Hi-fTE1=m!Y?gm89-caGsN~~$SBcd4d#KsoB?5*l#Ri!U}OFoKarHQ z^h;OTSG2j9;Q9qk@z>5A^;U?l5baS7Kx}9?RSCmr16rnc{_t>U>DFhT5y%7?B9H+x z%8mlE&57Qule=tABV@o);%ttyegOtH3UPRn(9&QnGGkRvq^v*N~)y*pM7*Or6QRoDKX4;ib;;Dp07v-d!hmnc~YJw z(wT^Rsw@hoC~-+ymTdXRF)d2jB56^x*nD~DxYw|w6zeWn7&;|8idD;3LRnQ)mLxmQ zTqHJEifrkHM0JsJWJ(ntDeu~N!V_8B=m;`V0<~`ZAvBz!luR$G&rDBGNGGCzrNClE zG(+hi-DpR7f(cL4zojS+uw(EmM@F+thBAa#^t%f`!2n zYDUayaVafJW;#Eb$ASiSf%J4sNsX2ecb!`<&qiN3v;hHm4{$%NOZOLI;pkkv4|+q9 zkm)mE$mpI7i>PQ!?~*{wrN-jQ_C^kyYunSgDRn_2UJ@%H@wE}wkXZmHw$`Dl|dUw!dSpRLaL&o-%) zR!B1^-D$JV``=mTyrgp31s83X;e(Z~x#B9d*WOwtOQvkOa^%?}U!ej;ij^u+W}#OW zSz@trl`8Dht6G&BwcdE=fa`YH>4v**nzYLvdz~<4x09NUn>OQ6oHp-UoUbZTFD_80 S=b`>9!M6KOjfZOl}Au7>FG@{>1S7AN}Iribl#Ake!Zs}ii-ma=y4WDy_#y-g< zh~)aOdft8kI7V_1o+C!E=768mzxSf=B@PjSBoIRa3BfE1Aqfy5KoAX3tcX>hjEYiM zD&iZ}mAX)ySi5%Xx-S(Fo2HH!YLfJS=T5^eo?ZrQ@9sutgLPUWbZ7)HTC=&CoWaxbh# zkP(9w*RMU51W40qr|GN<^|j}u-gEF)n{k?!5tF}k{e~85J{289QM9xssZS-kvD1y% z_lVznyYtPxR=U2~2|b~w*iBUph>FM|I10hPPyzf;+2!7(yPec8-8tY(fxAQEDxN-F zs}{gh3y9qD-)@i3)Y4jFVH#2M8jw0v;s5VX%l6$5mJGA2XRo8#Wz8NBh7{=SS>l8{ zDwB$z{*$c#^!~R-GD_cGAK6RtkmOmgJtW!I>=z8M1B4Z6*q&j}5@8pD3i$M?a{3hI zlu5cO+a+A5=(?n7($(?Jw^^QK@HV{+Z#ycu!Dv*)dt85e8~*FN%ZU5EVV6BZ>w`qi1|n(+hKYKK|+yKv@71DFgz6!60a85DW|m78V4L0K$hKgoFel zKmdf25+X9X^_b<6>~Y_Z--i_JB|B)z(|YEZ3=3lb4s4vA8QA9V$9H@;vf z@})Qu@U46hwS0=@8wbU4brt2R%rdFdah! zAWZ=P`gBUJMDOs-#6ooPFYEIT0?=_PeDw(UXhl<)dex>)$nm`H+&|gB85}DAg_SGNr5(?9AGtr2EHyi+cSnuZ%j;vS-A3+WVwxKi zv)r4DSxFUjIG6D}Df?NYH}lpcLCzXN-@H7^R~})8{$$kJaOm8$?T-?r7;^u|mR`46 zu}+_V?RbB>x4@?b?A)^>0gwPc^Op4qHLUH;SUOmlX`QM6;NlkQ#F6PrACedtNB z$2#>W(ME>ROQT~|C`jGS;{y-!{FDfqjQHo~jIy|EP`Lq>y#x-Zp893F7biG2a z#n;&7HM)|RrmN@Cv&ucqq6s%e1y63c%~!Q1QN3voh1<%zOs+^&V2b_Y$B?;0E0Pyj zSjD^vOoF$*bIRf7J@`nHX8xj*D4vU{Q?awqeyd25mu1XtK!! z-PxFVjuoY(QZ~P9`t#u7^QR@|atpkm$`rXH0>9m};KY0!xA;WEa?>p`p zuQD*PG-;!+4>B=jt{Mg-Go4DiO7Chf`d9<;d*kr|3ojUnX_M|hYIAxQ+}Y8pL~|hx zlmN9p5Vi|{Pu-*Er`IA7n(@oZ;O(-@x_qlqr<9jL*opO8U;7Lh6vTtp=>rZAnY|uu zg(VIq=U?SHX8_8z1oh3PYzli*TK}^GVau-0Wp?^ZEM6b=;~mLUec=rU|I2!99K;C6 z{>|p*!@RuhjXlwHx54si&mBeMYo!h49+z9qcDc5MW#lu-1mp4oWb!jM=c6t%v5r1j zUtNORXF<;~kwkZ@#6u05#(^nfpvOdIy>Ht$U5-_!=EodC^4}B5)vqX{#XJ8AqW-%rJ_O*DikVA z7#cM-HjPLmQKI;W7L6)K3}3P0aKwv;OJG1@WP~s=Bi?Q$X0XfwXUSvvCtZFU7AwTi z-ij2XE8#{|tp=l>lhX}jYWpA$a9Q%iM=5&=q0j)C=?eE3-J}hh(Nd*MPg{W(-GVjky0RpHzo>f zH?D?;m|NNraMl{jdi}5hGE8 zD3Mg+1d9ec2d(f0@Xo9fUQiH_>ltE_sfCXNFNhCJO^y=}hlOuk0-8d6L zlkslQzo57fI8crlEdL5+p+k=s*vK zp<8)q!_y9I!O=FLHIxc1Rw$l$Up%qU_*&HR!OV#$cwaUanvQuDDb;bY|hf>}H^h+0g+ zd?Ua^o=Es`u>n+aD24*aoK6G*P#N2^&6-lQG`Pi3BwI`cL{7zNy9O4^2&{4g5>rw@ zzPe|@tP}{70Q3d=0sVmiz(8OSFc`Ro2stPr;Z%rgQG#^e2St!D#}_UsL6jIt;iI-D zIyfMZrfH``R9HSDd2I~4Z5ELMT#<228drEm56D2(i84tZPQVa67Cqh30ye`l+@BRz zKqcG)A+UkY_q;%1=K^>Np1NlWm{2$;jkB^RPg$}sXmmJlxUyx0DcHjvra0p_HVj0b z`2zHK6^VKc&+v$yIq5G-0zQ-Z8~zT; zl>Q9jg_A#N2RZMJ0x)Yvto_;jIimtG0TBSGJ4QAS0^vYR*1C{DI&FJ^F`qu>(?&%< z|3R<<7)|*ft@5%Vl6P}>^ZP9DbS~5R0pZcq`_2eJj%x`1Fa7!de}4o1dlP(k4Scv--dA3e&wss7-v0%m1;)D% zh>HN?Z03UL$hh{OGT$-_9rT^EmRoOwS%%ED$s#MwvBF8mov_3?2>jkL>7I& zMO7Z7wzSrOb*@_Fjs(Vg%;*`4=l8C>@P0G)YPGDj@^wO#VMN&yX$*rOf%gzFI}?O4O7ju)nt=Qu@o7Kh>C;)L+62wgUg$c7ZE0Z3Q{tG zI3en#NR&h`nT~;(T{kU%mU&eQA7P2*+C~> zlB+lY)zGnzBQGtHivkBi1OzL?A?~k$D1j79Bys{U3e9X*6FQr-KE5HNx1v2V!nZsk z5e!I1h_?@5K30fu0f^fZ_ZZ+i=sfuG!z$jW;0=j@VjSlNMqH~(8sOAa(N?qtW5a@# z9ZR3m53pxn2|%c|+sU020s?R~5)ooiA<@7bu$SC09=+o(GLJCoBcZ5@Iq3Vh>WPp* zGBwo+=Y-QDGb<;``@#MjTV5wtl1c|61!4F}`m+aWPgis8{7vR;a{2qs?=BybvG3-wJyo4d5ivjrAPbLcw4qy> z?Htk5Oz5Pc3ml5d%q`JF#=g0kJ4%0!VVo z2-YMlNV>Kc#PKYPVp=DII|Nc78OI&0N9g@Wpzn;ued^6dQe+poKNdy|ym>~(u>i!n zJkfNe>fi=>uE7Z@hWPLCQ>Aof6d^&}uSn3y4l{FNP*+Zwa|q@ibBYbf)k3c`>ZpfS z#d7fw%-F9)0=-v3*0kW!jqu55sTPtDi18hJE(&6vCecl{R7<=?AcprPfWeT~w_ixf zWWj9w{SRIc$!7$KKoAy`Ks?-8uGG}cTed+>WttJCVLdR{{p|z_(B=w4i&G|O@c0c2 z4#@S+xrv?it|zNX**r@AuOZj-Zq8zOsbm6p=@bgK{#i+Vt#|u4TZhGdgK-T%8T~@v z6+yF0HbCgT!q@*bnqb>-$Y?_+&j-HBV#YE=l0Y;WXZYs)VEoud5Rp+Wgg&{z=K;A zq&ZYJ+uF2xlmsd2GevG%@AvHV*(-+rY$)_}<#XXlpE)(hkK4D9a4S*(k6crU!fnt4MAAaR$yJW zm=xC!L9@?^?F?knv$}cAq^fGg4f^qxjqHMv-D_?-Y0X7`9Hk2Y5QGPX3hK2yA)+Wv zRZHlh`X~F?r@5w=T8muS@!(0cGc!YB0`}@sbm)CWcS)h7m)+A@aU1KNqvrv0WzwDs zGs`ny-;U`ObwEAOVRQ1x!a#noSyDEw9`~2CfwrjChX&!{>hM#h@NTk! zqXRzj*`kruj&&YZg#kSK} zidu@E;!&xzV4)32f| zql^V0ctrQ@5y~P6DXijYsP+UdwI33w( z!{+KzQWwMW+$xa>U0f0ZspzcY6Y4va&cb9afwfU83TV&xjCgwiG%O-hLjMdX7u(@pYf6pPzW@)Ui^lUr(I)gU%M zZh<+`Y?2-=kUPFeJ#ASZBen8|N$6Ymq2N^VAYCKNQ&ts8*;(Fb@to&dVtWMEW}>$) zEU0^d!(rvPIQ@X02SJ=o^LdVuR9T>)AS$-GP_f+b%ZntE{-Gy`hv3jc_ta^Gb?4Ss zr`YGq!Zwb`$&3049-vpeE;;oNAJ0x42OZ4?er!(?)IH9=lhdiBEi@E2VolX*keCtDV}}|( zg?W`#wRS~H#9?` zr$dh0MygASZRiFAj3SD_l4j2OYinIY&E{uf1V$RaKPof6zeJo&1XfR`G6Hny<&o9~!fTnFlKGr^LBhwDeV}j%O#W z3SEf~!EZUJ%~->+@^r-TA`mgjkQD+G%?zcSCypPj${-_+wnJ^`(qS{9Gl|`74*_TA zKTiocb${FFgGHqfRaPH74eQXMy)d2^2ZCi8Yr7Wy12y9vuGJSKjyHut6emsyx;SGGOf5bC}J0dTpzZOYw!hd$_zD`Fp7s5*Y8HAWF6`^IY5W z_92cF2N1>g14$labjg&Ld(qS(9*2xaj0bz^2t8~)lruX&%bL^62t{6`dC@7bRpgK~ z(P2LK^YeCU`(WSLiQ~xeY>`r`tYU*n{bC$3?p}A7!KSbH5?e3#v4ufBQY9-GVA5d zsxTecVW#0}AV^Ul#hiIkKBn->1D$L__xfDN#&@*Z?7LaY^x4CjdSC4e_q?8SBx)1p zW=^+i*L|%}_#SUQ?@p$|bYA6R^0Km$m?}Q>MCzN;f*`3S5kL7X0}D*m%>^s+3hB+Y z-kG_oBOgFKsqO=kobQ|8$*UnTtwndophKZ4w%WCG;zLDcaWp!H9p#8N4AnRFk?~JA z@He2_6ji%uG;jmP&6{Cpy8z#jY5@e@Nn^+fOLBq^_PZ>;s=gIZ(NBk0KNjumWQpY9&S>6})co8M3YB4La1gMq@z z?eWbhB~>jARED0Wm50YMBfWAbKAk%c;SsL|>fW#!(I2<6uc1g$nqOUA+BP}gegr3O zpzFHqW8cH^in!8xG#^-C#JI|U9;+<^#w%B}&AF44WIXBr++o{ZX7G%S&yTR@rEDD= z9cl*sKA-F!)-BSr-Rrp+GmLS@&_hoYzCqy~spvvcj+XJHNx(X)35K61wd%x;Kzy_4 z-o{OI79M-*lo6c;#Qip8|I>aY-EX8?1!d#trZ+3JITtCED5y)i5Et6gHZcT8wG4e< zVq%!-$>w^dW|jCUt}@M<|I2+|Iy+(JZ-3`sAcrbsMxAn3NcuZRVpvorlhfWRdd}jY zbfSF>qIj{d)9Fe>)+_&$RD|NfJAgEFK5yvKaMcSHxUVIAzxcj#>kl2P`sf&b8wZSl z{ys8=Sofbw6XNi=PC0~4xT6SFnD@TlfF(4GqhLxsu)^a9>-^YQ%>t)ENj#K03BDwL&7+^fF zqSLubj`C%af@}`USLSc-Y|P#^wf_N|h`+=&`*x7^h1SfndHFalUhx9W8IzxjzE~l~ zV@4`*2H~olP!#1m4(F4W!RnJsElsjSYrINr{2?kcT$L4>q{|OgrPwM%p^i^6nDTiDYB*3mfR#u z?|{7p6@zUp<%4@&<1%{!wo=w(!m_^nyqxY#-F&w@d11T5j@X*Dsa=dJSa`C1o3nUL zV`K5^ZF%iKkv!s(qWmGF*`72|UY<12Vz+}dMwj5J$TPbKFpMp7ihvFqwz@T6kzd`~ zvBg=mrm?YT^_IMjA4wi@iBfQ@J((!6kCg!du_>5N00TV&AE*6ahDnRbzRDj>ik}po zSxTNV`*Zl|lhG0BvT)O<%9;5%O}s+v*mcA{sKc3xcdcmU&Jw@rSGpTRLrIpzrTo|- zrs;1}kVUD2UvlIvp~Q4vC~uL^ps zcZ$3~v8i3aD~HaTIy+Uh^Zwhv9KrKyAL&Ip|DovzN^-a#;Xl*cA{04>_&ncNSWAS# zD=&02YT-9o?7D6{*3wIhetUtNX}4#uDVF{6Sfksz5!}@w6&>8_rDTlgKQr2j?ECDA zV^1zxWJhR-tM>;b0%?)#rSMt$$<8U6EFuq@FPoGyFE(&FWZW-PLrZVL5f*sQPr8Ai zD7%5p!jj^==HtHkx^`XFa9mXAW~p%W+2Y};5&zG zz)4J~e|MNDY}@WC-PqbvI=I8twO`yE#PZf{CO<;Q>Hsc!W6q{Sco zqJK9jepT?ZltQKRbNK1w(GeC|xap6I_(w57vsC=rhTl*2WYuaS@~DU`OBdx37T_~* z*l>inc1xm~33_8@avm5Aj8L+QoF~8$EC}xdNJT!gR~8QRFcO49?f^RA6FOaKA3XAo zub7X+Wm|h4)f!E{0Fh<=;}GWI%W>F$z`1{GJ zp$^`&fYfl6JTE;qcN_d$R>jfZEY!{S#&aVbyps->bImM7@hLYOM;$lUSMFOSOkkpB zeVV^yzLaw6O?_cpqOLevTV=YCa>ilo z?$Z}k&C;cHJI)dk3H;yrzy`d40~>lPnz-t%<&{eVEga+bi=HdP){*LvN;B^882R@Q zc5mwG=bpXAr|0{;KMeDslepq1}iWLyF?|Z{cSb zoy>OU%TKO2F$9V>DV`O+7&xJ&&fCB-BEmA6mWdY?guH(Y>xiY(~7U+U3h(E zMWjdmM-rxHN!`HNoAVd$Jk6ov7jU5Boftua(b}iP)ZhL?IQP$4tbVPGAoCrVF>=Ba zsPK|Fm2(;f5+n!=pNTO7cur7m{ zF)?t$c_slnay)R&(t~jsdV!FMRY7LGW3K;0T@uQ~pQzZ;mCh+Bc;U)}%WJs;Y7a)>uay#&}Po zETamFJFN~|tueAE*BRAYZ8Rg6+R(&m%mHHy>X)QBrk0gCrY=dVf8t>+3x%4_**!fS zvui*Ql>QyVfL;$bpNaj7 zF+wTOZ*#_U%F?PA*|GKC$Zv!YfS8~FCGFWshz-Q3K4AjMOYezk{JSU`*MzY?FULC zYKjUPjjJ2{-u}&_lr;Bv1OOp;Dmh(EErcV$D$m3GtU|KB8V4q?lDM_B|G&w^62gUA zs&8&GJGrb{ayMi>ek}G>VRe7>%G_a-)HAL`2rT%rx^QxM0>-R(0u?nWB&;cgOg@!l z#eofiPy7ZFod+DiM<_wP2-C}0F+F+CnBMQy(9%Qm7*A8WZG9nS z*%92^J+_7~-E`}v2e>eS{|o10LG==A&Vaj$5A&tgQCWUpF0qDKmp=RIDs(Zuh56CF z6$W2JVH=u1D&hx9+F>hA7Dwcq_bKWmpE>ds=U+4A$7dOOuxqQbqW52WvAev@T9i(c(?I1QY1>%<=ir z5R}`LsDA=IKavNi1%3nz(@9RsN~|*cmMn?y)UcsQ9vbdR9ShMKXFL2tg1 zV2I{L#qR|xlu9O2;?=_*+DC$LE$-@ep}#Aw6a4M@@@#MTC=Kwj^`pqXJiza{e^d(s z{z>@K2>4=|& z-e&mHaX%dBxNQqnIM8q>?w^U?q5Cj!BXUQ5bL*EG2+r`K-?gBFl^#HcJD7hmwbpZ_ zIMYTOgVXJ)Ka?NeEEv>#0v-M~&hY+xCm)%C{`lC3jtg+0;rU1Zk&=cZG!QWPowfdk z^{WP|rjl7$L5NlMjpsQzn(W>wdo?v|`wov6dhwHo^x1`|Za_w(&7YKdUNRbk#Npg- zSMp$MOY%m`lbhS+O4-=bnliY==Ex0}ZEMyrWH6?I;NMjs9p~|x`~h5}um^_Yexeh8 z6#iFT#h~|TG^>*fQ?$Y5W>- zFa@y9O5p~4W8?PIOJg-tkm5PRUAq%atT- z#^L6(=sZzVUP2pxmLpTpRvH>2<>xSneDnFg2Bm0S=Ern;8v9#$wl_VhVYDF&vY^wL zG6UR2+V$1rSEK=7fbaJ83$h%(F3S!s4JlP*k2LprIsM8drQR9SI%MY%aB^ZIvP(jT z=9dP>*dxPKCTq~Ym);jJbv{vAedtY=N<67_nKnaV3=d3=Oip&St1~nOe1l46W=W^b zV90BE_fwpyU^Zu;nZN#LiC^)gW)`9gsuicg99eXpu+f>&E|{5>E$Fa?hJ^ESGz5O? z`S%ArIB6*#(`o6P8&XF+m}BckC)!{fGIQl}SNQsx2`f?|iud#)8J9wfFCfrx&fZMG6k?IMNr)41Drkz<~C&GlMr%b(angJ`h3L=yI%UHLvL zD_CWV(1K_vD$>!d+6aQD(1-+8ghOwP$Wr-j66iuh1o}-NeY83x=a}1DZ+sFkNoNen z-4>ElbAF*SaH(KP32XJ5HT05BteR1B(M>C6EfFlu4eHAZAR@VdGNL>QXs^|s-%@uG z&OZL|q4Fl+`pok3nb-Z_EdS@9>Ko3;%s^KB*k{~7AfalcvufE(iXo~A4O*U){(jfD z66m9dOd@Zi$UDMY6iFSH`VO(;``Pi(f5EH%ej4>kfbX|Zzrg`;=cH;pHyq;Mz6-t? zyzxz03`U*BTR^pBaTU@w))lLhYg>wP0`f|a@5J^n1J2i=QCw_gtZIyc5o3yoNzAn2 z>p9-1DE1bW?z6haZ7t3YsL2@K=L>SZYtY0oq~ti0HaaO2toS;P=tzDN7I{Yw4#_9W z80Z|nZrZ0qS9X%;Np&}m35!G2Z~oLk>$OdF7q>v-2gL`NnrmM{w@4FI6=iXD!IN$* zJ0gOdCT48i!s3#RDkWn4Au>>4FNzPWh?CLMW|r8G4&gV?f&bHtvbd0n1S#FJ!ft&% zgzx*_ZXZWWlf@}3isCZ_fssETMx}~uCJ_+f(>{}Je~ ztA4~ARy690gVA^Hnn%mmLM2kC++i5I#`R;R)7&YY0TxJ?O)=NI(>{Ukoq|7%bh5qG ztYDGNFn3lu@440(#s=A4G?g}k0T6}x;E6G^Q=Y2vt0o6~Mw*tCsAO&@-0YLb~A3+fq=di4%5>j&&5nbe(mq(@A1iTuE%TkVBe6xo5X{smjX6w=|da z6rf2?xz+*#wXK6;v6nEwH znCws&E!B${I0i4inDImuQyiu%kak%t(r$f0X;@rdrInL`L%-Q7%BKf9rboN0VkVa? zuDn;tFehF}f|MhnYrkZ$(g=fx4V={@!vfYTukTLB5qdPrq9Qx|9|Q=6*p#3*mK46m1h-aQ{?Lu z>!9#{RR6q+ciyvWzj)|)mrqBZ@z!4t@Gi+`IM(B!%vu<=@X{{s3=VfXmvbICgPrgb ziC{Gw&sb32j$3dlLi5CpImCeDx5pl2yt}QnfpR_`h2M>gx*o+91G)FTz4MeC4*B8P zut{G^%b{hvOVcm0<@8*JqpH}?FVSS;X57Tf6oV$?iOAK#&q_JTC||jD@?U>{EI3o> z|IAZTd4_xJ;}x{VXeg!S;Mvuu#*AG0l>bZAnY0g9|L{UH=KkBYyLU_VHJ!lt;Hy{5 zzx*nO6=|&N6yGUWn2&XJXrwVUIx(+4D>XZ7!IGaHW{X;YAw@{F@UQ!4=KV*!u&^}3 zoe(0`|391G&}C-T1i8`;jdF{&g5~trMaisMd0pcl1QYRiZ}dJtklF5Ji_}q>ArWSw zlaPaM`H9EEM{uU#cWB<*$XL)|UPmb>7o@HKre~kw89pUCZdBkg^2+$G=G5hsVys9# zx3eicPjlzX?c^kyG zrtbNfJN!JqUv+vXry($pg^+>&Etx|9g{N4gzfkR01n!ejtx@3K9#v+9d133YZE~#J z9Xr`(BN5Uw*_QdO!G_N8f`af4qanOgh=dKs4pcjZj0B_)+FwSjc~q@QHbO&OQgw=s z+t(OpR2A|)NR4EHGdVd#ua@P-#d1)CdcCD0gPH@q%YtSE1k5h9PCKK?5&I~P^+rtpWe52C!T3|+ zuvcR7t1$6=AHlY>Q9#kkc++qoKr5nS#(Iw>sAx1mp?$giY$lwkAX~VrC!s8_&^}oV z5d}(tuvx?-@f|}T1e?O7z(0>5_~QA$wP`P|-})G0c4sAa`7Mh#<{MI@mteDW&7({@ z;yD9na{asKL;Q@INtyQ;=A0T!>W-LkNVjrSs-sBT)Ei6B^2mzGW#pt4`#EQraXa4; zllSM9I3sNrj5~25>_Q7LpJDE2hzUgMhO^WF+}JS8Cjb%U7A1_bodN`e>di-e7kCi~ zT8$Co4v;0;b72Wan3)frVgis*AEM+U0HHEO0*8$rC+uZ!=X(#AETQI1-=8WOR&)D& ztzL&I5)P0M8n&S20s@g#Km=f0_FD&6lzVpimGdbu{HN$&e`D#{?jfDOPV_7Aq-Ej1 zO$v~r-2B9=bv}P_hSAc3&V!-PK{7`cNPdB9YCHhn5ZAn7Yxjy!H(Vh zw|v1HzTlaFKXTq!`ENSEg`nMncLD_H0l^r0RilG4`3E>7Znd-8u(4W&0~A) zV1Ht<*+&{Z@awDsk%k}j{427`9=N%bLr_R8!CzttW=%j9UP%IBlOzzi@=sF0!^|AYAu^8T$Qqg zc~Z78OUf2@YegZ2QF_Tn0=vIXmOzQnB9%a+hcIRY=6<~$`MUjY;B|XP9rB)m-pC}b zlp2K+sS#WrgGYMy>K$Zpn$|(EnSO?iPo)ARVPYo0?WQ3+zFf6M5FBOC5GPDW4>ZF7 zDAczaR0)d!-~fhZ1(fK_t+>c=iHd zx7GOk2HCZdUq6U9#GB$R@ixB`OmrAezrM2JE)ia6_&vBb?*qI(&r((VM+Y1Jdvr56A}w+(oZLEXpIdL0%x&;cL zaW*zh+>W${7;H<{jgpeUv*fWcRGTkwUS#}8U;YyR1@`MNLI zZwJaD2^lGgUdTSA)nw9Y)3gw^RufpThd=u6%DbF?&AiRz?nv@i=GS~Pvmj>obha#4 zclYGvg#MOj)%@tQK#m51=&bjk!G4!r{dn0~t80x5>0}nK#Pn{?@%cc21V;Q_{&Mb4 z{6_@BFM!wPzb3#tJ;K+|zt2|VG8@n-1bE()K%r8A8om_>{iXgSKG62cp=pgfd`73z z90{~jX%x*-wFc<}7aOCD#gzRWwCVL#Yq#U1tc(dzyQN*SQY# z^d0fGrX5-9nVocYCmfo(mkaE8kr_J|b<$RBM)ABDsy-y^ z|M>bqmYMwH;G1!mXBknZkY^)m)JSkskOxBACnx{|12Cvsp01s}Op(**aZhlH1^br2 z2ZDz94;fuY2x4bcA{!E;FTU!t;U{R2-J zX!6Lqh)MGwmqViNy8J3I8@k!w9@8ru1YBsE4l-6?1y*1Mg`o37TwqY%v;hz1#`g<& z!E^}lsb*}g7!dm06+wYAW+6Zd7=U|yG!+Ox4=Yeimn#S&Ej1JU6(l0PjO@u}6-?0r zE7<6KIA=kj5J86mGt{UzN}W!*osAZ5c$S+`HKRnEjaMUzZ$~?|>JU#&t%F;;daXJI zhv{HV_T*c%8$~TkD*Nipq1-Hg%iC8G^lT=f6-INEcapRKTTAzlpw4 zs7SFAe>kC($1_v8pVjFf7H>XI`QRTPgMbhb60ETnLyGBqvFt>&5gErh0S1xa{BRkR zsgO*EhVGyP*dfDqJACUq7aVff5l5XzFvV$5sXy1UksRR zhFPYWO>d4TMm#jvQ$~+HBk~BLA{c@rG=z>Y5GKMx*a!#VA|8k*Szpy&(_KU9YOdF4 z3~{rpl_J7=6Y&j%eMYR(-PKw1uNl){HAiTop+p-qLZg*qsY}(ai$`MrM>Aa&CfA2O eQJed7y(Th*9tAV#vCjIY>Obmy4EZ{nI0pa(xk_&U literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a13fb080feada1f051a039ce619a6085ee6d8dc9 GIT binary patch literal 15184 zcmV-WJFmodPew8T0RR9106S0s5C8xG0EEy006Odd0RR9100000000000000000000 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8c68e5744e4d08f9d720f9607d42d683c125f9fd GIT binary patch literal 16316 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..dac52ff90d4d35049fb62e9703259ddd48d26007 GIT binary patch literal 16556 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 OD_83@4G(X(2mk;RyE=FP literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..21502e09358f34ff5107052b8fe4ea20a1d98721 GIT binary patch literal 16520 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 new nt(typeof r == "string" ? r : r + "", void 0, Y), ht = (r, t) => { - if (k) r.adoptedStyleSheets = t.map((e) => e instanceof CSSStyleSheet ? e : e.styleSheet); +const U = (i) => new ot(typeof i == "string" ? i : i + "", void 0, tt), ht = (i, t) => { + if (M) i.adoptedStyleSheets = t.map((e) => e instanceof CSSStyleSheet ? e : e.styleSheet); else for (const e of t) { - const s = document.createElement("style"), i = U.litNonce; - i !== void 0 && s.setAttribute("nonce", i), s.textContent = e.cssText, r.appendChild(s); + const s = document.createElement("style"), n = k.litNonce; + n !== void 0 && s.setAttribute("nonce", n), s.textContent = e.cssText, i.appendChild(s); } -}, q = k ? (r) => r : (r) => r instanceof CSSStyleSheet ? ((t) => { +}, I = M ? (i) => i : (i) => i instanceof CSSStyleSheet ? ((t) => { let e = ""; for (const s of t.cssRules) e += s.cssText; - return ot(e); -})(r) : r; -const { is: at, defineProperty: lt, getOwnPropertyDescriptor: ct, getOwnPropertyNames: dt, getOwnPropertySymbols: pt, getPrototypeOf: ut } = Object, H = globalThis, Q = H.trustedTypes, $t = Q ? Q.emptyScript : "", _t = H.reactiveElementPolyfillSupport, b = (r, t) => r, R = { toAttribute(r, t) { + return U(e); +})(i) : i; +const { is: dt, defineProperty: pt, getOwnPropertyDescriptor: lt, getOwnPropertyNames: ct, getOwnPropertySymbols: ut, getPrototypeOf: $t } = Object, z = globalThis, Q = z.trustedTypes, ft = Q ? Q.emptyScript : "", gt = z.reactiveElementPolyfillSupport, E = (i, t) => i, T = { toAttribute(i, t) { switch (t) { case Boolean: - r = r ? $t : null; + i = i ? ft : null; break; case Object: case Array: - r = r == null ? r : JSON.stringify(r); + i = i == null ? i : JSON.stringify(i); } - return r; -}, fromAttribute(r, t) { - let e = r; + return i; +}, fromAttribute(i, t) { + let e = i; switch (t) { case Boolean: - e = r !== null; + e = i !== null; break; case Number: - e = r === null ? null : Number(r); + e = i === null ? null : Number(i); break; case Object: case Array: try { - e = JSON.parse(r); + e = JSON.parse(i); } catch { e = null; } } return e; -} }, tt = (r, t) => !at(r, t), W = { attribute: !0, type: String, converter: R, reflect: !1, useDefault: !1, hasChanged: tt }; -Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), H.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); -let y = class extends HTMLElement { +} }, et = (i, t) => !dt(i, t), W = { attribute: !0, type: String, converter: T, reflect: !1, useDefault: !1, hasChanged: et }; +Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), z.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); +let b = class extends HTMLElement { static addInitializer(t) { this._$Ei(), (this.l ??= []).push(t); } @@ -67,44 +67,44 @@ let y = class extends HTMLElement { } static createProperty(t, e = W) { 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 s = /* @__PURE__ */ Symbol(), i = this.getPropertyDescriptor(t, s, e); - i !== void 0 && lt(this.prototype, t, i); + const s = /* @__PURE__ */ Symbol(), n = this.getPropertyDescriptor(t, s, e); + n !== void 0 && pt(this.prototype, t, n); } } static getPropertyDescriptor(t, e, s) { - const { get: i, set: n } = ct(this.prototype, t) ?? { get() { + const { get: n, set: r } = lt(this.prototype, t) ?? { get() { return this[e]; - }, set(o) { - this[e] = o; + }, set(a) { + this[e] = a; } }; - return { get: i, set(o) { - const l = i?.call(this); - n?.call(this, o), this.requestUpdate(t, l, s); + return { get: n, set(a) { + const d = n?.call(this); + r?.call(this, a), this.requestUpdate(t, d, s); }, configurable: !0, enumerable: !0 }; } static getPropertyOptions(t) { return this.elementProperties.get(t) ?? W; } static _$Ei() { - if (this.hasOwnProperty(b("elementProperties"))) return; - const t = ut(this); + if (this.hasOwnProperty(E("elementProperties"))) return; + const t = $t(this); t.finalize(), t.l !== void 0 && (this.l = [...t.l]), this.elementProperties = new Map(t.elementProperties); } static finalize() { - if (this.hasOwnProperty(b("finalized"))) return; - if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(b("properties"))) { - const e = this.properties, s = [...dt(e), ...pt(e)]; - for (const i of s) this.createProperty(i, e[i]); + if (this.hasOwnProperty(E("finalized"))) return; + if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(E("properties"))) { + const e = this.properties, s = [...ct(e), ...ut(e)]; + for (const n of s) this.createProperty(n, e[n]); } const t = this[Symbol.metadata]; if (t !== null) { const e = litPropertyMetadata.get(t); - if (e !== void 0) for (const [s, i] of e) this.elementProperties.set(s, i); + if (e !== void 0) for (const [s, n] of e) this.elementProperties.set(s, n); } this._$Eh = /* @__PURE__ */ new Map(); for (const [e, s] of this.elementProperties) { - const i = this._$Eu(e, s); - i !== void 0 && this._$Eh.set(i, e); + const n = this._$Eu(e, s); + n !== void 0 && this._$Eh.set(n, e); } this.elementStyles = this.finalizeStyles(this.styles); } @@ -112,8 +112,8 @@ let y = class extends HTMLElement { const e = []; if (Array.isArray(t)) { const s = new Set(t.flat(1 / 0).reverse()); - for (const i of s) e.unshift(q(i)); - } else t !== void 0 && e.push(q(t)); + for (const n of s) e.unshift(I(n)); + } else t !== void 0 && e.push(I(t)); return e; } static _$Eu(t, e) { @@ -153,31 +153,31 @@ let y = class extends HTMLElement { this._$AK(t, s); } _$ET(t, e) { - const s = this.constructor.elementProperties.get(t), i = this.constructor._$Eu(t, s); - if (i !== void 0 && s.reflect === !0) { - const n = (s.converter?.toAttribute !== void 0 ? s.converter : R).toAttribute(e, s.type); - this._$Em = t, n == null ? this.removeAttribute(i) : this.setAttribute(i, n), this._$Em = null; + const s = this.constructor.elementProperties.get(t), n = this.constructor._$Eu(t, s); + if (n !== void 0 && s.reflect === !0) { + const r = (s.converter?.toAttribute !== void 0 ? s.converter : T).toAttribute(e, s.type); + this._$Em = t, r == null ? this.removeAttribute(n) : this.setAttribute(n, r), this._$Em = null; } } _$AK(t, e) { - const s = this.constructor, i = s._$Eh.get(t); - if (i !== void 0 && this._$Em !== i) { - const n = s.getPropertyOptions(i), o = typeof n.converter == "function" ? { fromAttribute: n.converter } : n.converter?.fromAttribute !== void 0 ? n.converter : R; - this._$Em = i; - const l = o.fromAttribute(e, n.type); - this[i] = l ?? this._$Ej?.get(i) ?? l, this._$Em = null; + const s = this.constructor, n = s._$Eh.get(t); + if (n !== void 0 && this._$Em !== n) { + const r = s.getPropertyOptions(n), a = typeof r.converter == "function" ? { fromAttribute: r.converter } : r.converter?.fromAttribute !== void 0 ? r.converter : T; + this._$Em = n; + const d = a.fromAttribute(e, r.type); + this[n] = d ?? this._$Ej?.get(n) ?? d, this._$Em = null; } } - requestUpdate(t, e, s, i = !1, n) { + requestUpdate(t, e, s, n = !1, r) { if (t !== void 0) { - const o = this.constructor; - if (i === !1 && (n = this[t]), s ??= o.getPropertyOptions(t), !((s.hasChanged ?? tt)(n, e) || s.useDefault && s.reflect && n === this._$Ej?.get(t) && !this.hasAttribute(o._$Eu(t, s)))) return; + const a = this.constructor; + if (n === !1 && (r = this[t]), s ??= a.getPropertyOptions(t), !((s.hasChanged ?? et)(r, e) || s.useDefault && s.reflect && r === this._$Ej?.get(t) && !this.hasAttribute(a._$Eu(t, s)))) return; this.C(t, e, s); } this.isUpdatePending === !1 && (this._$ES = this._$EP()); } - C(t, e, { useDefault: s, reflect: i, wrapped: n }, o) { - s && !(this._$Ej ??= /* @__PURE__ */ new Map()).has(t) && (this._$Ej.set(t, o ?? e ?? this[t]), n !== !0 || o !== void 0) || (this._$AL.has(t) || (this.hasUpdated || s || (e = void 0), this._$AL.set(t, e)), i === !0 && this._$Em !== t && (this._$Eq ??= /* @__PURE__ */ new Set()).add(t)); + C(t, e, { useDefault: s, reflect: n, wrapped: r }, a) { + s && !(this._$Ej ??= /* @__PURE__ */ new Map()).has(t) && (this._$Ej.set(t, a ?? e ?? this[t]), r !== !0 || a !== void 0) || (this._$AL.has(t) || (this.hasUpdated || s || (e = void 0), this._$AL.set(t, e)), n === !0 && this._$Em !== t && (this._$Eq ??= /* @__PURE__ */ new Set()).add(t)); } async _$EP() { this.isUpdatePending = !0; @@ -196,13 +196,13 @@ let y = class extends HTMLElement { if (!this.isUpdatePending) return; if (!this.hasUpdated) { if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) { - for (const [i, n] of this._$Ep) this[i] = n; + for (const [n, r] of this._$Ep) this[n] = r; this._$Ep = void 0; } const s = this.constructor.elementProperties; - if (s.size > 0) for (const [i, n] of s) { - const { wrapped: o } = n, l = this[i]; - o !== !0 || this._$AL.has(i) || l === void 0 || this.C(i, void 0, n, l); + if (s.size > 0) for (const [n, r] of s) { + const { wrapped: a } = r, d = this[n]; + a !== !0 || this._$AL.has(n) || d === void 0 || this.C(n, void 0, r, d); } } let t = !1; @@ -239,70 +239,70 @@ let y = class extends HTMLElement { firstUpdated(t) { } }; -y.elementStyles = [], y.shadowRootOptions = { mode: "open" }, y[b("elementProperties")] = /* @__PURE__ */ new Map(), y[b("finalized")] = /* @__PURE__ */ new Map(), _t?.({ ReactiveElement: y }), (H.reactiveElementVersions ??= []).push("2.1.2"); -const L = globalThis, V = (r) => r, O = L.trustedTypes, K = O ? O.createPolicy("lit-html", { createHTML: (r) => r }) : void 0, et = "$lit$", _ = `lit$${Math.random().toFixed(9).slice(2)}$`, st = "?" + _, ft = `<${st}>`, g = document, w = () => g.createComment(""), C = (r) => r === null || typeof r != "object" && typeof r != "function", z = Array.isArray, At = (r) => z(r) || typeof r?.[Symbol.iterator] == "function", T = `[ -\f\r]`, S = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, Z = /-->/g, J = />/g, f = RegExp(`>|${T}(?:([^\\s"'>=/]+)(${T}*=${T}*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`, "g"), F = /'/g, G = /"/g, it = /^(?:script|style|textarea|title)$/i, mt = (r) => (t, ...e) => ({ _$litType$: r, strings: t, values: e }), j = mt(1), v = /* @__PURE__ */ Symbol.for("lit-noChange"), d = /* @__PURE__ */ Symbol.for("lit-nothing"), X = /* @__PURE__ */ new WeakMap(), A = g.createTreeWalker(g, 129); -function rt(r, t) { - if (!z(r) || !r.hasOwnProperty("raw")) throw Error("invalid template strings array"); +b.elementStyles = [], b.shadowRootOptions = { mode: "open" }, b[E("elementProperties")] = /* @__PURE__ */ new Map(), b[E("finalized")] = /* @__PURE__ */ new Map(), gt?.({ ReactiveElement: b }), (z.reactiveElementVersions ??= []).push("2.1.2"); +const R = globalThis, V = (i) => i, N = R.trustedTypes, K = N ? N.createPolicy("lit-html", { createHTML: (i) => i }) : void 0, st = "$lit$", f = `lit$${Math.random().toFixed(9).slice(2)}$`, nt = "?" + f, _t = `<${nt}>`, y = document, x = () => y.createComment(""), w = (i) => i === null || typeof i != "object" && typeof i != "function", D = Array.isArray, mt = (i) => D(i) || typeof i?.[Symbol.iterator] == "function", H = `[ +\f\r]`, v = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, Z = /-->/g, J = />/g, g = RegExp(`>|${H}(?:([^\\s"'>=/]+)(${H}*=${H}*(?:[^ +\f\r"'\`<>=]|("|')|))|$)`, "g"), G = /'/g, X = /"/g, it = /^(?:script|style|textarea|title)$/i, yt = (i) => (t, ...e) => ({ _$litType$: i, strings: t, values: e }), B = yt(1), A = /* @__PURE__ */ Symbol.for("lit-noChange"), l = /* @__PURE__ */ Symbol.for("lit-nothing"), Y = /* @__PURE__ */ new WeakMap(), _ = y.createTreeWalker(y, 129); +function rt(i, t) { + if (!D(i) || !i.hasOwnProperty("raw")) throw Error("invalid template strings array"); return K !== void 0 ? K.createHTML(t) : t; } -const gt = (r, t) => { - const e = r.length - 1, s = []; - let i, n = t === 2 ? "" : t === 3 ? "" : "", o = S; - for (let l = 0; l < e; l++) { - const h = r[l]; - let c, p, a = -1, u = 0; - for (; u < h.length && (o.lastIndex = u, p = o.exec(h), p !== null); ) u = o.lastIndex, o === S ? p[1] === "!--" ? o = Z : p[1] !== void 0 ? o = J : p[2] !== void 0 ? (it.test(p[2]) && (i = RegExp("" ? (o = i ?? S, a = -1) : p[1] === void 0 ? a = -2 : (a = o.lastIndex - p[2].length, c = p[1], o = p[3] === void 0 ? f : p[3] === '"' ? G : F) : o === G || o === F ? o = f : o === Z || o === J ? o = S : (o = f, i = void 0); - const $ = o === f && r[l + 1].startsWith("/>") ? " " : ""; - n += o === S ? h + ft : a >= 0 ? (s.push(c), h.slice(0, a) + et + h.slice(a) + _ + $) : h + _ + (a === -2 ? l : $); +const bt = (i, t) => { + const e = i.length - 1, s = []; + let n, r = t === 2 ? "" : t === 3 ? "" : "", a = v; + for (let d = 0; d < e; d++) { + const o = i[d]; + let p, c, h = -1, u = 0; + for (; u < o.length && (a.lastIndex = u, c = a.exec(o), c !== null); ) u = a.lastIndex, a === v ? c[1] === "!--" ? a = Z : c[1] !== void 0 ? a = J : c[2] !== void 0 ? (it.test(c[2]) && (n = RegExp("" ? (a = n ?? v, h = -1) : c[1] === void 0 ? h = -2 : (h = a.lastIndex - c[2].length, p = c[1], a = c[3] === void 0 ? g : c[3] === '"' ? X : G) : a === X || a === G ? a = g : a === Z || a === J ? a = v : (a = g, n = void 0); + const $ = a === g && i[d + 1].startsWith("/>") ? " " : ""; + r += a === v ? o + _t : h >= 0 ? (s.push(p), o.slice(0, h) + st + o.slice(h) + f + $) : o + f + (h === -2 ? d : $); } - return [rt(r, n + (r[e] || "") + (t === 2 ? "" : t === 3 ? "" : "")), s]; + return [rt(i, r + (i[e] || "") + (t === 2 ? "" : t === 3 ? "" : "")), s]; }; -class P { +class S { constructor({ strings: t, _$litType$: e }, s) { - let i; + let n; this.parts = []; - let n = 0, o = 0; - const l = t.length - 1, h = this.parts, [c, p] = gt(t, e); - if (this.el = P.createElement(c, s), A.currentNode = this.el.content, e === 2 || e === 3) { - const a = this.el.content.firstChild; - a.replaceWith(...a.childNodes); + let r = 0, a = 0; + const d = t.length - 1, o = this.parts, [p, c] = bt(t, e); + if (this.el = S.createElement(p, s), _.currentNode = this.el.content, e === 2 || e === 3) { + const h = this.el.content.firstChild; + h.replaceWith(...h.childNodes); } - for (; (i = A.nextNode()) !== null && h.length < l; ) { - if (i.nodeType === 1) { - if (i.hasAttributes()) for (const a of i.getAttributeNames()) if (a.endsWith(et)) { - const u = p[o++], $ = i.getAttribute(a).split(_), N = /([.?@])?(.*)/.exec(u); - h.push({ type: 1, index: n, name: N[2], strings: $, ctor: N[1] === "." ? vt : N[1] === "?" ? Et : N[1] === "@" ? St : M }), i.removeAttribute(a); - } else a.startsWith(_) && (h.push({ type: 6, index: n }), i.removeAttribute(a)); - if (it.test(i.tagName)) { - const a = i.textContent.split(_), u = a.length - 1; + for (; (n = _.nextNode()) !== null && o.length < d; ) { + if (n.nodeType === 1) { + if (n.hasAttributes()) for (const h of n.getAttributeNames()) if (h.endsWith(st)) { + const u = c[a++], $ = n.getAttribute(h).split(f), P = /([.?@])?(.*)/.exec(u); + o.push({ type: 1, index: r, name: P[2], strings: $, ctor: P[1] === "." ? qt : P[1] === "?" ? vt : P[1] === "@" ? Et : O }), n.removeAttribute(h); + } else h.startsWith(f) && (o.push({ type: 6, index: r }), n.removeAttribute(h)); + if (it.test(n.tagName)) { + const h = n.textContent.split(f), u = h.length - 1; if (u > 0) { - i.textContent = O ? O.emptyScript : ""; - for (let $ = 0; $ < u; $++) i.append(a[$], w()), A.nextNode(), h.push({ type: 2, index: ++n }); - i.append(a[u], w()); + n.textContent = N ? N.emptyScript : ""; + for (let $ = 0; $ < u; $++) n.append(h[$], x()), _.nextNode(), o.push({ type: 2, index: ++r }); + n.append(h[u], x()); } } - } else if (i.nodeType === 8) if (i.data === st) h.push({ type: 2, index: n }); + } else if (n.nodeType === 8) if (n.data === nt) o.push({ type: 2, index: r }); else { - let a = -1; - for (; (a = i.data.indexOf(_, a + 1)) !== -1; ) h.push({ type: 7, index: n }), a += _.length - 1; + let h = -1; + for (; (h = n.data.indexOf(f, h + 1)) !== -1; ) o.push({ type: 7, index: r }), h += f.length - 1; } - n++; + r++; } } static createElement(t, e) { - const s = g.createElement("template"); + const s = y.createElement("template"); return s.innerHTML = t, s; } } -function E(r, t, e = r, s) { - if (t === v) return t; - let i = s !== void 0 ? e._$Co?.[s] : e._$Cl; - const n = C(t) ? void 0 : t._$litDirective$; - return i?.constructor !== n && (i?._$AO?.(!1), n === void 0 ? i = void 0 : (i = new n(r), i._$AT(r, e, s)), s !== void 0 ? (e._$Co ??= [])[s] = i : e._$Cl = i), i !== void 0 && (t = E(r, i._$AS(r, t.values), i, s)), t; +function q(i, t, e = i, s) { + if (t === A) return t; + let n = s !== void 0 ? e._$Co?.[s] : e._$Cl; + const r = w(t) ? void 0 : t._$litDirective$; + return n?.constructor !== r && (n?._$AO?.(!1), r === void 0 ? n = void 0 : (n = new r(i), n._$AT(i, e, s)), s !== void 0 ? (e._$Co ??= [])[s] = n : e._$Cl = n), n !== void 0 && (t = q(i, n._$AS(i, t.values), n, s)), t; } -class yt { +class At { constructor(t, e) { this._$AV = [], this._$AN = void 0, this._$AD = t, this._$AM = e; } @@ -313,29 +313,29 @@ class yt { return this._$AM._$AU; } u(t) { - const { el: { content: e }, parts: s } = this._$AD, i = (t?.creationScope ?? g).importNode(e, !0); - A.currentNode = i; - let n = A.nextNode(), o = 0, l = 0, h = s[0]; - for (; h !== void 0; ) { - if (o === h.index) { - let c; - h.type === 2 ? c = new x(n, n.nextSibling, this, t) : h.type === 1 ? c = new h.ctor(n, h.name, h.strings, this, t) : h.type === 6 && (c = new bt(n, this, t)), this._$AV.push(c), h = s[++l]; + const { el: { content: e }, parts: s } = this._$AD, n = (t?.creationScope ?? y).importNode(e, !0); + _.currentNode = n; + let r = _.nextNode(), a = 0, d = 0, o = s[0]; + for (; o !== void 0; ) { + if (a === o.index) { + let p; + o.type === 2 ? p = new C(r, r.nextSibling, this, t) : o.type === 1 ? p = new o.ctor(r, o.name, o.strings, this, t) : o.type === 6 && (p = new xt(r, this, t)), this._$AV.push(p), o = s[++d]; } - o !== h?.index && (n = A.nextNode(), o++); + a !== o?.index && (r = _.nextNode(), a++); } - return A.currentNode = g, i; + return _.currentNode = y, n; } p(t) { let e = 0; for (const s of this._$AV) s !== void 0 && (s.strings !== void 0 ? (s._$AI(t, s, e), e += s.strings.length - 2) : s._$AI(t[e])), e++; } } -class x { +class C { get _$AU() { return this._$AM?._$AU ?? this._$Cv; } - constructor(t, e, s, i) { - this.type = 2, this._$AH = d, this._$AN = void 0, this._$AA = t, this._$AB = e, this._$AM = s, this.options = i, this._$Cv = i?.isConnected ?? !0; + constructor(t, e, s, n) { + this.type = 2, this._$AH = l, this._$AN = void 0, this._$AA = t, this._$AB = e, this._$AM = s, this.options = n, this._$Cv = n?.isConnected ?? !0; } get parentNode() { let t = this._$AA.parentNode; @@ -349,7 +349,7 @@ class x { return this._$AB; } _$AI(t, e = this) { - t = E(this, t, e), C(t) ? t === d || t == null || t === "" ? (this._$AH !== d && this._$AR(), this._$AH = d) : t !== this._$AH && t !== v && this._(t) : t._$litType$ !== void 0 ? this.$(t) : t.nodeType !== void 0 ? this.T(t) : At(t) ? this.k(t) : this._(t); + t = q(this, t, e), w(t) ? t === l || t == null || t === "" ? (this._$AH !== l && this._$AR(), this._$AH = l) : t !== this._$AH && t !== A && this._(t) : t._$litType$ !== void 0 ? this.$(t) : t.nodeType !== void 0 ? this.T(t) : mt(t) ? this.k(t) : this._(t); } O(t) { return this._$AA.parentNode.insertBefore(t, this._$AB); @@ -358,26 +358,26 @@ class x { this._$AH !== t && (this._$AR(), this._$AH = this.O(t)); } _(t) { - this._$AH !== d && C(this._$AH) ? this._$AA.nextSibling.data = t : this.T(g.createTextNode(t)), this._$AH = t; + this._$AH !== l && w(this._$AH) ? this._$AA.nextSibling.data = t : this.T(y.createTextNode(t)), this._$AH = t; } $(t) { - const { values: e, _$litType$: s } = t, i = typeof s == "number" ? this._$AC(t) : (s.el === void 0 && (s.el = P.createElement(rt(s.h, s.h[0]), this.options)), s); - if (this._$AH?._$AD === i) this._$AH.p(e); + const { values: e, _$litType$: s } = t, n = typeof s == "number" ? this._$AC(t) : (s.el === void 0 && (s.el = S.createElement(rt(s.h, s.h[0]), this.options)), s); + if (this._$AH?._$AD === n) this._$AH.p(e); else { - const n = new yt(i, this), o = n.u(this.options); - n.p(e), this.T(o), this._$AH = n; + const r = new At(n, this), a = r.u(this.options); + r.p(e), this.T(a), this._$AH = r; } } _$AC(t) { - let e = X.get(t.strings); - return e === void 0 && X.set(t.strings, e = new P(t)), e; + let e = Y.get(t.strings); + return e === void 0 && Y.set(t.strings, e = new S(t)), e; } k(t) { - z(this._$AH) || (this._$AH = [], this._$AR()); + D(this._$AH) || (this._$AH = [], this._$AR()); const e = this._$AH; - let s, i = 0; - for (const n of t) i === e.length ? e.push(s = new x(this.O(w()), this.O(w()), this, this.options)) : s = e[i], s._$AI(n), i++; - i < e.length && (this._$AR(s && s._$AB.nextSibling, i), e.length = i); + let s, n = 0; + for (const r of t) n === e.length ? e.push(s = new C(this.O(x()), this.O(x()), this, this.options)) : s = e[n], s._$AI(r), n++; + n < e.length && (this._$AR(s && s._$AB.nextSibling, n), e.length = n); } _$AR(t = this._$AA.nextSibling, e) { for (this._$AP?.(!1, !0, e); t !== this._$AB; ) { @@ -389,61 +389,61 @@ class x { this._$AM === void 0 && (this._$Cv = t, this._$AP?.(t)); } } -class M { +class O { get tagName() { return this.element.tagName; } get _$AU() { return this._$AM._$AU; } - constructor(t, e, s, i, n) { - this.type = 1, this._$AH = d, this._$AN = void 0, this.element = t, this.name = e, this._$AM = i, this.options = n, s.length > 2 || s[0] !== "" || s[1] !== "" ? (this._$AH = Array(s.length - 1).fill(new String()), this.strings = s) : this._$AH = d; + constructor(t, e, s, n, r) { + this.type = 1, this._$AH = l, this._$AN = void 0, this.element = t, this.name = e, this._$AM = n, this.options = r, s.length > 2 || s[0] !== "" || s[1] !== "" ? (this._$AH = Array(s.length - 1).fill(new String()), this.strings = s) : this._$AH = l; } - _$AI(t, e = this, s, i) { - const n = this.strings; - let o = !1; - if (n === void 0) t = E(this, t, e, 0), o = !C(t) || t !== this._$AH && t !== v, o && (this._$AH = t); + _$AI(t, e = this, s, n) { + const r = this.strings; + let a = !1; + if (r === void 0) t = q(this, t, e, 0), a = !w(t) || t !== this._$AH && t !== A, a && (this._$AH = t); else { - const l = t; - let h, c; - for (t = n[0], h = 0; h < n.length - 1; h++) c = E(this, l[s + h], e, h), c === v && (c = this._$AH[h]), o ||= !C(c) || c !== this._$AH[h], c === d ? t = d : t !== d && (t += (c ?? "") + n[h + 1]), this._$AH[h] = c; + const d = t; + let o, p; + for (t = r[0], o = 0; o < r.length - 1; o++) p = q(this, d[s + o], e, o), p === A && (p = this._$AH[o]), a ||= !w(p) || p !== this._$AH[o], p === l ? t = l : t !== l && (t += (p ?? "") + r[o + 1]), this._$AH[o] = p; } - o && !i && this.j(t); + a && !n && this.j(t); } j(t) { - t === d ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t ?? ""); + t === l ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t ?? ""); } } -class vt extends M { +class qt extends O { constructor() { super(...arguments), this.type = 3; } j(t) { - this.element[this.name] = t === d ? void 0 : t; + this.element[this.name] = t === l ? void 0 : t; } } -class Et extends M { +class vt extends O { constructor() { super(...arguments), this.type = 4; } j(t) { - this.element.toggleAttribute(this.name, !!t && t !== d); + this.element.toggleAttribute(this.name, !!t && t !== l); } } -class St extends M { - constructor(t, e, s, i, n) { - super(t, e, s, i, n), this.type = 5; +class Et extends O { + constructor(t, e, s, n, r) { + super(t, e, s, n, r), this.type = 5; } _$AI(t, e = this) { - if ((t = E(this, t, e, 0) ?? d) === v) return; - const s = this._$AH, i = t === d && s !== d || t.capture !== s.capture || t.once !== s.once || t.passive !== s.passive, n = t !== d && (s === d || i); - i && this.element.removeEventListener(this.name, this, s), n && this.element.addEventListener(this.name, this, t), this._$AH = t; + if ((t = q(this, t, e, 0) ?? l) === A) return; + const s = this._$AH, n = t === l && s !== l || t.capture !== s.capture || t.once !== s.once || t.passive !== s.passive, r = t !== l && (s === l || n); + n && this.element.removeEventListener(this.name, this, s), 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 bt { +class xt { constructor(t, e, s) { this.element = t, this.type = 6, this._$AN = void 0, this._$AM = e, this.options = s; } @@ -451,22 +451,22 @@ class bt { return this._$AM._$AU; } _$AI(t) { - E(this, t); + q(this, t); } } -const wt = L.litHtmlPolyfillSupport; -wt?.(P, x), (L.litHtmlVersions ??= []).push("3.3.3"); -const Ct = (r, t, e) => { +const wt = R.litHtmlPolyfillSupport; +wt?.(S, C), (R.litHtmlVersions ??= []).push("3.3.3"); +const St = (i, t, e) => { const s = e?.renderBefore ?? t; - let i = s._$litPart$; - if (i === void 0) { - const n = e?.renderBefore ?? null; - s._$litPart$ = i = new x(t.insertBefore(w(), n), n, void 0, e ?? {}); + let n = s._$litPart$; + if (n === void 0) { + const r = e?.renderBefore ?? null; + s._$litPart$ = n = new C(t.insertBefore(x(), r), r, void 0, e ?? {}); } - return i._$AI(r), i; + return n._$AI(i), n; }; -const D = globalThis; -class m extends y { +const F = globalThis; +class m extends b { constructor() { super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0; } @@ -476,7 +476,7 @@ class m extends y { } update(t) { const e = this.render(); - this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t), this._$Do = Ct(e, this.renderRoot, this.renderOptions); + this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t), this._$Do = St(e, this.renderRoot, this.renderOptions); } connectedCallback() { super.connectedCallback(), this._$Do?.setConnected(!0); @@ -485,21 +485,23 @@ class m extends y { super.disconnectedCallback(), this._$Do?.setConnected(!1); } render() { - return v; + return A; } } -m._$litElement$ = !0, m.finalized = !0, D.litElementHydrateSupport?.({ LitElement: m }); -const Pt = D.litElementPolyfillSupport; -Pt?.({ LitElement: m }); -(D.litElementVersions ??= []).push("4.2.2"); -function B(r) { - window.customCards = window.customCards ?? [], window.customCards.push(r); +m._$litElement$ = !0, m.finalized = !0, F.litElementHydrateSupport?.({ LitElement: m }); +const Ct = F.litElementPolyfillSupport; +Ct?.({ LitElement: m }); +(F.litElementVersions ??= []).push("4.2.2"); +const at = ':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-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 L(i) { + window.customCards = window.customCards ?? [], window.customCards.push(i); } -class xt extends m { +class Pt extends m { static properties = { hass: { attribute: !1 }, _config: { state: !0 } }; + static styles = [U(at)]; hass; _config; setConfig(t) { @@ -511,20 +513,21 @@ class xt extends m { return 6; } render() { - return j`

    NestQuest Party Board
    `; + return B`
    NestQuest Party Board
    `; } } -customElements.define("nestquest-party-board-card", xt); -B({ +customElements.define("nestquest-party-board-card", Pt); +L({ type: "nestquest-party-board-card", name: "NestQuest Party Board", description: "Kids' attract screen for present and away adventurers." }); -class Nt extends m { +class kt extends m { static properties = { hass: { attribute: !1 }, _config: { state: !0 } }; + static styles = [U(at)]; hass; _config; setConfig(t) { @@ -536,20 +539,22 @@ class Nt extends m { return 8; } render() { - return j`
    NestQuest Quest Log
    `; + return B`
    NestQuest Quest Log
    `; } } -customElements.define("nestquest-quest-log-card", Nt); -B({ +customElements.define("nestquest-quest-log-card", kt); +L({ type: "nestquest-quest-log-card", name: "NestQuest Quest Log", description: "One child's daily quests, grouped by window." }); +const Nt = ":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 Ut extends m { static properties = { hass: { attribute: !1 }, _config: { state: !0 } }; + static styles = [U(Nt)]; hass; _config; setConfig(t) { @@ -561,12 +566,17 @@ class Ut extends m { return 8; } render() { - return j`
    NestQuest Admin
    `; + return B`
    NestQuest Admin
    `; } } customElements.define("nestquest-admin-card", Ut); -B({ +L({ type: "nestquest-admin-card", name: "NestQuest Admin", description: "Parent phone view for today, tasks, schedule, and history." }); +const zt = "/nestquest-static/nestquest-fonts.css"; +if (!document.querySelector('link[data-nq-fonts=""]')) { + const i = document.createElement("link"); + i.rel = "stylesheet", i.href = zt, i.dataset.nqFonts = "", document.head.appendChild(i); +} 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..f997486 --- /dev/null +++ b/custom_components/nestquest/www/nestquest-panel-tokens.css @@ -0,0 +1,94 @@ +/* ============================================================ + 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-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/frontend/src/cards/admin.ts b/frontend/src/cards/admin.ts index b15993b..580dd78 100644 --- a/frontend/src/cards/admin.ts +++ b/frontend/src/cards/admin.ts @@ -1,4 +1,5 @@ -import { LitElement, html } from "lit"; +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"; @@ -8,6 +9,8 @@ export class NestQuestAdminCard extends LitElement { _config: { state: true }, }; + static styles = [unsafeCSS(adminTokens)]; + hass?: HassLike; _config?: CardConfig; diff --git a/frontend/src/cards/party-board.ts b/frontend/src/cards/party-board.ts index d19bf8b..e6fbb38 100644 --- a/frontend/src/cards/party-board.ts +++ b/frontend/src/cards/party-board.ts @@ -1,4 +1,5 @@ -import { LitElement, html } from "lit"; +import { LitElement, html, unsafeCSS } from "lit"; +import panelTokens from "../../../custom_components/nestquest/www/nestquest-panel-tokens.css?inline"; import { registerCustomCard, type CardConfig, type HassLike } from "../types"; @@ -8,6 +9,8 @@ export class NestQuestPartyBoardCard extends LitElement { _config: { state: true }, }; + static styles = [unsafeCSS(panelTokens)]; + hass?: HassLike; _config?: CardConfig; diff --git a/frontend/src/cards/quest-log.ts b/frontend/src/cards/quest-log.ts index d56a788..b51929a 100644 --- a/frontend/src/cards/quest-log.ts +++ b/frontend/src/cards/quest-log.ts @@ -1,4 +1,5 @@ -import { LitElement, html } from "lit"; +import { LitElement, html, unsafeCSS } from "lit"; +import panelTokens from "../../../custom_components/nestquest/www/nestquest-panel-tokens.css?inline"; import { registerCustomCard, type CardConfig, type HassLike } from "../types"; @@ -8,6 +9,8 @@ export class NestQuestQuestLogCard extends LitElement { _config: { state: true }, }; + static styles = [unsafeCSS(panelTokens)]; + hass?: HassLike; _config?: CardConfig; diff --git a/frontend/src/nestquest-cards.ts b/frontend/src/nestquest-cards.ts index 3d08e0e..02316e3 100644 --- a/frontend/src/nestquest-cards.ts +++ b/frontend/src/nestquest-cards.ts @@ -1,3 +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/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/tests/test_frontend.py b/tests/test_frontend.py index 74e51a0..e32b1db 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -91,3 +91,67 @@ 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 + + +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}" From 1911b2bf6eb9af051be6ad056b8c24b2d27f897a Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Sun, 20 Sep 2026 12:39:47 -0400 Subject: [PATCH 38/46] Add the party board card (1a) read-only Add the party board card (1a) read-only --- custom_components/nestquest/binary_sensor.py | 11 + custom_components/nestquest/coordinator.py | 30 +- .../nestquest/www/nestquest-cards.js | 1121 ++++++++++++++--- frontend/src/cards/party-board.ts | 862 ++++++++++++- tests/test_binary_sensors.py | 54 +- tests/test_coordinator.py | 65 + 6 files changed, 1923 insertions(+), 220 deletions(-) diff --git a/custom_components/nestquest/binary_sensor.py b/custom_components/nestquest/binary_sensor.py index 7cd45b3..7c7fe10 100644 --- a/custom_components/nestquest/binary_sensor.py +++ b/custom_components/nestquest/binary_sensor.py @@ -135,6 +135,17 @@ class NestQuestPresentTodaySensor(_NestQuestChildBinarySensor): 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 diff --git a/custom_components/nestquest/coordinator.py b/custom_components/nestquest/coordinator.py index 08f9ad9..39dc777 100644 --- a/custom_components/nestquest/coordinator.py +++ b/custom_components/nestquest/coordinator.py @@ -50,6 +50,7 @@ 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, @@ -86,6 +87,11 @@ class ChildDaySnapshot: 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) @@ -162,10 +168,14 @@ class NestQuestCoordinator(DataUpdateCoordinator): schedules[child.id] = PresenceSchedule.decode( child.id, schedule.anchor_date, schedule.pattern ) - todays = await PresenceOverridesDao( + 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, today_iso) - if todays: + ).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). @@ -177,7 +187,7 @@ class NestQuestCoordinator(DataUpdateCoordinator): record.is_present, record.note, ) - for record in todays + for record in ranged ] engine = PresenceEngine(schedules, overrides) @@ -231,18 +241,28 @@ class NestQuestCoordinator(DataUpdateCoordinator): 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=engine.is_present(child.id, today), + 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, ) ) diff --git a/custom_components/nestquest/www/nestquest-cards.js b/custom_components/nestquest/www/nestquest-cards.js index db966a0..747ba7f 100644 --- a/custom_components/nestquest/www/nestquest-cards.js +++ b/custom_components/nestquest/www/nestquest-cards.js @@ -1,15 +1,15 @@ -const k = globalThis, M = k.ShadowRoot && (k.ShadyCSS === void 0 || k.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, tt = /* @__PURE__ */ Symbol(), j = /* @__PURE__ */ new WeakMap(); -let ot = class { - constructor(t, e, s) { - if (this._$cssResult$ = !0, s !== tt) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); +const T = globalThis, L = T.ShadowRoot && (T.ShadyCSS === void 0 || T.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, j = /* @__PURE__ */ Symbol(), K = /* @__PURE__ */ new WeakMap(); +let ct = class { + constructor(t, e, n) { + if (this._$cssResult$ = !0, n !== j) 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 (M && t === void 0) { - const s = e !== void 0 && e.length === 1; - s && (t = j.get(e)), t === void 0 && ((this.o = t = new CSSStyleSheet()).replaceSync(this.cssText), s && j.set(e, t)); + if (L && t === void 0) { + const n = e !== void 0 && e.length === 1; + n && (t = K.get(e)), t === void 0 && ((this.o = t = new CSSStyleSheet()).replaceSync(this.cssText), n && K.set(e, t)); } return t; } @@ -17,108 +17,115 @@ let ot = class { return this.cssText; } }; -const U = (i) => new ot(typeof i == "string" ? i : i + "", void 0, tt), ht = (i, t) => { - if (M) i.adoptedStyleSheets = t.map((e) => e instanceof CSSStyleSheet ? e : e.styleSheet); +const $ = (s) => new ct(typeof s == "string" ? s : s + "", void 0, j), _t = (s, ...t) => { + const e = s.length === 1 ? s[0] : t.reduce((n, i, r) => n + ((a) => { + if (a._$cssResult$ === !0) return a.cssText; + if (typeof a == "number") return a; + throw Error("Value passed to 'css' function must be a 'css' function result: " + a + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security."); + })(i) + s[r + 1], s[0]); + return new ct(e, s, j); +}, $t = (s, t) => { + if (L) s.adoptedStyleSheets = t.map((e) => e instanceof CSSStyleSheet ? e : e.styleSheet); else for (const e of t) { - const s = document.createElement("style"), n = k.litNonce; - n !== void 0 && s.setAttribute("nonce", n), s.textContent = e.cssText, i.appendChild(s); + const n = document.createElement("style"), i = T.litNonce; + i !== void 0 && n.setAttribute("nonce", i), n.textContent = e.cssText, s.appendChild(n); } -}, I = M ? (i) => i : (i) => i instanceof CSSStyleSheet ? ((t) => { +}, J = L ? (s) => s : (s) => s instanceof CSSStyleSheet ? ((t) => { let e = ""; - for (const s of t.cssRules) e += s.cssText; - return U(e); -})(i) : i; -const { is: dt, defineProperty: pt, getOwnPropertyDescriptor: lt, getOwnPropertyNames: ct, getOwnPropertySymbols: ut, getPrototypeOf: $t } = Object, z = globalThis, Q = z.trustedTypes, ft = Q ? Q.emptyScript : "", gt = z.reactiveElementPolyfillSupport, E = (i, t) => i, T = { toAttribute(i, t) { + for (const n of t.cssRules) e += n.cssText; + return $(e); +})(s) : s; +const { is: vt, defineProperty: qt, getOwnPropertyDescriptor: xt, getOwnPropertyNames: wt, getOwnPropertySymbols: At, getPrototypeOf: kt } = Object, M = globalThis, G = M.trustedTypes, Et = G ? G.emptyScript : "", Ct = M.reactiveElementPolyfillSupport, E = (s, t) => s, F = { toAttribute(s, t) { switch (t) { case Boolean: - i = i ? ft : null; + s = s ? Et : null; break; case Object: case Array: - i = i == null ? i : JSON.stringify(i); + s = s == null ? s : JSON.stringify(s); } - return i; -}, fromAttribute(i, t) { - let e = i; + return s; +}, fromAttribute(s, t) { + let e = s; switch (t) { case Boolean: - e = i !== null; + e = s !== null; break; case Number: - e = i === null ? null : Number(i); + e = s === null ? null : Number(s); break; case Object: case Array: try { - e = JSON.parse(i); + e = JSON.parse(s); } catch { e = null; } } return e; -} }, et = (i, t) => !dt(i, t), W = { attribute: !0, type: String, converter: T, reflect: !1, useDefault: !1, hasChanged: et }; -Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), z.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); -let b = class extends HTMLElement { +} }, ht = (s, t) => !vt(s, t), X = { attribute: !0, type: String, converter: F, reflect: !1, useDefault: !1, hasChanged: ht }; +Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), M.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); +let x = 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 = W) { + static createProperty(t, e = X) { 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 s = /* @__PURE__ */ Symbol(), n = this.getPropertyDescriptor(t, s, e); - n !== void 0 && pt(this.prototype, t, n); + const n = /* @__PURE__ */ Symbol(), i = this.getPropertyDescriptor(t, n, e); + i !== void 0 && qt(this.prototype, t, i); } } - static getPropertyDescriptor(t, e, s) { - const { get: n, set: r } = lt(this.prototype, t) ?? { get() { + static getPropertyDescriptor(t, e, n) { + const { get: i, set: r } = xt(this.prototype, t) ?? { get() { return this[e]; }, set(a) { this[e] = a; } }; - return { get: n, set(a) { - const d = n?.call(this); - r?.call(this, a), this.requestUpdate(t, d, s); + return { get: i, set(a) { + const l = i?.call(this); + r?.call(this, a), this.requestUpdate(t, l, n); }, configurable: !0, enumerable: !0 }; } static getPropertyOptions(t) { - return this.elementProperties.get(t) ?? W; + return this.elementProperties.get(t) ?? X; } static _$Ei() { if (this.hasOwnProperty(E("elementProperties"))) return; - const t = $t(this); + const t = kt(this); t.finalize(), t.l !== void 0 && (this.l = [...t.l]), this.elementProperties = new Map(t.elementProperties); } static finalize() { if (this.hasOwnProperty(E("finalized"))) return; if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(E("properties"))) { - const e = this.properties, s = [...ct(e), ...ut(e)]; - for (const n of s) this.createProperty(n, e[n]); + const e = this.properties, n = [...wt(e), ...At(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 [s, n] of e) this.elementProperties.set(s, n); + if (e !== void 0) for (const [n, i] of e) this.elementProperties.set(n, i); } this._$Eh = /* @__PURE__ */ new Map(); - for (const [e, s] of this.elementProperties) { - const n = this._$Eu(e, s); - n !== void 0 && this._$Eh.set(n, e); + 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 s = new Set(t.flat(1 / 0).reverse()); - for (const n of s) e.unshift(I(n)); - } else t !== void 0 && e.push(I(t)); + const n = new Set(t.flat(1 / 0).reverse()); + for (const i of n) e.unshift(J(i)); + } else t !== void 0 && e.push(J(t)); return e; } static _$Eu(t, e) { - const s = e.attribute; - return s === !1 ? void 0 : typeof s == "string" ? s : typeof t == "string" ? t.toLowerCase() : void 0; + 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(); @@ -134,12 +141,12 @@ let b = class extends HTMLElement { } _$E_() { const t = /* @__PURE__ */ new Map(), e = this.constructor.elementProperties; - for (const s of e.keys()) this.hasOwnProperty(s) && (t.set(s, this[s]), delete this[s]); + 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 ht(t, this.constructor.elementStyles), t; + return $t(t, this.constructor.elementStyles), t; } connectedCallback() { this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(!0), this._$EO?.forEach((t) => t.hostConnected?.()); @@ -149,35 +156,35 @@ let b = class extends HTMLElement { disconnectedCallback() { this._$EO?.forEach((t) => t.hostDisconnected?.()); } - attributeChangedCallback(t, e, s) { - this._$AK(t, s); + attributeChangedCallback(t, e, n) { + this._$AK(t, n); } _$ET(t, e) { - const s = this.constructor.elementProperties.get(t), n = this.constructor._$Eu(t, s); - if (n !== void 0 && s.reflect === !0) { - const r = (s.converter?.toAttribute !== void 0 ? s.converter : T).toAttribute(e, s.type); - this._$Em = t, r == null ? this.removeAttribute(n) : this.setAttribute(n, r), this._$Em = null; + 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 : F).toAttribute(e, n.type); + this._$Em = t, r == null ? this.removeAttribute(i) : this.setAttribute(i, r), this._$Em = null; } } _$AK(t, e) { - const s = this.constructor, n = s._$Eh.get(t); - if (n !== void 0 && this._$Em !== n) { - const r = s.getPropertyOptions(n), a = typeof r.converter == "function" ? { fromAttribute: r.converter } : r.converter?.fromAttribute !== void 0 ? r.converter : T; - this._$Em = n; - const d = a.fromAttribute(e, r.type); - this[n] = d ?? this._$Ej?.get(n) ?? d, this._$Em = null; + const n = this.constructor, i = n._$Eh.get(t); + if (i !== void 0 && this._$Em !== i) { + const r = n.getPropertyOptions(i), a = typeof r.converter == "function" ? { fromAttribute: r.converter } : r.converter?.fromAttribute !== void 0 ? r.converter : F; + this._$Em = i; + const l = a.fromAttribute(e, r.type); + this[i] = l ?? this._$Ej?.get(i) ?? l, this._$Em = null; } } - requestUpdate(t, e, s, n = !1, r) { + requestUpdate(t, e, n, i = !1, r) { if (t !== void 0) { const a = this.constructor; - if (n === !1 && (r = this[t]), s ??= a.getPropertyOptions(t), !((s.hasChanged ?? et)(r, e) || s.useDefault && s.reflect && r === this._$Ej?.get(t) && !this.hasAttribute(a._$Eu(t, s)))) return; - this.C(t, e, s); + if (i === !1 && (r = this[t]), n ??= a.getPropertyOptions(t), !((n.hasChanged ?? ht)(r, e) || n.useDefault && n.reflect && r === this._$Ej?.get(t) && !this.hasAttribute(a._$Eu(t, n)))) return; + this.C(t, e, n); } this.isUpdatePending === !1 && (this._$ES = this._$EP()); } - C(t, e, { useDefault: s, reflect: n, wrapped: r }, a) { - s && !(this._$Ej ??= /* @__PURE__ */ new Map()).has(t) && (this._$Ej.set(t, a ?? e ?? this[t]), r !== !0 || a !== void 0) || (this._$AL.has(t) || (this.hasUpdated || s || (e = void 0), this._$AL.set(t, e)), n === !0 && this._$Em !== t && (this._$Eq ??= /* @__PURE__ */ new Set()).add(t)); + C(t, e, { useDefault: n, reflect: i, wrapped: r }, a) { + n && !(this._$Ej ??= /* @__PURE__ */ new Map()).has(t) && (this._$Ej.set(t, a ?? e ?? this[t]), r !== !0 || a !== 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; @@ -196,21 +203,21 @@ let b = class extends HTMLElement { if (!this.isUpdatePending) return; if (!this.hasUpdated) { if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) { - for (const [n, r] of this._$Ep) this[n] = r; + for (const [i, r] of this._$Ep) this[i] = r; this._$Ep = void 0; } - const s = this.constructor.elementProperties; - if (s.size > 0) for (const [n, r] of s) { - const { wrapped: a } = r, d = this[n]; - a !== !0 || this._$AL.has(n) || d === void 0 || this.C(n, void 0, r, d); + const n = this.constructor.elementProperties; + if (n.size > 0) for (const [i, r] of n) { + const { wrapped: a } = r, l = this[i]; + a !== !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((s) => s.hostUpdate?.()), this.update(e)) : this._$EM(); - } catch (s) { - throw t = !1, this._$EM(), s; + 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); } @@ -239,70 +246,70 @@ let b = class extends HTMLElement { firstUpdated(t) { } }; -b.elementStyles = [], b.shadowRootOptions = { mode: "open" }, b[E("elementProperties")] = /* @__PURE__ */ new Map(), b[E("finalized")] = /* @__PURE__ */ new Map(), gt?.({ ReactiveElement: b }), (z.reactiveElementVersions ??= []).push("2.1.2"); -const R = globalThis, V = (i) => i, N = R.trustedTypes, K = N ? N.createPolicy("lit-html", { createHTML: (i) => i }) : void 0, st = "$lit$", f = `lit$${Math.random().toFixed(9).slice(2)}$`, nt = "?" + f, _t = `<${nt}>`, y = document, x = () => y.createComment(""), w = (i) => i === null || typeof i != "object" && typeof i != "function", D = Array.isArray, mt = (i) => D(i) || typeof i?.[Symbol.iterator] == "function", H = `[ -\f\r]`, v = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, Z = /-->/g, J = />/g, g = RegExp(`>|${H}(?:([^\\s"'>=/]+)(${H}*=${H}*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`, "g"), G = /'/g, X = /"/g, it = /^(?:script|style|textarea|title)$/i, yt = (i) => (t, ...e) => ({ _$litType$: i, strings: t, values: e }), B = yt(1), A = /* @__PURE__ */ Symbol.for("lit-noChange"), l = /* @__PURE__ */ Symbol.for("lit-nothing"), Y = /* @__PURE__ */ new WeakMap(), _ = y.createTreeWalker(y, 129); -function rt(i, t) { - if (!D(i) || !i.hasOwnProperty("raw")) throw Error("invalid template strings array"); - return K !== void 0 ? K.createHTML(t) : t; +x.elementStyles = [], x.shadowRootOptions = { mode: "open" }, x[E("elementProperties")] = /* @__PURE__ */ new Map(), x[E("finalized")] = /* @__PURE__ */ new Map(), Ct?.({ ReactiveElement: x }), (M.reactiveElementVersions ??= []).push("2.1.2"); +const B = globalThis, Y = (s) => s, z = B.trustedTypes, tt = z ? z.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, ut = "$lit$", y = `lit$${Math.random().toFixed(9).slice(2)}$`, ft = "?" + y, St = `<${ft}>`, q = document, C = () => q.createComment(""), S = (s) => s === null || typeof s != "object" && typeof s != "function", Q = Array.isArray, Nt = (s) => Q(s) || typeof s?.[Symbol.iterator] == "function", D = `[ +\f\r]`, k = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, et = /-->/g, nt = />/g, b = RegExp(`>|${D}(?:([^\\s"'>=/]+)(${D}*=${D}*(?:[^ +\f\r"'\`<>=]|("|')|))|$)`, "g"), st = /'/g, it = /"/g, gt = /^(?:script|style|textarea|title)$/i, Pt = (s) => (t, ...e) => ({ _$litType$: s, strings: t, values: e }), g = Pt(1), w = /* @__PURE__ */ Symbol.for("lit-noChange"), c = /* @__PURE__ */ Symbol.for("lit-nothing"), rt = /* @__PURE__ */ new WeakMap(), _ = q.createTreeWalker(q, 129); +function mt(s, t) { + if (!Q(s) || !s.hasOwnProperty("raw")) throw Error("invalid template strings array"); + return tt !== void 0 ? tt.createHTML(t) : t; } -const bt = (i, t) => { - const e = i.length - 1, s = []; - let n, r = t === 2 ? "" : t === 3 ? "" : "", a = v; - for (let d = 0; d < e; d++) { - const o = i[d]; - let p, c, h = -1, u = 0; - for (; u < o.length && (a.lastIndex = u, c = a.exec(o), c !== null); ) u = a.lastIndex, a === v ? c[1] === "!--" ? a = Z : c[1] !== void 0 ? a = J : c[2] !== void 0 ? (it.test(c[2]) && (n = RegExp("" ? (a = n ?? v, h = -1) : c[1] === void 0 ? h = -2 : (h = a.lastIndex - c[2].length, p = c[1], a = c[3] === void 0 ? g : c[3] === '"' ? X : G) : a === X || a === G ? a = g : a === Z || a === J ? a = v : (a = g, n = void 0); - const $ = a === g && i[d + 1].startsWith("/>") ? " " : ""; - r += a === v ? o + _t : h >= 0 ? (s.push(p), o.slice(0, h) + st + o.slice(h) + f + $) : o + f + (h === -2 ? d : $); +const Tt = (s, t) => { + const e = s.length - 1, n = []; + let i, r = t === 2 ? "" : t === 3 ? "" : "", a = k; + for (let l = 0; l < e; l++) { + const o = s[l]; + let d, h, p = -1, f = 0; + for (; f < o.length && (a.lastIndex = f, h = a.exec(o), h !== null); ) f = a.lastIndex, a === k ? h[1] === "!--" ? a = et : h[1] !== void 0 ? a = nt : h[2] !== void 0 ? (gt.test(h[2]) && (i = RegExp("" ? (a = i ?? k, p = -1) : h[1] === void 0 ? p = -2 : (p = a.lastIndex - h[2].length, d = h[1], a = h[3] === void 0 ? b : h[3] === '"' ? it : st) : a === it || a === st ? a = b : a === et || a === nt ? a = k : (a = b, i = void 0); + const u = a === b && s[l + 1].startsWith("/>") ? " " : ""; + r += a === k ? o + St : p >= 0 ? (n.push(d), o.slice(0, p) + ut + o.slice(p) + y + u) : o + y + (p === -2 ? l : u); } - return [rt(i, r + (i[e] || "") + (t === 2 ? "" : t === 3 ? "" : "")), s]; + return [mt(s, r + (s[e] || "") + (t === 2 ? "" : t === 3 ? "" : "")), n]; }; -class S { - constructor({ strings: t, _$litType$: e }, s) { - let n; +class N { + constructor({ strings: t, _$litType$: e }, n) { + let i; this.parts = []; let r = 0, a = 0; - const d = t.length - 1, o = this.parts, [p, c] = bt(t, e); - if (this.el = S.createElement(p, s), _.currentNode = this.el.content, e === 2 || e === 3) { - const h = this.el.content.firstChild; - h.replaceWith(...h.childNodes); + const l = t.length - 1, o = this.parts, [d, h] = Tt(t, e); + if (this.el = N.createElement(d, n), _.currentNode = this.el.content, e === 2 || e === 3) { + const p = this.el.content.firstChild; + p.replaceWith(...p.childNodes); } - for (; (n = _.nextNode()) !== null && o.length < d; ) { - if (n.nodeType === 1) { - if (n.hasAttributes()) for (const h of n.getAttributeNames()) if (h.endsWith(st)) { - const u = c[a++], $ = n.getAttribute(h).split(f), P = /([.?@])?(.*)/.exec(u); - o.push({ type: 1, index: r, name: P[2], strings: $, ctor: P[1] === "." ? qt : P[1] === "?" ? vt : P[1] === "@" ? Et : O }), n.removeAttribute(h); - } else h.startsWith(f) && (o.push({ type: 6, index: r }), n.removeAttribute(h)); - if (it.test(n.tagName)) { - const h = n.textContent.split(f), u = h.length - 1; - if (u > 0) { - n.textContent = N ? N.emptyScript : ""; - for (let $ = 0; $ < u; $++) n.append(h[$], x()), _.nextNode(), o.push({ type: 2, index: ++r }); - n.append(h[u], x()); + for (; (i = _.nextNode()) !== null && o.length < l; ) { + if (i.nodeType === 1) { + if (i.hasAttributes()) for (const p of i.getAttributeNames()) if (p.endsWith(ut)) { + const f = h[a++], u = i.getAttribute(p).split(y), m = /([.?@])?(.*)/.exec(f); + o.push({ type: 1, index: r, name: m[2], strings: u, ctor: m[1] === "." ? Ot : m[1] === "?" ? Mt : m[1] === "@" ? Ut : U }), i.removeAttribute(p); + } else p.startsWith(y) && (o.push({ type: 6, index: r }), i.removeAttribute(p)); + if (gt.test(i.tagName)) { + const p = i.textContent.split(y), f = p.length - 1; + if (f > 0) { + i.textContent = z ? z.emptyScript : ""; + for (let u = 0; u < f; u++) i.append(p[u], C()), _.nextNode(), o.push({ type: 2, index: ++r }); + i.append(p[f], C()); } } - } else if (n.nodeType === 8) if (n.data === nt) o.push({ type: 2, index: r }); + } else if (i.nodeType === 8) if (i.data === ft) o.push({ type: 2, index: r }); else { - let h = -1; - for (; (h = n.data.indexOf(f, h + 1)) !== -1; ) o.push({ type: 7, index: r }), h += f.length - 1; + let p = -1; + for (; (p = i.data.indexOf(y, p + 1)) !== -1; ) o.push({ type: 7, index: r }), p += y.length - 1; } r++; } } static createElement(t, e) { - const s = y.createElement("template"); - return s.innerHTML = t, s; + const n = q.createElement("template"); + return n.innerHTML = t, n; } } -function q(i, t, e = i, s) { - if (t === A) return t; - let n = s !== void 0 ? e._$Co?.[s] : e._$Cl; - const r = w(t) ? void 0 : t._$litDirective$; - return n?.constructor !== r && (n?._$AO?.(!1), r === void 0 ? n = void 0 : (n = new r(i), n._$AT(i, e, s)), s !== void 0 ? (e._$Co ??= [])[s] = n : e._$Cl = n), n !== void 0 && (t = q(i, n._$AS(i, t.values), n, s)), t; +function A(s, t, e = s, n) { + if (t === w) return t; + let i = n !== void 0 ? e._$Co?.[n] : e._$Cl; + const r = S(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 = A(s, i._$AS(s, t.values), i, n)), t; } -class At { +class zt { constructor(t, e) { this._$AV = [], this._$AN = void 0, this._$AD = t, this._$AM = e; } @@ -313,29 +320,29 @@ class At { return this._$AM._$AU; } u(t) { - const { el: { content: e }, parts: s } = this._$AD, n = (t?.creationScope ?? y).importNode(e, !0); - _.currentNode = n; - let r = _.nextNode(), a = 0, d = 0, o = s[0]; + const { el: { content: e }, parts: n } = this._$AD, i = (t?.creationScope ?? q).importNode(e, !0); + _.currentNode = i; + let r = _.nextNode(), a = 0, l = 0, o = n[0]; for (; o !== void 0; ) { if (a === o.index) { - let p; - o.type === 2 ? p = new C(r, r.nextSibling, this, t) : o.type === 1 ? p = new o.ctor(r, o.name, o.strings, this, t) : o.type === 6 && (p = new xt(r, this, t)), this._$AV.push(p), o = s[++d]; + let d; + o.type === 2 ? d = new P(r, r.nextSibling, this, t) : o.type === 1 ? d = new o.ctor(r, o.name, o.strings, this, t) : o.type === 6 && (d = new Ht(r, this, t)), this._$AV.push(d), o = n[++l]; } a !== o?.index && (r = _.nextNode(), a++); } - return _.currentNode = y, n; + return _.currentNode = q, i; } p(t) { let e = 0; - for (const s of this._$AV) s !== void 0 && (s.strings !== void 0 ? (s._$AI(t, s, e), e += s.strings.length - 2) : s._$AI(t[e])), e++; + 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 C { +class P { get _$AU() { return this._$AM?._$AU ?? this._$Cv; } - constructor(t, e, s, n) { - this.type = 2, this._$AH = l, this._$AN = void 0, this._$AA = t, this._$AB = e, this._$AM = s, this.options = n, this._$Cv = n?.isConnected ?? !0; + constructor(t, e, n, i) { + this.type = 2, this._$AH = c, 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; @@ -349,7 +356,7 @@ class C { return this._$AB; } _$AI(t, e = this) { - t = q(this, t, e), w(t) ? t === l || t == null || t === "" ? (this._$AH !== l && this._$AR(), this._$AH = l) : t !== this._$AH && t !== A && this._(t) : t._$litType$ !== void 0 ? this.$(t) : t.nodeType !== void 0 ? this.T(t) : mt(t) ? this.k(t) : this._(t); + t = A(this, t, e), S(t) ? t === c || t == null || t === "" ? (this._$AH !== c && this._$AR(), this._$AH = c) : t !== this._$AH && t !== w && this._(t) : t._$litType$ !== void 0 ? this.$(t) : t.nodeType !== void 0 ? this.T(t) : Nt(t) ? this.k(t) : this._(t); } O(t) { return this._$AA.parentNode.insertBefore(t, this._$AB); @@ -358,115 +365,115 @@ class C { this._$AH !== t && (this._$AR(), this._$AH = this.O(t)); } _(t) { - this._$AH !== l && w(this._$AH) ? this._$AA.nextSibling.data = t : this.T(y.createTextNode(t)), this._$AH = t; + this._$AH !== c && S(this._$AH) ? this._$AA.nextSibling.data = t : this.T(q.createTextNode(t)), this._$AH = t; } $(t) { - const { values: e, _$litType$: s } = t, n = typeof s == "number" ? this._$AC(t) : (s.el === void 0 && (s.el = S.createElement(rt(s.h, s.h[0]), this.options)), s); - if (this._$AH?._$AD === n) this._$AH.p(e); + const { values: e, _$litType$: n } = t, i = typeof n == "number" ? this._$AC(t) : (n.el === void 0 && (n.el = N.createElement(mt(n.h, n.h[0]), this.options)), n); + if (this._$AH?._$AD === i) this._$AH.p(e); else { - const r = new At(n, this), a = r.u(this.options); + const r = new zt(i, this), a = r.u(this.options); r.p(e), this.T(a), this._$AH = r; } } _$AC(t) { - let e = Y.get(t.strings); - return e === void 0 && Y.set(t.strings, e = new S(t)), e; + let e = rt.get(t.strings); + return e === void 0 && rt.set(t.strings, e = new N(t)), e; } k(t) { - D(this._$AH) || (this._$AH = [], this._$AR()); + Q(this._$AH) || (this._$AH = [], this._$AR()); const e = this._$AH; - let s, n = 0; - for (const r of t) n === e.length ? e.push(s = new C(this.O(x()), this.O(x()), this, this.options)) : s = e[n], s._$AI(r), n++; - n < e.length && (this._$AR(s && s._$AB.nextSibling, n), e.length = n); + let n, i = 0; + for (const r of t) i === e.length ? e.push(n = new P(this.O(C()), this.O(C()), 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 s = V(t).nextSibling; - V(t).remove(), t = s; + const n = Y(t).nextSibling; + Y(t).remove(), t = n; } } setConnected(t) { this._$AM === void 0 && (this._$Cv = t, this._$AP?.(t)); } } -class O { +class U { get tagName() { return this.element.tagName; } get _$AU() { return this._$AM._$AU; } - constructor(t, e, s, n, r) { - this.type = 1, this._$AH = l, this._$AN = void 0, this.element = t, this.name = e, this._$AM = n, this.options = r, s.length > 2 || s[0] !== "" || s[1] !== "" ? (this._$AH = Array(s.length - 1).fill(new String()), this.strings = s) : this._$AH = l; + constructor(t, e, n, i, r) { + this.type = 1, this._$AH = c, 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 = c; } - _$AI(t, e = this, s, n) { + _$AI(t, e = this, n, i) { const r = this.strings; let a = !1; - if (r === void 0) t = q(this, t, e, 0), a = !w(t) || t !== this._$AH && t !== A, a && (this._$AH = t); + if (r === void 0) t = A(this, t, e, 0), a = !S(t) || t !== this._$AH && t !== w, a && (this._$AH = t); else { - const d = t; - let o, p; - for (t = r[0], o = 0; o < r.length - 1; o++) p = q(this, d[s + o], e, o), p === A && (p = this._$AH[o]), a ||= !w(p) || p !== this._$AH[o], p === l ? t = l : t !== l && (t += (p ?? "") + r[o + 1]), this._$AH[o] = p; + const l = t; + let o, d; + for (t = r[0], o = 0; o < r.length - 1; o++) d = A(this, l[n + o], e, o), d === w && (d = this._$AH[o]), a ||= !S(d) || d !== this._$AH[o], d === c ? t = c : t !== c && (t += (d ?? "") + r[o + 1]), this._$AH[o] = d; } - a && !n && this.j(t); + a && !i && this.j(t); } j(t) { - t === l ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t ?? ""); + t === c ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t ?? ""); } } -class qt extends O { +class Ot extends U { constructor() { super(...arguments), this.type = 3; } j(t) { - this.element[this.name] = t === l ? void 0 : t; + this.element[this.name] = t === c ? void 0 : t; } } -class vt extends O { +class Mt extends U { constructor() { super(...arguments), this.type = 4; } j(t) { - this.element.toggleAttribute(this.name, !!t && t !== l); + this.element.toggleAttribute(this.name, !!t && t !== c); } } -class Et extends O { - constructor(t, e, s, n, r) { - super(t, e, s, n, r), this.type = 5; +class Ut extends U { + constructor(t, e, n, i, r) { + super(t, e, n, i, r), this.type = 5; } _$AI(t, e = this) { - if ((t = q(this, t, e, 0) ?? l) === A) return; - const s = this._$AH, n = t === l && s !== l || t.capture !== s.capture || t.once !== s.once || t.passive !== s.passive, r = t !== l && (s === l || n); - n && this.element.removeEventListener(this.name, this, s), r && this.element.addEventListener(this.name, this, t), this._$AH = t; + if ((t = A(this, t, e, 0) ?? c) === w) return; + const n = this._$AH, i = t === c && n !== c || t.capture !== n.capture || t.once !== n.once || t.passive !== n.passive, r = t !== c && (n === c || 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 xt { - constructor(t, e, s) { - this.element = t, this.type = 6, this._$AN = void 0, this._$AM = e, this.options = s; +class Ht { + 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) { - q(this, t); + A(this, t); } } -const wt = R.litHtmlPolyfillSupport; -wt?.(S, C), (R.litHtmlVersions ??= []).push("3.3.3"); -const St = (i, t, e) => { - const s = e?.renderBefore ?? t; - let n = s._$litPart$; - if (n === void 0) { +const Dt = B.litHtmlPolyfillSupport; +Dt?.(N, P), (B.litHtmlVersions ??= []).push("3.3.3"); +const Rt = (s, t, e) => { + const n = e?.renderBefore ?? t; + let i = n._$litPart$; + if (i === void 0) { const r = e?.renderBefore ?? null; - s._$litPart$ = n = new C(t.insertBefore(x(), r), r, void 0, e ?? {}); + n._$litPart$ = i = new P(t.insertBefore(C(), r), r, void 0, e ?? {}); } - return n._$AI(i), n; + return i._$AI(s), i; }; -const F = globalThis; -class m extends b { +const W = globalThis; +class v extends x { constructor() { super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0; } @@ -476,7 +483,7 @@ class m extends b { } update(t) { const e = this.render(); - this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t), this._$Do = St(e, this.renderRoot, this.renderOptions); + this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t), this._$Do = Rt(e, this.renderRoot, this.renderOptions); } connectedCallback() { super.connectedCallback(), this._$Do?.setConnected(!0); @@ -485,49 +492,745 @@ class m extends b { super.disconnectedCallback(), this._$Do?.setConnected(!1); } render() { - return A; + return w; } } -m._$litElement$ = !0, m.finalized = !0, F.litElementHydrateSupport?.({ LitElement: m }); -const Ct = F.litElementPolyfillSupport; -Ct?.({ LitElement: m }); -(F.litElementVersions ??= []).push("4.2.2"); -const at = ':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-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 L(i) { - window.customCards = window.customCards ?? [], window.customCards.push(i); +v._$litElement$ = !0, v.finalized = !0, W.litElementHydrateSupport?.({ LitElement: v }); +const It = W.litElementPolyfillSupport; +It?.({ LitElement: v }); +(W.litElementVersions ??= []).push("4.2.2"); +const yt = ':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-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 V(s) { + window.customCards = window.customCards ?? [], window.customCards.push(s); } -class Pt extends m { +const at = /* @__PURE__ */ new Map(); +function O(s, t) { + const e = `${s ?? "local"}|${JSON.stringify(t)}`, n = at.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 at.set(e, i), i; +} +function Ft(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 R(s, t) { + return `${O(t, { weekday: "long" }).format(s)}, ${O(t, { month: "short", day: "numeric" }).format(s)}`; +} +function Lt(s, t) { + return O(t, { + hour: "numeric", + minute: "2-digit", + hour12: !0 + }).format(s); +} +const jt = { + "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" +}, Bt = { + "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 ot(s) { + return typeof s == "string" ? s.trim() : ""; +} +function I(s, t) { + if (s == null || s === "") + return t; + const e = typeof s == "number" ? s : Number(s); + return Number.isFinite(e) ? e : t; +} +function lt(s) { + return Number.isFinite(s) ? Math.min(100, Math.max(0, s)) : 0; +} +function pt(s) { + return Math.max(0, Math.round(s)); +} +function Qt(s) { + return s.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); +} +function Wt(s, t) { + const e = O(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 a = e.find((l) => l.type === r); + return a ? Number(a.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 Vt(s, t) { + if (typeof s != "string") + return null; + const e = s.split("-"); + if (e.length !== 3) + return null; + const [n, i, r] = e.map((o) => Number(o)); + if (!n || !i || !r) + return null; + if (!t) { + const o = new Date(n, i - 1, r); + return Number.isNaN(o.getTime()) ? null : o; + } + const a = Date.UTC(n, i - 1, r), l = new Date(a - Wt(a, t)); + return Number.isNaN(l.getTime()) ? null : l; +} +function Zt(s) { + const t = jt[s]; + return t || s.charAt(0).toUpperCase() + s.slice(1); +} +function Kt(s, t) { + return Bt[s] ?? t; +} +const dt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Jt = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", Gt = _t` + * { + 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: ${$(Jt)}; + 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: ${$(dt)}; + 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: ${$(dt)}; + 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); + } + + .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; + } +`, Xt = g``, Yt = g``, te = g``; +class ee extends v { static properties = { hass: { attribute: !1 }, - _config: { state: !0 } + _config: { state: !0 }, + _now: { state: !0 } }; - static styles = [U(at)]; + static styles = [$(yt), Gt]; hass; _config; + _now = /* @__PURE__ */ new Date(); + _clockTimer; setConfig(t) { if (!t || typeof t != "object") throw new Error("Invalid configuration"); this._config = t; } getCardSize() { - return 6; + return 22; + } + connectedCallback() { + super.connectedCallback(), this._now = /* @__PURE__ */ new Date(), this._clockTimer = window.setInterval(() => { + this._now = /* @__PURE__ */ new Date(); + }, 1e3); + } + disconnectedCallback() { + this._clockTimer !== void 0 && (window.clearInterval(this._clockTimer), this._clockTimer = void 0), super.disconnectedCallback(); } render() { - return B`
    NestQuest Party Board
    `; + return g` +
    +
    +
    +
    + + + NestQuest + + +
    +

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

    +
    + ${this._plates().map((t) => this._renderPlate(t))} +
    +

    Tap your crest to open your Quest Log

    +
    + ${this._renderDock()} +
    + `; + } + _timeZone() { + return Ft(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._state(`sensor.nestquest_${t}_quests_due_today`), n = this._state( + `sensor.nestquest_${t}_quests_completed_today` + ), i = this._state( + `sensor.nestquest_${t}_completion_pct_today` + ), r = this._state( + `binary_sensor.nestquest_${t}_present_today` + ), a = e?.attributes ?? {}, l = pt(I(e?.state, 0)), o = pt(I(n?.state, 0)), d = i ? I(i.state, Number.NaN) : Number.NaN, h = Number.isFinite(d) ? lt(d) : l > 0 ? lt(o / l * 100) : 0; + let p = !0; + const f = a.present; + typeof f == "boolean" ? p = f : r && (p = String(r.state ?? "").trim().toLowerCase() === "on"); + let u = ""; + const m = a.child_name; + if (typeof m == "string" && m.trim()) + u = m.trim(); + else { + const H = r?.attributes?.child_name; + typeof H == "string" && H.trim() && (u = H.trim()); + } + u || (u = Qt(t)); + const Z = Vt( + r?.attributes?.next_present, + this._timeZone() + ), bt = Z ? `Returns ${R(Z, this._timeZone())}` : null; + return { + slug: t, + name: u, + initial: (u.charAt(0) || "?").toUpperCase(), + present: p, + completed: o, + due: l, + pct: h, + returns: bt + }; + } + _renderPlate(t) { + return t.present ? g` + + ` : g` +
    + + + ${t.initial} + + + ${t.name} + + ${Yt} + On travels + + ${t.returns ?? "Returns —"} + +
    + `; + } + _openQuestLog(t) { + const e = ot(this._config?.quest_log_path); + e && (window.history.pushState(null, "", `${e.replace(/\/+$/, "")}/${t}`), window.dispatchEvent(new Event("location-changed"))); + } + _dockWeather() { + const t = ot(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, a = null; + const l = i.forecast; + if (Array.isArray(l) && l.length > 0) { + const o = l[0]; + if (o && typeof o == "object") { + const d = o; + r = this._optionalNumber(d.temperature), a = this._optionalNumber(d.templow); + } + } + return { + condition: n, + temperature: this._optionalNumber(i.temperature), + high: r, + low: a + }; + } + _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 g` +
    + ${R(this._now, this._timeZone())} + + ${Lt(this._now, this._timeZone())} +
    + `; + const e = Zt(t.condition), n = Kt(t.condition, e), i = t.temperature === null ? c : g`${Math.round(t.temperature)}°`, r = t.high === null || t.low === null ? null : `${Math.round(t.high)}° / ${Math.round(t.low)}°`; + return g` +
    + ${te} + ${i} + + + ${r === null ? e : `${e} · ${r}`} + + + ${n} +
    + `; } } -customElements.define("nestquest-party-board-card", Pt); -L({ +customElements.define("nestquest-party-board-card", ee); +V({ type: "nestquest-party-board-card", name: "NestQuest Party Board", description: "Kids' attract screen for present and away adventurers." }); -class kt extends m { +class ne extends v { static properties = { hass: { attribute: !1 }, _config: { state: !0 } }; - static styles = [U(at)]; + static styles = [$(yt)]; hass; _config; setConfig(t) { @@ -539,22 +1242,22 @@ class kt extends m { return 8; } render() { - return B`
    NestQuest Quest Log
    `; + return g`
    NestQuest Quest Log
    `; } } -customElements.define("nestquest-quest-log-card", kt); -L({ +customElements.define("nestquest-quest-log-card", ne); +V({ type: "nestquest-quest-log-card", name: "NestQuest Quest Log", description: "One child's daily quests, grouped by window." }); -const Nt = ":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 Ut extends m { +const se = ":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 ie extends v { static properties = { hass: { attribute: !1 }, _config: { state: !0 } }; - static styles = [U(Nt)]; + static styles = [$(se)]; hass; _config; setConfig(t) { @@ -566,17 +1269,17 @@ class Ut extends m { return 8; } render() { - return B`
    NestQuest Admin
    `; + return g`
    NestQuest Admin
    `; } } -customElements.define("nestquest-admin-card", Ut); -L({ +customElements.define("nestquest-admin-card", ie); +V({ type: "nestquest-admin-card", name: "NestQuest Admin", description: "Parent phone view for today, tasks, schedule, and history." }); -const zt = "/nestquest-static/nestquest-fonts.css"; +const re = "/nestquest-static/nestquest-fonts.css"; if (!document.querySelector('link[data-nq-fonts=""]')) { - const i = document.createElement("link"); - i.rel = "stylesheet", i.href = zt, i.dataset.nqFonts = "", document.head.appendChild(i); + const s = document.createElement("link"); + s.rel = "stylesheet", s.href = re, s.dataset.nqFonts = "", document.head.appendChild(s); } diff --git a/frontend/src/cards/party-board.ts b/frontend/src/cards/party-board.ts index e6fbb38..891c368 100644 --- a/frontend/src/cards/party-board.ts +++ b/frontend/src/cards/party-board.ts @@ -1,18 +1,604 @@ -import { LitElement, html, unsafeCSS } from "lit"; +import { LitElement, css, html, nothing, unsafeCSS } 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 ChildPlate = { + slug: string; + name: string; + initial: string; + present: boolean; + completed: number; + due: number; + pct: number; + returns: string | null; +}; + +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); + } + + .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_CLOUD_SUN = html``; + export class NestQuestPartyBoardCard extends LitElement { static properties = { hass: { attribute: false }, _config: { state: true }, + _now: { state: true }, }; - static styles = [unsafeCSS(panelTokens)]; + static styles = [unsafeCSS(panelTokens), boardStyles]; hass?: HassLike; _config?: CardConfig; + _now = new Date(); + private _clockTimer?: number; setConfig(config: CardConfig): void { if (!config || typeof config !== "object") { @@ -22,11 +608,279 @@ export class NestQuestPartyBoardCard extends LitElement { } getCardSize(): number { - return 6; + return 22; + } + + connectedCallback(): void { + super.connectedCallback(); + this._now = new Date(); + this._clockTimer = window.setInterval(() => { + this._now = new Date(); + }, 1000); + } + + disconnectedCallback(): void { + if (this._clockTimer !== undefined) { + window.clearInterval(this._clockTimer); + this._clockTimer = undefined; + } + super.disconnectedCallback(); } render() { - return html`
    NestQuest Party Board
    `; + return html` +
    +
    +
    +
    + + + NestQuest + + +
    +

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

    +
    + ${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 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, + completed, + due, + pct, + returns, + }; + } + + private _renderPlate(plate: ChildPlate) { + 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} +
    + `; } } diff --git a/tests/test_binary_sensors.py b/tests/test_binary_sensors.py index 2c31abf..a4a31e2 100644 --- a/tests/test_binary_sensors.py +++ b/tests/test_binary_sensors.py @@ -6,7 +6,7 @@ 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 +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 @@ -128,7 +128,57 @@ async def test_binary_sensor_identity_and_attributes(hass, make_entry) -> None: assert present.name == "NestQuest Ada present today" for entity in (all_done, present): assert entity.device_info.name == "NestQuest Ada" - assert entity.extra_state_attributes == { + 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_coordinator.py b/tests/test_coordinator.py index 745c53a..415318e 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -78,9 +78,43 @@ async def test_coordinator_snapshot_counts_and_states(hass, make_entry) -> None: 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: @@ -184,6 +218,37 @@ async def test_coordinator_presence_resolves_schedule_and_override( 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: From 0d2e0d6f26a5be705da4e66ce65e12b44ae74874 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Sun, 20 Sep 2026 13:54:14 -0400 Subject: [PATCH 39/46] Add the quest log card (1b) read-only Add the quest log card (1b) read-only --- .../nestquest/www/nestquest-cards.js | 1839 ++++++++++++++--- .../nestquest/www/nestquest-panel-tokens.css | 2 + design/tokens/nestquest-panel-tokens.css | 2 + frontend/src/cards/quest-log.ts | 1417 ++++++++++++- 4 files changed, 2958 insertions(+), 302 deletions(-) diff --git a/custom_components/nestquest/www/nestquest-cards.js b/custom_components/nestquest/www/nestquest-cards.js index 747ba7f..85e4f59 100644 --- a/custom_components/nestquest/www/nestquest-cards.js +++ b/custom_components/nestquest/www/nestquest-cards.js @@ -1,15 +1,15 @@ -const T = globalThis, L = T.ShadowRoot && (T.ShadyCSS === void 0 || T.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, j = /* @__PURE__ */ Symbol(), K = /* @__PURE__ */ new WeakMap(); -let ct = class { +const L = globalThis, Y = L.ShadowRoot && (L.ShadyCSS === void 0 || L.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, tt = /* @__PURE__ */ Symbol(), rt = /* @__PURE__ */ new WeakMap(); +let Nt = class { constructor(t, e, n) { - if (this._$cssResult$ = !0, n !== j) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); + if (this._$cssResult$ = !0, n !== tt) 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 (L && t === void 0) { + if (Y && t === void 0) { const n = e !== void 0 && e.length === 1; - n && (t = K.get(e)), t === void 0 && ((this.o = t = new CSSStyleSheet()).replaceSync(this.cssText), n && K.set(e, t)); + n && (t = rt.get(e)), t === void 0 && ((this.o = t = new CSSStyleSheet()).replaceSync(this.cssText), n && rt.set(e, t)); } return t; } @@ -17,101 +17,101 @@ let ct = class { return this.cssText; } }; -const $ = (s) => new ct(typeof s == "string" ? s : s + "", void 0, j), _t = (s, ...t) => { - const e = s.length === 1 ? s[0] : t.reduce((n, i, r) => n + ((a) => { - if (a._$cssResult$ === !0) return a.cssText; - if (typeof a == "number") return a; - throw Error("Value passed to 'css' function must be a 'css' function result: " + a + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security."); - })(i) + s[r + 1], s[0]); - return new ct(e, s, j); -}, $t = (s, t) => { - if (L) s.adoptedStyleSheets = t.map((e) => e instanceof CSSStyleSheet ? e : e.styleSheet); +const b = (i) => new Nt(typeof i == "string" ? i : i + "", void 0, tt), Tt = (i, ...t) => { + const e = i.length === 1 ? i[0] : t.reduce((n, s, 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."); + })(s) + i[r + 1], i[0]); + return new Nt(e, i, tt); +}, Dt = (i, t) => { + if (Y) i.adoptedStyleSheets = t.map((e) => e instanceof CSSStyleSheet ? e : e.styleSheet); else for (const e of t) { - const n = document.createElement("style"), i = T.litNonce; - i !== void 0 && n.setAttribute("nonce", i), n.textContent = e.cssText, s.appendChild(n); + const n = document.createElement("style"), s = L.litNonce; + s !== void 0 && n.setAttribute("nonce", s), n.textContent = e.cssText, i.appendChild(n); } -}, J = L ? (s) => s : (s) => s instanceof CSSStyleSheet ? ((t) => { +}, ot = Y ? (i) => i : (i) => i instanceof CSSStyleSheet ? ((t) => { let e = ""; for (const n of t.cssRules) e += n.cssText; - return $(e); -})(s) : s; -const { is: vt, defineProperty: qt, getOwnPropertyDescriptor: xt, getOwnPropertyNames: wt, getOwnPropertySymbols: At, getPrototypeOf: kt } = Object, M = globalThis, G = M.trustedTypes, Et = G ? G.emptyScript : "", Ct = M.reactiveElementPolyfillSupport, E = (s, t) => s, F = { toAttribute(s, t) { + return b(e); +})(i) : i; +const { is: Ht, defineProperty: jt, getOwnPropertyDescriptor: Lt, getOwnPropertyNames: Rt, getOwnPropertySymbols: Bt, getPrototypeOf: Ft } = Object, F = globalThis, at = F.trustedTypes, Qt = at ? at.emptyScript : "", Wt = F.reactiveElementPolyfillSupport, U = (i, t) => i, X = { toAttribute(i, t) { switch (t) { case Boolean: - s = s ? Et : null; + i = i ? Qt : null; break; case Object: case Array: - s = s == null ? s : JSON.stringify(s); + i = i == null ? i : JSON.stringify(i); } - return s; -}, fromAttribute(s, t) { - let e = s; + return i; +}, fromAttribute(i, t) { + let e = i; switch (t) { case Boolean: - e = s !== null; + e = i !== null; break; case Number: - e = s === null ? null : Number(s); + e = i === null ? null : Number(i); break; case Object: case Array: try { - e = JSON.parse(s); + e = JSON.parse(i); } catch { e = null; } } return e; -} }, ht = (s, t) => !vt(s, t), X = { attribute: !0, type: String, converter: F, reflect: !1, useDefault: !1, hasChanged: ht }; -Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), M.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); -let x = class extends HTMLElement { +} }, Mt = (i, t) => !Ht(i, t), lt = { attribute: !0, type: String, converter: X, reflect: !1, useDefault: !1, hasChanged: Mt }; +Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), F.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); +let T = 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 = X) { + static createProperty(t, e = lt) { 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); + const n = /* @__PURE__ */ Symbol(), s = this.getPropertyDescriptor(t, n, e); + s !== void 0 && jt(this.prototype, t, s); } } static getPropertyDescriptor(t, e, n) { - const { get: i, set: r } = xt(this.prototype, t) ?? { get() { + const { get: s, set: r } = Lt(this.prototype, t) ?? { get() { return this[e]; - }, set(a) { - this[e] = a; + }, set(o) { + this[e] = o; } }; - return { get: i, set(a) { - const l = i?.call(this); - r?.call(this, a), this.requestUpdate(t, l, n); + return { get: s, set(o) { + const l = s?.call(this); + r?.call(this, o), this.requestUpdate(t, l, n); }, configurable: !0, enumerable: !0 }; } static getPropertyOptions(t) { - return this.elementProperties.get(t) ?? X; + return this.elementProperties.get(t) ?? lt; } static _$Ei() { - if (this.hasOwnProperty(E("elementProperties"))) return; - const t = kt(this); + if (this.hasOwnProperty(U("elementProperties"))) return; + const t = Ft(this); t.finalize(), t.l !== void 0 && (this.l = [...t.l]), this.elementProperties = new Map(t.elementProperties); } static finalize() { - if (this.hasOwnProperty(E("finalized"))) return; - if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(E("properties"))) { - const e = this.properties, n = [...wt(e), ...At(e)]; - for (const i of n) this.createProperty(i, e[i]); + if (this.hasOwnProperty(U("finalized"))) return; + if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(U("properties"))) { + const e = this.properties, n = [...Rt(e), ...Bt(e)]; + for (const s of n) this.createProperty(s, e[s]); } 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); + if (e !== void 0) for (const [n, s] of e) this.elementProperties.set(n, s); } 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); + const s = this._$Eu(e, n); + s !== void 0 && this._$Eh.set(s, e); } this.elementStyles = this.finalizeStyles(this.styles); } @@ -119,8 +119,8 @@ let x = class extends HTMLElement { const e = []; if (Array.isArray(t)) { const n = new Set(t.flat(1 / 0).reverse()); - for (const i of n) e.unshift(J(i)); - } else t !== void 0 && e.push(J(t)); + for (const s of n) e.unshift(ot(s)); + } else t !== void 0 && e.push(ot(t)); return e; } static _$Eu(t, e) { @@ -146,7 +146,7 @@ let x = class extends HTMLElement { } createRenderRoot() { const t = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions); - return $t(t, this.constructor.elementStyles), t; + return Dt(t, this.constructor.elementStyles), t; } connectedCallback() { this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(!0), this._$EO?.forEach((t) => t.hostConnected?.()); @@ -160,31 +160,31 @@ let x = class extends HTMLElement { 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 : F).toAttribute(e, n.type); - this._$Em = t, r == null ? this.removeAttribute(i) : this.setAttribute(i, r), this._$Em = null; + const n = this.constructor.elementProperties.get(t), s = this.constructor._$Eu(t, n); + if (s !== void 0 && n.reflect === !0) { + const r = (n.converter?.toAttribute !== void 0 ? n.converter : X).toAttribute(e, n.type); + this._$Em = t, r == null ? this.removeAttribute(s) : this.setAttribute(s, 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), a = typeof r.converter == "function" ? { fromAttribute: r.converter } : r.converter?.fromAttribute !== void 0 ? r.converter : F; - this._$Em = i; - const l = a.fromAttribute(e, r.type); - this[i] = l ?? this._$Ej?.get(i) ?? l, this._$Em = null; + const n = this.constructor, s = n._$Eh.get(t); + if (s !== void 0 && this._$Em !== s) { + const r = n.getPropertyOptions(s), o = typeof r.converter == "function" ? { fromAttribute: r.converter } : r.converter?.fromAttribute !== void 0 ? r.converter : X; + this._$Em = s; + const l = o.fromAttribute(e, r.type); + this[s] = l ?? this._$Ej?.get(s) ?? l, this._$Em = null; } } - requestUpdate(t, e, n, i = !1, r) { + requestUpdate(t, e, n, s = !1, r) { if (t !== void 0) { - const a = this.constructor; - if (i === !1 && (r = this[t]), n ??= a.getPropertyOptions(t), !((n.hasChanged ?? ht)(r, e) || n.useDefault && n.reflect && r === this._$Ej?.get(t) && !this.hasAttribute(a._$Eu(t, n)))) return; + const o = this.constructor; + if (s === !1 && (r = this[t]), n ??= o.getPropertyOptions(t), !((n.hasChanged ?? Mt)(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 }, a) { - n && !(this._$Ej ??= /* @__PURE__ */ new Map()).has(t) && (this._$Ej.set(t, a ?? e ?? this[t]), r !== !0 || a !== 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)); + C(t, e, { useDefault: n, reflect: s, 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)), s === !0 && this._$Em !== t && (this._$Eq ??= /* @__PURE__ */ new Set()).add(t)); } async _$EP() { this.isUpdatePending = !0; @@ -203,13 +203,13 @@ let x = class extends HTMLElement { if (!this.isUpdatePending) return; if (!this.hasUpdated) { if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) { - for (const [i, r] of this._$Ep) this[i] = r; + for (const [s, r] of this._$Ep) this[s] = r; this._$Ep = void 0; } const n = this.constructor.elementProperties; - if (n.size > 0) for (const [i, r] of n) { - const { wrapped: a } = r, l = this[i]; - a !== !0 || this._$AL.has(i) || l === void 0 || this.C(i, void 0, r, l); + if (n.size > 0) for (const [s, r] of n) { + const { wrapped: o } = r, l = this[s]; + o !== !0 || this._$AL.has(s) || l === void 0 || this.C(s, void 0, r, l); } } let t = !1; @@ -246,70 +246,70 @@ let x = class extends HTMLElement { firstUpdated(t) { } }; -x.elementStyles = [], x.shadowRootOptions = { mode: "open" }, x[E("elementProperties")] = /* @__PURE__ */ new Map(), x[E("finalized")] = /* @__PURE__ */ new Map(), Ct?.({ ReactiveElement: x }), (M.reactiveElementVersions ??= []).push("2.1.2"); -const B = globalThis, Y = (s) => s, z = B.trustedTypes, tt = z ? z.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, ut = "$lit$", y = `lit$${Math.random().toFixed(9).slice(2)}$`, ft = "?" + y, St = `<${ft}>`, q = document, C = () => q.createComment(""), S = (s) => s === null || typeof s != "object" && typeof s != "function", Q = Array.isArray, Nt = (s) => Q(s) || typeof s?.[Symbol.iterator] == "function", D = `[ -\f\r]`, k = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, et = /-->/g, nt = />/g, b = RegExp(`>|${D}(?:([^\\s"'>=/]+)(${D}*=${D}*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`, "g"), st = /'/g, it = /"/g, gt = /^(?:script|style|textarea|title)$/i, Pt = (s) => (t, ...e) => ({ _$litType$: s, strings: t, values: e }), g = Pt(1), w = /* @__PURE__ */ Symbol.for("lit-noChange"), c = /* @__PURE__ */ Symbol.for("lit-nothing"), rt = /* @__PURE__ */ new WeakMap(), _ = q.createTreeWalker(q, 129); -function mt(s, t) { - if (!Q(s) || !s.hasOwnProperty("raw")) throw Error("invalid template strings array"); - return tt !== void 0 ? tt.createHTML(t) : t; +T.elementStyles = [], T.shadowRootOptions = { mode: "open" }, T[U("elementProperties")] = /* @__PURE__ */ new Map(), T[U("finalized")] = /* @__PURE__ */ new Map(), Wt?.({ ReactiveElement: T }), (F.reactiveElementVersions ??= []).push("2.1.2"); +const et = globalThis, dt = (i) => i, R = et.trustedTypes, pt = R ? R.createPolicy("lit-html", { createHTML: (i) => i }) : void 0, zt = "$lit$", x = `lit$${Math.random().toFixed(9).slice(2)}$`, Pt = "?" + x, Zt = `<${Pt}>`, S = document, I = () => S.createComment(""), D = (i) => i === null || typeof i != "object" && typeof i != "function", nt = Array.isArray, Vt = (i) => nt(i) || typeof i?.[Symbol.iterator] == "function", W = `[ +\f\r]`, P = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, ct = /-->/g, ht = />/g, q = RegExp(`>|${W}(?:([^\\s"'>=/]+)(${W}*=${W}*(?:[^ +\f\r"'\`<>=]|("|')|))|$)`, "g"), ut = /'/g, ft = /"/g, Ot = /^(?:script|style|textarea|title)$/i, Kt = (i) => (t, ...e) => ({ _$litType$: i, strings: t, values: e }), g = Kt(1), E = /* @__PURE__ */ Symbol.for("lit-noChange"), m = /* @__PURE__ */ Symbol.for("lit-nothing"), mt = /* @__PURE__ */ new WeakMap(), A = S.createTreeWalker(S, 129); +function Ut(i, t) { + if (!nt(i) || !i.hasOwnProperty("raw")) throw Error("invalid template strings array"); + return pt !== void 0 ? pt.createHTML(t) : t; } -const Tt = (s, t) => { - const e = s.length - 1, n = []; - let i, r = t === 2 ? "" : t === 3 ? "" : "", a = k; +const Jt = (i, t) => { + const e = i.length - 1, n = []; + let s, r = t === 2 ? "" : t === 3 ? "" : "", o = P; for (let l = 0; l < e; l++) { - const o = s[l]; - let d, h, p = -1, f = 0; - for (; f < o.length && (a.lastIndex = f, h = a.exec(o), h !== null); ) f = a.lastIndex, a === k ? h[1] === "!--" ? a = et : h[1] !== void 0 ? a = nt : h[2] !== void 0 ? (gt.test(h[2]) && (i = RegExp("" ? (a = i ?? k, p = -1) : h[1] === void 0 ? p = -2 : (p = a.lastIndex - h[2].length, d = h[1], a = h[3] === void 0 ? b : h[3] === '"' ? it : st) : a === it || a === st ? a = b : a === et || a === nt ? a = k : (a = b, i = void 0); - const u = a === b && s[l + 1].startsWith("/>") ? " " : ""; - r += a === k ? o + St : p >= 0 ? (n.push(d), o.slice(0, p) + ut + o.slice(p) + y + u) : o + y + (p === -2 ? l : u); + const a = i[l]; + let p, u, d = -1, h = 0; + for (; h < a.length && (o.lastIndex = h, u = o.exec(a), u !== null); ) h = o.lastIndex, o === P ? u[1] === "!--" ? o = ct : u[1] !== void 0 ? o = ht : u[2] !== void 0 ? (Ot.test(u[2]) && (s = RegExp("" ? (o = s ?? P, d = -1) : u[1] === void 0 ? d = -2 : (d = o.lastIndex - u[2].length, p = u[1], o = u[3] === void 0 ? q : u[3] === '"' ? ft : ut) : o === ft || o === ut ? o = q : o === ct || o === ht ? o = P : (o = q, s = void 0); + const c = o === q && i[l + 1].startsWith("/>") ? " " : ""; + r += o === P ? a + Zt : d >= 0 ? (n.push(p), a.slice(0, d) + zt + a.slice(d) + x + c) : a + x + (d === -2 ? l : c); } - return [mt(s, r + (s[e] || "") + (t === 2 ? "" : t === 3 ? "" : "")), n]; + return [Ut(i, r + (i[e] || "") + (t === 2 ? "" : t === 3 ? "" : "")), n]; }; -class N { +class H { constructor({ strings: t, _$litType$: e }, n) { - let i; + let s; this.parts = []; - let r = 0, a = 0; - const l = t.length - 1, o = this.parts, [d, h] = Tt(t, e); - if (this.el = N.createElement(d, n), _.currentNode = this.el.content, e === 2 || e === 3) { - const p = this.el.content.firstChild; - p.replaceWith(...p.childNodes); + let r = 0, o = 0; + const l = t.length - 1, a = this.parts, [p, u] = Jt(t, e); + if (this.el = H.createElement(p, n), A.currentNode = this.el.content, e === 2 || e === 3) { + const d = this.el.content.firstChild; + d.replaceWith(...d.childNodes); } - for (; (i = _.nextNode()) !== null && o.length < l; ) { - if (i.nodeType === 1) { - if (i.hasAttributes()) for (const p of i.getAttributeNames()) if (p.endsWith(ut)) { - const f = h[a++], u = i.getAttribute(p).split(y), m = /([.?@])?(.*)/.exec(f); - o.push({ type: 1, index: r, name: m[2], strings: u, ctor: m[1] === "." ? Ot : m[1] === "?" ? Mt : m[1] === "@" ? Ut : U }), i.removeAttribute(p); - } else p.startsWith(y) && (o.push({ type: 6, index: r }), i.removeAttribute(p)); - if (gt.test(i.tagName)) { - const p = i.textContent.split(y), f = p.length - 1; - if (f > 0) { - i.textContent = z ? z.emptyScript : ""; - for (let u = 0; u < f; u++) i.append(p[u], C()), _.nextNode(), o.push({ type: 2, index: ++r }); - i.append(p[f], C()); + for (; (s = A.nextNode()) !== null && a.length < l; ) { + if (s.nodeType === 1) { + if (s.hasAttributes()) for (const d of s.getAttributeNames()) if (d.endsWith(zt)) { + const h = u[o++], c = s.getAttribute(d).split(x), f = /([.?@])?(.*)/.exec(h); + a.push({ type: 1, index: r, name: f[2], strings: c, ctor: f[1] === "." ? Xt : f[1] === "?" ? Yt : f[1] === "@" ? te : Q }), s.removeAttribute(d); + } else d.startsWith(x) && (a.push({ type: 6, index: r }), s.removeAttribute(d)); + if (Ot.test(s.tagName)) { + const d = s.textContent.split(x), h = d.length - 1; + if (h > 0) { + s.textContent = R ? R.emptyScript : ""; + for (let c = 0; c < h; c++) s.append(d[c], I()), A.nextNode(), a.push({ type: 2, index: ++r }); + s.append(d[h], I()); } } - } else if (i.nodeType === 8) if (i.data === ft) o.push({ type: 2, index: r }); + } else if (s.nodeType === 8) if (s.data === Pt) a.push({ type: 2, index: r }); else { - let p = -1; - for (; (p = i.data.indexOf(y, p + 1)) !== -1; ) o.push({ type: 7, index: r }), p += y.length - 1; + let d = -1; + for (; (d = s.data.indexOf(x, d + 1)) !== -1; ) a.push({ type: 7, index: r }), d += x.length - 1; } r++; } } static createElement(t, e) { - const n = q.createElement("template"); + const n = S.createElement("template"); return n.innerHTML = t, n; } } -function A(s, t, e = s, n) { - if (t === w) return t; - let i = n !== void 0 ? e._$Co?.[n] : e._$Cl; - const r = S(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 = A(s, i._$AS(s, t.values), i, n)), t; +function M(i, t, e = i, n) { + if (t === E) return t; + let s = n !== void 0 ? e._$Co?.[n] : e._$Cl; + const r = D(t) ? void 0 : t._$litDirective$; + return s?.constructor !== r && (s?._$AO?.(!1), r === void 0 ? s = void 0 : (s = new r(i), s._$AT(i, e, n)), n !== void 0 ? (e._$Co ??= [])[n] = s : e._$Cl = s), s !== void 0 && (t = M(i, s._$AS(i, t.values), s, n)), t; } -class zt { +class Gt { constructor(t, e) { this._$AV = [], this._$AN = void 0, this._$AD = t, this._$AM = e; } @@ -320,29 +320,29 @@ class zt { return this._$AM._$AU; } u(t) { - const { el: { content: e }, parts: n } = this._$AD, i = (t?.creationScope ?? q).importNode(e, !0); - _.currentNode = i; - let r = _.nextNode(), a = 0, l = 0, o = n[0]; - for (; o !== void 0; ) { - if (a === o.index) { - let d; - o.type === 2 ? d = new P(r, r.nextSibling, this, t) : o.type === 1 ? d = new o.ctor(r, o.name, o.strings, this, t) : o.type === 6 && (d = new Ht(r, this, t)), this._$AV.push(d), o = n[++l]; + const { el: { content: e }, parts: n } = this._$AD, s = (t?.creationScope ?? S).importNode(e, !0); + A.currentNode = s; + let r = A.nextNode(), o = 0, l = 0, a = n[0]; + for (; a !== void 0; ) { + if (o === a.index) { + let p; + a.type === 2 ? p = new z(r, r.nextSibling, this, t) : a.type === 1 ? p = new a.ctor(r, a.name, a.strings, this, t) : a.type === 6 && (p = new ee(r, this, t)), this._$AV.push(p), a = n[++l]; } - a !== o?.index && (r = _.nextNode(), a++); + o !== a?.index && (r = A.nextNode(), o++); } - return _.currentNode = q, i; + return A.currentNode = S, s; } 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 P { +class z { get _$AU() { return this._$AM?._$AU ?? this._$Cv; } - constructor(t, e, n, i) { - this.type = 2, this._$AH = c, this._$AN = void 0, this._$AA = t, this._$AB = e, this._$AM = n, this.options = i, this._$Cv = i?.isConnected ?? !0; + constructor(t, e, n, s) { + this.type = 2, this._$AH = m, this._$AN = void 0, this._$AA = t, this._$AB = e, this._$AM = n, this.options = s, this._$Cv = s?.isConnected ?? !0; } get parentNode() { let t = this._$AA.parentNode; @@ -356,7 +356,7 @@ class P { return this._$AB; } _$AI(t, e = this) { - t = A(this, t, e), S(t) ? t === c || t == null || t === "" ? (this._$AH !== c && this._$AR(), this._$AH = c) : t !== this._$AH && t !== w && this._(t) : t._$litType$ !== void 0 ? this.$(t) : t.nodeType !== void 0 ? this.T(t) : Nt(t) ? this.k(t) : this._(t); + t = M(this, t, e), D(t) ? t === m || t == null || t === "" ? (this._$AH !== m && this._$AR(), this._$AH = m) : t !== this._$AH && t !== E && this._(t) : t._$litType$ !== void 0 ? this.$(t) : t.nodeType !== void 0 ? this.T(t) : Vt(t) ? this.k(t) : this._(t); } O(t) { return this._$AA.parentNode.insertBefore(t, this._$AB); @@ -365,92 +365,92 @@ class P { this._$AH !== t && (this._$AR(), this._$AH = this.O(t)); } _(t) { - this._$AH !== c && S(this._$AH) ? this._$AA.nextSibling.data = t : this.T(q.createTextNode(t)), this._$AH = t; + this._$AH !== m && D(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 = N.createElement(mt(n.h, n.h[0]), this.options)), n); - if (this._$AH?._$AD === i) this._$AH.p(e); + const { values: e, _$litType$: n } = t, s = typeof n == "number" ? this._$AC(t) : (n.el === void 0 && (n.el = H.createElement(Ut(n.h, n.h[0]), this.options)), n); + if (this._$AH?._$AD === s) this._$AH.p(e); else { - const r = new zt(i, this), a = r.u(this.options); - r.p(e), this.T(a), this._$AH = r; + const r = new Gt(s, this), o = r.u(this.options); + r.p(e), this.T(o), this._$AH = r; } } _$AC(t) { - let e = rt.get(t.strings); - return e === void 0 && rt.set(t.strings, e = new N(t)), e; + let e = mt.get(t.strings); + return e === void 0 && mt.set(t.strings, e = new H(t)), e; } k(t) { - Q(this._$AH) || (this._$AH = [], this._$AR()); + nt(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 P(this.O(C()), this.O(C()), this, this.options)) : n = e[i], n._$AI(r), i++; - i < e.length && (this._$AR(n && n._$AB.nextSibling, i), e.length = i); + let n, s = 0; + for (const r of t) s === e.length ? e.push(n = new z(this.O(I()), this.O(I()), this, this.options)) : n = e[s], n._$AI(r), s++; + s < e.length && (this._$AR(n && n._$AB.nextSibling, s), e.length = s); } _$AR(t = this._$AA.nextSibling, e) { for (this._$AP?.(!1, !0, e); t !== this._$AB; ) { - const n = Y(t).nextSibling; - Y(t).remove(), t = n; + const n = dt(t).nextSibling; + dt(t).remove(), t = n; } } setConnected(t) { this._$AM === void 0 && (this._$Cv = t, this._$AP?.(t)); } } -class U { +class Q { get tagName() { return this.element.tagName; } get _$AU() { return this._$AM._$AU; } - constructor(t, e, n, i, r) { - this.type = 1, this._$AH = c, 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 = c; + constructor(t, e, n, s, r) { + this.type = 1, this._$AH = m, this._$AN = void 0, this.element = t, this.name = e, this._$AM = s, this.options = r, n.length > 2 || n[0] !== "" || n[1] !== "" ? (this._$AH = Array(n.length - 1).fill(new String()), this.strings = n) : this._$AH = m; } - _$AI(t, e = this, n, i) { + _$AI(t, e = this, n, s) { const r = this.strings; - let a = !1; - if (r === void 0) t = A(this, t, e, 0), a = !S(t) || t !== this._$AH && t !== w, a && (this._$AH = t); + let o = !1; + if (r === void 0) t = M(this, t, e, 0), o = !D(t) || t !== this._$AH && t !== E, o && (this._$AH = t); else { const l = t; - let o, d; - for (t = r[0], o = 0; o < r.length - 1; o++) d = A(this, l[n + o], e, o), d === w && (d = this._$AH[o]), a ||= !S(d) || d !== this._$AH[o], d === c ? t = c : t !== c && (t += (d ?? "") + r[o + 1]), this._$AH[o] = d; + let a, p; + for (t = r[0], a = 0; a < r.length - 1; a++) p = M(this, l[n + a], e, a), p === E && (p = this._$AH[a]), o ||= !D(p) || p !== this._$AH[a], p === m ? t = m : t !== m && (t += (p ?? "") + r[a + 1]), this._$AH[a] = p; } - a && !i && this.j(t); + o && !s && this.j(t); } j(t) { - t === c ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t ?? ""); + t === m ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t ?? ""); } } -class Ot extends U { +class Xt extends Q { constructor() { super(...arguments), this.type = 3; } j(t) { - this.element[this.name] = t === c ? void 0 : t; + this.element[this.name] = t === m ? void 0 : t; } } -class Mt extends U { +class Yt extends Q { constructor() { super(...arguments), this.type = 4; } j(t) { - this.element.toggleAttribute(this.name, !!t && t !== c); + this.element.toggleAttribute(this.name, !!t && t !== m); } } -class Ut extends U { - constructor(t, e, n, i, r) { - super(t, e, n, i, r), this.type = 5; +class te extends Q { + constructor(t, e, n, s, r) { + super(t, e, n, s, r), this.type = 5; } _$AI(t, e = this) { - if ((t = A(this, t, e, 0) ?? c) === w) return; - const n = this._$AH, i = t === c && n !== c || t.capture !== n.capture || t.once !== n.once || t.passive !== n.passive, r = t !== c && (n === c || i); - i && this.element.removeEventListener(this.name, this, n), r && this.element.addEventListener(this.name, this, t), this._$AH = t; + if ((t = M(this, t, e, 0) ?? m) === E) return; + const n = this._$AH, s = t === m && n !== m || t.capture !== n.capture || t.once !== n.once || t.passive !== n.passive, r = t !== m && (n === m || s); + s && 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 Ht { +class ee { constructor(t, e, n) { this.element = t, this.type = 6, this._$AN = void 0, this._$AM = e, this.options = n; } @@ -458,22 +458,22 @@ class Ht { return this._$AM._$AU; } _$AI(t) { - A(this, t); + M(this, t); } } -const Dt = B.litHtmlPolyfillSupport; -Dt?.(N, P), (B.litHtmlVersions ??= []).push("3.3.3"); -const Rt = (s, t, e) => { +const ne = { I: z }, ie = et.litHtmlPolyfillSupport; +ie?.(H, z), (et.litHtmlVersions ??= []).push("3.3.3"); +const se = (i, t, e) => { const n = e?.renderBefore ?? t; - let i = n._$litPart$; - if (i === void 0) { + let s = n._$litPart$; + if (s === void 0) { const r = e?.renderBefore ?? null; - n._$litPart$ = i = new P(t.insertBefore(C(), r), r, void 0, e ?? {}); + n._$litPart$ = s = new z(t.insertBefore(I(), r), r, void 0, e ?? {}); } - return i._$AI(s), i; + return s._$AI(i), s; }; -const W = globalThis; -class v extends x { +const it = globalThis; +let C = class extends T { constructor() { super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0; } @@ -483,7 +483,7 @@ class v extends x { } update(t) { const e = this.render(); - this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t), this._$Do = Rt(e, this.renderRoot, this.renderOptions); + this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t), this._$Do = se(e, this.renderRoot, this.renderOptions); } connectedCallback() { super.connectedCallback(), this._$Do?.setConnected(!0); @@ -492,51 +492,51 @@ class v extends x { super.disconnectedCallback(), this._$Do?.setConnected(!1); } render() { - return w; + return E; } +}; +C._$litElement$ = !0, C.finalized = !0, it.litElementHydrateSupport?.({ LitElement: C }); +const re = it.litElementPolyfillSupport; +re?.({ LitElement: C }); +(it.litElementVersions ??= []).push("4.2.2"); +const It = ':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 st(i) { + window.customCards = window.customCards ?? [], window.customCards.push(i); } -v._$litElement$ = !0, v.finalized = !0, W.litElementHydrateSupport?.({ LitElement: v }); -const It = W.litElementPolyfillSupport; -It?.({ LitElement: v }); -(W.litElementVersions ??= []).push("4.2.2"); -const yt = ':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-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 V(s) { - window.customCards = window.customCards ?? [], window.customCards.push(s); -} -const at = /* @__PURE__ */ new Map(); -function O(s, t) { - const e = `${s ?? "local"}|${JSON.stringify(t)}`, n = at.get(e); +const gt = /* @__PURE__ */ new Map(); +function B(i, t) { + const e = `${i ?? "local"}|${JSON.stringify(t)}`, n = gt.get(e); if (n) return n; - let i; + let s; try { - i = new Intl.DateTimeFormat("en-US", { + s = new Intl.DateTimeFormat("en-US", { ...t, - ...s ? { timeZone: s } : {} + ...i ? { timeZone: i } : {} }); } catch { - i = new Intl.DateTimeFormat("en-US", t); + s = new Intl.DateTimeFormat("en-US", t); } - return at.set(e, i), i; + return gt.set(e, s), s; } -function Ft(s) { - const t = s?.config; +function oe(i) { + const t = i?.config; if (!t || typeof t != "object") return; const e = t.time_zone; return typeof e == "string" && e.trim() ? e.trim() : void 0; } -function R(s, t) { - return `${O(t, { weekday: "long" }).format(s)}, ${O(t, { month: "short", day: "numeric" }).format(s)}`; +function Z(i, t) { + return `${B(t, { weekday: "long" }).format(i)}, ${B(t, { month: "short", day: "numeric" }).format(i)}`; } -function Lt(s, t) { - return O(t, { +function ae(i, t) { + return B(t, { hour: "numeric", minute: "2-digit", hour12: !0 - }).format(s); + }).format(i); } -const jt = { +const le = { "clear-night": "Clear", cloudy: "Cloudy", exceptional: "Clear", @@ -552,7 +552,7 @@ const jt = { sunny: "Sunny", windy: "Windy", "windy-variant": "Windy" -}, Bt = { +}, de = { "clear-night": "Clear night skies", cloudy: "Grey skies today", exceptional: "A striking day", @@ -569,26 +569,26 @@ const jt = { windy: "A blustery day", "windy-variant": "A blustery day" }; -function ot(s) { - return typeof s == "string" ? s.trim() : ""; +function _t(i) { + return typeof i == "string" ? i.trim() : ""; } -function I(s, t) { - if (s == null || s === "") +function V(i, t) { + if (i == null || i === "") return t; - const e = typeof s == "number" ? s : Number(s); + const e = typeof i == "number" ? i : Number(i); return Number.isFinite(e) ? e : t; } -function lt(s) { - return Number.isFinite(s) ? Math.min(100, Math.max(0, s)) : 0; +function yt(i) { + return Number.isFinite(i) ? Math.min(100, Math.max(0, i)) : 0; } -function pt(s) { - return Math.max(0, Math.round(s)); +function bt(i) { + return Math.max(0, Math.round(i)); } -function Qt(s) { - return s.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); +function pe(i) { + return i.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); } -function Wt(s, t) { - const e = O(t, { +function ce(i, t) { + const e = B(t, { year: "numeric", month: "2-digit", day: "2-digit", @@ -596,10 +596,10 @@ function Wt(s, t) { minute: "2-digit", second: "2-digit", hourCycle: "h23" - }).formatToParts(new Date(s)), n = (r) => { - const a = e.find((l) => l.type === r); - return a ? Number(a.value) : Number.NaN; - }, i = Date.UTC( + }).formatToParts(new Date(i)), n = (r) => { + const o = e.find((l) => l.type === r); + return o ? Number(o.value) : Number.NaN; + }, s = Date.UTC( n("year"), n("month") - 1, n("day"), @@ -607,32 +607,32 @@ function Wt(s, t) { n("minute"), n("second") ); - return Number.isFinite(i) ? i - s : 0; + return Number.isFinite(s) ? s - i : 0; } -function Vt(s, t) { - if (typeof s != "string") +function he(i, t) { + if (typeof i != "string") return null; - const e = s.split("-"); + const e = i.split("-"); if (e.length !== 3) return null; - const [n, i, r] = e.map((o) => Number(o)); - if (!n || !i || !r) + const [n, s, r] = e.map((a) => Number(a)); + if (!n || !s || !r) return null; if (!t) { - const o = new Date(n, i - 1, r); - return Number.isNaN(o.getTime()) ? null : o; + const a = new Date(n, s - 1, r); + return Number.isNaN(a.getTime()) ? null : a; } - const a = Date.UTC(n, i - 1, r), l = new Date(a - Wt(a, t)); + const o = Date.UTC(n, s - 1, r), l = new Date(o - ce(o, t)); return Number.isNaN(l.getTime()) ? null : l; } -function Zt(s) { - const t = jt[s]; - return t || s.charAt(0).toUpperCase() + s.slice(1); +function ue(i) { + const t = le[i]; + return t || i.charAt(0).toUpperCase() + i.slice(1); } -function Kt(s, t) { - return Bt[s] ?? t; +function fe(i, t) { + return de[i] ?? t; } -const dt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Jt = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", Gt = _t` +const vt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", me = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", ge = Tt` * { box-sizing: border-box; } @@ -704,7 +704,7 @@ const dt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Jt = "polygon(50% justify-content: center; width: 34px; height: 34px; - clip-path: ${$(Jt)}; + clip-path: ${b(me)}; background: var(--nq-brand-gradient); } @@ -798,7 +798,7 @@ const dt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Jt = "polygon(50% width: 188px; height: 214px; padding: 4px; - clip-path: ${$(dt)}; + clip-path: ${b(vt)}; background: rgba(255, 255, 255, 0.22); } @@ -809,7 +809,7 @@ const dt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Jt = "polygon(50% align-items: center; justify-content: center; padding-bottom: 70px; - clip-path: ${$(dt)}; + clip-path: ${b(vt)}; background: var(--nq-p-crest); } @@ -971,7 +971,7 @@ const dt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Jt = "polygon(50% font-weight: 800; line-height: 1; } -`, Xt = g` -`, Yt = g``, ye = g` -`, te = g``, be = g` `; -class ee extends v { +class ve extends C { static properties = { hass: { attribute: !1 }, _config: { state: !0 }, _now: { state: !0 } }; - static styles = [$(yt), Gt]; + static styles = [b(It), ge]; hass; _config; _now = /* @__PURE__ */ new Date(); @@ -1052,7 +1052,7 @@ class ee extends v { -

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

    +

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

    ${this._plates().map((t) => this._renderPlate(t))}
    @@ -1063,7 +1063,7 @@ class ee extends v { `; } _timeZone() { - return Ft(this.hass); + return oe(this.hass); } _state(t) { const n = this.hass?.states?.[t]; @@ -1083,36 +1083,36 @@ class ee extends v { _plate(t) { const e = this._state(`sensor.nestquest_${t}_quests_due_today`), n = this._state( `sensor.nestquest_${t}_quests_completed_today` - ), i = this._state( + ), s = this._state( `sensor.nestquest_${t}_completion_pct_today` ), r = this._state( `binary_sensor.nestquest_${t}_present_today` - ), a = e?.attributes ?? {}, l = pt(I(e?.state, 0)), o = pt(I(n?.state, 0)), d = i ? I(i.state, Number.NaN) : Number.NaN, h = Number.isFinite(d) ? lt(d) : l > 0 ? lt(o / l * 100) : 0; - let p = !0; - const f = a.present; - typeof f == "boolean" ? p = f : r && (p = String(r.state ?? "").trim().toLowerCase() === "on"); - let u = ""; - const m = a.child_name; - if (typeof m == "string" && m.trim()) - u = m.trim(); + ), o = e?.attributes ?? {}, l = bt(V(e?.state, 0)), a = bt(V(n?.state, 0)), p = s ? V(s.state, Number.NaN) : Number.NaN, u = Number.isFinite(p) ? yt(p) : l > 0 ? yt(a / l * 100) : 0; + let d = !0; + const h = o.present; + typeof h == "boolean" ? d = h : r && (d = String(r.state ?? "").trim().toLowerCase() === "on"); + let c = ""; + const f = o.child_name; + if (typeof f == "string" && f.trim()) + c = f.trim(); else { - const H = r?.attributes?.child_name; - typeof H == "string" && H.trim() && (u = H.trim()); + const N = r?.attributes?.child_name; + typeof N == "string" && N.trim() && (c = N.trim()); } - u || (u = Qt(t)); - const Z = Vt( + c || (c = pe(t)); + const _ = he( r?.attributes?.next_present, this._timeZone() - ), bt = Z ? `Returns ${R(Z, this._timeZone())}` : null; + ), v = _ ? `Returns ${Z(_, this._timeZone())}` : null; return { slug: t, - name: u, - initial: (u.charAt(0) || "?").toUpperCase(), - present: p, - completed: o, + name: c, + initial: (c.charAt(0) || "?").toUpperCase(), + present: d, + completed: a, due: l, - pct: h, - returns: bt + pct: u, + returns: v }; } _renderPlate(t) { @@ -1130,7 +1130,7 @@ class ee extends v { ${t.name} - ${Xt} + ${_e} Home today @@ -1149,7 +1149,7 @@ class ee extends v { ${t.name} - ${Yt} + ${ye} On travels ${t.returns ?? "Returns —"} @@ -1158,11 +1158,11 @@ class ee extends v { `; } _openQuestLog(t) { - const e = ot(this._config?.quest_log_path); + const e = _t(this._config?.quest_log_path); e && (window.history.pushState(null, "", `${e.replace(/\/+$/, "")}/${t}`), window.dispatchEvent(new Event("location-changed"))); } _dockWeather() { - const t = ot(this._config?.weather_entity); + const t = _t(this._config?.weather_entity); if (!t) return null; const e = this._state(t); @@ -1171,21 +1171,21 @@ class ee extends v { const n = String(e.state ?? "").trim(); if (!n || n === "unavailable" || n === "unknown") return null; - const i = e.attributes ?? {}; - let r = null, a = null; - const l = i.forecast; + const s = e.attributes ?? {}; + let r = null, o = null; + const l = s.forecast; if (Array.isArray(l) && l.length > 0) { - const o = l[0]; - if (o && typeof o == "object") { - const d = o; - r = this._optionalNumber(d.temperature), a = this._optionalNumber(d.templow); + 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), + temperature: this._optionalNumber(s.temperature), high: r, - low: a + low: o }; } _optionalNumber(t) { @@ -1199,16 +1199,16 @@ class ee extends v { if (!t) return g`
    - ${R(this._now, this._timeZone())} + ${Z(this._now, this._timeZone())} - ${Lt(this._now, this._timeZone())} + ${ae(this._now, this._timeZone())}
    `; - const e = Zt(t.condition), n = Kt(t.condition, e), i = t.temperature === null ? c : g`${Math.round(t.temperature)}°`, r = t.high === null || t.low === null ? null : `${Math.round(t.high)}° / ${Math.round(t.low)}°`; + const e = ue(t.condition), n = fe(t.condition, e), s = t.temperature === null ? m : g`${Math.round(t.temperature)}°`, r = t.high === null || t.low === null ? null : `${Math.round(t.high)}° / ${Math.round(t.low)}°`; return g`
    - ${te} - ${i} + ${be} + ${s} ${r === null ? e : `${e} · ${r}`} @@ -1219,45 +1219,1288 @@ class ee extends v { `; } } -customElements.define("nestquest-party-board-card", ee); -V({ +customElements.define("nestquest-party-board-card", ve); +st({ type: "nestquest-party-board-card", name: "NestQuest Party Board", description: "Kids' attract screen for present and away adventurers." }); -class ne extends v { +const $e = { CHILD: 2 }, xe = (i) => (...t) => ({ _$litDirective$: i, values: t }); +let qe = 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: we } = ne, $t = (i) => i, xt = () => document.createComment(""), O = (i, t, e) => { + const n = i._$AA.parentNode, s = t === void 0 ? i._$AB : t._$AA; + if (e === void 0) { + const r = n.insertBefore(xt(), s), o = n.insertBefore(xt(), s); + e = new we(r, o, i, i.options); + } else { + const r = e._$AB.nextSibling, o = e._$AM, l = o !== i; + if (l) { + let a; + e._$AQ?.(i), e._$AM = i, e._$AP !== void 0 && (a = i._$AU) !== o._$AU && e._$AP(a); + } + if (r !== s || l) { + let a = e._$AA; + for (; a !== r; ) { + const p = $t(a).nextSibling; + $t(n).insertBefore(a, s), a = p; + } + } + } + return e; +}, w = (i, t, e = i) => (i._$AI(t, e), i), ke = {}, Ae = (i, t = ke) => i._$AH = t, Ce = (i) => i._$AH, K = (i) => { + i._$AR(), i._$AA.remove(); +}; +const qt = (i, t, e) => { + const n = /* @__PURE__ */ new Map(); + for (let s = t; s <= e; s++) n.set(i[s], s); + return n; +}, wt = xe(class extends qe { + constructor(i) { + if (super(i), i.type !== $e.CHILD) throw Error("repeat() can only be used in text expressions"); + } + dt(i, t, e) { + let n; + e === void 0 ? e = t : t !== void 0 && (n = t); + const s = [], r = []; + let o = 0; + for (const l of i) s[o] = n ? n(l, o) : o, r[o] = e(l, o), o++; + return { values: r, keys: s }; + } + render(i, t, e) { + return this.dt(i, t, e).values; + } + update(i, [t, e, n]) { + const s = Ce(i), { values: r, keys: o } = this.dt(t, e, n); + if (!Array.isArray(s)) return this.ut = o, r; + const l = this.ut ??= [], a = []; + let p, u, d = 0, h = s.length - 1, c = 0, f = r.length - 1; + for (; d <= h && c <= f; ) if (s[d] === null) d++; + else if (s[h] === null) h--; + else if (l[d] === o[c]) a[c] = w(s[d], r[c]), d++, c++; + else if (l[h] === o[f]) a[f] = w(s[h], r[f]), h--, f--; + else if (l[d] === o[f]) a[f] = w(s[d], r[f]), O(i, a[f + 1], s[d]), d++, f--; + else if (l[h] === o[c]) a[c] = w(s[h], r[c]), O(i, s[d], s[h]), h--, c++; + else if (p === void 0 && (p = qt(o, c, f), u = qt(l, d, h)), p.has(l[d])) if (p.has(l[h])) { + const _ = u.get(o[c]), v = _ !== void 0 ? s[_] : null; + if (v === null) { + const N = O(i, s[d]); + w(N, r[c]), a[c] = N; + } else a[c] = w(v, r[c]), O(i, s[d], v), s[_] = null; + c++; + } else K(s[h]), h--; + else K(s[d]), d++; + for (; c <= f; ) { + const _ = O(i, a[f + 1]); + w(_, r[c]), a[c++] = _; + } + for (; d <= h; ) { + const _ = s[d++]; + _ !== null && K(_); + } + return this.ut = o, Ae(i, a), E; + } +}), kt = /* @__PURE__ */ new Map(); +function j(i, t) { + const e = `${i ?? "local"}|${JSON.stringify(t)}`, n = kt.get(e); + if (n) + return n; + let s; + try { + s = new Intl.DateTimeFormat("en-US", { + ...t, + ...i ? { timeZone: i } : {} + }); + } catch { + s = new Intl.DateTimeFormat("en-US", t); + } + return kt.set(e, s), s; +} +function Se(i) { + const t = i?.config; + if (!t || typeof t != "object") + return; + const e = t.time_zone; + return typeof e == "string" && e.trim() ? e.trim() : void 0; +} +function Ee(i, t) { + return `${j(t, { weekday: "long" }).format(i)}, ${j(t, { month: "short", day: "numeric" }).format(i)}`; +} +function Ne(i, t) { + return j(t, { weekday: "long" }).format(i); +} +function Te(i, t) { + const e = new Date(i); + return Number.isNaN(e.getTime()) ? "" : j(t, { + hour: "numeric", + minute: "2-digit", + hour12: !0 + }).format(e); +} +function Me(i) { + if (!i) + return null; + const t = /^(\d{1,2}):(\d{2})$/.exec(i.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 k(i) { + return typeof i == "string" ? i.trim() : ""; +} +function y(i, t) { + if (i == null || i === "") + return t; + const e = typeof i == "number" ? i : Number(i); + return Number.isFinite(e) ? e : t; +} +function $(i) { + return Math.max(0, Math.round(i)); +} +function At(i) { + return i.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); +} +function ze(i, t) { + const e = j(t, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23" + }).formatToParts(new Date(i)), n = (r) => { + const o = e.find((l) => l.type === r); + return o ? Number(o.value) : Number.NaN; + }, s = Date.UTC( + n("year"), + n("month") - 1, + n("day"), + n("hour"), + n("minute"), + n("second") + ); + return Number.isFinite(s) ? s - i : 0; +} +function Pe(i, t) { + if (typeof i != "string") + return null; + const e = i.split("-"); + if (e.length !== 3) + return null; + const [n, s, r] = e.map((a) => Number(a)); + if (!n || !s || !r) + return null; + if (!t) { + const a = new Date(n, s - 1, r); + return Number.isNaN(a.getTime()) ? null : a; + } + const o = Date.UTC(n, s - 1, r), l = new Date(o - ze(o, t)); + return Number.isNaN(l.getTime()) ? null : l; +} +function Oe() { + const i = window.location.pathname.split("/").filter(Boolean); + return i.length > 0 ? i[i.length - 1].toLowerCase() : ""; +} +function Ue(i) { + if (!i || typeof i != "object") + return null; + const t = i, e = k(t.state).toLowerCase(); + if (e !== "open" && e !== "completed") + return null; + const n = y(t.id, 0); + return n ? { + id: n, + title: k(t.title) || "Quest", + icon: k(t.icon) || null, + window: k(t.window).toLowerCase(), + due_time: k(t.due_time) || null, + state: e, + overdue: t.overdue === !0, + completed_at: k(t.completed_at) || null + } : null; +} +const Ct = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ie = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", St = [-9, 6, -4], De = Tt` + * { + 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: ${b(Ct)}; + 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: ${b(Ct)}; + 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; + } + + .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: ${b(Ie)}; + 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); + } + + .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; + } +`, J = g``, He = g``, G = g``, je = g``, Le = g``, Re = g``, Et = [ + { key: "morning", name: "Morning", range: "Until 11:59 AM", icon: je }, + { key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: Le }, + { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: Re } +]; +class Be extends C { static properties = { hass: { attribute: !1 }, - _config: { state: !0 } + _config: { state: !0 }, + _now: { state: !0 }, + _confirm: { state: !0 } }; - static styles = [$(yt)]; + static styles = [b(It), De]; hass; _config; + _now = /* @__PURE__ */ new Date(); + _confirm = null; + _clockTimer; + _idleTimer; + _confirmTimer; setConfig(t) { if (!t || typeof t != "object") throw new Error("Invalid configuration"); this._config = t; } getCardSize() { - return 8; + return 22; + } + connectedCallback() { + super.connectedCallback(), 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(); + } + disconnectedCallback() { + this._stopClock(), this._clearIdle(), this._clearConfirmTimer(), window.removeEventListener("pointerdown", this._onActivity, !0), window.removeEventListener("touchstart", this._onActivity, !0), window.removeEventListener("keydown", this._onActivity, !0), super.disconnectedCallback(); } render() { - return g`
    NestQuest Quest Log
    `; + return g` +
    +
    +
    + ${this._renderHeader()} +
    + ${Et.map((t) => this._renderColumn(t))} +
    + ${this._renderConfirm()} +
    + `; } + _timeZone() { + return Se(this.hass); + } + _state(t) { + const n = this.hass?.states?.[t]; + return n && typeof n == "object" ? n : null; + } + _childSlug() { + return Oe(); + } + _boardPath() { + return k(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; + } + _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` + ), s = $(y(e?.state, 0)), r = $(y(n?.state, 0)); + return { due: s, completed: r }; + } + _instances() { + const t = this._childSlug(); + if (!t) + return []; + const e = this._state(`sensor.nestquest_${t}_quests_due_today`)?.attributes?.instances; + return Array.isArray(e) ? e.map(Ue).filter((n) => n !== null) : []; + } + _remaining() { + const t = this._childSlug(); + if (!t) + return 0; + const e = this._state( + `sensor.nestquest_${t}_quests_remaining_today` + ); + if (e) + return $(y(e.state, 0)); + const { due: n, completed: s } = this._childCounts(t); + return $(n - s); + } + _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: $(y(e?.state, 0)), + due: $(y(t?.state, 0)) + }; + const n = this._childSlug(), { due: s, completed: r } = this._childCounts(n); + return { completed: r, due: s }; + } + _otherChildren() { + const t = this.hass?.states; + if (!t) + return []; + const e = this._childSlug(), n = this._timeZone(), s = []; + 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 = At(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 d = this._state( + `sensor.nestquest_${l}_quests_completed_today` + ), h = $(y(t[r]?.state, 0)), c = $(y(d?.state, 0)); + let f = null; + if (!u) { + const _ = this._state( + `binary_sensor.nestquest_${l}_present_today` + ), v = Pe( + _?.attributes?.next_present, + n + ); + f = v ? Ne(v, n) : null; + } + s.push({ + slug: l, + name: p, + present: u, + due: h, + completed: c, + returnsWeekday: f + }); + } + return s.sort((r, o) => r.name.localeCompare(o.name)); + } + _renderHeader() { + const t = this._childSlug(), n = this._childName(t) ?? (t ? At(t) : null), { due: s, completed: r } = this._childCounts(t), o = this._remaining(), l = this._partyCounts(), a = n ? `${n}'s Quest Log` : "Quest Log", p = (n?.charAt(0) || "?").toUpperCase(); + return g` +
    + +
    +

    ${a}

    +

    + ${Ee(this._now, this._timeZone())} · ${r} of ${s} + claimed +

    +
    +
    + +
    + + ${o} quest${o === 1 ? "" : "s"} left + + + Party progress · ${l.completed} of ${l.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") + ), s = e.filter( + (l) => l.state === "completed" + ), r = e.length; + let o = m; + 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)) + ), d = Math.max(56, Math.min(72, u - 2)); + let h = `--nq-p-quest-gap: ${p}px; --nq-p-quest-min-height: ${u}px; --nq-p-button-height: ${d}px`; + if (r >= 5) { + const c = Math.max(0, Math.min(22, Math.floor((u - d - 2) / 2))), f = Math.max(44, Math.min(64, u - 2 * c - 2)); + h += `; --nq-p-quest-pad: ${c}px; --nq-p-tile-size: ${f}px`; + } + o = h; + } + return g` +
    +
    + ${t.icon} +
    + ${t.name} + ${t.range} +
    + ${s.length}/${e.length} +
    +
    + ${wt( + n, + (l) => l.id, + (l) => this._renderQuest(l, !1, 0) + )} + ${wt( + s, + (l) => l.id, + (l, a) => this._renderQuest(l, !0, a) + )} + ${t.key === "afternoon" ? this._renderRollup() : m} +
    +
    + `; + } + _renderQuest(t, e, n) { + if (e) { + const o = St[n % St.length], l = t.completed_at ? Te(t.completed_at, this._timeZone()) : ""; + return g` +
    + +
    + ${t.title} + + ${l ? `Claimed ${l}` : "Claimed"} + +
    + +
    + `; + } + const s = Me(t.due_time), r = t.overdue ? { + text: s ? `Overdue · due ${s}` : "Overdue", + late: !0 + } : { + text: s ? `Due by ${s}` : "Due today", + late: !1 + }; + return g` +
    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 m; + 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 g` +
    + Party roll-up +

    ${e.join(" ")}

    +
    + `; + } + _renderConfirm() { + if (this._confirm === null) + return m; + const t = this._instances().find( + (n) => n.id === this._confirm + ); + if (!t) + return m; + const e = Et.find((n) => n.key === t.window); + return g` +
    this._closeConfirm()}> + +
    + `; + } + _openConfirm(t) { + this._confirm = t, this._armConfirmTimer(); + } + _closeConfirm() { + this._clearConfirmTimer(), this._confirm = null; + } + _confirmComplete() { + this._closeConfirm(); + } + _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); + } + _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._armIdle(), this._confirm !== null && this._armConfirmTimer(); + }; } -customElements.define("nestquest-quest-log-card", ne); -V({ +customElements.define("nestquest-quest-log-card", Be); +st({ type: "nestquest-quest-log-card", name: "NestQuest Quest Log", description: "One child's daily quests, grouped by window." }); -const se = ":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 ie extends v { +const Fe = ":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 Qe extends C { static properties = { hass: { attribute: !1 }, _config: { state: !0 } }; - static styles = [$(se)]; + static styles = [b(Fe)]; hass; _config; setConfig(t) { @@ -1272,14 +2515,14 @@ class ie extends v { return g`
    NestQuest Admin
    `; } } -customElements.define("nestquest-admin-card", ie); -V({ +customElements.define("nestquest-admin-card", Qe); +st({ type: "nestquest-admin-card", name: "NestQuest Admin", description: "Parent phone view for today, tasks, schedule, and history." }); -const re = "/nestquest-static/nestquest-fonts.css"; +const We = "/nestquest-static/nestquest-fonts.css"; if (!document.querySelector('link[data-nq-fonts=""]')) { - const s = document.createElement("link"); - s.rel = "stylesheet", s.href = re, s.dataset.nqFonts = "", document.head.appendChild(s); + const i = document.createElement("link"); + i.rel = "stylesheet", i.href = We, i.dataset.nqFonts = "", document.head.appendChild(i); } diff --git a/custom_components/nestquest/www/nestquest-panel-tokens.css b/custom_components/nestquest/www/nestquest-panel-tokens.css index f997486..9f51ab6 100644 --- a/custom_components/nestquest/www/nestquest-panel-tokens.css +++ b/custom_components/nestquest/www/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/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/src/cards/quest-log.ts b/frontend/src/cards/quest-log.ts index b51929a..ed27510 100644 --- a/frontend/src/cards/quest-log.ts +++ b/frontend/src/cards/quest-log.ts @@ -1,18 +1,905 @@ -import { LitElement, html, unsafeCSS } from "lit"; +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 StateObject = { + state: unknown; + attributes?: Record | null; +}; + +type PanelInstance = { + id: number; + title: string; + icon: string | null; + window: string; + due_time: string | null; + state: string; + overdue: boolean; + completed_at: string | 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; +}; + +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 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; + } + return { + id, + 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, + }; +} + +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; + } + + .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); + } + + .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; + } +`; + +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 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 }, }; - static styles = [unsafeCSS(panelTokens)]; + static styles = [unsafeCSS(panelTokens), logStyles]; hass?: HassLike; _config?: CardConfig; + _now = new Date(); + _confirm: number | null = null; + private _clockTimer?: number; + private _idleTimer?: number; + private _confirmTimer?: number; setConfig(config: CardConfig): void { if (!config || typeof config !== "object") { @@ -22,12 +909,534 @@ export class NestQuestQuestLogCard extends LitElement { } getCardSize(): number { - return 8; + return 22; + } + + connectedCallback(): void { + super.connectedCallback(); + 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(); + } + + disconnectedCallback(): void { + this._stopClock(); + this._clearIdle(); + this._clearConfirmTimer(); + window.removeEventListener("pointerdown", this._onActivity, true); + window.removeEventListener("touchstart", this._onActivity, true); + window.removeEventListener("keydown", this._onActivity, true); + super.disconnectedCallback(); } render() { - return html`
    NestQuest Quest Log
    `; + return html` +
    +
    +
    + ${this._renderHeader()} +
    + ${WINDOW_DEFS.map((windowDef) => this._renderColumn(windowDef))} +
    + ${this._renderConfirm()} +
    + `; } + + 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 _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 []; + } + return payload + .map(toPanelInstance) + .filter((instance): instance is PanelInstance => instance !== null); + } + + 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 _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() { + const slug = this._childSlug(); + const name = this._childName(slug); + const displayName = name ?? (slug ? titleCaseSlug(slug) : null); + const { due, completed } = this._childCounts(slug); + const remaining = this._remaining(); + const party = this._partyCounts(); + const title = displayName ? `${displayName}'s Quest Log` : "Quest Log"; + const initial = (displayName?.charAt(0) || "?").toUpperCase(); + return html` +
    + +
    +

    ${title}

    +

    + ${formatDay(this._now, this._timeZone())} · ${completed} of ${due} + claimed +

    +
    +
    + +
    + + ${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 { + this._closeConfirm(); + } + + 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 _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 => { + this._armIdle(); + if (this._confirm !== null) { + this._armConfirmTimer(); + } + }; } customElements.define("nestquest-quest-log-card", NestQuestQuestLogCard); From 028215fa3892c8b3cd5252af5274427c4c068410 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Sun, 20 Sep 2026 14:04:58 -0400 Subject: [PATCH 40/46] Define and implement the panel's empty and error states Define and implement the panel's empty and error states --- .../nestquest/www/nestquest-cards.js | 1877 ++++++++++------- design/PANEL-EMPTY-STATES.md | 104 + frontend/src/cards/party-board.ts | 194 +- frontend/src/cards/quest-log.ts | 366 +++- 4 files changed, 1768 insertions(+), 773 deletions(-) create mode 100644 design/PANEL-EMPTY-STATES.md diff --git a/custom_components/nestquest/www/nestquest-cards.js b/custom_components/nestquest/www/nestquest-cards.js index 85e4f59..6b9d8d9 100644 --- a/custom_components/nestquest/www/nestquest-cards.js +++ b/custom_components/nestquest/www/nestquest-cards.js @@ -1,200 +1,200 @@ -const L = globalThis, Y = L.ShadowRoot && (L.ShadyCSS === void 0 || L.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, tt = /* @__PURE__ */ Symbol(), rt = /* @__PURE__ */ new WeakMap(); -let Nt = class { - constructor(t, e, n) { - if (this._$cssResult$ = !0, n !== tt) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); - this.cssText = t, this.t = e; +const L = globalThis, te = L.ShadowRoot && (L.ShadyCSS === void 0 || L.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, ne = /* @__PURE__ */ Symbol(), ae = /* @__PURE__ */ new WeakMap(); +let ze = class { + constructor(e, t, n) { + if (this._$cssResult$ = !0, n !== ne) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); + this.cssText = e, this.t = t; } get styleSheet() { - let t = this.o; - const e = this.t; - if (Y && t === void 0) { - const n = e !== void 0 && e.length === 1; - n && (t = rt.get(e)), t === void 0 && ((this.o = t = new CSSStyleSheet()).replaceSync(this.cssText), n && rt.set(e, t)); + let e = this.o; + const t = this.t; + if (te && e === void 0) { + const n = t !== void 0 && t.length === 1; + n && (e = ae.get(t)), e === void 0 && ((this.o = e = new CSSStyleSheet()).replaceSync(this.cssText), n && ae.set(t, e)); } - return t; + return e; } toString() { return this.cssText; } }; -const b = (i) => new Nt(typeof i == "string" ? i : i + "", void 0, tt), Tt = (i, ...t) => { - const e = i.length === 1 ? i[0] : t.reduce((n, s, r) => n + ((o) => { +const v = (s) => new ze(typeof s == "string" ? s : s + "", void 0, ne), Pe = (s, ...e) => { + const t = s.length === 1 ? s[0] : e.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."); - })(s) + i[r + 1], i[0]); - return new Nt(e, i, tt); -}, Dt = (i, t) => { - if (Y) i.adoptedStyleSheets = t.map((e) => e instanceof CSSStyleSheet ? e : e.styleSheet); - else for (const e of t) { - const n = document.createElement("style"), s = L.litNonce; - s !== void 0 && n.setAttribute("nonce", s), n.textContent = e.cssText, i.appendChild(n); + })(i) + s[r + 1], s[0]); + return new ze(t, s, ne); +}, Be = (s, e) => { + if (te) s.adoptedStyleSheets = e.map((t) => t instanceof CSSStyleSheet ? t : t.styleSheet); + else for (const t of e) { + const n = document.createElement("style"), i = L.litNonce; + i !== void 0 && n.setAttribute("nonce", i), n.textContent = t.cssText, s.appendChild(n); } -}, ot = Y ? (i) => i : (i) => i instanceof CSSStyleSheet ? ((t) => { - let e = ""; - for (const n of t.cssRules) e += n.cssText; - return b(e); -})(i) : i; -const { is: Ht, defineProperty: jt, getOwnPropertyDescriptor: Lt, getOwnPropertyNames: Rt, getOwnPropertySymbols: Bt, getPrototypeOf: Ft } = Object, F = globalThis, at = F.trustedTypes, Qt = at ? at.emptyScript : "", Wt = F.reactiveElementPolyfillSupport, U = (i, t) => i, X = { toAttribute(i, t) { - switch (t) { +}, le = te ? (s) => s : (s) => s instanceof CSSStyleSheet ? ((e) => { + let t = ""; + for (const n of e.cssRules) t += n.cssText; + return v(t); +})(s) : s; +const { is: Fe, defineProperty: Qe, getOwnPropertyDescriptor: We, getOwnPropertyNames: Ze, getOwnPropertySymbols: Ve, getPrototypeOf: Ge } = Object, Q = globalThis, de = Q.trustedTypes, Ke = de ? de.emptyScript : "", Je = Q.reactiveElementPolyfillSupport, P = (s, e) => s, ee = { toAttribute(s, e) { + switch (e) { case Boolean: - i = i ? Qt : null; + s = s ? Ke : null; break; case Object: case Array: - i = i == null ? i : JSON.stringify(i); + s = s == null ? s : JSON.stringify(s); } - return i; -}, fromAttribute(i, t) { - let e = i; - switch (t) { + return s; +}, fromAttribute(s, e) { + let t = s; + switch (e) { case Boolean: - e = i !== null; + t = s !== null; break; case Number: - e = i === null ? null : Number(i); + t = s === null ? null : Number(s); break; case Object: case Array: try { - e = JSON.parse(i); + t = JSON.parse(s); } catch { - e = null; + t = null; } } - return e; -} }, Mt = (i, t) => !Ht(i, t), lt = { attribute: !0, type: String, converter: X, reflect: !1, useDefault: !1, hasChanged: Mt }; -Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), F.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); -let T = class extends HTMLElement { - static addInitializer(t) { - this._$Ei(), (this.l ??= []).push(t); + return t; +} }, Ie = (s, e) => !Fe(s, e), ce = { attribute: !0, type: String, converter: ee, reflect: !1, useDefault: !1, hasChanged: Ie }; +Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), Q.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); +let N = class extends HTMLElement { + static addInitializer(e) { + this._$Ei(), (this.l ??= []).push(e); } static get observedAttributes() { return this.finalize(), this._$Eh && [...this._$Eh.keys()]; } - static createProperty(t, e = lt) { - 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(), s = this.getPropertyDescriptor(t, n, e); - s !== void 0 && jt(this.prototype, t, s); + static createProperty(e, t = ce) { + if (t.state && (t.attribute = !1), this._$Ei(), this.prototype.hasOwnProperty(e) && ((t = Object.create(t)).wrapped = !0), this.elementProperties.set(e, t), !t.noAccessor) { + const n = /* @__PURE__ */ Symbol(), i = this.getPropertyDescriptor(e, n, t); + i !== void 0 && Qe(this.prototype, e, i); } } - static getPropertyDescriptor(t, e, n) { - const { get: s, set: r } = Lt(this.prototype, t) ?? { get() { - return this[e]; + static getPropertyDescriptor(e, t, n) { + const { get: i, set: r } = We(this.prototype, e) ?? { get() { + return this[t]; }, set(o) { - this[e] = o; + this[t] = o; } }; - return { get: s, set(o) { - const l = s?.call(this); - r?.call(this, o), this.requestUpdate(t, l, n); + return { get: i, set(o) { + const l = i?.call(this); + r?.call(this, o), this.requestUpdate(e, l, n); }, configurable: !0, enumerable: !0 }; } - static getPropertyOptions(t) { - return this.elementProperties.get(t) ?? lt; + static getPropertyOptions(e) { + return this.elementProperties.get(e) ?? ce; } static _$Ei() { - if (this.hasOwnProperty(U("elementProperties"))) return; - const t = Ft(this); - t.finalize(), t.l !== void 0 && (this.l = [...t.l]), this.elementProperties = new Map(t.elementProperties); + if (this.hasOwnProperty(P("elementProperties"))) return; + const e = Ge(this); + e.finalize(), e.l !== void 0 && (this.l = [...e.l]), this.elementProperties = new Map(e.elementProperties); } static finalize() { - if (this.hasOwnProperty(U("finalized"))) return; - if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(U("properties"))) { - const e = this.properties, n = [...Rt(e), ...Bt(e)]; - for (const s of n) this.createProperty(s, e[s]); + if (this.hasOwnProperty(P("finalized"))) return; + if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(P("properties"))) { + const t = this.properties, n = [...Ze(t), ...Ve(t)]; + for (const i of n) this.createProperty(i, t[i]); } - const t = this[Symbol.metadata]; - if (t !== null) { - const e = litPropertyMetadata.get(t); - if (e !== void 0) for (const [n, s] of e) this.elementProperties.set(n, s); + const e = this[Symbol.metadata]; + if (e !== null) { + const t = litPropertyMetadata.get(e); + if (t !== void 0) for (const [n, i] of t) this.elementProperties.set(n, i); } this._$Eh = /* @__PURE__ */ new Map(); - for (const [e, n] of this.elementProperties) { - const s = this._$Eu(e, n); - s !== void 0 && this._$Eh.set(s, e); + for (const [t, n] of this.elementProperties) { + const i = this._$Eu(t, n); + i !== void 0 && this._$Eh.set(i, t); } 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 s of n) e.unshift(ot(s)); - } else t !== void 0 && e.push(ot(t)); - return e; + static finalizeStyles(e) { + const t = []; + if (Array.isArray(e)) { + const n = new Set(e.flat(1 / 0).reverse()); + for (const i of n) t.unshift(le(i)); + } else e !== void 0 && t.push(le(e)); + return t; } - static _$Eu(t, e) { - const n = e.attribute; - return n === !1 ? void 0 : typeof n == "string" ? n : typeof t == "string" ? t.toLowerCase() : void 0; + static _$Eu(e, t) { + const n = t.attribute; + return n === !1 ? void 0 : typeof n == "string" ? n : typeof e == "string" ? e.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)); + this._$ES = new Promise((e) => this.enableUpdating = e), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((e) => e(this)); } - addController(t) { - (this._$EO ??= /* @__PURE__ */ new Set()).add(t), this.renderRoot !== void 0 && this.isConnected && t.hostConnected?.(); + addController(e) { + (this._$EO ??= /* @__PURE__ */ new Set()).add(e), this.renderRoot !== void 0 && this.isConnected && e.hostConnected?.(); } - removeController(t) { - this._$EO?.delete(t); + removeController(e) { + this._$EO?.delete(e); } _$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); + const e = /* @__PURE__ */ new Map(), t = this.constructor.elementProperties; + for (const n of t.keys()) this.hasOwnProperty(n) && (e.set(n, this[n]), delete this[n]); + e.size > 0 && (this._$Ep = e); } createRenderRoot() { - const t = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions); - return Dt(t, this.constructor.elementStyles), t; + const e = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions); + return Be(e, this.constructor.elementStyles), e; } connectedCallback() { - this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(!0), this._$EO?.forEach((t) => t.hostConnected?.()); + this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(!0), this._$EO?.forEach((e) => e.hostConnected?.()); } - enableUpdating(t) { + enableUpdating(e) { } disconnectedCallback() { - this._$EO?.forEach((t) => t.hostDisconnected?.()); + this._$EO?.forEach((e) => e.hostDisconnected?.()); } - attributeChangedCallback(t, e, n) { - this._$AK(t, n); + attributeChangedCallback(e, t, n) { + this._$AK(e, n); } - _$ET(t, e) { - const n = this.constructor.elementProperties.get(t), s = this.constructor._$Eu(t, n); - if (s !== void 0 && n.reflect === !0) { - const r = (n.converter?.toAttribute !== void 0 ? n.converter : X).toAttribute(e, n.type); - this._$Em = t, r == null ? this.removeAttribute(s) : this.setAttribute(s, r), this._$Em = null; + _$ET(e, t) { + const n = this.constructor.elementProperties.get(e), i = this.constructor._$Eu(e, n); + if (i !== void 0 && n.reflect === !0) { + const r = (n.converter?.toAttribute !== void 0 ? n.converter : ee).toAttribute(t, n.type); + this._$Em = e, r == null ? this.removeAttribute(i) : this.setAttribute(i, r), this._$Em = null; } } - _$AK(t, e) { - const n = this.constructor, s = n._$Eh.get(t); - if (s !== void 0 && this._$Em !== s) { - const r = n.getPropertyOptions(s), o = typeof r.converter == "function" ? { fromAttribute: r.converter } : r.converter?.fromAttribute !== void 0 ? r.converter : X; - this._$Em = s; - const l = o.fromAttribute(e, r.type); - this[s] = l ?? this._$Ej?.get(s) ?? l, this._$Em = null; + _$AK(e, t) { + const n = this.constructor, i = n._$Eh.get(e); + 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 : ee; + this._$Em = i; + const l = o.fromAttribute(t, r.type); + this[i] = l ?? this._$Ej?.get(i) ?? l, this._$Em = null; } } - requestUpdate(t, e, n, s = !1, r) { - if (t !== void 0) { + requestUpdate(e, t, n, i = !1, r) { + if (e !== void 0) { const o = this.constructor; - if (s === !1 && (r = this[t]), n ??= o.getPropertyOptions(t), !((n.hasChanged ?? Mt)(r, e) || n.useDefault && n.reflect && r === this._$Ej?.get(t) && !this.hasAttribute(o._$Eu(t, n)))) return; - this.C(t, e, n); + if (i === !1 && (r = this[e]), n ??= o.getPropertyOptions(e), !((n.hasChanged ?? Ie)(r, t) || n.useDefault && n.reflect && r === this._$Ej?.get(e) && !this.hasAttribute(o._$Eu(e, n)))) return; + this.C(e, t, n); } this.isUpdatePending === !1 && (this._$ES = this._$EP()); } - C(t, e, { useDefault: n, reflect: s, 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)), s === !0 && this._$Em !== t && (this._$Eq ??= /* @__PURE__ */ new Set()).add(t)); + C(e, t, { useDefault: n, reflect: i, wrapped: r }, o) { + n && !(this._$Ej ??= /* @__PURE__ */ new Map()).has(e) && (this._$Ej.set(e, o ?? t ?? this[e]), r !== !0 || o !== void 0) || (this._$AL.has(e) || (this.hasUpdated || n || (t = void 0), this._$AL.set(e, t)), i === !0 && this._$Em !== e && (this._$Eq ??= /* @__PURE__ */ new Set()).add(e)); } async _$EP() { this.isUpdatePending = !0; try { await this._$ES; - } catch (e) { - Promise.reject(e); + } catch (t) { + Promise.reject(t); } - const t = this.scheduleUpdate(); - return t != null && await t, !this.isUpdatePending; + const e = this.scheduleUpdate(); + return e != null && await e, !this.isUpdatePending; } scheduleUpdate() { return this.performUpdate(); @@ -203,28 +203,28 @@ let T = class extends HTMLElement { if (!this.isUpdatePending) return; if (!this.hasUpdated) { if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) { - for (const [s, r] of this._$Ep) this[s] = r; + 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 [s, r] of n) { - const { wrapped: o } = r, l = this[s]; - o !== !0 || this._$AL.has(s) || l === void 0 || this.C(s, void 0, r, l); + 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; + let e = !1; + const t = this._$AL; try { - t = this.shouldUpdate(e), t ? (this.willUpdate(e), this._$EO?.forEach((n) => n.hostUpdate?.()), this.update(e)) : this._$EM(); + e = this.shouldUpdate(t), e ? (this.willUpdate(t), this._$EO?.forEach((n) => n.hostUpdate?.()), this.update(t)) : this._$EM(); } catch (n) { - throw t = !1, this._$EM(), n; + throw e = !1, this._$EM(), n; } - t && this._$AE(e); + e && this._$AE(t); } - willUpdate(t) { + willUpdate(e) { } - _$AE(t) { - this._$EO?.forEach((e) => e.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = !0, this.firstUpdated(t)), this.updated(t); + _$AE(e) { + this._$EO?.forEach((t) => t.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = !0, this.firstUpdated(e)), this.updated(e); } _$EM() { this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = !1; @@ -235,83 +235,83 @@ let T = class extends HTMLElement { getUpdateComplete() { return this._$ES; } - shouldUpdate(t) { + shouldUpdate(e) { return !0; } - update(t) { - this._$Eq &&= this._$Eq.forEach((e) => this._$ET(e, this[e])), this._$EM(); + update(e) { + this._$Eq &&= this._$Eq.forEach((t) => this._$ET(t, this[t])), this._$EM(); } - updated(t) { + updated(e) { } - firstUpdated(t) { + firstUpdated(e) { } }; -T.elementStyles = [], T.shadowRootOptions = { mode: "open" }, T[U("elementProperties")] = /* @__PURE__ */ new Map(), T[U("finalized")] = /* @__PURE__ */ new Map(), Wt?.({ ReactiveElement: T }), (F.reactiveElementVersions ??= []).push("2.1.2"); -const et = globalThis, dt = (i) => i, R = et.trustedTypes, pt = R ? R.createPolicy("lit-html", { createHTML: (i) => i }) : void 0, zt = "$lit$", x = `lit$${Math.random().toFixed(9).slice(2)}$`, Pt = "?" + x, Zt = `<${Pt}>`, S = document, I = () => S.createComment(""), D = (i) => i === null || typeof i != "object" && typeof i != "function", nt = Array.isArray, Vt = (i) => nt(i) || typeof i?.[Symbol.iterator] == "function", W = `[ -\f\r]`, P = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, ct = /-->/g, ht = />/g, q = RegExp(`>|${W}(?:([^\\s"'>=/]+)(${W}*=${W}*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`, "g"), ut = /'/g, ft = /"/g, Ot = /^(?:script|style|textarea|title)$/i, Kt = (i) => (t, ...e) => ({ _$litType$: i, strings: t, values: e }), g = Kt(1), E = /* @__PURE__ */ Symbol.for("lit-noChange"), m = /* @__PURE__ */ Symbol.for("lit-nothing"), mt = /* @__PURE__ */ new WeakMap(), A = S.createTreeWalker(S, 129); -function Ut(i, t) { - if (!nt(i) || !i.hasOwnProperty("raw")) throw Error("invalid template strings array"); - return pt !== void 0 ? pt.createHTML(t) : t; +N.elementStyles = [], N.shadowRootOptions = { mode: "open" }, N[P("elementProperties")] = /* @__PURE__ */ new Map(), N[P("finalized")] = /* @__PURE__ */ new Map(), Je?.({ ReactiveElement: N }), (Q.reactiveElementVersions ??= []).push("2.1.2"); +const se = globalThis, pe = (s) => s, B = se.trustedTypes, he = B ? B.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, Ue = "$lit$", x = `lit$${Math.random().toFixed(9).slice(2)}$`, De = "?" + x, Xe = `<${De}>`, S = document, I = () => S.createComment(""), U = (s) => s === null || typeof s != "object" && typeof s != "function", ie = Array.isArray, Ye = (s) => ie(s) || typeof s?.[Symbol.iterator] == "function", V = `[ +\f\r]`, O = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, ue = /-->/g, fe = />/g, q = RegExp(`>|${V}(?:([^\\s"'>=/]+)(${V}*=${V}*(?:[^ +\f\r"'\`<>=]|("|')|))|$)`, "g"), me = /'/g, ge = /"/g, He = /^(?:script|style|textarea|title)$/i, et = (s) => (e, ...t) => ({ _$litType$: s, strings: e, values: t }), p = et(1), E = /* @__PURE__ */ Symbol.for("lit-noChange"), g = /* @__PURE__ */ Symbol.for("lit-nothing"), ye = /* @__PURE__ */ new WeakMap(), A = S.createTreeWalker(S, 129); +function je(s, e) { + if (!ie(s) || !s.hasOwnProperty("raw")) throw Error("invalid template strings array"); + return he !== void 0 ? he.createHTML(e) : e; } -const Jt = (i, t) => { - const e = i.length - 1, n = []; - let s, r = t === 2 ? "" : t === 3 ? "" : "", o = P; - for (let l = 0; l < e; l++) { - const a = i[l]; - let p, u, d = -1, h = 0; - for (; h < a.length && (o.lastIndex = h, u = o.exec(a), u !== null); ) h = o.lastIndex, o === P ? u[1] === "!--" ? o = ct : u[1] !== void 0 ? o = ht : u[2] !== void 0 ? (Ot.test(u[2]) && (s = RegExp("" ? (o = s ?? P, d = -1) : u[1] === void 0 ? d = -2 : (d = o.lastIndex - u[2].length, p = u[1], o = u[3] === void 0 ? q : u[3] === '"' ? ft : ut) : o === ft || o === ut ? o = q : o === ct || o === ht ? o = P : (o = q, s = void 0); - const c = o === q && i[l + 1].startsWith("/>") ? " " : ""; - r += o === P ? a + Zt : d >= 0 ? (n.push(p), a.slice(0, d) + zt + a.slice(d) + x + c) : a + x + (d === -2 ? l : c); +const tt = (s, e) => { + const t = s.length - 1, n = []; + let i, r = e === 2 ? "" : e === 3 ? "" : "", o = O; + for (let l = 0; l < t; l++) { + const a = s[l]; + let c, u, d = -1, f = 0; + for (; f < a.length && (o.lastIndex = f, u = o.exec(a), u !== null); ) f = o.lastIndex, o === O ? u[1] === "!--" ? o = ue : u[1] !== void 0 ? o = fe : u[2] !== void 0 ? (He.test(u[2]) && (i = RegExp("" ? (o = i ?? O, d = -1) : u[1] === void 0 ? d = -2 : (d = o.lastIndex - u[2].length, c = u[1], o = u[3] === void 0 ? q : u[3] === '"' ? ge : me) : o === ge || o === me ? o = q : o === ue || o === fe ? o = O : (o = q, i = void 0); + const h = o === q && s[l + 1].startsWith("/>") ? " " : ""; + r += o === O ? a + Xe : d >= 0 ? (n.push(c), a.slice(0, d) + Ue + a.slice(d) + x + h) : a + x + (d === -2 ? l : h); } - return [Ut(i, r + (i[e] || "") + (t === 2 ? "" : t === 3 ? "" : "")), n]; + return [je(s, r + (s[t] || "") + (e === 2 ? "" : e === 3 ? "" : "")), n]; }; -class H { - constructor({ strings: t, _$litType$: e }, n) { - let s; +class D { + constructor({ strings: e, _$litType$: t }, n) { + let i; this.parts = []; let r = 0, o = 0; - const l = t.length - 1, a = this.parts, [p, u] = Jt(t, e); - if (this.el = H.createElement(p, n), A.currentNode = this.el.content, e === 2 || e === 3) { + const l = e.length - 1, a = this.parts, [c, u] = tt(e, t); + if (this.el = D.createElement(c, n), A.currentNode = this.el.content, t === 2 || t === 3) { const d = this.el.content.firstChild; d.replaceWith(...d.childNodes); } - for (; (s = A.nextNode()) !== null && a.length < l; ) { - if (s.nodeType === 1) { - if (s.hasAttributes()) for (const d of s.getAttributeNames()) if (d.endsWith(zt)) { - const h = u[o++], c = s.getAttribute(d).split(x), f = /([.?@])?(.*)/.exec(h); - a.push({ type: 1, index: r, name: f[2], strings: c, ctor: f[1] === "." ? Xt : f[1] === "?" ? Yt : f[1] === "@" ? te : Q }), s.removeAttribute(d); - } else d.startsWith(x) && (a.push({ type: 6, index: r }), s.removeAttribute(d)); - if (Ot.test(s.tagName)) { - const d = s.textContent.split(x), h = d.length - 1; - if (h > 0) { - s.textContent = R ? R.emptyScript : ""; - for (let c = 0; c < h; c++) s.append(d[c], I()), A.nextNode(), a.push({ type: 2, index: ++r }); - s.append(d[h], I()); + for (; (i = A.nextNode()) !== null && a.length < l; ) { + if (i.nodeType === 1) { + if (i.hasAttributes()) for (const d of i.getAttributeNames()) if (d.endsWith(Ue)) { + const f = u[o++], h = i.getAttribute(d).split(x), m = /([.?@])?(.*)/.exec(f); + a.push({ type: 1, index: r, name: m[2], strings: h, ctor: m[1] === "." ? st : m[1] === "?" ? it : m[1] === "@" ? rt : W }), i.removeAttribute(d); + } else d.startsWith(x) && (a.push({ type: 6, index: r }), i.removeAttribute(d)); + if (He.test(i.tagName)) { + const d = i.textContent.split(x), f = d.length - 1; + if (f > 0) { + i.textContent = B ? B.emptyScript : ""; + for (let h = 0; h < f; h++) i.append(d[h], I()), A.nextNode(), a.push({ type: 2, index: ++r }); + i.append(d[f], I()); } } - } else if (s.nodeType === 8) if (s.data === Pt) a.push({ type: 2, index: r }); + } else if (i.nodeType === 8) if (i.data === De) a.push({ type: 2, index: r }); else { let d = -1; - for (; (d = s.data.indexOf(x, d + 1)) !== -1; ) a.push({ type: 7, index: r }), d += x.length - 1; + for (; (d = i.data.indexOf(x, d + 1)) !== -1; ) a.push({ type: 7, index: r }), d += x.length - 1; } r++; } } - static createElement(t, e) { + static createElement(e, t) { const n = S.createElement("template"); - return n.innerHTML = t, n; + return n.innerHTML = e, n; } } -function M(i, t, e = i, n) { - if (t === E) return t; - let s = n !== void 0 ? e._$Co?.[n] : e._$Cl; - const r = D(t) ? void 0 : t._$litDirective$; - return s?.constructor !== r && (s?._$AO?.(!1), r === void 0 ? s = void 0 : (s = new r(i), s._$AT(i, e, n)), n !== void 0 ? (e._$Co ??= [])[n] = s : e._$Cl = s), s !== void 0 && (t = M(i, s._$AS(i, t.values), s, n)), t; +function T(s, e, t = s, n) { + if (e === E) return e; + let i = n !== void 0 ? t._$Co?.[n] : t._$Cl; + const r = U(e) ? void 0 : e._$litDirective$; + return i?.constructor !== r && (i?._$AO?.(!1), r === void 0 ? i = void 0 : (i = new r(s), i._$AT(s, t, n)), n !== void 0 ? (t._$Co ??= [])[n] = i : t._$Cl = i), i !== void 0 && (e = T(s, i._$AS(s, e.values), i, n)), e; } -class Gt { - constructor(t, e) { - this._$AV = [], this._$AN = void 0, this._$AD = t, this._$AM = e; +class nt { + constructor(e, t) { + this._$AV = [], this._$AN = void 0, this._$AD = e, this._$AM = t; } get parentNode() { return this._$AM.parentNode; @@ -319,35 +319,35 @@ class Gt { get _$AU() { return this._$AM._$AU; } - u(t) { - const { el: { content: e }, parts: n } = this._$AD, s = (t?.creationScope ?? S).importNode(e, !0); - A.currentNode = s; + u(e) { + const { el: { content: t }, parts: n } = this._$AD, i = (e?.creationScope ?? S).importNode(t, !0); + A.currentNode = i; let r = A.nextNode(), o = 0, l = 0, a = n[0]; for (; a !== void 0; ) { if (o === a.index) { - let p; - a.type === 2 ? p = new z(r, r.nextSibling, this, t) : a.type === 1 ? p = new a.ctor(r, a.name, a.strings, this, t) : a.type === 6 && (p = new ee(r, this, t)), this._$AV.push(p), a = n[++l]; + let c; + a.type === 2 ? c = new M(r, r.nextSibling, this, e) : a.type === 1 ? c = new a.ctor(r, a.name, a.strings, this, e) : a.type === 6 && (c = new ot(r, this, e)), this._$AV.push(c), a = n[++l]; } o !== a?.index && (r = A.nextNode(), o++); } - return A.currentNode = S, s; + return A.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++; + p(e) { + let t = 0; + for (const n of this._$AV) n !== void 0 && (n.strings !== void 0 ? (n._$AI(e, n, t), t += n.strings.length - 2) : n._$AI(e[t])), t++; } } -class z { +class M { get _$AU() { return this._$AM?._$AU ?? this._$Cv; } - constructor(t, e, n, s) { - this.type = 2, this._$AH = m, this._$AN = void 0, this._$AA = t, this._$AB = e, this._$AM = n, this.options = s, this._$Cv = s?.isConnected ?? !0; + constructor(e, t, n, i) { + this.type = 2, this._$AH = g, this._$AN = void 0, this._$AA = e, this._$AB = t, 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; + let e = this._$AA.parentNode; + const t = this._$AM; + return t !== void 0 && e?.nodeType === 11 && (e = t.parentNode), e; } get startNode() { return this._$AA; @@ -355,135 +355,135 @@ class z { get endNode() { return this._$AB; } - _$AI(t, e = this) { - t = M(this, t, e), D(t) ? t === m || t == null || t === "" ? (this._$AH !== m && this._$AR(), this._$AH = m) : t !== this._$AH && t !== E && this._(t) : t._$litType$ !== void 0 ? this.$(t) : t.nodeType !== void 0 ? this.T(t) : Vt(t) ? this.k(t) : this._(t); + _$AI(e, t = this) { + e = T(this, e, t), U(e) ? e === g || e == null || e === "" ? (this._$AH !== g && this._$AR(), this._$AH = g) : e !== this._$AH && e !== E && this._(e) : e._$litType$ !== void 0 ? this.$(e) : e.nodeType !== void 0 ? this.T(e) : Ye(e) ? this.k(e) : this._(e); } - O(t) { - return this._$AA.parentNode.insertBefore(t, this._$AB); + O(e) { + return this._$AA.parentNode.insertBefore(e, this._$AB); } - T(t) { - this._$AH !== t && (this._$AR(), this._$AH = this.O(t)); + T(e) { + this._$AH !== e && (this._$AR(), this._$AH = this.O(e)); } - _(t) { - this._$AH !== m && D(this._$AH) ? this._$AA.nextSibling.data = t : this.T(S.createTextNode(t)), this._$AH = t; + _(e) { + this._$AH !== g && U(this._$AH) ? this._$AA.nextSibling.data = e : this.T(S.createTextNode(e)), this._$AH = e; } - $(t) { - const { values: e, _$litType$: n } = t, s = typeof n == "number" ? this._$AC(t) : (n.el === void 0 && (n.el = H.createElement(Ut(n.h, n.h[0]), this.options)), n); - if (this._$AH?._$AD === s) this._$AH.p(e); + $(e) { + const { values: t, _$litType$: n } = e, i = typeof n == "number" ? this._$AC(e) : (n.el === void 0 && (n.el = D.createElement(je(n.h, n.h[0]), this.options)), n); + if (this._$AH?._$AD === i) this._$AH.p(t); else { - const r = new Gt(s, this), o = r.u(this.options); - r.p(e), this.T(o), this._$AH = r; + const r = new nt(i, this), o = r.u(this.options); + r.p(t), this.T(o), this._$AH = r; } } - _$AC(t) { - let e = mt.get(t.strings); - return e === void 0 && mt.set(t.strings, e = new H(t)), e; + _$AC(e) { + let t = ye.get(e.strings); + return t === void 0 && ye.set(e.strings, t = new D(e)), t; } - k(t) { - nt(this._$AH) || (this._$AH = [], this._$AR()); - const e = this._$AH; - let n, s = 0; - for (const r of t) s === e.length ? e.push(n = new z(this.O(I()), this.O(I()), this, this.options)) : n = e[s], n._$AI(r), s++; - s < e.length && (this._$AR(n && n._$AB.nextSibling, s), e.length = s); + k(e) { + ie(this._$AH) || (this._$AH = [], this._$AR()); + const t = this._$AH; + let n, i = 0; + for (const r of e) i === t.length ? t.push(n = new M(this.O(I()), this.O(I()), this, this.options)) : n = t[i], n._$AI(r), i++; + i < t.length && (this._$AR(n && n._$AB.nextSibling, i), t.length = i); } - _$AR(t = this._$AA.nextSibling, e) { - for (this._$AP?.(!1, !0, e); t !== this._$AB; ) { - const n = dt(t).nextSibling; - dt(t).remove(), t = n; + _$AR(e = this._$AA.nextSibling, t) { + for (this._$AP?.(!1, !0, t); e !== this._$AB; ) { + const n = pe(e).nextSibling; + pe(e).remove(), e = n; } } - setConnected(t) { - this._$AM === void 0 && (this._$Cv = t, this._$AP?.(t)); + setConnected(e) { + this._$AM === void 0 && (this._$Cv = e, this._$AP?.(e)); } } -class Q { +class W { get tagName() { return this.element.tagName; } get _$AU() { return this._$AM._$AU; } - constructor(t, e, n, s, r) { - this.type = 1, this._$AH = m, this._$AN = void 0, this.element = t, this.name = e, this._$AM = s, this.options = r, n.length > 2 || n[0] !== "" || n[1] !== "" ? (this._$AH = Array(n.length - 1).fill(new String()), this.strings = n) : this._$AH = m; + constructor(e, t, n, i, r) { + this.type = 1, this._$AH = g, this._$AN = void 0, this.element = e, this.name = t, 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, s) { + _$AI(e, t = this, n, i) { const r = this.strings; let o = !1; - if (r === void 0) t = M(this, t, e, 0), o = !D(t) || t !== this._$AH && t !== E, o && (this._$AH = t); + if (r === void 0) e = T(this, e, t, 0), o = !U(e) || e !== this._$AH && e !== E, o && (this._$AH = e); else { - const l = t; - let a, p; - for (t = r[0], a = 0; a < r.length - 1; a++) p = M(this, l[n + a], e, a), p === E && (p = this._$AH[a]), o ||= !D(p) || p !== this._$AH[a], p === m ? t = m : t !== m && (t += (p ?? "") + r[a + 1]), this._$AH[a] = p; + const l = e; + let a, c; + for (e = r[0], a = 0; a < r.length - 1; a++) c = T(this, l[n + a], t, a), c === E && (c = this._$AH[a]), o ||= !U(c) || c !== this._$AH[a], c === g ? e = g : e !== g && (e += (c ?? "") + r[a + 1]), this._$AH[a] = c; } - o && !s && this.j(t); + o && !i && this.j(e); } - j(t) { - t === m ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t ?? ""); + j(e) { + e === g ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, e ?? ""); } } -class Xt extends Q { +class st extends W { constructor() { super(...arguments), this.type = 3; } - j(t) { - this.element[this.name] = t === m ? void 0 : t; + j(e) { + this.element[this.name] = e === g ? void 0 : e; } } -class Yt extends Q { +class it extends W { constructor() { super(...arguments), this.type = 4; } - j(t) { - this.element.toggleAttribute(this.name, !!t && t !== m); + j(e) { + this.element.toggleAttribute(this.name, !!e && e !== g); } } -class te extends Q { - constructor(t, e, n, s, r) { - super(t, e, n, s, r), this.type = 5; +class rt extends W { + constructor(e, t, n, i, r) { + super(e, t, n, i, r), this.type = 5; } - _$AI(t, e = this) { - if ((t = M(this, t, e, 0) ?? m) === E) return; - const n = this._$AH, s = t === m && n !== m || t.capture !== n.capture || t.once !== n.once || t.passive !== n.passive, r = t !== m && (n === m || s); - s && this.element.removeEventListener(this.name, this, n), r && this.element.addEventListener(this.name, this, t), this._$AH = t; + _$AI(e, t = this) { + if ((e = T(this, e, t, 0) ?? g) === E) return; + const n = this._$AH, i = e === g && n !== g || e.capture !== n.capture || e.once !== n.once || e.passive !== n.passive, r = e !== g && (n === g || i); + i && this.element.removeEventListener(this.name, this, n), r && this.element.addEventListener(this.name, this, e), this._$AH = e; } - handleEvent(t) { - typeof this._$AH == "function" ? this._$AH.call(this.options?.host ?? this.element, t) : this._$AH.handleEvent(t); + handleEvent(e) { + typeof this._$AH == "function" ? this._$AH.call(this.options?.host ?? this.element, e) : this._$AH.handleEvent(e); } } -class ee { - constructor(t, e, n) { - this.element = t, this.type = 6, this._$AN = void 0, this._$AM = e, this.options = n; +class ot { + constructor(e, t, n) { + this.element = e, this.type = 6, this._$AN = void 0, this._$AM = t, this.options = n; } get _$AU() { return this._$AM._$AU; } - _$AI(t) { - M(this, t); + _$AI(e) { + T(this, e); } } -const ne = { I: z }, ie = et.litHtmlPolyfillSupport; -ie?.(H, z), (et.litHtmlVersions ??= []).push("3.3.3"); -const se = (i, t, e) => { - const n = e?.renderBefore ?? t; - let s = n._$litPart$; - if (s === void 0) { - const r = e?.renderBefore ?? null; - n._$litPart$ = s = new z(t.insertBefore(I(), r), r, void 0, e ?? {}); +const at = { I: M }, lt = se.litHtmlPolyfillSupport; +lt?.(D, M), (se.litHtmlVersions ??= []).push("3.3.3"); +const dt = (s, e, t) => { + const n = t?.renderBefore ?? e; + let i = n._$litPart$; + if (i === void 0) { + const r = t?.renderBefore ?? null; + n._$litPart$ = i = new M(e.insertBefore(I(), r), r, void 0, t ?? {}); } - return s._$AI(i), s; + return i._$AI(s), i; }; -const it = globalThis; -let C = class extends T { +const re = globalThis; +let C = class extends N { constructor() { super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0; } createRenderRoot() { - const t = super.createRenderRoot(); - return this.renderOptions.renderBefore ??= t.firstChild, t; + const e = super.createRenderRoot(); + return this.renderOptions.renderBefore ??= e.firstChild, e; } - update(t) { - const e = this.render(); - this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t), this._$Do = se(e, this.renderRoot, this.renderOptions); + update(e) { + const t = this.render(); + this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(e), this._$Do = dt(t, this.renderRoot, this.renderOptions); } connectedCallback() { super.connectedCallback(), this._$Do?.setConnected(!0); @@ -495,48 +495,48 @@ let C = class extends T { return E; } }; -C._$litElement$ = !0, C.finalized = !0, it.litElementHydrateSupport?.({ LitElement: C }); -const re = it.litElementPolyfillSupport; -re?.({ LitElement: C }); -(it.litElementVersions ??= []).push("4.2.2"); -const It = ':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 st(i) { - window.customCards = window.customCards ?? [], window.customCards.push(i); +C._$litElement$ = !0, C.finalized = !0, re.litElementHydrateSupport?.({ LitElement: C }); +const ct = re.litElementPolyfillSupport; +ct?.({ LitElement: C }); +(re.litElementVersions ??= []).push("4.2.2"); +const Re = ':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 oe(s) { + window.customCards = window.customCards ?? [], window.customCards.push(s); } -const gt = /* @__PURE__ */ new Map(); -function B(i, t) { - const e = `${i ?? "local"}|${JSON.stringify(t)}`, n = gt.get(e); +const _e = /* @__PURE__ */ new Map(); +function F(s, e) { + const t = `${s ?? "local"}|${JSON.stringify(e)}`, n = _e.get(t); if (n) return n; - let s; + let i; try { - s = new Intl.DateTimeFormat("en-US", { - ...t, - ...i ? { timeZone: i } : {} + i = new Intl.DateTimeFormat("en-US", { + ...e, + ...s ? { timeZone: s } : {} }); } catch { - s = new Intl.DateTimeFormat("en-US", t); + i = new Intl.DateTimeFormat("en-US", e); } - return gt.set(e, s), s; + return _e.set(t, i), i; } -function oe(i) { - const t = i?.config; - if (!t || typeof t != "object") +function pt(s) { + const e = s?.config; + if (!e || typeof e != "object") return; - const e = t.time_zone; - return typeof e == "string" && e.trim() ? e.trim() : void 0; + const t = e.time_zone; + return typeof t == "string" && t.trim() ? t.trim() : void 0; } -function Z(i, t) { - return `${B(t, { weekday: "long" }).format(i)}, ${B(t, { month: "short", day: "numeric" }).format(i)}`; +function G(s, e) { + return `${F(e, { weekday: "long" }).format(s)}, ${F(e, { month: "short", day: "numeric" }).format(s)}`; } -function ae(i, t) { - return B(t, { +function ht(s, e) { + return F(e, { hour: "numeric", minute: "2-digit", hour12: !0 - }).format(i); + }).format(s); } -const le = { +const ut = { "clear-night": "Clear", cloudy: "Cloudy", exceptional: "Clear", @@ -552,7 +552,7 @@ const le = { sunny: "Sunny", windy: "Windy", "windy-variant": "Windy" -}, de = { +}, ft = { "clear-night": "Clear night skies", cloudy: "Grey skies today", exceptional: "A striking day", @@ -569,26 +569,26 @@ const le = { windy: "A blustery day", "windy-variant": "A blustery day" }; -function _t(i) { - return typeof i == "string" ? i.trim() : ""; +function ve(s) { + return typeof s == "string" ? s.trim() : ""; } -function V(i, t) { - if (i == null || i === "") - return t; - const e = typeof i == "number" ? i : Number(i); - return Number.isFinite(e) ? e : t; +function K(s, e) { + if (s == null || s === "") + return e; + const t = typeof s == "number" ? s : Number(s); + return Number.isFinite(t) ? t : e; } -function yt(i) { - return Number.isFinite(i) ? Math.min(100, Math.max(0, i)) : 0; +function be(s) { + return Number.isFinite(s) ? Math.min(100, Math.max(0, s)) : 0; } -function bt(i) { - return Math.max(0, Math.round(i)); +function $e(s) { + return Math.max(0, Math.round(s)); } -function pe(i) { - return i.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); +function mt(s) { + return s.split(/[-_]+/).filter(Boolean).map((e) => e.charAt(0).toUpperCase() + e.slice(1)).join(" "); } -function ce(i, t) { - const e = B(t, { +function gt(s, e) { + const t = F(e, { year: "numeric", month: "2-digit", day: "2-digit", @@ -596,10 +596,10 @@ function ce(i, t) { minute: "2-digit", second: "2-digit", hourCycle: "h23" - }).formatToParts(new Date(i)), n = (r) => { - const o = e.find((l) => l.type === r); + }).formatToParts(new Date(s)), n = (r) => { + const o = t.find((l) => l.type === r); return o ? Number(o.value) : Number.NaN; - }, s = Date.UTC( + }, i = Date.UTC( n("year"), n("month") - 1, n("day"), @@ -607,32 +607,32 @@ function ce(i, t) { n("minute"), n("second") ); - return Number.isFinite(s) ? s - i : 0; + return Number.isFinite(i) ? i - s : 0; } -function he(i, t) { - if (typeof i != "string") +function yt(s, e) { + if (typeof s != "string") return null; - const e = i.split("-"); - if (e.length !== 3) + const t = s.split("-"); + if (t.length !== 3) return null; - const [n, s, r] = e.map((a) => Number(a)); - if (!n || !s || !r) + const [n, i, r] = t.map((a) => Number(a)); + if (!n || !i || !r) return null; - if (!t) { - const a = new Date(n, s - 1, r); + if (!e) { + const a = new Date(n, i - 1, r); return Number.isNaN(a.getTime()) ? null : a; } - const o = Date.UTC(n, s - 1, r), l = new Date(o - ce(o, t)); + const o = Date.UTC(n, i - 1, r), l = new Date(o - gt(o, e)); return Number.isNaN(l.getTime()) ? null : l; } -function ue(i) { - const t = le[i]; - return t || i.charAt(0).toUpperCase() + i.slice(1); +function _t(s) { + const e = ut[s]; + return e || s.charAt(0).toUpperCase() + s.slice(1); } -function fe(i, t) { - return de[i] ?? t; +function vt(s, e) { + return ft[s] ?? e; } -const vt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", me = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", ge = Tt` +const xe = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", bt = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", $t = Pe` * { box-sizing: border-box; } @@ -704,7 +704,7 @@ const vt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", me = "polygon(50% justify-content: center; width: 34px; height: 34px; - clip-path: ${b(me)}; + clip-path: ${v(bt)}; background: var(--nq-brand-gradient); } @@ -798,7 +798,7 @@ const vt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", me = "polygon(50% width: 188px; height: 214px; padding: 4px; - clip-path: ${b(vt)}; + clip-path: ${v(xe)}; background: rgba(255, 255, 255, 0.22); } @@ -809,7 +809,7 @@ const vt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", me = "polygon(50% align-items: center; justify-content: center; padding-bottom: 70px; - clip-path: ${b(vt)}; + clip-path: ${v(xe)}; background: var(--nq-p-crest); } @@ -906,6 +906,62 @@ const vt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", me = "polygon(50% 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; } @@ -971,7 +1027,7 @@ const vt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", me = "polygon(50% font-weight: 800; line-height: 1; } -`, _e = g` -`, ye = g``, qt = p` -`, be = g``, wt = p``, kt = p``, At = p``, qe = { + icon: kt, + headline: "NestQuest is not set up yet", + body: "A parent needs to finish setting up NestQuest before the party can gather." +}, Ct = { + icon: At, + headline: "The records cannot be reached", + body: "The party's records are quiet right now. NestQuest will return shortly." +}, St = p` `; -class ve extends C { +class Et extends C { static properties = { hass: { attribute: !1 }, _config: { state: !0 }, _now: { state: !0 } }; - static styles = [b(It), ge]; + static styles = [v(Re), $t]; hass; _config; _now = /* @__PURE__ */ new Date(); _clockTimer; - setConfig(t) { - if (!t || typeof t != "object") + setConfig(e) { + if (!e || typeof e != "object") throw new Error("Invalid configuration"); - this._config = t; + this._config = e; } getCardSize() { return 22; @@ -1037,7 +1135,8 @@ class ve extends C { this._clockTimer !== void 0 && (window.clearInterval(this._clockTimer), this._clockTimer = void 0), super.disconnectedCallback(); } render() { - return g` + const e = this._boardNotice(); + return p`
    @@ -1052,166 +1151,210 @@ class ve extends C {
    -

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

    -
    - ${this._plates().map((t) => this._renderPlate(t))} -
    -

    Tap your crest to open your Quest Log

    +

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

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

    Tap your crest to open your Quest Log

    + `}
    ${this._renderDock()}
    `; } _timeZone() { - return oe(this.hass); + return pt(this.hass); } - _state(t) { - const n = this.hass?.states?.[t]; + _state(e) { + const n = this.hass?.states?.[e]; 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()) : []; + const e = this._config.child_order; + return Array.isArray(e) ? e.filter( + (t) => typeof t == "string" && t.trim().length > 0 + ).map((t) => t.trim()) : []; } _plates() { - return this._childSlugs().map((t) => this._plate(t)); + return this._childSlugs().map((e) => this._plate(e)); } - _plate(t) { - const e = this._state(`sensor.nestquest_${t}_quests_due_today`), n = this._state( - `sensor.nestquest_${t}_quests_completed_today` - ), s = this._state( - `sensor.nestquest_${t}_completion_pct_today` + _plate(e) { + const t = this._dueSensorResolution(e), n = this._state(`sensor.nestquest_${e}_quests_due_today`), i = this._state( + `sensor.nestquest_${e}_quests_completed_today` ), r = this._state( - `binary_sensor.nestquest_${t}_present_today` - ), o = e?.attributes ?? {}, l = bt(V(e?.state, 0)), a = bt(V(n?.state, 0)), p = s ? V(s.state, Number.NaN) : Number.NaN, u = Number.isFinite(p) ? yt(p) : l > 0 ? yt(a / l * 100) : 0; - let d = !0; - const h = o.present; - typeof h == "boolean" ? d = h : r && (d = String(r.state ?? "").trim().toLowerCase() === "on"); - let c = ""; - const f = o.child_name; - if (typeof f == "string" && f.trim()) - c = f.trim(); + `sensor.nestquest_${e}_completion_pct_today` + ), o = this._state( + `binary_sensor.nestquest_${e}_present_today` + ), l = n?.attributes ?? {}, a = $e(K(n?.state, 0)), c = $e(K(i?.state, 0)), u = r ? K(r.state, Number.NaN) : Number.NaN, d = Number.isFinite(u) ? be(u) : a > 0 ? be(c / 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 y = l.child_name; + if (typeof y == "string" && y.trim()) + m = y.trim(); else { - const N = r?.attributes?.child_name; - typeof N == "string" && N.trim() && (c = N.trim()); + const Z = o?.attributes?.child_name; + typeof Z == "string" && Z.trim() && (m = Z.trim()); } - c || (c = pe(t)); - const _ = he( - r?.attributes?.next_present, + m || (m = mt(e)); + const b = yt( + o?.attributes?.next_present, this._timeZone() - ), v = _ ? `Returns ${Z(_, this._timeZone())}` : null; + ), j = b ? `Returns ${G(b, this._timeZone())}` : null; return { - slug: t, - name: c, - initial: (c.charAt(0) || "?").toUpperCase(), - present: d, - completed: a, - due: l, - pct: u, - returns: v + slug: e, + name: m, + initial: (m.charAt(0) || "?").toUpperCase(), + present: t === null ? f : !1, + completed: c, + due: a, + pct: d, + returns: j, + unresolved: t }; } - _renderPlate(t) { - return t.present ? g` + /** "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(e) { + const t = this._state(`sensor.nestquest_${e}_quests_due_today`); + if (!t) + return "missing"; + const n = String(t.state ?? "").trim().toLowerCase(); + return !n || n === "unavailable" || n === "unknown" ? "stale" : null; + } + _boardNotice() { + const e = this._plates(); + return e.length === 0 ? qe : e.every((t) => t.unresolved !== null) ? e.some((t) => t.unresolved === "stale") ? Ct : qe : null; + } + _renderNotice(e) { + return p` +
    +
    + ${e.icon} + ${e.headline} +

    ${e.body}

    +
    +
    + `; + } + _renderPlate(e) { + return e.unresolved ? p` +
    + + + ${e.initial} + + + ${e.name} + + ${wt} + Unknown + + — + +
    + ` : e.present ? p` - ` : g` + ` : p`
    - ${t.initial} + ${e.initial} - ${t.name} + ${e.name} - ${ye} + ${qt} On travels - ${t.returns ?? "Returns —"} + ${e.returns ?? "Returns —"}
    `; } - _openQuestLog(t) { - const e = _t(this._config?.quest_log_path); - e && (window.history.pushState(null, "", `${e.replace(/\/+$/, "")}/${t}`), window.dispatchEvent(new Event("location-changed"))); + _openQuestLog(e) { + const t = ve(this._config?.quest_log_path); + t && (window.history.pushState(null, "", `${t.replace(/\/+$/, "")}/${e}`), window.dispatchEvent(new Event("location-changed"))); } _dockWeather() { - const t = _t(this._config?.weather_entity); - if (!t) - return null; - const e = this._state(t); + const e = ve(this._config?.weather_entity); if (!e) return null; - const n = String(e.state ?? "").trim(); + const t = this._state(e); + if (!t) + return null; + const n = String(t.state ?? "").trim(); if (!n || n === "unavailable" || n === "unknown") return null; - const s = e.attributes ?? {}; + const i = t.attributes ?? {}; let r = null, o = null; - const l = s.forecast; + 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); + const c = a; + r = this._optionalNumber(c.temperature), o = this._optionalNumber(c.templow); } } return { condition: n, - temperature: this._optionalNumber(s.temperature), + temperature: this._optionalNumber(i.temperature), high: r, low: o }; } - _optionalNumber(t) { - if (t == null || t === "") + _optionalNumber(e) { + if (e == null || e === "") return null; - const e = typeof t == "number" ? t : Number(t); - return Number.isFinite(e) ? e : null; + const t = typeof e == "number" ? e : Number(e); + return Number.isFinite(t) ? t : null; } _renderDock() { - const t = this._dockWeather(); - if (!t) - return g` + const e = this._dockWeather(); + if (!e) + return p`
    - ${Z(this._now, this._timeZone())} + ${G(this._now, this._timeZone())} - ${ae(this._now, this._timeZone())} + ${ht(this._now, this._timeZone())}
    `; - const e = ue(t.condition), n = fe(t.condition, e), s = t.temperature === null ? m : g`${Math.round(t.temperature)}°`, r = t.high === null || t.low === null ? null : `${Math.round(t.high)}° / ${Math.round(t.low)}°`; - return g` + const t = _t(e.condition), n = vt(e.condition, t), i = e.temperature === null ? g : p`${Math.round(e.temperature)}°`, r = e.high === null || e.low === null ? null : `${Math.round(e.high)}° / ${Math.round(e.low)}°`; + return p`
    - ${be} - ${s} + ${St} + ${i} - ${r === null ? e : `${e} · ${r}`} + ${r === null ? t : `${t} · ${r}`} ${n} @@ -1219,167 +1362,167 @@ class ve extends C { `; } } -customElements.define("nestquest-party-board-card", ve); -st({ +customElements.define("nestquest-party-board-card", Et); +oe({ type: "nestquest-party-board-card", name: "NestQuest Party Board", description: "Kids' attract screen for present and away adventurers." }); -const $e = { CHILD: 2 }, xe = (i) => (...t) => ({ _$litDirective$: i, values: t }); -let qe = class { - constructor(t) { +const Nt = { CHILD: 2 }, Tt = (s) => (...e) => ({ _$litDirective$: s, values: e }); +let Mt = class { + constructor(e) { } get _$AU() { return this._$AM._$AU; } - _$AT(t, e, n) { - this._$Ct = t, this._$AM = e, this._$Ci = n; + _$AT(e, t, n) { + this._$Ct = e, this._$AM = t, this._$Ci = n; } - _$AS(t, e) { - return this.update(t, e); + _$AS(e, t) { + return this.update(e, t); } - update(t, e) { - return this.render(...e); + update(e, t) { + return this.render(...t); } }; -const { I: we } = ne, $t = (i) => i, xt = () => document.createComment(""), O = (i, t, e) => { - const n = i._$AA.parentNode, s = t === void 0 ? i._$AB : t._$AA; - if (e === void 0) { - const r = n.insertBefore(xt(), s), o = n.insertBefore(xt(), s); - e = new we(r, o, i, i.options); +const { I: Ot } = at, we = (s) => s, ke = () => document.createComment(""), z = (s, e, t) => { + const n = s._$AA.parentNode, i = e === void 0 ? s._$AB : e._$AA; + if (t === void 0) { + const r = n.insertBefore(ke(), i), o = n.insertBefore(ke(), i); + t = new Ot(r, o, s, s.options); } else { - const r = e._$AB.nextSibling, o = e._$AM, l = o !== i; + const r = t._$AB.nextSibling, o = t._$AM, l = o !== s; if (l) { let a; - e._$AQ?.(i), e._$AM = i, e._$AP !== void 0 && (a = i._$AU) !== o._$AU && e._$AP(a); + t._$AQ?.(s), t._$AM = s, t._$AP !== void 0 && (a = s._$AU) !== o._$AU && t._$AP(a); } - if (r !== s || l) { - let a = e._$AA; + if (r !== i || l) { + let a = t._$AA; for (; a !== r; ) { - const p = $t(a).nextSibling; - $t(n).insertBefore(a, s), a = p; + const c = we(a).nextSibling; + we(n).insertBefore(a, i), a = c; } } } - return e; -}, w = (i, t, e = i) => (i._$AI(t, e), i), ke = {}, Ae = (i, t = ke) => i._$AH = t, Ce = (i) => i._$AH, K = (i) => { - i._$AR(), i._$AA.remove(); + return t; +}, w = (s, e, t = s) => (s._$AI(e, t), s), zt = {}, Pt = (s, e = zt) => s._$AH = e, It = (s) => s._$AH, J = (s) => { + s._$AR(), s._$AA.remove(); }; -const qt = (i, t, e) => { +const Ae = (s, e, t) => { const n = /* @__PURE__ */ new Map(); - for (let s = t; s <= e; s++) n.set(i[s], s); + for (let i = e; i <= t; i++) n.set(s[i], i); return n; -}, wt = xe(class extends qe { - constructor(i) { - if (super(i), i.type !== $e.CHILD) throw Error("repeat() can only be used in text expressions"); +}, Ce = Tt(class extends Mt { + constructor(s) { + if (super(s), s.type !== Nt.CHILD) throw Error("repeat() can only be used in text expressions"); } - dt(i, t, e) { + dt(s, e, t) { let n; - e === void 0 ? e = t : t !== void 0 && (n = t); - const s = [], r = []; + t === void 0 ? t = e : e !== void 0 && (n = e); + const i = [], r = []; let o = 0; - for (const l of i) s[o] = n ? n(l, o) : o, r[o] = e(l, o), o++; - return { values: r, keys: s }; + for (const l of s) i[o] = n ? n(l, o) : o, r[o] = t(l, o), o++; + return { values: r, keys: i }; } - render(i, t, e) { - return this.dt(i, t, e).values; + render(s, e, t) { + return this.dt(s, e, t).values; } - update(i, [t, e, n]) { - const s = Ce(i), { values: r, keys: o } = this.dt(t, e, n); - if (!Array.isArray(s)) return this.ut = o, r; + update(s, [e, t, n]) { + const i = It(s), { values: r, keys: o } = this.dt(e, t, n); + if (!Array.isArray(i)) return this.ut = o, r; const l = this.ut ??= [], a = []; - let p, u, d = 0, h = s.length - 1, c = 0, f = r.length - 1; - for (; d <= h && c <= f; ) if (s[d] === null) d++; - else if (s[h] === null) h--; - else if (l[d] === o[c]) a[c] = w(s[d], r[c]), d++, c++; - else if (l[h] === o[f]) a[f] = w(s[h], r[f]), h--, f--; - else if (l[d] === o[f]) a[f] = w(s[d], r[f]), O(i, a[f + 1], s[d]), d++, f--; - else if (l[h] === o[c]) a[c] = w(s[h], r[c]), O(i, s[d], s[h]), h--, c++; - else if (p === void 0 && (p = qt(o, c, f), u = qt(l, d, h)), p.has(l[d])) if (p.has(l[h])) { - const _ = u.get(o[c]), v = _ !== void 0 ? s[_] : null; - if (v === null) { - const N = O(i, s[d]); - w(N, r[c]), a[c] = N; - } else a[c] = w(v, r[c]), O(i, s[d], v), s[_] = null; - c++; - } else K(s[h]), h--; - else K(s[d]), d++; - for (; c <= f; ) { - const _ = O(i, a[f + 1]); - w(_, r[c]), a[c++] = _; + let c, u, d = 0, f = i.length - 1, h = 0, m = r.length - 1; + for (; d <= f && h <= m; ) if (i[d] === null) d++; + else if (i[f] === null) f--; + else if (l[d] === o[h]) a[h] = w(i[d], r[h]), d++, h++; + else if (l[f] === o[m]) a[m] = w(i[f], r[m]), f--, m--; + else if (l[d] === o[m]) a[m] = w(i[d], r[m]), z(s, a[m + 1], i[d]), d++, m--; + else if (l[f] === o[h]) a[h] = w(i[f], r[h]), z(s, i[d], i[f]), f--, h++; + else if (c === void 0 && (c = Ae(o, h, m), u = Ae(l, d, f)), c.has(l[d])) if (c.has(l[f])) { + const y = u.get(o[h]), b = y !== void 0 ? i[y] : null; + if (b === null) { + const j = z(s, i[d]); + w(j, r[h]), a[h] = j; + } else a[h] = w(b, r[h]), z(s, i[d], b), i[y] = null; + h++; + } else J(i[f]), f--; + else J(i[d]), d++; + for (; h <= m; ) { + const y = z(s, a[m + 1]); + w(y, r[h]), a[h++] = y; } - for (; d <= h; ) { - const _ = s[d++]; - _ !== null && K(_); + for (; d <= f; ) { + const y = i[d++]; + y !== null && J(y); } - return this.ut = o, Ae(i, a), E; + return this.ut = o, Pt(s, a), E; } -}), kt = /* @__PURE__ */ new Map(); -function j(i, t) { - const e = `${i ?? "local"}|${JSON.stringify(t)}`, n = kt.get(e); +}), Se = /* @__PURE__ */ new Map(); +function H(s, e) { + const t = `${s ?? "local"}|${JSON.stringify(e)}`, n = Se.get(t); if (n) return n; - let s; + let i; try { - s = new Intl.DateTimeFormat("en-US", { - ...t, - ...i ? { timeZone: i } : {} + i = new Intl.DateTimeFormat("en-US", { + ...e, + ...s ? { timeZone: s } : {} }); } catch { - s = new Intl.DateTimeFormat("en-US", t); + i = new Intl.DateTimeFormat("en-US", e); } - return kt.set(e, s), s; + return Se.set(t, i), i; } -function Se(i) { - const t = i?.config; - if (!t || typeof t != "object") +function Ut(s) { + const e = s?.config; + if (!e || typeof e != "object") return; - const e = t.time_zone; - return typeof e == "string" && e.trim() ? e.trim() : void 0; + const t = e.time_zone; + return typeof t == "string" && t.trim() ? t.trim() : void 0; } -function Ee(i, t) { - return `${j(t, { weekday: "long" }).format(i)}, ${j(t, { month: "short", day: "numeric" }).format(i)}`; +function Ee(s, e) { + return `${H(e, { weekday: "long" }).format(s)}, ${H(e, { month: "short", day: "numeric" }).format(s)}`; } -function Ne(i, t) { - return j(t, { weekday: "long" }).format(i); +function Dt(s, e) { + return H(e, { weekday: "long" }).format(s); } -function Te(i, t) { - const e = new Date(i); - return Number.isNaN(e.getTime()) ? "" : j(t, { +function Ht(s, e) { + const t = new Date(s); + return Number.isNaN(t.getTime()) ? "" : H(e, { hour: "numeric", minute: "2-digit", hour12: !0 - }).format(e); + }).format(t); } -function Me(i) { - if (!i) +function jt(s) { + if (!s) return null; - const t = /^(\d{1,2}):(\d{2})$/.exec(i.trim()); - if (!t) + const e = /^(\d{1,2}):(\d{2})$/.exec(s.trim()); + if (!e) return null; - const e = Number(t[1]); - if (e > 23) + const t = Number(e[1]); + if (t > 23) return null; - const n = e >= 12 ? "PM" : "AM"; - return `${e % 12 === 0 ? 12 : e % 12}:${t[2]} ${n}`; + const n = t >= 12 ? "PM" : "AM"; + return `${t % 12 === 0 ? 12 : t % 12}:${e[2]} ${n}`; } -function k(i) { - return typeof i == "string" ? i.trim() : ""; +function k(s) { + return typeof s == "string" ? s.trim() : ""; } -function y(i, t) { - if (i == null || i === "") - return t; - const e = typeof i == "number" ? i : Number(i); - return Number.isFinite(e) ? e : t; +function _(s, e) { + if (s == null || s === "") + return e; + const t = typeof s == "number" ? s : Number(s); + return Number.isFinite(t) ? t : e; } -function $(i) { - return Math.max(0, Math.round(i)); +function $(s) { + return Math.max(0, Math.round(s)); } -function At(i) { - return i.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); +function X(s) { + return s.split(/[-_]+/).filter(Boolean).map((e) => e.charAt(0).toUpperCase() + e.slice(1)).join(" "); } -function ze(i, t) { - const e = j(t, { +function Rt(s, e) { + const t = H(e, { year: "numeric", month: "2-digit", day: "2-digit", @@ -1387,10 +1530,10 @@ function ze(i, t) { minute: "2-digit", second: "2-digit", hourCycle: "h23" - }).formatToParts(new Date(i)), n = (r) => { - const o = e.find((l) => l.type === r); + }).formatToParts(new Date(s)), n = (r) => { + const o = t.find((l) => l.type === r); return o ? Number(o.value) : Number.NaN; - }, s = Date.UTC( + }, i = Date.UTC( n("year"), n("month") - 1, n("day"), @@ -1398,47 +1541,47 @@ function ze(i, t) { n("minute"), n("second") ); - return Number.isFinite(s) ? s - i : 0; + return Number.isFinite(i) ? i - s : 0; } -function Pe(i, t) { - if (typeof i != "string") +function Ne(s, e) { + if (typeof s != "string") return null; - const e = i.split("-"); - if (e.length !== 3) + const t = s.split("-"); + if (t.length !== 3) return null; - const [n, s, r] = e.map((a) => Number(a)); - if (!n || !s || !r) + const [n, i, r] = t.map((a) => Number(a)); + if (!n || !i || !r) return null; - if (!t) { - const a = new Date(n, s - 1, r); + if (!e) { + const a = new Date(n, i - 1, r); return Number.isNaN(a.getTime()) ? null : a; } - const o = Date.UTC(n, s - 1, r), l = new Date(o - ze(o, t)); + const o = Date.UTC(n, i - 1, r), l = new Date(o - Rt(o, e)); return Number.isNaN(l.getTime()) ? null : l; } -function Oe() { - const i = window.location.pathname.split("/").filter(Boolean); - return i.length > 0 ? i[i.length - 1].toLowerCase() : ""; +function Lt() { + const s = window.location.pathname.split("/").filter(Boolean); + return s.length > 0 ? s[s.length - 1].toLowerCase() : ""; } -function Ue(i) { - if (!i || typeof i != "object") +function Bt(s) { + if (!s || typeof s != "object") return null; - const t = i, e = k(t.state).toLowerCase(); - if (e !== "open" && e !== "completed") + const e = s, t = k(e.state).toLowerCase(); + if (t !== "open" && t !== "completed") return null; - const n = y(t.id, 0); + const n = _(e.id, 0); return n ? { id: n, - title: k(t.title) || "Quest", - icon: k(t.icon) || null, - window: k(t.window).toLowerCase(), - due_time: k(t.due_time) || null, - state: e, - overdue: t.overdue === !0, - completed_at: k(t.completed_at) || null + title: k(e.title) || "Quest", + icon: k(e.icon) || null, + window: k(e.window).toLowerCase(), + due_time: k(e.due_time) || null, + state: t, + overdue: e.overdue === !0, + completed_at: k(e.completed_at) || null } : null; } -const Ct = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ie = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", St = [-9, 6, -4], De = Tt` +const Te = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ft = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", Me = [-9, 6, -4], Qt = Pe` * { box-sizing: border-box; } @@ -1498,7 +1641,7 @@ const Ct = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ie = "polygon(50% width: 96px; height: 110px; padding: 3px; - clip-path: ${b(Ct)}; + clip-path: ${v(Te)}; background: rgba(255, 255, 255, 0.22); } @@ -1509,7 +1652,7 @@ const Ct = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ie = "polygon(50% align-items: center; justify-content: center; padding-bottom: 32px; - clip-path: ${b(Ct)}; + clip-path: ${v(Te)}; background: var(--nq-p-crest); } @@ -1521,6 +1664,10 @@ const Ct = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ie = "polygon(50% color: #ffffff; } + .crest.away .crest-face { + background: var(--nq-p-crest-away); + } + .titles { display: flex; flex-direction: column; @@ -1571,7 +1718,7 @@ const Ct = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ie = "polygon(50% justify-content: center; width: 44px; height: 44px; - clip-path: ${b(Ie)}; + clip-path: ${v(Ft)}; background: var(--nq-brand-gradient); } @@ -1868,6 +2015,81 @@ const Ct = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ie = "polygon(50% 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; @@ -2007,7 +2229,7 @@ const Ct = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ie = "polygon(50% width: 38px; height: 38px; } -`, J = g` -`, He = g``, Wt = p` -`, G = g``, Y = p` -`, je = g``, Zt = p` -`, Le = g``, Le = p` -`, Re = g``, Vt = p` -`, Et = [ - { key: "morning", name: "Morning", range: "Until 11:59 AM", icon: je }, +`, Gt = p``, Kt = p``, Jt = p``, Xt = p``, Yt = { + icon: Kt, + headline: "No adventurer chosen", + body: "Open The Party and tap your crest to open your quest log." +}, en = { + icon: Jt, + headline: "NestQuest is not set up yet", + body: "A parent needs to finish setting up NestQuest." +}, tn = { + icon: Xt, + headline: "The records cannot be reached", + body: "The party's records are quiet right now. NestQuest will return shortly." +}, Oe = [ + { key: "morning", name: "Morning", range: "Until 11:59 AM", icon: Zt }, { key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: Le }, - { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: Re } + { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: Vt } ]; -class Be extends C { +class nn extends C { static properties = { hass: { attribute: !1 }, _config: { state: !0 }, _now: { state: !0 }, _confirm: { state: !0 } }; - static styles = [b(It), De]; + static styles = [v(Re), Qt]; hass; _config; _now = /* @__PURE__ */ new Date(); @@ -2105,10 +2384,10 @@ class Be extends C { _clockTimer; _idleTimer; _confirmTimer; - setConfig(t) { - if (!t || typeof t != "object") + setConfig(e) { + if (!e || typeof e != "object") throw new Error("Invalid configuration"); - this._config = t; + this._config = e; } getCardSize() { return 22; @@ -2122,223 +2401,337 @@ class Be extends C { this._stopClock(), this._clearIdle(), this._clearConfirmTimer(), window.removeEventListener("pointerdown", this._onActivity, !0), window.removeEventListener("touchstart", this._onActivity, !0), window.removeEventListener("keydown", this._onActivity, !0), super.disconnectedCallback(); } render() { - return g` + const e = this._logView(); + return p`
    - ${this._renderHeader()} -
    - ${Et.map((t) => this._renderColumn(t))} -
    + ${this._renderMain(e)} ${this._renderConfirm()}
    `; } - _timeZone() { - return Se(this.hass); + _renderMain(e) { + switch (e.kind) { + case "no-adventurer": + return this._renderNotice(Yt); + case "not-set-up": + return this._renderNotice(en); + case "unreachable": + return this._renderNotice(tn); + case "away": { + const t = ["The quest log unlocks when they return."]; + return e.returns && t.push(`Returns ${e.returns}`), p` + ${this._renderHeader(e)} +
    +
    + ${Gt} + ${e.name} is on travels + ${t.map((n) => p`

    ${n}

    `)} +
    +
    + `; + } + case "empty-day": + return p` + ${this._renderHeader(e)} +
    +
    + ${Le} + No quests today +

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

    +
    +
    + `; + case "complete-day": + return p` + ${this._renderHeader(e)} +
    +
    + ${R} + Quest complete +

    + ${`Every quest is claimed, ${e.name}. The seal is set for today.`} +

    +

    Returning to The Party shortly.

    +
    +
    + `; + default: + return p` + ${this._renderHeader(e)} +
    + ${Oe.map((t) => this._renderColumn(t))} +
    + `; + } } - _state(t) { - const n = this.hass?.states?.[t]; + /** Resolve the log's single render state; the priority order is + * design/PANEL-EMPTY-STATES.md §2. */ + _logView() { + const e = this._childSlug(); + if (!e) + return { kind: "no-adventurer" }; + const t = this._dueSensorResolution(e); + if (t === "missing") + return { kind: "not-set-up" }; + if (t === "stale") + return { kind: "unreachable" }; + const n = this._childName(e) ?? X(e); + if (!this._childPresent(e)) + return { kind: "away", name: n, returns: this._awayReturns(e) }; + const i = this._state(`sensor.nestquest_${e}_quests_due_today`); + return $(_(i?.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(e) { + const t = this._state(`sensor.nestquest_${e}_quests_due_today`); + if (!t) + return "missing"; + const n = String(t.state ?? "").trim().toLowerCase(); + return !n || n === "unavailable" || n === "unknown" ? "stale" : null; + } + _childPresent(e) { + const n = this._state(`sensor.nestquest_${e}_quests_due_today`)?.attributes?.present; + if (typeof n == "boolean") + return n; + const i = this._state( + `binary_sensor.nestquest_${e}_present_today` + ); + return i ? String(i.state ?? "").trim().toLowerCase() === "on" : !0; + } + _awayReturns(e) { + const t = this._timeZone(), n = this._state( + `binary_sensor.nestquest_${e}_present_today` + ), i = Ne( + n?.attributes?.next_present, + t + ); + return i ? Ee(i, t) : null; + } + _renderNotice(e) { + return p` +
    +
    + ${e.icon} + ${e.headline} +

    ${e.body}

    +
    +
    + `; + } + _timeZone() { + return Ut(this.hass); + } + _state(e) { + const n = this.hass?.states?.[e]; return n && typeof n == "object" ? n : null; } _childSlug() { - return Oe(); + return Lt(); } _boardPath() { return k(this._config?.board_path).replace(/\/+$/, ""); } _idleSeconds() { - const t = y(this._config?.idle_return_seconds, 40); - return t > 0 ? t : 40; + const e = _(this._config?.idle_return_seconds, 40); + return e > 0 ? e : 40; } _confirmSeconds() { - const t = y(this._config?.confirm_timeout_seconds, 15); - return t > 0 ? t : 15; + const e = _(this._config?.confirm_timeout_seconds, 15); + return e > 0 ? e : 15; } - _childName(t) { - if (!t) + _childName(e) { + if (!e) return null; - const e = this._state(`sensor.nestquest_${t}_quests_due_today`)?.attributes?.child_name; - return typeof e == "string" && e.trim() ? e.trim() : null; + const t = this._state(`sensor.nestquest_${e}_quests_due_today`)?.attributes?.child_name; + return typeof t == "string" && t.trim() ? t.trim() : null; } - _childCounts(t) { - const e = this._state(`sensor.nestquest_${t}_quests_due_today`), n = this._state( - `sensor.nestquest_${t}_quests_completed_today` - ), s = $(y(e?.state, 0)), r = $(y(n?.state, 0)); - return { due: s, completed: r }; + _childCounts(e) { + const t = this._state(`sensor.nestquest_${e}_quests_due_today`), n = this._state( + `sensor.nestquest_${e}_quests_completed_today` + ), i = $(_(t?.state, 0)), r = $(_(n?.state, 0)); + return { due: i, completed: r }; } _instances() { - const t = this._childSlug(); - if (!t) + const e = this._childSlug(); + if (!e) return []; - const e = this._state(`sensor.nestquest_${t}_quests_due_today`)?.attributes?.instances; - return Array.isArray(e) ? e.map(Ue).filter((n) => n !== null) : []; + const t = this._state(`sensor.nestquest_${e}_quests_due_today`)?.attributes?.instances; + return Array.isArray(t) ? t.map(Bt).filter((n) => n !== null) : []; } _remaining() { - const t = this._childSlug(); - if (!t) + const e = this._childSlug(); + if (!e) return 0; - const e = this._state( - `sensor.nestquest_${t}_quests_remaining_today` + const t = this._state( + `sensor.nestquest_${e}_quests_remaining_today` ); - if (e) - return $(y(e.state, 0)); - const { due: n, completed: s } = this._childCounts(t); - return $(n - s); + if (t) + return $(_(t.state, 0)); + const { due: n, completed: i } = this._childCounts(e); + return $(n - i); } _partyCounts() { - const t = this._state( + const e = this._state( "sensor.nestquest_household_quests_due_today" - ), e = this._state( + ), t = this._state( "sensor.nestquest_household_quests_completed_today" ); - if (t || e) + if (e || t) return { - completed: $(y(e?.state, 0)), - due: $(y(t?.state, 0)) + completed: $(_(t?.state, 0)), + due: $(_(e?.state, 0)) }; - const n = this._childSlug(), { due: s, completed: r } = this._childCounts(n); - return { completed: r, due: s }; + const n = this._childSlug(), { due: i, completed: r } = this._childCounts(n); + return { completed: r, due: i }; } _otherChildren() { - const t = this.hass?.states; - if (!t) + const e = this.hass?.states; + if (!e) return []; - const e = this._childSlug(), n = this._timeZone(), s = []; - for (const r of Object.keys(t)) { + const t = this._childSlug(), n = this._timeZone(), i = []; + for (const r of Object.keys(e)) { const o = /^sensor\.nestquest_(.+)_quests_due_today$/.exec(r); - if (!o || o[1] === "household" || o[1] === e) + if (!o || o[1] === "household" || o[1] === t) 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 = At(l); + const l = o[1], a = e[r]?.attributes ?? {}; + let c = ""; + typeof a.child_name == "string" && a.child_name.trim() ? c = a.child_name.trim() : c = X(l); let u = !0; if (typeof a.present == "boolean") u = a.present; else { - const _ = this._state( + const y = this._state( `binary_sensor.nestquest_${l}_present_today` ); - u = _ === null || String(_.state ?? "").trim().toLowerCase() !== "off"; + u = y === null || String(y.state ?? "").trim().toLowerCase() !== "off"; } const d = this._state( `sensor.nestquest_${l}_quests_completed_today` - ), h = $(y(t[r]?.state, 0)), c = $(y(d?.state, 0)); - let f = null; + ), f = $(_(e[r]?.state, 0)), h = $(_(d?.state, 0)); + let m = null; if (!u) { - const _ = this._state( + const y = this._state( `binary_sensor.nestquest_${l}_present_today` - ), v = Pe( - _?.attributes?.next_present, + ), b = Ne( + y?.attributes?.next_present, n ); - f = v ? Ne(v, n) : null; + m = b ? Dt(b, n) : null; } - s.push({ + i.push({ slug: l, - name: p, + name: c, present: u, - due: h, - completed: c, - returnsWeekday: f + due: f, + completed: h, + returnsWeekday: m }); } - return s.sort((r, o) => r.name.localeCompare(o.name)); + return i.sort((r, o) => r.name.localeCompare(o.name)); } - _renderHeader() { - const t = this._childSlug(), n = this._childName(t) ?? (t ? At(t) : null), { due: s, completed: r } = this._childCounts(t), o = this._remaining(), l = this._partyCounts(), a = n ? `${n}'s Quest Log` : "Quest Log", p = (n?.charAt(0) || "?").toUpperCase(); - return g` + _renderHeader(e) { + const t = this._childSlug(), n = e.kind === "away", i = n ? e.name : this._childName(t) ?? (t ? X(t) : null), { due: r, completed: o } = this._childCounts(t), l = this._remaining(), a = this._partyCounts(), c = i ? `${i}'s Quest Log` : "Quest Log", u = (i?.charAt(0) || "?").toUpperCase(); + return p`
    -
    `; } - _renderColumn(t) { - const e = this._instances().filter( - (l) => l.window === t.key - ), n = e.filter((l) => l.state === "open").sort( + _renderColumn(e) { + const t = this._instances().filter( + (l) => l.window === e.key + ), n = t.filter((l) => l.state === "open").sort( (l, a) => (l.due_time ?? "99:99").localeCompare(a.due_time ?? "99:99") - ), s = e.filter( + ), i = t.filter( (l) => l.state === "completed" - ), r = e.length; - let o = m; + ), r = t.length; + let o = g; if (r > 0) { - const a = 644 - (t.key === "afternoon" && this._otherChildren().length > 0 ? 160 : 0), p = Math.min( + const a = 644 - (e.key === "afternoon" && this._otherChildren().length > 0 ? 160 : 0), c = 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)) + Math.max(58, Math.floor((a - (r - 1) * c) / r)) ), d = Math.max(56, Math.min(72, u - 2)); - let h = `--nq-p-quest-gap: ${p}px; --nq-p-quest-min-height: ${u}px; --nq-p-button-height: ${d}px`; + let f = `--nq-p-quest-gap: ${c}px; --nq-p-quest-min-height: ${u}px; --nq-p-button-height: ${d}px`; if (r >= 5) { - const c = Math.max(0, Math.min(22, Math.floor((u - d - 2) / 2))), f = Math.max(44, Math.min(64, u - 2 * c - 2)); - h += `; --nq-p-quest-pad: ${c}px; --nq-p-tile-size: ${f}px`; + const h = Math.max(0, Math.min(22, Math.floor((u - d - 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 = h; + o = f; } - return g` + return p`
    - ${t.icon} + ${e.icon}
    - ${t.name} - ${t.range} + ${e.name} + ${e.range}
    - ${s.length}/${e.length} + ${i.length}/${t.length}
    - ${wt( + ${Ce( n, (l) => l.id, (l) => this._renderQuest(l, !1, 0) )} - ${wt( - s, + ${Ce( + i, (l) => l.id, (l, a) => this._renderQuest(l, !0, a) )} - ${t.key === "afternoon" ? this._renderRollup() : m} + ${e.key === "afternoon" ? this._renderRollup() : g}
    `; } - _renderQuest(t, e, n) { - if (e) { - const o = St[n % St.length], l = t.completed_at ? Te(t.completed_at, this._timeZone()) : ""; - return g` -
    - + _renderQuest(e, t, n) { + if (t) { + const o = Me[n % Me.length], l = e.completed_at ? Ht(e.completed_at, this._timeZone()) : ""; + return p` +
    +
    - ${t.title} + ${e.title} ${l ? `Claimed ${l}` : "Claimed"} @@ -2348,68 +2741,68 @@ class Be extends C { aria-hidden="true" style="--seal-rot: ${o}deg" > - ${J} + ${R}
    `; } - const s = Me(t.due_time), r = t.overdue ? { - text: s ? `Overdue · due ${s}` : "Overdue", + const i = jt(e.due_time), r = e.overdue ? { + text: i ? `Overdue · due ${i}` : "Overdue", late: !0 } : { - text: s ? `Due by ${s}` : "Due today", + text: i ? `Due by ${i}` : "Due today", late: !1 }; - return g` + return p`
    this._openConfirm(t.id)} + data-instance-id=${e.id} + aria-label="Complete ${e.title}" + @click=${() => this._openConfirm(e.id)} @keydown=${(o) => { - (o.key === "Enter" || o.key === " ") && (o.preventDefault(), this._openConfirm(t.id)); + (o.key === "Enter" || o.key === " ") && (o.preventDefault(), this._openConfirm(e.id)); }} > - +
    - ${t.title} - ${r.text} + ${e.title} + ${r.text}
    `; } _renderRollup() { - const t = this._otherChildren(); - if (t.length === 0) - return m; - 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 g` + const e = this._otherChildren(); + if (e.length === 0) + return g; + const t = e.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 p`
    Party roll-up -

    ${e.join(" ")}

    +

    ${t.join(" ")}

    `; } _renderConfirm() { if (this._confirm === null) - return m; - const t = this._instances().find( + return g; + const e = this._instances().find( (n) => n.id === this._confirm ); - if (!t) - return m; - const e = Et.find((n) => n.key === t.window); - return g` + if (!e) + return g; + const t = Oe.find((n) => n.key === e.window); + return p`
    this._closeConfirm()}>
    n.stopPropagation()} >
    - +
    - ${e?.name ?? "Quest"} + ${t?.name ?? "Quest"} - ${t.title} + ${e.title}

    @@ -2436,7 +2829,7 @@ class Be extends C { type="button" @click=${() => this._closeConfirm()} > - ${He} + ${Wt} Not yet

    @@ -2452,8 +2845,8 @@ class Be extends C {
    `; } - _openConfirm(t) { - this._confirm = t, this._armConfirmTimer(); + _openConfirm(e) { + this._confirm = e, this._armConfirmTimer(); } _closeConfirm() { this._clearConfirmTimer(), this._confirm = null; @@ -2478,8 +2871,8 @@ class Be extends C { this._idleTimer !== void 0 && (window.clearTimeout(this._idleTimer), this._idleTimer = void 0); } _returnToBoard() { - const t = this._boardPath(); - t && (window.history.pushState(null, "", t), window.dispatchEvent(new Event("location-changed"))); + const e = this._boardPath(); + e && (window.history.pushState(null, "", e), window.dispatchEvent(new Event("location-changed"))); } _stopClock() { this._clockTimer !== void 0 && (window.clearInterval(this._clockTimer), this._clockTimer = void 0); @@ -2488,41 +2881,41 @@ class Be extends C { this._armIdle(), this._confirm !== null && this._armConfirmTimer(); }; } -customElements.define("nestquest-quest-log-card", Be); -st({ +customElements.define("nestquest-quest-log-card", nn); +oe({ type: "nestquest-quest-log-card", name: "NestQuest Quest Log", description: "One child's daily quests, grouped by window." }); -const Fe = ":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 Qe extends C { +const sn = ":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 rn extends C { static properties = { hass: { attribute: !1 }, _config: { state: !0 } }; - static styles = [b(Fe)]; + static styles = [v(sn)]; hass; _config; - setConfig(t) { - if (!t || typeof t != "object") + setConfig(e) { + if (!e || typeof e != "object") throw new Error("Invalid configuration"); - this._config = t; + this._config = e; } getCardSize() { return 8; } render() { - return g`
    NestQuest Admin
    `; + return p`
    NestQuest Admin
    `; } } -customElements.define("nestquest-admin-card", Qe); -st({ +customElements.define("nestquest-admin-card", rn); +oe({ type: "nestquest-admin-card", name: "NestQuest Admin", description: "Parent phone view for today, tasks, schedule, and history." }); -const We = "/nestquest-static/nestquest-fonts.css"; +const on = "/nestquest-static/nestquest-fonts.css"; if (!document.querySelector('link[data-nq-fonts=""]')) { - const i = document.createElement("link"); - i.rel = "stylesheet", i.href = We, i.dataset.nqFonts = "", document.head.appendChild(i); + const s = document.createElement("link"); + s.rel = "stylesheet", s.href = on, s.dataset.nqFonts = "", document.head.appendChild(s); } 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/frontend/src/cards/party-board.ts b/frontend/src/cards/party-board.ts index 891c368..13a022d 100644 --- a/frontend/src/cards/party-board.ts +++ b/frontend/src/cards/party-board.ts @@ -1,4 +1,4 @@ -import { LitElement, css, html, nothing, unsafeCSS } from "lit"; +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"; @@ -17,6 +17,14 @@ type ChildPlate = { 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 = { @@ -476,6 +484,62 @@ const boardStyles = css` 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; } @@ -569,6 +633,58 @@ 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`
    @@ -644,10 +761,14 @@ export class NestQuestPartyBoardCard extends LitElement {

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

    -
    - ${this._plates().map((plate) => this._renderPlate(plate))} -
    -

    Tap your crest to open your Quest Log

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

    Tap your crest to open your Quest Log

    + `}
    ${this._renderDock()}
    @@ -687,6 +808,7 @@ export class NestQuestPartyBoardCard extends LitElement { } 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` @@ -744,15 +866,75 @@ export class NestQuestPartyBoardCard extends LitElement { slug, name, initial: (name.charAt(0) || "?").toUpperCase(), - present, + 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` - ` : p` + ` : c`
    - ${e.initial} + ${t.initial} - ${e.name} + ${t.name} - ${qt} + ${we} On travels - ${e.returns ?? "Returns —"} + ${t.returns ?? "Returns —"}
    `; } - _openQuestLog(e) { - const t = ve(this._config?.quest_log_path); - t && (window.history.pushState(null, "", `${t.replace(/\/+$/, "")}/${e}`), window.dispatchEvent(new Event("location-changed"))); + _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 e = ve(this._config?.weather_entity); - if (!e) - return null; - const t = this._state(e); + const t = vt(this._config?.weather_entity); if (!t) return null; - const n = String(t.state ?? "").trim(); + 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 = t.attributes ?? {}; + 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 c = a; - r = this._optionalNumber(c.temperature), o = this._optionalNumber(c.templow); + const p = a; + r = this._optionalNumber(p.temperature), o = this._optionalNumber(p.templow); } } return { @@ -1331,30 +1331,30 @@ class Et extends C { low: o }; } - _optionalNumber(e) { - if (e == null || e === "") + _optionalNumber(t) { + if (t == null || t === "") return null; - const t = typeof e == "number" ? e : Number(e); - return Number.isFinite(t) ? t : null; + const e = typeof t == "number" ? t : Number(t); + return Number.isFinite(e) ? e : null; } _renderDock() { - const e = this._dockWeather(); - if (!e) - return p` + const t = this._dockWeather(); + if (!t) + return c`
    ${G(this._now, this._timeZone())} - ${ht(this._now, this._timeZone())} + ${he(this._now, this._timeZone())}
    `; - const t = _t(e.condition), n = vt(e.condition, t), i = e.temperature === null ? g : p`${Math.round(e.temperature)}°`, r = e.high === null || e.low === null ? null : `${Math.round(e.high)}° / ${Math.round(e.low)}°`; - return p` + const e = ye(t.condition), n = ve(t.condition, e), i = t.temperature === null ? g : c`${Math.round(t.temperature)}°`, r = t.high === null || t.low === null ? null : `${Math.round(t.high)}° / ${Math.round(t.low)}°`; + return c`
    - ${St} + ${Se} ${i} - ${r === null ? t : `${t} · ${r}`} + ${r === null ? e : `${e} · ${r}`} ${n} @@ -1362,167 +1362,167 @@ class Et extends C { `; } } -customElements.define("nestquest-party-board-card", Et); -oe({ +customElements.define("nestquest-party-board-card", Ee); +ot({ type: "nestquest-party-board-card", name: "NestQuest Party Board", description: "Kids' attract screen for present and away adventurers." }); -const Nt = { CHILD: 2 }, Tt = (s) => (...e) => ({ _$litDirective$: s, values: e }); -let Mt = class { - constructor(e) { +const Ne = { CHILD: 2 }, Te = (s) => (...t) => ({ _$litDirective$: s, values: t }); +let Me = class { + constructor(t) { } get _$AU() { return this._$AM._$AU; } - _$AT(e, t, n) { - this._$Ct = e, this._$AM = t, this._$Ci = n; + _$AT(t, e, n) { + this._$Ct = t, this._$AM = e, this._$Ci = n; } - _$AS(e, t) { - return this.update(e, t); + _$AS(t, e) { + return this.update(t, e); } - update(e, t) { - return this.render(...t); + update(t, e) { + return this.render(...e); } }; -const { I: Ot } = at, we = (s) => s, ke = () => document.createComment(""), z = (s, e, t) => { - const n = s._$AA.parentNode, i = e === void 0 ? s._$AB : e._$AA; - if (t === void 0) { - const r = n.insertBefore(ke(), i), o = n.insertBefore(ke(), i); - t = new Ot(r, o, s, s.options); +const { I: Oe } = ae, qt = (s) => s, kt = () => document.createComment(""), z = (s, t, e) => { + const n = s._$AA.parentNode, i = t === void 0 ? s._$AB : t._$AA; + if (e === void 0) { + const r = n.insertBefore(kt(), i), o = n.insertBefore(kt(), i); + e = new Oe(r, o, s, s.options); } else { - const r = t._$AB.nextSibling, o = t._$AM, l = o !== s; + const r = e._$AB.nextSibling, o = e._$AM, l = o !== s; if (l) { let a; - t._$AQ?.(s), t._$AM = s, t._$AP !== void 0 && (a = s._$AU) !== o._$AU && t._$AP(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 = t._$AA; + let a = e._$AA; for (; a !== r; ) { - const c = we(a).nextSibling; - we(n).insertBefore(a, i), a = c; + const p = qt(a).nextSibling; + qt(n).insertBefore(a, i), a = p; } } } - return t; -}, w = (s, e, t = s) => (s._$AI(e, t), s), zt = {}, Pt = (s, e = zt) => s._$AH = e, It = (s) => s._$AH, J = (s) => { + return e; +}, q = (s, t, e = s) => (s._$AI(t, e), s), ze = {}, Pe = (s, t = ze) => s._$AH = t, Ie = (s) => s._$AH, J = (s) => { s._$AR(), s._$AA.remove(); }; -const Ae = (s, e, t) => { +const At = (s, t, e) => { const n = /* @__PURE__ */ new Map(); - for (let i = e; i <= t; i++) n.set(s[i], i); + for (let i = t; i <= e; i++) n.set(s[i], i); return n; -}, Ce = Tt(class extends Mt { +}, Ct = Te(class extends Me { constructor(s) { - if (super(s), s.type !== Nt.CHILD) throw Error("repeat() can only be used in text expressions"); + if (super(s), s.type !== Ne.CHILD) throw Error("repeat() can only be used in text expressions"); } - dt(s, e, t) { + dt(s, t, e) { let n; - t === void 0 ? t = e : e !== void 0 && (n = e); + 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] = t(l, o), o++; + 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, e, t) { - return this.dt(s, e, t).values; + render(s, t, e) { + return this.dt(s, t, e).values; } - update(s, [e, t, n]) { - const i = It(s), { values: r, keys: o } = this.dt(e, t, n); + update(s, [t, e, n]) { + const i = Ie(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 c, u, d = 0, f = i.length - 1, h = 0, m = r.length - 1; + let p, u, d = 0, f = i.length - 1, h = 0, m = r.length - 1; for (; d <= f && h <= m; ) if (i[d] === null) d++; else if (i[f] === null) f--; - else if (l[d] === o[h]) a[h] = w(i[d], r[h]), d++, h++; - else if (l[f] === o[m]) a[m] = w(i[f], r[m]), f--, m--; - else if (l[d] === o[m]) a[m] = w(i[d], r[m]), z(s, a[m + 1], i[d]), d++, m--; - else if (l[f] === o[h]) a[h] = w(i[f], r[h]), z(s, i[d], i[f]), f--, h++; - else if (c === void 0 && (c = Ae(o, h, m), u = Ae(l, d, f)), c.has(l[d])) if (c.has(l[f])) { - const y = u.get(o[h]), b = y !== void 0 ? i[y] : null; + else if (l[d] === o[h]) a[h] = q(i[d], r[h]), d++, h++; + else if (l[f] === o[m]) a[m] = q(i[f], r[m]), f--, m--; + else if (l[d] === o[m]) a[m] = q(i[d], r[m]), z(s, a[m + 1], i[d]), d++, m--; + else if (l[f] === o[h]) a[h] = q(i[f], r[h]), z(s, i[d], i[f]), f--, h++; + else if (p === void 0 && (p = At(o, h, m), u = At(l, d, f)), p.has(l[d])) if (p.has(l[f])) { + const _ = u.get(o[h]), b = _ !== void 0 ? i[_] : null; if (b === null) { const j = z(s, i[d]); - w(j, r[h]), a[h] = j; - } else a[h] = w(b, r[h]), z(s, i[d], b), i[y] = null; + q(j, r[h]), a[h] = j; + } else a[h] = q(b, r[h]), z(s, i[d], b), i[_] = null; h++; } else J(i[f]), f--; else J(i[d]), d++; for (; h <= m; ) { - const y = z(s, a[m + 1]); - w(y, r[h]), a[h++] = y; + const _ = z(s, a[m + 1]); + q(_, r[h]), a[h++] = _; } for (; d <= f; ) { - const y = i[d++]; - y !== null && J(y); + const _ = i[d++]; + _ !== null && J(_); } - return this.ut = o, Pt(s, a), E; + return this.ut = o, Pe(s, a), E; } -}), Se = /* @__PURE__ */ new Map(); -function H(s, e) { - const t = `${s ?? "local"}|${JSON.stringify(e)}`, n = Se.get(t); +}), St = /* @__PURE__ */ new Map(); +function H(s, t) { + const e = `${s ?? "local"}|${JSON.stringify(t)}`, n = St.get(e); if (n) return n; let i; try { i = new Intl.DateTimeFormat("en-US", { - ...e, + ...t, ...s ? { timeZone: s } : {} }); } catch { - i = new Intl.DateTimeFormat("en-US", e); + i = new Intl.DateTimeFormat("en-US", t); } - return Se.set(t, i), i; + return St.set(e, i), i; } -function Ut(s) { - const e = s?.config; - if (!e || typeof e != "object") +function Ue(s) { + const t = s?.config; + if (!t || typeof t != "object") return; - const t = e.time_zone; - return typeof t == "string" && t.trim() ? t.trim() : void 0; + const e = t.time_zone; + return typeof e == "string" && e.trim() ? e.trim() : void 0; } -function Ee(s, e) { - return `${H(e, { weekday: "long" }).format(s)}, ${H(e, { month: "short", day: "numeric" }).format(s)}`; +function Et(s, t) { + return `${H(t, { weekday: "long" }).format(s)}, ${H(t, { month: "short", day: "numeric" }).format(s)}`; } -function Dt(s, e) { - return H(e, { weekday: "long" }).format(s); +function De(s, t) { + return H(t, { weekday: "long" }).format(s); } -function Ht(s, e) { - const t = new Date(s); - return Number.isNaN(t.getTime()) ? "" : H(e, { +function He(s, t) { + const e = new Date(s); + return Number.isNaN(e.getTime()) ? "" : H(t, { hour: "numeric", minute: "2-digit", hour12: !0 - }).format(t); + }).format(e); } -function jt(s) { +function je(s) { if (!s) return null; - const e = /^(\d{1,2}):(\d{2})$/.exec(s.trim()); - if (!e) + const t = /^(\d{1,2}):(\d{2})$/.exec(s.trim()); + if (!t) return null; - const t = Number(e[1]); - if (t > 23) + const e = Number(t[1]); + if (e > 23) return null; - const n = t >= 12 ? "PM" : "AM"; - return `${t % 12 === 0 ? 12 : t % 12}:${e[2]} ${n}`; + const n = e >= 12 ? "PM" : "AM"; + return `${e % 12 === 0 ? 12 : e % 12}:${t[2]} ${n}`; } function k(s) { return typeof s == "string" ? s.trim() : ""; } -function _(s, e) { +function y(s, t) { if (s == null || s === "") - return e; - const t = typeof s == "number" ? s : Number(s); - return Number.isFinite(t) ? t : e; + return t; + const e = typeof s == "number" ? s : Number(s); + return Number.isFinite(e) ? e : t; } -function $(s) { +function x(s) { return Math.max(0, Math.round(s)); } function X(s) { - return s.split(/[-_]+/).filter(Boolean).map((e) => e.charAt(0).toUpperCase() + e.slice(1)).join(" "); + return s.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); } -function Rt(s, e) { - const t = H(e, { +function Re(s, t) { + const e = H(t, { year: "numeric", month: "2-digit", day: "2-digit", @@ -1531,7 +1531,7 @@ function Rt(s, e) { second: "2-digit", hourCycle: "h23" }).formatToParts(new Date(s)), n = (r) => { - const o = t.find((l) => l.type === r); + const o = e.find((l) => l.type === r); return o ? Number(o.value) : Number.NaN; }, i = Date.UTC( n("year"), @@ -1543,45 +1543,49 @@ function Rt(s, e) { ); return Number.isFinite(i) ? i - s : 0; } -function Ne(s, e) { +function Nt(s, t) { if (typeof s != "string") return null; - const t = s.split("-"); - if (t.length !== 3) + const e = s.split("-"); + if (e.length !== 3) return null; - const [n, i, r] = t.map((a) => Number(a)); + const [n, i, r] = e.map((a) => Number(a)); if (!n || !i || !r) return null; - if (!e) { + 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 - Rt(o, e)); + const o = Date.UTC(n, i - 1, r), l = new Date(o - Re(o, t)); return Number.isNaN(l.getTime()) ? null : l; } -function Lt() { +function Le() { const s = window.location.pathname.split("/").filter(Boolean); return s.length > 0 ? s[s.length - 1].toLowerCase() : ""; } -function Bt(s) { +function Be(s) { if (!s || typeof s != "object") return null; - const e = s, t = k(e.state).toLowerCase(); - if (t !== "open" && t !== "completed") + const t = s, e = k(t.state).toLowerCase(); + if (e !== "open" && e !== "completed") return null; - const n = _(e.id, 0); - return n ? { + const n = y(t.id, 0); + if (!n) + return null; + const i = y(t.child_id, 0); + return { id: n, - title: k(e.title) || "Quest", - icon: k(e.icon) || null, - window: k(e.window).toLowerCase(), - due_time: k(e.due_time) || null, - state: t, - overdue: e.overdue === !0, - completed_at: k(e.completed_at) || null - } : null; + child_id: i > 0 ? i : null, + title: k(t.title) || "Quest", + icon: k(t.icon) || null, + window: k(t.window).toLowerCase(), + due_time: k(t.due_time) || null, + state: e, + overdue: t.overdue === !0, + completed_at: k(t.completed_at) || null + }; } -const Te = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ft = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", Me = [-9, 6, -4], Qt = Pe` +const Tt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Fe = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", Mt = [-9, 6, -4], Qe = Pt` * { box-sizing: border-box; } @@ -1641,7 +1645,7 @@ const Te = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ft = "polygon(50% width: 96px; height: 110px; padding: 3px; - clip-path: ${v(Te)}; + clip-path: ${v(Tt)}; background: rgba(255, 255, 255, 0.22); } @@ -1652,7 +1656,7 @@ const Te = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ft = "polygon(50% align-items: center; justify-content: center; padding-bottom: 32px; - clip-path: ${v(Te)}; + clip-path: ${v(Tt)}; background: var(--nq-p-crest); } @@ -1718,7 +1722,7 @@ const Te = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ft = "polygon(50% justify-content: center; width: 44px; height: 44px; - clip-path: ${v(Ft)}; + clip-path: ${v(Fe)}; background: var(--nq-brand-gradient); } @@ -2229,7 +2233,42 @@ const Te = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ft = "polygon(50% width: 38px; height: 38px; } -`, R = p` -`, Wt = p``, We = c` -`, Y = p``, Y = c` -`, Zt = p``, Ze = c` -`, Le = p``, Lt = c` -`, Vt = p``, Ve = c` -`, Gt = p``, Ge = c` -`, Kt = p``, Ke = c` -`, Jt = p``, Je = c` -`, Xt = p``, Xe = c` -`, Yt = { - icon: Kt, +`, Ye = { + icon: Ke, headline: "No adventurer chosen", body: "Open The Party and tap your crest to open your quest log." -}, en = { - icon: Jt, +}, tn = { + icon: Je, headline: "NestQuest is not set up yet", body: "A parent needs to finish setting up NestQuest." -}, tn = { - icon: Xt, +}, en = { + icon: Xe, headline: "The records cannot be reached", body: "The party's records are quiet right now. NestQuest will return shortly." -}, Oe = [ - { key: "morning", name: "Morning", range: "Until 11:59 AM", icon: Zt }, - { key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: Le }, - { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: Vt } +}, Ot = [ + { key: "morning", name: "Morning", range: "Until 11:59 AM", icon: Ze }, + { key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: Lt }, + { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: Ve } ]; class nn extends C { static properties = { hass: { attribute: !1 }, _config: { state: !0 }, _now: { state: !0 }, - _confirm: { state: !0 } + _confirm: { state: !0 }, + _optimistic: { state: !0 }, + _toast: { state: !0 } }; - static styles = [v(Re), Qt]; + static styles = [v(Rt), Qe]; 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; _clockTimer; _idleTimer; _confirmTimer; - setConfig(e) { - if (!e || typeof e != "object") + _toastTimer; + setConfig(t) { + if (!t || typeof t != "object") throw new Error("Invalid configuration"); - this._config = e; + this._config = t; } getCardSize() { return 22; @@ -2398,72 +2444,78 @@ class nn extends C { }, 6e4), window.addEventListener("pointerdown", this._onActivity, !0), window.addEventListener("touchstart", this._onActivity, !0), window.addEventListener("keydown", this._onActivity, !0), this._armIdle(); } disconnectedCallback() { - this._stopClock(), this._clearIdle(), this._clearConfirmTimer(), window.removeEventListener("pointerdown", this._onActivity, !0), window.removeEventListener("touchstart", this._onActivity, !0), window.removeEventListener("keydown", this._onActivity, !0), super.disconnectedCallback(); + this._stopClock(), this._clearIdle(), this._clearConfirmTimer(), this._clearToastTimer(), window.removeEventListener("pointerdown", this._onActivity, !0), window.removeEventListener("touchstart", this._onActivity, !0), window.removeEventListener("keydown", this._onActivity, !0), super.disconnectedCallback(); } render() { - const e = this._logView(); - return p` + const t = this._logView(); + return c`
    - ${this._renderMain(e)} + ${this._renderMain(t)} ${this._renderConfirm()} + ${this._renderToast()}
    `; } - _renderMain(e) { - switch (e.kind) { + _renderToast() { + return this._toast === null ? g : c` +
    ${this._toast}
    + `; + } + _renderMain(t) { + switch (t.kind) { case "no-adventurer": - return this._renderNotice(Yt); + return this._renderNotice(Ye); case "not-set-up": - return this._renderNotice(en); - case "unreachable": return this._renderNotice(tn); + case "unreachable": + return this._renderNotice(en); case "away": { - const t = ["The quest log unlocks when they return."]; - return e.returns && t.push(`Returns ${e.returns}`), p` - ${this._renderHeader(e)} + const e = ["The quest log unlocks when they return."]; + return t.returns && e.push(`Returns ${t.returns}`), c` + ${this._renderHeader(t)}
    - ${Gt} - ${e.name} is on travels - ${t.map((n) => p`

    ${n}

    `)} + ${Ge} + ${t.name} is on travels + ${e.map((n) => c`

    ${n}

    `)}
    `; } case "empty-day": - return p` - ${this._renderHeader(e)} + return c` + ${this._renderHeader(t)}
    - ${Le} + ${Lt} No quests today

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

    `; case "complete-day": - return p` - ${this._renderHeader(e)} + return c` + ${this._renderHeader(t)}
    ${R} Quest complete

    - ${`Every quest is claimed, ${e.name}. The seal is set for today.`} + ${`Every quest is claimed, ${t.name}. The seal is set for today.`}

    Returning to The Party shortly.

    `; default: - return p` - ${this._renderHeader(e)} + return c` + ${this._renderHeader(t)}
    - ${Oe.map((t) => this._renderColumn(t))} + ${Ot.map((e) => this._renderColumn(e))}
    `; } @@ -2471,163 +2523,179 @@ class nn extends C { /** Resolve the log's single render state; the priority order is * design/PANEL-EMPTY-STATES.md §2. */ _logView() { - const e = this._childSlug(); - if (!e) + const t = this._childSlug(); + if (!t) return { kind: "no-adventurer" }; - const t = this._dueSensorResolution(e); - if (t === "missing") + const e = this._dueSensorResolution(t); + if (e === "missing") return { kind: "not-set-up" }; - if (t === "stale") + if (e === "stale") return { kind: "unreachable" }; - const n = this._childName(e) ?? X(e); - if (!this._childPresent(e)) - return { kind: "away", name: n, returns: this._awayReturns(e) }; - const i = this._state(`sensor.nestquest_${e}_quests_due_today`); - return $(_(i?.state, 0)) === 0 ? { kind: "empty-day", name: n } : this._remaining() === 0 ? { kind: "complete-day", name: n } : { kind: "normal" }; + const n = this._childName(t) ?? X(t); + if (!this._childPresent(t)) + return { kind: "away", name: n, returns: this._awayReturns(t) }; + const i = this._state(`sensor.nestquest_${t}_quests_due_today`); + return x(y(i?.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(e) { - const t = this._state(`sensor.nestquest_${e}_quests_due_today`); - if (!t) + _dueSensorResolution(t) { + const e = this._state(`sensor.nestquest_${t}_quests_due_today`); + if (!e) return "missing"; - const n = String(t.state ?? "").trim().toLowerCase(); + const n = String(e.state ?? "").trim().toLowerCase(); return !n || n === "unavailable" || n === "unknown" ? "stale" : null; } - _childPresent(e) { - const n = this._state(`sensor.nestquest_${e}_quests_due_today`)?.attributes?.present; + _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_${e}_present_today` + `binary_sensor.nestquest_${t}_present_today` ); return i ? String(i.state ?? "").trim().toLowerCase() === "on" : !0; } - _awayReturns(e) { - const t = this._timeZone(), n = this._state( - `binary_sensor.nestquest_${e}_present_today` - ), i = Ne( + _awayReturns(t) { + const e = this._timeZone(), n = this._state( + `binary_sensor.nestquest_${t}_present_today` + ), i = Nt( n?.attributes?.next_present, - t + e ); - return i ? Ee(i, t) : null; + return i ? Et(i, e) : null; } - _renderNotice(e) { - return p` + _renderNotice(t) { + return c`
    - ${e.icon} - ${e.headline} -

    ${e.body}

    + ${t.icon} + ${t.headline} +

    ${t.body}

    `; } _timeZone() { - return Ut(this.hass); + return Ue(this.hass); } - _state(e) { - const n = this.hass?.states?.[e]; + _state(t) { + const n = this.hass?.states?.[t]; return n && typeof n == "object" ? n : null; } _childSlug() { - return Lt(); + return Le(); } _boardPath() { return k(this._config?.board_path).replace(/\/+$/, ""); } _idleSeconds() { - const e = _(this._config?.idle_return_seconds, 40); - return e > 0 ? e : 40; + const t = y(this._config?.idle_return_seconds, 40); + return t > 0 ? t : 40; } _confirmSeconds() { - const e = _(this._config?.confirm_timeout_seconds, 15); - return e > 0 ? e : 15; + const t = y(this._config?.confirm_timeout_seconds, 15); + return t > 0 ? t : 15; } - _childName(e) { - if (!e) + _childName(t) { + if (!t) return null; - const t = this._state(`sensor.nestquest_${e}_quests_due_today`)?.attributes?.child_name; - return typeof t == "string" && t.trim() ? t.trim() : null; + const e = this._state(`sensor.nestquest_${t}_quests_due_today`)?.attributes?.child_name; + return typeof e == "string" && e.trim() ? e.trim() : null; } - _childCounts(e) { - const t = this._state(`sensor.nestquest_${e}_quests_due_today`), n = this._state( - `sensor.nestquest_${e}_quests_completed_today` - ), i = $(_(t?.state, 0)), r = $(_(n?.state, 0)); + _childCounts(t) { + const e = this._state(`sensor.nestquest_${t}_quests_due_today`), n = this._state( + `sensor.nestquest_${t}_quests_completed_today` + ), i = x(y(e?.state, 0)), r = x(y(n?.state, 0)); return { due: i, completed: r }; } _instances() { - const e = this._childSlug(); - if (!e) + const t = this._childSlug(); + if (!t) return []; - const t = this._state(`sensor.nestquest_${e}_quests_due_today`)?.attributes?.instances; - return Array.isArray(t) ? t.map(Bt).filter((n) => n !== null) : []; + const e = this._state(`sensor.nestquest_${t}_quests_due_today`)?.attributes?.instances; + if (!Array.isArray(e)) + return []; + const n = e.map(Be).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 e = this._childSlug(); - if (!e) + const t = this._childSlug(); + if (!t) return 0; - const t = this._state( - `sensor.nestquest_${e}_quests_remaining_today` + const e = this._state( + `sensor.nestquest_${t}_quests_remaining_today` ); - if (t) - return $(_(t.state, 0)); - const { due: n, completed: i } = this._childCounts(e); - return $(n - i); + if (e) + return x(y(e.state, 0)); + const { due: n, completed: i } = this._childCounts(t); + return x(n - i); } _partyCounts() { - const e = this._state( + const t = this._state( "sensor.nestquest_household_quests_due_today" - ), t = this._state( + ), e = this._state( "sensor.nestquest_household_quests_completed_today" ); - if (e || t) + if (t || e) return { - completed: $(_(t?.state, 0)), - due: $(_(e?.state, 0)) + completed: x(y(e?.state, 0)), + due: x(y(t?.state, 0)) }; const n = this._childSlug(), { due: i, completed: r } = this._childCounts(n); return { completed: r, due: i }; } _otherChildren() { - const e = this.hass?.states; - if (!e) + const t = this.hass?.states; + if (!t) return []; - const t = this._childSlug(), n = this._timeZone(), i = []; - for (const r of Object.keys(e)) { + 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] === t) + if (!o || o[1] === "household" || o[1] === e) continue; - const l = o[1], a = e[r]?.attributes ?? {}; - let c = ""; - typeof a.child_name == "string" && a.child_name.trim() ? c = a.child_name.trim() : c = X(l); + 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 = X(l); let u = !0; if (typeof a.present == "boolean") u = a.present; else { - const y = this._state( + const _ = this._state( `binary_sensor.nestquest_${l}_present_today` ); - u = y === null || String(y.state ?? "").trim().toLowerCase() !== "off"; + u = _ === null || String(_.state ?? "").trim().toLowerCase() !== "off"; } const d = this._state( `sensor.nestquest_${l}_quests_completed_today` - ), f = $(_(e[r]?.state, 0)), h = $(_(d?.state, 0)); + ), f = x(y(t[r]?.state, 0)), h = x(y(d?.state, 0)); let m = null; if (!u) { - const y = this._state( + const _ = this._state( `binary_sensor.nestquest_${l}_present_today` - ), b = Ne( - y?.attributes?.next_present, + ), b = Nt( + _?.attributes?.next_present, n ); - m = b ? Dt(b, n) : null; + m = b ? De(b, n) : null; } i.push({ slug: l, - name: c, + name: p, present: u, due: f, completed: h, @@ -2636,9 +2704,9 @@ class nn extends C { } return i.sort((r, o) => r.name.localeCompare(o.name)); } - _renderHeader(e) { - const t = this._childSlug(), n = e.kind === "away", i = n ? e.name : this._childName(t) ?? (t ? X(t) : null), { due: r, completed: o } = this._childCounts(t), l = this._remaining(), a = this._partyCounts(), c = i ? `${i}'s Quest Log` : "Quest Log", u = (i?.charAt(0) || "?").toUpperCase(); - return p` + _renderHeader(t) { + const e = this._childSlug(), n = t.kind === "away", i = n ? t.name : this._childName(e) ?? (e ? X(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 c`
    -

    ${c}

    +

    ${p}

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

    - ${n ? g : p` + ${n ? g : c`
    `; } - _renderColumn(e) { - const t = this._instances().filter( - (l) => l.window === e.key - ), n = t.filter((l) => l.state === "open").sort( + _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 = t.filter( + ), i = e.filter( (l) => l.state === "completed" - ), r = t.length; + ), r = e.length; let o = g; if (r > 0) { - const a = 644 - (e.key === "afternoon" && this._otherChildren().length > 0 ? 160 : 0), c = Math.min( + 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) * c) / r)) + Math.max(58, Math.floor((a - (r - 1) * p) / r)) ), d = Math.max(56, Math.min(72, u - 2)); - let f = `--nq-p-quest-gap: ${c}px; --nq-p-quest-min-height: ${u}px; --nq-p-button-height: ${d}px`; + let f = `--nq-p-quest-gap: ${p}px; --nq-p-quest-min-height: ${u}px; --nq-p-button-height: ${d}px`; if (r >= 5) { const h = Math.max(0, Math.min(22, Math.floor((u - d - 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 p` + return c`
    - ${e.icon} + ${t.icon}
    - ${e.name} - ${e.range} + ${t.name} + ${t.range}
    - ${i.length}/${t.length} + ${i.length}/${e.length}
    - ${Ce( + ${Ct( n, (l) => l.id, (l) => this._renderQuest(l, !1, 0) )} - ${Ce( + ${Ct( i, (l) => l.id, (l, a) => this._renderQuest(l, !0, a) )} - ${e.key === "afternoon" ? this._renderRollup() : g} + ${t.key === "afternoon" ? this._renderRollup() : g}
    `; } - _renderQuest(e, t, n) { - if (t) { - const o = Me[n % Me.length], l = e.completed_at ? Ht(e.completed_at, this._timeZone()) : ""; - return p` -
    + _renderQuest(t, e, n) { + if (e) { + const o = Mt[n % Mt.length], l = t.completed_at ? He(t.completed_at, this._timeZone()) : ""; + return c` +
    - ${e.title} + ${t.title} ${l ? `Claimed ${l}` : "Claimed"} @@ -2746,34 +2814,34 @@ class nn extends C {
    `; } - const i = jt(e.due_time), r = e.overdue ? { + const i = je(t.due_time), r = t.overdue ? { text: i ? `Overdue · due ${i}` : "Overdue", late: !0 } : { text: i ? `Due by ${i}` : "Due today", late: !1 }; - return p` + return c`
    this._openConfirm(e.id)} + data-instance-id=${t.id} + aria-label="Complete ${t.title}" + @click=${() => this._openConfirm(t.id)} @keydown=${(o) => { - (o.key === "Enter" || o.key === " ") && (o.preventDefault(), this._openConfirm(e.id)); + (o.key === "Enter" || o.key === " ") && (o.preventDefault(), this._openConfirm(t.id)); }} >
    - ${e.title} + ${t.title} ${r.text}
    `; } + 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": @@ -1318,9 +1374,32 @@ export class NestQuestQuestLogCard extends LitElement { if (!Array.isArray(payload)) { return []; } - return payload + 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 { @@ -1698,7 +1777,94 @@ export class NestQuestQuestLogCard extends LitElement { } 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 { From a2c027deca1c4de528e1a7c3915231456ed08437 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Sun, 20 Sep 2026 18:39:11 -0400 Subject: [PATCH 42/46] Add the quest complete screen (1c) Add the quest complete screen (1c) --- .../nestquest/www/nestquest-cards.js | 1181 +++++++++++------ design/ENTITIES-AND-SERVICES.md | 1 + frontend/src/cards/quest-log.ts | 463 ++++++- 3 files changed, 1214 insertions(+), 431 deletions(-) diff --git a/custom_components/nestquest/www/nestquest-cards.js b/custom_components/nestquest/www/nestquest-cards.js index e60d13e..a6aa494 100644 --- a/custom_components/nestquest/www/nestquest-cards.js +++ b/custom_components/nestquest/www/nestquest-cards.js @@ -1,15 +1,15 @@ -const L = globalThis, et = L.ShadowRoot && (L.ShadyCSS === void 0 || L.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, nt = /* @__PURE__ */ Symbol(), at = /* @__PURE__ */ new WeakMap(); +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 !== nt) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); + 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 (et && t === void 0) { + if (nt && t === void 0) { const n = e !== void 0 && e.length === 1; - n && (t = at.get(e)), t === void 0 && ((this.o = t = new CSSStyleSheet()).replaceSync(this.cssText), n && at.set(e, t)); + n && (t = lt.get(e)), t === void 0 && ((this.o = t = new CSSStyleSheet()).replaceSync(this.cssText), n && lt.set(e, t)); } return t; } @@ -17,101 +17,101 @@ let zt = class { return this.cssText; } }; -const v = (s) => new zt(typeof s == "string" ? s : s + "", void 0, nt), Pt = (s, ...t) => { - const e = s.length === 1 ? s[0] : t.reduce((n, i, r) => n + ((o) => { +const b = (i) => new zt(typeof i == "string" ? i : i + "", void 0, it), Pt = (i, ...t) => { + const e = i.length === 1 ? i[0] : t.reduce((n, s, 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, nt); -}, Bt = (s, t) => { - if (et) s.adoptedStyleSheets = t.map((e) => e instanceof CSSStyleSheet ? e : e.styleSheet); + })(s) + i[r + 1], i[0]); + return new zt(e, i, it); +}, Bt = (i, t) => { + if (nt) i.adoptedStyleSheets = t.map((e) => e instanceof CSSStyleSheet ? e : e.styleSheet); else for (const e of t) { - const n = document.createElement("style"), i = L.litNonce; - i !== void 0 && n.setAttribute("nonce", i), n.textContent = e.cssText, s.appendChild(n); + const n = document.createElement("style"), s = B.litNonce; + s !== void 0 && n.setAttribute("nonce", s), n.textContent = e.cssText, i.appendChild(n); } -}, lt = et ? (s) => s : (s) => s instanceof CSSStyleSheet ? ((t) => { +}, dt = nt ? (i) => i : (i) => i 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: Vt, getPrototypeOf: Gt } = Object, Q = globalThis, dt = Q.trustedTypes, Kt = dt ? dt.emptyScript : "", Jt = Q.reactiveElementPolyfillSupport, P = (s, t) => s, tt = { toAttribute(s, t) { + return b(e); +})(i) : i; +const { is: Ft, defineProperty: Qt, getOwnPropertyDescriptor: Wt, getOwnPropertyNames: Zt, getOwnPropertySymbols: Vt, getPrototypeOf: Gt } = Object, W = globalThis, ct = W.trustedTypes, Kt = ct ? ct.emptyScript : "", Jt = W.reactiveElementPolyfillSupport, I = (i, t) => i, et = { toAttribute(i, t) { switch (t) { case Boolean: - s = s ? Kt : null; + i = i ? Kt : null; break; case Object: case Array: - s = s == null ? s : JSON.stringify(s); + i = i == null ? i : JSON.stringify(i); } - return s; -}, fromAttribute(s, t) { - let e = s; + return i; +}, fromAttribute(i, t) { + let e = i; switch (t) { case Boolean: - e = s !== null; + e = i !== null; break; case Number: - e = s === null ? null : Number(s); + e = i === null ? null : Number(i); break; case Object: case Array: try { - e = JSON.parse(s); + e = JSON.parse(i); } catch { e = null; } } return e; -} }, It = (s, t) => !Ft(s, t), ct = { attribute: !0, type: String, converter: tt, reflect: !1, useDefault: !1, hasChanged: It }; -Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), Q.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); -let N = class extends HTMLElement { +} }, It = (i, t) => !Ft(i, 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 T = 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 = ct) { + 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); + const n = /* @__PURE__ */ Symbol(), s = this.getPropertyDescriptor(t, n, e); + s !== void 0 && Qt(this.prototype, t, s); } } static getPropertyDescriptor(t, e, n) { - const { get: i, set: r } = Wt(this.prototype, t) ?? { get() { + const { get: s, 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); + return { get: s, set(o) { + const l = s?.call(this); r?.call(this, o), this.requestUpdate(t, l, n); }, configurable: !0, enumerable: !0 }; } static getPropertyOptions(t) { - return this.elementProperties.get(t) ?? ct; + return this.elementProperties.get(t) ?? pt; } static _$Ei() { - if (this.hasOwnProperty(P("elementProperties"))) return; + if (this.hasOwnProperty(I("elementProperties"))) return; const t = Gt(this); t.finalize(), t.l !== void 0 && (this.l = [...t.l]), this.elementProperties = new Map(t.elementProperties); } static finalize() { - if (this.hasOwnProperty(P("finalized"))) return; - if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(P("properties"))) { + if (this.hasOwnProperty(I("finalized"))) return; + if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(I("properties"))) { const e = this.properties, n = [...Zt(e), ...Vt(e)]; - for (const i of n) this.createProperty(i, e[i]); + for (const s of n) this.createProperty(s, e[s]); } 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); + if (e !== void 0) for (const [n, s] of e) this.elementProperties.set(n, s); } 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); + const s = this._$Eu(e, n); + s !== void 0 && this._$Eh.set(s, e); } this.elementStyles = this.finalizeStyles(this.styles); } @@ -119,8 +119,8 @@ let N = class extends HTMLElement { const e = []; if (Array.isArray(t)) { const n = new Set(t.flat(1 / 0).reverse()); - for (const i of n) e.unshift(lt(i)); - } else t !== void 0 && e.push(lt(t)); + for (const s of n) e.unshift(dt(s)); + } else t !== void 0 && e.push(dt(t)); return e; } static _$Eu(t, e) { @@ -160,31 +160,31 @@ let N = class extends HTMLElement { 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 : tt).toAttribute(e, n.type); - this._$Em = t, r == null ? this.removeAttribute(i) : this.setAttribute(i, r), this._$Em = null; + const n = this.constructor.elementProperties.get(t), s = this.constructor._$Eu(t, n); + if (s !== 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(s) : this.setAttribute(s, 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 : tt; - this._$Em = i; + const n = this.constructor, s = n._$Eh.get(t); + if (s !== void 0 && this._$Em !== s) { + const r = n.getPropertyOptions(s), o = typeof r.converter == "function" ? { fromAttribute: r.converter } : r.converter?.fromAttribute !== void 0 ? r.converter : et; + this._$Em = s; const l = o.fromAttribute(e, r.type); - this[i] = l ?? this._$Ej?.get(i) ?? l, this._$Em = null; + this[s] = l ?? this._$Ej?.get(s) ?? l, this._$Em = null; } } - requestUpdate(t, e, n, i = !1, r) { + requestUpdate(t, e, n, s = !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; + if (s === !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)); + C(t, e, { useDefault: n, reflect: s, 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)), s === !0 && this._$Em !== t && (this._$Eq ??= /* @__PURE__ */ new Set()).add(t)); } async _$EP() { this.isUpdatePending = !0; @@ -203,13 +203,13 @@ let N = class extends HTMLElement { if (!this.isUpdatePending) return; if (!this.hasUpdated) { if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) { - for (const [i, r] of this._$Ep) this[i] = r; + for (const [s, r] of this._$Ep) this[s] = 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); + if (n.size > 0) for (const [s, r] of n) { + const { wrapped: o } = r, l = this[s]; + o !== !0 || this._$AL.has(s) || l === void 0 || this.C(s, void 0, r, l); } } let t = !1; @@ -246,54 +246,54 @@ let N = class extends HTMLElement { firstUpdated(t) { } }; -N.elementStyles = [], N.shadowRootOptions = { mode: "open" }, N[P("elementProperties")] = /* @__PURE__ */ new Map(), N[P("finalized")] = /* @__PURE__ */ new Map(), Jt?.({ ReactiveElement: N }), (Q.reactiveElementVersions ??= []).push("2.1.2"); -const st = globalThis, pt = (s) => s, B = st.trustedTypes, ht = B ? B.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, Ut = "$lit$", $ = `lit$${Math.random().toFixed(9).slice(2)}$`, Dt = "?" + $, Xt = `<${Dt}>`, S = document, I = () => S.createComment(""), U = (s) => s === null || typeof s != "object" && typeof s != "function", it = Array.isArray, Yt = (s) => it(s) || typeof s?.[Symbol.iterator] == "function", V = `[ -\f\r]`, O = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, ut = /-->/g, ft = />/g, w = RegExp(`>|${V}(?:([^\\s"'>=/]+)(${V}*=${V}*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`, "g"), mt = /'/g, gt = /"/g, Ht = /^(?:script|style|textarea|title)$/i, te = (s) => (t, ...e) => ({ _$litType$: s, strings: t, values: e }), c = te(1), E = /* @__PURE__ */ Symbol.for("lit-noChange"), g = /* @__PURE__ */ Symbol.for("lit-nothing"), _t = /* @__PURE__ */ new WeakMap(), A = S.createTreeWalker(S, 129); -function jt(s, t) { - if (!it(s) || !s.hasOwnProperty("raw")) throw Error("invalid template strings array"); - return ht !== void 0 ? ht.createHTML(t) : t; +T.elementStyles = [], T.shadowRootOptions = { mode: "open" }, T[I("elementProperties")] = /* @__PURE__ */ new Map(), T[I("finalized")] = /* @__PURE__ */ new Map(), Jt?.({ ReactiveElement: T }), (W.reactiveElementVersions ??= []).push("2.1.2"); +const st = globalThis, ht = (i) => i, F = st.trustedTypes, ut = F ? F.createPolicy("lit-html", { createHTML: (i) => i }) : void 0, Dt = "$lit$", w = `lit$${Math.random().toFixed(9).slice(2)}$`, Ut = "?" + w, Xt = `<${Ut}>`, S = document, D = () => S.createComment(""), U = (i) => i === null || typeof i != "object" && typeof i != "function", rt = Array.isArray, Yt = (i) => rt(i) || typeof i?.[Symbol.iterator] == "function", G = `[ +\f\r]`, z = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, ft = /-->/g, mt = />/g, q = RegExp(`>|${G}(?:([^\\s"'>=/]+)(${G}*=${G}*(?:[^ +\f\r"'\`<>=]|("|')|))|$)`, "g"), gt = /'/g, _t = /"/g, jt = /^(?:script|style|textarea|title)$/i, te = (i) => (t, ...e) => ({ _$litType$: i, strings: t, values: e }), c = te(1), N = /* @__PURE__ */ Symbol.for("lit-noChange"), g = /* @__PURE__ */ Symbol.for("lit-nothing"), yt = /* @__PURE__ */ new WeakMap(), C = S.createTreeWalker(S, 129); +function Ht(i, t) { + if (!rt(i) || !i.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 = O; +const ee = (i, t) => { + const e = i.length - 1, n = []; + let s, r = t === 2 ? "" : t === 3 ? "" : "", o = z; for (let l = 0; l < e; l++) { - const a = s[l]; + const a = i[l]; let p, u, d = -1, f = 0; - for (; f < a.length && (o.lastIndex = f, u = o.exec(a), u !== null); ) f = o.lastIndex, o === O ? u[1] === "!--" ? o = ut : u[1] !== void 0 ? o = ft : u[2] !== void 0 ? (Ht.test(u[2]) && (i = RegExp("" ? (o = i ?? O, d = -1) : u[1] === void 0 ? d = -2 : (d = o.lastIndex - u[2].length, p = u[1], o = u[3] === void 0 ? w : u[3] === '"' ? gt : mt) : o === gt || o === mt ? o = w : o === ut || o === ft ? o = O : (o = w, i = void 0); - const h = o === w && s[l + 1].startsWith("/>") ? " " : ""; - r += o === O ? a + Xt : d >= 0 ? (n.push(p), a.slice(0, d) + Ut + a.slice(d) + $ + h) : a + $ + (d === -2 ? l : h); + 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 ? (jt.test(u[2]) && (s = RegExp("" ? (o = s ?? z, d = -1) : u[1] === void 0 ? d = -2 : (d = 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, s = void 0); + const h = o === q && i[l + 1].startsWith("/>") ? " " : ""; + r += o === z ? a + Xt : d >= 0 ? (n.push(p), a.slice(0, d) + Dt + a.slice(d) + w + h) : a + w + (d === -2 ? l : h); } - return [jt(s, r + (s[e] || "") + (t === 2 ? "" : t === 3 ? "" : "")), n]; + return [Ht(i, r + (i[e] || "") + (t === 2 ? "" : t === 3 ? "" : "")), n]; }; -class D { +class j { constructor({ strings: t, _$litType$: e }, n) { - let i; + let s; this.parts = []; let r = 0, o = 0; const l = t.length - 1, a = this.parts, [p, u] = ee(t, e); - if (this.el = D.createElement(p, n), A.currentNode = this.el.content, e === 2 || e === 3) { + if (this.el = j.createElement(p, n), C.currentNode = this.el.content, e === 2 || e === 3) { const d = this.el.content.firstChild; d.replaceWith(...d.childNodes); } - for (; (i = A.nextNode()) !== null && a.length < l; ) { - if (i.nodeType === 1) { - if (i.hasAttributes()) for (const d of i.getAttributeNames()) if (d.endsWith(Ut)) { - const f = u[o++], h = i.getAttribute(d).split($), m = /([.?@])?(.*)/.exec(f); - a.push({ type: 1, index: r, name: m[2], strings: h, ctor: m[1] === "." ? se : m[1] === "?" ? ie : m[1] === "@" ? re : W }), i.removeAttribute(d); - } else d.startsWith($) && (a.push({ type: 6, index: r }), i.removeAttribute(d)); - if (Ht.test(i.tagName)) { - const d = i.textContent.split($), f = d.length - 1; + for (; (s = C.nextNode()) !== null && a.length < l; ) { + if (s.nodeType === 1) { + if (s.hasAttributes()) for (const d of s.getAttributeNames()) if (d.endsWith(Dt)) { + const f = u[o++], h = s.getAttribute(d).split(w), 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 }), s.removeAttribute(d); + } else d.startsWith(w) && (a.push({ type: 6, index: r }), s.removeAttribute(d)); + if (jt.test(s.tagName)) { + const d = s.textContent.split(w), f = d.length - 1; if (f > 0) { - i.textContent = B ? B.emptyScript : ""; - for (let h = 0; h < f; h++) i.append(d[h], I()), A.nextNode(), a.push({ type: 2, index: ++r }); - i.append(d[f], I()); + s.textContent = F ? F.emptyScript : ""; + for (let h = 0; h < f; h++) s.append(d[h], D()), C.nextNode(), a.push({ type: 2, index: ++r }); + s.append(d[f], D()); } } - } else if (i.nodeType === 8) if (i.data === Dt) a.push({ type: 2, index: r }); + } else if (s.nodeType === 8) if (s.data === Ut) a.push({ type: 2, index: r }); else { let d = -1; - for (; (d = i.data.indexOf($, d + 1)) !== -1; ) a.push({ type: 7, index: r }), d += $.length - 1; + for (; (d = s.data.indexOf(w, d + 1)) !== -1; ) a.push({ type: 7, index: r }), d += w.length - 1; } r++; } @@ -303,11 +303,11 @@ class D { return n.innerHTML = t, n; } } -function T(s, t, e = s, n) { - if (t === E) return t; - let i = n !== void 0 ? e._$Co?.[n] : e._$Cl; +function E(i, t, e = i, n) { + if (t === N) return t; + let s = 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 = T(s, i._$AS(s, t.values), i, n)), t; + return s?.constructor !== r && (s?._$AO?.(!1), r === void 0 ? s = void 0 : (s = new r(i), s._$AT(i, e, n)), n !== void 0 ? (e._$Co ??= [])[n] = s : e._$Cl = s), s !== void 0 && (t = E(i, s._$AS(i, t.values), s, n)), t; } class ne { constructor(t, e) { @@ -320,29 +320,29 @@ class ne { return this._$AM._$AU; } u(t) { - const { el: { content: e }, parts: n } = this._$AD, i = (t?.creationScope ?? S).importNode(e, !0); - A.currentNode = i; - let r = A.nextNode(), o = 0, l = 0, a = n[0]; + const { el: { content: e }, parts: n } = this._$AD, s = (t?.creationScope ?? S).importNode(e, !0); + C.currentNode = s; + 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 M(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]; + 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 = A.nextNode(), o++); + o !== a?.index && (r = C.nextNode(), o++); } - return A.currentNode = S, i; + return C.currentNode = S, s; } 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 M { +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; + constructor(t, e, n, s) { + this.type = 2, this._$AH = g, this._$AN = void 0, this._$AA = t, this._$AB = e, this._$AM = n, this.options = s, this._$Cv = s?.isConnected ?? !0; } get parentNode() { let t = this._$AA.parentNode; @@ -356,7 +356,7 @@ class M { return this._$AB; } _$AI(t, e = this) { - t = T(this, t, e), U(t) ? t === g || t == null || t === "" ? (this._$AH !== g && this._$AR(), this._$AH = g) : t !== this._$AH && t !== E && this._(t) : t._$litType$ !== void 0 ? this.$(t) : t.nodeType !== void 0 ? this.T(t) : Yt(t) ? this.k(t) : this._(t); + t = E(this, t, e), U(t) ? t === g || t == null || t === "" ? (this._$AH !== g && this._$AR(), this._$AH = g) : t !== this._$AH && t !== N && 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); @@ -368,60 +368,60 @@ class M { 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 = D.createElement(jt(n.h, n.h[0]), this.options)), n); - if (this._$AH?._$AD === i) this._$AH.p(e); + const { values: e, _$litType$: n } = t, s = typeof n == "number" ? this._$AC(t) : (n.el === void 0 && (n.el = j.createElement(Ht(n.h, n.h[0]), this.options)), n); + if (this._$AH?._$AD === s) this._$AH.p(e); else { - const r = new ne(i, this), o = r.u(this.options); + const r = new ne(s, this), o = r.u(this.options); r.p(e), this.T(o), this._$AH = r; } } _$AC(t) { - let e = _t.get(t.strings); - return e === void 0 && _t.set(t.strings, e = new D(t)), e; + let e = yt.get(t.strings); + return e === void 0 && yt.set(t.strings, e = new j(t)), e; } k(t) { - it(this._$AH) || (this._$AH = [], this._$AR()); + 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 M(this.O(I()), this.O(I()), this, this.options)) : n = e[i], n._$AI(r), i++; - i < e.length && (this._$AR(n && n._$AB.nextSibling, i), e.length = i); + let n, s = 0; + for (const r of t) s === e.length ? e.push(n = new O(this.O(D()), this.O(D()), this, this.options)) : n = e[s], n._$AI(r), s++; + s < e.length && (this._$AR(n && n._$AB.nextSibling, s), e.length = s); } _$AR(t = this._$AA.nextSibling, e) { for (this._$AP?.(!1, !0, e); t !== this._$AB; ) { - const n = pt(t).nextSibling; - pt(t).remove(), t = n; + const n = ht(t).nextSibling; + ht(t).remove(), t = n; } } setConnected(t) { this._$AM === void 0 && (this._$Cv = t, this._$AP?.(t)); } } -class W { +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; + constructor(t, e, n, s, r) { + this.type = 1, this._$AH = g, this._$AN = void 0, this.element = t, this.name = e, this._$AM = s, 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) { + _$AI(t, e = this, n, s) { const r = this.strings; let o = !1; - if (r === void 0) t = T(this, t, e, 0), o = !U(t) || t !== this._$AH && t !== E, o && (this._$AH = t); + if (r === void 0) t = E(this, t, e, 0), o = !U(t) || t !== this._$AH && t !== N, o && (this._$AH = t); else { const l = t; let a, p; - for (t = r[0], a = 0; a < r.length - 1; a++) p = T(this, l[n + a], e, a), p === E && (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; + for (t = r[0], a = 0; a < r.length - 1; a++) p = E(this, l[n + a], e, a), p === N && (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); + o && !s && this.j(t); } j(t) { t === g ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t ?? ""); } } -class se extends W { +class ie extends Z { constructor() { super(...arguments), this.type = 3; } @@ -429,7 +429,7 @@ class se extends W { this.element[this.name] = t === g ? void 0 : t; } } -class ie extends W { +class se extends Z { constructor() { super(...arguments), this.type = 4; } @@ -437,14 +437,14 @@ class ie extends W { this.element.toggleAttribute(this.name, !!t && t !== g); } } -class re extends W { - constructor(t, e, n, i, r) { - super(t, e, n, i, r), this.type = 5; +class re extends Z { + constructor(t, e, n, s, r) { + super(t, e, n, s, r), this.type = 5; } _$AI(t, e = this) { - if ((t = T(this, t, e, 0) ?? g) === E) 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; + if ((t = E(this, t, e, 0) ?? g) === N) return; + const n = this._$AH, s = t === g && n !== g || t.capture !== n.capture || t.once !== n.once || t.passive !== n.passive, r = t !== g && (n === g || s); + s && 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); @@ -458,22 +458,22 @@ class oe { return this._$AM._$AU; } _$AI(t) { - T(this, t); + E(this, t); } } -const ae = { I: M }, le = st.litHtmlPolyfillSupport; -le?.(D, M), (st.litHtmlVersions ??= []).push("3.3.3"); -const de = (s, t, e) => { +const ae = { I: O }, le = st.litHtmlPolyfillSupport; +le?.(j, O), (st.litHtmlVersions ??= []).push("3.3.3"); +const de = (i, t, e) => { const n = e?.renderBefore ?? t; - let i = n._$litPart$; - if (i === void 0) { + let s = n._$litPart$; + if (s === void 0) { const r = e?.renderBefore ?? null; - n._$litPart$ = i = new M(t.insertBefore(I(), r), r, void 0, e ?? {}); + n._$litPart$ = s = new O(t.insertBefore(D(), r), r, void 0, e ?? {}); } - return i._$AI(s), i; + return s._$AI(i), s; }; -const rt = globalThis; -let C = class extends N { +const ot = globalThis; +let A = class extends T { constructor() { super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0; } @@ -492,49 +492,49 @@ let C = class extends N { super.disconnectedCallback(), this._$Do?.setConnected(!1); } render() { - return E; + return N; } }; -C._$litElement$ = !0, C.finalized = !0, rt.litElementHydrateSupport?.({ LitElement: C }); -const ce = rt.litElementPolyfillSupport; -ce?.({ LitElement: C }); -(rt.litElementVersions ??= []).push("4.2.2"); -const Rt = ':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 ot(s) { - window.customCards = window.customCards ?? [], window.customCards.push(s); +A._$litElement$ = !0, A.finalized = !0, ot.litElementHydrateSupport?.({ LitElement: A }); +const ce = ot.litElementPolyfillSupport; +ce?.({ LitElement: A }); +(ot.litElementVersions ??= []).push("4.2.2"); +const Lt = ':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(i) { + window.customCards = window.customCards ?? [], window.customCards.push(i); } -const yt = /* @__PURE__ */ new Map(); -function F(s, t) { - const e = `${s ?? "local"}|${JSON.stringify(t)}`, n = yt.get(e); +const vt = /* @__PURE__ */ new Map(); +function Q(i, t) { + const e = `${i ?? "local"}|${JSON.stringify(t)}`, n = vt.get(e); if (n) return n; - let i; + let s; try { - i = new Intl.DateTimeFormat("en-US", { + s = new Intl.DateTimeFormat("en-US", { ...t, - ...s ? { timeZone: s } : {} + ...i ? { timeZone: i } : {} }); } catch { - i = new Intl.DateTimeFormat("en-US", t); + s = new Intl.DateTimeFormat("en-US", t); } - return yt.set(e, i), i; + return vt.set(e, s), s; } -function pe(s) { - const t = s?.config; +function pe(i) { + const t = i?.config; if (!t || typeof t != "object") return; const e = t.time_zone; return typeof e == "string" && e.trim() ? e.trim() : void 0; } -function G(s, t) { - return `${F(t, { weekday: "long" }).format(s)}, ${F(t, { month: "short", day: "numeric" }).format(s)}`; +function K(i, t) { + return `${Q(t, { weekday: "long" }).format(i)}, ${Q(t, { month: "short", day: "numeric" }).format(i)}`; } -function he(s, t) { - return F(t, { +function he(i, t) { + return Q(t, { hour: "numeric", minute: "2-digit", hour12: !0 - }).format(s); + }).format(i); } const ue = { "clear-night": "Clear", @@ -569,26 +569,26 @@ const ue = { windy: "A blustery day", "windy-variant": "A blustery day" }; -function vt(s) { - return typeof s == "string" ? s.trim() : ""; +function bt(i) { + return typeof i == "string" ? i.trim() : ""; } -function K(s, t) { - if (s == null || s === "") +function J(i, t) { + if (i == null || i === "") return t; - const e = typeof s == "number" ? s : Number(s); + const e = typeof i == "number" ? i : Number(i); return Number.isFinite(e) ? e : t; } -function bt(s) { - return Number.isFinite(s) ? Math.min(100, Math.max(0, s)) : 0; +function xt(i) { + return Number.isFinite(i) ? Math.min(100, Math.max(0, i)) : 0; } -function xt(s) { - return Math.max(0, Math.round(s)); +function $t(i) { + return Math.max(0, Math.round(i)); } -function me(s) { - return s.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); +function me(i) { + return i.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); } -function ge(s, t) { - const e = F(t, { +function ge(i, t) { + const e = Q(t, { year: "numeric", month: "2-digit", day: "2-digit", @@ -596,10 +596,10 @@ function ge(s, t) { minute: "2-digit", second: "2-digit", hourCycle: "h23" - }).formatToParts(new Date(s)), n = (r) => { + }).formatToParts(new Date(i)), n = (r) => { const o = e.find((l) => l.type === r); return o ? Number(o.value) : Number.NaN; - }, i = Date.UTC( + }, s = Date.UTC( n("year"), n("month") - 1, n("day"), @@ -607,32 +607,32 @@ function ge(s, t) { n("minute"), n("second") ); - return Number.isFinite(i) ? i - s : 0; + return Number.isFinite(s) ? s - i : 0; } -function _e(s, t) { - if (typeof s != "string") +function _e(i, t) { + if (typeof i != "string") return null; - const e = s.split("-"); + const e = i.split("-"); if (e.length !== 3) return null; - const [n, i, r] = e.map((a) => Number(a)); - if (!n || !i || !r) + const [n, s, r] = e.map((a) => Number(a)); + if (!n || !s || !r) return null; if (!t) { - const a = new Date(n, i - 1, r); + const a = new Date(n, s - 1, r); return Number.isNaN(a.getTime()) ? null : a; } - const o = Date.UTC(n, i - 1, r), l = new Date(o - ge(o, t)); + const o = Date.UTC(n, s - 1, r), l = new Date(o - ge(o, t)); return Number.isNaN(l.getTime()) ? null : l; } -function ye(s) { - const t = ue[s]; - return t || s.charAt(0).toUpperCase() + s.slice(1); +function ye(i) { + const t = ue[i]; + return t || i.charAt(0).toUpperCase() + i.slice(1); } -function ve(s, t) { - return fe[s] ?? t; +function ve(i, t) { + return fe[i] ?? t; } -const $t = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", be = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", xe = Pt` +const wt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", be = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", xe = Pt` * { box-sizing: border-box; } @@ -704,7 +704,7 @@ const $t = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", be = "polygon(50% justify-content: center; width: 34px; height: 34px; - clip-path: ${v(be)}; + clip-path: ${b(be)}; background: var(--nq-brand-gradient); } @@ -798,7 +798,7 @@ const $t = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", be = "polygon(50% width: 188px; height: 214px; padding: 4px; - clip-path: ${v($t)}; + clip-path: ${b(wt)}; background: rgba(255, 255, 255, 0.22); } @@ -809,7 +809,7 @@ const $t = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", be = "polygon(50% align-items: center; justify-content: center; padding-bottom: 70px; - clip-path: ${v($t)}; + clip-path: ${b(wt)}; background: var(--nq-p-crest); } @@ -1072,7 +1072,7 @@ const $t = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", be = "polygon(50% -`, Ae = c``, Ce = c` -`, wt = { +`, qt = { icon: ke, headline: "NestQuest is not set up yet", body: "A parent needs to finish setting up NestQuest before the party can gather." -}, Ce = { - icon: Ae, +}, Ae = { + icon: Ce, headline: "The records cannot be reached", body: "The party's records are quiet right now. NestQuest will return shortly." }, Se = c` `; -class Ee extends C { +class Ne extends A { static properties = { hass: { attribute: !1 }, _config: { state: !0 }, _now: { state: !0 } }; - static styles = [v(Rt), xe]; + static styles = [b(Lt), xe]; hass; _config; _now = /* @__PURE__ */ new Date(); @@ -1151,7 +1151,7 @@ class Ee extends C {
    -

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

    +

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

    ${t ? this._renderNotice(t) : c`
    ${this._plates().map((e) => this._renderPlate(e))} @@ -1182,13 +1182,13 @@ class Ee extends C { 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( + const e = this._dueSensorResolution(t), n = this._state(`sensor.nestquest_${t}_quests_due_today`), s = 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 = xt(K(n?.state, 0)), p = xt(K(i?.state, 0)), u = r ? K(r.state, Number.NaN) : Number.NaN, d = Number.isFinite(u) ? bt(u) : a > 0 ? bt(p / a * 100) : 0; + ), l = n?.attributes ?? {}, a = $t(J(n?.state, 0)), p = $t(J(s?.state, 0)), u = r ? J(r.state, Number.NaN) : Number.NaN, d = 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"); @@ -1197,14 +1197,14 @@ class Ee extends C { if (typeof _ == "string" && _.trim()) m = _.trim(); else { - const Z = o?.attributes?.child_name; - typeof Z == "string" && Z.trim() && (m = Z.trim()); + const V = o?.attributes?.child_name; + typeof V == "string" && V.trim() && (m = V.trim()); } m || (m = me(t)); - const b = _e( + const x = _e( o?.attributes?.next_present, this._timeZone() - ), j = b ? `Returns ${G(b, this._timeZone())}` : null; + ), H = x ? `Returns ${K(x, this._timeZone())}` : null; return { slug: t, name: m, @@ -1213,7 +1213,7 @@ class Ee extends C { completed: p, due: a, pct: d, - returns: j, + returns: H, unresolved: e }; } @@ -1230,7 +1230,7 @@ class Ee extends C { } _boardNotice() { const t = this._plates(); - return t.length === 0 ? wt : t.every((e) => e.unresolved !== null) ? t.some((e) => e.unresolved === "stale") ? Ce : wt : null; + return t.length === 0 ? qt : t.every((e) => e.unresolved !== null) ? t.some((e) => e.unresolved === "stale") ? Ae : qt : null; } _renderNotice(t) { return c` @@ -1301,11 +1301,11 @@ class Ee extends C { `; } _openQuestLog(t) { - const e = vt(this._config?.quest_log_path); + const e = bt(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); + const t = bt(this._config?.weather_entity); if (!t) return null; const e = this._state(t); @@ -1314,9 +1314,9 @@ class Ee extends C { const n = String(e.state ?? "").trim(); if (!n || n === "unavailable" || n === "unknown") return null; - const i = e.attributes ?? {}; + const s = e.attributes ?? {}; let r = null, o = null; - const l = i.forecast; + const l = s.forecast; if (Array.isArray(l) && l.length > 0) { const a = l[0]; if (a && typeof a == "object") { @@ -1326,7 +1326,7 @@ class Ee extends C { } return { condition: n, - temperature: this._optionalNumber(i.temperature), + temperature: this._optionalNumber(s.temperature), high: r, low: o }; @@ -1342,16 +1342,16 @@ class Ee extends C { if (!t) return c`
    - ${G(this._now, this._timeZone())} + ${K(this._now, this._timeZone())} ${he(this._now, this._timeZone())}
    `; - const e = ye(t.condition), n = ve(t.condition, e), i = t.temperature === null ? g : c`${Math.round(t.temperature)}°`, r = t.high === null || t.low === null ? null : `${Math.round(t.high)}° / ${Math.round(t.low)}°`; + const e = ye(t.condition), n = ve(t.condition, e), s = t.temperature === null ? g : c`${Math.round(t.temperature)}°`, r = t.high === null || t.low === null ? null : `${Math.round(t.high)}° / ${Math.round(t.low)}°`; return c`
    ${Se} - ${i} + ${s} ${r === null ? e : `${e} · ${r}`} @@ -1362,13 +1362,13 @@ class Ee extends C { `; } } -customElements.define("nestquest-party-board-card", Ee); -ot({ +customElements.define("nestquest-party-board-card", Ne); +at({ type: "nestquest-party-board-card", name: "NestQuest Party Board", description: "Kids' attract screen for present and away adventurers." }); -const Ne = { CHILD: 2 }, Te = (s) => (...t) => ({ _$litDirective$: s, values: t }); +const Te = { CHILD: 2 }, Ee = (i) => (...t) => ({ _$litDirective$: i, values: t }); let Me = class { constructor(t) { } @@ -1385,119 +1385,126 @@ let Me = class { return this.render(...e); } }; -const { I: Oe } = ae, qt = (s) => s, kt = () => document.createComment(""), z = (s, t, e) => { - const n = s._$AA.parentNode, i = t === void 0 ? s._$AB : t._$AA; +const { I: Oe } = ae, kt = (i) => i, Ct = () => document.createComment(""), P = (i, t, e) => { + const n = i._$AA.parentNode, s = t === void 0 ? i._$AB : t._$AA; if (e === void 0) { - const r = n.insertBefore(kt(), i), o = n.insertBefore(kt(), i); - e = new Oe(r, o, s, s.options); + const r = n.insertBefore(Ct(), s), o = n.insertBefore(Ct(), s); + e = new Oe(r, o, i, i.options); } else { - const r = e._$AB.nextSibling, o = e._$AM, l = o !== s; + const r = e._$AB.nextSibling, o = e._$AM, l = o !== i; if (l) { let a; - e._$AQ?.(s), e._$AM = s, e._$AP !== void 0 && (a = s._$AU) !== o._$AU && e._$AP(a); + e._$AQ?.(i), e._$AM = i, e._$AP !== void 0 && (a = i._$AU) !== o._$AU && e._$AP(a); } - if (r !== i || l) { + if (r !== s || l) { let a = e._$AA; for (; a !== r; ) { - const p = qt(a).nextSibling; - qt(n).insertBefore(a, i), a = p; + const p = kt(a).nextSibling; + kt(n).insertBefore(a, s), a = p; } } } return e; -}, q = (s, t, e = s) => (s._$AI(t, e), s), ze = {}, Pe = (s, t = ze) => s._$AH = t, Ie = (s) => s._$AH, J = (s) => { - s._$AR(), s._$AA.remove(); +}, k = (i, t, e = i) => (i._$AI(t, e), i), ze = {}, Pe = (i, t = ze) => i._$AH = t, Ie = (i) => i._$AH, X = (i) => { + i._$AR(), i._$AA.remove(); }; -const At = (s, t, e) => { +const At = (i, t, e) => { const n = /* @__PURE__ */ new Map(); - for (let i = t; i <= e; i++) n.set(s[i], i); + for (let s = t; s <= e; s++) n.set(i[s], s); return n; -}, Ct = Te(class extends Me { - constructor(s) { - if (super(s), s.type !== Ne.CHILD) throw Error("repeat() can only be used in text expressions"); +}, St = Ee(class extends Me { + constructor(i) { + if (super(i), i.type !== Te.CHILD) throw Error("repeat() can only be used in text expressions"); } - dt(s, t, e) { + dt(i, t, e) { let n; e === void 0 ? e = t : t !== void 0 && (n = t); - const i = [], r = []; + const s = [], 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 }; + for (const l of i) s[o] = n ? n(l, o) : o, r[o] = e(l, o), o++; + return { values: r, keys: s }; } - render(s, t, e) { - return this.dt(s, t, e).values; + render(i, t, e) { + return this.dt(i, t, e).values; } - update(s, [t, e, n]) { - const i = Ie(s), { values: r, keys: o } = this.dt(t, e, n); - if (!Array.isArray(i)) return this.ut = o, r; + update(i, [t, e, n]) { + const s = Ie(i), { values: r, keys: o } = this.dt(t, e, n); + if (!Array.isArray(s)) return this.ut = o, r; const l = this.ut ??= [], a = []; - let p, u, d = 0, f = i.length - 1, h = 0, m = r.length - 1; - for (; d <= f && h <= m; ) if (i[d] === null) d++; - else if (i[f] === null) f--; - else if (l[d] === o[h]) a[h] = q(i[d], r[h]), d++, h++; - else if (l[f] === o[m]) a[m] = q(i[f], r[m]), f--, m--; - else if (l[d] === o[m]) a[m] = q(i[d], r[m]), z(s, a[m + 1], i[d]), d++, m--; - else if (l[f] === o[h]) a[h] = q(i[f], r[h]), z(s, i[d], i[f]), f--, h++; + let p, u, d = 0, f = s.length - 1, h = 0, m = r.length - 1; + for (; d <= f && h <= m; ) if (s[d] === null) d++; + else if (s[f] === null) f--; + else if (l[d] === o[h]) a[h] = k(s[d], r[h]), d++, h++; + else if (l[f] === o[m]) a[m] = k(s[f], r[m]), f--, m--; + else if (l[d] === o[m]) a[m] = k(s[d], r[m]), P(i, a[m + 1], s[d]), d++, m--; + else if (l[f] === o[h]) a[h] = k(s[f], r[h]), P(i, s[d], s[f]), f--, h++; else if (p === void 0 && (p = At(o, h, m), u = At(l, d, f)), p.has(l[d])) if (p.has(l[f])) { - const _ = u.get(o[h]), b = _ !== void 0 ? i[_] : null; - if (b === null) { - const j = z(s, i[d]); - q(j, r[h]), a[h] = j; - } else a[h] = q(b, r[h]), z(s, i[d], b), i[_] = null; + const _ = u.get(o[h]), x = _ !== void 0 ? s[_] : null; + if (x === null) { + const H = P(i, s[d]); + k(H, r[h]), a[h] = H; + } else a[h] = k(x, r[h]), P(i, s[d], x), s[_] = null; h++; - } else J(i[f]), f--; - else J(i[d]), d++; + } else X(s[f]), f--; + else X(s[d]), d++; for (; h <= m; ) { - const _ = z(s, a[m + 1]); - q(_, r[h]), a[h++] = _; + const _ = P(i, a[m + 1]); + k(_, r[h]), a[h++] = _; } for (; d <= f; ) { - const _ = i[d++]; - _ !== null && J(_); + const _ = s[d++]; + _ !== null && X(_); } - return this.ut = o, Pe(s, a), E; + return this.ut = o, Pe(i, a), N; } -}), St = /* @__PURE__ */ new Map(); -function H(s, t) { - const e = `${s ?? "local"}|${JSON.stringify(t)}`, n = St.get(e); +}), Nt = /* @__PURE__ */ new Map(); +function M(i, t) { + const e = `${i ?? "local"}|${JSON.stringify(t)}`, n = Nt.get(e); if (n) return n; - let i; + let s; try { - i = new Intl.DateTimeFormat("en-US", { + s = new Intl.DateTimeFormat("en-US", { ...t, - ...s ? { timeZone: s } : {} + ...i ? { timeZone: i } : {} }); } catch { - i = new Intl.DateTimeFormat("en-US", t); + s = new Intl.DateTimeFormat("en-US", t); } - return St.set(e, i), i; + return Nt.set(e, s), s; } -function Ue(s) { - const t = s?.config; +function De(i) { + const t = i?.config; if (!t || typeof t != "object") return; const e = t.time_zone; return typeof e == "string" && e.trim() ? e.trim() : void 0; } -function Et(s, t) { - return `${H(t, { weekday: "long" }).format(s)}, ${H(t, { month: "short", day: "numeric" }).format(s)}`; +function L(i, t) { + return `${M(t, { weekday: "long" }).format(i)}, ${M(t, { month: "short", day: "numeric" }).format(i)}`; } -function De(s, t) { - return H(t, { weekday: "long" }).format(s); +function Ue(i, t) { + return M(t, { weekday: "long" }).format(i); } -function He(s, t) { - const e = new Date(s); - return Number.isNaN(e.getTime()) ? "" : H(t, { +function je(i, t) { + const e = new Date(i); + return Number.isNaN(e.getTime()) ? "" : M(t, { hour: "numeric", minute: "2-digit", hour12: !0 }).format(e); } -function je(s) { - if (!s) +function He(i, t) { + return M(t, { + hour: "numeric", + minute: "2-digit", + hour12: !0 + }).format(i); +} +function Le(i) { + if (!i) return null; - const t = /^(\d{1,2}):(\d{2})$/.exec(s.trim()); + const t = /^(\d{1,2}):(\d{2})$/.exec(i.trim()); if (!t) return null; const e = Number(t[1]); @@ -1506,23 +1513,23 @@ function je(s) { const n = e >= 12 ? "PM" : "AM"; return `${e % 12 === 0 ? 12 : e % 12}:${t[2]} ${n}`; } -function k(s) { - return typeof s == "string" ? s.trim() : ""; +function $(i) { + return typeof i == "string" ? i.trim() : ""; } -function y(s, t) { - if (s == null || s === "") +function y(i, t) { + if (i == null || i === "") return t; - const e = typeof s == "number" ? s : Number(s); + const e = typeof i == "number" ? i : Number(i); return Number.isFinite(e) ? e : t; } -function x(s) { - return Math.max(0, Math.round(s)); +function v(i) { + return Math.max(0, Math.round(i)); } -function X(s) { - return s.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); +function Y(i) { + return i.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); } -function Re(s, t) { - const e = H(t, { +function Re(i, t) { + const e = M(t, { year: "numeric", month: "2-digit", day: "2-digit", @@ -1530,10 +1537,10 @@ function Re(s, t) { minute: "2-digit", second: "2-digit", hourCycle: "h23" - }).formatToParts(new Date(s)), n = (r) => { + }).formatToParts(new Date(i)), n = (r) => { const o = e.find((l) => l.type === r); return o ? Number(o.value) : Number.NaN; - }, i = Date.UTC( + }, s = Date.UTC( n("year"), n("month") - 1, n("day"), @@ -1541,51 +1548,92 @@ function Re(s, t) { n("minute"), n("second") ); - return Number.isFinite(i) ? i - s : 0; + return Number.isFinite(s) ? s - i : 0; } -function Nt(s, t) { - if (typeof s != "string") +function Tt(i, t) { + if (typeof i != "string") return null; - const e = s.split("-"); + const e = i.split("-"); if (e.length !== 3) return null; - const [n, i, r] = e.map((a) => Number(a)); - if (!n || !i || !r) + const [n, s, r] = e.map((a) => Number(a)); + if (!n || !s || !r) return null; if (!t) { - const a = new Date(n, i - 1, r); + const a = new Date(n, s - 1, r); return Number.isNaN(a.getTime()) ? null : a; } - const o = Date.UTC(n, i - 1, r), l = new Date(o - Re(o, t)); + const o = Date.UTC(n, s - 1, r), l = new Date(o - Re(o, t)); return Number.isNaN(l.getTime()) ? null : l; } -function Le() { - const s = window.location.pathname.split("/").filter(Boolean); - return s.length > 0 ? s[s.length - 1].toLowerCase() : ""; +function Be() { + const i = window.location.pathname.split("/").filter(Boolean); + return i.length > 0 ? i[i.length - 1].toLowerCase() : ""; } -function Be(s) { - if (!s || typeof s != "object") +function Fe(i) { + if (!i || typeof i != "object") return null; - const t = s, e = k(t.state).toLowerCase(); + const t = i, e = $(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); + const s = y(t.child_id, 0); return { id: n, - child_id: i > 0 ? i : null, - title: k(t.title) || "Quest", - icon: k(t.icon) || null, - window: k(t.window).toLowerCase(), - due_time: k(t.due_time) || null, + child_id: s > 0 ? s : null, + title: $(t.title) || "Quest", + icon: $(t.icon) || null, + window: $(t.window).toLowerCase(), + due_time: $(t.due_time) || null, state: e, overdue: t.overdue === !0, - completed_at: k(t.completed_at) || null + completed_at: $(t.completed_at) || null, + on_time: t.on_time === !0 ? !0 : t.on_time === !1 ? !1 : null }; } -const Tt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Fe = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", Mt = [-9, 6, -4], Qe = Pt` +const Qe = { + "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" +}, We = { + "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 Ze(i) { + const t = Qe[i]; + return t || i.charAt(0).toUpperCase() + i.slice(1); +} +function Ve(i, t) { + return We[i] ?? t; +} +const Et = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ge = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", Mt = [-9, 6, -4], Ke = Pt` * { box-sizing: border-box; } @@ -1645,7 +1693,7 @@ const Tt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Fe = "polygon(50% width: 96px; height: 110px; padding: 3px; - clip-path: ${v(Tt)}; + clip-path: ${b(Et)}; background: rgba(255, 255, 255, 0.22); } @@ -1656,7 +1704,7 @@ const Tt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Fe = "polygon(50% align-items: center; justify-content: center; padding-bottom: 32px; - clip-path: ${v(Tt)}; + clip-path: ${b(Et)}; background: var(--nq-p-crest); } @@ -1722,7 +1770,7 @@ const Tt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Fe = "polygon(50% justify-content: center; width: 44px; height: 44px; - clip-path: ${v(Fe)}; + clip-path: ${b(Ge)}; background: var(--nq-brand-gradient); } @@ -2268,6 +2316,170 @@ const Tt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Fe = "polygon(50% 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; + } `, R = c` -`, We = c``, Je = c` -`, Y = c``, tt = c` -`, Ze = c``, Xe = c` -`, Lt = c``, Rt = c` -`, Ve = c``, Ye = c` -`, Ge = c``, tn = c` -`, Ke = c``, en = c` -`, Je = c``, nn = c` -`, Xe = c``, sn = c` -`, Ye = { - icon: Ke, +`, rn = c``, on = { + icon: en, headline: "No adventurer chosen", body: "Open The Party and tap your crest to open your quest log." -}, tn = { - icon: Je, +}, an = { + icon: nn, headline: "NestQuest is not set up yet", body: "A parent needs to finish setting up NestQuest." -}, en = { - icon: Xe, +}, ln = { + icon: sn, 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: Ze }, - { key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: Lt }, - { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: Ve } + { key: "morning", name: "Morning", range: "Until 11:59 AM", icon: Xe }, + { key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: Rt }, + { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: Ye } ]; -class nn extends C { +class dn extends A { static properties = { hass: { attribute: !1 }, _config: { state: !0 }, _now: { state: !0 }, _confirm: { state: !0 }, _optimistic: { state: !0 }, - _toast: { state: !0 } + _toast: { state: !0 }, + _countdown: { state: !0 } }; - static styles = [v(Rt), Qe]; + static styles = [b(Lt), Ke]; hass; _config; _now = /* @__PURE__ */ new Date(); @@ -2426,10 +2650,12 @@ class nn extends C { * answered; the seal shows immediately and reverts on failure. */ _optimistic = /* @__PURE__ */ new Map(); _toast = null; + _countdown = null; _clockTimer; _idleTimer; _confirmTimer; _toastTimer; + _completeTimer; setConfig(t) { if (!t || typeof t != "object") throw new Error("Invalid configuration"); @@ -2441,10 +2667,13 @@ class nn extends C { connectedCallback() { super.connectedCallback(), 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(); + }, 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._logView().kind === "complete-day" ? (this._clearIdle(), this._armCompleteTimer()) : (this._clearCompleteTimer(), this._countdown = null, this._idleTimer === void 0 && this._armIdle()); } disconnectedCallback() { - this._stopClock(), this._clearIdle(), this._clearConfirmTimer(), this._clearToastTimer(), window.removeEventListener("pointerdown", this._onActivity, !0), window.removeEventListener("touchstart", this._onActivity, !0), window.removeEventListener("keydown", this._onActivity, !0), super.disconnectedCallback(); + 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(); @@ -2466,18 +2695,18 @@ class nn extends C { _renderMain(t) { switch (t.kind) { case "no-adventurer": - return this._renderNotice(Ye); + return this._renderNotice(on); case "not-set-up": - return this._renderNotice(tn); + return this._renderNotice(an); case "unreachable": - return this._renderNotice(en); + return this._renderNotice(ln); case "away": { const e = ["The quest log unlocks when they return."]; return t.returns && e.push(`Returns ${t.returns}`), c` ${this._renderHeader(t)}
    - ${Ge} + ${tn} ${t.name} is on travels ${e.map((n) => c`

    ${n}

    `)}
    @@ -2489,7 +2718,7 @@ class nn extends C { ${this._renderHeader(t)}
    - ${Lt} + ${Rt} No quests today

    ${`Nothing was posted for today, ${t.name}. Enjoy the day's rest!`} @@ -2497,20 +2726,38 @@ class nn extends C {

    `; - case "complete-day": + case "complete-day": { + const e = this._completeStats(), n = this._countdown ?? this._completeSeconds(); return c` - ${this._renderHeader(t)} -
    -
    - ${R} - Quest complete -

    - ${`Every quest is claimed, ${t.name}. The seal is set for today.`} -

    -

    Returning to The Party shortly.

    +
    + +

    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 c` ${this._renderHeader(t)} @@ -2531,11 +2778,14 @@ class nn extends C { return { kind: "not-set-up" }; if (e === "stale") return { kind: "unreachable" }; - const n = this._childName(t) ?? X(t); + const n = this._childName(t) ?? Y(t); if (!this._childPresent(t)) return { kind: "away", name: n, returns: this._awayReturns(t) }; - const i = this._state(`sensor.nestquest_${t}_quests_due_today`); - return x(y(i?.state, 0)) === 0 ? { kind: "empty-day", name: n } : this._remaining() === 0 ? { kind: "complete-day", name: n } : { kind: "normal" }; + const s = this._state(`binary_sensor.nestquest_${t}_all_done`); + if (s && String(s.state ?? "").trim().toLowerCase() === "on") + return { kind: "complete-day", name: n }; + const r = this._state(`sensor.nestquest_${t}_quests_due_today`); + return v(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 @@ -2552,19 +2802,19 @@ class nn extends C { const n = this._state(`sensor.nestquest_${t}_quests_due_today`)?.attributes?.present; if (typeof n == "boolean") return n; - const i = this._state( + const s = this._state( `binary_sensor.nestquest_${t}_present_today` ); - return i ? String(i.state ?? "").trim().toLowerCase() === "on" : !0; + return s ? String(s.state ?? "").trim().toLowerCase() === "on" : !0; } _awayReturns(t) { const e = this._timeZone(), n = this._state( `binary_sensor.nestquest_${t}_present_today` - ), i = Nt( + ), s = Tt( n?.attributes?.next_present, e ); - return i ? Et(i, e) : null; + return s ? L(s, e) : null; } _renderNotice(t) { return c` @@ -2578,17 +2828,17 @@ class nn extends C { `; } _timeZone() { - return Ue(this.hass); + return De(this.hass); } _state(t) { const n = this.hass?.states?.[t]; return n && typeof n == "object" ? n : null; } _childSlug() { - return Le(); + return Be(); } _boardPath() { - return k(this._config?.board_path).replace(/\/+$/, ""); + return $(this._config?.board_path).replace(/\/+$/, ""); } _idleSeconds() { const t = y(this._config?.idle_return_seconds, 40); @@ -2598,6 +2848,10 @@ class nn extends C { 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; @@ -2607,8 +2861,8 @@ class nn extends C { _childCounts(t) { const e = this._state(`sensor.nestquest_${t}_quests_due_today`), n = this._state( `sensor.nestquest_${t}_quests_completed_today` - ), i = x(y(e?.state, 0)), r = x(y(n?.state, 0)); - return { due: i, completed: r }; + ), s = v(y(e?.state, 0)), r = v(y(n?.state, 0)); + return { due: s, completed: r }; } _instances() { const t = this._childSlug(); @@ -2617,21 +2871,21 @@ class nn extends C { const e = this._state(`sensor.nestquest_${t}_quests_due_today`)?.attributes?.instances; if (!Array.isArray(e)) return []; - const n = e.map(Be).filter((r) => r !== null); + const n = e.map(Fe).filter((r) => r !== null); if (this._optimistic.size === 0) return n; - let i = !1; + let s = !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; + this._optimistic.delete(r.id), s = !0; continue; } r.state = "completed", r.overdue = !1, r.completed_at = o; } } - return i && (this._optimistic = new Map(this._optimistic)), n; + return s && (this._optimistic = new Map(this._optimistic)), n; } _remaining() { const t = this._childSlug(); @@ -2641,9 +2895,9 @@ class nn extends C { `sensor.nestquest_${t}_quests_remaining_today` ); if (e) - return x(y(e.state, 0)); - const { due: n, completed: i } = this._childCounts(t); - return x(n - i); + return v(y(e.state, 0)); + const { due: n, completed: s } = this._childCounts(t); + return v(n - s); } _partyCounts() { const t = this._state( @@ -2653,24 +2907,39 @@ class nn extends C { ); if (t || e) return { - completed: x(y(e?.state, 0)), - due: x(y(t?.state, 0)) + completed: v(y(e?.state, 0)), + due: v(y(t?.state, 0)) }; - const n = this._childSlug(), { due: i, completed: r } = this._childCounts(n); - return { completed: r, due: i }; + const n = this._childSlug(), { due: s, completed: r } = this._childCounts(n); + return { completed: r, due: s }; + } + _completeStats() { + const t = this._childCounts(this._childSlug()).completed, e = this._instances().filter( + (r) => r.on_time === !0 + ).length, n = v(t - e), s = this._partyCounts(); + return { + claimed: t, + onTime: e, + late: n, + party: `${s.completed}/${s.due}` + }; + } + _completeSub(t) { + const e = L(this._now, this._timeZone()), { claimed: n, onTime: s, late: r } = this._completeStats(); + return n === 0 ? `${t} sealed the day on ${e}.` : r === 0 ? `${t} claimed every quest on ${e} — all on time.` : s === 0 ? `${t} claimed every quest on ${e} — all late.` : `${t} claimed every quest on ${e} — ${s} on time, ${r} late.`; } _otherChildren() { const t = this.hass?.states; if (!t) return []; - const e = this._childSlug(), n = this._timeZone(), i = []; + const e = this._childSlug(), n = this._timeZone(), s = []; 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 = X(l); + 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; @@ -2682,18 +2951,18 @@ class nn extends C { } const d = this._state( `sensor.nestquest_${l}_quests_completed_today` - ), f = x(y(t[r]?.state, 0)), h = x(y(d?.state, 0)); + ), f = v(y(t[r]?.state, 0)), h = v(y(d?.state, 0)); let m = null; if (!u) { const _ = this._state( `binary_sensor.nestquest_${l}_present_today` - ), b = Nt( + ), x = Tt( _?.attributes?.next_present, n ); - m = b ? De(b, n) : null; + m = x ? Ue(x, n) : null; } - i.push({ + s.push({ slug: l, name: p, present: u, @@ -2702,10 +2971,10 @@ class nn extends C { returnsWeekday: m }); } - return i.sort((r, o) => r.name.localeCompare(o.name)); + return s.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 ? X(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(); + const e = this._childSlug(), n = t.kind === "away", s = n ? t.name : this._childName(e) ?? (e ? Y(e) : null), { due: r, completed: o } = this._childCounts(e), l = this._remaining(), a = this._partyCounts(), p = s ? `${s}'s Quest Log` : "Quest Log", u = (s?.charAt(0) || "?").toUpperCase(); return c`
    - ${i.length}/${e.length} + ${s.length}/${e.length}
    - ${Ct( + ${St( n, (l) => l.id, (l) => this._renderQuest(l, !1, 0) )} - ${Ct( - i, + ${St( + s, (l) => l.id, (l, a) => this._renderQuest(l, !0, a) )} @@ -2794,10 +3063,10 @@ class nn extends C { } _renderQuest(t, e, n) { if (e) { - const o = Mt[n % Mt.length], l = t.completed_at ? He(t.completed_at, this._timeZone()) : ""; + const o = Mt[n % Mt.length], l = t.completed_at ? je(t.completed_at, this._timeZone()) : ""; return c`
    - +
    ${t.title} @@ -2814,11 +3083,11 @@ class nn extends C {
    `; } - const i = je(t.due_time), r = t.overdue ? { - text: i ? `Overdue · due ${i}` : "Overdue", + const s = Le(t.due_time), r = t.overdue ? { + text: s ? `Overdue · due ${s}` : "Overdue", late: !0 } : { - text: i ? `Due by ${i}` : "Due today", + text: s ? `Due by ${s}` : "Due today", late: !1 }; return c` @@ -2833,7 +3102,7 @@ class nn extends C { (o.key === "Enter" || o.key === " ") && (o.preventDefault(), this._openConfirm(t.id)); }} > - +
    ${t.title} ${r.text} @@ -2879,7 +3148,7 @@ class nn extends C { @click=${(n) => n.stopPropagation()} >
    - +
    ${e?.name ?? "Quest"} @@ -2897,7 +3166,7 @@ class nn extends C { type="button" @click=${() => this._closeConfirm()} > - ${We} + ${Je} Not yet
    `; - case "complete-day": + case "complete-day": { + const stats = this._completeStats(); + const remaining = this._countdown ?? this._completeSeconds(); return html` - ${this._renderHeader(view)} -
    -
    - ${ICON_CHECK} - Quest complete -

    - ${`Every quest is claimed, ${view.name}. The seal is set for today.`} -

    -

    Returning to The Party shortly.

    +
    + +

    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)} @@ -1248,6 +1533,10 @@ export class NestQuestQuestLogCard extends LitElement { 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) { @@ -1342,6 +1631,11 @@ export class NestQuestQuestLogCard extends LitElement { 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; @@ -1435,6 +1729,41 @@ export class NestQuestQuestLogCard extends LitElement { 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 @@ -1897,6 +2226,112 @@ export class NestQuestQuestLogCard extends LitElement { } } + 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) { @@ -1914,7 +2349,9 @@ export class NestQuestQuestLogCard extends LitElement { } private _onActivity = (): void => { - this._armIdle(); + if (this._logView().kind !== "complete-day") { + this._armIdle(); + } if (this._confirm !== null) { this._armConfirmTimer(); } From 030ba83b14a243daea9dec7ca4b4ef3597dee455 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Sun, 20 Sep 2026 19:17:52 -0400 Subject: [PATCH 43/46] Subscribe panel cards to live NestQuest events (#130) --- .../nestquest/www/nestquest-cards.js | 981 ++++++++++-------- frontend/src/cards/party-board.ts | 100 ++ frontend/src/cards/quest-log.ts | 97 ++ 3 files changed, 735 insertions(+), 443 deletions(-) diff --git a/custom_components/nestquest/www/nestquest-cards.js b/custom_components/nestquest/www/nestquest-cards.js index a6aa494..ede7aab 100644 --- a/custom_components/nestquest/www/nestquest-cards.js +++ b/custom_components/nestquest/www/nestquest-cards.js @@ -17,55 +17,55 @@ let zt = class { return this.cssText; } }; -const b = (i) => new zt(typeof i == "string" ? i : i + "", void 0, it), Pt = (i, ...t) => { - const e = i.length === 1 ? i[0] : t.reduce((n, s, r) => n + ((o) => { +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."); - })(s) + i[r + 1], i[0]); - return new zt(e, i, it); -}, Bt = (i, t) => { - if (nt) i.adoptedStyleSheets = t.map((e) => e instanceof CSSStyleSheet ? e : e.styleSheet); + })(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"), s = B.litNonce; - s !== void 0 && n.setAttribute("nonce", s), n.textContent = e.cssText, i.appendChild(n); + const n = document.createElement("style"), i = B.litNonce; + i !== void 0 && n.setAttribute("nonce", i), n.textContent = e.cssText, s.appendChild(n); } -}, dt = nt ? (i) => i : (i) => i instanceof CSSStyleSheet ? ((t) => { +}, ct = nt ? (s) => s : (s) => s instanceof CSSStyleSheet ? ((t) => { let e = ""; for (const n of t.cssRules) e += n.cssText; - return b(e); -})(i) : i; -const { is: Ft, defineProperty: Qt, getOwnPropertyDescriptor: Wt, getOwnPropertyNames: Zt, getOwnPropertySymbols: Vt, getPrototypeOf: Gt } = Object, W = globalThis, ct = W.trustedTypes, Kt = ct ? ct.emptyScript : "", Jt = W.reactiveElementPolyfillSupport, I = (i, t) => i, et = { toAttribute(i, t) { + 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: - i = i ? Kt : null; + s = s ? Kt : null; break; case Object: case Array: - i = i == null ? i : JSON.stringify(i); + s = s == null ? s : JSON.stringify(s); } - return i; -}, fromAttribute(i, t) { - let e = i; + return s; +}, fromAttribute(s, t) { + let e = s; switch (t) { case Boolean: - e = i !== null; + e = s !== null; break; case Number: - e = i === null ? null : Number(i); + e = s === null ? null : Number(s); break; case Object: case Array: try { - e = JSON.parse(i); + e = JSON.parse(s); } catch { e = null; } } return e; -} }, It = (i, t) => !Ft(i, t), pt = { attribute: !0, type: String, converter: et, reflect: !1, useDefault: !1, hasChanged: It }; +} }, 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 T = class extends HTMLElement { +let E = class extends HTMLElement { static addInitializer(t) { this._$Ei(), (this.l ??= []).push(t); } @@ -74,18 +74,18 @@ let T = class extends HTMLElement { } 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(), s = this.getPropertyDescriptor(t, n, e); - s !== void 0 && Qt(this.prototype, t, s); + 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: s, set: r } = Wt(this.prototype, t) ?? { get() { + const { get: i, set: r } = Wt(this.prototype, t) ?? { get() { return this[e]; }, set(o) { this[e] = o; } }; - return { get: s, set(o) { - const l = s?.call(this); + return { get: i, set(o) { + const l = i?.call(this); r?.call(this, o), this.requestUpdate(t, l, n); }, configurable: !0, enumerable: !0 }; } @@ -94,24 +94,24 @@ let T = class extends HTMLElement { } static _$Ei() { if (this.hasOwnProperty(I("elementProperties"))) return; - const t = Gt(this); + 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), ...Vt(e)]; - for (const s of n) this.createProperty(s, e[s]); + 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, s] of e) this.elementProperties.set(n, s); + 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 s = this._$Eu(e, n); - s !== void 0 && this._$Eh.set(s, e); + const i = this._$Eu(e, n); + i !== void 0 && this._$Eh.set(i, e); } this.elementStyles = this.finalizeStyles(this.styles); } @@ -119,8 +119,8 @@ let T = class extends HTMLElement { const e = []; if (Array.isArray(t)) { const n = new Set(t.flat(1 / 0).reverse()); - for (const s of n) e.unshift(dt(s)); - } else t !== void 0 && e.push(dt(t)); + for (const i of n) e.unshift(ct(i)); + } else t !== void 0 && e.push(ct(t)); return e; } static _$Eu(t, e) { @@ -160,31 +160,31 @@ let T = class extends HTMLElement { this._$AK(t, n); } _$ET(t, e) { - const n = this.constructor.elementProperties.get(t), s = this.constructor._$Eu(t, n); - if (s !== void 0 && n.reflect === !0) { + 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(s) : this.setAttribute(s, r), this._$Em = null; + this._$Em = t, r == null ? this.removeAttribute(i) : this.setAttribute(i, r), this._$Em = null; } } _$AK(t, e) { - const n = this.constructor, s = n._$Eh.get(t); - if (s !== void 0 && this._$Em !== s) { - const r = n.getPropertyOptions(s), o = typeof r.converter == "function" ? { fromAttribute: r.converter } : r.converter?.fromAttribute !== void 0 ? r.converter : et; - this._$Em = s; + 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[s] = l ?? this._$Ej?.get(s) ?? l, this._$Em = null; + this[i] = l ?? this._$Ej?.get(i) ?? l, this._$Em = null; } } - requestUpdate(t, e, n, s = !1, r) { + requestUpdate(t, e, n, i = !1, r) { if (t !== void 0) { const o = this.constructor; - if (s === !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; + 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: s, 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)), s === !0 && this._$Em !== t && (this._$Eq ??= /* @__PURE__ */ new Set()).add(t)); + 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; @@ -203,13 +203,13 @@ let T = class extends HTMLElement { if (!this.isUpdatePending) return; if (!this.hasUpdated) { if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) { - for (const [s, r] of this._$Ep) this[s] = r; + 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 [s, r] of n) { - const { wrapped: o } = r, l = this[s]; - o !== !0 || this._$AL.has(s) || l === void 0 || this.C(s, void 0, r, l); + 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; @@ -246,54 +246,54 @@ let T = class extends HTMLElement { firstUpdated(t) { } }; -T.elementStyles = [], T.shadowRootOptions = { mode: "open" }, T[I("elementProperties")] = /* @__PURE__ */ new Map(), T[I("finalized")] = /* @__PURE__ */ new Map(), Jt?.({ ReactiveElement: T }), (W.reactiveElementVersions ??= []).push("2.1.2"); -const st = globalThis, ht = (i) => i, F = st.trustedTypes, ut = F ? F.createPolicy("lit-html", { createHTML: (i) => i }) : void 0, Dt = "$lit$", w = `lit$${Math.random().toFixed(9).slice(2)}$`, Ut = "?" + w, Xt = `<${Ut}>`, S = document, D = () => S.createComment(""), U = (i) => i === null || typeof i != "object" && typeof i != "function", rt = Array.isArray, Yt = (i) => rt(i) || typeof i?.[Symbol.iterator] == "function", G = `[ -\f\r]`, z = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, ft = /-->/g, mt = />/g, q = RegExp(`>|${G}(?:([^\\s"'>=/]+)(${G}*=${G}*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`, "g"), gt = /'/g, _t = /"/g, jt = /^(?:script|style|textarea|title)$/i, te = (i) => (t, ...e) => ({ _$litType$: i, strings: t, values: e }), c = te(1), N = /* @__PURE__ */ Symbol.for("lit-noChange"), g = /* @__PURE__ */ Symbol.for("lit-nothing"), yt = /* @__PURE__ */ new WeakMap(), C = S.createTreeWalker(S, 129); -function Ht(i, t) { - if (!rt(i) || !i.hasOwnProperty("raw")) throw Error("invalid template strings array"); +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 = (i, t) => { - const e = i.length - 1, n = []; - let s, r = t === 2 ? "" : t === 3 ? "" : "", o = z; +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 = i[l]; - let p, u, d = -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 ? (jt.test(u[2]) && (s = RegExp("" ? (o = s ?? z, d = -1) : u[1] === void 0 ? d = -2 : (d = 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, s = void 0); - const h = o === q && i[l + 1].startsWith("/>") ? " " : ""; - r += o === z ? a + Xt : d >= 0 ? (n.push(p), a.slice(0, d) + Dt + a.slice(d) + w + h) : a + w + (d === -2 ? l : h); + 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 [Ht(i, r + (i[e] || "") + (t === 2 ? "" : t === 3 ? "" : "")), n]; + return [Dt(s, r + (s[e] || "") + (t === 2 ? "" : t === 3 ? "" : "")), n]; }; -class j { +class R { constructor({ strings: t, _$litType$: e }, n) { - let s; + 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 = j.createElement(p, n), C.currentNode = this.el.content, e === 2 || e === 3) { - const d = this.el.content.firstChild; - d.replaceWith(...d.childNodes); + 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 (; (s = C.nextNode()) !== null && a.length < l; ) { - if (s.nodeType === 1) { - if (s.hasAttributes()) for (const d of s.getAttributeNames()) if (d.endsWith(Dt)) { - const f = u[o++], h = s.getAttribute(d).split(w), 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 }), s.removeAttribute(d); - } else d.startsWith(w) && (a.push({ type: 6, index: r }), s.removeAttribute(d)); - if (jt.test(s.tagName)) { - const d = s.textContent.split(w), f = d.length - 1; + 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) { - s.textContent = F ? F.emptyScript : ""; - for (let h = 0; h < f; h++) s.append(d[h], D()), C.nextNode(), a.push({ type: 2, index: ++r }); - s.append(d[f], D()); + 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 (s.nodeType === 8) if (s.data === Ut) a.push({ type: 2, index: r }); + } else if (i.nodeType === 8) if (i.data === Ut) a.push({ type: 2, index: r }); else { - let d = -1; - for (; (d = s.data.indexOf(w, d + 1)) !== -1; ) a.push({ type: 7, index: r }), d += w.length - 1; + let c = -1; + for (; (c = i.data.indexOf($, c + 1)) !== -1; ) a.push({ type: 7, index: r }), c += $.length - 1; } r++; } @@ -303,11 +303,11 @@ class j { return n.innerHTML = t, n; } } -function E(i, t, e = i, n) { - if (t === N) return t; - let s = n !== void 0 ? e._$Co?.[n] : e._$Cl; +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 s?.constructor !== r && (s?._$AO?.(!1), r === void 0 ? s = void 0 : (s = new r(i), s._$AT(i, e, n)), n !== void 0 ? (e._$Co ??= [])[n] = s : e._$Cl = s), s !== void 0 && (t = E(i, s._$AS(i, t.values), s, n)), t; + 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) { @@ -320,8 +320,8 @@ class ne { return this._$AM._$AU; } u(t) { - const { el: { content: e }, parts: n } = this._$AD, s = (t?.creationScope ?? S).importNode(e, !0); - C.currentNode = s; + 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) { @@ -330,7 +330,7 @@ class ne { } o !== a?.index && (r = C.nextNode(), o++); } - return C.currentNode = S, s; + return C.currentNode = S, i; } p(t) { let e = 0; @@ -341,8 +341,8 @@ class O { get _$AU() { return this._$AM?._$AU ?? this._$Cv; } - constructor(t, e, n, s) { - this.type = 2, this._$AH = g, this._$AN = void 0, this._$AA = t, this._$AB = e, this._$AM = n, this.options = s, this._$Cv = s?.isConnected ?? !0; + 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; @@ -356,7 +356,7 @@ class O { return this._$AB; } _$AI(t, e = this) { - t = E(this, t, e), U(t) ? t === g || t == null || t === "" ? (this._$AH !== g && this._$AR(), this._$AH = g) : t !== this._$AH && t !== N && this._(t) : t._$litType$ !== void 0 ? this.$(t) : t.nodeType !== void 0 ? this.T(t) : Yt(t) ? this.k(t) : this._(t); + 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); @@ -368,23 +368,23 @@ class O { 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, s = typeof n == "number" ? this._$AC(t) : (n.el === void 0 && (n.el = j.createElement(Ht(n.h, n.h[0]), this.options)), n); - if (this._$AH?._$AD === s) this._$AH.p(e); + 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(s, this), o = r.u(this.options); + 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 j(t)), e; + 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, s = 0; - for (const r of t) s === e.length ? e.push(n = new O(this.O(D()), this.O(D()), this, this.options)) : n = e[s], n._$AI(r), s++; - s < e.length && (this._$AR(n && n._$AB.nextSibling, s), e.length = s); + 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; ) { @@ -403,19 +403,19 @@ class Z { get _$AU() { return this._$AM._$AU; } - constructor(t, e, n, s, r) { - this.type = 1, this._$AH = g, this._$AN = void 0, this.element = t, this.name = e, this._$AM = s, 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; + 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, s) { + _$AI(t, e = this, n, i) { const r = this.strings; let o = !1; - if (r === void 0) t = E(this, t, e, 0), o = !U(t) || t !== this._$AH && t !== N, o && (this._$AH = t); + 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 = E(this, l[n + a], e, a), p === N && (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; + 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 && !s && this.j(t); + o && !i && this.j(t); } j(t) { t === g ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t ?? ""); @@ -438,13 +438,13 @@ class se extends Z { } } class re extends Z { - constructor(t, e, n, s, r) { - super(t, e, n, s, r), this.type = 5; + constructor(t, e, n, i, r) { + super(t, e, n, i, r), this.type = 5; } _$AI(t, e = this) { - if ((t = E(this, t, e, 0) ?? g) === N) return; - const n = this._$AH, s = t === g && n !== g || t.capture !== n.capture || t.once !== n.once || t.passive !== n.passive, r = t !== g && (n === g || s); - s && this.element.removeEventListener(this.name, this, n), r && this.element.addEventListener(this.name, this, t), this._$AH = t; + 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); @@ -458,22 +458,22 @@ class oe { return this._$AM._$AU; } _$AI(t) { - E(this, t); + N(this, t); } } const ae = { I: O }, le = st.litHtmlPolyfillSupport; -le?.(j, O), (st.litHtmlVersions ??= []).push("3.3.3"); -const de = (i, t, e) => { +le?.(R, O), (st.litHtmlVersions ??= []).push("3.3.3"); +const ce = (s, t, e) => { const n = e?.renderBefore ?? t; - let s = n._$litPart$; - if (s === void 0) { + let i = n._$litPart$; + if (i === void 0) { const r = e?.renderBefore ?? null; - n._$litPart$ = s = new O(t.insertBefore(D(), r), r, void 0, e ?? {}); + n._$litPart$ = i = new O(t.insertBefore(L(), r), r, void 0, e ?? {}); } - return s._$AI(i), s; + return i._$AI(s), i; }; const ot = globalThis; -let A = class extends T { +let A = class extends E { constructor() { super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0; } @@ -483,7 +483,7 @@ let A = class extends T { } update(t) { const e = this.render(); - this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t), this._$Do = de(e, this.renderRoot, this.renderOptions); + 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); @@ -492,51 +492,56 @@ let A = class extends T { super.disconnectedCallback(), this._$Do?.setConnected(!1); } render() { - return N; + return T; } }; A._$litElement$ = !0, A.finalized = !0, ot.litElementHydrateSupport?.({ LitElement: A }); -const ce = ot.litElementPolyfillSupport; -ce?.({ LitElement: A }); +const de = ot.litElementPolyfillSupport; +de?.({ LitElement: A }); (ot.litElementVersions ??= []).push("4.2.2"); -const Lt = ':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(i) { - window.customCards = window.customCards ?? [], window.customCards.push(i); +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 vt = /* @__PURE__ */ new Map(); -function Q(i, t) { - const e = `${i ?? "local"}|${JSON.stringify(t)}`, n = vt.get(e); +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 s; + let i; try { - s = new Intl.DateTimeFormat("en-US", { + i = new Intl.DateTimeFormat("en-US", { ...t, - ...i ? { timeZone: i } : {} + ...s ? { timeZone: s } : {} }); } catch { - s = new Intl.DateTimeFormat("en-US", t); + i = new Intl.DateTimeFormat("en-US", t); } - return vt.set(e, s), s; + return bt.set(e, i), i; } -function pe(i) { - const t = i?.config; +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(i, t) { - return `${Q(t, { weekday: "long" }).format(i)}, ${Q(t, { month: "short", day: "numeric" }).format(i)}`; +function K(s, t) { + return `${Q(t, { weekday: "long" }).format(s)}, ${Q(t, { month: "short", day: "numeric" }).format(s)}`; } -function he(i, t) { +function ue(s, t) { return Q(t, { hour: "numeric", minute: "2-digit", hour12: !0 - }).format(i); + }).format(s); } -const ue = { +const fe = { "clear-night": "Clear", cloudy: "Cloudy", exceptional: "Clear", @@ -552,7 +557,7 @@ const ue = { sunny: "Sunny", windy: "Windy", "windy-variant": "Windy" -}, fe = { +}, me = { "clear-night": "Clear night skies", cloudy: "Grey skies today", exceptional: "A striking day", @@ -569,25 +574,25 @@ const ue = { windy: "A blustery day", "windy-variant": "A blustery day" }; -function bt(i) { - return typeof i == "string" ? i.trim() : ""; +function vt(s) { + return typeof s == "string" ? s.trim() : ""; } -function J(i, t) { - if (i == null || i === "") +function J(s, t) { + if (s == null || s === "") return t; - const e = typeof i == "number" ? i : Number(i); + const e = typeof s == "number" ? s : Number(s); return Number.isFinite(e) ? e : t; } -function xt(i) { - return Number.isFinite(i) ? Math.min(100, Math.max(0, i)) : 0; +function xt(s) { + return Number.isFinite(s) ? Math.min(100, Math.max(0, s)) : 0; } -function $t(i) { - return Math.max(0, Math.round(i)); +function wt(s) { + return Math.max(0, Math.round(s)); } -function me(i) { - return i.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); +function ge(s) { + return s.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); } -function ge(i, t) { +function _e(s, t) { const e = Q(t, { year: "numeric", month: "2-digit", @@ -596,10 +601,10 @@ function ge(i, t) { minute: "2-digit", second: "2-digit", hourCycle: "h23" - }).formatToParts(new Date(i)), n = (r) => { + }).formatToParts(new Date(s)), n = (r) => { const o = e.find((l) => l.type === r); return o ? Number(o.value) : Number.NaN; - }, s = Date.UTC( + }, i = Date.UTC( n("year"), n("month") - 1, n("day"), @@ -607,32 +612,32 @@ function ge(i, t) { n("minute"), n("second") ); - return Number.isFinite(s) ? s - i : 0; + return Number.isFinite(i) ? i - s : 0; } -function _e(i, t) { - if (typeof i != "string") +function ye(s, t) { + if (typeof s != "string") return null; - const e = i.split("-"); + const e = s.split("-"); if (e.length !== 3) return null; - const [n, s, r] = e.map((a) => Number(a)); - if (!n || !s || !r) + const [n, i, r] = e.map((a) => Number(a)); + if (!n || !i || !r) return null; if (!t) { - const a = new Date(n, s - 1, r); + const a = new Date(n, i - 1, r); return Number.isNaN(a.getTime()) ? null : a; } - const o = Date.UTC(n, s - 1, r), l = new Date(o - ge(o, t)); + const o = Date.UTC(n, i - 1, r), l = new Date(o - _e(o, t)); return Number.isNaN(l.getTime()) ? null : l; } -function ye(i) { - const t = ue[i]; - return t || i.charAt(0).toUpperCase() + i.slice(1); +function be(s) { + const t = fe[s]; + return t || s.charAt(0).toUpperCase() + s.slice(1); } -function ve(i, t) { - return fe[i] ?? t; +function ve(s, t) { + return me[s] ?? t; } -const wt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", be = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", xe = Pt` +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; } @@ -704,7 +709,7 @@ const wt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", be = "polygon(50% justify-content: center; width: 34px; height: 34px; - clip-path: ${b(be)}; + clip-path: ${v(xe)}; background: var(--nq-brand-gradient); } @@ -798,7 +803,7 @@ const wt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", be = "polygon(50% width: 188px; height: 214px; padding: 4px; - clip-path: ${b(wt)}; + clip-path: ${v($t)}; background: rgba(255, 255, 255, 0.22); } @@ -809,7 +814,7 @@ const wt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", be = "polygon(50% align-items: center; justify-content: center; padding-bottom: 70px; - clip-path: ${b(wt)}; + clip-path: ${v($t)}; background: var(--nq-p-crest); } @@ -1027,7 +1032,7 @@ const wt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", be = "polygon(50% font-weight: 800; line-height: 1; } -`, $e = c` -`, we = c``, qe = d` -`, qe = c``, ke = d` -`, ke = c``, Ce = d` -`, Ce = c``, Ae = d` `, qt = { - icon: ke, + icon: Ce, headline: "NestQuest is not set up yet", body: "A parent needs to finish setting up NestQuest before the party can gather." -}, Ae = { - icon: Ce, +}, Se = { + icon: Ae, headline: "The records cannot be reached", body: "The party's records are quiet right now. NestQuest will return shortly." -}, Se = c` `; -class Ne extends A { +class Ee extends A { static properties = { hass: { attribute: !1 }, _config: { state: !0 }, _now: { state: !0 } }; - static styles = [b(Lt), xe]; + 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._now = /* @__PURE__ */ new Date(), this._clockTimer = window.setInterval(() => { + super.connectedCallback(), this._subscribeLive(), this._now = /* @__PURE__ */ new Date(), this._clockTimer = window.setInterval(() => { this._now = /* @__PURE__ */ new Date(); }, 1e3); } + updated() { + this._subscribeLive(); + } disconnectedCallback() { - this._clockTimer !== void 0 && (window.clearInterval(this._clockTimer), this._clockTimer = void 0), super.disconnectedCallback(); + this._unsubscribeLive(), this._clockTimer !== void 0 && (window.clearInterval(this._clockTimer), this._clockTimer = void 0), super.disconnectedCallback(); } render() { const t = this._boardNotice(); - return c` + return d`
    @@ -1152,7 +1201,7 @@ class Ne extends A {

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

    - ${t ? this._renderNotice(t) : c` + ${t ? this._renderNotice(t) : d`
    ${this._plates().map((e) => this._renderPlate(e))}
    @@ -1164,7 +1213,7 @@ class Ne extends A { `; } _timeZone() { - return pe(this.hass); + return he(this.hass); } _state(t) { const n = this.hass?.states?.[t]; @@ -1182,13 +1231,13 @@ class Ne extends A { return this._childSlugs().map((t) => this._plate(t)); } _plate(t) { - const e = this._dueSensorResolution(t), n = this._state(`sensor.nestquest_${t}_quests_due_today`), s = this._state( + 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 = $t(J(n?.state, 0)), p = $t(J(s?.state, 0)), u = r ? J(r.state, Number.NaN) : Number.NaN, d = Number.isFinite(u) ? xt(u) : a > 0 ? xt(p / a * 100) : 0; + ), 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"); @@ -1197,14 +1246,14 @@ class Ne extends A { if (typeof _ == "string" && _.trim()) m = _.trim(); else { - const V = o?.attributes?.child_name; - typeof V == "string" && V.trim() && (m = V.trim()); + const G = o?.attributes?.child_name; + typeof G == "string" && G.trim() && (m = G.trim()); } - m || (m = me(t)); - const x = _e( + m || (m = ge(t)); + const x = ye( o?.attributes?.next_present, this._timeZone() - ), H = x ? `Returns ${K(x, this._timeZone())}` : null; + ), D = x ? `Returns ${K(x, this._timeZone())}` : null; return { slug: t, name: m, @@ -1212,8 +1261,8 @@ class Ne extends A { present: e === null ? f : !1, completed: p, due: a, - pct: d, - returns: H, + pct: c, + returns: D, unresolved: e }; } @@ -1230,10 +1279,10 @@ class Ne extends A { } _boardNotice() { const t = this._plates(); - return t.length === 0 ? qt : t.every((e) => e.unresolved !== null) ? t.some((e) => e.unresolved === "stale") ? Ae : qt : null; + return t.length === 0 ? qt : t.every((e) => e.unresolved !== null) ? t.some((e) => e.unresolved === "stale") ? Se : qt : null; } _renderNotice(t) { - return c` + return d`
    ${t.icon} @@ -1244,7 +1293,7 @@ class Ne extends A { `; } _renderPlate(t) { - return t.unresolved ? c` + return t.unresolved ? d`
    @@ -1253,13 +1302,13 @@ class Ne extends A { ${t.name} - ${qe} + ${ke} Unknown —
    - ` : t.present ? c` + ` : t.present ? d` - ` : c` + ` : d`
    @@ -1292,7 +1341,7 @@ class Ne extends A { ${t.name} - ${we} + ${qe} On travels ${t.returns ?? "Returns —"} @@ -1301,11 +1350,11 @@ class Ne extends A { `; } _openQuestLog(t) { - const e = bt(this._config?.quest_log_path); + 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 = bt(this._config?.weather_entity); + const t = vt(this._config?.weather_entity); if (!t) return null; const e = this._state(t); @@ -1314,9 +1363,9 @@ class Ne extends A { const n = String(e.state ?? "").trim(); if (!n || n === "unavailable" || n === "unknown") return null; - const s = e.attributes ?? {}; + const i = e.attributes ?? {}; let r = null, o = null; - const l = s.forecast; + const l = i.forecast; if (Array.isArray(l) && l.length > 0) { const a = l[0]; if (a && typeof a == "object") { @@ -1326,7 +1375,7 @@ class Ne extends A { } return { condition: n, - temperature: this._optionalNumber(s.temperature), + temperature: this._optionalNumber(i.temperature), high: r, low: o }; @@ -1340,18 +1389,18 @@ class Ne extends A { _renderDock() { const t = this._dockWeather(); if (!t) - return c` + return d`
    ${K(this._now, this._timeZone())} - ${he(this._now, this._timeZone())} + ${ue(this._now, this._timeZone())}
    `; - const e = ye(t.condition), n = ve(t.condition, e), s = t.temperature === null ? g : c`${Math.round(t.temperature)}°`, r = t.high === null || t.low === null ? null : `${Math.round(t.high)}° / ${Math.round(t.low)}°`; - return c` + 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`
    - ${Se} - ${s} + ${Te} + ${i} ${r === null ? e : `${e} · ${r}`} @@ -1362,14 +1411,14 @@ class Ne extends A { `; } } -customElements.define("nestquest-party-board-card", Ne); +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 Te = { CHILD: 2 }, Ee = (i) => (...t) => ({ _$litDirective$: i, values: t }); -let Me = class { +const Ne = { CHILD: 2 }, Me = (s) => (...t) => ({ _$litDirective$: s, values: t }); +let Oe = class { constructor(t) { } get _$AU() { @@ -1385,126 +1434,131 @@ let Me = class { return this.render(...e); } }; -const { I: Oe } = ae, kt = (i) => i, Ct = () => document.createComment(""), P = (i, t, e) => { - const n = i._$AA.parentNode, s = t === void 0 ? i._$AB : t._$AA; +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(), s), o = n.insertBefore(Ct(), s); - e = new Oe(r, o, i, i.options); + 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 !== i; + const r = e._$AB.nextSibling, o = e._$AM, l = o !== s; if (l) { let a; - e._$AQ?.(i), e._$AM = i, e._$AP !== void 0 && (a = i._$AU) !== o._$AU && e._$AP(a); + e._$AQ?.(s), e._$AM = s, e._$AP !== void 0 && (a = s._$AU) !== o._$AU && e._$AP(a); } - if (r !== s || l) { + if (r !== i || l) { let a = e._$AA; for (; a !== r; ) { const p = kt(a).nextSibling; - kt(n).insertBefore(a, s), a = p; + kt(n).insertBefore(a, i), a = p; } } } return e; -}, k = (i, t, e = i) => (i._$AI(t, e), i), ze = {}, Pe = (i, t = ze) => i._$AH = t, Ie = (i) => i._$AH, X = (i) => { - i._$AR(), i._$AA.remove(); +}, 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 = (i, t, e) => { +const At = (s, t, e) => { const n = /* @__PURE__ */ new Map(); - for (let s = t; s <= e; s++) n.set(i[s], s); + for (let i = t; i <= e; i++) n.set(s[i], i); return n; -}, St = Ee(class extends Me { - constructor(i) { - if (super(i), i.type !== Te.CHILD) throw Error("repeat() can only be used in text expressions"); +}, 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(i, t, e) { + dt(s, t, e) { let n; e === void 0 ? e = t : t !== void 0 && (n = t); - const s = [], r = []; + const i = [], r = []; let o = 0; - for (const l of i) s[o] = n ? n(l, o) : o, r[o] = e(l, o), o++; - return { values: r, keys: s }; + for (const l of s) i[o] = n ? n(l, o) : o, r[o] = e(l, o), o++; + return { values: r, keys: i }; } - render(i, t, e) { - return this.dt(i, t, e).values; + render(s, t, e) { + return this.dt(s, t, e).values; } - update(i, [t, e, n]) { - const s = Ie(i), { values: r, keys: o } = this.dt(t, e, n); - if (!Array.isArray(s)) return this.ut = o, r; + 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, d = 0, f = s.length - 1, h = 0, m = r.length - 1; - for (; d <= f && h <= m; ) if (s[d] === null) d++; - else if (s[f] === null) f--; - else if (l[d] === o[h]) a[h] = k(s[d], r[h]), d++, h++; - else if (l[f] === o[m]) a[m] = k(s[f], r[m]), f--, m--; - else if (l[d] === o[m]) a[m] = k(s[d], r[m]), P(i, a[m + 1], s[d]), d++, m--; - else if (l[f] === o[h]) a[h] = k(s[f], r[h]), P(i, s[d], s[f]), f--, h++; - else if (p === void 0 && (p = At(o, h, m), u = At(l, d, f)), p.has(l[d])) if (p.has(l[f])) { - const _ = u.get(o[h]), x = _ !== void 0 ? s[_] : null; + 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 H = P(i, s[d]); - k(H, r[h]), a[h] = H; - } else a[h] = k(x, r[h]), P(i, s[d], x), s[_] = 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(s[f]), f--; - else X(s[d]), d++; + } else X(i[f]), f--; + else X(i[c]), c++; for (; h <= m; ) { - const _ = P(i, a[m + 1]); + const _ = P(s, a[m + 1]); k(_, r[h]), a[h++] = _; } - for (; d <= f; ) { - const _ = s[d++]; + for (; c <= f; ) { + const _ = i[c++]; _ !== null && X(_); } - return this.ut = o, Pe(i, a), N; + return this.ut = o, Ie(s, a), T; } -}), Nt = /* @__PURE__ */ new Map(); -function M(i, t) { - const e = `${i ?? "local"}|${JSON.stringify(t)}`, n = Nt.get(e); +}), 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 s; + let i; try { - s = new Intl.DateTimeFormat("en-US", { + i = new Intl.DateTimeFormat("en-US", { ...t, - ...i ? { timeZone: i } : {} + ...s ? { timeZone: s } : {} }); } catch { - s = new Intl.DateTimeFormat("en-US", t); + i = new Intl.DateTimeFormat("en-US", t); } - return Nt.set(e, s), s; + return Tt.set(e, i), i; } -function De(i) { - const t = i?.config; +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 L(i, t) { - return `${M(t, { weekday: "long" }).format(i)}, ${M(t, { month: "short", day: "numeric" }).format(i)}`; +function j(s, t) { + return `${M(t, { weekday: "long" }).format(s)}, ${M(t, { month: "short", day: "numeric" }).format(s)}`; } -function Ue(i, t) { - return M(t, { weekday: "long" }).format(i); +function De(s, t) { + return M(t, { weekday: "long" }).format(s); } -function je(i, t) { - const e = new Date(i); +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(i, t) { +function He(s, t) { return M(t, { hour: "numeric", minute: "2-digit", hour12: !0 - }).format(i); + }).format(s); } -function Le(i) { - if (!i) +function Be(s) { + if (!s) return null; - const t = /^(\d{1,2}):(\d{2})$/.exec(i.trim()); + const t = /^(\d{1,2}):(\d{2})$/.exec(s.trim()); if (!t) return null; const e = Number(t[1]); @@ -1513,22 +1567,22 @@ function Le(i) { const n = e >= 12 ? "PM" : "AM"; return `${e % 12 === 0 ? 12 : e % 12}:${t[2]} ${n}`; } -function $(i) { - return typeof i == "string" ? i.trim() : ""; +function w(s) { + return typeof s == "string" ? s.trim() : ""; } -function y(i, t) { - if (i == null || i === "") +function y(s, t) { + if (s == null || s === "") return t; - const e = typeof i == "number" ? i : Number(i); + const e = typeof s == "number" ? s : Number(s); return Number.isFinite(e) ? e : t; } -function v(i) { - return Math.max(0, Math.round(i)); +function b(s) { + return Math.max(0, Math.round(s)); } -function Y(i) { - return i.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); +function Y(s) { + return s.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); } -function Re(i, t) { +function Fe(s, t) { const e = M(t, { year: "numeric", month: "2-digit", @@ -1537,10 +1591,10 @@ function Re(i, t) { minute: "2-digit", second: "2-digit", hourCycle: "h23" - }).formatToParts(new Date(i)), n = (r) => { + }).formatToParts(new Date(s)), n = (r) => { const o = e.find((l) => l.type === r); return o ? Number(o.value) : Number.NaN; - }, s = Date.UTC( + }, i = Date.UTC( n("year"), n("month") - 1, n("day"), @@ -1548,52 +1602,52 @@ function Re(i, t) { n("minute"), n("second") ); - return Number.isFinite(s) ? s - i : 0; + return Number.isFinite(i) ? i - s : 0; } -function Tt(i, t) { - if (typeof i != "string") +function Et(s, t) { + if (typeof s != "string") return null; - const e = i.split("-"); + const e = s.split("-"); if (e.length !== 3) return null; - const [n, s, r] = e.map((a) => Number(a)); - if (!n || !s || !r) + const [n, i, r] = e.map((a) => Number(a)); + if (!n || !i || !r) return null; if (!t) { - const a = new Date(n, s - 1, r); + const a = new Date(n, i - 1, r); return Number.isNaN(a.getTime()) ? null : a; } - const o = Date.UTC(n, s - 1, r), l = new Date(o - Re(o, t)); + const o = Date.UTC(n, i - 1, r), l = new Date(o - Fe(o, t)); return Number.isNaN(l.getTime()) ? null : l; } -function Be() { - const i = window.location.pathname.split("/").filter(Boolean); - return i.length > 0 ? i[i.length - 1].toLowerCase() : ""; +function Qe() { + const s = window.location.pathname.split("/").filter(Boolean); + return s.length > 0 ? s[s.length - 1].toLowerCase() : ""; } -function Fe(i) { - if (!i || typeof i != "object") +function We(s) { + if (!s || typeof s != "object") return null; - const t = i, e = $(t.state).toLowerCase(); + 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 s = y(t.child_id, 0); + const i = y(t.child_id, 0); return { id: n, - child_id: s > 0 ? s : null, - title: $(t.title) || "Quest", - icon: $(t.icon) || null, - window: $(t.window).toLowerCase(), - due_time: $(t.due_time) || null, + 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: $(t.completed_at) || null, + completed_at: w(t.completed_at) || null, on_time: t.on_time === !0 ? !0 : t.on_time === !1 ? !1 : null }; } -const Qe = { +const Ze = { "clear-night": "Clear", cloudy: "Cloudy", exceptional: "Clear", @@ -1609,7 +1663,7 @@ const Qe = { sunny: "Sunny", windy: "Windy", "windy-variant": "Windy" -}, We = { +}, Ge = { "clear-night": "Clear night skies", cloudy: "Grey skies today", exceptional: "A striking day", @@ -1626,14 +1680,14 @@ const Qe = { windy: "A blustery day", "windy-variant": "A blustery day" }; -function Ze(i) { - const t = Qe[i]; - return t || i.charAt(0).toUpperCase() + i.slice(1); +function Ve(s) { + const t = Ze[s]; + return t || s.charAt(0).toUpperCase() + s.slice(1); } -function Ve(i, t) { - return We[i] ?? t; +function Ke(s, t) { + return Ge[s] ?? t; } -const Et = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ge = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", Mt = [-9, 6, -4], Ke = Pt` +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; } @@ -1693,7 +1747,7 @@ const Et = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ge = "polygon(50% width: 96px; height: 110px; padding: 3px; - clip-path: ${b(Et)}; + clip-path: ${v(Nt)}; background: rgba(255, 255, 255, 0.22); } @@ -1704,7 +1758,7 @@ const Et = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ge = "polygon(50% align-items: center; justify-content: center; padding-bottom: 32px; - clip-path: ${b(Et)}; + clip-path: ${v(Nt)}; background: var(--nq-p-crest); } @@ -1770,7 +1824,7 @@ const Et = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ge = "polygon(50% justify-content: center; width: 44px; height: 44px; - clip-path: ${b(Ge)}; + clip-path: ${v(Je)}; background: var(--nq-brand-gradient); } @@ -2480,7 +2534,7 @@ const Et = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ge = "polygon(50% font-weight: 800; line-height: 1; } -`, R = c` -`, Je = c``, Ye = d` -`, tt = c``, tt = d` -`, Xe = c``, tn = d` -`, Rt = c``, Ht = d` -`, Ye = c``, en = d` -`, tn = c``, nn = d` -`, en = c``, sn = d` -`, nn = c``, rn = d` -`, sn = c``, on = d` -`, rn = c``, an = d` -`, on = { - icon: en, +`, ln = { + icon: sn, headline: "No adventurer chosen", body: "Open The Party and tap your crest to open your quest log." -}, an = { - icon: nn, +}, cn = { + icon: rn, headline: "NestQuest is not set up yet", body: "A parent needs to finish setting up NestQuest." -}, ln = { - icon: sn, +}, 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: Xe }, - { key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: Rt }, - { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: Ye } + { 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 dn extends A { +class pn extends A { static properties = { hass: { attribute: !1 }, _config: { state: !0 }, @@ -2641,7 +2695,7 @@ class dn extends A { _toast: { state: !0 }, _countdown: { state: !0 } }; - static styles = [b(Lt), Ke]; + static styles = [v(jt), Xe]; hass; _config; _now = /* @__PURE__ */ new Date(); @@ -2656,28 +2710,69 @@ class dn extends A { _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._now = /* @__PURE__ */ new Date(), this._clockTimer = window.setInterval(() => { + 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._logView().kind === "complete-day" ? (this._clearIdle(), this._armCompleteTimer()) : (this._clearCompleteTimer(), this._countdown = null, this._idleTimer === void 0 && this._armIdle()); + this._subscribeLive(), this._logView().kind === "complete-day" ? (this._clearIdle(), this._armCompleteTimer()) : (this._clearCompleteTimer(), this._countdown = null, this._idleTimer === void 0 && this._armIdle()); } disconnectedCallback() { - 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(); + 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 c` + return d`
    @@ -2688,37 +2783,37 @@ class dn extends A { `; } _renderToast() { - return this._toast === null ? g : c` + return this._toast === null ? g : d`
    ${this._toast}
    `; } _renderMain(t) { switch (t.kind) { case "no-adventurer": - return this._renderNotice(on); - case "not-set-up": - return this._renderNotice(an); - case "unreachable": 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}`), c` + return t.returns && e.push(`Returns ${t.returns}`), d` ${this._renderHeader(t)}
    - ${tn} + ${nn} ${t.name} is on travels - ${e.map((n) => c`

    ${n}

    `)} + ${e.map((n) => d`

    ${n}

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

    ${`Nothing was posted for today, ${t.name}. Enjoy the day's rest!`} @@ -2728,9 +2823,9 @@ class dn extends A { `; case "complete-day": { const e = this._completeStats(), n = this._countdown ?? this._completeSeconds(); - return c` + return d`

    - +

    Quest complete

    ${this._completeSub(t.name)}

    @@ -2759,7 +2854,7 @@ class dn extends A { `; } default: - return c` + return d` ${this._renderHeader(t)}

    ${Ot.map((e) => this._renderColumn(e))} @@ -2781,11 +2876,11 @@ class dn extends A { const n = this._childName(t) ?? Y(t); if (!this._childPresent(t)) return { kind: "away", name: n, returns: this._awayReturns(t) }; - const s = this._state(`binary_sensor.nestquest_${t}_all_done`); - if (s && String(s.state ?? "").trim().toLowerCase() === "on") + 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 v(y(r?.state, 0)) === 0 ? { kind: "empty-day", name: n } : this._remaining() === 0 ? { kind: "complete-day", name: n } : { kind: "normal" }; + 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 @@ -2802,22 +2897,22 @@ class dn extends A { const n = this._state(`sensor.nestquest_${t}_quests_due_today`)?.attributes?.present; if (typeof n == "boolean") return n; - const s = this._state( + const i = this._state( `binary_sensor.nestquest_${t}_present_today` ); - return s ? String(s.state ?? "").trim().toLowerCase() === "on" : !0; + return i ? String(i.state ?? "").trim().toLowerCase() === "on" : !0; } _awayReturns(t) { const e = this._timeZone(), n = this._state( `binary_sensor.nestquest_${t}_present_today` - ), s = Tt( + ), i = Et( n?.attributes?.next_present, e ); - return s ? L(s, e) : null; + return i ? j(i, e) : null; } _renderNotice(t) { - return c` + return d`
    ${t.icon} @@ -2828,17 +2923,17 @@ class dn extends A { `; } _timeZone() { - return De(this.hass); + return Re(this.hass); } _state(t) { const n = this.hass?.states?.[t]; return n && typeof n == "object" ? n : null; } _childSlug() { - return Be(); + return Qe(); } _boardPath() { - return $(this._config?.board_path).replace(/\/+$/, ""); + return w(this._config?.board_path).replace(/\/+$/, ""); } _idleSeconds() { const t = y(this._config?.idle_return_seconds, 40); @@ -2861,8 +2956,8 @@ class dn extends A { _childCounts(t) { const e = this._state(`sensor.nestquest_${t}_quests_due_today`), n = this._state( `sensor.nestquest_${t}_quests_completed_today` - ), s = v(y(e?.state, 0)), r = v(y(n?.state, 0)); - return { due: s, completed: r }; + ), i = b(y(e?.state, 0)), r = b(y(n?.state, 0)); + return { due: i, completed: r }; } _instances() { const t = this._childSlug(); @@ -2871,21 +2966,21 @@ class dn extends A { const e = this._state(`sensor.nestquest_${t}_quests_due_today`)?.attributes?.instances; if (!Array.isArray(e)) return []; - const n = e.map(Fe).filter((r) => r !== null); + const n = e.map(We).filter((r) => r !== null); if (this._optimistic.size === 0) return n; - let s = !1; + 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), s = !0; + this._optimistic.delete(r.id), i = !0; continue; } r.state = "completed", r.overdue = !1, r.completed_at = o; } } - return s && (this._optimistic = new Map(this._optimistic)), n; + return i && (this._optimistic = new Map(this._optimistic)), n; } _remaining() { const t = this._childSlug(); @@ -2895,9 +2990,9 @@ class dn extends A { `sensor.nestquest_${t}_quests_remaining_today` ); if (e) - return v(y(e.state, 0)); - const { due: n, completed: s } = this._childCounts(t); - return v(n - s); + return b(y(e.state, 0)); + const { due: n, completed: i } = this._childCounts(t); + return b(n - i); } _partyCounts() { const t = this._state( @@ -2907,32 +3002,32 @@ class dn extends A { ); if (t || e) return { - completed: v(y(e?.state, 0)), - due: v(y(t?.state, 0)) + completed: b(y(e?.state, 0)), + due: b(y(t?.state, 0)) }; - const n = this._childSlug(), { due: s, completed: r } = this._childCounts(n); - return { completed: r, due: s }; + 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 = v(t - e), s = this._partyCounts(); + ).length, n = b(t - e), i = this._partyCounts(); return { claimed: t, onTime: e, late: n, - party: `${s.completed}/${s.due}` + party: `${i.completed}/${i.due}` }; } _completeSub(t) { - const e = L(this._now, this._timeZone()), { claimed: n, onTime: s, late: r } = this._completeStats(); - return n === 0 ? `${t} sealed the day on ${e}.` : r === 0 ? `${t} claimed every quest on ${e} — all on time.` : s === 0 ? `${t} claimed every quest on ${e} — all late.` : `${t} claimed every quest on ${e} — ${s} on time, ${r} late.`; + 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(), s = []; + 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) @@ -2949,20 +3044,20 @@ class dn extends A { ); u = _ === null || String(_.state ?? "").trim().toLowerCase() !== "off"; } - const d = this._state( + const c = this._state( `sensor.nestquest_${l}_quests_completed_today` - ), f = v(y(t[r]?.state, 0)), h = v(y(d?.state, 0)); + ), 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 = Tt( + ), x = Et( _?.attributes?.next_present, n ); - m = x ? Ue(x, n) : null; + m = x ? De(x, n) : null; } - s.push({ + i.push({ slug: l, name: p, present: u, @@ -2971,11 +3066,11 @@ class dn extends A { returnsWeekday: m }); } - return s.sort((r, o) => r.name.localeCompare(o.name)); + return i.sort((r, o) => r.name.localeCompare(o.name)); } _renderHeader(t) { - const e = this._childSlug(), n = t.kind === "away", s = n ? t.name : this._childName(e) ?? (e ? Y(e) : null), { due: r, completed: o } = this._childCounts(e), l = this._remaining(), a = this._partyCounts(), p = s ? `${s}'s Quest Log` : "Quest Log", u = (s?.charAt(0) || "?").toUpperCase(); - return c` + 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`
    ${St( @@ -3052,7 +3147,7 @@ class dn extends A { (l) => this._renderQuest(l, !1, 0) )} ${St( - s, + i, (l) => l.id, (l, a) => this._renderQuest(l, !0, a) )} @@ -3064,7 +3159,7 @@ class dn extends A { _renderQuest(t, e, n) { if (e) { const o = Mt[n % Mt.length], l = t.completed_at ? je(t.completed_at, this._timeZone()) : ""; - return c` + return d`
    @@ -3078,19 +3173,19 @@ class dn extends A { aria-hidden="true" style="--seal-rot: ${o}deg" > - ${R} + ${H}
    `; } - const s = Le(t.due_time), r = t.overdue ? { - text: s ? `Overdue · due ${s}` : "Overdue", + const i = Be(t.due_time), r = t.overdue ? { + text: i ? `Overdue · due ${i}` : "Overdue", late: !0 } : { - text: s ? `Due by ${s}` : "Due today", + text: i ? `Due by ${i}` : "Due today", late: !1 }; - return c` + return d`
    this._openConfirm(t.id)} > - ${R} + ${H} Complete
    @@ -3123,7 +3218,7 @@ class dn extends A { 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 c` + return d`
    Party roll-up

    ${e.join(" ")}

    @@ -3139,7 +3234,7 @@ class dn extends A { if (!t) return g; const e = Ot.find((n) => n.key === t.window); - return c` + return d`
    this._closeConfirm()}>
    this._closeConfirm()} > - ${Je} + ${Ye} Not yet
    @@ -3285,7 +3380,7 @@ class dn extends A { return Number.isFinite(e) ? e : null; } _dockWeather() { - const t = $(this._config?.weather_entity); + const t = w(this._config?.weather_entity); if (!t) return null; const e = this._state(t); @@ -3294,9 +3389,9 @@ class dn extends A { const n = String(e.state ?? "").trim(); if (!n || n === "unavailable" || n === "unknown") return null; - const s = e.attributes ?? {}; + const i = e.attributes ?? {}; let r = null, o = null; - const l = s.forecast; + const l = i.forecast; if (Array.isArray(l) && l.length > 0) { const a = l[0]; if (a && typeof a == "object") { @@ -3306,7 +3401,7 @@ class dn extends A { } return { condition: n, - temperature: this._optionalNumber(s.temperature), + temperature: this._optionalNumber(i.temperature), high: r, low: o }; @@ -3314,18 +3409,18 @@ class dn extends A { _renderDock() { const t = this._dockWeather(); if (!t) - return c` + return d`
    - ${L(this._now, this._timeZone())} + ${j(this._now, this._timeZone())} ${He(this._now, this._timeZone())}
    `; - const e = Ze(t.condition), n = Ve(t.condition, e), s = t.temperature === null ? g : c`${Math.round(t.temperature)}°`, r = t.high === null || t.low === null ? null : `${Math.round(t.high)}° / ${Math.round(t.low)}°`; - return c` + 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`
    - ${rn} - ${s} + ${an} + ${i} ${r === null ? e : `${e} · ${r}`} @@ -3346,19 +3441,19 @@ class dn extends A { this._logView().kind !== "complete-day" && this._armIdle(), this._confirm !== null && this._armConfirmTimer(); }; } -customElements.define("nestquest-quest-log-card", dn); +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 cn = ":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 pn extends A { +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 = [b(cn)]; + static styles = [v(hn)]; hass; _config; setConfig(t) { @@ -3370,17 +3465,17 @@ class pn extends A { return 8; } render() { - return c`
    NestQuest Admin
    `; + return d`
    NestQuest Admin
    `; } } -customElements.define("nestquest-admin-card", pn); +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 hn = "/nestquest-static/nestquest-fonts.css"; +const fn = "/nestquest-static/nestquest-fonts.css"; if (!document.querySelector('link[data-nq-fonts=""]')) { - const i = document.createElement("link"); - i.rel = "stylesheet", i.href = hn, i.dataset.nqFonts = "", document.head.appendChild(i); + const s = document.createElement("link"); + s.rel = "stylesheet", s.href = fn, s.dataset.nqFonts = "", document.head.appendChild(s); } diff --git a/frontend/src/cards/party-board.ts b/frontend/src/cards/party-board.ts index 13a022d..d012872 100644 --- a/frontend/src/cards/party-board.ts +++ b/frontend/src/cards/party-board.ts @@ -8,6 +8,20 @@ type StateObject = { 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; @@ -715,6 +729,10 @@ export class NestQuestPartyBoardCard extends LitElement { _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") { @@ -723,19 +741,101 @@ export class NestQuestPartyBoardCard extends LitElement { 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; diff --git a/frontend/src/cards/quest-log.ts b/frontend/src/cards/quest-log.ts index 3271526..fba8929 100644 --- a/frontend/src/cards/quest-log.ts +++ b/frontend/src/cards/quest-log.ts @@ -13,6 +13,20 @@ type StateObject = { 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; @@ -1361,6 +1375,10 @@ export class NestQuestQuestLogCard extends LitElement { 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") { @@ -1369,12 +1387,89 @@ export class NestQuestQuestLogCard extends LitElement { 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(); @@ -1389,6 +1484,7 @@ export class NestQuestQuestLogCard extends LitElement { } protected updated(): void { + this._subscribeLive(); if (this._logView().kind === "complete-day") { this._clearIdle(); this._armCompleteTimer(); @@ -1402,6 +1498,7 @@ export class NestQuestQuestLogCard extends LitElement { } disconnectedCallback(): void { + this._unsubscribeLive(); this._stopClock(); this._clearIdle(); this._clearConfirmTimer(); From 4cb07fbca45371cb37150e2b6ca2f8b4985f0e6e Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Sun, 20 Sep 2026 19:29:00 -0400 Subject: [PATCH 44/46] Record 1920x1080 panel layout and TouchHub dock behaviour Record 1920x1080 panel layout and TouchHub dock behaviour --- README.md | 8 ++++++++ tests/test_frontend.py | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/README.md b/README.md index 7887abe..fd29aa7 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,14 @@ 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/tests/test_frontend.py b/tests/test_frontend.py index e32b1db..59c5989 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -93,6 +93,14 @@ def test_readme_documents_npm_build() -> None: 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", From 43ad44ee4859204ce8cabe71a462e5fefc23e382 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Sun, 20 Sep 2026 19:41:57 -0400 Subject: [PATCH 45/46] Panel card tests including the no-admin-controls assertion Panel card tests including the no-admin-controls assertion --- tests/test_panel_cards.py | 160 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 tests/test_panel_cards.py 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 From 7386999cf7cfa72461d78e2ef347b76e77ba90b9 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Sun, 20 Sep 2026 20:20:52 -0400 Subject: [PATCH 46/46] TouchHub setup runbook TouchHub setup runbook --- README.md | 79 ++++++++++++++++++++++++++++++++++++++++++ tests/test_frontend.py | 16 +++++++++ 2 files changed, 95 insertions(+) diff --git a/README.md b/README.md index fd29aa7..a590c80 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,85 @@ Sensors the automations read: `sensor.nestquest__quests_due_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. diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 59c5989..3fc7e94 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -163,3 +163,19 @@ def test_frontend_and_www_have_no_cdn_font_references() -> None: 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