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
+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);