Make the stored application timezone authoritative everywhere

The kids' Quest Log again renders each instance's overdue state from
the API snapshot's `overdue` flag, which the API computes in the stored
application timezone. The browser-local overdue check from task
fd1093b5 (`_isOverdue` against `_now`) is removed; `_now` only drives
the header clock and date.

The admin settings route (PATCH /api/v1/admin/settings), the only
settings writer, now holds the missed-sweep scheduler's settings_lock
across the write and calls settings_changed, so saving `timezone` or
`day_rollover_time` re-plans a sleeping scheduler (review finding
TZ-001). An app without a scheduler just writes.

Tests: the card shows the snapshot's overdue state whatever the
browser's zone or clock says; an admin PATCH of the zone or the
rollover re-plans a sleeping scheduler; one saved zone
(Asia/Kolkata) is read identically by the panel snapshot, the admin
snapshot and the scheduler delay.

Task: 7e173881-fb99-426f-a2f0-2678439ffc39

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 07:49:59 -04:00
co-authored by Claude Opus 5.5
parent 69db92b7b8
commit b3542b0551
6 changed files with 373 additions and 297 deletions
+4 -26
View File
@@ -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,20 +1749,6 @@ 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);
}
@@ -1856,6 +1833,7 @@ export class NestQuestQuestLogCard extends LitElement {
continue;
}
instance.state = "completed";
instance.overdue = false;
instance.completed_at = stampedAt;
}
if (settled) {
@@ -2152,7 +2130,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,