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 09:50:25 -04:00
co-authored by Claude Opus 5.5
parent 69db92b7b8
commit e5601811d6
6 changed files with 373 additions and 297 deletions
+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
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 — overdue by the panel's pinned clock
(07:15 local: the 07:00 quest is late, the 08:15 one is not)."""
``class="meta late"`` overdue — overdue as the API snapshot says."""
generated = _generate_dashboard(
{}, _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 {
"id": instance_id,
"definition_id": instance_id,
@@ -1116,9 +1115,9 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
"title": title,
"icon": None,
"window": "morning",
"due_time": due_time,
"due_time": "08:15",
"state": "open",
"overdue": False,
"overdue": overdue,
"completed_at": None,
"on_time": None,
}
@@ -1131,8 +1130,8 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
child_id=1,
present=True,
instances=[
quest(41, "Make Bed", "08:15"),
quest(42, "Brush Teeth", "07:00"),
quest(41, "Make Bed", overdue=False),
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],
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:]:
@@ -1175,19 +1172,22 @@ def _quest_metas(html: str) -> dict[str, tuple[str, str]]:
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."""
def test_quest_log_overdue_follows_the_api_snapshot_not_the_panel_clock() -> None:
"""The stored application timezone is the single source of local-time
truth: the API computes each instance's ``overdue`` in that zone and
the card renders exactly that, whatever the panel browser's own clock
or zone says. Here the browser runs in Pacific/Auckland and its clock
is pinned (and later ticked) to instants where every due time has, or
has not yet, arrived on its local wall clock — the card must still
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(
{}, _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 {
"id": instance_id,
"definition_id": instance_id,
@@ -1197,7 +1197,7 @@ def test_quest_log_overdue_follows_the_panel_local_clock() -> None:
"window": "morning",
"due_time": due_time,
"state": "open",
"overdue": True,
"overdue": overdue,
"completed_at": None,
"on_time": None,
}
@@ -1210,29 +1210,29 @@ def test_quest_log_overdue_follows_the_panel_local_clock() -> None:
child_id=1,
present=True,
instances=[
quest(42, "Brush Teeth", "08:15"),
quest(43, "Feed Fish", None),
quest(42, "Brush Teeth", "08:15", overdue=True),
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(
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",
now="2026-09-29T17:00:00Z",
advance_to=["2026-09-29T23:00:00Z"],
tz="Pacific/Auckland",
)
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 = (
due_by = ('class="meta"', "Due by 7:00 AM")
before, (after,) = (
_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 at_0815 == {"42": overdue, "43": not_due}, at_0815
assert at_0816 == {"42": overdue, "43": not_due}, at_0816
assert before == {"42": overdue, "43": due_by}, before
assert after == {"42": overdue, "43": due_by}, after
def test_quest_log_crest_matches_the_spec_geometry() -> None: