Remove every automatic timezone-setting mechanism (task 7f685179)

The application timezone is now only ever set by an admin saving it:

- API: drop adopt_reported_timezone; the panel household clock reads the
  stored settings only and ignores any timezone request header.
- Integration: stop reporting Home Assistant's time_zone in an
  X-NestQuest-Timezone header (snapshot, completion and SSE requests).
- Admin app: remove the browser-zone detection and the automatic PATCH
  on load; the timezone field stays a manual control.
- Remove the timezone_configured marker from the core settings, the
  settings store and the admin settings API. A stored document that
  still carries the key loads harmlessly (unknown keys are ignored).

Tests replace the adoption expectations with proofs that a panel
timezone header never changes the stored settings, a fresh install's
empty zone stays empty with no settings write, the admin settings carry
no timezone_configured, and a manual admin timezone PATCH persists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 07:00:06 -04:00
co-authored by Claude Opus 5.5
parent 6e25c9e42c
commit 5019b0cdc2
16 changed files with 282 additions and 546 deletions
+7 -12
View File
@@ -165,13 +165,12 @@ integration's Home-Assistant config-entry options remain a temporary,
SEPARATE source until Feature 18 wires the client to the API — the two
are deliberately not unified here:
- ``GET /settings`` returns the current effective settings: all twelve
- ``GET /settings`` returns the current effective settings: all eleven
documented fields (the rolling horizon, the day rollover time, the
notify target, the three notification times, the four enable
toggles, the ``timezone`` — the IANA household zone the
household-local dates derive from, empty meaning host local — and
``timezone_configured``, whether the admin explicitly chose that
zone) — the defaults on a fresh database.
toggles, and the ``timezone`` — the IANA household zone the
household-local dates derive from, empty meaning host local; only an
admin's ``PATCH`` ever sets it) — the defaults on a fresh database.
- ``PATCH /settings`` updates ONLY the supplied fields: the body is a
JSON object of settings field names to new values, passed STRAIGHT
to :func:`nestquest_core.settings_store.update_settings` — the
@@ -906,7 +905,7 @@ class AdminSettingsResponse(BaseModel):
"""The effective settings as the admin plane serializes them.
Mirrors the core :class:`~nestquest_core.settings.NestQuestSettings`
— all TWELVE documented fields, resolved (every absent stored field
— all ELEVEN documented fields, resolved (every absent stored field
already fell back to its default in the core). The same shape both
the GET and the PATCH route answer with.
"""
@@ -924,9 +923,6 @@ class AdminSettingsResponse(BaseModel):
#: The household IANA time zone the API reads its clock in; ``""``
#: means the API host's local time.
timezone: str
#: Whether the admin explicitly chose ``timezone`` (even ``""``);
#: ``False`` lets the admin UI auto-set the browser's zone once.
timezone_configured: bool
class AdminMissedSweepResponse(BaseModel):
@@ -2183,7 +2179,7 @@ def _settings_response(
"""Serialize one core NestQuestSettings into the documented payload.
Explicit field-by-field (not ``dataclasses.asdict``) so the payload
stays pinned to the twelve documented fields even if the core
stays pinned to the eleven documented fields even if the core
dataclass later grows another one.
"""
return AdminSettingsResponse(
@@ -2198,7 +2194,6 @@ def _settings_response(
end_of_day_report_enabled=settings.end_of_day_report_enabled,
celebration_enabled=settings.celebration_enabled,
timezone=settings.timezone,
timezone_configured=settings.timezone_configured,
)
@@ -2296,7 +2291,7 @@ async def admin_history_csv(
response_model=AdminSettingsResponse,
)
async def admin_get_settings(request: Request) -> AdminSettingsResponse:
"""Return the current effective settings (all twelve documented fields).
"""Return the current effective settings (all eleven documented fields).
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
+11 -45
View File
@@ -9,9 +9,8 @@ this router inherits the check).
The snapshot route is a thin adapter over the bundled core: it performs
NO business logic. It reads the live database off ``app.state.db``,
resolves ONE timezone-aware ``now`` (the host clock re-expressed in the
household ``timezone`` — the API host may run UTC while the household
does not; until the admin chooses a zone it is the Home Assistant zone
the integration reports in :data:`PANEL_TIMEZONE_HEADER`), calls the pure
household ``timezone`` the admin stored — the API host may run UTC
while the household does not), calls the pure
:func:`nestquest_core.snapshot.build_snapshot`, shapes each child's
instances through :func:`nestquest_core.snapshot.instance_payload` with
``include_missed=False`` (D-009: missed instances are OMITTED from the
@@ -54,7 +53,6 @@ from __future__ import annotations
import datetime
import importlib
import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
@@ -64,7 +62,6 @@ from api.database import DatabaseState
from api.dependencies import require_panel_token
from api.nestquest_core import (
core_completion,
core_const,
core_events,
core_settings_store,
core_snapshot,
@@ -72,12 +69,6 @@ from api.nestquest_core import (
from api import transitions as api_transitions
from api.sse import transition_event_stream
LOGGER = logging.getLogger(__name__)
#: The request header the integration reports Home Assistant's
#: configured IANA ``time_zone`` in.
PANEL_TIMEZONE_HEADER = core_const.PANEL_TIMEZONE_HEADER
#: The instance DAO, imported on the app's core copy for the
#: child-mismatch guard on the complete route.
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
@@ -188,42 +179,17 @@ def _local_now() -> datetime.datetime:
async def _household_clock(
database: object, request: Request
database: object,
) -> tuple[object, datetime.datetime]:
"""Return the settings and ONE household-local clock read.
"""Return the stored settings and ONE household-local clock read.
The API host may run UTC while the household does not, so the one
:func:`_local_now` read is re-expressed in the settings' ``timezone``
(:meth:`NestQuestSettings.household_now`). The integration reports
Home Assistant's ``time_zone`` in :data:`PANEL_TIMEZONE_HEADER`;
until the admin chooses a zone it is adopted and persisted
(:func:`nestquest_core.settings_store.adopt_reported_timezone`), so
the admin plane follows it too, and the already-sleeping missed-sweep
scheduler is told (:meth:`MissedSweepScheduler.settings_changed`) so
it re-plans for the household rollover — under the scheduler's
``settings_lock``, so the adoption never lands between its settings
check and its sweep. An invalid
reported zone is logged and ignored — the panel keeps serving on
the stored settings.
:func:`_local_now` read is re-expressed in the stored settings'
``timezone`` (:meth:`NestQuestSettings.household_now`). The zone
comes from the stored settings ONLY — nothing the request carries
can change it.
"""
reported = request.headers.get(PANEL_TIMEZONE_HEADER, "").strip()
if reported:
scheduler = request.app.state.sweep_scheduler
try:
async with scheduler.settings_lock:
settings = await core_settings_store.adopt_reported_timezone(
database, reported
)
scheduler.settings_changed(settings)
except ValueError:
LOGGER.warning(
"Ignoring invalid %s header value %r",
PANEL_TIMEZONE_HEADER,
reported,
)
settings = await core_settings_store.load_settings(database)
else:
settings = await core_settings_store.load_settings(database)
settings = await core_settings_store.load_settings(database)
return settings, settings.household_now(_local_now())
@@ -245,7 +211,7 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
:class:`PanelSnapshotResponse`.
"""
state: DatabaseState = request.app.state.db
settings, now = await _household_clock(state.database, request)
settings, now = await _household_clock(state.database)
snapshot = await core_snapshot.build_snapshot(
state.database, settings, now
)
@@ -332,7 +298,7 @@ async def panel_complete_instance(
detail="Quest instance not found for this child",
)
_settings, now = await _household_clock(database, request)
_settings, now = await _household_clock(database)
try:
result = await core_completion.complete_instance(
database,
+10 -10
View File
@@ -13,7 +13,7 @@ local date when unset) and publishes each returned
``nestquest_quest_missed`` event on the app's ONE transition publisher
(the same SSE stream every route publishes through).
A household zone adopted from Home Assistant while the loop is asleep
A household zone changed while the loop is asleep
must not leave it waiting for the OLD zone's rollover (a UTC host would
otherwise sweep at UTC midnight — 20:00 in New York — marking quests
missed hours early). :meth:`MissedSweepScheduler.settings_changed`
@@ -21,17 +21,17 @@ wakes the sleeping loop to re-plan whenever the zone or rollover time
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.
Guarantee: no sweep runs for a plan a committed adoption has replaced.
An adopting writer holds :attr:`MissedSweepScheduler.settings_lock`
Guarantee: no sweep runs for a plan a committed settings write has
replaced. A settings writer holds :attr:`MissedSweepScheduler.settings_lock`
across its write AND its :meth:`~MissedSweepScheduler.settings_changed`
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 sweep itself. So an adoption commits either before a plan read
the sweep itself. So a write commits either before a plan read
(which then sees it), after one (``settings_changed`` compares against
that exact plan and wakes the wait), or after the sweep — never between
a read and what the loop does with it. The lock is never held across
the wait, and the loop takes nothing else before it, so a waiting
panel request cannot deadlock: it is delayed at most by one settings
writer cannot deadlock: it is delayed at most by one settings
read or one sweep.
All sweep policy lives in the core (the watermark, the
@@ -150,7 +150,7 @@ class MissedSweepScheduler:
self._planned: tuple[str, str] | None = None
#: Set by :meth:`settings_changed` to cut the current wait short.
self._wake = asyncio.Event()
#: Serialises settings adoption with planning and sweeping (see
#: Serialises settings writes with planning and sweeping (see
#: the module docstring's guarantee).
self.settings_lock = asyncio.Lock()
@@ -174,8 +174,8 @@ class MissedSweepScheduler:
def settings_changed(self, settings: object) -> None:
"""Re-plan the pending sweep if ``settings`` moved its rollover.
Called with freshly stored settings (e.g. a zone adopted from
Home Assistant), with :attr:`settings_lock` held across the write
Called with freshly stored settings (e.g. an admin's zone
change), with :attr:`settings_lock` held across the write
and this call so the comparison is against the loop's current
plan, never a read still in flight. A differing ``timezone`` or
``day_rollover_time`` wakes the sleeping loop, which reloads the
@@ -194,7 +194,7 @@ class MissedSweepScheduler:
"""Schedule → sweep → repeat, forever (until cancelled)."""
while True:
try:
# Under the lock no adoption can commit between this read
# Under the lock no settings write can commit between this read
# and the recorded plan, so any later one wakes the wait.
async with self.settings_lock:
self._wake.clear()
@@ -233,7 +233,7 @@ class MissedSweepScheduler:
# Re-read before sweeping: a zone or rollover change the
# wake-up missed means this is not the household's
# rollover any more, so re-plan instead of sweeping. The
# lock keeps an adoption from committing between this
# lock keeps a settings write from committing between this
# check and the sweep.
async with self.settings_lock:
settings = await core_settings_store.load_settings(