From e5601811d616a759a68d25dea55a3b534426071f Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 30 Sep 2026 09:50:25 -0400 Subject: [PATCH] Make the stored application timezone authoritative everywhere The kids' Quest Log again renders each instance's overdue state from the API snapshot's `overdue` flag, which the API computes in the stored application timezone. The browser-local overdue check from task fd1093b5 (`_isOverdue` against `_now`) is removed; `_now` only drives the header clock and date. The admin settings route (PATCH /api/v1/admin/settings), the only settings writer, now holds the missed-sweep scheduler's settings_lock across the write and calls settings_changed, so saving `timezone` or `day_rollover_time` re-plans a sleeping scheduler (review finding TZ-001). An app without a scheduler just writes. Tests: the card shows the snapshot's overdue state whatever the browser's zone or clock says; an admin PATCH of the zone or the rollover re-plans a sleeping scheduler; one saved zone (Asia/Kolkata) is read identically by the panel snapshot, the admin snapshot and the scheduler delay. Task: 7e173881-fb99-426f-a2f0-2678439ffc39 Co-Authored-By: Claude Opus 5.5 --- api/routes_admin.py | 19 +- api/scheduler.py | 3 +- .../nestquest/www/nestquest-cards.js | 456 +++++++++--------- frontend/src/cards/quest-log.ts | 30 +- tests/test_api_household_clock.py | 100 ++++ tests/test_panel_cards.py | 62 +-- 6 files changed, 373 insertions(+), 297 deletions(-) diff --git a/api/routes_admin.py b/api/routes_admin.py index 8874230..2876028 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -2330,12 +2330,25 @@ async def admin_update_settings( every value has validated — so a rejected update never changes the stored settings. Every core ``ValueError`` is 422 (:func:`_raise_settings_error`); anything else re-raises. + + The write holds the missed-sweep scheduler's ``settings_lock`` and + then tells it (:meth:`~api.scheduler.MissedSweepScheduler.settings_changed`), + so a saved ``timezone`` or ``day_rollover_time`` re-plans a sleeping + scheduler (an app without one — no lifespan — just writes). """ state: DatabaseState = request.app.state.db + scheduler = getattr(request.app.state, "sweep_scheduler", None) try: - settings = await core_settings_store.update_settings( - state.database, changes - ) + if scheduler is None: + settings = await core_settings_store.update_settings( + state.database, changes + ) + else: + async with scheduler.settings_lock: + settings = await core_settings_store.update_settings( + state.database, changes + ) + scheduler.settings_changed(settings) except ValueError as error: _raise_settings_error(error) return _settings_response(settings) diff --git a/api/scheduler.py b/api/scheduler.py index f31d4f4..e0e4769 100644 --- a/api/scheduler.py +++ b/api/scheduler.py @@ -22,7 +22,8 @@ it planned with has moved, and the loop re-reads the settings before sweeping and re-plans instead if they no longer match its plan. Guarantee: no sweep runs for a plan a committed settings write has -replaced. A settings writer holds :attr:`MissedSweepScheduler.settings_lock` +replaced. The settings writer — the admin ``PATCH /api/v1/admin/settings`` +route, the only one — holds :attr:`MissedSweepScheduler.settings_lock` across its write AND its :meth:`~MissedSweepScheduler.settings_changed` call; the loop holds the same lock across its plan read (read, record the plan, clear the wake flag) and across the pre-sweep re-read plus diff --git a/custom_components/nestquest/www/nestquest-cards.js b/custom_components/nestquest/www/nestquest-cards.js index c740b22..a819a0b 100644 --- a/custom_components/nestquest/www/nestquest-cards.js +++ b/custom_components/nestquest/www/nestquest-cards.js @@ -18,11 +18,11 @@ let Fe = class { } }; const x = (i) => new Fe(typeof i == "string" ? i : i + "", void 0, de), Ve = (i, ...e) => { - const t = i.length === 1 ? i[0] : e.reduce((n, s, r) => n + ((a) => { - if (a._$cssResult$ === !0) return a.cssText; - if (typeof a == "number") return a; - throw Error("Value passed to 'css' function must be a 'css' function result: " + a + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security."); - })(s) + i[r + 1], i[0]); + const t = i.length === 1 ? i[0] : e.reduce((n, s, a) => n + ((r) => { + if (r._$cssResult$ === !0) return r.cssText; + if (typeof r == "number") return r; + throw Error("Value passed to 'css' function must be a 'css' function result: " + r + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security."); + })(s) + i[a + 1], i[0]); return new Fe(t, i, de); }, tt = (i, e) => { if (ce) i.adoptedStyleSheets = e.map((t) => t instanceof CSSStyleSheet ? t : t.styleSheet); @@ -35,7 +35,7 @@ const x = (i) => new Fe(typeof i == "string" ? i : i + "", void 0, de), Ve = (i, for (const n of e.cssRules) t += n.cssText; return x(t); })(i) : i; -const { is: nt, defineProperty: it, getOwnPropertyDescriptor: st, getOwnPropertyNames: rt, getOwnPropertySymbols: at, getPrototypeOf: ot } = Object, K = globalThis, _e = K.trustedTypes, lt = _e ? _e.emptyScript : "", ct = K.reactiveElementPolyfillSupport, I = (i, e) => i, re = { toAttribute(i, e) { +const { is: nt, defineProperty: it, getOwnPropertyDescriptor: st, getOwnPropertyNames: at, getOwnPropertySymbols: rt, getPrototypeOf: ot } = Object, K = globalThis, _e = K.trustedTypes, lt = _e ? _e.emptyScript : "", ct = K.reactiveElementPolyfillSupport, I = (i, e) => i, ae = { toAttribute(i, e) { switch (e) { case Boolean: i = i ? lt : null; @@ -63,7 +63,7 @@ const { is: nt, defineProperty: it, getOwnPropertyDescriptor: st, getOwnProperty } } return t; -} }, Ze = (i, e) => !nt(i, e), ye = { attribute: !0, type: String, converter: re, reflect: !1, useDefault: !1, hasChanged: Ze }; +} }, Ze = (i, e) => !nt(i, e), ye = { attribute: !0, type: String, converter: ae, reflect: !1, useDefault: !1, hasChanged: Ze }; Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), K.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); let T = class extends HTMLElement { static addInitializer(e) { @@ -79,14 +79,14 @@ let T = class extends HTMLElement { } } static getPropertyDescriptor(e, t, n) { - const { get: s, set: r } = st(this.prototype, e) ?? { get() { + const { get: s, set: a } = st(this.prototype, e) ?? { get() { return this[t]; - }, set(a) { - this[t] = a; + }, set(r) { + this[t] = r; } }; - return { get: s, set(a) { + return { get: s, set(r) { const l = s?.call(this); - r?.call(this, a), this.requestUpdate(e, l, n); + a?.call(this, r), this.requestUpdate(e, l, n); }, configurable: !0, enumerable: !0 }; } static getPropertyOptions(e) { @@ -100,7 +100,7 @@ let T = class extends HTMLElement { static finalize() { if (this.hasOwnProperty(I("finalized"))) return; if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(I("properties"))) { - const t = this.properties, n = [...rt(t), ...at(t)]; + const t = this.properties, n = [...at(t), ...rt(t)]; for (const s of n) this.createProperty(s, t[s]); } const e = this[Symbol.metadata]; @@ -162,29 +162,29 @@ let T = class extends HTMLElement { _$ET(e, t) { const n = this.constructor.elementProperties.get(e), s = this.constructor._$Eu(e, n); if (s !== void 0 && n.reflect === !0) { - const r = (n.converter?.toAttribute !== void 0 ? n.converter : re).toAttribute(t, n.type); - this._$Em = e, r == null ? this.removeAttribute(s) : this.setAttribute(s, r), this._$Em = null; + const a = (n.converter?.toAttribute !== void 0 ? n.converter : ae).toAttribute(t, n.type); + this._$Em = e, a == null ? this.removeAttribute(s) : this.setAttribute(s, a), this._$Em = null; } } _$AK(e, t) { const n = this.constructor, s = n._$Eh.get(e); if (s !== void 0 && this._$Em !== s) { - const r = n.getPropertyOptions(s), a = typeof r.converter == "function" ? { fromAttribute: r.converter } : r.converter?.fromAttribute !== void 0 ? r.converter : re; + const a = n.getPropertyOptions(s), r = typeof a.converter == "function" ? { fromAttribute: a.converter } : a.converter?.fromAttribute !== void 0 ? a.converter : ae; this._$Em = s; - const l = a.fromAttribute(t, r.type); + const l = r.fromAttribute(t, a.type); this[s] = l ?? this._$Ej?.get(s) ?? l, this._$Em = null; } } - requestUpdate(e, t, n, s = !1, r) { + requestUpdate(e, t, n, s = !1, a) { if (e !== void 0) { - const a = this.constructor; - if (s === !1 && (r = this[e]), n ??= a.getPropertyOptions(e), !((n.hasChanged ?? Ze)(r, t) || n.useDefault && n.reflect && r === this._$Ej?.get(e) && !this.hasAttribute(a._$Eu(e, n)))) return; + const r = this.constructor; + if (s === !1 && (a = this[e]), n ??= r.getPropertyOptions(e), !((n.hasChanged ?? Ze)(a, t) || n.useDefault && n.reflect && a === this._$Ej?.get(e) && !this.hasAttribute(r._$Eu(e, n)))) return; this.C(e, t, n); } this.isUpdatePending === !1 && (this._$ES = this._$EP()); } - C(e, t, { useDefault: n, reflect: s, wrapped: r }, a) { - n && !(this._$Ej ??= /* @__PURE__ */ new Map()).has(e) && (this._$Ej.set(e, a ?? t ?? this[e]), r !== !0 || a !== void 0) || (this._$AL.has(e) || (this.hasUpdated || n || (t = void 0), this._$AL.set(e, t)), s === !0 && this._$Em !== e && (this._$Eq ??= /* @__PURE__ */ new Set()).add(e)); + C(e, t, { useDefault: n, reflect: s, wrapped: a }, r) { + n && !(this._$Ej ??= /* @__PURE__ */ new Map()).has(e) && (this._$Ej.set(e, r ?? t ?? this[e]), a !== !0 || r !== void 0) || (this._$AL.has(e) || (this.hasUpdated || n || (t = void 0), this._$AL.set(e, t)), s === !0 && this._$Em !== e && (this._$Eq ??= /* @__PURE__ */ new Set()).add(e)); } async _$EP() { this.isUpdatePending = !0; @@ -203,13 +203,13 @@ let T = class extends HTMLElement { if (!this.isUpdatePending) return; if (!this.hasUpdated) { if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) { - for (const [s, r] of this._$Ep) this[s] = r; + for (const [s, a] of this._$Ep) this[s] = a; this._$Ep = void 0; } const n = this.constructor.elementProperties; - if (n.size > 0) for (const [s, r] of n) { - const { wrapped: a } = r, l = this[s]; - a !== !0 || this._$AL.has(s) || l === void 0 || this.C(s, void 0, r, l); + if (n.size > 0) for (const [s, a] of n) { + const { wrapped: r } = a, l = this[s]; + r !== !0 || this._$AL.has(s) || l === void 0 || this.C(s, void 0, a, l); } } let e = !1; @@ -256,21 +256,21 @@ function Je(i, e) { } const ht = (i, e) => { const t = i.length - 1, n = []; - let s, r = e === 2 ? "" : e === 3 ? "" : "", a = L; + let s, a = e === 2 ? "" : e === 3 ? "" : "", r = L; for (let l = 0; l < t; l++) { const o = i[l]; let p, u, c = -1, f = 0; - for (; f < o.length && (a.lastIndex = f, u = a.exec(o), u !== null); ) f = a.lastIndex, a === L ? u[1] === "!--" ? a = xe : u[1] !== void 0 ? a = we : u[2] !== void 0 ? (Ge.test(u[2]) && (s = RegExp("" ? (a = s ?? L, c = -1) : u[1] === void 0 ? c = -2 : (c = a.lastIndex - u[2].length, p = u[1], a = u[3] === void 0 ? $ : u[3] === '"' ? $e : ke) : a === $e || a === ke ? a = $ : a === xe || a === we ? a = L : (a = $, s = void 0); - const h = a === $ && i[l + 1].startsWith("/>") ? " " : ""; - r += a === L ? o + dt : c >= 0 ? (n.push(p), o.slice(0, c) + Qe + o.slice(c) + k + h) : o + k + (c === -2 ? l : h); + for (; f < o.length && (r.lastIndex = f, u = r.exec(o), u !== null); ) f = r.lastIndex, r === L ? u[1] === "!--" ? r = xe : u[1] !== void 0 ? r = we : u[2] !== void 0 ? (Ge.test(u[2]) && (s = RegExp("" ? (r = s ?? L, c = -1) : u[1] === void 0 ? c = -2 : (c = r.lastIndex - u[2].length, p = u[1], r = u[3] === void 0 ? $ : u[3] === '"' ? $e : ke) : r === $e || r === ke ? r = $ : r === xe || r === we ? r = L : (r = $, s = void 0); + const h = r === $ && i[l + 1].startsWith("/>") ? " " : ""; + a += r === L ? o + dt : c >= 0 ? (n.push(p), o.slice(0, c) + Qe + o.slice(c) + k + h) : o + k + (c === -2 ? l : h); } - return [Je(i, r + (i[t] || "") + (e === 2 ? "" : e === 3 ? "" : "")), n]; + return [Je(i, a + (i[t] || "") + (e === 2 ? "" : e === 3 ? "" : "")), n]; }; class R { constructor({ strings: e, _$litType$: t }, n) { let s; this.parts = []; - let r = 0, a = 0; + let a = 0, r = 0; const l = e.length - 1, o = this.parts, [p, u] = ht(e, t); if (this.el = R.createElement(p, n), M.currentNode = this.el.content, t === 2 || t === 3) { const c = this.el.content.firstChild; @@ -279,23 +279,23 @@ class R { for (; (s = M.nextNode()) !== null && o.length < l; ) { if (s.nodeType === 1) { if (s.hasAttributes()) for (const c of s.getAttributeNames()) if (c.endsWith(Qe)) { - const f = u[a++], h = s.getAttribute(c).split(k), m = /([.?@])?(.*)/.exec(f); - o.push({ type: 1, index: r, name: m[2], strings: h, ctor: m[1] === "." ? ft : m[1] === "?" ? mt : m[1] === "@" ? gt : J }), s.removeAttribute(c); - } else c.startsWith(k) && (o.push({ type: 6, index: r }), s.removeAttribute(c)); + const f = u[r++], h = s.getAttribute(c).split(k), m = /([.?@])?(.*)/.exec(f); + o.push({ type: 1, index: a, name: m[2], strings: h, ctor: m[1] === "." ? ft : m[1] === "?" ? mt : m[1] === "@" ? gt : J }), s.removeAttribute(c); + } else c.startsWith(k) && (o.push({ type: 6, index: a }), s.removeAttribute(c)); if (Ge.test(s.tagName)) { const c = s.textContent.split(k), f = c.length - 1; if (f > 0) { s.textContent = V ? V.emptyScript : ""; - for (let h = 0; h < f; h++) s.append(c[h], P()), M.nextNode(), o.push({ type: 2, index: ++r }); + for (let h = 0; h < f; h++) s.append(c[h], P()), M.nextNode(), o.push({ type: 2, index: ++a }); s.append(c[f], P()); } } - } else if (s.nodeType === 8) if (s.data === We) o.push({ type: 2, index: r }); + } else if (s.nodeType === 8) if (s.data === We) o.push({ type: 2, index: a }); else { let c = -1; - for (; (c = s.data.indexOf(k, c + 1)) !== -1; ) o.push({ type: 7, index: r }), c += k.length - 1; + for (; (c = s.data.indexOf(k, c + 1)) !== -1; ) o.push({ type: 7, index: a }), c += k.length - 1; } - r++; + a++; } } static createElement(e, t) { @@ -306,8 +306,8 @@ class R { function z(i, e, t = i, n) { if (e === S) return e; let s = n !== void 0 ? t._$Co?.[n] : t._$Cl; - const r = H(e) ? void 0 : e._$litDirective$; - return s?.constructor !== r && (s?._$AO?.(!1), r === void 0 ? s = void 0 : (s = new r(i), s._$AT(i, t, n)), n !== void 0 ? (t._$Co ??= [])[n] = s : t._$Cl = s), s !== void 0 && (e = z(i, s._$AS(i, e.values), s, n)), e; + const a = H(e) ? void 0 : e._$litDirective$; + return s?.constructor !== a && (s?._$AO?.(!1), a === void 0 ? s = void 0 : (s = new a(i), s._$AT(i, t, n)), n !== void 0 ? (t._$Co ??= [])[n] = s : t._$Cl = s), s !== void 0 && (e = z(i, s._$AS(i, e.values), s, n)), e; } class ut { constructor(e, t) { @@ -322,13 +322,13 @@ class ut { u(e) { const { el: { content: t }, parts: n } = this._$AD, s = (e?.creationScope ?? A).importNode(t, !0); M.currentNode = s; - let r = M.nextNode(), a = 0, l = 0, o = n[0]; + let a = M.nextNode(), r = 0, l = 0, o = n[0]; for (; o !== void 0; ) { - if (a === o.index) { + if (r === o.index) { let p; - o.type === 2 ? p = new N(r, r.nextSibling, this, e) : o.type === 1 ? p = new o.ctor(r, o.name, o.strings, this, e) : o.type === 6 && (p = new _t(r, this, e)), this._$AV.push(p), o = n[++l]; + o.type === 2 ? p = new N(a, a.nextSibling, this, e) : o.type === 1 ? p = new o.ctor(a, o.name, o.strings, this, e) : o.type === 6 && (p = new _t(a, this, e)), this._$AV.push(p), o = n[++l]; } - a !== o?.index && (r = M.nextNode(), a++); + r !== o?.index && (a = M.nextNode(), r++); } return M.currentNode = A, s; } @@ -371,8 +371,8 @@ class N { const { values: t, _$litType$: n } = e, s = typeof n == "number" ? this._$AC(e) : (n.el === void 0 && (n.el = R.createElement(Je(n.h, n.h[0]), this.options)), n); if (this._$AH?._$AD === s) this._$AH.p(t); else { - const r = new ut(s, this), a = r.u(this.options); - r.p(t), this.T(a), this._$AH = r; + const a = new ut(s, this), r = a.u(this.options); + a.p(t), this.T(r), this._$AH = a; } } _$AC(e) { @@ -383,7 +383,7 @@ class N { he(this._$AH) || (this._$AH = [], this._$AR()); const t = this._$AH; let n, s = 0; - for (const r of e) s === t.length ? t.push(n = new N(this.O(P()), this.O(P()), this, this.options)) : n = t[s], n._$AI(r), s++; + for (const a of e) s === t.length ? t.push(n = new N(this.O(P()), this.O(P()), this, this.options)) : n = t[s], n._$AI(a), s++; s < t.length && (this._$AR(n && n._$AB.nextSibling, s), t.length = s); } _$AR(e = this._$AA.nextSibling, t) { @@ -403,19 +403,19 @@ class J { get _$AU() { return this._$AM._$AU; } - constructor(e, t, n, s, r) { - this.type = 1, this._$AH = g, this._$AN = void 0, this.element = e, this.name = t, this._$AM = s, this.options = r, n.length > 2 || n[0] !== "" || n[1] !== "" ? (this._$AH = Array(n.length - 1).fill(new String()), this.strings = n) : this._$AH = g; + constructor(e, t, n, s, a) { + this.type = 1, this._$AH = g, this._$AN = void 0, this.element = e, this.name = t, this._$AM = s, this.options = a, n.length > 2 || n[0] !== "" || n[1] !== "" ? (this._$AH = Array(n.length - 1).fill(new String()), this.strings = n) : this._$AH = g; } _$AI(e, t = this, n, s) { - const r = this.strings; - let a = !1; - if (r === void 0) e = z(this, e, t, 0), a = !H(e) || e !== this._$AH && e !== S, a && (this._$AH = e); + const a = this.strings; + let r = !1; + if (a === void 0) e = z(this, e, t, 0), r = !H(e) || e !== this._$AH && e !== S, r && (this._$AH = e); else { const l = e; let o, p; - for (e = r[0], o = 0; o < r.length - 1; o++) p = z(this, l[n + o], t, o), p === S && (p = this._$AH[o]), a ||= !H(p) || p !== this._$AH[o], p === g ? e = g : e !== g && (e += (p ?? "") + r[o + 1]), this._$AH[o] = p; + for (e = a[0], o = 0; o < a.length - 1; o++) p = z(this, l[n + o], t, o), p === S && (p = this._$AH[o]), r ||= !H(p) || p !== this._$AH[o], p === g ? e = g : e !== g && (e += (p ?? "") + a[o + 1]), this._$AH[o] = p; } - a && !s && this.j(e); + r && !s && this.j(e); } j(e) { e === g ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, e ?? ""); @@ -438,13 +438,13 @@ class mt extends J { } } class gt extends J { - constructor(e, t, n, s, r) { - super(e, t, n, s, r), this.type = 5; + constructor(e, t, n, s, a) { + super(e, t, n, s, a), this.type = 5; } _$AI(e, t = this) { if ((e = z(this, e, t, 0) ?? g) === S) return; - const n = this._$AH, s = e === g && n !== g || e.capture !== n.capture || e.once !== n.once || e.passive !== n.passive, r = e !== g && (n === g || s); - s && this.element.removeEventListener(this.name, this, n), r && this.element.addEventListener(this.name, this, e), this._$AH = e; + const n = this._$AH, s = e === g && n !== g || e.capture !== n.capture || e.once !== n.once || e.passive !== n.passive, a = e !== g && (n === g || s); + s && this.element.removeEventListener(this.name, this, n), a && this.element.addEventListener(this.name, this, e), this._$AH = e; } handleEvent(e) { typeof this._$AH == "function" ? this._$AH.call(this.options?.host ?? this.element, e) : this._$AH.handleEvent(e); @@ -467,8 +467,8 @@ const vt = (i, e, t) => { const n = t?.renderBefore ?? e; let s = n._$litPart$; if (s === void 0) { - const r = t?.renderBefore ?? null; - n._$litPart$ = s = new N(e.insertBefore(P(), r), r, void 0, t ?? {}); + const a = t?.renderBefore ?? null; + n._$litPart$ = s = new N(e.insertBefore(P(), a), a, void 0, t ?? {}); } return s._$AI(i), s; }; @@ -607,9 +607,9 @@ function St(i, e) { minute: "2-digit", second: "2-digit", hourCycle: "h23" - }).formatToParts(new Date(i)), n = (r) => { - const a = t.find((l) => l.type === r); - return a ? Number(a.value) : Number.NaN; + }).formatToParts(new Date(i)), n = (a) => { + const r = t.find((l) => l.type === a); + return r ? Number(r.value) : Number.NaN; }, s = Date.UTC( n("year"), n("month") - 1, @@ -626,14 +626,14 @@ function Tt(i, e) { const t = i.split("-"); if (t.length !== 3) return null; - const [n, s, r] = t.map((o) => Number(o)); - if (!n || !s || !r) + const [n, s, a] = t.map((o) => Number(o)); + if (!n || !s || !a) return null; if (!e) { - const o = new Date(n, s - 1, r); + const o = new Date(n, s - 1, a); return Number.isNaN(o.getTime()) ? null : o; } - const a = Date.UTC(n, s - 1, r), l = new Date(a - St(a, e)); + const r = Date.UTC(n, s - 1, a), l = new Date(r - St(r, e)); return Number.isNaN(l.getTime()) ? null : l; } function zt(i) { @@ -1180,8 +1180,8 @@ const ze = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 2 return; this._subscribed = !0; const t = ++this._subGeneration, n = (s) => { - s.then((r) => { - this._subscribed && t === this._subGeneration ? this._unsubs.push(r) : r(); + s.then((a) => { + this._subscribed && t === this._subGeneration ? this._unsubs.push(a) : a(); }).catch(() => { t === this._subGeneration && (this._unsubscribeLive(), this._armSubscribeRetry()); }); @@ -1276,25 +1276,25 @@ const ze = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 2 _plate(e) { const t = this._dueSensorResolution(e), n = this._state(`sensor.nestquest_${e}_quests_due_today`), s = this._state( `sensor.nestquest_${e}_quests_completed_today` - ), r = this._state( - `sensor.nestquest_${e}_completion_pct_today` ), a = this._state( + `sensor.nestquest_${e}_completion_pct_today` + ), r = this._state( `binary_sensor.nestquest_${e}_present_today` - ), l = n?.attributes ?? {}, o = Te(te(n?.state, 0)), p = Te(te(s?.state, 0)), u = r ? te(r.state, Number.NaN) : Number.NaN, c = Number.isFinite(u) ? Se(u) : o > 0 ? Se(p / o * 100) : 0; + ), l = n?.attributes ?? {}, o = Te(te(n?.state, 0)), p = Te(te(s?.state, 0)), u = a ? te(a.state, Number.NaN) : Number.NaN, c = Number.isFinite(u) ? Se(u) : o > 0 ? Se(p / o * 100) : 0; let f = !0; const h = l.present; - typeof h == "boolean" ? f = h : a && (f = String(a.state ?? "").trim().toLowerCase() === "on"); + typeof h == "boolean" ? f = h : r && (f = String(r.state ?? "").trim().toLowerCase() === "on"); let m = ""; const _ = l.child_name; if (typeof _ == "string" && _.trim()) m = _.trim(); else { - const X = a?.attributes?.child_name; + const X = r?.attributes?.child_name; typeof X == "string" && X.trim() && (m = X.trim()); } m || (m = At(e)); const v = Tt( - a?.attributes?.next_present, + r?.attributes?.next_present, this._timeZone() ), U = v ? `Returns ${ee(v, this._timeZone())}` : null; return { @@ -1407,20 +1407,20 @@ const ze = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 2 if (!n || n === "unavailable" || n === "unknown") return null; const s = t.attributes ?? {}; - let r = null, a = null; + let a = null, r = null; const l = s.forecast; if (Array.isArray(l) && l.length > 0) { const o = l[0]; if (o && typeof o == "object") { const p = o; - r = this._optionalNumber(p.temperature), a = this._optionalNumber(p.templow); + a = this._optionalNumber(p.temperature), r = this._optionalNumber(p.templow); } } return { condition: n, temperature: this._optionalNumber(s.temperature), - high: r, - low: a + high: a, + low: r }; } _optionalNumber(e) { @@ -1439,14 +1439,14 @@ const ze = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 2 ${$t(this._now, this._timeZone())} `; - const t = zt(e.condition), n = Et(e.condition, t), s = e.temperature === null ? g : d`${Math.round(e.temperature)}°`, r = e.high === null || e.low === null ? null : `${Math.round(e.high)}° / ${Math.round(e.low)}°`; + const t = zt(e.condition), n = Et(e.condition, t), s = e.temperature === null ? g : d`${Math.round(e.temperature)}°`, a = e.high === null || e.low === null ? null : `${Math.round(e.high)}° / ${Math.round(e.low)}°`; return d`
${Dt} ${s} - ${r === null ? t : `${t} · ${r}`} + ${a === null ? t : `${t} · ${a}`} ${n} @@ -1459,8 +1459,8 @@ Q.properties = { _config: { state: !0 }, _now: { state: !0 } }, Q.styles = [x(Xe), Bt]; -let ae = Q; -customElements.define("nestquest-party-board-card", ae); +let re = Q; +customElements.define("nestquest-party-board-card", re); fe({ type: "nestquest-party-board-card", name: "NestQuest Party Board", @@ -1486,17 +1486,17 @@ let Zt = class { const { I: Qt } = yt, Le = (i) => i, Oe = () => document.createComment(""), O = (i, e, t) => { const n = i._$AA.parentNode, s = e === void 0 ? i._$AB : e._$AA; if (t === void 0) { - const r = n.insertBefore(Oe(), s), a = n.insertBefore(Oe(), s); - t = new Qt(r, a, i, i.options); + const a = n.insertBefore(Oe(), s), r = n.insertBefore(Oe(), s); + t = new Qt(a, r, i, i.options); } else { - const r = t._$AB.nextSibling, a = t._$AM, l = a !== i; + const a = t._$AB.nextSibling, r = t._$AM, l = r !== i; if (l) { let o; - t._$AQ?.(i), t._$AM = i, t._$AP !== void 0 && (o = i._$AU) !== a._$AU && t._$AP(o); + t._$AQ?.(i), t._$AM = i, t._$AP !== void 0 && (o = i._$AU) !== r._$AU && t._$AP(o); } - if (r !== s || l) { + if (a !== s || l) { let o = t._$AA; - for (; o !== r; ) { + for (; o !== a; ) { const p = Le(o).nextSibling; Le(n).insertBefore(o, s), o = p; } @@ -1517,43 +1517,43 @@ const Be = (i, e, t) => { dt(i, e, t) { let n; t === void 0 ? t = e : e !== void 0 && (n = e); - const s = [], r = []; - let a = 0; - for (const l of i) s[a] = n ? n(l, a) : a, r[a] = t(l, a), a++; - return { values: r, keys: s }; + const s = [], a = []; + let r = 0; + for (const l of i) s[r] = n ? n(l, r) : r, a[r] = t(l, r), r++; + return { values: a, keys: s }; } render(i, e, t) { return this.dt(i, e, t).values; } update(i, [e, t, n]) { - const s = Kt(i), { values: r, keys: a } = this.dt(e, t, n); - if (!Array.isArray(s)) return this.ut = a, r; + const s = Kt(i), { values: a, keys: r } = this.dt(e, t, n); + if (!Array.isArray(s)) return this.ut = r, a; const l = this.ut ??= [], o = []; - let p, u, c = 0, f = s.length - 1, h = 0, m = r.length - 1; + let p, u, c = 0, f = s.length - 1, h = 0, m = a.length - 1; for (; c <= f && h <= m; ) if (s[c] === null) c++; else if (s[f] === null) f--; - else if (l[c] === a[h]) o[h] = q(s[c], r[h]), c++, h++; - else if (l[f] === a[m]) o[m] = q(s[f], r[m]), f--, m--; - else if (l[c] === a[m]) o[m] = q(s[c], r[m]), O(i, o[m + 1], s[c]), c++, m--; - else if (l[f] === a[h]) o[h] = q(s[f], r[h]), O(i, s[c], s[f]), f--, h++; - else if (p === void 0 && (p = Be(a, h, m), u = Be(l, c, f)), p.has(l[c])) if (p.has(l[f])) { - const _ = u.get(a[h]), v = _ !== void 0 ? s[_] : null; + else if (l[c] === r[h]) o[h] = q(s[c], a[h]), c++, h++; + else if (l[f] === r[m]) o[m] = q(s[f], a[m]), f--, m--; + else if (l[c] === r[m]) o[m] = q(s[c], a[m]), O(i, o[m + 1], s[c]), c++, m--; + else if (l[f] === r[h]) o[h] = q(s[f], a[h]), O(i, s[c], s[f]), f--, h++; + else if (p === void 0 && (p = Be(r, h, m), u = Be(l, c, f)), p.has(l[c])) if (p.has(l[f])) { + const _ = u.get(r[h]), v = _ !== void 0 ? s[_] : null; if (v === null) { const U = O(i, s[c]); - q(U, r[h]), o[h] = U; - } else o[h] = q(v, r[h]), O(i, s[c], v), s[_] = null; + q(U, a[h]), o[h] = U; + } else o[h] = q(v, a[h]), O(i, s[c], v), s[_] = null; h++; } else ne(s[f]), f--; else ne(s[c]), c++; for (; h <= m; ) { const _ = O(i, o[m + 1]); - q(_, r[h]), o[h++] = _; + q(_, a[h]), o[h++] = _; } for (; c <= f; ) { const _ = s[c++]; _ !== null && ne(_); } - return this.ut = a, Gt(i, o), S; + return this.ut = r, Gt(i, o), S; } }), Jt = [ { @@ -2289,7 +2289,7 @@ function sn(i, e) { return e; } } -const rn = [ +const an = [ "nestquest_quest_completed", "nestquest_quest_uncompleted", "nestquest_quest_missed", @@ -2310,7 +2310,7 @@ function E(i, e) { } return He.set(t, s), s; } -function an(i) { +function rn(i) { const e = i?.config; if (!e || typeof e != "object") return; @@ -2350,13 +2350,6 @@ function dn(i) { const n = t >= 12 ? "PM" : "AM"; return `${t % 12 === 0 ? 12 : t % 12}:${e[2]} ${n}`; } -function pn(i) { - const e = i ? /^(\d{1,2}):(\d{2})$/.exec(i.trim()) : null; - if (!e) - return null; - const t = Number(e[1]), n = Number(e[2]); - return t > 23 || n > 59 ? null : t * 60 + n; -} function w(i) { return typeof i == "string" ? i.trim() : ""; } @@ -2372,7 +2365,7 @@ function b(i) { function ie(i) { return i.split(/[-_]+/).filter(Boolean).map((e) => e.charAt(0).toUpperCase() + e.slice(1)).join(" "); } -function hn(i, e) { +function pn(i, e) { const t = E(e, { year: "numeric", month: "2-digit", @@ -2381,9 +2374,9 @@ function hn(i, e) { minute: "2-digit", second: "2-digit", hourCycle: "h23" - }).formatToParts(new Date(i)), n = (r) => { - const a = t.find((l) => l.type === r); - return a ? Number(a.value) : Number.NaN; + }).formatToParts(new Date(i)), n = (a) => { + const r = t.find((l) => l.type === a); + return r ? Number(r.value) : Number.NaN; }, s = Date.UTC( n("year"), n("month") - 1, @@ -2400,21 +2393,21 @@ function Re(i, e) { const t = i.split("-"); if (t.length !== 3) return null; - const [n, s, r] = t.map((o) => Number(o)); - if (!n || !s || !r) + const [n, s, a] = t.map((o) => Number(o)); + if (!n || !s || !a) return null; if (!e) { - const o = new Date(n, s - 1, r); + const o = new Date(n, s - 1, a); return Number.isNaN(o.getTime()) ? null : o; } - const a = Date.UTC(n, s - 1, r), l = new Date(a - hn(a, e)); + const r = Date.UTC(n, s - 1, a), l = new Date(r - pn(r, e)); return Number.isNaN(l.getTime()) ? null : l; } -function un() { +function hn() { const i = window.location.pathname.split("/").filter(Boolean); return i.length > 0 ? i[i.length - 1].toLowerCase() : ""; } -function fn(i) { +function un(i) { if (!i || typeof i != "object") return null; const e = i, t = w(e.state).toLowerCase(); @@ -2432,11 +2425,12 @@ function fn(i) { window: w(e.window).toLowerCase(), due_time: w(e.due_time) || null, state: t, + overdue: e.overdue === !0, completed_at: w(e.completed_at) || null, on_time: e.on_time === !0 ? !0 : e.on_time === !1 ? !1 : null }; } -const mn = { +const fn = { "clear-night": "Clear", cloudy: "Cloudy", exceptional: "Clear", @@ -2452,7 +2446,7 @@ const mn = { sunny: "Sunny", windy: "Windy", "windy-variant": "Windy" -}, gn = { +}, mn = { "clear-night": "Clear night skies", cloudy: "Grey skies today", exceptional: "A striking day", @@ -2469,14 +2463,14 @@ const mn = { windy: "A blustery day", "windy-variant": "A blustery day" }; -function _n(i) { - const e = mn[i]; +function gn(i) { + const e = fn[i]; return e || i.charAt(0).toUpperCase() + i.slice(1); } -function yn(i, e) { - return gn[i] ?? e; +function _n(i, e) { + return mn[i] ?? e; } -const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", bn = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", je = [-9, 6, -4], vn = Ve` +const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", je = [-9, 6, -4], bn = Ve` * { box-sizing: border-box; } @@ -2644,7 +2638,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", bn = "polygon(50% justify-content: center; width: 44px; height: 44px; - clip-path: ${x(bn)}; + clip-path: ${x(yn)}; background: var(--nq-brand-gradient); } @@ -3369,7 +3363,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", bn = "polygon(50% aria-hidden="true" > -`, xn = d``, vn = d` -`, wn = d``, xn = d` -`, kn = d``, wn = d` -`, $n = d``, kn = d` -`, qn = d``, $n = d` -`, Mn = d``, qn = d` -`, Cn = d``, Mn = d` -`, An = d``, Cn = d` -`, Sn = d``, An = d` -`, Tn = { - icon: Mn, +`, Sn = { + icon: qn, headline: "No adventurer chosen", body: "Open The Party and tap your crest to open your quest log." -}, zn = { - icon: Cn, +}, Tn = { + icon: Mn, headline: "NestQuest is not set up yet", body: "A parent needs to finish setting up NestQuest." -}, En = { - icon: An, +}, zn = { + icon: Cn, headline: "The records cannot be reached", body: "The party's records are quiet right now. NestQuest will return shortly." }, De = [ - { key: "morning", name: "Morning", range: "Until 11:59 AM", icon: kn }, + { key: "morning", name: "Morning", range: "Until 11:59 AM", icon: wn }, { key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: Ye }, - { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: $n } + { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: kn } ], 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 = () => { @@ -3542,13 +3536,13 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", bn = "polygon(50% return; this._subscribed = !0; const t = ++this._subGeneration, n = (s) => { - s.then((r) => { - this._subscribed && t === this._subGeneration ? this._unsubs.push(r) : r(); + s.then((a) => { + this._subscribed && t === this._subGeneration ? this._unsubs.push(a) : a(); }).catch(() => { t === this._subGeneration && (this._unsubscribeLive(), this._armSubscribeRetry()); }); }; - for (const s of rn) + for (const s of an) n( e.subscribeEvents(() => this.requestUpdate(), s) ); @@ -3601,18 +3595,18 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", bn = "polygon(50% _renderMain(e) { switch (e.kind) { case "no-adventurer": - return this._renderNotice(Tn); + return this._renderNotice(Sn); case "not-set-up": - return this._renderNotice(zn); + return this._renderNotice(Tn); case "unreachable": - return this._renderNotice(En); + return this._renderNotice(zn); case "away": { const t = ["The quest log unlocks when they return."]; return e.returns && t.push(`Returns ${e.returns}`), d` ${this._renderHeader(e)}
- ${qn} + ${$n} ${e.name} is on travels ${t.map((n) => d`

${n}

`)}
@@ -3690,8 +3684,8 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", bn = "polygon(50% const s = this._state(`binary_sensor.nestquest_${e}_all_done`); if (s && String(s.state ?? "").trim().toLowerCase() === "on") return { kind: "complete-day", name: n }; - const r = this._state(`sensor.nestquest_${e}_quests_due_today`); - return b(y(r?.state, 0)) === 0 ? { kind: "empty-day", name: n } : this._remaining() === 0 ? { kind: "complete-day", name: n } : { kind: "normal" }; + const a = this._state(`sensor.nestquest_${e}_quests_due_today`); + return b(y(a?.state, 0)) === 0 ? { kind: "empty-day", name: n } : this._remaining() === 0 ? { kind: "complete-day", name: n } : { kind: "normal" }; } /** "missing" when the due sensor does not exist (integration not * configured), "stale" when it answers unavailable/unknown (backend @@ -3733,25 +3727,15 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", bn = "polygon(50%
`; } - /** Overdue by the panel's own clock: the browser runs on the - household's real local time, so an open quest turns overdue once its - ``due_time`` wall time has arrived there — whatever zone the API or - Home Assistant computed the API's ``overdue`` flag in. With no due - time it is never overdue (the API's rule too). ``_now`` is reactive - and ticks every 60 s, so the line flips on the minute it falls due. */ - _isOverdue(e) { - const t = pn(e.due_time); - return e.state !== "open" || t === null ? !1 : this._now.getHours() * 60 + this._now.getMinutes() >= t; - } _timeZone() { - return an(this.hass); + return rn(this.hass); } _state(e) { const n = this.hass?.states?.[e]; return n && typeof n == "object" ? n : null; } _childSlug() { - return un(); + return hn(); } _boardPath() { return w(this._config?.board_path).replace(/\/+$/, ""); @@ -3777,8 +3761,8 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", bn = "polygon(50% _childCounts(e) { const t = this._state(`sensor.nestquest_${e}_quests_due_today`), n = this._state( `sensor.nestquest_${e}_quests_completed_today` - ), s = b(y(t?.state, 0)), r = b(y(n?.state, 0)); - return { due: s, completed: r }; + ), s = b(y(t?.state, 0)), a = b(y(n?.state, 0)); + return { due: s, completed: a }; } _instances() { const e = this._childSlug(); @@ -3787,18 +3771,18 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", bn = "polygon(50% const t = this._state(`sensor.nestquest_${e}_quests_due_today`)?.attributes?.instances; if (!Array.isArray(t)) return []; - const n = t.map(fn).filter((r) => r !== null); + const n = t.map(un).filter((a) => a !== null); if (this._optimistic.size === 0) return n; let s = !1; - for (const r of n) { - const a = this._optimistic.get(r.id); - if (a !== void 0) { - if (r.state === "completed") { - this._optimistic.delete(r.id), s = !0; + for (const a of n) { + const r = this._optimistic.get(a.id); + if (r !== void 0) { + if (a.state === "completed") { + this._optimistic.delete(a.id), s = !0; continue; } - r.state = "completed", r.completed_at = a; + a.state = "completed", a.overdue = !1, a.completed_at = r; } } return s && (this._optimistic = new Map(this._optimistic)), n; @@ -3826,12 +3810,12 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", bn = "polygon(50% completed: b(y(t?.state, 0)), due: b(y(e?.state, 0)) }; - const n = this._childSlug(), { due: s, completed: r } = this._childCounts(n); - return { completed: r, due: s }; + const n = this._childSlug(), { due: s, completed: a } = this._childCounts(n); + return { completed: a, due: s }; } _completeStats() { const e = this._childCounts(this._childSlug()).completed, t = this._instances().filter( - (r) => r.on_time === !0 + (a) => a.on_time === !0 ).length, n = b(e - t), s = this._partyCounts(); return { claimed: e, @@ -3841,19 +3825,19 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", bn = "polygon(50% }; } _completeSub(e) { - const t = j(this._now, this._timeZone()), { claimed: n, onTime: s, late: r } = this._completeStats(); - return n === 0 ? `${e} sealed the day on ${t}.` : r === 0 ? `${e} claimed every quest on ${t} — all on time.` : s === 0 ? `${e} claimed every quest on ${t} — all late.` : `${e} claimed every quest on ${t} — ${s} on time, ${r} late.`; + const t = j(this._now, this._timeZone()), { claimed: n, onTime: s, late: a } = this._completeStats(); + return n === 0 ? `${e} sealed the day on ${t}.` : a === 0 ? `${e} claimed every quest on ${t} — all on time.` : s === 0 ? `${e} claimed every quest on ${t} — all late.` : `${e} claimed every quest on ${t} — ${s} on time, ${a} late.`; } _otherChildren() { const e = this.hass?.states; if (!e) return []; const t = this._childSlug(), n = this._timeZone(), s = []; - for (const r of Object.keys(e)) { - const a = /^sensor\.nestquest_(.+)_quests_due_today$/.exec(r); - if (!a || a[1] === "household" || a[1] === t) + for (const a of Object.keys(e)) { + const r = /^sensor\.nestquest_(.+)_quests_due_today$/.exec(a); + if (!r || r[1] === "household" || r[1] === t) continue; - const l = a[1], o = e[r]?.attributes ?? {}; + const l = r[1], o = e[a]?.attributes ?? {}; let p = ""; typeof o.child_name == "string" && o.child_name.trim() ? p = o.child_name.trim() : p = ie(l); let u = !0; @@ -3867,7 +3851,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", bn = "polygon(50% } const c = this._state( `sensor.nestquest_${l}_quests_completed_today` - ), f = b(y(e[r]?.state, 0)), h = b(y(c?.state, 0)); + ), f = b(y(e[a]?.state, 0)), h = b(y(c?.state, 0)); let m = null; if (!u) { const _ = this._state( @@ -3887,10 +3871,10 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", bn = "polygon(50% returnsWeekday: m }); } - return s.sort((r, a) => r.name.localeCompare(a.name)); + return s.sort((a, r) => a.name.localeCompare(r.name)); } _renderHeader(e) { - const t = this._childSlug(), n = e.kind === "away", s = n ? e.name : this._childName(t) ?? (t ? ie(t) : null), { due: r, completed: a } = this._childCounts(t), l = this._remaining(), o = this._partyCounts(), p = s ? `${s}'s Quest Log` : "Quest Log", u = (s?.charAt(0) || "?").toUpperCase(); + const t = this._childSlug(), n = e.kind === "away", s = n ? e.name : this._childName(t) ?? (t ? ie(t) : null), { due: a, completed: r } = this._childCounts(t), l = this._remaining(), o = this._partyCounts(), p = s ? `${s}'s Quest Log` : "Quest Log", u = (s?.charAt(0) || "?").toUpperCase(); return d`
${this._boardPath() ? d` @@ -3899,7 +3883,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", bn = "polygon(50% type="button" @click=${() => this._returnToBoard()} > - ${wn} + ${xn} The Party ` : g} @@ -3912,7 +3896,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", bn = "polygon(50%

${p}

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

${n ? g : d` @@ -3940,28 +3924,28 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", bn = "polygon(50% (l, o) => (l.due_time ?? "99:99").localeCompare(o.due_time ?? "99:99") ), s = t.filter( (l) => l.state === "completed" - ), r = t.length; - let a = g; - if (r > 0) { + ), a = t.length; + let r = g; + if (a > 0) { const o = 644 - (e.key === "afternoon" && this._otherChildren().length > 0 ? 160 : 0), p = Math.min( 24, - Math.max(8, Math.floor((o - r * 74) / Math.max(r - 1, 1))) + Math.max(8, Math.floor((o - a * 74) / Math.max(a - 1, 1))) ), u = Math.min( 116, - Math.max(58, Math.floor((o - (r - 1) * p) / r)) + Math.max(58, Math.floor((o - (a - 1) * p) / a)) ), c = Math.max(56, Math.min(72, u - 2)); let f = `--nq-p-quest-gap: ${p}px; --nq-p-quest-min-height: ${u}px; --nq-p-button-height: ${c}px`; - if (r >= 5) { + if (a >= 5) { const h = Math.max(0, Math.min(22, Math.floor((u - c - 2) / 2))), m = Math.max(44, Math.min(64, u - 2 * h - 2)); f += `; --nq-p-quest-pad: ${h}px; --nq-p-tile-size: ${m}px`; } - a = f; + r = f; } return d`
${e.icon} @@ -3989,7 +3973,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", bn = "polygon(50% } _renderQuest(e, t, n) { if (t) { - const a = je[n % je.length], l = e.completed_at ? ln(e.completed_at, this._timeZone()) : ""; + const r = je[n % je.length], l = e.completed_at ? ln(e.completed_at, this._timeZone()) : ""; return d`
@@ -4002,14 +3986,14 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", bn = "polygon(50%
`; } - const s = dn(e.due_time), r = this._isOverdue(e) ? { + const s = dn(e.due_time), a = e.overdue ? { text: s ? `Overdue · due ${s}` : "Overdue", late: !0 } : { @@ -4024,14 +4008,14 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", bn = "polygon(50% data-instance-id=${e.id} aria-label="Complete ${e.title}" @click=${() => this._openConfirm(e.id)} - @keydown=${(a) => { - (a.key === "Enter" || a.key === " ") && (a.preventDefault(), this._openConfirm(e.id)); + @keydown=${(r) => { + (r.key === "Enter" || r.key === " ") && (r.preventDefault(), this._openConfirm(e.id)); }} >
${e.title} - ${r.text} + ${a.text}
`; - const t = _n(e.condition), n = yn(e.condition, t), s = e.temperature === null ? g : d`${Math.round(e.temperature)}°`, r = e.high === null || e.low === null ? null : `${Math.round(e.high)}° / ${Math.round(e.low)}°`; + const t = gn(e.condition), n = _n(e.condition, t), s = e.temperature === null ? g : d`${Math.round(e.temperature)}°`, a = e.high === null || e.low === null ? null : `${Math.round(e.high)}° / ${Math.round(e.low)}°`; return d`
- ${Sn} + ${An} ${s} - ${r === null ? t : `${t} · ${r}`} + ${a === null ? t : `${t} · ${a}`} ${n} @@ -4277,7 +4261,7 @@ W.properties = { _optimistic: { state: !0 }, _toast: { state: !0 }, _countdown: { state: !0 } -}, W.styles = [x(Xe), vn]; +}, W.styles = [x(Xe), bn]; let oe = W; customElements.define("nestquest-quest-log-card", oe); fe({ @@ -4285,7 +4269,7 @@ fe({ name: "NestQuest Quest Log", description: "One child's daily quests, grouped by window." }); -const Nn = ":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 { +const En = ":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(e) { if (!e || typeof e != "object") throw new Error("Invalid configuration"); @@ -4301,7 +4285,7 @@ const Nn = ":host{--nq-brand-blue: #3B3AB8;--nq-brand-purple: #A035CC;--nq-brand G.properties = { hass: { attribute: !1 }, _config: { state: !0 } -}, G.styles = [x(Nn)]; +}, G.styles = [x(En)]; let le = G; customElements.define("nestquest-admin-card", le); fe({ @@ -4312,59 +4296,59 @@ fe({ function et(i) { return typeof i == "string" ? i.trim() : ""; } -function Ln(i) { +function Nn(i) { if (i === null || typeof i != "object" || Array.isArray(i)) return ""; const e = i.slug; return typeof e == "string" ? e.trim() : ""; } -function On(i) { +function Ln(i) { const e = et(i.url_path).replace(/^\/+|\/+$/g, ""); return e || (window.location.pathname.split("/").filter(Boolean)[0] ?? ""); } -function Bn(i) { +function On(i) { if (i === null || typeof i != "object" || Array.isArray(i)) return ""; const e = i.name; return typeof e == "string" ? e.trim() : ""; } -function In(i) { +function Bn(i) { const n = i?.states?.["sensor.nestquest_household_quests_due_today"]?.attributes?.child_roster; - return Array.isArray(n) ? n.map((s) => ({ slug: Ln(s), name: Bn(s) })).filter((s) => s.slug.length > 0) : []; + return Array.isArray(n) ? n.map((s) => ({ slug: Nn(s), name: On(s) })).filter((s) => s.slug.length > 0) : []; } -class Pn { +class In { static async generate(e, t) { - const n = `/${On(e)}`, s = et(e.weather_entity), r = { + const n = `/${Ln(e)}`, s = et(e.weather_entity), a = { type: "custom:nestquest-party-board-card", quest_log_path: n }; - s && (r.weather_entity = s); - const a = { + s && (a.weather_entity = s); + const r = { type: "custom:nestquest-quest-log-card", board_path: n }; - return s && (a.weather_entity = s), { + return s && (r.weather_entity = s), { views: [ { path: "party", title: "The Party", type: "panel", - cards: [r] + cards: [a] }, - ...In(t).map((l) => ({ + ...Bn(t).map((l) => ({ path: l.slug, title: l.name ? `${l.name}'s Quest Log` : `${l.slug}'s Quest Log`, type: "panel", - cards: [{ ...a }] + cards: [{ ...r }] })) ] }; } } window.customStrategies = window.customStrategies ?? {}; -window.customStrategies["nestquest-party"] = Pn; -const Hn = "/nestquest-static/nestquest-fonts.css"; +window.customStrategies["nestquest-party"] = In; +const Pn = "/nestquest-static/nestquest-fonts.css"; if (!document.querySelector('link[data-nq-fonts=""]')) { const i = document.createElement("link"); - i.rel = "stylesheet", i.href = Hn, i.dataset.nqFonts = "", document.head.appendChild(i); + i.rel = "stylesheet", i.href = Pn, i.dataset.nqFonts = "", document.head.appendChild(i); } diff --git a/frontend/src/cards/quest-log.ts b/frontend/src/cards/quest-log.ts index ce06bc1..9e49bfe 100644 --- a/frontend/src/cards/quest-log.ts +++ b/frontend/src/cards/quest-log.ts @@ -36,6 +36,7 @@ type PanelInstance = { window: string; due_time: string | null; state: string; + overdue: boolean; completed_at: string | null; on_time: boolean | null; }; @@ -153,17 +154,6 @@ function formatWallClock(value: string | null): string | null { return `${hour12}:${match[2]} ${suffix}`; } -/** Minutes past midnight of a household ``HH:MM`` wall time, or null. */ -function wallClockMinutes(value: string | null): number | null { - const match = value ? /^(\d{1,2}):(\d{2})$/.exec(value.trim()) : null; - if (!match) { - return null; - } - const hours = Number(match[1]); - const minutes = Number(match[2]); - return hours > 23 || minutes > 59 ? null : hours * 60 + minutes; -} - function asString(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } @@ -263,6 +253,7 @@ function toPanelInstance(value: unknown): PanelInstance | null { window: asString(row.window).toLowerCase(), due_time: asString(row.due_time) || null, state, + overdue: row.overdue === true, completed_at: asString(row.completed_at) || null, on_time: row.on_time === true ? true : row.on_time === false ? false : null, }; @@ -1758,20 +1749,6 @@ export class NestQuestQuestLogCard extends LitElement { `; } - /** Overdue by the panel's own clock: the browser runs on the - household's real local time, so an open quest turns overdue once its - ``due_time`` wall time has arrived there — whatever zone the API or - Home Assistant computed the API's ``overdue`` flag in. With no due - time it is never overdue (the API's rule too). ``_now`` is reactive - and ticks every 60 s, so the line flips on the minute it falls due. */ - private _isOverdue(instance: PanelInstance): boolean { - const due = wallClockMinutes(instance.due_time); - if (instance.state !== "open" || due === null) { - return false; - } - return this._now.getHours() * 60 + this._now.getMinutes() >= due; - } - private _timeZone(): string | undefined { return hassTimeZone(this.hass); } @@ -1856,6 +1833,7 @@ export class NestQuestQuestLogCard extends LitElement { continue; } instance.state = "completed"; + instance.overdue = false; instance.completed_at = stampedAt; } if (settled) { @@ -2152,7 +2130,7 @@ export class NestQuestQuestLogCard extends LitElement { `; } const dueAt = formatWallClock(instance.due_time); - const meta = this._isOverdue(instance) + const meta = instance.overdue ? { text: dueAt ? `Overdue · due ${dueAt}` : "Overdue", late: true, diff --git a/tests/test_api_household_clock.py b/tests/test_api_household_clock.py index 94ea144..13c05f0 100644 --- a/tests/test_api_household_clock.py +++ b/tests/test_api_household_clock.py @@ -92,6 +92,7 @@ async def household(temp_db_path: str, monkeypatch) -> SimpleNamespace: seed=seed, clock=clock, database=runner.app.state.db.database, + app=runner.app, ) @@ -532,6 +533,105 @@ async def test_scheduler_rechecks_settings_before_sweeping( await database.close() +# --- the admin settings route is the scheduler's writer (task 7e173881) ------ +# +# ``PATCH /api/v1/admin/settings`` is the only settings writer: it holds +# the scheduler's settings lock across the write and tells the scheduler, +# so a saved zone or rollover re-plans a sleeping scheduler. + + +async def _sleeping_scheduler(household: SimpleNamespace): + """Swap in a scheduler on the pinned clock; return it and its delays. + + Waits until it sleeps on its first plan. + """ + await household.app.state.sweep_scheduler.stop() + delays: list[float] = [] + slept = asyncio.Condition() + + async def sleep_stub(seconds: float) -> None: + async with slept: + delays.append(seconds) + slept.notify_all() + await asyncio.Event().wait() + + scheduler = MissedSweepScheduler( + household.database, + clock=lambda: household.clock["now"], + sleep=sleep_stub, + ) + household.app.state.sweep_scheduler = scheduler + scheduler.start() + + async def wait_for_sleeps(count: int) -> None: + async with slept: + await asyncio.wait_for( + slept.wait_for(lambda: len(delays) == count), timeout=5 + ) + + await wait_for_sleeps(1) + return scheduler, delays, wait_for_sleeps + + +@pytest.mark.parametrize( + ("changes", "replanned_delay"), + [ + # 13:00 UTC is 09:00 EDT: New York midnight is 15 hours away. + ({"timezone": HOUSEHOLD_ZONE}, _NEW_YORK_MIDNIGHT_DELAY), + # Still UTC: 13:00 to a 14:30 rollover is 90 minutes. + ({"day_rollover_time": "14:30"}, 90 * 60), + ], + ids=["timezone", "day_rollover_time"], +) +async def test_admin_settings_patch_replans_a_sleeping_scheduler( + household: SimpleNamespace, changes: dict, replanned_delay: float +) -> None: + household.clock["now"] = _utc(13, 0) + scheduler, delays, wait_for_sleeps = await _sleeping_scheduler(household) + try: + response = await household.client.patch( + "/api/v1/admin/settings", json=changes, headers=_admin_headers() + ) + assert response.status_code == 200 + await wait_for_sleeps(2) + finally: + await scheduler.stop() + assert delays == [ + pytest.approx(_UTC_MIDNIGHT_DELAY), + pytest.approx(replanned_delay), + ] + + +async def test_admin_saved_zone_is_authoritative_across_surfaces( + household: SimpleNamespace, +) -> None: + """ONE instant, read by every surface in exactly the admin's zone. + + Asia/Kolkata (UTC+05:30) matches neither the UTC host nor any other + zone in play. 04:30:01 UTC is 10:00:01 there: the 10:00 quest is + overdue on both snapshots (it would not be in UTC, 04:30), the + household day is the 26th, and the scheduler sleeps the 299 s to a + 10:05 rollover (UTC would be 5h35m). + """ + household.clock["now"] = _utc(4, 30, 1) + scheduler, delays, wait_for_sleeps = await _sleeping_scheduler(household) + try: + response = await household.client.patch( + "/api/v1/admin/settings", + json={"timezone": "Asia/Kolkata", "day_rollover_time": "10:05"}, + headers=_admin_headers(), + ) + assert response.status_code == 200 + await wait_for_sleeps(2) + for plane in ("panel", "admin"): + today_iso, instance = await _pack_bag(household, plane) + assert today_iso == TODAY.isoformat(), plane + assert instance["overdue"] is True, plane + finally: + await scheduler.stop() + assert delays[1] == pytest.approx(299) + + # --- settings writes are serialised with planning and sweeping (TZ-003) ------ # # A zone write racing the loop's own settings reads must never leave a diff --git a/tests/test_panel_cards.py b/tests/test_panel_cards.py index 5028d05..7065bbe 100644 --- a/tests/test_panel_cards.py +++ b/tests/test_panel_cards.py @@ -1102,13 +1102,12 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None: ``meta ${late ? "late" : nothing}``, Lit dropped the whole class attribute for every quest that was not late, leaving the due line unstyled. The rendered markup must carry ``class="meta"`` on time and - ``class="meta late"`` overdue — overdue by the panel's pinned clock - (07:15 local: the 07:00 quest is late, the 08:15 one is not).""" + ``class="meta late"`` overdue — overdue as the API snapshot says.""" generated = _generate_dashboard( {}, _three_child_states(), url="http://homeassistant.local/nestquest" ) - def quest(instance_id: int, title: str, due_time: str) -> dict: + def quest(instance_id: int, title: str, overdue: bool) -> dict: return { "id": instance_id, "definition_id": instance_id, @@ -1116,9 +1115,9 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None: "title": title, "icon": None, "window": "morning", - "due_time": due_time, + "due_time": "08:15", "state": "open", - "overdue": False, + "overdue": overdue, "completed_at": None, "on_time": None, } @@ -1131,8 +1130,8 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None: child_id=1, present=True, instances=[ - quest(41, "Make Bed", "08:15"), - quest(42, "Brush Teeth", "07:00"), + quest(41, "Make Bed", overdue=False), + quest(42, "Brush Teeth", overdue=True), ], ), } @@ -1140,8 +1139,6 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None: generated["views"][1]["cards"][0], states, url="http://homeassistant.local/nestquest/ada", - now="2026-09-29T11:15:00Z", - tz="America/New_York", ) metas = {} for card in result["html"].split('data-instance-id="')[1:]: @@ -1175,19 +1172,22 @@ def _quest_metas(html: str) -> dict[str, tuple[str, str]]: return metas -def test_quest_log_overdue_follows_the_panel_local_clock() -> None: - """Overdue is decided by the panel's own local clock, not the API's - ``overdue`` flag. The owner saw 8:15 AM quests read OVERDUE at 7:15 AM - America/New_York: the API's flag had been computed on a UTC wall clock - (11:15). Here the API still says ``overdue: true`` and Home - Assistant reports ``time_zone`` UTC, yet at 07:15 local the 08:15 quest - is due, at 08:15 (and 08:16, via the card's own clock tick) it is - overdue, and a quest with no due time is never overdue.""" +def test_quest_log_overdue_follows_the_api_snapshot_not_the_panel_clock() -> None: + """The stored application timezone is the single source of local-time + truth: the API computes each instance's ``overdue`` in that zone and + the card renders exactly that, whatever the panel browser's own clock + or zone says. Here the browser runs in Pacific/Auckland and its clock + is pinned (and later ticked) to instants where every due time has, or + has not yet, arrived on its local wall clock — the card must still + show the snapshot's state: the ``overdue: true`` 08:15 quest reads + OVERDUE even though the browser reads long before 08:15, and the + ``overdue: false`` 07:00 quest stays due even though the browser reads + long after 07:00.""" generated = _generate_dashboard( {}, _three_child_states(), url="http://homeassistant.local/nestquest" ) - def quest(instance_id: int, title: str, due_time: str | None) -> dict: + def quest(instance_id: int, title: str, due_time: str, overdue: bool) -> dict: return { "id": instance_id, "definition_id": instance_id, @@ -1197,7 +1197,7 @@ def test_quest_log_overdue_follows_the_panel_local_clock() -> None: "window": "morning", "due_time": due_time, "state": "open", - "overdue": True, + "overdue": overdue, "completed_at": None, "on_time": None, } @@ -1210,29 +1210,29 @@ def test_quest_log_overdue_follows_the_panel_local_clock() -> None: child_id=1, present=True, instances=[ - quest(42, "Brush Teeth", "08:15"), - quest(43, "Feed Fish", None), + quest(42, "Brush Teeth", "08:15", overdue=True), + quest(43, "Feed Fish", "07:00", overdue=False), ], ), } + # 2026-09-29T17:00Z is 06:00 NZDT on the 30th: before both due times. + # 2026-09-29T23:00Z is 12:00 NZDT: after both. result = _render_quest_log( generated["views"][1]["cards"][0], states, url="http://homeassistant.local/nestquest/ada", - now="2026-09-29T11:15:00Z", - advance_to=["2026-09-29T12:15:00Z", "2026-09-29T12:16:00Z"], - tz="America/New_York", + now="2026-09-29T17:00:00Z", + advance_to=["2026-09-29T23:00:00Z"], + tz="Pacific/Auckland", ) - due_by = ('class="meta"', "Due by 8:15 AM") overdue = ('class="meta late"', "Overdue · due 8:15 AM") - not_due = ('class="meta"', "Due today") - at_0715, at_0815, at_0816 = ( + due_by = ('class="meta"', "Due by 7:00 AM") + before, (after,) = ( _quest_metas(result["html"]), - *(_quest_metas(tick["html"]) for tick in result["ticks"]), + [_quest_metas(tick["html"]) for tick in result["ticks"]], ) - assert at_0715 == {"42": due_by, "43": not_due}, at_0715 - assert at_0815 == {"42": overdue, "43": not_due}, at_0815 - assert at_0816 == {"42": overdue, "43": not_due}, at_0816 + assert before == {"42": overdue, "43": due_by}, before + assert after == {"42": overdue, "43": due_by}, after def test_quest_log_crest_matches_the_spec_geometry() -> None: