`, be = g`
`;
-class ee extends v {
+class ve extends C {
static properties = {
hass: { attribute: !1 },
_config: { state: !0 },
_now: { state: !0 }
};
- static styles = [$(yt), Gt];
+ static styles = [b(It), ge];
hass;
_config;
_now = /* @__PURE__ */ new Date();
@@ -1052,7 +1052,7 @@ class ee extends v {
- The Party · ${R(this._now, this._timeZone())}
+ The Party · ${Z(this._now, this._timeZone())}
${this._plates().map((t) => this._renderPlate(t))}
@@ -1063,7 +1063,7 @@ class ee extends v {
`;
}
_timeZone() {
- return Ft(this.hass);
+ return oe(this.hass);
}
_state(t) {
const n = this.hass?.states?.[t];
@@ -1083,36 +1083,36 @@ class ee extends v {
_plate(t) {
const e = this._state(`sensor.nestquest_${t}_quests_due_today`), n = this._state(
`sensor.nestquest_${t}_quests_completed_today`
- ), i = this._state(
+ ), s = 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();
+ ), o = e?.attributes ?? {}, l = bt(V(e?.state, 0)), a = bt(V(n?.state, 0)), p = s ? V(s.state, Number.NaN) : Number.NaN, u = Number.isFinite(p) ? yt(p) : l > 0 ? yt(a / l * 100) : 0;
+ let d = !0;
+ const h = o.present;
+ typeof h == "boolean" ? d = h : r && (d = String(r.state ?? "").trim().toLowerCase() === "on");
+ let c = "";
+ const f = o.child_name;
+ if (typeof f == "string" && f.trim())
+ c = f.trim();
else {
- const H = r?.attributes?.child_name;
- typeof H == "string" && H.trim() && (u = H.trim());
+ const N = r?.attributes?.child_name;
+ typeof N == "string" && N.trim() && (c = N.trim());
}
- u || (u = Qt(t));
- const Z = Vt(
+ c || (c = pe(t));
+ const _ = he(
r?.attributes?.next_present,
this._timeZone()
- ), bt = Z ? `Returns ${R(Z, this._timeZone())}` : null;
+ ), v = _ ? `Returns ${Z(_, this._timeZone())}` : null;
return {
slug: t,
- name: u,
- initial: (u.charAt(0) || "?").toUpperCase(),
- present: p,
- completed: o,
+ name: c,
+ initial: (c.charAt(0) || "?").toUpperCase(),
+ present: d,
+ completed: a,
due: l,
- pct: h,
- returns: bt
+ pct: u,
+ returns: v
};
}
_renderPlate(t) {
@@ -1130,7 +1130,7 @@ class ee extends v {
${t.name}
- ${Xt}
+ ${_e}
Home today
@@ -1149,7 +1149,7 @@ class ee extends v {
${t.name}
- ${Yt}
+ ${ye}
On travels
${t.returns ?? "Returns —"}
@@ -1158,11 +1158,11 @@ class ee extends v {
`;
}
_openQuestLog(t) {
- const e = ot(this._config?.quest_log_path);
+ const e = _t(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);
+ const t = _t(this._config?.weather_entity);
if (!t)
return null;
const e = this._state(t);
@@ -1171,21 +1171,21 @@ class ee extends v {
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;
+ const s = e.attributes ?? {};
+ let r = null, o = null;
+ const l = s.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);
+ const a = l[0];
+ if (a && typeof a == "object") {
+ const p = a;
+ r = this._optionalNumber(p.temperature), o = this._optionalNumber(p.templow);
}
}
return {
condition: n,
- temperature: this._optionalNumber(i.temperature),
+ temperature: this._optionalNumber(s.temperature),
high: r,
- low: a
+ low: o
};
}
_optionalNumber(t) {
@@ -1199,16 +1199,16 @@ class ee extends v {
if (!t)
return g`
- ${R(this._now, this._timeZone())}
+ ${Z(this._now, this._timeZone())}
- ${Lt(this._now, this._timeZone())}
+ ${ae(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)}°`;
+ const e = ue(t.condition), n = fe(t.condition, e), s = t.temperature === null ? m : 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}
+ ${be}
+ ${s}
${r === null ? e : `${e} · ${r}`}
@@ -1219,45 +1219,1288 @@ class ee extends v {
`;
}
}
-customElements.define("nestquest-party-board-card", ee);
-V({
+customElements.define("nestquest-party-board-card", ve);
+st({
type: "nestquest-party-board-card",
name: "NestQuest Party Board",
description: "Kids' attract screen for present and away adventurers."
});
-class ne extends v {
+const $e = { CHILD: 2 }, xe = (i) => (...t) => ({ _$litDirective$: i, values: t });
+let qe = class {
+ constructor(t) {
+ }
+ get _$AU() {
+ return this._$AM._$AU;
+ }
+ _$AT(t, e, n) {
+ this._$Ct = t, this._$AM = e, this._$Ci = n;
+ }
+ _$AS(t, e) {
+ return this.update(t, e);
+ }
+ update(t, e) {
+ return this.render(...e);
+ }
+};
+const { I: we } = ne, $t = (i) => i, xt = () => document.createComment(""), O = (i, t, e) => {
+ const n = i._$AA.parentNode, s = t === void 0 ? i._$AB : t._$AA;
+ if (e === void 0) {
+ const r = n.insertBefore(xt(), s), o = n.insertBefore(xt(), s);
+ e = new we(r, o, i, i.options);
+ } else {
+ const r = e._$AB.nextSibling, o = e._$AM, l = o !== i;
+ if (l) {
+ let a;
+ e._$AQ?.(i), e._$AM = i, e._$AP !== void 0 && (a = i._$AU) !== o._$AU && e._$AP(a);
+ }
+ if (r !== s || l) {
+ let a = e._$AA;
+ for (; a !== r; ) {
+ const p = $t(a).nextSibling;
+ $t(n).insertBefore(a, s), a = p;
+ }
+ }
+ }
+ return e;
+}, w = (i, t, e = i) => (i._$AI(t, e), i), ke = {}, Ae = (i, t = ke) => i._$AH = t, Ce = (i) => i._$AH, K = (i) => {
+ i._$AR(), i._$AA.remove();
+};
+const qt = (i, t, e) => {
+ const n = /* @__PURE__ */ new Map();
+ for (let s = t; s <= e; s++) n.set(i[s], s);
+ return n;
+}, wt = xe(class extends qe {
+ constructor(i) {
+ if (super(i), i.type !== $e.CHILD) throw Error("repeat() can only be used in text expressions");
+ }
+ dt(i, t, e) {
+ let n;
+ e === void 0 ? e = t : t !== void 0 && (n = t);
+ const s = [], r = [];
+ let o = 0;
+ for (const l of i) s[o] = n ? n(l, o) : o, r[o] = e(l, o), o++;
+ return { values: r, keys: s };
+ }
+ render(i, t, e) {
+ return this.dt(i, t, e).values;
+ }
+ update(i, [t, e, n]) {
+ const s = Ce(i), { values: r, keys: o } = this.dt(t, e, n);
+ if (!Array.isArray(s)) return this.ut = o, r;
+ const l = this.ut ??= [], a = [];
+ let p, u, d = 0, h = s.length - 1, c = 0, f = r.length - 1;
+ for (; d <= h && c <= f; ) if (s[d] === null) d++;
+ else if (s[h] === null) h--;
+ else if (l[d] === o[c]) a[c] = w(s[d], r[c]), d++, c++;
+ else if (l[h] === o[f]) a[f] = w(s[h], r[f]), h--, f--;
+ else if (l[d] === o[f]) a[f] = w(s[d], r[f]), O(i, a[f + 1], s[d]), d++, f--;
+ else if (l[h] === o[c]) a[c] = w(s[h], r[c]), O(i, s[d], s[h]), h--, c++;
+ else if (p === void 0 && (p = qt(o, c, f), u = qt(l, d, h)), p.has(l[d])) if (p.has(l[h])) {
+ const _ = u.get(o[c]), v = _ !== void 0 ? s[_] : null;
+ if (v === null) {
+ const N = O(i, s[d]);
+ w(N, r[c]), a[c] = N;
+ } else a[c] = w(v, r[c]), O(i, s[d], v), s[_] = null;
+ c++;
+ } else K(s[h]), h--;
+ else K(s[d]), d++;
+ for (; c <= f; ) {
+ const _ = O(i, a[f + 1]);
+ w(_, r[c]), a[c++] = _;
+ }
+ for (; d <= h; ) {
+ const _ = s[d++];
+ _ !== null && K(_);
+ }
+ return this.ut = o, Ae(i, a), E;
+ }
+}), kt = /* @__PURE__ */ new Map();
+function j(i, t) {
+ const e = `${i ?? "local"}|${JSON.stringify(t)}`, n = kt.get(e);
+ if (n)
+ return n;
+ let s;
+ try {
+ s = new Intl.DateTimeFormat("en-US", {
+ ...t,
+ ...i ? { timeZone: i } : {}
+ });
+ } catch {
+ s = new Intl.DateTimeFormat("en-US", t);
+ }
+ return kt.set(e, s), s;
+}
+function Se(i) {
+ const t = i?.config;
+ if (!t || typeof t != "object")
+ return;
+ const e = t.time_zone;
+ return typeof e == "string" && e.trim() ? e.trim() : void 0;
+}
+function Ee(i, t) {
+ return `${j(t, { weekday: "long" }).format(i)}, ${j(t, { month: "short", day: "numeric" }).format(i)}`;
+}
+function Ne(i, t) {
+ return j(t, { weekday: "long" }).format(i);
+}
+function Te(i, t) {
+ const e = new Date(i);
+ return Number.isNaN(e.getTime()) ? "" : j(t, {
+ hour: "numeric",
+ minute: "2-digit",
+ hour12: !0
+ }).format(e);
+}
+function Me(i) {
+ if (!i)
+ return null;
+ const t = /^(\d{1,2}):(\d{2})$/.exec(i.trim());
+ if (!t)
+ return null;
+ const e = Number(t[1]);
+ if (e > 23)
+ return null;
+ const n = e >= 12 ? "PM" : "AM";
+ return `${e % 12 === 0 ? 12 : e % 12}:${t[2]} ${n}`;
+}
+function k(i) {
+ return typeof i == "string" ? i.trim() : "";
+}
+function y(i, t) {
+ if (i == null || i === "")
+ return t;
+ const e = typeof i == "number" ? i : Number(i);
+ return Number.isFinite(e) ? e : t;
+}
+function $(i) {
+ return Math.max(0, Math.round(i));
+}
+function At(i) {
+ return i.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" ");
+}
+function ze(i, t) {
+ const e = j(t, {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hourCycle: "h23"
+ }).formatToParts(new Date(i)), n = (r) => {
+ const o = e.find((l) => l.type === r);
+ return o ? Number(o.value) : Number.NaN;
+ }, s = Date.UTC(
+ n("year"),
+ n("month") - 1,
+ n("day"),
+ n("hour"),
+ n("minute"),
+ n("second")
+ );
+ return Number.isFinite(s) ? s - i : 0;
+}
+function Pe(i, t) {
+ if (typeof i != "string")
+ return null;
+ const e = i.split("-");
+ if (e.length !== 3)
+ return null;
+ const [n, s, r] = e.map((a) => Number(a));
+ if (!n || !s || !r)
+ return null;
+ if (!t) {
+ const a = new Date(n, s - 1, r);
+ return Number.isNaN(a.getTime()) ? null : a;
+ }
+ const o = Date.UTC(n, s - 1, r), l = new Date(o - ze(o, t));
+ return Number.isNaN(l.getTime()) ? null : l;
+}
+function Oe() {
+ const i = window.location.pathname.split("/").filter(Boolean);
+ return i.length > 0 ? i[i.length - 1].toLowerCase() : "";
+}
+function Ue(i) {
+ if (!i || typeof i != "object")
+ return null;
+ const t = i, e = k(t.state).toLowerCase();
+ if (e !== "open" && e !== "completed")
+ return null;
+ const n = y(t.id, 0);
+ return n ? {
+ id: n,
+ title: k(t.title) || "Quest",
+ icon: k(t.icon) || null,
+ window: k(t.window).toLowerCase(),
+ due_time: k(t.due_time) || null,
+ state: e,
+ overdue: t.overdue === !0,
+ completed_at: k(t.completed_at) || null
+ } : null;
+}
+const Ct = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ie = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", St = [-9, 6, -4], De = Tt`
+ * {
+ 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;
+ 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;
+ }
+
+ .header {
+ position: absolute;
+ top: 56px;
+ left: var(--nq-p-page-inset);
+ right: var(--nq-p-page-inset);
+ display: flex;
+ align-items: center;
+ gap: 28px;
+ }
+
+ .crest {
+ position: relative;
+ flex: none;
+ width: 96px;
+ height: 110px;
+ padding: 3px;
+ clip-path: ${b(Ct)};
+ background: rgba(255, 255, 255, 0.22);
+ }
+
+ .crest-face {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding-bottom: 32px;
+ clip-path: ${b(Ct)};
+ background: var(--nq-p-crest);
+ }
+
+ .crest .initial {
+ font-family: var(--nq-p-font-display);
+ font-size: 44px;
+ font-weight: 700;
+ line-height: 1;
+ color: #ffffff;
+ }
+
+ .titles {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-width: 0;
+ }
+
+ .title {
+ margin: 0;
+ font-family: var(--nq-p-font-display);
+ font-size: var(--nq-p-size-title);
+ font-weight: 700;
+ line-height: 1.1;
+ color: var(--nq-p-ink);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .sub {
+ margin: 0;
+ font-family: var(--nq-p-font-heading);
+ font-size: 20px;
+ font-weight: 600;
+ letter-spacing: 0.24em;
+ text-transform: uppercase;
+ line-height: 1.2;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .remaining {
+ margin-left: auto;
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ padding: 18px 28px;
+ border: 2px solid var(--nq-p-panel-border);
+ border-radius: 14px;
+ background: var(--nq-p-card-panel);
+ box-shadow: var(--nq-p-panel-shadow);
+ }
+
+ .d20 {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 44px;
+ height: 44px;
+ clip-path: ${b(Ie)};
+ background: var(--nq-brand-gradient);
+ }
+
+ .d20-numeral {
+ font-family: var(--nq-p-font-heading);
+ font-size: 19px;
+ font-weight: 700;
+ line-height: 1;
+ color: #ffffff;
+ }
+
+ .remaining-text {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ .remaining-count {
+ font-family: var(--nq-p-font-heading);
+ font-size: 28px;
+ font-weight: 700;
+ line-height: 1.1;
+ color: var(--nq-p-ink);
+ }
+
+ .party-line {
+ font-family: var(--nq-p-font-body);
+ font-size: var(--nq-p-size-body);
+ font-weight: 600;
+ line-height: 1.2;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .columns {
+ position: absolute;
+ top: 236px;
+ left: var(--nq-p-page-inset);
+ right: var(--nq-p-page-inset);
+ bottom: 130px;
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 34px;
+ }
+
+ .column {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ }
+
+ .column-head {
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding-bottom: 14px;
+ border-bottom: var(--nq-p-rule);
+ }
+
+ .column-head svg {
+ flex: none;
+ width: 34px;
+ height: 34px;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .column-names {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+ }
+
+ .column-name {
+ font-family: var(--nq-p-font-heading);
+ font-size: var(--nq-p-size-section);
+ font-weight: 700;
+ line-height: 1.1;
+ color: var(--nq-p-ink);
+ }
+
+ .column-range {
+ font-family: var(--nq-p-font-heading);
+ font-size: var(--nq-p-size-label);
+ font-weight: 600;
+ letter-spacing: var(--nq-p-track-label);
+ text-transform: uppercase;
+ line-height: 1.2;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .column-count {
+ margin-left: auto;
+ flex: none;
+ font-family: var(--nq-p-font-heading);
+ font-size: 26px;
+ font-weight: 700;
+ line-height: 1.1;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .stack {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ overflow: visible;
+ gap: var(--nq-p-quest-gap);
+ margin-top: 22px;
+ }
+
+ .quest {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 22px;
+ min-height: var(--nq-p-quest-min-height);
+ padding: var(--nq-p-quest-pad);
+ border: 1px solid var(--nq-p-card-border);
+ border-radius: var(--nq-p-radius-card);
+ background: var(--nq-p-card-open);
+ box-shadow: var(--nq-p-card-shadow);
+ -webkit-tap-highlight-color: transparent;
+ transition: transform var(--nq-dur-micro) var(--nq-ease-out);
+ }
+
+ .quest.tappable {
+ cursor: pointer;
+ }
+
+ .quest.tappable:active {
+ transform: scale(0.98);
+ }
+
+ .quest.sealed {
+ background: var(--nq-p-card-done);
+ opacity: 0.82;
+ box-shadow: none;
+ }
+
+ .tile {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--nq-p-tile-size);
+ height: var(--nq-p-tile-size);
+ border-radius: 10px;
+ background: var(--nq-p-icon-tile);
+ color: #ffffff;
+ }
+
+ .tile svg {
+ width: 34px;
+ height: 34px;
+ }
+
+ .sealed .tile {
+ background: var(--nq-p-icon-tile-done);
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .quest .body {
+ flex: 1 1 0;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ .quest .quest-title {
+ font-family: var(--nq-p-font-heading);
+ font-size: var(--nq-p-size-quest);
+ font-weight: 700;
+ line-height: 1.15;
+ color: var(--nq-p-ink);
+ overflow-wrap: anywhere;
+ }
+
+ .sealed .quest-title {
+ color: var(--nq-p-ink-muted);
+ text-decoration: line-through;
+ }
+
+ .quest .meta {
+ font-family: var(--nq-p-font-body);
+ font-size: var(--nq-p-size-body);
+ font-weight: 600;
+ line-height: 1.2;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .quest .meta.late {
+ font-family: var(--nq-p-font-heading);
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--nq-p-ink-late);
+ }
+
+ button.complete {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ height: var(--nq-p-button-height);
+ min-width: var(--nq-p-button-min-width);
+ border: none;
+ border-radius: 10px;
+ background: var(--nq-brand-gradient);
+ color: #ffffff;
+ font-family: var(--nq-p-font-heading);
+ font-size: 24px;
+ font-weight: 700;
+ line-height: 1;
+ box-shadow: 0 3px 0 rgba(40, 20, 60, 0.35);
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+ transition: transform var(--nq-dur-micro) var(--nq-ease-out);
+ }
+
+ button.complete:active {
+ transform: scale(0.98);
+ }
+
+ button.complete svg {
+ width: 30px;
+ height: 30px;
+ }
+
+ button:focus-visible {
+ outline: 3px solid var(--nq-brand-purple);
+ outline-offset: 4px;
+ }
+
+ .seal {
+ position: absolute;
+ right: 16px;
+ top: -12px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--nq-p-seal-size);
+ height: var(--nq-p-seal-size);
+ border-radius: var(--nq-p-radius-pill);
+ background: var(--nq-p-seal);
+ box-shadow: var(--nq-p-seal-shadow);
+ color: rgba(255, 235, 235, 0.95);
+ transform: rotate(var(--seal-rot, 0deg));
+ animation: seal-in var(--nq-dur-base) var(--nq-ease-out);
+ pointer-events: none;
+ }
+
+ .seal svg {
+ width: 38px;
+ height: 38px;
+ }
+
+ @keyframes seal-in {
+ from {
+ opacity: 0;
+ transform: scale(1.15) rotate(var(--seal-rot, 0deg));
+ }
+ to {
+ opacity: 1;
+ transform: scale(1) rotate(var(--seal-rot, 0deg));
+ }
+ }
+
+ .rollup {
+ border: 2px dashed var(--nq-p-panel-border);
+ border-radius: 14px;
+ padding: 16px 22px;
+ }
+
+ .rollup-label {
+ display: block;
+ font-family: var(--nq-p-font-heading);
+ font-size: 18px;
+ font-weight: 600;
+ letter-spacing: var(--nq-p-track-label);
+ text-transform: uppercase;
+ line-height: 1.2;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .rollup-body {
+ margin: 6px 0 0;
+ font-family: var(--nq-p-font-body);
+ font-size: 23px;
+ font-weight: 600;
+ line-height: 1.35;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .scrim {
+ position: absolute;
+ inset: 0;
+ z-index: 10;
+ background: rgba(30, 20, 10, 0.62);
+ }
+
+ .dialog {
+ position: absolute;
+ top: 196px;
+ left: 460px;
+ width: 1000px;
+ border: 3px solid rgba(92, 62, 26, 0.5);
+ border-radius: 20px;
+ background: var(--nq-p-card-panel);
+ box-shadow: 0 26px 60px rgba(20, 10, 0, 0.5);
+ padding: 64px 68px 56px;
+ animation: dialog-in var(--nq-dur-modal) var(--nq-ease-out);
+ }
+
+ @keyframes dialog-in {
+ from {
+ opacity: 0;
+ transform: scale(0.96);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+ }
+
+ .dialog-header {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ }
+
+ .dialog-tile {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 104px;
+ height: 104px;
+ border-radius: 12px;
+ background: var(--nq-p-icon-tile);
+ color: #ffffff;
+ }
+
+ .dialog-tile svg {
+ width: 56px;
+ height: 56px;
+ }
+
+ .dialog-titles {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-width: 0;
+ }
+
+ .dialog-kicker {
+ font-family: var(--nq-p-font-heading);
+ font-size: var(--nq-p-size-label);
+ font-weight: 600;
+ letter-spacing: 0.24em;
+ text-transform: uppercase;
+ line-height: 1.2;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .dialog-quest-title {
+ font-family: var(--nq-p-font-display);
+ font-size: 58px;
+ font-weight: 700;
+ line-height: 1.1;
+ color: var(--nq-p-ink);
+ overflow-wrap: anywhere;
+ }
+
+ .dialog-body {
+ margin: 28px 0 0;
+ font-family: var(--nq-p-font-body);
+ font-size: 32px;
+ font-weight: 600;
+ line-height: 1.35;
+ color: #3f2f1c;
+ }
+
+ .dialog-buttons {
+ display: flex;
+ gap: 24px;
+ margin-top: 40px;
+ }
+
+ button.confirm-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ height: var(--nq-p-confirm-button);
+ border-radius: 10px;
+ font-family: var(--nq-p-font-heading);
+ font-weight: 700;
+ line-height: 1;
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+ transition: transform var(--nq-dur-micro) var(--nq-ease-out);
+ }
+
+ button.confirm-button:active {
+ transform: scale(0.98);
+ }
+
+ button.not-yet {
+ flex: 1;
+ border: 2px solid rgba(92, 62, 26, 0.5);
+ background: rgba(92, 62, 26, 0.1);
+ color: var(--nq-p-ink);
+ font-size: 30px;
+ }
+
+ button.not-yet svg {
+ width: 34px;
+ height: 34px;
+ }
+
+ button.do-complete {
+ flex: 2;
+ border: none;
+ background: var(--nq-brand-gradient);
+ color: #ffffff;
+ font-size: 32px;
+ }
+
+ button.do-complete svg {
+ width: 38px;
+ height: 38px;
+ }
+`, J = g`
+
+ `, He = g`
+
+
+ `, G = g`
+
+ `, je = g`
+
+
+
+
+
+
+
+
+ `, Le = g`
+
+
+
+
+
+
+
+
+
+ `, Re = g`
+
+ `, Et = [
+ { key: "morning", name: "Morning", range: "Until 11:59 AM", icon: je },
+ { key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: Le },
+ { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: Re }
+];
+class Be extends C {
static properties = {
hass: { attribute: !1 },
- _config: { state: !0 }
+ _config: { state: !0 },
+ _now: { state: !0 },
+ _confirm: { state: !0 }
};
- static styles = [$(yt)];
+ static styles = [b(It), De];
hass;
_config;
+ _now = /* @__PURE__ */ new Date();
+ _confirm = null;
+ _clockTimer;
+ _idleTimer;
+ _confirmTimer;
setConfig(t) {
if (!t || typeof t != "object")
throw new Error("Invalid configuration");
this._config = t;
}
getCardSize() {
- return 8;
+ return 22;
+ }
+ connectedCallback() {
+ super.connectedCallback(), this._now = /* @__PURE__ */ new Date(), this._clockTimer = window.setInterval(() => {
+ this._now = /* @__PURE__ */ new Date();
+ }, 6e4), window.addEventListener("pointerdown", this._onActivity, !0), window.addEventListener("touchstart", this._onActivity, !0), window.addEventListener("keydown", this._onActivity, !0), this._armIdle();
+ }
+ disconnectedCallback() {
+ this._stopClock(), this._clearIdle(), this._clearConfirmTimer(), window.removeEventListener("pointerdown", this._onActivity, !0), window.removeEventListener("touchstart", this._onActivity, !0), window.removeEventListener("keydown", this._onActivity, !0), super.disconnectedCallback();
}
render() {
- return g`NestQuest Quest Log
`;
+ return g`
+
+
+
+ ${this._renderHeader()}
+
+ ${Et.map((t) => this._renderColumn(t))}
+
+ ${this._renderConfirm()}
+
+ `;
}
+ _timeZone() {
+ return Se(this.hass);
+ }
+ _state(t) {
+ const n = this.hass?.states?.[t];
+ return n && typeof n == "object" ? n : null;
+ }
+ _childSlug() {
+ return Oe();
+ }
+ _boardPath() {
+ return k(this._config?.board_path).replace(/\/+$/, "");
+ }
+ _idleSeconds() {
+ const t = y(this._config?.idle_return_seconds, 40);
+ return t > 0 ? t : 40;
+ }
+ _confirmSeconds() {
+ const t = y(this._config?.confirm_timeout_seconds, 15);
+ return t > 0 ? t : 15;
+ }
+ _childName(t) {
+ if (!t)
+ return null;
+ const e = this._state(`sensor.nestquest_${t}_quests_due_today`)?.attributes?.child_name;
+ return typeof e == "string" && e.trim() ? e.trim() : null;
+ }
+ _childCounts(t) {
+ const e = this._state(`sensor.nestquest_${t}_quests_due_today`), n = this._state(
+ `sensor.nestquest_${t}_quests_completed_today`
+ ), s = $(y(e?.state, 0)), r = $(y(n?.state, 0));
+ return { due: s, completed: r };
+ }
+ _instances() {
+ const t = this._childSlug();
+ if (!t)
+ return [];
+ const e = this._state(`sensor.nestquest_${t}_quests_due_today`)?.attributes?.instances;
+ return Array.isArray(e) ? e.map(Ue).filter((n) => n !== null) : [];
+ }
+ _remaining() {
+ const t = this._childSlug();
+ if (!t)
+ return 0;
+ const e = this._state(
+ `sensor.nestquest_${t}_quests_remaining_today`
+ );
+ if (e)
+ return $(y(e.state, 0));
+ const { due: n, completed: s } = this._childCounts(t);
+ return $(n - s);
+ }
+ _partyCounts() {
+ const t = this._state(
+ "sensor.nestquest_household_quests_due_today"
+ ), e = this._state(
+ "sensor.nestquest_household_quests_completed_today"
+ );
+ if (t || e)
+ return {
+ completed: $(y(e?.state, 0)),
+ due: $(y(t?.state, 0))
+ };
+ const n = this._childSlug(), { due: s, completed: r } = this._childCounts(n);
+ return { completed: r, due: s };
+ }
+ _otherChildren() {
+ const t = this.hass?.states;
+ if (!t)
+ return [];
+ const e = this._childSlug(), n = this._timeZone(), s = [];
+ for (const r of Object.keys(t)) {
+ const o = /^sensor\.nestquest_(.+)_quests_due_today$/.exec(r);
+ if (!o || o[1] === "household" || o[1] === e)
+ continue;
+ const l = o[1], a = t[r]?.attributes ?? {};
+ let p = "";
+ typeof a.child_name == "string" && a.child_name.trim() ? p = a.child_name.trim() : p = At(l);
+ let u = !0;
+ if (typeof a.present == "boolean")
+ u = a.present;
+ else {
+ const _ = this._state(
+ `binary_sensor.nestquest_${l}_present_today`
+ );
+ u = _ === null || String(_.state ?? "").trim().toLowerCase() !== "off";
+ }
+ const d = this._state(
+ `sensor.nestquest_${l}_quests_completed_today`
+ ), h = $(y(t[r]?.state, 0)), c = $(y(d?.state, 0));
+ let f = null;
+ if (!u) {
+ const _ = this._state(
+ `binary_sensor.nestquest_${l}_present_today`
+ ), v = Pe(
+ _?.attributes?.next_present,
+ n
+ );
+ f = v ? Ne(v, n) : null;
+ }
+ s.push({
+ slug: l,
+ name: p,
+ present: u,
+ due: h,
+ completed: c,
+ returnsWeekday: f
+ });
+ }
+ return s.sort((r, o) => r.name.localeCompare(o.name));
+ }
+ _renderHeader() {
+ const t = this._childSlug(), n = this._childName(t) ?? (t ? At(t) : null), { due: s, completed: r } = this._childCounts(t), o = this._remaining(), l = this._partyCounts(), a = n ? `${n}'s Quest Log` : "Quest Log", p = (n?.charAt(0) || "?").toUpperCase();
+ return g`
+
+ `;
+ }
+ _renderColumn(t) {
+ const e = this._instances().filter(
+ (l) => l.window === t.key
+ ), n = e.filter((l) => l.state === "open").sort(
+ (l, a) => (l.due_time ?? "99:99").localeCompare(a.due_time ?? "99:99")
+ ), s = e.filter(
+ (l) => l.state === "completed"
+ ), r = e.length;
+ let o = m;
+ if (r > 0) {
+ const a = 644 - (t.key === "afternoon" && this._otherChildren().length > 0 ? 160 : 0), p = Math.min(
+ 24,
+ Math.max(8, Math.floor((a - r * 74) / Math.max(r - 1, 1)))
+ ), u = Math.min(
+ 116,
+ Math.max(58, Math.floor((a - (r - 1) * p) / r))
+ ), d = Math.max(56, Math.min(72, u - 2));
+ let h = `--nq-p-quest-gap: ${p}px; --nq-p-quest-min-height: ${u}px; --nq-p-button-height: ${d}px`;
+ if (r >= 5) {
+ const c = Math.max(0, Math.min(22, Math.floor((u - d - 2) / 2))), f = Math.max(44, Math.min(64, u - 2 * c - 2));
+ h += `; --nq-p-quest-pad: ${c}px; --nq-p-tile-size: ${f}px`;
+ }
+ o = h;
+ }
+ return g`
+
+
+ ${t.icon}
+
+ ${t.name}
+ ${t.range}
+
+
${s.length}/${e.length}
+
+
+ ${wt(
+ n,
+ (l) => l.id,
+ (l) => this._renderQuest(l, !1, 0)
+ )}
+ ${wt(
+ s,
+ (l) => l.id,
+ (l, a) => this._renderQuest(l, !0, a)
+ )}
+ ${t.key === "afternoon" ? this._renderRollup() : m}
+
+
+ `;
+ }
+ _renderQuest(t, e, n) {
+ if (e) {
+ const o = St[n % St.length], l = t.completed_at ? Te(t.completed_at, this._timeZone()) : "";
+ return g`
+
+
${G}
+
+ ${t.title}
+
+ ${l ? `Claimed ${l}` : "Claimed"}
+
+
+
+ ${J}
+
+
+ `;
+ }
+ const s = Me(t.due_time), r = t.overdue ? {
+ text: s ? `Overdue · due ${s}` : "Overdue",
+ late: !0
+ } : {
+ text: s ? `Due by ${s}` : "Due today",
+ late: !1
+ };
+ return g`
+ this._openConfirm(t.id)}
+ @keydown=${(o) => {
+ (o.key === "Enter" || o.key === " ") && (o.preventDefault(), this._openConfirm(t.id));
+ }}
+ >
+
${G}
+
+ ${t.title}
+ ${r.text}
+
+
this._openConfirm(t.id)}
+ >
+ ${J}
+ Complete
+
+
+ `;
+ }
+ _renderRollup() {
+ const t = this._otherChildren();
+ if (t.length === 0)
+ return m;
+ const e = t.slice(0, 2).map((n) => n.present ? n.due > 0 && n.completed >= n.due ? `${n.name} has finished their log.` : n.due > n.completed ? `${n.name} has ${n.due - n.completed} quests left.` : `${n.name} has no quests today.` : n.returnsWeekday ? `${n.name} is on travels until ${n.returnsWeekday}.` : `${n.name} is on travels.`);
+ return g`
+
+
Party roll-up
+
${e.join(" ")}
+
+ `;
+ }
+ _renderConfirm() {
+ if (this._confirm === null)
+ return m;
+ const t = this._instances().find(
+ (n) => n.id === this._confirm
+ );
+ if (!t)
+ return m;
+ const e = Et.find((n) => n.key === t.window);
+ return g`
+ this._closeConfirm()}>
+
n.stopPropagation()}
+ >
+
+
+ Mark this quest complete? Once the seal is set, only a parent can
+ undo it.
+
+
+ this._closeConfirm()}
+ >
+ ${He}
+ Not yet
+
+ this._confirmComplete()}
+ >
+ ${J}
+ Complete
+
+
+
+
+ `;
+ }
+ _openConfirm(t) {
+ this._confirm = t, this._armConfirmTimer();
+ }
+ _closeConfirm() {
+ this._clearConfirmTimer(), this._confirm = null;
+ }
+ _confirmComplete() {
+ this._closeConfirm();
+ }
+ _armConfirmTimer() {
+ this._clearConfirmTimer(), this._confirmTimer = window.setTimeout(() => {
+ this._confirmTimer = void 0, this._closeConfirm();
+ }, this._confirmSeconds() * 1e3);
+ }
+ _clearConfirmTimer() {
+ this._confirmTimer !== void 0 && (window.clearTimeout(this._confirmTimer), this._confirmTimer = void 0);
+ }
+ _armIdle() {
+ this._clearIdle(), this._idleTimer = window.setTimeout(() => {
+ this._idleTimer = void 0, this._returnToBoard();
+ }, this._idleSeconds() * 1e3);
+ }
+ _clearIdle() {
+ this._idleTimer !== void 0 && (window.clearTimeout(this._idleTimer), this._idleTimer = void 0);
+ }
+ _returnToBoard() {
+ const t = this._boardPath();
+ t && (window.history.pushState(null, "", t), window.dispatchEvent(new Event("location-changed")));
+ }
+ _stopClock() {
+ this._clockTimer !== void 0 && (window.clearInterval(this._clockTimer), this._clockTimer = void 0);
+ }
+ _onActivity = () => {
+ this._armIdle(), this._confirm !== null && this._armConfirmTimer();
+ };
}
-customElements.define("nestquest-quest-log-card", ne);
-V({
+customElements.define("nestquest-quest-log-card", Be);
+st({
type: "nestquest-quest-log-card",
name: "NestQuest Quest Log",
description: "One child's daily quests, grouped by window."
});
-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 {
+const Fe = ":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 Qe extends C {
static properties = {
hass: { attribute: !1 },
_config: { state: !0 }
};
- static styles = [$(se)];
+ static styles = [b(Fe)];
hass;
_config;
setConfig(t) {
@@ -1272,14 +2515,14 @@ class ie extends v {
return g`NestQuest Admin
`;
}
}
-customElements.define("nestquest-admin-card", ie);
-V({
+customElements.define("nestquest-admin-card", Qe);
+st({
type: "nestquest-admin-card",
name: "NestQuest Admin",
description: "Parent phone view for today, tasks, schedule, and history."
});
-const re = "/nestquest-static/nestquest-fonts.css";
+const We = "/nestquest-static/nestquest-fonts.css";
if (!document.querySelector('link[data-nq-fonts=""]')) {
- const s = document.createElement("link");
- s.rel = "stylesheet", s.href = re, s.dataset.nqFonts = "", document.head.appendChild(s);
+ const i = document.createElement("link");
+ i.rel = "stylesheet", i.href = We, i.dataset.nqFonts = "", document.head.appendChild(i);
}
diff --git a/custom_components/nestquest/www/nestquest-panel-tokens.css b/custom_components/nestquest/www/nestquest-panel-tokens.css
index f997486..9f51ab6 100644
--- a/custom_components/nestquest/www/nestquest-panel-tokens.css
+++ b/custom_components/nestquest/www/nestquest-panel-tokens.css
@@ -77,6 +77,8 @@
/* ── Metrics: touch ───────────────────────────────────── */
--nq-p-quest-min-height: 116px; /* whole card is the tap target */
--nq-p-quest-gap: 24px; /* separation between tap targets */
+ --nq-p-quest-pad: 22px; /* padding inside a quest card */
+ --nq-p-tile-size: 64px; /* quest icon tile */
--nq-p-button-height: 72px; /* "Complete" button on a quest card */
--nq-p-button-min-width: 150px;
--nq-p-confirm-button: 108px; /* confirm dialog buttons */
diff --git a/design/tokens/nestquest-panel-tokens.css b/design/tokens/nestquest-panel-tokens.css
index f997486..9f51ab6 100644
--- a/design/tokens/nestquest-panel-tokens.css
+++ b/design/tokens/nestquest-panel-tokens.css
@@ -77,6 +77,8 @@
/* ── Metrics: touch ───────────────────────────────────── */
--nq-p-quest-min-height: 116px; /* whole card is the tap target */
--nq-p-quest-gap: 24px; /* separation between tap targets */
+ --nq-p-quest-pad: 22px; /* padding inside a quest card */
+ --nq-p-tile-size: 64px; /* quest icon tile */
--nq-p-button-height: 72px; /* "Complete" button on a quest card */
--nq-p-button-min-width: 150px;
--nq-p-confirm-button: 108px; /* confirm dialog buttons */
diff --git a/frontend/src/cards/quest-log.ts b/frontend/src/cards/quest-log.ts
index b51929a..ed27510 100644
--- a/frontend/src/cards/quest-log.ts
+++ b/frontend/src/cards/quest-log.ts
@@ -1,18 +1,905 @@
-import { LitElement, html, unsafeCSS } from "lit";
+import { LitElement, css, html, nothing, unsafeCSS, type TemplateResult } from "lit";
+import { repeat } from "lit/directives/repeat.js";
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 PanelInstance = {
+ id: number;
+ title: string;
+ icon: string | null;
+ window: string;
+ due_time: string | null;
+ state: string;
+ overdue: boolean;
+ completed_at: string | null;
+};
+
+type WindowDef = {
+ key: string;
+ name: string;
+ range: string;
+ icon: TemplateResult;
+};
+
+type OtherChild = {
+ slug: string;
+ name: string;
+ present: boolean;
+ due: number;
+ completed: number;
+ returnsWeekday: string | 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 formatWeekday(date: Date, timeZone: string | undefined): string {
+ return zonedFormatter(timeZone, { weekday: "long" }).format(date);
+}
+
+function formatInstant(value: string, timeZone: string | undefined): string {
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) {
+ return "";
+ }
+ return zonedFormatter(timeZone, {
+ hour: "numeric",
+ minute: "2-digit",
+ hour12: true,
+ }).format(parsed);
+}
+
+function formatWallClock(value: string | null): string | null {
+ if (!value) {
+ return null;
+ }
+ const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
+ if (!match) {
+ return null;
+ }
+ const hours = Number(match[1]);
+ if (hours > 23) {
+ return null;
+ }
+ const suffix = hours >= 12 ? "PM" : "AM";
+ const hour12 = hours % 12 === 0 ? 12 : hours % 12;
+ return `${hour12}:${match[2]} ${suffix}`;
+}
+
+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 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 childSlugFromPath(): string {
+ const segments = window.location.pathname.split("/").filter(Boolean);
+ return segments.length > 0
+ ? segments[segments.length - 1].toLowerCase()
+ : "";
+}
+
+function toPanelInstance(value: unknown): PanelInstance | null {
+ if (!value || typeof value !== "object") {
+ return null;
+ }
+ const row = value as Record;
+ const state = asString(row.state).toLowerCase();
+ if (state !== "open" && state !== "completed") {
+ return null;
+ }
+ const id = asNumber(row.id, 0);
+ if (!id) {
+ return null;
+ }
+ return {
+ id,
+ title: asString(row.title) || "Quest",
+ icon: asString(row.icon) || 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,
+ };
+}
+
+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 SEAL_ROTATIONS = [-9, 6, -4];
+
+const logStyles = 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;
+ 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;
+ }
+
+ .header {
+ position: absolute;
+ top: 56px;
+ left: var(--nq-p-page-inset);
+ right: var(--nq-p-page-inset);
+ display: flex;
+ align-items: center;
+ gap: 28px;
+ }
+
+ .crest {
+ position: relative;
+ flex: none;
+ width: 96px;
+ height: 110px;
+ padding: 3px;
+ 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: 32px;
+ clip-path: ${unsafeCSS(SHIELD_CLIP)};
+ background: var(--nq-p-crest);
+ }
+
+ .crest .initial {
+ font-family: var(--nq-p-font-display);
+ font-size: 44px;
+ font-weight: 700;
+ line-height: 1;
+ color: #ffffff;
+ }
+
+ .titles {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-width: 0;
+ }
+
+ .title {
+ margin: 0;
+ font-family: var(--nq-p-font-display);
+ font-size: var(--nq-p-size-title);
+ font-weight: 700;
+ line-height: 1.1;
+ color: var(--nq-p-ink);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .sub {
+ margin: 0;
+ font-family: var(--nq-p-font-heading);
+ font-size: 20px;
+ font-weight: 600;
+ letter-spacing: 0.24em;
+ text-transform: uppercase;
+ line-height: 1.2;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .remaining {
+ margin-left: auto;
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ padding: 18px 28px;
+ border: 2px solid var(--nq-p-panel-border);
+ border-radius: 14px;
+ background: var(--nq-p-card-panel);
+ box-shadow: var(--nq-p-panel-shadow);
+ }
+
+ .d20 {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 44px;
+ height: 44px;
+ clip-path: ${unsafeCSS(HEXAGON_CLIP)};
+ background: var(--nq-brand-gradient);
+ }
+
+ .d20-numeral {
+ font-family: var(--nq-p-font-heading);
+ font-size: 19px;
+ font-weight: 700;
+ line-height: 1;
+ color: #ffffff;
+ }
+
+ .remaining-text {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ .remaining-count {
+ font-family: var(--nq-p-font-heading);
+ font-size: 28px;
+ font-weight: 700;
+ line-height: 1.1;
+ color: var(--nq-p-ink);
+ }
+
+ .party-line {
+ font-family: var(--nq-p-font-body);
+ font-size: var(--nq-p-size-body);
+ font-weight: 600;
+ line-height: 1.2;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .columns {
+ position: absolute;
+ top: 236px;
+ left: var(--nq-p-page-inset);
+ right: var(--nq-p-page-inset);
+ bottom: 130px;
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 34px;
+ }
+
+ .column {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ }
+
+ .column-head {
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding-bottom: 14px;
+ border-bottom: var(--nq-p-rule);
+ }
+
+ .column-head svg {
+ flex: none;
+ width: 34px;
+ height: 34px;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .column-names {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+ }
+
+ .column-name {
+ font-family: var(--nq-p-font-heading);
+ font-size: var(--nq-p-size-section);
+ font-weight: 700;
+ line-height: 1.1;
+ color: var(--nq-p-ink);
+ }
+
+ .column-range {
+ font-family: var(--nq-p-font-heading);
+ font-size: var(--nq-p-size-label);
+ font-weight: 600;
+ letter-spacing: var(--nq-p-track-label);
+ text-transform: uppercase;
+ line-height: 1.2;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .column-count {
+ margin-left: auto;
+ flex: none;
+ font-family: var(--nq-p-font-heading);
+ font-size: 26px;
+ font-weight: 700;
+ line-height: 1.1;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .stack {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ overflow: visible;
+ gap: var(--nq-p-quest-gap);
+ margin-top: 22px;
+ }
+
+ .quest {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 22px;
+ min-height: var(--nq-p-quest-min-height);
+ padding: var(--nq-p-quest-pad);
+ border: 1px solid var(--nq-p-card-border);
+ border-radius: var(--nq-p-radius-card);
+ background: var(--nq-p-card-open);
+ box-shadow: var(--nq-p-card-shadow);
+ -webkit-tap-highlight-color: transparent;
+ transition: transform var(--nq-dur-micro) var(--nq-ease-out);
+ }
+
+ .quest.tappable {
+ cursor: pointer;
+ }
+
+ .quest.tappable:active {
+ transform: scale(0.98);
+ }
+
+ .quest.sealed {
+ background: var(--nq-p-card-done);
+ opacity: 0.82;
+ box-shadow: none;
+ }
+
+ .tile {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--nq-p-tile-size);
+ height: var(--nq-p-tile-size);
+ border-radius: 10px;
+ background: var(--nq-p-icon-tile);
+ color: #ffffff;
+ }
+
+ .tile svg {
+ width: 34px;
+ height: 34px;
+ }
+
+ .sealed .tile {
+ background: var(--nq-p-icon-tile-done);
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .quest .body {
+ flex: 1 1 0;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ .quest .quest-title {
+ font-family: var(--nq-p-font-heading);
+ font-size: var(--nq-p-size-quest);
+ font-weight: 700;
+ line-height: 1.15;
+ color: var(--nq-p-ink);
+ overflow-wrap: anywhere;
+ }
+
+ .sealed .quest-title {
+ color: var(--nq-p-ink-muted);
+ text-decoration: line-through;
+ }
+
+ .quest .meta {
+ font-family: var(--nq-p-font-body);
+ font-size: var(--nq-p-size-body);
+ font-weight: 600;
+ line-height: 1.2;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .quest .meta.late {
+ font-family: var(--nq-p-font-heading);
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--nq-p-ink-late);
+ }
+
+ button.complete {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ height: var(--nq-p-button-height);
+ min-width: var(--nq-p-button-min-width);
+ border: none;
+ border-radius: 10px;
+ background: var(--nq-brand-gradient);
+ color: #ffffff;
+ font-family: var(--nq-p-font-heading);
+ font-size: 24px;
+ font-weight: 700;
+ line-height: 1;
+ box-shadow: 0 3px 0 rgba(40, 20, 60, 0.35);
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+ transition: transform var(--nq-dur-micro) var(--nq-ease-out);
+ }
+
+ button.complete:active {
+ transform: scale(0.98);
+ }
+
+ button.complete svg {
+ width: 30px;
+ height: 30px;
+ }
+
+ button:focus-visible {
+ outline: 3px solid var(--nq-brand-purple);
+ outline-offset: 4px;
+ }
+
+ .seal {
+ position: absolute;
+ right: 16px;
+ top: -12px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--nq-p-seal-size);
+ height: var(--nq-p-seal-size);
+ border-radius: var(--nq-p-radius-pill);
+ background: var(--nq-p-seal);
+ box-shadow: var(--nq-p-seal-shadow);
+ color: rgba(255, 235, 235, 0.95);
+ transform: rotate(var(--seal-rot, 0deg));
+ animation: seal-in var(--nq-dur-base) var(--nq-ease-out);
+ pointer-events: none;
+ }
+
+ .seal svg {
+ width: 38px;
+ height: 38px;
+ }
+
+ @keyframes seal-in {
+ from {
+ opacity: 0;
+ transform: scale(1.15) rotate(var(--seal-rot, 0deg));
+ }
+ to {
+ opacity: 1;
+ transform: scale(1) rotate(var(--seal-rot, 0deg));
+ }
+ }
+
+ .rollup {
+ border: 2px dashed var(--nq-p-panel-border);
+ border-radius: 14px;
+ padding: 16px 22px;
+ }
+
+ .rollup-label {
+ display: block;
+ font-family: var(--nq-p-font-heading);
+ font-size: 18px;
+ font-weight: 600;
+ letter-spacing: var(--nq-p-track-label);
+ text-transform: uppercase;
+ line-height: 1.2;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .rollup-body {
+ margin: 6px 0 0;
+ font-family: var(--nq-p-font-body);
+ font-size: 23px;
+ font-weight: 600;
+ line-height: 1.35;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .scrim {
+ position: absolute;
+ inset: 0;
+ z-index: 10;
+ background: rgba(30, 20, 10, 0.62);
+ }
+
+ .dialog {
+ position: absolute;
+ top: 196px;
+ left: 460px;
+ width: 1000px;
+ border: 3px solid rgba(92, 62, 26, 0.5);
+ border-radius: 20px;
+ background: var(--nq-p-card-panel);
+ box-shadow: 0 26px 60px rgba(20, 10, 0, 0.5);
+ padding: 64px 68px 56px;
+ animation: dialog-in var(--nq-dur-modal) var(--nq-ease-out);
+ }
+
+ @keyframes dialog-in {
+ from {
+ opacity: 0;
+ transform: scale(0.96);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+ }
+
+ .dialog-header {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ }
+
+ .dialog-tile {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 104px;
+ height: 104px;
+ border-radius: 12px;
+ background: var(--nq-p-icon-tile);
+ color: #ffffff;
+ }
+
+ .dialog-tile svg {
+ width: 56px;
+ height: 56px;
+ }
+
+ .dialog-titles {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-width: 0;
+ }
+
+ .dialog-kicker {
+ font-family: var(--nq-p-font-heading);
+ font-size: var(--nq-p-size-label);
+ font-weight: 600;
+ letter-spacing: 0.24em;
+ text-transform: uppercase;
+ line-height: 1.2;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .dialog-quest-title {
+ font-family: var(--nq-p-font-display);
+ font-size: 58px;
+ font-weight: 700;
+ line-height: 1.1;
+ color: var(--nq-p-ink);
+ overflow-wrap: anywhere;
+ }
+
+ .dialog-body {
+ margin: 28px 0 0;
+ font-family: var(--nq-p-font-body);
+ font-size: 32px;
+ font-weight: 600;
+ line-height: 1.35;
+ color: #3f2f1c;
+ }
+
+ .dialog-buttons {
+ display: flex;
+ gap: 24px;
+ margin-top: 40px;
+ }
+
+ button.confirm-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ height: var(--nq-p-confirm-button);
+ border-radius: 10px;
+ font-family: var(--nq-p-font-heading);
+ font-weight: 700;
+ line-height: 1;
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+ transition: transform var(--nq-dur-micro) var(--nq-ease-out);
+ }
+
+ button.confirm-button:active {
+ transform: scale(0.98);
+ }
+
+ button.not-yet {
+ flex: 1;
+ border: 2px solid rgba(92, 62, 26, 0.5);
+ background: rgba(92, 62, 26, 0.1);
+ color: var(--nq-p-ink);
+ font-size: 30px;
+ }
+
+ button.not-yet svg {
+ width: 34px;
+ height: 34px;
+ }
+
+ button.do-complete {
+ flex: 2;
+ border: none;
+ background: var(--nq-brand-gradient);
+ color: #ffffff;
+ font-size: 32px;
+ }
+
+ button.do-complete svg {
+ width: 38px;
+ height: 38px;
+ }
+`;
+
+const ICON_CHECK = html`
+
+ `;
+
+const ICON_X = html`
+
+
+ `;
+
+const ICON_STAR = html`
+
+ `;
+
+const ICON_SUNRISE = html`
+
+
+
+
+
+
+
+
+ `;
+
+const ICON_SUN = html`
+
+
+
+
+
+
+
+
+
+ `;
+
+const ICON_MOON = html`
+
+ `;
+
+const WINDOW_DEFS: WindowDef[] = [
+ { key: "morning", name: "Morning", range: "Until 11:59 AM", icon: ICON_SUNRISE },
+ { key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: ICON_SUN },
+ { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: ICON_MOON },
+];
+
export class NestQuestQuestLogCard extends LitElement {
static properties = {
hass: { attribute: false },
_config: { state: true },
+ _now: { state: true },
+ _confirm: { state: true },
};
- static styles = [unsafeCSS(panelTokens)];
+ static styles = [unsafeCSS(panelTokens), logStyles];
hass?: HassLike;
_config?: CardConfig;
+ _now = new Date();
+ _confirm: number | null = null;
+ private _clockTimer?: number;
+ private _idleTimer?: number;
+ private _confirmTimer?: number;
setConfig(config: CardConfig): void {
if (!config || typeof config !== "object") {
@@ -22,12 +909,534 @@ export class NestQuestQuestLogCard extends LitElement {
}
getCardSize(): number {
- return 8;
+ return 22;
+ }
+
+ connectedCallback(): void {
+ super.connectedCallback();
+ this._now = new Date();
+ this._clockTimer = window.setInterval(() => {
+ this._now = new Date();
+ }, 60000);
+ window.addEventListener("pointerdown", this._onActivity, true);
+ window.addEventListener("touchstart", this._onActivity, true);
+ window.addEventListener("keydown", this._onActivity, true);
+ this._armIdle();
+ }
+
+ disconnectedCallback(): void {
+ this._stopClock();
+ this._clearIdle();
+ this._clearConfirmTimer();
+ window.removeEventListener("pointerdown", this._onActivity, true);
+ window.removeEventListener("touchstart", this._onActivity, true);
+ window.removeEventListener("keydown", this._onActivity, true);
+ super.disconnectedCallback();
}
render() {
- return html`NestQuest Quest Log
`;
+ return html`
+
+
+
+ ${this._renderHeader()}
+
+ ${WINDOW_DEFS.map((windowDef) => this._renderColumn(windowDef))}
+
+ ${this._renderConfirm()}
+
+ `;
}
+
+ 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 _childSlug(): string {
+ return childSlugFromPath();
+ }
+
+ private _boardPath(): string {
+ return asString(this._config?.board_path).replace(/\/+$/, "");
+ }
+
+ private _idleSeconds(): number {
+ const seconds = asNumber(this._config?.idle_return_seconds, 40);
+ return seconds > 0 ? seconds : 40;
+ }
+
+ private _confirmSeconds(): number {
+ const seconds = asNumber(this._config?.confirm_timeout_seconds, 15);
+ return seconds > 0 ? seconds : 15;
+ }
+
+ private _childName(slug: string): string | null {
+ if (!slug) {
+ return null;
+ }
+ const name = this._state(`sensor.nestquest_${slug}_quests_due_today`)
+ ?.attributes?.child_name;
+ if (typeof name === "string" && name.trim()) {
+ return name.trim();
+ }
+ return null;
+ }
+
+ private _childCounts(slug: string): { due: number; completed: number } {
+ const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`);
+ const completedSensor = this._state(
+ `sensor.nestquest_${slug}_quests_completed_today`
+ );
+ const due = clampCount(asNumber(dueSensor?.state, 0));
+ const completed = clampCount(asNumber(completedSensor?.state, 0));
+ return { due, completed };
+ }
+
+ private _instances(): PanelInstance[] {
+ const slug = this._childSlug();
+ if (!slug) {
+ return [];
+ }
+ const payload = this._state(`sensor.nestquest_${slug}_quests_due_today`)
+ ?.attributes?.instances;
+ if (!Array.isArray(payload)) {
+ return [];
+ }
+ return payload
+ .map(toPanelInstance)
+ .filter((instance): instance is PanelInstance => instance !== null);
+ }
+
+ private _remaining(): number {
+ const slug = this._childSlug();
+ if (!slug) {
+ return 0;
+ }
+ const sensor = this._state(
+ `sensor.nestquest_${slug}_quests_remaining_today`
+ );
+ if (sensor) {
+ return clampCount(asNumber(sensor.state, 0));
+ }
+ const { due, completed } = this._childCounts(slug);
+ return clampCount(due - completed);
+ }
+
+ private _partyCounts(): { completed: number; due: number } {
+ const householdDue = this._state(
+ "sensor.nestquest_household_quests_due_today"
+ );
+ const householdCompleted = this._state(
+ "sensor.nestquest_household_quests_completed_today"
+ );
+ if (householdDue || householdCompleted) {
+ return {
+ completed: clampCount(asNumber(householdCompleted?.state, 0)),
+ due: clampCount(asNumber(householdDue?.state, 0)),
+ };
+ }
+ const slug = this._childSlug();
+ const { due, completed } = this._childCounts(slug);
+ return { completed, due };
+ }
+
+ private _otherChildren(): OtherChild[] {
+ const states = this.hass?.states as
+ | Record
+ | undefined;
+ if (!states) {
+ return [];
+ }
+ const slug = this._childSlug();
+ const timeZone = this._timeZone();
+ const result: OtherChild[] = [];
+ for (const entityId of Object.keys(states)) {
+ const match = /^sensor\.nestquest_(.+)_quests_due_today$/.exec(entityId);
+ if (!match || match[1] === "household" || match[1] === slug) {
+ continue;
+ }
+ const childSlug = match[1];
+ const attrs = states[entityId]?.attributes ?? {};
+ let name = "";
+ if (typeof attrs.child_name === "string" && attrs.child_name.trim()) {
+ name = attrs.child_name.trim();
+ } else {
+ name = titleCaseSlug(childSlug);
+ }
+ let present = true;
+ if (typeof attrs.present === "boolean") {
+ present = attrs.present;
+ } else {
+ const presence = this._state(
+ `binary_sensor.nestquest_${childSlug}_present_today`
+ );
+ present =
+ presence === null ||
+ String(presence.state ?? "").trim().toLowerCase() !== "off";
+ }
+ const completedSensor = this._state(
+ `sensor.nestquest_${childSlug}_quests_completed_today`
+ );
+ const due = clampCount(asNumber(states[entityId]?.state, 0));
+ const completed = clampCount(asNumber(completedSensor?.state, 0));
+ let returnsWeekday: string | null = null;
+ if (!present) {
+ const presence = this._state(
+ `binary_sensor.nestquest_${childSlug}_present_today`
+ );
+ const nextPresent = parseIsoDate(
+ presence?.attributes?.next_present,
+ timeZone
+ );
+ returnsWeekday = nextPresent
+ ? formatWeekday(nextPresent, timeZone)
+ : null;
+ }
+ result.push({
+ slug: childSlug,
+ name,
+ present,
+ due,
+ completed,
+ returnsWeekday,
+ });
+ }
+ return result.sort((a, b) => a.name.localeCompare(b.name));
+ }
+
+ private _renderHeader() {
+ const slug = this._childSlug();
+ const name = this._childName(slug);
+ const displayName = name ?? (slug ? titleCaseSlug(slug) : null);
+ const { due, completed } = this._childCounts(slug);
+ const remaining = this._remaining();
+ const party = this._partyCounts();
+ const title = displayName ? `${displayName}'s Quest Log` : "Quest Log";
+ const initial = (displayName?.charAt(0) || "?").toUpperCase();
+ return html`
+
+ `;
+ }
+
+ private _renderColumn(windowDef: WindowDef) {
+ const instances = this._instances().filter(
+ (instance) => instance.window === windowDef.key
+ );
+ const open = instances
+ .filter((instance) => instance.state === "open")
+ .sort((a, b) =>
+ (a.due_time ?? "99:99").localeCompare(b.due_time ?? "99:99")
+ );
+ const sealed = instances.filter(
+ (instance) => instance.state === "completed"
+ );
+ const count = instances.length;
+ let stackStyle: string | typeof nothing = nothing;
+ if (count > 0) {
+ const rollupReserve =
+ windowDef.key === "afternoon" && this._otherChildren().length > 0
+ ? 160
+ : 0;
+ const available = 644 - rollupReserve;
+ const gap = Math.min(
+ 24,
+ Math.max(8, Math.floor((available - count * 74) / Math.max(count - 1, 1)))
+ );
+ const minHeight = Math.min(
+ 116,
+ Math.max(58, Math.floor((available - (count - 1) * gap) / count))
+ );
+ const buttonHeight = Math.max(56, Math.min(72, minHeight - 2));
+ let stackVars = `--nq-p-quest-gap: ${gap}px; --nq-p-quest-min-height: ${minHeight}px; --nq-p-button-height: ${buttonHeight}px`;
+ if (count >= 5) {
+ const pad = Math.max(0, Math.min(22, Math.floor((minHeight - buttonHeight - 2) / 2)));
+ const tile = Math.max(44, Math.min(64, minHeight - 2 * pad - 2));
+ stackVars += `; --nq-p-quest-pad: ${pad}px; --nq-p-tile-size: ${tile}px`;
+ }
+ stackStyle = stackVars;
+ }
+ return html`
+
+
+ ${windowDef.icon}
+
+ ${windowDef.name}
+ ${windowDef.range}
+
+
${sealed.length}/${instances.length}
+
+
+ ${repeat(
+ open,
+ (instance) => instance.id,
+ (instance) => this._renderQuest(instance, false, 0)
+ )}
+ ${repeat(
+ sealed,
+ (instance) => instance.id,
+ (instance, index) => this._renderQuest(instance, true, index)
+ )}
+ ${windowDef.key === "afternoon" ? this._renderRollup() : nothing}
+
+
+ `;
+ }
+
+ private _renderQuest(
+ instance: PanelInstance,
+ sealed: boolean,
+ sealedIndex: number
+ ) {
+ if (sealed) {
+ const rotation = SEAL_ROTATIONS[sealedIndex % SEAL_ROTATIONS.length];
+ const claimedAt = instance.completed_at
+ ? formatInstant(instance.completed_at, this._timeZone())
+ : "";
+ return html`
+
+
${ICON_STAR}
+
+ ${instance.title}
+
+ ${claimedAt ? `Claimed ${claimedAt}` : "Claimed"}
+
+
+
+ ${ICON_CHECK}
+
+
+ `;
+ }
+ const dueAt = formatWallClock(instance.due_time);
+ const meta = instance.overdue
+ ? {
+ text: dueAt ? `Overdue · due ${dueAt}` : "Overdue",
+ late: true,
+ }
+ : {
+ text: dueAt ? `Due by ${dueAt}` : "Due today",
+ late: false,
+ };
+ return html`
+ this._openConfirm(instance.id)}
+ @keydown=${(event: KeyboardEvent) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ this._openConfirm(instance.id);
+ }
+ }}
+ >
+
${ICON_STAR}
+
+ ${instance.title}
+ ${meta.text}
+
+
this._openConfirm(instance.id)}
+ >
+ ${ICON_CHECK}
+ Complete
+
+
+ `;
+ }
+
+ private _renderRollup() {
+ const others = this._otherChildren();
+ if (others.length === 0) {
+ return nothing;
+ }
+ const sentences = others.slice(0, 2).map((child) => {
+ if (!child.present) {
+ return child.returnsWeekday
+ ? `${child.name} is on travels until ${child.returnsWeekday}.`
+ : `${child.name} is on travels.`;
+ }
+ if (child.due > 0 && child.completed >= child.due) {
+ return `${child.name} has finished their log.`;
+ }
+ if (child.due > child.completed) {
+ return `${child.name} has ${child.due - child.completed} quests left.`;
+ }
+ return `${child.name} has no quests today.`;
+ });
+ return html`
+
+
Party roll-up
+
${sentences.join(" ")}
+
+ `;
+ }
+
+ private _renderConfirm() {
+ if (this._confirm === null) {
+ return nothing;
+ }
+ const instance = this._instances().find(
+ (candidate) => candidate.id === this._confirm
+ );
+ if (!instance) {
+ return nothing;
+ }
+ const windowDef = WINDOW_DEFS.find((entry) => entry.key === instance.window);
+ return html`
+ this._closeConfirm()}>
+
event.stopPropagation()}
+ >
+
+
+ Mark this quest complete? Once the seal is set, only a parent can
+ undo it.
+
+
+ this._closeConfirm()}
+ >
+ ${ICON_X}
+ Not yet
+
+ this._confirmComplete()}
+ >
+ ${ICON_CHECK}
+ Complete
+
+
+
+
+ `;
+ }
+
+ private _openConfirm(instanceId: number): void {
+ this._confirm = instanceId;
+ this._armConfirmTimer();
+ }
+
+ private _closeConfirm(): void {
+ this._clearConfirmTimer();
+ this._confirm = null;
+ }
+
+ private _confirmComplete(): void {
+ this._closeConfirm();
+ }
+
+ private _armConfirmTimer(): void {
+ this._clearConfirmTimer();
+ this._confirmTimer = window.setTimeout(() => {
+ this._confirmTimer = undefined;
+ this._closeConfirm();
+ }, this._confirmSeconds() * 1000);
+ }
+
+ private _clearConfirmTimer(): void {
+ if (this._confirmTimer !== undefined) {
+ window.clearTimeout(this._confirmTimer);
+ this._confirmTimer = undefined;
+ }
+ }
+
+ private _armIdle(): void {
+ this._clearIdle();
+ this._idleTimer = window.setTimeout(() => {
+ this._idleTimer = undefined;
+ this._returnToBoard();
+ }, this._idleSeconds() * 1000);
+ }
+
+ private _clearIdle(): void {
+ if (this._idleTimer !== undefined) {
+ window.clearTimeout(this._idleTimer);
+ this._idleTimer = undefined;
+ }
+ }
+
+ private _returnToBoard(): void {
+ const path = this._boardPath();
+ if (!path) {
+ return;
+ }
+ window.history.pushState(null, "", path);
+ window.dispatchEvent(new Event("location-changed"));
+ }
+
+ private _stopClock(): void {
+ if (this._clockTimer !== undefined) {
+ window.clearInterval(this._clockTimer);
+ this._clockTimer = undefined;
+ }
+ }
+
+ private _onActivity = (): void => {
+ this._armIdle();
+ if (this._confirm !== null) {
+ this._armConfirmTimer();
+ }
+ };
}
customElements.define("nestquest-quest-log-card", NestQuestQuestLogCard);