Decide quest-log overdue from the panel's own local clock

The kids' Quest Log rendered OVERDUE from the API's per-instance
`overdue` flag, which is computed on whatever wall clock the API
resolves for the household. When that clock is UTC (HA `time_zone`
UTC, a pre-0.9.6 API/integration, or a stored zone adopted over),
an 8:15 AM quest reads OVERDUE at 7:15 AM America/New_York.

The card now marks an open quest overdue when its `due_time` wall
time has arrived on the panel browser's local clock (`_now`, a
reactive state ticking every 60 s; `>=` so it flips on the due
minute). With no `due_time` a quest is never overdue, matching the
API's rule. The API's `overdue` field is unchanged for admin and
automation consumers; the card simply no longer reads it.

The jsdom quest-log harness can now pin the clock (`now`) and fire
the card's own clock tick at later instants (`advance_to`), and the
Python helper sets the node process `TZ`.

Task: fd1093b5-e6ae-47e6-aee9-1e8f5ee74e3f

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 07:23:44 -04:00
co-authored by Claude Opus 5.5
parent 55b9ce915b
commit 51afb9371a
4 changed files with 407 additions and 232 deletions
File diff suppressed because it is too large Load Diff
+26 -4
View File
@@ -36,7 +36,6 @@ type PanelInstance = {
window: string; window: string;
due_time: string | null; due_time: string | null;
state: string; state: string;
overdue: boolean;
completed_at: string | null; completed_at: string | null;
on_time: boolean | null; on_time: boolean | null;
}; };
@@ -154,6 +153,17 @@ function formatWallClock(value: string | null): string | null {
return `${hour12}:${match[2]} ${suffix}`; return `${hour12}:${match[2]} ${suffix}`;
} }
/** Minutes past midnight of a household ``HH:MM`` wall time, or null. */
function wallClockMinutes(value: string | null): number | null {
const match = value ? /^(\d{1,2}):(\d{2})$/.exec(value.trim()) : null;
if (!match) {
return null;
}
const hours = Number(match[1]);
const minutes = Number(match[2]);
return hours > 23 || minutes > 59 ? null : hours * 60 + minutes;
}
function asString(value: unknown): string { function asString(value: unknown): string {
return typeof value === "string" ? value.trim() : ""; return typeof value === "string" ? value.trim() : "";
} }
@@ -253,7 +263,6 @@ function toPanelInstance(value: unknown): PanelInstance | null {
window: asString(row.window).toLowerCase(), window: asString(row.window).toLowerCase(),
due_time: asString(row.due_time) || null, due_time: asString(row.due_time) || null,
state, state,
overdue: row.overdue === true,
completed_at: asString(row.completed_at) || null, completed_at: asString(row.completed_at) || null,
on_time: row.on_time === true ? true : row.on_time === false ? false : null, on_time: row.on_time === true ? true : row.on_time === false ? false : null,
}; };
@@ -1749,6 +1758,20 @@ export class NestQuestQuestLogCard extends LitElement {
`; `;
} }
/** Overdue by the panel's own clock: the browser runs on the
household's real local time, so an open quest turns overdue once its
``due_time`` wall time has arrived there — whatever zone the API or
Home Assistant computed the API's ``overdue`` flag in. With no due
time it is never overdue (the API's rule too). ``_now`` is reactive
and ticks every 60 s, so the line flips on the minute it falls due. */
private _isOverdue(instance: PanelInstance): boolean {
const due = wallClockMinutes(instance.due_time);
if (instance.state !== "open" || due === null) {
return false;
}
return this._now.getHours() * 60 + this._now.getMinutes() >= due;
}
private _timeZone(): string | undefined { private _timeZone(): string | undefined {
return hassTimeZone(this.hass); return hassTimeZone(this.hass);
} }
@@ -1833,7 +1856,6 @@ export class NestQuestQuestLogCard extends LitElement {
continue; continue;
} }
instance.state = "completed"; instance.state = "completed";
instance.overdue = false;
instance.completed_at = stampedAt; instance.completed_at = stampedAt;
} }
if (settled) { if (settled) {
@@ -2130,7 +2152,7 @@ export class NestQuestQuestLogCard extends LitElement {
`; `;
} }
const dueAt = formatWallClock(instance.due_time); const dueAt = formatWallClock(instance.due_time);
const meta = instance.overdue const meta = this._isOverdue(instance)
? { ? {
text: dueAt ? `Overdue · due ${dueAt}` : "Overdue", text: dueAt ? `Overdue · due ${dueAt}` : "Overdue",
late: true, late: true,
+46 -1
View File
@@ -16,6 +16,12 @@
* reports every hass.callService call as tap: {dialog_opened, service_calls}. * reports every hass.callService call as tap: {dialog_opened, service_calls}.
* With tap_return: true the harness taps the header's return control and * With tap_return: true the harness taps the header's return control and
* reports it as returned: {rendered, label} before the final snapshot. * reports it as returned: {rendered, label} before the final snapshot.
* With now: <ISO instant> the card's clock is pinned there (argument-less
* new Date() and Date.now() return it; the process TZ decides its local
* wall time), and with advance_to: [<ISO instant>, ...] the clock moves to
* each instant in turn and the card's own 60 s clock-tick callbacks fire,
* one snapshot per instant reported as ticks (the top-level snapshot is
* taken before the clock moves).
* The Python panel tests drive this harness so the * The Python panel tests drive this harness so the
* assertions run against the exact bundle HACS ships. * assertions run against the exact bundle HACS ships.
*/ */
@@ -47,6 +53,33 @@ globalThis.window = dom.window;
// jsdom Event must shadow it for the return navigation to be delivered. // jsdom Event must shadow it for the return navigation to be delivered.
globalThis.Event = dom.window.Event; globalThis.Event = dom.window.Event;
// Pin the clock: argument-less construction and Date.now() read `pinned`,
// every other Date use is the real class.
let pinned = spec.now !== undefined ? new Date(spec.now).getTime() : null;
if (pinned !== null) {
const RealDate = Date;
class PinnedDate extends RealDate {
constructor(...args) {
super(...(args.length === 0 ? [pinned] : args));
}
static now() {
return pinned;
}
}
globalThis.Date = PinnedDate;
dom.window.Date = PinnedDate;
}
// Record the card's 60 s clock ticks so advance_to can fire them on demand.
const clockTicks = [];
const realSetInterval = dom.window.setInterval.bind(dom.window);
dom.window.setInterval = (callback, ms, ...rest) => {
if (ms === 60000) {
clockTicks.push(callback);
}
return realSetInterval(callback, ms, ...rest);
};
await import(pathToFileURL(spec.bundle).href); await import(pathToFileURL(spec.bundle).href);
const serviceCalls = []; const serviceCalls = [];
@@ -123,6 +156,18 @@ if (spec.idle_ms !== undefined) {
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms)); await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
} }
const payload = JSON.stringify({ ...snapshot(), probe, tap, returned }); const settled = snapshot();
let ticks = null;
if (spec.advance_to !== undefined) {
ticks = [];
for (const instant of spec.advance_to) {
pinned = new Date(instant).getTime();
clockTicks.forEach((callback) => callback());
await element.updateComplete;
ticks.push(snapshot());
}
}
const payload = JSON.stringify({ ...settled, probe, tap, returned, ticks });
await new Promise((resolve) => process.stdout.write(payload, () => resolve())); await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0); process.exit(0);
+99 -7
View File
@@ -21,6 +21,7 @@ end-to-end through the quest-log harness.
from __future__ import annotations from __future__ import annotations
import json import json
import os
import re import re
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -831,6 +832,9 @@ def _render_quest_log(
probe_ms: int | None = None, probe_ms: int | None = None,
tap_complete: int | None = None, tap_complete: int | None = None,
tap_return: bool = False, tap_return: bool = False,
now: str | None = None,
advance_to: list[str] | None = None,
tz: str | None = None,
) -> dict: ) -> dict:
"""Render the built bundle's quest log and report its location. """Render the built bundle's quest log and report its location.
@@ -840,7 +844,10 @@ def _render_quest_log(
segment — waits the optional probe window and captures one snapshot segment — waits the optional probe window and captures one snapshot
of the render mid-wait, then the optional idle window, and prints of the render mid-wait, then the optional idle window, and prints
the final pathname and rendered title (plus the probe snapshot when the final pathname and rendered title (plus the probe snapshot when
``probe_ms`` was given). ``probe_ms`` was given). ``now`` pins the card's clock to an ISO
instant, ``advance_to`` moves it through further instants firing the
card's clock tick (one snapshot each, under ``ticks``), and ``tz``
sets the node process's ``TZ`` — the panel browser's local zone.
""" """
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build" assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
assert QUEST_LOG_HARNESS.is_file() assert QUEST_LOG_HARNESS.is_file()
@@ -858,12 +865,17 @@ def _render_quest_log(
spec["tap_complete"] = tap_complete spec["tap_complete"] = tap_complete
if tap_return: if tap_return:
spec["tap_return"] = True spec["tap_return"] = True
if now is not None:
spec["now"] = now
if advance_to is not None:
spec["advance_to"] = advance_to
completed = subprocess.run( completed = subprocess.run(
["node", str(QUEST_LOG_HARNESS)], ["node", str(QUEST_LOG_HARNESS)],
input=json.dumps(spec), input=json.dumps(spec),
capture_output=True, capture_output=True,
text=True, text=True,
timeout=120, timeout=120,
env={**os.environ, "TZ": tz} if tz is not None else None,
) )
assert completed.returncode == 0, ( assert completed.returncode == 0, (
f"quest-log harness failed: {completed.stderr}" f"quest-log harness failed: {completed.stderr}"
@@ -1090,12 +1102,13 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
``meta ${late ? "late" : nothing}``, Lit dropped the whole class ``meta ${late ? "late" : nothing}``, Lit dropped the whole class
attribute for every quest that was not late, leaving the due line attribute for every quest that was not late, leaving the due line
unstyled. The rendered markup must carry ``class="meta"`` on time and unstyled. The rendered markup must carry ``class="meta"`` on time and
``class="meta late"`` overdue.""" ``class="meta late"`` overdue — overdue by the panel's pinned clock
(07:15 local: the 07:00 quest is late, the 08:15 one is not)."""
generated = _generate_dashboard( generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest" {}, _three_child_states(), url="http://homeassistant.local/nestquest"
) )
def quest(instance_id: int, title: str, overdue: bool) -> dict: def quest(instance_id: int, title: str, due_time: str) -> dict:
return { return {
"id": instance_id, "id": instance_id,
"definition_id": instance_id, "definition_id": instance_id,
@@ -1103,9 +1116,9 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
"title": title, "title": title,
"icon": None, "icon": None,
"window": "morning", "window": "morning",
"due_time": "08:15", "due_time": due_time,
"state": "open", "state": "open",
"overdue": overdue, "overdue": False,
"completed_at": None, "completed_at": None,
"on_time": None, "on_time": None,
} }
@@ -1118,8 +1131,8 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
child_id=1, child_id=1,
present=True, present=True,
instances=[ instances=[
quest(41, "Make Bed", overdue=False), quest(41, "Make Bed", "08:15"),
quest(42, "Brush Teeth", overdue=True), quest(42, "Brush Teeth", "07:00"),
], ],
), ),
} }
@@ -1127,6 +1140,8 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
generated["views"][1]["cards"][0], generated["views"][1]["cards"][0],
states, states,
url="http://homeassistant.local/nestquest/ada", url="http://homeassistant.local/nestquest/ada",
now="2026-09-29T11:15:00Z",
tz="America/New_York",
) )
metas = {} metas = {}
for card in result["html"].split('data-instance-id="')[1:]: for card in result["html"].split('data-instance-id="')[1:]:
@@ -1143,6 +1158,83 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
assert metas["42"].strip() == 'class="meta late"', metas["42"] assert metas["42"].strip() == 'class="meta late"', metas["42"]
def _quest_metas(html: str) -> dict[str, tuple[str, str]]:
"""Map each rendered open quest's instance id to its meta line's
attributes and text."""
metas = {}
for card in html.split('data-instance-id="')[1:]:
meta = re.search(
r'<span class="quest-title">.*?</span>'
r"\s*(?:<!--[^>]*-->\s*)*<span([^>]*)>(.*?)</span>",
card,
re.S,
)
assert meta is not None, card[:800]
text = " ".join(re.sub(r"<!--[^>]*-->", "", meta.group(2)).split())
metas[card[: card.index('"')]] = (meta.group(1).strip(), text)
return metas
def test_quest_log_overdue_follows_the_panel_local_clock() -> None:
"""Overdue is decided by the panel's own local clock, not the API's
``overdue`` flag. The owner saw 8:15 AM quests read OVERDUE at 7:15 AM
America/New_York: the API's flag had been computed on a UTC wall clock
(11:15). Here the API still says ``overdue: true`` and Home
Assistant reports ``time_zone`` UTC, yet at 07:15 local the 08:15 quest
is due, at 08:15 (and 08:16, via the card's own clock tick) it is
overdue, and a quest with no due time is never overdue."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
def quest(instance_id: int, title: str, due_time: str | None) -> dict:
return {
"id": instance_id,
"definition_id": instance_id,
"child_id": 1,
"title": title,
"icon": None,
"window": "morning",
"due_time": due_time,
"state": "open",
"overdue": True,
"completed_at": None,
"on_time": None,
}
states = {
**_three_child_states(),
"sensor.nestquest_ada_quests_due_today": _state(
"2",
child_name="Ada",
child_id=1,
present=True,
instances=[
quest(42, "Brush Teeth", "08:15"),
quest(43, "Feed Fish", None),
],
),
}
result = _render_quest_log(
generated["views"][1]["cards"][0],
states,
url="http://homeassistant.local/nestquest/ada",
now="2026-09-29T11:15:00Z",
advance_to=["2026-09-29T12:15:00Z", "2026-09-29T12:16:00Z"],
tz="America/New_York",
)
due_by = ('class="meta"', "Due by 8:15 AM")
overdue = ('class="meta late"', "Overdue · due 8:15 AM")
not_due = ('class="meta"', "Due today")
at_0715, at_0815, at_0816 = (
_quest_metas(result["html"]),
*(_quest_metas(tick["html"]) for tick in result["ticks"]),
)
assert at_0715 == {"42": due_by, "43": not_due}, at_0715
assert at_0815 == {"42": overdue, "43": not_due}, at_0815
assert at_0816 == {"42": overdue, "43": not_due}, at_0816
def test_quest_log_crest_matches_the_spec_geometry() -> None: def test_quest_log_crest_matches_the_spec_geometry() -> None:
"""PANEL-SPEC §3 header crest: 96x110 shield, 4px ring of """PANEL-SPEC §3 header crest: 96x110 shield, 4px ring of
rgba(255,255,255,.22) around the --nq-p-crest face, 44px/700 display rgba(255,255,255,.22) around the --nq-p-crest face, 44px/700 display