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:
@@ -92,6 +92,7 @@ async def household(temp_db_path: str, monkeypatch) -> SimpleNamespace:
|
||||
seed=seed,
|
||||
clock=clock,
|
||||
database=runner.app.state.db.database,
|
||||
app=runner.app,
|
||||
)
|
||||
|
||||
|
||||
@@ -532,6 +533,105 @@ async def test_scheduler_rechecks_settings_before_sweeping(
|
||||
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) ------
|
||||
#
|
||||
# A zone write racing the loop's own settings reads must never leave a
|
||||
|
||||
+31
-31
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user