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
+16 -3
View File
@@ -2330,12 +2330,25 @@ async def admin_update_settings(
every value has validated — so a rejected update never changes the every value has validated — so a rejected update never changes the
stored settings. Every core ``ValueError`` is 422 stored settings. Every core ``ValueError`` is 422
(:func:`_raise_settings_error`); anything else re-raises. (:func:`_raise_settings_error`); anything else re-raises.
The write holds the missed-sweep scheduler's ``settings_lock`` and
then tells it (:meth:`~api.scheduler.MissedSweepScheduler.settings_changed`),
so a saved ``timezone`` or ``day_rollover_time`` re-plans a sleeping
scheduler (an app without one — no lifespan — just writes).
""" """
state: DatabaseState = request.app.state.db state: DatabaseState = request.app.state.db
scheduler = getattr(request.app.state, "sweep_scheduler", None)
try: try:
settings = await core_settings_store.update_settings( if scheduler is None:
state.database, changes settings = await core_settings_store.update_settings(
) state.database, changes
)
else:
async with scheduler.settings_lock:
settings = await core_settings_store.update_settings(
state.database, changes
)
scheduler.settings_changed(settings)
except ValueError as error: except ValueError as error:
_raise_settings_error(error) _raise_settings_error(error)
return _settings_response(settings) return _settings_response(settings)
+2 -1
View File
@@ -22,7 +22,8 @@ it planned with has moved, and the loop re-reads the settings before
sweeping and re-plans instead if they no longer match its plan. sweeping and re-plans instead if they no longer match its plan.
Guarantee: no sweep runs for a plan a committed settings write has Guarantee: no sweep runs for a plan a committed settings write has
replaced. A settings writer holds :attr:`MissedSweepScheduler.settings_lock` replaced. The settings writer — the admin ``PATCH /api/v1/admin/settings``
route, the only one — holds :attr:`MissedSweepScheduler.settings_lock`
across its write AND its :meth:`~MissedSweepScheduler.settings_changed` across its write AND its :meth:`~MissedSweepScheduler.settings_changed`
call; the loop holds the same lock across its plan read (read, record call; the loop holds the same lock across its plan read (read, record
the plan, clear the wake flag) and across the pre-sweep re-read plus the plan, clear the wake flag) and across the pre-sweep re-read plus
File diff suppressed because it is too large Load Diff
+4 -26
View File
@@ -36,6 +36,7 @@ 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;
}; };
@@ -153,17 +154,6 @@ 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() : "";
} }
@@ -263,6 +253,7 @@ 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,
}; };
@@ -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 { private _timeZone(): string | undefined {
return hassTimeZone(this.hass); return hassTimeZone(this.hass);
} }
@@ -1856,6 +1833,7 @@ 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) {
@@ -2152,7 +2130,7 @@ export class NestQuestQuestLogCard extends LitElement {
`; `;
} }
const dueAt = formatWallClock(instance.due_time); const dueAt = formatWallClock(instance.due_time);
const meta = this._isOverdue(instance) const meta = instance.overdue
? { ? {
text: dueAt ? `Overdue · due ${dueAt}` : "Overdue", text: dueAt ? `Overdue · due ${dueAt}` : "Overdue",
late: true, late: true,
+100
View File
@@ -92,6 +92,7 @@ async def household(temp_db_path: str, monkeypatch) -> SimpleNamespace:
seed=seed, seed=seed,
clock=clock, clock=clock,
database=runner.app.state.db.database, database=runner.app.state.db.database,
app=runner.app,
) )
@@ -532,6 +533,105 @@ async def test_scheduler_rechecks_settings_before_sweeping(
await database.close() await database.close()
# --- the admin settings route is the scheduler's writer (task 7e173881) ------
#
# ``PATCH /api/v1/admin/settings`` is the only settings writer: it holds
# the scheduler's settings lock across the write and tells the scheduler,
# so a saved zone or rollover re-plans a sleeping scheduler.
async def _sleeping_scheduler(household: SimpleNamespace):
"""Swap in a scheduler on the pinned clock; return it and its delays.
Waits until it sleeps on its first plan.
"""
await household.app.state.sweep_scheduler.stop()
delays: list[float] = []
slept = asyncio.Condition()
async def sleep_stub(seconds: float) -> None:
async with slept:
delays.append(seconds)
slept.notify_all()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
household.database,
clock=lambda: household.clock["now"],
sleep=sleep_stub,
)
household.app.state.sweep_scheduler = scheduler
scheduler.start()
async def wait_for_sleeps(count: int) -> None:
async with slept:
await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == count), timeout=5
)
await wait_for_sleeps(1)
return scheduler, delays, wait_for_sleeps
@pytest.mark.parametrize(
("changes", "replanned_delay"),
[
# 13:00 UTC is 09:00 EDT: New York midnight is 15 hours away.
({"timezone": HOUSEHOLD_ZONE}, _NEW_YORK_MIDNIGHT_DELAY),
# Still UTC: 13:00 to a 14:30 rollover is 90 minutes.
({"day_rollover_time": "14:30"}, 90 * 60),
],
ids=["timezone", "day_rollover_time"],
)
async def test_admin_settings_patch_replans_a_sleeping_scheduler(
household: SimpleNamespace, changes: dict, replanned_delay: float
) -> None:
household.clock["now"] = _utc(13, 0)
scheduler, delays, wait_for_sleeps = await _sleeping_scheduler(household)
try:
response = await household.client.patch(
"/api/v1/admin/settings", json=changes, headers=_admin_headers()
)
assert response.status_code == 200
await wait_for_sleeps(2)
finally:
await scheduler.stop()
assert delays == [
pytest.approx(_UTC_MIDNIGHT_DELAY),
pytest.approx(replanned_delay),
]
async def test_admin_saved_zone_is_authoritative_across_surfaces(
household: SimpleNamespace,
) -> None:
"""ONE instant, read by every surface in exactly the admin's zone.
Asia/Kolkata (UTC+05:30) matches neither the UTC host nor any other
zone in play. 04:30:01 UTC is 10:00:01 there: the 10:00 quest is
overdue on both snapshots (it would not be in UTC, 04:30), the
household day is the 26th, and the scheduler sleeps the 299 s to a
10:05 rollover (UTC would be 5h35m).
"""
household.clock["now"] = _utc(4, 30, 1)
scheduler, delays, wait_for_sleeps = await _sleeping_scheduler(household)
try:
response = await household.client.patch(
"/api/v1/admin/settings",
json={"timezone": "Asia/Kolkata", "day_rollover_time": "10:05"},
headers=_admin_headers(),
)
assert response.status_code == 200
await wait_for_sleeps(2)
for plane in ("panel", "admin"):
today_iso, instance = await _pack_bag(household, plane)
assert today_iso == TODAY.isoformat(), plane
assert instance["overdue"] is True, plane
finally:
await scheduler.stop()
assert delays[1] == pytest.approx(299)
# --- settings writes are serialised with planning and sweeping (TZ-003) ------ # --- settings writes are serialised with planning and sweeping (TZ-003) ------
# #
# A zone write racing the loop's own settings reads must never leave a # A zone write racing the loop's own settings reads must never leave a
+31 -31
View File
@@ -1102,13 +1102,12 @@ 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 — overdue by the panel's pinned clock ``class="meta late"`` overdue — overdue as the API snapshot says."""
(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, due_time: str) -> dict: def quest(instance_id: int, title: str, overdue: bool) -> dict:
return { return {
"id": instance_id, "id": instance_id,
"definition_id": instance_id, "definition_id": instance_id,
@@ -1116,9 +1115,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": due_time, "due_time": "08:15",
"state": "open", "state": "open",
"overdue": False, "overdue": overdue,
"completed_at": None, "completed_at": None,
"on_time": None, "on_time": None,
} }
@@ -1131,8 +1130,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", "08:15"), quest(41, "Make Bed", overdue=False),
quest(42, "Brush Teeth", "07:00"), quest(42, "Brush Teeth", overdue=True),
], ],
), ),
} }
@@ -1140,8 +1139,6 @@ 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:]:
@@ -1175,19 +1172,22 @@ def _quest_metas(html: str) -> dict[str, tuple[str, str]]:
return metas return metas
def test_quest_log_overdue_follows_the_panel_local_clock() -> None: def test_quest_log_overdue_follows_the_api_snapshot_not_the_panel_clock() -> None:
"""Overdue is decided by the panel's own local clock, not the API's """The stored application timezone is the single source of local-time
``overdue`` flag. The owner saw 8:15 AM quests read OVERDUE at 7:15 AM truth: the API computes each instance's ``overdue`` in that zone and
America/New_York: the API's flag had been computed on a UTC wall clock the card renders exactly that, whatever the panel browser's own clock
(11:15). Here the API still says ``overdue: true`` and Home or zone says. Here the browser runs in Pacific/Auckland and its clock
Assistant reports ``time_zone`` UTC, yet at 07:15 local the 08:15 quest is pinned (and later ticked) to instants where every due time has, or
is due, at 08:15 (and 08:16, via the card's own clock tick) it is has not yet, arrived on its local wall clock — the card must still
overdue, and a quest with no due time is never overdue.""" show the snapshot's state: the ``overdue: true`` 08:15 quest reads
OVERDUE even though the browser reads long before 08:15, and the
``overdue: false`` 07:00 quest stays due even though the browser reads
long after 07:00."""
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, due_time: str | None) -> dict: def quest(instance_id: int, title: str, due_time: str, overdue: bool) -> dict:
return { return {
"id": instance_id, "id": instance_id,
"definition_id": instance_id, "definition_id": instance_id,
@@ -1197,7 +1197,7 @@ def test_quest_log_overdue_follows_the_panel_local_clock() -> None:
"window": "morning", "window": "morning",
"due_time": due_time, "due_time": due_time,
"state": "open", "state": "open",
"overdue": True, "overdue": overdue,
"completed_at": None, "completed_at": None,
"on_time": None, "on_time": None,
} }
@@ -1210,29 +1210,29 @@ def test_quest_log_overdue_follows_the_panel_local_clock() -> None:
child_id=1, child_id=1,
present=True, present=True,
instances=[ instances=[
quest(42, "Brush Teeth", "08:15"), quest(42, "Brush Teeth", "08:15", overdue=True),
quest(43, "Feed Fish", None), quest(43, "Feed Fish", "07:00", overdue=False),
], ],
), ),
} }
# 2026-09-29T17:00Z is 06:00 NZDT on the 30th: before both due times.
# 2026-09-29T23:00Z is 12:00 NZDT: after both.
result = _render_quest_log( result = _render_quest_log(
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", now="2026-09-29T17:00:00Z",
advance_to=["2026-09-29T12:15:00Z", "2026-09-29T12:16:00Z"], advance_to=["2026-09-29T23:00:00Z"],
tz="America/New_York", tz="Pacific/Auckland",
) )
due_by = ('class="meta"', "Due by 8:15 AM")
overdue = ('class="meta late"', "Overdue · due 8:15 AM") overdue = ('class="meta late"', "Overdue · due 8:15 AM")
not_due = ('class="meta"', "Due today") due_by = ('class="meta"', "Due by 7:00 AM")
at_0715, at_0815, at_0816 = ( before, (after,) = (
_quest_metas(result["html"]), _quest_metas(result["html"]),
*(_quest_metas(tick["html"]) for tick in result["ticks"]), [_quest_metas(tick["html"]) for tick in result["ticks"]],
) )
assert at_0715 == {"42": due_by, "43": not_due}, at_0715 assert before == {"42": overdue, "43": due_by}, before
assert at_0815 == {"42": overdue, "43": not_due}, at_0815 assert after == {"42": overdue, "43": due_by}, after
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: