TZ-002: with no zone stored the API reads its clock in the host's local time, but the panel fell back to Home Assistant's zone and the admin app to the browser's, so dates could disagree near midnight. The API now names the zone it actually uses (api/host_zone.py: the stored zone, else the host's own IANA zone from TZ, /etc/localtime or /etc/timezone) and publishes it as the panel snapshot's `timezone` and the admin settings' read-only `effective_timezone`. Both cards and the admin app format in that one zone; Home Assistant's or the browser's zone applies only when none is published. TZ-003: snapshot_from_api_payload reads `timezone` with a "" default, so an integration upgraded ahead of its API keeps refreshing against the pre-change payload. Task 7e173881-fb99-426f-a2f0-2678439ffc39 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
329 lines
13 KiB
Python
329 lines
13 KiB
Python
"""Pure, Home-Assistant-free panel snapshot builder.
|
|
|
|
The household snapshot the NestQuest panel renders — active children in
|
|
sort order, each child's today-instances in the documented payload shape
|
|
(missed instances OMITTED from the entity-facing attribute payload, but
|
|
retained on the snapshot for the count rollups), per-child counts and
|
|
presence, and the custody cycle day — used to be assembled inline inside
|
|
:mod:`custom_components.nestquest.coordinator`'s
|
|
``_async_update_data``. That assembly is HA-coupled only because its
|
|
host is a :class:`~homeassistant.helpers.update_coordinator.DataUpdateCoordinator`;
|
|
the assembly itself touches no HA surface.
|
|
|
|
This module holds the pure pieces — the snapshot dataclasses
|
|
(:class:`QuestInstanceView`, :class:`ChildDaySnapshot`,
|
|
:class:`NestQuestSnapshot`), the cycle-day helper (:func:`_cycle_day`),
|
|
the panel payload shaper (:func:`instance_payload`), and the async
|
|
:func:`build_snapshot` builder — so the HA coordinator and the API
|
|
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
|
|
clock read the caller already resolved, from which it derives
|
|
``today``) and returns the snapshot, mirroring exactly what the
|
|
coordinator used to do inline.
|
|
|
|
``settings`` is accepted because the API route hands in the entry's
|
|
:class:`~.settings.NestQuestSettings`; it is CURRENTLY UNUSED — the
|
|
assembly reads nothing from it — and is retained so the builder's
|
|
contract stays stable if a future panel feature derives scan caps or
|
|
rollup behaviour from configuration. No speculative read is wired in.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
from collections.abc import Iterable
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from .completion import derive_state
|
|
from .dao_children import ChildrenDao
|
|
from .dao_instances import CompletionEventsDao, QuestInstancesDao
|
|
from .dao_presence import PresenceOverridesDao, PresencePatternsDao
|
|
from .dao_rules import QuestDefinitionsDao
|
|
from .db import NestQuestDatabase
|
|
from .icons import normalize_icon_for_read
|
|
from .presence import (
|
|
MAX_PREVIEW_SCAN_DAYS,
|
|
PresenceEngine,
|
|
PresenceOverride,
|
|
PresencePattern,
|
|
)
|
|
from .settings import NestQuestSettings
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class QuestInstanceView:
|
|
"""One instance plus everything the entities render from it."""
|
|
|
|
instance_id: int
|
|
definition_id: int
|
|
child_id: int
|
|
title: str
|
|
icon: str | None
|
|
window: str
|
|
due_date: str
|
|
due_time: str | None
|
|
state: str # open | done | missed
|
|
overdue: bool
|
|
completed_at: str | None
|
|
was_on_time: bool | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ChildDaySnapshot:
|
|
"""One active child's day: instances, presence, and rollup counts."""
|
|
|
|
child_id: int
|
|
child_name: str
|
|
instances: tuple[QuestInstanceView, ...]
|
|
present: bool
|
|
due_today: int
|
|
completed_today: int
|
|
remaining_today: int
|
|
completion_pct: int
|
|
#: For a child away today: the ISO date of their next present day
|
|
#: (the panel's away plate renders ``Returns <weekday>, <Mon D>``
|
|
#: from it). ``None`` when the child is present today or no
|
|
#: present day exists within the presence engine's scan cap.
|
|
next_present: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class NestQuestSnapshot:
|
|
"""The coordinator's whole-entity data payload."""
|
|
|
|
today_iso: str
|
|
children: tuple[ChildDaySnapshot, ...]
|
|
cycle_day: int
|
|
#: The application timezone the admin stored (``""`` when unset —
|
|
#: the host's local time; the API publishes its effective zone in
|
|
#: its place, api/host_zone.py). The panel cards format their
|
|
#: visible date and clock in it; it never re-resolves the day itself.
|
|
timezone: str = ""
|
|
|
|
|
|
def _cycle_day(
|
|
patterns: dict[int, list[PresencePattern]],
|
|
children,
|
|
today: datetime.date,
|
|
) -> int:
|
|
"""Return today's 1-based day in the first patterned child's cycle.
|
|
|
|
The cycle is that child's FIRST pattern (lowest id) — one number
|
|
for the household's cycle-day sensor even when a child carries
|
|
several patterns. Anchor-date arithmetic ONLY (D-004): the day
|
|
offset is computed from the pattern's own anchor, never ISO week
|
|
numbers or parity, so a 53-week year cannot silently invert the
|
|
cycle.
|
|
"""
|
|
for child in children:
|
|
entries = patterns.get(child.id)
|
|
if not entries:
|
|
continue
|
|
pattern = entries[0]
|
|
days = (today - pattern.anchor_date).days
|
|
cycle_length = pattern.cycle_length_weeks * 7
|
|
return (days % cycle_length) + 1
|
|
return 0
|
|
|
|
|
|
def instance_payload(
|
|
instances: Iterable[QuestInstanceView], *, include_missed: bool
|
|
) -> list[dict[str, Any]]:
|
|
"""Shape instance dicts per ENTITIES-AND-SERVICES.md §1.
|
|
|
|
The panel payload ``state`` is the documented ``open`` | ``completed``
|
|
spelling, so the builder's derived ``done`` maps to ``completed`` here;
|
|
``open`` passes through. ``missed`` instances are OMITTED from the
|
|
entity-facing attribute payload (decision 5) unless ``include_missed``
|
|
is set — the admin payload (D-009) keeps them with their ``missed``
|
|
state. ``on_time`` passes the builder's ``was_on_time`` through
|
|
verbatim (``True`` / ``False`` for a completed instance, ``None`` for
|
|
an open one). This is the SINGLE shared implementation both the HA
|
|
sensor layer and the API panel route read; it touches no Home
|
|
Assistant surface.
|
|
"""
|
|
payload: list[dict[str, Any]] = []
|
|
for view in instances:
|
|
if view.state == "missed" and not include_missed:
|
|
continue
|
|
payload.append(
|
|
{
|
|
"id": view.instance_id,
|
|
"definition_id": view.definition_id,
|
|
"child_id": view.child_id,
|
|
"title": view.title,
|
|
"icon": view.icon,
|
|
"window": view.window,
|
|
"due_time": view.due_time,
|
|
"state": (
|
|
"completed" if view.state == "done" else view.state
|
|
),
|
|
"overdue": view.overdue,
|
|
"completed_at": view.completed_at,
|
|
"on_time": view.was_on_time,
|
|
}
|
|
)
|
|
return payload
|
|
|
|
|
|
async def build_snapshot(
|
|
database: NestQuestDatabase,
|
|
settings: NestQuestSettings,
|
|
now: datetime.datetime,
|
|
) -> NestQuestSnapshot:
|
|
"""Build the household panel snapshot for ``today`` at the ``now`` instant.
|
|
|
|
Mirrors the assembly that lived in the coordinator's
|
|
``_async_update_data``: active children in sort order, each child's
|
|
today-instances with derived state (open / done / missed via
|
|
:func:`~.completion.derive_state`) and the overdue flag, per-child
|
|
presence (resolved through :class:`~.presence.PresenceEngine`), the
|
|
per-child rollup counts, and the custody cycle day of the first
|
|
scheduled active child. Missed instances are retained on the
|
|
snapshot (the rollup counts and the panel's missed plate need them)
|
|
but the entity-facing attribute payload omits them at the
|
|
:func:`instance_payload` layer — this builder produces the shared
|
|
shape both consumers read.
|
|
|
|
``now`` is the HA-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
|
|
calls :func:`datetime.datetime.now` — so the instant is pinned in
|
|
tests. ``today`` is derived INSIDE the builder as ``now.date()``,
|
|
so the calendar date that anchors the due-date and cycle-day
|
|
arithmetic is always the SAME clock read as the wall-clock time;
|
|
the two can never disagree. ``settings`` is accepted but currently
|
|
unused (see the module docstring).
|
|
"""
|
|
today = now.date()
|
|
today_iso = today.isoformat()
|
|
|
|
children = await ChildrenDao(database).list_active()
|
|
|
|
patterns: dict[int, list[PresencePattern]] = {}
|
|
overrides: dict[int, list[PresenceOverride]] = {}
|
|
for child in children:
|
|
records = await PresencePatternsDao(database).list_by_child(child.id)
|
|
if records:
|
|
patterns[child.id] = [
|
|
PresencePattern.decode(
|
|
record.child_id,
|
|
record.name,
|
|
record.kind,
|
|
record.anchor_date,
|
|
record.pattern,
|
|
)
|
|
for record in records
|
|
]
|
|
window_end = today + datetime.timedelta(
|
|
days=MAX_PREVIEW_SCAN_DAYS
|
|
)
|
|
window_end_iso = window_end.isoformat()
|
|
ranged = await PresenceOverridesDao(
|
|
database
|
|
).list_by_child_and_range(child.id, today_iso, window_end_iso)
|
|
if ranged:
|
|
# The engine validates MODEL objects, not DAO records;
|
|
# each record converts losslessly (the schema guarantees
|
|
# end >= start and a real is_present).
|
|
overrides[child.id] = [
|
|
PresenceOverride(
|
|
record.child_id,
|
|
record.start_date,
|
|
record.end_date,
|
|
record.is_present,
|
|
record.note,
|
|
)
|
|
for record in ranged
|
|
]
|
|
engine = PresenceEngine(patterns, overrides)
|
|
|
|
instances_dao = QuestInstancesDao(database)
|
|
events_dao = CompletionEventsDao(database)
|
|
definitions_dao = QuestDefinitionsDao(database)
|
|
|
|
snapshots: list[ChildDaySnapshot] = []
|
|
for child in children:
|
|
instances = await instances_dao.list_by_date_range(
|
|
child.id, today_iso, today_iso
|
|
)
|
|
views: list[QuestInstanceView] = []
|
|
for instance in instances:
|
|
latest = await events_dao.get_latest_for_instance(instance.id)
|
|
definition = await definitions_dao.get(instance.definition_id)
|
|
state = derive_state(instance, latest, today)
|
|
overdue = False
|
|
if state == "open" and instance.due_date == today_iso:
|
|
if instance.due_time is not None:
|
|
due_moment = datetime.datetime.strptime(
|
|
instance.due_time, "%H:%M"
|
|
).time()
|
|
overdue = now.time() > due_moment
|
|
views.append(
|
|
QuestInstanceView(
|
|
instance_id=instance.id,
|
|
definition_id=instance.definition_id,
|
|
child_id=instance.child_id,
|
|
title=definition.title if definition else "Quest",
|
|
icon=(
|
|
normalize_icon_for_read(definition.icon)
|
|
if definition
|
|
else None
|
|
),
|
|
window=instance.window,
|
|
due_date=instance.due_date,
|
|
due_time=instance.due_time,
|
|
state=state,
|
|
overdue=overdue,
|
|
completed_at=(
|
|
latest.occurred_at
|
|
if latest is not None
|
|
and latest.event_type == "completed"
|
|
else None
|
|
),
|
|
was_on_time=(
|
|
latest.was_on_time
|
|
if latest is not None
|
|
and latest.event_type == "completed"
|
|
else None
|
|
),
|
|
)
|
|
)
|
|
due = len(views)
|
|
completed = sum(1 for view in views if view.state == "done")
|
|
remaining = due - completed
|
|
child_present = engine.is_present(child.id, today)
|
|
next_present: str | None = None
|
|
if not child_present:
|
|
try:
|
|
next_present = engine.next_present_dates(
|
|
child.id, today, 1
|
|
)[0].isoformat()
|
|
except ValueError:
|
|
next_present = None
|
|
snapshots.append(
|
|
ChildDaySnapshot(
|
|
child_id=child.id,
|
|
child_name=child.display_name,
|
|
instances=tuple(views),
|
|
present=child_present,
|
|
due_today=due,
|
|
completed_today=completed,
|
|
remaining_today=remaining,
|
|
completion_pct=(
|
|
round(completed * 100 / due) if due else 100
|
|
),
|
|
next_present=next_present,
|
|
)
|
|
)
|
|
|
|
return NestQuestSnapshot(
|
|
today_iso=today_iso,
|
|
children=tuple(snapshots),
|
|
cycle_day=_cycle_day(patterns, children, today),
|
|
timezone=settings.timezone,
|
|
)
|