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
+16 -3
View File
@@ -2330,12 +2330,25 @@ async def admin_update_settings(
every value has validated — so a rejected update never changes the
stored settings. Every core ``ValueError`` is 422
(: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
scheduler = getattr(request.app.state, "sweep_scheduler", None)
try:
settings = await core_settings_store.update_settings(
state.database, changes
)
if scheduler is None:
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:
_raise_settings_error(error)
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.
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`
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