diff --git a/README.md b/README.md index 73a3097..a857f87 100644 --- a/README.md +++ b/README.md @@ -159,9 +159,50 @@ Sensors the automations read: `sensor.nestquest__quests_due_today` 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. +is simply a Home Assistant client. The path below is zero-config: the +integration registers its panel dashboard itself on setup, so no YAML +and no hand-built views are involved. -1. **Create a dedicated kiosk user.** In *Settings → People → Users*, add +1. **Install the integration and add the config entry.** Install + NestQuest via HACS or manually (see Installation above) and restart + Home Assistant. Then, in *Settings → Devices & Services*, click + **Add Integration**, search for **NestQuest**, and submit the flow — + it has no fields; it just creates the single config entry. Then open + the entry, click **Configure**, and fill the panel-plane API + connection in the options flow: + + - **NestQuest API base URL** — the full `http://host:port` URL of + your NestQuest API service (default `http://127.0.0.1:8000`). + - **Panel service token** — the token the panel presents to the API + service as its Bearer credential. It may stay empty until you have + one; a pasted token must be unpadded. + + The form's remaining fields (panel idle timeout, coordinator update + interval, last-good snapshot staleness) have defaults that serve the + panel and need no change here. + +2. **The panel dashboard appears automatically.** On setup the + integration registers one storage-mode Lovelace dashboard: a sidebar + entry titled **NestQuest** at the URL path `/nestquest`, whose stored + config is nothing but the **`custom:nestquest-party` strategy** + (`strategy: { type: custom:nestquest-party }`). The strategy + generates the views at render time from the household roster: + + - **The Party** — the party-board view at `/nestquest/party`; the + dashboard root `/nestquest` shows it as the first view. + - one quest-log view per child at `/nestquest/` + ("Ada's Quest Log", …), in roster order. + + Nothing is hand-configured: no YAML, no manual views, no + `child_order`, no `quest_log_path`/`board_path` — the board and the + quest logs compute their navigation paths from the dashboard's own + URL — and every generated view is already in **Panel** layout (see + the two constraints below). If the Lovelace surfaces are unavailable + (for example when Lovelace runs in YAML mode), setup logs a warning + and skips the registration; create the dashboard manually with the + same strategy as described in the manual alternative below. + +3. **Create a dedicated kiosk user.** In *Settings → People → Users*, add a new user for the wall device. Make it **non-admin**, and ensure it is not in the NestQuest admin allowlist. The allowlist blocks the NestQuest **admin** operations — `uncomplete_quest`, `create_quest_definition`, @@ -174,66 +215,101 @@ is simply a Home Assistant client. 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): +4. **Open the dashboard and point TouchHub at it.** Open + `http:///nestquest` in a browser first and check the + party board renders with your children's crests. Then, in the + TouchHub launcher, add the **Home Assistant Lovelace** app to the + dock and paste the dashboard URL from step 2 — the dashboard root, + `/nestquest` (TouchHub loads that URL directly as the kiosk landing + page). Set TouchHub **Auto-Return** to a value GREATER than the + quest log's `idle_return_seconds` (default 40s) — recommend **60s or + higher**. If Auto-Return is at or below 40s, TouchHub returns to its + launcher before the quest log's idle return can navigate back to the + party board. - - **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. +5. **Log the kiosk session in.** Sign in on the device as the dedicated + kiosk user from step 3, and leave that session logged in. TouchHub loads the dashboard URL directly. The NestQuest cards are normal Lovelace cards, not an iframe, so no Home Assistant framing setting is needed. +### The two constraints + +- **Panel layout.** Every view must be in **Panel** (single-card) + layout: Home Assistant's default Masonry layout constrains the + 1080px-tall NestQuest card to a narrow column, while Panel renders the + card full-screen at 1920×1080. The zero-config path already satisfies + the requirement — the strategy generates every view as + `type: panel` — while the manual alternative below requires setting + the layout by hand on each view (Edit Dashboard → pencil on the view + → View type: Panel). + +- **TouchHub Auto-Return.** TouchHub's **Auto-Return** must be greater + than the quest log's `idle_return_seconds` (default 40s) — recommend + **60s or higher** — so the launcher does not reclaim the wall device + before the quest log's own idle return navigates back to the party + board. + +### Manual alternative: build the dashboard by hand + +If you prefer not to use the zero-config dashboard above — or its +registration was skipped — configure a Lovelace dashboard whose **URL +slug is `nestquest`** (Settings → Dashboards → Edit → URL slug) +yourself. Two options, in decreasing order of convenience: + +- **Use the strategy.** Give the dashboard + + ```yaml + strategy: + type: custom:nestquest-party + ``` + + and no views of your own — the strategy generates the party-board and + quest-log views (each already in Panel layout) from the household + roster at render time. Optional `url_path` and `weather_entity` keys + may sit beside `strategy:`. + +- **Build the two views by hand.** A functional panel needs **two** + views in that dashboard, each with its own card config from + `design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL + paths (the path segment after the dashboard slug): + + - **View 1 path `board`** (URL `/nestquest/board`) — party board + (`type: custom:nestquest-party-board-card`): the household quest + board. Set `weather_entity` (optional; the dock falls back to + date/time), `quest_log_path: /nestquest/log` (the view the crest tap + navigates to), and `child_order: [slug1, slug2, slug3]` listing each + child's slug in display order. + + - **View 2 path `log`** (URL `/nestquest/log`) — quest log + (`type: custom:nestquest-quest-log-card`): the per-child quest screen + the party board hops to. Set `board_path: /nestquest/board` (the + party-board view to return to), `weather_entity`, + `idle_return_seconds` (must be shorter than TouchHub Auto-Return — + see the two constraints above), `confirm_timeout_seconds`, and + `complete_screen_seconds`. The quest log reads the selected child's + slug from the view's URL path, so the board → log hop is a plain + Lovelace navigation. + + The dashboard contains only the NestQuest panel card for each view — + no other cards, no admin controls. Each view's layout MUST be set to + **Panel** — see the two constraints above for why; the strategy + generates it for you, hand-built views do not get it for free. + + The `quest_log_path` and `board_path` values must match the dashboard + slug and view paths above: `//`. If you chose `nestquest` + as the dashboard slug with view paths `board` and `log`, the card + configs are already correct. If you pick different names, substitute + your actual paths in **both** card configs — e.g. a dashboard slug + `chores` with view paths `main` and `history` needs + `quest_log_path: /chores/history` and `board_path: /chores/main`. + Navigation between the two views fails (the crest tap or back button + lands on a 404) if the card config paths do not match the dashboard + slug and view paths exactly. + + See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML. + ## Developer Setup and Testing This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management. diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 76493ca..479ab03 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -5,7 +5,8 @@ SQLite file, the migrations, and every business rule. Setup builds the entry's ONE shared API client, the coordinator that polls the panel snapshot through it, and the SSE subscription that re-fires the API's transition frames on the HA bus, registers the single -``nestquest.complete_quest`` proxy service, and forwards the sensor and +``nestquest.complete_quest`` proxy service, registers the zero-config +panel dashboard (Feature 20), and forwards the sensor and binary_sensor platforms. """ from __future__ import annotations @@ -29,6 +30,7 @@ from .const import ( PLATFORMS, ) from .coordinator import NestQuestCoordinator, coordinator_client_from_entry +from .dashboard import async_register_dashboard from .frontend import async_register_frontend from .services import async_deregister_services, async_register_services from .sse import NestQuestEventStream @@ -87,6 +89,13 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NestQuest from a config entry.""" await async_register_frontend(hass) + # The zero-config panel dashboard (Feature 20): register the + # storage-mode strategy dashboard if no dashboard under the stable + # url_path exists yet. Unavailability of the Lovelace dashboards + # collection (YAML mode, lovelace not loaded) logs a warning and + # NEVER fails setup; an existing dashboard — ours or the user's — + # is left untouched, so a reload cannot duplicate it. + await async_register_dashboard(hass) hass.data.setdefault(DOMAIN, {}) async def _async_update_listener( diff --git a/custom_components/nestquest/core/const.py b/custom_components/nestquest/core/const.py index 4faadd7..d8aba4b 100644 --- a/custom_components/nestquest/core/const.py +++ b/custom_components/nestquest/core/const.py @@ -43,6 +43,18 @@ DOMAIN_SERVICES: tuple[str, ...] = ( SQLITE_DB_FILENAME = "nestquest.db" +# Zero-config panel dashboard (Feature 20): ONE storage-mode Lovelace +# dashboard registered on setup whose config carries the bundle's +# ``custom:nestquest-party`` strategy (frontend/src/strategy.ts) — the +# views are generated at render time from the household roster. The +# url_path IS the domain: the stable key idempotency is keyed on (a +# reload or a second entry never duplicates it, and a dashboard already +# registered under it — ours or the user's — is left untouched). +DASHBOARD_URL_PATH = DOMAIN +DASHBOARD_TITLE = "NestQuest" +DASHBOARD_ICON = "mdi:party-popper" +DASHBOARD_STRATEGY_TYPE = "custom:nestquest-party" + # Configuration keys and default values CONF_HORIZON_DAYS = "horizon_days" DEFAULT_HORIZON_DAYS = 14 diff --git a/custom_components/nestquest/dashboard.py b/custom_components/nestquest/dashboard.py new file mode 100644 index 0000000..17bd6cb --- /dev/null +++ b/custom_components/nestquest/dashboard.py @@ -0,0 +1,215 @@ +"""Zero-config panel dashboard registration for NestQuest (Feature 20). + +On setup the integration registers ONE storage-mode Lovelace dashboard +whose CONFIG uses the bundle's ``custom:nestquest-party`` strategy (see +``frontend/src/strategy.ts``): the views themselves are GENERATED at +render time from the household roster, so the stored dashboard config +carries nothing but the strategy reference. + +Registration mirrors the REAL two-step sequence the lovelace +integration itself uses when it creates the onboarding map dashboard +(``homeassistant/components/lovelace/__init__.py``, +``_create_map_dashboard``): + +1. create the dashboard METADATA through the ``DashboardsCollection`` + (``hass.data["lovelace"]["dashboards_collection"]``) — url_path, + title, icon, sidebar visibility. The strategy reference is NOT part + of the create payload: the collection's create schema allows only + the metadata fields, so a ``strategy`` key there fails the whole + create (this was the silent no-op the first implementation shipped). +2. save the dashboard CONFIG — ``{"strategy": ...}`` — through the + per-url_path CONTENT manager that the collection's change listener + registers under ``hass.data["lovelace"]["dashboards"][url_path]`` + (a ``LovelaceStorage``), exactly the second step HA performs. + +Registration is IDEMPOTENT — a dashboard already under the stable +``url_path`` (ours or a user's) skips BOTH steps and its stored config +is never overwritten — and the Lovelace surfaces being unavailable +(YAML mode, lovelace not loaded) is a logged warning, never a setup +failure. +""" +from __future__ import annotations + +import asyncio + +from homeassistant.core import HomeAssistant + +from .const import ( + DASHBOARD_ICON, + DASHBOARD_STRATEGY_TYPE, + DASHBOARD_TITLE, + DASHBOARD_URL_PATH, + LOGGER, +) + +_LOGGER = LOGGER + +# Single-flight guard for the check-then-create below: HA can set up +# several config entries CONCURRENTLY, and two overlapping setups would +# both pass the exists-check and race on the url_path create. The lock +# is per hass instance (kept as an attribute on the hass object, since +# hass.data is the integrations' shared registry the unload path +# empties) rather than one module-level asyncio.Lock — a bare +# module-level lock binds to whichever event loop first awaits it and +# raises RuntimeError on any other loop, while the race it must prevent +# only exists between setups running on the SAME hass anyway. +_REGISTRATION_LOCK_ATTR = "_nestquest_dashboard_registration_lock" + + +def _registration_lock(hass: HomeAssistant) -> asyncio.Lock: + """Return the per-hass registration lock, creating it on first use.""" + lock = getattr(hass, _REGISTRATION_LOCK_ATTR, None) + if lock is None: + lock = asyncio.Lock() + setattr(hass, _REGISTRATION_LOCK_ATTR, lock) + return lock + + +def _lovelace_data(hass: HomeAssistant) -> dict | None: + """Return ``hass.data["lovelace"]`` when it is the real dict shape. + + The lovelace integration stores a plain DICT of surfaces (``mode``, + ``dashboards``, ``dashboards_collection``, ``resources``, + ``yaml_dashboards``) under the ``lovelace`` key; anything else + (missing key, unexpected object) means the surfaces below cannot be + reached and registration must warn and continue. + """ + lovelace_data = hass.data.get("lovelace") + if not isinstance(lovelace_data, dict): + return None + return lovelace_data + + +def _dashboards_collection(hass: HomeAssistant): + """Return the Lovelace dashboards METADATA collection, or None. + + The collection lives at ``hass.data["lovelace"] + ["dashboards_collection"]`` — NOT under ``["dashboards"]``, which + maps url_path to the per-dashboard CONTENT managers + (``LovelaceStorage``/``LovelaceYAML``). A missing key (lovelace + not loaded) and a missing/None entry (YAML mode never sets the + collection) both mean "cannot create storage dashboards here" — + callers log and continue. The presence of the surface is verified + by capability (``async_create_item`` callable), not by type, so no + lovelace import is needed. + """ + lovelace_data = _lovelace_data(hass) + if lovelace_data is None: + return None + collection = lovelace_data.get("dashboards_collection") + if collection is None or not callable( + getattr(collection, "async_create_item", None) + ): + return None + return collection + + +def _dashboard_store(hass: HomeAssistant, url_path: str): + """Return a url_path's dashboard CONTENT manager, or None. + + After a metadata create, the collection's change listener registers + a ``LovelaceStorage`` for the new dashboard under + ``hass.data["lovelace"]["dashboards"][url_path]``; the dashboard + CONFIG (the strategy reference) is saved through THAT manager — + the second step of HA's own dashboard creation. + """ + lovelace_data = _lovelace_data(hass) + if lovelace_data is None: + return None + dashboards = lovelace_data.get("dashboards") + if not isinstance(dashboards, dict): + return None + store = dashboards.get(url_path) + if store is None or not callable(getattr(store, "async_save", None)): + return None + return store + + +def _item_url_path(item) -> str | None: + """Read a dashboard item's ``url_path`` through either access shape. + + The collection hands back storage items as dicts while builtin + dashboards come back as config objects; the url_path is reachable + through ``item["url_path"]`` or ``item.url_path`` respectively. + """ + url_path = getattr(item, "url_path", None) + if isinstance(url_path, str): + return url_path + get_url_path = getattr(item, "get", None) + if callable(get_url_path): + value = get_url_path("url_path") + if isinstance(value, str): + return value + return None + + +def _existing_dashboard(items) -> bool: + """Whether a dashboard under the stable url_path already exists.""" + return any(_item_url_path(item) == DASHBOARD_URL_PATH for item in items) + + +async def async_register_dashboard(hass: HomeAssistant) -> bool: + """Register the NestQuest panel dashboard exactly once. + + Returns True when a dashboard under the stable ``url_path`` exists + after the call (pre-existing or created here), False when the + Lovelace surfaces were unavailable or refused. A dashboard already + registered under the stable ``url_path`` — whether ours from a + previous setup or a user's own — skips BOTH steps and is left + COMPLETELY alone: its stored config is never overwritten, it is + never deleted, never a duplicate. The strategy config is saved + ONLY for the dashboard created in THIS call, whose fresh content + store is empty, so the save cannot clobber anything. + """ + collection = _dashboards_collection(hass) + if collection is None: + _LOGGER.warning( + "Lovelace dashboards collection unavailable; the NestQuest " + "panel dashboard was not registered. Enable the Lovelace " + "integration in storage mode or create a dashboard with " + "strategy %s manually.", + DASHBOARD_STRATEGY_TYPE, + ) + return False + try: + # Single-flight: two concurrent entry setups must not both pass + # the exists-check and race on the url_path create. + async with _registration_lock(hass): + # async_items() is a SYNC @callback on the real collection + # (helpers/collection.py ObservableCollection) — not awaited. + if _existing_dashboard(collection.async_items()): + return True + await collection.async_create_item( + { + "url_path": DASHBOARD_URL_PATH, + "title": DASHBOARD_TITLE, + "icon": DASHBOARD_ICON, + "show_in_sidebar": True, + "allow_single_word": True, + } + ) + # Step two (mirroring HA's _create_map_dashboard): the + # strategy CONFIG goes through the fresh dashboard's + # content manager, not through the metadata collection. + store = _dashboard_store(hass, DASHBOARD_URL_PATH) + if store is None: + _LOGGER.warning( + "The NestQuest panel dashboard was registered but its " + "Lovelace content manager never appeared; add the " + "strategy %s to the dashboard manually.", + DASHBOARD_STRATEGY_TYPE, + ) + return False + await store.async_save( + {"strategy": {"type": DASHBOARD_STRATEGY_TYPE}} + ) + except Exception: + _LOGGER.warning( + "Registering the NestQuest panel dashboard in the Lovelace " + "dashboards collection failed; set it up manually with " + "strategy %s.", + DASHBOARD_STRATEGY_TYPE, + exc_info=True, + ) + return False + return True diff --git a/custom_components/nestquest/manifest.json b/custom_components/nestquest/manifest.json index 157d18a..c4fe61d 100644 --- a/custom_components/nestquest/manifest.json +++ b/custom_components/nestquest/manifest.json @@ -7,7 +7,8 @@ "config_flow": true, "dependencies": [ "frontend", - "http" + "http", + "lovelace" ], "documentation": "https://github.com/Maniacs-Mansion/NestQuest", "integration_type": "hub", diff --git a/custom_components/nestquest/sensor.py b/custom_components/nestquest/sensor.py index eef0417..7bfdfc1 100644 --- a/custom_components/nestquest/sensor.py +++ b/custom_components/nestquest/sensor.py @@ -32,6 +32,7 @@ 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 homeassistant.util import slugify from .const import DOMAIN from .coordinator import ( @@ -41,13 +42,21 @@ from .coordinator import ( ) from .core.snapshot import instance_payload -#: Home Assistant's state machine refuses states longer than 255 +#: HA'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 +#: Entity-id length cap the roster applies to HA's slugify: HA's +#: slugifier itself does NOT truncate, but the entity registry derives +#: entity ids as ``.`` and truncates the full entity_id at +#: MAX_LENGTH_STATE_ENTITY_ID (64), so for this module's ``sensor.`` +#: entities the slugged object id alone may not exceed +#: 64 - len("sensor."). +_MAX_SLUG_LENGTH = 64 - len("sensor.") + def _state_within_limit(state: Any) -> Any: """Return ``state`` unchanged, refusing strings over HA's limit. @@ -98,6 +107,51 @@ def _next_open_quest( ) +def _slugify(text: str) -> str: + """Slugify a display name the way Home Assistant derives entity ids. + + Runs ``homeassistant.util.slugify`` — the real slugifier HA runs + entity names through, which transliterates every script to ASCII + through unidecode (so non-Latin names survive: ``京子`` slugs to + ``jing_zi``) before folding to lowercase and turning every run of + non-alphanumerics into one underscore, answering HA's ``unknown`` + sentinel when a name slugs to nothing — then caps the result at + the object-id length the entity registry enforces (the registry + truncates the full ``sensor.`` + slug entity_id at 64 characters, + so the slug alone is capped like this; HA's slugify does not + truncate by itself). + """ + return slugify(text)[:_MAX_SLUG_LENGTH] + + +def _child_roster(children: Iterable[ChildDaySnapshot]) -> list[dict[str, Any]]: + """The ordered child roster the panel cards discover children from. + + One entry per ACTIVE child in the snapshot's order (the API's + sort_order, then id): ``child_id``, ``name``, and the ``slug`` the + entity-id derivation produces for the child's name — the piece the + cards need to build ``sensor.nestquest__...`` ids. Duplicate + display names get the registry's ``_2``/``_3`` suffix in snapshot + order, matching how HA resolves the collision at registration. The + rollups' ``children`` attribute (per-child counts) is unchanged and + carries a different shape on purpose. + """ + slugs: dict[str, int] = {} + roster: list[dict[str, Any]] = [] + for child in children: + base = _slugify(child.child_name) + taken = slugs.get(base, 0) + 1 + slugs[base] = taken + roster.append( + { + "child_id": child.child_id, + "name": child.child_name, + "slug": base if taken == 1 else f"{base}_{taken}", + } + ) + return roster + + class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity): """Base for the per-child day sensors. @@ -356,7 +410,12 @@ class _NestQuestHouseholdSensor(CoordinatorEntity, SensorEntity): class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor): - """Total quests due today across active children.""" + """Total quests due today across active children. + + Also publishes ``child_roster`` (see :func:`_child_roster`): the + ordered child identity list the panel cards read to discover the + board's children when the card config carries no ``child_order``. + """ _attr_name = "NestQuest household quests due today" _attr_state_class = SensorStateClass.MEASUREMENT @@ -380,7 +439,8 @@ class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor): "children": { str(child.child_id): child.due_today for child in data.children - } + }, + "child_roster": _child_roster(data.children), } diff --git a/custom_components/nestquest/www/nestquest-cards.js b/custom_components/nestquest/www/nestquest-cards.js index ede7aab..d67e35d 100644 --- a/custom_components/nestquest/www/nestquest-cards.js +++ b/custom_components/nestquest/www/nestquest-cards.js @@ -1,7 +1,7 @@ -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(); +const B = globalThis, nt = B.ShadowRoot && (B.ShadyCSS === void 0 || B.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, st = /* @__PURE__ */ Symbol(), lt = /* @__PURE__ */ new WeakMap(); let zt = class { constructor(t, e, n) { - if (this._$cssResult$ = !0, n !== it) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); + if (this._$cssResult$ = !0, n !== st) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); this.cssText = t, this.t = e; } get styleSheet() { @@ -17,14 +17,14 @@ let zt = class { return this.cssText; } }; -const v = (s) => new zt(typeof s == "string" ? s : s + "", void 0, it), Pt = (s, ...t) => { +const v = (s) => new zt(typeof s == "string" ? s : s + "", void 0, st), Pt = (s, ...t) => { const e = s.length === 1 ? s[0] : t.reduce((n, i, r) => n + ((o) => { if (o._$cssResult$ === !0) return o.cssText; if (typeof o == "number") return o; throw Error("Value passed to 'css' function must be a 'css' function result: " + o + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security."); })(i) + s[r + 1], s[0]); - return new zt(e, s, it); -}, Bt = (s, t) => { + return new zt(e, s, st); +}, Ft = (s, t) => { if (nt) s.adoptedStyleSheets = t.map((e) => e instanceof CSSStyleSheet ? e : e.styleSheet); else for (const e of t) { const n = document.createElement("style"), i = B.litNonce; @@ -35,10 +35,10 @@ const v = (s) => new zt(typeof s == "string" ? s : s + "", void 0, it), Pt = (s, for (const n of t.cssRules) e += n.cssText; return v(e); })(s) : s; -const { is: Ft, defineProperty: Qt, getOwnPropertyDescriptor: Wt, getOwnPropertyNames: Zt, getOwnPropertySymbols: Gt, getPrototypeOf: Vt } = Object, W = globalThis, dt = W.trustedTypes, Kt = dt ? dt.emptyScript : "", Jt = W.reactiveElementPolyfillSupport, I = (s, t) => s, et = { toAttribute(s, t) { +const { is: Qt, defineProperty: Wt, getOwnPropertyDescriptor: Zt, getOwnPropertyNames: Gt, getOwnPropertySymbols: Vt, getPrototypeOf: Kt } = Object, W = globalThis, dt = W.trustedTypes, Jt = dt ? dt.emptyScript : "", Xt = W.reactiveElementPolyfillSupport, I = (s, t) => s, et = { toAttribute(s, t) { switch (t) { case Boolean: - s = s ? Kt : null; + s = s ? Jt : null; break; case Object: case Array: @@ -63,7 +63,7 @@ const { is: Ft, defineProperty: Qt, getOwnPropertyDescriptor: Wt, getOwnProperty } } return e; -} }, It = (s, t) => !Ft(s, t), pt = { attribute: !0, type: String, converter: et, reflect: !1, useDefault: !1, hasChanged: It }; +} }, It = (s, t) => !Qt(s, t), pt = { attribute: !0, type: String, converter: et, reflect: !1, useDefault: !1, hasChanged: It }; Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), W.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); let E = class extends HTMLElement { static addInitializer(t) { @@ -75,11 +75,11 @@ let E = 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(), i = this.getPropertyDescriptor(t, n, e); - i !== void 0 && Qt(this.prototype, t, i); + i !== void 0 && Wt(this.prototype, t, i); } } static getPropertyDescriptor(t, e, n) { - const { get: i, set: r } = Wt(this.prototype, t) ?? { get() { + const { get: i, set: r } = Zt(this.prototype, t) ?? { get() { return this[e]; }, set(o) { this[e] = o; @@ -94,13 +94,13 @@ let E = class extends HTMLElement { } static _$Ei() { if (this.hasOwnProperty(I("elementProperties"))) return; - const t = Vt(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(I("finalized"))) return; if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(I("properties"))) { - const e = this.properties, n = [...Zt(e), ...Gt(e)]; + const e = this.properties, n = [...Gt(e), ...Vt(e)]; for (const i of n) this.createProperty(i, e[i]); } const t = this[Symbol.metadata]; @@ -146,7 +146,7 @@ let E = class extends HTMLElement { } createRenderRoot() { const t = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions); - return Bt(t, this.constructor.elementStyles), t; + return Ft(t, this.constructor.elementStyles), t; } connectedCallback() { this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(!0), this._$EO?.forEach((t) => t.hostConnected?.()); @@ -246,15 +246,15 @@ let E = class extends HTMLElement { firstUpdated(t) { } }; -E.elementStyles = [], E.shadowRootOptions = { mode: "open" }, E[I("elementProperties")] = /* @__PURE__ */ new Map(), E[I("finalized")] = /* @__PURE__ */ new Map(), Jt?.({ ReactiveElement: E }), (W.reactiveElementVersions ??= []).push("2.1.2"); -const st = globalThis, ht = (s) => s, F = st.trustedTypes, ut = F ? F.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, Lt = "$lit$", $ = `lit$${Math.random().toFixed(9).slice(2)}$`, Ut = "?" + $, Xt = `<${Ut}>`, S = document, L = () => S.createComment(""), U = (s) => s === null || typeof s != "object" && typeof s != "function", rt = Array.isArray, Yt = (s) => rt(s) || typeof s?.[Symbol.iterator] == "function", V = `[ +E.elementStyles = [], E.shadowRootOptions = { mode: "open" }, E[I("elementProperties")] = /* @__PURE__ */ new Map(), E[I("finalized")] = /* @__PURE__ */ new Map(), Xt?.({ ReactiveElement: E }), (W.reactiveElementVersions ??= []).push("2.1.2"); +const it = globalThis, ht = (s) => s, F = it.trustedTypes, ut = F ? F.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, Lt = "$lit$", $ = `lit$${Math.random().toFixed(9).slice(2)}$`, Ut = "?" + $, Yt = `<${Ut}>`, S = document, L = () => S.createComment(""), U = (s) => s === null || typeof s != "object" && typeof s != "function", rt = Array.isArray, te = (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) { +\f\r"'\`<>=]|("|')|))|$)`, "g"), gt = /'/g, _t = /"/g, Rt = /^(?:script|style|textarea|title)$/i, ee = (s) => (t, ...e) => ({ _$litType$: s, strings: t, values: e }), d = ee(1), T = /* @__PURE__ */ Symbol.for("lit-noChange"), g = /* @__PURE__ */ Symbol.for("lit-nothing"), yt = /* @__PURE__ */ new WeakMap(), C = S.createTreeWalker(S, 129); +function jt(s, t) { if (!rt(s) || !s.hasOwnProperty("raw")) throw Error("invalid template strings array"); return ut !== void 0 ? ut.createHTML(t) : t; } -const ee = (s, t) => { +const ne = (s, t) => { const e = s.length - 1, n = []; let i, r = t === 2 ? "" : t === 3 ? "" : "", o = z; for (let l = 0; l < e; l++) { @@ -262,16 +262,16 @@ const ee = (s, t) => { 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); + r += o === z ? a + Yt : c >= 0 ? (n.push(p), a.slice(0, c) + Lt + a.slice(c) + $ + h) : a + $ + (c === -2 ? l : h); } - return [Dt(s, r + (s[e] || "") + (t === 2 ? "" : t === 3 ? "" : "")), n]; + return [jt(s, r + (s[e] || "") + (t === 2 ? "" : t === 3 ? "" : "")), n]; }; class R { constructor({ strings: t, _$litType$: e }, n) { let i; this.parts = []; let r = 0, o = 0; - const l = t.length - 1, a = this.parts, [p, u] = ee(t, e); + const l = t.length - 1, a = this.parts, [p, u] = ne(t, e); if (this.el = R.createElement(p, n), C.currentNode = this.el.content, e === 2 || e === 3) { const c = this.el.content.firstChild; c.replaceWith(...c.childNodes); @@ -280,7 +280,7 @@ class R { 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); + a.push({ type: 1, index: r, name: m[2], strings: h, ctor: m[1] === "." ? ie : m[1] === "?" ? re : m[1] === "@" ? oe : 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; @@ -309,7 +309,7 @@ function N(s, t, e = s, n) { const r = U(t) ? void 0 : t._$litDirective$; return i?.constructor !== r && (i?._$AO?.(!1), r === void 0 ? i = void 0 : (i = new r(s), i._$AT(s, e, n)), n !== void 0 ? (e._$Co ??= [])[n] = i : e._$Cl = i), i !== void 0 && (t = N(s, i._$AS(s, t.values), i, n)), t; } -class ne { +class se { constructor(t, e) { this._$AV = [], this._$AN = void 0, this._$AD = t, this._$AM = e; } @@ -326,7 +326,7 @@ class ne { for (; a !== void 0; ) { if (o === a.index) { let p; - a.type === 2 ? p = new O(r, r.nextSibling, this, t) : a.type === 1 ? p = new a.ctor(r, a.name, a.strings, this, t) : a.type === 6 && (p = new oe(r, this, t)), this._$AV.push(p), a = n[++l]; + 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 ae(r, this, t)), this._$AV.push(p), a = n[++l]; } o !== a?.index && (r = C.nextNode(), o++); } @@ -356,7 +356,7 @@ class O { return this._$AB; } _$AI(t, e = this) { - t = N(this, t, e), U(t) ? t === g || t == null || t === "" ? (this._$AH !== g && this._$AR(), this._$AH = g) : t !== this._$AH && t !== T && this._(t) : t._$litType$ !== void 0 ? this.$(t) : t.nodeType !== void 0 ? this.T(t) : Yt(t) ? this.k(t) : this._(t); + 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) : te(t) ? this.k(t) : this._(t); } O(t) { return this._$AA.parentNode.insertBefore(t, this._$AB); @@ -368,10 +368,10 @@ 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, i = typeof n == "number" ? this._$AC(t) : (n.el === void 0 && (n.el = R.createElement(Dt(n.h, n.h[0]), this.options)), n); + const { values: e, _$litType$: n } = t, i = typeof n == "number" ? this._$AC(t) : (n.el === void 0 && (n.el = R.createElement(jt(n.h, n.h[0]), this.options)), n); if (this._$AH?._$AD === i) this._$AH.p(e); else { - const r = new ne(i, this), o = r.u(this.options); + const r = new se(i, this), o = r.u(this.options); r.p(e), this.T(o), this._$AH = r; } } @@ -429,7 +429,7 @@ class ie extends Z { this.element[this.name] = t === g ? void 0 : t; } } -class se extends Z { +class re extends Z { constructor() { super(...arguments), this.type = 4; } @@ -437,7 +437,7 @@ class se extends Z { this.element.toggleAttribute(this.name, !!t && t !== g); } } -class re extends Z { +class oe extends Z { constructor(t, e, n, i, r) { super(t, e, n, i, r), this.type = 5; } @@ -450,7 +450,7 @@ class re extends Z { typeof this._$AH == "function" ? this._$AH.call(this.options?.host ?? this.element, t) : this._$AH.handleEvent(t); } } -class oe { +class ae { constructor(t, e, n) { this.element = t, this.type = 6, this._$AN = void 0, this._$AM = e, this.options = n; } @@ -461,9 +461,9 @@ class oe { N(this, t); } } -const ae = { I: O }, le = st.litHtmlPolyfillSupport; -le?.(R, O), (st.litHtmlVersions ??= []).push("3.3.3"); -const ce = (s, t, e) => { +const le = { I: O }, ce = it.litHtmlPolyfillSupport; +ce?.(R, O), (it.litHtmlVersions ??= []).push("3.3.3"); +const de = (s, t, e) => { const n = e?.renderBefore ?? t; let i = n._$litPart$; if (i === void 0) { @@ -483,7 +483,7 @@ let A = class extends E { } update(t) { const e = this.render(); - this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t), this._$Do = ce(e, this.renderRoot, this.renderOptions); + this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t), this._$Do = de(e, this.renderRoot, this.renderOptions); } connectedCallback() { super.connectedCallback(), this._$Do?.setConnected(!0); @@ -496,14 +496,14 @@ let A = class extends E { } }; A._$litElement$ = !0, A.finalized = !0, ot.litElementHydrateSupport?.({ LitElement: A }); -const de = ot.litElementPolyfillSupport; -de?.({ LitElement: A }); +const pe = ot.litElementPolyfillSupport; +pe?.({ LitElement: A }); (ot.litElementVersions ??= []).push("4.2.2"); -const jt = ':host{--nq-p-parchment-top: #f0e4c7;--nq-p-parchment-bottom: #ddcca3;--nq-p-parchment: radial-gradient(ellipse at 25% 10%, rgba(255,255,255,.5), transparent 55%), radial-gradient(ellipse at 85% 90%, rgba(120,86,44,.28), transparent 60%), repeating-linear-gradient(93deg, rgba(150,115,70,.05) 0 2px, transparent 2px 6px), repeating-linear-gradient(2deg, rgba(150,115,70,.04) 0 3px, transparent 3px 7px), linear-gradient(var(--nq-p-parchment-top), var(--nq-p-parchment-bottom));--nq-p-vignette: inset 0 0 200px rgba(80,52,20,.35);--nq-p-card-open: linear-gradient(#fdf7e6, #f2e7c9);--nq-p-card-done: linear-gradient(#f0e8d3, #e6dcc2);--nq-p-card-panel: linear-gradient(#fcf6e6, #f1e6ca);--nq-p-card-border: rgba(120,88,48,.38);--nq-p-panel-border: rgba(92,62,26,.4);--nq-p-card-shadow: 0 3px 0 rgba(120,88,48,.2), inset 0 1px 0 rgba(255,255,255,.7);--nq-p-panel-shadow: 0 6px 0 rgba(92,62,26,.18), inset 0 2px 0 rgba(255,255,255,.7);--nq-p-frame-outer: 2px solid rgba(92,62,26,.45);--nq-p-frame-inner: 1px solid rgba(92,62,26,.28);--nq-p-rule: 2px solid rgba(92,62,26,.35);--nq-p-ink: #2b1f14;--nq-p-ink-secondary: #5c452a;--nq-p-ink-muted: #6f6455;--nq-p-ink-away: #4d4433;--nq-p-ink-late: #8f1526;--nq-brand-blue: #3B3AB8;--nq-brand-purple: #A035CC;--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-p-icon-tile: var(--nq-brand-gradient);--nq-p-icon-tile-done: rgba(92,62,26,.16);--nq-p-crest: var(--nq-brand-gradient);--nq-p-crest-away: linear-gradient(135deg, #6b6b7a, #7a7286);--nq-p-seal: radial-gradient(circle at 35% 30%, #a8283a, #6d1322);--nq-p-seal-shadow: 0 4px 10px rgba(60,10,20,.4), inset 0 0 0 4px rgba(255,255,255,.14);--nq-p-seal-size: 78px;--nq-p-dock-bg: rgba(43,31,20,.9);--nq-p-dock-ink: #f7efdb;--nq-p-dock-ink-secondary: #d8c9a6;--nq-p-dock-divider: rgba(233,220,189,.3);--nq-p-dock-height: 84px;--nq-p-font-display: "Cinzel Decorative", Cinzel, serif;--nq-p-font-heading: Cinzel, serif;--nq-p-font-body: Nunito, system-ui, sans-serif;--nq-p-size-hero: 86px;--nq-p-size-wordmark: 66px;--nq-p-size-title: 56px;--nq-p-size-name: 48px;--nq-p-size-section: 32px;--nq-p-size-quest: 30px;--nq-p-size-body: 22px;--nq-p-size-label: 21px;--nq-p-track-label: .14em;--nq-p-track-kicker: .3em;--nq-p-quest-min-height: 116px;--nq-p-quest-gap: 24px;--nq-p-quest-pad: 22px;--nq-p-tile-size: 64px;--nq-p-button-height: 72px;--nq-p-button-min-width: 150px;--nq-p-confirm-button: 108px;--nq-p-radius-card: 12px;--nq-p-radius-panel: 20px;--nq-p-radius-pill: 9999px;--nq-p-page-inset: 62px;--nq-p-frame-inset: 26px;--nq-ease-out: cubic-bezier(0, 0, .2, 1);--nq-dur-micro: .12s;--nq-dur-base: .2s;--nq-dur-modal: .35s}'; +const Dt = ':host{--nq-p-parchment-top: #f0e4c7;--nq-p-parchment-bottom: #ddcca3;--nq-p-parchment: radial-gradient(ellipse at 25% 10%, rgba(255,255,255,.5), transparent 55%), radial-gradient(ellipse at 85% 90%, rgba(120,86,44,.28), transparent 60%), repeating-linear-gradient(93deg, rgba(150,115,70,.05) 0 2px, transparent 2px 6px), repeating-linear-gradient(2deg, rgba(150,115,70,.04) 0 3px, transparent 3px 7px), linear-gradient(var(--nq-p-parchment-top), var(--nq-p-parchment-bottom));--nq-p-vignette: inset 0 0 200px rgba(80,52,20,.35);--nq-p-card-open: linear-gradient(#fdf7e6, #f2e7c9);--nq-p-card-done: linear-gradient(#f0e8d3, #e6dcc2);--nq-p-card-panel: linear-gradient(#fcf6e6, #f1e6ca);--nq-p-card-border: rgba(120,88,48,.38);--nq-p-panel-border: rgba(92,62,26,.4);--nq-p-card-shadow: 0 3px 0 rgba(120,88,48,.2), inset 0 1px 0 rgba(255,255,255,.7);--nq-p-panel-shadow: 0 6px 0 rgba(92,62,26,.18), inset 0 2px 0 rgba(255,255,255,.7);--nq-p-frame-outer: 2px solid rgba(92,62,26,.45);--nq-p-frame-inner: 1px solid rgba(92,62,26,.28);--nq-p-rule: 2px solid rgba(92,62,26,.35);--nq-p-ink: #2b1f14;--nq-p-ink-secondary: #5c452a;--nq-p-ink-muted: #6f6455;--nq-p-ink-away: #4d4433;--nq-p-ink-late: #8f1526;--nq-brand-blue: #3B3AB8;--nq-brand-purple: #A035CC;--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-p-icon-tile: var(--nq-brand-gradient);--nq-p-icon-tile-done: rgba(92,62,26,.16);--nq-p-crest: var(--nq-brand-gradient);--nq-p-crest-away: linear-gradient(135deg, #6b6b7a, #7a7286);--nq-p-seal: radial-gradient(circle at 35% 30%, #a8283a, #6d1322);--nq-p-seal-shadow: 0 4px 10px rgba(60,10,20,.4), inset 0 0 0 4px rgba(255,255,255,.14);--nq-p-seal-size: 78px;--nq-p-dock-bg: rgba(43,31,20,.9);--nq-p-dock-ink: #f7efdb;--nq-p-dock-ink-secondary: #d8c9a6;--nq-p-dock-divider: rgba(233,220,189,.3);--nq-p-dock-height: 84px;--nq-p-font-display: "Cinzel Decorative", Cinzel, serif;--nq-p-font-heading: Cinzel, serif;--nq-p-font-body: Nunito, system-ui, sans-serif;--nq-p-size-hero: 86px;--nq-p-size-wordmark: 66px;--nq-p-size-title: 56px;--nq-p-size-name: 48px;--nq-p-size-section: 32px;--nq-p-size-quest: 30px;--nq-p-size-body: 22px;--nq-p-size-label: 21px;--nq-p-track-label: .14em;--nq-p-track-kicker: .3em;--nq-p-quest-min-height: 116px;--nq-p-quest-gap: 24px;--nq-p-quest-pad: 22px;--nq-p-tile-size: 64px;--nq-p-button-height: 72px;--nq-p-button-min-width: 150px;--nq-p-confirm-button: 108px;--nq-p-radius-card: 12px;--nq-p-radius-panel: 20px;--nq-p-radius-pill: 9999px;--nq-p-page-inset: 62px;--nq-p-frame-inset: 26px;--nq-ease-out: cubic-bezier(0, 0, .2, 1);--nq-dur-micro: .12s;--nq-dur-base: .2s;--nq-dur-modal: .35s}'; function at(s) { window.customCards = window.customCards ?? [], window.customCards.push(s); } -const pe = [ +const he = [ "nestquest_quest_completed", "nestquest_quest_uncompleted", "nestquest_quest_missed", @@ -524,7 +524,7 @@ function Q(s, t) { } return bt.set(e, i), i; } -function he(s) { +function ue(s) { const t = s?.config; if (!t || typeof t != "object") return; @@ -534,14 +534,14 @@ function he(s) { function K(s, t) { return `${Q(t, { weekday: "long" }).format(s)}, ${Q(t, { month: "short", day: "numeric" }).format(s)}`; } -function ue(s, t) { +function fe(s, t) { return Q(t, { hour: "numeric", minute: "2-digit", hour12: !0 }).format(s); } -const fe = { +const me = { "clear-night": "Clear", cloudy: "Cloudy", exceptional: "Clear", @@ -557,7 +557,7 @@ const fe = { sunny: "Sunny", windy: "Windy", "windy-variant": "Windy" -}, me = { +}, ge = { "clear-night": "Clear night skies", cloudy: "Grey skies today", exceptional: "A striking day", @@ -583,16 +583,22 @@ function J(s, t) { const e = typeof s == "number" ? s : Number(s); return Number.isFinite(e) ? e : t; } +function _e(s) { + if (s === null || typeof s != "object" || Array.isArray(s)) + return ""; + const t = s.slug; + return typeof t == "string" ? t.trim() : ""; +} function xt(s) { return Number.isFinite(s) ? Math.min(100, Math.max(0, s)) : 0; } function wt(s) { return Math.max(0, Math.round(s)); } -function ge(s) { +function ye(s) { return s.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); } -function _e(s, t) { +function be(s, t) { const e = Q(t, { year: "numeric", month: "2-digit", @@ -614,7 +620,7 @@ function _e(s, t) { ); return Number.isFinite(i) ? i - s : 0; } -function ye(s, t) { +function ve(s, t) { if (typeof s != "string") return null; const e = s.split("-"); @@ -627,17 +633,17 @@ function ye(s, t) { const a = new Date(n, i - 1, r); return Number.isNaN(a.getTime()) ? null : a; } - const o = Date.UTC(n, i - 1, r), l = new Date(o - _e(o, t)); + const o = Date.UTC(n, i - 1, r), l = new Date(o - be(o, t)); return Number.isNaN(l.getTime()) ? null : l; } -function be(s) { - const t = fe[s]; +function xe(s) { + const t = me[s]; return t || s.charAt(0).toUpperCase() + s.slice(1); } -function ve(s, t) { - return me[s] ?? t; +function we(s, t) { + return ge[s] ?? t; } -const $t = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", xe = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", we = Pt` +const $t = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", $e = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", qe = Pt` * { box-sizing: border-box; } @@ -709,7 +715,7 @@ const $t = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", xe = "polygon(50% justify-content: center; width: 34px; height: 34px; - clip-path: ${v(xe)}; + clip-path: ${v($e)}; background: var(--nq-brand-gradient); } @@ -1032,7 +1038,7 @@ const $t = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", xe = "polygon(50% font-weight: 800; line-height: 1; } -`, $e = d` -`, qe = d``, Ce = d` -`, ke = d``, Ae = d` -`, Ce = d``, Se = d` -`, Ae = d``, Te = d` `, qt = { - icon: Ce, + icon: Se, headline: "NestQuest is not set up yet", body: "A parent needs to finish setting up NestQuest before the party can gather." -}, Se = { - icon: Ae, +}, Ee = { + icon: Te, headline: "The records cannot be reached", body: "The party's records are quiet right now. NestQuest will return shortly." -}, Te = d` `; -class Ee extends A { +class Me extends A { static properties = { hass: { attribute: !1 }, _config: { state: !0 }, _now: { state: !0 } }; - static styles = [v(jt), we]; + static styles = [v(Dt), qe]; hass; _config; _now = /* @__PURE__ */ new Date(); @@ -1150,7 +1156,7 @@ class Ee extends A { e === this._subGeneration && (this._unsubscribeLive(), this._armSubscribeRetry()); }); }; - for (const i of pe) + for (const i of he) n( t.subscribeEvents(() => this.requestUpdate(), i) ); @@ -1213,7 +1219,7 @@ class Ee extends A { `; } _timeZone() { - return he(this.hass); + return ue(this.hass); } _state(t) { const n = this.hass?.states?.[t]; @@ -1225,7 +1231,19 @@ class Ee extends A { const t = this._config.child_order; return Array.isArray(t) ? t.filter( (e) => typeof e == "string" && e.trim().length > 0 - ).map((e) => e.trim()) : []; + ).map((e) => e.trim()) : this._discoveredChildSlugs(); + } + /** Zero-config discovery (Feature 20): the ordered child roster the + * integration publishes on the household rollup + * (custom_components/nestquest/sensor.py — ``child_roster``, the + * snapshot's sort_order, then id), each entry carrying the slug the + * per-child entity ids are built from. Consulted only when the + * config has no child_order — explicit configuration wins. */ + _discoveredChildSlugs() { + const e = this._state( + "sensor.nestquest_household_quests_due_today" + )?.attributes?.child_roster; + return Array.isArray(e) ? e.map((n) => _e(n)).filter((n) => n.length > 0) : []; } _plates() { return this._childSlugs().map((t) => this._plate(t)); @@ -1249,11 +1267,11 @@ class Ee extends A { const G = o?.attributes?.child_name; typeof G == "string" && G.trim() && (m = G.trim()); } - m || (m = ge(t)); - const x = ye( + m || (m = ye(t)); + const x = ve( o?.attributes?.next_present, this._timeZone() - ), D = x ? `Returns ${K(x, this._timeZone())}` : null; + ), j = x ? `Returns ${K(x, this._timeZone())}` : null; return { slug: t, name: m, @@ -1262,7 +1280,7 @@ class Ee extends A { completed: p, due: a, pct: c, - returns: D, + returns: j, unresolved: e }; } @@ -1279,7 +1297,7 @@ class Ee extends A { } _boardNotice() { const t = this._plates(); - return t.length === 0 ? qt : t.every((e) => e.unresolved !== null) ? t.some((e) => e.unresolved === "stale") ? Se : qt : null; + return t.length === 0 ? qt : t.every((e) => e.unresolved !== null) ? t.some((e) => e.unresolved === "stale") ? Ee : qt : null; } _renderNotice(t) { return d` @@ -1302,7 +1320,7 @@ class Ee extends A { ${t.name} - ${ke} + ${Ae} Unknown — @@ -1322,7 +1340,7 @@ class Ee extends A { ${t.name} - ${$e} + ${ke} Home today @@ -1341,7 +1359,7 @@ class Ee extends A { ${t.name} - ${qe} + ${Ce} On travels ${t.returns ?? "Returns —"} @@ -1393,13 +1411,13 @@ class Ee extends A {
${K(this._now, this._timeZone())} - ${ue(this._now, this._timeZone())} + ${fe(this._now, this._timeZone())}
`; - const e = be(t.condition), n = ve(t.condition, e), i = t.temperature === null ? g : d`${Math.round(t.temperature)}°`, r = t.high === null || t.low === null ? null : `${Math.round(t.high)}° / ${Math.round(t.low)}°`; + const e = xe(t.condition), n = we(t.condition, e), i = t.temperature === null ? g : d`${Math.round(t.temperature)}°`, r = t.high === null || t.low === null ? null : `${Math.round(t.high)}° / ${Math.round(t.low)}°`; return d`
- ${Te} + ${Ne} ${i} @@ -1411,14 +1429,14 @@ class Ee extends A { `; } } -customElements.define("nestquest-party-board-card", Ee); +customElements.define("nestquest-party-board-card", Me); at({ type: "nestquest-party-board-card", name: "NestQuest Party Board", description: "Kids' attract screen for present and away adventurers." }); -const Ne = { CHILD: 2 }, Me = (s) => (...t) => ({ _$litDirective$: s, values: t }); -let Oe = class { +const Oe = { CHILD: 2 }, ze = (s) => (...t) => ({ _$litDirective$: s, values: t }); +let Pe = class { constructor(t) { } get _$AU() { @@ -1434,11 +1452,11 @@ let Oe = class { return this.render(...e); } }; -const { I: ze } = ae, kt = (s) => s, Ct = () => document.createComment(""), P = (s, t, e) => { +const { I: Ie } = le, kt = (s) => s, Ct = () => document.createComment(""), P = (s, t, e) => { const n = s._$AA.parentNode, i = t === void 0 ? s._$AB : t._$AA; if (e === void 0) { const r = n.insertBefore(Ct(), i), o = n.insertBefore(Ct(), i); - e = new ze(r, o, s, s.options); + e = new Ie(r, o, s, s.options); } else { const r = e._$AB.nextSibling, o = e._$AM, l = o !== s; if (l) { @@ -1454,16 +1472,16 @@ const { I: ze } = ae, kt = (s) => s, Ct = () => document.createComment(""), P = } } return e; -}, k = (s, t, e = s) => (s._$AI(t, e), s), Pe = {}, Ie = (s, t = Pe) => s._$AH = t, Le = (s) => s._$AH, X = (s) => { +}, k = (s, t, e = s) => (s._$AI(t, e), s), Le = {}, Ue = (s, t = Le) => s._$AH = t, Re = (s) => s._$AH, X = (s) => { s._$AR(), s._$AA.remove(); }; const At = (s, t, e) => { const n = /* @__PURE__ */ new Map(); for (let i = t; i <= e; i++) n.set(s[i], i); return n; -}, St = Me(class extends Oe { +}, St = ze(class extends Pe { constructor(s) { - if (super(s), s.type !== Ne.CHILD) throw Error("repeat() can only be used in text expressions"); + if (super(s), s.type !== Oe.CHILD) throw Error("repeat() can only be used in text expressions"); } dt(s, t, e) { let n; @@ -1477,7 +1495,7 @@ const At = (s, t, e) => { return this.dt(s, t, e).values; } update(s, [t, e, n]) { - const i = Le(s), { values: r, keys: o } = this.dt(t, e, n); + const i = Re(s), { values: r, keys: o } = this.dt(t, e, n); if (!Array.isArray(i)) return this.ut = o, r; const l = this.ut ??= [], a = []; let p, u, c = 0, f = i.length - 1, h = 0, m = r.length - 1; @@ -1490,8 +1508,8 @@ const At = (s, t, e) => { else if (p === void 0 && (p = At(o, h, m), u = At(l, c, f)), p.has(l[c])) if (p.has(l[f])) { const _ = u.get(o[h]), x = _ !== void 0 ? i[_] : null; if (x === null) { - const D = P(s, i[c]); - k(D, r[h]), a[h] = D; + const j = P(s, i[c]); + k(j, r[h]), a[h] = j; } else a[h] = k(x, r[h]), P(s, i[c], x), i[_] = null; h++; } else X(i[f]), f--; @@ -1504,9 +1522,9 @@ const At = (s, t, e) => { const _ = i[c++]; _ !== null && X(_); } - return this.ut = o, Ie(s, a), T; + return this.ut = o, Ue(s, a), T; } -}), Ue = [ +}), je = [ "nestquest_quest_completed", "nestquest_quest_uncompleted", "nestquest_quest_missed", @@ -1527,20 +1545,20 @@ function M(s, t) { } return Tt.set(e, i), i; } -function Re(s) { +function De(s) { const t = s?.config; if (!t || typeof t != "object") return; const e = t.time_zone; return typeof e == "string" && e.trim() ? e.trim() : void 0; } -function j(s, t) { +function D(s, t) { return `${M(t, { weekday: "long" }).format(s)}, ${M(t, { month: "short", day: "numeric" }).format(s)}`; } -function De(s, t) { +function He(s, t) { return M(t, { weekday: "long" }).format(s); } -function je(s, t) { +function Be(s, t) { const e = new Date(s); return Number.isNaN(e.getTime()) ? "" : M(t, { hour: "numeric", @@ -1548,14 +1566,14 @@ function je(s, t) { hour12: !0 }).format(e); } -function He(s, t) { +function Fe(s, t) { return M(t, { hour: "numeric", minute: "2-digit", hour12: !0 }).format(s); } -function Be(s) { +function Qe(s) { if (!s) return null; const t = /^(\d{1,2}):(\d{2})$/.exec(s.trim()); @@ -1582,7 +1600,7 @@ function b(s) { function Y(s) { return s.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); } -function Fe(s, t) { +function We(s, t) { const e = M(t, { year: "numeric", month: "2-digit", @@ -1617,14 +1635,14 @@ function Et(s, t) { const a = new Date(n, i - 1, r); return Number.isNaN(a.getTime()) ? null : a; } - const o = Date.UTC(n, i - 1, r), l = new Date(o - Fe(o, t)); + const o = Date.UTC(n, i - 1, r), l = new Date(o - We(o, t)); return Number.isNaN(l.getTime()) ? null : l; } -function Qe() { +function Ze() { const s = window.location.pathname.split("/").filter(Boolean); return s.length > 0 ? s[s.length - 1].toLowerCase() : ""; } -function We(s) { +function Ge(s) { if (!s || typeof s != "object") return null; const t = s, e = w(t.state).toLowerCase(); @@ -1647,7 +1665,7 @@ function We(s) { on_time: t.on_time === !0 ? !0 : t.on_time === !1 ? !1 : null }; } -const Ze = { +const Ve = { "clear-night": "Clear", cloudy: "Cloudy", exceptional: "Clear", @@ -1663,7 +1681,7 @@ const Ze = { sunny: "Sunny", windy: "Windy", "windy-variant": "Windy" -}, Ge = { +}, Ke = { "clear-night": "Clear night skies", cloudy: "Grey skies today", exceptional: "A striking day", @@ -1680,14 +1698,14 @@ const Ze = { windy: "A blustery day", "windy-variant": "A blustery day" }; -function Ve(s) { - const t = Ze[s]; +function Je(s) { + const t = Ve[s]; return t || s.charAt(0).toUpperCase() + s.slice(1); } -function Ke(s, t) { - return Ge[s] ?? t; +function Xe(s, t) { + return Ke[s] ?? t; } -const Nt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Je = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", Mt = [-9, 6, -4], Xe = Pt` +const Nt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ye = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", Mt = [-9, 6, -4], tn = Pt` * { box-sizing: border-box; } @@ -1824,7 +1842,7 @@ const Nt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Je = "polygon(50% justify-content: center; width: 44px; height: 44px; - clip-path: ${v(Je)}; + clip-path: ${v(Ye)}; background: var(--nq-brand-gradient); } @@ -2544,7 +2562,7 @@ const Nt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Je = "polygon(50% aria-hidden="true" > -`, Ye = d``, en = d` -`, tn = d``, nn = d` -`, en = d``, sn = d` -`, nn = d``, rn = d` -`, sn = d``, on = d` -`, rn = d``, an = d` -`, on = d``, ln = d` -`, an = d``, cn = d` -`, ln = { - icon: sn, +`, dn = { + icon: on, headline: "No adventurer chosen", body: "Open The Party and tap your crest to open your quest log." -}, cn = { - icon: rn, +}, pn = { + icon: an, headline: "NestQuest is not set up yet", body: "A parent needs to finish setting up NestQuest." -}, dn = { - icon: on, +}, hn = { + icon: ln, headline: "The records cannot be reached", body: "The party's records are quiet right now. NestQuest will return shortly." }, Ot = [ - { key: "morning", name: "Morning", range: "Until 11:59 AM", icon: tn }, + { key: "morning", name: "Morning", range: "Until 11:59 AM", icon: nn }, { key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: Ht }, - { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: en } + { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: sn } ]; -class pn extends A { +class un extends A { static properties = { hass: { attribute: !1 }, _config: { state: !0 }, @@ -2695,7 +2713,7 @@ class pn extends A { _toast: { state: !0 }, _countdown: { state: !0 } }; - static styles = [v(jt), Xe]; + static styles = [v(Dt), tn]; hass; _config; _now = /* @__PURE__ */ new Date(); @@ -2737,7 +2755,7 @@ class pn extends A { e === this._subGeneration && (this._unsubscribeLive(), this._armSubscribeRetry()); }); }; - for (const i of Ue) + for (const i of je) n( t.subscribeEvents(() => this.requestUpdate(), i) ); @@ -2790,18 +2808,18 @@ class pn extends A { _renderMain(t) { switch (t.kind) { case "no-adventurer": - return this._renderNotice(ln); - case "not-set-up": - return this._renderNotice(cn); - case "unreachable": return this._renderNotice(dn); + case "not-set-up": + return this._renderNotice(pn); + case "unreachable": + return this._renderNotice(hn); case "away": { const e = ["The quest log unlocks when they return."]; return t.returns && e.push(`Returns ${t.returns}`), d` ${this._renderHeader(t)}
- ${nn} + ${rn} ${t.name} is on travels ${e.map((n) => d`

${n}

`)}
@@ -2909,7 +2927,7 @@ class pn extends A { n?.attributes?.next_present, e ); - return i ? j(i, e) : null; + return i ? D(i, e) : null; } _renderNotice(t) { return d` @@ -2923,14 +2941,14 @@ class pn extends A { `; } _timeZone() { - return Re(this.hass); + return De(this.hass); } _state(t) { const n = this.hass?.states?.[t]; return n && typeof n == "object" ? n : null; } _childSlug() { - return Qe(); + return Ze(); } _boardPath() { return w(this._config?.board_path).replace(/\/+$/, ""); @@ -2966,7 +2984,7 @@ class pn extends A { const e = this._state(`sensor.nestquest_${t}_quests_due_today`)?.attributes?.instances; if (!Array.isArray(e)) return []; - const n = e.map(We).filter((r) => r !== null); + const n = e.map(Ge).filter((r) => r !== null); if (this._optimistic.size === 0) return n; let i = !1; @@ -3020,7 +3038,7 @@ class pn extends A { }; } _completeSub(t) { - const e = j(this._now, this._timeZone()), { claimed: n, onTime: i, late: r } = this._completeStats(); + const e = D(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() { @@ -3055,7 +3073,7 @@ class pn extends A { _?.attributes?.next_present, n ); - m = x ? De(x, n) : null; + m = x ? He(x, n) : null; } i.push({ slug: l, @@ -3080,7 +3098,7 @@ class pn extends A {

${p}

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

@@ -3158,7 +3176,7 @@ class pn extends A { } _renderQuest(t, e, n) { if (e) { - const o = Mt[n % Mt.length], l = t.completed_at ? je(t.completed_at, this._timeZone()) : ""; + const o = Mt[n % Mt.length], l = t.completed_at ? Be(t.completed_at, this._timeZone()) : ""; return d`
@@ -3178,7 +3196,7 @@ class pn extends A {
`; } - const i = Be(t.due_time), r = t.overdue ? { + const i = Qe(t.due_time), r = t.overdue ? { text: i ? `Overdue · due ${i}` : "Overdue", late: !0 } : { @@ -3261,7 +3279,7 @@ class pn extends A { type="button" @click=${() => this._closeConfirm()} > - ${Ye} + ${en} Not yet
`; - 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)}°`; + const e = Je(t.condition), n = Xe(t.condition, e), i = t.temperature === null ? g : d`${Math.round(t.temperature)}°`, r = t.high === null || t.low === null ? null : `${Math.round(t.high)}° / ${Math.round(t.low)}°`; return d`
- ${an} + ${cn} ${i} @@ -3441,19 +3459,19 @@ class pn extends A { this._logView().kind !== "complete-day" && this._armIdle(), this._confirm !== null && this._armConfirmTimer(); }; } -customElements.define("nestquest-quest-log-card", pn); +customElements.define("nestquest-quest-log-card", un); at({ type: "nestquest-quest-log-card", name: "NestQuest Quest Log", description: "One child's daily quests, grouped by window." }); -const hn = ":host{--nq-brand-blue: #3B3AB8;--nq-brand-purple: #A035CC;--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-a-page: #F4F4F6;--nq-a-surface: #FFFFFF;--nq-a-surface-subtle: #FAFAFA;--nq-a-selected: #EBEBF8;--nq-a-border: #E4E4EA;--nq-a-divider: #EDEDF1;--nq-a-ink: #18181D;--nq-a-ink-secondary: #52525E;--nq-a-ink-tertiary: #70707E;--nq-a-success: #15803D;--nq-a-danger: #DC2626;--nq-a-danger-strong: #B91C1C;--nq-a-danger-bg: #FEE2E2;--nq-a-warning-ink: #92400E;--nq-a-warning-ink-2: #78350F;--nq-a-warning-bg: #FEF3C7;--nq-a-warning-border: rgba(217,119,6,.35);--nq-a-info-bg: #DBEAFE;--nq-a-info-ink: #1E3A8A;--nq-a-info-icon: #1D4ED8;--nq-a-reversal: #7D28A0;--nq-a-font: Nunito, system-ui, sans-serif;--nq-a-size-screen: 24px;--nq-a-size-hero: 32px;--nq-a-size-card-title: 19px;--nq-a-size-stat: 22px;--nq-a-size-row: 13.5px;--nq-a-size-meta: 11px;--nq-a-size-label: 10px;--nq-a-track-label: .1em;--nq-a-density-page-pad: 14px 16px 92px;--nq-a-density-gap: 10px;--nq-a-density-card-pad: 13px;--nq-a-density-row-pad: 11px 13px;--nq-a-density-radius: 8px;--nq-a-density-shadow: none;--nq-a-tabbar-height: 70px;--nq-a-tap-min: 44px;--nq-a-radius-pill: 9999px;--nq-a-radius-sheet: 20px 20px 0 0;--nq-a-sheet-shadow: 0 -8px 28px rgba(9,9,11,.18);--nq-a-progress-height: 6px;--nq-ease-out: cubic-bezier(0, 0, .2, 1);--nq-dur-micro: .12s;--nq-dur-base: .2s;--nq-dur-modal: .35s}"; -class un extends A { +const fn = ":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 mn extends A { static properties = { hass: { attribute: !1 }, _config: { state: !0 } }; - static styles = [v(hn)]; + static styles = [v(fn)]; hass; _config; setConfig(t) { @@ -3468,14 +3486,68 @@ class un extends A { return d`
NestQuest Admin
`; } } -customElements.define("nestquest-admin-card", un); +customElements.define("nestquest-admin-card", mn); at({ type: "nestquest-admin-card", name: "NestQuest Admin", description: "Parent phone view for today, tasks, schedule, and history." }); -const fn = "/nestquest-static/nestquest-fonts.css"; +function Bt(s) { + return typeof s == "string" ? s.trim() : ""; +} +function gn(s) { + if (s === null || typeof s != "object" || Array.isArray(s)) + return ""; + const t = s.slug; + return typeof t == "string" ? t.trim() : ""; +} +function _n(s) { + const t = Bt(s.url_path).replace(/^\/+|\/+$/g, ""); + return t || (window.location.pathname.split("/").filter(Boolean)[0] ?? ""); +} +function yn(s) { + if (s === null || typeof s != "object" || Array.isArray(s)) + return ""; + const t = s.name; + return typeof t == "string" ? t.trim() : ""; +} +function bn(s) { + const n = s?.states?.["sensor.nestquest_household_quests_due_today"]?.attributes?.child_roster; + return Array.isArray(n) ? n.map((i) => ({ slug: gn(i), name: yn(i) })).filter((i) => i.slug.length > 0) : []; +} +class vn { + static async generate(t, e) { + const n = `/${_n(t)}`, i = Bt(t.weather_entity), r = { + type: "custom:nestquest-party-board-card", + quest_log_path: n + }; + i && (r.weather_entity = i); + const o = { + type: "custom:nestquest-quest-log-card", + board_path: n + }; + return i && (o.weather_entity = i), { + views: [ + { + path: "party", + title: "The Party", + type: "panel", + cards: [r] + }, + ...bn(e).map((l) => ({ + path: l.slug, + title: l.name ? `${l.name}'s Quest Log` : `${l.slug}'s Quest Log`, + type: "panel", + cards: [{ ...o }] + })) + ] + }; + } +} +window.customStrategies = window.customStrategies ?? {}; +window.customStrategies["nestquest-party"] = vn; +const xn = "/nestquest-static/nestquest-fonts.css"; if (!document.querySelector('link[data-nq-fonts=""]')) { const s = document.createElement("link"); - s.rel = "stylesheet", s.href = fn, s.dataset.nqFonts = "", document.head.appendChild(s); + s.rel = "stylesheet", s.href = xn, s.dataset.nqFonts = "", document.head.appendChild(s); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2a21a54..358034e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,10 +9,197 @@ "lit": "^3.3.0" }, "devDependencies": { + "jsdom": "^30.1.1", "typescript": "^5.9.2", "vite": "^7.1.5" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.1.tgz", + "integrity": "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.0", + "@csstools/css-color-parser": "^4.2.3", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz", + "integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz", + "integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -455,6 +642,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@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", @@ -892,6 +1097,79 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -967,6 +1245,66 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz", + "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.2.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.10.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lit": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz", @@ -998,6 +1336,23 @@ "@types/trusted-types": "^2.0.2" } }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/nanoid": { "version": "3.3.19", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", @@ -1017,6 +1372,19 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1066,6 +1434,26 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.63.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz", @@ -1112,6 +1500,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1139,6 +1540,52 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.4.14", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.14.tgz", + "integrity": "sha512-EahQoi+Q5oqmG3yxRHx+bwn36OaLbtw6jnTFcGjc8fcmktzTgk45xRnzAx8Ch87PFEiqRhoc3FWBmY+LBrvEGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.14" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.14", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.14.tgz", + "integrity": "sha512-KYkjfJHnIC5t+Gy7hPrMHgJmWc/N9FfmS+fggRPSlpFckidpB9HD6OzPsSTkfDH+MpZgcu2tJPGgLfHl979N1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1153,6 +1600,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.0.tgz", + "integrity": "sha512-AdzuGcAkzhbha3GgCSoUBx2P0quwym6qdFNCQz/fDGZc8w9XJ741wHUbt2kTmfPz+ZIOMkySFkiY7RXmiDDC8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -1227,6 +1684,71 @@ "optional": true } } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz", + "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/frontend/package.json b/frontend/package.json index 7e6dc9b..47b5619 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "lit": "^3.3.0" }, "devDependencies": { + "jsdom": "^30.1.1", "typescript": "^5.9.2", "vite": "^7.1.5" } diff --git a/frontend/src/cards/party-board.ts b/frontend/src/cards/party-board.ts index d012872..65c7925 100644 --- a/frontend/src/cards/party-board.ts +++ b/frontend/src/cards/party-board.ts @@ -143,6 +143,16 @@ function asNumber(value: unknown, fallback: number): number { return Number.isFinite(parsed) ? parsed : fallback; } +/** The slug a discovered roster entry carries, or "" when the entry is + * not an object with a non-empty slug string. */ +function asRosterSlug(entry: unknown): string { + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + return ""; + } + const slug = (entry as Record).slug; + return typeof slug === "string" ? slug.trim() : ""; +} + function clampPercent(value: number): number { if (!Number.isFinite(value)) { return 0; @@ -892,15 +902,34 @@ export class NestQuestPartyBoardCard extends LitElement { return []; } const order = this._config.child_order; - if (!Array.isArray(order)) { + if (Array.isArray(order)) { + return order + .filter( + (entry): entry is string => + typeof entry === "string" && entry.trim().length > 0 + ) + .map((entry) => entry.trim()); + } + return this._discoveredChildSlugs(); + } + + /** Zero-config discovery (Feature 20): the ordered child roster the + * integration publishes on the household rollup + * (custom_components/nestquest/sensor.py — ``child_roster``, the + * snapshot's sort_order, then id), each entry carrying the slug the + * per-child entity ids are built from. Consulted only when the + * config has no child_order — explicit configuration wins. */ + private _discoveredChildSlugs(): string[] { + const household = this._state( + "sensor.nestquest_household_quests_due_today" + ); + const roster = household?.attributes?.child_roster; + if (!Array.isArray(roster)) { return []; } - return order - .filter( - (entry): entry is string => - typeof entry === "string" && entry.trim().length > 0 - ) - .map((entry) => entry.trim()); + return roster + .map((entry) => asRosterSlug(entry)) + .filter((slug) => slug.length > 0); } private _plates(): ChildPlate[] { diff --git a/frontend/src/nestquest-cards.ts b/frontend/src/nestquest-cards.ts index 02316e3..f6f60d8 100644 --- a/frontend/src/nestquest-cards.ts +++ b/frontend/src/nestquest-cards.ts @@ -1,6 +1,7 @@ import "./cards/party-board"; import "./cards/quest-log"; import "./cards/admin"; +import "./strategy"; const FONTS_HREF = "/nestquest-static/nestquest-fonts.css"; if (!document.querySelector('link[data-nq-fonts=""]')) { diff --git a/frontend/src/strategy.ts b/frontend/src/strategy.ts new file mode 100644 index 0000000..598b3ae --- /dev/null +++ b/frontend/src/strategy.ts @@ -0,0 +1,161 @@ +import type { HassLike } from "./types"; + +type StateObject = { + state: unknown; + attributes?: Record | null; +}; + +type RosterEntry = { + name: string; + slug: string; +}; + +type View = { + path?: string; + title?: string; + type: string; + cards: Record[]; +}; + +type LovelaceConfig = { + views: View[]; +}; + +type StrategyConfig = Record; + +type StrategyClass = { + generate: ( + config: StrategyConfig, + hass: HassLike | undefined + ) => Promise; +}; + +declare global { + interface Window { + customStrategies?: Record; + } +} + +function asString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +/** The slug a discovered roster entry carries, or "" when the entry is + * not an object with a non-empty slug string — the same shape check + * the party board applies to the same attribute. */ +function asRosterSlug(entry: unknown): string { + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + return ""; + } + const slug = (entry as Record).slug; + return typeof slug === "string" ? slug.trim() : ""; +} + +/** The dashboard's URL path segment, used to COMPUTE the card paths the + * navigation needs. A dashboard lives at a single top-level segment + * (/), so the first pathname segment is the dashboard root — + * this is where the strategy renders, and navigating back to it shows + * the first view (the party board). An explicit ``url_path`` in the + * strategy configuration wins for setups that need to pin it. */ +function dashboardUrlPath(config: StrategyConfig): string { + const configured = asString(config.url_path).replace(/^\/+|\/+$/g, ""); + if (configured) { + return configured; + } + const segment = window.location.pathname.split("/").filter(Boolean)[0]; + return segment ?? ""; +} + +/** The display name a roster entry carries, or "" when it is not a + * non-empty string — the roster's ``name`` field (sensor.py + * ``_child_roster``), used for the quest-log view titles. */ +function asRosterName(entry: unknown): string { + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + return ""; + } + const name = (entry as Record).name; + return typeof name === "string" ? name.trim() : ""; +} + +/** The ordered child roster the integration publishes on the household + * rollup (custom_components/nestquest/sensor.py — ``child_roster``, + * the snapshot's sort_order, then id). The same source the party + * board's plate discovery reads, consumed here at render time so the + * quest-log views exist for exactly the active children. */ +function rosterEntries(hass: HassLike | undefined): RosterEntry[] { + const states = hass?.states as Record | undefined; + const household = states?.["sensor.nestquest_household_quests_due_today"]; + const roster = household?.attributes?.child_roster; + if (!Array.isArray(roster)) { + return []; + } + return roster + .map((entry) => ({ slug: asRosterSlug(entry), name: asRosterName(entry) })) + .filter((entry) => entry.slug.length > 0); +} + +/** + * Zero-config NestQuest dashboard strategy (Feature 20). + * + * A dashboard configured with ``strategy: { type: custom:nestquest-party }`` + * gets its views GENERATED at render time from the household roster: one + * party-board view (the dashboard root) and one quest-log view per child, + * ordered by the roster's sort_order. The paths the cards navigate by are + * COMPUTED, not configured: + * + * - the board's ``quest_log_path`` is the dashboard root; tapping a crest + * pushes ``/`` (party-board.ts ``_openQuestLog``); + * - each log view's ``path`` IS the child's slug, so the log resolves its + * child from the last pathname segment (quest-log.ts + * ``childSlugFromPath``) and its ``board_path`` back to the dashboard + * root (quest-log.ts ``_returnToBoard``). + * + * With zero children the board view still renders — the board card shows + * its own "not set up yet" notice for an empty roster, so no log views are + * needed. ``url_path`` and ``weather_entity`` may still be set explicitly + * in the strategy configuration; everything else is derived. + */ +export class NestQuestPartyStrategy { + static async generate( + config: StrategyConfig, + hass: HassLike | undefined + ): Promise { + const root = `/${dashboardUrlPath(config)}`; + const weatherEntity = asString(config.weather_entity); + const boardCard: Record = { + type: "custom:nestquest-party-board-card", + quest_log_path: root, + }; + if (weatherEntity) { + boardCard.weather_entity = weatherEntity; + } + const logCard: Record = { + type: "custom:nestquest-quest-log-card", + board_path: root, + }; + if (weatherEntity) { + logCard.weather_entity = weatherEntity; + } + return { + views: [ + { + path: "party", + title: "The Party", + type: "panel", + cards: [boardCard], + }, + ...rosterEntries(hass).map((entry) => ({ + path: entry.slug, + title: entry.name + ? `${entry.name}'s Quest Log` + : `${entry.slug}'s Quest Log`, + type: "panel", + cards: [{ ...logCard }], + })), + ], + }; + } +} + +window.customStrategies = window.customStrategies ?? {}; +window.customStrategies["nestquest-party"] = NestQuestPartyStrategy; diff --git a/frontend/tests/render-party-board.mjs b/frontend/tests/render-party-board.mjs new file mode 100644 index 0000000..4b0309f --- /dev/null +++ b/frontend/tests/render-party-board.mjs @@ -0,0 +1,80 @@ +/** + * Render the built party-board card under jsdom and print its plates. + * + * Usage: node render-party-board.mjs < spec.json + * + * The spec JSON carries {bundle, tag, config, hass, url?, click_plate?}; + * stdout receives one JSON object {plates, tappable, location, html} where + * plates is the ordered plate list the shadow DOM renders (name + + * present/away/unknown kind), tappable the plate names rendered as tappable + *