import { LitElement, css, html, nothing, unsafeCSS, type TemplateResult } from "lit"; import panelTokens from "../../../custom_components/nestquest/www/nestquest-panel-tokens.css?inline"; import { registerCustomCard, type CardConfig, type HassLike } from "../types"; type StateObject = { state: unknown; attributes?: Record | null; }; type HassConnection = { subscribeEvents: ( callback: (event: unknown) => void, eventType: string ) => Promise<() => void>; }; const NESTQUEST_EVENT_TYPES = [ "nestquest_quest_completed", "nestquest_quest_uncompleted", "nestquest_quest_missed", "nestquest_child_day_complete", ]; type ChildPlate = { slug: string; name: string; initial: string; present: boolean; completed: number; due: number; pct: number; returns: string | null; /** Set when the child's sensors cannot resolve; the plate shows "Unknown". */ unresolved: "missing" | "stale" | null; }; type BoardNotice = { icon: TemplateResult; headline: string; body: string; }; type DockWeather = { condition: string; temperature: number | null; high: number | null; low: number | null; }; const FORMATTER_CACHE = new Map(); function zonedFormatter( timeZone: string | undefined, options: Intl.DateTimeFormatOptions ): Intl.DateTimeFormat { const key = `${timeZone ?? "local"}|${JSON.stringify(options)}`; const cached = FORMATTER_CACHE.get(key); if (cached) { return cached; } let formatter: Intl.DateTimeFormat; try { formatter = new Intl.DateTimeFormat("en-US", { ...options, ...(timeZone ? { timeZone } : {}), }); } catch { formatter = new Intl.DateTimeFormat("en-US", options); } FORMATTER_CACHE.set(key, formatter); return formatter; } function hassTimeZone(hass: HassLike | undefined): string | undefined { const config = hass?.config; if (!config || typeof config !== "object") { return undefined; } const timeZone = (config as Record).time_zone; return typeof timeZone === "string" && timeZone.trim() ? timeZone.trim() : undefined; } function formatDay(date: Date, timeZone: string | undefined): string { return `${zonedFormatter(timeZone, { weekday: "long" }).format(date)}, ${zonedFormatter(timeZone, { month: "short", day: "numeric" }).format(date)}`; } function formatTime(date: Date, timeZone: string | undefined): string { return zonedFormatter(timeZone, { hour: "numeric", minute: "2-digit", hour12: true, }).format(date); } const CONDITION_LABELS: Record = { "clear-night": "Clear", cloudy: "Cloudy", exceptional: "Clear", fog: "Foggy", hail: "Hail", lightning: "Storms", "lightning-rimmed": "Storms", partlycloudy: "Partly cloudy", pouring: "Heavy rain", rainy: "Rain", snowy: "Snow", "snowy-rainy": "Sleet", sunny: "Sunny", windy: "Windy", "windy-variant": "Windy", }; const CONDITION_PHRASES: Record = { "clear-night": "Clear night skies", cloudy: "Grey skies today", exceptional: "A striking day", fog: "Mist on the road", hail: "Ice from the sky", lightning: "Storms may roll in", "lightning-rimmed": "Storms may roll in", partlycloudy: "Sun between clouds", pouring: "Heavy rain outside", rainy: "Rain on the walls", snowy: "Snow on the peaks", "snowy-rainy": "Sleet may fall", sunny: "Clear skies today", windy: "A blustery day", "windy-variant": "A blustery day", }; function asString(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } function asNumber(value: unknown, fallback: number): number { if (value === null || value === undefined || value === "") { return fallback; } const parsed = typeof value === "number" ? value : Number(value); return Number.isFinite(parsed) ? parsed : fallback; } /** 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; } return Math.min(100, Math.max(0, value)); } function clampCount(value: number): number { return Math.max(0, Math.round(value)); } function titleCaseSlug(slug: string): string { return slug .split(/[-_]+/) .filter(Boolean) .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); } function zoneOffsetMs(instant: number, timeZone: string): number { const parts = zonedFormatter(timeZone, { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23", }).formatToParts(new Date(instant)); const read = (type: Intl.DateTimeFormatPartTypes): number => { const part = parts.find((entry) => entry.type === type); return part ? Number(part.value) : Number.NaN; }; const asUtc = Date.UTC( read("year"), read("month") - 1, read("day"), read("hour"), read("minute"), read("second") ); return Number.isFinite(asUtc) ? asUtc - instant : 0; } function parseIsoDate(value: unknown, timeZone?: string): Date | null { if (typeof value !== "string") { return null; } const parts = value.split("-"); if (parts.length !== 3) { return null; } const [year, month, day] = parts.map((part) => Number(part)); if (!year || !month || !day) { return null; } if (!timeZone) { const local = new Date(year, month - 1, day); return Number.isNaN(local.getTime()) ? null : local; } const utcMs = Date.UTC(year, month - 1, day); const date = new Date(utcMs - zoneOffsetMs(utcMs, timeZone)); return Number.isNaN(date.getTime()) ? null : date; } function conditionLabel(condition: string): string { const known = CONDITION_LABELS[condition]; if (known) { return known; } return condition.charAt(0).toUpperCase() + condition.slice(1); } function conditionPhrase(condition: string, label: string): string { return CONDITION_PHRASES[condition] ?? label; } const SHIELD_CLIP = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)"; // The brand monogram (design/logos/NestQuest_Logo.png): a compass rose — // four long cardinal points, four short diagonal points, a ring broken by // the points — around a d20 hexagon carrying a knocked-out "N". Drawn on // a 100-unit grid and filled with the brand gradient; the masks cut the // parchment gaps so the mark stays transparent on any backdrop. const MONOGRAM_POINTS = "M50 3 44.5 24.1 50 26 55.5 24.1Z M100 50 75.9 44.5 74 50 75.9 55.5Z " + "M50 97 55.5 75.9 50 74 44.5 75.9Z M0 50 24.1 55.5 26 50 24.1 44.5Z " + "M29.1 29.1 29.7 36.3 36.3 29.7Z M70.9 29.1 63.7 29.7 70.3 36.3Z " + "M70.9 70.9 70.3 63.7 63.7 70.3Z M29.1 70.9 36.3 70.3 29.7 63.7Z"; const MONOGRAM_HEX = "M50 26.5 70.4 38.2 70.4 61.8 50 73.5 29.6 61.8 29.6 38.2Z"; const MONOGRAM_FACETS = "M29.6 38.2H70.4M29.6 61.8H70.4M50 26.5 36.5 38.2M50 26.5 63.5 38.2" + "M50 73.5 36.5 61.8M50 73.5 63.5 61.8M36.5 38.2V61.8M63.5 38.2V61.8" + "M29.6 38.2 36.5 61.8M36.5 38.2 29.6 61.8" + "M70.4 38.2 63.5 61.8M63.5 38.2 70.4 61.8"; const MONOGRAM = html``; const boardStyles = css` * { box-sizing: border-box; } :host { display: block; height: 100%; --nq-brand-gradient-h: linear-gradient( 90deg, var(--nq-brand-blue), var(--nq-brand-purple) ); } .board { position: relative; display: flex; flex-direction: column; align-items: center; height: 1080px; padding: 0; overflow: hidden; background: var(--nq-p-parchment); box-shadow: var(--nq-p-vignette); font-family: var(--nq-p-font-body); } .frame { position: absolute; pointer-events: none; } .frame-outer { inset: 26px; border: var(--nq-p-frame-outer); border-radius: 14px; } .frame-inner { inset: 36px; border: var(--nq-p-frame-inner); border-radius: 8px; } .wordmark-row { position: absolute; top: 64px; left: 0; right: 0; display: flex; align-items: center; justify-content: center; gap: 26px; width: 100%; } .rule { flex: 1 1 0; max-width: 340px; height: 2px; border-radius: 1px; background: rgba(92, 62, 26, 0.35); } .monogram { flex: none; display: block; width: 80px; height: 80px; } .wordmark { font-family: var(--nq-p-font-display); font-size: var(--nq-p-size-wordmark); font-weight: 900; line-height: 1.15; background: var(--nq-brand-gradient); -webkit-background-clip: text; background-clip: text; color: transparent; -webkit-text-fill-color: transparent; } .kicker { position: absolute; top: 154px; left: 0; right: 0; font-family: var(--nq-p-font-heading); font-size: 22px; font-weight: 600; letter-spacing: var(--nq-p-track-kicker); text-transform: uppercase; line-height: 1.2; text-align: center; color: var(--nq-p-ink-secondary); } .plates { position: absolute; top: 262px; left: 110px; right: 110px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 56px; } .plate { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 26px; height: 600px; padding: 44px 28px 40px; border: 2px solid var(--nq-p-panel-border); border-radius: var(--nq-p-radius-panel); background: var(--nq-p-card-panel); box-shadow: var(--nq-p-panel-shadow); font: inherit; } button.plate { width: 100%; color: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: transform var(--nq-dur-micro) var(--nq-ease-out); } button.plate:active { transform: scale(0.98); } button.plate:focus-visible { outline: 3px solid var(--nq-brand-purple); outline-offset: 4px; } .plate.away { border: 2px dashed var(--nq-p-panel-border); background: linear-gradient(#f2ecdd, #e6dcc6); box-shadow: none; } .crest { position: relative; flex: none; width: 188px; height: 214px; padding: 4px; clip-path: ${unsafeCSS(SHIELD_CLIP)}; background: rgba(255, 255, 255, 0.22); } .crest-face { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; padding-bottom: 70px; clip-path: ${unsafeCSS(SHIELD_CLIP)}; background: var(--nq-p-crest); } .crest.away .crest-face { background: var(--nq-p-crest-away); } .crest .initial { font-family: var(--nq-p-font-display); font-size: 82px; font-weight: 700; line-height: 1; color: #ffffff; } .name { font-family: var(--nq-p-font-heading); font-size: var(--nq-p-size-name); font-weight: 700; letter-spacing: 0.02em; line-height: 1.1; color: var(--nq-p-ink); } .plate.away .name { color: var(--nq-p-ink-away); } .pill { display: inline-flex; align-items: center; gap: 10px; padding: 8px 20px; border-radius: var(--nq-p-radius-pill); background: rgba(22, 120, 60, 0.35); font-family: var(--nq-p-font-heading); font-size: 22px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; line-height: 1; color: #146b36; } .pill svg { width: 22px; height: 22px; color: #157a3c; } .pill.away { background: rgba(92, 62, 26, 0.16); color: var(--nq-p-ink-away); } .pill.away svg { color: var(--nq-p-ink-away); } .progress-line { font-family: var(--nq-p-font-heading); font-size: 24px; font-weight: 700; line-height: 1.2; color: var(--nq-p-ink-secondary); } .bar { width: 100%; height: 16px; border: 1px solid rgba(92, 62, 26, 0.35); border-radius: var(--nq-p-radius-pill); background: rgba(92, 62, 26, 0.18); overflow: hidden; } .bar .fill { display: block; height: 100%; border-radius: inherit; background: var(--nq-brand-gradient-h); } .hint { position: absolute; top: 896px; left: 0; right: 0; margin: 0; font-family: var(--nq-p-font-body); font-size: 27px; font-weight: 600; line-height: 1.4; text-align: center; color: var(--nq-p-ink-secondary); } .plate.unknown { border: 2px dashed var(--nq-p-panel-border); background: linear-gradient(#f2ecdd, #e6dcc6); box-shadow: none; } .notice-wrap { position: absolute; top: 262px; left: 110px; right: 110px; bottom: 190px; display: flex; align-items: center; justify-content: center; } .notice { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px; width: 100%; max-width: 980px; padding: 56px 64px; border: 2px solid var(--nq-p-panel-border); border-radius: var(--nq-p-radius-panel); background: var(--nq-p-card-panel); box-shadow: var(--nq-p-panel-shadow); text-align: center; } .notice svg { width: 46px; height: 46px; color: var(--nq-p-ink-secondary); } .notice-headline { font-family: var(--nq-p-font-heading); font-size: 40px; font-weight: 700; line-height: 1.15; color: var(--nq-p-ink); } .notice-body { margin: 0; font-family: var(--nq-p-font-body); font-size: 24px; font-weight: 600; line-height: 1.4; color: var(--nq-p-ink-secondary); } .spacer { flex: 1 1 0; } .dock { position: absolute; left: 46px; right: 46px; bottom: 46px; height: var(--nq-p-dock-height); display: flex; align-items: center; justify-content: center; gap: 24px; padding: 0 40px; border-radius: 14px; background: var(--nq-p-dock-bg); pointer-events: none; user-select: none; color: var(--nq-p-dock-ink); } .dock svg { flex: none; width: 40px; height: 40px; color: var(--nq-p-dock-ink); } .dock .divider { flex: none; width: 1px; height: 40px; background: var(--nq-p-dock-divider); } .dock .temp { font-family: var(--nq-p-font-body); font-size: 38px; font-weight: 800; line-height: 1; } .dock .condition { font-family: var(--nq-p-font-body); font-size: 26px; font-weight: 600; line-height: 1.2; color: var(--nq-p-dock-ink-secondary); } .dock .date { font-family: var(--nq-p-font-body); font-size: 26px; font-weight: 600; line-height: 1.2; color: var(--nq-p-dock-ink-secondary); } .dock .clock { font-family: var(--nq-p-font-body); font-size: 38px; font-weight: 800; line-height: 1; } `; const ICON_CHECK_CIRCLE = html``; const ICON_TENT = html``; const ICON_COMPASS = html``; const ICON_ALERT = html``; const ICON_CLOUD_OFF = html``; const BOARD_NOTICE_NOT_SET_UP: BoardNotice = { icon: ICON_ALERT, headline: "NestQuest is not set up yet", body: "A parent needs to finish setting up NestQuest before the party can gather.", }; const BOARD_NOTICE_UNREACHABLE: BoardNotice = { icon: ICON_CLOUD_OFF, headline: "The records cannot be reached", body: "The party's records are quiet right now. NestQuest will return shortly.", }; const ICON_CLOUD_SUN = html``; export class NestQuestPartyBoardCard extends LitElement { static properties = { hass: { attribute: false }, _config: { state: true }, _now: { state: true }, }; static styles = [unsafeCSS(panelTokens), boardStyles]; hass?: HassLike; _config?: CardConfig; _now = new Date(); private _clockTimer?: number; private _unsubs: Array<() => void> = []; private _subscribed = false; private _subGeneration = 0; private _retryTimer?: number; setConfig(config: CardConfig): void { if (!config || typeof config !== "object") { throw new Error("Invalid configuration"); } this._config = config; } private _hassConnection(): HassConnection | null { const connection = (this.hass as { connection?: unknown } | undefined) ?.connection; if ( !connection || typeof connection !== "object" || typeof (connection as HassConnection).subscribeEvents !== "function" ) { return null; } return connection as HassConnection; } private _subscribeLive(): void { if (!this.isConnected) { return; } if (this._subscribed) { return; } const connection = this._hassConnection(); if (!connection) { return; } this._subscribed = true; const token = ++this._subGeneration; const track = (unsubPromise: Promise<() => void>): void => { unsubPromise .then((unsub) => { if (this._subscribed && token === this._subGeneration) { this._unsubs.push(unsub); } else { unsub(); } }) .catch(() => { if (token === this._subGeneration) { this._unsubscribeLive(); this._armSubscribeRetry(); } }); }; for (const eventType of NESTQUEST_EVENT_TYPES) { track( connection.subscribeEvents(() => this.requestUpdate(), eventType) ); } } private _armSubscribeRetry(): void { if (this._retryTimer !== undefined) { return; } this._retryTimer = window.setTimeout(() => { this._retryTimer = undefined; this._subscribeLive(); }, 1000); } private _clearSubscribeRetry(): void { if (this._retryTimer !== undefined) { window.clearTimeout(this._retryTimer); this._retryTimer = undefined; } } private _unsubscribeLive(): void { this._clearSubscribeRetry(); this._subGeneration++; for (const unsub of this._unsubs) { unsub(); } this._unsubs = []; this._subscribed = false; } getCardSize(): number { return 22; } connectedCallback(): void { super.connectedCallback(); this._subscribeLive(); this._now = new Date(); this._clockTimer = window.setInterval(() => { this._now = new Date(); }, 1000); } protected updated(): void { this._subscribeLive(); } disconnectedCallback(): void { this._unsubscribeLive(); if (this._clockTimer !== undefined) { window.clearInterval(this._clockTimer); this._clockTimer = undefined; } super.disconnectedCallback(); } render() { const notice = this._boardNotice(); return html`
${MONOGRAM} NestQuest

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

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

Tap your crest to open your Quest Log

`}
${this._renderDock()}
`; } /** The zone the visible date and clock render in: the API's effective * application timezone — the stored zone, or the API host's when none * is stored (published on the household rollup by * custom_components/nestquest/sensor.py — ``timezone``). Only when the * rollup carries none does Home Assistant's zone (or the browser's) * apply. */ private _timeZone(): string | undefined { const stored = this._state("sensor.nestquest_household_quests_due_today") ?.attributes?.timezone; return typeof stored === "string" && stored.trim() ? stored.trim() : hassTimeZone(this.hass); } private _state(entityId: string): StateObject | null { const states = this.hass?.states as | Record | undefined; const stateObj = states?.[entityId]; return stateObj && typeof stateObj === "object" ? stateObj : null; } private _childSlugs(): string[] { if (!this._config) { return []; } const order = this._config.child_order; if (Array.isArray(order)) { return 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 roster .map((entry) => asRosterSlug(entry)) .filter((slug) => slug.length > 0); } private _plates(): ChildPlate[] { return this._childSlugs().map((slug) => this._plate(slug)); } private _plate(slug: string): ChildPlate { const unresolved = this._dueSensorResolution(slug); const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`); const doneSensor = this._state( `sensor.nestquest_${slug}_quests_completed_today` ); const pctSensor = this._state( `sensor.nestquest_${slug}_completion_pct_today` ); const presence = this._state( `binary_sensor.nestquest_${slug}_present_today` ); const dueAttrs = dueSensor?.attributes ?? {}; const due = clampCount(asNumber(dueSensor?.state, 0)); const completed = clampCount(asNumber(doneSensor?.state, 0)); const rawPct = pctSensor ? asNumber(pctSensor.state, Number.NaN) : Number.NaN; const pct = Number.isFinite(rawPct) ? clampPercent(rawPct) : due > 0 ? clampPercent((completed / due) * 100) : 0; let present = true; const presentAttr = dueAttrs.present; if (typeof presentAttr === "boolean") { present = presentAttr; } else if (presence) { present = String(presence.state ?? "").trim().toLowerCase() === "on"; } let name = ""; const dueName = dueAttrs.child_name; if (typeof dueName === "string" && dueName.trim()) { name = dueName.trim(); } else { const presenceName = presence?.attributes?.child_name; if (typeof presenceName === "string" && presenceName.trim()) { name = presenceName.trim(); } } if (!name) { name = titleCaseSlug(slug); } const returnsDate = parseIsoDate( presence?.attributes?.next_present, this._timeZone() ); const returns = returnsDate ? `Returns ${formatDay(returnsDate, this._timeZone())}` : null; return { slug, name, initial: (name.charAt(0) || "?").toUpperCase(), present: unresolved === null ? present : false, completed, due, pct, returns, unresolved, }; } /** "missing" when the due sensor does not exist (integration not * configured), "stale" when it answers unavailable/unknown (backend * unreachable or the child is absent from the snapshot), null when * it resolves. */ private _dueSensorResolution(slug: string): "missing" | "stale" | null { const sensor = this._state(`sensor.nestquest_${slug}_quests_due_today`); if (!sensor) { return "missing"; } const state = String(sensor.state ?? "").trim().toLowerCase(); if (!state || state === "unavailable" || state === "unknown") { return "stale"; } return null; } private _boardNotice(): BoardNotice | null { const plates = this._plates(); if (plates.length === 0) { return BOARD_NOTICE_NOT_SET_UP; } if (plates.every((plate) => plate.unresolved !== null)) { return plates.some((plate) => plate.unresolved === "stale") ? BOARD_NOTICE_UNREACHABLE : BOARD_NOTICE_NOT_SET_UP; } return null; } private _renderNotice(notice: BoardNotice) { return html`
${notice.icon} ${notice.headline}

${notice.body}

`; } private _renderPlate(plate: ChildPlate) { if (plate.unresolved) { return html`
${plate.initial} ${plate.name} ${ICON_COMPASS} Unknown —
`; } if (plate.present) { return html` `; } return html`
${plate.initial} ${plate.name} ${ICON_TENT} On travels ${plate.returns ?? "Returns —"}
`; } private _openQuestLog(slug: string): void { const path = asString(this._config?.quest_log_path); if (!path) { return; } window.history.pushState(null, "", `${path.replace(/\/+$/, "")}/${slug}`); window.dispatchEvent(new Event("location-changed")); } private _dockWeather(): DockWeather | null { const entityId = asString(this._config?.weather_entity); if (!entityId) { return null; } const stateObj = this._state(entityId); if (!stateObj) { return null; } const condition = String(stateObj.state ?? "").trim(); if (!condition || condition === "unavailable" || condition === "unknown") { return null; } const attrs = stateObj.attributes ?? {}; let high: number | null = null; let low: number | null = null; const forecast = attrs.forecast; if (Array.isArray(forecast) && forecast.length > 0) { const entry = forecast[0]; if (entry && typeof entry === "object") { const typed = entry as Record; high = this._optionalNumber(typed.temperature); low = this._optionalNumber(typed.templow); } } return { condition, temperature: this._optionalNumber(attrs.temperature), high, low, }; } private _optionalNumber(value: unknown): number | null { if (value === null || value === undefined || value === "") { return null; } const parsed = typeof value === "number" ? value : Number(value); return Number.isFinite(parsed) ? parsed : null; } private _renderDock() { const weather = this._dockWeather(); if (!weather) { return html`
${formatDay(this._now, this._timeZone())} ${formatTime(this._now, this._timeZone())}
`; } const label = conditionLabel(weather.condition); const phrase = conditionPhrase(weather.condition, label); const temperature = weather.temperature === null ? nothing : html`${Math.round(weather.temperature)}°`; const hiLo = weather.high === null || weather.low === null ? null : `${Math.round(weather.high)}° / ${Math.round(weather.low)}°`; return html`
${ICON_CLOUD_SUN} ${temperature} ${hiLo === null ? label : `${label} · ${hiLo}`} ${phrase}
`; } } customElements.define("nestquest-party-board-card", NestQuestPartyBoardCard); registerCustomCard({ type: "nestquest-party-board-card", name: "NestQuest Party Board", description: "Kids' attract screen for present and away adventurers.", });