diff --git a/custom_components/nestquest/binary_sensor.py b/custom_components/nestquest/binary_sensor.py index 7cd45b3..7c7fe10 100644 --- a/custom_components/nestquest/binary_sensor.py +++ b/custom_components/nestquest/binary_sensor.py @@ -135,6 +135,17 @@ class NestQuestPresentTodaySensor(_NestQuestChildBinarySensor): return None return child.present + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Base attributes plus, for a child away today, the ISO date + of their next present day (the panel's away plate renders + ``Returns , `` from it).""" + base = super().extra_state_attributes + if base is None: + return None + child = self._child + return {**base, "next_present": child.next_present if child else None} + def _child_binary_sensors( coordinator: NestQuestCoordinator, child: ChildDaySnapshot diff --git a/custom_components/nestquest/coordinator.py b/custom_components/nestquest/coordinator.py index 08f9ad9..39dc777 100644 --- a/custom_components/nestquest/coordinator.py +++ b/custom_components/nestquest/coordinator.py @@ -50,6 +50,7 @@ from .dao_rules import QuestDefinitionsDao from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao from .db import NestQuestDatabase from .presence import ( + MAX_PREVIEW_SCAN_DAYS, PresenceEngine, PresenceOverride, PresenceSchedule, @@ -86,6 +87,11 @@ class ChildDaySnapshot: completed_today: int remaining_today: int completion_pct: int + #: For a child away today: the ISO date of their next present day + #: (the panel's away plate renders ``Returns , `` + #: from it). ``None`` when the child is present today or no + #: present day exists within the presence engine's scan cap. + next_present: str | None = None @dataclass(frozen=True) @@ -162,10 +168,14 @@ class NestQuestCoordinator(DataUpdateCoordinator): schedules[child.id] = PresenceSchedule.decode( child.id, schedule.anchor_date, schedule.pattern ) - todays = await PresenceOverridesDao( + window_end = today + datetime.timedelta( + days=MAX_PREVIEW_SCAN_DAYS + ) + window_end_iso = window_end.isoformat() + ranged = await PresenceOverridesDao( self.database - ).list_by_child_and_range(child.id, today_iso, today_iso) - if todays: + ).list_by_child_and_range(child.id, today_iso, window_end_iso) + if ranged: # The engine validates MODEL objects, not DAO records; # each record converts losslessly (the schema guarantees # end >= start and a real is_present). @@ -177,7 +187,7 @@ class NestQuestCoordinator(DataUpdateCoordinator): record.is_present, record.note, ) - for record in todays + for record in ranged ] engine = PresenceEngine(schedules, overrides) @@ -231,18 +241,28 @@ class NestQuestCoordinator(DataUpdateCoordinator): due = len(views) completed = sum(1 for view in views if view.state == "done") remaining = due - completed + child_present = engine.is_present(child.id, today) + next_present: str | None = None + if not child_present: + try: + next_present = engine.next_present_dates( + child.id, today, 1 + )[0].isoformat() + except ValueError: + next_present = None snapshots.append( ChildDaySnapshot( child_id=child.id, child_name=child.display_name, instances=tuple(views), - present=engine.is_present(child.id, today), + present=child_present, due_today=due, completed_today=completed, remaining_today=remaining, completion_pct=( round(completed * 100 / due) if due else 100 ), + next_present=next_present, ) ) diff --git a/custom_components/nestquest/www/nestquest-cards.js b/custom_components/nestquest/www/nestquest-cards.js index db966a0..747ba7f 100644 --- a/custom_components/nestquest/www/nestquest-cards.js +++ b/custom_components/nestquest/www/nestquest-cards.js @@ -1,15 +1,15 @@ -const k = globalThis, M = k.ShadowRoot && (k.ShadyCSS === void 0 || k.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, tt = /* @__PURE__ */ Symbol(), j = /* @__PURE__ */ new WeakMap(); -let ot = class { - constructor(t, e, s) { - if (this._$cssResult$ = !0, s !== tt) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); +const T = globalThis, L = T.ShadowRoot && (T.ShadyCSS === void 0 || T.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, j = /* @__PURE__ */ Symbol(), K = /* @__PURE__ */ new WeakMap(); +let ct = class { + constructor(t, e, n) { + if (this._$cssResult$ = !0, n !== j) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); this.cssText = t, this.t = e; } get styleSheet() { let t = this.o; const e = this.t; - if (M && t === void 0) { - const s = e !== void 0 && e.length === 1; - s && (t = j.get(e)), t === void 0 && ((this.o = t = new CSSStyleSheet()).replaceSync(this.cssText), s && j.set(e, t)); + if (L && t === void 0) { + const n = e !== void 0 && e.length === 1; + n && (t = K.get(e)), t === void 0 && ((this.o = t = new CSSStyleSheet()).replaceSync(this.cssText), n && K.set(e, t)); } return t; } @@ -17,108 +17,115 @@ let ot = class { return this.cssText; } }; -const U = (i) => new ot(typeof i == "string" ? i : i + "", void 0, tt), ht = (i, t) => { - if (M) i.adoptedStyleSheets = t.map((e) => e instanceof CSSStyleSheet ? e : e.styleSheet); +const $ = (s) => new ct(typeof s == "string" ? s : s + "", void 0, j), _t = (s, ...t) => { + const e = s.length === 1 ? s[0] : t.reduce((n, i, 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."); + })(i) + s[r + 1], s[0]); + return new ct(e, s, j); +}, $t = (s, t) => { + if (L) s.adoptedStyleSheets = t.map((e) => e instanceof CSSStyleSheet ? e : e.styleSheet); else for (const e of t) { - const s = document.createElement("style"), n = k.litNonce; - n !== void 0 && s.setAttribute("nonce", n), s.textContent = e.cssText, i.appendChild(s); + const n = document.createElement("style"), i = T.litNonce; + i !== void 0 && n.setAttribute("nonce", i), n.textContent = e.cssText, s.appendChild(n); } -}, I = M ? (i) => i : (i) => i instanceof CSSStyleSheet ? ((t) => { +}, J = L ? (s) => s : (s) => s instanceof CSSStyleSheet ? ((t) => { let e = ""; - for (const s of t.cssRules) e += s.cssText; - return U(e); -})(i) : i; -const { is: dt, defineProperty: pt, getOwnPropertyDescriptor: lt, getOwnPropertyNames: ct, getOwnPropertySymbols: ut, getPrototypeOf: $t } = Object, z = globalThis, Q = z.trustedTypes, ft = Q ? Q.emptyScript : "", gt = z.reactiveElementPolyfillSupport, E = (i, t) => i, T = { toAttribute(i, t) { + for (const n of t.cssRules) e += n.cssText; + return $(e); +})(s) : s; +const { is: vt, defineProperty: qt, getOwnPropertyDescriptor: xt, getOwnPropertyNames: wt, getOwnPropertySymbols: At, getPrototypeOf: kt } = Object, M = globalThis, G = M.trustedTypes, Et = G ? G.emptyScript : "", Ct = M.reactiveElementPolyfillSupport, E = (s, t) => s, F = { toAttribute(s, t) { switch (t) { case Boolean: - i = i ? ft : null; + s = s ? Et : null; break; case Object: case Array: - i = i == null ? i : JSON.stringify(i); + s = s == null ? s : JSON.stringify(s); } - return i; -}, fromAttribute(i, t) { - let e = i; + return s; +}, fromAttribute(s, t) { + let e = s; switch (t) { case Boolean: - e = i !== null; + e = s !== null; break; case Number: - e = i === null ? null : Number(i); + e = s === null ? null : Number(s); break; case Object: case Array: try { - e = JSON.parse(i); + e = JSON.parse(s); } catch { e = null; } } return e; -} }, et = (i, t) => !dt(i, t), W = { attribute: !0, type: String, converter: T, reflect: !1, useDefault: !1, hasChanged: et }; -Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), z.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); -let b = class extends HTMLElement { +} }, ht = (s, t) => !vt(s, t), X = { attribute: !0, type: String, converter: F, reflect: !1, useDefault: !1, hasChanged: ht }; +Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), M.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); +let x = class extends HTMLElement { static addInitializer(t) { this._$Ei(), (this.l ??= []).push(t); } static get observedAttributes() { return this.finalize(), this._$Eh && [...this._$Eh.keys()]; } - static createProperty(t, e = W) { + static createProperty(t, e = X) { if (e.state && (e.attribute = !1), this._$Ei(), this.prototype.hasOwnProperty(t) && ((e = Object.create(e)).wrapped = !0), this.elementProperties.set(t, e), !e.noAccessor) { - const s = /* @__PURE__ */ Symbol(), n = this.getPropertyDescriptor(t, s, e); - n !== void 0 && pt(this.prototype, t, n); + const n = /* @__PURE__ */ Symbol(), i = this.getPropertyDescriptor(t, n, e); + i !== void 0 && qt(this.prototype, t, i); } } - static getPropertyDescriptor(t, e, s) { - const { get: n, set: r } = lt(this.prototype, t) ?? { get() { + static getPropertyDescriptor(t, e, n) { + const { get: i, set: r } = xt(this.prototype, t) ?? { get() { return this[e]; }, set(a) { this[e] = a; } }; - return { get: n, set(a) { - const d = n?.call(this); - r?.call(this, a), this.requestUpdate(t, d, s); + return { get: i, set(a) { + const l = i?.call(this); + r?.call(this, a), this.requestUpdate(t, l, n); }, configurable: !0, enumerable: !0 }; } static getPropertyOptions(t) { - return this.elementProperties.get(t) ?? W; + return this.elementProperties.get(t) ?? X; } static _$Ei() { if (this.hasOwnProperty(E("elementProperties"))) return; - const t = $t(this); + const t = kt(this); t.finalize(), t.l !== void 0 && (this.l = [...t.l]), this.elementProperties = new Map(t.elementProperties); } static finalize() { if (this.hasOwnProperty(E("finalized"))) return; if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(E("properties"))) { - const e = this.properties, s = [...ct(e), ...ut(e)]; - for (const n of s) this.createProperty(n, e[n]); + const e = this.properties, n = [...wt(e), ...At(e)]; + for (const i of n) this.createProperty(i, e[i]); } const t = this[Symbol.metadata]; if (t !== null) { const e = litPropertyMetadata.get(t); - if (e !== void 0) for (const [s, n] of e) this.elementProperties.set(s, n); + if (e !== void 0) for (const [n, i] of e) this.elementProperties.set(n, i); } this._$Eh = /* @__PURE__ */ new Map(); - for (const [e, s] of this.elementProperties) { - const n = this._$Eu(e, s); - n !== void 0 && this._$Eh.set(n, e); + for (const [e, n] of this.elementProperties) { + const i = this._$Eu(e, n); + i !== void 0 && this._$Eh.set(i, e); } this.elementStyles = this.finalizeStyles(this.styles); } static finalizeStyles(t) { const e = []; if (Array.isArray(t)) { - const s = new Set(t.flat(1 / 0).reverse()); - for (const n of s) e.unshift(I(n)); - } else t !== void 0 && e.push(I(t)); + const n = new Set(t.flat(1 / 0).reverse()); + for (const i of n) e.unshift(J(i)); + } else t !== void 0 && e.push(J(t)); return e; } static _$Eu(t, e) { - const s = e.attribute; - return s === !1 ? void 0 : typeof s == "string" ? s : typeof t == "string" ? t.toLowerCase() : void 0; + const n = e.attribute; + return n === !1 ? void 0 : typeof n == "string" ? n : typeof t == "string" ? t.toLowerCase() : void 0; } constructor() { super(), this._$Ep = void 0, this.isUpdatePending = !1, this.hasUpdated = !1, this._$Em = null, this._$Ev(); @@ -134,12 +141,12 @@ let b = class extends HTMLElement { } _$E_() { const t = /* @__PURE__ */ new Map(), e = this.constructor.elementProperties; - for (const s of e.keys()) this.hasOwnProperty(s) && (t.set(s, this[s]), delete this[s]); + for (const n of e.keys()) this.hasOwnProperty(n) && (t.set(n, this[n]), delete this[n]); t.size > 0 && (this._$Ep = t); } createRenderRoot() { const t = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions); - return ht(t, this.constructor.elementStyles), t; + return $t(t, this.constructor.elementStyles), t; } connectedCallback() { this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(!0), this._$EO?.forEach((t) => t.hostConnected?.()); @@ -149,35 +156,35 @@ let b = class extends HTMLElement { disconnectedCallback() { this._$EO?.forEach((t) => t.hostDisconnected?.()); } - attributeChangedCallback(t, e, s) { - this._$AK(t, s); + attributeChangedCallback(t, e, n) { + this._$AK(t, n); } _$ET(t, e) { - const s = this.constructor.elementProperties.get(t), n = this.constructor._$Eu(t, s); - if (n !== void 0 && s.reflect === !0) { - const r = (s.converter?.toAttribute !== void 0 ? s.converter : T).toAttribute(e, s.type); - this._$Em = t, r == null ? this.removeAttribute(n) : this.setAttribute(n, r), this._$Em = null; + const n = this.constructor.elementProperties.get(t), i = this.constructor._$Eu(t, n); + if (i !== void 0 && n.reflect === !0) { + const r = (n.converter?.toAttribute !== void 0 ? n.converter : F).toAttribute(e, n.type); + this._$Em = t, r == null ? this.removeAttribute(i) : this.setAttribute(i, r), this._$Em = null; } } _$AK(t, e) { - const s = this.constructor, n = s._$Eh.get(t); - if (n !== void 0 && this._$Em !== n) { - const r = s.getPropertyOptions(n), a = typeof r.converter == "function" ? { fromAttribute: r.converter } : r.converter?.fromAttribute !== void 0 ? r.converter : T; - this._$Em = n; - const d = a.fromAttribute(e, r.type); - this[n] = d ?? this._$Ej?.get(n) ?? d, this._$Em = null; + const n = this.constructor, i = n._$Eh.get(t); + if (i !== void 0 && this._$Em !== i) { + const r = n.getPropertyOptions(i), a = typeof r.converter == "function" ? { fromAttribute: r.converter } : r.converter?.fromAttribute !== void 0 ? r.converter : F; + this._$Em = i; + const l = a.fromAttribute(e, r.type); + this[i] = l ?? this._$Ej?.get(i) ?? l, this._$Em = null; } } - requestUpdate(t, e, s, n = !1, r) { + requestUpdate(t, e, n, i = !1, r) { if (t !== void 0) { const a = this.constructor; - if (n === !1 && (r = this[t]), s ??= a.getPropertyOptions(t), !((s.hasChanged ?? et)(r, e) || s.useDefault && s.reflect && r === this._$Ej?.get(t) && !this.hasAttribute(a._$Eu(t, s)))) return; - this.C(t, e, s); + if (i === !1 && (r = this[t]), n ??= a.getPropertyOptions(t), !((n.hasChanged ?? ht)(r, e) || n.useDefault && n.reflect && r === this._$Ej?.get(t) && !this.hasAttribute(a._$Eu(t, n)))) return; + this.C(t, e, n); } this.isUpdatePending === !1 && (this._$ES = this._$EP()); } - C(t, e, { useDefault: s, reflect: n, wrapped: r }, a) { - s && !(this._$Ej ??= /* @__PURE__ */ new Map()).has(t) && (this._$Ej.set(t, a ?? e ?? this[t]), r !== !0 || a !== void 0) || (this._$AL.has(t) || (this.hasUpdated || s || (e = void 0), this._$AL.set(t, e)), n === !0 && this._$Em !== t && (this._$Eq ??= /* @__PURE__ */ new Set()).add(t)); + C(t, e, { useDefault: n, reflect: i, wrapped: r }, a) { + n && !(this._$Ej ??= /* @__PURE__ */ new Map()).has(t) && (this._$Ej.set(t, a ?? e ?? this[t]), r !== !0 || a !== void 0) || (this._$AL.has(t) || (this.hasUpdated || n || (e = void 0), this._$AL.set(t, e)), i === !0 && this._$Em !== t && (this._$Eq ??= /* @__PURE__ */ new Set()).add(t)); } async _$EP() { this.isUpdatePending = !0; @@ -196,21 +203,21 @@ let b = class extends HTMLElement { if (!this.isUpdatePending) return; if (!this.hasUpdated) { if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) { - for (const [n, r] of this._$Ep) this[n] = r; + for (const [i, r] of this._$Ep) this[i] = r; this._$Ep = void 0; } - const s = this.constructor.elementProperties; - if (s.size > 0) for (const [n, r] of s) { - const { wrapped: a } = r, d = this[n]; - a !== !0 || this._$AL.has(n) || d === void 0 || this.C(n, void 0, r, d); + const n = this.constructor.elementProperties; + if (n.size > 0) for (const [i, r] of n) { + const { wrapped: a } = r, l = this[i]; + a !== !0 || this._$AL.has(i) || l === void 0 || this.C(i, void 0, r, l); } } let t = !1; const e = this._$AL; try { - t = this.shouldUpdate(e), t ? (this.willUpdate(e), this._$EO?.forEach((s) => s.hostUpdate?.()), this.update(e)) : this._$EM(); - } catch (s) { - throw t = !1, this._$EM(), s; + t = this.shouldUpdate(e), t ? (this.willUpdate(e), this._$EO?.forEach((n) => n.hostUpdate?.()), this.update(e)) : this._$EM(); + } catch (n) { + throw t = !1, this._$EM(), n; } t && this._$AE(e); } @@ -239,70 +246,70 @@ let b = class extends HTMLElement { firstUpdated(t) { } }; -b.elementStyles = [], b.shadowRootOptions = { mode: "open" }, b[E("elementProperties")] = /* @__PURE__ */ new Map(), b[E("finalized")] = /* @__PURE__ */ new Map(), gt?.({ ReactiveElement: b }), (z.reactiveElementVersions ??= []).push("2.1.2"); -const R = globalThis, V = (i) => i, N = R.trustedTypes, K = N ? N.createPolicy("lit-html", { createHTML: (i) => i }) : void 0, st = "$lit$", f = `lit$${Math.random().toFixed(9).slice(2)}$`, nt = "?" + f, _t = `<${nt}>`, y = document, x = () => y.createComment(""), w = (i) => i === null || typeof i != "object" && typeof i != "function", D = Array.isArray, mt = (i) => D(i) || typeof i?.[Symbol.iterator] == "function", H = `[ -\f\r]`, v = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, Z = /-->/g, J = />/g, g = RegExp(`>|${H}(?:([^\\s"'>=/]+)(${H}*=${H}*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`, "g"), G = /'/g, X = /"/g, it = /^(?:script|style|textarea|title)$/i, yt = (i) => (t, ...e) => ({ _$litType$: i, strings: t, values: e }), B = yt(1), A = /* @__PURE__ */ Symbol.for("lit-noChange"), l = /* @__PURE__ */ Symbol.for("lit-nothing"), Y = /* @__PURE__ */ new WeakMap(), _ = y.createTreeWalker(y, 129); -function rt(i, t) { - if (!D(i) || !i.hasOwnProperty("raw")) throw Error("invalid template strings array"); - return K !== void 0 ? K.createHTML(t) : t; +x.elementStyles = [], x.shadowRootOptions = { mode: "open" }, x[E("elementProperties")] = /* @__PURE__ */ new Map(), x[E("finalized")] = /* @__PURE__ */ new Map(), Ct?.({ ReactiveElement: x }), (M.reactiveElementVersions ??= []).push("2.1.2"); +const B = globalThis, Y = (s) => s, z = B.trustedTypes, tt = z ? z.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, ut = "$lit$", y = `lit$${Math.random().toFixed(9).slice(2)}$`, ft = "?" + y, St = `<${ft}>`, q = document, C = () => q.createComment(""), S = (s) => s === null || typeof s != "object" && typeof s != "function", Q = Array.isArray, Nt = (s) => Q(s) || typeof s?.[Symbol.iterator] == "function", D = `[ +\f\r]`, k = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, et = /-->/g, nt = />/g, b = RegExp(`>|${D}(?:([^\\s"'>=/]+)(${D}*=${D}*(?:[^ +\f\r"'\`<>=]|("|')|))|$)`, "g"), st = /'/g, it = /"/g, gt = /^(?:script|style|textarea|title)$/i, Pt = (s) => (t, ...e) => ({ _$litType$: s, strings: t, values: e }), g = Pt(1), w = /* @__PURE__ */ Symbol.for("lit-noChange"), c = /* @__PURE__ */ Symbol.for("lit-nothing"), rt = /* @__PURE__ */ new WeakMap(), _ = q.createTreeWalker(q, 129); +function mt(s, t) { + if (!Q(s) || !s.hasOwnProperty("raw")) throw Error("invalid template strings array"); + return tt !== void 0 ? tt.createHTML(t) : t; } -const bt = (i, t) => { - const e = i.length - 1, s = []; - let n, r = t === 2 ? "" : t === 3 ? "" : "", a = v; - for (let d = 0; d < e; d++) { - const o = i[d]; - let p, c, h = -1, u = 0; - for (; u < o.length && (a.lastIndex = u, c = a.exec(o), c !== null); ) u = a.lastIndex, a === v ? c[1] === "!--" ? a = Z : c[1] !== void 0 ? a = J : c[2] !== void 0 ? (it.test(c[2]) && (n = RegExp("" ? (a = n ?? v, h = -1) : c[1] === void 0 ? h = -2 : (h = a.lastIndex - c[2].length, p = c[1], a = c[3] === void 0 ? g : c[3] === '"' ? X : G) : a === X || a === G ? a = g : a === Z || a === J ? a = v : (a = g, n = void 0); - const $ = a === g && i[d + 1].startsWith("/>") ? " " : ""; - r += a === v ? o + _t : h >= 0 ? (s.push(p), o.slice(0, h) + st + o.slice(h) + f + $) : o + f + (h === -2 ? d : $); +const Tt = (s, t) => { + const e = s.length - 1, n = []; + let i, r = t === 2 ? "" : t === 3 ? "" : "", a = k; + for (let l = 0; l < e; l++) { + const o = s[l]; + let d, h, p = -1, f = 0; + for (; f < o.length && (a.lastIndex = f, h = a.exec(o), h !== null); ) f = a.lastIndex, a === k ? h[1] === "!--" ? a = et : h[1] !== void 0 ? a = nt : h[2] !== void 0 ? (gt.test(h[2]) && (i = RegExp("" ? (a = i ?? k, p = -1) : h[1] === void 0 ? p = -2 : (p = a.lastIndex - h[2].length, d = h[1], a = h[3] === void 0 ? b : h[3] === '"' ? it : st) : a === it || a === st ? a = b : a === et || a === nt ? a = k : (a = b, i = void 0); + const u = a === b && s[l + 1].startsWith("/>") ? " " : ""; + r += a === k ? o + St : p >= 0 ? (n.push(d), o.slice(0, p) + ut + o.slice(p) + y + u) : o + y + (p === -2 ? l : u); } - return [rt(i, r + (i[e] || "") + (t === 2 ? "" : t === 3 ? "" : "")), s]; + return [mt(s, r + (s[e] || "") + (t === 2 ? "" : t === 3 ? "" : "")), n]; }; -class S { - constructor({ strings: t, _$litType$: e }, s) { - let n; +class N { + constructor({ strings: t, _$litType$: e }, n) { + let i; this.parts = []; let r = 0, a = 0; - const d = t.length - 1, o = this.parts, [p, c] = bt(t, e); - if (this.el = S.createElement(p, s), _.currentNode = this.el.content, e === 2 || e === 3) { - const h = this.el.content.firstChild; - h.replaceWith(...h.childNodes); + const l = t.length - 1, o = this.parts, [d, h] = Tt(t, e); + if (this.el = N.createElement(d, n), _.currentNode = this.el.content, e === 2 || e === 3) { + const p = this.el.content.firstChild; + p.replaceWith(...p.childNodes); } - for (; (n = _.nextNode()) !== null && o.length < d; ) { - if (n.nodeType === 1) { - if (n.hasAttributes()) for (const h of n.getAttributeNames()) if (h.endsWith(st)) { - const u = c[a++], $ = n.getAttribute(h).split(f), P = /([.?@])?(.*)/.exec(u); - o.push({ type: 1, index: r, name: P[2], strings: $, ctor: P[1] === "." ? qt : P[1] === "?" ? vt : P[1] === "@" ? Et : O }), n.removeAttribute(h); - } else h.startsWith(f) && (o.push({ type: 6, index: r }), n.removeAttribute(h)); - if (it.test(n.tagName)) { - const h = n.textContent.split(f), u = h.length - 1; - if (u > 0) { - n.textContent = N ? N.emptyScript : ""; - for (let $ = 0; $ < u; $++) n.append(h[$], x()), _.nextNode(), o.push({ type: 2, index: ++r }); - n.append(h[u], x()); + for (; (i = _.nextNode()) !== null && o.length < l; ) { + if (i.nodeType === 1) { + if (i.hasAttributes()) for (const p of i.getAttributeNames()) if (p.endsWith(ut)) { + const f = h[a++], u = i.getAttribute(p).split(y), m = /([.?@])?(.*)/.exec(f); + o.push({ type: 1, index: r, name: m[2], strings: u, ctor: m[1] === "." ? Ot : m[1] === "?" ? Mt : m[1] === "@" ? Ut : U }), i.removeAttribute(p); + } else p.startsWith(y) && (o.push({ type: 6, index: r }), i.removeAttribute(p)); + if (gt.test(i.tagName)) { + const p = i.textContent.split(y), f = p.length - 1; + if (f > 0) { + i.textContent = z ? z.emptyScript : ""; + for (let u = 0; u < f; u++) i.append(p[u], C()), _.nextNode(), o.push({ type: 2, index: ++r }); + i.append(p[f], C()); } } - } else if (n.nodeType === 8) if (n.data === nt) o.push({ type: 2, index: r }); + } else if (i.nodeType === 8) if (i.data === ft) o.push({ type: 2, index: r }); else { - let h = -1; - for (; (h = n.data.indexOf(f, h + 1)) !== -1; ) o.push({ type: 7, index: r }), h += f.length - 1; + let p = -1; + for (; (p = i.data.indexOf(y, p + 1)) !== -1; ) o.push({ type: 7, index: r }), p += y.length - 1; } r++; } } static createElement(t, e) { - const s = y.createElement("template"); - return s.innerHTML = t, s; + const n = q.createElement("template"); + return n.innerHTML = t, n; } } -function q(i, t, e = i, s) { - if (t === A) return t; - let n = s !== void 0 ? e._$Co?.[s] : e._$Cl; - const r = w(t) ? void 0 : t._$litDirective$; - return n?.constructor !== r && (n?._$AO?.(!1), r === void 0 ? n = void 0 : (n = new r(i), n._$AT(i, e, s)), s !== void 0 ? (e._$Co ??= [])[s] = n : e._$Cl = n), n !== void 0 && (t = q(i, n._$AS(i, t.values), n, s)), t; +function A(s, t, e = s, n) { + if (t === w) return t; + let i = n !== void 0 ? e._$Co?.[n] : e._$Cl; + const r = S(t) ? void 0 : t._$litDirective$; + return i?.constructor !== r && (i?._$AO?.(!1), r === void 0 ? i = void 0 : (i = new r(s), i._$AT(s, e, n)), n !== void 0 ? (e._$Co ??= [])[n] = i : e._$Cl = i), i !== void 0 && (t = A(s, i._$AS(s, t.values), i, n)), t; } -class At { +class zt { constructor(t, e) { this._$AV = [], this._$AN = void 0, this._$AD = t, this._$AM = e; } @@ -313,29 +320,29 @@ class At { return this._$AM._$AU; } u(t) { - const { el: { content: e }, parts: s } = this._$AD, n = (t?.creationScope ?? y).importNode(e, !0); - _.currentNode = n; - let r = _.nextNode(), a = 0, d = 0, o = s[0]; + const { el: { content: e }, parts: n } = this._$AD, i = (t?.creationScope ?? q).importNode(e, !0); + _.currentNode = i; + let r = _.nextNode(), a = 0, l = 0, o = n[0]; for (; o !== void 0; ) { if (a === o.index) { - let p; - o.type === 2 ? p = new C(r, r.nextSibling, this, t) : o.type === 1 ? p = new o.ctor(r, o.name, o.strings, this, t) : o.type === 6 && (p = new xt(r, this, t)), this._$AV.push(p), o = s[++d]; + let d; + o.type === 2 ? d = new P(r, r.nextSibling, this, t) : o.type === 1 ? d = new o.ctor(r, o.name, o.strings, this, t) : o.type === 6 && (d = new Ht(r, this, t)), this._$AV.push(d), o = n[++l]; } a !== o?.index && (r = _.nextNode(), a++); } - return _.currentNode = y, n; + return _.currentNode = q, i; } p(t) { let e = 0; - for (const s of this._$AV) s !== void 0 && (s.strings !== void 0 ? (s._$AI(t, s, e), e += s.strings.length - 2) : s._$AI(t[e])), e++; + for (const n of this._$AV) n !== void 0 && (n.strings !== void 0 ? (n._$AI(t, n, e), e += n.strings.length - 2) : n._$AI(t[e])), e++; } } -class C { +class P { get _$AU() { return this._$AM?._$AU ?? this._$Cv; } - constructor(t, e, s, n) { - this.type = 2, this._$AH = l, this._$AN = void 0, this._$AA = t, this._$AB = e, this._$AM = s, this.options = n, this._$Cv = n?.isConnected ?? !0; + constructor(t, e, n, i) { + this.type = 2, this._$AH = c, this._$AN = void 0, this._$AA = t, this._$AB = e, this._$AM = n, this.options = i, this._$Cv = i?.isConnected ?? !0; } get parentNode() { let t = this._$AA.parentNode; @@ -349,7 +356,7 @@ class C { return this._$AB; } _$AI(t, e = this) { - t = q(this, t, e), w(t) ? t === l || t == null || t === "" ? (this._$AH !== l && this._$AR(), this._$AH = l) : t !== this._$AH && t !== A && this._(t) : t._$litType$ !== void 0 ? this.$(t) : t.nodeType !== void 0 ? this.T(t) : mt(t) ? this.k(t) : this._(t); + t = A(this, t, e), S(t) ? t === c || t == null || t === "" ? (this._$AH !== c && this._$AR(), this._$AH = c) : t !== this._$AH && t !== w && this._(t) : t._$litType$ !== void 0 ? this.$(t) : t.nodeType !== void 0 ? this.T(t) : Nt(t) ? this.k(t) : this._(t); } O(t) { return this._$AA.parentNode.insertBefore(t, this._$AB); @@ -358,115 +365,115 @@ class C { this._$AH !== t && (this._$AR(), this._$AH = this.O(t)); } _(t) { - this._$AH !== l && w(this._$AH) ? this._$AA.nextSibling.data = t : this.T(y.createTextNode(t)), this._$AH = t; + this._$AH !== c && S(this._$AH) ? this._$AA.nextSibling.data = t : this.T(q.createTextNode(t)), this._$AH = t; } $(t) { - const { values: e, _$litType$: s } = t, n = typeof s == "number" ? this._$AC(t) : (s.el === void 0 && (s.el = S.createElement(rt(s.h, s.h[0]), this.options)), s); - if (this._$AH?._$AD === n) this._$AH.p(e); + const { values: e, _$litType$: n } = t, i = typeof n == "number" ? this._$AC(t) : (n.el === void 0 && (n.el = N.createElement(mt(n.h, n.h[0]), this.options)), n); + if (this._$AH?._$AD === i) this._$AH.p(e); else { - const r = new At(n, this), a = r.u(this.options); + const r = new zt(i, this), a = r.u(this.options); r.p(e), this.T(a), this._$AH = r; } } _$AC(t) { - let e = Y.get(t.strings); - return e === void 0 && Y.set(t.strings, e = new S(t)), e; + let e = rt.get(t.strings); + return e === void 0 && rt.set(t.strings, e = new N(t)), e; } k(t) { - D(this._$AH) || (this._$AH = [], this._$AR()); + Q(this._$AH) || (this._$AH = [], this._$AR()); const e = this._$AH; - let s, n = 0; - for (const r of t) n === e.length ? e.push(s = new C(this.O(x()), this.O(x()), this, this.options)) : s = e[n], s._$AI(r), n++; - n < e.length && (this._$AR(s && s._$AB.nextSibling, n), e.length = n); + let n, i = 0; + for (const r of t) i === e.length ? e.push(n = new P(this.O(C()), this.O(C()), this, this.options)) : n = e[i], n._$AI(r), i++; + i < e.length && (this._$AR(n && n._$AB.nextSibling, i), e.length = i); } _$AR(t = this._$AA.nextSibling, e) { for (this._$AP?.(!1, !0, e); t !== this._$AB; ) { - const s = V(t).nextSibling; - V(t).remove(), t = s; + const n = Y(t).nextSibling; + Y(t).remove(), t = n; } } setConnected(t) { this._$AM === void 0 && (this._$Cv = t, this._$AP?.(t)); } } -class O { +class U { get tagName() { return this.element.tagName; } get _$AU() { return this._$AM._$AU; } - constructor(t, e, s, n, r) { - this.type = 1, this._$AH = l, this._$AN = void 0, this.element = t, this.name = e, this._$AM = n, this.options = r, s.length > 2 || s[0] !== "" || s[1] !== "" ? (this._$AH = Array(s.length - 1).fill(new String()), this.strings = s) : this._$AH = l; + constructor(t, e, n, i, r) { + this.type = 1, this._$AH = c, this._$AN = void 0, this.element = t, this.name = e, this._$AM = i, this.options = r, n.length > 2 || n[0] !== "" || n[1] !== "" ? (this._$AH = Array(n.length - 1).fill(new String()), this.strings = n) : this._$AH = c; } - _$AI(t, e = this, s, n) { + _$AI(t, e = this, n, i) { const r = this.strings; let a = !1; - if (r === void 0) t = q(this, t, e, 0), a = !w(t) || t !== this._$AH && t !== A, a && (this._$AH = t); + if (r === void 0) t = A(this, t, e, 0), a = !S(t) || t !== this._$AH && t !== w, a && (this._$AH = t); else { - const d = t; - let o, p; - for (t = r[0], o = 0; o < r.length - 1; o++) p = q(this, d[s + o], e, o), p === A && (p = this._$AH[o]), a ||= !w(p) || p !== this._$AH[o], p === l ? t = l : t !== l && (t += (p ?? "") + r[o + 1]), this._$AH[o] = p; + const l = t; + let o, d; + for (t = r[0], o = 0; o < r.length - 1; o++) d = A(this, l[n + o], e, o), d === w && (d = this._$AH[o]), a ||= !S(d) || d !== this._$AH[o], d === c ? t = c : t !== c && (t += (d ?? "") + r[o + 1]), this._$AH[o] = d; } - a && !n && this.j(t); + a && !i && this.j(t); } j(t) { - t === l ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t ?? ""); + t === c ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t ?? ""); } } -class qt extends O { +class Ot extends U { constructor() { super(...arguments), this.type = 3; } j(t) { - this.element[this.name] = t === l ? void 0 : t; + this.element[this.name] = t === c ? void 0 : t; } } -class vt extends O { +class Mt extends U { constructor() { super(...arguments), this.type = 4; } j(t) { - this.element.toggleAttribute(this.name, !!t && t !== l); + this.element.toggleAttribute(this.name, !!t && t !== c); } } -class Et extends O { - constructor(t, e, s, n, r) { - super(t, e, s, n, r), this.type = 5; +class Ut extends U { + constructor(t, e, n, i, r) { + super(t, e, n, i, r), this.type = 5; } _$AI(t, e = this) { - if ((t = q(this, t, e, 0) ?? l) === A) return; - const s = this._$AH, n = t === l && s !== l || t.capture !== s.capture || t.once !== s.once || t.passive !== s.passive, r = t !== l && (s === l || n); - n && this.element.removeEventListener(this.name, this, s), r && this.element.addEventListener(this.name, this, t), this._$AH = t; + if ((t = A(this, t, e, 0) ?? c) === w) return; + const n = this._$AH, i = t === c && n !== c || t.capture !== n.capture || t.once !== n.once || t.passive !== n.passive, r = t !== c && (n === c || i); + i && this.element.removeEventListener(this.name, this, n), r && this.element.addEventListener(this.name, this, t), this._$AH = t; } handleEvent(t) { typeof this._$AH == "function" ? this._$AH.call(this.options?.host ?? this.element, t) : this._$AH.handleEvent(t); } } -class xt { - constructor(t, e, s) { - this.element = t, this.type = 6, this._$AN = void 0, this._$AM = e, this.options = s; +class Ht { + constructor(t, e, n) { + this.element = t, this.type = 6, this._$AN = void 0, this._$AM = e, this.options = n; } get _$AU() { return this._$AM._$AU; } _$AI(t) { - q(this, t); + A(this, t); } } -const wt = R.litHtmlPolyfillSupport; -wt?.(S, C), (R.litHtmlVersions ??= []).push("3.3.3"); -const St = (i, t, e) => { - const s = e?.renderBefore ?? t; - let n = s._$litPart$; - if (n === void 0) { +const Dt = B.litHtmlPolyfillSupport; +Dt?.(N, P), (B.litHtmlVersions ??= []).push("3.3.3"); +const Rt = (s, t, e) => { + const n = e?.renderBefore ?? t; + let i = n._$litPart$; + if (i === void 0) { const r = e?.renderBefore ?? null; - s._$litPart$ = n = new C(t.insertBefore(x(), r), r, void 0, e ?? {}); + n._$litPart$ = i = new P(t.insertBefore(C(), r), r, void 0, e ?? {}); } - return n._$AI(i), n; + return i._$AI(s), i; }; -const F = globalThis; -class m extends b { +const W = globalThis; +class v extends x { constructor() { super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0; } @@ -476,7 +483,7 @@ class m extends b { } update(t) { const e = this.render(); - this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t), this._$Do = St(e, this.renderRoot, this.renderOptions); + this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t), this._$Do = Rt(e, this.renderRoot, this.renderOptions); } connectedCallback() { super.connectedCallback(), this._$Do?.setConnected(!0); @@ -485,49 +492,745 @@ class m extends b { super.disconnectedCallback(), this._$Do?.setConnected(!1); } render() { - return A; + return w; } } -m._$litElement$ = !0, m.finalized = !0, F.litElementHydrateSupport?.({ LitElement: m }); -const Ct = F.litElementPolyfillSupport; -Ct?.({ LitElement: m }); -(F.litElementVersions ??= []).push("4.2.2"); -const at = ':host{--nq-p-parchment-top: #f0e4c7;--nq-p-parchment-bottom: #ddcca3;--nq-p-parchment: radial-gradient(ellipse at 25% 10%, rgba(255,255,255,.5), transparent 55%), radial-gradient(ellipse at 85% 90%, rgba(120,86,44,.28), transparent 60%), repeating-linear-gradient(93deg, rgba(150,115,70,.05) 0 2px, transparent 2px 6px), repeating-linear-gradient(2deg, rgba(150,115,70,.04) 0 3px, transparent 3px 7px), linear-gradient(var(--nq-p-parchment-top), var(--nq-p-parchment-bottom));--nq-p-vignette: inset 0 0 200px rgba(80,52,20,.35);--nq-p-card-open: linear-gradient(#fdf7e6, #f2e7c9);--nq-p-card-done: linear-gradient(#f0e8d3, #e6dcc2);--nq-p-card-panel: linear-gradient(#fcf6e6, #f1e6ca);--nq-p-card-border: rgba(120,88,48,.38);--nq-p-panel-border: rgba(92,62,26,.4);--nq-p-card-shadow: 0 3px 0 rgba(120,88,48,.2), inset 0 1px 0 rgba(255,255,255,.7);--nq-p-panel-shadow: 0 6px 0 rgba(92,62,26,.18), inset 0 2px 0 rgba(255,255,255,.7);--nq-p-frame-outer: 2px solid rgba(92,62,26,.45);--nq-p-frame-inner: 1px solid rgba(92,62,26,.28);--nq-p-rule: 2px solid rgba(92,62,26,.35);--nq-p-ink: #2b1f14;--nq-p-ink-secondary: #5c452a;--nq-p-ink-muted: #6f6455;--nq-p-ink-away: #4d4433;--nq-p-ink-late: #8f1526;--nq-brand-blue: #3B3AB8;--nq-brand-purple: #A035CC;--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-p-icon-tile: var(--nq-brand-gradient);--nq-p-icon-tile-done: rgba(92,62,26,.16);--nq-p-crest: var(--nq-brand-gradient);--nq-p-crest-away: linear-gradient(135deg, #6b6b7a, #7a7286);--nq-p-seal: radial-gradient(circle at 35% 30%, #a8283a, #6d1322);--nq-p-seal-shadow: 0 4px 10px rgba(60,10,20,.4), inset 0 0 0 4px rgba(255,255,255,.14);--nq-p-seal-size: 78px;--nq-p-dock-bg: rgba(43,31,20,.9);--nq-p-dock-ink: #f7efdb;--nq-p-dock-ink-secondary: #d8c9a6;--nq-p-dock-divider: rgba(233,220,189,.3);--nq-p-dock-height: 84px;--nq-p-font-display: "Cinzel Decorative", Cinzel, serif;--nq-p-font-heading: Cinzel, serif;--nq-p-font-body: Nunito, system-ui, sans-serif;--nq-p-size-hero: 86px;--nq-p-size-wordmark: 66px;--nq-p-size-title: 56px;--nq-p-size-name: 48px;--nq-p-size-section: 32px;--nq-p-size-quest: 30px;--nq-p-size-body: 22px;--nq-p-size-label: 21px;--nq-p-track-label: .14em;--nq-p-track-kicker: .3em;--nq-p-quest-min-height: 116px;--nq-p-quest-gap: 24px;--nq-p-button-height: 72px;--nq-p-button-min-width: 150px;--nq-p-confirm-button: 108px;--nq-p-radius-card: 12px;--nq-p-radius-panel: 20px;--nq-p-radius-pill: 9999px;--nq-p-page-inset: 62px;--nq-p-frame-inset: 26px;--nq-ease-out: cubic-bezier(0, 0, .2, 1);--nq-dur-micro: .12s;--nq-dur-base: .2s;--nq-dur-modal: .35s}'; -function L(i) { - window.customCards = window.customCards ?? [], window.customCards.push(i); +v._$litElement$ = !0, v.finalized = !0, W.litElementHydrateSupport?.({ LitElement: v }); +const It = W.litElementPolyfillSupport; +It?.({ LitElement: v }); +(W.litElementVersions ??= []).push("4.2.2"); +const yt = ':host{--nq-p-parchment-top: #f0e4c7;--nq-p-parchment-bottom: #ddcca3;--nq-p-parchment: radial-gradient(ellipse at 25% 10%, rgba(255,255,255,.5), transparent 55%), radial-gradient(ellipse at 85% 90%, rgba(120,86,44,.28), transparent 60%), repeating-linear-gradient(93deg, rgba(150,115,70,.05) 0 2px, transparent 2px 6px), repeating-linear-gradient(2deg, rgba(150,115,70,.04) 0 3px, transparent 3px 7px), linear-gradient(var(--nq-p-parchment-top), var(--nq-p-parchment-bottom));--nq-p-vignette: inset 0 0 200px rgba(80,52,20,.35);--nq-p-card-open: linear-gradient(#fdf7e6, #f2e7c9);--nq-p-card-done: linear-gradient(#f0e8d3, #e6dcc2);--nq-p-card-panel: linear-gradient(#fcf6e6, #f1e6ca);--nq-p-card-border: rgba(120,88,48,.38);--nq-p-panel-border: rgba(92,62,26,.4);--nq-p-card-shadow: 0 3px 0 rgba(120,88,48,.2), inset 0 1px 0 rgba(255,255,255,.7);--nq-p-panel-shadow: 0 6px 0 rgba(92,62,26,.18), inset 0 2px 0 rgba(255,255,255,.7);--nq-p-frame-outer: 2px solid rgba(92,62,26,.45);--nq-p-frame-inner: 1px solid rgba(92,62,26,.28);--nq-p-rule: 2px solid rgba(92,62,26,.35);--nq-p-ink: #2b1f14;--nq-p-ink-secondary: #5c452a;--nq-p-ink-muted: #6f6455;--nq-p-ink-away: #4d4433;--nq-p-ink-late: #8f1526;--nq-brand-blue: #3B3AB8;--nq-brand-purple: #A035CC;--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-p-icon-tile: var(--nq-brand-gradient);--nq-p-icon-tile-done: rgba(92,62,26,.16);--nq-p-crest: var(--nq-brand-gradient);--nq-p-crest-away: linear-gradient(135deg, #6b6b7a, #7a7286);--nq-p-seal: radial-gradient(circle at 35% 30%, #a8283a, #6d1322);--nq-p-seal-shadow: 0 4px 10px rgba(60,10,20,.4), inset 0 0 0 4px rgba(255,255,255,.14);--nq-p-seal-size: 78px;--nq-p-dock-bg: rgba(43,31,20,.9);--nq-p-dock-ink: #f7efdb;--nq-p-dock-ink-secondary: #d8c9a6;--nq-p-dock-divider: rgba(233,220,189,.3);--nq-p-dock-height: 84px;--nq-p-font-display: "Cinzel Decorative", Cinzel, serif;--nq-p-font-heading: Cinzel, serif;--nq-p-font-body: Nunito, system-ui, sans-serif;--nq-p-size-hero: 86px;--nq-p-size-wordmark: 66px;--nq-p-size-title: 56px;--nq-p-size-name: 48px;--nq-p-size-section: 32px;--nq-p-size-quest: 30px;--nq-p-size-body: 22px;--nq-p-size-label: 21px;--nq-p-track-label: .14em;--nq-p-track-kicker: .3em;--nq-p-quest-min-height: 116px;--nq-p-quest-gap: 24px;--nq-p-button-height: 72px;--nq-p-button-min-width: 150px;--nq-p-confirm-button: 108px;--nq-p-radius-card: 12px;--nq-p-radius-panel: 20px;--nq-p-radius-pill: 9999px;--nq-p-page-inset: 62px;--nq-p-frame-inset: 26px;--nq-ease-out: cubic-bezier(0, 0, .2, 1);--nq-dur-micro: .12s;--nq-dur-base: .2s;--nq-dur-modal: .35s}'; +function V(s) { + window.customCards = window.customCards ?? [], window.customCards.push(s); } -class Pt extends m { +const at = /* @__PURE__ */ new Map(); +function O(s, t) { + const e = `${s ?? "local"}|${JSON.stringify(t)}`, n = at.get(e); + if (n) + return n; + let i; + try { + i = new Intl.DateTimeFormat("en-US", { + ...t, + ...s ? { timeZone: s } : {} + }); + } catch { + i = new Intl.DateTimeFormat("en-US", t); + } + return at.set(e, i), i; +} +function Ft(s) { + const t = s?.config; + if (!t || typeof t != "object") + return; + const e = t.time_zone; + return typeof e == "string" && e.trim() ? e.trim() : void 0; +} +function R(s, t) { + return `${O(t, { weekday: "long" }).format(s)}, ${O(t, { month: "short", day: "numeric" }).format(s)}`; +} +function Lt(s, t) { + return O(t, { + hour: "numeric", + minute: "2-digit", + hour12: !0 + }).format(s); +} +const jt = { + "clear-night": "Clear", + cloudy: "Cloudy", + exceptional: "Clear", + fog: "Foggy", + hail: "Hail", + lightning: "Storms", + "lightning-rimmed": "Storms", + partlycloudy: "Partly cloudy", + pouring: "Heavy rain", + rainy: "Rain", + snowy: "Snow", + "snowy-rainy": "Sleet", + sunny: "Sunny", + windy: "Windy", + "windy-variant": "Windy" +}, Bt = { + "clear-night": "Clear night skies", + cloudy: "Grey skies today", + exceptional: "A striking day", + fog: "Mist on the road", + hail: "Ice from the sky", + lightning: "Storms may roll in", + "lightning-rimmed": "Storms may roll in", + partlycloudy: "Sun between clouds", + pouring: "Heavy rain outside", + rainy: "Rain on the walls", + snowy: "Snow on the peaks", + "snowy-rainy": "Sleet may fall", + sunny: "Clear skies today", + windy: "A blustery day", + "windy-variant": "A blustery day" +}; +function ot(s) { + return typeof s == "string" ? s.trim() : ""; +} +function I(s, t) { + if (s == null || s === "") + return t; + const e = typeof s == "number" ? s : Number(s); + return Number.isFinite(e) ? e : t; +} +function lt(s) { + return Number.isFinite(s) ? Math.min(100, Math.max(0, s)) : 0; +} +function pt(s) { + return Math.max(0, Math.round(s)); +} +function Qt(s) { + return s.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" "); +} +function Wt(s, t) { + const e = O(t, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23" + }).formatToParts(new Date(s)), n = (r) => { + const a = e.find((l) => l.type === r); + return a ? Number(a.value) : Number.NaN; + }, i = Date.UTC( + n("year"), + n("month") - 1, + n("day"), + n("hour"), + n("minute"), + n("second") + ); + return Number.isFinite(i) ? i - s : 0; +} +function Vt(s, t) { + if (typeof s != "string") + return null; + const e = s.split("-"); + if (e.length !== 3) + return null; + const [n, i, r] = e.map((o) => Number(o)); + if (!n || !i || !r) + return null; + if (!t) { + const o = new Date(n, i - 1, r); + return Number.isNaN(o.getTime()) ? null : o; + } + const a = Date.UTC(n, i - 1, r), l = new Date(a - Wt(a, t)); + return Number.isNaN(l.getTime()) ? null : l; +} +function Zt(s) { + const t = jt[s]; + return t || s.charAt(0).toUpperCase() + s.slice(1); +} +function Kt(s, t) { + return Bt[s] ?? t; +} +const dt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Jt = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", Gt = _t` + * { + box-sizing: border-box; + } + + :host { + display: block; + height: 100%; + --nq-brand-gradient-h: linear-gradient( + 90deg, + var(--nq-brand-blue), + var(--nq-brand-purple) + ); + } + + .board { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + height: 1080px; + padding: 0; + overflow: hidden; + background: var(--nq-p-parchment); + box-shadow: var(--nq-p-vignette); + font-family: var(--nq-p-font-body); + } + + .frame { + position: absolute; + pointer-events: none; + } + + .frame-outer { + inset: 26px; + border: var(--nq-p-frame-outer); + border-radius: 14px; + } + + .frame-inner { + inset: 36px; + border: var(--nq-p-frame-inner); + border-radius: 8px; + } + + .wordmark-row { + position: absolute; + top: 64px; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 26px; + width: 100%; + } + + .rule { + flex: 1 1 0; + max-width: 340px; + height: 2px; + border-radius: 1px; + background: rgba(92, 62, 26, 0.35); + } + + .d20 { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + clip-path: ${$(Jt)}; + background: var(--nq-brand-gradient); + } + + .d20-numeral { + font-family: var(--nq-p-font-heading); + font-size: 15px; + font-weight: 700; + line-height: 1; + color: #ffffff; + } + + .wordmark { + font-family: var(--nq-p-font-display); + font-size: var(--nq-p-size-wordmark); + font-weight: 900; + line-height: 1.15; + background: var(--nq-brand-gradient); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; + } + + .kicker { + position: absolute; + top: 154px; + left: 0; + right: 0; + font-family: var(--nq-p-font-heading); + font-size: 22px; + font-weight: 600; + letter-spacing: var(--nq-p-track-kicker); + text-transform: uppercase; + line-height: 1.2; + text-align: center; + color: var(--nq-p-ink-secondary); + } + + .plates { + position: absolute; + top: 262px; + left: 110px; + right: 110px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 56px; + } + + .plate { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 26px; + height: 600px; + padding: 44px 28px 40px; + border: 2px solid var(--nq-p-panel-border); + border-radius: var(--nq-p-radius-panel); + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + font: inherit; + } + + button.plate { + width: 100%; + color: inherit; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform var(--nq-dur-micro) var(--nq-ease-out); + } + + button.plate:active { + transform: scale(0.98); + } + + button.plate:focus-visible { + outline: 3px solid var(--nq-brand-purple); + outline-offset: 4px; + } + + .plate.away { + border: 2px dashed var(--nq-p-panel-border); + background: linear-gradient(#f2ecdd, #e6dcc6); + box-shadow: none; + } + + .crest { + position: relative; + flex: none; + width: 188px; + height: 214px; + padding: 4px; + clip-path: ${$(dt)}; + background: rgba(255, 255, 255, 0.22); + } + + .crest-face { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding-bottom: 70px; + clip-path: ${$(dt)}; + background: var(--nq-p-crest); + } + + .crest.away .crest-face { + background: var(--nq-p-crest-away); + } + + .crest .initial { + font-family: var(--nq-p-font-display); + font-size: 82px; + font-weight: 700; + line-height: 1; + color: #ffffff; + } + + .name { + font-family: var(--nq-p-font-heading); + font-size: var(--nq-p-size-name); + font-weight: 700; + letter-spacing: 0.02em; + line-height: 1.1; + color: var(--nq-p-ink); + } + + .plate.away .name { + color: var(--nq-p-ink-away); + } + + .pill { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 8px 20px; + border-radius: var(--nq-p-radius-pill); + background: rgba(22, 120, 60, 0.35); + font-family: var(--nq-p-font-heading); + font-size: 22px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + line-height: 1; + color: #146b36; + } + + .pill svg { + width: 22px; + height: 22px; + color: #157a3c; + } + + .pill.away { + background: rgba(92, 62, 26, 0.16); + color: var(--nq-p-ink-away); + } + + .pill.away svg { + color: var(--nq-p-ink-away); + } + + .progress-line { + font-family: var(--nq-p-font-heading); + font-size: 24px; + font-weight: 700; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .bar { + width: 100%; + height: 16px; + border: 1px solid rgba(92, 62, 26, 0.35); + border-radius: var(--nq-p-radius-pill); + background: rgba(92, 62, 26, 0.18); + overflow: hidden; + } + + .bar .fill { + display: block; + height: 100%; + border-radius: inherit; + background: var(--nq-brand-gradient-h); + } + + .hint { + position: absolute; + top: 930px; + left: 0; + right: 0; + font-family: var(--nq-p-font-body); + font-size: 24px; + font-weight: 600; + line-height: 1.4; + text-align: center; + color: var(--nq-p-ink-secondary); + } + + .spacer { + flex: 1 1 0; + } + + .dock { + position: absolute; + left: 46px; + right: 46px; + bottom: 46px; + height: var(--nq-p-dock-height); + display: flex; + align-items: center; + justify-content: center; + gap: 24px; + padding: 0 40px; + border-radius: 14px; + background: var(--nq-p-dock-bg); + pointer-events: none; + user-select: none; + color: var(--nq-p-dock-ink); + } + + .dock svg { + flex: none; + width: 40px; + height: 40px; + color: var(--nq-p-dock-ink); + } + + .dock .divider { + flex: none; + width: 1px; + height: 40px; + background: var(--nq-p-dock-divider); + } + + .dock .temp { + font-family: var(--nq-p-font-body); + font-size: 38px; + font-weight: 800; + line-height: 1; + } + + .dock .condition { + font-family: var(--nq-p-font-body); + font-size: 26px; + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-dock-ink-secondary); + } + + .dock .date { + font-family: var(--nq-p-font-body); + font-size: 26px; + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-dock-ink-secondary); + } + + .dock .clock { + font-family: var(--nq-p-font-body); + font-size: 38px; + font-weight: 800; + line-height: 1; + } +`, Xt = g``, Yt = g``, te = g``; +class ee extends v { static properties = { hass: { attribute: !1 }, - _config: { state: !0 } + _config: { state: !0 }, + _now: { state: !0 } }; - static styles = [U(at)]; + static styles = [$(yt), Gt]; hass; _config; + _now = /* @__PURE__ */ new Date(); + _clockTimer; setConfig(t) { if (!t || typeof t != "object") throw new Error("Invalid configuration"); this._config = t; } getCardSize() { - return 6; + return 22; + } + connectedCallback() { + super.connectedCallback(), this._now = /* @__PURE__ */ new Date(), this._clockTimer = window.setInterval(() => { + this._now = /* @__PURE__ */ new Date(); + }, 1e3); + } + disconnectedCallback() { + this._clockTimer !== void 0 && (window.clearInterval(this._clockTimer), this._clockTimer = void 0), super.disconnectedCallback(); } render() { - return B`
NestQuest Party Board
`; + return g` +
+
+
+
+ + + NestQuest + + +
+

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

+
+ ${this._plates().map((t) => this._renderPlate(t))} +
+

Tap your crest to open your Quest Log

+
+ ${this._renderDock()} +
+ `; + } + _timeZone() { + return Ft(this.hass); + } + _state(t) { + const n = this.hass?.states?.[t]; + return n && typeof n == "object" ? n : null; + } + _childSlugs() { + if (!this._config) + return []; + const t = this._config.child_order; + return Array.isArray(t) ? t.filter( + (e) => typeof e == "string" && e.trim().length > 0 + ).map((e) => e.trim()) : []; + } + _plates() { + return this._childSlugs().map((t) => this._plate(t)); + } + _plate(t) { + const e = this._state(`sensor.nestquest_${t}_quests_due_today`), n = this._state( + `sensor.nestquest_${t}_quests_completed_today` + ), i = this._state( + `sensor.nestquest_${t}_completion_pct_today` + ), r = this._state( + `binary_sensor.nestquest_${t}_present_today` + ), a = e?.attributes ?? {}, l = pt(I(e?.state, 0)), o = pt(I(n?.state, 0)), d = i ? I(i.state, Number.NaN) : Number.NaN, h = Number.isFinite(d) ? lt(d) : l > 0 ? lt(o / l * 100) : 0; + let p = !0; + const f = a.present; + typeof f == "boolean" ? p = f : r && (p = String(r.state ?? "").trim().toLowerCase() === "on"); + let u = ""; + const m = a.child_name; + if (typeof m == "string" && m.trim()) + u = m.trim(); + else { + const H = r?.attributes?.child_name; + typeof H == "string" && H.trim() && (u = H.trim()); + } + u || (u = Qt(t)); + const Z = Vt( + r?.attributes?.next_present, + this._timeZone() + ), bt = Z ? `Returns ${R(Z, this._timeZone())}` : null; + return { + slug: t, + name: u, + initial: (u.charAt(0) || "?").toUpperCase(), + present: p, + completed: o, + due: l, + pct: h, + returns: bt + }; + } + _renderPlate(t) { + return t.present ? g` + + ` : g` +
+ + + ${t.initial} + + + ${t.name} + + ${Yt} + On travels + + ${t.returns ?? "Returns —"} + +
+ `; + } + _openQuestLog(t) { + const e = ot(this._config?.quest_log_path); + e && (window.history.pushState(null, "", `${e.replace(/\/+$/, "")}/${t}`), window.dispatchEvent(new Event("location-changed"))); + } + _dockWeather() { + const t = ot(this._config?.weather_entity); + if (!t) + return null; + const e = this._state(t); + if (!e) + return null; + const n = String(e.state ?? "").trim(); + if (!n || n === "unavailable" || n === "unknown") + return null; + const i = e.attributes ?? {}; + let r = null, a = null; + const l = i.forecast; + if (Array.isArray(l) && l.length > 0) { + const o = l[0]; + if (o && typeof o == "object") { + const d = o; + r = this._optionalNumber(d.temperature), a = this._optionalNumber(d.templow); + } + } + return { + condition: n, + temperature: this._optionalNumber(i.temperature), + high: r, + low: a + }; + } + _optionalNumber(t) { + if (t == null || t === "") + return null; + const e = typeof t == "number" ? t : Number(t); + return Number.isFinite(e) ? e : null; + } + _renderDock() { + const t = this._dockWeather(); + if (!t) + return g` +
+ ${R(this._now, this._timeZone())} + + ${Lt(this._now, this._timeZone())} +
+ `; + const e = Zt(t.condition), n = Kt(t.condition, e), i = t.temperature === null ? c : g`${Math.round(t.temperature)}°`, r = t.high === null || t.low === null ? null : `${Math.round(t.high)}° / ${Math.round(t.low)}°`; + return g` +
+ ${te} + ${i} + + + ${r === null ? e : `${e} · ${r}`} + + + ${n} +
+ `; } } -customElements.define("nestquest-party-board-card", Pt); -L({ +customElements.define("nestquest-party-board-card", ee); +V({ type: "nestquest-party-board-card", name: "NestQuest Party Board", description: "Kids' attract screen for present and away adventurers." }); -class kt extends m { +class ne extends v { static properties = { hass: { attribute: !1 }, _config: { state: !0 } }; - static styles = [U(at)]; + static styles = [$(yt)]; hass; _config; setConfig(t) { @@ -539,22 +1242,22 @@ class kt extends m { return 8; } render() { - return B`
NestQuest Quest Log
`; + return g`
NestQuest Quest Log
`; } } -customElements.define("nestquest-quest-log-card", kt); -L({ +customElements.define("nestquest-quest-log-card", ne); +V({ type: "nestquest-quest-log-card", name: "NestQuest Quest Log", description: "One child's daily quests, grouped by window." }); -const Nt = ":host{--nq-brand-blue: #3B3AB8;--nq-brand-purple: #A035CC;--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-a-page: #F4F4F6;--nq-a-surface: #FFFFFF;--nq-a-surface-subtle: #FAFAFA;--nq-a-selected: #EBEBF8;--nq-a-border: #E4E4EA;--nq-a-divider: #EDEDF1;--nq-a-ink: #18181D;--nq-a-ink-secondary: #52525E;--nq-a-ink-tertiary: #70707E;--nq-a-success: #15803D;--nq-a-danger: #DC2626;--nq-a-danger-strong: #B91C1C;--nq-a-danger-bg: #FEE2E2;--nq-a-warning-ink: #92400E;--nq-a-warning-ink-2: #78350F;--nq-a-warning-bg: #FEF3C7;--nq-a-warning-border: rgba(217,119,6,.35);--nq-a-info-bg: #DBEAFE;--nq-a-info-ink: #1E3A8A;--nq-a-info-icon: #1D4ED8;--nq-a-reversal: #7D28A0;--nq-a-font: Nunito, system-ui, sans-serif;--nq-a-size-screen: 24px;--nq-a-size-hero: 32px;--nq-a-size-card-title: 19px;--nq-a-size-stat: 22px;--nq-a-size-row: 13.5px;--nq-a-size-meta: 11px;--nq-a-size-label: 10px;--nq-a-track-label: .1em;--nq-a-density-page-pad: 14px 16px 92px;--nq-a-density-gap: 10px;--nq-a-density-card-pad: 13px;--nq-a-density-row-pad: 11px 13px;--nq-a-density-radius: 8px;--nq-a-density-shadow: none;--nq-a-tabbar-height: 70px;--nq-a-tap-min: 44px;--nq-a-radius-pill: 9999px;--nq-a-radius-sheet: 20px 20px 0 0;--nq-a-sheet-shadow: 0 -8px 28px rgba(9,9,11,.18);--nq-a-progress-height: 6px;--nq-ease-out: cubic-bezier(0, 0, .2, 1);--nq-dur-micro: .12s;--nq-dur-base: .2s;--nq-dur-modal: .35s}"; -class Ut extends m { +const se = ":host{--nq-brand-blue: #3B3AB8;--nq-brand-purple: #A035CC;--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-a-page: #F4F4F6;--nq-a-surface: #FFFFFF;--nq-a-surface-subtle: #FAFAFA;--nq-a-selected: #EBEBF8;--nq-a-border: #E4E4EA;--nq-a-divider: #EDEDF1;--nq-a-ink: #18181D;--nq-a-ink-secondary: #52525E;--nq-a-ink-tertiary: #70707E;--nq-a-success: #15803D;--nq-a-danger: #DC2626;--nq-a-danger-strong: #B91C1C;--nq-a-danger-bg: #FEE2E2;--nq-a-warning-ink: #92400E;--nq-a-warning-ink-2: #78350F;--nq-a-warning-bg: #FEF3C7;--nq-a-warning-border: rgba(217,119,6,.35);--nq-a-info-bg: #DBEAFE;--nq-a-info-ink: #1E3A8A;--nq-a-info-icon: #1D4ED8;--nq-a-reversal: #7D28A0;--nq-a-font: Nunito, system-ui, sans-serif;--nq-a-size-screen: 24px;--nq-a-size-hero: 32px;--nq-a-size-card-title: 19px;--nq-a-size-stat: 22px;--nq-a-size-row: 13.5px;--nq-a-size-meta: 11px;--nq-a-size-label: 10px;--nq-a-track-label: .1em;--nq-a-density-page-pad: 14px 16px 92px;--nq-a-density-gap: 10px;--nq-a-density-card-pad: 13px;--nq-a-density-row-pad: 11px 13px;--nq-a-density-radius: 8px;--nq-a-density-shadow: none;--nq-a-tabbar-height: 70px;--nq-a-tap-min: 44px;--nq-a-radius-pill: 9999px;--nq-a-radius-sheet: 20px 20px 0 0;--nq-a-sheet-shadow: 0 -8px 28px rgba(9,9,11,.18);--nq-a-progress-height: 6px;--nq-ease-out: cubic-bezier(0, 0, .2, 1);--nq-dur-micro: .12s;--nq-dur-base: .2s;--nq-dur-modal: .35s}"; +class ie extends v { static properties = { hass: { attribute: !1 }, _config: { state: !0 } }; - static styles = [U(Nt)]; + static styles = [$(se)]; hass; _config; setConfig(t) { @@ -566,17 +1269,17 @@ class Ut extends m { return 8; } render() { - return B`
NestQuest Admin
`; + return g`
NestQuest Admin
`; } } -customElements.define("nestquest-admin-card", Ut); -L({ +customElements.define("nestquest-admin-card", ie); +V({ type: "nestquest-admin-card", name: "NestQuest Admin", description: "Parent phone view for today, tasks, schedule, and history." }); -const zt = "/nestquest-static/nestquest-fonts.css"; +const re = "/nestquest-static/nestquest-fonts.css"; if (!document.querySelector('link[data-nq-fonts=""]')) { - const i = document.createElement("link"); - i.rel = "stylesheet", i.href = zt, i.dataset.nqFonts = "", document.head.appendChild(i); + const s = document.createElement("link"); + s.rel = "stylesheet", s.href = re, s.dataset.nqFonts = "", document.head.appendChild(s); } diff --git a/frontend/src/cards/party-board.ts b/frontend/src/cards/party-board.ts index e6fbb38..891c368 100644 --- a/frontend/src/cards/party-board.ts +++ b/frontend/src/cards/party-board.ts @@ -1,18 +1,604 @@ -import { LitElement, html, unsafeCSS } from "lit"; +import { LitElement, css, html, nothing, unsafeCSS } from "lit"; import panelTokens from "../../../custom_components/nestquest/www/nestquest-panel-tokens.css?inline"; import { registerCustomCard, type CardConfig, type HassLike } from "../types"; +type StateObject = { + state: unknown; + attributes?: Record | null; +}; + +type ChildPlate = { + slug: string; + name: string; + initial: string; + present: boolean; + completed: number; + due: number; + pct: number; + returns: string | null; +}; + +type DockWeather = { + condition: string; + temperature: number | null; + high: number | null; + low: number | null; +}; + +const FORMATTER_CACHE = new Map(); + +function zonedFormatter( + timeZone: string | undefined, + options: Intl.DateTimeFormatOptions +): Intl.DateTimeFormat { + const key = `${timeZone ?? "local"}|${JSON.stringify(options)}`; + const cached = FORMATTER_CACHE.get(key); + if (cached) { + return cached; + } + let formatter: Intl.DateTimeFormat; + try { + formatter = new Intl.DateTimeFormat("en-US", { + ...options, + ...(timeZone ? { timeZone } : {}), + }); + } catch { + formatter = new Intl.DateTimeFormat("en-US", options); + } + FORMATTER_CACHE.set(key, formatter); + return formatter; +} + +function hassTimeZone(hass: HassLike | undefined): string | undefined { + const config = hass?.config; + if (!config || typeof config !== "object") { + return undefined; + } + const timeZone = (config as Record).time_zone; + return typeof timeZone === "string" && timeZone.trim() + ? timeZone.trim() + : undefined; +} + +function formatDay(date: Date, timeZone: string | undefined): string { + return `${zonedFormatter(timeZone, { weekday: "long" }).format(date)}, ${zonedFormatter(timeZone, { month: "short", day: "numeric" }).format(date)}`; +} + +function formatTime(date: Date, timeZone: string | undefined): string { + return zonedFormatter(timeZone, { + hour: "numeric", + minute: "2-digit", + hour12: true, + }).format(date); +} + +const CONDITION_LABELS: Record = { + "clear-night": "Clear", + cloudy: "Cloudy", + exceptional: "Clear", + fog: "Foggy", + hail: "Hail", + lightning: "Storms", + "lightning-rimmed": "Storms", + partlycloudy: "Partly cloudy", + pouring: "Heavy rain", + rainy: "Rain", + snowy: "Snow", + "snowy-rainy": "Sleet", + sunny: "Sunny", + windy: "Windy", + "windy-variant": "Windy", +}; + +const CONDITION_PHRASES: Record = { + "clear-night": "Clear night skies", + cloudy: "Grey skies today", + exceptional: "A striking day", + fog: "Mist on the road", + hail: "Ice from the sky", + lightning: "Storms may roll in", + "lightning-rimmed": "Storms may roll in", + partlycloudy: "Sun between clouds", + pouring: "Heavy rain outside", + rainy: "Rain on the walls", + snowy: "Snow on the peaks", + "snowy-rainy": "Sleet may fall", + sunny: "Clear skies today", + windy: "A blustery day", + "windy-variant": "A blustery day", +}; + +function asString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function asNumber(value: unknown, fallback: number): number { + if (value === null || value === undefined || value === "") { + return fallback; + } + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function clampPercent(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Math.min(100, Math.max(0, value)); +} + +function clampCount(value: number): number { + return Math.max(0, Math.round(value)); +} + +function titleCaseSlug(slug: string): string { + return slug + .split(/[-_]+/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +function zoneOffsetMs(instant: number, timeZone: string): number { + const parts = zonedFormatter(timeZone, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }).formatToParts(new Date(instant)); + const read = (type: Intl.DateTimeFormatPartTypes): number => { + const part = parts.find((entry) => entry.type === type); + return part ? Number(part.value) : Number.NaN; + }; + const asUtc = Date.UTC( + read("year"), + read("month") - 1, + read("day"), + read("hour"), + read("minute"), + read("second") + ); + return Number.isFinite(asUtc) ? asUtc - instant : 0; +} + +function parseIsoDate(value: unknown, timeZone?: string): Date | null { + if (typeof value !== "string") { + return null; + } + const parts = value.split("-"); + if (parts.length !== 3) { + return null; + } + const [year, month, day] = parts.map((part) => Number(part)); + if (!year || !month || !day) { + return null; + } + if (!timeZone) { + const local = new Date(year, month - 1, day); + return Number.isNaN(local.getTime()) ? null : local; + } + const utcMs = Date.UTC(year, month - 1, day); + const date = new Date(utcMs - zoneOffsetMs(utcMs, timeZone)); + return Number.isNaN(date.getTime()) ? null : date; +} + +function conditionLabel(condition: string): string { + const known = CONDITION_LABELS[condition]; + if (known) { + return known; + } + return condition.charAt(0).toUpperCase() + condition.slice(1); +} + +function conditionPhrase(condition: string, label: string): string { + return CONDITION_PHRASES[condition] ?? label; +} + +const SHIELD_CLIP = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)"; +const HEXAGON_CLIP = + "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)"; + +const boardStyles = css` + * { + box-sizing: border-box; + } + + :host { + display: block; + height: 100%; + --nq-brand-gradient-h: linear-gradient( + 90deg, + var(--nq-brand-blue), + var(--nq-brand-purple) + ); + } + + .board { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + height: 1080px; + padding: 0; + overflow: hidden; + background: var(--nq-p-parchment); + box-shadow: var(--nq-p-vignette); + font-family: var(--nq-p-font-body); + } + + .frame { + position: absolute; + pointer-events: none; + } + + .frame-outer { + inset: 26px; + border: var(--nq-p-frame-outer); + border-radius: 14px; + } + + .frame-inner { + inset: 36px; + border: var(--nq-p-frame-inner); + border-radius: 8px; + } + + .wordmark-row { + position: absolute; + top: 64px; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 26px; + width: 100%; + } + + .rule { + flex: 1 1 0; + max-width: 340px; + height: 2px; + border-radius: 1px; + background: rgba(92, 62, 26, 0.35); + } + + .d20 { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + clip-path: ${unsafeCSS(HEXAGON_CLIP)}; + background: var(--nq-brand-gradient); + } + + .d20-numeral { + font-family: var(--nq-p-font-heading); + font-size: 15px; + font-weight: 700; + line-height: 1; + color: #ffffff; + } + + .wordmark { + font-family: var(--nq-p-font-display); + font-size: var(--nq-p-size-wordmark); + font-weight: 900; + line-height: 1.15; + background: var(--nq-brand-gradient); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; + } + + .kicker { + position: absolute; + top: 154px; + left: 0; + right: 0; + font-family: var(--nq-p-font-heading); + font-size: 22px; + font-weight: 600; + letter-spacing: var(--nq-p-track-kicker); + text-transform: uppercase; + line-height: 1.2; + text-align: center; + color: var(--nq-p-ink-secondary); + } + + .plates { + position: absolute; + top: 262px; + left: 110px; + right: 110px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 56px; + } + + .plate { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 26px; + height: 600px; + padding: 44px 28px 40px; + border: 2px solid var(--nq-p-panel-border); + border-radius: var(--nq-p-radius-panel); + background: var(--nq-p-card-panel); + box-shadow: var(--nq-p-panel-shadow); + font: inherit; + } + + button.plate { + width: 100%; + color: inherit; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform var(--nq-dur-micro) var(--nq-ease-out); + } + + button.plate:active { + transform: scale(0.98); + } + + button.plate:focus-visible { + outline: 3px solid var(--nq-brand-purple); + outline-offset: 4px; + } + + .plate.away { + border: 2px dashed var(--nq-p-panel-border); + background: linear-gradient(#f2ecdd, #e6dcc6); + box-shadow: none; + } + + .crest { + position: relative; + flex: none; + width: 188px; + height: 214px; + padding: 4px; + clip-path: ${unsafeCSS(SHIELD_CLIP)}; + background: rgba(255, 255, 255, 0.22); + } + + .crest-face { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding-bottom: 70px; + clip-path: ${unsafeCSS(SHIELD_CLIP)}; + background: var(--nq-p-crest); + } + + .crest.away .crest-face { + background: var(--nq-p-crest-away); + } + + .crest .initial { + font-family: var(--nq-p-font-display); + font-size: 82px; + font-weight: 700; + line-height: 1; + color: #ffffff; + } + + .name { + font-family: var(--nq-p-font-heading); + font-size: var(--nq-p-size-name); + font-weight: 700; + letter-spacing: 0.02em; + line-height: 1.1; + color: var(--nq-p-ink); + } + + .plate.away .name { + color: var(--nq-p-ink-away); + } + + .pill { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 8px 20px; + border-radius: var(--nq-p-radius-pill); + background: rgba(22, 120, 60, 0.35); + font-family: var(--nq-p-font-heading); + font-size: 22px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + line-height: 1; + color: #146b36; + } + + .pill svg { + width: 22px; + height: 22px; + color: #157a3c; + } + + .pill.away { + background: rgba(92, 62, 26, 0.16); + color: var(--nq-p-ink-away); + } + + .pill.away svg { + color: var(--nq-p-ink-away); + } + + .progress-line { + font-family: var(--nq-p-font-heading); + font-size: 24px; + font-weight: 700; + line-height: 1.2; + color: var(--nq-p-ink-secondary); + } + + .bar { + width: 100%; + height: 16px; + border: 1px solid rgba(92, 62, 26, 0.35); + border-radius: var(--nq-p-radius-pill); + background: rgba(92, 62, 26, 0.18); + overflow: hidden; + } + + .bar .fill { + display: block; + height: 100%; + border-radius: inherit; + background: var(--nq-brand-gradient-h); + } + + .hint { + position: absolute; + top: 930px; + left: 0; + right: 0; + font-family: var(--nq-p-font-body); + font-size: 24px; + font-weight: 600; + line-height: 1.4; + text-align: center; + color: var(--nq-p-ink-secondary); + } + + .spacer { + flex: 1 1 0; + } + + .dock { + position: absolute; + left: 46px; + right: 46px; + bottom: 46px; + height: var(--nq-p-dock-height); + display: flex; + align-items: center; + justify-content: center; + gap: 24px; + padding: 0 40px; + border-radius: 14px; + background: var(--nq-p-dock-bg); + pointer-events: none; + user-select: none; + color: var(--nq-p-dock-ink); + } + + .dock svg { + flex: none; + width: 40px; + height: 40px; + color: var(--nq-p-dock-ink); + } + + .dock .divider { + flex: none; + width: 1px; + height: 40px; + background: var(--nq-p-dock-divider); + } + + .dock .temp { + font-family: var(--nq-p-font-body); + font-size: 38px; + font-weight: 800; + line-height: 1; + } + + .dock .condition { + font-family: var(--nq-p-font-body); + font-size: 26px; + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-dock-ink-secondary); + } + + .dock .date { + font-family: var(--nq-p-font-body); + font-size: 26px; + font-weight: 600; + line-height: 1.2; + color: var(--nq-p-dock-ink-secondary); + } + + .dock .clock { + font-family: var(--nq-p-font-body); + font-size: 38px; + font-weight: 800; + line-height: 1; + } +`; + +const ICON_CHECK_CIRCLE = html``; + +const ICON_TENT = html``; + +const ICON_CLOUD_SUN = html``; + export class NestQuestPartyBoardCard extends LitElement { static properties = { hass: { attribute: false }, _config: { state: true }, + _now: { state: true }, }; - static styles = [unsafeCSS(panelTokens)]; + static styles = [unsafeCSS(panelTokens), boardStyles]; hass?: HassLike; _config?: CardConfig; + _now = new Date(); + private _clockTimer?: number; setConfig(config: CardConfig): void { if (!config || typeof config !== "object") { @@ -22,11 +608,279 @@ export class NestQuestPartyBoardCard extends LitElement { } getCardSize(): number { - return 6; + return 22; + } + + connectedCallback(): void { + super.connectedCallback(); + this._now = new Date(); + this._clockTimer = window.setInterval(() => { + this._now = new Date(); + }, 1000); + } + + disconnectedCallback(): void { + if (this._clockTimer !== undefined) { + window.clearInterval(this._clockTimer); + this._clockTimer = undefined; + } + super.disconnectedCallback(); } render() { - return html`
NestQuest Party Board
`; + return html` +
+
+
+
+ + + NestQuest + + +
+

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

+
+ ${this._plates().map((plate) => this._renderPlate(plate))} +
+

Tap your crest to open your Quest Log

+
+ ${this._renderDock()} +
+ `; + } + + private _timeZone(): string | undefined { + return hassTimeZone(this.hass); + } + + private _state(entityId: string): StateObject | null { + const states = this.hass?.states as + | Record + | undefined; + const stateObj = states?.[entityId]; + return stateObj && typeof stateObj === "object" ? stateObj : null; + } + + private _childSlugs(): string[] { + if (!this._config) { + return []; + } + const order = this._config.child_order; + if (!Array.isArray(order)) { + return []; + } + return order + .filter( + (entry): entry is string => + typeof entry === "string" && entry.trim().length > 0 + ) + .map((entry) => entry.trim()); + } + + private _plates(): ChildPlate[] { + return this._childSlugs().map((slug) => this._plate(slug)); + } + + private _plate(slug: string): ChildPlate { + const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`); + const doneSensor = this._state( + `sensor.nestquest_${slug}_quests_completed_today` + ); + const pctSensor = this._state( + `sensor.nestquest_${slug}_completion_pct_today` + ); + const presence = this._state( + `binary_sensor.nestquest_${slug}_present_today` + ); + const dueAttrs = dueSensor?.attributes ?? {}; + + const due = clampCount(asNumber(dueSensor?.state, 0)); + const completed = clampCount(asNumber(doneSensor?.state, 0)); + const rawPct = pctSensor + ? asNumber(pctSensor.state, Number.NaN) + : Number.NaN; + const pct = Number.isFinite(rawPct) + ? clampPercent(rawPct) + : due > 0 + ? clampPercent((completed / due) * 100) + : 0; + + let present = true; + const presentAttr = dueAttrs.present; + if (typeof presentAttr === "boolean") { + present = presentAttr; + } else if (presence) { + present = String(presence.state ?? "").trim().toLowerCase() === "on"; + } + + let name = ""; + const dueName = dueAttrs.child_name; + if (typeof dueName === "string" && dueName.trim()) { + name = dueName.trim(); + } else { + const presenceName = presence?.attributes?.child_name; + if (typeof presenceName === "string" && presenceName.trim()) { + name = presenceName.trim(); + } + } + if (!name) { + name = titleCaseSlug(slug); + } + + const returnsDate = parseIsoDate( + presence?.attributes?.next_present, + this._timeZone() + ); + const returns = returnsDate + ? `Returns ${formatDay(returnsDate, this._timeZone())}` + : null; + + return { + slug, + name, + initial: (name.charAt(0) || "?").toUpperCase(), + present, + completed, + due, + pct, + returns, + }; + } + + private _renderPlate(plate: ChildPlate) { + if (plate.present) { + return html` + + `; + } + return html` +
+ + + ${plate.initial} + + + ${plate.name} + + ${ICON_TENT} + On travels + + ${plate.returns ?? "Returns —"} + +
+ `; + } + + private _openQuestLog(slug: string): void { + const path = asString(this._config?.quest_log_path); + if (!path) { + return; + } + window.history.pushState(null, "", `${path.replace(/\/+$/, "")}/${slug}`); + window.dispatchEvent(new Event("location-changed")); + } + + private _dockWeather(): DockWeather | null { + const entityId = asString(this._config?.weather_entity); + if (!entityId) { + return null; + } + const stateObj = this._state(entityId); + if (!stateObj) { + return null; + } + const condition = String(stateObj.state ?? "").trim(); + if (!condition || condition === "unavailable" || condition === "unknown") { + return null; + } + const attrs = stateObj.attributes ?? {}; + let high: number | null = null; + let low: number | null = null; + const forecast = attrs.forecast; + if (Array.isArray(forecast) && forecast.length > 0) { + const entry = forecast[0]; + if (entry && typeof entry === "object") { + const typed = entry as Record; + high = this._optionalNumber(typed.temperature); + low = this._optionalNumber(typed.templow); + } + } + return { + condition, + temperature: this._optionalNumber(attrs.temperature), + high, + low, + }; + } + + private _optionalNumber(value: unknown): number | null { + if (value === null || value === undefined || value === "") { + return null; + } + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + + private _renderDock() { + const weather = this._dockWeather(); + if (!weather) { + return html` +
+ ${formatDay(this._now, this._timeZone())} + + ${formatTime(this._now, this._timeZone())} +
+ `; + } + const label = conditionLabel(weather.condition); + const phrase = conditionPhrase(weather.condition, label); + const temperature = + weather.temperature === null + ? nothing + : html`${Math.round(weather.temperature)}°`; + const hiLo = + weather.high === null || weather.low === null + ? null + : `${Math.round(weather.high)}° / ${Math.round(weather.low)}°`; + return html` +
+ ${ICON_CLOUD_SUN} + ${temperature} + + + ${hiLo === null ? label : `${label} · ${hiLo}`} + + + ${phrase} +
+ `; } } diff --git a/tests/test_binary_sensors.py b/tests/test_binary_sensors.py index 2c31abf..a4a31e2 100644 --- a/tests/test_binary_sensors.py +++ b/tests/test_binary_sensors.py @@ -6,7 +6,7 @@ import datetime from conftest import wire_entry_to_registry from custom_components.nestquest import async_setup_entry -from custom_components.nestquest.children import create_child +from custom_components.nestquest.children import create_child, list_children from custom_components.nestquest.const import DOMAIN from custom_components.nestquest.dao_presence import PresenceSchedulesDao from custom_components.nestquest.materialize import materialize @@ -128,7 +128,57 @@ async def test_binary_sensor_identity_and_attributes(hass, make_entry) -> None: assert present.name == "NestQuest Ada present today" for entity in (all_done, present): assert entity.device_info.name == "NestQuest Ada" - assert entity.extra_state_attributes == { + assert all_done.extra_state_attributes == { "child_id": ada.id, "child_name": "Ada", } + assert present.extra_state_attributes == { + "child_id": ada.id, + "child_name": "Ada", + "next_present": None, + } + + +async def test_present_today_carries_next_present_when_away( + hass, make_entry +) -> None: + """A child away today carries the ISO date of their next present + day (the panel's away plate renders ``Returns , `` + from it); an always-absent pattern carries ``None``.""" + entry = await _setup_entry(hass, make_entry) + coordinator = entry.runtime_data.coordinator + database = entry.runtime_data.database + ada, _bo = await _seed_two_children(database) + await create_child(database, "Cory") + today = datetime.date.today() + pattern = ",".join( + str(day) for day in range(7) if day != today.weekday() + ) + await PresenceSchedulesDao(database).upsert_by_child( + ada.id, 1, today.isoformat(), pattern + ) + await PresenceSchedulesDao(database).upsert_by_child( + await _cory_id(database), 1, today.isoformat(), "" + ) + await coordinator.async_refresh() + + assert _binary(hass, ada.id, "present_today").is_on is False + assert _binary(hass, ada.id, "present_today").extra_state_attributes[ + "next_present" + ] == (today + datetime.timedelta(days=1)).isoformat() + + cory = [ + child for child in coordinator.data.children + if child.child_name == "Cory" + ][0] + assert _binary(hass, cory.child_id, "present_today").is_on is False + assert _binary(hass, cory.child_id, "present_today").extra_state_attributes[ + "next_present" + ] is None + + +async def _cory_id(database) -> int: + for child in await list_children(database): + if child.display_name == "Cory": + return child.id + raise AssertionError("Cory was not created") diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 745c53a..415318e 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -78,9 +78,43 @@ async def test_coordinator_snapshot_counts_and_states(hass, make_entry) -> None: assert instance.title == "Brush teeth" assert instance.window == "morning" assert ada.present is True, "no schedule means present every day" + assert ada.next_present is None, "present child has no returns date" assert snapshot.cycle_day == 0, "no schedule anywhere" +async def test_coordinator_next_present_for_away_child( + hass, make_entry +) -> None: + """A child absent today carries the ISO date of their next present + day; an always-absent pattern carries None instead.""" + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + database = entry.runtime_data.database + await create_child(database, "Ada") + ada = (await list_children(database))[0] + today = datetime.date.today() + pattern = ",".join( + str(day) for day in range(7) if day != today.weekday() + ) + await PresenceSchedulesDao(database).upsert_by_child( + ada.id, 1, today.isoformat(), pattern + ) + await create_child(database, "Bo") + bo = (await list_children(database))[1] + await PresenceSchedulesDao(database).upsert_by_child( + bo.id, 1, today.isoformat(), "" + ) + snapshot = await coordinator._async_update_data() + by_name = {child.child_name: child for child in snapshot.children} + assert by_name["Ada"].present is False + assert by_name["Ada"].next_present == ( + today + datetime.timedelta(days=1) + ).isoformat() + assert by_name["Bo"].present is False + assert by_name["Bo"].next_present is None + + async def test_coordinator_reflects_completion_and_uncompletion( hass, make_entry ) -> None: @@ -184,6 +218,37 @@ async def test_coordinator_presence_resolves_schedule_and_override( assert snapshot.children[0].present is True +async def test_coordinator_next_present_honours_future_override( + hass, make_entry +) -> None: + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + database = entry.runtime_data.database + await create_child(database, "Ada") + ada = (await list_children(database))[0] + today = datetime.date.today() + tomorrow = today + datetime.timedelta(days=1) + # Scheduled present tomorrow (single-day cycle, weekday set set below + # covers every day), but overridden absent tomorrow. + await PresenceSchedulesDao(database).upsert_by_child( + ada.id, 1, today.isoformat(), "0,1,2,3,4,5,6" + ) + from custom_components.nestquest.dao_presence import ( + PresenceOverridesDao, + ) + + await PresenceOverridesDao(database).create( + ada.id, tomorrow.isoformat(), tomorrow.isoformat(), False + ) + snapshot = await coordinator._async_update_data() + child = snapshot.children[0] + assert child.present is True + assert child.next_present != tomorrow.isoformat(), ( + "a future absent override must remove tomorrow from the preview" + ) + + async def test_coordinator_derives_missed_for_past_due_open( hass, make_entry ) -> None: