Manual timezone is the single source of local-time truth (task 7e173881) (#285)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #285.
This commit is contained in:
@@ -937,8 +937,18 @@ export class NestQuestPartyBoardCard extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/** The zone the visible date and clock render in: the API's effective
|
||||
* application timezone — the stored zone, or the API host's when none
|
||||
* is stored (published on the household rollup by
|
||||
* custom_components/nestquest/sensor.py — ``timezone``). Only when the
|
||||
* rollup carries none does Home Assistant's zone (or the browser's)
|
||||
* apply. */
|
||||
private _timeZone(): string | undefined {
|
||||
return hassTimeZone(this.hass);
|
||||
const stored = this._state("sensor.nestquest_household_quests_due_today")
|
||||
?.attributes?.timezone;
|
||||
return typeof stored === "string" && stored.trim()
|
||||
? stored.trim()
|
||||
: hassTimeZone(this.hass);
|
||||
}
|
||||
|
||||
private _state(entityId: string): StateObject | null {
|
||||
|
||||
@@ -36,6 +36,7 @@ type PanelInstance = {
|
||||
window: string;
|
||||
due_time: string | null;
|
||||
state: string;
|
||||
overdue: boolean;
|
||||
completed_at: string | null;
|
||||
on_time: boolean | null;
|
||||
};
|
||||
@@ -153,17 +154,6 @@ 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() : "";
|
||||
}
|
||||
@@ -263,6 +253,7 @@ 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,
|
||||
};
|
||||
@@ -1758,22 +1749,18 @@ 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;
|
||||
}
|
||||
|
||||
/** The zone the visible date and clock render in: the API's effective
|
||||
* application timezone — the stored zone, or the API host's when none
|
||||
* is stored (published on the household rollup by
|
||||
* custom_components/nestquest/sensor.py — ``timezone``). Only when the
|
||||
* rollup carries none does Home Assistant's zone (or the browser's)
|
||||
* apply. */
|
||||
private _timeZone(): string | undefined {
|
||||
return hassTimeZone(this.hass);
|
||||
const stored = this._state("sensor.nestquest_household_quests_due_today")
|
||||
?.attributes?.timezone;
|
||||
return typeof stored === "string" && stored.trim()
|
||||
? stored.trim()
|
||||
: hassTimeZone(this.hass);
|
||||
}
|
||||
|
||||
private _state(entityId: string): StateObject | null {
|
||||
@@ -1856,6 +1843,7 @@ export class NestQuestQuestLogCard extends LitElement {
|
||||
continue;
|
||||
}
|
||||
instance.state = "completed";
|
||||
instance.overdue = false;
|
||||
instance.completed_at = stampedAt;
|
||||
}
|
||||
if (settled) {
|
||||
@@ -2152,7 +2140,7 @@ export class NestQuestQuestLogCard extends LitElement {
|
||||
`;
|
||||
}
|
||||
const dueAt = formatWallClock(instance.due_time);
|
||||
const meta = this._isOverdue(instance)
|
||||
const meta = instance.overdue
|
||||
? {
|
||||
text: dueAt ? `Overdue · due ${dueAt}` : "Overdue",
|
||||
late: true,
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
*
|
||||
* Usage: node render-party-board.mjs < spec.json
|
||||
*
|
||||
* The spec JSON carries {bundle, tag, config, hass, url?, click_plate?};
|
||||
* The spec JSON carries {bundle, tag, config, hass, url?, click_plate?, now?};
|
||||
* stdout receives one JSON object {plates, tappable, location, html} where
|
||||
* plates is the ordered plate list the shadow DOM renders (name +
|
||||
* present/away/unknown kind), tappable the plate names rendered as tappable
|
||||
* <button> plates, and location the window pathname after the optional plate
|
||||
* tap. The Python panel tests drive this harness so the assertions run
|
||||
* against the exact bundle HACS ships.
|
||||
* tap. 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). The Python panel tests drive this harness
|
||||
* so the assertions run against the exact bundle HACS ships.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -39,6 +41,23 @@ globalThis.window = dom.window;
|
||||
// jsdom Event must shadow it for the dispatched event 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.
|
||||
if (spec.now !== undefined) {
|
||||
const pinned = new Date(spec.now).getTime();
|
||||
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;
|
||||
}
|
||||
|
||||
await import(pathToFileURL(spec.bundle).href);
|
||||
|
||||
const element = document.createElement(spec.tag);
|
||||
|
||||
Reference in New Issue
Block a user