Release: panel-local overdue correction (0.9.7) (2026-09-29) #280

Merged
overseer merged 4 commits from release/2026-09-29-overdue-panel-clock into main 2026-09-29 09:01:04 -04:00
4 changed files with 407 additions and 232 deletions
Showing only changes of commit 48eae05e1f - Show all commits
File diff suppressed because it is too large Load Diff
+26 -4
View File
@@ -36,7 +36,6 @@ type PanelInstance = {
window: string;
due_time: string | null;
state: string;
overdue: boolean;
completed_at: string | null;
on_time: boolean | null;
};
@@ -154,6 +153,17 @@ function formatWallClock(value: string | null): string | null {
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 {
return typeof value === "string" ? value.trim() : "";
}
@@ -253,7 +263,6 @@ function toPanelInstance(value: unknown): PanelInstance | 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,
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 {
return hassTimeZone(this.hass);
}
@@ -1833,7 +1856,6 @@ export class NestQuestQuestLogCard extends LitElement {
continue;
}
instance.state = "completed";
instance.overdue = false;
instance.completed_at = stampedAt;
}
if (settled) {
@@ -2130,7 +2152,7 @@ export class NestQuestQuestLogCard extends LitElement {
`;
}
const dueAt = formatWallClock(instance.due_time);
const meta = instance.overdue
const meta = this._isOverdue(instance)
? {
text: dueAt ? `Overdue · due ${dueAt}` : "Overdue",
late: true,
+46 -1
View File
@@ -16,6 +16,12 @@
* reports every hass.callService call as tap: {dialog_opened, service_calls}.
* With tap_return: true the harness taps the header's return control and
* 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
* 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.
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);
const serviceCalls = [];
@@ -123,6 +156,18 @@ if (spec.idle_ms !== undefined) {
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()));
process.exit(0);
+99 -7
View File
@@ -21,6 +21,7 @@ end-to-end through the quest-log harness.
from __future__ import annotations
import json
import os
import re
import subprocess
from pathlib import Path
@@ -831,6 +832,9 @@ def _render_quest_log(
probe_ms: int | None = None,
tap_complete: int | None = None,
tap_return: bool = False,
now: str | None = None,
advance_to: list[str] | None = None,
tz: str | None = None,
) -> dict:
"""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
of the render mid-wait, then the optional idle window, and prints
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 QUEST_LOG_HARNESS.is_file()
@@ -858,12 +865,17 @@ def _render_quest_log(
spec["tap_complete"] = tap_complete
if tap_return:
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(
["node", str(QUEST_LOG_HARNESS)],
input=json.dumps(spec),
capture_output=True,
text=True,
timeout=120,
env={**os.environ, "TZ": tz} if tz is not None else None,
)
assert completed.returncode == 0, (
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
attribute for every quest that was not late, leaving the due line
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(
{}, _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 {
"id": instance_id,
"definition_id": instance_id,
@@ -1103,9 +1116,9 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
"title": title,
"icon": None,
"window": "morning",
"due_time": "08:15",
"due_time": due_time,
"state": "open",
"overdue": overdue,
"overdue": False,
"completed_at": None,
"on_time": None,
}
@@ -1118,8 +1131,8 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
child_id=1,
present=True,
instances=[
quest(41, "Make Bed", overdue=False),
quest(42, "Brush Teeth", overdue=True),
quest(41, "Make Bed", "08:15"),
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],
states,
url="http://homeassistant.local/nestquest/ada",
now="2026-09-29T11:15:00Z",
tz="America/New_York",
)
metas = {}
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"]
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:
"""PANEL-SPEC §3 header crest: 96x110 shield, 4px ring of
rgba(255,255,255,.22) around the --nq-p-crest face, 44px/700 display