Generate the zero-config panel dashboard from the household roster (Feature 20)

The board and quest-log cards navigate by URL path, but the paths had
to be configured by hand per view. Add a custom dashboard strategy
(window.customStrategies['nestquest-party'], bundled with the cards) so
a dashboard with strategy { type: custom:nestquest-party } gets its
views GENERATED at render time: a party-board view at the dashboard
root and one quest-log view per child in the household roster's
sort_order. The card paths are computed — the board's quest_log_path is
the root, each log's path is the child slug (so childSlugFromPath
resolves it) and its board_path points back — making quest_log_path and
board_path derived rather than configured. Zero children still render
the board alone (its own not-set-up notice covers the empty roster),
and an explicit url_path in the strategy config pins the root.
This commit is contained in:
2026-09-23 09:48:57 -04:00
parent 316503cf73
commit 46f45aa4e5
5 changed files with 720 additions and 376 deletions
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,6 +1,7 @@
import "./cards/party-board"; import "./cards/party-board";
import "./cards/quest-log"; import "./cards/quest-log";
import "./cards/admin"; import "./cards/admin";
import "./strategy";
const FONTS_HREF = "/nestquest-static/nestquest-fonts.css"; const FONTS_HREF = "/nestquest-static/nestquest-fonts.css";
if (!document.querySelector('link[data-nq-fonts=""]')) { if (!document.querySelector('link[data-nq-fonts=""]')) {
+145
View File
@@ -0,0 +1,145 @@
import type { HassLike } from "./types";
type StateObject = {
state: unknown;
attributes?: Record<string, unknown> | null;
};
type RosterEntry = {
slug: string;
};
type View = {
path?: string;
title?: string;
cards: Record<string, unknown>[];
};
type LovelaceConfig = {
views: View[];
};
type StrategyConfig = Record<string, unknown>;
type StrategyClass = {
new (): unknown;
generate: (
config: StrategyConfig,
hass: HassLike | undefined
) => Promise<LovelaceConfig>;
};
declare global {
interface Window {
customStrategies?: Record<string, StrategyClass>;
}
}
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<string, unknown>).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
* (/<url_path>), 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 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[] {
const states = hass?.states as Record<string, StateObject> | 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) => asRosterSlug(entry))
.filter((slug) => 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 ``<root>/<slug>`` (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<LovelaceConfig> {
const root = `/${dashboardUrlPath(config)}`;
const weatherEntity = asString(config.weather_entity);
const boardCard: Record<string, unknown> = {
type: "custom:nestquest-party-board-card",
quest_log_path: root,
};
if (weatherEntity) {
boardCard.weather_entity = weatherEntity;
}
const logCard: Record<string, unknown> = {
type: "custom:nestquest-quest-log-card",
board_path: root,
};
if (weatherEntity) {
logCard.weather_entity = weatherEntity;
}
return {
views: [
{
path: "party",
title: "The Party",
cards: [boardCard],
},
...rosterSlugs(hass).map<View>((slug) => ({
path: slug,
title: `${slug}'s Quest Log`,
cards: [{ ...logCard }],
})),
],
};
}
}
window.customStrategies = window.customStrategies ?? {};
window.customStrategies["nestquest-party"] = NestQuestPartyStrategy;
+48
View File
@@ -0,0 +1,48 @@
/**
* Import the built bundle into jsdom and generate the dashboard strategy's
* views.
*
* Usage: node render-strategy.mjs < spec.json
*
* The spec JSON carries {bundle, url, config, hass}; stdout receives one
* JSON object {views} — the strategy output's views array with the paths
* and card configs the generated dashboard renders. The Python panel
* tests drive this harness so the assertions run against the exact bundle
* HACS ships.
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { JSDOM } from "jsdom";
const spec = JSON.parse(readFileSync(0, "utf8"));
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: spec.url ?? "http://homeassistant.local/nestquest/party",
pretendToBeVisual: true,
});
// Expose the jsdom globals the Lit bundle expects. Node's own intrinsics
// (Object, Array, ...) are kept — only what globalThis lacks is copied.
for (const key of Object.getOwnPropertyNames(dom.window)) {
if (!(key in globalThis)) {
try {
globalThis[key] = dom.window[key];
} catch {
// jsdom getters may throw on access; the strategy does not need them.
}
}
}
globalThis.window = dom.window;
await import(pathToFileURL(spec.bundle).href);
const strategy = window.customStrategies?.["nestquest-party"];
if (!strategy) {
console.error("nestquest-party strategy is not registered on the bundle");
process.exit(1);
}
const lovelace = await strategy.generate(spec.config, spec.hass);
const payload = JSON.stringify({ views: lovelace.views });
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0);
+104
View File
@@ -24,6 +24,8 @@ PARTY_BOARD = FRONTEND_CARDS_DIR / "party-board.ts"
QUEST_LOG = FRONTEND_CARDS_DIR / "quest-log.ts" QUEST_LOG = FRONTEND_CARDS_DIR / "quest-log.ts"
BUNDLE_PATH = REPO_ROOT / "custom_components" / "nestquest" / "www" / "nestquest-cards.js" BUNDLE_PATH = REPO_ROOT / "custom_components" / "nestquest" / "www" / "nestquest-cards.js"
RENDER_HARNESS = FRONTEND_DIR / "tests" / "render-party-board.mjs" RENDER_HARNESS = FRONTEND_DIR / "tests" / "render-party-board.mjs"
STRATEGY_HARNESS = FRONTEND_DIR / "tests" / "render-strategy.mjs"
STRATEGY_SOURCE = FRONTEND_DIR / "src" / "strategy.ts"
PANEL_SOURCES = (PARTY_BOARD, QUEST_LOG) PANEL_SOURCES = (PARTY_BOARD, QUEST_LOG)
@@ -287,3 +289,105 @@ def test_explicit_child_order_still_wins_over_discovery() -> None:
) )
assert [plate["name"] for plate in result["plates"]] == ["Bo", "Ada"] assert [plate["name"] for plate in result["plates"]] == ["Bo", "Ada"]
assert "Cory" not in [plate["name"] for plate in result["plates"]] assert "Cory" not in [plate["name"] for plate in result["plates"]]
# --- Dashboard strategy tests -------------------------------------------------
STRATEGY_NAME = "nestquest-party"
def _generate_dashboard(config: dict, states: dict, url: str | None = None) -> dict:
"""Generate the strategy's dashboard from the built bundle.
Drives frontend/tests/render-strategy.mjs: the harness imports the
committed bundle into a jsdom window at the given dashboard URL and
prints the views ``window.customStrategies["nestquest-party"].
generate(config, hass)`` returns.
"""
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
assert STRATEGY_HARNESS.is_file()
spec = {
"bundle": str(BUNDLE_PATH),
"url": url or "http://homeassistant.local/nestquest/party",
"config": config,
"hass": {"config": {"time_zone": "UTC"}, "states": states},
}
completed = subprocess.run(
["node", str(STRATEGY_HARNESS)],
input=json.dumps(spec),
capture_output=True,
text=True,
timeout=120,
)
assert completed.returncode == 0, (
f"strategy harness failed: {completed.stderr}"
)
return json.loads(completed.stdout)
def test_strategy_is_registered_on_the_bundle() -> None:
"""The strategy ships in the same bundle as the cards and registers
itself under window.customStrategies so a dashboard can use
strategy: { type: custom:nestquest-party }."""
source = _read(STRATEGY_SOURCE)
assert "customStrategies" in source
assert f'["{STRATEGY_NAME}"]' in source or f'"{STRATEGY_NAME}"' in source
bundle = _read(BUNDLE_PATH)
assert "nestquest-party" in bundle
def test_strategy_generates_views_for_three_children() -> None:
"""For the seeded three-child household the strategy generates one
party-board view plus one quest-log view per child in roster order,
with COMPUTED paths: the board view at the dashboard root, each log
view at the child's slug so quest-log.ts's childSlugFromPath reads
the child from the URL's last segment."""
result = _generate_dashboard({}, _three_child_states())
views = result["views"]
assert len(views) == 4
assert [view["path"] for view in views] == [
"party",
"ada",
"bo",
"cory",
]
def test_strategy_wires_navigation_between_the_views() -> None:
"""The board's quest_log_path and each log's board_path are computed
to the dashboard root the strategy renders at, so party-board.ts's
crest tap pushes <root>/<slug> and quest-log.ts's idle return goes
back to the board — neither path is configured by hand."""
dashboard_url = "http://homeassistant.local/nestquest"
root = "/nestquest"
result = _generate_dashboard(
{}, _three_child_states(), url=dashboard_url
)
views = result["views"]
board = views[0]["cards"][0]
assert board["type"] == "custom:nestquest-party-board-card"
assert board["quest_log_path"] == root
for slug, view in zip(("ada", "bo", "cory"), views[1:]):
assert view["cards"][0]["type"] == "custom:nestquest-quest-log-card"
assert view["cards"][0]["board_path"] == root
def test_strategy_zero_children_still_generates_the_board() -> None:
"""With an empty roster (NestQuest not set up yet) the strategy
renders the board view alone — the board card shows its own
"not set up yet" notice for an empty roster — and never crashes."""
result = _generate_dashboard({}, {})
assert len(result["views"]) == 1
assert result["views"][0]["path"] == "party"
def test_strategy_config_overrides_still_apply() -> None:
"""Explicit strategy configuration wins where the design allows: an
explicit url_path pins the computed dashboard root."""
result = _generate_dashboard(
{"url_path": "/wallboard"},
_three_child_states(),
)
root = "/wallboard"
assert result["views"][0]["cards"][0]["quest_log_path"] == root
for view in result["views"][1:]:
assert view["cards"][0]["board_path"] == root