Fix premature OVERDUE on the API and the Complete tap doing nothing
Overdue: the API read the host clock (the deployed container runs UTC, the household is US Eastern), so every morning quest turned overdue from ~04:00 local, and the snapshot routes built a default settings object instead of the stored one. Add an additive `timezone` setting (IANA name; "" keeps the host-local behaviour), validated through the shared settings field table and exposed on the admin settings GET/PATCH. Every API clock read (panel/admin routes, the missed-sweep scheduler) now re-expresses the one host read in that zone, and the snapshot routes load the persisted settings. Complete: with target ES2022, TypeScript's useDefineForClassFields default emitted the cards' reactive fields as native class fields that shadow Lit's accessors, so setting `_confirm` never re-rendered and the confirm dialog never opened. Set useDefineForClassFields to false (Lit's documented TypeScript setting) and rebuild the bundle. Tests pin the clock at the due-time boundary for both snapshot shapes, cover the scheduler and on-time stamp, and drive the built bundle's Complete tap through to the complete_quest service call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+41
-13
@@ -867,7 +867,7 @@ class AdminSettingsResponse(BaseModel):
|
||||
"""The effective settings as the admin plane serializes them.
|
||||
|
||||
Mirrors the core :class:`~nestquest_core.settings.NestQuestSettings`
|
||||
— all TEN 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.
|
||||
"""
|
||||
@@ -882,6 +882,9 @@ class AdminSettingsResponse(BaseModel):
|
||||
afternoon_reminder_enabled: bool
|
||||
end_of_day_report_enabled: bool
|
||||
celebration_enabled: bool
|
||||
#: The household IANA time zone the API reads its clock in; ``""``
|
||||
#: means the API host's local time.
|
||||
timezone: str
|
||||
|
||||
|
||||
class AdminMissedSweepResponse(BaseModel):
|
||||
@@ -1178,6 +1181,26 @@ def _local_now() -> datetime.datetime:
|
||||
return datetime.datetime.now().astimezone()
|
||||
|
||||
|
||||
async def _household_clock(
|
||||
database: object,
|
||||
) -> tuple[object, datetime.datetime]:
|
||||
"""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 stored settings'
|
||||
``timezone`` (:meth:`NestQuestSettings.household_now`); an unset
|
||||
zone keeps the host's local time.
|
||||
"""
|
||||
settings = await core_settings_store.load_settings(database)
|
||||
return settings, settings.household_now(_local_now())
|
||||
|
||||
|
||||
async def _household_today(database: object) -> datetime.date:
|
||||
"""Return the household-local date of ONE :func:`_household_clock` read."""
|
||||
_settings, now = await _household_clock(database)
|
||||
return now.date()
|
||||
|
||||
|
||||
def _rule_from_request(rule: AdminRuleRequest) -> core_recurrence.ScheduleRule:
|
||||
"""Build the core ScheduleRule from the request's ``rule`` object.
|
||||
|
||||
@@ -1496,7 +1519,7 @@ async def admin_create_quest_definition(
|
||||
response_model=AdminOccurrencesPreviewResponse,
|
||||
)
|
||||
async def admin_preview_occurrences(
|
||||
body: AdminOccurrencesPreviewRequest,
|
||||
body: AdminOccurrencesPreviewRequest, request: Request
|
||||
) -> AdminOccurrencesPreviewResponse:
|
||||
"""Return the first ``count`` dates the supplied rule fires on.
|
||||
|
||||
@@ -1509,12 +1532,15 @@ async def admin_preview_occurrences(
|
||||
call over the bounded window ``[start_date, start_date + 366
|
||||
days]`` — ``start_date`` defaulting to ONE :func:`_local_now` clock
|
||||
read — truncated to ``count``. Every core ``ValueError`` is 422
|
||||
(:func:`_raise_preview_error`). No database access.
|
||||
(:func:`_raise_preview_error`). The only database access is the
|
||||
settings read behind the household clock.
|
||||
"""
|
||||
start_date = (
|
||||
body.start_date
|
||||
if body.start_date is not None
|
||||
else _local_now().date().isoformat()
|
||||
else (
|
||||
await _household_today(request.app.state.db.database)
|
||||
).isoformat()
|
||||
)
|
||||
try:
|
||||
rule = _rule_from_request(body.rule)
|
||||
@@ -1839,7 +1865,7 @@ async def admin_presence_override_consequence(
|
||||
body.start_date,
|
||||
body.end_date,
|
||||
body.is_present,
|
||||
today=_local_now().date(),
|
||||
today=await _household_today(state.database),
|
||||
)
|
||||
)
|
||||
except ValueError as error:
|
||||
@@ -1926,7 +1952,7 @@ async def admin_uncomplete_instance(
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
database = state.database
|
||||
now = _local_now()
|
||||
_settings, now = await _household_clock(database)
|
||||
sub = claims.get("sub")
|
||||
try:
|
||||
result = await core_completion.uncomplete_instance(
|
||||
@@ -1992,7 +2018,7 @@ async def admin_regenerate(
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
database = state.database
|
||||
today = _local_now().date()
|
||||
today = await _household_today(state.database)
|
||||
|
||||
if body.scope == "household":
|
||||
end_date = today + datetime.timedelta(
|
||||
@@ -2077,7 +2103,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 ten documented fields even if the core
|
||||
stays pinned to the eleven documented fields even if the core
|
||||
dataclass later grows another one.
|
||||
"""
|
||||
return AdminSettingsResponse(
|
||||
@@ -2091,6 +2117,7 @@ def _settings_response(
|
||||
afternoon_reminder_enabled=settings.afternoon_reminder_enabled,
|
||||
end_of_day_report_enabled=settings.end_of_day_report_enabled,
|
||||
celebration_enabled=settings.celebration_enabled,
|
||||
timezone=settings.timezone,
|
||||
)
|
||||
|
||||
|
||||
@@ -2122,7 +2149,7 @@ async def admin_history_query(
|
||||
``ValueError`` is 422 (:func:`_raise_history_error`).
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
today = _local_now().date()
|
||||
today = await _household_today(state.database)
|
||||
try:
|
||||
rows = await core_history.query_history(
|
||||
state.database, filter, start, end, today=today
|
||||
@@ -2165,7 +2192,7 @@ async def admin_history_csv(
|
||||
JSON route's (:func:`_raise_history_error`).
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
today = _local_now().date()
|
||||
today = await _household_today(state.database)
|
||||
try:
|
||||
csv_text = await core_history.export_history_csv(
|
||||
state.database, filter, start, end, today=today
|
||||
@@ -2188,7 +2215,7 @@ async def admin_history_csv(
|
||||
response_model=AdminSettingsResponse,
|
||||
)
|
||||
async def admin_get_settings(request: Request) -> AdminSettingsResponse:
|
||||
"""Return the current effective settings (all ten 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
|
||||
@@ -2280,7 +2307,7 @@ async def admin_run_missed_sweep(
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
database = state.database
|
||||
today = _local_now().date()
|
||||
today = await _household_today(state.database)
|
||||
try:
|
||||
events = await core_sweep.run_missed_sweep(database, today=today)
|
||||
except ValueError as error:
|
||||
@@ -2309,8 +2336,9 @@ async def admin_snapshot(request: Request) -> AdminSnapshotResponse:
|
||||
:class:`AdminSnapshotResponse`.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
settings, now = await _household_clock(state.database)
|
||||
snapshot = await core_snapshot.build_snapshot(
|
||||
state.database, core_settings.NestQuestSettings(), _local_now()
|
||||
state.database, settings, now
|
||||
)
|
||||
return AdminSnapshotResponse(
|
||||
today_iso=snapshot.today_iso,
|
||||
|
||||
+21
-6
@@ -8,9 +8,9 @@ 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 API host's local time — the
|
||||
API is the household's "local" host now that the domain no longer lives
|
||||
inside Home Assistant), calls the pure
|
||||
resolves ONE timezone-aware ``now`` (the host clock re-expressed in the
|
||||
stored household ``timezone`` setting — 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
|
||||
@@ -63,7 +63,7 @@ from api.dependencies import require_panel_token
|
||||
from api.nestquest_core import (
|
||||
core_completion,
|
||||
core_events,
|
||||
core_settings,
|
||||
core_settings_store,
|
||||
core_snapshot,
|
||||
)
|
||||
from api import transitions as api_transitions
|
||||
@@ -178,6 +178,20 @@ def _local_now() -> datetime.datetime:
|
||||
return datetime.datetime.now().astimezone()
|
||||
|
||||
|
||||
async def _household_clock(
|
||||
database: object,
|
||||
) -> tuple[object, datetime.datetime]:
|
||||
"""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 stored settings'
|
||||
``timezone`` (:meth:`NestQuestSettings.household_now`); an unset
|
||||
zone keeps the host's local time.
|
||||
"""
|
||||
settings = await core_settings_store.load_settings(database)
|
||||
return settings, settings.household_now(_local_now())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/snapshot",
|
||||
summary="Today's household snapshot",
|
||||
@@ -196,8 +210,9 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
|
||||
:class:`PanelSnapshotResponse`.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
settings, now = await _household_clock(state.database)
|
||||
snapshot = await core_snapshot.build_snapshot(
|
||||
state.database, core_settings.NestQuestSettings(), _local_now()
|
||||
state.database, settings, now
|
||||
)
|
||||
return PanelSnapshotResponse(
|
||||
today_iso=snapshot.today_iso,
|
||||
@@ -282,7 +297,7 @@ async def panel_complete_instance(
|
||||
detail="Quest instance not found for this child",
|
||||
)
|
||||
|
||||
now = _local_now()
|
||||
_settings, now = await _household_clock(database)
|
||||
try:
|
||||
result = await core_completion.complete_instance(
|
||||
database,
|
||||
|
||||
+10
-3
@@ -8,7 +8,8 @@ configured ``day_rollover_time`` — read fresh from the settings store
|
||||
at the top of EVERY cycle, so an admin's settings change applies on
|
||||
the next scheduling decision without a restart — then runs the
|
||||
HA-free core's :func:`nestquest_core.sweep.run_missed_sweep` for the
|
||||
API host's local date and publishes each returned
|
||||
household-local date (the stored ``timezone`` setting; the API host's
|
||||
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).
|
||||
|
||||
@@ -130,7 +131,11 @@ class MissedSweepScheduler:
|
||||
)
|
||||
await self._sleep(_RETRY_BACKOFF_SECONDS)
|
||||
continue
|
||||
delay = _delay_seconds(rollover, self._clock())
|
||||
# The host may run UTC: the rollover is a household wall-clock
|
||||
# time, so the clock read is re-expressed in the stored zone.
|
||||
delay = _delay_seconds(
|
||||
rollover, settings.household_now(self._clock())
|
||||
)
|
||||
LOGGER.info(
|
||||
"NestQuest missed sweep scheduled for %s (%.0fs from now)",
|
||||
settings.day_rollover_time,
|
||||
@@ -138,7 +143,9 @@ class MissedSweepScheduler:
|
||||
)
|
||||
await self._sleep(delay)
|
||||
try:
|
||||
await self._run_once(self._clock().date())
|
||||
await self._run_once(
|
||||
settings.household_now(self._clock()).date()
|
||||
)
|
||||
except Exception:
|
||||
# Not silent either: the sweep error is logged and the
|
||||
# loop backs off before the next cycle, whose watermark
|
||||
|
||||
Reference in New Issue
Block a user