Format the panel and admin date and clock in the stored application timezone

The panel snapshot now carries the admin-saved `timezone` ("" when unset);
the coordinator threads it onto the household due-today sensor, and the
Party Board and Quest Log cards format their kicker, header and dock
date and clock in it, falling back to Home Assistant's zone only when
none is stored. The admin app loads the stored zone once
(AppTimezoneProvider) and uses it for completion clocks, History day
grouping, clocks and lateness, and "today" in Schedule, History and new
task defaults; Settings publishes a saved zone app-wide at once.

Resolves review finding TZ-001 (task 7e173881).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 08:17:26 -04:00
co-authored by Claude Opus 5.5
parent b3542b0551
commit bb748c3647
40 changed files with 910 additions and 126 deletions
+1
View File
@@ -910,6 +910,7 @@ def coordinator_client_from_test_override():
{
"today_iso": datetime.date.today().isoformat(),
"cycle_day": 0,
"timezone": "",
"children": [],
}
)
+4 -2
View File
@@ -165,7 +165,9 @@ async def test_admin_jwt_returns_documented_snapshot(
Every field is asserted as an explicit literal, with ONE clock
read for the request. With no missed view in the real snapshot,
the admin body equals the panel body for the same household.
the admin body equals the panel body for the same household — the
panel body additionally carries the stored ``timezone`` its cards
format the visible date and clock in (unset here).
"""
client = snapshot_household.client
seed = snapshot_household.seed
@@ -241,7 +243,7 @@ async def test_admin_jwt_returns_documented_snapshot(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
assert panel.status_code == 200
assert panel.json() == body
assert panel.json() == {**body, "timezone": ""}
async def test_admin_keeps_missed_instance_panel_omits_it(
+18
View File
@@ -632,6 +632,24 @@ async def test_admin_saved_zone_is_authoritative_across_surfaces(
assert delays[1] == pytest.approx(299)
async def test_panel_snapshot_publishes_the_stored_zone(
household: SimpleNamespace,
) -> None:
"""The panel snapshot carries the stored application zone verbatim so
the cards format their visible date and clock in it — ``""`` until the
admin saves one, then exactly the saved zone."""
async def panel_zone() -> str:
response = await household.client.get(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
assert response.status_code == 200
return response.json()["timezone"]
assert await panel_zone() == ""
await _set_zone(household, "Pacific/Auckland")
assert await panel_zone() == "Pacific/Auckland"
# --- settings writes are serialised with planning and sweeping (TZ-003) ------
#
# A zone write racing the loop's own settings reads must never leave a
+1
View File
@@ -60,6 +60,7 @@ def _payload(children) -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": 0,
"timezone": "",
"children": list(children),
}
+1
View File
@@ -55,6 +55,7 @@ def _empty_snapshot() -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": 0,
"timezone": "",
"children": [],
}
+7 -1
View File
@@ -93,6 +93,7 @@ class FakeMonotonic:
FIXTURE_SNAPSHOT = {
"today_iso": _today_iso(),
"cycle_day": 3,
"timezone": "",
"children": [
{
"child_id": 1,
@@ -793,7 +794,12 @@ async def test_snapshot_from_api_payload_empty_household() -> None:
"""An empty household (no children) is a valid snapshot: empty
children tuple, cycle day passes through."""
snapshot = snapshot_from_api_payload(
{"today_iso": "2026-09-23", "cycle_day": 0, "children": []}
{
"today_iso": "2026-09-23",
"cycle_day": 0,
"timezone": "",
"children": [],
}
)
assert snapshot.children == ()
assert snapshot.cycle_day == 0
+1
View File
@@ -332,6 +332,7 @@ async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> Non
return {
"today_iso": built.today_iso,
"cycle_day": built.cycle_day,
"timezone": built.timezone,
"children": [
{
"child_id": child.child_id,
+1
View File
@@ -67,6 +67,7 @@ def _payload(children) -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": 0,
"timezone": "",
"children": list(children),
}
+1
View File
@@ -76,6 +76,7 @@ def _payload(children, cycle_day=0) -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": cycle_day,
"timezone": "",
"children": list(children),
}
+1
View File
@@ -70,6 +70,7 @@ def _payload(children) -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": 0,
"timezone": "",
"children": list(children),
}
+73 -1
View File
@@ -567,6 +567,8 @@ def _render_party_board(
states: dict,
url: str | None = None,
click_plate: str | None = None,
now: str | None = None,
tz: str | None = None,
) -> dict:
"""Render the built bundle's party board and return its plates.
@@ -575,7 +577,9 @@ def _render_party_board(
mounts the card with the given config and hass snapshot, optionally
taps the named plate, and prints the ordered plates its shadow DOM
renders, which of them are tappable buttons, and the pathname after
the optional tap.
the optional tap. ``now`` pins the card's clock to an ISO instant
and ``tz`` sets the node process's ``TZ`` — the panel browser's
local zone.
"""
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
assert RENDER_HARNESS.is_file()
@@ -589,12 +593,15 @@ def _render_party_board(
spec["url"] = url
if click_plate is not None:
spec["click_plate"] = click_plate
if now is not None:
spec["now"] = now
completed = subprocess.run(
["node", str(RENDER_HARNESS)],
input=json.dumps(spec),
capture_output=True,
text=True,
timeout=120,
env={**os.environ, "TZ": tz} if tz is not None else None,
)
assert completed.returncode == 0, (
f"render harness failed: {completed.stderr}"
@@ -1235,6 +1242,71 @@ def test_quest_log_overdue_follows_the_api_snapshot_not_the_panel_clock() -> Non
assert after == {"42": overdue, "43": due_by}, after
def test_panel_date_and_clock_follow_the_stored_application_timezone() -> None:
"""The stored application timezone is the single source of local-time
truth for the panel's visible date and clock too: the household rollup
publishes it (``timezone``) and both cards format in it — the party
board's kicker and dock date and clock, the quest log's header date —
even though Home Assistant's zone and the panel browser's zone are both
UTC. At 2026-09-29T23:30Z it is still Tuesday 11:30 PM in UTC but
already Wednesday 12:30 PM in Pacific/Auckland (NZDT)."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
states = _three_child_states()
states["sensor.nestquest_household_quests_due_today"]["attributes"][
"timezone"
] = "Pacific/Auckland"
now = "2026-09-29T23:30:00Z"
def text(html: str) -> str:
return re.sub(r"<!--.*?-->", "", html).replace("\u202f", " ")
def board_date_and_clock(html: str) -> tuple[str, str, str]:
found = re.search(
r'<p class="kicker">The Party · (.*?)</p>.*?'
r'<span class="date">(.*?)</span>.*?<span class="clock">(.*?)</span>',
text(html),
re.DOTALL,
)
assert found is not None, html
return found.group(1), found.group(2), found.group(3)
board = _render_party_board(
generated["views"][0]["cards"][0], states, now=now, tz="UTC"
)["html"]
assert board_date_and_clock(board) == (
"Wednesday, Sep 30",
"Wednesday, Sep 30",
"12:30 PM",
)
log = _render_quest_log(
generated["views"][1]["cards"][0],
states,
url="http://homeassistant.local/nestquest/ada",
now=now,
tz="UTC",
)["html"]
sub = re.search(r'<p class="sub">\s*(.*?) ·', text(log), re.DOTALL)
assert sub is not None, log
assert sub.group(1) == "Wednesday, Sep 30"
# With no stored zone the documented fallback applies: Home
# Assistant's zone (UTC here) formats the same instant.
del states["sensor.nestquest_household_quests_due_today"]["attributes"][
"timezone"
]
fallback = _render_party_board(
generated["views"][0]["cards"][0], states, now=now, tz="UTC"
)["html"]
assert board_date_and_clock(fallback) == (
"Tuesday, Sep 29",
"Tuesday, Sep 29",
"11:30 PM",
)
def test_quest_log_crest_matches_the_spec_geometry() -> None:
"""PANEL-SPEC §3 header crest: 96x110 shield, 4px ring of
rgba(255,255,255,.22) around the --nq-p-crest face, 44px/700 display
+17
View File
@@ -76,6 +76,7 @@ def _payload(children) -> dict:
return {
"today_iso": _today_iso(),
"cycle_day": 0,
"timezone": "",
"children": list(children),
}
@@ -569,6 +570,22 @@ async def test_household_due_sensor_publishes_the_ordered_child_roster(
}
async def test_household_due_sensor_publishes_the_stored_zone(
hass, make_entry
) -> None:
"""The rollup carries the snapshot's stored application zone verbatim
(the panel cards format their date and clock in it); ``""`` — no zone
stored — passes through unchanged for the cards' documented fallback."""
zoned = {**_seeded_payload(), "timezone": "Pacific/Auckland"}
entry, coordinator, _client = await _setup_entry(
hass, make_entry, zoned, _seeded_payload()
)
rollup = hass.entities["nestquest_household_quests_due_today"]
assert rollup.extra_state_attributes["timezone"] == "Pacific/Auckland"
await coordinator.async_refresh()
assert rollup.extra_state_attributes["timezone"] == ""
async def test_household_roster_tracks_the_snapshot_on_refresh(
hass, make_entry
) -> None:
+6 -1
View File
@@ -151,7 +151,12 @@ DOCUMENTED_FIELDS = {
#: A minimal panel snapshot payload for the full-setup wiring tests
#: (the shape ``snapshot_from_api_payload`` consumes; no children).
MINIMAL_SNAPSHOT = {"today_iso": "2026-09-23", "cycle_day": 1, "children": []}
MINIMAL_SNAPSHOT = {
"today_iso": "2026-09-23",
"cycle_day": 1,
"timezone": "",
"children": [],
}
# ---------------------------------------------------------------------------