+ ${this._plates().map((t) => this._renderPlate(t))}
+
+
- ${be}
- ${s}
+ ${St}
+ ${i}
- ${r === null ? e : `${e} · ${r}`}
+ ${r === null ? t : `${t} · ${r}`}
${n}
@@ -1219,167 +1362,167 @@ class ve extends C {
`;
}
}
-customElements.define("nestquest-party-board-card", ve);
-st({
+customElements.define("nestquest-party-board-card", Et);
+oe({
type: "nestquest-party-board-card",
name: "NestQuest Party Board",
description: "Kids' attract screen for present and away adventurers."
});
-const $e = { CHILD: 2 }, xe = (i) => (...t) => ({ _$litDirective$: i, values: t });
-let qe = class {
- constructor(t) {
+const Nt = { CHILD: 2 }, Tt = (s) => (...e) => ({ _$litDirective$: s, values: e });
+let Mt = class {
+ constructor(e) {
}
get _$AU() {
return this._$AM._$AU;
}
- _$AT(t, e, n) {
- this._$Ct = t, this._$AM = e, this._$Ci = n;
+ _$AT(e, t, n) {
+ this._$Ct = e, this._$AM = t, this._$Ci = n;
}
- _$AS(t, e) {
- return this.update(t, e);
+ _$AS(e, t) {
+ return this.update(e, t);
}
- update(t, e) {
- return this.render(...e);
+ update(e, t) {
+ return this.render(...t);
}
};
-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);
+const { I: Ot } = at, we = (s) => s, ke = () => document.createComment(""), z = (s, e, t) => {
+ const n = s._$AA.parentNode, i = e === void 0 ? s._$AB : e._$AA;
+ if (t === void 0) {
+ const r = n.insertBefore(ke(), i), o = n.insertBefore(ke(), i);
+ t = new Ot(r, o, s, s.options);
} else {
- const r = e._$AB.nextSibling, o = e._$AM, l = o !== i;
+ const r = t._$AB.nextSibling, o = t._$AM, l = o !== s;
if (l) {
let a;
- e._$AQ?.(i), e._$AM = i, e._$AP !== void 0 && (a = i._$AU) !== o._$AU && e._$AP(a);
+ t._$AQ?.(s), t._$AM = s, t._$AP !== void 0 && (a = s._$AU) !== o._$AU && t._$AP(a);
}
- if (r !== s || l) {
- let a = e._$AA;
+ if (r !== i || l) {
+ let a = t._$AA;
for (; a !== r; ) {
- const p = $t(a).nextSibling;
- $t(n).insertBefore(a, s), a = p;
+ const c = we(a).nextSibling;
+ we(n).insertBefore(a, i), a = c;
}
}
}
- 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();
+ return t;
+}, w = (s, e, t = s) => (s._$AI(e, t), s), zt = {}, Pt = (s, e = zt) => s._$AH = e, It = (s) => s._$AH, J = (s) => {
+ s._$AR(), s._$AA.remove();
};
-const qt = (i, t, e) => {
+const Ae = (s, e, t) => {
const n = /* @__PURE__ */ new Map();
- for (let s = t; s <= e; s++) n.set(i[s], s);
+ for (let i = e; i <= t; i++) n.set(s[i], i);
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");
+}, Ce = Tt(class extends Mt {
+ constructor(s) {
+ if (super(s), s.type !== Nt.CHILD) throw Error("repeat() can only be used in text expressions");
}
- dt(i, t, e) {
+ dt(s, e, t) {
let n;
- e === void 0 ? e = t : t !== void 0 && (n = t);
- const s = [], r = [];
+ t === void 0 ? t = e : e !== void 0 && (n = e);
+ const i = [], 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 };
+ for (const l of s) i[o] = n ? n(l, o) : o, r[o] = t(l, o), o++;
+ return { values: r, keys: i };
}
- render(i, t, e) {
- return this.dt(i, t, e).values;
+ render(s, e, t) {
+ return this.dt(s, e, t).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;
+ update(s, [e, t, n]) {
+ const i = It(s), { values: r, keys: o } = this.dt(e, t, n);
+ if (!Array.isArray(i)) 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++] = _;
+ let c, u, d = 0, f = i.length - 1, h = 0, m = r.length - 1;
+ for (; d <= f && h <= m; ) if (i[d] === null) d++;
+ else if (i[f] === null) f--;
+ else if (l[d] === o[h]) a[h] = w(i[d], r[h]), d++, h++;
+ else if (l[f] === o[m]) a[m] = w(i[f], r[m]), f--, m--;
+ else if (l[d] === o[m]) a[m] = w(i[d], r[m]), z(s, a[m + 1], i[d]), d++, m--;
+ else if (l[f] === o[h]) a[h] = w(i[f], r[h]), z(s, i[d], i[f]), f--, h++;
+ else if (c === void 0 && (c = Ae(o, h, m), u = Ae(l, d, f)), c.has(l[d])) if (c.has(l[f])) {
+ const y = u.get(o[h]), b = y !== void 0 ? i[y] : null;
+ if (b === null) {
+ const j = z(s, i[d]);
+ w(j, r[h]), a[h] = j;
+ } else a[h] = w(b, r[h]), z(s, i[d], b), i[y] = null;
+ h++;
+ } else J(i[f]), f--;
+ else J(i[d]), d++;
+ for (; h <= m; ) {
+ const y = z(s, a[m + 1]);
+ w(y, r[h]), a[h++] = y;
}
- for (; d <= h; ) {
- const _ = s[d++];
- _ !== null && K(_);
+ for (; d <= f; ) {
+ const y = i[d++];
+ y !== null && J(y);
}
- return this.ut = o, Ae(i, a), E;
+ return this.ut = o, Pt(s, a), E;
}
-}), kt = /* @__PURE__ */ new Map();
-function j(i, t) {
- const e = `${i ?? "local"}|${JSON.stringify(t)}`, n = kt.get(e);
+}), Se = /* @__PURE__ */ new Map();
+function H(s, e) {
+ const t = `${s ?? "local"}|${JSON.stringify(e)}`, n = Se.get(t);
if (n)
return n;
- let s;
+ let i;
try {
- s = new Intl.DateTimeFormat("en-US", {
- ...t,
- ...i ? { timeZone: i } : {}
+ i = new Intl.DateTimeFormat("en-US", {
+ ...e,
+ ...s ? { timeZone: s } : {}
});
} catch {
- s = new Intl.DateTimeFormat("en-US", t);
+ i = new Intl.DateTimeFormat("en-US", e);
}
- return kt.set(e, s), s;
+ return Se.set(t, i), i;
}
-function Se(i) {
- const t = i?.config;
- if (!t || typeof t != "object")
+function Ut(s) {
+ const e = s?.config;
+ if (!e || typeof e != "object")
return;
- const e = t.time_zone;
- return typeof e == "string" && e.trim() ? e.trim() : void 0;
+ const t = e.time_zone;
+ return typeof t == "string" && t.trim() ? t.trim() : void 0;
}
-function Ee(i, t) {
- return `${j(t, { weekday: "long" }).format(i)}, ${j(t, { month: "short", day: "numeric" }).format(i)}`;
+function Ee(s, e) {
+ return `${H(e, { weekday: "long" }).format(s)}, ${H(e, { month: "short", day: "numeric" }).format(s)}`;
}
-function Ne(i, t) {
- return j(t, { weekday: "long" }).format(i);
+function Dt(s, e) {
+ return H(e, { weekday: "long" }).format(s);
}
-function Te(i, t) {
- const e = new Date(i);
- return Number.isNaN(e.getTime()) ? "" : j(t, {
+function Ht(s, e) {
+ const t = new Date(s);
+ return Number.isNaN(t.getTime()) ? "" : H(e, {
hour: "numeric",
minute: "2-digit",
hour12: !0
- }).format(e);
+ }).format(t);
}
-function Me(i) {
- if (!i)
+function jt(s) {
+ if (!s)
return null;
- const t = /^(\d{1,2}):(\d{2})$/.exec(i.trim());
- if (!t)
+ const e = /^(\d{1,2}):(\d{2})$/.exec(s.trim());
+ if (!e)
return null;
- const e = Number(t[1]);
- if (e > 23)
+ const t = Number(e[1]);
+ if (t > 23)
return null;
- const n = e >= 12 ? "PM" : "AM";
- return `${e % 12 === 0 ? 12 : e % 12}:${t[2]} ${n}`;
+ const n = t >= 12 ? "PM" : "AM";
+ return `${t % 12 === 0 ? 12 : t % 12}:${e[2]} ${n}`;
}
-function k(i) {
- return typeof i == "string" ? i.trim() : "";
+function k(s) {
+ return typeof s == "string" ? s.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 _(s, e) {
+ if (s == null || s === "")
+ return e;
+ const t = typeof s == "number" ? s : Number(s);
+ return Number.isFinite(t) ? t : e;
}
-function $(i) {
- return Math.max(0, Math.round(i));
+function $(s) {
+ return Math.max(0, Math.round(s));
}
-function At(i) {
- return i.split(/[-_]+/).filter(Boolean).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(" ");
+function X(s) {
+ return s.split(/[-_]+/).filter(Boolean).map((e) => e.charAt(0).toUpperCase() + e.slice(1)).join(" ");
}
-function ze(i, t) {
- const e = j(t, {
+function Rt(s, e) {
+ const t = H(e, {
year: "numeric",
month: "2-digit",
day: "2-digit",
@@ -1387,10 +1530,10 @@ function ze(i, t) {
minute: "2-digit",
second: "2-digit",
hourCycle: "h23"
- }).formatToParts(new Date(i)), n = (r) => {
- const o = e.find((l) => l.type === r);
+ }).formatToParts(new Date(s)), n = (r) => {
+ const o = t.find((l) => l.type === r);
return o ? Number(o.value) : Number.NaN;
- }, s = Date.UTC(
+ }, i = Date.UTC(
n("year"),
n("month") - 1,
n("day"),
@@ -1398,47 +1541,47 @@ function ze(i, t) {
n("minute"),
n("second")
);
- return Number.isFinite(s) ? s - i : 0;
+ return Number.isFinite(i) ? i - s : 0;
}
-function Pe(i, t) {
- if (typeof i != "string")
+function Ne(s, e) {
+ if (typeof s != "string")
return null;
- const e = i.split("-");
- if (e.length !== 3)
+ const t = s.split("-");
+ if (t.length !== 3)
return null;
- const [n, s, r] = e.map((a) => Number(a));
- if (!n || !s || !r)
+ const [n, i, r] = t.map((a) => Number(a));
+ if (!n || !i || !r)
return null;
- if (!t) {
- const a = new Date(n, s - 1, r);
+ if (!e) {
+ const a = new Date(n, i - 1, r);
return Number.isNaN(a.getTime()) ? null : a;
}
- const o = Date.UTC(n, s - 1, r), l = new Date(o - ze(o, t));
+ const o = Date.UTC(n, i - 1, r), l = new Date(o - Rt(o, e));
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 Lt() {
+ const s = window.location.pathname.split("/").filter(Boolean);
+ return s.length > 0 ? s[s.length - 1].toLowerCase() : "";
}
-function Ue(i) {
- if (!i || typeof i != "object")
+function Bt(s) {
+ if (!s || typeof s != "object")
return null;
- const t = i, e = k(t.state).toLowerCase();
- if (e !== "open" && e !== "completed")
+ const e = s, t = k(e.state).toLowerCase();
+ if (t !== "open" && t !== "completed")
return null;
- const n = y(t.id, 0);
+ const n = _(e.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
+ title: k(e.title) || "Quest",
+ icon: k(e.icon) || null,
+ window: k(e.window).toLowerCase(),
+ due_time: k(e.due_time) || null,
+ state: t,
+ overdue: e.overdue === !0,
+ completed_at: k(e.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`
+const Te = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ft = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", Me = [-9, 6, -4], Qt = Pe`
* {
box-sizing: border-box;
}
@@ -1498,7 +1641,7 @@ const Ct = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ie = "polygon(50%
width: 96px;
height: 110px;
padding: 3px;
- clip-path: ${b(Ct)};
+ clip-path: ${v(Te)};
background: rgba(255, 255, 255, 0.22);
}
@@ -1509,7 +1652,7 @@ const Ct = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ie = "polygon(50%
align-items: center;
justify-content: center;
padding-bottom: 32px;
- clip-path: ${b(Ct)};
+ clip-path: ${v(Te)};
background: var(--nq-p-crest);
}
@@ -1521,6 +1664,10 @@ const Ct = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ie = "polygon(50%
color: #ffffff;
}
+ .crest.away .crest-face {
+ background: var(--nq-p-crest-away);
+ }
+
.titles {
display: flex;
flex-direction: column;
@@ -1571,7 +1718,7 @@ const Ct = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ie = "polygon(50%
justify-content: center;
width: 44px;
height: 44px;
- clip-path: ${b(Ie)};
+ clip-path: ${v(Ft)};
background: var(--nq-brand-gradient);
}
@@ -1868,6 +2015,81 @@ const Ct = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ie = "polygon(50%
color: var(--nq-p-ink-secondary);
}
+ .notice-wrap {
+ position: absolute;
+ top: 236px;
+ left: var(--nq-p-page-inset);
+ right: var(--nq-p-page-inset);
+ bottom: 130px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .notice {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 20px;
+ width: 100%;
+ max-width: 980px;
+ padding: 56px 64px;
+ border: 2px solid var(--nq-p-panel-border);
+ border-radius: 20px;
+ background: var(--nq-p-card-panel);
+ box-shadow: var(--nq-p-panel-shadow);
+ text-align: center;
+ }
+
+ .notice svg {
+ width: 46px;
+ height: 46px;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .notice.away {
+ background: linear-gradient(#f2ecdd, #e6dcc6);
+ border-style: dashed;
+ box-shadow: none;
+ }
+
+ .notice-headline {
+ font-family: var(--nq-p-font-heading);
+ font-size: 40px;
+ font-weight: 700;
+ line-height: 1.15;
+ color: var(--nq-p-ink);
+ }
+
+ .notice-body {
+ margin: 0;
+ font-family: var(--nq-p-font-body);
+ font-size: 24px;
+ font-weight: 600;
+ line-height: 1.4;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .notice-seal {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 78px;
+ height: 78px;
+ 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(-6deg);
+ }
+
+ .notice-seal svg {
+ width: 38px;
+ height: 38px;
+ color: rgba(255, 235, 235, 0.95);
+ }
+
.scrim {
position: absolute;
inset: 0;
@@ -2007,7 +2229,7 @@ const Ct = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ie = "polygon(50%
width: 38px;
height: 38px;
}
-`, J = g`
- `, He = g`
`, Wt = p`
- `, G = g``, Y = p`
- `, je = g``, Zt = p`
- `, Le = g``, Le = p`
- `, Re = g``, Vt = p`
- `, Et = [
- { key: "morning", name: "Morning", range: "Until 11:59 AM", icon: je },
+ `, Gt = p`
+
+
+ `, Kt = p`
+
+
+ `, Jt = p`
+
+
+
+ `, Xt = p`
+
+
+ `, Yt = {
+ icon: Kt,
+ headline: "No adventurer chosen",
+ body: "Open The Party and tap your crest to open your quest log."
+}, en = {
+ icon: Jt,
+ headline: "NestQuest is not set up yet",
+ body: "A parent needs to finish setting up NestQuest."
+}, tn = {
+ icon: Xt,
+ headline: "The records cannot be reached",
+ body: "The party's records are quiet right now. NestQuest will return shortly."
+}, Oe = [
+ { key: "morning", name: "Morning", range: "Until 11:59 AM", icon: Zt },
{ key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: Le },
- { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: Re }
+ { key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: Vt }
];
-class Be extends C {
+class nn extends C {
static properties = {
hass: { attribute: !1 },
_config: { state: !0 },
_now: { state: !0 },
_confirm: { state: !0 }
};
- static styles = [b(It), De];
+ static styles = [v(Re), Qt];
hass;
_config;
_now = /* @__PURE__ */ new Date();
@@ -2105,10 +2384,10 @@ class Be extends C {
_clockTimer;
_idleTimer;
_confirmTimer;
- setConfig(t) {
- if (!t || typeof t != "object")
+ setConfig(e) {
+ if (!e || typeof e != "object")
throw new Error("Invalid configuration");
- this._config = t;
+ this._config = e;
}
getCardSize() {
return 22;
@@ -2122,223 +2401,337 @@ class Be extends C {
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`
+ const e = this._logView();
+ return p`
- ${this._renderHeader()}
-
- ${Et.map((t) => this._renderColumn(t))}
-
+ ${this._renderMain(e)}
${this._renderConfirm()}
`;
}
- _timeZone() {
- return Se(this.hass);
+ _renderMain(e) {
+ switch (e.kind) {
+ case "no-adventurer":
+ return this._renderNotice(Yt);
+ case "not-set-up":
+ return this._renderNotice(en);
+ case "unreachable":
+ return this._renderNotice(tn);
+ case "away": {
+ const t = ["The quest log unlocks when they return."];
+ return e.returns && t.push(`Returns ${e.returns}`), p`
+ ${this._renderHeader(e)}
+
+
+ ${Gt}
+
${e.name} is on travels
+ ${t.map((n) => p`
${n}
`)}
+
+
+ `;
+ }
+ case "empty-day":
+ return p`
+ ${this._renderHeader(e)}
+
+
+ ${Le}
+
No quests today
+
+ ${`Nothing was posted for today, ${e.name}. Enjoy the day's rest!`}
+
+
+
+ `;
+ case "complete-day":
+ return p`
+ ${this._renderHeader(e)}
+
+
+
${R}
+
Quest complete
+
+ ${`Every quest is claimed, ${e.name}. The seal is set for today.`}
+
+
Returning to The Party shortly.
+
+
+ `;
+ default:
+ return p`
+ ${this._renderHeader(e)}
+
+ ${Oe.map((t) => this._renderColumn(t))}
+
+ `;
+ }
}
- _state(t) {
- const n = this.hass?.states?.[t];
+ /** Resolve the log's single render state; the priority order is
+ * design/PANEL-EMPTY-STATES.md §2. */
+ _logView() {
+ const e = this._childSlug();
+ if (!e)
+ return { kind: "no-adventurer" };
+ const t = this._dueSensorResolution(e);
+ if (t === "missing")
+ return { kind: "not-set-up" };
+ if (t === "stale")
+ return { kind: "unreachable" };
+ const n = this._childName(e) ?? X(e);
+ if (!this._childPresent(e))
+ return { kind: "away", name: n, returns: this._awayReturns(e) };
+ const i = this._state(`sensor.nestquest_${e}_quests_due_today`);
+ return $(_(i?.state, 0)) === 0 ? { kind: "empty-day", name: n } : this._remaining() === 0 ? { kind: "complete-day", name: n } : { kind: "normal" };
+ }
+ /** "missing" when the due sensor does not exist (integration not
+ * configured), "stale" when it answers unavailable/unknown (backend
+ * unreachable or the child is absent from the snapshot), null when
+ * it resolves. */
+ _dueSensorResolution(e) {
+ const t = this._state(`sensor.nestquest_${e}_quests_due_today`);
+ if (!t)
+ return "missing";
+ const n = String(t.state ?? "").trim().toLowerCase();
+ return !n || n === "unavailable" || n === "unknown" ? "stale" : null;
+ }
+ _childPresent(e) {
+ const n = this._state(`sensor.nestquest_${e}_quests_due_today`)?.attributes?.present;
+ if (typeof n == "boolean")
+ return n;
+ const i = this._state(
+ `binary_sensor.nestquest_${e}_present_today`
+ );
+ return i ? String(i.state ?? "").trim().toLowerCase() === "on" : !0;
+ }
+ _awayReturns(e) {
+ const t = this._timeZone(), n = this._state(
+ `binary_sensor.nestquest_${e}_present_today`
+ ), i = Ne(
+ n?.attributes?.next_present,
+ t
+ );
+ return i ? Ee(i, t) : null;
+ }
+ _renderNotice(e) {
+ return p`
+
+
+ ${e.icon}
+
${e.headline}
+
${e.body}
+
+
+ `;
+ }
+ _timeZone() {
+ return Ut(this.hass);
+ }
+ _state(e) {
+ const n = this.hass?.states?.[e];
return n && typeof n == "object" ? n : null;
}
_childSlug() {
- return Oe();
+ return Lt();
}
_boardPath() {
return k(this._config?.board_path).replace(/\/+$/, "");
}
_idleSeconds() {
- const t = y(this._config?.idle_return_seconds, 40);
- return t > 0 ? t : 40;
+ const e = _(this._config?.idle_return_seconds, 40);
+ return e > 0 ? e : 40;
}
_confirmSeconds() {
- const t = y(this._config?.confirm_timeout_seconds, 15);
- return t > 0 ? t : 15;
+ const e = _(this._config?.confirm_timeout_seconds, 15);
+ return e > 0 ? e : 15;
}
- _childName(t) {
- if (!t)
+ _childName(e) {
+ if (!e)
return null;
- const e = this._state(`sensor.nestquest_${t}_quests_due_today`)?.attributes?.child_name;
- return typeof e == "string" && e.trim() ? e.trim() : null;
+ const t = this._state(`sensor.nestquest_${e}_quests_due_today`)?.attributes?.child_name;
+ return typeof t == "string" && t.trim() ? t.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 };
+ _childCounts(e) {
+ const t = this._state(`sensor.nestquest_${e}_quests_due_today`), n = this._state(
+ `sensor.nestquest_${e}_quests_completed_today`
+ ), i = $(_(t?.state, 0)), r = $(_(n?.state, 0));
+ return { due: i, completed: r };
}
_instances() {
- const t = this._childSlug();
- if (!t)
+ const e = this._childSlug();
+ if (!e)
return [];
- const e = this._state(`sensor.nestquest_${t}_quests_due_today`)?.attributes?.instances;
- return Array.isArray(e) ? e.map(Ue).filter((n) => n !== null) : [];
+ const t = this._state(`sensor.nestquest_${e}_quests_due_today`)?.attributes?.instances;
+ return Array.isArray(t) ? t.map(Bt).filter((n) => n !== null) : [];
}
_remaining() {
- const t = this._childSlug();
- if (!t)
+ const e = this._childSlug();
+ if (!e)
return 0;
- const e = this._state(
- `sensor.nestquest_${t}_quests_remaining_today`
+ const t = this._state(
+ `sensor.nestquest_${e}_quests_remaining_today`
);
- if (e)
- return $(y(e.state, 0));
- const { due: n, completed: s } = this._childCounts(t);
- return $(n - s);
+ if (t)
+ return $(_(t.state, 0));
+ const { due: n, completed: i } = this._childCounts(e);
+ return $(n - i);
}
_partyCounts() {
- const t = this._state(
+ const e = this._state(
"sensor.nestquest_household_quests_due_today"
- ), e = this._state(
+ ), t = this._state(
"sensor.nestquest_household_quests_completed_today"
);
- if (t || e)
+ if (e || t)
return {
- completed: $(y(e?.state, 0)),
- due: $(y(t?.state, 0))
+ completed: $(_(t?.state, 0)),
+ due: $(_(e?.state, 0))
};
- const n = this._childSlug(), { due: s, completed: r } = this._childCounts(n);
- return { completed: r, due: s };
+ const n = this._childSlug(), { due: i, completed: r } = this._childCounts(n);
+ return { completed: r, due: i };
}
_otherChildren() {
- const t = this.hass?.states;
- if (!t)
+ const e = this.hass?.states;
+ if (!e)
return [];
- const e = this._childSlug(), n = this._timeZone(), s = [];
- for (const r of Object.keys(t)) {
+ const t = this._childSlug(), n = this._timeZone(), i = [];
+ for (const r of Object.keys(e)) {
const o = /^sensor\.nestquest_(.+)_quests_due_today$/.exec(r);
- if (!o || o[1] === "household" || o[1] === e)
+ if (!o || o[1] === "household" || o[1] === t)
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);
+ const l = o[1], a = e[r]?.attributes ?? {};
+ let c = "";
+ typeof a.child_name == "string" && a.child_name.trim() ? c = a.child_name.trim() : c = X(l);
let u = !0;
if (typeof a.present == "boolean")
u = a.present;
else {
- const _ = this._state(
+ const y = this._state(
`binary_sensor.nestquest_${l}_present_today`
);
- u = _ === null || String(_.state ?? "").trim().toLowerCase() !== "off";
+ u = y === null || String(y.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;
+ ), f = $(_(e[r]?.state, 0)), h = $(_(d?.state, 0));
+ let m = null;
if (!u) {
- const _ = this._state(
+ const y = this._state(
`binary_sensor.nestquest_${l}_present_today`
- ), v = Pe(
- _?.attributes?.next_present,
+ ), b = Ne(
+ y?.attributes?.next_present,
n
);
- f = v ? Ne(v, n) : null;
+ m = b ? Dt(b, n) : null;
}
- s.push({
+ i.push({
slug: l,
- name: p,
+ name: c,
present: u,
- due: h,
- completed: c,
- returnsWeekday: f
+ due: f,
+ completed: h,
+ returnsWeekday: m
});
}
- return s.sort((r, o) => r.name.localeCompare(o.name));
+ return i.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`
+ _renderHeader(e) {
+ const t = this._childSlug(), n = e.kind === "away", i = n ? e.name : this._childName(t) ?? (t ? X(t) : null), { due: r, completed: o } = this._childCounts(t), l = this._remaining(), a = this._partyCounts(), c = i ? `${i}'s Quest Log` : "Quest Log", u = (i?.charAt(0) || "?").toUpperCase();
+ return p`
`;
}
- _renderColumn(t) {
- const e = this._instances().filter(
- (l) => l.window === t.key
- ), n = e.filter((l) => l.state === "open").sort(
+ _renderColumn(e) {
+ const t = this._instances().filter(
+ (l) => l.window === e.key
+ ), n = t.filter((l) => l.state === "open").sort(
(l, a) => (l.due_time ?? "99:99").localeCompare(a.due_time ?? "99:99")
- ), s = e.filter(
+ ), i = t.filter(
(l) => l.state === "completed"
- ), r = e.length;
- let o = m;
+ ), r = t.length;
+ let o = g;
if (r > 0) {
- const a = 644 - (t.key === "afternoon" && this._otherChildren().length > 0 ? 160 : 0), p = Math.min(
+ const a = 644 - (e.key === "afternoon" && this._otherChildren().length > 0 ? 160 : 0), c = 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))
+ Math.max(58, Math.floor((a - (r - 1) * c) / 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`;
+ let f = `--nq-p-quest-gap: ${c}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`;
+ const h = Math.max(0, Math.min(22, Math.floor((u - d - 2) / 2))), m = Math.max(44, Math.min(64, u - 2 * h - 2));
+ f += `; --nq-p-quest-pad: ${h}px; --nq-p-tile-size: ${m}px`;
}
- o = h;
+ o = f;
}
- return g`
+ return p`
- ${t.icon}
+ ${e.icon}
- ${t.name}
- ${t.range}
+ ${e.name}
+ ${e.range}
-
${s.length}/${e.length}
+
${i.length}/${t.length}
- ${wt(
+ ${Ce(
n,
(l) => l.id,
(l) => this._renderQuest(l, !1, 0)
)}
- ${wt(
- s,
+ ${Ce(
+ i,
(l) => l.id,
(l, a) => this._renderQuest(l, !0, a)
)}
- ${t.key === "afternoon" ? this._renderRollup() : m}
+ ${e.key === "afternoon" ? this._renderRollup() : g}
`;
}
- _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}
+ _renderQuest(e, t, n) {
+ if (t) {
+ const o = Me[n % Me.length], l = e.completed_at ? Ht(e.completed_at, this._timeZone()) : "";
+ return p`
+
+
${Y}
- ${t.title}
+ ${e.title}
${l ? `Claimed ${l}` : "Claimed"}
@@ -2348,68 +2741,68 @@ class Be extends C {
aria-hidden="true"
style="--seal-rot: ${o}deg"
>
- ${J}
+ ${R}
`;
}
- const s = Me(t.due_time), r = t.overdue ? {
- text: s ? `Overdue · due ${s}` : "Overdue",
+ const i = jt(e.due_time), r = e.overdue ? {
+ text: i ? `Overdue · due ${i}` : "Overdue",
late: !0
} : {
- text: s ? `Due by ${s}` : "Due today",
+ text: i ? `Due by ${i}` : "Due today",
late: !1
};
- return g`
+ return p`
this._openConfirm(t.id)}
+ data-instance-id=${e.id}
+ aria-label="Complete ${e.title}"
+ @click=${() => this._openConfirm(e.id)}
@keydown=${(o) => {
- (o.key === "Enter" || o.key === " ") && (o.preventDefault(), this._openConfirm(t.id));
+ (o.key === "Enter" || o.key === " ") && (o.preventDefault(), this._openConfirm(e.id));
}}
>
-
${G}
+
${Y}
- ${t.title}
- ${r.text}
+ ${e.title}
+ ${r.text}
this._openConfirm(t.id)}
+ @click=${() => this._openConfirm(e.id)}
>
- ${J}
+ ${R}
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`
+ const e = this._otherChildren();
+ if (e.length === 0)
+ return g;
+ const t = e.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 p`
Party roll-up
-
${e.join(" ")}
+
${t.join(" ")}
`;
}
_renderConfirm() {
if (this._confirm === null)
- return m;
- const t = this._instances().find(
+ return g;
+ const e = this._instances().find(
(n) => n.id === this._confirm
);
- if (!t)
- return m;
- const e = Et.find((n) => n.key === t.window);
- return g`
+ if (!e)
+ return g;
+ const t = Oe.find((n) => n.key === e.window);
+ return p`
this._closeConfirm()}>
n.stopPropagation()}
>
@@ -2436,7 +2829,7 @@ class Be extends C {
type="button"
@click=${() => this._closeConfirm()}
>
- ${He}
+ ${Wt}
Not yet
this._confirmComplete()}
>
- ${J}
+ ${R}
Complete
@@ -2452,8 +2845,8 @@ class Be extends C {
`;
}
- _openConfirm(t) {
- this._confirm = t, this._armConfirmTimer();
+ _openConfirm(e) {
+ this._confirm = e, this._armConfirmTimer();
}
_closeConfirm() {
this._clearConfirmTimer(), this._confirm = null;
@@ -2478,8 +2871,8 @@ class Be extends C {
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")));
+ const e = this._boardPath();
+ e && (window.history.pushState(null, "", e), window.dispatchEvent(new Event("location-changed")));
}
_stopClock() {
this._clockTimer !== void 0 && (window.clearInterval(this._clockTimer), this._clockTimer = void 0);
@@ -2488,41 +2881,41 @@ class Be extends C {
this._armIdle(), this._confirm !== null && this._armConfirmTimer();
};
}
-customElements.define("nestquest-quest-log-card", Be);
-st({
+customElements.define("nestquest-quest-log-card", nn);
+oe({
type: "nestquest-quest-log-card",
name: "NestQuest Quest Log",
description: "One child's daily quests, grouped by window."
});
-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 {
+const sn = ":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 rn extends C {
static properties = {
hass: { attribute: !1 },
_config: { state: !0 }
};
- static styles = [b(Fe)];
+ static styles = [v(sn)];
hass;
_config;
- setConfig(t) {
- if (!t || typeof t != "object")
+ setConfig(e) {
+ if (!e || typeof e != "object")
throw new Error("Invalid configuration");
- this._config = t;
+ this._config = e;
}
getCardSize() {
return 8;
}
render() {
- return g`
NestQuest Admin
`;
+ return p`
NestQuest Admin
`;
}
}
-customElements.define("nestquest-admin-card", Qe);
-st({
+customElements.define("nestquest-admin-card", rn);
+oe({
type: "nestquest-admin-card",
name: "NestQuest Admin",
description: "Parent phone view for today, tasks, schedule, and history."
});
-const We = "/nestquest-static/nestquest-fonts.css";
+const on = "/nestquest-static/nestquest-fonts.css";
if (!document.querySelector('link[data-nq-fonts=""]')) {
- const i = document.createElement("link");
- i.rel = "stylesheet", i.href = We, i.dataset.nqFonts = "", document.head.appendChild(i);
+ const s = document.createElement("link");
+ s.rel = "stylesheet", s.href = on, s.dataset.nqFonts = "", document.head.appendChild(s);
}
diff --git a/design/PANEL-EMPTY-STATES.md b/design/PANEL-EMPTY-STATES.md
new file mode 100644
index 0000000..87bc76f
--- /dev/null
+++ b/design/PANEL-EMPTY-STATES.md
@@ -0,0 +1,104 @@
+# Panel empty & error states — design addendum
+
+Closes the gap DESIGN-HANDOFF.md lists under "Open questions": *"Empty and error
+states are unspecified by request (child with no quests, everyone away, integration
+unconfigured, DB unreachable). Panel must not render a blank parchment — decide
+before shipping the card."*
+
+All decisions below reuse PANEL-SPEC.md's approved aesthetic — parchment ground,
+double frame, dock, panel tokens, no text below 22px, no alpha-muted ink — and add
+one shared component per card: a **notice panel** (radius 20px, `--nq-p-card-panel`,
+2px `--nq-p-panel-border`, `--nq-p-panel-shadow`, centred icon + headline + body).
+The frame, wordmark row, kicker and dock always render; only the content area
+changes. **No state renders a blank parchment.**
+
+## 1. What the data can say
+
+The cards read `sensor.nestquest_
_quests_due_today` (plus siblings). One of
+four things comes back, and the card must tell them apart:
+
+| Backend condition | What the card sees | Card's interpretation |
+|---|---|---|
+| Resolving (DB fine) | numeric state + attributes (`child_name`, `present`, `instances`) | normal, all states below |
+| Integration not configured | entity does not exist at all | **not set up** |
+| Backend unreachable (DB locked, refresh failed) | state `unavailable` | **records unreachable** |
+| Child deactivated / absent from snapshot | state `unknown` | **records unreachable** (same panel voice: the records do not answer) |
+
+An away child (presence `present: false`, from the due-sensor attributes with the
+presence binary sensor as fallback) is *not* an error: it is a first-class panel
+state ("On travels"). Away children get no instances (materialiser skips them), so
+away is detected from the presence flag, never from a zero quest count.
+
+## 2. Quest log — state priority
+
+`nestquest-quest-log-card` resolves exactly one state, in this order:
+
+1. **No adventurer in the path** (direct navigation to a URL with no child slug —
+ misconfigured `board_path`/`quest_log_path`) → notice *"No adventurer chosen"*,
+ body *"Open The Party and tap your crest to open your quest log."*
+2. **Not set up** (entity missing) → notice *"NestQuest is not set up yet"*, body
+ *"A parent needs to finish setting up NestQuest."*
+3. **Records unreachable** (`unavailable`/`unknown`) → notice *"The records cannot
+ be reached"*, body *"The party's records are quiet right now. NestQuest will
+ return shortly."*
+4. **Away** (present flag false) → away notice: dashed muted panel (the away-plate
+ gradient), tent icon, *" is on travels"*, *"The quest log unlocks when
+ they return."* and, when `next_present` is known, *"Returns , "*.
+ Direct navigation is the one path that can reach this state (the board's away
+ plate is not tappable); the log answers it honestly instead of showing an empty
+ column set. Header renders with the crest in away colours and the sub line
+ `… · On travels`; the remaining-count block is hidden (it is meaningless while
+ away).
+5. **No quests today** (present, due = 0) → all-clear notice, sun icon, *"No quests
+ today"*, body *"Nothing was posted for today, . Enjoy the day's rest!"* —
+ never three bare empty columns. The header and remaining block stay (0 quests
+ left, party progress), so the log still reads like the log.
+6. **All quests done** (due > 0, remaining = 0) → the fallback celebration that
+ shows until the 1c quest-complete screen takes over: centred wax-seal mark (the
+ `--nq-p-seal` tokens at 78px, check glyph, −6°) with headline *"Quest complete"*
+ — the same phrase screen 1c uses — and body *"Every quest is claimed, .
+ The seal is set for today. Returning to The Party shortly."* The idle return
+ (§3.5) is what actually navigates back; 1c remains the designed takeover.
+7. **Normal** → the three-window column layout of PANEL-SPEC §3.1 unchanged. A
+ window with no quests still renders its head alone, exactly as the spec says.
+
+Notices sit in the columns' content area, vertically centred. Error notices (states
+1–3) replace the header entirely — there is no honest name to headline when the
+records do not answer — while states 4–6 keep the full header.
+
+## 3. Party board — state priority
+
+`nestquest-party-board-card` resolves per configured child, then at board level:
+
+1. **No children configured** (`child_order` empty) → board notice *"NestQuest is
+ not set up yet"*, body *"A parent needs to finish setting up NestQuest before
+ the party can gather."*
+2. **Every configured child unresolvable** → one board notice instead of a grid of
+ broken plates: *"NestQuest is not set up yet"* when the entities are missing,
+ *"The records cannot be reached"* when they answer `unavailable`/`unknown`.
+3. **Partial** (some children resolve, some do not) → the board still renders the
+ resolving plates; an unresolvable child renders a muted, dashed plate in the
+ away geometry with a compass icon and the pill label **"Unknown"**, progress
+ line `—`, empty bar, **not tappable**. It must not claim "Home today" (0 of 0)
+ and must not lie "On travels": the records simply do not say.
+4. **Normal** → the plates grid of PANEL-SPEC §2 unchanged (present + away plates).
+
+The hint line ("Tap your crest…") is hidden in board-level notice states — there
+is nothing to tap.
+
+## 4. Shared rules
+
+- **Never a blank parchment.** Every render path emits the frames, wordmark,
+ kicker, dock, and either content or a notice panel. The dock already degrades to
+ date + time when weather is unset (PANEL-SPEC §2.3); that behaviour is unchanged.
+- **Notice panel type scale:** icon 46px, headline 40px/700 Cinzel
+ (`--nq-p-font-heading`, `--nq-p-ink`), body 24px/600 Nunito
+ (`--nq-p-ink-secondary`), `role="status"` so assistive tech announces it.
+- **Tone.** The panel speaks tabletop (PANEL-SPEC vocabulary) but never lies: an
+ error says the records cannot be reached, not that everything is fine. Error
+ copy names the actor who can fix it (a parent) or promises the return of the
+ data — no stack traces, no entity IDs.
+- **No interaction in error/away states.** No confirm dialog, no idle return
+ changes, no navigation attempts. Notices are static.
+- Both cards implement the same resolution rules, so the board and the log never
+ disagree about whether the integration is up.
diff --git a/frontend/src/cards/party-board.ts b/frontend/src/cards/party-board.ts
index 891c368..13a022d 100644
--- a/frontend/src/cards/party-board.ts
+++ b/frontend/src/cards/party-board.ts
@@ -1,4 +1,4 @@
-import { LitElement, css, html, nothing, unsafeCSS } from "lit";
+import { LitElement, css, html, nothing, unsafeCSS, type TemplateResult } from "lit";
import panelTokens from "../../../custom_components/nestquest/www/nestquest-panel-tokens.css?inline";
import { registerCustomCard, type CardConfig, type HassLike } from "../types";
@@ -17,6 +17,14 @@ type ChildPlate = {
due: number;
pct: number;
returns: string | null;
+ /** Set when the child's sensors cannot resolve; the plate shows "Unknown". */
+ unresolved: "missing" | "stale" | null;
+};
+
+type BoardNotice = {
+ icon: TemplateResult;
+ headline: string;
+ body: string;
};
type DockWeather = {
@@ -476,6 +484,62 @@ const boardStyles = css`
color: var(--nq-p-ink-secondary);
}
+ .plate.unknown {
+ border: 2px dashed var(--nq-p-panel-border);
+ background: linear-gradient(#f2ecdd, #e6dcc6);
+ box-shadow: none;
+ }
+
+ .notice-wrap {
+ position: absolute;
+ top: 262px;
+ left: 110px;
+ right: 110px;
+ bottom: 190px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .notice {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 20px;
+ width: 100%;
+ max-width: 980px;
+ padding: 56px 64px;
+ 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);
+ text-align: center;
+ }
+
+ .notice svg {
+ width: 46px;
+ height: 46px;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .notice-headline {
+ font-family: var(--nq-p-font-heading);
+ font-size: 40px;
+ font-weight: 700;
+ line-height: 1.15;
+ color: var(--nq-p-ink);
+ }
+
+ .notice-body {
+ margin: 0;
+ font-family: var(--nq-p-font-body);
+ font-size: 24px;
+ font-weight: 600;
+ line-height: 1.4;
+ color: var(--nq-p-ink-secondary);
+ }
+
.spacer {
flex: 1 1 0;
}
@@ -569,6 +633,58 @@ const ICON_TENT = html`
`;
+const ICON_COMPASS = html`
+
+
+ `;
+
+const ICON_ALERT = html`
+
+
+
+ `;
+
+const ICON_CLOUD_OFF = html`
+
+
+ `;
+
+const BOARD_NOTICE_NOT_SET_UP: BoardNotice = {
+ icon: ICON_ALERT,
+ headline: "NestQuest is not set up yet",
+ body: "A parent needs to finish setting up NestQuest before the party can gather.",
+};
+
+const BOARD_NOTICE_UNREACHABLE: BoardNotice = {
+ icon: ICON_CLOUD_OFF,
+ headline: "The records cannot be reached",
+ body: "The party's records are quiet right now. NestQuest will return shortly.",
+};
+
const ICON_CLOUD_SUN = html`
@@ -644,10 +761,14 @@ export class NestQuestPartyBoardCard extends LitElement {
The Party · ${formatDay(this._now, this._timeZone())}
-
- ${this._plates().map((plate) => this._renderPlate(plate))}
-
-
Tap your crest to open your Quest Log
+ ${notice
+ ? this._renderNotice(notice)
+ : html`
+
+ ${this._plates().map((plate) => this._renderPlate(plate))}
+
+
Tap your crest to open your Quest Log
+ `}
${this._renderDock()}
@@ -687,6 +808,7 @@ export class NestQuestPartyBoardCard extends LitElement {
}
private _plate(slug: string): ChildPlate {
+ const unresolved = this._dueSensorResolution(slug);
const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`);
const doneSensor = this._state(
`sensor.nestquest_${slug}_quests_completed_today`
@@ -744,15 +866,75 @@ export class NestQuestPartyBoardCard extends LitElement {
slug,
name,
initial: (name.charAt(0) || "?").toUpperCase(),
- present,
+ present: unresolved === null ? present : false,
completed,
due,
pct,
returns,
+ unresolved,
};
}
+ /** "missing" when the due sensor does not exist (integration not
+ * configured), "stale" when it answers unavailable/unknown (backend
+ * unreachable or the child is absent from the snapshot), null when
+ * it resolves. */
+ private _dueSensorResolution(slug: string): "missing" | "stale" | null {
+ const sensor = this._state(`sensor.nestquest_${slug}_quests_due_today`);
+ if (!sensor) {
+ return "missing";
+ }
+ const state = String(sensor.state ?? "").trim().toLowerCase();
+ if (!state || state === "unavailable" || state === "unknown") {
+ return "stale";
+ }
+ return null;
+ }
+
+ private _boardNotice(): BoardNotice | null {
+ const plates = this._plates();
+ if (plates.length === 0) {
+ return BOARD_NOTICE_NOT_SET_UP;
+ }
+ if (plates.every((plate) => plate.unresolved !== null)) {
+ return plates.some((plate) => plate.unresolved === "stale")
+ ? BOARD_NOTICE_UNREACHABLE
+ : BOARD_NOTICE_NOT_SET_UP;
+ }
+ return null;
+ }
+
+ private _renderNotice(notice: BoardNotice) {
+ return html`
+
+
+ ${notice.icon}
+
${notice.headline}
+
${notice.body}
+
+
+ `;
+ }
+
private _renderPlate(plate: ChildPlate) {
+ if (plate.unresolved) {
+ return html`
+
+
+
+ ${plate.initial}
+
+
+ ${plate.name}
+
+ ${ICON_COMPASS}
+ Unknown
+
+ —
+
+
+ `;
+ }
if (plate.present) {
return html`
();
function zonedFormatter(
@@ -299,6 +316,10 @@ const logStyles = css`
color: #ffffff;
}
+ .crest.away .crest-face {
+ background: var(--nq-p-crest-away);
+ }
+
.titles {
display: flex;
flex-direction: column;
@@ -646,6 +667,81 @@ const logStyles = css`
color: var(--nq-p-ink-secondary);
}
+ .notice-wrap {
+ position: absolute;
+ top: 236px;
+ left: var(--nq-p-page-inset);
+ right: var(--nq-p-page-inset);
+ bottom: 130px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .notice {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 20px;
+ width: 100%;
+ max-width: 980px;
+ padding: 56px 64px;
+ border: 2px solid var(--nq-p-panel-border);
+ border-radius: 20px;
+ background: var(--nq-p-card-panel);
+ box-shadow: var(--nq-p-panel-shadow);
+ text-align: center;
+ }
+
+ .notice svg {
+ width: 46px;
+ height: 46px;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .notice.away {
+ background: linear-gradient(#f2ecdd, #e6dcc6);
+ border-style: dashed;
+ box-shadow: none;
+ }
+
+ .notice-headline {
+ font-family: var(--nq-p-font-heading);
+ font-size: 40px;
+ font-weight: 700;
+ line-height: 1.15;
+ color: var(--nq-p-ink);
+ }
+
+ .notice-body {
+ margin: 0;
+ font-family: var(--nq-p-font-body);
+ font-size: 24px;
+ font-weight: 600;
+ line-height: 1.4;
+ color: var(--nq-p-ink-secondary);
+ }
+
+ .notice-seal {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 78px;
+ height: 78px;
+ 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(-6deg);
+ }
+
+ .notice-seal svg {
+ width: 38px;
+ height: 38px;
+ color: rgba(255, 235, 235, 0.95);
+ }
+
.scrim {
position: absolute;
inset: 0;
@@ -877,6 +973,77 @@ const ICON_MOON = html`
`;
+const ICON_TENT = html`
+
+
+ `;
+
+const ICON_COMPASS = html`
+
+
+ `;
+
+const ICON_ALERT = html`
+
+
+
+ `;
+
+const ICON_CLOUD_OFF = html`
+
+
+ `;
+
+const LOG_NOTICE_NO_ADVENTURER: LogNotice = {
+ icon: ICON_COMPASS,
+ headline: "No adventurer chosen",
+ body: "Open The Party and tap your crest to open your quest log.",
+};
+
+const LOG_NOTICE_NOT_SET_UP: LogNotice = {
+ icon: ICON_ALERT,
+ headline: "NestQuest is not set up yet",
+ body: "A parent needs to finish setting up NestQuest.",
+};
+
+const LOG_NOTICE_UNREACHABLE: LogNotice = {
+ icon: ICON_CLOUD_OFF,
+ headline: "The records cannot be reached",
+ body: "The party's records are quiet right now. NestQuest will return shortly.",
+};
+
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 },
@@ -935,19 +1102,162 @@ export class NestQuestQuestLogCard extends LitElement {
}
render() {
+ const view = this._logView();
return html`
- ${this._renderHeader()}
-
- ${WINDOW_DEFS.map((windowDef) => this._renderColumn(windowDef))}
-
+ ${this._renderMain(view)}
${this._renderConfirm()}
`;
}
+ private _renderMain(view: LogView): TemplateResult {
+ switch (view.kind) {
+ case "no-adventurer":
+ return this._renderNotice(LOG_NOTICE_NO_ADVENTURER);
+ case "not-set-up":
+ return this._renderNotice(LOG_NOTICE_NOT_SET_UP);
+ case "unreachable":
+ return this._renderNotice(LOG_NOTICE_UNREACHABLE);
+ case "away": {
+ const body = ["The quest log unlocks when they return."];
+ if (view.returns) {
+ body.push(`Returns ${view.returns}`);
+ }
+ return html`
+ ${this._renderHeader(view)}
+
+
+ ${ICON_TENT}
+
${view.name} is on travels
+ ${body.map((line) => html`
${line}
`)}
+
+
+ `;
+ }
+ case "empty-day":
+ return html`
+ ${this._renderHeader(view)}
+
+
+ ${ICON_SUN}
+
No quests today
+
+ ${`Nothing was posted for today, ${view.name}. Enjoy the day's rest!`}
+
+
+
+ `;
+ case "complete-day":
+ return html`
+ ${this._renderHeader(view)}
+
+
+
${ICON_CHECK}
+
Quest complete
+
+ ${`Every quest is claimed, ${view.name}. The seal is set for today.`}
+
+
Returning to The Party shortly.
+
+
+ `;
+ default:
+ return html`
+ ${this._renderHeader(view)}
+
+ ${WINDOW_DEFS.map((windowDef) => this._renderColumn(windowDef))}
+
+ `;
+ }
+ }
+
+ /** Resolve the log's single render state; the priority order is
+ * design/PANEL-EMPTY-STATES.md §2. */
+ private _logView(): LogView {
+ const slug = this._childSlug();
+ if (!slug) {
+ return { kind: "no-adventurer" };
+ }
+ const resolution = this._dueSensorResolution(slug);
+ if (resolution === "missing") {
+ return { kind: "not-set-up" };
+ }
+ if (resolution === "stale") {
+ return { kind: "unreachable" };
+ }
+ const name = this._childName(slug) ?? titleCaseSlug(slug);
+ if (!this._childPresent(slug)) {
+ return { kind: "away", name, returns: this._awayReturns(slug) };
+ }
+ const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`);
+ const due = clampCount(asNumber(dueSensor?.state, 0));
+ if (due === 0) {
+ return { kind: "empty-day", name };
+ }
+ if (this._remaining() === 0) {
+ return { kind: "complete-day", name };
+ }
+ return { kind: "normal" };
+ }
+
+ /** "missing" when the due sensor does not exist (integration not
+ * configured), "stale" when it answers unavailable/unknown (backend
+ * unreachable or the child is absent from the snapshot), null when
+ * it resolves. */
+ private _dueSensorResolution(slug: string): "missing" | "stale" | null {
+ const sensor = this._state(`sensor.nestquest_${slug}_quests_due_today`);
+ if (!sensor) {
+ return "missing";
+ }
+ const state = String(sensor.state ?? "").trim().toLowerCase();
+ if (!state || state === "unavailable" || state === "unknown") {
+ return "stale";
+ }
+ return null;
+ }
+
+ private _childPresent(slug: string): boolean {
+ const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`);
+ const presentAttr = dueSensor?.attributes?.present;
+ if (typeof presentAttr === "boolean") {
+ return presentAttr;
+ }
+ const presence = this._state(
+ `binary_sensor.nestquest_${slug}_present_today`
+ );
+ if (presence) {
+ return String(presence.state ?? "").trim().toLowerCase() === "on";
+ }
+ return true;
+ }
+
+ private _awayReturns(slug: string): string | null {
+ const timeZone = this._timeZone();
+ const presence = this._state(
+ `binary_sensor.nestquest_${slug}_present_today`
+ );
+ const nextPresent = parseIsoDate(
+ presence?.attributes?.next_present,
+ timeZone
+ );
+ return nextPresent ? formatDay(nextPresent, timeZone) : null;
+ }
+
+ private _renderNotice(notice: LogNotice): TemplateResult {
+ return html`
+
+
+ ${notice.icon}
+
${notice.headline}
+
${notice.body}
+
+
+ `;
+ }
+
private _timeZone(): string | undefined {
return hassTimeZone(this.hass);
}
@@ -1110,18 +1420,20 @@ export class NestQuestQuestLogCard extends LitElement {
return result.sort((a, b) => a.name.localeCompare(b.name));
}
- private _renderHeader() {
+ private _renderHeader(view: LogView) {
const slug = this._childSlug();
- const name = this._childName(slug);
- const displayName = name ?? (slug ? titleCaseSlug(slug) : null);
+ const away = view.kind === "away";
+ const name = away
+ ? view.name
+ : (this._childName(slug) ?? (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();
+ const title = name ? `${name}'s Quest Log` : "Quest Log";
+ const initial = (name?.charAt(0) || "?").toUpperCase();
return html`
`;
}