Document the manual, admin-set application timezone (task e9aa78f3) (#286)

Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #286.
This commit is contained in:
2026-09-30 11:13:24 -04:00
committed by approver_agent
parent 0e726ac64f
commit 52895199be
16 changed files with 114 additions and 74 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ snapshot (the same ChildDaySnapshot the day sensors read):
celebration automation must never fire on an empty day (the
day-complete event carries the same rule, Feature 10 task 7).
- ``binary_sensor.nestquest_<child>_present_today`` — ON when the
presence engine resolves the child present on the HA-local date
presence engine resolves the child present on the household-local date
(schedules and overrides, D-004 anchor-date arithmetic).
Naming and registry identity follow the day sensors' contract: names
+11 -11
View File
@@ -10,7 +10,7 @@ un-completing (no-op if already open) is :func:`uncomplete_instance`.
Instance current state is derived from the latest event via
:func:`instance_state` (``open`` / ``done`` / ``missed``); there is
no status column. ``missed`` is derived for past-due open instances
(``due_date`` before HA-local ``today``) and is never stored as an
(``due_date`` before household-local ``today``) and is never stored as an
event_type.
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
@@ -71,10 +71,10 @@ def _completion_moment(
now: datetime.datetime | None,
today: datetime.date | None,
) -> tuple[datetime.date, datetime.time | None]:
"""Return the HA-local completion date and optional wall-clock time.
"""Return the household-local completion date and optional wall-clock time.
``today`` is the HA-local calendar date when threaded (Feature 07
style). ``now`` is a timezone-aware HA-local datetime; its wall
``today`` is the household-local calendar date when threaded (Feature 07
style). ``now`` is a timezone-aware household-local datetime; its wall
clock is used as-is and is never converted to UTC. When ``today``
is omitted the date comes from ``now``. When both are omitted the
host calendar date is the Feature 07 fallback — never the UTC
@@ -98,7 +98,7 @@ def _completion_moment(
def _resolve_today(today: datetime.date | None) -> datetime.date:
"""Return the threaded HA-local date, or the host calendar date."""
"""Return the threaded household-local date, or the host calendar date."""
if today is None:
return datetime.date.today()
return today
@@ -138,7 +138,7 @@ def derive_state(
Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or
``missed`` from the latest event plus the due date (``missed`` is
derived, never an event_type). ``today`` is the caller-resolved
HA-local date.
household-local date.
"""
return _derived_state(instance, latest, today)
@@ -345,7 +345,7 @@ async def instance_state(
"""Return ``open``, ``done``, or ``missed`` from the latest event.
No events, or a latest event of uncompleted, is ``open`` unless
``due_date`` is before ``today`` (HA-local, threaded), in which
``due_date`` is before ``today`` (household-local, threaded), in which
case it is ``missed``. A latest event of completed is ``done``
even when past due. ``missed`` is never stored as an event_type.
Unknown ``instance_id`` raises ValueError naming the field.
@@ -414,9 +414,9 @@ async def complete_instance(
latest-event, and the append run under one connection lock so a
concurrent complete cannot double-append.
``was_on_time`` is computed from the HA-local completion moment
``was_on_time`` is computed from the household-local completion moment
(threaded ``now`` / ``today``) against the instance ``due_date``
and optional ``due_time``. ``now`` is the timezone-aware HA-local
and optional ``due_time``. ``now`` is the timezone-aware household-local
datetime; its wall clock is never converted to UTC. Same-day
``due_time`` comparison requires ``now``.
"""
@@ -471,7 +471,7 @@ async def uncomplete_instance(
"""Append an uncompleted event unless the instance is already open.
Returns the derived state ``open``, or ``missed`` when the instance
is past due (``due_date`` before HA-local ``today``); ``appended``
is past due (``due_date`` before household-local ``today``); ``appended``
reports whether THIS call appended (False on an already-open
no-op). Unknown ``instance_id`` raises ValueError naming the
field. Existence, latest-event, and the append run under one
@@ -529,7 +529,7 @@ async def list_missed_for_child(
"""Return the child's missed instances due in the closed date range.
Missed means the latest event state is open and ``due_date`` is
before ``today`` (HA-local, threaded). Uses
before ``today`` (household-local, threaded). Uses
:meth:`QuestInstancesDao.list_by_date_range` plus derived state.
Never appends a missed event. Instances and latest events are
read inside the connection lock so the list is one snapshot.
+6 -2
View File
@@ -115,8 +115,12 @@ DEFAULT_AUTOMATION_ENABLED = True
# The household's IANA time zone (e.g. ``America/New_York``) the API
# service reads its clock in. The empty string is the API host's own
# local time. Only an admin sets it (manually, from the admin app);
# nothing sets it automatically. The integration itself always uses
# Home Assistant's ``time_zone`` and ignores this field.
# nothing sets it automatically. The integration relays the API's
# effective application timezone (the coordinator carries it and the
# household sensor publishes it for the panel cards) and never derives
# the application clock from Home Assistant's configuration; the Home
# Assistant / browser zone applies only when the API publishes no zone
# (e.g. an older API).
CONF_TIMEZONE = "timezone"
DEFAULT_TIMEZONE = ""
@@ -68,7 +68,7 @@ def _resolve_today(today: datetime.date | None) -> datetime.date:
around midnight, so when the caller has already resolved the local
date (``today``) that anchor is authoritative; ``None`` falls back to
the host clock (:func:`_today`), which is exactly the previous
behavior for callers with no HA-local date.
behavior for callers with no household-local date.
"""
return _today() if today is None else today
@@ -225,7 +225,7 @@ class QuestInstancesDao:
- ``due_date`` must be TODAY or later: instances are never
generated in the past (Feature 07). The caller's
materialization horizon is future-dated by definition.
``today`` optionally pins the resolved HA-local date the
``today`` optionally pins the resolved household-local date the
guard compares against (falling back to the host clock when
omitted) so a household time zone behind the host around
midnight is not misread as "past".
@@ -248,7 +248,7 @@ class QuestInstancesDao:
# check runs after the lock is acquired, because a caller can
# wait on the lock across midnight — a date that was "today"
# at call time may be "yesterday" by the time the INSERT runs.
# A caller-supplied ``today`` (HA-local) is used for BOTH
# A caller-supplied ``today`` (household-local) is used for BOTH
# checks and never re-read, since it is already the anchor the
# batch runs against.
today_date = _resolve_today(today)
@@ -257,7 +257,7 @@ class QuestInstancesDao:
async with self._database.transaction():
# Re-read "today" under the lock: this is the date the
# insert actually executes on, so the no-past rule
# holds across midnight rollovers. A pinned HA-local
# holds across midnight rollovers. A pinned household-local
# anchor is exempt from the re-read (see above).
if today is None:
today_date = _today()
@@ -364,7 +364,7 @@ class QuestInstancesDao:
rather than raising, so the rest of the batch is unaffected. The
no-past rule (instances are never generated in the past) is
preserved from :meth:`upsert` and still raises ValueError; ``today``
optionally pins the HA-local anchor it compares against. An
optionally pins the household-local anchor it compares against. An
instance that ALREADY has a completion event is also skipped
(returns ``None``) rather than raised: it is immutable, so the
walk leaves it untouched instead of failing the batch — the
@@ -504,7 +504,7 @@ class QuestInstancesDao:
append-only contract: an instance that was ever completed is
never reported missed, even when later reversed — history keeps
both rows and the sweep never mutates any). The watermark is
the HA-local date the sweep last ran, and the window is
the household-local date the sweep last ran, and the window is
``[watermark, today)``: the nightly run entering day D+1 sweeps
exactly day D's instances, so every day's quests are announced
once — at the midnight after their due date — and a same-night
@@ -544,9 +544,10 @@ class QuestInstancesDao:
rolling horizon from now), so a caller cannot use this to
rewrite past open instances either — the day-rollover missed
sweep (Feature 11) handles past-due instances instead. ``today``
optionally pins the HA-local anchor the clamp compares against
(falling back to the host clock when omitted). Any instance with
a completion event is never touched, regardless of its date.
optionally pins the household-local anchor the clamp compares
against (falling back to the host clock when omitted). Any
instance with a completion event is never touched, regardless of
its date.
Returns the number deleted.
"""
_validate_date(cutoff_date, "cutoff_date")
@@ -581,8 +582,9 @@ class QuestInstancesDao:
one transaction — an instance that gains a completion event
mid-flight cannot be deleted. The cutoff cannot be backdated below
today, for the same reason as the definition-scoped method (see its
docstring). ``today`` optionally pins the HA-local anchor the clamp
compares against (falling back to the host clock when omitted).
docstring). ``today`` optionally pins the household-local anchor
the clamp compares against (falling back to the host clock when
omitted).
Any instance with a completion event is never touched, regardless
of its date.
+9 -8
View File
@@ -39,10 +39,11 @@ ISO-8601 timestamp. Completion payloads additionally carry
``was_on_time``. No other personal data: names and titles are the
household's own records, and nothing beyond them leaks.
Timezone policy: the day-complete check needs the HA-local "today",
which the caller passes in as a plain :class:`datetime.date`. Nothing
here reads ``hass.config.time_zone`` — the integration owns HA
timezone, the core owns the calendar arithmetic.
Timezone policy: the day-complete check needs the household-local
"today", which the caller passes in as a plain :class:`datetime.date`.
Nothing here reads a timezone — the API owns the clock (the
admin-stored application timezone, or the API host's local zone when
none is stored), the core owns the calendar arithmetic.
"""
from __future__ import annotations
@@ -147,10 +148,10 @@ async def build_child_day_complete_event(
completion of TODAY'S instance evaluates it: completing a
back-dated or future instance must not announce a cleared day.
``today`` is the HA-local calendar date the day-complete rule is
evaluated against; the integration reads it from
``hass.config.time_zone`` and passes it in, so this module never
touches HA config.
``today`` is the household-local calendar date the day-complete
rule is evaluated against; the API resolves it in the application
timezone and passes it in, so this module never reads a clock or a
timezone of its own.
"""
# Only a completion of TODAY'S instance evaluates the day-complete
# rule: completing a back-dated or future instance must not
+2 -2
View File
@@ -153,7 +153,7 @@ def _validate_range(start_date: str, end_date: str) -> tuple[str, str]:
def _resolve_today(today: datetime.date | None) -> datetime.date:
"""Return the threaded HA-local date, or the host calendar date."""
"""Return the threaded household-local date, or the host calendar date."""
if today is None:
return datetime.date.today()
return today
@@ -283,7 +283,7 @@ async def query_history(
Rows are ordered deterministically by ``due_date``, then instance,
then event id (the per-instance append order, so a completion and
its reversal keep their order). ``today`` pins the HA-local date
its reversal keep their order). ``today`` pins the household-local date
the missed derivation compares due dates against (the host calendar
date when omitted). The whole read runs under the task-reentrant
connection lock — :func:`list_missed_for_child` re-enters it — so
@@ -255,12 +255,12 @@ async def materialize(
after a presence change) cannot touch any unrelated child's rows.
When omitted (or None) the walk covers every assignee as before.
``today`` optionally pins the resolved HA-local date the walk's
``today`` optionally pins the resolved household-local date the walk's
no-past guard compares against; it is threaded into every
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` write. When
omitted the host clock (``datetime.date.today``) is used, which is the
historical default. A caller that computed the horizon in Home
Assistant's configured time zone MUST pass that same date here so a
historical default. A caller that computed the horizon in the
household's application time zone MUST pass that same date here so a
time zone behind the host around midnight is not rejected as "past".
``start_date`` is clamped up to the resolved "today" (the same
@@ -298,8 +298,8 @@ async def materialize(
start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.datetime.strptime(end_date, "%Y-%m-%d").date()
# Clamp a past start up to today (the HA-local anchor when the caller
# pinned one, else the host clock) so the walk can never emit a
# Clamp a past start up to today (the household-local anchor when the
# caller pinned one, else the host clock) so the walk can never emit a
# past-dated instance; the DAO's no-past guard remains the backstop.
# A range entirely in the past therefore yields zero instances.
today_date = _resolve_today(today)
@@ -345,7 +345,7 @@ async def regenerate_for_definition(
:func:`~.dao_instances._today` — so the delete cutoff and the
re-materialization range share one anchor, and the walk's
``upsert_if_valid`` no-past guard never rejects the regenerated
range. ``today`` optionally pins a caller-resolved HA-local date
range. ``today`` optionally pins a caller-resolved household-local date
(threaded into both the delete cutoff and the re-materialization)
so a household time zone behind the host around midnight stays
consistent. ``horizon_days`` sizes the re-materialization window;
@@ -413,9 +413,9 @@ async def regenerate_for_child(
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`),
so the delete cutoff and the re-materialization window share ONE
execution-day anchor even when the call is queued across midnight.
``today`` optionally pins a caller-resolved HA-local date, threaded
``today`` optionally pins a caller-resolved household-local date, threaded
into the delete cutoff and the re-materialization for the same
host-vs-HA time-zone consistency. ``horizon_days`` sizes the
host-vs-household time-zone consistency. ``horizon_days`` sizes the
re-materialization window; when omitted it falls back to
:data:`~.const.DEFAULT_HORIZON_DAYS`. A bool, non-int, or sub-1 value
raises ValueError BEFORE any delete. Returns the number of instances
@@ -345,7 +345,7 @@ async def edit_quest_definition(
future instances only; no ``quest_instances`` or
``completion_events`` row is touched.
``today`` optionally pins the caller-resolved HA-local date the
``today`` optionally pins the caller-resolved household-local date the
regeneration's no-past guard and horizon are anchored to (threaded
into :func:`~.materialize.regenerate_for_definition`); it is a plain
``datetime.date`` with no HA import here — the Feature 09 service
@@ -449,7 +449,7 @@ async def assign_child(
``quest_definition_assignees`` link is written; existing instances
and completion history are never touched.
``today`` optionally pins the caller-resolved HA-local date threaded
``today`` optionally pins the caller-resolved household-local date threaded
into the assignment's regeneration (see :func:`edit_quest_definition`).
``horizon_days`` optionally sizes the regeneration's re-materialization
window the same way.
@@ -491,7 +491,7 @@ async def unassign_child(
argument. Only the ``quest_definition_assignees`` link is written;
existing instances and completion history are never touched.
``today`` optionally pins the caller-resolved HA-local date threaded
``today`` optionally pins the caller-resolved household-local date threaded
into the unassignment's regeneration (see :func:`edit_quest_definition`).
``horizon_days`` optionally sizes the regeneration's re-materialization
window the same way.
@@ -548,7 +548,7 @@ async def set_quest_definition_active(
connection-scoped lock, so the returned bundle is a consistent
snapshot that a concurrent mutation cannot race.
``today`` optionally pins the caller-resolved HA-local date threaded
``today`` optionally pins the caller-resolved household-local date threaded
into the activation change's regeneration (see
:func:`edit_quest_definition`). ``horizon_days`` optionally sizes the
regeneration's re-materialization window the same way.
+6 -7
View File
@@ -316,12 +316,11 @@ class NestQuestSettings:
The API service does not run inside Home Assistant, so its one
clock read (an aware host-local ``now``) is converted here: with
:attr:`timezone` set — the admin's choice, or else the zone the
integration reports from Home Assistant — the SAME instant is
re-expressed in that zone, so ``.date()`` and ``.time()`` are the
household's wall clock even when the host runs UTC. With
:attr:`timezone` empty the instant is returned unchanged (the
host's local time).
:attr:`timezone` set — only ever by an admin, from the admin
app — the SAME instant is re-expressed in that zone, so
``.date()`` and ``.time()`` are the household's wall clock even
when the host runs UTC. With :attr:`timezone` empty the instant
is returned unchanged (the host's local time).
"""
if not self.timezone:
return instant
@@ -341,7 +340,7 @@ class NestQuestSettings:
"""Return the rolling horizon window ``[today, today + horizon_days]``.
``today`` is REQUIRED (there is no host-clock default): the
integration passes the HA-local ``hass.config.time_zone`` date so
API passes the household-local date (:meth:`household_now`) so
the horizon tracks the household's own day, and a silent
host-clock fallback would be a wrong-day footgun (a test or a
caller that forgets to pass ``today`` would otherwise materialize
+2 -2
View File
@@ -18,7 +18,7 @@ the panel payload shaper (:func:`instance_payload`), and the async
service's panel snapshot route share ONE implementation of both the
snapshot assembly and the entity-facing attribute payload shape.
Nothing here imports :mod:`homeassistant`; the builder takes the
database, the settings object, and the HA-local ``now`` (a single
database, the settings object, and the household-local ``now`` (a single
clock read the caller already resolved, from which it derives
``today``) and returns the snapshot, mirroring exactly what the
coordinator used to do inline.
@@ -187,7 +187,7 @@ async def build_snapshot(
:func:`instance_payload` layer — this builder produces the shared
shape both consumers read.
``now`` is the HA-local timezone-aware datetime the caller already
``now`` is the household-local timezone-aware datetime the caller already
resolved (a SINGLE clock read); the overdue check compares
``now.time()`` against the instance's ``due_time`` for today's open
instances. The builder performs NO internal clock read — it never
+7 -6
View File
@@ -39,9 +39,10 @@ lets exactly one of them win the claim — the loser returns [] and
announces nothing.
The caller supplies ``today`` as a plain :class:`datetime.date`: the
household-local calendar date, read from ``hass.config.time_zone`` by
the integration or from the configured household timezone by the API.
This module never reads a timezone database or config of its own.
household-local calendar date, which the API reads in the
admin-stored application timezone (the API host's local zone when none
is stored). This module never reads a timezone database or config of
its own.
"""
from __future__ import annotations
@@ -97,9 +98,9 @@ async def run_missed_sweep(
"""Build one missed event per unswept past-due open instance.
``today`` is the household-local calendar date the sweep runs for
(the caller owns the clock: the integration reads it from
``hass.config.time_zone``, the API from the configured household
timezone, falling back to its host's local time when unset).
(the caller owns the clock: the API reads it in the admin-stored
application timezone, falling back to its host's local time when
unset).
Returns the ``(event_type, payload)`` tuples to announce, EMPTY
when the watermark already covers ``today`` (a same-night rerun) —
the caller decides how to publish them and announces nothing on
+32
View File
@@ -99,6 +99,38 @@ A lone `home` pattern answers exactly as the old schedule did, so no
child's presence changes. The drop is one-way and 0.7.1 refuses a
version-9 database; see the rollback step in `deploy/RUNBOOK.md` §11.
### Application timezone (manual, admin-set)
The application timezone is set **manually by an admin from the admin
app** — the Settings screen's timezone picker, which reads and writes the
`timezone` field of `GET`/`PATCH /api/v1/admin/settings`. Nothing sets it
automatically: there is no Home Assistant zone adoption, no browser-zone
auto-detect, and no auto-save when a screen loads. Only an admin's `PATCH`
ever changes the stored value.
The stored value is the **single source of local-time truth for the whole
application.** The API reads its one clock and re-expresses it in that zone
(`NestQuestSettings.household_now`), so every local date and time comes
from it:
- the panel snapshot's today and overdue state, and both panel cards'
visible dates and clocks (the snapshot publishes the zone as `timezone`,
which the integration exposes on the household rollup sensor);
- completion timestamps (the local completion date and the on-time check);
- the missed-sweep scheduler's household rollover;
- every admin read, and the admin app's local-time formatting.
**Empty means the API host's local zone.** While `timezone` is empty the
API reads its clock in the host's local time, and publishes that zone as
the effective zone (`api/host_zone.py`: the `effective_timezone` field of
the admin settings response and the snapshot's `timezone`) until an admin
sets one. No code fills the empty value in.
**A save re-plans the sweep.** An admin save that changes `timezone` or
`day_rollover_time` notifies the running missed-sweep scheduler
(`MissedSweepScheduler.settings_changed`, under its settings lock), which
re-plans its next sweep for the new household rollover.
## 4. Authentication: two planes
The kid panel and the admin PWA have opposite trust levels, so they use
+2 -2
View File
@@ -1017,8 +1017,8 @@ def make_hass() -> tuple:
hass.bus = EventBus()
hass.http = HttpRegistry()
hass.extra_js_urls: list[str] = []
# A valid IANA time zone (the day-rollover listener reads it to
# compute "today" in HA local time).
# A valid IANA time zone for the fake Home Assistant config (the
# NestQuest application timezone is the admin-set setting, not this).
hass.config.time_zone = "UTC"
# The minimal entity registry: entities self-register through
+1 -1
View File
@@ -834,7 +834,7 @@ def test_complete_instance_no_due_time_after_due_date_is_late(
def test_complete_instance_ha_local_wall_clock_not_utc(tmp_path) -> None:
"""HA-local 11:00 with due_time 12:00 is on time even when UTC is 19:00.
"""Household-local 11:00, due 12:00, is on time even when UTC is 19:00.
The helper's zone is UTC-8, so 11:00 local is 19:00 UTC. Converting
to UTC before comparing would mark this late.
+1 -1
View File
@@ -136,7 +136,7 @@ async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> Non
# Single clock read for the seed/snapshot anchor (no midnight skew);
# the coordinator no longer owns a clock read (the API serves the
# snapshot), so the test resolves the HA-local zone directly.
# snapshot), so the test resolves its own local zone directly.
time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
# Pin the build instant at 12:00 local: past Ada's 10:00 due time
+9 -8
View File
@@ -940,14 +940,15 @@ def test_materialize_rejects_malformed_or_inverted_bounds(tmp_path) -> None:
def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None:
"""The clamp and no-past guard compare against a caller-supplied HA-local today.
"""The clamp and no-past guard compare against a caller-supplied today.
A household time zone behind the host clock around midnight resolves a
HA-local "today" one calendar day earlier than the host's
household-local "today" one calendar day earlier than the host's
``date.today()``. The walk clamps its start to that anchor: without
a pinned HA-local date the effective start is the host's today (one
day after ha_today), so the now-past day is skipped rather than
rejected, and only the host-today-onward days materialize.
a pinned household-local date the effective start is the host's
today (one day after ha_today), so the now-past day is skipped
rather than rejected, and only the host-today-onward days
materialize.
"""
async def _body(database):
children = ChildrenDao(database)
@@ -963,9 +964,9 @@ def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None:
["morning"],
)
# Without a pinned HA-local today, the walk clamps its start to the
# host's today (one day after ha_today); the past day is dropped and
# the 14 host-today-onward days materialize.
# Without a pinned household-local today, the walk clamps its start
# to the host's today (one day after ha_today); the past day is
# dropped and the 14 host-today-onward days materialize.
without_pin = await materialize(database, start_iso, end_iso)
assert without_pin == 14