diff --git a/.gitignore b/.gitignore index 7b33e7f..cc771bf 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ .venv/ .pytest_cache/ *.metadata.json +frontend/node_modules/ diff --git a/README.md b/README.md index 5f54fdd..a590c80 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,85 @@ Sensors the automations read: `sensor.nestquest__quests_due_today` `..._quests_completed_today`, and `binary_sensor.nestquest__present_today`. +## TouchHub setup + +Run these steps in order to display the NestQuest panel on a TouchHub +wall device. The panel is an HA Lovelace dashboard, so the kiosk browser +is simply a Home Assistant client. + +1. **Create a dedicated kiosk user.** In *Settings → People → Users*, add + a new user for the wall device. Make it **non-admin**, and ensure it is + not in the NestQuest admin allowlist. The allowlist blocks the NestQuest + **admin** operations — `uncomplete_quest`, `create_quest_definition`, + `update_quest_definition`, `set_quest_definition_active`, + `set_presence_pattern`, `create_presence_override`, + `delete_presence_override`, `export_history_csv`, and `manage_child` + (plus `regenerate` flows) — it does not make the account view-only. The + kiosk user *can* call `nestquest.complete_quest`, the one open NestQuest + service, to complete quests from the panel, and as a non-admin Home + Assistant user it may also reach other permitted Home Assistant entities + and services. + +2. **Build a dashboard with only the NestQuest panel cards.** Create a + Lovelace dashboard whose **URL slug is `nestquest`** (Settings → + Dashboards → Edit → URL slug). A functional panel needs **two** views + in that dashboard, each with its own card config from + `design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL + paths (the path segment after the dashboard slug): + + - **View 1 path `board`** (URL `/nestquest/board`) — party board + (`type: custom:nestquest-party-board-card`): the household quest + board. Set `weather_entity` (optional; the dock falls back to + date/time), `quest_log_path: /nestquest/log` (the view the crest tap + navigates to), and `child_order: [slug1, slug2, slug3]` listing each + child's slug in display order. + + - **View 2 path `log`** (URL `/nestquest/log`) — quest log + (`type: custom:nestquest-quest-log-card`): the per-child quest screen + the party board hops to. Set `board_path: /nestquest/board` (the + party-board view to return to), `weather_entity`, + `idle_return_seconds` (must be shorter than TouchHub Auto-Return), + `confirm_timeout_seconds`, and `complete_screen_seconds`. The quest + log reads the selected child's slug from the view's URL path, so the + board → log hop is a plain Lovelace navigation. + + Each view's layout MUST be set to **Panel** (Edit Dashboard → pencil on the + view → View type: Panel). Home Assistant's default Masonry layout constrains + the 1080px-tall NestQuest card to a narrow column; Panel (single-card) + layout is what renders the card full-screen at 1920×1080. + + The dashboard contains only the NestQuest panel card for each view — + no other cards, no admin controls. + + The `quest_log_path` and `board_path` values must match the dashboard + slug and view paths above: `//`. If you chose `nestquest` + as the dashboard slug with view paths `board` and `log`, the card + configs are already correct. If you pick different names, substitute + your actual paths in **both** card configs — e.g. a dashboard slug + `chores` with view paths `main` and `history` needs + `quest_log_path: /chores/history` and `board_path: /chores/main`. + Navigation between the two views fails (the crest tap or back button + lands on a 404) if the card config paths do not match the dashboard + slug and view paths exactly. + + See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML. + +3. **Point TouchHub at it.** In the TouchHub launcher, add the **Home + Assistant Lovelace** app to the dock and paste the dashboard URL from + step 2 — the party-board view URL, e.g. `/nestquest/board` (TouchHub + loads that URL directly as the kiosk landing page). Set TouchHub + **Auto-Return** to a value GREATER than the quest log's + `idle_return_seconds` (default 40s) — recommend **60s or higher**. If + Auto-Return is at or below 40s, TouchHub returns to its launcher before + the quest log's idle return can navigate back to the party board. + +4. **Log the kiosk session in.** Sign in on the device as the dedicated + kiosk user from step 1, and leave that session logged in. + +TouchHub loads the dashboard URL directly. The NestQuest cards are normal +Lovelace cards, not an iframe, so no Home Assistant framing setting is +needed. + ## Developer Setup and Testing This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management. @@ -172,6 +251,28 @@ To set up the development environment and install dependencies: uv sync ``` +### Frontend cards + +The Lovelace cards live in `frontend/` (TypeScript + Lit) and build to a +single bundle at `custom_components/nestquest/www/nestquest-cards.js`. The +integration registers that bundle as a Lovelace resource automatically on +setup — do not add it by hand. Card YAML is documented in +`design/ENTITIES-AND-SERVICES.md` §4. + +```bash +cd frontend +npm install +npm run build +``` + +### Kids panel layout + +The Lovelace panel cards are designed for the wall display's confirmed +resolution, 1920×1080 landscape, with no page scroll. TouchHub's bottom +dock overlays the NestQuest weather dock strip until it auto-hides, so +no tappable NestQuest control sits in that strip (the dock carries +`pointer-events: none`). Keep it that way when editing the cards. + ### Running Tests Run the test suite with `pytest`: diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 143e9e0..53e395d 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -28,6 +28,7 @@ from .const import ( LOGGER, PLATFORMS, ) +from .frontend import async_register_frontend from .coordinator import NestQuestCoordinator from .db import NestQuestDatabase from .materialize import materialize as _materialize_run @@ -410,11 +411,13 @@ def _deregister_services(hass: HomeAssistant) -> None: async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: """Set up the NestQuest integration. YAML configuration is not used, returns True.""" + await async_register_frontend(hass) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NestQuest from a config entry.""" + await async_register_frontend(hass) hass.data.setdefault(DOMAIN, {}) existing = hass.data[DOMAIN].get(entry.entry_id) if existing is not None: diff --git a/custom_components/nestquest/binary_sensor.py b/custom_components/nestquest/binary_sensor.py index 7cd45b3..7c7fe10 100644 --- a/custom_components/nestquest/binary_sensor.py +++ b/custom_components/nestquest/binary_sensor.py @@ -135,6 +135,17 @@ class NestQuestPresentTodaySensor(_NestQuestChildBinarySensor): return None return child.present + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Base attributes plus, for a child away today, the ISO date + of their next present day (the panel's away plate renders + ``Returns , `` from it).""" + base = super().extra_state_attributes + if base is None: + return None + child = self._child + return {**base, "next_present": child.next_present if child else None} + def _child_binary_sensors( coordinator: NestQuestCoordinator, child: ChildDaySnapshot diff --git a/custom_components/nestquest/coordinator.py b/custom_components/nestquest/coordinator.py index 08f9ad9..39dc777 100644 --- a/custom_components/nestquest/coordinator.py +++ b/custom_components/nestquest/coordinator.py @@ -50,6 +50,7 @@ from .dao_rules import QuestDefinitionsDao from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao from .db import NestQuestDatabase from .presence import ( + MAX_PREVIEW_SCAN_DAYS, PresenceEngine, PresenceOverride, PresenceSchedule, @@ -86,6 +87,11 @@ class ChildDaySnapshot: completed_today: int remaining_today: int completion_pct: int + #: For a child away today: the ISO date of their next present day + #: (the panel's away plate renders ``Returns , `` + #: from it). ``None`` when the child is present today or no + #: present day exists within the presence engine's scan cap. + next_present: str | None = None @dataclass(frozen=True) @@ -162,10 +168,14 @@ class NestQuestCoordinator(DataUpdateCoordinator): schedules[child.id] = PresenceSchedule.decode( child.id, schedule.anchor_date, schedule.pattern ) - todays = await PresenceOverridesDao( + window_end = today + datetime.timedelta( + days=MAX_PREVIEW_SCAN_DAYS + ) + window_end_iso = window_end.isoformat() + ranged = await PresenceOverridesDao( self.database - ).list_by_child_and_range(child.id, today_iso, today_iso) - if todays: + ).list_by_child_and_range(child.id, today_iso, window_end_iso) + if ranged: # The engine validates MODEL objects, not DAO records; # each record converts losslessly (the schema guarantees # end >= start and a real is_present). @@ -177,7 +187,7 @@ class NestQuestCoordinator(DataUpdateCoordinator): record.is_present, record.note, ) - for record in todays + for record in ranged ] engine = PresenceEngine(schedules, overrides) @@ -231,18 +241,28 @@ class NestQuestCoordinator(DataUpdateCoordinator): due = len(views) completed = sum(1 for view in views if view.state == "done") remaining = due - completed + child_present = engine.is_present(child.id, today) + next_present: str | None = None + if not child_present: + try: + next_present = engine.next_present_dates( + child.id, today, 1 + )[0].isoformat() + except ValueError: + next_present = None snapshots.append( ChildDaySnapshot( child_id=child.id, child_name=child.display_name, instances=tuple(views), - present=engine.is_present(child.id, today), + present=child_present, due_today=due, completed_today=completed, remaining_today=remaining, completion_pct=( round(completed * 100 / due) if due else 100 ), + next_present=next_present, ) ) diff --git a/custom_components/nestquest/frontend.py b/custom_components/nestquest/frontend.py new file mode 100644 index 0000000..c46ccbc --- /dev/null +++ b/custom_components/nestquest/frontend.py @@ -0,0 +1,35 @@ +"""Lovelace card bundle registration for NestQuest.""" +from __future__ import annotations + +from pathlib import Path + +from homeassistant.components.frontend import add_extra_js_url +from homeassistant.components.http import StaticPathConfig +from homeassistant.core import HomeAssistant + +WWW_DIR = Path(__file__).parent / "www" +FRONTEND_URL_BASE = "/nestquest-static" +BUNDLE_FILENAME = "nestquest-cards.js" +BUNDLE_URL = f"{FRONTEND_URL_BASE}/{BUNDLE_FILENAME}" +_REGISTERED_ATTR = "_nestquest_frontend_registered" + + +async def async_register_frontend(hass: HomeAssistant) -> None: + """Serve the card bundle and inject it into every Lovelace page. + + Idempotent across config entries: static paths and extra JS URLs are + process-global, so a second entry must not register them again. + """ + if getattr(hass, _REGISTERED_ATTR, False): + return + await hass.http.async_register_static_paths( + [ + StaticPathConfig( + FRONTEND_URL_BASE, + str(WWW_DIR), + cache_headers=False, + ) + ] + ) + add_extra_js_url(hass, BUNDLE_URL) + setattr(hass, _REGISTERED_ATTR, True) diff --git a/custom_components/nestquest/manifest.json b/custom_components/nestquest/manifest.json index ffef4b2..a85ae11 100644 --- a/custom_components/nestquest/manifest.json +++ b/custom_components/nestquest/manifest.json @@ -5,6 +5,10 @@ "@overseer" ], "config_flow": true, + "dependencies": [ + "frontend", + "http" + ], "documentation": "https://github.com/talon2king/NestQuest", "integration_type": "hub", "iot_class": "local_polling", diff --git a/custom_components/nestquest/www/fonts/cinzel-decorative-latin-700-normal.woff2 b/custom_components/nestquest/www/fonts/cinzel-decorative-latin-700-normal.woff2 new file mode 100644 index 0000000..0e184e3 Binary files /dev/null and b/custom_components/nestquest/www/fonts/cinzel-decorative-latin-700-normal.woff2 differ diff --git a/custom_components/nestquest/www/fonts/cinzel-decorative-latin-900-normal.woff2 b/custom_components/nestquest/www/fonts/cinzel-decorative-latin-900-normal.woff2 new file mode 100644 index 0000000..204373f Binary files /dev/null and b/custom_components/nestquest/www/fonts/cinzel-decorative-latin-900-normal.woff2 differ diff --git a/custom_components/nestquest/www/fonts/cinzel-latin-600-normal.woff2 b/custom_components/nestquest/www/fonts/cinzel-latin-600-normal.woff2 new file mode 100644 index 0000000..a4b5e1b Binary files /dev/null and b/custom_components/nestquest/www/fonts/cinzel-latin-600-normal.woff2 differ diff --git a/custom_components/nestquest/www/fonts/cinzel-latin-700-normal.woff2 b/custom_components/nestquest/www/fonts/cinzel-latin-700-normal.woff2 new file mode 100644 index 0000000..a13fb08 Binary files /dev/null and b/custom_components/nestquest/www/fonts/cinzel-latin-700-normal.woff2 differ diff --git a/custom_components/nestquest/www/fonts/cinzel-latin-900-normal.woff2 b/custom_components/nestquest/www/fonts/cinzel-latin-900-normal.woff2 new file mode 100644 index 0000000..01b7714 Binary files /dev/null and b/custom_components/nestquest/www/fonts/cinzel-latin-900-normal.woff2 differ diff --git a/custom_components/nestquest/www/fonts/nunito-latin-400-normal.woff2 b/custom_components/nestquest/www/fonts/nunito-latin-400-normal.woff2 new file mode 100644 index 0000000..8c68e57 Binary files /dev/null and b/custom_components/nestquest/www/fonts/nunito-latin-400-normal.woff2 differ diff --git a/custom_components/nestquest/www/fonts/nunito-latin-600-normal.woff2 b/custom_components/nestquest/www/fonts/nunito-latin-600-normal.woff2 new file mode 100644 index 0000000..dac52ff Binary files /dev/null and b/custom_components/nestquest/www/fonts/nunito-latin-600-normal.woff2 differ diff --git a/custom_components/nestquest/www/fonts/nunito-latin-700-normal.woff2 b/custom_components/nestquest/www/fonts/nunito-latin-700-normal.woff2 new file mode 100644 index 0000000..5b423ba Binary files /dev/null and b/custom_components/nestquest/www/fonts/nunito-latin-700-normal.woff2 differ diff --git a/custom_components/nestquest/www/fonts/nunito-latin-800-normal.woff2 b/custom_components/nestquest/www/fonts/nunito-latin-800-normal.woff2 new file mode 100644 index 0000000..21502e0 Binary files /dev/null and b/custom_components/nestquest/www/fonts/nunito-latin-800-normal.woff2 differ diff --git a/custom_components/nestquest/www/nestquest-admin-tokens.css b/custom_components/nestquest/www/nestquest-admin-tokens.css new file mode 100644 index 0000000..64329b8 --- /dev/null +++ b/custom_components/nestquest/www/nestquest-admin-tokens.css @@ -0,0 +1,76 @@ +/* ============================================================ + NestQuest — Parent admin card tokens + Surface: phone, 390x844 reference. Plain CubeCraft styling, + no tabletop treatment, plain domain words in the UI. + Scope on the card host: nestquest-admin-card. + Derived from the approved mockups (admin screens 1d-1i). + Two densities were built; COMPACT is the default. Flip + --nq-a-density-* to the roomy values for the alternative. + ============================================================ */ + +:host { + /* ── Brand + neutrals (CubeCraft tokens, verbatim) ─────── */ + --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; /* row being edited */ + --nq-a-border: #E4E4EA; + --nq-a-divider: #EDEDF1; + + --nq-a-ink: #18181D; + --nq-a-ink-secondary: #52525E; + --nq-a-ink-tertiary: #70707E; /* inactive rows, unselected nav icons */ + + --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; /* un-complete events in history */ + + /* ── Type ─────────────────────────────────────────────── */ + --nq-a-font: Nunito, system-ui, sans-serif; + --nq-a-size-screen: 24px; /* 800, screen title */ + --nq-a-size-hero: 32px; /* 800, roomy "8 of 12 done" */ + --nq-a-size-card-title: 19px; /* 800, roomy child card */ + --nq-a-size-stat: 22px; /* 800, compact stat number */ + --nq-a-size-row: 13.5px;/* 700, list row title */ + --nq-a-size-meta: 11px; /* 600, row meta */ + --nq-a-size-label: 10px; /* 700, uppercase field label */ + --nq-a-track-label: .10em; + + /* ── Density: COMPACT (default) ───────────────────────── */ + --nq-a-density-page-pad: 14px 16px 92px; /* bottom clears the 70px tab bar */ + --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; + /* Density: ROOMY — page-pad 18px 20px 92px; gap 16px; + card-pad 18px; radius 12px; shadow 0 4px 12px rgba(0,0,0,.06) */ + + /* ── Metrics ──────────────────────────────────────────── */ + --nq-a-tabbar-height: 70px; /* + 12px safe-area padding */ + --nq-a-tap-min: 44px; /* every row/control clears this */ + --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; /* 8px roomy, 10px hero */ + + /* ── Motion ───────────────────────────────────────────── */ + --nq-ease-out: cubic-bezier(0, 0, .2, 1); + --nq-dur-micro: 120ms; + --nq-dur-base: 200ms; + --nq-dur-modal: 350ms; +} diff --git a/custom_components/nestquest/www/nestquest-cards.js b/custom_components/nestquest/www/nestquest-cards.js new file mode 100644 index 0000000..ede7aab --- /dev/null +++ b/custom_components/nestquest/www/nestquest-cards.js @@ -0,0 +1,3481 @@ +const B = globalThis, nt = B.ShadowRoot && (B.ShadyCSS === void 0 || B.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, it = /* @__PURE__ */ Symbol(), lt = /* @__PURE__ */ new WeakMap(); +let zt = class { + constructor(t, e, n) { + if (this._$cssResult$ = !0, n !== it) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); + this.cssText = t, this.t = e; + } + get styleSheet() { + let t = this.o; + const e = this.t; + if (nt && t === void 0) { + const n = e !== void 0 && e.length === 1; + n && (t = lt.get(e)), t === void 0 && ((this.o = t = new CSSStyleSheet()).replaceSync(this.cssText), n && lt.set(e, t)); + } + return t; + } + toString() { + return this.cssText; + } +}; +const v = (s) => new zt(typeof s == "string" ? s : s + "", void 0, it), Pt = (s, ...t) => { + const e = s.length === 1 ? s[0] : t.reduce((n, i, r) => n + ((o) => { + if (o._$cssResult$ === !0) return o.cssText; + if (typeof o == "number") return o; + throw Error("Value passed to 'css' function must be a 'css' function result: " + o + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security."); + })(i) + s[r + 1], s[0]); + return new zt(e, s, it); +}, Bt = (s, t) => { + if (nt) s.adoptedStyleSheets = t.map((e) => e instanceof CSSStyleSheet ? e : e.styleSheet); + else for (const e of t) { + const n = document.createElement("style"), i = B.litNonce; + i !== void 0 && n.setAttribute("nonce", i), n.textContent = e.cssText, s.appendChild(n); + } +}, ct = nt ? (s) => s : (s) => s instanceof CSSStyleSheet ? ((t) => { + let e = ""; + for (const n of t.cssRules) e += n.cssText; + return v(e); +})(s) : s; +const { is: Ft, defineProperty: Qt, getOwnPropertyDescriptor: Wt, getOwnPropertyNames: Zt, getOwnPropertySymbols: Gt, getPrototypeOf: Vt } = Object, W = globalThis, dt = W.trustedTypes, Kt = dt ? dt.emptyScript : "", Jt = W.reactiveElementPolyfillSupport, I = (s, t) => s, et = { toAttribute(s, t) { + switch (t) { + case Boolean: + s = s ? Kt : null; + break; + case Object: + case Array: + s = s == null ? s : JSON.stringify(s); + } + return s; +}, fromAttribute(s, t) { + let e = s; + switch (t) { + case Boolean: + e = s !== null; + break; + case Number: + e = s === null ? null : Number(s); + break; + case Object: + case Array: + try { + e = JSON.parse(s); + } catch { + e = null; + } + } + return e; +} }, It = (s, t) => !Ft(s, t), pt = { attribute: !0, type: String, converter: et, reflect: !1, useDefault: !1, hasChanged: It }; +Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), W.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); +let E = class extends HTMLElement { + static addInitializer(t) { + this._$Ei(), (this.l ??= []).push(t); + } + static get observedAttributes() { + return this.finalize(), this._$Eh && [...this._$Eh.keys()]; + } + static createProperty(t, e = 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); + } + } + static getPropertyDescriptor(t, e, n) { + const { get: i, set: r } = Wt(this.prototype, t) ?? { get() { + return this[e]; + }, set(o) { + this[e] = o; + } }; + return { get: i, set(o) { + const l = i?.call(this); + r?.call(this, o), this.requestUpdate(t, l, n); + }, configurable: !0, enumerable: !0 }; + } + static getPropertyOptions(t) { + return this.elementProperties.get(t) ?? pt; + } + static _$Ei() { + if (this.hasOwnProperty(I("elementProperties"))) return; + const t = Vt(this); + t.finalize(), t.l !== void 0 && (this.l = [...t.l]), this.elementProperties = new Map(t.elementProperties); + } + static finalize() { + if (this.hasOwnProperty(I("finalized"))) return; + if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(I("properties"))) { + const e = this.properties, n = [...Zt(e), ...Gt(e)]; + for (const i of n) this.createProperty(i, e[i]); + } + const t = this[Symbol.metadata]; + if (t !== null) { + const e = litPropertyMetadata.get(t); + if (e !== void 0) for (const [n, i] of e) this.elementProperties.set(n, i); + } + this._$Eh = /* @__PURE__ */ new Map(); + for (const [e, n] of this.elementProperties) { + const i = this._$Eu(e, n); + i !== void 0 && this._$Eh.set(i, e); + } + this.elementStyles = this.finalizeStyles(this.styles); + } + static finalizeStyles(t) { + const e = []; + if (Array.isArray(t)) { + const n = new Set(t.flat(1 / 0).reverse()); + for (const i of n) e.unshift(ct(i)); + } else t !== void 0 && e.push(ct(t)); + return e; + } + static _$Eu(t, e) { + const n = e.attribute; + return n === !1 ? void 0 : typeof n == "string" ? n : typeof t == "string" ? t.toLowerCase() : void 0; + } + constructor() { + super(), this._$Ep = void 0, this.isUpdatePending = !1, this.hasUpdated = !1, this._$Em = null, this._$Ev(); + } + _$Ev() { + this._$ES = new Promise((t) => this.enableUpdating = t), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t) => t(this)); + } + addController(t) { + (this._$EO ??= /* @__PURE__ */ new Set()).add(t), this.renderRoot !== void 0 && this.isConnected && t.hostConnected?.(); + } + removeController(t) { + this._$EO?.delete(t); + } + _$E_() { + const t = /* @__PURE__ */ new Map(), e = this.constructor.elementProperties; + for (const n of e.keys()) this.hasOwnProperty(n) && (t.set(n, this[n]), delete this[n]); + t.size > 0 && (this._$Ep = t); + } + createRenderRoot() { + const t = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions); + return Bt(t, this.constructor.elementStyles), t; + } + connectedCallback() { + this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(!0), this._$EO?.forEach((t) => t.hostConnected?.()); + } + enableUpdating(t) { + } + disconnectedCallback() { + this._$EO?.forEach((t) => t.hostDisconnected?.()); + } + attributeChangedCallback(t, e, n) { + this._$AK(t, n); + } + _$ET(t, e) { + const n = this.constructor.elementProperties.get(t), i = this.constructor._$Eu(t, n); + if (i !== void 0 && n.reflect === !0) { + const r = (n.converter?.toAttribute !== void 0 ? n.converter : et).toAttribute(e, n.type); + this._$Em = t, r == null ? this.removeAttribute(i) : this.setAttribute(i, r), this._$Em = null; + } + } + _$AK(t, e) { + const n = this.constructor, i = n._$Eh.get(t); + if (i !== void 0 && this._$Em !== i) { + const r = n.getPropertyOptions(i), o = typeof r.converter == "function" ? { fromAttribute: r.converter } : r.converter?.fromAttribute !== void 0 ? r.converter : et; + this._$Em = i; + const l = o.fromAttribute(e, r.type); + this[i] = l ?? this._$Ej?.get(i) ?? l, this._$Em = null; + } + } + requestUpdate(t, e, n, i = !1, r) { + if (t !== void 0) { + const o = this.constructor; + if (i === !1 && (r = this[t]), n ??= o.getPropertyOptions(t), !((n.hasChanged ?? It)(r, e) || n.useDefault && n.reflect && r === this._$Ej?.get(t) && !this.hasAttribute(o._$Eu(t, n)))) return; + this.C(t, e, n); + } + this.isUpdatePending === !1 && (this._$ES = this._$EP()); + } + C(t, e, { useDefault: n, reflect: i, wrapped: r }, o) { + n && !(this._$Ej ??= /* @__PURE__ */ new Map()).has(t) && (this._$Ej.set(t, o ?? e ?? this[t]), r !== !0 || o !== void 0) || (this._$AL.has(t) || (this.hasUpdated || n || (e = void 0), this._$AL.set(t, e)), i === !0 && this._$Em !== t && (this._$Eq ??= /* @__PURE__ */ new Set()).add(t)); + } + async _$EP() { + this.isUpdatePending = !0; + try { + await this._$ES; + } catch (e) { + Promise.reject(e); + } + const t = this.scheduleUpdate(); + return t != null && await t, !this.isUpdatePending; + } + scheduleUpdate() { + return this.performUpdate(); + } + performUpdate() { + if (!this.isUpdatePending) return; + if (!this.hasUpdated) { + if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) { + for (const [i, r] of this._$Ep) this[i] = r; + this._$Ep = void 0; + } + const n = this.constructor.elementProperties; + if (n.size > 0) for (const [i, r] of n) { + const { wrapped: o } = r, l = this[i]; + o !== !0 || this._$AL.has(i) || l === void 0 || this.C(i, void 0, r, l); + } + } + let t = !1; + const e = this._$AL; + try { + t = this.shouldUpdate(e), t ? (this.willUpdate(e), this._$EO?.forEach((n) => n.hostUpdate?.()), this.update(e)) : this._$EM(); + } catch (n) { + throw t = !1, this._$EM(), n; + } + t && this._$AE(e); + } + willUpdate(t) { + } + _$AE(t) { + this._$EO?.forEach((e) => e.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = !0, this.firstUpdated(t)), this.updated(t); + } + _$EM() { + this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = !1; + } + get updateComplete() { + return this.getUpdateComplete(); + } + getUpdateComplete() { + return this._$ES; + } + shouldUpdate(t) { + return !0; + } + update(t) { + this._$Eq &&= this._$Eq.forEach((e) => this._$ET(e, this[e])), this._$EM(); + } + updated(t) { + } + firstUpdated(t) { + } +}; +E.elementStyles = [], E.shadowRootOptions = { mode: "open" }, E[I("elementProperties")] = /* @__PURE__ */ new Map(), E[I("finalized")] = /* @__PURE__ */ new Map(), Jt?.({ ReactiveElement: E }), (W.reactiveElementVersions ??= []).push("2.1.2"); +const st = globalThis, ht = (s) => s, F = st.trustedTypes, ut = F ? F.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, Lt = "$lit$", $ = `lit$${Math.random().toFixed(9).slice(2)}$`, Ut = "?" + $, Xt = `<${Ut}>`, S = document, L = () => S.createComment(""), U = (s) => s === null || typeof s != "object" && typeof s != "function", rt = Array.isArray, Yt = (s) => rt(s) || typeof s?.[Symbol.iterator] == "function", V = `[ +\f\r]`, z = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, ft = /-->/g, mt = />/g, q = RegExp(`>|${V}(?:([^\\s"'>=/]+)(${V}*=${V}*(?:[^ +\f\r"'\`<>=]|("|')|))|$)`, "g"), gt = /'/g, _t = /"/g, Rt = /^(?:script|style|textarea|title)$/i, te = (s) => (t, ...e) => ({ _$litType$: s, strings: t, values: e }), d = te(1), T = /* @__PURE__ */ Symbol.for("lit-noChange"), g = /* @__PURE__ */ Symbol.for("lit-nothing"), yt = /* @__PURE__ */ new WeakMap(), C = S.createTreeWalker(S, 129); +function Dt(s, t) { + if (!rt(s) || !s.hasOwnProperty("raw")) throw Error("invalid template strings array"); + return ut !== void 0 ? ut.createHTML(t) : t; +} +const ee = (s, t) => { + const e = s.length - 1, n = []; + let i, r = t === 2 ? "" : t === 3 ? "" : "", o = z; + for (let l = 0; l < e; l++) { + const a = s[l]; + let p, u, c = -1, f = 0; + for (; f < a.length && (o.lastIndex = f, u = o.exec(a), u !== null); ) f = o.lastIndex, o === z ? u[1] === "!--" ? o = ft : u[1] !== void 0 ? o = mt : u[2] !== void 0 ? (Rt.test(u[2]) && (i = RegExp("" ? (o = i ?? z, c = -1) : u[1] === void 0 ? c = -2 : (c = o.lastIndex - u[2].length, p = u[1], o = u[3] === void 0 ? q : u[3] === '"' ? _t : gt) : o === _t || o === gt ? o = q : o === ft || o === mt ? o = z : (o = q, i = void 0); + const h = o === q && s[l + 1].startsWith("/>") ? " " : ""; + r += o === z ? a + Xt : c >= 0 ? (n.push(p), a.slice(0, c) + Lt + a.slice(c) + $ + h) : a + $ + (c === -2 ? l : h); + } + return [Dt(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); + if (this.el = R.createElement(p, n), C.currentNode = this.el.content, e === 2 || e === 3) { + const c = this.el.content.firstChild; + c.replaceWith(...c.childNodes); + } + for (; (i = C.nextNode()) !== null && a.length < l; ) { + if (i.nodeType === 1) { + if (i.hasAttributes()) for (const c of i.getAttributeNames()) if (c.endsWith(Lt)) { + const f = u[o++], h = i.getAttribute(c).split($), m = /([.?@])?(.*)/.exec(f); + a.push({ type: 1, index: r, name: m[2], strings: h, ctor: m[1] === "." ? ie : m[1] === "?" ? se : m[1] === "@" ? re : Z }), i.removeAttribute(c); + } else c.startsWith($) && (a.push({ type: 6, index: r }), i.removeAttribute(c)); + if (Rt.test(i.tagName)) { + const c = i.textContent.split($), f = c.length - 1; + if (f > 0) { + i.textContent = F ? F.emptyScript : ""; + for (let h = 0; h < f; h++) i.append(c[h], L()), C.nextNode(), a.push({ type: 2, index: ++r }); + i.append(c[f], L()); + } + } + } else if (i.nodeType === 8) if (i.data === Ut) a.push({ type: 2, index: r }); + else { + let c = -1; + for (; (c = i.data.indexOf($, c + 1)) !== -1; ) a.push({ type: 7, index: r }), c += $.length - 1; + } + r++; + } + } + static createElement(t, e) { + const n = S.createElement("template"); + return n.innerHTML = t, n; + } +} +function N(s, t, e = s, n) { + if (t === T) return t; + let i = n !== void 0 ? e._$Co?.[n] : e._$Cl; + const r = U(t) ? void 0 : t._$litDirective$; + return i?.constructor !== r && (i?._$AO?.(!1), r === void 0 ? i = void 0 : (i = new r(s), i._$AT(s, e, n)), n !== void 0 ? (e._$Co ??= [])[n] = i : e._$Cl = i), i !== void 0 && (t = N(s, i._$AS(s, t.values), i, n)), t; +} +class ne { + constructor(t, e) { + this._$AV = [], this._$AN = void 0, this._$AD = t, this._$AM = e; + } + get parentNode() { + return this._$AM.parentNode; + } + get _$AU() { + return this._$AM._$AU; + } + u(t) { + const { el: { content: e }, parts: n } = this._$AD, i = (t?.creationScope ?? S).importNode(e, !0); + C.currentNode = i; + let r = C.nextNode(), o = 0, l = 0, a = n[0]; + for (; a !== void 0; ) { + if (o === a.index) { + 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]; + } + o !== a?.index && (r = C.nextNode(), o++); + } + return C.currentNode = S, i; + } + p(t) { + let e = 0; + for (const n of this._$AV) n !== void 0 && (n.strings !== void 0 ? (n._$AI(t, n, e), e += n.strings.length - 2) : n._$AI(t[e])), e++; + } +} +class O { + get _$AU() { + return this._$AM?._$AU ?? this._$Cv; + } + constructor(t, e, n, i) { + this.type = 2, this._$AH = g, this._$AN = void 0, this._$AA = t, this._$AB = e, this._$AM = n, this.options = i, this._$Cv = i?.isConnected ?? !0; + } + get parentNode() { + let t = this._$AA.parentNode; + const e = this._$AM; + return e !== void 0 && t?.nodeType === 11 && (t = e.parentNode), t; + } + get startNode() { + return this._$AA; + } + get endNode() { + return this._$AB; + } + _$AI(t, e = this) { + t = N(this, t, e), U(t) ? t === g || t == null || t === "" ? (this._$AH !== g && this._$AR(), this._$AH = g) : t !== this._$AH && t !== T && this._(t) : t._$litType$ !== void 0 ? this.$(t) : t.nodeType !== void 0 ? this.T(t) : Yt(t) ? this.k(t) : this._(t); + } + O(t) { + return this._$AA.parentNode.insertBefore(t, this._$AB); + } + T(t) { + this._$AH !== t && (this._$AR(), this._$AH = this.O(t)); + } + _(t) { + 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); + if (this._$AH?._$AD === i) this._$AH.p(e); + else { + const r = new ne(i, this), o = r.u(this.options); + r.p(e), this.T(o), this._$AH = r; + } + } + _$AC(t) { + let e = yt.get(t.strings); + return e === void 0 && yt.set(t.strings, e = new R(t)), e; + } + k(t) { + rt(this._$AH) || (this._$AH = [], this._$AR()); + const e = this._$AH; + let n, i = 0; + for (const r of t) i === e.length ? e.push(n = new O(this.O(L()), this.O(L()), this, this.options)) : n = e[i], n._$AI(r), i++; + i < e.length && (this._$AR(n && n._$AB.nextSibling, i), e.length = i); + } + _$AR(t = this._$AA.nextSibling, e) { + for (this._$AP?.(!1, !0, e); t !== this._$AB; ) { + const n = ht(t).nextSibling; + ht(t).remove(), t = n; + } + } + setConnected(t) { + this._$AM === void 0 && (this._$Cv = t, this._$AP?.(t)); + } +} +class Z { + get tagName() { + return this.element.tagName; + } + get _$AU() { + return this._$AM._$AU; + } + constructor(t, e, n, i, r) { + this.type = 1, this._$AH = g, this._$AN = void 0, this.element = t, this.name = e, this._$AM = i, this.options = r, n.length > 2 || n[0] !== "" || n[1] !== "" ? (this._$AH = Array(n.length - 1).fill(new String()), this.strings = n) : this._$AH = g; + } + _$AI(t, e = this, n, i) { + const r = this.strings; + let o = !1; + if (r === void 0) t = N(this, t, e, 0), o = !U(t) || t !== this._$AH && t !== T, o && (this._$AH = t); + else { + const l = t; + let a, p; + for (t = r[0], a = 0; a < r.length - 1; a++) p = N(this, l[n + a], e, a), p === T && (p = this._$AH[a]), o ||= !U(p) || p !== this._$AH[a], p === g ? t = g : t !== g && (t += (p ?? "") + r[a + 1]), this._$AH[a] = p; + } + o && !i && this.j(t); + } + j(t) { + t === g ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t ?? ""); + } +} +class ie extends Z { + constructor() { + super(...arguments), this.type = 3; + } + j(t) { + this.element[this.name] = t === g ? void 0 : t; + } +} +class se extends Z { + constructor() { + super(...arguments), this.type = 4; + } + j(t) { + this.element.toggleAttribute(this.name, !!t && t !== g); + } +} +class re extends Z { + constructor(t, e, n, i, r) { + super(t, e, n, i, r), this.type = 5; + } + _$AI(t, e = this) { + if ((t = N(this, t, e, 0) ?? g) === T) return; + const n = this._$AH, i = t === g && n !== g || t.capture !== n.capture || t.once !== n.once || t.passive !== n.passive, r = t !== g && (n === g || i); + i && this.element.removeEventListener(this.name, this, n), r && this.element.addEventListener(this.name, this, t), this._$AH = t; + } + handleEvent(t) { + typeof this._$AH == "function" ? this._$AH.call(this.options?.host ?? this.element, t) : this._$AH.handleEvent(t); + } +} +class oe { + constructor(t, e, n) { + this.element = t, this.type = 6, this._$AN = void 0, this._$AM = e, this.options = n; + } + get _$AU() { + return this._$AM._$AU; + } + _$AI(t) { + 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 n = e?.renderBefore ?? t; + let i = n._$litPart$; + if (i === void 0) { + const r = e?.renderBefore ?? null; + n._$litPart$ = i = new O(t.insertBefore(L(), r), r, void 0, e ?? {}); + } + return i._$AI(s), i; +}; +const ot = globalThis; +let A = class extends E { + constructor() { + super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0; + } + createRenderRoot() { + const t = super.createRenderRoot(); + return this.renderOptions.renderBefore ??= t.firstChild, t; + } + update(t) { + const e = this.render(); + this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t), this._$Do = ce(e, this.renderRoot, this.renderOptions); + } + connectedCallback() { + super.connectedCallback(), this._$Do?.setConnected(!0); + } + disconnectedCallback() { + super.disconnectedCallback(), this._$Do?.setConnected(!1); + } + render() { + return T; + } +}; +A._$litElement$ = !0, A.finalized = !0, ot.litElementHydrateSupport?.({ LitElement: A }); +const de = ot.litElementPolyfillSupport; +de?.({ 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}'; +function at(s) { + window.customCards = window.customCards ?? [], window.customCards.push(s); +} +const pe = [ + "nestquest_quest_completed", + "nestquest_quest_uncompleted", + "nestquest_quest_missed", + "nestquest_child_day_complete" +], bt = /* @__PURE__ */ new Map(); +function Q(s, t) { + const e = `${s ?? "local"}|${JSON.stringify(t)}`, n = bt.get(e); + if (n) + return n; + let i; + try { + i = new Intl.DateTimeFormat("en-US", { + ...t, + ...s ? { timeZone: s } : {} + }); + } catch { + i = new Intl.DateTimeFormat("en-US", t); + } + return bt.set(e, i), i; +} +function he(s) { + const t = s?.config; + if (!t || typeof t != "object") + return; + const e = t.time_zone; + return typeof e == "string" && e.trim() ? e.trim() : void 0; +} +function K(s, t) { + return `${Q(t, { weekday: "long" }).format(s)}, ${Q(t, { month: "short", day: "numeric" }).format(s)}`; +} +function ue(s, t) { + return Q(t, { + hour: "numeric", + minute: "2-digit", + hour12: !0 + }).format(s); +} +const fe = { + "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" +}, me = { + "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 vt(s) { + return typeof s == "string" ? s.trim() : ""; +} +function J(s, t) { + if (s == null || s === "") + return t; + const e = typeof s == "number" ? s : Number(s); + return Number.isFinite(e) ? e : t; +} +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) { + return s.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); +} +function _e(s, t) { + const e = Q(t, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23" + }).formatToParts(new Date(s)), n = (r) => { + const o = e.find((l) => l.type === r); + return o ? Number(o.value) : Number.NaN; + }, i = Date.UTC( + n("year"), + n("month") - 1, + n("day"), + n("hour"), + n("minute"), + n("second") + ); + return Number.isFinite(i) ? i - s : 0; +} +function ye(s, t) { + if (typeof s != "string") + return null; + const e = s.split("-"); + if (e.length !== 3) + return null; + const [n, i, r] = e.map((a) => Number(a)); + if (!n || !i || !r) + return null; + if (!t) { + const a = new Date(n, i - 1, r); + return Number.isNaN(a.getTime()) ? null : a; + } + const o = Date.UTC(n, i - 1, r), l = new Date(o - _e(o, t)); + return Number.isNaN(l.getTime()) ? null : l; +} +function be(s) { + const t = fe[s]; + return t || s.charAt(0).toUpperCase() + s.slice(1); +} +function ve(s, t) { + return me[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` + * { + box-sizing: border-box; + } + + :host { + display: block; + height: 100%; + --nq-brand-gradient-h: linear-gradient( + 90deg, + var(--nq-brand-blue), + var(--nq-brand-purple) + ); + } + + .board { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + height: 1080px; + padding: 0; + overflow: hidden; + background: var(--nq-p-parchment); + box-shadow: var(--nq-p-vignette); + font-family: var(--nq-p-font-body); + } + + .frame { + position: absolute; + pointer-events: none; + } + + .frame-outer { + inset: 26px; + border: var(--nq-p-frame-outer); + border-radius: 14px; + } + + .frame-inner { + inset: 36px; + border: var(--nq-p-frame-inner); + border-radius: 8px; + } + + .wordmark-row { + position: absolute; + top: 64px; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 26px; + width: 100%; + } + + .rule { + flex: 1 1 0; + max-width: 340px; + height: 2px; + border-radius: 1px; + background: rgba(92, 62, 26, 0.35); + } + + .d20 { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + clip-path: ${v(xe)}; + background: var(--nq-brand-gradient); + } + + .d20-numeral { + font-family: var(--nq-p-font-heading); + font-size: 15px; + font-weight: 700; + line-height: 1; + color: #ffffff; + } + + .wordmark { + font-family: var(--nq-p-font-display); + font-size: var(--nq-p-size-wordmark); + font-weight: 900; + line-height: 1.15; + background: var(--nq-brand-gradient); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; + } + + .kicker { + position: absolute; + top: 154px; + left: 0; + right: 0; + font-family: var(--nq-p-font-heading); + font-size: 22px; + font-weight: 600; + letter-spacing: var(--nq-p-track-kicker); + text-transform: uppercase; + line-height: 1.2; + text-align: center; + color: var(--nq-p-ink-secondary); + } + + .plates { + position: absolute; + top: 262px; + left: 110px; + right: 110px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 56px; + } + + .plate { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 26px; + height: 600px; + padding: 44px 28px 40px; + border: 2px solid var(--nq-p-panel-border); + border-radius: var(--nq-p-radius-panel); + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + font: inherit; + } + + button.plate { + width: 100%; + color: inherit; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform var(--nq-dur-micro) var(--nq-ease-out); + } + + button.plate:active { + transform: scale(0.98); + } + + button.plate:focus-visible { + outline: 3px solid var(--nq-brand-purple); + outline-offset: 4px; + } + + .plate.away { + border: 2px dashed var(--nq-p-panel-border); + background: linear-gradient(#f2ecdd, #e6dcc6); + box-shadow: none; + } + + .crest { + position: relative; + flex: none; + width: 188px; + height: 214px; + padding: 4px; + clip-path: ${v($t)}; + 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: ${v($t)}; + background: var(--nq-p-crest); + } + + .crest.away .crest-face { + background: var(--nq-p-crest-away); + } + + .crest .initial { + font-family: var(--nq-p-font-display); + font-size: 82px; + font-weight: 700; + line-height: 1; + color: #ffffff; + } + + .name { + font-family: var(--nq-p-font-heading); + font-size: var(--nq-p-size-name); + font-weight: 700; + letter-spacing: 0.02em; + line-height: 1.1; + color: var(--nq-p-ink); + } + + .plate.away .name { + color: var(--nq-p-ink-away); + } + + .pill { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 8px 20px; + border-radius: var(--nq-p-radius-pill); + background: rgba(22, 120, 60, 0.35); + font-family: var(--nq-p-font-heading); + font-size: 22px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + line-height: 1; + color: #146b36; + } + + .pill svg { + width: 22px; + height: 22px; + color: #157a3c; + } + + .pill.away { + background: rgba(92, 62, 26, 0.16); + color: var(--nq-p-ink-away); + } + + .pill.away svg { + color: var(--nq-p-ink-away); + } + + .progress-line { + font-family: var(--nq-p-font-heading); + font-size: 24px; + font-weight: 700; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .bar { + width: 100%; + height: 16px; + border: 1px solid rgba(92, 62, 26, 0.35); + border-radius: var(--nq-p-radius-pill); + background: rgba(92, 62, 26, 0.18); + overflow: hidden; + } + + .bar .fill { + display: block; + height: 100%; + border-radius: inherit; + background: var(--nq-brand-gradient-h); + } + + .hint { + position: absolute; + top: 930px; + left: 0; + right: 0; + font-family: var(--nq-p-font-body); + font-size: 24px; + font-weight: 600; + line-height: 1.4; + text-align: center; + color: var(--nq-p-ink-secondary); + } + + .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; + } +`, $e = d``, qe = d``, ke = d``, Ce = d``, Ae = d``, qt = { + icon: Ce, + headline: "NestQuest is not set up yet", + body: "A parent needs to finish setting up NestQuest before the party can gather." +}, Se = { + icon: Ae, + 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 { + static properties = { + hass: { attribute: !1 }, + _config: { state: !0 }, + _now: { state: !0 } + }; + static styles = [v(jt), we]; + hass; + _config; + _now = /* @__PURE__ */ new Date(); + _clockTimer; + _unsubs = []; + _subscribed = !1; + _subGeneration = 0; + _retryTimer; + setConfig(t) { + if (!t || typeof t != "object") + throw new Error("Invalid configuration"); + this._config = t; + } + _hassConnection() { + const t = this.hass?.connection; + return !t || typeof t != "object" || typeof t.subscribeEvents != "function" ? null : t; + } + _subscribeLive() { + if (!this.isConnected || this._subscribed) + return; + const t = this._hassConnection(); + if (!t) + return; + this._subscribed = !0; + const e = ++this._subGeneration, n = (i) => { + i.then((r) => { + this._subscribed && e === this._subGeneration ? this._unsubs.push(r) : r(); + }).catch(() => { + e === this._subGeneration && (this._unsubscribeLive(), this._armSubscribeRetry()); + }); + }; + for (const i of pe) + n( + t.subscribeEvents(() => this.requestUpdate(), i) + ); + } + _armSubscribeRetry() { + this._retryTimer === void 0 && (this._retryTimer = window.setTimeout(() => { + this._retryTimer = void 0, this._subscribeLive(); + }, 1e3)); + } + _clearSubscribeRetry() { + this._retryTimer !== void 0 && (window.clearTimeout(this._retryTimer), this._retryTimer = void 0); + } + _unsubscribeLive() { + this._clearSubscribeRetry(), this._subGeneration++; + for (const t of this._unsubs) + t(); + this._unsubs = [], this._subscribed = !1; + } + getCardSize() { + return 22; + } + connectedCallback() { + super.connectedCallback(), this._subscribeLive(), this._now = /* @__PURE__ */ new Date(), this._clockTimer = window.setInterval(() => { + this._now = /* @__PURE__ */ new Date(); + }, 1e3); + } + updated() { + this._subscribeLive(); + } + disconnectedCallback() { + this._unsubscribeLive(), this._clockTimer !== void 0 && (window.clearInterval(this._clockTimer), this._clockTimer = void 0), super.disconnectedCallback(); + } + render() { + const t = this._boardNotice(); + return d` +
+
+
+
+ + + NestQuest + + +
+

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

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

Tap your crest to open your Quest Log

+ `} +
+ ${this._renderDock()} +
+ `; + } + _timeZone() { + return he(this.hass); + } + _state(t) { + const n = this.hass?.states?.[t]; + return n && typeof n == "object" ? n : null; + } + _childSlugs() { + if (!this._config) + return []; + const t = this._config.child_order; + return Array.isArray(t) ? t.filter( + (e) => typeof e == "string" && e.trim().length > 0 + ).map((e) => e.trim()) : []; + } + _plates() { + return this._childSlugs().map((t) => this._plate(t)); + } + _plate(t) { + const e = this._dueSensorResolution(t), n = this._state(`sensor.nestquest_${t}_quests_due_today`), i = this._state( + `sensor.nestquest_${t}_quests_completed_today` + ), r = this._state( + `sensor.nestquest_${t}_completion_pct_today` + ), o = this._state( + `binary_sensor.nestquest_${t}_present_today` + ), l = n?.attributes ?? {}, a = wt(J(n?.state, 0)), p = wt(J(i?.state, 0)), u = r ? J(r.state, Number.NaN) : Number.NaN, c = Number.isFinite(u) ? xt(u) : a > 0 ? xt(p / a * 100) : 0; + let f = !0; + const h = l.present; + typeof h == "boolean" ? f = h : o && (f = String(o.state ?? "").trim().toLowerCase() === "on"); + let m = ""; + const _ = l.child_name; + if (typeof _ == "string" && _.trim()) + m = _.trim(); + else { + const G = o?.attributes?.child_name; + typeof G == "string" && G.trim() && (m = G.trim()); + } + m || (m = ge(t)); + const x = ye( + o?.attributes?.next_present, + this._timeZone() + ), D = x ? `Returns ${K(x, this._timeZone())}` : null; + return { + slug: t, + name: m, + initial: (m.charAt(0) || "?").toUpperCase(), + present: e === null ? f : !1, + completed: p, + due: a, + pct: c, + returns: D, + unresolved: e + }; + } + /** "missing" when the due sensor does not exist (integration not + * configured), "stale" when it answers unavailable/unknown (backend + * unreachable or the child is absent from the snapshot), null when + * it resolves. */ + _dueSensorResolution(t) { + const e = this._state(`sensor.nestquest_${t}_quests_due_today`); + if (!e) + return "missing"; + const n = String(e.state ?? "").trim().toLowerCase(); + return !n || n === "unavailable" || n === "unknown" ? "stale" : null; + } + _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; + } + _renderNotice(t) { + return d` +
+
+ ${t.icon} + ${t.headline} +

${t.body}

+
+
+ `; + } + _renderPlate(t) { + return t.unresolved ? d` +
+ + + ${t.initial} + + + ${t.name} + + ${ke} + Unknown + + — + +
+ ` : t.present ? d` + + ` : d` +
+ + + ${t.initial} + + + ${t.name} + + ${qe} + On travels + + ${t.returns ?? "Returns —"} + +
+ `; + } + _openQuestLog(t) { + const e = vt(this._config?.quest_log_path); + e && (window.history.pushState(null, "", `${e.replace(/\/+$/, "")}/${t}`), window.dispatchEvent(new Event("location-changed"))); + } + _dockWeather() { + const t = vt(this._config?.weather_entity); + if (!t) + return null; + const e = this._state(t); + if (!e) + return null; + const n = String(e.state ?? "").trim(); + if (!n || n === "unavailable" || n === "unknown") + return null; + const i = e.attributes ?? {}; + let r = null, o = null; + const l = i.forecast; + if (Array.isArray(l) && l.length > 0) { + const a = l[0]; + if (a && typeof a == "object") { + const p = a; + r = this._optionalNumber(p.temperature), o = this._optionalNumber(p.templow); + } + } + return { + condition: n, + temperature: this._optionalNumber(i.temperature), + high: r, + low: o + }; + } + _optionalNumber(t) { + if (t == null || t === "") + return null; + const e = typeof t == "number" ? t : Number(t); + return Number.isFinite(e) ? e : null; + } + _renderDock() { + const t = this._dockWeather(); + if (!t) + return d` +
+ ${K(this._now, this._timeZone())} + + ${ue(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)}°`; + return d` +
+ ${Te} + ${i} + + + ${r === null ? e : `${e} · ${r}`} + + + ${n} +
+ `; + } +} +customElements.define("nestquest-party-board-card", Ee); +at({ + type: "nestquest-party-board-card", + name: "NestQuest Party Board", + description: "Kids' attract screen for present and away adventurers." +}); +const Ne = { CHILD: 2 }, Me = (s) => (...t) => ({ _$litDirective$: s, values: t }); +let Oe = class { + constructor(t) { + } + get _$AU() { + return this._$AM._$AU; + } + _$AT(t, e, n) { + this._$Ct = t, this._$AM = e, this._$Ci = n; + } + _$AS(t, e) { + return this.update(t, e); + } + update(t, e) { + return this.render(...e); + } +}; +const { I: ze } = ae, kt = (s) => s, Ct = () => document.createComment(""), P = (s, t, e) => { + const n = s._$AA.parentNode, i = t === void 0 ? s._$AB : t._$AA; + if (e === void 0) { + const r = n.insertBefore(Ct(), i), o = n.insertBefore(Ct(), i); + e = new ze(r, o, s, s.options); + } else { + const r = e._$AB.nextSibling, o = e._$AM, l = o !== s; + if (l) { + let a; + e._$AQ?.(s), e._$AM = s, e._$AP !== void 0 && (a = s._$AU) !== o._$AU && e._$AP(a); + } + if (r !== i || l) { + let a = e._$AA; + for (; a !== r; ) { + const p = kt(a).nextSibling; + kt(n).insertBefore(a, i), a = 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) => { + 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 { + constructor(s) { + if (super(s), s.type !== Ne.CHILD) throw Error("repeat() can only be used in text expressions"); + } + dt(s, t, e) { + let n; + e === void 0 ? e = t : t !== void 0 && (n = t); + const i = [], r = []; + let o = 0; + for (const l of s) i[o] = n ? n(l, o) : o, r[o] = e(l, o), o++; + return { values: r, keys: i }; + } + render(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); + 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; + for (; c <= f && h <= m; ) if (i[c] === null) c++; + else if (i[f] === null) f--; + else if (l[c] === o[h]) a[h] = k(i[c], r[h]), c++, h++; + else if (l[f] === o[m]) a[m] = k(i[f], r[m]), f--, m--; + else if (l[c] === o[m]) a[m] = k(i[c], r[m]), P(s, a[m + 1], i[c]), c++, m--; + else if (l[f] === o[h]) a[h] = k(i[f], r[h]), P(s, i[c], i[f]), f--, h++; + else if (p === void 0 && (p = At(o, h, m), u = At(l, c, f)), p.has(l[c])) if (p.has(l[f])) { + const _ = u.get(o[h]), x = _ !== void 0 ? i[_] : null; + if (x === null) { + const D = P(s, i[c]); + k(D, r[h]), a[h] = D; + } else a[h] = k(x, r[h]), P(s, i[c], x), i[_] = null; + h++; + } else X(i[f]), f--; + else X(i[c]), c++; + for (; h <= m; ) { + const _ = P(s, a[m + 1]); + k(_, r[h]), a[h++] = _; + } + for (; c <= f; ) { + const _ = i[c++]; + _ !== null && X(_); + } + return this.ut = o, Ie(s, a), T; + } +}), Ue = [ + "nestquest_quest_completed", + "nestquest_quest_uncompleted", + "nestquest_quest_missed", + "nestquest_child_day_complete" +], Tt = /* @__PURE__ */ new Map(); +function M(s, t) { + const e = `${s ?? "local"}|${JSON.stringify(t)}`, n = Tt.get(e); + if (n) + return n; + let i; + try { + i = new Intl.DateTimeFormat("en-US", { + ...t, + ...s ? { timeZone: s } : {} + }); + } catch { + i = new Intl.DateTimeFormat("en-US", t); + } + return Tt.set(e, i), i; +} +function Re(s) { + const t = s?.config; + if (!t || typeof t != "object") + return; + const e = t.time_zone; + return typeof e == "string" && e.trim() ? e.trim() : void 0; +} +function j(s, t) { + return `${M(t, { weekday: "long" }).format(s)}, ${M(t, { month: "short", day: "numeric" }).format(s)}`; +} +function De(s, t) { + return M(t, { weekday: "long" }).format(s); +} +function je(s, t) { + const e = new Date(s); + return Number.isNaN(e.getTime()) ? "" : M(t, { + hour: "numeric", + minute: "2-digit", + hour12: !0 + }).format(e); +} +function He(s, t) { + return M(t, { + hour: "numeric", + minute: "2-digit", + hour12: !0 + }).format(s); +} +function Be(s) { + if (!s) + return null; + const t = /^(\d{1,2}):(\d{2})$/.exec(s.trim()); + if (!t) + return null; + const e = Number(t[1]); + if (e > 23) + return null; + const n = e >= 12 ? "PM" : "AM"; + return `${e % 12 === 0 ? 12 : e % 12}:${t[2]} ${n}`; +} +function w(s) { + return typeof s == "string" ? s.trim() : ""; +} +function y(s, t) { + if (s == null || s === "") + return t; + const e = typeof s == "number" ? s : Number(s); + return Number.isFinite(e) ? e : t; +} +function b(s) { + return Math.max(0, Math.round(s)); +} +function Y(s) { + return s.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); +} +function Fe(s, t) { + const e = M(t, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23" + }).formatToParts(new Date(s)), n = (r) => { + const o = e.find((l) => l.type === r); + return o ? Number(o.value) : Number.NaN; + }, i = Date.UTC( + n("year"), + n("month") - 1, + n("day"), + n("hour"), + n("minute"), + n("second") + ); + return Number.isFinite(i) ? i - s : 0; +} +function Et(s, t) { + if (typeof s != "string") + return null; + const e = s.split("-"); + if (e.length !== 3) + return null; + const [n, i, r] = e.map((a) => Number(a)); + if (!n || !i || !r) + return null; + if (!t) { + const a = new Date(n, i - 1, r); + return Number.isNaN(a.getTime()) ? null : a; + } + const o = Date.UTC(n, i - 1, r), l = new Date(o - Fe(o, t)); + return Number.isNaN(l.getTime()) ? null : l; +} +function Qe() { + const s = window.location.pathname.split("/").filter(Boolean); + return s.length > 0 ? s[s.length - 1].toLowerCase() : ""; +} +function We(s) { + if (!s || typeof s != "object") + return null; + const t = s, e = w(t.state).toLowerCase(); + if (e !== "open" && e !== "completed") + return null; + const n = y(t.id, 0); + if (!n) + return null; + const i = y(t.child_id, 0); + return { + id: n, + child_id: i > 0 ? i : null, + title: w(t.title) || "Quest", + icon: w(t.icon) || null, + window: w(t.window).toLowerCase(), + due_time: w(t.due_time) || null, + state: e, + overdue: t.overdue === !0, + completed_at: w(t.completed_at) || null, + on_time: t.on_time === !0 ? !0 : t.on_time === !1 ? !1 : null + }; +} +const Ze = { + "clear-night": "Clear", + cloudy: "Cloudy", + exceptional: "Clear", + fog: "Foggy", + hail: "Hail", + lightning: "Storms", + "lightning-rainy": "Storms", + partlycloudy: "Partly cloudy", + pouring: "Heavy rain", + rainy: "Rain", + snowy: "Snow", + "snowy-rainy": "Sleet", + sunny: "Sunny", + windy: "Windy", + "windy-variant": "Windy" +}, Ge = { + "clear-night": "Clear night skies", + cloudy: "Grey skies today", + exceptional: "A striking day", + fog: "Mist on the road", + hail: "Ice from the sky", + lightning: "Storms may roll in", + "lightning-rainy": "Storms may roll in", + partlycloudy: "Sun between clouds", + pouring: "Heavy rain outside", + rainy: "Rain on the walls", + snowy: "Snow on the peaks", + "snowy-rainy": "Sleet may fall", + sunny: "Clear skies today", + windy: "A blustery day", + "windy-variant": "A blustery day" +}; +function Ve(s) { + const t = Ze[s]; + return t || s.charAt(0).toUpperCase() + s.slice(1); +} +function Ke(s, t) { + return Ge[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` + * { + box-sizing: border-box; + } + + :host { + display: block; + height: 100%; + --nq-brand-gradient-h: linear-gradient( + 90deg, + var(--nq-brand-blue), + var(--nq-brand-purple) + ); + } + + .board { + position: relative; + display: flex; + flex-direction: column; + height: 1080px; + padding: 0; + overflow: hidden; + background: var(--nq-p-parchment); + box-shadow: var(--nq-p-vignette); + font-family: var(--nq-p-font-body); + } + + .frame { + position: absolute; + pointer-events: none; + } + + .frame-outer { + inset: 26px; + border: var(--nq-p-frame-outer); + border-radius: 14px; + } + + .frame-inner { + inset: 36px; + border: var(--nq-p-frame-inner); + border-radius: 8px; + } + + .header { + position: absolute; + top: 56px; + left: var(--nq-p-page-inset); + right: var(--nq-p-page-inset); + display: flex; + align-items: center; + gap: 28px; + } + + .crest { + position: relative; + flex: none; + width: 96px; + height: 110px; + padding: 3px; + clip-path: ${v(Nt)}; + background: rgba(255, 255, 255, 0.22); + } + + .crest-face { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding-bottom: 32px; + clip-path: ${v(Nt)}; + background: var(--nq-p-crest); + } + + .crest .initial { + font-family: var(--nq-p-font-display); + font-size: 44px; + font-weight: 700; + line-height: 1; + color: #ffffff; + } + + .crest.away .crest-face { + background: var(--nq-p-crest-away); + } + + .titles { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + } + + .title { + margin: 0; + font-family: var(--nq-p-font-display); + font-size: var(--nq-p-size-title); + font-weight: 700; + line-height: 1.1; + color: var(--nq-p-ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .sub { + margin: 0; + font-family: var(--nq-p-font-heading); + font-size: 20px; + font-weight: 600; + letter-spacing: 0.24em; + text-transform: uppercase; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .remaining { + margin-left: auto; + flex: none; + display: flex; + align-items: center; + gap: 20px; + padding: 18px 28px; + border: 2px solid var(--nq-p-panel-border); + border-radius: 14px; + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + } + + .d20 { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + clip-path: ${v(Je)}; + background: var(--nq-brand-gradient); + } + + .d20-numeral { + font-family: var(--nq-p-font-heading); + font-size: 19px; + font-weight: 700; + line-height: 1; + color: #ffffff; + } + + .remaining-text { + display: flex; + flex-direction: column; + gap: 4px; + } + + .remaining-count { + font-family: var(--nq-p-font-heading); + font-size: 28px; + font-weight: 700; + line-height: 1.1; + color: var(--nq-p-ink); + } + + .party-line { + font-family: var(--nq-p-font-body); + font-size: var(--nq-p-size-body); + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .columns { + position: absolute; + top: 236px; + left: var(--nq-p-page-inset); + right: var(--nq-p-page-inset); + bottom: 130px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 34px; + } + + .column { + display: flex; + flex-direction: column; + min-height: 0; + } + + .column-head { + flex: none; + display: flex; + align-items: center; + gap: 14px; + padding-bottom: 14px; + border-bottom: var(--nq-p-rule); + } + + .column-head svg { + flex: none; + width: 34px; + height: 34px; + color: var(--nq-p-ink-secondary); + } + + .column-names { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + .column-name { + font-family: var(--nq-p-font-heading); + font-size: var(--nq-p-size-section); + font-weight: 700; + line-height: 1.1; + color: var(--nq-p-ink); + } + + .column-range { + font-family: var(--nq-p-font-heading); + font-size: var(--nq-p-size-label); + font-weight: 600; + letter-spacing: var(--nq-p-track-label); + text-transform: uppercase; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .column-count { + margin-left: auto; + flex: none; + font-family: var(--nq-p-font-heading); + font-size: 26px; + font-weight: 700; + line-height: 1.1; + color: var(--nq-p-ink-secondary); + } + + .stack { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: visible; + gap: var(--nq-p-quest-gap); + margin-top: 22px; + } + + .quest { + position: relative; + display: flex; + align-items: center; + gap: 22px; + min-height: var(--nq-p-quest-min-height); + padding: var(--nq-p-quest-pad); + border: 1px solid var(--nq-p-card-border); + border-radius: var(--nq-p-radius-card); + background: var(--nq-p-card-open); + box-shadow: var(--nq-p-card-shadow); + -webkit-tap-highlight-color: transparent; + transition: transform var(--nq-dur-micro) var(--nq-ease-out); + } + + .quest.tappable { + cursor: pointer; + } + + .quest.tappable:active { + transform: scale(0.98); + } + + .quest.sealed { + background: var(--nq-p-card-done); + opacity: 0.82; + box-shadow: none; + } + + .tile { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--nq-p-tile-size); + height: var(--nq-p-tile-size); + border-radius: 10px; + background: var(--nq-p-icon-tile); + color: #ffffff; + } + + .tile svg { + width: 34px; + height: 34px; + } + + .sealed .tile { + background: var(--nq-p-icon-tile-done); + color: var(--nq-p-ink-secondary); + } + + .quest .body { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + } + + .quest .quest-title { + font-family: var(--nq-p-font-heading); + font-size: var(--nq-p-size-quest); + font-weight: 700; + line-height: 1.15; + color: var(--nq-p-ink); + overflow-wrap: anywhere; + } + + .sealed .quest-title { + color: var(--nq-p-ink-muted); + text-decoration: line-through; + } + + .quest .meta { + font-family: var(--nq-p-font-body); + font-size: var(--nq-p-size-body); + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .quest .meta.late { + font-family: var(--nq-p-font-heading); + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--nq-p-ink-late); + } + + button.complete { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + height: var(--nq-p-button-height); + min-width: var(--nq-p-button-min-width); + border: none; + border-radius: 10px; + background: var(--nq-brand-gradient); + color: #ffffff; + font-family: var(--nq-p-font-heading); + font-size: 24px; + font-weight: 700; + line-height: 1; + box-shadow: 0 3px 0 rgba(40, 20, 60, 0.35); + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform var(--nq-dur-micro) var(--nq-ease-out); + } + + button.complete:active { + transform: scale(0.98); + } + + button.complete svg { + width: 30px; + height: 30px; + } + + button:focus-visible { + outline: 3px solid var(--nq-brand-purple); + outline-offset: 4px; + } + + .seal { + position: absolute; + right: 16px; + top: -12px; + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--nq-p-seal-size); + height: var(--nq-p-seal-size); + border-radius: var(--nq-p-radius-pill); + background: var(--nq-p-seal); + box-shadow: var(--nq-p-seal-shadow); + color: rgba(255, 235, 235, 0.95); + transform: rotate(var(--seal-rot, 0deg)); + animation: seal-in var(--nq-dur-base) var(--nq-ease-out); + pointer-events: none; + } + + .seal svg { + width: 38px; + height: 38px; + } + + @keyframes seal-in { + from { + opacity: 0; + transform: scale(1.15) rotate(var(--seal-rot, 0deg)); + } + to { + opacity: 1; + transform: scale(1) rotate(var(--seal-rot, 0deg)); + } + } + + .rollup { + border: 2px dashed var(--nq-p-panel-border); + border-radius: 14px; + padding: 16px 22px; + } + + .rollup-label { + display: block; + font-family: var(--nq-p-font-heading); + font-size: 18px; + font-weight: 600; + letter-spacing: var(--nq-p-track-label); + text-transform: uppercase; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .rollup-body { + margin: 6px 0 0; + font-family: var(--nq-p-font-body); + font-size: 23px; + font-weight: 600; + line-height: 1.35; + color: var(--nq-p-ink-secondary); + } + + .notice-wrap { + position: absolute; + top: 236px; + left: var(--nq-p-page-inset); + right: var(--nq-p-page-inset); + bottom: 130px; + display: flex; + align-items: center; + justify-content: center; + } + + .notice { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 20px; + width: 100%; + max-width: 980px; + padding: 56px 64px; + border: 2px solid var(--nq-p-panel-border); + border-radius: 20px; + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + text-align: center; + } + + .notice svg { + width: 46px; + height: 46px; + color: var(--nq-p-ink-secondary); + } + + .notice.away { + background: linear-gradient(#f2ecdd, #e6dcc6); + border-style: dashed; + box-shadow: none; + } + + .notice-headline { + font-family: var(--nq-p-font-heading); + font-size: 40px; + font-weight: 700; + line-height: 1.15; + color: var(--nq-p-ink); + } + + .notice-body { + margin: 0; + font-family: var(--nq-p-font-body); + font-size: 24px; + font-weight: 600; + line-height: 1.4; + color: var(--nq-p-ink-secondary); + } + + .notice-seal { + display: inline-flex; + align-items: center; + justify-content: center; + width: 78px; + height: 78px; + border-radius: var(--nq-p-radius-pill); + background: var(--nq-p-seal); + box-shadow: var(--nq-p-seal-shadow); + color: rgba(255, 235, 235, 0.95); + transform: rotate(-6deg); + } + + .notice-seal svg { + width: 38px; + height: 38px; + color: rgba(255, 235, 235, 0.95); + } + + .scrim { + position: absolute; + inset: 0; + z-index: 10; + background: rgba(30, 20, 10, 0.62); + } + + .dialog { + position: absolute; + top: 196px; + left: 460px; + width: 1000px; + border: 3px solid rgba(92, 62, 26, 0.5); + border-radius: 20px; + background: var(--nq-p-card-panel); + box-shadow: 0 26px 60px rgba(20, 10, 0, 0.5); + padding: 64px 68px 56px; + animation: dialog-in var(--nq-dur-modal) var(--nq-ease-out); + } + + @keyframes dialog-in { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } + } + + .dialog-header { + display: flex; + align-items: center; + gap: 24px; + } + + .dialog-tile { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 104px; + height: 104px; + border-radius: 12px; + background: var(--nq-p-icon-tile); + color: #ffffff; + } + + .dialog-tile svg { + width: 56px; + height: 56px; + } + + .dialog-titles { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + } + + .dialog-kicker { + font-family: var(--nq-p-font-heading); + font-size: var(--nq-p-size-label); + font-weight: 600; + letter-spacing: 0.24em; + text-transform: uppercase; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .dialog-quest-title { + font-family: var(--nq-p-font-display); + font-size: 58px; + font-weight: 700; + line-height: 1.1; + color: var(--nq-p-ink); + overflow-wrap: anywhere; + } + + .dialog-body { + margin: 28px 0 0; + font-family: var(--nq-p-font-body); + font-size: 32px; + font-weight: 600; + line-height: 1.35; + color: #3f2f1c; + } + + .dialog-buttons { + display: flex; + gap: 24px; + margin-top: 40px; + } + + button.confirm-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 12px; + height: var(--nq-p-confirm-button); + border-radius: 10px; + font-family: var(--nq-p-font-heading); + font-weight: 700; + line-height: 1; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform var(--nq-dur-micro) var(--nq-ease-out); + } + + button.confirm-button:active { + transform: scale(0.98); + } + + button.not-yet { + flex: 1; + border: 2px solid rgba(92, 62, 26, 0.5); + background: rgba(92, 62, 26, 0.1); + color: var(--nq-p-ink); + font-size: 30px; + } + + button.not-yet svg { + width: 34px; + height: 34px; + } + + button.do-complete { + flex: 2; + border: none; + background: var(--nq-brand-gradient); + color: #ffffff; + font-size: 32px; + } + + button.do-complete svg { + width: 38px; + height: 38px; + } + + .toast { + position: absolute; + left: 50%; + bottom: 158px; + z-index: 20; + transform: translateX(-50%); + max-width: calc(100% - 124px); + padding: 20px 32px; + border: 2px solid var(--nq-p-panel-border); + border-radius: 14px; + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + font-family: var(--nq-p-font-body); + font-size: 23px; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--nq-p-ink-secondary); + animation: toast-in var(--nq-dur-base) var(--nq-ease-out); + pointer-events: none; + } + + @keyframes toast-in { + from { + opacity: 0; + transform: translate(-50%, 12px); + } + to { + opacity: 1; + transform: translate(-50%, 0); + } + } + + .complete-screen { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + padding: 96px 62px 130px; + text-align: center; + } + + .complete-seal { + display: inline-flex; + align-items: center; + justify-content: center; + width: 168px; + height: 168px; + border-radius: var(--nq-p-radius-pill); + background: var(--nq-p-seal); + box-shadow: 0 10px 24px rgba(60, 10, 20, 0.42); + color: rgba(255, 235, 235, 0.95); + transform: rotate(-6deg); + } + + .complete-seal svg { + width: 86px; + height: 86px; + } + + .complete-title { + margin: 28px 0 0; + font-family: var(--nq-p-font-display); + font-size: 86px; + font-weight: 900; + line-height: 1.1; + background: var(--nq-brand-gradient-h); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; + } + + .complete-sub { + margin: 16px 0 0; + max-width: 1000px; + font-family: var(--nq-p-font-body); + font-size: 34px; + font-weight: 600; + line-height: 1.35; + color: var(--nq-p-ink-secondary); + text-wrap: pretty; + } + + .complete-countdown { + margin: 12px 0 0; + font-family: var(--nq-p-font-body); + font-size: 25px; + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .complete-stats { + position: absolute; + top: 540px; + left: 200px; + right: 200px; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 26px; + } + + .complete-stat { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px 16px; + border: 2px solid var(--nq-p-panel-border); + border-radius: 16px; + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + } + + .complete-stat .numeral { + font-family: var(--nq-p-font-heading); + font-size: 58px; + font-weight: 900; + line-height: 1; + color: var(--nq-p-ink); + } + + .complete-stat .label { + font-family: var(--nq-p-font-heading); + font-size: 17px; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .dock { + position: absolute; + left: 46px; + right: 46px; + bottom: 46px; + height: var(--nq-p-dock-height); + display: flex; + align-items: center; + justify-content: center; + gap: 24px; + padding: 0 40px; + border-radius: 14px; + background: var(--nq-p-dock-bg); + pointer-events: none; + user-select: none; + color: var(--nq-p-dock-ink); + } + + .dock svg { + flex: none; + width: 40px; + height: 40px; + color: var(--nq-p-dock-ink); + } + + .dock .divider { + flex: none; + width: 1px; + height: 40px; + background: var(--nq-p-dock-divider); + } + + .dock .temp { + font-family: var(--nq-p-font-body); + font-size: 38px; + font-weight: 800; + line-height: 1; + } + + .dock .condition { + font-family: var(--nq-p-font-body); + font-size: 26px; + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-dock-ink-secondary); + } + + .dock .date { + font-family: var(--nq-p-font-body); + font-size: 26px; + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-dock-ink-secondary); + } + + .dock .clock { + font-family: var(--nq-p-font-body); + font-size: 38px; + font-weight: 800; + line-height: 1; + } +`, H = d``, Ye = d``, tt = d``, tn = d``, Ht = d``, en = d``, nn = d``, sn = d``, rn = d``, on = d``, an = d``, ln = { + icon: sn, + headline: "No adventurer chosen", + body: "Open The Party and tap your crest to open your quest log." +}, cn = { + icon: rn, + headline: "NestQuest is not set up yet", + body: "A parent needs to finish setting up NestQuest." +}, dn = { + icon: on, + headline: "The records cannot be reached", + body: "The party's records are quiet right now. NestQuest will return shortly." +}, Ot = [ + { key: "morning", name: "Morning", range: "Until 11:59 AM", icon: tn }, + { key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: Ht }, + { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: en } +]; +class pn extends A { + static properties = { + hass: { attribute: !1 }, + _config: { state: !0 }, + _now: { state: !0 }, + _confirm: { state: !0 }, + _optimistic: { state: !0 }, + _toast: { state: !0 }, + _countdown: { state: !0 } + }; + static styles = [v(jt), Xe]; + hass; + _config; + _now = /* @__PURE__ */ new Date(); + _confirm = null; + /** instanceId → the completion instant stamped before the service has + * answered; the seal shows immediately and reverts on failure. */ + _optimistic = /* @__PURE__ */ new Map(); + _toast = null; + _countdown = null; + _clockTimer; + _idleTimer; + _confirmTimer; + _toastTimer; + _completeTimer; + _unsubs = []; + _subscribed = !1; + _subGeneration = 0; + _retryTimer; + setConfig(t) { + if (!t || typeof t != "object") + throw new Error("Invalid configuration"); + this._config = t; + } + _hassConnection() { + const t = this.hass?.connection; + return !t || typeof t != "object" || typeof t.subscribeEvents != "function" ? null : t; + } + _subscribeLive() { + if (!this.isConnected || this._subscribed) + return; + const t = this._hassConnection(); + if (!t) + return; + this._subscribed = !0; + const e = ++this._subGeneration, n = (i) => { + i.then((r) => { + this._subscribed && e === this._subGeneration ? this._unsubs.push(r) : r(); + }).catch(() => { + e === this._subGeneration && (this._unsubscribeLive(), this._armSubscribeRetry()); + }); + }; + for (const i of Ue) + n( + t.subscribeEvents(() => this.requestUpdate(), i) + ); + } + _armSubscribeRetry() { + this._retryTimer === void 0 && (this._retryTimer = window.setTimeout(() => { + this._retryTimer = void 0, this._subscribeLive(); + }, 1e3)); + } + _clearSubscribeRetry() { + this._retryTimer !== void 0 && (window.clearTimeout(this._retryTimer), this._retryTimer = void 0); + } + _unsubscribeLive() { + this._clearSubscribeRetry(), this._subGeneration++; + for (const t of this._unsubs) + t(); + this._unsubs = [], this._subscribed = !1; + } + getCardSize() { + return 22; + } + connectedCallback() { + super.connectedCallback(), this._subscribeLive(), this._now = /* @__PURE__ */ new Date(), this._clockTimer = window.setInterval(() => { + this._now = /* @__PURE__ */ new Date(); + }, 6e4), window.addEventListener("pointerdown", this._onActivity, !0), window.addEventListener("touchstart", this._onActivity, !0), window.addEventListener("keydown", this._onActivity, !0), this._armIdle(), this._logView().kind === "complete-day" && this._armCompleteTimer(); + } + updated() { + this._subscribeLive(), this._logView().kind === "complete-day" ? (this._clearIdle(), this._armCompleteTimer()) : (this._clearCompleteTimer(), this._countdown = null, this._idleTimer === void 0 && this._armIdle()); + } + disconnectedCallback() { + this._unsubscribeLive(), this._stopClock(), this._clearIdle(), this._clearConfirmTimer(), this._clearToastTimer(), this._clearCompleteTimer(), window.removeEventListener("pointerdown", this._onActivity, !0), window.removeEventListener("touchstart", this._onActivity, !0), window.removeEventListener("keydown", this._onActivity, !0), super.disconnectedCallback(); + } + render() { + const t = this._logView(); + return d` +
+
+
+ ${this._renderMain(t)} + ${this._renderConfirm()} + ${this._renderToast()} +
+ `; + } + _renderToast() { + return this._toast === null ? g : d` +
${this._toast}
+ `; + } + _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 "away": { + const e = ["The quest log unlocks when they return."]; + return t.returns && e.push(`Returns ${t.returns}`), d` + ${this._renderHeader(t)} +
+
+ ${nn} + ${t.name} is on travels + ${e.map((n) => d`

${n}

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

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

+
+
+ `; + case "complete-day": { + const e = this._completeStats(), n = this._countdown ?? this._completeSeconds(); + return d` +
+ +

Quest complete

+

${this._completeSub(t.name)}

+

+ Returning to The Party in ${n} seconds +

+
+
+ ${e.claimed} + Quests claimed +
+
+ ${e.onTime} + On time +
+
+ ${e.late} + Late +
+
+ ${e.party} + The Party +
+
+
+ ${this._renderDock()} + `; + } + default: + return d` + ${this._renderHeader(t)} +
+ ${Ot.map((e) => this._renderColumn(e))} +
+ `; + } + } + /** Resolve the log's single render state; the priority order is + * design/PANEL-EMPTY-STATES.md §2. */ + _logView() { + const t = this._childSlug(); + if (!t) + return { kind: "no-adventurer" }; + const e = this._dueSensorResolution(t); + if (e === "missing") + return { kind: "not-set-up" }; + if (e === "stale") + return { kind: "unreachable" }; + const n = this._childName(t) ?? Y(t); + if (!this._childPresent(t)) + return { kind: "away", name: n, returns: this._awayReturns(t) }; + const i = this._state(`binary_sensor.nestquest_${t}_all_done`); + if (i && String(i.state ?? "").trim().toLowerCase() === "on") + return { kind: "complete-day", name: n }; + const r = this._state(`sensor.nestquest_${t}_quests_due_today`); + return b(y(r?.state, 0)) === 0 ? { kind: "empty-day", name: n } : this._remaining() === 0 ? { kind: "complete-day", name: n } : { kind: "normal" }; + } + /** "missing" when the due sensor does not exist (integration not + * configured), "stale" when it answers unavailable/unknown (backend + * unreachable or the child is absent from the snapshot), null when + * it resolves. */ + _dueSensorResolution(t) { + const e = this._state(`sensor.nestquest_${t}_quests_due_today`); + if (!e) + return "missing"; + const n = String(e.state ?? "").trim().toLowerCase(); + return !n || n === "unavailable" || n === "unknown" ? "stale" : null; + } + _childPresent(t) { + const n = this._state(`sensor.nestquest_${t}_quests_due_today`)?.attributes?.present; + if (typeof n == "boolean") + return n; + const i = this._state( + `binary_sensor.nestquest_${t}_present_today` + ); + return i ? String(i.state ?? "").trim().toLowerCase() === "on" : !0; + } + _awayReturns(t) { + const e = this._timeZone(), n = this._state( + `binary_sensor.nestquest_${t}_present_today` + ), i = Et( + n?.attributes?.next_present, + e + ); + return i ? j(i, e) : null; + } + _renderNotice(t) { + return d` +
+
+ ${t.icon} + ${t.headline} +

${t.body}

+
+
+ `; + } + _timeZone() { + return Re(this.hass); + } + _state(t) { + const n = this.hass?.states?.[t]; + return n && typeof n == "object" ? n : null; + } + _childSlug() { + return Qe(); + } + _boardPath() { + return w(this._config?.board_path).replace(/\/+$/, ""); + } + _idleSeconds() { + const t = y(this._config?.idle_return_seconds, 40); + return t > 0 ? t : 40; + } + _confirmSeconds() { + const t = y(this._config?.confirm_timeout_seconds, 15); + return t > 0 ? t : 15; + } + _completeSeconds() { + const t = y(this._config?.complete_screen_seconds, 12); + return t > 0 ? t : 12; + } + _childName(t) { + if (!t) + return null; + const e = this._state(`sensor.nestquest_${t}_quests_due_today`)?.attributes?.child_name; + return typeof e == "string" && e.trim() ? e.trim() : null; + } + _childCounts(t) { + const e = this._state(`sensor.nestquest_${t}_quests_due_today`), n = this._state( + `sensor.nestquest_${t}_quests_completed_today` + ), i = b(y(e?.state, 0)), r = b(y(n?.state, 0)); + return { due: i, completed: r }; + } + _instances() { + const t = this._childSlug(); + if (!t) + return []; + 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); + if (this._optimistic.size === 0) + return n; + let i = !1; + for (const r of n) { + const o = this._optimistic.get(r.id); + if (o !== void 0) { + if (r.state === "completed") { + this._optimistic.delete(r.id), i = !0; + continue; + } + r.state = "completed", r.overdue = !1, r.completed_at = o; + } + } + return i && (this._optimistic = new Map(this._optimistic)), n; + } + _remaining() { + const t = this._childSlug(); + if (!t) + return 0; + const e = this._state( + `sensor.nestquest_${t}_quests_remaining_today` + ); + if (e) + return b(y(e.state, 0)); + const { due: n, completed: i } = this._childCounts(t); + return b(n - i); + } + _partyCounts() { + const t = this._state( + "sensor.nestquest_household_quests_due_today" + ), e = this._state( + "sensor.nestquest_household_quests_completed_today" + ); + if (t || e) + return { + completed: b(y(e?.state, 0)), + due: b(y(t?.state, 0)) + }; + const n = this._childSlug(), { due: i, completed: r } = this._childCounts(n); + return { completed: r, due: i }; + } + _completeStats() { + const t = this._childCounts(this._childSlug()).completed, e = this._instances().filter( + (r) => r.on_time === !0 + ).length, n = b(t - e), i = this._partyCounts(); + return { + claimed: t, + onTime: e, + late: n, + party: `${i.completed}/${i.due}` + }; + } + _completeSub(t) { + const e = j(this._now, this._timeZone()), { claimed: n, onTime: i, late: r } = this._completeStats(); + return n === 0 ? `${t} sealed the day on ${e}.` : r === 0 ? `${t} claimed every quest on ${e} — all on time.` : i === 0 ? `${t} claimed every quest on ${e} — all late.` : `${t} claimed every quest on ${e} — ${i} on time, ${r} late.`; + } + _otherChildren() { + const t = this.hass?.states; + if (!t) + return []; + const e = this._childSlug(), n = this._timeZone(), i = []; + for (const r of Object.keys(t)) { + const o = /^sensor\.nestquest_(.+)_quests_due_today$/.exec(r); + if (!o || o[1] === "household" || o[1] === e) + continue; + const l = o[1], a = t[r]?.attributes ?? {}; + let p = ""; + typeof a.child_name == "string" && a.child_name.trim() ? p = a.child_name.trim() : p = Y(l); + let u = !0; + if (typeof a.present == "boolean") + u = a.present; + else { + const _ = this._state( + `binary_sensor.nestquest_${l}_present_today` + ); + u = _ === null || String(_.state ?? "").trim().toLowerCase() !== "off"; + } + const c = this._state( + `sensor.nestquest_${l}_quests_completed_today` + ), f = b(y(t[r]?.state, 0)), h = b(y(c?.state, 0)); + let m = null; + if (!u) { + const _ = this._state( + `binary_sensor.nestquest_${l}_present_today` + ), x = Et( + _?.attributes?.next_present, + n + ); + m = x ? De(x, n) : null; + } + i.push({ + slug: l, + name: p, + present: u, + due: f, + completed: h, + returnsWeekday: m + }); + } + return i.sort((r, o) => r.name.localeCompare(o.name)); + } + _renderHeader(t) { + const e = this._childSlug(), n = t.kind === "away", i = n ? t.name : this._childName(e) ?? (e ? Y(e) : null), { due: r, completed: o } = this._childCounts(e), l = this._remaining(), a = this._partyCounts(), p = i ? `${i}'s Quest Log` : "Quest Log", u = (i?.charAt(0) || "?").toUpperCase(); + return d` +
+ +
+

${p}

+

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

+
+ ${n ? g : d` +
+ +
+ + ${l} quest${l === 1 ? "" : "s"} left + + + Party progress · ${a.completed} of ${a.due} today + +
+
+ `} +
+ `; + } + _renderColumn(t) { + const e = this._instances().filter( + (l) => l.window === t.key + ), n = e.filter((l) => l.state === "open").sort( + (l, a) => (l.due_time ?? "99:99").localeCompare(a.due_time ?? "99:99") + ), i = e.filter( + (l) => l.state === "completed" + ), r = e.length; + let o = g; + if (r > 0) { + const a = 644 - (t.key === "afternoon" && this._otherChildren().length > 0 ? 160 : 0), p = Math.min( + 24, + Math.max(8, Math.floor((a - r * 74) / Math.max(r - 1, 1))) + ), u = Math.min( + 116, + Math.max(58, Math.floor((a - (r - 1) * p) / r)) + ), c = Math.max(56, Math.min(72, u - 2)); + let f = `--nq-p-quest-gap: ${p}px; --nq-p-quest-min-height: ${u}px; --nq-p-button-height: ${c}px`; + if (r >= 5) { + const h = Math.max(0, Math.min(22, Math.floor((u - c - 2) / 2))), m = Math.max(44, Math.min(64, u - 2 * h - 2)); + f += `; --nq-p-quest-pad: ${h}px; --nq-p-tile-size: ${m}px`; + } + o = f; + } + return d` +
+
+ ${t.icon} +
+ ${t.name} + ${t.range} +
+ ${i.length}/${e.length} +
+
+ ${St( + n, + (l) => l.id, + (l) => this._renderQuest(l, !1, 0) + )} + ${St( + i, + (l) => l.id, + (l, a) => this._renderQuest(l, !0, a) + )} + ${t.key === "afternoon" ? this._renderRollup() : g} +
+
+ `; + } + _renderQuest(t, e, n) { + if (e) { + const o = Mt[n % Mt.length], l = t.completed_at ? je(t.completed_at, this._timeZone()) : ""; + return d` +
+ +
+ ${t.title} + + ${l ? `Claimed ${l}` : "Claimed"} + +
+ +
+ `; + } + const i = Be(t.due_time), r = t.overdue ? { + text: i ? `Overdue · due ${i}` : "Overdue", + late: !0 + } : { + text: i ? `Due by ${i}` : "Due today", + late: !1 + }; + return d` +
this._openConfirm(t.id)} + @keydown=${(o) => { + (o.key === "Enter" || o.key === " ") && (o.preventDefault(), this._openConfirm(t.id)); + }} + > + +
+ ${t.title} + ${r.text} +
+ +
+ `; + } + _renderRollup() { + const t = this._otherChildren(); + if (t.length === 0) + return g; + const e = t.slice(0, 2).map((n) => n.present ? n.due > 0 && n.completed >= n.due ? `${n.name} has finished their log.` : n.due > n.completed ? `${n.name} has ${n.due - n.completed} quests left.` : `${n.name} has no quests today.` : n.returnsWeekday ? `${n.name} is on travels until ${n.returnsWeekday}.` : `${n.name} is on travels.`); + return d` +
+ Party roll-up +

${e.join(" ")}

+
+ `; + } + _renderConfirm() { + if (this._confirm === null) + return g; + const t = this._instances().find( + (n) => n.id === this._confirm + ); + if (!t) + return g; + const e = Ot.find((n) => n.key === t.window); + return d` +
this._closeConfirm()}> + +
+ `; + } + _openConfirm(t) { + this._confirm = t, this._armConfirmTimer(); + } + _closeConfirm() { + this._clearConfirmTimer(), this._confirm = null; + } + _confirmComplete() { + const t = this._confirm === null ? void 0 : this._instances().find( + (e) => e.id === this._confirm + ); + this._closeConfirm(), !(!t || t.state !== "open") && this._completeQuest(t); + } + /** Seal first, ask the service second: the seal is stamped and the column + * re-sorted immediately (PANEL-SPEC §4); a service failure reverts the + * card and raises the parchment toast. */ + async _completeQuest(t) { + if (this._optimistic.has(t.id)) + return; + const e = (/* @__PURE__ */ new Date()).toISOString(); + this._optimistic = new Map(this._optimistic).set(t.id, e); + try { + await this._callCompleteQuest(t); + } catch { + const n = new Map(this._optimistic); + n.delete(t.id), this._optimistic = n, this._showToast( + "NestQuest could not set the seal just now. Please try again." + ); + } + } + async _callCompleteQuest(t) { + const e = this.hass; + if (typeof e?.callService != "function") + throw new Error("Home Assistant is not connected"); + const n = this._actorChildId(t); + if (n === null) + throw new Error("The tapped adventurer is unknown"); + await e.callService.call(e, "nestquest", "complete_quest", { + instance_id: t.id, + actor: "panel", + actor_child_id: n + }); + } + /** The tapped profile (decision 9): the instance's own child_id, falling + * back to the due sensor's child-level attribute. */ + _actorChildId(t) { + if (t.child_id !== null) + return t.child_id; + const e = this._childSlug(), n = y( + this._state(`sensor.nestquest_${e}_quests_due_today`)?.attributes?.child_id, + 0 + ); + return n > 0 ? n : null; + } + _showToast(t) { + this._toast = t, this._clearToastTimer(), this._toastTimer = window.setTimeout(() => { + this._toastTimer = void 0, this._toast = null; + }, 6e3); + } + _clearToastTimer() { + this._toastTimer !== void 0 && (window.clearTimeout(this._toastTimer), this._toastTimer = void 0); + } + _armConfirmTimer() { + this._clearConfirmTimer(), this._confirmTimer = window.setTimeout(() => { + this._confirmTimer = void 0, this._closeConfirm(); + }, this._confirmSeconds() * 1e3); + } + _clearConfirmTimer() { + this._confirmTimer !== void 0 && (window.clearTimeout(this._confirmTimer), this._confirmTimer = void 0); + } + _armIdle() { + this._clearIdle(), this._idleTimer = window.setTimeout(() => { + this._idleTimer = void 0, this._returnToBoard(); + }, this._idleSeconds() * 1e3); + } + _clearIdle() { + this._idleTimer !== void 0 && (window.clearTimeout(this._idleTimer), this._idleTimer = void 0); + } + _armCompleteTimer() { + if (this._completeTimer === void 0) { + if (this._countdown === null && (this._countdown = this._completeSeconds()), this._countdown <= 0) { + this._returnToBoard(); + return; + } + this._completeTimer = window.setInterval(() => { + const t = (this._countdown ?? 1) - 1; + if (t <= 0) { + this._countdown = 0, this._clearCompleteTimer(), this._returnToBoard(); + return; + } + this._countdown = t; + }, 1e3); + } + } + _clearCompleteTimer() { + this._completeTimer !== void 0 && (window.clearInterval(this._completeTimer), this._completeTimer = void 0); + } + _optionalNumber(t) { + if (t == null || t === "") + return null; + const e = typeof t == "number" ? t : Number(t); + return Number.isFinite(e) ? e : null; + } + _dockWeather() { + const t = w(this._config?.weather_entity); + if (!t) + return null; + const e = this._state(t); + if (!e) + return null; + const n = String(e.state ?? "").trim(); + if (!n || n === "unavailable" || n === "unknown") + return null; + const i = e.attributes ?? {}; + let r = null, o = null; + const l = i.forecast; + if (Array.isArray(l) && l.length > 0) { + const a = l[0]; + if (a && typeof a == "object") { + const p = a; + r = this._optionalNumber(p.temperature), o = this._optionalNumber(p.templow); + } + } + return { + condition: n, + temperature: this._optionalNumber(i.temperature), + high: r, + low: o + }; + } + _renderDock() { + const t = this._dockWeather(); + if (!t) + return d` +
+ ${j(this._now, this._timeZone())} + + ${He(this._now, this._timeZone())} +
+ `; + const e = Ve(t.condition), n = Ke(t.condition, e), i = t.temperature === null ? g : d`${Math.round(t.temperature)}°`, r = t.high === null || t.low === null ? null : `${Math.round(t.high)}° / ${Math.round(t.low)}°`; + return d` +
+ ${an} + ${i} + + + ${r === null ? e : `${e} · ${r}`} + + + ${n} +
+ `; + } + _returnToBoard() { + const t = this._boardPath(); + t && (window.history.pushState(null, "", t), window.dispatchEvent(new Event("location-changed"))); + } + _stopClock() { + this._clockTimer !== void 0 && (window.clearInterval(this._clockTimer), this._clockTimer = void 0); + } + _onActivity = () => { + this._logView().kind !== "complete-day" && this._armIdle(), this._confirm !== null && this._armConfirmTimer(); + }; +} +customElements.define("nestquest-quest-log-card", pn); +at({ + type: "nestquest-quest-log-card", + name: "NestQuest Quest Log", + description: "One child's daily quests, grouped by window." +}); +const hn = ":host{--nq-brand-blue: #3B3AB8;--nq-brand-purple: #A035CC;--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-a-page: #F4F4F6;--nq-a-surface: #FFFFFF;--nq-a-surface-subtle: #FAFAFA;--nq-a-selected: #EBEBF8;--nq-a-border: #E4E4EA;--nq-a-divider: #EDEDF1;--nq-a-ink: #18181D;--nq-a-ink-secondary: #52525E;--nq-a-ink-tertiary: #70707E;--nq-a-success: #15803D;--nq-a-danger: #DC2626;--nq-a-danger-strong: #B91C1C;--nq-a-danger-bg: #FEE2E2;--nq-a-warning-ink: #92400E;--nq-a-warning-ink-2: #78350F;--nq-a-warning-bg: #FEF3C7;--nq-a-warning-border: rgba(217,119,6,.35);--nq-a-info-bg: #DBEAFE;--nq-a-info-ink: #1E3A8A;--nq-a-info-icon: #1D4ED8;--nq-a-reversal: #7D28A0;--nq-a-font: Nunito, system-ui, sans-serif;--nq-a-size-screen: 24px;--nq-a-size-hero: 32px;--nq-a-size-card-title: 19px;--nq-a-size-stat: 22px;--nq-a-size-row: 13.5px;--nq-a-size-meta: 11px;--nq-a-size-label: 10px;--nq-a-track-label: .1em;--nq-a-density-page-pad: 14px 16px 92px;--nq-a-density-gap: 10px;--nq-a-density-card-pad: 13px;--nq-a-density-row-pad: 11px 13px;--nq-a-density-radius: 8px;--nq-a-density-shadow: none;--nq-a-tabbar-height: 70px;--nq-a-tap-min: 44px;--nq-a-radius-pill: 9999px;--nq-a-radius-sheet: 20px 20px 0 0;--nq-a-sheet-shadow: 0 -8px 28px rgba(9,9,11,.18);--nq-a-progress-height: 6px;--nq-ease-out: cubic-bezier(0, 0, .2, 1);--nq-dur-micro: .12s;--nq-dur-base: .2s;--nq-dur-modal: .35s}"; +class un extends A { + static properties = { + hass: { attribute: !1 }, + _config: { state: !0 } + }; + static styles = [v(hn)]; + hass; + _config; + setConfig(t) { + if (!t || typeof t != "object") + throw new Error("Invalid configuration"); + this._config = t; + } + getCardSize() { + return 8; + } + render() { + return d`
NestQuest Admin
`; + } +} +customElements.define("nestquest-admin-card", un); +at({ + type: "nestquest-admin-card", + name: "NestQuest Admin", + description: "Parent phone view for today, tasks, schedule, and history." +}); +const fn = "/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); +} diff --git a/custom_components/nestquest/www/nestquest-fonts.css b/custom_components/nestquest/www/nestquest-fonts.css new file mode 100644 index 0000000..72382dd --- /dev/null +++ b/custom_components/nestquest/www/nestquest-fonts.css @@ -0,0 +1,71 @@ +@font-face { + font-family: "Nunito"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("./fonts/nunito-latin-400-normal.woff2") format("woff2"); +} + +@font-face { + font-family: "Nunito"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("./fonts/nunito-latin-600-normal.woff2") format("woff2"); +} + +@font-face { + font-family: "Nunito"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("./fonts/nunito-latin-700-normal.woff2") format("woff2"); +} + +@font-face { + font-family: "Nunito"; + font-style: normal; + font-weight: 800; + font-display: swap; + src: url("./fonts/nunito-latin-800-normal.woff2") format("woff2"); +} + +@font-face { + font-family: "Cinzel"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("./fonts/cinzel-latin-600-normal.woff2") format("woff2"); +} + +@font-face { + font-family: "Cinzel"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("./fonts/cinzel-latin-700-normal.woff2") format("woff2"); +} + +@font-face { + font-family: "Cinzel"; + font-style: normal; + font-weight: 900; + font-display: swap; + src: url("./fonts/cinzel-latin-900-normal.woff2") format("woff2"); +} + +@font-face { + font-family: "Cinzel Decorative"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("./fonts/cinzel-decorative-latin-700-normal.woff2") format("woff2"); +} + +@font-face { + font-family: "Cinzel Decorative"; + font-style: normal; + font-weight: 900; + font-display: swap; + src: url("./fonts/cinzel-decorative-latin-900-normal.woff2") format("woff2"); +} diff --git a/custom_components/nestquest/www/nestquest-panel-tokens.css b/custom_components/nestquest/www/nestquest-panel-tokens.css new file mode 100644 index 0000000..9f51ab6 --- /dev/null +++ b/custom_components/nestquest/www/nestquest-panel-tokens.css @@ -0,0 +1,96 @@ +/* ============================================================ + NestQuest — Kids' panel card tokens + Surface: DAKboard DAKOS wall panel, 1920x1080 landscape, + viewed from ~3 ft, no login, read/complete only. + Scope these on the card host: nestquest-quest-log-card, + nestquest-party-board-card. + Derived from the approved mockups (panel screens 1a, 1b, 1c, 1j). + ============================================================ */ + +:host { + /* ── Parchment ground ─────────────────────────────────── */ + --nq-p-parchment-top: #f0e4c7; + --nq-p-parchment-bottom: #ddcca3; + --nq-p-parchment: radial-gradient(ellipse at 25% 10%, rgba(255,255,255,.5), transparent 55%), + radial-gradient(ellipse at 85% 90%, rgba(120,86,44,.28), transparent 60%), + repeating-linear-gradient(93deg, rgba(150,115,70,.05) 0 2px, transparent 2px 6px), + repeating-linear-gradient(2deg, rgba(150,115,70,.04) 0 3px, transparent 3px 7px), + linear-gradient(var(--nq-p-parchment-top), var(--nq-p-parchment-bottom)); + --nq-p-vignette: inset 0 0 200px rgba(80,52,20,.35); + + /* Card surfaces sitting on parchment */ + --nq-p-card-open: linear-gradient(#fdf7e6, #f2e7c9); /* incomplete quest */ + --nq-p-card-done: linear-gradient(#f0e8d3, #e6dcc2); /* sealed quest */ + --nq-p-card-panel: linear-gradient(#fcf6e6, #f1e6ca); /* adventurer plate, stat block */ + --nq-p-card-border: rgba(120,88,48,.38); + --nq-p-panel-border: rgba(92,62,26,.40); + --nq-p-card-shadow: 0 3px 0 rgba(120,88,48,.20), inset 0 1px 0 rgba(255,255,255,.70); + --nq-p-panel-shadow: 0 6px 0 rgba(92,62,26,.18), inset 0 2px 0 rgba(255,255,255,.70); + --nq-p-frame-outer: 2px solid rgba(92,62,26,.45); + --nq-p-frame-inner: 1px solid rgba(92,62,26,.28); + --nq-p-rule: 2px solid rgba(92,62,26,.35); + + /* ── Ink (all values >= 4.5:1 on parchment) ───────────── */ + --nq-p-ink: #2b1f14; /* titles, quest names */ + --nq-p-ink-secondary: #5c452a; /* meta, window labels, counts */ + --nq-p-ink-muted: #6f6455; /* sealed quest titles only */ + --nq-p-ink-away: #4d4433; /* away adventurer name */ + --nq-p-ink-late: #8f1526; /* overdue line */ + + /* ── Brand (CubeCraft, toned down: accents only) ──────── */ + --nq-brand-blue: #3B3AB8; + --nq-brand-purple: #A035CC; + --nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple)); + --nq-p-icon-tile: var(--nq-brand-gradient); /* open quest icon tile */ + --nq-p-icon-tile-done: rgba(92,62,26,.16); + --nq-p-crest: var(--nq-brand-gradient); + --nq-p-crest-away: linear-gradient(135deg, #6b6b7a, #7a7286); + + /* ── Wax seal (completed marker) ──────────────────────── */ + --nq-p-seal: radial-gradient(circle at 35% 30%, #a8283a, #6d1322); + --nq-p-seal-shadow: 0 4px 10px rgba(60,10,20,.40), inset 0 0 0 4px rgba(255,255,255,.14); + --nq-p-seal-size: 78px; /* 168px on the quest-complete screen */ + + /* ── Dock (bottom strip, non-interactive) ─────────────── */ + --nq-p-dock-bg: rgba(43,31,20,.90); + --nq-p-dock-ink: #f7efdb; + --nq-p-dock-ink-secondary: #d8c9a6; + --nq-p-dock-divider: rgba(233,220,189,.30); + --nq-p-dock-height: 84px; + + /* ── Type ─────────────────────────────────────────────── */ + --nq-p-font-display: 'Cinzel Decorative', Cinzel, serif; /* NestQuest, names, Quest complete */ + --nq-p-font-heading: Cinzel, serif; /* section heads, quest titles, numerals */ + --nq-p-font-body: Nunito, system-ui, sans-serif; /* meta lines, dock */ + + --nq-p-size-hero: 86px; /* 900 weight, "Quest complete" */ + --nq-p-size-wordmark: 66px; /* 900 weight */ + --nq-p-size-title: 56px; /* 700, "Declan's Quest Log" */ + --nq-p-size-name: 48px; /* 700, adventurer name */ + --nq-p-size-section: 32px; /* 700, Morning / Afternoon / Evening */ + --nq-p-size-quest: 30px; /* 700, quest title */ + --nq-p-size-body: 22px; /* 600 Nunito, meta — PANEL MINIMUM */ + --nq-p-size-label: 21px; /* 600 Cinzel, uppercase, tracked */ + --nq-p-track-label: .14em; + --nq-p-track-kicker: .30em; + + /* ── Metrics: touch ───────────────────────────────────── */ + --nq-p-quest-min-height: 116px; /* whole card is the tap target */ + --nq-p-quest-gap: 24px; /* separation between tap targets */ + --nq-p-quest-pad: 22px; /* padding inside a quest card */ + --nq-p-tile-size: 64px; /* quest icon tile */ + --nq-p-button-height: 72px; /* "Complete" button on a quest card */ + --nq-p-button-min-width: 150px; + --nq-p-confirm-button: 108px; /* confirm dialog buttons */ + --nq-p-radius-card: 12px; + --nq-p-radius-panel: 20px; + --nq-p-radius-pill: 9999px; + --nq-p-page-inset: 62px; + --nq-p-frame-inset: 26px; + + /* ── Motion (CubeCraft easing) ────────────────────────── */ + --nq-ease-out: cubic-bezier(0, 0, .2, 1); + --nq-dur-micro: 120ms; + --nq-dur-base: 200ms; + --nq-dur-modal: 350ms; +} diff --git a/design/ENTITIES-AND-SERVICES.md b/design/ENTITIES-AND-SERVICES.md index 798926b..71fce72 100644 --- a/design/ENTITIES-AND-SERVICES.md +++ b/design/ENTITIES-AND-SERVICES.md @@ -90,6 +90,7 @@ child_order: [declan, jordyn, chloe] # kids' panel, view 2 type: custom:nestquest-quest-log-card board_path: /nestquest/board +weather_entity: weather.home # optional; dock falls back to date/time idle_return_seconds: 40 # must be shorter than TouchHub Auto-Return confirm_timeout_seconds: 15 complete_screen_seconds: 12 diff --git a/design/PANEL-EMPTY-STATES.md b/design/PANEL-EMPTY-STATES.md new file mode 100644 index 0000000..87bc76f --- /dev/null +++ b/design/PANEL-EMPTY-STATES.md @@ -0,0 +1,104 @@ +# Panel empty & error states — design addendum + +Closes the gap DESIGN-HANDOFF.md lists under "Open questions": *"Empty and error +states are unspecified by request (child with no quests, everyone away, integration +unconfigured, DB unreachable). Panel must not render a blank parchment — decide +before shipping the card."* + +All decisions below reuse PANEL-SPEC.md's approved aesthetic — parchment ground, +double frame, dock, panel tokens, no text below 22px, no alpha-muted ink — and add +one shared component per card: a **notice panel** (radius 20px, `--nq-p-card-panel`, +2px `--nq-p-panel-border`, `--nq-p-panel-shadow`, centred icon + headline + body). +The frame, wordmark row, kicker and dock always render; only the content area +changes. **No state renders a blank parchment.** + +## 1. What the data can say + +The cards read `sensor.nestquest__quests_due_today` (plus siblings). One of +four things comes back, and the card must tell them apart: + +| Backend condition | What the card sees | Card's interpretation | +|---|---|---| +| Resolving (DB fine) | numeric state + attributes (`child_name`, `present`, `instances`) | normal, all states below | +| Integration not configured | entity does not exist at all | **not set up** | +| Backend unreachable (DB locked, refresh failed) | state `unavailable` | **records unreachable** | +| Child deactivated / absent from snapshot | state `unknown` | **records unreachable** (same panel voice: the records do not answer) | + +An away child (presence `present: false`, from the due-sensor attributes with the +presence binary sensor as fallback) is *not* an error: it is a first-class panel +state ("On travels"). Away children get no instances (materialiser skips them), so +away is detected from the presence flag, never from a zero quest count. + +## 2. Quest log — state priority + +`nestquest-quest-log-card` resolves exactly one state, in this order: + +1. **No adventurer in the path** (direct navigation to a URL with no child slug — + misconfigured `board_path`/`quest_log_path`) → notice *"No adventurer chosen"*, + body *"Open The Party and tap your crest to open your quest log."* +2. **Not set up** (entity missing) → notice *"NestQuest is not set up yet"*, body + *"A parent needs to finish setting up NestQuest."* +3. **Records unreachable** (`unavailable`/`unknown`) → notice *"The records cannot + be reached"*, body *"The party's records are quiet right now. NestQuest will + return shortly."* +4. **Away** (present flag false) → away notice: dashed muted panel (the away-plate + gradient), tent icon, *" is on travels"*, *"The quest log unlocks when + they return."* and, when `next_present` is known, *"Returns , "*. + Direct navigation is the one path that can reach this state (the board's away + plate is not tappable); the log answers it honestly instead of showing an empty + column set. Header renders with the crest in away colours and the sub line + `… · On travels`; the remaining-count block is hidden (it is meaningless while + away). +5. **No quests today** (present, due = 0) → all-clear notice, sun icon, *"No quests + today"*, body *"Nothing was posted for today, . Enjoy the day's rest!"* — + never three bare empty columns. The header and remaining block stay (0 quests + left, party progress), so the log still reads like the log. +6. **All quests done** (due > 0, remaining = 0) → the fallback celebration that + shows until the 1c quest-complete screen takes over: centred wax-seal mark (the + `--nq-p-seal` tokens at 78px, check glyph, −6°) with headline *"Quest complete"* + — the same phrase screen 1c uses — and body *"Every quest is claimed, . + The seal is set for today. Returning to The Party shortly."* The idle return + (§3.5) is what actually navigates back; 1c remains the designed takeover. +7. **Normal** → the three-window column layout of PANEL-SPEC §3.1 unchanged. A + window with no quests still renders its head alone, exactly as the spec says. + +Notices sit in the columns' content area, vertically centred. Error notices (states +1–3) replace the header entirely — there is no honest name to headline when the +records do not answer — while states 4–6 keep the full header. + +## 3. Party board — state priority + +`nestquest-party-board-card` resolves per configured child, then at board level: + +1. **No children configured** (`child_order` empty) → board notice *"NestQuest is + not set up yet"*, body *"A parent needs to finish setting up NestQuest before + the party can gather."* +2. **Every configured child unresolvable** → one board notice instead of a grid of + broken plates: *"NestQuest is not set up yet"* when the entities are missing, + *"The records cannot be reached"* when they answer `unavailable`/`unknown`. +3. **Partial** (some children resolve, some do not) → the board still renders the + resolving plates; an unresolvable child renders a muted, dashed plate in the + away geometry with a compass icon and the pill label **"Unknown"**, progress + line `—`, empty bar, **not tappable**. It must not claim "Home today" (0 of 0) + and must not lie "On travels": the records simply do not say. +4. **Normal** → the plates grid of PANEL-SPEC §2 unchanged (present + away plates). + +The hint line ("Tap your crest…") is hidden in board-level notice states — there +is nothing to tap. + +## 4. Shared rules + +- **Never a blank parchment.** Every render path emits the frames, wordmark, + kicker, dock, and either content or a notice panel. The dock already degrades to + date + time when weather is unset (PANEL-SPEC §2.3); that behaviour is unchanged. +- **Notice panel type scale:** icon 46px, headline 40px/700 Cinzel + (`--nq-p-font-heading`, `--nq-p-ink`), body 24px/600 Nunito + (`--nq-p-ink-secondary`), `role="status"` so assistive tech announces it. +- **Tone.** The panel speaks tabletop (PANEL-SPEC vocabulary) but never lies: an + error says the records cannot be reached, not that everything is fine. Error + copy names the actor who can fix it (a parent) or promises the return of the + data — no stack traces, no entity IDs. +- **No interaction in error/away states.** No confirm dialog, no idle return + changes, no navigation attempts. Notices are static. +- Both cards implement the same resolution rules, so the board and the log never + disagree about whether the integration is up. diff --git a/design/tokens/nestquest-panel-tokens.css b/design/tokens/nestquest-panel-tokens.css index f997486..9f51ab6 100644 --- a/design/tokens/nestquest-panel-tokens.css +++ b/design/tokens/nestquest-panel-tokens.css @@ -77,6 +77,8 @@ /* ── Metrics: touch ───────────────────────────────────── */ --nq-p-quest-min-height: 116px; /* whole card is the tap target */ --nq-p-quest-gap: 24px; /* separation between tap targets */ + --nq-p-quest-pad: 22px; /* padding inside a quest card */ + --nq-p-tile-size: 64px; /* quest icon tile */ --nq-p-button-height: 72px; /* "Complete" button on a quest card */ --nq-p-button-min-width: 150px; --nq-p-confirm-button: 108px; /* confirm dialog buttons */ diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..2a21a54 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1232 @@ +{ + "name": "nestquest-cards", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nestquest-cards", + "dependencies": { + "lit": "^3.3.0" + }, + "devDependencies": { + "typescript": "^5.9.2", + "vite": "^7.1.5" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz", + "integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@lit/reactive-element": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz", + "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz", + "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz", + "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz", + "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz", + "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz", + "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz", + "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz", + "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz", + "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz", + "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz", + "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz", + "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz", + "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz", + "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz", + "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz", + "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz", + "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz", + "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz", + "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz", + "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz", + "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz", + "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz", + "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz", + "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz", + "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz", + "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lit": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz", + "integrity": "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^2.1.0", + "lit-element": "^4.2.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-element": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz", + "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0", + "@lit/reactive-element": "^2.1.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-html": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.3.tgz", + "integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz", + "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.4", + "@rollup/rollup-android-arm64": "4.63.4", + "@rollup/rollup-darwin-arm64": "4.63.4", + "@rollup/rollup-darwin-x64": "4.63.4", + "@rollup/rollup-freebsd-arm64": "4.63.4", + "@rollup/rollup-freebsd-x64": "4.63.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", + "@rollup/rollup-linux-arm-musleabihf": "4.63.4", + "@rollup/rollup-linux-arm64-gnu": "4.63.4", + "@rollup/rollup-linux-arm64-musl": "4.63.4", + "@rollup/rollup-linux-loong64-gnu": "4.63.4", + "@rollup/rollup-linux-loong64-musl": "4.63.4", + "@rollup/rollup-linux-ppc64-gnu": "4.63.4", + "@rollup/rollup-linux-ppc64-musl": "4.63.4", + "@rollup/rollup-linux-riscv64-gnu": "4.63.4", + "@rollup/rollup-linux-riscv64-musl": "4.63.4", + "@rollup/rollup-linux-s390x-gnu": "4.63.4", + "@rollup/rollup-linux-x64-gnu": "4.63.4", + "@rollup/rollup-linux-x64-musl": "4.63.4", + "@rollup/rollup-openbsd-x64": "4.63.4", + "@rollup/rollup-openharmony-arm64": "4.63.4", + "@rollup/rollup-win32-arm64-msvc": "4.63.4", + "@rollup/rollup-win32-ia32-msvc": "4.63.4", + "@rollup/rollup-win32-x64-gnu": "4.63.4", + "@rollup/rollup-win32-x64-msvc": "4.63.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7e6dc9b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,15 @@ +{ + "name": "nestquest-cards", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + }, + "dependencies": { + "lit": "^3.3.0" + }, + "devDependencies": { + "typescript": "^5.9.2", + "vite": "^7.1.5" + } +} diff --git a/frontend/src/cards/admin.ts b/frontend/src/cards/admin.ts new file mode 100644 index 0000000..580dd78 --- /dev/null +++ b/frontend/src/cards/admin.ts @@ -0,0 +1,38 @@ +import { LitElement, html, unsafeCSS } from "lit"; +import adminTokens from "../../../custom_components/nestquest/www/nestquest-admin-tokens.css?inline"; + +import { registerCustomCard, type CardConfig, type HassLike } from "../types"; + +export class NestQuestAdminCard extends LitElement { + static properties = { + hass: { attribute: false }, + _config: { state: true }, + }; + + static styles = [unsafeCSS(adminTokens)]; + + hass?: HassLike; + _config?: CardConfig; + + setConfig(config: CardConfig): void { + if (!config || typeof config !== "object") { + throw new Error("Invalid configuration"); + } + this._config = config; + } + + getCardSize(): number { + return 8; + } + + render() { + return html`
NestQuest Admin
`; + } +} + +customElements.define("nestquest-admin-card", NestQuestAdminCard); +registerCustomCard({ + type: "nestquest-admin-card", + name: "NestQuest Admin", + description: "Parent phone view for today, tasks, schedule, and history.", +}); diff --git a/frontend/src/cards/party-board.ts b/frontend/src/cards/party-board.ts new file mode 100644 index 0000000..d012872 --- /dev/null +++ b/frontend/src/cards/party-board.ts @@ -0,0 +1,1174 @@ +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; +} + +function clampPercent(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Math.min(100, Math.max(0, value)); +} + +function clampCount(value: number): number { + return Math.max(0, Math.round(value)); +} + +function titleCaseSlug(slug: string): string { + return slug + .split(/[-_]+/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +function zoneOffsetMs(instant: number, timeZone: string): number { + const parts = zonedFormatter(timeZone, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }).formatToParts(new Date(instant)); + const read = (type: Intl.DateTimeFormatPartTypes): number => { + const part = parts.find((entry) => entry.type === type); + return part ? Number(part.value) : Number.NaN; + }; + const asUtc = Date.UTC( + read("year"), + read("month") - 1, + read("day"), + read("hour"), + read("minute"), + read("second") + ); + return Number.isFinite(asUtc) ? asUtc - instant : 0; +} + +function parseIsoDate(value: unknown, timeZone?: string): Date | null { + if (typeof value !== "string") { + return null; + } + const parts = value.split("-"); + if (parts.length !== 3) { + return null; + } + const [year, month, day] = parts.map((part) => Number(part)); + if (!year || !month || !day) { + return null; + } + if (!timeZone) { + const local = new Date(year, month - 1, day); + return Number.isNaN(local.getTime()) ? null : local; + } + const utcMs = Date.UTC(year, month - 1, day); + const date = new Date(utcMs - zoneOffsetMs(utcMs, timeZone)); + return Number.isNaN(date.getTime()) ? null : date; +} + +function conditionLabel(condition: string): string { + const known = CONDITION_LABELS[condition]; + if (known) { + return known; + } + return condition.charAt(0).toUpperCase() + condition.slice(1); +} + +function conditionPhrase(condition: string, label: string): string { + return CONDITION_PHRASES[condition] ?? label; +} + +const SHIELD_CLIP = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)"; +const HEXAGON_CLIP = + "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)"; + +const boardStyles = css` + * { + box-sizing: border-box; + } + + :host { + display: block; + height: 100%; + --nq-brand-gradient-h: linear-gradient( + 90deg, + var(--nq-brand-blue), + var(--nq-brand-purple) + ); + } + + .board { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + height: 1080px; + padding: 0; + overflow: hidden; + background: var(--nq-p-parchment); + box-shadow: var(--nq-p-vignette); + font-family: var(--nq-p-font-body); + } + + .frame { + position: absolute; + pointer-events: none; + } + + .frame-outer { + inset: 26px; + border: var(--nq-p-frame-outer); + border-radius: 14px; + } + + .frame-inner { + inset: 36px; + border: var(--nq-p-frame-inner); + border-radius: 8px; + } + + .wordmark-row { + position: absolute; + top: 64px; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 26px; + width: 100%; + } + + .rule { + flex: 1 1 0; + max-width: 340px; + height: 2px; + border-radius: 1px; + background: rgba(92, 62, 26, 0.35); + } + + .d20 { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + clip-path: ${unsafeCSS(HEXAGON_CLIP)}; + background: var(--nq-brand-gradient); + } + + .d20-numeral { + font-family: var(--nq-p-font-heading); + font-size: 15px; + font-weight: 700; + line-height: 1; + color: #ffffff; + } + + .wordmark { + font-family: var(--nq-p-font-display); + font-size: var(--nq-p-size-wordmark); + font-weight: 900; + line-height: 1.15; + background: var(--nq-brand-gradient); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; + } + + .kicker { + position: absolute; + top: 154px; + left: 0; + right: 0; + font-family: var(--nq-p-font-heading); + font-size: 22px; + font-weight: 600; + letter-spacing: var(--nq-p-track-kicker); + text-transform: uppercase; + line-height: 1.2; + text-align: center; + color: var(--nq-p-ink-secondary); + } + + .plates { + position: absolute; + top: 262px; + left: 110px; + right: 110px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 56px; + } + + .plate { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 26px; + height: 600px; + padding: 44px 28px 40px; + border: 2px solid var(--nq-p-panel-border); + border-radius: var(--nq-p-radius-panel); + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + font: inherit; + } + + button.plate { + width: 100%; + color: inherit; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform var(--nq-dur-micro) var(--nq-ease-out); + } + + button.plate:active { + transform: scale(0.98); + } + + button.plate:focus-visible { + outline: 3px solid var(--nq-brand-purple); + outline-offset: 4px; + } + + .plate.away { + border: 2px dashed var(--nq-p-panel-border); + background: linear-gradient(#f2ecdd, #e6dcc6); + box-shadow: none; + } + + .crest { + position: relative; + flex: none; + width: 188px; + height: 214px; + padding: 4px; + clip-path: ${unsafeCSS(SHIELD_CLIP)}; + background: rgba(255, 255, 255, 0.22); + } + + .crest-face { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding-bottom: 70px; + clip-path: ${unsafeCSS(SHIELD_CLIP)}; + background: var(--nq-p-crest); + } + + .crest.away .crest-face { + background: var(--nq-p-crest-away); + } + + .crest .initial { + font-family: var(--nq-p-font-display); + font-size: 82px; + font-weight: 700; + line-height: 1; + color: #ffffff; + } + + .name { + font-family: var(--nq-p-font-heading); + font-size: var(--nq-p-size-name); + font-weight: 700; + letter-spacing: 0.02em; + line-height: 1.1; + color: var(--nq-p-ink); + } + + .plate.away .name { + color: var(--nq-p-ink-away); + } + + .pill { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 8px 20px; + border-radius: var(--nq-p-radius-pill); + background: rgba(22, 120, 60, 0.35); + font-family: var(--nq-p-font-heading); + font-size: 22px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + line-height: 1; + color: #146b36; + } + + .pill svg { + width: 22px; + height: 22px; + color: #157a3c; + } + + .pill.away { + background: rgba(92, 62, 26, 0.16); + color: var(--nq-p-ink-away); + } + + .pill.away svg { + color: var(--nq-p-ink-away); + } + + .progress-line { + font-family: var(--nq-p-font-heading); + font-size: 24px; + font-weight: 700; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .bar { + width: 100%; + height: 16px; + border: 1px solid rgba(92, 62, 26, 0.35); + border-radius: var(--nq-p-radius-pill); + background: rgba(92, 62, 26, 0.18); + overflow: hidden; + } + + .bar .fill { + display: block; + height: 100%; + border-radius: inherit; + background: var(--nq-brand-gradient-h); + } + + .hint { + position: absolute; + top: 930px; + left: 0; + right: 0; + font-family: var(--nq-p-font-body); + font-size: 24px; + font-weight: 600; + line-height: 1.4; + text-align: center; + color: var(--nq-p-ink-secondary); + } + + .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` +
+
+
+
+ + + 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()} +
+ `; + } + + private _timeZone(): string | undefined { + return hassTimeZone(this.hass); + } + + private _state(entityId: string): StateObject | null { + const states = this.hass?.states as + | Record + | undefined; + const stateObj = states?.[entityId]; + return stateObj && typeof stateObj === "object" ? stateObj : null; + } + + private _childSlugs(): string[] { + if (!this._config) { + return []; + } + const order = this._config.child_order; + if (!Array.isArray(order)) { + return []; + } + return order + .filter( + (entry): entry is string => + typeof entry === "string" && entry.trim().length > 0 + ) + .map((entry) => entry.trim()); + } + + private _plates(): ChildPlate[] { + return this._childSlugs().map((slug) => this._plate(slug)); + } + + private _plate(slug: string): ChildPlate { + const 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.", +}); diff --git a/frontend/src/cards/quest-log.ts b/frontend/src/cards/quest-log.ts new file mode 100644 index 0000000..fba8929 --- /dev/null +++ b/frontend/src/cards/quest-log.ts @@ -0,0 +1,2463 @@ +import { LitElement, css, html, nothing, unsafeCSS, type TemplateResult } from "lit"; +import { repeat } from "lit/directives/repeat.js"; +import panelTokens from "../../../custom_components/nestquest/www/nestquest-panel-tokens.css?inline"; + +import { registerCustomCard, type CardConfig, type HassLike } from "../types"; + +type QuestLogCardConfig = CardConfig & { + weather_entity?: string; +}; + +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 PanelInstance = { + id: number; + child_id: number | null; + title: string; + icon: string | null; + window: string; + due_time: string | null; + state: string; + overdue: boolean; + completed_at: string | null; + on_time: boolean | null; +}; + +type WindowDef = { + key: string; + name: string; + range: string; + icon: TemplateResult; +}; + +type OtherChild = { + slug: string; + name: string; + present: boolean; + due: number; + completed: number; + returnsWeekday: string | null; +}; + +/** The one state the log resolves before rendering anything; see + * design/PANEL-EMPTY-STATES.md for the decisions behind each kind. */ +type LogView = + | { kind: "no-adventurer" } + | { kind: "not-set-up" } + | { kind: "unreachable" } + | { kind: "away"; name: string; returns: string | null } + | { kind: "empty-day"; name: string } + | { kind: "complete-day"; name: string } + | { kind: "normal" }; + +type LogNotice = { + icon: TemplateResult; + headline: string; + body: string; +}; + +const FORMATTER_CACHE = new Map(); + +function zonedFormatter( + timeZone: string | undefined, + options: Intl.DateTimeFormatOptions +): Intl.DateTimeFormat { + const key = `${timeZone ?? "local"}|${JSON.stringify(options)}`; + const cached = FORMATTER_CACHE.get(key); + if (cached) { + return cached; + } + let formatter: Intl.DateTimeFormat; + try { + formatter = new Intl.DateTimeFormat("en-US", { + ...options, + ...(timeZone ? { timeZone } : {}), + }); + } catch { + formatter = new Intl.DateTimeFormat("en-US", options); + } + FORMATTER_CACHE.set(key, formatter); + return formatter; +} + +function hassTimeZone(hass: HassLike | undefined): string | undefined { + const config = hass?.config; + if (!config || typeof config !== "object") { + return undefined; + } + const timeZone = (config as Record).time_zone; + return typeof timeZone === "string" && timeZone.trim() + ? timeZone.trim() + : undefined; +} + +function formatDay(date: Date, timeZone: string | undefined): string { + return `${zonedFormatter(timeZone, { weekday: "long" }).format(date)}, ${zonedFormatter(timeZone, { month: "short", day: "numeric" }).format(date)}`; +} + +function formatWeekday(date: Date, timeZone: string | undefined): string { + return zonedFormatter(timeZone, { weekday: "long" }).format(date); +} + +function formatInstant(value: string, timeZone: string | undefined): string { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return ""; + } + return zonedFormatter(timeZone, { + hour: "numeric", + minute: "2-digit", + hour12: true, + }).format(parsed); +} + +function formatTime(date: Date, timeZone: string | undefined): string { + return zonedFormatter(timeZone, { + hour: "numeric", + minute: "2-digit", + hour12: true, + }).format(date); +} + +function formatWallClock(value: string | null): string | null { + if (!value) { + return null; + } + const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim()); + if (!match) { + return null; + } + const hours = Number(match[1]); + if (hours > 23) { + return null; + } + const suffix = hours >= 12 ? "PM" : "AM"; + const hour12 = hours % 12 === 0 ? 12 : hours % 12; + return `${hour12}:${match[2]} ${suffix}`; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function asNumber(value: unknown, fallback: number): number { + if (value === null || value === undefined || value === "") { + return fallback; + } + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function clampCount(value: number): number { + return Math.max(0, Math.round(value)); +} + +function titleCaseSlug(slug: string): string { + return slug + .split(/[-_]+/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +function zoneOffsetMs(instant: number, timeZone: string): number { + const parts = zonedFormatter(timeZone, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }).formatToParts(new Date(instant)); + const read = (type: Intl.DateTimeFormatPartTypes): number => { + const part = parts.find((entry) => entry.type === type); + return part ? Number(part.value) : Number.NaN; + }; + const asUtc = Date.UTC( + read("year"), + read("month") - 1, + read("day"), + read("hour"), + read("minute"), + read("second") + ); + return Number.isFinite(asUtc) ? asUtc - instant : 0; +} + +function parseIsoDate(value: unknown, timeZone?: string): Date | null { + if (typeof value !== "string") { + return null; + } + const parts = value.split("-"); + if (parts.length !== 3) { + return null; + } + const [year, month, day] = parts.map((part) => Number(part)); + if (!year || !month || !day) { + return null; + } + if (!timeZone) { + const local = new Date(year, month - 1, day); + return Number.isNaN(local.getTime()) ? null : local; + } + const utcMs = Date.UTC(year, month - 1, day); + const date = new Date(utcMs - zoneOffsetMs(utcMs, timeZone)); + return Number.isNaN(date.getTime()) ? null : date; +} + +function childSlugFromPath(): string { + const segments = window.location.pathname.split("/").filter(Boolean); + return segments.length > 0 + ? segments[segments.length - 1].toLowerCase() + : ""; +} + +function toPanelInstance(value: unknown): PanelInstance | null { + if (!value || typeof value !== "object") { + return null; + } + const row = value as Record; + const state = asString(row.state).toLowerCase(); + if (state !== "open" && state !== "completed") { + return null; + } + const id = asNumber(row.id, 0); + if (!id) { + return null; + } + const childId = asNumber(row.child_id, 0); + return { + id, + child_id: childId > 0 ? childId : null, + title: asString(row.title) || "Quest", + icon: asString(row.icon) || null, + window: asString(row.window).toLowerCase(), + due_time: asString(row.due_time) || null, + state, + overdue: row.overdue === true, + completed_at: asString(row.completed_at) || null, + on_time: row.on_time === true ? true : row.on_time === false ? false : null, + }; +} + +type DockWeather = { + condition: string; + temperature: number | null; + high: number | null; + low: number | null; +}; + +const CONDITION_LABELS: Record = { + "clear-night": "Clear", + cloudy: "Cloudy", + exceptional: "Clear", + fog: "Foggy", + hail: "Hail", + lightning: "Storms", + "lightning-rainy": "Storms", + partlycloudy: "Partly cloudy", + pouring: "Heavy rain", + rainy: "Rain", + snowy: "Snow", + "snowy-rainy": "Sleet", + sunny: "Sunny", + windy: "Windy", + "windy-variant": "Windy", +}; + +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-rainy": "Storms may roll in", + partlycloudy: "Sun between clouds", + pouring: "Heavy rain outside", + rainy: "Rain on the walls", + snowy: "Snow on the peaks", + "snowy-rainy": "Sleet may fall", + sunny: "Clear skies today", + windy: "A blustery day", + "windy-variant": "A blustery day", +}; + +function conditionLabel(condition: string): string { + const known = CONDITION_LABELS[condition]; + if (known) { + return known; + } + return condition.charAt(0).toUpperCase() + condition.slice(1); +} + +function conditionPhrase(condition: string, label: string): string { + return CONDITION_PHRASES[condition] ?? label; +} + +const SHIELD_CLIP = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)"; +const HEXAGON_CLIP = + "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)"; + +const SEAL_ROTATIONS = [-9, 6, -4]; + +const logStyles = css` + * { + box-sizing: border-box; + } + + :host { + display: block; + height: 100%; + --nq-brand-gradient-h: linear-gradient( + 90deg, + var(--nq-brand-blue), + var(--nq-brand-purple) + ); + } + + .board { + position: relative; + display: flex; + flex-direction: column; + height: 1080px; + padding: 0; + overflow: hidden; + background: var(--nq-p-parchment); + box-shadow: var(--nq-p-vignette); + font-family: var(--nq-p-font-body); + } + + .frame { + position: absolute; + pointer-events: none; + } + + .frame-outer { + inset: 26px; + border: var(--nq-p-frame-outer); + border-radius: 14px; + } + + .frame-inner { + inset: 36px; + border: var(--nq-p-frame-inner); + border-radius: 8px; + } + + .header { + position: absolute; + top: 56px; + left: var(--nq-p-page-inset); + right: var(--nq-p-page-inset); + display: flex; + align-items: center; + gap: 28px; + } + + .crest { + position: relative; + flex: none; + width: 96px; + height: 110px; + padding: 3px; + clip-path: ${unsafeCSS(SHIELD_CLIP)}; + background: rgba(255, 255, 255, 0.22); + } + + .crest-face { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding-bottom: 32px; + clip-path: ${unsafeCSS(SHIELD_CLIP)}; + background: var(--nq-p-crest); + } + + .crest .initial { + font-family: var(--nq-p-font-display); + font-size: 44px; + font-weight: 700; + line-height: 1; + color: #ffffff; + } + + .crest.away .crest-face { + background: var(--nq-p-crest-away); + } + + .titles { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + } + + .title { + margin: 0; + font-family: var(--nq-p-font-display); + font-size: var(--nq-p-size-title); + font-weight: 700; + line-height: 1.1; + color: var(--nq-p-ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .sub { + margin: 0; + font-family: var(--nq-p-font-heading); + font-size: 20px; + font-weight: 600; + letter-spacing: 0.24em; + text-transform: uppercase; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .remaining { + margin-left: auto; + flex: none; + display: flex; + align-items: center; + gap: 20px; + padding: 18px 28px; + border: 2px solid var(--nq-p-panel-border); + border-radius: 14px; + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + } + + .d20 { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + clip-path: ${unsafeCSS(HEXAGON_CLIP)}; + background: var(--nq-brand-gradient); + } + + .d20-numeral { + font-family: var(--nq-p-font-heading); + font-size: 19px; + font-weight: 700; + line-height: 1; + color: #ffffff; + } + + .remaining-text { + display: flex; + flex-direction: column; + gap: 4px; + } + + .remaining-count { + font-family: var(--nq-p-font-heading); + font-size: 28px; + font-weight: 700; + line-height: 1.1; + color: var(--nq-p-ink); + } + + .party-line { + font-family: var(--nq-p-font-body); + font-size: var(--nq-p-size-body); + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .columns { + position: absolute; + top: 236px; + left: var(--nq-p-page-inset); + right: var(--nq-p-page-inset); + bottom: 130px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 34px; + } + + .column { + display: flex; + flex-direction: column; + min-height: 0; + } + + .column-head { + flex: none; + display: flex; + align-items: center; + gap: 14px; + padding-bottom: 14px; + border-bottom: var(--nq-p-rule); + } + + .column-head svg { + flex: none; + width: 34px; + height: 34px; + color: var(--nq-p-ink-secondary); + } + + .column-names { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + .column-name { + font-family: var(--nq-p-font-heading); + font-size: var(--nq-p-size-section); + font-weight: 700; + line-height: 1.1; + color: var(--nq-p-ink); + } + + .column-range { + font-family: var(--nq-p-font-heading); + font-size: var(--nq-p-size-label); + font-weight: 600; + letter-spacing: var(--nq-p-track-label); + text-transform: uppercase; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .column-count { + margin-left: auto; + flex: none; + font-family: var(--nq-p-font-heading); + font-size: 26px; + font-weight: 700; + line-height: 1.1; + color: var(--nq-p-ink-secondary); + } + + .stack { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: visible; + gap: var(--nq-p-quest-gap); + margin-top: 22px; + } + + .quest { + position: relative; + display: flex; + align-items: center; + gap: 22px; + min-height: var(--nq-p-quest-min-height); + padding: var(--nq-p-quest-pad); + border: 1px solid var(--nq-p-card-border); + border-radius: var(--nq-p-radius-card); + background: var(--nq-p-card-open); + box-shadow: var(--nq-p-card-shadow); + -webkit-tap-highlight-color: transparent; + transition: transform var(--nq-dur-micro) var(--nq-ease-out); + } + + .quest.tappable { + cursor: pointer; + } + + .quest.tappable:active { + transform: scale(0.98); + } + + .quest.sealed { + background: var(--nq-p-card-done); + opacity: 0.82; + box-shadow: none; + } + + .tile { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--nq-p-tile-size); + height: var(--nq-p-tile-size); + border-radius: 10px; + background: var(--nq-p-icon-tile); + color: #ffffff; + } + + .tile svg { + width: 34px; + height: 34px; + } + + .sealed .tile { + background: var(--nq-p-icon-tile-done); + color: var(--nq-p-ink-secondary); + } + + .quest .body { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + } + + .quest .quest-title { + font-family: var(--nq-p-font-heading); + font-size: var(--nq-p-size-quest); + font-weight: 700; + line-height: 1.15; + color: var(--nq-p-ink); + overflow-wrap: anywhere; + } + + .sealed .quest-title { + color: var(--nq-p-ink-muted); + text-decoration: line-through; + } + + .quest .meta { + font-family: var(--nq-p-font-body); + font-size: var(--nq-p-size-body); + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .quest .meta.late { + font-family: var(--nq-p-font-heading); + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--nq-p-ink-late); + } + + button.complete { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + height: var(--nq-p-button-height); + min-width: var(--nq-p-button-min-width); + border: none; + border-radius: 10px; + background: var(--nq-brand-gradient); + color: #ffffff; + font-family: var(--nq-p-font-heading); + font-size: 24px; + font-weight: 700; + line-height: 1; + box-shadow: 0 3px 0 rgba(40, 20, 60, 0.35); + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform var(--nq-dur-micro) var(--nq-ease-out); + } + + button.complete:active { + transform: scale(0.98); + } + + button.complete svg { + width: 30px; + height: 30px; + } + + button:focus-visible { + outline: 3px solid var(--nq-brand-purple); + outline-offset: 4px; + } + + .seal { + position: absolute; + right: 16px; + top: -12px; + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--nq-p-seal-size); + height: var(--nq-p-seal-size); + border-radius: var(--nq-p-radius-pill); + background: var(--nq-p-seal); + box-shadow: var(--nq-p-seal-shadow); + color: rgba(255, 235, 235, 0.95); + transform: rotate(var(--seal-rot, 0deg)); + animation: seal-in var(--nq-dur-base) var(--nq-ease-out); + pointer-events: none; + } + + .seal svg { + width: 38px; + height: 38px; + } + + @keyframes seal-in { + from { + opacity: 0; + transform: scale(1.15) rotate(var(--seal-rot, 0deg)); + } + to { + opacity: 1; + transform: scale(1) rotate(var(--seal-rot, 0deg)); + } + } + + .rollup { + border: 2px dashed var(--nq-p-panel-border); + border-radius: 14px; + padding: 16px 22px; + } + + .rollup-label { + display: block; + font-family: var(--nq-p-font-heading); + font-size: 18px; + font-weight: 600; + letter-spacing: var(--nq-p-track-label); + text-transform: uppercase; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .rollup-body { + margin: 6px 0 0; + font-family: var(--nq-p-font-body); + font-size: 23px; + font-weight: 600; + line-height: 1.35; + color: var(--nq-p-ink-secondary); + } + + .notice-wrap { + position: absolute; + top: 236px; + left: var(--nq-p-page-inset); + right: var(--nq-p-page-inset); + bottom: 130px; + display: flex; + align-items: center; + justify-content: center; + } + + .notice { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 20px; + width: 100%; + max-width: 980px; + padding: 56px 64px; + border: 2px solid var(--nq-p-panel-border); + border-radius: 20px; + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + text-align: center; + } + + .notice svg { + width: 46px; + height: 46px; + color: var(--nq-p-ink-secondary); + } + + .notice.away { + background: linear-gradient(#f2ecdd, #e6dcc6); + border-style: dashed; + box-shadow: none; + } + + .notice-headline { + font-family: var(--nq-p-font-heading); + font-size: 40px; + font-weight: 700; + line-height: 1.15; + color: var(--nq-p-ink); + } + + .notice-body { + margin: 0; + font-family: var(--nq-p-font-body); + font-size: 24px; + font-weight: 600; + line-height: 1.4; + color: var(--nq-p-ink-secondary); + } + + .notice-seal { + display: inline-flex; + align-items: center; + justify-content: center; + width: 78px; + height: 78px; + border-radius: var(--nq-p-radius-pill); + background: var(--nq-p-seal); + box-shadow: var(--nq-p-seal-shadow); + color: rgba(255, 235, 235, 0.95); + transform: rotate(-6deg); + } + + .notice-seal svg { + width: 38px; + height: 38px; + color: rgba(255, 235, 235, 0.95); + } + + .scrim { + position: absolute; + inset: 0; + z-index: 10; + background: rgba(30, 20, 10, 0.62); + } + + .dialog { + position: absolute; + top: 196px; + left: 460px; + width: 1000px; + border: 3px solid rgba(92, 62, 26, 0.5); + border-radius: 20px; + background: var(--nq-p-card-panel); + box-shadow: 0 26px 60px rgba(20, 10, 0, 0.5); + padding: 64px 68px 56px; + animation: dialog-in var(--nq-dur-modal) var(--nq-ease-out); + } + + @keyframes dialog-in { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } + } + + .dialog-header { + display: flex; + align-items: center; + gap: 24px; + } + + .dialog-tile { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 104px; + height: 104px; + border-radius: 12px; + background: var(--nq-p-icon-tile); + color: #ffffff; + } + + .dialog-tile svg { + width: 56px; + height: 56px; + } + + .dialog-titles { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + } + + .dialog-kicker { + font-family: var(--nq-p-font-heading); + font-size: var(--nq-p-size-label); + font-weight: 600; + letter-spacing: 0.24em; + text-transform: uppercase; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .dialog-quest-title { + font-family: var(--nq-p-font-display); + font-size: 58px; + font-weight: 700; + line-height: 1.1; + color: var(--nq-p-ink); + overflow-wrap: anywhere; + } + + .dialog-body { + margin: 28px 0 0; + font-family: var(--nq-p-font-body); + font-size: 32px; + font-weight: 600; + line-height: 1.35; + color: #3f2f1c; + } + + .dialog-buttons { + display: flex; + gap: 24px; + margin-top: 40px; + } + + button.confirm-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 12px; + height: var(--nq-p-confirm-button); + border-radius: 10px; + font-family: var(--nq-p-font-heading); + font-weight: 700; + line-height: 1; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform var(--nq-dur-micro) var(--nq-ease-out); + } + + button.confirm-button:active { + transform: scale(0.98); + } + + button.not-yet { + flex: 1; + border: 2px solid rgba(92, 62, 26, 0.5); + background: rgba(92, 62, 26, 0.1); + color: var(--nq-p-ink); + font-size: 30px; + } + + button.not-yet svg { + width: 34px; + height: 34px; + } + + button.do-complete { + flex: 2; + border: none; + background: var(--nq-brand-gradient); + color: #ffffff; + font-size: 32px; + } + + button.do-complete svg { + width: 38px; + height: 38px; + } + + .toast { + position: absolute; + left: 50%; + bottom: 158px; + z-index: 20; + transform: translateX(-50%); + max-width: calc(100% - 124px); + padding: 20px 32px; + border: 2px solid var(--nq-p-panel-border); + border-radius: 14px; + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + font-family: var(--nq-p-font-body); + font-size: 23px; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--nq-p-ink-secondary); + animation: toast-in var(--nq-dur-base) var(--nq-ease-out); + pointer-events: none; + } + + @keyframes toast-in { + from { + opacity: 0; + transform: translate(-50%, 12px); + } + to { + opacity: 1; + transform: translate(-50%, 0); + } + } + + .complete-screen { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + padding: 96px 62px 130px; + text-align: center; + } + + .complete-seal { + display: inline-flex; + align-items: center; + justify-content: center; + width: 168px; + height: 168px; + border-radius: var(--nq-p-radius-pill); + background: var(--nq-p-seal); + box-shadow: 0 10px 24px rgba(60, 10, 20, 0.42); + color: rgba(255, 235, 235, 0.95); + transform: rotate(-6deg); + } + + .complete-seal svg { + width: 86px; + height: 86px; + } + + .complete-title { + margin: 28px 0 0; + font-family: var(--nq-p-font-display); + font-size: 86px; + font-weight: 900; + line-height: 1.1; + background: var(--nq-brand-gradient-h); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; + } + + .complete-sub { + margin: 16px 0 0; + max-width: 1000px; + font-family: var(--nq-p-font-body); + font-size: 34px; + font-weight: 600; + line-height: 1.35; + color: var(--nq-p-ink-secondary); + text-wrap: pretty; + } + + .complete-countdown { + margin: 12px 0 0; + font-family: var(--nq-p-font-body); + font-size: 25px; + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .complete-stats { + position: absolute; + top: 540px; + left: 200px; + right: 200px; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 26px; + } + + .complete-stat { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px 16px; + border: 2px solid var(--nq-p-panel-border); + border-radius: 16px; + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + } + + .complete-stat .numeral { + font-family: var(--nq-p-font-heading); + font-size: 58px; + font-weight: 900; + line-height: 1; + color: var(--nq-p-ink); + } + + .complete-stat .label { + font-family: var(--nq-p-font-heading); + font-size: 17px; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .dock { + position: absolute; + left: 46px; + right: 46px; + bottom: 46px; + height: var(--nq-p-dock-height); + display: flex; + align-items: center; + justify-content: center; + gap: 24px; + padding: 0 40px; + border-radius: 14px; + background: var(--nq-p-dock-bg); + pointer-events: none; + user-select: none; + color: var(--nq-p-dock-ink); + } + + .dock svg { + flex: none; + width: 40px; + height: 40px; + color: var(--nq-p-dock-ink); + } + + .dock .divider { + flex: none; + width: 1px; + height: 40px; + background: var(--nq-p-dock-divider); + } + + .dock .temp { + font-family: var(--nq-p-font-body); + font-size: 38px; + font-weight: 800; + line-height: 1; + } + + .dock .condition { + font-family: var(--nq-p-font-body); + font-size: 26px; + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-dock-ink-secondary); + } + + .dock .date { + font-family: var(--nq-p-font-body); + font-size: 26px; + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-dock-ink-secondary); + } + + .dock .clock { + font-family: var(--nq-p-font-body); + font-size: 38px; + font-weight: 800; + line-height: 1; + } +`; + +const ICON_CHECK = html``; + +const ICON_X = html``; + +const ICON_STAR = html``; + +const ICON_SUNRISE = html``; + +const ICON_SUN = html``; + +const ICON_MOON = html``; + +const ICON_TENT = html``; + +const ICON_COMPASS = html``; + +const ICON_ALERT = html``; + +const ICON_CLOUD_OFF = html``; + +const ICON_CLOUD_MOON = html``; + +const LOG_NOTICE_NO_ADVENTURER: LogNotice = { + icon: ICON_COMPASS, + headline: "No adventurer chosen", + body: "Open The Party and tap your crest to open your quest log.", +}; + +const LOG_NOTICE_NOT_SET_UP: LogNotice = { + icon: ICON_ALERT, + headline: "NestQuest is not set up yet", + body: "A parent needs to finish setting up NestQuest.", +}; + +const LOG_NOTICE_UNREACHABLE: LogNotice = { + icon: ICON_CLOUD_OFF, + headline: "The records cannot be reached", + body: "The party's records are quiet right now. NestQuest will return shortly.", +}; + +const WINDOW_DEFS: WindowDef[] = [ + { key: "morning", name: "Morning", range: "Until 11:59 AM", icon: ICON_SUNRISE }, + { key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: ICON_SUN }, + { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: ICON_MOON }, +]; + +export class NestQuestQuestLogCard extends LitElement { + static properties = { + hass: { attribute: false }, + _config: { state: true }, + _now: { state: true }, + _confirm: { state: true }, + _optimistic: { state: true }, + _toast: { state: true }, + _countdown: { state: true }, + }; + + static styles = [unsafeCSS(panelTokens), logStyles]; + + hass?: HassLike; + _config?: QuestLogCardConfig; + _now = new Date(); + _confirm: number | null = null; + /** instanceId → the completion instant stamped before the service has + * answered; the seal shows immediately and reverts on failure. */ + _optimistic = new Map(); + _toast: string | null = null; + _countdown: number | null = null; + private _clockTimer?: number; + private _idleTimer?: number; + private _confirmTimer?: number; + private _toastTimer?: number; + private _completeTimer?: number; + private _unsubs: Array<() => void> = []; + private _subscribed = false; + private _subGeneration = 0; + private _retryTimer?: number; + + setConfig(config: QuestLogCardConfig): void { + if (!config || typeof config !== "object") { + 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(); + }, 60000); + window.addEventListener("pointerdown", this._onActivity, true); + window.addEventListener("touchstart", this._onActivity, true); + window.addEventListener("keydown", this._onActivity, true); + this._armIdle(); + if (this._logView().kind === "complete-day") { + this._armCompleteTimer(); + } + } + + protected updated(): void { + this._subscribeLive(); + if (this._logView().kind === "complete-day") { + this._clearIdle(); + this._armCompleteTimer(); + } else { + this._clearCompleteTimer(); + this._countdown = null; + if (this._idleTimer === undefined) { + this._armIdle(); + } + } + } + + disconnectedCallback(): void { + this._unsubscribeLive(); + this._stopClock(); + this._clearIdle(); + this._clearConfirmTimer(); + this._clearToastTimer(); + this._clearCompleteTimer(); + window.removeEventListener("pointerdown", this._onActivity, true); + window.removeEventListener("touchstart", this._onActivity, true); + window.removeEventListener("keydown", this._onActivity, true); + super.disconnectedCallback(); + } + + render() { + const view = this._logView(); + return html` +
+
+
+ ${this._renderMain(view)} + ${this._renderConfirm()} + ${this._renderToast()} +
+ `; + } + + private _renderToast(): TemplateResult | typeof nothing { + if (this._toast === null) { + return nothing; + } + return html` +
${this._toast}
+ `; + } + + private _renderMain(view: LogView): TemplateResult { + switch (view.kind) { + case "no-adventurer": + return this._renderNotice(LOG_NOTICE_NO_ADVENTURER); + case "not-set-up": + return this._renderNotice(LOG_NOTICE_NOT_SET_UP); + case "unreachable": + return this._renderNotice(LOG_NOTICE_UNREACHABLE); + case "away": { + const body = ["The quest log unlocks when they return."]; + if (view.returns) { + body.push(`Returns ${view.returns}`); + } + return html` + ${this._renderHeader(view)} +
+
+ ${ICON_TENT} + ${view.name} is on travels + ${body.map((line) => html`

${line}

`)} +
+
+ `; + } + case "empty-day": + return html` + ${this._renderHeader(view)} +
+
+ ${ICON_SUN} + No quests today +

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

+
+
+ `; + case "complete-day": { + const stats = this._completeStats(); + const remaining = this._countdown ?? this._completeSeconds(); + return html` +
+ +

Quest complete

+

${this._completeSub(view.name)}

+

+ Returning to The Party in ${remaining} seconds +

+
+
+ ${stats.claimed} + Quests claimed +
+
+ ${stats.onTime} + On time +
+
+ ${stats.late} + Late +
+
+ ${stats.party} + The Party +
+
+
+ ${this._renderDock()} + `; + } + default: + return html` + ${this._renderHeader(view)} +
+ ${WINDOW_DEFS.map((windowDef) => this._renderColumn(windowDef))} +
+ `; + } + } + + /** Resolve the log's single render state; the priority order is + * design/PANEL-EMPTY-STATES.md §2. */ + private _logView(): LogView { + const slug = this._childSlug(); + if (!slug) { + return { kind: "no-adventurer" }; + } + const resolution = this._dueSensorResolution(slug); + if (resolution === "missing") { + return { kind: "not-set-up" }; + } + if (resolution === "stale") { + return { kind: "unreachable" }; + } + const name = this._childName(slug) ?? titleCaseSlug(slug); + if (!this._childPresent(slug)) { + return { kind: "away", name, returns: this._awayReturns(slug) }; + } + const allDone = this._state(`binary_sensor.nestquest_${slug}_all_done`); + if (allDone && String(allDone.state ?? "").trim().toLowerCase() === "on") { + return { kind: "complete-day", name }; + } + const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`); + const due = clampCount(asNumber(dueSensor?.state, 0)); + if (due === 0) { + return { kind: "empty-day", name }; + } + if (this._remaining() === 0) { + return { kind: "complete-day", name }; + } + return { kind: "normal" }; + } + + /** "missing" when the due sensor does not exist (integration not + * configured), "stale" when it answers unavailable/unknown (backend + * unreachable or the child is absent from the snapshot), null when + * it resolves. */ + 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 _childPresent(slug: string): boolean { + const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`); + const presentAttr = dueSensor?.attributes?.present; + if (typeof presentAttr === "boolean") { + return presentAttr; + } + const presence = this._state( + `binary_sensor.nestquest_${slug}_present_today` + ); + if (presence) { + return String(presence.state ?? "").trim().toLowerCase() === "on"; + } + return true; + } + + private _awayReturns(slug: string): string | null { + const timeZone = this._timeZone(); + const presence = this._state( + `binary_sensor.nestquest_${slug}_present_today` + ); + const nextPresent = parseIsoDate( + presence?.attributes?.next_present, + timeZone + ); + return nextPresent ? formatDay(nextPresent, timeZone) : null; + } + + private _renderNotice(notice: LogNotice): TemplateResult { + return html` +
+
+ ${notice.icon} + ${notice.headline} +

${notice.body}

+
+
+ `; + } + + private _timeZone(): string | undefined { + return hassTimeZone(this.hass); + } + + private _state(entityId: string): StateObject | null { + const states = this.hass?.states as Record | undefined; + const stateObj = states?.[entityId]; + return stateObj && typeof stateObj === "object" ? stateObj : null; + } + + private _childSlug(): string { + return childSlugFromPath(); + } + + private _boardPath(): string { + return asString(this._config?.board_path).replace(/\/+$/, ""); + } + + private _idleSeconds(): number { + const seconds = asNumber(this._config?.idle_return_seconds, 40); + return seconds > 0 ? seconds : 40; + } + + private _confirmSeconds(): number { + const seconds = asNumber(this._config?.confirm_timeout_seconds, 15); + return seconds > 0 ? seconds : 15; + } + + private _completeSeconds(): number { + const seconds = asNumber(this._config?.complete_screen_seconds, 12); + return seconds > 0 ? seconds : 12; + } + + private _childName(slug: string): string | null { + if (!slug) { + return null; + } + const name = this._state(`sensor.nestquest_${slug}_quests_due_today`) + ?.attributes?.child_name; + if (typeof name === "string" && name.trim()) { + return name.trim(); + } + return null; + } + + private _childCounts(slug: string): { due: number; completed: number } { + const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`); + const completedSensor = this._state( + `sensor.nestquest_${slug}_quests_completed_today` + ); + const due = clampCount(asNumber(dueSensor?.state, 0)); + const completed = clampCount(asNumber(completedSensor?.state, 0)); + return { due, completed }; + } + + private _instances(): PanelInstance[] { + const slug = this._childSlug(); + if (!slug) { + return []; + } + const payload = this._state(`sensor.nestquest_${slug}_quests_due_today`) + ?.attributes?.instances; + if (!Array.isArray(payload)) { + return []; + } + const instances = payload + .map(toPanelInstance) + .filter((instance): instance is PanelInstance => instance !== null); + if (this._optimistic.size === 0) { + return instances; + } + let settled = false; + for (const instance of instances) { + const stampedAt = this._optimistic.get(instance.id); + if (stampedAt === undefined) { + continue; + } + if (instance.state === "completed") { + // the backend has caught up; the real record replaces the overlay + this._optimistic.delete(instance.id); + settled = true; + continue; + } + instance.state = "completed"; + instance.overdue = false; + instance.completed_at = stampedAt; + } + if (settled) { + this._optimistic = new Map(this._optimistic); + } + return instances; + } + + private _remaining(): number { + const slug = this._childSlug(); + if (!slug) { + return 0; + } + const sensor = this._state( + `sensor.nestquest_${slug}_quests_remaining_today` + ); + if (sensor) { + return clampCount(asNumber(sensor.state, 0)); + } + const { due, completed } = this._childCounts(slug); + return clampCount(due - completed); + } + + private _partyCounts(): { completed: number; due: number } { + const householdDue = this._state( + "sensor.nestquest_household_quests_due_today" + ); + const householdCompleted = this._state( + "sensor.nestquest_household_quests_completed_today" + ); + if (householdDue || householdCompleted) { + return { + completed: clampCount(asNumber(householdCompleted?.state, 0)), + due: clampCount(asNumber(householdDue?.state, 0)), + }; + } + const slug = this._childSlug(); + const { due, completed } = this._childCounts(slug); + return { completed, due }; + } + + private _completeStats(): { + claimed: number; + onTime: number; + late: number; + party: string; + } { + const claimed = this._childCounts(this._childSlug()).completed; + const onTime = this._instances().filter( + (instance) => instance.on_time === true + ).length; + const late = clampCount(claimed - onTime); + const party = this._partyCounts(); + return { + claimed, + onTime, + late, + party: `${party.completed}/${party.due}`, + }; + } + + private _completeSub(name: string): string { + const date = formatDay(this._now, this._timeZone()); + const { claimed, onTime, late } = this._completeStats(); + if (claimed === 0) { + return `${name} sealed the day on ${date}.`; + } + if (late === 0) { + return `${name} claimed every quest on ${date} — all on time.`; + } + if (onTime === 0) { + return `${name} claimed every quest on ${date} — all late.`; + } + return `${name} claimed every quest on ${date} — ${onTime} on time, ${late} late.`; + } + + private _otherChildren(): OtherChild[] { + const states = this.hass?.states as + | Record + | undefined; + if (!states) { + return []; + } + const slug = this._childSlug(); + const timeZone = this._timeZone(); + const result: OtherChild[] = []; + for (const entityId of Object.keys(states)) { + const match = /^sensor\.nestquest_(.+)_quests_due_today$/.exec(entityId); + if (!match || match[1] === "household" || match[1] === slug) { + continue; + } + const childSlug = match[1]; + const attrs = states[entityId]?.attributes ?? {}; + let name = ""; + if (typeof attrs.child_name === "string" && attrs.child_name.trim()) { + name = attrs.child_name.trim(); + } else { + name = titleCaseSlug(childSlug); + } + let present = true; + if (typeof attrs.present === "boolean") { + present = attrs.present; + } else { + const presence = this._state( + `binary_sensor.nestquest_${childSlug}_present_today` + ); + present = + presence === null || + String(presence.state ?? "").trim().toLowerCase() !== "off"; + } + const completedSensor = this._state( + `sensor.nestquest_${childSlug}_quests_completed_today` + ); + const due = clampCount(asNumber(states[entityId]?.state, 0)); + const completed = clampCount(asNumber(completedSensor?.state, 0)); + let returnsWeekday: string | null = null; + if (!present) { + const presence = this._state( + `binary_sensor.nestquest_${childSlug}_present_today` + ); + const nextPresent = parseIsoDate( + presence?.attributes?.next_present, + timeZone + ); + returnsWeekday = nextPresent + ? formatWeekday(nextPresent, timeZone) + : null; + } + result.push({ + slug: childSlug, + name, + present, + due, + completed, + returnsWeekday, + }); + } + return result.sort((a, b) => a.name.localeCompare(b.name)); + } + + private _renderHeader(view: LogView) { + const slug = this._childSlug(); + const away = view.kind === "away"; + const name = away + ? view.name + : (this._childName(slug) ?? (slug ? titleCaseSlug(slug) : null)); + const { due, completed } = this._childCounts(slug); + const remaining = this._remaining(); + const party = this._partyCounts(); + const title = name ? `${name}'s Quest Log` : "Quest Log"; + const initial = (name?.charAt(0) || "?").toUpperCase(); + return html` +
+ +
+

${title}

+

+ ${formatDay(this._now, this._timeZone())} · + ${away ? "On travels" : `${completed} of ${due} claimed`} +

+
+ ${away + ? nothing + : html` +
+ +
+ + ${remaining} quest${remaining === 1 ? "" : "s"} left + + + Party progress · ${party.completed} of ${party.due} today + +
+
+ `} +
+ `; + } + + private _renderColumn(windowDef: WindowDef) { + const instances = this._instances().filter( + (instance) => instance.window === windowDef.key + ); + const open = instances + .filter((instance) => instance.state === "open") + .sort((a, b) => + (a.due_time ?? "99:99").localeCompare(b.due_time ?? "99:99") + ); + const sealed = instances.filter( + (instance) => instance.state === "completed" + ); + const count = instances.length; + let stackStyle: string | typeof nothing = nothing; + if (count > 0) { + const rollupReserve = + windowDef.key === "afternoon" && this._otherChildren().length > 0 + ? 160 + : 0; + const available = 644 - rollupReserve; + const gap = Math.min( + 24, + Math.max(8, Math.floor((available - count * 74) / Math.max(count - 1, 1))) + ); + const minHeight = Math.min( + 116, + Math.max(58, Math.floor((available - (count - 1) * gap) / count)) + ); + const buttonHeight = Math.max(56, Math.min(72, minHeight - 2)); + let stackVars = `--nq-p-quest-gap: ${gap}px; --nq-p-quest-min-height: ${minHeight}px; --nq-p-button-height: ${buttonHeight}px`; + if (count >= 5) { + const pad = Math.max(0, Math.min(22, Math.floor((minHeight - buttonHeight - 2) / 2))); + const tile = Math.max(44, Math.min(64, minHeight - 2 * pad - 2)); + stackVars += `; --nq-p-quest-pad: ${pad}px; --nq-p-tile-size: ${tile}px`; + } + stackStyle = stackVars; + } + return html` +
+
+ ${windowDef.icon} +
+ ${windowDef.name} + ${windowDef.range} +
+ ${sealed.length}/${instances.length} +
+
+ ${repeat( + open, + (instance) => instance.id, + (instance) => this._renderQuest(instance, false, 0) + )} + ${repeat( + sealed, + (instance) => instance.id, + (instance, index) => this._renderQuest(instance, true, index) + )} + ${windowDef.key === "afternoon" ? this._renderRollup() : nothing} +
+
+ `; + } + + private _renderQuest( + instance: PanelInstance, + sealed: boolean, + sealedIndex: number + ) { + if (sealed) { + const rotation = SEAL_ROTATIONS[sealedIndex % SEAL_ROTATIONS.length]; + const claimedAt = instance.completed_at + ? formatInstant(instance.completed_at, this._timeZone()) + : ""; + return html` +
+ +
+ ${instance.title} + + ${claimedAt ? `Claimed ${claimedAt}` : "Claimed"} + +
+ +
+ `; + } + const dueAt = formatWallClock(instance.due_time); + const meta = instance.overdue + ? { + text: dueAt ? `Overdue · due ${dueAt}` : "Overdue", + late: true, + } + : { + text: dueAt ? `Due by ${dueAt}` : "Due today", + late: false, + }; + return html` +
this._openConfirm(instance.id)} + @keydown=${(event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + this._openConfirm(instance.id); + } + }} + > + +
+ ${instance.title} + ${meta.text} +
+ +
+ `; + } + + private _renderRollup() { + const others = this._otherChildren(); + if (others.length === 0) { + return nothing; + } + const sentences = others.slice(0, 2).map((child) => { + if (!child.present) { + return child.returnsWeekday + ? `${child.name} is on travels until ${child.returnsWeekday}.` + : `${child.name} is on travels.`; + } + if (child.due > 0 && child.completed >= child.due) { + return `${child.name} has finished their log.`; + } + if (child.due > child.completed) { + return `${child.name} has ${child.due - child.completed} quests left.`; + } + return `${child.name} has no quests today.`; + }); + return html` +
+ Party roll-up +

${sentences.join(" ")}

+
+ `; + } + + private _renderConfirm() { + if (this._confirm === null) { + return nothing; + } + const instance = this._instances().find( + (candidate) => candidate.id === this._confirm + ); + if (!instance) { + return nothing; + } + const windowDef = WINDOW_DEFS.find((entry) => entry.key === instance.window); + return html` +
this._closeConfirm()}> + +
+ `; + } + + private _openConfirm(instanceId: number): void { + this._confirm = instanceId; + this._armConfirmTimer(); + } + + private _closeConfirm(): void { + this._clearConfirmTimer(); + this._confirm = null; + } + + private _confirmComplete(): void { + const instance = + this._confirm === null + ? undefined + : this._instances().find( + (candidate) => candidate.id === this._confirm + ); + this._closeConfirm(); + if (!instance || instance.state !== "open") { + return; + } + this._completeQuest(instance); + } + + /** Seal first, ask the service second: the seal is stamped and the column + * re-sorted immediately (PANEL-SPEC §4); a service failure reverts the + * card and raises the parchment toast. */ + private async _completeQuest(instance: PanelInstance): Promise { + if (this._optimistic.has(instance.id)) { + return; + } + const stampedAt = new Date().toISOString(); + this._optimistic = new Map(this._optimistic).set(instance.id, stampedAt); + try { + await this._callCompleteQuest(instance); + } catch { + const reverted = new Map(this._optimistic); + reverted.delete(instance.id); + this._optimistic = reverted; + this._showToast( + "NestQuest could not set the seal just now. Please try again." + ); + } + } + + private async _callCompleteQuest(instance: PanelInstance): Promise { + const hass = this.hass as + | { + callService?: ( + this: unknown, + domain: string, + service: string, + data?: Record + ) => Promise; + } + | undefined; + if (typeof hass?.callService !== "function") { + throw new Error("Home Assistant is not connected"); + } + const actorChildId = this._actorChildId(instance); + if (actorChildId === null) { + throw new Error("The tapped adventurer is unknown"); + } + await hass.callService.call(hass, "nestquest", "complete_quest", { + instance_id: instance.id, + actor: "panel", + actor_child_id: actorChildId, + }); + } + + /** The tapped profile (decision 9): the instance's own child_id, falling + * back to the due sensor's child-level attribute. */ + private _actorChildId(instance: PanelInstance): number | null { + if (instance.child_id !== null) { + return instance.child_id; + } + const slug = this._childSlug(); + const fromSensor = asNumber( + this._state(`sensor.nestquest_${slug}_quests_due_today`)?.attributes + ?.child_id, + 0 + ); + return fromSensor > 0 ? fromSensor : null; + } + + private _showToast(message: string): void { + this._toast = message; + this._clearToastTimer(); + this._toastTimer = window.setTimeout(() => { + this._toastTimer = undefined; + this._toast = null; + }, 6000); + } + + private _clearToastTimer(): void { + if (this._toastTimer !== undefined) { + window.clearTimeout(this._toastTimer); + this._toastTimer = undefined; + } + } + + private _armConfirmTimer(): void { + this._clearConfirmTimer(); + this._confirmTimer = window.setTimeout(() => { + this._confirmTimer = undefined; + this._closeConfirm(); + }, this._confirmSeconds() * 1000); + } + + private _clearConfirmTimer(): void { + if (this._confirmTimer !== undefined) { + window.clearTimeout(this._confirmTimer); + this._confirmTimer = undefined; + } + } + + private _armIdle(): void { + this._clearIdle(); + this._idleTimer = window.setTimeout(() => { + this._idleTimer = undefined; + this._returnToBoard(); + }, this._idleSeconds() * 1000); + } + + private _clearIdle(): void { + if (this._idleTimer !== undefined) { + window.clearTimeout(this._idleTimer); + this._idleTimer = undefined; + } + } + + private _armCompleteTimer(): void { + if (this._completeTimer !== undefined) { + return; + } + if (this._countdown === null) { + this._countdown = this._completeSeconds(); + } + if (this._countdown <= 0) { + this._returnToBoard(); + return; + } + this._completeTimer = window.setInterval(() => { + const remaining = (this._countdown ?? 1) - 1; + if (remaining <= 0) { + this._countdown = 0; + this._clearCompleteTimer(); + this._returnToBoard(); + return; + } + this._countdown = remaining; + }, 1000); + } + + private _clearCompleteTimer(): void { + if (this._completeTimer !== undefined) { + window.clearInterval(this._completeTimer); + this._completeTimer = undefined; + } + } + + private _optionalNumber(value: unknown): number | null { + if (value === null || value === undefined || value === "") { + return null; + } + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + + private _dockWeather(): DockWeather | null { + const entityId = asString(this._config?.weather_entity); + if (!entityId) { + return null; + } + const stateObj = this._state(entityId); + if (!stateObj) { + return null; + } + const condition = String(stateObj.state ?? "").trim(); + if (!condition || condition === "unavailable" || condition === "unknown") { + return null; + } + const attrs = stateObj.attributes ?? {}; + let high: number | null = null; + let low: number | null = null; + const forecast = attrs.forecast; + if (Array.isArray(forecast) && forecast.length > 0) { + const entry = forecast[0]; + if (entry && typeof entry === "object") { + const typed = entry as Record; + high = this._optionalNumber(typed.temperature); + low = this._optionalNumber(typed.templow); + } + } + return { + condition, + temperature: this._optionalNumber(attrs.temperature), + high, + low, + }; + } + + private _renderDock() { + const weather = this._dockWeather(); + if (!weather) { + return html` +
+ ${formatDay(this._now, this._timeZone())} + + ${formatTime(this._now, this._timeZone())} +
+ `; + } + const label = conditionLabel(weather.condition); + const phrase = conditionPhrase(weather.condition, label); + const temperature = + weather.temperature === null + ? nothing + : html`${Math.round(weather.temperature)}°`; + const hiLo = + weather.high === null || weather.low === null + ? null + : `${Math.round(weather.high)}° / ${Math.round(weather.low)}°`; + return html` +
+ ${ICON_CLOUD_MOON} + ${temperature} + + + ${hiLo === null ? label : `${label} · ${hiLo}`} + + + ${phrase} +
+ `; + } + + private _returnToBoard(): void { + const path = this._boardPath(); + if (!path) { + return; + } + window.history.pushState(null, "", path); + window.dispatchEvent(new Event("location-changed")); + } + + private _stopClock(): void { + if (this._clockTimer !== undefined) { + window.clearInterval(this._clockTimer); + this._clockTimer = undefined; + } + } + + private _onActivity = (): void => { + if (this._logView().kind !== "complete-day") { + this._armIdle(); + } + if (this._confirm !== null) { + this._armConfirmTimer(); + } + }; +} + +customElements.define("nestquest-quest-log-card", NestQuestQuestLogCard); +registerCustomCard({ + type: "nestquest-quest-log-card", + name: "NestQuest Quest Log", + description: "One child's daily quests, grouped by window.", +}); diff --git a/frontend/src/nestquest-cards.ts b/frontend/src/nestquest-cards.ts new file mode 100644 index 0000000..02316e3 --- /dev/null +++ b/frontend/src/nestquest-cards.ts @@ -0,0 +1,12 @@ +import "./cards/party-board"; +import "./cards/quest-log"; +import "./cards/admin"; + +const FONTS_HREF = "/nestquest-static/nestquest-fonts.css"; +if (!document.querySelector('link[data-nq-fonts=""]')) { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = FONTS_HREF; + link.dataset.nqFonts = ""; + document.head.appendChild(link); +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..4854ed6 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,20 @@ +export type HassLike = Record; + +export type CardConfig = Record; + +export type CustomCardInfo = { + type: string; + name: string; + description: string; +}; + +declare global { + interface Window { + customCards?: CustomCardInfo[]; + } +} + +export function registerCustomCard(info: CustomCardInfo): void { + window.customCards = window.customCards ?? []; + window.customCards.push(info); +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..8963332 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..1c3265e --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + build: { + outDir: "../custom_components/nestquest/www", + emptyOutDir: false, + sourcemap: false, + lib: { + entry: "src/nestquest-cards.ts", + formats: ["es"], + fileName: () => "nestquest-cards.js", + }, + rollupOptions: { + output: { + inlineDynamicImports: true, + }, + }, + }, +}); diff --git a/tests/conftest.py b/tests/conftest.py index e2dfd3d..6d6c9c4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,6 +51,8 @@ _HA_MODULES = ( "homeassistant.components", "homeassistant.components.sensor", "homeassistant.components.binary_sensor", + "homeassistant.components.http", + "homeassistant.components.frontend", ) def _find_spec(name: str): @@ -96,6 +98,8 @@ for _parent, _child in ( ("homeassistant", "components"), ("homeassistant.components", "sensor"), ("homeassistant.components", "binary_sensor"), + ("homeassistant.components", "http"), + ("homeassistant.components", "frontend"), ): setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"]) @@ -429,6 +433,28 @@ _sensor_mock.SensorStateClass = SensorStateClass _binary_sensor_mock = _ha_mock("homeassistant.components.binary_sensor") _binary_sensor_mock.BinarySensorEntity = BinarySensorEntity + +@dataclass(frozen=True) +class StaticPathConfig: + """Stand-in mirroring homeassistant.components.http.StaticPathConfig.""" + + url_path: str + path: str + cache_headers: bool = True + + +def _add_extra_js_url(hass, url, es5=False): + """Stand-in mirroring homeassistant.components.frontend.add_extra_js_url.""" + urls = getattr(hass, "extra_js_urls", None) + if urls is None: + hass.extra_js_urls = [] + urls = hass.extra_js_urls + urls.append(url) + + +_ha_mock("homeassistant.components.http").StaticPathConfig = StaticPathConfig +_ha_mock("homeassistant.components.frontend").add_extra_js_url = _add_extra_js_url + import voluptuous as vol @@ -549,6 +575,16 @@ def _async_track_time_change(hass, action, hour=None, minute=None, second=None): _ha_mock("homeassistant.helpers.event").async_track_time_change = _async_track_time_change +class HttpRegistry: + """Models HA's ``hass.http`` static-path registration.""" + + def __init__(self): + self.static_paths: list[StaticPathConfig] = [] + + async def async_register_static_paths(self, configs): + self.static_paths.extend(list(configs)) + + class EventBus: """Models HA's ``hass.bus`` event bus. @@ -697,6 +733,8 @@ def make_hass() -> tuple: hass.time_change = TimeChangeRegistry(hass) hass.services = ServiceRegistry(hass) hass.bus = EventBus() + hass.http = HttpRegistry() + hass.extra_js_urls: list[str] = [] # A valid IANA time zone (the day-rollover listener reads it to # compute "today" in HA local time). hass.config.time_zone = "UTC" diff --git a/tests/test_binary_sensors.py b/tests/test_binary_sensors.py index 2c31abf..a4a31e2 100644 --- a/tests/test_binary_sensors.py +++ b/tests/test_binary_sensors.py @@ -6,7 +6,7 @@ import datetime from conftest import wire_entry_to_registry from custom_components.nestquest import async_setup_entry -from custom_components.nestquest.children import create_child +from custom_components.nestquest.children import create_child, list_children from custom_components.nestquest.const import DOMAIN from custom_components.nestquest.dao_presence import PresenceSchedulesDao from custom_components.nestquest.materialize import materialize @@ -128,7 +128,57 @@ async def test_binary_sensor_identity_and_attributes(hass, make_entry) -> None: assert present.name == "NestQuest Ada present today" for entity in (all_done, present): assert entity.device_info.name == "NestQuest Ada" - assert entity.extra_state_attributes == { + assert all_done.extra_state_attributes == { "child_id": ada.id, "child_name": "Ada", } + assert present.extra_state_attributes == { + "child_id": ada.id, + "child_name": "Ada", + "next_present": None, + } + + +async def test_present_today_carries_next_present_when_away( + hass, make_entry +) -> None: + """A child away today carries the ISO date of their next present + day (the panel's away plate renders ``Returns , `` + from it); an always-absent pattern carries ``None``.""" + entry = await _setup_entry(hass, make_entry) + coordinator = entry.runtime_data.coordinator + database = entry.runtime_data.database + ada, _bo = await _seed_two_children(database) + await create_child(database, "Cory") + today = datetime.date.today() + pattern = ",".join( + str(day) for day in range(7) if day != today.weekday() + ) + await PresenceSchedulesDao(database).upsert_by_child( + ada.id, 1, today.isoformat(), pattern + ) + await PresenceSchedulesDao(database).upsert_by_child( + await _cory_id(database), 1, today.isoformat(), "" + ) + await coordinator.async_refresh() + + assert _binary(hass, ada.id, "present_today").is_on is False + assert _binary(hass, ada.id, "present_today").extra_state_attributes[ + "next_present" + ] == (today + datetime.timedelta(days=1)).isoformat() + + cory = [ + child for child in coordinator.data.children + if child.child_name == "Cory" + ][0] + assert _binary(hass, cory.child_id, "present_today").is_on is False + assert _binary(hass, cory.child_id, "present_today").extra_state_attributes[ + "next_present" + ] is None + + +async def _cory_id(database) -> int: + for child in await list_children(database): + if child.display_name == "Cory": + return child.id + raise AssertionError("Cory was not created") diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 745c53a..415318e 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -78,9 +78,43 @@ async def test_coordinator_snapshot_counts_and_states(hass, make_entry) -> None: assert instance.title == "Brush teeth" assert instance.window == "morning" assert ada.present is True, "no schedule means present every day" + assert ada.next_present is None, "present child has no returns date" assert snapshot.cycle_day == 0, "no schedule anywhere" +async def test_coordinator_next_present_for_away_child( + hass, make_entry +) -> None: + """A child absent today carries the ISO date of their next present + day; an always-absent pattern carries None instead.""" + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + database = entry.runtime_data.database + await create_child(database, "Ada") + ada = (await list_children(database))[0] + today = datetime.date.today() + pattern = ",".join( + str(day) for day in range(7) if day != today.weekday() + ) + await PresenceSchedulesDao(database).upsert_by_child( + ada.id, 1, today.isoformat(), pattern + ) + await create_child(database, "Bo") + bo = (await list_children(database))[1] + await PresenceSchedulesDao(database).upsert_by_child( + bo.id, 1, today.isoformat(), "" + ) + snapshot = await coordinator._async_update_data() + by_name = {child.child_name: child for child in snapshot.children} + assert by_name["Ada"].present is False + assert by_name["Ada"].next_present == ( + today + datetime.timedelta(days=1) + ).isoformat() + assert by_name["Bo"].present is False + assert by_name["Bo"].next_present is None + + async def test_coordinator_reflects_completion_and_uncompletion( hass, make_entry ) -> None: @@ -184,6 +218,37 @@ async def test_coordinator_presence_resolves_schedule_and_override( assert snapshot.children[0].present is True +async def test_coordinator_next_present_honours_future_override( + hass, make_entry +) -> None: + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + database = entry.runtime_data.database + await create_child(database, "Ada") + ada = (await list_children(database))[0] + today = datetime.date.today() + tomorrow = today + datetime.timedelta(days=1) + # Scheduled present tomorrow (single-day cycle, weekday set set below + # covers every day), but overridden absent tomorrow. + await PresenceSchedulesDao(database).upsert_by_child( + ada.id, 1, today.isoformat(), "0,1,2,3,4,5,6" + ) + from custom_components.nestquest.dao_presence import ( + PresenceOverridesDao, + ) + + await PresenceOverridesDao(database).create( + ada.id, tomorrow.isoformat(), tomorrow.isoformat(), False + ) + snapshot = await coordinator._async_update_data() + child = snapshot.children[0] + assert child.present is True + assert child.next_present != tomorrow.isoformat(), ( + "a future absent override must remove tomorrow from the preview" + ) + + async def test_coordinator_derives_missed_for_past_due_open( hass, make_entry ) -> None: diff --git a/tests/test_frontend.py b/tests/test_frontend.py new file mode 100644 index 0000000..3fc7e94 --- /dev/null +++ b/tests/test_frontend.py @@ -0,0 +1,181 @@ +"""Tests for the Lovelace card toolchain and auto-registered resource.""" +from __future__ import annotations + +import json +from pathlib import Path + +from conftest import wire_entry_to_registry + +from custom_components.nestquest import async_setup, async_setup_entry +from custom_components.nestquest.frontend import ( + BUNDLE_FILENAME, + BUNDLE_URL, + FRONTEND_URL_BASE, + WWW_DIR, + async_register_frontend, +) + +REPO_ROOT = Path(__file__).parent.parent +FRONTEND_DIR = REPO_ROOT / "frontend" +BUNDLE_PATH = WWW_DIR / BUNDLE_FILENAME +CARD_TYPES = ( + "nestquest-party-board-card", + "nestquest-quest-log-card", + "nestquest-admin-card", +) + + +def test_frontend_package_documents_vite_lit_build() -> None: + package = json.loads((FRONTEND_DIR / "package.json").read_text(encoding="utf-8")) + assert package["scripts"]["build"] == "vite build" + assert "lit" in package["dependencies"] + assert "typescript" in package["devDependencies"] + assert "vite" in package["devDependencies"] + + +def test_card_modules_exist_as_separate_sources() -> None: + src = FRONTEND_DIR / "src" + assert (src / "nestquest-cards.ts").is_file() + assert (src / "cards" / "party-board.ts").is_file() + assert (src / "cards" / "quest-log.ts").is_file() + assert (src / "cards" / "admin.ts").is_file() + entry = (src / "nestquest-cards.ts").read_text(encoding="utf-8") + assert "./cards/party-board" in entry + assert "./cards/quest-log" in entry + assert "./cards/admin" in entry + + +def test_card_sources_have_no_visual_editor() -> None: + for path in (FRONTEND_DIR / "src" / "cards").glob("*.ts"): + text = path.read_text(encoding="utf-8") + assert "getConfigElement" not in text + assert "setConfig" in text + assert "getCardSize" in text + + +def test_built_bundle_contains_all_three_cards() -> None: + assert BUNDLE_PATH.is_file() + bundle = BUNDLE_PATH.read_text(encoding="utf-8") + for card_type in CARD_TYPES: + assert card_type in bundle + + +async def test_setup_registers_static_path_and_extra_js(hass, make_entry) -> None: + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + assert len(hass.http.static_paths) == 1 + registered = hass.http.static_paths[0] + assert registered.url_path == FRONTEND_URL_BASE + assert registered.path == str(WWW_DIR) + assert hass.extra_js_urls == [BUNDLE_URL] + + +async def test_async_setup_registers_frontend_without_a_config_entry(hass) -> None: + assert await async_setup(hass, {}) is True + assert hass.extra_js_urls == [BUNDLE_URL] + assert hass.http.static_paths[0].url_path == FRONTEND_URL_BASE + + +async def test_frontend_registration_is_idempotent(hass, make_entry) -> None: + await async_register_frontend(hass) + await async_register_frontend(hass) + entry_a = wire_entry_to_registry(make_entry(entry_id="a"), hass.registry) + entry_b = wire_entry_to_registry(make_entry(entry_id="b"), hass.registry) + assert await async_setup_entry(hass, entry_a) is True + assert await async_setup_entry(hass, entry_b) is True + assert len(hass.http.static_paths) == 1 + assert hass.extra_js_urls == [BUNDLE_URL] + + +def test_readme_documents_npm_build() -> None: + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + assert "npm run build" in readme + assert "nestquest-cards.js" in readme + + +def test_readme_documents_panel_resolution_and_touchhub_dock() -> None: + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + assert "1920×1080" in readme + assert "TouchHub" in readme + assert "dock" in readme + assert "pointer-events: none" in readme + + +WOFF2_FILES = ( + "nunito-latin-400-normal.woff2", + "nunito-latin-600-normal.woff2", + "nunito-latin-700-normal.woff2", + "nunito-latin-800-normal.woff2", + "cinzel-latin-600-normal.woff2", + "cinzel-latin-700-normal.woff2", + "cinzel-latin-900-normal.woff2", + "cinzel-decorative-latin-700-normal.woff2", + "cinzel-decorative-latin-900-normal.woff2", +) +CDN_MARKERS = ( + "fonts.googleapis.com", + "fonts.gstatic.com", + "cdn.jsdelivr.net", + "unpkg.com", + "cdnjs.cloudflare.com", +) + + +def test_www_contains_required_woff2_fonts() -> None: + fonts_dir = WWW_DIR / "fonts" + for name in WOFF2_FILES: + path = fonts_dir / name + assert path.is_file(), name + assert path.read_bytes()[:4] == b"wOF2" + + +def test_font_css_declares_swap_and_local_urls() -> None: + css = (WWW_DIR / "nestquest-fonts.css").read_text(encoding="utf-8") + assert css.count("font-display: swap") == 9 + assert css.count("@font-face") == 9 + assert 'font-family: "Nunito"' in css + assert 'font-family: "Cinzel"' in css + assert 'font-family: "Cinzel Decorative"' in css + for name in WOFF2_FILES: + assert f"./fonts/{name}" in css + assert "http://" not in css + assert "https://" not in css + + +def test_token_css_copied_verbatim_from_design() -> None: + design = REPO_ROOT / "design" / "tokens" + for name in ("nestquest-panel-tokens.css", "nestquest-admin-tokens.css"): + expected = (design / name).read_bytes() + actual = (WWW_DIR / name).read_bytes() + assert actual == expected + + +def test_frontend_and_www_have_no_cdn_font_references() -> None: + roots = (FRONTEND_DIR, WWW_DIR) + for root in roots: + for path in root.rglob("*"): + if not path.is_file(): + continue + if "node_modules" in path.parts: + continue + if path.suffix.lower() in {".woff2", ".png", ".jpg"}: + continue + text = path.read_text(encoding="utf-8", errors="ignore") + for marker in CDN_MARKERS: + assert marker not in text, f"{path}: {marker}" + + +def test_readme_documents_the_touchhub_setup_runbook() -> None: + readme = " ".join( + (REPO_ROOT / "README.md").read_text(encoding="utf-8").split() + ) + for required in ("TouchHub", "kiosk"): + assert required in readme, required + assert "not in the NestQuest admin allowlist" in readme + assert "contains only the NestQuest panel card" in readme + assert "nestquest-party-board-card" in readme + assert "nestquest-quest-log-card" in readme + assert "Panel" in readme + assert "Auto-Return" in readme + assert "use_x_frame_options" not in readme + assert "clickjacking" not in readme diff --git a/tests/test_panel_cards.py b/tests/test_panel_cards.py new file mode 100644 index 0000000..bb0daad --- /dev/null +++ b/tests/test_panel_cards.py @@ -0,0 +1,160 @@ +"""Source-level tests for the kid panel cards. + +The panel cards are Lit/TypeScript modules bundled by Vite; these tests assert +the contract by reading the card sources and the built bundle, in the same +spirit as test_frontend.py. They cover the states decided in +design/PANEL-EMPTY-STATES.md and PANEL-SPEC.md, and guard the kid-facing +bundles against ever gaining a parent-only service call. +""" +from __future__ import annotations + +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent +FRONTEND_CARDS_DIR = REPO_ROOT / "frontend" / "src" / "cards" +PARTY_BOARD = FRONTEND_CARDS_DIR / "party-board.ts" +QUEST_LOG = FRONTEND_CARDS_DIR / "quest-log.ts" +BUNDLE_PATH = REPO_ROOT / "custom_components" / "nestquest" / "www" / "nestquest-cards.js" + +PANEL_SOURCES = (PARTY_BOARD, QUEST_LOG) + +# Parent-only NestQuest services. The party board and quest log are the +# kid-facing panel: they may complete a quest for the tapped child, and nothing +# else. Any of these names appearing in a panel source or bundle means an admin +# control has leaked into the kid panel. +ADMIN_ONLY_SERVICES = ( + "uncomplete_quest", + "create_quest_definition", + "update_quest_definition", + "set_quest_definition_active", + "set_presence_pattern", + "create_presence_override", + "delete_presence_override", + "export_history_csv", + "manage_child", + "regenerate", +) + +WINDOW_KEYS = ("morning", "afternoon", "evening") +WINDOW_NAMES = ("Morning", "Afternoon", "Evening") + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_party_board_renders_present_and_away_plates() -> None: + source = _read(PARTY_BOARD) + assert "nestquest-party-board-card" in source + # Present adventurers get the tappable crest plate. + assert "button.plate" in source + assert 'class="plate"' in source + assert "Home today" in source + assert "aria-label=\"Open ${plate.name}'s Quest Log\"" in source + # Away adventurers get the dashed On travels plate, not a tappable one. + assert ".plate.away" in source + assert 'class="plate away"' in source + assert "On travels" in source + assert "next_present" in source + assert "Returns ${formatDay(returnsDate" in source + + +def test_quest_log_renders_three_window_columns() -> None: + source = _read(QUEST_LOG) + for key, name in zip(WINDOW_KEYS, WINDOW_NAMES): + assert f'key: "{key}"' in source, key + assert f'name: "{name}"' in source, name + assert "grid-template-columns: repeat(3, 1fr)" in source + assert 'class="column-head"' in source + assert 'class="column-names"' in source + assert 'class="column-range"' in source + + +def test_empty_window_still_renders_its_column_head() -> None: + """A window with no quests renders the head alone (PANEL-SPEC §3.1).""" + source = _read(QUEST_LOG) + column = source[source.index("private _renderColumn(windowDef: WindowDef)") :] + column = column[: column.index("private _renderQuest(")] + # The head is emitted unconditionally, before the stack is built. + assert 'class="column-head"' in column + assert "sealed.length}/${instances.length}" in column + # There is no early return that would drop the head on an empty window. + assert "instances.length === 0" not in column + assert "return nothing" not in column + + +def test_complete_quest_is_optimistic_with_rollback() -> None: + source = _read(QUEST_LOG) + assert "_optimistic" in source + assert "new Date().toISOString()" in source + assert ".set(instance.id, stampedAt)" in source + # The overlay is applied to the rendered instance before the service answers. + assert 'instance.state = "completed"' in source + # A rejected call reverts the overlay and raises the parchment toast. + assert "reverted.delete(instance.id)" in source + assert "NestQuest could not set the seal just now. Please try again." in source + assert "_showToast(" in source + assert "await this._callCompleteQuest(instance)" in source + assert '"nestquest", "complete_quest"' in source + + +def test_idle_return_to_board() -> None: + source = _read(QUEST_LOG) + assert "idle_return_seconds" in source + assert "_armIdle" in source + assert "_clearIdle" in source + assert "this._idleSeconds() * 1000" in source + assert "board_path" in source + assert "_returnToBoard" in source + assert 'window.dispatchEvent(new Event("location-changed"))' in source + + +def test_decided_empty_and_error_states_are_present() -> None: + log = _read(QUEST_LOG) + board = _read(PARTY_BOARD) + for kind in ( + "no-adventurer", + "not-set-up", + "unreachable", + "away", + "empty-day", + "complete-day", + ): + assert f'kind: "{kind}"' in log, kind + assert "PANEL-EMPTY-STATES" in log + assert "No adventurer chosen" in log + assert "NestQuest is not set up yet" in log + assert "The records cannot be reached" in log + assert "No quests today" in log + assert 'role="status"' in log + # The board distinguishes the same setup/unreachable cases at board level. + assert "NestQuest is not set up yet" in board + assert "The records cannot be reached" in board + assert "BOARD_NOTICE_NOT_SET_UP" in board + assert "BOARD_NOTICE_UNREACHABLE" in board + + +def test_quest_complete_trigger() -> None: + source = _read(QUEST_LOG) + assert "binary_sensor.nestquest_${slug}_all_done" in source + assert "nestquest_child_day_complete" in source + assert "NESTQUEST_EVENT_TYPES" in source + assert 'class="complete-screen"' in source + assert "Quest complete" in source + assert "Returning to The Party in" in source + assert "_armCompleteTimer" in source + assert "complete_screen_seconds" in source + + +def test_panel_cards_reference_no_admin_only_services() -> None: + # Only the kid panel sources are scanned: the bundle also contains the + # sibling admin card, so its parent-only service names are expected there. + for path in PANEL_SOURCES: + text = _read(path) + for service in ADMIN_ONLY_SERVICES: + assert service not in text, f"{path}: {service}" + # The bundle is still built and carries both panel card custom elements. + assert BUNDLE_PATH.is_file() + bundle = _read(BUNDLE_PATH) + assert "nestquest-party-board-card" in bundle + assert "nestquest-quest-log-card" in bundle \ No newline at end of file