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 celebration automation must never fire on an empty day (the
day-complete event carries the same rule, Feature 10 task 7). day-complete event carries the same rule, Feature 10 task 7).
- ``binary_sensor.nestquest_<child>_present_today`` — ON when the - ``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). (schedules and overrides, D-004 anchor-date arithmetic).
Naming and registry identity follow the day sensors' contract: names 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 Instance current state is derived from the latest event via
:func:`instance_state` (``open`` / ``done`` / ``missed``); there is :func:`instance_state` (``open`` / ``done`` / ``missed``); there is
no status column. ``missed`` is derived for past-due open instances 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. event_type.
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
@@ -71,10 +71,10 @@ def _completion_moment(
now: datetime.datetime | None, now: datetime.datetime | None,
today: datetime.date | None, today: datetime.date | None,
) -> tuple[datetime.date, datetime.time | 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 ``today`` is the household-local calendar date when threaded (Feature 07
style). ``now`` is a timezone-aware HA-local datetime; its wall style). ``now`` is a timezone-aware household-local datetime; its wall
clock is used as-is and is never converted to UTC. When ``today`` 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 is omitted the date comes from ``now``. When both are omitted the
host calendar date is the Feature 07 fallback — never the UTC 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: 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: if today is None:
return datetime.date.today() return datetime.date.today()
return today return today
@@ -138,7 +138,7 @@ def derive_state(
Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or
``missed`` from the latest event plus the due date (``missed`` is ``missed`` from the latest event plus the due date (``missed`` is
derived, never an event_type). ``today`` is the caller-resolved derived, never an event_type). ``today`` is the caller-resolved
HA-local date. household-local date.
""" """
return _derived_state(instance, latest, today) return _derived_state(instance, latest, today)
@@ -345,7 +345,7 @@ async def instance_state(
"""Return ``open``, ``done``, or ``missed`` from the latest event. """Return ``open``, ``done``, or ``missed`` from the latest event.
No events, or a latest event of uncompleted, is ``open`` unless 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`` case it is ``missed``. A latest event of completed is ``done``
even when past due. ``missed`` is never stored as an event_type. even when past due. ``missed`` is never stored as an event_type.
Unknown ``instance_id`` raises ValueError naming the field. 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 latest-event, and the append run under one connection lock so a
concurrent complete cannot double-append. 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`` (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 datetime; its wall clock is never converted to UTC. Same-day
``due_time`` comparison requires ``now``. ``due_time`` comparison requires ``now``.
""" """
@@ -471,7 +471,7 @@ async def uncomplete_instance(
"""Append an uncompleted event unless the instance is already open. """Append an uncompleted event unless the instance is already open.
Returns the derived state ``open``, or ``missed`` when the instance 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 reports whether THIS call appended (False on an already-open
no-op). Unknown ``instance_id`` raises ValueError naming the no-op). Unknown ``instance_id`` raises ValueError naming the
field. Existence, latest-event, and the append run under one 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. """Return the child's missed instances due in the closed date range.
Missed means the latest event state is open and ``due_date`` is 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. :meth:`QuestInstancesDao.list_by_date_range` plus derived state.
Never appends a missed event. Instances and latest events are Never appends a missed event. Instances and latest events are
read inside the connection lock so the list is one snapshot. 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 # 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 # 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); # local time. Only an admin sets it (manually, from the admin app);
# nothing sets it automatically. The integration itself always uses # nothing sets it automatically. The integration relays the API's
# Home Assistant's ``time_zone`` and ignores this field. # 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" CONF_TIMEZONE = "timezone"
DEFAULT_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 around midnight, so when the caller has already resolved the local
date (``today``) that anchor is authoritative; ``None`` falls back to date (``today``) that anchor is authoritative; ``None`` falls back to
the host clock (:func:`_today`), which is exactly the previous 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 return _today() if today is None else today
@@ -225,7 +225,7 @@ class QuestInstancesDao:
- ``due_date`` must be TODAY or later: instances are never - ``due_date`` must be TODAY or later: instances are never
generated in the past (Feature 07). The caller's generated in the past (Feature 07). The caller's
materialization horizon is future-dated by definition. 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 guard compares against (falling back to the host clock when
omitted) so a household time zone behind the host around omitted) so a household time zone behind the host around
midnight is not misread as "past". midnight is not misread as "past".
@@ -248,7 +248,7 @@ class QuestInstancesDao:
# check runs after the lock is acquired, because a caller can # check runs after the lock is acquired, because a caller can
# wait on the lock across midnight — a date that was "today" # wait on the lock across midnight — a date that was "today"
# at call time may be "yesterday" by the time the INSERT runs. # 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 # checks and never re-read, since it is already the anchor the
# batch runs against. # batch runs against.
today_date = _resolve_today(today) today_date = _resolve_today(today)
@@ -257,7 +257,7 @@ class QuestInstancesDao:
async with self._database.transaction(): async with self._database.transaction():
# Re-read "today" under the lock: this is the date the # Re-read "today" under the lock: this is the date the
# insert actually executes on, so the no-past rule # 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). # anchor is exempt from the re-read (see above).
if today is None: if today is None:
today_date = _today() today_date = _today()
@@ -364,7 +364,7 @@ class QuestInstancesDao:
rather than raising, so the rest of the batch is unaffected. The rather than raising, so the rest of the batch is unaffected. The
no-past rule (instances are never generated in the past) is no-past rule (instances are never generated in the past) is
preserved from :meth:`upsert` and still raises ValueError; ``today`` 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 instance that ALREADY has a completion event is also skipped
(returns ``None``) rather than raised: it is immutable, so the (returns ``None``) rather than raised: it is immutable, so the
walk leaves it untouched instead of failing the batch — 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 append-only contract: an instance that was ever completed is
never reported missed, even when later reversed — history keeps never reported missed, even when later reversed — history keeps
both rows and the sweep never mutates any). The watermark is 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 ``[watermark, today)``: the nightly run entering day D+1 sweeps
exactly day D's instances, so every day's quests are announced exactly day D's instances, so every day's quests are announced
once — at the midnight after their due date — and a same-night 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 rolling horizon from now), so a caller cannot use this to
rewrite past open instances either — the day-rollover missed rewrite past open instances either — the day-rollover missed
sweep (Feature 11) handles past-due instances instead. ``today`` sweep (Feature 11) handles past-due instances instead. ``today``
optionally pins the HA-local anchor the clamp compares against optionally pins the household-local anchor the clamp compares
(falling back to the host clock when omitted). Any instance with against (falling back to the host clock when omitted). Any
a completion event is never touched, regardless of its date. instance with a completion event is never touched, regardless of
its date.
Returns the number deleted. Returns the number deleted.
""" """
_validate_date(cutoff_date, "cutoff_date") _validate_date(cutoff_date, "cutoff_date")
@@ -581,8 +582,9 @@ class QuestInstancesDao:
one transaction — an instance that gains a completion event one transaction — an instance that gains a completion event
mid-flight cannot be deleted. The cutoff cannot be backdated below mid-flight cannot be deleted. The cutoff cannot be backdated below
today, for the same reason as the definition-scoped method (see its today, for the same reason as the definition-scoped method (see its
docstring). ``today`` optionally pins the HA-local anchor the clamp docstring). ``today`` optionally pins the household-local anchor
compares against (falling back to the host clock when omitted). the clamp compares against (falling back to the host clock when
omitted).
Any instance with a completion event is never touched, regardless Any instance with a completion event is never touched, regardless
of its date. 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 ``was_on_time``. No other personal data: names and titles are the
household's own records, and nothing beyond them leaks. household's own records, and nothing beyond them leaks.
Timezone policy: the day-complete check needs the HA-local "today", Timezone policy: the day-complete check needs the household-local
which the caller passes in as a plain :class:`datetime.date`. Nothing "today", which the caller passes in as a plain :class:`datetime.date`.
here reads ``hass.config.time_zone`` — the integration owns HA Nothing here reads a timezone — the API owns the clock (the
timezone, the core owns the calendar arithmetic. 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 from __future__ import annotations
@@ -147,10 +148,10 @@ async def build_child_day_complete_event(
completion of TODAY'S instance evaluates it: completing a completion of TODAY'S instance evaluates it: completing a
back-dated or future instance must not announce a cleared day. back-dated or future instance must not announce a cleared day.
``today`` is the HA-local calendar date the day-complete rule is ``today`` is the household-local calendar date the day-complete
evaluated against; the integration reads it from rule is evaluated against; the API resolves it in the application
``hass.config.time_zone`` and passes it in, so this module never timezone and passes it in, so this module never reads a clock or a
touches HA config. timezone of its own.
""" """
# Only a completion of TODAY'S instance evaluates the day-complete # Only a completion of TODAY'S instance evaluates the day-complete
# rule: completing a back-dated or future instance must not # 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: 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: if today is None:
return datetime.date.today() return datetime.date.today()
return today return today
@@ -283,7 +283,7 @@ async def query_history(
Rows are ordered deterministically by ``due_date``, then instance, Rows are ordered deterministically by ``due_date``, then instance,
then event id (the per-instance append order, so a completion and 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 the missed derivation compares due dates against (the host calendar
date when omitted). The whole read runs under the task-reentrant date when omitted). The whole read runs under the task-reentrant
connection lock — :func:`list_missed_for_child` re-enters it — so 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. after a presence change) cannot touch any unrelated child's rows.
When omitted (or None) the walk covers every assignee as before. 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 no-past guard compares against; it is threaded into every
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` write. When :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` write. When
omitted the host clock (``datetime.date.today``) is used, which is the omitted the host clock (``datetime.date.today``) is used, which is the
historical default. A caller that computed the horizon in Home historical default. A caller that computed the horizon in the
Assistant's configured time zone MUST pass that same date here so a 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". time zone behind the host around midnight is not rejected as "past".
``start_date`` is clamped up to the resolved "today" (the same ``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() start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.datetime.strptime(end_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 # Clamp a past start up to today (the household-local anchor when the
# pinned one, else the host clock) so the walk can never emit a # 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. # past-dated instance; the DAO's no-past guard remains the backstop.
# A range entirely in the past therefore yields zero instances. # A range entirely in the past therefore yields zero instances.
today_date = _resolve_today(today) today_date = _resolve_today(today)
@@ -345,7 +345,7 @@ async def regenerate_for_definition(
:func:`~.dao_instances._today` — so the delete cutoff and the :func:`~.dao_instances._today` — so the delete cutoff and the
re-materialization range share one anchor, and the walk's re-materialization range share one anchor, and the walk's
``upsert_if_valid`` no-past guard never rejects the regenerated ``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) (threaded into both the delete cutoff and the re-materialization)
so a household time zone behind the host around midnight stays so a household time zone behind the host around midnight stays
consistent. ``horizon_days`` sizes the re-materialization window; 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`), :meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`),
so the delete cutoff and the re-materialization window share ONE so the delete cutoff and the re-materialization window share ONE
execution-day anchor even when the call is queued across midnight. 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 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 re-materialization window; when omitted it falls back to
:data:`~.const.DEFAULT_HORIZON_DAYS`. A bool, non-int, or sub-1 value :data:`~.const.DEFAULT_HORIZON_DAYS`. A bool, non-int, or sub-1 value
raises ValueError BEFORE any delete. Returns the number of instances 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 future instances only; no ``quest_instances`` or
``completion_events`` row is touched. ``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 regeneration's no-past guard and horizon are anchored to (threaded
into :func:`~.materialize.regenerate_for_definition`); it is a plain into :func:`~.materialize.regenerate_for_definition`); it is a plain
``datetime.date`` with no HA import here — the Feature 09 service ``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 ``quest_definition_assignees`` link is written; existing instances
and completion history are never touched. 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`). into the assignment's regeneration (see :func:`edit_quest_definition`).
``horizon_days`` optionally sizes the regeneration's re-materialization ``horizon_days`` optionally sizes the regeneration's re-materialization
window the same way. window the same way.
@@ -491,7 +491,7 @@ async def unassign_child(
argument. Only the ``quest_definition_assignees`` link is written; argument. Only the ``quest_definition_assignees`` link is written;
existing instances and completion history are never touched. 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`). into the unassignment's regeneration (see :func:`edit_quest_definition`).
``horizon_days`` optionally sizes the regeneration's re-materialization ``horizon_days`` optionally sizes the regeneration's re-materialization
window the same way. window the same way.
@@ -548,7 +548,7 @@ async def set_quest_definition_active(
connection-scoped lock, so the returned bundle is a consistent connection-scoped lock, so the returned bundle is a consistent
snapshot that a concurrent mutation cannot race. 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 into the activation change's regeneration (see
:func:`edit_quest_definition`). ``horizon_days`` optionally sizes the :func:`edit_quest_definition`). ``horizon_days`` optionally sizes the
regeneration's re-materialization window the same way. 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 The API service does not run inside Home Assistant, so its one
clock read (an aware host-local ``now``) is converted here: with clock read (an aware host-local ``now``) is converted here: with
:attr:`timezone` set — the admin's choice, or else the zone the :attr:`timezone` set — only ever by an admin, from the admin
integration reports from Home Assistant — the SAME instant is app — the SAME instant is re-expressed in that zone, so
re-expressed in that zone, so ``.date()`` and ``.time()`` are the ``.date()`` and ``.time()`` are the household's wall clock even
household's wall clock even when the host runs UTC. With when the host runs UTC. With :attr:`timezone` empty the instant
:attr:`timezone` empty the instant is returned unchanged (the is returned unchanged (the host's local time).
host's local time).
""" """
if not self.timezone: if not self.timezone:
return instant return instant
@@ -341,7 +340,7 @@ class NestQuestSettings:
"""Return the rolling horizon window ``[today, today + horizon_days]``. """Return the rolling horizon window ``[today, today + horizon_days]``.
``today`` is REQUIRED (there is no host-clock default): the ``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 the horizon tracks the household's own day, and a silent
host-clock fallback would be a wrong-day footgun (a test or a host-clock fallback would be a wrong-day footgun (a test or a
caller that forgets to pass ``today`` would otherwise materialize 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 service's panel snapshot route share ONE implementation of both the
snapshot assembly and the entity-facing attribute payload shape. snapshot assembly and the entity-facing attribute payload shape.
Nothing here imports :mod:`homeassistant`; the builder takes the 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 clock read the caller already resolved, from which it derives
``today``) and returns the snapshot, mirroring exactly what the ``today``) and returns the snapshot, mirroring exactly what the
coordinator used to do inline. coordinator used to do inline.
@@ -187,7 +187,7 @@ async def build_snapshot(
:func:`instance_payload` layer — this builder produces the shared :func:`instance_payload` layer — this builder produces the shared
shape both consumers read. 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 resolved (a SINGLE clock read); the overdue check compares
``now.time()`` against the instance's ``due_time`` for today's open ``now.time()`` against the instance's ``due_time`` for today's open
instances. The builder performs NO internal clock read — it never 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. announces nothing.
The caller supplies ``today`` as a plain :class:`datetime.date`: the The caller supplies ``today`` as a plain :class:`datetime.date`: the
household-local calendar date, read from ``hass.config.time_zone`` by household-local calendar date, which the API reads in the
the integration or from the configured household timezone by the API. admin-stored application timezone (the API host's local zone when none
This module never reads a timezone database or config of its own. is stored). This module never reads a timezone database or config of
its own.
""" """
from __future__ import annotations from __future__ import annotations
@@ -97,9 +98,9 @@ async def run_missed_sweep(
"""Build one missed event per unswept past-due open instance. """Build one missed event per unswept past-due open instance.
``today`` is the household-local calendar date the sweep runs for ``today`` is the household-local calendar date the sweep runs for
(the caller owns the clock: the integration reads it from (the caller owns the clock: the API reads it in the admin-stored
``hass.config.time_zone``, the API from the configured household application timezone, falling back to its host's local time when
timezone, falling back to its host's local time when unset). unset).
Returns the ``(event_type, payload)`` tuples to announce, EMPTY Returns the ``(event_type, payload)`` tuples to announce, EMPTY
when the watermark already covers ``today`` (a same-night rerun) — when the watermark already covers ``today`` (a same-night rerun) —
the caller decides how to publish them and announces nothing on 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 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. 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 ## 4. Authentication: two planes
The kid panel and the admin PWA have opposite trust levels, so they use 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.bus = EventBus()
hass.http = HttpRegistry() hass.http = HttpRegistry()
hass.extra_js_urls: list[str] = [] hass.extra_js_urls: list[str] = []
# A valid IANA time zone (the day-rollover listener reads it to # A valid IANA time zone for the fake Home Assistant config (the
# compute "today" in HA local time). # NestQuest application timezone is the admin-set setting, not this).
hass.config.time_zone = "UTC" hass.config.time_zone = "UTC"
# The minimal entity registry: entities self-register through # 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: 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 The helper's zone is UTC-8, so 11:00 local is 19:00 UTC. Converting
to UTC before comparing would mark this late. 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); # Single clock read for the seed/snapshot anchor (no midnight skew);
# the coordinator no longer owns a clock read (the API serves the # 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) time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date() today = datetime.datetime.now(time_zone).date()
# Pin the build instant at 12:00 local: past Ada's 10:00 due time # 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: 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 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 ``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 a pinned household-local date the effective start is the host's
day after ha_today), so the now-past day is skipped rather than today (one day after ha_today), so the now-past day is skipped
rejected, and only the host-today-onward days materialize. rather than rejected, and only the host-today-onward days
materialize.
""" """
async def _body(database): async def _body(database):
children = ChildrenDao(database) children = ChildrenDao(database)
@@ -963,9 +964,9 @@ def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None:
["morning"], ["morning"],
) )
# Without a pinned HA-local today, the walk clamps its start to the # Without a pinned household-local today, the walk clamps its start
# host's today (one day after ha_today); the past day is dropped and # to the host's today (one day after ha_today); the past day is
# the 14 host-today-onward days materialize. # dropped and the 14 host-today-onward days materialize.
without_pin = await materialize(database, start_iso, end_iso) without_pin = await materialize(database, start_iso, end_iso)
assert without_pin == 14 assert without_pin == 14