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:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user