Compare commits

..
Author SHA1 Message Date
overseer 5278f130bd Accept weather_entity on the quest log card 2026-09-20 18:35:53 -04:00
overseer 6d87b1e595 Suspend idle return while the quest complete screen is showing 2026-09-20 18:16:24 -04:00
overseer 2180b2fd49 Add the quest complete screen (1c) 2026-09-20 18:10:52 -04:00
dev_agent 2b45ebf035 Wire complete_quest with optimistic seal and parchment toast
Wire complete_quest with optimistic seal and parchment toast
2026-09-20 14:19:39 -04:00
dev_agent 028215fa38 Define and implement the panel's empty and error states
Define and implement the panel's empty and error states
2026-09-20 14:04:58 -04:00
3 changed files with 1881 additions and 812 deletions
File diff suppressed because it is too large Load Diff
+1
View File
@@ -90,6 +90,7 @@ child_order: [declan, jordyn, chloe]
# kids' panel, view 2
type: custom:nestquest-quest-log-card
board_path: /nestquest/board
weather_entity: weather.home # optional; dock falls back to date/time
idle_return_seconds: 40 # must be shorter than TouchHub Auto-Return
confirm_timeout_seconds: 15
complete_screen_seconds: 12
+617 -14
View File
@@ -4,6 +4,10 @@ import panelTokens from "../../../custom_components/nestquest/www/nestquest-pane
import { registerCustomCard, type CardConfig, type HassLike } from "../types";
type QuestLogCardConfig = CardConfig & {
weather_entity?: string;
};
type StateObject = {
state: unknown;
attributes?: Record<string, unknown> | null;
@@ -11,6 +15,7 @@ type StateObject = {
type PanelInstance = {
id: number;
child_id: number | null;
title: string;
icon: string | null;
window: string;
@@ -18,6 +23,7 @@ type PanelInstance = {
state: string;
overdue: boolean;
completed_at: string | null;
on_time: boolean | null;
};
type WindowDef = {
@@ -108,6 +114,14 @@ function formatInstant(value: string, timeZone: string | undefined): string {
}).format(parsed);
}
function formatTime(date: Date, timeZone: string | undefined): string {
return zonedFormatter(timeZone, {
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(date);
}
function formatWallClock(value: string | null): string | null {
if (!value) {
return null;
@@ -215,8 +229,10 @@ function toPanelInstance(value: unknown): PanelInstance | null {
if (!id) {
return null;
}
const childId = asNumber(row.child_id, 0);
return {
id,
child_id: childId > 0 ? childId : null,
title: asString(row.title) || "Quest",
icon: asString(row.icon) || null,
window: asString(row.window).toLowerCase(),
@@ -224,9 +240,65 @@ function toPanelInstance(value: unknown): PanelInstance | null {
state,
overdue: row.overdue === true,
completed_at: asString(row.completed_at) || null,
on_time: row.on_time === true ? true : row.on_time === false ? false : null,
};
}
type DockWeather = {
condition: string;
temperature: number | null;
high: number | null;
low: number | null;
};
const CONDITION_LABELS: Record<string, string> = {
"clear-night": "Clear",
cloudy: "Cloudy",
exceptional: "Clear",
fog: "Foggy",
hail: "Hail",
lightning: "Storms",
"lightning-rainy": "Storms",
partlycloudy: "Partly cloudy",
pouring: "Heavy rain",
rainy: "Rain",
snowy: "Snow",
"snowy-rainy": "Sleet",
sunny: "Sunny",
windy: "Windy",
"windy-variant": "Windy",
};
const CONDITION_PHRASES: Record<string, string> = {
"clear-night": "Clear night skies",
cloudy: "Grey skies today",
exceptional: "A striking day",
fog: "Mist on the road",
hail: "Ice from the sky",
lightning: "Storms may roll in",
"lightning-rainy": "Storms may roll in",
partlycloudy: "Sun between clouds",
pouring: "Heavy rain outside",
rainy: "Rain on the walls",
snowy: "Snow on the peaks",
"snowy-rainy": "Sleet may fall",
sunny: "Clear skies today",
windy: "A blustery day",
"windy-variant": "A blustery day",
};
function conditionLabel(condition: string): string {
const known = CONDITION_LABELS[condition];
if (known) {
return known;
}
return condition.charAt(0).toUpperCase() + condition.slice(1);
}
function conditionPhrase(condition: string, label: string): string {
return CONDITION_PHRASES[condition] ?? label;
}
const SHIELD_CLIP = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)";
const HEXAGON_CLIP =
"polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)";
@@ -881,6 +953,205 @@ const logStyles = css`
width: 38px;
height: 38px;
}
.toast {
position: absolute;
left: 50%;
bottom: 158px;
z-index: 20;
transform: translateX(-50%);
max-width: calc(100% - 124px);
padding: 20px 32px;
border: 2px solid var(--nq-p-panel-border);
border-radius: 14px;
background: var(--nq-p-card-panel);
box-shadow: var(--nq-p-panel-shadow);
font-family: var(--nq-p-font-body);
font-size: 23px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--nq-p-ink-secondary);
animation: toast-in var(--nq-dur-base) var(--nq-ease-out);
pointer-events: none;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translate(-50%, 12px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
.complete-screen {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
padding: 96px 62px 130px;
text-align: center;
}
.complete-seal {
display: inline-flex;
align-items: center;
justify-content: center;
width: 168px;
height: 168px;
border-radius: var(--nq-p-radius-pill);
background: var(--nq-p-seal);
box-shadow: 0 10px 24px rgba(60, 10, 20, 0.42);
color: rgba(255, 235, 235, 0.95);
transform: rotate(-6deg);
}
.complete-seal svg {
width: 86px;
height: 86px;
}
.complete-title {
margin: 28px 0 0;
font-family: var(--nq-p-font-display);
font-size: 86px;
font-weight: 900;
line-height: 1.1;
background: var(--nq-brand-gradient-h);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
}
.complete-sub {
margin: 16px 0 0;
max-width: 1000px;
font-family: var(--nq-p-font-body);
font-size: 34px;
font-weight: 600;
line-height: 1.35;
color: var(--nq-p-ink-secondary);
text-wrap: pretty;
}
.complete-countdown {
margin: 12px 0 0;
font-family: var(--nq-p-font-body);
font-size: 25px;
font-weight: 600;
line-height: 1.2;
color: var(--nq-p-ink-secondary);
}
.complete-stats {
position: absolute;
top: 540px;
left: 200px;
right: 200px;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 26px;
}
.complete-stat {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 28px 16px;
border: 2px solid var(--nq-p-panel-border);
border-radius: 16px;
background: var(--nq-p-card-panel);
box-shadow: var(--nq-p-panel-shadow);
}
.complete-stat .numeral {
font-family: var(--nq-p-font-heading);
font-size: 58px;
font-weight: 900;
line-height: 1;
color: var(--nq-p-ink);
}
.complete-stat .label {
font-family: var(--nq-p-font-heading);
font-size: 17px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
line-height: 1.2;
color: var(--nq-p-ink-secondary);
}
.dock {
position: absolute;
left: 46px;
right: 46px;
bottom: 46px;
height: var(--nq-p-dock-height);
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
padding: 0 40px;
border-radius: 14px;
background: var(--nq-p-dock-bg);
pointer-events: none;
user-select: none;
color: var(--nq-p-dock-ink);
}
.dock svg {
flex: none;
width: 40px;
height: 40px;
color: var(--nq-p-dock-ink);
}
.dock .divider {
flex: none;
width: 1px;
height: 40px;
background: var(--nq-p-dock-divider);
}
.dock .temp {
font-family: var(--nq-p-font-body);
font-size: 38px;
font-weight: 800;
line-height: 1;
}
.dock .condition {
font-family: var(--nq-p-font-body);
font-size: 26px;
font-weight: 600;
line-height: 1.2;
color: var(--nq-p-dock-ink-secondary);
}
.dock .date {
font-family: var(--nq-p-font-body);
font-size: 26px;
font-weight: 600;
line-height: 1.2;
color: var(--nq-p-dock-ink-secondary);
}
.dock .clock {
font-family: var(--nq-p-font-body);
font-size: 38px;
font-weight: 800;
line-height: 1;
}
`;
const ICON_CHECK = html`<svg
@@ -1026,6 +1297,19 @@ const ICON_CLOUD_OFF = html`<svg
<path d="m2 2 20 20"></path>
</svg>`;
const ICON_CLOUD_MOON = html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M13 16a3 3 0 1 1 0 6H7a5 5 0 1 1 4.9-6Z"></path>
<path d="M10.1 9A6 6 0 0 1 16 4a4.5 4.5 0 0 0 8.9 4.2v.1a6 6 0 0 1-5.2 5.7"></path>
</svg>`;
const LOG_NOTICE_NO_ADVENTURER: LogNotice = {
icon: ICON_COMPASS,
headline: "No adventurer chosen",
@@ -1056,19 +1340,29 @@ export class NestQuestQuestLogCard extends LitElement {
_config: { state: true },
_now: { state: true },
_confirm: { state: true },
_optimistic: { state: true },
_toast: { state: true },
_countdown: { state: true },
};
static styles = [unsafeCSS(panelTokens), logStyles];
hass?: HassLike;
_config?: CardConfig;
_config?: QuestLogCardConfig;
_now = new Date();
_confirm: number | null = null;
/** instanceId → the completion instant stamped before the service has
* answered; the seal shows immediately and reverts on failure. */
_optimistic = new Map<number, string>();
_toast: string | null = null;
_countdown: number | null = null;
private _clockTimer?: number;
private _idleTimer?: number;
private _confirmTimer?: number;
private _toastTimer?: number;
private _completeTimer?: number;
setConfig(config: CardConfig): void {
setConfig(config: QuestLogCardConfig): void {
if (!config || typeof config !== "object") {
throw new Error("Invalid configuration");
}
@@ -1089,12 +1383,30 @@ export class NestQuestQuestLogCard extends LitElement {
window.addEventListener("touchstart", this._onActivity, true);
window.addEventListener("keydown", this._onActivity, true);
this._armIdle();
if (this._logView().kind === "complete-day") {
this._armCompleteTimer();
}
}
protected updated(): void {
if (this._logView().kind === "complete-day") {
this._clearIdle();
this._armCompleteTimer();
} else {
this._clearCompleteTimer();
this._countdown = null;
if (this._idleTimer === undefined) {
this._armIdle();
}
}
}
disconnectedCallback(): void {
this._stopClock();
this._clearIdle();
this._clearConfirmTimer();
this._clearToastTimer();
this._clearCompleteTimer();
window.removeEventListener("pointerdown", this._onActivity, true);
window.removeEventListener("touchstart", this._onActivity, true);
window.removeEventListener("keydown", this._onActivity, true);
@@ -1109,10 +1421,20 @@ export class NestQuestQuestLogCard extends LitElement {
<div class="frame frame-inner"></div>
${this._renderMain(view)}
${this._renderConfirm()}
${this._renderToast()}
</div>
`;
}
private _renderToast(): TemplateResult | typeof nothing {
if (this._toast === null) {
return nothing;
}
return html`
<div class="toast" role="status">${this._toast}</div>
`;
}
private _renderMain(view: LogView): TemplateResult {
switch (view.kind) {
case "no-adventurer":
@@ -1150,20 +1472,39 @@ export class NestQuestQuestLogCard extends LitElement {
</div>
</div>
`;
case "complete-day":
case "complete-day": {
const stats = this._completeStats();
const remaining = this._countdown ?? this._completeSeconds();
return html`
${this._renderHeader(view)}
<div class="notice-wrap">
<div class="notice" role="status">
<span class="notice-seal">${ICON_CHECK}</span>
<span class="notice-headline">Quest complete</span>
<p class="notice-body">
${`Every quest is claimed, ${view.name}. The seal is set for today.`}
</p>
<p class="notice-body">Returning to The Party shortly.</p>
<div class="complete-screen" role="status">
<span class="complete-seal" aria-hidden="true">${ICON_CHECK}</span>
<h1 class="complete-title">Quest complete</h1>
<p class="complete-sub">${this._completeSub(view.name)}</p>
<p class="complete-countdown">
Returning to The Party in ${remaining} seconds
</p>
<div class="complete-stats">
<div class="complete-stat">
<span class="numeral">${stats.claimed}</span>
<span class="label">Quests claimed</span>
</div>
<div class="complete-stat">
<span class="numeral">${stats.onTime}</span>
<span class="label">On time</span>
</div>
<div class="complete-stat">
<span class="numeral">${stats.late}</span>
<span class="label">Late</span>
</div>
<div class="complete-stat">
<span class="numeral">${stats.party}</span>
<span class="label">The Party</span>
</div>
</div>
</div>
${this._renderDock()}
`;
}
default:
return html`
${this._renderHeader(view)}
@@ -1192,6 +1533,10 @@ export class NestQuestQuestLogCard extends LitElement {
if (!this._childPresent(slug)) {
return { kind: "away", name, returns: this._awayReturns(slug) };
}
const allDone = this._state(`binary_sensor.nestquest_${slug}_all_done`);
if (allDone && String(allDone.state ?? "").trim().toLowerCase() === "on") {
return { kind: "complete-day", name };
}
const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`);
const due = clampCount(asNumber(dueSensor?.state, 0));
if (due === 0) {
@@ -1286,6 +1631,11 @@ export class NestQuestQuestLogCard extends LitElement {
return seconds > 0 ? seconds : 15;
}
private _completeSeconds(): number {
const seconds = asNumber(this._config?.complete_screen_seconds, 12);
return seconds > 0 ? seconds : 12;
}
private _childName(slug: string): string | null {
if (!slug) {
return null;
@@ -1318,9 +1668,32 @@ export class NestQuestQuestLogCard extends LitElement {
if (!Array.isArray(payload)) {
return [];
}
return payload
const instances = payload
.map(toPanelInstance)
.filter((instance): instance is PanelInstance => instance !== null);
if (this._optimistic.size === 0) {
return instances;
}
let settled = false;
for (const instance of instances) {
const stampedAt = this._optimistic.get(instance.id);
if (stampedAt === undefined) {
continue;
}
if (instance.state === "completed") {
// the backend has caught up; the real record replaces the overlay
this._optimistic.delete(instance.id);
settled = true;
continue;
}
instance.state = "completed";
instance.overdue = false;
instance.completed_at = stampedAt;
}
if (settled) {
this._optimistic = new Map(this._optimistic);
}
return instances;
}
private _remaining(): number {
@@ -1356,6 +1729,41 @@ export class NestQuestQuestLogCard extends LitElement {
return { completed, due };
}
private _completeStats(): {
claimed: number;
onTime: number;
late: number;
party: string;
} {
const claimed = this._childCounts(this._childSlug()).completed;
const onTime = this._instances().filter(
(instance) => instance.on_time === true
).length;
const late = clampCount(claimed - onTime);
const party = this._partyCounts();
return {
claimed,
onTime,
late,
party: `${party.completed}/${party.due}`,
};
}
private _completeSub(name: string): string {
const date = formatDay(this._now, this._timeZone());
const { claimed, onTime, late } = this._completeStats();
if (claimed === 0) {
return `${name} sealed the day on ${date}.`;
}
if (late === 0) {
return `${name} claimed every quest on ${date} — all on time.`;
}
if (onTime === 0) {
return `${name} claimed every quest on ${date} — all late.`;
}
return `${name} claimed every quest on ${date} — ${onTime} on time, ${late} late.`;
}
private _otherChildren(): OtherChild[] {
const states = this.hass?.states as
| Record<string, StateObject>
@@ -1698,7 +2106,94 @@ export class NestQuestQuestLogCard extends LitElement {
}
private _confirmComplete(): void {
const instance =
this._confirm === null
? undefined
: this._instances().find(
(candidate) => candidate.id === this._confirm
);
this._closeConfirm();
if (!instance || instance.state !== "open") {
return;
}
this._completeQuest(instance);
}
/** Seal first, ask the service second: the seal is stamped and the column
* re-sorted immediately (PANEL-SPEC §4); a service failure reverts the
* card and raises the parchment toast. */
private async _completeQuest(instance: PanelInstance): Promise<void> {
if (this._optimistic.has(instance.id)) {
return;
}
const stampedAt = new Date().toISOString();
this._optimistic = new Map(this._optimistic).set(instance.id, stampedAt);
try {
await this._callCompleteQuest(instance);
} catch {
const reverted = new Map(this._optimistic);
reverted.delete(instance.id);
this._optimistic = reverted;
this._showToast(
"NestQuest could not set the seal just now. Please try again."
);
}
}
private async _callCompleteQuest(instance: PanelInstance): Promise<void> {
const hass = this.hass as
| {
callService?: (
this: unknown,
domain: string,
service: string,
data?: Record<string, unknown>
) => Promise<unknown>;
}
| undefined;
if (typeof hass?.callService !== "function") {
throw new Error("Home Assistant is not connected");
}
const actorChildId = this._actorChildId(instance);
if (actorChildId === null) {
throw new Error("The tapped adventurer is unknown");
}
await hass.callService.call(hass, "nestquest", "complete_quest", {
instance_id: instance.id,
actor: "panel",
actor_child_id: actorChildId,
});
}
/** The tapped profile (decision 9): the instance's own child_id, falling
* back to the due sensor's child-level attribute. */
private _actorChildId(instance: PanelInstance): number | null {
if (instance.child_id !== null) {
return instance.child_id;
}
const slug = this._childSlug();
const fromSensor = asNumber(
this._state(`sensor.nestquest_${slug}_quests_due_today`)?.attributes
?.child_id,
0
);
return fromSensor > 0 ? fromSensor : null;
}
private _showToast(message: string): void {
this._toast = message;
this._clearToastTimer();
this._toastTimer = window.setTimeout(() => {
this._toastTimer = undefined;
this._toast = null;
}, 6000);
}
private _clearToastTimer(): void {
if (this._toastTimer !== undefined) {
window.clearTimeout(this._toastTimer);
this._toastTimer = undefined;
}
}
private _armConfirmTimer(): void {
@@ -1731,6 +2226,112 @@ export class NestQuestQuestLogCard extends LitElement {
}
}
private _armCompleteTimer(): void {
if (this._completeTimer !== undefined) {
return;
}
if (this._countdown === null) {
this._countdown = this._completeSeconds();
}
if (this._countdown <= 0) {
this._returnToBoard();
return;
}
this._completeTimer = window.setInterval(() => {
const remaining = (this._countdown ?? 1) - 1;
if (remaining <= 0) {
this._countdown = 0;
this._clearCompleteTimer();
this._returnToBoard();
return;
}
this._countdown = remaining;
}, 1000);
}
private _clearCompleteTimer(): void {
if (this._completeTimer !== undefined) {
window.clearInterval(this._completeTimer);
this._completeTimer = undefined;
}
}
private _optionalNumber(value: unknown): number | null {
if (value === null || value === undefined || value === "") {
return null;
}
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
private _dockWeather(): DockWeather | null {
const entityId = asString(this._config?.weather_entity);
if (!entityId) {
return null;
}
const stateObj = this._state(entityId);
if (!stateObj) {
return null;
}
const condition = String(stateObj.state ?? "").trim();
if (!condition || condition === "unavailable" || condition === "unknown") {
return null;
}
const attrs = stateObj.attributes ?? {};
let high: number | null = null;
let low: number | null = null;
const forecast = attrs.forecast;
if (Array.isArray(forecast) && forecast.length > 0) {
const entry = forecast[0];
if (entry && typeof entry === "object") {
const typed = entry as Record<string, unknown>;
high = this._optionalNumber(typed.temperature);
low = this._optionalNumber(typed.templow);
}
}
return {
condition,
temperature: this._optionalNumber(attrs.temperature),
high,
low,
};
}
private _renderDock() {
const weather = this._dockWeather();
if (!weather) {
return html`
<div class="dock">
<span class="date">${formatDay(this._now, this._timeZone())}</span>
<span class="divider"></span>
<span class="clock">${formatTime(this._now, this._timeZone())}</span>
</div>
`;
}
const label = conditionLabel(weather.condition);
const phrase = conditionPhrase(weather.condition, label);
const temperature =
weather.temperature === null
? nothing
: html`<span class="temp">${Math.round(weather.temperature)}°</span>`;
const hiLo =
weather.high === null || weather.low === null
? null
: `${Math.round(weather.high)}° / ${Math.round(weather.low)}°`;
return html`
<div class="dock">
${ICON_CLOUD_MOON}
${temperature}
<span class="divider"></span>
<span class="condition">
${hiLo === null ? label : `${label} · ${hiLo}`}
</span>
<span class="divider"></span>
<span class="condition">${phrase}</span>
</div>
`;
}
private _returnToBoard(): void {
const path = this._boardPath();
if (!path) {
@@ -1748,7 +2349,9 @@ export class NestQuestQuestLogCard extends LitElement {
}
private _onActivity = (): void => {
this._armIdle();
if (this._logView().kind !== "complete-day") {
this._armIdle();
}
if (this._confirm !== null) {
this._armConfirmTimer();
}