Compare commits

...
Author SHA1 Message Date
overseerandClaude Opus 5.5 4e0474ef71 Describe settings form test accurately: ten form fields, timezone is API-only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:08:46 -04:00
overseerandClaude Opus 5.5 e287c145b0 Correct stale settings-count and API clock docstrings
The household-timezone change added an eleventh setting (timezone) and
made the API's household-local dates derive from it. Update the admin
routes' settings, missed-sweep and occurrence-preview docstrings and
core/sweep.py's docstrings to say so (empty timezone falls back to
host local). Documentation only; no behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:04:43 -04:00
dev_agent 7adee7c3c0 Feature review remediation: DST-correct missed-sweep scheduler delay (#257) 2026-09-26 09:58:16 -04:00
5 changed files with 115 additions and 34 deletions
+1 -1
View File
@@ -370,7 +370,7 @@ describe("SettingsScreen — preferences", () => {
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
}
it("renders the current value of all ten fields", async () => {
it("renders the current value of the ten settings form fields (timezone is API-only)", async () => {
const { form } = await renderPreferences();
expect(field(form, "Planning horizon (days)").value).toBe("14");
expect(field(form, "Day rollover").value).toBe("03:00");
+25 -19
View File
@@ -165,10 +165,12 @@ integration's Home-Assistant config-entry options remain a temporary,
SEPARATE source until Feature 18 wires the client to the API — the two
are deliberately not unified here:
- ``GET /settings`` returns the current effective settings: all ten
- ``GET /settings`` returns the current effective settings: all eleven
documented fields (the rolling horizon, the day rollover time, the
notify target, the three notification times and the four enable
toggles) — the defaults on a fresh database.
notify target, the three notification times, the four enable
toggles and the ``timezone`` — the IANA household zone the
household-local dates derive from, empty meaning host local) — the
defaults on a fresh database.
- ``PATCH /settings`` updates ONLY the supplied fields: the body is a
JSON object of settings field names to new values, passed STRAIGHT
to :func:`nestquest_core.settings_store.update_settings` — the
@@ -184,18 +186,19 @@ The missed-sweep trigger (task 058c7b69) follows the same pattern over
:mod:`nestquest_core.sweep` — the HA-free sweep policy the
integration's rollover listener shares:
- ``POST /missed-sweep`` runs the sweep for the API host's local
``today`` (ONE :func:`_local_now` clock read threaded into the core
call) and publishes each returned ``(event_type, payload)`` pair
DIRECTLY on the app's ONE transition publisher — the sweep-built
payload, never re-fetched (the core commits its watermark before
returning, so a failed re-fetch after it would lose the missed event
forever), the same publish-what-the-core-built shape
api/scheduler.py uses. The watermark rule
(a same-day rerun announces nothing, a post-downtime run sweeps the
accumulated window once), the no-completion-event rule and the
read-only-over-the-domain-tables guarantee all live in the core; the
response reports how many transitions were published.
- ``POST /missed-sweep`` runs the sweep for the household-local
``today`` (ONE :func:`_local_now` clock read re-expressed in the
configured household ``timezone`` — empty falls back to host local —
threaded into the core call) and publishes each returned
``(event_type, payload)`` pair DIRECTLY on the app's ONE transition
publisher — the sweep-built payload, never re-fetched (the core
commits its watermark before returning, so a failed re-fetch after it
would lose the missed event forever), the same
publish-what-the-core-built shape api/scheduler.py uses. The
watermark rule (a same-day rerun announces nothing, a post-downtime
run sweeps the accumulated window once), the no-completion-event rule
and the read-only-over-the-domain-tables guarantee all live in the
core; the response reports how many transitions were published.
The occurrence-preview route (task 5f843564) lets the admin PWA's
Definitions edit sheet show the next dates a rule fires on WITHOUT
@@ -205,9 +208,10 @@ reimplementing the recurrence rules client-side:
``rule`` object create/edit take (:class:`AdminRuleRequest`, built
through the SAME :func:`_rule_from_request` / core ``from_dict``
validation, so a rejected rule is 422 exactly as on create), an
optional ``start_date`` (default: the API host's local ``today``,
ONE :func:`_local_now` clock read) and an optional ``count`` (1..50,
default 10). The dates come from ONE
optional ``start_date`` (default: the household-local ``today`` —
ONE :func:`_local_now` clock read re-expressed in the configured
household ``timezone``, empty falling back to host local) and an
optional ``count`` (1..50, default 10). The dates come from ONE
:func:`nestquest_core.recurrence.occurrences_between` call — the
same engine the materializer walks — so the preview can only agree
with what would be generated. The scan is BOUNDED server-side: the
@@ -2281,7 +2285,9 @@ async def admin_run_missed_sweep(
handler performs NO auth of its own). Thin adapter over the SAME
HA-free sweep policy the integration's rollover listener runs
(:mod:`nestquest_core.sweep`): ONE timezone-aware clock read
(:func:`_local_now`) pins the API host's local ``today``, and ONE
(:func:`_local_now`, re-expressed in the configured household
``timezone``; empty falls back to host local) pins the
household-local ``today``, and ONE
:func:`nestquest_core.sweep.run_missed_sweep` call does everything
else — the past-due open-instance query, the documented payload
build and the watermark update (a same-day rerun is an empty
+47 -10
View File
@@ -44,19 +44,56 @@ LOGGER = logging.getLogger(__name__)
_RETRY_BACKOFF_SECONDS = 60.0
def _delay_seconds(target: datetime.time, now: datetime.datetime) -> float:
"""Seconds from ``now`` until the next occurrence of ``target``.
#: Upper bound on the minute-by-minute search past a nonexistent wall
#: time; real DST gaps are an hour, the largest ever skipped a day.
_MAX_GAP_MINUTES = 2 * 24 * 60
``target`` is a plain ``HH:MM`` time of day: the next wall-clock
occurrence strictly AFTER ``now`` (an equal time schedules for
tomorrow, so the just-run sweep is never instantly re-run).
def _resolve_wall_time(
day: datetime.date, target: datetime.time, zone: datetime.tzinfo
) -> datetime.datetime:
"""The aware instant ``target`` (``HH:MM``) names on ``day`` in ``zone``.
A nonexistent wall time (inside a spring-forward gap) resolves to
the first valid wall minute at/after it — the gap's end, e.g. 02:30
on a 02:00→03:00 day becomes 03:00. An ambiguous wall time (the
repeated fall-back hour) resolves to its FIRST occurrence
(``fold=0``).
"""
scheduled = now.replace(
hour=target.hour, minute=target.minute, second=0, microsecond=0
wall = datetime.datetime.combine(
day, datetime.time(target.hour, target.minute)
)
if scheduled <= now:
scheduled += datetime.timedelta(days=1)
return (scheduled - now).total_seconds()
for _ in range(_MAX_GAP_MINUTES):
candidate = wall.replace(tzinfo=zone)
round_trip = candidate.astimezone(datetime.timezone.utc).astimezone(
zone
)
if round_trip.replace(tzinfo=None) == wall:
return candidate
wall += datetime.timedelta(minutes=1)
raise ValueError(f"no valid wall time at/after {target} on {day}")
def _delay_seconds(target: datetime.time, now: datetime.datetime) -> float:
"""Real seconds from ``now`` until the next occurrence of ``target``.
``target`` is a plain ``HH:MM`` household time of day, resolved in
``now``'s zone by :func:`_resolve_wall_time` (nonexistent → first
valid instant after the gap; ambiguous → first occurrence). The
result is the next such instant strictly AFTER ``now`` (an equal
time schedules for tomorrow, so the just-run sweep is never
instantly re-run). Both instants are compared and subtracted in
UTC: same-``tzinfo`` aware arithmetic is wall-clock arithmetic and
would be an hour off across a DST transition.
"""
now_utc = now.astimezone(datetime.timezone.utc)
day = now.date()
while True:
scheduled = _resolve_wall_time(day, target, now.tzinfo)
scheduled_utc = scheduled.astimezone(datetime.timezone.utc)
if scheduled_utc > now_utc:
return (scheduled_utc - now_utc).total_seconds()
day += datetime.timedelta(days=1)
class MissedSweepScheduler:
+4 -3
View File
@@ -40,8 +40,8 @@ announces nothing.
The caller supplies ``today`` as a plain :class:`datetime.date`: the
household-local calendar date, read from ``hass.config.time_zone`` by
the integration or from the API host's local clock by the API. This
module never reads a timezone database or config of its own.
the integration or from the configured household timezone by the API.
This module never reads a timezone database or config of its own.
"""
from __future__ import annotations
@@ -98,7 +98,8 @@ async def run_missed_sweep(
``today`` is the household-local calendar date the sweep runs for
(the caller owns the clock: the integration reads it from
``hass.config.time_zone``, the API from its host's local time).
``hass.config.time_zone``, the API from the configured household
timezone, falling back to its host's local time when unset).
Returns the ``(event_type, payload)`` tuples to announce, EMPTY
when the watermark already covers ``today`` (a same-night rerun) —
the caller decides how to publish them and announces nothing on
+38 -1
View File
@@ -32,7 +32,7 @@ import pytest
from api import routes_admin, routes_panel
from api.database import _executor
from api.scheduler import MissedSweepScheduler
from api.scheduler import MissedSweepScheduler, _delay_seconds
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
@@ -270,3 +270,40 @@ async def test_scheduler_waits_for_rollover_in_household_time(
assert delays == [pytest.approx(65 * 60)]
finally:
await database.close()
# --- the rollover delay across DST (America/New_York) ------------------------
NEW_YORK = ZoneInfo("America/New_York")
def _ny(*args: int, fold: int = 0) -> datetime.datetime:
return datetime.datetime(*args, tzinfo=NEW_YORK, fold=fold)
@pytest.mark.parametrize(
("now", "target", "expected"),
[
# Ordinary EDT day: 09:00 -> 10:05 is 65 minutes.
(_ny(2026, 6, 15, 9, 0), datetime.time(10, 5), 65 * 60),
# An equal time schedules for tomorrow (strictly after now).
(_ny(2026, 6, 15, 10, 5), datetime.time(10, 5), 86400),
# Spring-forward: 01:00 EST -> 03:00 EDT is one real hour.
(_ny(2026, 3, 8, 1, 0), datetime.time(3, 0), 3600),
# Fall-back: 00:30 EDT -> 02:00 EST is two and a half real hours.
(_ny(2026, 11, 1, 0, 30), datetime.time(2, 0), 9000),
# Nonexistent 02:30 on spring-forward day: the first valid instant
# at/after it is 03:00 EDT, one real hour after 01:00 EST.
(_ny(2026, 3, 8, 1, 0), datetime.time(2, 30), 3600),
# Ambiguous 01:30 on fall-back day resolves to the FIRST (EDT)
# occurrence: from 00:30 EDT that is one real hour.
(_ny(2026, 11, 1, 0, 30), datetime.time(1, 30), 3600),
# Already past the first 01:30 (now is 01:15 EST, the repeated
# hour): not the second occurrence, but tomorrow's 01:30 EST.
(_ny(2026, 11, 1, 1, 15, fold=1), datetime.time(1, 30), 87300),
],
)
def test_delay_seconds_is_true_elapsed_time_across_dst(
now: datetime.datetime, target: datetime.time, expected: float
) -> None:
assert _delay_seconds(target, now) == expected