From 64aa8e99f692b401e5daae6990402f0fcb1bb8f6 Mon Sep 17 00:00:00 2001 From: Joshua Date: Sat, 26 Sep 2026 09:29:24 -0400 Subject: [PATCH] Fix premature OVERDUE on the API and the Complete tap doing nothing Overdue: the API read the host clock (the deployed container runs UTC, the household is US Eastern), so every morning quest turned overdue from ~04:00 local, and the snapshot routes built a default settings object instead of the stored one. Add an additive `timezone` setting (IANA name; "" keeps the host-local behaviour), validated through the shared settings field table and exposed on the admin settings GET/PATCH. Every API clock read (panel/admin routes, the missed-sweep scheduler) now re-expresses the one host read in that zone, and the snapshot routes load the persisted settings. Complete: with target ES2022, TypeScript's useDefineForClassFields default emitted the cards' reactive fields as native class fields that shadow Lit's accessors, so setting `_confirm` never re-rendered and the confirm dialog never opened. Set useDefineForClassFields to false (Lit's documented TypeScript setting) and rebuild the bundle. Tests pin the clock at the due-time boundary for both snapshot shapes, cover the scheduler and on-time stamp, and drive the built bundle's Complete tap through to the complete_quest service call. Co-Authored-By: Claude Opus 5.5 --- api/routes_admin.py | 54 +- api/routes_panel.py | 27 +- api/scheduler.py | 13 +- custom_components/nestquest/core/const.py | 7 + custom_components/nestquest/core/settings.py | 43 ++ .../nestquest/core/settings_store.py | 9 +- .../nestquest/www/nestquest-cards.js | 616 +++++++++--------- frontend/tests/render-quest-log.mjs | 31 +- frontend/tsconfig.json | 1 + tests/test_api_admin_settings.py | 9 +- tests/test_api_household_clock.py | 272 ++++++++ tests/test_panel_cards.py | 63 ++ 12 files changed, 792 insertions(+), 353 deletions(-) create mode 100644 tests/test_api_household_clock.py diff --git a/api/routes_admin.py b/api/routes_admin.py index b70e8eb..9f9890c 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -867,7 +867,7 @@ class AdminSettingsResponse(BaseModel): """The effective settings as the admin plane serializes them. Mirrors the core :class:`~nestquest_core.settings.NestQuestSettings` - — all TEN documented fields, resolved (every absent stored field + — all ELEVEN documented fields, resolved (every absent stored field already fell back to its default in the core). The same shape both the GET and the PATCH route answer with. """ @@ -882,6 +882,9 @@ class AdminSettingsResponse(BaseModel): afternoon_reminder_enabled: bool end_of_day_report_enabled: bool celebration_enabled: bool + #: The household IANA time zone the API reads its clock in; ``""`` + #: means the API host's local time. + timezone: str class AdminMissedSweepResponse(BaseModel): @@ -1178,6 +1181,26 @@ def _local_now() -> datetime.datetime: return datetime.datetime.now().astimezone() +async def _household_clock( + database: object, +) -> tuple[object, datetime.datetime]: + """Return the stored settings and ONE household-local clock read. + + The API host may run UTC while the household does not, so the one + :func:`_local_now` read is re-expressed in the stored settings' + ``timezone`` (:meth:`NestQuestSettings.household_now`); an unset + zone keeps the host's local time. + """ + settings = await core_settings_store.load_settings(database) + return settings, settings.household_now(_local_now()) + + +async def _household_today(database: object) -> datetime.date: + """Return the household-local date of ONE :func:`_household_clock` read.""" + _settings, now = await _household_clock(database) + return now.date() + + def _rule_from_request(rule: AdminRuleRequest) -> core_recurrence.ScheduleRule: """Build the core ScheduleRule from the request's ``rule`` object. @@ -1496,7 +1519,7 @@ async def admin_create_quest_definition( response_model=AdminOccurrencesPreviewResponse, ) async def admin_preview_occurrences( - body: AdminOccurrencesPreviewRequest, + body: AdminOccurrencesPreviewRequest, request: Request ) -> AdminOccurrencesPreviewResponse: """Return the first ``count`` dates the supplied rule fires on. @@ -1509,12 +1532,15 @@ async def admin_preview_occurrences( call over the bounded window ``[start_date, start_date + 366 days]`` — ``start_date`` defaulting to ONE :func:`_local_now` clock read — truncated to ``count``. Every core ``ValueError`` is 422 - (:func:`_raise_preview_error`). No database access. + (:func:`_raise_preview_error`). The only database access is the + settings read behind the household clock. """ start_date = ( body.start_date if body.start_date is not None - else _local_now().date().isoformat() + else ( + await _household_today(request.app.state.db.database) + ).isoformat() ) try: rule = _rule_from_request(body.rule) @@ -1839,7 +1865,7 @@ async def admin_presence_override_consequence( body.start_date, body.end_date, body.is_present, - today=_local_now().date(), + today=await _household_today(state.database), ) ) except ValueError as error: @@ -1926,7 +1952,7 @@ async def admin_uncomplete_instance( """ state: DatabaseState = request.app.state.db database = state.database - now = _local_now() + _settings, now = await _household_clock(database) sub = claims.get("sub") try: result = await core_completion.uncomplete_instance( @@ -1992,7 +2018,7 @@ async def admin_regenerate( """ state: DatabaseState = request.app.state.db database = state.database - today = _local_now().date() + today = await _household_today(state.database) if body.scope == "household": end_date = today + datetime.timedelta( @@ -2077,7 +2103,7 @@ def _settings_response( """Serialize one core NestQuestSettings into the documented payload. Explicit field-by-field (not ``dataclasses.asdict``) so the payload - stays pinned to the ten documented fields even if the core + stays pinned to the eleven documented fields even if the core dataclass later grows another one. """ return AdminSettingsResponse( @@ -2091,6 +2117,7 @@ def _settings_response( afternoon_reminder_enabled=settings.afternoon_reminder_enabled, end_of_day_report_enabled=settings.end_of_day_report_enabled, celebration_enabled=settings.celebration_enabled, + timezone=settings.timezone, ) @@ -2122,7 +2149,7 @@ async def admin_history_query( ``ValueError`` is 422 (:func:`_raise_history_error`). """ state: DatabaseState = request.app.state.db - today = _local_now().date() + today = await _household_today(state.database) try: rows = await core_history.query_history( state.database, filter, start, end, today=today @@ -2165,7 +2192,7 @@ async def admin_history_csv( JSON route's (:func:`_raise_history_error`). """ state: DatabaseState = request.app.state.db - today = _local_now().date() + today = await _household_today(state.database) try: csv_text = await core_history.export_history_csv( state.database, filter, start, end, today=today @@ -2188,7 +2215,7 @@ async def admin_history_csv( response_model=AdminSettingsResponse, ) async def admin_get_settings(request: Request) -> AdminSettingsResponse: - """Return the current effective settings (all ten documented fields). + """Return the current effective settings (all eleven documented fields). Requires a valid admin JWT (the router's shared :func:`~api.auth.require_admin` dependency — the ONE check; this @@ -2280,7 +2307,7 @@ async def admin_run_missed_sweep( """ state: DatabaseState = request.app.state.db database = state.database - today = _local_now().date() + today = await _household_today(state.database) try: events = await core_sweep.run_missed_sweep(database, today=today) except ValueError as error: @@ -2309,8 +2336,9 @@ async def admin_snapshot(request: Request) -> AdminSnapshotResponse: :class:`AdminSnapshotResponse`. """ state: DatabaseState = request.app.state.db + settings, now = await _household_clock(state.database) snapshot = await core_snapshot.build_snapshot( - state.database, core_settings.NestQuestSettings(), _local_now() + state.database, settings, now ) return AdminSnapshotResponse( today_iso=snapshot.today_iso, diff --git a/api/routes_panel.py b/api/routes_panel.py index 6405384..200c431 100644 --- a/api/routes_panel.py +++ b/api/routes_panel.py @@ -8,9 +8,9 @@ this router inherits the check). The snapshot route is a thin adapter over the bundled core: it performs NO business logic. It reads the live database off ``app.state.db``, -resolves ONE timezone-aware ``now`` (the API host's local time — the -API is the household's "local" host now that the domain no longer lives -inside Home Assistant), calls the pure +resolves ONE timezone-aware ``now`` (the host clock re-expressed in the +stored household ``timezone`` setting — the API host may run UTC while +the household does not), calls the pure :func:`nestquest_core.snapshot.build_snapshot`, shapes each child's instances through :func:`nestquest_core.snapshot.instance_payload` with ``include_missed=False`` (D-009: missed instances are OMITTED from the @@ -63,7 +63,7 @@ from api.dependencies import require_panel_token from api.nestquest_core import ( core_completion, core_events, - core_settings, + core_settings_store, core_snapshot, ) from api import transitions as api_transitions @@ -178,6 +178,20 @@ def _local_now() -> datetime.datetime: return datetime.datetime.now().astimezone() +async def _household_clock( + database: object, +) -> tuple[object, datetime.datetime]: + """Return the stored settings and ONE household-local clock read. + + The API host may run UTC while the household does not, so the one + :func:`_local_now` read is re-expressed in the stored settings' + ``timezone`` (:meth:`NestQuestSettings.household_now`); an unset + zone keeps the host's local time. + """ + settings = await core_settings_store.load_settings(database) + return settings, settings.household_now(_local_now()) + + @router.get( "/snapshot", summary="Today's household snapshot", @@ -196,8 +210,9 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse: :class:`PanelSnapshotResponse`. """ state: DatabaseState = request.app.state.db + settings, now = await _household_clock(state.database) snapshot = await core_snapshot.build_snapshot( - state.database, core_settings.NestQuestSettings(), _local_now() + state.database, settings, now ) return PanelSnapshotResponse( today_iso=snapshot.today_iso, @@ -282,7 +297,7 @@ async def panel_complete_instance( detail="Quest instance not found for this child", ) - now = _local_now() + _settings, now = await _household_clock(database) try: result = await core_completion.complete_instance( database, diff --git a/api/scheduler.py b/api/scheduler.py index 32dcc07..092447b 100644 --- a/api/scheduler.py +++ b/api/scheduler.py @@ -8,7 +8,8 @@ configured ``day_rollover_time`` — read fresh from the settings store at the top of EVERY cycle, so an admin's settings change applies on the next scheduling decision without a restart — then runs the HA-free core's :func:`nestquest_core.sweep.run_missed_sweep` for the -API host's local date and publishes each returned +household-local date (the stored ``timezone`` setting; the API host's +local date when unset) and publishes each returned ``nestquest_quest_missed`` event on the app's ONE transition publisher (the same SSE stream every route publishes through). @@ -130,7 +131,11 @@ class MissedSweepScheduler: ) await self._sleep(_RETRY_BACKOFF_SECONDS) continue - delay = _delay_seconds(rollover, self._clock()) + # The host may run UTC: the rollover is a household wall-clock + # time, so the clock read is re-expressed in the stored zone. + delay = _delay_seconds( + rollover, settings.household_now(self._clock()) + ) LOGGER.info( "NestQuest missed sweep scheduled for %s (%.0fs from now)", settings.day_rollover_time, @@ -138,7 +143,9 @@ class MissedSweepScheduler: ) await self._sleep(delay) try: - await self._run_once(self._clock().date()) + await self._run_once( + settings.household_now(self._clock()).date() + ) except Exception: # Not silent either: the sweep error is logged and the # loop backs off before the next cycle, whose watermark diff --git a/custom_components/nestquest/core/const.py b/custom_components/nestquest/core/const.py index d8aba4b..5074190 100644 --- a/custom_components/nestquest/core/const.py +++ b/custom_components/nestquest/core/const.py @@ -112,6 +112,13 @@ CONF_AFTERNOON_REMINDER_ENABLED = "afternoon_reminder_enabled" CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled" CONF_CELEBRATION_ENABLED = "celebration_enabled" DEFAULT_AUTOMATION_ENABLED = True +# The household's IANA time zone (e.g. ``America/New_York``) the API +# service reads its clock in. The empty string keeps the historical +# behaviour — the API host's own local time — so an existing install is +# unchanged until the owner sets it. The integration itself always uses +# Home Assistant's configured ``time_zone`` and ignores this field. +CONF_TIMEZONE = "timezone" +DEFAULT_TIMEZONE = "" # Panel-plane API client (Feature 18): the integration reaches the # NestQuest API service (Feature 16) over HTTP instead of the database. diff --git a/custom_components/nestquest/core/settings.py b/custom_components/nestquest/core/settings.py index c432335..1fd57a5 100644 --- a/custom_components/nestquest/core/settings.py +++ b/custom_components/nestquest/core/settings.py @@ -35,6 +35,7 @@ from collections.abc import Callable from dataclasses import dataclass from types import MappingProxyType from typing import Any, Mapping +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from .const import ( CONF_AFTERNOON_REMINDER_ENABLED, @@ -47,12 +48,14 @@ from .const import ( CONF_MORNING_SUMMARY_ENABLED, CONF_MORNING_SUMMARY_TIME, CONF_NOTIFY_TARGET, + CONF_TIMEZONE, DEFAULT_AFTERNOON_REMINDER_TIME, DEFAULT_AUTOMATION_ENABLED, DEFAULT_DAY_ROLLOVER_TIME, DEFAULT_END_OF_DAY_REPORT_TIME, DEFAULT_HORIZON_DAYS, DEFAULT_MORNING_SUMMARY_TIME, + DEFAULT_TIMEZONE, LOGGER, TIME_PATTERN, ) @@ -153,6 +156,29 @@ def _resolve_optional_str(value: Any) -> str: return value.strip() +def _resolve_timezone(value: Any) -> str: + """Validate a household time zone and return it. + + The empty string means "the API host's local time" (the historical + behaviour); anything else must be an IANA key :class:`ZoneInfo` + resolves (e.g. ``America/New_York``). A non-str (including + ``None``) is rejected; the ``None``-as-absent mapping lives ONLY in + :meth:`NestQuestSettings.from_options` (via :func:`_option_value`). + """ + if not isinstance(value, str): + raise ValueError(f"timezone must be a string, got {value!r}") + if value == "": + return value + try: + ZoneInfo(value) + except (ZoneInfoNotFoundError, ValueError) as error: + raise ValueError( + f"timezone must be an IANA time zone name such as " + f"'America/New_York', got {value!r}" + ) from error + return value + + #: The single field table the two builders share. Each row is #: ``(field_name, CONF_* key, default, validator)``. ``from_options`` #: applies every validator (raising on the first invalid value) while @@ -214,6 +240,7 @@ _FIELDS: tuple[tuple[str, str, Any, Callable[[Any], Any]], ...] = ( DEFAULT_AUTOMATION_ENABLED, _resolve_bool, ), + ("timezone", CONF_TIMEZONE, DEFAULT_TIMEZONE, _resolve_timezone), ) @@ -243,6 +270,7 @@ class NestQuestSettings: afternoon_reminder_enabled: bool = DEFAULT_AUTOMATION_ENABLED end_of_day_report_enabled: bool = DEFAULT_AUTOMATION_ENABLED celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED + timezone: str = DEFAULT_TIMEZONE def __post_init__(self) -> None: # Validate STRICTLY at construction so a hand-built instance @@ -281,6 +309,21 @@ class NestQuestSettings: raise ValueError( f"{name} must be a boolean, got {value!r}" ) + _resolve_timezone(self.timezone) + + def household_now(self, instant: datetime.datetime) -> datetime.datetime: + """Return the aware ``instant`` expressed in the household's zone. + + The API service has no Home Assistant ``time_zone``, so its one + clock read (an aware host-local ``now``) is converted here: with + :attr:`timezone` set the SAME instant is re-expressed in that + zone, so ``.date()`` and ``.time()`` are the household's wall + clock even when the host runs UTC. With :attr:`timezone` empty + the instant is returned unchanged (the host's local time). + """ + if not self.timezone: + return instant + return instant.astimezone(ZoneInfo(self.timezone)) @property def day_rollover_hour_minute(self) -> tuple[int, int]: diff --git a/custom_components/nestquest/core/settings_store.py b/custom_components/nestquest/core/settings_store.py index 9f9c91f..319b3d8 100644 --- a/custom_components/nestquest/core/settings_store.py +++ b/custom_components/nestquest/core/settings_store.py @@ -18,7 +18,7 @@ API client; the two are deliberately NOT unified here, and neither side reads the other's values. STORED SERIALIZATION — the document at ``SETTINGS_META_KEY`` is a JSON -object holding exactly the TEN settings fields, keyed by field name +object holding exactly the ELEVEN settings fields, keyed by field name (which equals the ``CONF_*`` option key for every field), complete after the first update: @@ -30,7 +30,10 @@ after the first update: core's normalizer), - ``morning_summary_enabled``, ``afternoon_reminder_enabled``, ``end_of_day_report_enabled``, ``celebration_enabled`` — real - booleans. + booleans, +- ``timezone`` — ``""`` (the API host's local time) or an IANA time + zone name such as ``America/New_York``. A document stored before + this field existed simply lacks it and resolves to ``""``. Only values whose core validators ACCEPTED are ever written, so a stored document always round-trips through the core's option builders. @@ -172,7 +175,7 @@ async def update_settings( update can never leave a half-applied document behind. The validated changes are merged onto the current effective - settings and the merged (complete, ten-field) document is persisted + settings and the merged (complete, eleven-field) document is persisted in one atomic upsert; the updated settings are returned. The whole read → merge → write span runs inside the per-database settings lock, so concurrent updates queue instead of interleaving. diff --git a/custom_components/nestquest/www/nestquest-cards.js b/custom_components/nestquest/www/nestquest-cards.js index e5452d7..453bd1a 100644 --- a/custom_components/nestquest/www/nestquest-cards.js +++ b/custom_components/nestquest/www/nestquest-cards.js @@ -1,15 +1,15 @@ -const B = globalThis, nt = B.ShadowRoot && (B.ShadyCSS === void 0 || B.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, st = /* @__PURE__ */ Symbol(), lt = /* @__PURE__ */ new WeakMap(); -let Pt = class { +const B = globalThis, lt = B.ShadowRoot && (B.ShadyCSS === void 0 || B.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, ct = /* @__PURE__ */ Symbol(), ft = /* @__PURE__ */ new WeakMap(); +let Dt = class { constructor(t, e, n) { - if (this._$cssResult$ = !0, n !== st) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); + if (this._$cssResult$ = !0, n !== ct) 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) { + if (lt && 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)); + n && (t = ft.get(e)), t === void 0 && ((this.o = t = new CSSStyleSheet()).replaceSync(this.cssText), n && ft.set(e, t)); } return t; } @@ -17,28 +17,28 @@ let Pt = class { return this.cssText; } }; -const x = (s) => new Pt(typeof s == "string" ? s : s + "", void 0, st), It = (s, ...t) => { +const x = (s) => new Dt(typeof s == "string" ? s : s + "", void 0, ct), Ht = (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 Pt(e, s, st); -}, Qt = (s, t) => { - if (nt) s.adoptedStyleSheets = t.map((e) => e instanceof CSSStyleSheet ? e : e.styleSheet); + return new Dt(e, s, ct); +}, Jt = (s, t) => { + if (lt) 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) => { +}, mt = lt ? (s) => s : (s) => s instanceof CSSStyleSheet ? ((t) => { let e = ""; for (const n of t.cssRules) e += n.cssText; return x(e); })(s) : s; -const { is: Zt, defineProperty: Wt, getOwnPropertyDescriptor: Gt, getOwnPropertyNames: Vt, getOwnPropertySymbols: Kt, getPrototypeOf: Jt } = Object, Z = globalThis, dt = Z.trustedTypes, Xt = dt ? dt.emptyScript : "", Yt = Z.reactiveElementPolyfillSupport, I = (s, t) => s, et = { toAttribute(s, t) { +const { is: Xt, defineProperty: Yt, getOwnPropertyDescriptor: te, getOwnPropertyNames: ee, getOwnPropertySymbols: ne, getPrototypeOf: se } = Object, V = globalThis, gt = V.trustedTypes, ie = gt ? gt.emptyScript : "", re = V.reactiveElementPolyfillSupport, I = (s, t) => s, it = { toAttribute(s, t) { switch (t) { case Boolean: - s = s ? Xt : null; + s = s ? ie : null; break; case Object: case Array: @@ -63,23 +63,23 @@ const { is: Zt, defineProperty: Wt, getOwnPropertyDescriptor: Gt, getOwnProperty } } return e; -} }, Ut = (s, t) => !Zt(s, t), pt = { attribute: !0, type: String, converter: et, reflect: !1, useDefault: !1, hasChanged: Ut }; -Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), Z.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); -let N = class extends HTMLElement { +} }, Bt = (s, t) => !Xt(s, t), _t = { attribute: !0, type: String, converter: it, reflect: !1, useDefault: !1, hasChanged: Bt }; +Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), V.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) { + static createProperty(t, e = _t) { 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 && Wt(this.prototype, t, i); + i !== void 0 && Yt(this.prototype, t, i); } } static getPropertyDescriptor(t, e, n) { - const { get: i, set: r } = Gt(this.prototype, t) ?? { get() { + const { get: i, set: r } = te(this.prototype, t) ?? { get() { return this[e]; }, set(o) { this[e] = o; @@ -90,17 +90,17 @@ let N = class extends HTMLElement { }, configurable: !0, enumerable: !0 }; } static getPropertyOptions(t) { - return this.elementProperties.get(t) ?? pt; + return this.elementProperties.get(t) ?? _t; } static _$Ei() { if (this.hasOwnProperty(I("elementProperties"))) return; - const t = Jt(this); + const t = se(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 = [...Vt(e), ...Kt(e)]; + const e = this.properties, n = [...ee(e), ...ne(e)]; for (const i of n) this.createProperty(i, e[i]); } const t = this[Symbol.metadata]; @@ -119,8 +119,8 @@ let N = class extends HTMLElement { 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)); + for (const i of n) e.unshift(mt(i)); + } else t !== void 0 && e.push(mt(t)); return e; } static _$Eu(t, e) { @@ -146,7 +146,7 @@ let N = class extends HTMLElement { } createRenderRoot() { const t = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions); - return Qt(t, this.constructor.elementStyles), t; + return Jt(t, this.constructor.elementStyles), t; } connectedCallback() { this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(!0), this._$EO?.forEach((t) => t.hostConnected?.()); @@ -162,14 +162,14 @@ let N = class extends HTMLElement { _$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); + const r = (n.converter?.toAttribute !== void 0 ? n.converter : it).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; + const r = n.getPropertyOptions(i), o = typeof r.converter == "function" ? { fromAttribute: r.converter } : r.converter?.fromAttribute !== void 0 ? r.converter : it; this._$Em = i; const l = o.fromAttribute(e, r.type); this[i] = l ?? this._$Ej?.get(i) ?? l, this._$Em = null; @@ -178,7 +178,7 @@ let N = class extends HTMLElement { 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 ?? Ut)(r, e) || n.useDefault && n.reflect && r === this._$Ej?.get(t) && !this.hasAttribute(o._$Eu(t, n)))) return; + if (i === !1 && (r = this[t]), n ??= o.getPropertyOptions(t), !((n.hasChanged ?? Bt)(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()); @@ -246,51 +246,51 @@ let N = class extends HTMLElement { firstUpdated(t) { } }; -N.elementStyles = [], N.shadowRootOptions = { mode: "open" }, N[I("elementProperties")] = /* @__PURE__ */ new Map(), N[I("finalized")] = /* @__PURE__ */ new Map(), Yt?.({ ReactiveElement: N }), (Z.reactiveElementVersions ??= []).push("2.1.2"); -const it = globalThis, ht = (s) => s, F = it.trustedTypes, ut = F ? F.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, Lt = "$lit$", $ = `lit$${Math.random().toFixed(9).slice(2)}$`, Rt = "?" + $, te = `<${Rt}>`, S = document, U = () => S.createComment(""), L = (s) => s === null || typeof s != "object" && typeof s != "function", rt = Array.isArray, ee = (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, jt = /^(?:script|style|textarea|title)$/i, ne = (s) => (t, ...e) => ({ _$litType$: s, strings: t, values: e }), d = ne(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; +E.elementStyles = [], E.shadowRootOptions = { mode: "open" }, E[I("elementProperties")] = /* @__PURE__ */ new Map(), E[I("finalized")] = /* @__PURE__ */ new Map(), re?.({ ReactiveElement: E }), (V.reactiveElementVersions ??= []).push("2.1.2"); +const dt = globalThis, yt = (s) => s, F = dt.trustedTypes, bt = F ? F.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, Ft = "$lit$", $ = `lit$${Math.random().toFixed(9).slice(2)}$`, Zt = "?" + $, oe = `<${Zt}>`, S = document, U = () => S.createComment(""), R = (s) => s === null || typeof s != "object" && typeof s != "function", pt = Array.isArray, ae = (s) => pt(s) || typeof s?.[Symbol.iterator] == "function", X = `[ +\f\r]`, z = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, vt = /-->/g, xt = />/g, q = RegExp(`>|${X}(?:([^\\s"'>=/]+)(${X}*=${X}*(?:[^ +\f\r"'\`<>=]|("|')|))|$)`, "g"), wt = /'/g, $t = /"/g, Qt = /^(?:script|style|textarea|title)$/i, le = (s) => (t, ...e) => ({ _$litType$: s, strings: t, values: e }), d = le(1), T = /* @__PURE__ */ Symbol.for("lit-noChange"), g = /* @__PURE__ */ Symbol.for("lit-nothing"), qt = /* @__PURE__ */ new WeakMap(), A = S.createTreeWalker(S, 129); +function Wt(s, t) { + if (!pt(s) || !s.hasOwnProperty("raw")) throw Error("invalid template strings array"); + return bt !== void 0 ? bt.createHTML(t) : t; } -const se = (s, t) => { +const ce = (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 ? (jt.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); + for (; f < a.length && (o.lastIndex = f, u = o.exec(a), u !== null); ) f = o.lastIndex, o === z ? u[1] === "!--" ? o = vt : u[1] !== void 0 ? o = xt : u[2] !== void 0 ? (Qt.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 : wt) : o === $t || o === wt ? o = q : o === vt || o === xt ? o = z : (o = q, i = void 0); const h = o === q && s[l + 1].startsWith("/>") ? " " : ""; - r += o === z ? a + te : c >= 0 ? (n.push(p), a.slice(0, c) + Lt + a.slice(c) + $ + h) : a + $ + (c === -2 ? l : h); + r += o === z ? a + oe : c >= 0 ? (n.push(p), a.slice(0, c) + Ft + a.slice(c) + $ + h) : a + $ + (c === -2 ? l : h); } - return [Dt(s, r + (s[e] || "") + (t === 2 ? "" : t === 3 ? "" : "")), n]; + return [Wt(s, r + (s[e] || "") + (t === 2 ? "" : t === 3 ? "" : "")), n]; }; -class R { +class L { 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] = se(t, e); - if (this.el = R.createElement(p, n), C.currentNode = this.el.content, e === 2 || e === 3) { + const l = t.length - 1, a = this.parts, [p, u] = ce(t, e); + if (this.el = L.createElement(p, n), A.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; ) { + for (; (i = A.nextNode()) !== null && a.length < l; ) { if (i.nodeType === 1) { - if (i.hasAttributes()) for (const c of i.getAttributeNames()) if (c.endsWith(Lt)) { + if (i.hasAttributes()) for (const c of i.getAttributeNames()) if (c.endsWith(Ft)) { 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] === "." ? re : m[1] === "?" ? oe : m[1] === "@" ? ae : W }), i.removeAttribute(c); + a.push({ type: 1, index: r, name: m[2], strings: h, ctor: m[1] === "." ? pe : m[1] === "?" ? he : m[1] === "@" ? ue : K }), i.removeAttribute(c); } else c.startsWith($) && (a.push({ type: 6, index: r }), i.removeAttribute(c)); - if (jt.test(i.tagName)) { + if (Qt.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], U()), C.nextNode(), a.push({ type: 2, index: ++r }); + for (let h = 0; h < f; h++) i.append(c[h], U()), A.nextNode(), a.push({ type: 2, index: ++r }); i.append(c[f], U()); } } - } else if (i.nodeType === 8) if (i.data === Rt) a.push({ type: 2, index: r }); + } else if (i.nodeType === 8) if (i.data === Zt) 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; @@ -303,13 +303,13 @@ class R { return n.innerHTML = t, n; } } -function E(s, t, e = s, 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 = L(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 = E(s, i._$AS(s, t.values), i, n)), t; + const r = R(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 ie { +class de { constructor(t, e) { this._$AV = [], this._$AN = void 0, this._$AD = t, this._$AM = e; } @@ -321,16 +321,16 @@ class ie { } 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]; + A.currentNode = i; + let r = A.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 le(r, this, t)), this._$AV.push(p), a = n[++l]; + a.type === 2 ? p = new O(r, r.nextSibling, this, t) : a.type === 1 ? p = new a.ctor(r, a.name, a.strings, this, t) : a.type === 6 && (p = new fe(r, this, t)), this._$AV.push(p), a = n[++l]; } - o !== a?.index && (r = C.nextNode(), o++); + o !== a?.index && (r = A.nextNode(), o++); } - return C.currentNode = S, i; + return A.currentNode = S, i; } p(t) { let e = 0; @@ -356,7 +356,7 @@ class O { return this._$AB; } _$AI(t, e = this) { - t = E(this, t, e), L(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) : ee(t) ? this.k(t) : this._(t); + t = N(this, t, e), R(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) : ae(t) ? this.k(t) : this._(t); } O(t) { return this._$AA.parentNode.insertBefore(t, this._$AB); @@ -365,22 +365,22 @@ class O { this._$AH !== t && (this._$AR(), this._$AH = this.O(t)); } _(t) { - this._$AH !== g && L(this._$AH) ? this._$AA.nextSibling.data = t : this.T(S.createTextNode(t)), this._$AH = t; + this._$AH !== g && R(this._$AH) ? this._$AA.nextSibling.data = t : this.T(S.createTextNode(t)), this._$AH = t; } $(t) { - const { values: e, _$litType$: n } = t, i = typeof n == "number" ? this._$AC(t) : (n.el === void 0 && (n.el = R.createElement(Dt(n.h, n.h[0]), this.options)), n); + const { values: e, _$litType$: n } = t, i = typeof n == "number" ? this._$AC(t) : (n.el === void 0 && (n.el = L.createElement(Wt(n.h, n.h[0]), this.options)), n); if (this._$AH?._$AD === i) this._$AH.p(e); else { - const r = new ie(i, this), o = r.u(this.options); + const r = new de(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; + let e = qt.get(t.strings); + return e === void 0 && qt.set(t.strings, e = new L(t)), e; } k(t) { - rt(this._$AH) || (this._$AH = [], this._$AR()); + pt(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(U()), this.O(U()), this, this.options)) : n = e[i], n._$AI(r), i++; @@ -388,15 +388,15 @@ class O { } _$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; + const n = yt(t).nextSibling; + yt(t).remove(), t = n; } } setConnected(t) { this._$AM === void 0 && (this._$Cv = t, this._$AP?.(t)); } } -class W { +class K { get tagName() { return this.element.tagName; } @@ -409,11 +409,11 @@ class W { _$AI(t, e = this, n, i) { const r = this.strings; let o = !1; - if (r === void 0) t = E(this, t, e, 0), o = !L(t) || t !== this._$AH && t !== T, o && (this._$AH = t); + if (r === void 0) t = N(this, t, e, 0), o = !R(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 = E(this, l[n + a], e, a), p === T && (p = this._$AH[a]), o ||= !L(p) || p !== this._$AH[a], p === g ? t = g : t !== g && (t += (p ?? "") + r[a + 1]), this._$AH[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 ||= !R(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); } @@ -421,7 +421,7 @@ class W { t === g ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t ?? ""); } } -class re extends W { +class pe extends K { constructor() { super(...arguments), this.type = 3; } @@ -429,7 +429,7 @@ class re extends W { this.element[this.name] = t === g ? void 0 : t; } } -class oe extends W { +class he extends K { constructor() { super(...arguments), this.type = 4; } @@ -437,12 +437,12 @@ class oe extends W { this.element.toggleAttribute(this.name, !!t && t !== g); } } -class ae extends W { +class ue extends K { constructor(t, e, n, i, r) { super(t, e, n, i, r), this.type = 5; } _$AI(t, e = this) { - if ((t = E(this, t, e, 0) ?? g) === T) return; + 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; } @@ -450,7 +450,7 @@ class ae extends W { typeof this._$AH == "function" ? this._$AH.call(this.options?.host ?? this.element, t) : this._$AH.handleEvent(t); } } -class le { +class fe { constructor(t, e, n) { this.element = t, this.type = 6, this._$AN = void 0, this._$AM = e, this.options = n; } @@ -458,12 +458,12 @@ class le { return this._$AM._$AU; } _$AI(t) { - E(this, t); + N(this, t); } } -const ce = { I: O }, de = it.litHtmlPolyfillSupport; -de?.(R, O), (it.litHtmlVersions ??= []).push("3.3.3"); -const pe = (s, t, e) => { +const me = { I: O }, ge = dt.litHtmlPolyfillSupport; +ge?.(L, O), (dt.litHtmlVersions ??= []).push("3.3.3"); +const _e = (s, t, e) => { const n = e?.renderBefore ?? t; let i = n._$litPart$; if (i === void 0) { @@ -472,8 +472,8 @@ const pe = (s, t, e) => { } return i._$AI(s), i; }; -const ot = globalThis; -let A = class extends N { +const ht = globalThis; +let C = class extends E { constructor() { super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0; } @@ -483,7 +483,7 @@ let A = class extends N { } update(t) { const e = this.render(); - this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t), this._$Do = pe(e, this.renderRoot, this.renderOptions); + this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t), this._$Do = _e(e, this.renderRoot, this.renderOptions); } connectedCallback() { super.connectedCallback(), this._$Do?.setConnected(!0); @@ -495,22 +495,22 @@ let A = class extends N { return T; } }; -A._$litElement$ = !0, A.finalized = !0, ot.litElementHydrateSupport?.({ LitElement: A }); -const he = ot.litElementPolyfillSupport; -he?.({ LitElement: A }); -(ot.litElementVersions ??= []).push("4.2.2"); -const Ht = ':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) { +C._$litElement$ = !0, C.finalized = !0, ht.litElementHydrateSupport?.({ LitElement: C }); +const ye = ht.litElementPolyfillSupport; +ye?.({ LitElement: C }); +(ht.litElementVersions ??= []).push("4.2.2"); +const Gt = ':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 ut(s) { window.customCards = window.customCards ?? [], window.customCards.push(s); } -const ue = [ +const be = [ "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); +], kt = /* @__PURE__ */ new Map(); +function Z(s, t) { + const e = `${s ?? "local"}|${JSON.stringify(t)}`, n = kt.get(e); if (n) return n; let i; @@ -522,26 +522,26 @@ function Q(s, t) { } catch { i = new Intl.DateTimeFormat("en-US", t); } - return bt.set(e, i), i; + return kt.set(e, i), i; } -function fe(s) { +function ve(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 Y(s, t) { + return `${Z(t, { weekday: "long" }).format(s)}, ${Z(t, { month: "short", day: "numeric" }).format(s)}`; } -function me(s, t) { - return Q(t, { +function xe(s, t) { + return Z(t, { hour: "numeric", minute: "2-digit", hour12: !0 }).format(s); } -const ge = { +const we = { "clear-night": "Clear", cloudy: "Cloudy", exceptional: "Clear", @@ -557,7 +557,7 @@ const ge = { sunny: "Sunny", windy: "Windy", "windy-variant": "Windy" -}, _e = { +}, $e = { "clear-night": "Clear night skies", cloudy: "Grey skies today", exceptional: "A striking day", @@ -574,32 +574,32 @@ const ge = { windy: "A blustery day", "windy-variant": "A blustery day" }; -function vt(s) { +function At(s) { return typeof s == "string" ? s.trim() : ""; } -function J(s, t) { +function tt(s, t) { if (s == null || s === "") return t; const e = typeof s == "number" ? s : Number(s); return Number.isFinite(e) ? e : t; } -function ye(s) { +function qe(s) { if (s === null || typeof s != "object" || Array.isArray(s)) return ""; const t = s.slug; return typeof t == "string" ? t.trim() : ""; } -function xt(s) { +function Ct(s) { return Number.isFinite(s) ? Math.min(100, Math.max(0, s)) : 0; } -function wt(s) { +function St(s) { return Math.max(0, Math.round(s)); } -function be(s) { +function ke(s) { return s.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); } -function ve(s, t) { - const e = Q(t, { +function Ae(s, t) { + const e = Z(t, { year: "numeric", month: "2-digit", day: "2-digit", @@ -620,7 +620,7 @@ function ve(s, t) { ); return Number.isFinite(i) ? i - s : 0; } -function xe(s, t) { +function Ce(s, t) { if (typeof s != "string") return null; const e = s.split("-"); @@ -633,17 +633,17 @@ function xe(s, t) { const a = new Date(n, i - 1, r); return Number.isNaN(a.getTime()) ? null : a; } - const o = Date.UTC(n, i - 1, r), l = new Date(o - ve(o, t)); + const o = Date.UTC(n, i - 1, r), l = new Date(o - Ae(o, t)); return Number.isNaN(l.getTime()) ? null : l; } -function we(s) { - const t = ge[s]; +function Se(s) { + const t = we[s]; return t || s.charAt(0).toUpperCase() + s.slice(1); } -function $e(s, t) { - return _e[s] ?? t; +function Te(s, t) { + return $e[s] ?? t; } -const $t = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", qt = "M50 3 44.5 24.1 50 26 55.5 24.1Z M100 50 75.9 44.5 74 50 75.9 55.5Z M50 97 55.5 75.9 50 74 44.5 75.9Z M0 50 24.1 55.5 26 50 24.1 44.5Z M29.1 29.1 29.7 36.3 36.3 29.7Z M70.9 29.1 63.7 29.7 70.3 36.3Z M70.9 70.9 70.3 63.7 63.7 70.3Z M29.1 70.9 36.3 70.3 29.7 63.7Z", qe = "M50 26.5 70.4 38.2 70.4 61.8 50 73.5 29.6 61.8 29.6 38.2Z", ke = "M29.6 38.2H70.4M29.6 61.8H70.4M50 26.5 36.5 38.2M50 26.5 63.5 38.2M50 73.5 36.5 61.8M50 73.5 63.5 61.8M36.5 38.2V61.8M63.5 38.2V61.8M29.6 38.2 36.5 61.8M36.5 38.2 29.6 61.8M70.4 38.2 63.5 61.8M63.5 38.2 70.4 61.8", Ce = d``, Ae = It` +`, Oe = Ht` * { box-sizing: border-box; } @@ -850,7 +850,7 @@ const $t = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", qt = "M50 3 44.5 2 width: 188px; height: 214px; padding: 4px; - clip-path: ${x($t)}; + clip-path: ${x(Tt)}; background: rgba(255, 255, 255, 0.22); } @@ -861,7 +861,7 @@ const $t = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", qt = "M50 3 44.5 2 align-items: center; justify-content: center; padding-bottom: 70px; - clip-path: ${x($t)}; + clip-path: ${x(Tt)}; background: var(--nq-p-crest); } @@ -1080,7 +1080,7 @@ const $t = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", qt = "M50 3 44.5 2 font-weight: 800; line-height: 1; } -`, Se = d` -`, Te = d``, Pe = d` -`, Ne = d``, Ie = d` -`, Ee = d``, Ue = d` -`, Me = d``, Re = d` -`, kt = { - icon: Ee, +`, Nt = { + icon: Ue, headline: "NestQuest is not set up yet", body: "A parent needs to finish setting up NestQuest before the party can gather." -}, Oe = { - icon: Me, +}, Le = { + icon: Re, headline: "The records cannot be reached", body: "The party's records are quiet right now. NestQuest will return shortly." -}, ze = d` -`; -class Pe extends A { - static properties = { - hass: { attribute: !1 }, - _config: { state: !0 }, - _now: { state: !0 } - }; - static styles = [x(Ht), Ae]; - hass; - _config; - _now = /* @__PURE__ */ new Date(); - _clockTimer; - _unsubs = []; - _subscribed = !1; - _subGeneration = 0; - _retryTimer; +`, Q = class Q extends C { + constructor() { + super(...arguments), this._now = /* @__PURE__ */ new Date(), this._unsubs = [], this._subscribed = !1, this._subGeneration = 0; + } setConfig(t) { if (!t || typeof t != "object") throw new Error("Invalid configuration"); @@ -1198,7 +1186,7 @@ class Pe extends A { e === this._subGeneration && (this._unsubscribeLive(), this._armSubscribeRetry()); }); }; - for (const i of ue) + for (const i of be) n( t.subscribeEvents(() => this.requestUpdate(), i) ); @@ -1239,11 +1227,11 @@ class Pe extends A {
- ${Ce} + ${Me} NestQuest
-

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

+

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

${t ? this._renderNotice(t) : d`
${this._plates().map((e) => this._renderPlate(e))} @@ -1256,7 +1244,7 @@ class Pe extends A { `; } _timeZone() { - return fe(this.hass); + return ve(this.hass); } _state(t) { const n = this.hass?.states?.[t]; @@ -1280,7 +1268,7 @@ class Pe extends A { const e = this._state( "sensor.nestquest_household_quests_due_today" )?.attributes?.child_roster; - return Array.isArray(e) ? e.map((n) => ye(n)).filter((n) => n.length > 0) : []; + return Array.isArray(e) ? e.map((n) => qe(n)).filter((n) => n.length > 0) : []; } _plates() { return this._childSlugs().map((t) => this._plate(t)); @@ -1292,7 +1280,7 @@ class Pe extends A { `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; + ), l = n?.attributes ?? {}, a = St(tt(n?.state, 0)), p = St(tt(i?.state, 0)), u = r ? tt(r.state, Number.NaN) : Number.NaN, c = Number.isFinite(u) ? Ct(u) : a > 0 ? Ct(p / a * 100) : 0; let f = !0; const h = l.present; typeof h == "boolean" ? f = h : o && (f = String(o.state ?? "").trim().toLowerCase() === "on"); @@ -1301,14 +1289,14 @@ class Pe extends A { if (typeof _ == "string" && _.trim()) m = _.trim(); else { - const G = o?.attributes?.child_name; - typeof G == "string" && G.trim() && (m = G.trim()); + const J = o?.attributes?.child_name; + typeof J == "string" && J.trim() && (m = J.trim()); } - m || (m = be(t)); - const v = xe( + m || (m = ke(t)); + const v = Ce( o?.attributes?.next_present, this._timeZone() - ), j = v ? `Returns ${K(v, this._timeZone())}` : null; + ), j = v ? `Returns ${Y(v, this._timeZone())}` : null; return { slug: t, name: m, @@ -1334,7 +1322,7 @@ class Pe extends A { } _boardNotice() { const t = this._plates(); - return t.length === 0 ? kt : t.every((e) => e.unresolved !== null) ? t.some((e) => e.unresolved === "stale") ? Oe : kt : null; + return t.length === 0 ? Nt : t.every((e) => e.unresolved !== null) ? t.some((e) => e.unresolved === "stale") ? Le : Nt : null; } _renderNotice(t) { return d` @@ -1357,7 +1345,7 @@ class Pe extends A { ${t.name} - ${Ne} + ${Ie} Unknown — @@ -1377,7 +1365,7 @@ class Pe extends A { ${t.name} - ${Se} + ${ze} Home today @@ -1396,7 +1384,7 @@ class Pe extends A { ${t.name} - ${Te} + ${Pe} On travels ${t.returns ?? "Returns —"} @@ -1405,11 +1393,11 @@ class Pe extends A { `; } _openQuestLog(t) { - const e = vt(this._config?.quest_log_path); + const e = At(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); + const t = At(this._config?.weather_entity); if (!t) return null; const e = this._state(t); @@ -1446,15 +1434,15 @@ class Pe extends A { if (!t) return d`
- ${K(this._now, this._timeZone())} + ${Y(this._now, this._timeZone())} - ${me(this._now, this._timeZone())} + ${xe(this._now, this._timeZone())}
`; - const e = we(t.condition), n = $e(t.condition, e), i = t.temperature === null ? g : d`${Math.round(t.temperature)}°`, r = t.high === null || t.low === null ? null : `${Math.round(t.high)}° / ${Math.round(t.low)}°`; + const e = Se(t.condition), n = Te(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`
- ${ze} + ${je} ${i} @@ -1465,15 +1453,21 @@ class Pe extends A {
`; } -} -customElements.define("nestquest-party-board-card", Pe); -at({ +}; +Q.properties = { + hass: { attribute: !1 }, + _config: { state: !0 }, + _now: { state: !0 } +}, Q.styles = [x(Gt), Oe]; +let rt = Q; +customElements.define("nestquest-party-board-card", rt); +ut({ type: "nestquest-party-board-card", name: "NestQuest Party Board", description: "Kids' attract screen for present and away adventurers." }); -const Ie = { CHILD: 2 }, Ue = (s) => (...t) => ({ _$litDirective$: s, values: t }); -let Le = class { +const De = { CHILD: 2 }, He = (s) => (...t) => ({ _$litDirective$: s, values: t }); +let Be = class { constructor(t) { } get _$AU() { @@ -1489,11 +1483,11 @@ let Le = class { return this.render(...e); } }; -const { I: Re } = ce, Ct = (s) => s, At = () => document.createComment(""), P = (s, t, e) => { +const { I: Fe } = me, Mt = (s) => s, Ot = () => 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(At(), i), o = n.insertBefore(At(), i); - e = new Re(r, o, s, s.options); + const r = n.insertBefore(Ot(), i), o = n.insertBefore(Ot(), i); + e = new Fe(r, o, s, s.options); } else { const r = e._$AB.nextSibling, o = e._$AM, l = o !== s; if (l) { @@ -1503,22 +1497,22 @@ const { I: Re } = ce, Ct = (s) => s, At = () => document.createComment(""), P = if (r !== i || l) { let a = e._$AA; for (; a !== r; ) { - const p = Ct(a).nextSibling; - Ct(n).insertBefore(a, i), a = p; + const p = Mt(a).nextSibling; + Mt(n).insertBefore(a, i), a = p; } } } return e; -}, k = (s, t, e = s) => (s._$AI(t, e), s), je = {}, De = (s, t = je) => s._$AH = t, He = (s) => s._$AH, X = (s) => { +}, k = (s, t, e = s) => (s._$AI(t, e), s), Ze = {}, Qe = (s, t = Ze) => s._$AH = t, We = (s) => s._$AH, et = (s) => { s._$AR(), s._$AA.remove(); }; -const St = (s, t, e) => { +const zt = (s, t, e) => { const n = /* @__PURE__ */ new Map(); for (let i = t; i <= e; i++) n.set(s[i], i); return n; -}, Tt = Ue(class extends Le { +}, Pt = He(class extends Be { constructor(s) { - if (super(s), s.type !== Ie.CHILD) throw Error("repeat() can only be used in text expressions"); + if (super(s), s.type !== De.CHILD) throw Error("repeat() can only be used in text expressions"); } dt(s, t, e) { let n; @@ -1532,7 +1526,7 @@ const St = (s, t, e) => { return this.dt(s, t, e).values; } update(s, [t, e, n]) { - const i = He(s), { values: r, keys: o } = this.dt(t, e, n); + const i = We(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; @@ -1542,33 +1536,33 @@ const St = (s, t, e) => { 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 = St(o, h, m), u = St(l, c, f)), p.has(l[c])) if (p.has(l[f])) { + else if (p === void 0 && (p = zt(o, h, m), u = zt(l, c, f)), p.has(l[c])) if (p.has(l[f])) { const _ = u.get(o[h]), v = _ !== void 0 ? i[_] : null; if (v === null) { const j = P(s, i[c]); k(j, r[h]), a[h] = j; } else a[h] = k(v, r[h]), P(s, i[c], v), i[_] = null; h++; - } else X(i[f]), f--; - else X(i[c]), c++; + } else et(i[f]), f--; + else et(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(_); + _ !== null && et(_); } - return this.ut = o, De(s, a), T; + return this.ut = o, Qe(s, a), T; } -}), Be = [ +}), Ge = [ "nestquest_quest_completed", "nestquest_quest_uncompleted", "nestquest_quest_missed", "nestquest_child_day_complete" -], Nt = /* @__PURE__ */ new Map(); +], It = /* @__PURE__ */ new Map(); function M(s, t) { - const e = `${s ?? "local"}|${JSON.stringify(t)}`, n = Nt.get(e); + const e = `${s ?? "local"}|${JSON.stringify(t)}`, n = It.get(e); if (n) return n; let i; @@ -1580,9 +1574,9 @@ function M(s, t) { } catch { i = new Intl.DateTimeFormat("en-US", t); } - return Nt.set(e, i), i; + return It.set(e, i), i; } -function Fe(s) { +function Ve(s) { const t = s?.config; if (!t || typeof t != "object") return; @@ -1592,10 +1586,10 @@ function Fe(s) { function D(s, t) { return `${M(t, { weekday: "long" }).format(s)}, ${M(t, { month: "short", day: "numeric" }).format(s)}`; } -function Qe(s, t) { +function Ke(s, t) { return M(t, { weekday: "long" }).format(s); } -function Ze(s, t) { +function Je(s, t) { const e = new Date(s); return Number.isNaN(e.getTime()) ? "" : M(t, { hour: "numeric", @@ -1603,14 +1597,14 @@ function Ze(s, t) { hour12: !0 }).format(e); } -function We(s, t) { +function Xe(s, t) { return M(t, { hour: "numeric", minute: "2-digit", hour12: !0 }).format(s); } -function Ge(s) { +function Ye(s) { if (!s) return null; const t = /^(\d{1,2}):(\d{2})$/.exec(s.trim()); @@ -1634,10 +1628,10 @@ function y(s, t) { function b(s) { return Math.max(0, Math.round(s)); } -function Y(s) { +function nt(s) { return s.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); } -function Ve(s, t) { +function tn(s, t) { const e = M(t, { year: "numeric", month: "2-digit", @@ -1659,7 +1653,7 @@ function Ve(s, t) { ); return Number.isFinite(i) ? i - s : 0; } -function Et(s, t) { +function Ut(s, t) { if (typeof s != "string") return null; const e = s.split("-"); @@ -1672,14 +1666,14 @@ function Et(s, t) { const a = new Date(n, i - 1, r); return Number.isNaN(a.getTime()) ? null : a; } - const o = Date.UTC(n, i - 1, r), l = new Date(o - Ve(o, t)); + const o = Date.UTC(n, i - 1, r), l = new Date(o - tn(o, t)); return Number.isNaN(l.getTime()) ? null : l; } -function Ke() { +function en() { const s = window.location.pathname.split("/").filter(Boolean); return s.length > 0 ? s[s.length - 1].toLowerCase() : ""; } -function Je(s) { +function nn(s) { if (!s || typeof s != "object") return null; const t = s, e = w(t.state).toLowerCase(); @@ -1702,7 +1696,7 @@ function Je(s) { on_time: t.on_time === !0 ? !0 : t.on_time === !1 ? !1 : null }; } -const Xe = { +const sn = { "clear-night": "Clear", cloudy: "Cloudy", exceptional: "Clear", @@ -1718,7 +1712,7 @@ const Xe = { sunny: "Sunny", windy: "Windy", "windy-variant": "Windy" -}, Ye = { +}, rn = { "clear-night": "Clear night skies", cloudy: "Grey skies today", exceptional: "A striking day", @@ -1735,14 +1729,14 @@ const Xe = { windy: "A blustery day", "windy-variant": "A blustery day" }; -function tn(s) { - const t = Xe[s]; +function on(s) { + const t = sn[s]; return t || s.charAt(0).toUpperCase() + s.slice(1); } -function en(s, t) { - return Ye[s] ?? t; +function an(s, t) { + return rn[s] ?? t; } -const Mt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", nn = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", Ot = [-9, 6, -4], sn = It` +const Rt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", ln = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", Lt = [-9, 6, -4], cn = Ht` * { box-sizing: border-box; } @@ -1802,7 +1796,7 @@ const Mt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", nn = "polygon(50% width: 96px; height: 110px; padding: 4px; - clip-path: ${x(Mt)}; + clip-path: ${x(Rt)}; background: rgba(255, 255, 255, 0.22); } @@ -1813,7 +1807,7 @@ const Mt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", nn = "polygon(50% align-items: center; justify-content: center; padding-bottom: 32px; - clip-path: ${x(Mt)}; + clip-path: ${x(Rt)}; background: var(--nq-p-crest); } @@ -1879,7 +1873,7 @@ const Mt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", nn = "polygon(50% justify-content: center; width: 44px; height: 44px; - clip-path: ${x(nn)}; + clip-path: ${x(ln)}; background: var(--nq-brand-gradient); } @@ -2599,7 +2593,7 @@ const Mt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", nn = "polygon(50% aria-hidden="true" > -`, rn = d``, dn = d` -`, tt = d``, st = d` -`, on = d``, pn = d` -`, Bt = d``, Vt = d` -`, an = d``, hn = d` -`, ln = d``, un = d` -`, cn = d``, fn = d` -`, dn = d``, mn = d` -`, pn = d``, gn = d` -`, hn = d``, _n = d` -`, un = { - icon: cn, +`, yn = { + icon: fn, headline: "No adventurer chosen", body: "Open The Party and tap your crest to open your quest log." -}, fn = { - icon: dn, +}, bn = { + icon: mn, headline: "NestQuest is not set up yet", body: "A parent needs to finish setting up NestQuest." -}, mn = { - icon: pn, +}, vn = { + icon: gn, headline: "The records cannot be reached", body: "The party's records are quiet right now. NestQuest will return shortly." -}, zt = [ - { key: "morning", name: "Morning", range: "Until 11:59 AM", icon: on }, - { key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: Bt }, - { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: an } -]; -class gn 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 = [x(Ht), sn]; - 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; +}, jt = [ + { key: "morning", name: "Morning", range: "Until 11:59 AM", icon: pn }, + { key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: Vt }, + { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: hn } +], W = class W extends C { + constructor() { + super(...arguments), this._now = /* @__PURE__ */ new Date(), this._confirm = null, this._optimistic = /* @__PURE__ */ new Map(), this._toast = null, this._countdown = null, this._unsubs = [], this._subscribed = !1, this._subGeneration = 0, this._onActivity = () => { + this._logView().kind !== "complete-day" && this._armIdle(), this._confirm !== null && this._armConfirmTimer(); + }; + } setConfig(t) { if (!t || typeof t != "object") throw new Error("Invalid configuration"); @@ -2792,7 +2762,7 @@ class gn extends A { e === this._subGeneration && (this._unsubscribeLive(), this._armSubscribeRetry()); }); }; - for (const i of Be) + for (const i of Ge) n( t.subscribeEvents(() => this.requestUpdate(), i) ); @@ -2845,18 +2815,18 @@ class gn extends A { _renderMain(t) { switch (t.kind) { case "no-adventurer": - return this._renderNotice(un); + return this._renderNotice(yn); case "not-set-up": - return this._renderNotice(fn); + return this._renderNotice(bn); case "unreachable": - return this._renderNotice(mn); + return this._renderNotice(vn); case "away": { const e = ["The quest log unlocks when they return."]; return t.returns && e.push(`Returns ${t.returns}`), d` ${this._renderHeader(t)}
- ${ln} + ${un} ${t.name} is on travels ${e.map((n) => d`

${n}

`)}
@@ -2868,7 +2838,7 @@ class gn extends A { ${this._renderHeader(t)}
- ${Bt} + ${Vt} No quests today

${`Nothing was posted for today, ${t.name}. Enjoy the day's rest!`} @@ -2912,7 +2882,7 @@ class gn extends A { return d` ${this._renderHeader(t)}

- ${zt.map((e) => this._renderColumn(e))} + ${jt.map((e) => this._renderColumn(e))}
`; } @@ -2928,7 +2898,7 @@ class gn extends A { return { kind: "not-set-up" }; if (e === "stale") return { kind: "unreachable" }; - const n = this._childName(t) ?? Y(t); + const n = this._childName(t) ?? nt(t); if (!this._childPresent(t)) return { kind: "away", name: n, returns: this._awayReturns(t) }; const i = this._state(`binary_sensor.nestquest_${t}_all_done`); @@ -2960,7 +2930,7 @@ class gn extends A { _awayReturns(t) { const e = this._timeZone(), n = this._state( `binary_sensor.nestquest_${t}_present_today` - ), i = Et( + ), i = Ut( n?.attributes?.next_present, e ); @@ -2978,14 +2948,14 @@ class gn extends A { `; } _timeZone() { - return Fe(this.hass); + return Ve(this.hass); } _state(t) { const n = this.hass?.states?.[t]; return n && typeof n == "object" ? n : null; } _childSlug() { - return Ke(); + return en(); } _boardPath() { return w(this._config?.board_path).replace(/\/+$/, ""); @@ -3021,7 +2991,7 @@ class gn extends A { const e = this._state(`sensor.nestquest_${t}_quests_due_today`)?.attributes?.instances; if (!Array.isArray(e)) return []; - const n = e.map(Je).filter((r) => r !== null); + const n = e.map(nn).filter((r) => r !== null); if (this._optimistic.size === 0) return n; let i = !1; @@ -3089,7 +3059,7 @@ class gn extends A { 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); + typeof a.child_name == "string" && a.child_name.trim() ? p = a.child_name.trim() : p = nt(l); let u = !0; if (typeof a.present == "boolean") u = a.present; @@ -3106,11 +3076,11 @@ class gn extends A { if (!u) { const _ = this._state( `binary_sensor.nestquest_${l}_present_today` - ), v = Et( + ), v = Ut( _?.attributes?.next_present, n ); - m = v ? Qe(v, n) : null; + m = v ? Ke(v, n) : null; } i.push({ slug: l, @@ -3124,7 +3094,7 @@ class gn extends A { 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(); + const e = this._childSlug(), n = t.kind === "away", i = n ? t.name : this._childName(e) ?? (e ? nt(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`
- ${Tt( + ${Pt( n, (l) => l.id, (l) => this._renderQuest(l, !1, 0) )} - ${Tt( + ${Pt( i, (l) => l.id, (l, a) => this._renderQuest(l, !0, a) @@ -3213,10 +3183,10 @@ class gn extends A { } _renderQuest(t, e, n) { if (e) { - const o = Ot[n % Ot.length], l = t.completed_at ? Ze(t.completed_at, this._timeZone()) : ""; + const o = Lt[n % Lt.length], l = t.completed_at ? Je(t.completed_at, this._timeZone()) : ""; return d`
- +
${t.title} @@ -3233,7 +3203,7 @@ class gn extends A {
`; } - const i = Ge(t.due_time), r = t.overdue ? { + const i = Ye(t.due_time), r = t.overdue ? { text: i ? `Overdue · due ${i}` : "Overdue", late: !0 } : { @@ -3252,7 +3222,7 @@ class gn extends A { (o.key === "Enter" || o.key === " ") && (o.preventDefault(), this._openConfirm(t.id)); }} > - +
${t.title} ${r.text} @@ -3288,7 +3258,7 @@ class gn extends A { ); if (!t) return g; - const e = zt.find((n) => n.key === t.window); + const e = jt.find((n) => n.key === t.window); return d`
this._closeConfirm()}>
n.stopPropagation()} >
- +
${e?.name ?? "Quest"} @@ -3316,7 +3286,7 @@ class gn extends A { type="button" @click=${() => this._closeConfirm()} > - ${rn} + ${dn} Not yet
`; - const e = tn(t.condition), n = en(t.condition, e), i = t.temperature === null ? g : d`${Math.round(t.temperature)}°`, r = t.high === null || t.low === null ? null : `${Math.round(t.high)}° / ${Math.round(t.low)}°`; + const e = on(t.condition), n = an(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`
- ${hn} + ${_n} ${i} @@ -3492,25 +3462,24 @@ class gn extends A { _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", gn); -at({ +}; +W.properties = { + hass: { attribute: !1 }, + _config: { state: !0 }, + _now: { state: !0 }, + _confirm: { state: !0 }, + _optimistic: { state: !0 }, + _toast: { state: !0 }, + _countdown: { state: !0 } +}, W.styles = [x(Gt), cn]; +let ot = W; +customElements.define("nestquest-quest-log-card", ot); +ut({ type: "nestquest-quest-log-card", name: "NestQuest Quest Log", description: "One child's daily quests, grouped by window." }); -const _n = ":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 yn extends A { - static properties = { - hass: { attribute: !1 }, - _config: { state: !0 } - }; - static styles = [x(_n)]; - hass; - _config; +const xn = ":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}", G = class G extends C { setConfig(t) { if (!t || typeof t != "object") throw new Error("Invalid configuration"); @@ -3522,39 +3491,44 @@ class yn extends A { render() { return d`
NestQuest Admin
`; } -} -customElements.define("nestquest-admin-card", yn); -at({ +}; +G.properties = { + hass: { attribute: !1 }, + _config: { state: !0 } +}, G.styles = [x(xn)]; +let at = G; +customElements.define("nestquest-admin-card", at); +ut({ type: "nestquest-admin-card", name: "NestQuest Admin", description: "Parent phone view for today, tasks, schedule, and history." }); -function Ft(s) { +function Kt(s) { return typeof s == "string" ? s.trim() : ""; } -function bn(s) { +function wn(s) { if (s === null || typeof s != "object" || Array.isArray(s)) return ""; const t = s.slug; return typeof t == "string" ? t.trim() : ""; } -function vn(s) { - const t = Ft(s.url_path).replace(/^\/+|\/+$/g, ""); +function $n(s) { + const t = Kt(s.url_path).replace(/^\/+|\/+$/g, ""); return t || (window.location.pathname.split("/").filter(Boolean)[0] ?? ""); } -function xn(s) { +function qn(s) { if (s === null || typeof s != "object" || Array.isArray(s)) return ""; const t = s.name; return typeof t == "string" ? t.trim() : ""; } -function wn(s) { +function kn(s) { const n = s?.states?.["sensor.nestquest_household_quests_due_today"]?.attributes?.child_roster; - return Array.isArray(n) ? n.map((i) => ({ slug: bn(i), name: xn(i) })).filter((i) => i.slug.length > 0) : []; + return Array.isArray(n) ? n.map((i) => ({ slug: wn(i), name: qn(i) })).filter((i) => i.slug.length > 0) : []; } -class $n { +class An { static async generate(t, e) { - const n = `/${vn(t)}`, i = Ft(t.weather_entity), r = { + const n = `/${$n(t)}`, i = Kt(t.weather_entity), r = { type: "custom:nestquest-party-board-card", quest_log_path: n }; @@ -3571,7 +3545,7 @@ class $n { type: "panel", cards: [r] }, - ...wn(e).map((l) => ({ + ...kn(e).map((l) => ({ path: l.slug, title: l.name ? `${l.name}'s Quest Log` : `${l.slug}'s Quest Log`, type: "panel", @@ -3582,9 +3556,9 @@ class $n { } } window.customStrategies = window.customStrategies ?? {}; -window.customStrategies["nestquest-party"] = $n; -const qn = "/nestquest-static/nestquest-fonts.css"; +window.customStrategies["nestquest-party"] = An; +const Cn = "/nestquest-static/nestquest-fonts.css"; if (!document.querySelector('link[data-nq-fonts=""]')) { const s = document.createElement("link"); - s.rel = "stylesheet", s.href = qn, s.dataset.nqFonts = "", document.head.appendChild(s); + s.rel = "stylesheet", s.href = Cn, s.dataset.nqFonts = "", document.head.appendChild(s); } diff --git a/frontend/tests/render-quest-log.mjs b/frontend/tests/render-quest-log.mjs index f0ccdc9..13a1435 100644 --- a/frontend/tests/render-quest-log.mjs +++ b/frontend/tests/render-quest-log.mjs @@ -10,7 +10,10 @@ * receives one JSON object {location, headline, countdown, html, probe} — * the final window pathname, the rendered title, the complete-screen * countdown text, the rendered shadow DOM, and the probe snapshot when - * probe_ms was given. The Python panel tests drive this harness so the + * probe_ms was given. With tap_complete: the harness taps that + * card's Complete button, records whether the confirm dialog opened, taps + * the dialog's Complete, and reports every hass.callService call as + * {dialog_opened, service_calls}. The Python panel tests drive this harness so the * assertions run against the exact bundle HACS ships. */ import { readFileSync } from "node:fs"; @@ -43,12 +46,34 @@ globalThis.Event = dom.window.Event; await import(pathToFileURL(spec.bundle).href); +const serviceCalls = []; +const hass = { + ...spec.hass, + callService(domain, service, data) { + serviceCalls.push({ domain, service, data }); + return Promise.resolve(); + }, +}; + const element = document.createElement("nestquest-quest-log-card"); element.setConfig(spec.config); -element.hass = spec.hass; +element.hass = hass; document.body.appendChild(element); await element.updateComplete; +let tap = null; +if (spec.tap_complete !== undefined) { + element.shadowRoot + .querySelector(`.quest[data-instance-id="${spec.tap_complete}"] button.complete`) + .click(); + await element.updateComplete; + const confirm = element.shadowRoot.querySelector("button.do-complete"); + confirm?.click(); + await element.updateComplete; + await new Promise((resolve) => setTimeout(resolve, 0)); + tap = { dialog_opened: confirm !== null, service_calls: serviceCalls }; +} + const snapshot = () => ({ location: window.location.pathname, headline: element.shadowRoot.querySelector(".title")?.textContent?.trim() ?? "", @@ -68,6 +93,6 @@ if (spec.idle_ms !== undefined) { await new Promise((resolve) => setTimeout(resolve, spec.idle_ms)); } -const payload = JSON.stringify({ ...snapshot(), probe }); +const payload = JSON.stringify({ ...snapshot(), probe, tap }); await new Promise((resolve) => process.stdout.write(payload, () => resolve())); process.exit(0); \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 8963332..078669b 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "ES2022", + "useDefineForClassFields": false, "module": "ESNext", "moduleResolution": "bundler", "lib": ["ES2022", "DOM"], diff --git a/tests/test_api_admin_settings.py b/tests/test_api_admin_settings.py index 94604a1..a50f3f9 100644 --- a/tests/test_api_admin_settings.py +++ b/tests/test_api_admin_settings.py @@ -13,7 +13,7 @@ proven against the DATABASE the routes wrote, not a parallel copy. Proven per the done-condition, everything read back through the API: - ``GET /api/v1/admin/settings`` returns the current effective settings - — the ten documented fields at their defaults on a fresh database. + — the eleven documented fields at their defaults on a fresh database. - ``PATCH /api/v1/admin/settings`` PERSISTS the supplied fields and the NEXT read reflects them; a later partial update merges onto the current effective settings (omitted fields keep their stored value), @@ -60,7 +60,7 @@ _dao_meta = importlib.import_module("nestquest_core.dao_meta") SETTINGS_META_KEY = "settings" #: The all-defaults effective settings a fresh database reports — the -#: documented ten fields, asserted literally so the payload contract is +#: documented eleven fields, asserted literally so the payload contract is #: pinned here, independent of the core constants. DEFAULT_SETTINGS = { "horizon_days": 14, @@ -73,6 +73,7 @@ DEFAULT_SETTINGS = { "afternoon_reminder_enabled": True, "end_of_day_report_enabled": True, "celebration_enabled": True, + "timezone": "", } @@ -103,7 +104,7 @@ async def settings_client(temp_db_path: str) -> SimpleNamespace: async def test_get_settings_returns_defaults_on_a_fresh_database( settings_client: SimpleNamespace, ) -> None: - """GET returns the ten documented fields at their defaults.""" + """GET returns the eleven documented fields at their defaults.""" response = await settings_client.client.get( "/api/v1/admin/settings", headers=_admin_headers() ) @@ -139,7 +140,7 @@ async def test_update_persists_and_the_next_read_reflects_it( assert reread.status_code == 200 assert reread.json() == expected - # ...and the DATABASE holds the merged document: the complete ten + # ...and the DATABASE holds the merged document: the complete eleven # fields, keyed by field name, under the well-known meta_state key. raw = await _dao_meta.MetaStateDao(settings_client.database).get( SETTINGS_META_KEY diff --git a/tests/test_api_household_clock.py b/tests/test_api_household_clock.py new file mode 100644 index 0000000..fd8aa99 --- /dev/null +++ b/tests/test_api_household_clock.py @@ -0,0 +1,272 @@ +"""Household-local clock tests for the API service (task fa7475ba). + +The deployed API container runs UTC while the household is US Eastern, +so reading the HOST clock marked every morning quest overdue from the +small hours. The API now re-expresses its ONE host clock read in the +stored ``timezone`` setting (``NestQuestSettings.household_now``). + +Every test pins the host clock to a UTC instant (the deployed +container's zone) and sets the household zone through the real admin +``PATCH /api/v1/admin/settings`` route, then reads BOTH snapshot shapes: + +- one second before the 10:00 due time: NOT overdue (previously + flagged, because 13:59:59 UTC > 10:00); +- exactly at 10:00:00: NOT overdue (the builder's strict + ``now.time() > due`` convention); +- one second after: overdue; +- 23:30 household time, already the next day in UTC: ``today_iso`` is + still the household's day. + +An unset zone keeps the historical host-local behaviour, and the +missed-sweep scheduler waits for the rollover in household time. +""" +from __future__ import annotations + +import asyncio +import datetime +import importlib +from types import SimpleNamespace +from zoneinfo import ZoneInfo + +import pytest + +from api import routes_admin, routes_panel +from api.database import _executor +from api.scheduler import MissedSweepScheduler +from tests.admin_jwt_harness import ( + ADMIN_KEY, + KID, + AdminRunner, + jwks_for, + make_token, +) +from tests.test_api_panel import _seed_household + +_settings = importlib.import_module("nestquest_core.settings") +_settings_store = importlib.import_module("nestquest_core.settings_store") +_core_db = importlib.import_module("nestquest_core.db") +_core_migrations = importlib.import_module("nestquest_core.migrations") + +#: The panel service token the admin harness app is configured with. +HARNESS_PANEL_TOKEN = "admin-test-panel-token" + +UTC = ZoneInfo("UTC") +HOUSEHOLD_ZONE = "America/New_York" + +#: A fixed household day on Eastern Daylight Time (UTC-4). +TODAY = datetime.date(2026, 9, 26) + + +def _utc(hour: int, minute: int, second: int = 0, *, day: int = 26): + """An aware UTC instant on September ``day`` 2026.""" + return datetime.datetime(2026, 9, day, hour, minute, second, tzinfo=UTC) + + +def _admin_headers() -> dict[str, str]: + return {"Authorization": f"Bearer {make_token()}"} + + +def _panel_headers() -> dict[str, str]: + return {"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"} + + +@pytest.fixture +async def household(temp_db_path: str, monkeypatch) -> SimpleNamespace: + """The seeded household on the admin-plane app, host clock pinnable. + + Ada's open "Pack bag" quest is due 10:00 on :data:`TODAY`. Both + routes' host clock read returns ``clock["now"]`` (a UTC instant). + """ + seed = await _seed_household(temp_db_path, TODAY, _utc(16, 0)) + clock = {"now": _utc(12, 0)} + monkeypatch.setattr(routes_admin, "_local_now", lambda: clock["now"]) + monkeypatch.setattr(routes_panel, "_local_now", lambda: clock["now"]) + runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) + async with runner as client: + yield SimpleNamespace(client=client, seed=seed, clock=clock) + + +async def _set_zone(household: SimpleNamespace, zone: str) -> None: + response = await household.client.patch( + "/api/v1/admin/settings", + json={"timezone": zone}, + headers=_admin_headers(), + ) + assert response.status_code == 200 + assert response.json()["timezone"] == zone + + +async def _pack_bag(household: SimpleNamespace, plane: str) -> tuple[str, dict]: + """Return ``(today_iso, Ada's Pack bag instance)`` from one snapshot.""" + headers = _admin_headers() if plane == "admin" else _panel_headers() + response = await household.client.get( + f"/api/v1/{plane}/snapshot", headers=headers + ) + assert response.status_code == 200 + body = response.json() + ada = next( + child + for child in body["children"] + if child["child_id"] == household.seed.ada.id + ) + (instance,) = ada["instances"] + assert instance["title"] == "Pack bag" + assert instance["due_time"] == "10:00" + return body["today_iso"], instance + + +@pytest.mark.parametrize("plane", ["panel", "admin"]) +@pytest.mark.parametrize( + ("host_now", "overdue"), + [ + # 09:59:59 EDT — the pre-fix code compared 13:59:59 (UTC) > 10:00. + (_utc(13, 59, 59), False), + # 10:00:00 EDT exactly — strict ``>``: not yet overdue. + (_utc(14, 0, 0), False), + # 10:00:01 EDT — overdue. + (_utc(14, 0, 1), True), + ], + ids=["before-due", "at-due", "after-due"], +) +async def test_overdue_boundary_uses_household_zone( + household: SimpleNamespace, + plane: str, + host_now: datetime.datetime, + overdue: bool, +) -> None: + await _set_zone(household, HOUSEHOLD_ZONE) + household.clock["now"] = host_now + today_iso, instance = await _pack_bag(household, plane) + assert today_iso == TODAY.isoformat() + assert instance["state"] == "open" + assert instance["overdue"] is overdue + + +@pytest.mark.parametrize("plane", ["panel", "admin"]) +async def test_household_day_survives_utc_midnight( + household: SimpleNamespace, plane: str +) -> None: + """23:30 EDT is already tomorrow in UTC; the household day is today.""" + await _set_zone(household, HOUSEHOLD_ZONE) + household.clock["now"] = _utc(3, 30, day=27) + today_iso, instance = await _pack_bag(household, plane) + assert today_iso == TODAY.isoformat() + assert instance["overdue"] is True + + +@pytest.mark.parametrize("plane", ["panel", "admin"]) +async def test_unset_zone_keeps_host_local_time( + household: SimpleNamespace, plane: str +) -> None: + """Backward compatible: no stored zone reads the host clock as-is.""" + household.clock["now"] = _utc(13, 59, 59) + today_iso, instance = await _pack_bag(household, plane) + assert today_iso == TODAY.isoformat() + assert instance["overdue"] is True + + +async def test_panel_completion_on_time_uses_household_zone( + household: SimpleNamespace, +) -> None: + """A 09:30 EDT tap on a 10:00 quest is on time (13:30 UTC is not).""" + await _set_zone(household, HOUSEHOLD_ZONE) + household.clock["now"] = _utc(13, 30) + seed = household.seed + response = await household.client.post( + f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete", + headers=_panel_headers(), + json={"actor_child_id": seed.ada.id}, + ) + assert response.status_code == 200 + _today_iso, instance = await _pack_bag(household, "admin") + assert instance["state"] == "completed" + assert instance["on_time"] is True + + +async def test_invalid_zone_is_rejected(household: SimpleNamespace) -> None: + response = await household.client.patch( + "/api/v1/admin/settings", + json={"timezone": "Mars/Olympus_Mons"}, + headers=_admin_headers(), + ) + assert response.status_code == 422 + assert "timezone" in str(response.json()["detail"]) + reread = await household.client.get( + "/api/v1/admin/settings", headers=_admin_headers() + ) + assert reread.json()["timezone"] == "" + + +# --- the settings object --------------------------------------------------- + + +def test_settings_timezone_defaults_to_host_local() -> None: + settings = _settings.NestQuestSettings() + assert settings.timezone == "" + instant = _utc(13, 0) + assert settings.household_now(instant) is instant + + +def test_settings_household_now_converts_the_same_instant() -> None: + settings = _settings.NestQuestSettings(timezone=HOUSEHOLD_ZONE) + local = settings.household_now(_utc(13, 0)) + assert local == _utc(13, 0) + assert (local.date(), local.time()) == (TODAY, datetime.time(9, 0)) + + +@pytest.mark.parametrize("value", ["Mars/Olympus_Mons", "../etc/passwd", 5, None]) +def test_settings_rejects_invalid_timezone(value: object) -> None: + with pytest.raises(ValueError, match="timezone"): + _settings.NestQuestSettings(timezone=value) + + +def test_from_options_reads_and_resiliently_defaults_timezone() -> None: + assert ( + _settings.NestQuestSettings.from_options( + {"timezone": HOUSEHOLD_ZONE} + ).timezone + == HOUSEHOLD_ZONE + ) + assert _settings.NestQuestSettings.from_options({}).timezone == "" + assert ( + _settings.NestQuestSettings.from_options_resilient( + {"timezone": "Nowhere/Land", "horizon_days": 9} + ).timezone + == "" + ) + + +# --- the missed-sweep scheduler ---------------------------------------------- + + +async def test_scheduler_waits_for_rollover_in_household_time( + temp_db_path: str, +) -> None: + """09:00 EDT (13:00 UTC) to a 10:05 rollover is 65 minutes away.""" + database = _core_db.NestQuestDatabase(_executor) + await database.open(temp_db_path) + try: + await _core_migrations.apply_migrations(database) + await _settings_store.update_settings( + database, + {"day_rollover_time": "10:05", "timezone": HOUSEHOLD_ZONE}, + ) + delays: list[float] = [] + first_sleep = asyncio.Event() + + async def sleep_stub(seconds: float) -> None: + delays.append(seconds) + first_sleep.set() + await asyncio.Event().wait() + + scheduler = MissedSweepScheduler( + database, clock=lambda: _utc(13, 0), sleep=sleep_stub + ) + scheduler.start() + try: + await asyncio.wait_for(first_sleep.wait(), timeout=5) + finally: + await scheduler.stop() + assert delays == [pytest.approx(65 * 60)] + finally: + await database.close() diff --git a/tests/test_panel_cards.py b/tests/test_panel_cards.py index 668327e..332badb 100644 --- a/tests/test_panel_cards.py +++ b/tests/test_panel_cards.py @@ -828,6 +828,7 @@ def _render_quest_log( url: str, idle_ms: int | None = None, probe_ms: int | None = None, + tap_complete: int | None = None, ) -> dict: """Render the built bundle's quest log and report its location. @@ -851,6 +852,8 @@ def _render_quest_log( spec["idle_ms"] = idle_ms if probe_ms is not None: spec["probe_ms"] = probe_ms + if tap_complete is not None: + spec["tap_complete"] = tap_complete completed = subprocess.run( ["node", str(QUEST_LOG_HARNESS)], input=json.dumps(spec), @@ -952,6 +955,66 @@ def test_quest_complete_screen_returns_to_the_board_after_the_countdown() -> Non assert result["location"] == "/nestquest" +def test_tapping_complete_confirms_and_calls_the_service() -> None: + """Tapping an open quest's Complete opens the confirm dialog, and the + dialog's Complete calls nestquest.complete_quest with the panel actor. + With ``useDefineForClassFields`` on (the ES2022 default) the card's + reactive fields were emitted as native class fields that shadowed + Lit's accessors: ``_confirm`` changed but no re-render was scheduled, + so the dialog never opened and the tap did nothing.""" + generated = _generate_dashboard( + {}, _three_child_states(), url="http://homeassistant.local/nestquest" + ) + open_quest = { + "id": 42, + "definition_id": 7, + "child_id": 1, + "title": "Brush Teeth", + "icon": None, + "window": "morning", + "due_time": "08:15", + "state": "open", + "overdue": True, + "completed_at": None, + "on_time": None, + } + states = { + **_three_child_states(), + "sensor.nestquest_ada_quests_due_today": _state( + "1", + child_name="Ada", + child_id=1, + present=True, + instances=[open_quest], + ), + } + result = _render_quest_log( + generated["views"][1]["cards"][0], + states, + url="http://homeassistant.local/nestquest/ada", + tap_complete=open_quest["id"], + ) + assert result["tap"]["dialog_opened"] is True + assert result["tap"]["service_calls"] == [ + { + "domain": "nestquest", + "service": "complete_quest", + "data": { + "instance_id": open_quest["id"], + "actor": "panel", + "actor_child_id": open_quest["child_id"], + }, + } + ] + + +def test_card_build_keeps_lit_reactive_accessors() -> None: + """The frontend build must not emit class fields that shadow Lit's + reactive property accessors (Lit's documented TypeScript setting).""" + tsconfig = json.loads((FRONTEND_DIR / "tsconfig.json").read_text()) + assert tsconfig["compilerOptions"]["useDefineForClassFields"] is False + + def test_quest_log_crest_keeps_its_shield_classes() -> None: """PANEL-SPEC §3 header crest: a present child's crest renders as the styled shield, not a bare span. Lit removes an attribute outright when