From e5601811d616a759a68d25dea55a3b534426071f Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 30 Sep 2026 09:50:25 -0400 Subject: [PATCH 1/3] 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: -- 2.55.0 From cf170214a6afd54a2604d5356fd305a539991331 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 30 Sep 2026 09:50:25 -0400 Subject: [PATCH 2/3] Format the panel and admin date and clock in the stored application timezone The panel snapshot now carries the admin-saved `timezone` ("" when unset); the coordinator threads it onto the household due-today sensor, and the Party Board and Quest Log cards format their kicker, header and dock date and clock in it, falling back to Home Assistant's zone only when none is stored. The admin app loads the stored zone once (AppTimezoneProvider) and uses it for completion clocks, History day grouping, clocks and lateness, and "today" in Schedule, History and new task defaults; Settings publishes a saved zone app-wide at once. Resolves review finding TZ-001 (task 7e173881). Co-Authored-By: Claude Opus 5.5 --- admin/src/App.test.tsx | 53 +++++++-- admin/src/App.tsx | 22 ++-- admin/src/definitions/DefinitionsTab.test.tsx | 54 ++++++++- admin/src/definitions/DefinitionsTab.tsx | 42 ++++--- admin/src/history/HistoryTab.test.tsx | 112 ++++++++++++++++++ admin/src/history/HistoryTab.tsx | 18 +-- admin/src/history/format.ts | 45 ++++--- admin/src/schedule/OverrideEditor.test.tsx | 2 + admin/src/schedule/ScheduleTab.test.tsx | 55 ++++++++- admin/src/schedule/ScheduleTab.tsx | 9 +- admin/src/schedule/cycle.ts | 7 -- admin/src/settings/SettingsScreen.test.tsx | 40 +++++++ admin/src/settings/SettingsScreen.tsx | 10 +- admin/src/time/AppTimezone.tsx | 91 ++++++++++++++ admin/src/time/zone.test.ts | 60 ++++++++++ admin/src/time/zone.ts | 79 ++++++++++++ admin/src/today/TodayTab.test.tsx | 54 +++++++++ admin/src/today/TodayTab.tsx | 4 +- admin/src/today/format.ts | 14 ++- api/routes_panel.py | 12 +- custom_components/nestquest/coordinator.py | 3 + custom_components/nestquest/core/snapshot.py | 5 + custom_components/nestquest/sensor.py | 3 + .../nestquest/www/nestquest-cards.js | 60 ++++++---- frontend/src/cards/party-board.ts | 10 +- frontend/src/cards/quest-log.ts | 10 +- frontend/tests/render-party-board.mjs | 25 +++- tests/conftest.py | 1 + tests/test_api_admin_snapshot.py | 6 +- tests/test_api_household_clock.py | 18 +++ tests/test_binary_sensors.py | 1 + tests/test_complete_quest_proxy.py | 1 + tests/test_coordinator.py | 8 +- tests/test_core_snapshot.py | 1 + tests/test_entity_lifecycle.py | 1 + tests/test_entity_matrix.py | 1 + tests/test_events.py | 1 + tests/test_panel_cards.py | 74 +++++++++++- tests/test_sensors.py | 17 +++ tests/test_sse_subscription.py | 7 +- 40 files changed, 910 insertions(+), 126 deletions(-) create mode 100644 admin/src/time/AppTimezone.tsx create mode 100644 admin/src/time/zone.test.ts create mode 100644 admin/src/time/zone.ts diff --git a/admin/src/App.test.tsx b/admin/src/App.test.tsx index 7a38e6a..442f2ee 100644 --- a/admin/src/App.test.tsx +++ b/admin/src/App.test.tsx @@ -2,10 +2,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import App from "./App"; +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +let settingsResponse: () => Promise; + describe("NestQuest Admin shell", () => { beforeEach(() => { - // The Today tab fetches the admin snapshot; keep the shell tests offline. - vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); + settingsResponse = () => Promise.resolve(jsonResponse({ timezone: "" })); + // The shell loads the stored application timezone; every tab's own + // requests stay pending so the shell tests remain offline. + vi.stubGlobal( + "fetch", + vi.fn((url: string) => + String(url).endsWith("/api/v1/admin/settings") + ? settingsResponse() + : new Promise(() => {}), + ), + ); }); afterEach(() => { @@ -22,10 +40,10 @@ describe("NestQuest Admin shell", () => { } }); - it("starts on the Today screen and switches on tab click", () => { + it("starts on the Today screen and switches on tab click", async () => { render(); - expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy(); + expect(await screen.findByRole("heading", { name: "Today" })).toBeTruthy(); fireEvent.click(screen.getByRole("tab", { name: "History" })); @@ -36,12 +54,33 @@ describe("NestQuest Admin shell", () => { ); }); - it("the Tasks tab renders the definitions screen", () => { + it("the Tasks tab renders the definitions screen", async () => { render(); fireEvent.click(screen.getByRole("tab", { name: "Tasks" })); - expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy(); + expect(await screen.findByRole("heading", { name: "Tasks" })).toBeTruthy(); expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder"); }); -}); \ No newline at end of file + + it("renders no screen until the stored application timezone is known", async () => { + settingsResponse = () => new Promise(() => {}); + render(); + + expect(screen.getByTestId("timezone-loading")).toBeTruthy(); + expect(screen.queryByRole("heading", { name: "Today" })).toBeNull(); + }); + + it("a failed timezone load says so and retries", async () => { + settingsResponse = () => Promise.resolve(jsonResponse({ detail: "boom" }, 500)); + render(); + + await screen.findByTestId("timezone-error"); + expect(screen.queryByRole("heading", { name: "Today" })).toBeNull(); + + settingsResponse = () => Promise.resolve(jsonResponse({ timezone: "Pacific/Auckland" })); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + + expect(await screen.findByRole("heading", { name: "Today" })).toBeTruthy(); + }); +}); diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 5f44fad..31ab78a 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -4,6 +4,7 @@ import { TransitionsProvider } from "./api/useTransitions"; import DefinitionsTab from "./definitions/DefinitionsTab"; import HistoryTab from "./history/HistoryTab"; import ScheduleTab from "./schedule/ScheduleTab"; +import { AppTimezoneProvider } from "./time/AppTimezone"; import TodayTab from "./today/TodayTab"; const TABS = [ @@ -32,15 +33,18 @@ export default function App() { role="tabpanel" aria-labelledby={`tab-${active}`} > - {active === "today" ? ( - - ) : active === "tasks" ? ( - - ) : active === "schedule" ? ( - - ) : ( - - )} + {/* Every screen's local time is the stored application timezone. */} + + {active === "today" ? ( + + ) : active === "tasks" ? ( + + ) : active === "schedule" ? ( + + ) : ( + + )} +
- {rowTime(row)} + {rowTime(row, timeZone)} ); } @@ -161,7 +162,10 @@ function FooterNote() { ); } -export default function HistoryTab({ today = localTodayIso() }: { today?: string }) { +/** `today` ("YYYY-MM-DD") defaults to today in the application timezone. */ +export default function HistoryTab({ today: todayProp }: { today?: string }) { + const timeZone = useAppTimezone(); + const today = todayProp ?? todayIn(timeZone); const [filter, setFilter] = useState("all"); const [state, setState] = useState({ kind: "loading" }); const [attempt, setAttempt] = useState(0); @@ -244,7 +248,7 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string ); } else { - const groups = groupByDay(shown.data.rows); + const groups = groupByDay(shown.data.rows, timeZone); body = groups.length > 0 ? ( groups.map((group) => ( @@ -252,7 +256,7 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string

{dayLabel(group.day, today)}

    {group.rows.map((row, index) => ( - + ))}
diff --git a/admin/src/history/format.ts b/admin/src/history/format.ts index 7575563..5b64058 100644 --- a/admin/src/history/format.ts +++ b/admin/src/history/format.ts @@ -4,12 +4,13 @@ * Calendar dates ("YYYY-MM-DD") are handled by their components (never * `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous * day in negative-offset zones); event stamps are UTC and shown in the - * viewer's local time. + * application timezone (`timeZone`: the stored IANA zone, "" = browser). */ import type { HistoryRow } from "../api/history"; -import { dayNumber, isoFromDayNumber, localTodayIso } from "../schedule/cycle"; +import { dayNumber, isoFromDayNumber } from "../schedule/cycle"; import { shortDate } from "../schedule/format"; import { formatClockTime, formatDueTime } from "../today/format"; +import { isoDateIn, wallClockInstant } from "../time/zone"; const MS_PER_MINUTE = 60_000; @@ -19,25 +20,21 @@ export function shiftIso(iso: string, days: number): string { } /** The local calendar day an event belongs to (due_date for missed rows). */ -export function rowDay(row: HistoryRow): string { - return row.occurred_at ? localTodayIso(new Date(row.occurred_at)) : row.due_date; +export function rowDay(row: HistoryRow, timeZone: string): string { + return row.occurred_at ? isoDateIn(new Date(row.occurred_at), timeZone) : row.due_date; } -/** The row's due moment as local wall-clock time, or null without a due time. */ -function dueMoment(row: HistoryRow): Date | null { +/** The row's due moment (epoch ms) as local wall-clock time, or null without a due time. */ +function dueMoment(row: HistoryRow, timeZone: string): number | null { const match = /^(\d{2}):(\d{2})/.exec(row.due_time ?? ""); if (!match) return null; - const [year, month, day] = row.due_date.split("-").map(Number); - return new Date(year, month - 1, day, Number(match[1]), Number(match[2])); + return wallClockInstant(row.due_date, Number(match[1]), Number(match[2]), timeZone); } /** Newest first; a missed row sorts at its due moment (or the end of its day). */ -function sortKey(row: HistoryRow): number { +function sortKey(row: HistoryRow, timeZone: string): number { if (row.occurred_at) return Date.parse(row.occurred_at); - const due = dueMoment(row); - if (due) return due.getTime(); - const [year, month, day] = row.due_date.split("-").map(Number); - return new Date(year, month - 1, day, 23, 59).getTime(); + return dueMoment(row, timeZone) ?? wallClockInstant(row.due_date, 23, 59, timeZone); } export interface DayGroup { @@ -46,10 +43,10 @@ export interface DayGroup { } /** Rows grouped by day, newest day first and newest row first within a day. */ -export function groupByDay(rows: HistoryRow[]): DayGroup[] { +export function groupByDay(rows: HistoryRow[], timeZone: string): DayGroup[] { const groups = new Map(); for (const row of rows) { - const day = rowDay(row); + const day = rowDay(row, timeZone); groups.set(day, [...(groups.get(day) ?? []), row]); } return [...groups.entries()] @@ -57,7 +54,7 @@ export function groupByDay(rows: HistoryRow[]): DayGroup[] { .map(([day, dayRows]) => ({ day, // Stable sort: rows at the same moment keep the API's append order. - rows: [...dayRows].sort((a, b) => sortKey(b) - sortKey(a)), + rows: [...dayRows].sort((a, b) => sortKey(b, timeZone) - sortKey(a, timeZone)), })); } @@ -72,23 +69,23 @@ export function dayLabel(day: string, today: string): string { } /** "on time", "late 22 min", or "late" when the lateness is not derivable. */ -function onTimeFlag(row: HistoryRow): string | null { +function onTimeFlag(row: HistoryRow, timeZone: string): string | null { if (row.event_type !== "completed" || row.was_on_time === null) return null; if (row.was_on_time) return "on time"; - const due = dueMoment(row); - if (!due || !row.occurred_at) return "late"; - const minutes = Math.round((Date.parse(row.occurred_at) - due.getTime()) / MS_PER_MINUTE); + const due = dueMoment(row, timeZone); + if (due === null || !row.occurred_at) return "late"; + const minutes = Math.round((Date.parse(row.occurred_at) - due) / MS_PER_MINUTE); return minutes > 0 ? `late ${minutes} min` : "late"; } /** ` · ` plus the on-time flag for completions. */ -export function rowMeta(row: HistoryRow): string { - const flag = onTimeFlag(row); +export function rowMeta(row: HistoryRow, timeZone: string): string { + const flag = onTimeFlag(row, timeZone); return [row.event_type, row.actor, flag].filter(Boolean).join(" · "); } /** The event's local clock time; a missed row shows its due time, if any. */ -export function rowTime(row: HistoryRow): string { - if (row.occurred_at) return formatClockTime(row.occurred_at); +export function rowTime(row: HistoryRow, timeZone: string): string { + if (row.occurred_at) return formatClockTime(row.occurred_at, timeZone); return row.due_time ? formatDueTime(row.due_time) : ""; } diff --git a/admin/src/schedule/OverrideEditor.test.tsx b/admin/src/schedule/OverrideEditor.test.tsx index c57c5d5..8e57c6c 100644 --- a/admin/src/schedule/OverrideEditor.test.tsx +++ b/admin/src/schedule/OverrideEditor.test.tsx @@ -74,6 +74,8 @@ async function routeFetch(url: string, init: RequestInit = {}): Promise o.id !== Number(deleteMatch[1])); return jsonResponse({ status: "ok" }); } + // The shell loads the stored application timezone first ("" = unset). + if (path.endsWith("/admin/settings")) return jsonResponse({ timezone: "" }); if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN }); const patternsMatch = /\/children\/(\d+)\/presence-patterns$/.exec(path); if (patternsMatch) { diff --git a/admin/src/schedule/ScheduleTab.test.tsx b/admin/src/schedule/ScheduleTab.test.tsx index 91b0e62..087c0b8 100644 --- a/admin/src/schedule/ScheduleTab.test.tsx +++ b/admin/src/schedule/ScheduleTab.test.tsx @@ -1,9 +1,10 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import ScheduleTab from "./ScheduleTab"; import type { AdminChild } from "../api/definitions"; import type { PresenceOverride, PresencePattern } from "../api/presence"; import { storeTokens } from "../auth/oidc"; +import { AppTimezoneProvider } from "../time/AppTimezone"; import { cycleWeekIndex, monthDates, weekdayOf } from "./cycle"; function child(id: number, display_name: string, sort_order: number, is_active = true): AdminChild { @@ -655,3 +656,55 @@ describe("ScheduleTab", () => { expect(await screen.findByTestId("pattern-1")).toBeTruthy(); }); }); + +describe("ScheduleTab today in the stored application timezone", () => { + async function renderWithZone(timezone: string) { + fetchMock = vi.fn(async (url: string, init?: RequestInit) => + String(url).endsWith("/api/v1/admin/settings") + ? jsonResponse({ timezone }) + : routeFetch(url, init), + ); + vi.stubGlobal("fetch", fetchMock); + render( + + + , + ); + await screen.findByTestId("pattern-1"); + } + + beforeAll(() => { + vi.stubEnv("TZ", "UTC"); + }); + + afterAll(() => { + vi.unstubAllEnvs(); + }); + + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + patterns = { 1: [DECLAN_HOME], 2: [MAEVE_THU_FRI, MAEVE_WEEKENDS], 3: [] }; + writeFailure = null; + // 13:00 UTC on Sep 23 is already 01:00 on Sep 24 in Auckland. + vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-09-23T13:00:00Z") }); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("marks today in the stored zone", async () => { + await renderWithZone("Pacific/Auckland"); + expect(day("2026-09-24").className).toContain("schedule-day--today"); + expect(day("2026-09-23").className).not.toContain("schedule-day--today"); + }); + + it("an empty stored zone marks the browser's local today", async () => { + await renderWithZone(""); + expect(day("2026-09-23").className).toContain("schedule-day--today"); + expect(day("2026-09-24").className).not.toContain("schedule-day--today"); + }); +}); diff --git a/admin/src/schedule/ScheduleTab.tsx b/admin/src/schedule/ScheduleTab.tsx index 06074ac..16243f2 100644 --- a/admin/src/schedule/ScheduleTab.tsx +++ b/admin/src/schedule/ScheduleTab.tsx @@ -20,7 +20,6 @@ import { import { cycleLabel, dayState, - localTodayIso, monthDates, monthStart, patternDaysLabel, @@ -38,6 +37,8 @@ import { } from "./glyphs"; import OverrideEditor from "./OverrideEditor"; import PatternEditor from "./PatternEditor"; +import { useAppTimezone } from "../time/AppTimezone"; +import { todayIn } from "../time/zone"; import "./ScheduleTab.css"; /** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */ @@ -324,11 +325,13 @@ function OverrideRow({ override, childName, deletion, onAskDelete, onKeep, onDel } export interface ScheduleTabProps { - /** "YYYY-MM-DD"; defaults to the viewer's local date. */ + /** "YYYY-MM-DD"; defaults to today in the application timezone. */ today?: string; } -export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProps) { +export default function ScheduleTab({ today: todayProp }: ScheduleTabProps) { + const timeZone = useAppTimezone(); + const today = todayProp ?? todayIn(timeZone); const [state, setState] = useState({ kind: "loading" }); const [attempt, setAttempt] = useState(0); const [selectedId, setSelectedId] = useState(null); diff --git a/admin/src/schedule/cycle.ts b/admin/src/schedule/cycle.ts index d8e3491..9701b88 100644 --- a/admin/src/schedule/cycle.ts +++ b/admin/src/schedule/cycle.ts @@ -55,13 +55,6 @@ export function cycleWeekIndex(anchorDate: string, date: string, cycleLengthWeek return mod(Math.floor(days / 7), cycleLengthWeeks); } -/** The viewer's local calendar date. */ -export function localTodayIso(now: Date = new Date()): string { - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - return `${now.getFullYear()}-${month}-${day}`; -} - /** "YYYY-MM-01" of the month containing `iso`, shifted by `delta` months. */ export function monthStart(iso: string, delta = 0): string { const [year, month] = iso.split("-").map(Number); diff --git a/admin/src/settings/SettingsScreen.test.tsx b/admin/src/settings/SettingsScreen.test.tsx index 3c4cfc2..3be1e5d 100644 --- a/admin/src/settings/SettingsScreen.test.tsx +++ b/admin/src/settings/SettingsScreen.test.tsx @@ -6,6 +6,7 @@ import SettingsScreen, { SCROLL_BOTTOM_PADDING } from "./SettingsScreen"; import type { AdminChild } from "../api/definitions"; import type { HouseholdSettings } from "../api/settings"; import { storeTokens } from "../auth/oidc"; +import { AppTimezoneProvider, useAppTimezone } from "../time/AppTimezone"; const CHILDREN_PATH = "/api/v1/admin/children"; const REORDER_PATH = `${CHILDREN_PATH}/reorder`; @@ -791,3 +792,42 @@ describe("SettingsScreen — manual timezone control", () => { } }); }); + +describe("SettingsScreen — the saved zone applies app-wide", () => { + function ZoneProbe() { + return {useAppTimezone() || "(browser)"}; + } + + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + children = [child(1, "Declan", 1)]; + settings = { ...DEFAULT_SETTINGS, timezone: "" }; + writeResponse = null; + fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init)); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("a saved zone becomes the application timezone at once", async () => { + render( + + + + , + ); + const form = await screen.findByRole("form", { name: "Preferences" }); + expect(screen.getByTestId("app-zone").textContent).toBe("(browser)"); + + fireEvent.change(within(form).getByRole("combobox", { name: "Timezone" }), { + target: { value: "America/New_York" }, + }); + fireEvent.click(within(form).getByRole("button", { name: "Save preferences" })); + await screen.findByText("Preferences saved."); + expect(screen.getByTestId("app-zone").textContent).toBe("America/New_York"); + }); +}); diff --git a/admin/src/settings/SettingsScreen.tsx b/admin/src/settings/SettingsScreen.tsx index 1f31baa..6a176ee 100644 --- a/admin/src/settings/SettingsScreen.tsx +++ b/admin/src/settings/SettingsScreen.tsx @@ -7,7 +7,8 @@ * Preferences: the household settings (planning horizon, day rollover, * timezone, notifications). Save patches only the changed fields. The * timezone — the application's source of local time — is only ever set by - * the admin picking or typing a zone here and saving it. + * the admin picking or typing a zone here and saving it; every zone loaded + * or saved here is published app-wide (time/AppTimezone.tsx). * * One write at a time across both sections. */ @@ -35,6 +36,7 @@ import { type SettingsChanges, } from "../api/settings"; import { FOCUSABLE, inertOutside } from "../schedule/OverrideEditor"; +import { usePublishAppTimezone } from "../time/AppTimezone"; import { ArrowDownGlyph, ArrowUpGlyph, ChevronLeftGlyph, PlusGlyph } from "./glyphs"; import "./SettingsScreen.css"; @@ -593,6 +595,7 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) { const titleRef = useRef(null); const addRef = useRef(null); const formWasOpen = useRef(false); + const publishTimezone = usePublishAppTimezone(); // Modal focus: move in on open, keep the background out of reach, and hand // focus back to the opener (the Settings button) on close. @@ -634,7 +637,9 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) { setPreferences({ kind: "loading" }); fetchSettings() .then((settings) => { - if (!cancelled) setPreferences({ kind: "ready", settings }); + if (cancelled) return; + setPreferences({ kind: "ready", settings }); + publishTimezone(settings.timezone); }) .catch((error: unknown) => { if (cancelled) return; @@ -728,6 +733,7 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) { try { const settings = await updateSettings(changes); setPreferences({ kind: "ready", settings }); + publishTimezone(settings.timezone); setPreferencesMessage({ kind: "info", text: "Preferences saved." }); } catch (error) { setPreferencesMessage({ diff --git a/admin/src/time/AppTimezone.tsx b/admin/src/time/AppTimezone.tsx new file mode 100644 index 0000000..b31a440 --- /dev/null +++ b/admin/src/time/AppTimezone.tsx @@ -0,0 +1,91 @@ +/** + * The stored application timezone, loaded once per signed-in shell. + * + * fetches the household settings and renders its + * screens only once the zone is known (a failed load says so and offers a + * retry), so no screen ever computes local time in a zone it will not keep. + * The Settings screen publishes every zone it loads or saves, so a change + * applies app-wide at once. + * + * Without a provider (a screen rendered on its own, e.g. in a test) + * useAppTimezone() is "" — the documented unset value, i.e. browser-local. + */ +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; +import { ApiForbiddenError } from "../api/client"; +import { fetchSettings } from "../api/settings"; + +interface ZoneHub { + timeZone: string; + publish(timeZone: string): void; +} + +const AppTimezoneContext = createContext(null); + +type LoadState = + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "ready"; timeZone: string }; + +export function AppTimezoneProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({ kind: "loading" }); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + let cancelled = false; + setState({ kind: "loading" }); + fetchSettings() + .then((settings) => { + if (!cancelled) setState({ kind: "ready", timeZone: settings.timezone }); + }) + .catch((error: unknown) => { + if (cancelled) return; + setState({ + kind: "error", + message: + error instanceof ApiForbiddenError + ? error.message + : "The application timezone could not be loaded. Check your connection and try again.", + }); + }); + return () => { + cancelled = true; + }; + }, [attempt]); + + if (state.kind === "loading") { + return ( +
+ Loading… +
+ ); + } + if (state.kind === "error") { + return ( +
+

{state.message}

+ +
+ ); + } + const hub: ZoneHub = { + timeZone: state.timeZone, + publish: (timeZone) => + setState((prev) => + prev.kind === "ready" && prev.timeZone === timeZone ? prev : { kind: "ready", timeZone }, + ), + }; + return {children}; +} + +/** The stored application timezone: an IANA name, or "" (browser-local). */ +export function useAppTimezone(): string { + return useContext(AppTimezoneContext)?.timeZone ?? ""; +} + +/** Publish a zone just loaded or saved; a no-op without a provider. */ +export function usePublishAppTimezone(): (timeZone: string) => void { + const hub = useContext(AppTimezoneContext); + return (timeZone) => hub?.publish(timeZone); +} diff --git a/admin/src/time/zone.test.ts b/admin/src/time/zone.test.ts new file mode 100644 index 0000000..03c3511 --- /dev/null +++ b/admin/src/time/zone.test.ts @@ -0,0 +1,60 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { isoDateIn, todayIn, wallClockInstant, zoneOption } from "./zone"; +import { formatClockTime } from "../today/format"; + +// 13:00 UTC on Sep 23 is already 01:00 on Sep 24 in Auckland (NZST, UTC+12). +const NOW = new Date("2026-09-23T13:00:00Z"); + +describe("application timezone helpers (process in UTC)", () => { + beforeAll(() => { + vi.stubEnv("TZ", "UTC"); + }); + + afterAll(() => { + vi.unstubAllEnvs(); + }); + + it("a stored zone decides the calendar date; empty means the browser's zone", () => { + expect(todayIn("Pacific/Auckland", NOW)).toBe("2026-09-24"); + expect(isoDateIn(NOW, "Pacific/Auckland")).toBe("2026-09-24"); + expect(todayIn("", NOW)).toBe("2026-09-23"); + }); + + it("wall-clock time converts in the stored zone, across its DST change", () => { + // NZST (UTC+12) before 27 Sep 2026, NZDT (UTC+13) after. + expect(wallClockInstant("2026-09-24", 0, 10, "Pacific/Auckland")).toBe( + Date.parse("2026-09-23T12:10:00Z"), + ); + expect(wallClockInstant("2026-09-28", 7, 30, "Pacific/Auckland")).toBe( + Date.parse("2026-09-27T18:30:00Z"), + ); + expect(wallClockInstant("2026-09-24", 0, 10, "")).toBe(Date.parse("2026-09-24T00:10:00Z")); + }); + + it("the completion clock reads in the stored zone", () => { + expect(formatClockTime("2026-09-23T11:04:00Z", "Pacific/Auckland")).toBe("11:04 PM"); + expect(formatClockTime("2026-09-23T11:04:00Z", "")).toBe("11:04 AM"); + }); + + it("an invalid stored zone fails fast", () => { + expect(zoneOption("")).toBeUndefined(); + expect(() => todayIn("Not/AZone", NOW)).toThrow(RangeError); + }); +}); + +describe("empty zone keeps browser-local behavior", () => { + beforeAll(() => { + vi.stubEnv("TZ", "America/Los_Angeles"); + }); + + afterAll(() => { + vi.unstubAllEnvs(); + }); + + it("matches the browser's own calendar and wall clock", () => { + // 13:00 UTC is 06:00 PDT on Sep 23. + expect(todayIn("", NOW)).toBe("2026-09-23"); + expect(wallClockInstant("2026-09-23", 7, 30, "")).toBe(new Date(2026, 8, 23, 7, 30).getTime()); + expect(formatClockTime("2026-09-23T14:04:00Z", "")).toBe("7:04 AM"); + }); +}); diff --git a/admin/src/time/zone.ts b/admin/src/time/zone.ts new file mode 100644 index 0000000..ea8b1de --- /dev/null +++ b/admin/src/time/zone.ts @@ -0,0 +1,79 @@ +/** + * Local time in the application timezone. + * + * The admin-saved zone (settings `timezone`, an IANA name) is the single + * source of local-time truth for the whole application. "" means no zone is + * stored, and only then does the browser's own zone apply. Every helper takes + * that stored value as-is; an invalid zone name throws (Intl's RangeError). + */ + +/** The `timeZone` option for Intl: the stored zone, or undefined (browser) for "". */ +export function zoneOption(timeZone: string): string | undefined { + return timeZone === "" ? undefined : timeZone; +} + +interface WallClock { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; +} + +function wallClock(instant: number, timeZone: string): WallClock { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: zoneOption(timeZone), + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).formatToParts(instant); + const part = (type: Intl.DateTimeFormatPartTypes) => + Number(parts.find((p) => p.type === type)?.value); + return { + year: part("year"), + month: part("month"), + day: part("day"), + hour: part("hour"), + minute: part("minute"), + second: part("second"), + }; +} + +/** The "YYYY-MM-DD" calendar date of `instant` in the zone. */ +export function isoDateIn(instant: Date, timeZone: string): string { + const { year, month, day } = wallClock(instant.getTime(), timeZone); + return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +} + +/** Today's calendar date in the zone. */ +export function todayIn(timeZone: string, now: Date = new Date()): string { + return isoDateIn(now, timeZone); +} + +/** The zone's UTC offset in ms at `instant` (wall clock minus UTC). */ +function offsetAt(instant: number, timeZone: string): number { + const c = wallClock(instant, timeZone); + const wholeSeconds = Math.floor(instant / 1000) * 1000; + return Date.UTC(c.year, c.month - 1, c.day, c.hour, c.minute, c.second) - wholeSeconds; +} + +/** + * Epoch ms at which the zone's wall clock reads `hour:minute` on the + * "YYYY-MM-DD" date. Two passes settle the offset across a DST change. + */ +export function wallClockInstant( + iso: string, + hour: number, + minute: number, + timeZone: string, +): number { + const [year, month, day] = iso.split("-").map(Number); + const asUtc = Date.UTC(year, month - 1, day, hour, minute); + const guess = asUtc - offsetAt(asUtc, timeZone); + return asUtc - offsetAt(guess, timeZone); +} diff --git a/admin/src/today/TodayTab.test.tsx b/admin/src/today/TodayTab.test.tsx index 41a59b7..9fd54c9 100644 --- a/admin/src/today/TodayTab.test.tsx +++ b/admin/src/today/TodayTab.test.tsx @@ -5,6 +5,7 @@ import type { AdminSnapshot, SnapshotInstance } from "../api/snapshot"; import { TransitionsProvider } from "../api/useTransitions"; import { frame, testStream, type TestStream } from "../api/testStream"; import { storeTokens } from "../auth/oidc"; +import { AppTimezoneProvider } from "../time/AppTimezone"; function instance(overrides: Partial): SnapshotInstance { return { @@ -406,3 +407,56 @@ describe("TodayTab Undo and live updates", () => { await waitFor(() => expect(streams[0].cancelled()).toBe(true)); }); }); + +describe("TodayTab in the stored application timezone", () => { + function renderWithZone(timezone: string) { + fetchMock.mockImplementation((url: string) => + Promise.resolve( + String(url).endsWith("/api/v1/admin/settings") + ? jsonResponse({ timezone }) + : jsonResponse(SNAPSHOT), + ), + ); + render( + + + , + ); + } + + beforeAll(() => { + // The browser runs in UTC; the household lives in Auckland (UTC+12 on Sep 23). + vi.stubEnv("TZ", "UTC"); + }); + + afterAll(() => { + vi.unstubAllEnvs(); + }); + + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("shows the completion clock in the stored zone, not the browser's", async () => { + renderWithZone("Pacific/Auckland"); + const completed = await screen.findByTestId("attention-12"); + // 11:04 UTC is 11:04 PM in Auckland. + expect(within(completed).getByText("Completed 11:04 PM")).toBeTruthy(); + // The header's day is the API's household-local today_iso, as-is. + expect(screen.getByText("Wednesday, September 23 · cycle day 5 of 14")).toBeTruthy(); + }); + + it("an empty stored zone keeps the browser's local clock", async () => { + renderWithZone(""); + const completed = await screen.findByTestId("attention-12"); + expect(within(completed).getByText("Completed 11:04 AM")).toBeTruthy(); + }); +}); diff --git a/admin/src/today/TodayTab.tsx b/admin/src/today/TodayTab.tsx index 7646b87..a9519ec 100644 --- a/admin/src/today/TodayTab.tsx +++ b/admin/src/today/TodayTab.tsx @@ -13,6 +13,7 @@ import { ApiRequestError } from "../api/definitions"; import { uncompleteInstance } from "../api/instances"; import { useTransitions } from "../api/useTransitions"; import SettingsScreen from "../settings/SettingsScreen"; +import { useAppTimezone } from "../time/AppTimezone"; import { formatClockTime, formatDueTime, @@ -165,6 +166,7 @@ function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl } const { instance, childName } = item; const overdue = isOverdueOpen(instance); const undoing = undo.pendingId === instance.id; + const timeZone = useAppTimezone(); // TODO(e57facc2): wire "Mark done". The admin plane has no complete route // yet, so that action is inert here. return ( @@ -187,7 +189,7 @@ function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl } ) : ( // The snapshot carries no completion actor, so "from panel" is not printed.
- Completed {formatClockTime(instance.completed_at as string)} + Completed {formatClockTime(instance.completed_at as string, timeZone)}
)} diff --git a/admin/src/today/format.ts b/admin/src/today/format.ts index 80537c0..bfbfe97 100644 --- a/admin/src/today/format.ts +++ b/admin/src/today/format.ts @@ -4,8 +4,10 @@ * Calendar dates ("YYYY-MM-DD") are parsed by their components and formatted * in UTC, so the printed day never shifts with the viewer's offset — unlike * `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous - * day in negative-offset zones. + * day in negative-offset zones. Instants are shown in the application + * timezone (time/zone.ts). */ +import { zoneOption } from "../time/zone"; const LOCALE = "en-US"; @@ -44,7 +46,11 @@ export function formatDueTime(hhmm: string): string { return format(date, { hour: "numeric", minute: "2-digit", timeZone: "UTC" }); } -/** A UTC ISO timestamp shown as the viewer's local time, "7:04 AM". */ -export function formatClockTime(isoTimestamp: string): string { - return format(new Date(isoTimestamp), { hour: "numeric", minute: "2-digit" }); +/** A UTC ISO timestamp shown as wall-clock time in `timeZone` ("" = browser), "7:04 AM". */ +export function formatClockTime(isoTimestamp: string, timeZone: string): string { + return format(new Date(isoTimestamp), { + hour: "numeric", + minute: "2-digit", + timeZone: zoneOption(timeZone), + }); } diff --git a/api/routes_panel.py b/api/routes_panel.py index eb84e4d..54dde57 100644 --- a/api/routes_panel.py +++ b/api/routes_panel.py @@ -21,6 +21,7 @@ panel payload), and returns the documented JSON shape: { "today_iso": "2026-09-22", "cycle_day": 2, + "timezone": "Pacific/Auckland", "children": [ { "child_id": 1, @@ -45,7 +46,9 @@ panel payload), and returns the documented JSON shape: active children ``ORDER BY sort_order, id``); ``state`` is the documented panel spelling ``open`` | ``completed`` (§1 of design/ENTITIES-AND-SERVICES.md); ``cycle_day`` is today's 1-based day -in the first scheduled child's custody cycle (0 when no child has one). +in the first scheduled child's custody cycle (0 when no child has one); +``timezone`` is the application timezone the admin stored (``""`` when +unset), which the panel cards format their visible date and clock in. The same :class:`PanelSnapshotResponse` Pydantic model is the route's ``response_model``, so the OpenAPI document carries the shape too. """ @@ -126,12 +129,14 @@ class PanelSnapshotResponse(BaseModel): ``today_iso`` anchors the payload's day; ``cycle_day`` is today's 1-based day in the first scheduled child's custody cycle (0 when no - child has a schedule); ``children`` is in the household's sort - order. + child has a schedule); ``timezone`` is the stored application + timezone (``""`` when unset); ``children`` is in the household's + sort order. """ today_iso: str cycle_day: int + timezone: str children: list[PanelChildPayload] @@ -218,6 +223,7 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse: return PanelSnapshotResponse( today_iso=snapshot.today_iso, cycle_day=snapshot.cycle_day, + timezone=snapshot.timezone, children=[ PanelChildPayload( child_id=child.child_id, diff --git a/custom_components/nestquest/coordinator.py b/custom_components/nestquest/coordinator.py index 22cbb24..1f26b27 100644 --- a/custom_components/nestquest/coordinator.py +++ b/custom_components/nestquest/coordinator.py @@ -111,6 +111,8 @@ def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot: through verbatim; - ``present`` and ``next_present`` pass through verbatim (the API resolved them through the same presence engine server-side); + - ``timezone`` (the stored application timezone, ``""`` when unset) + passes through verbatim for the cards' date and clock; - the instance list is rebuilt IN ORDER, so the panel payload the sensors emit matches the route's order byte for byte. @@ -161,6 +163,7 @@ def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot: today_iso=payload["today_iso"], children=tuple(children), cycle_day=payload["cycle_day"], + timezone=payload["timezone"], ) diff --git a/custom_components/nestquest/core/snapshot.py b/custom_components/nestquest/core/snapshot.py index 88679be..5e438ee 100644 --- a/custom_components/nestquest/core/snapshot.py +++ b/custom_components/nestquest/core/snapshot.py @@ -96,6 +96,10 @@ class NestQuestSnapshot: today_iso: str children: tuple[ChildDaySnapshot, ...] cycle_day: int + #: The application timezone the admin stored (``""`` when unset — + #: the host's local time). The panel cards format their visible + #: date and clock in it; it never re-resolves the day itself. + timezone: str = "" def _cycle_day( @@ -319,4 +323,5 @@ async def build_snapshot( today_iso=today_iso, children=tuple(snapshots), cycle_day=_cycle_day(patterns, children, today), + timezone=settings.timezone, ) diff --git a/custom_components/nestquest/sensor.py b/custom_components/nestquest/sensor.py index 7bfdfc1..2475dda 100644 --- a/custom_components/nestquest/sensor.py +++ b/custom_components/nestquest/sensor.py @@ -441,6 +441,9 @@ class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor): for child in data.children }, "child_roster": _child_roster(data.children), + # The stored application timezone ("" when unset): the + # panel cards format their visible date and clock in it. + "timezone": data.timezone, } diff --git a/custom_components/nestquest/www/nestquest-cards.js b/custom_components/nestquest/www/nestquest-cards.js index a819a0b..594df65 100644 --- a/custom_components/nestquest/www/nestquest-cards.js +++ b/custom_components/nestquest/www/nestquest-cards.js @@ -65,7 +65,7 @@ const { is: nt, defineProperty: it, getOwnPropertyDescriptor: st, getOwnProperty return t; } }, 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 { +let z = class extends HTMLElement { static addInitializer(e) { this._$Ei(), (this.l ??= []).push(e); } @@ -246,7 +246,7 @@ let T = class extends HTMLElement { firstUpdated(e) { } }; -T.elementStyles = [], T.shadowRootOptions = { mode: "open" }, T[I("elementProperties")] = /* @__PURE__ */ new Map(), T[I("finalized")] = /* @__PURE__ */ new Map(), ct?.({ ReactiveElement: T }), (K.reactiveElementVersions ??= []).push("2.1.2"); +z.elementStyles = [], z.shadowRootOptions = { mode: "open" }, z[I("elementProperties")] = /* @__PURE__ */ new Map(), z[I("finalized")] = /* @__PURE__ */ new Map(), ct?.({ ReactiveElement: z }), (K.reactiveElementVersions ??= []).push("2.1.2"); const pe = globalThis, be = (i) => i, V = pe.trustedTypes, ve = V ? V.createPolicy("lit-html", { createHTML: (i) => i }) : void 0, Qe = "$lit$", k = `lit$${Math.random().toFixed(9).slice(2)}$`, We = "?" + k, dt = `<${We}>`, A = document, P = () => A.createComment(""), H = (i) => i === null || typeof i != "object" && typeof i != "function", he = Array.isArray, pt = (i) => he(i) || typeof i?.[Symbol.iterator] == "function", Y = `[ \f\r]`, L = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, xe = /-->/g, we = />/g, $ = RegExp(`>|${Y}(?:([^\\s"'>=/]+)(${Y}*=${Y}*(?:[^ \f\r"'\`<>=]|("|')|))|$)`, "g"), ke = /'/g, $e = /"/g, Ge = /^(?:script|style|textarea|title)$/i, Ke = (i) => (e, ...t) => ({ _$litType$: i, strings: e, values: t }), d = Ke(1), qe = Ke(2), S = /* @__PURE__ */ Symbol.for("lit-noChange"), g = /* @__PURE__ */ Symbol.for("lit-nothing"), Me = /* @__PURE__ */ new WeakMap(), M = A.createTreeWalker(A, 129); @@ -303,11 +303,11 @@ class R { return n.innerHTML = e, n; } } -function z(i, e, t = i, n) { +function T(i, e, t = i, n) { if (e === S) return e; let s = n !== void 0 ? t._$Co?.[n] : t._$Cl; 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; + 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 = T(i, s._$AS(i, e.values), s, n)), e; } class ut { constructor(e, t) { @@ -356,7 +356,7 @@ class N { return this._$AB; } _$AI(e, t = this) { - e = z(this, e, t), H(e) ? e === g || e == null || e === "" ? (this._$AH !== g && this._$AR(), this._$AH = g) : e !== this._$AH && e !== S && this._(e) : e._$litType$ !== void 0 ? this.$(e) : e.nodeType !== void 0 ? this.T(e) : pt(e) ? this.k(e) : this._(e); + e = T(this, e, t), H(e) ? e === g || e == null || e === "" ? (this._$AH !== g && this._$AR(), this._$AH = g) : e !== this._$AH && e !== S && this._(e) : e._$litType$ !== void 0 ? this.$(e) : e.nodeType !== void 0 ? this.T(e) : pt(e) ? this.k(e) : this._(e); } O(e) { return this._$AA.parentNode.insertBefore(e, this._$AB); @@ -409,11 +409,11 @@ class J { _$AI(e, t = this, n, s) { 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); + if (a === void 0) e = T(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 = 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; + for (e = a[0], o = 0; o < a.length - 1; o++) p = T(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; } r && !s && this.j(e); } @@ -442,7 +442,7 @@ class gt extends J { super(e, t, n, s, a), this.type = 5; } _$AI(e, t = this) { - if ((e = z(this, e, t, 0) ?? g) === S) return; + if ((e = T(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, 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; } @@ -458,7 +458,7 @@ class _t { return this._$AM._$AU; } _$AI(e) { - z(this, e); + T(this, e); } } const yt = { I: N }, bt = pe.litHtmlPolyfillSupport; @@ -473,7 +473,7 @@ const vt = (i, e, t) => { return s._$AI(i), s; }; const ue = globalThis; -let C = class extends T { +let C = class extends z { constructor() { super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0; } @@ -592,7 +592,7 @@ function Ct(i) { function Se(i) { return Number.isFinite(i) ? Math.min(100, Math.max(0, i)) : 0; } -function Te(i) { +function ze(i) { return Math.max(0, Math.round(i)); } function At(i) { @@ -620,7 +620,7 @@ function St(i, e) { ); return Number.isFinite(s) ? s - i : 0; } -function Tt(i, e) { +function zt(i, e) { if (typeof i != "string") return null; const t = i.split("-"); @@ -636,14 +636,14 @@ function Tt(i, 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) { +function Tt(i) { const e = qt[i]; return e || i.charAt(0).toUpperCase() + i.slice(1); } function Et(i, e) { return Mt[i] ?? e; } -const ze = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "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", Nt = "M50 26.5 70.4 38.2 70.4 61.8 50 73.5 29.6 61.8 29.6 38.2Z", Lt = "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", Ot = d`${Math.round(e.temperature)}°`, a = e.high === null || e.low === null ? null : `${Math.round(e.high)}° / ${Math.round(e.low)}°`; + const t = Tt(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} @@ -3501,11 +3506,11 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50% icon: qn, headline: "No adventurer chosen", body: "Open The Party and tap your crest to open your quest log." -}, Tn = { +}, zn = { icon: Mn, headline: "NestQuest is not set up yet", body: "A parent needs to finish setting up NestQuest." -}, zn = { +}, Tn = { icon: Cn, headline: "The records cannot be reached", body: "The party's records are quiet right now. NestQuest will return shortly." @@ -3597,9 +3602,9 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50% case "no-adventurer": return this._renderNotice(Sn); case "not-set-up": - return this._renderNotice(Tn); - case "unreachable": return this._renderNotice(zn); + case "unreachable": + return this._renderNotice(Tn); case "away": { const t = ["The quest log unlocks when they return."]; return e.returns && t.push(`Returns ${e.returns}`), d` @@ -3727,8 +3732,13 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%
`; } + /** The zone the visible date and clock render in: the application + * timezone the admin stored (published on the household rollup by + * custom_components/nestquest/sensor.py — ``timezone``). Only when + * none is stored does Home Assistant's zone (or the browser's) apply. */ _timeZone() { - return rn(this.hass); + const e = this._state("sensor.nestquest_household_quests_due_today")?.attributes?.timezone; + return typeof e == "string" && e.trim() ? e.trim() : rn(this.hass); } _state(e) { const n = this.hass?.states?.[e]; diff --git a/frontend/src/cards/party-board.ts b/frontend/src/cards/party-board.ts index 8a7c8eb..c28c977 100644 --- a/frontend/src/cards/party-board.ts +++ b/frontend/src/cards/party-board.ts @@ -937,8 +937,16 @@ export class NestQuestPartyBoardCard extends LitElement { `; } + /** The zone the visible date and clock render in: the application + * timezone the admin stored (published on the household rollup by + * custom_components/nestquest/sensor.py — ``timezone``). Only when + * none is stored does Home Assistant's zone (or the browser's) apply. */ private _timeZone(): string | undefined { - return hassTimeZone(this.hass); + const stored = this._state("sensor.nestquest_household_quests_due_today") + ?.attributes?.timezone; + return typeof stored === "string" && stored.trim() + ? stored.trim() + : hassTimeZone(this.hass); } private _state(entityId: string): StateObject | null { diff --git a/frontend/src/cards/quest-log.ts b/frontend/src/cards/quest-log.ts index 9e49bfe..d3e5882 100644 --- a/frontend/src/cards/quest-log.ts +++ b/frontend/src/cards/quest-log.ts @@ -1749,8 +1749,16 @@ export class NestQuestQuestLogCard extends LitElement { `; } + /** The zone the visible date and clock render in: the application + * timezone the admin stored (published on the household rollup by + * custom_components/nestquest/sensor.py — ``timezone``). Only when + * none is stored does Home Assistant's zone (or the browser's) apply. */ private _timeZone(): string | undefined { - return hassTimeZone(this.hass); + const stored = this._state("sensor.nestquest_household_quests_due_today") + ?.attributes?.timezone; + return typeof stored === "string" && stored.trim() + ? stored.trim() + : hassTimeZone(this.hass); } private _state(entityId: string): StateObject | null { diff --git a/frontend/tests/render-party-board.mjs b/frontend/tests/render-party-board.mjs index 4b0309f..59346d7 100644 --- a/frontend/tests/render-party-board.mjs +++ b/frontend/tests/render-party-board.mjs @@ -3,13 +3,15 @@ * * Usage: node render-party-board.mjs < spec.json * - * The spec JSON carries {bundle, tag, config, hass, url?, click_plate?}; + * The spec JSON carries {bundle, tag, config, hass, url?, click_plate?, now?}; * stdout receives one JSON object {plates, tappable, location, html} where * plates is the ordered plate list the shadow DOM renders (name + * present/away/unknown kind), tappable the plate names rendered as tappable *