diff --git a/README.md b/README.md index 73a3097..fd6c0c5 100644 --- a/README.md +++ b/README.md @@ -174,8 +174,23 @@ 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 → +2. **Build a dashboard with only the NestQuest panel cards.** The + recommended path is the **zero-config dashboard strategy**: create a + Lovelace dashboard whose URL slug is `nestquest` with + + ```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:`. The manual instructions below describe + building the two views by hand if you prefer not to use the strategy. + + **Manual alternative:** 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 diff --git a/custom_components/nestquest/www/nestquest-cards.js b/custom_components/nestquest/www/nestquest-cards.js index 07ad934..d67e35d 100644 --- a/custom_components/nestquest/www/nestquest-cards.js +++ b/custom_components/nestquest/www/nestquest-cards.js @@ -3506,10 +3506,16 @@ function _n(s) { return t || (window.location.pathname.split("/").filter(Boolean)[0] ?? ""); } function yn(s) { - const n = s?.states?.["sensor.nestquest_household_quests_due_today"]?.attributes?.child_roster; - return Array.isArray(n) ? n.map((i) => gn(i)).filter((i) => i.length > 0) : []; + if (s === null || typeof s != "object" || Array.isArray(s)) + return ""; + const t = s.name; + return typeof t == "string" ? t.trim() : ""; } -class bn { +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", @@ -3525,11 +3531,13 @@ class bn { { path: "party", title: "The Party", + type: "panel", cards: [r] }, - ...yn(e).map((l) => ({ - path: l, - title: `${l}'s Quest Log`, + ...bn(e).map((l) => ({ + path: l.slug, + title: l.name ? `${l.name}'s Quest Log` : `${l.slug}'s Quest Log`, + type: "panel", cards: [{ ...o }] })) ] @@ -3537,9 +3545,9 @@ class bn { } } window.customStrategies = window.customStrategies ?? {}; -window.customStrategies["nestquest-party"] = bn; -const vn = "/nestquest-static/nestquest-fonts.css"; +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 = vn, s.dataset.nqFonts = "", document.head.appendChild(s); + s.rel = "stylesheet", s.href = xn, s.dataset.nqFonts = "", document.head.appendChild(s); } diff --git a/frontend/src/strategy.ts b/frontend/src/strategy.ts index fb4ecff..598b3ae 100644 --- a/frontend/src/strategy.ts +++ b/frontend/src/strategy.ts @@ -6,12 +6,14 @@ type StateObject = { }; type RosterEntry = { + name: string; slug: string; }; type View = { path?: string; title?: string; + type: string; cards: Record[]; }; @@ -22,7 +24,6 @@ type LovelaceConfig = { type StrategyConfig = Record; type StrategyClass = { - new (): unknown; generate: ( config: StrategyConfig, hass: HassLike | undefined @@ -65,12 +66,23 @@ function dashboardUrlPath(config: StrategyConfig): string { 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 rosterSlugs(hass: HassLike | undefined): string[] { +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; @@ -78,8 +90,8 @@ function rosterSlugs(hass: HassLike | undefined): string[] { return []; } return roster - .map((entry) => asRosterSlug(entry)) - .filter((slug) => slug.length > 0); + .map((entry) => ({ slug: asRosterSlug(entry), name: asRosterName(entry) })) + .filter((entry) => entry.slug.length > 0); } /** @@ -129,11 +141,15 @@ export class NestQuestPartyStrategy { { path: "party", title: "The Party", + type: "panel", cards: [boardCard], }, - ...rosterSlugs(hass).map((slug) => ({ - path: slug, - title: `${slug}'s Quest Log`, + ...rosterEntries(hass).map((entry) => ({ + path: entry.slug, + title: entry.name + ? `${entry.name}'s Quest Log` + : `${entry.slug}'s Quest Log`, + type: "panel", cards: [{ ...logCard }], })), ], diff --git a/tests/test_panel_cards.py b/tests/test_panel_cards.py index 11a103c..3bc47bd 100644 --- a/tests/test_panel_cards.py +++ b/tests/test_panel_cards.py @@ -350,6 +350,61 @@ def test_strategy_generates_views_for_three_children() -> None: "bo", "cory", ] + assert [view["title"] for view in views] == [ + "The Party", + "Ada's Quest Log", + "Bo's Quest Log", + "Cory's Quest Log", + ] + + +def test_strategy_every_view_is_panel_type() -> None: + """Every generated view carries ``type: "panel"``: Home Assistant + defaults an unspecified view to Masonry, which constrains the + 1080px-tall NestQuest card to a narrow column (README's TouchHub + setup warns of exactly this); with a generated dashboard there is + no editor step left to set Panel by hand.""" + result = _generate_dashboard({}, _three_child_states()) + for view in result["views"]: + assert view["type"] == "panel" + + +def test_strategy_log_view_titles_use_the_roster_display_name() -> None: + """Quest-log view titles come from the roster's ``name`` field (the + child's display name), not the lowercased entity-id slug; the slug + stays the view's path.""" + result = _generate_dashboard( + {}, + { + "sensor.nestquest_household_quests_due_today": _state( + "0", + child_roster=[ + {"child_id": 7, "name": "Milo", "slug": "milo"}, + ], + ), + }, + ) + views = result["views"] + assert len(views) == 2 + assert views[1]["path"] == "milo" + assert views[1]["title"] == "Milo's Quest Log" + + +def test_strategy_passes_weather_entity_through_to_the_cards() -> None: + """An explicit ``weather_entity`` in the strategy configuration is + passed through to the generated card configs (both the board and + the quest log render the weather dock when it is set).""" + result = _generate_dashboard( + {"weather_entity": "weather.home"}, + _three_child_states(), + ) + views = result["views"] + for view in views: + assert view["cards"][0]["weather_entity"] == "weather.home" + # And without it, no weather_entity key is emitted at all. + unset = _generate_dashboard({}, _three_child_states()) + for view in unset["views"]: + assert "weather_entity" not in view["cards"][0] def test_strategy_wires_navigation_between_the_views() -> None: