From 51afb9371acfcdfaa391f20e7ba66a938ee4b7af Mon Sep 17 00:00:00 2001 From: Joshua Date: Tue, 29 Sep 2026 07:23:44 -0400 Subject: [PATCH] Decide quest-log overdue from the panel's own local clock The kids' Quest Log rendered OVERDUE from the API's per-instance `overdue` flag, which is computed on whatever wall clock the API resolves for the household. When that clock is UTC (HA `time_zone` UTC, a pre-0.9.6 API/integration, or a stored zone adopted over), an 8:15 AM quest reads OVERDUE at 7:15 AM America/New_York. The card now marks an open quest overdue when its `due_time` wall time has arrived on the panel browser's local clock (`_now`, a reactive state ticking every 60 s; `>=` so it flips on the due minute). With no `due_time` a quest is never overdue, matching the API's rule. The API's `overdue` field is unchanged for admin and automation consumers; the card simply no longer reads it. The jsdom quest-log harness can now pin the clock (`now`) and fire the card's own clock tick at later instants (`advance_to`), and the Python helper sets the node process `TZ`. Task: fd1093b5-e6ae-47e6-aee9-1e8f5ee74e3f Co-Authored-By: Claude Opus 5.5 --- .../nestquest/www/nestquest-cards.js | 456 +++++++++--------- frontend/src/cards/quest-log.ts | 30 +- frontend/tests/render-quest-log.mjs | 47 +- tests/test_panel_cards.py | 106 +++- 4 files changed, 407 insertions(+), 232 deletions(-) diff --git a/custom_components/nestquest/www/nestquest-cards.js b/custom_components/nestquest/www/nestquest-cards.js index a819a0b..c740b22 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, 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]); + 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]); 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: 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) { +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) { 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: ae, reflect: !1, useDefault: !1, hasChanged: Ze }; +} }, Ze = (i, e) => !nt(i, e), ye = { attribute: !0, type: String, converter: re, 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: a } = st(this.prototype, e) ?? { get() { + const { get: s, set: r } = st(this.prototype, e) ?? { get() { return this[t]; - }, set(r) { - this[t] = r; + }, set(a) { + this[t] = a; } }; - return { get: s, set(r) { + return { get: s, set(a) { const l = s?.call(this); - a?.call(this, r), this.requestUpdate(e, l, n); + r?.call(this, a), 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 = [...at(t), ...rt(t)]; + const t = this.properties, n = [...rt(t), ...at(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 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; + 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; } } _$AK(e, t) { const n = this.constructor, s = n._$Eh.get(e); if (s !== void 0 && this._$Em !== s) { - const a = n.getPropertyOptions(s), r = typeof a.converter == "function" ? { fromAttribute: a.converter } : a.converter?.fromAttribute !== void 0 ? a.converter : ae; + const r = n.getPropertyOptions(s), a = typeof r.converter == "function" ? { fromAttribute: r.converter } : r.converter?.fromAttribute !== void 0 ? r.converter : re; this._$Em = s; - const l = r.fromAttribute(t, a.type); + const l = a.fromAttribute(t, r.type); this[s] = l ?? this._$Ej?.get(s) ?? l, this._$Em = null; } } - requestUpdate(e, t, n, s = !1, a) { + requestUpdate(e, t, n, s = !1, r) { if (e !== void 0) { - 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; + 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; this.C(e, t, n); } this.isUpdatePending === !1 && (this._$ES = this._$EP()); } - 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)); + 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)); } 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, a] of this._$Ep) this[s] = a; + for (const [s, r] of this._$Ep) this[s] = r; this._$Ep = void 0; } const n = this.constructor.elementProperties; - 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); + 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); } } let e = !1; @@ -256,21 +256,21 @@ function Je(i, e) { } const ht = (i, e) => { const t = i.length - 1, n = []; - let s, a = e === 2 ? "" : e === 3 ? "" : "", r = L; + let s, r = e === 2 ? "" : e === 3 ? "" : "", a = L; for (let l = 0; l < t; l++) { const o = i[l]; let p, u, c = -1, f = 0; - 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); + 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); } - return [Je(i, a + (i[t] || "") + (e === 2 ? "" : e === 3 ? "" : "")), n]; + return [Je(i, r + (i[t] || "") + (e === 2 ? "" : e === 3 ? "" : "")), n]; }; class R { constructor({ strings: e, _$litType$: t }, n) { let s; this.parts = []; - let a = 0, r = 0; + let r = 0, a = 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[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)); + 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)); 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: ++a }); + for (let h = 0; h < f; h++) s.append(c[h], P()), M.nextNode(), o.push({ type: 2, index: ++r }); s.append(c[f], P()); } } - } else if (s.nodeType === 8) if (s.data === We) o.push({ type: 2, index: a }); + } else if (s.nodeType === 8) if (s.data === We) o.push({ type: 2, index: r }); else { let c = -1; - for (; (c = s.data.indexOf(k, c + 1)) !== -1; ) o.push({ type: 7, index: a }), c += k.length - 1; + for (; (c = s.data.indexOf(k, c + 1)) !== -1; ) o.push({ type: 7, index: r }), c += k.length - 1; } - a++; + r++; } } 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 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; + 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; } 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 a = M.nextNode(), r = 0, l = 0, o = n[0]; + let r = M.nextNode(), a = 0, l = 0, o = n[0]; for (; o !== void 0; ) { - if (r === o.index) { + if (a === o.index) { let p; - 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]; + 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]; } - r !== o?.index && (a = M.nextNode(), r++); + a !== o?.index && (r = M.nextNode(), a++); } 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 a = new ut(s, this), r = a.u(this.options); - a.p(t), this.T(r), this._$AH = a; + const r = new ut(s, this), a = r.u(this.options); + r.p(t), this.T(a), this._$AH = r; } } _$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 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++; + 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++; 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, 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; + 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; } _$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); + 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); 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 = 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; } - r && !s && this.j(e); + a && !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, a) { - super(e, t, n, s, a), this.type = 5; + constructor(e, t, n, s, r) { + super(e, t, n, s, r), 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, 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; + 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; } 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 a = t?.renderBefore ?? null; - n._$litPart$ = s = new N(e.insertBefore(P(), a), a, void 0, t ?? {}); + const r = t?.renderBefore ?? null; + n._$litPart$ = s = new N(e.insertBefore(P(), r), r, 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 = (a) => { - const r = t.find((l) => l.type === a); - return r ? Number(r.value) : Number.NaN; + }).formatToParts(new Date(i)), n = (r) => { + const a = t.find((l) => l.type === r); + return a ? Number(a.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, a] = t.map((o) => Number(o)); - if (!n || !s || !a) + const [n, s, r] = t.map((o) => Number(o)); + if (!n || !s || !r) return null; if (!e) { - const o = new Date(n, s - 1, a); + const o = new Date(n, s - 1, r); return Number.isNaN(o.getTime()) ? null : o; } - const r = Date.UTC(n, s - 1, a), l = new Date(r - St(r, e)); + const a = Date.UTC(n, s - 1, r), l = new Date(a - St(a, 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((a) => { - this._subscribed && t === this._subGeneration ? this._unsubs.push(a) : a(); + s.then((r) => { + this._subscribed && t === this._subGeneration ? this._unsubs.push(r) : r(); }).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` - ), a = this._state( - `sensor.nestquest_${e}_completion_pct_today` ), r = this._state( + `sensor.nestquest_${e}_completion_pct_today` + ), a = this._state( `binary_sensor.nestquest_${e}_present_today` - ), 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; + ), 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; let f = !0; const h = l.present; - typeof h == "boolean" ? f = h : r && (f = String(r.state ?? "").trim().toLowerCase() === "on"); + typeof h == "boolean" ? f = h : a && (f = String(a.state ?? "").trim().toLowerCase() === "on"); let m = ""; const _ = l.child_name; if (typeof _ == "string" && _.trim()) m = _.trim(); else { - const X = r?.attributes?.child_name; + const X = a?.attributes?.child_name; typeof X == "string" && X.trim() && (m = X.trim()); } m || (m = At(e)); const v = Tt( - r?.attributes?.next_present, + a?.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 a = null, r = null; + let r = null, a = null; const l = s.forecast; if (Array.isArray(l) && l.length > 0) { const o = l[0]; if (o && typeof o == "object") { const p = o; - a = this._optionalNumber(p.temperature), r = this._optionalNumber(p.templow); + r = this._optionalNumber(p.temperature), a = this._optionalNumber(p.templow); } } return { condition: n, temperature: this._optionalNumber(s.temperature), - high: a, - low: r + high: r, + low: a }; } _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)}°`, a = 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)}°`, r = e.high === null || e.low === null ? null : `${Math.round(e.high)}° / ${Math.round(e.low)}°`; return d`
${Dt} ${s} - ${a === null ? t : `${t} · ${a}`} + ${r === null ? t : `${t} · ${r}`} ${n} @@ -1459,8 +1459,8 @@ Q.properties = { _config: { state: !0 }, _now: { state: !0 } }, Q.styles = [x(Xe), Bt]; -let re = Q; -customElements.define("nestquest-party-board-card", re); +let ae = Q; +customElements.define("nestquest-party-board-card", ae); 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 a = n.insertBefore(Oe(), s), r = n.insertBefore(Oe(), s); - t = new Qt(a, r, i, i.options); + const r = n.insertBefore(Oe(), s), a = n.insertBefore(Oe(), s); + t = new Qt(r, a, i, i.options); } else { - const a = t._$AB.nextSibling, r = t._$AM, l = r !== i; + const r = t._$AB.nextSibling, a = t._$AM, l = a !== i; if (l) { let o; - t._$AQ?.(i), t._$AM = i, t._$AP !== void 0 && (o = i._$AU) !== r._$AU && t._$AP(o); + t._$AQ?.(i), t._$AM = i, t._$AP !== void 0 && (o = i._$AU) !== a._$AU && t._$AP(o); } - if (a !== s || l) { + if (r !== s || l) { let o = t._$AA; - for (; o !== a; ) { + for (; o !== r; ) { 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 = [], 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 }; + 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 }; } render(i, e, t) { return this.dt(i, e, t).values; } update(i, [e, t, n]) { - const s = Kt(i), { values: a, keys: r } = this.dt(e, t, n); - if (!Array.isArray(s)) return this.ut = r, a; + const s = Kt(i), { values: r, keys: a } = this.dt(e, t, n); + if (!Array.isArray(s)) return this.ut = a, r; const l = this.ut ??= [], o = []; - let p, u, c = 0, f = s.length - 1, h = 0, m = a.length - 1; + let p, u, c = 0, f = s.length - 1, h = 0, m = r.length - 1; for (; c <= f && h <= m; ) if (s[c] === null) c++; else if (s[f] === null) f--; - 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; + 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; if (v === null) { const U = O(i, s[c]); - q(U, a[h]), o[h] = U; - } else o[h] = q(v, a[h]), O(i, s[c], v), s[_] = null; + q(U, r[h]), o[h] = U; + } else o[h] = q(v, r[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(_, a[h]), o[h++] = _; + q(_, r[h]), o[h++] = _; } for (; c <= f; ) { const _ = s[c++]; _ !== null && ne(_); } - return this.ut = r, Gt(i, o), S; + return this.ut = a, Gt(i, o), S; } }), Jt = [ { @@ -2289,7 +2289,7 @@ function sn(i, e) { return e; } } -const an = [ +const rn = [ "nestquest_quest_completed", "nestquest_quest_uncompleted", "nestquest_quest_missed", @@ -2310,7 +2310,7 @@ function E(i, e) { } return He.set(t, s), s; } -function rn(i) { +function an(i) { const e = i?.config; if (!e || typeof e != "object") return; @@ -2350,6 +2350,13 @@ 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() : ""; } @@ -2365,7 +2372,7 @@ function b(i) { function ie(i) { return i.split(/[-_]+/).filter(Boolean).map((e) => e.charAt(0).toUpperCase() + e.slice(1)).join(" "); } -function pn(i, e) { +function hn(i, e) { const t = E(e, { year: "numeric", month: "2-digit", @@ -2374,9 +2381,9 @@ function pn(i, e) { minute: "2-digit", second: "2-digit", hourCycle: "h23" - }).formatToParts(new Date(i)), n = (a) => { - const r = t.find((l) => l.type === a); - return r ? Number(r.value) : Number.NaN; + }).formatToParts(new Date(i)), n = (r) => { + const a = t.find((l) => l.type === r); + return a ? Number(a.value) : Number.NaN; }, s = Date.UTC( n("year"), n("month") - 1, @@ -2393,21 +2400,21 @@ function Re(i, e) { const t = i.split("-"); if (t.length !== 3) return null; - const [n, s, a] = t.map((o) => Number(o)); - if (!n || !s || !a) + const [n, s, r] = t.map((o) => Number(o)); + if (!n || !s || !r) return null; if (!e) { - const o = new Date(n, s - 1, a); + const o = new Date(n, s - 1, r); return Number.isNaN(o.getTime()) ? null : o; } - const r = Date.UTC(n, s - 1, a), l = new Date(r - pn(r, e)); + const a = Date.UTC(n, s - 1, r), l = new Date(a - hn(a, e)); return Number.isNaN(l.getTime()) ? null : l; } -function hn() { +function un() { const i = window.location.pathname.split("/").filter(Boolean); return i.length > 0 ? i[i.length - 1].toLowerCase() : ""; } -function un(i) { +function fn(i) { if (!i || typeof i != "object") return null; const e = i, t = w(e.state).toLowerCase(); @@ -2425,12 +2432,11 @@ function un(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 fn = { +const mn = { "clear-night": "Clear", cloudy: "Cloudy", exceptional: "Clear", @@ -2446,7 +2452,7 @@ const fn = { sunny: "Sunny", windy: "Windy", "windy-variant": "Windy" -}, mn = { +}, gn = { "clear-night": "Clear night skies", cloudy: "Grey skies today", exceptional: "A striking day", @@ -2463,14 +2469,14 @@ const fn = { windy: "A blustery day", "windy-variant": "A blustery day" }; -function gn(i) { - const e = fn[i]; +function _n(i) { + const e = mn[i]; return e || i.charAt(0).toUpperCase() + i.slice(1); } -function _n(i, e) { - return mn[i] ?? e; +function yn(i, e) { + return gn[i] ?? e; } -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` +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` * { box-sizing: border-box; } @@ -2638,7 +2644,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50% justify-content: center; width: 44px; height: 44px; - clip-path: ${x(yn)}; + clip-path: ${x(bn)}; background: var(--nq-brand-gradient); } @@ -3363,7 +3369,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50% aria-hidden="true" > -`, vn = d``, xn = d` -`, xn = d``, wn = d` -`, wn = d``, kn = d` -`, kn = d``, $n = d` -`, $n = d``, qn = d` -`, qn = d``, Mn = d` -`, Mn = d``, Cn = d` -`, Cn = d``, An = d` -`, An = d``, Sn = d` -`, Sn = { - icon: qn, +`, Tn = { + icon: Mn, headline: "No adventurer chosen", body: "Open The Party and tap your crest to open your quest log." -}, Tn = { - icon: Mn, - headline: "NestQuest is not set up yet", - body: "A parent needs to finish setting up NestQuest." }, zn = { icon: Cn, + headline: "NestQuest is not set up yet", + body: "A parent needs to finish setting up NestQuest." +}, En = { + icon: An, 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: wn }, + { key: "morning", name: "Morning", range: "Until 11:59 AM", icon: kn }, { key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: Ye }, - { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: kn } + { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: $n } ], 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 = () => { @@ -3536,13 +3542,13 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50% return; this._subscribed = !0; const t = ++this._subGeneration, n = (s) => { - s.then((a) => { - this._subscribed && t === this._subGeneration ? this._unsubs.push(a) : a(); + s.then((r) => { + this._subscribed && t === this._subGeneration ? this._unsubs.push(r) : r(); }).catch(() => { t === this._subGeneration && (this._unsubscribeLive(), this._armSubscribeRetry()); }); }; - for (const s of an) + for (const s of rn) n( e.subscribeEvents(() => this.requestUpdate(), s) ); @@ -3595,18 +3601,18 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50% _renderMain(e) { switch (e.kind) { case "no-adventurer": - return this._renderNotice(Sn); - case "not-set-up": return this._renderNotice(Tn); - case "unreachable": + case "not-set-up": return this._renderNotice(zn); + case "unreachable": + return this._renderNotice(En); case "away": { const t = ["The quest log unlocks when they return."]; return e.returns && t.push(`Returns ${e.returns}`), d` ${this._renderHeader(e)}
- ${$n} + ${qn} ${e.name} is on travels ${t.map((n) => d`

${n}

`)}
@@ -3684,8 +3690,8 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "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 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" }; + 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" }; } /** "missing" when the due sensor does not exist (integration not * configured), "stale" when it answers unavailable/unknown (backend @@ -3727,15 +3733,25 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "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 rn(this.hass); + return an(this.hass); } _state(e) { const n = this.hass?.states?.[e]; return n && typeof n == "object" ? n : null; } _childSlug() { - return hn(); + return un(); } _boardPath() { return w(this._config?.board_path).replace(/\/+$/, ""); @@ -3761,8 +3777,8 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "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)), a = b(y(n?.state, 0)); - return { due: s, completed: a }; + ), s = b(y(t?.state, 0)), r = b(y(n?.state, 0)); + return { due: s, completed: r }; } _instances() { const e = this._childSlug(); @@ -3771,18 +3787,18 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50% const t = this._state(`sensor.nestquest_${e}_quests_due_today`)?.attributes?.instances; if (!Array.isArray(t)) return []; - const n = t.map(un).filter((a) => a !== null); + const n = t.map(fn).filter((r) => r !== null); if (this._optimistic.size === 0) return n; let s = !1; - 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; + 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; continue; } - a.state = "completed", a.overdue = !1, a.completed_at = r; + r.state = "completed", r.completed_at = a; } } return s && (this._optimistic = new Map(this._optimistic)), n; @@ -3810,12 +3826,12 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50% completed: b(y(t?.state, 0)), due: b(y(e?.state, 0)) }; - const n = this._childSlug(), { due: s, completed: a } = this._childCounts(n); - return { completed: a, due: s }; + const n = this._childSlug(), { due: s, completed: r } = this._childCounts(n); + return { completed: r, due: s }; } _completeStats() { const e = this._childCounts(this._childSlug()).completed, t = this._instances().filter( - (a) => a.on_time === !0 + (r) => r.on_time === !0 ).length, n = b(e - t), s = this._partyCounts(); return { claimed: e, @@ -3825,19 +3841,19 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50% }; } _completeSub(e) { - 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.`; + 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.`; } _otherChildren() { const e = this.hass?.states; if (!e) return []; const t = this._childSlug(), n = this._timeZone(), s = []; - for (const a of Object.keys(e)) { - const r = /^sensor\.nestquest_(.+)_quests_due_today$/.exec(a); - if (!r || r[1] === "household" || r[1] === t) + for (const r of Object.keys(e)) { + const a = /^sensor\.nestquest_(.+)_quests_due_today$/.exec(r); + if (!a || a[1] === "household" || a[1] === t) continue; - const l = r[1], o = e[a]?.attributes ?? {}; + const l = a[1], o = e[r]?.attributes ?? {}; let p = ""; typeof o.child_name == "string" && o.child_name.trim() ? p = o.child_name.trim() : p = ie(l); let u = !0; @@ -3851,7 +3867,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50% } const c = this._state( `sensor.nestquest_${l}_quests_completed_today` - ), f = b(y(e[a]?.state, 0)), h = b(y(c?.state, 0)); + ), f = b(y(e[r]?.state, 0)), h = b(y(c?.state, 0)); let m = null; if (!u) { const _ = this._state( @@ -3871,10 +3887,10 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50% returnsWeekday: m }); } - return s.sort((a, r) => a.name.localeCompare(r.name)); + return s.sort((r, a) => r.name.localeCompare(a.name)); } _renderHeader(e) { - 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(); + 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(); return d`
${this._boardPath() ? d` @@ -3883,7 +3899,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50% type="button" @click=${() => this._returnToBoard()} > - ${xn} + ${wn} The Party ` : g} @@ -3896,7 +3912,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%

${p}

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

${n ? g : d` @@ -3924,28 +3940,28 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50% (l, o) => (l.due_time ?? "99:99").localeCompare(o.due_time ?? "99:99") ), s = t.filter( (l) => l.state === "completed" - ), a = t.length; - let r = g; - if (a > 0) { + ), r = t.length; + let a = g; + if (r > 0) { const o = 644 - (e.key === "afternoon" && this._otherChildren().length > 0 ? 160 : 0), p = Math.min( 24, - Math.max(8, Math.floor((o - a * 74) / Math.max(a - 1, 1))) + Math.max(8, Math.floor((o - r * 74) / Math.max(r - 1, 1))) ), u = Math.min( 116, - Math.max(58, Math.floor((o - (a - 1) * p) / a)) + Math.max(58, Math.floor((o - (r - 1) * p) / r)) ), c = Math.max(56, Math.min(72, u - 2)); let f = `--nq-p-quest-gap: ${p}px; --nq-p-quest-min-height: ${u}px; --nq-p-button-height: ${c}px`; - if (a >= 5) { + if (r >= 5) { const h = Math.max(0, Math.min(22, Math.floor((u - c - 2) / 2))), m = Math.max(44, Math.min(64, u - 2 * h - 2)); f += `; --nq-p-quest-pad: ${h}px; --nq-p-tile-size: ${m}px`; } - r = f; + a = f; } return d`
${e.icon} @@ -3973,7 +3989,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50% } _renderQuest(e, t, n) { if (t) { - const r = je[n % je.length], l = e.completed_at ? ln(e.completed_at, this._timeZone()) : ""; + const a = je[n % je.length], l = e.completed_at ? ln(e.completed_at, this._timeZone()) : ""; return d`
@@ -3986,14 +4002,14 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%
`; } - const s = dn(e.due_time), a = e.overdue ? { + const s = dn(e.due_time), r = this._isOverdue(e) ? { text: s ? `Overdue · due ${s}` : "Overdue", late: !0 } : { @@ -4008,14 +4024,14 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50% data-instance-id=${e.id} aria-label="Complete ${e.title}" @click=${() => this._openConfirm(e.id)} - @keydown=${(r) => { - (r.key === "Enter" || r.key === " ") && (r.preventDefault(), this._openConfirm(e.id)); + @keydown=${(a) => { + (a.key === "Enter" || a.key === " ") && (a.preventDefault(), this._openConfirm(e.id)); }} >
${e.title} - ${a.text} + ${r.text}
`; - 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)}°`; + 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)}°`; return d`
- ${An} + ${Sn} ${s} - ${a === null ? t : `${t} · ${a}`} + ${r === null ? t : `${t} · ${r}`} ${n} @@ -4261,7 +4277,7 @@ W.properties = { _optimistic: { state: !0 }, _toast: { state: !0 }, _countdown: { state: !0 } -}, W.styles = [x(Xe), bn]; +}, W.styles = [x(Xe), vn]; let oe = W; customElements.define("nestquest-quest-log-card", oe); fe({ @@ -4269,7 +4285,7 @@ fe({ name: "NestQuest Quest Log", description: "One child's daily quests, grouped by window." }); -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 { +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 { setConfig(e) { if (!e || typeof e != "object") throw new Error("Invalid configuration"); @@ -4285,7 +4301,7 @@ const En = ":host{--nq-brand-blue: #3B3AB8;--nq-brand-purple: #A035CC;--nq-brand G.properties = { hass: { attribute: !1 }, _config: { state: !0 } -}, G.styles = [x(En)]; +}, G.styles = [x(Nn)]; let le = G; customElements.define("nestquest-admin-card", le); fe({ @@ -4296,59 +4312,59 @@ fe({ function et(i) { return typeof i == "string" ? i.trim() : ""; } -function Nn(i) { +function Ln(i) { if (i === null || typeof i != "object" || Array.isArray(i)) return ""; const e = i.slug; return typeof e == "string" ? e.trim() : ""; } -function Ln(i) { +function On(i) { const e = et(i.url_path).replace(/^\/+|\/+$/g, ""); return e || (window.location.pathname.split("/").filter(Boolean)[0] ?? ""); } -function On(i) { +function Bn(i) { if (i === null || typeof i != "object" || Array.isArray(i)) return ""; const e = i.name; return typeof e == "string" ? e.trim() : ""; } -function Bn(i) { +function In(i) { const n = i?.states?.["sensor.nestquest_household_quests_due_today"]?.attributes?.child_roster; - return Array.isArray(n) ? n.map((s) => ({ slug: Nn(s), name: On(s) })).filter((s) => s.slug.length > 0) : []; + return Array.isArray(n) ? n.map((s) => ({ slug: Ln(s), name: Bn(s) })).filter((s) => s.slug.length > 0) : []; } -class In { +class Pn { static async generate(e, t) { - const n = `/${Ln(e)}`, s = et(e.weather_entity), a = { + const n = `/${On(e)}`, s = et(e.weather_entity), r = { type: "custom:nestquest-party-board-card", quest_log_path: n }; - s && (a.weather_entity = s); - const r = { + s && (r.weather_entity = s); + const a = { type: "custom:nestquest-quest-log-card", board_path: n }; - return s && (r.weather_entity = s), { + return s && (a.weather_entity = s), { views: [ { path: "party", title: "The Party", type: "panel", - cards: [a] + cards: [r] }, - ...Bn(t).map((l) => ({ + ...In(t).map((l) => ({ path: l.slug, title: l.name ? `${l.name}'s Quest Log` : `${l.slug}'s Quest Log`, type: "panel", - cards: [{ ...r }] + cards: [{ ...a }] })) ] }; } } window.customStrategies = window.customStrategies ?? {}; -window.customStrategies["nestquest-party"] = In; -const Pn = "/nestquest-static/nestquest-fonts.css"; +window.customStrategies["nestquest-party"] = Pn; +const Hn = "/nestquest-static/nestquest-fonts.css"; if (!document.querySelector('link[data-nq-fonts=""]')) { const i = document.createElement("link"); - i.rel = "stylesheet", i.href = Pn, i.dataset.nqFonts = "", document.head.appendChild(i); + i.rel = "stylesheet", i.href = Hn, i.dataset.nqFonts = "", document.head.appendChild(i); } diff --git a/frontend/src/cards/quest-log.ts b/frontend/src/cards/quest-log.ts index 9e49bfe..ce06bc1 100644 --- a/frontend/src/cards/quest-log.ts +++ b/frontend/src/cards/quest-log.ts @@ -36,7 +36,6 @@ type PanelInstance = { window: string; due_time: string | null; state: string; - overdue: boolean; completed_at: string | null; on_time: boolean | null; }; @@ -154,6 +153,17 @@ 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() : ""; } @@ -253,7 +263,6 @@ 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, }; @@ -1749,6 +1758,20 @@ 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); } @@ -1833,7 +1856,6 @@ export class NestQuestQuestLogCard extends LitElement { continue; } instance.state = "completed"; - instance.overdue = false; instance.completed_at = stampedAt; } if (settled) { @@ -2130,7 +2152,7 @@ export class NestQuestQuestLogCard extends LitElement { `; } const dueAt = formatWallClock(instance.due_time); - const meta = instance.overdue + const meta = this._isOverdue(instance) ? { text: dueAt ? `Overdue · due ${dueAt}` : "Overdue", late: true, diff --git a/frontend/tests/render-quest-log.mjs b/frontend/tests/render-quest-log.mjs index c605d45..73b374e 100644 --- a/frontend/tests/render-quest-log.mjs +++ b/frontend/tests/render-quest-log.mjs @@ -16,6 +16,12 @@ * reports every hass.callService call as tap: {dialog_opened, service_calls}. * With tap_return: true the harness taps the header's return control and * reports it as returned: {rendered, label} before the final snapshot. + * With now: the card's clock is pinned there (argument-less + * new Date() and Date.now() return it; the process TZ decides its local + * wall time), and with advance_to: [, ...] the clock moves to + * each instant in turn and the card's own 60 s clock-tick callbacks fire, + * one snapshot per instant reported as ticks (the top-level snapshot is + * taken before the clock moves). * The Python panel tests drive this harness so the * assertions run against the exact bundle HACS ships. */ @@ -47,6 +53,33 @@ globalThis.window = dom.window; // jsdom Event must shadow it for the return navigation to be delivered. globalThis.Event = dom.window.Event; +// Pin the clock: argument-less construction and Date.now() read `pinned`, +// every other Date use is the real class. +let pinned = spec.now !== undefined ? new Date(spec.now).getTime() : null; +if (pinned !== null) { + const RealDate = Date; + class PinnedDate extends RealDate { + constructor(...args) { + super(...(args.length === 0 ? [pinned] : args)); + } + static now() { + return pinned; + } + } + globalThis.Date = PinnedDate; + dom.window.Date = PinnedDate; +} + +// Record the card's 60 s clock ticks so advance_to can fire them on demand. +const clockTicks = []; +const realSetInterval = dom.window.setInterval.bind(dom.window); +dom.window.setInterval = (callback, ms, ...rest) => { + if (ms === 60000) { + clockTicks.push(callback); + } + return realSetInterval(callback, ms, ...rest); +}; + await import(pathToFileURL(spec.bundle).href); const serviceCalls = []; @@ -123,6 +156,18 @@ if (spec.idle_ms !== undefined) { await new Promise((resolve) => setTimeout(resolve, spec.idle_ms)); } -const payload = JSON.stringify({ ...snapshot(), probe, tap, returned }); +const settled = snapshot(); +let ticks = null; +if (spec.advance_to !== undefined) { + ticks = []; + for (const instant of spec.advance_to) { + pinned = new Date(instant).getTime(); + clockTicks.forEach((callback) => callback()); + await element.updateComplete; + ticks.push(snapshot()); + } +} + +const payload = JSON.stringify({ ...settled, probe, tap, returned, ticks }); await new Promise((resolve) => process.stdout.write(payload, () => resolve())); process.exit(0); \ No newline at end of file diff --git a/tests/test_panel_cards.py b/tests/test_panel_cards.py index a82117d..5028d05 100644 --- a/tests/test_panel_cards.py +++ b/tests/test_panel_cards.py @@ -21,6 +21,7 @@ end-to-end through the quest-log harness. from __future__ import annotations import json +import os import re import subprocess from pathlib import Path @@ -831,6 +832,9 @@ def _render_quest_log( probe_ms: int | None = None, tap_complete: int | None = None, tap_return: bool = False, + now: str | None = None, + advance_to: list[str] | None = None, + tz: str | None = None, ) -> dict: """Render the built bundle's quest log and report its location. @@ -840,7 +844,10 @@ def _render_quest_log( segment — waits the optional probe window and captures one snapshot of the render mid-wait, then the optional idle window, and prints the final pathname and rendered title (plus the probe snapshot when - ``probe_ms`` was given). + ``probe_ms`` was given). ``now`` pins the card's clock to an ISO + instant, ``advance_to`` moves it through further instants firing the + card's clock tick (one snapshot each, under ``ticks``), and ``tz`` + sets the node process's ``TZ`` — the panel browser's local zone. """ assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build" assert QUEST_LOG_HARNESS.is_file() @@ -858,12 +865,17 @@ def _render_quest_log( spec["tap_complete"] = tap_complete if tap_return: spec["tap_return"] = True + if now is not None: + spec["now"] = now + if advance_to is not None: + spec["advance_to"] = advance_to completed = subprocess.run( ["node", str(QUEST_LOG_HARNESS)], input=json.dumps(spec), capture_output=True, text=True, timeout=120, + env={**os.environ, "TZ": tz} if tz is not None else None, ) assert completed.returncode == 0, ( f"quest-log harness failed: {completed.stderr}" @@ -1090,12 +1102,13 @@ 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.""" + ``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).""" generated = _generate_dashboard( {}, _three_child_states(), url="http://homeassistant.local/nestquest" ) - def quest(instance_id: int, title: str, overdue: bool) -> dict: + def quest(instance_id: int, title: str, due_time: str) -> dict: return { "id": instance_id, "definition_id": instance_id, @@ -1103,9 +1116,9 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None: "title": title, "icon": None, "window": "morning", - "due_time": "08:15", + "due_time": due_time, "state": "open", - "overdue": overdue, + "overdue": False, "completed_at": None, "on_time": None, } @@ -1118,8 +1131,8 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None: child_id=1, present=True, instances=[ - quest(41, "Make Bed", overdue=False), - quest(42, "Brush Teeth", overdue=True), + quest(41, "Make Bed", "08:15"), + quest(42, "Brush Teeth", "07:00"), ], ), } @@ -1127,6 +1140,8 @@ 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:]: @@ -1143,6 +1158,83 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None: assert metas["42"].strip() == 'class="meta late"', metas["42"] +def _quest_metas(html: str) -> dict[str, tuple[str, str]]: + """Map each rendered open quest's instance id to its meta line's + attributes and text.""" + metas = {} + for card in html.split('data-instance-id="')[1:]: + meta = re.search( + r'.*?' + r"\s*(?:\s*)*]*)>(.*?)", + card, + re.S, + ) + assert meta is not None, card[:800] + text = " ".join(re.sub(r"", "", meta.group(2)).split()) + metas[card[: card.index('"')]] = (meta.group(1).strip(), text) + 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.""" + generated = _generate_dashboard( + {}, _three_child_states(), url="http://homeassistant.local/nestquest" + ) + + def quest(instance_id: int, title: str, due_time: str | None) -> dict: + return { + "id": instance_id, + "definition_id": instance_id, + "child_id": 1, + "title": title, + "icon": None, + "window": "morning", + "due_time": due_time, + "state": "open", + "overdue": True, + "completed_at": None, + "on_time": None, + } + + states = { + **_three_child_states(), + "sensor.nestquest_ada_quests_due_today": _state( + "2", + child_name="Ada", + child_id=1, + present=True, + instances=[ + quest(42, "Brush Teeth", "08:15"), + quest(43, "Feed Fish", None), + ], + ), + } + 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", + ) + 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 = ( + _quest_metas(result["html"]), + *(_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 + + def test_quest_log_crest_matches_the_spec_geometry() -> None: """PANEL-SPEC §3 header crest: 96x110 shield, 4px ring of rgba(255,255,255,.22) around the --nq-p-crest face, 44px/700 display