883 lines
38 KiB
Python
883 lines
38 KiB
Python
"""The task recurrence as a single value object (Schedule).
|
||
|
||
Phase 2 of docs/design/schedule-model-v2.md: the recurrence math is centralized
|
||
here behind one interface (``next_due`` / ``span_days``) and adapted from the
|
||
existing flat task fields via :meth:`Schedule.from_legacy` — **no storage change
|
||
and no behaviour change** (the logic is a faithful move of the old
|
||
``MaintenanceTask.next_due``).
|
||
|
||
The point is the boundary: callers ask the Schedule for a computed date/span,
|
||
never for raw ``every`` / ``unit``. Adding calendar patterns later
|
||
(``weekdays`` / ``nth_weekday`` / ``day_of_month``) is then a new ``kind`` +
|
||
branch here, not another field threaded through every consumer.
|
||
|
||
Dates in/out are ``datetime.date`` objects; string parsing stays at the model
|
||
boundary so this module is pure and trivially unit-testable.
|
||
|
||
The ``calendar`` kind (#187 / D#157) is the one kind whose occurrences are not
|
||
computable from the schedule itself: they are the start dates of a Home
|
||
Assistant calendar entity's events (waste collection, a club's fixture list).
|
||
Fetching those is async and lives in the coordinator; this module only reads
|
||
them through a pluggable provider (:func:`set_calendar_occurrence_provider`),
|
||
so the engine stays pure and sync.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import statistics
|
||
from collections.abc import Callable, Mapping, Sequence
|
||
from dataclasses import dataclass
|
||
from datetime import date, timedelta
|
||
from itertools import pairwise
|
||
from typing import Any
|
||
|
||
from ..const import MAX_INTERVAL_DAYS, SCHEDULE_OFFSET_MAX_DAYS
|
||
from .dates import (
|
||
INTERVAL_UNITS,
|
||
add_interval,
|
||
interval_span_days,
|
||
next_day_of_month,
|
||
next_nth_weekday,
|
||
next_weekday_in_set,
|
||
parse_iso_date,
|
||
roll_back_to_business_day,
|
||
)
|
||
|
||
_LOGGER = logging.getLogger(__name__)
|
||
|
||
# Recurrence kinds. Phase 2 covers the v2.6.x set; the calendar kinds
|
||
# (weekdays / nth_weekday / day_of_month) arrive with the roadmap feature.
|
||
KIND_INTERVAL = "interval"
|
||
KIND_ONE_TIME = "one_time"
|
||
KIND_MANUAL = "manual"
|
||
KIND_WEEKDAYS = "weekdays" # e.g. every Mon & Thu
|
||
KIND_NTH_WEEKDAY = "nth_weekday" # e.g. 1st Saturday of the month
|
||
KIND_DAY_OF_MONTH = "day_of_month" # e.g. the 15th
|
||
KIND_CALENDAR = "calendar" # once per event of a HA calendar entity (#187)
|
||
|
||
# The calendar kinds are fixed schedules (occurrences are absolute dates), so the
|
||
# completion/planned anchor distinction doesn't apply to them.
|
||
_CALENDAR_KINDS = (KIND_WEEKDAYS, KIND_NTH_WEEKDAY, KIND_DAY_OF_MONTH, KIND_CALENDAR)
|
||
|
||
# Longest accepted calendar entity_id (HA's own entity-id ceiling).
|
||
_MAX_ENTITY_ID_LENGTH = 255
|
||
# Nominal cycle length of a calendar-entity task when its events don't reveal
|
||
# one (a single known event, or none): weekly, the most common pickup rhythm.
|
||
_CALENDAR_DEFAULT_SPAN_DAYS = 7
|
||
|
||
# --- calendar-entity occurrences (provider hook) ----------------------------
|
||
#
|
||
# The engine is pure and sync; the coordinator fetches a calendar entity's
|
||
# events asynchronously (``calendar.get_events``) and installs a provider that
|
||
# answers "which dates does calendar.xyz have events on?" from its cache.
|
||
# Without a provider (unit tests, options-flow previews before the shared
|
||
# runtime is up) every calendar-entity schedule simply has no occurrences.
|
||
CalendarOccurrenceProvider = Callable[[str], Sequence[date]]
|
||
_calendar_provider: CalendarOccurrenceProvider | None = None
|
||
_provider_failures_logged: set[str] = set()
|
||
|
||
|
||
def set_calendar_occurrence_provider(provider: CalendarOccurrenceProvider | None) -> None:
|
||
"""Install (or, with ``None``, remove) the calendar-entity occurrence source."""
|
||
global _calendar_provider # process-wide hook by design
|
||
_calendar_provider = provider
|
||
_provider_failures_logged.clear()
|
||
|
||
|
||
def calendar_occurrences(entity_id: str) -> tuple[date, ...]:
|
||
"""The known event start dates of ``entity_id``, sorted and deduplicated.
|
||
|
||
Empty when no provider is installed, the entity is unknown to it, or it
|
||
has no events in the fetched window.
|
||
"""
|
||
if _calendar_provider is None or not entity_id:
|
||
return ()
|
||
try:
|
||
raw = _calendar_provider(entity_id)
|
||
except Exception: # noqa: BLE001 — a broken provider must never take next_due down
|
||
# Once per entity: next_due is recomputed on every refresh.
|
||
if entity_id not in _provider_failures_logged:
|
||
_provider_failures_logged.add(entity_id)
|
||
_LOGGER.warning("Calendar dates of %s could not be read", entity_id, exc_info=True)
|
||
return ()
|
||
return tuple(sorted({d for d in raw if isinstance(d, date)}))
|
||
|
||
# Planned-anchor month/year stepping is bounded to avoid an unbounded loop on
|
||
# absurd data (a task untouched for >2000 cycles falls back to the last step).
|
||
_MAX_PLANNED_STEPS = 2000
|
||
|
||
# (#83) offset bound: ±15 days covers every sensible "N days before/after the
|
||
# pattern date" case without letting a bogus payload shift schedules by years.
|
||
_MAX_OFFSET_DAYS = SCHEDULE_OFFSET_MAX_DAYS # const: one source for flows, WS and TS
|
||
|
||
|
||
def _coerce_int(raw: object) -> int | None:
|
||
"""Best-effort int from a persisted/imported value.
|
||
|
||
Ints pass, integral floats and numeric strings coerce, everything else
|
||
-> None. Imports and hand-edited payloads carried e.g. ``every: "30"`` or
|
||
``nth: 2.0``, which raised TypeError on EVERY refresh — one bad field took
|
||
the whole object's sensors down (bug audit 2026-08-22). The read path must
|
||
degrade to "no value", never crash.
|
||
"""
|
||
if isinstance(raw, bool):
|
||
return None
|
||
if isinstance(raw, int):
|
||
return raw
|
||
if isinstance(raw, float):
|
||
return int(raw) if raw.is_integer() else None
|
||
if isinstance(raw, str):
|
||
try:
|
||
val = float(raw.strip())
|
||
except ValueError:
|
||
return None
|
||
return int(val) if val.is_integer() else None
|
||
return None
|
||
|
||
|
||
# Longest interval per unit: MAX_INTERVAL_DAYS (10 years) in that unit. The
|
||
# flat interval_days was capped on write, the nested ``schedule.every`` was
|
||
# not — "every 8000 years" reached the refresh and died on year 10026, every
|
||
# refresh, every restart (bug audit 2026-09-27). Clamped on READ, so stored
|
||
# garbage from any write path or an old backup is harmless too.
|
||
_MAX_EVERY_BY_UNIT = {
|
||
"days": MAX_INTERVAL_DAYS,
|
||
"weeks": MAX_INTERVAL_DAYS // 7,
|
||
"months": (MAX_INTERVAL_DAYS // 365) * 12,
|
||
"years": MAX_INTERVAL_DAYS // 365,
|
||
}
|
||
|
||
|
||
def _sanitize_unit(raw: object) -> str:
|
||
"""One of ``INTERVAL_UNITS``; anything else (a list, "fortnights", None)
|
||
reads as days — the historical fallback of ``add_interval`` — instead of
|
||
being carried into storage and every consumer (bug audit 2026-09-27)."""
|
||
return raw if isinstance(raw, str) and raw in INTERVAL_UNITS else "days"
|
||
|
||
|
||
def _sanitize_every(raw: object, unit: str = "days") -> int | None:
|
||
"""Interval count >= 1 (clamped to the unit's maximum), or None."""
|
||
val = _coerce_int(raw)
|
||
if val is None or val < 1:
|
||
return None
|
||
return min(val, _MAX_EVERY_BY_UNIT.get(unit, MAX_INTERVAL_DAYS))
|
||
|
||
|
||
def _sanitize_nth(raw: object) -> int | None:
|
||
"""nth 1..5, or -1 = last occurrence; anything else -> None."""
|
||
val = _coerce_int(raw)
|
||
if val is None:
|
||
return None
|
||
if val == -1 or 1 <= val <= 5:
|
||
return val
|
||
return None
|
||
|
||
|
||
def _sanitize_weekday(raw: object) -> int | None:
|
||
"""Weekday 0=Mon..6=Sun, or None."""
|
||
val = _coerce_int(raw)
|
||
return val if val is not None and 0 <= val <= 6 else None
|
||
|
||
|
||
def _sanitize_weekdays(raw: object) -> tuple[int, ...]:
|
||
"""A deduped, sorted tuple of valid weekdays (0..6); () on garbage."""
|
||
if not isinstance(raw, (list, tuple)):
|
||
return ()
|
||
seen = {wd for item in raw if (wd := _sanitize_weekday(item)) is not None}
|
||
return tuple(sorted(seen))
|
||
|
||
|
||
def _sanitize_offset(raw: object) -> int:
|
||
val = _coerce_int(raw)
|
||
if val is None:
|
||
return 0
|
||
return max(-_MAX_OFFSET_DAYS, min(val, _MAX_OFFSET_DAYS))
|
||
|
||
|
||
def _sanitize_day(raw: object) -> int | None:
|
||
"""day 1..31, or -1 = last day of the month; anything else -> None."""
|
||
val = _coerce_int(raw)
|
||
if val is None:
|
||
return None
|
||
if val == -1 or 1 <= val <= 31:
|
||
return val
|
||
return None
|
||
|
||
|
||
def _sanitize_calendar_entity(raw: object) -> str | None:
|
||
"""A ``calendar.*`` entity_id (trimmed, ≤255 chars), or None."""
|
||
if not isinstance(raw, str):
|
||
return None
|
||
val = raw.strip()
|
||
if not val.startswith("calendar.") or len(val) <= len("calendar.") or len(val) > _MAX_ENTITY_ID_LENGTH:
|
||
return None
|
||
return val
|
||
|
||
|
||
def _sanitize_months(raw: object) -> tuple[int, ...]:
|
||
"""A deduped, sorted tuple of valid month numbers (1..12); [] on garbage."""
|
||
if not isinstance(raw, (list, tuple)):
|
||
return ()
|
||
seen = {m for item in raw if (m := _coerce_int(item)) is not None and 1 <= m <= 12}
|
||
return tuple(sorted(seen))
|
||
|
||
|
||
def _parse_ends(raw: object) -> tuple[int | None, date | None]:
|
||
"""Read a finite-series ``ends`` block: (count>=1 or None, until-date or None)."""
|
||
if not isinstance(raw, Mapping):
|
||
return None, None
|
||
count = _coerce_int(raw.get("count"))
|
||
valid_count = count if count is not None and count >= 1 else None
|
||
until_raw = raw.get("until")
|
||
until = parse_iso_date(until_raw) if isinstance(until_raw, str) else None
|
||
return valid_count, until
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Schedule:
|
||
"""A task's time recurrence. Triggers (sensors) are orthogonal and handled
|
||
by the coordinator's status precedence, not here."""
|
||
|
||
kind: str = KIND_MANUAL
|
||
every: int | None = None # interval count (legacy: interval_days)
|
||
unit: str = "days" # days | weeks | months | years
|
||
anchor: str = "completion" # completion | planned
|
||
due_date: date | None = None # one_time
|
||
weekdays: tuple[int, ...] = () # weekdays kind: 0=Mon … 6=Sun
|
||
nth: int | None = None # nth_weekday kind: 1..5, or -1 = last
|
||
weekday: int | None = None # nth_weekday kind: 0=Mon … 6=Sun
|
||
day: int | None = None # day_of_month kind: 1..31 (clamped), -1 = last day
|
||
months: tuple[int, ...] = () # nth_weekday/day_of_month: restrict months (1..12)
|
||
# (#83) end-of-month scheduling extras — calendar kinds only:
|
||
business: bool = False # day_of_month: roll a weekend date back to Friday
|
||
offset_days: int = 0 # shift the computed occurrence by ±N days
|
||
# calendar kind (#187): the HA calendar entity whose event start dates are
|
||
# the occurrences (read through the provider hook, see module docstring).
|
||
entity_id: str | None = None
|
||
# Seasonal active window: months (1..12) the task may be due in. A computed
|
||
# due date outside the window rolls forward to the 1st of the next active
|
||
# month, so the task pauses through the off-season and resumes in season.
|
||
# Empty = active all year. Applies to interval + calendar kinds (not one_time).
|
||
season_months: tuple[int, ...] = ()
|
||
# Finite series: the recurrence ends after `ends_count` completions and/or
|
||
# once the next occurrence would fall after `ends_until`. Either, both, or
|
||
# neither. When the end is reached the task stops re-arming (next_due None)
|
||
# and reads as done, like a completed one-time task.
|
||
ends_count: int | None = None
|
||
ends_until: date | None = None
|
||
|
||
@classmethod
|
||
def from_legacy(
|
||
cls,
|
||
*,
|
||
schedule_type: str | None,
|
||
interval_days: int | None,
|
||
interval_unit: str | None,
|
||
interval_anchor: str | None,
|
||
due_date: str | None,
|
||
) -> Schedule:
|
||
"""Adapt the flat v2.6.x task fields to a Schedule (no storage change).
|
||
|
||
Mirrors the old ``next_due`` dispatch exactly: one-time → ``one_time``;
|
||
any positive interval → ``interval`` (incl. a sensor task's safety
|
||
interval, since next-due was always schedule_type-agnostic except
|
||
one-time); otherwise ``manual`` (no schedule).
|
||
"""
|
||
if schedule_type == KIND_ONE_TIME:
|
||
return cls(kind=KIND_ONE_TIME, due_date=parse_iso_date(due_date))
|
||
# Coerce — an imported flat payload can carry interval_days as a
|
||
# string, and `"30" <= 0` raises TypeError on every refresh.
|
||
unit = _sanitize_unit(interval_unit)
|
||
every = _sanitize_every(interval_days, unit)
|
||
if every is None:
|
||
return cls(kind=KIND_MANUAL)
|
||
return cls(
|
||
kind=KIND_INTERVAL,
|
||
every=every,
|
||
unit=unit,
|
||
anchor=interval_anchor or "completion",
|
||
)
|
||
|
||
def is_finite(self) -> bool:
|
||
"""True when the recurrence has an end condition (count and/or until)."""
|
||
return self.ends_count is not None or self.ends_until is not None
|
||
|
||
@property
|
||
def is_calendar_kind(self) -> bool:
|
||
"""A fixed calendar (weekdays, nth weekday, day of month, calendar
|
||
entity) rather than an interval — completions cover occurrences."""
|
||
return self.kind in _CALENDAR_KINDS
|
||
|
||
def next_due(
|
||
self,
|
||
*,
|
||
last_performed: date | None,
|
||
created_at: date | None,
|
||
last_planned_due: date | None,
|
||
today: date,
|
||
times_performed: int = 0,
|
||
due_override: date | None = None,
|
||
) -> date | None:
|
||
"""The next due date, or None for manual / archived one-time tasks.
|
||
|
||
Layers three per-task modifiers over the raw recurrence:
|
||
- ``due_override`` postpones just the current cycle (until completed);
|
||
- ``season_months`` rolls an off-season date to the next active month;
|
||
- the finite-series end (``ends_count`` / ``ends_until``) stops re-arming.
|
||
A one_time task keeps its fixed date and ignores season/finite.
|
||
|
||
Never raises: a date past the calendar's end (year 9999 — a
|
||
far-future reset date, an absurd interval, a season roll in 9999)
|
||
reads as "no due date". It used to raise OverflowError / ValueError
|
||
from inside the coordinator refresh, which took the whole object
|
||
down on every refresh and every restart (bug audit 2026-09-27).
|
||
"""
|
||
try:
|
||
return self._next_due(
|
||
last_performed=last_performed,
|
||
created_at=created_at,
|
||
last_planned_due=last_planned_due,
|
||
today=today,
|
||
times_performed=times_performed,
|
||
due_override=due_override,
|
||
)
|
||
except (OverflowError, ValueError):
|
||
return None
|
||
|
||
def _next_due(
|
||
self,
|
||
*,
|
||
last_performed: date | None,
|
||
created_at: date | None,
|
||
last_planned_due: date | None,
|
||
today: date,
|
||
times_performed: int,
|
||
due_override: date | None,
|
||
) -> date | None:
|
||
""":meth:`next_due` without the end-of-calendar guard."""
|
||
# Finite series exhausted by completion count → terminally done. Checked
|
||
# BEFORE the override so postponing a finished series can't resurrect it
|
||
# (there is no current cycle left to postpone).
|
||
if self.ends_count is not None and times_performed >= self.ends_count:
|
||
return None
|
||
|
||
# A postponed occurrence wins for the current cycle — until it's been
|
||
# completed past the override date (then fall through to normal cadence).
|
||
# It still respects the series end date.
|
||
if due_override is not None and (last_performed is None or due_override > last_performed):
|
||
if self.ends_until is not None and due_override > self.ends_until:
|
||
return None
|
||
return due_override
|
||
|
||
result = self._compute_next_due(
|
||
last_performed=last_performed,
|
||
created_at=created_at,
|
||
last_planned_due=last_planned_due,
|
||
today=today,
|
||
)
|
||
if self.kind == KIND_ONE_TIME:
|
||
return result
|
||
result = self._roll_to_season(result)
|
||
# Calendar kinds: completing or skipping ahead of the date covers that
|
||
# occurrence — complete()/skip() remember it in ``last_planned_due``.
|
||
# The next occurrence strictly after the completion day was the very
|
||
# date just done, so a Monday task completed on Saturday was due again
|
||
# two days later and overdue on Tuesday (bug audit 2026-09-26).
|
||
# EVERY occurrence up to that covered one is consumed, not just an
|
||
# exact match: a second early completion/skip (Saturday: done for
|
||
# Monday; Sunday: skip the next Monday too) re-anchored on the
|
||
# following occurrence while the next one after the new completion
|
||
# day was still the Monday already covered — the due date jumped
|
||
# back a week (bug audit 2026-09-27, R SCH-1). Only a
|
||
# last_planned_due that IS an occurrence of the current schedule
|
||
# counts: a stale value (e.g. from a former interval schedule, or a
|
||
# changed weekday set) never swallows an occurrence.
|
||
if (
|
||
self.kind in _CALENDAR_KINDS
|
||
and result is not None
|
||
and last_performed is not None
|
||
and last_planned_due is not None
|
||
and last_performed < result <= last_planned_due
|
||
and self._calendar_occurrence(last_planned_due, inclusive=True) == last_planned_due
|
||
):
|
||
result = self._roll_to_season(self._calendar_occurrence(last_planned_due, inclusive=False))
|
||
|
||
# Finite series ends once the next occurrence would fall past until.
|
||
if self.ends_until is not None and result is not None and result > self.ends_until:
|
||
return None
|
||
return result
|
||
|
||
def _roll_to_season(self, d: date | None) -> date | None:
|
||
"""Roll a due date outside the seasonal window into the next active
|
||
month; a no-op when there's no window or the date is in season.
|
||
|
||
Interval kinds land on the month's 1st ("due once the season starts").
|
||
Calendar kinds PRESERVE their pattern inside the window — a "2nd
|
||
Saturday" task must come due on the 2nd Saturday of the active month,
|
||
not on the 1st (#83). If the pattern (or its ±offset) misses the
|
||
active month, the search continues into the next one, bounded.
|
||
"""
|
||
if d is None or not self.season_months or d.month in self.season_months:
|
||
return d
|
||
year, month = d.year, d.month
|
||
for _ in range(24):
|
||
month += 1
|
||
if month > 12:
|
||
month, year = 1, year + 1
|
||
if month not in self.season_months:
|
||
continue
|
||
if self.kind not in _CALENDAR_KINDS:
|
||
return date(year, month, 1)
|
||
occ = self._calendar_occurrence(date(year, month, 1), inclusive=True)
|
||
if occ is None:
|
||
# A calendar entity has simply run out of known events — no
|
||
# date, not a made-up one; the pattern kinds fall back to the
|
||
# month's first day (their pattern is defined but undefined
|
||
# in this month's data).
|
||
return None if self.kind == KIND_CALENDAR else date(year, month, 1)
|
||
if occ.month in self.season_months:
|
||
return occ
|
||
# Pattern (e.g. a 5th Friday) or its offset fell outside the
|
||
# window — keep searching from where the occurrence landed.
|
||
year, month = occ.year, occ.month
|
||
return d # season_months held only invalid values — leave the date as-is
|
||
|
||
def _compute_next_due(
|
||
self,
|
||
*,
|
||
last_performed: date | None,
|
||
created_at: date | None,
|
||
last_planned_due: date | None,
|
||
today: date,
|
||
) -> date | None:
|
||
"""The raw next due date before the seasonal window is applied."""
|
||
if self.kind == KIND_ONE_TIME:
|
||
# Due on the fixed date; archived (no re-arm) once completed.
|
||
if last_performed is not None or self.due_date is None:
|
||
return None
|
||
return self.due_date
|
||
|
||
if self.kind in _CALENDAR_KINDS:
|
||
# Fixed calendar schedule. First-time anchors on created_at/today so
|
||
# a never-done task stays visibly overdue once its date passes (the
|
||
# #30 lesson); after completion it's the next occurrence strictly
|
||
# after last_performed. The completion/planned anchor doesn't apply.
|
||
if last_performed is not None:
|
||
return self._calendar_occurrence(last_performed, inclusive=False)
|
||
return self._calendar_occurrence(created_at or today, inclusive=True)
|
||
|
||
if self.kind != KIND_INTERVAL:
|
||
return None
|
||
|
||
every = self.every or 0
|
||
if every <= 0:
|
||
return None
|
||
|
||
if last_performed is None:
|
||
# First-time anchor: created_at if known, else today (issue #30).
|
||
return add_interval(created_at or today, every, self.unit)
|
||
|
||
if self.anchor == "planned":
|
||
# Anchor from the previously planned due date so a late completion
|
||
# doesn't drift the schedule.
|
||
anchor = last_planned_due or last_performed
|
||
if self.unit in (None, "days", "weeks"):
|
||
step = every * (7 if self.unit == "weeks" else 1)
|
||
days_gap = (last_performed - anchor).days
|
||
periods = 1 if days_gap < 0 else (days_gap // step) + 1
|
||
return anchor + timedelta(days=periods * step)
|
||
# Calendar units (months/years): step until past last_performed.
|
||
# Multiply from the ORIGINAL anchor instead of iterating on the
|
||
# clamped result — iterative adds let February permanently drag a
|
||
# day-31 anchor to day 28 (Jan 31 → Feb 28 → Mar 28 …); n×interval
|
||
# from the anchor recovers the intended day per target month
|
||
# (Jan 31 + 2 months = Mar 31). The clamp can still stick across
|
||
# CYCLES when a February due itself becomes the next anchor — the
|
||
# day_of_month kind is the tool for hard month-end pinning.
|
||
candidate = anchor
|
||
for n in range(1, _MAX_PLANNED_STEPS + 1):
|
||
candidate = add_interval(anchor, n * every, self.unit)
|
||
if candidate > last_performed:
|
||
return candidate
|
||
return candidate
|
||
|
||
return add_interval(last_performed, every, self.unit)
|
||
|
||
def _calendar_occurrence(self, ref: date, *, inclusive: bool) -> date | None:
|
||
"""Next EFFECTIVE occurrence of a calendar kind on/after ``ref``.
|
||
|
||
(#83) The effective date is the base pattern date, optionally rolled
|
||
back to a business day (``business``, day_of_month only), then shifted
|
||
by ``offset_days``. Bases are searched from ``ref - offset`` so the
|
||
shifted result still lands on/after ``ref``; when the business
|
||
rollback pushes a candidate before ``ref``, the next base is tried
|
||
(bounded — a rollback moves at most a few days, ≤14 even with a
|
||
Workday-provided holiday calendar in play).
|
||
"""
|
||
offset = timedelta(days=self.offset_days)
|
||
search = ref - offset
|
||
search_inclusive = inclusive
|
||
for _ in range(6):
|
||
base = self._base_occurrence(search, inclusive=search_inclusive)
|
||
if base is None:
|
||
return None
|
||
effective = base
|
||
if self.business and self.kind == KIND_DAY_OF_MONTH:
|
||
effective = roll_back_to_business_day(effective)
|
||
effective = effective + offset
|
||
if (effective >= ref) if inclusive else (effective > ref):
|
||
return effective
|
||
search = base
|
||
search_inclusive = False # strictly after the base just tried
|
||
return None
|
||
|
||
def _base_occurrence(self, ref: date, *, inclusive: bool) -> date | None:
|
||
"""Next base pattern date of a calendar kind on/after ``ref``."""
|
||
months = self.months or None
|
||
if self.kind == KIND_WEEKDAYS:
|
||
return next_weekday_in_set(ref, self.weekdays, inclusive=inclusive)
|
||
if self.kind == KIND_NTH_WEEKDAY:
|
||
if self.nth is None or self.weekday is None:
|
||
return None
|
||
return next_nth_weekday(ref, self.nth, self.weekday, months, inclusive=inclusive)
|
||
if self.kind == KIND_DAY_OF_MONTH:
|
||
if self.day is None:
|
||
return None
|
||
return next_day_of_month(ref, self.day, months, inclusive=inclusive)
|
||
if self.kind == KIND_CALENDAR:
|
||
# The first known event start date on/after (or strictly after)
|
||
# ref — "once per event": a completion on the pickup day moves the
|
||
# task to the NEXT pickup, never back onto the one just handled.
|
||
if self.entity_id is None:
|
||
return None
|
||
for occ in calendar_occurrences(self.entity_id):
|
||
if (occ >= ref) if inclusive else (occ > ref):
|
||
return occ
|
||
return None
|
||
return None
|
||
|
||
def span_days(self) -> int:
|
||
"""Approximate length of one cycle in days (0 when there is no recurrence).
|
||
|
||
For progress bars and the due-soon warning cap — unit-aware, so a
|
||
6-month task is ~183 days, not 6. The calendar kinds use a nominal cycle
|
||
(weekly → 7, monthly patterns → 30); a calendar entity's cycle is the
|
||
median gap between its known events (7 when fewer than two are known).
|
||
"""
|
||
if self.kind == KIND_INTERVAL:
|
||
return interval_span_days(self.every, self.unit)
|
||
if self.kind == KIND_WEEKDAYS:
|
||
return 7
|
||
if self.kind in (KIND_NTH_WEEKDAY, KIND_DAY_OF_MONTH):
|
||
return 30
|
||
if self.kind == KIND_CALENDAR:
|
||
occ = calendar_occurrences(self.entity_id or "")
|
||
gaps = [(b - a).days for a, b in pairwise(occ) if (b - a).days > 0]
|
||
return int(statistics.median(gaps)) if gaps else _CALENDAR_DEFAULT_SPAN_DAYS
|
||
return 0
|
||
|
||
# --- serialization (Phase 3: nested `schedule` storage) ----------------
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
"""Canonical nested form for storage. Defaults are omitted to keep the
|
||
stored dict minimal (``unit`` defaults to days, ``anchor`` to completion)."""
|
||
d: dict[str, Any] = {"kind": self.kind}
|
||
if self.kind == KIND_INTERVAL:
|
||
d["every"] = self.every
|
||
if self.unit and self.unit != "days":
|
||
d["unit"] = self.unit
|
||
if self.anchor and self.anchor != "completion":
|
||
d["anchor"] = self.anchor
|
||
elif self.kind == KIND_ONE_TIME and self.due_date is not None:
|
||
d["due_date"] = self.due_date.isoformat()
|
||
elif self.kind == KIND_WEEKDAYS:
|
||
d["weekdays"] = list(self.weekdays)
|
||
elif self.kind == KIND_NTH_WEEKDAY:
|
||
d["nth"] = self.nth
|
||
d["weekday"] = self.weekday
|
||
if self.months:
|
||
d["months"] = list(self.months)
|
||
elif self.kind == KIND_DAY_OF_MONTH:
|
||
d["day"] = self.day
|
||
if self.months:
|
||
d["months"] = list(self.months)
|
||
if self.business:
|
||
d["business"] = True
|
||
elif self.kind == KIND_CALENDAR:
|
||
d["entity_id"] = self.entity_id
|
||
if self.kind in _CALENDAR_KINDS and self.offset_days:
|
||
d["offset"] = self.offset_days
|
||
# Seasonal window applies to any recurring kind (not one_time/manual).
|
||
if self.season_months and self.kind not in (KIND_ONE_TIME, KIND_MANUAL):
|
||
d["season_months"] = list(self.season_months)
|
||
# Finite-series end condition (recurring kinds only).
|
||
if self.is_finite() and self.kind not in (KIND_ONE_TIME, KIND_MANUAL):
|
||
ends: dict[str, Any] = {}
|
||
if self.ends_count is not None:
|
||
ends["count"] = self.ends_count
|
||
if self.ends_until is not None:
|
||
ends["until"] = self.ends_until.isoformat()
|
||
if ends:
|
||
d["ends"] = ends
|
||
return d
|
||
|
||
@classmethod
|
||
def from_dict(cls, d: Mapping[str, Any]) -> Schedule:
|
||
"""Read the nested form produced by :meth:`to_dict`."""
|
||
kind = d.get("kind", KIND_MANUAL)
|
||
if kind == KIND_ONE_TIME:
|
||
return cls(kind=KIND_ONE_TIME, due_date=parse_iso_date(d.get("due_date")))
|
||
season = _sanitize_months(d.get("season_months"))
|
||
ends_count, ends_until = _parse_ends(d.get("ends"))
|
||
if kind == KIND_INTERVAL:
|
||
unit = _sanitize_unit(d.get("unit"))
|
||
return cls(
|
||
kind=KIND_INTERVAL,
|
||
every=_sanitize_every(d.get("every"), unit),
|
||
unit=unit,
|
||
anchor=d.get("anchor") or "completion",
|
||
season_months=season,
|
||
ends_count=ends_count,
|
||
ends_until=ends_until,
|
||
)
|
||
if kind == KIND_WEEKDAYS:
|
||
return cls(
|
||
kind=KIND_WEEKDAYS,
|
||
weekdays=_sanitize_weekdays(d.get("weekdays")),
|
||
offset_days=_sanitize_offset(d.get("offset")),
|
||
season_months=season,
|
||
ends_count=ends_count,
|
||
ends_until=ends_until,
|
||
)
|
||
if kind == KIND_NTH_WEEKDAY:
|
||
return cls(
|
||
kind=KIND_NTH_WEEKDAY,
|
||
nth=_sanitize_nth(d.get("nth")),
|
||
weekday=_sanitize_weekday(d.get("weekday")),
|
||
months=_sanitize_months(d.get("months")),
|
||
offset_days=_sanitize_offset(d.get("offset")),
|
||
season_months=season,
|
||
ends_count=ends_count,
|
||
ends_until=ends_until,
|
||
)
|
||
if kind == KIND_DAY_OF_MONTH:
|
||
return cls(
|
||
kind=KIND_DAY_OF_MONTH,
|
||
day=_sanitize_day(d.get("day")),
|
||
months=_sanitize_months(d.get("months")),
|
||
business=d.get("business") is True,
|
||
offset_days=_sanitize_offset(d.get("offset")),
|
||
season_months=season,
|
||
ends_count=ends_count,
|
||
ends_until=ends_until,
|
||
)
|
||
if kind == KIND_CALENDAR:
|
||
return cls(
|
||
kind=KIND_CALENDAR,
|
||
entity_id=_sanitize_calendar_entity(d.get("entity_id")),
|
||
offset_days=_sanitize_offset(d.get("offset")),
|
||
season_months=season,
|
||
ends_count=ends_count,
|
||
ends_until=ends_until,
|
||
)
|
||
return cls(kind=KIND_MANUAL)
|
||
|
||
@classmethod
|
||
def parse(cls, task: Mapping[str, Any]) -> Schedule:
|
||
"""Build from a task dict — nested ``schedule`` if present, else the flat
|
||
v2.6.x fields. The single read path during/after migration (and for old
|
||
exports), so both formats are accepted forever."""
|
||
nested = task.get("schedule")
|
||
if isinstance(nested, Mapping):
|
||
return cls.from_dict(nested)
|
||
return cls.from_legacy(
|
||
schedule_type=task.get("schedule_type"),
|
||
interval_days=task.get("interval_days"),
|
||
interval_unit=task.get("interval_unit"),
|
||
interval_anchor=task.get("interval_anchor"),
|
||
due_date=task.get("due_date"),
|
||
)
|
||
|
||
|
||
|
||
def preview_occurrences(
|
||
schedule: Schedule,
|
||
*,
|
||
last_performed: date | None,
|
||
times_performed: int = 0,
|
||
today: date,
|
||
count: int = 3,
|
||
) -> tuple[list[date], bool]:
|
||
"""The next ``count`` occurrences a schedule produces, plus whether the
|
||
series ends within them.
|
||
|
||
Simulates an ON-TIME completion per step (last_performed and the
|
||
planned anchor advance, times_performed increments), so completion-
|
||
anchored intervals, calendar kinds, season windows, business-day rolls,
|
||
±offsets and finite series all advance exactly as the engine would.
|
||
Single source of truth for BOTH preview surfaces — the panel's
|
||
``schedule/preview`` WS command and the options flow's next-dates line
|
||
(#83; keep them DRY through this helper).
|
||
"""
|
||
occurrences: list[date] = []
|
||
series_ended = False
|
||
lp = last_performed
|
||
lpd: date | None = None
|
||
times = times_performed
|
||
for _ in range(count):
|
||
nxt = schedule.next_due(
|
||
last_performed=lp,
|
||
created_at=today,
|
||
last_planned_due=lpd,
|
||
today=today,
|
||
times_performed=times,
|
||
)
|
||
if nxt is None:
|
||
# A calendar entity that merely ran out of KNOWN events has not
|
||
# ended its series — only a finite one has.
|
||
series_ended = schedule.kind != KIND_CALENDAR or schedule.is_finite()
|
||
break
|
||
if occurrences and nxt <= occurrences[-1]: # pragma: no cover
|
||
break # safety net: the engine must advance — never loop
|
||
occurrences.append(nxt)
|
||
lp = nxt
|
||
lpd = nxt
|
||
times += 1
|
||
return occurrences, series_ended
|
||
|
||
def is_recurring(task: Mapping[str, Any]) -> bool:
|
||
"""True iff the task dict has a cycling schedule (interval or calendar kind).
|
||
|
||
One-off and manual tasks don't re-arm; a recurring task gets a fresh cycle
|
||
when its object is unarchived (D2) or resumed from a seasonal pause (N3).
|
||
Single source for both — the websocket layer delegates here.
|
||
"""
|
||
return Schedule.parse(task).kind in (KIND_INTERVAL, *_CALENDAR_KINDS)
|
||
|
||
|
||
# --- flat <-> nested adapters (Phase 3) ------------------------------------
|
||
#
|
||
# Readers that still speak the flat v2.6.x shape (the WS payload, export, CSV,
|
||
# the edit-form prefill) go through these so there is exactly one translation
|
||
# point. ``schedule_type`` is *derived*: sensors (a trigger) are orthogonal to
|
||
# the recurrence kind, so a triggered task reports "sensor_based" regardless of
|
||
# whether it also carries a safety interval.
|
||
|
||
|
||
FLAT_RECURRENCE_KEYS = (
|
||
"schedule_type",
|
||
"interval_days",
|
||
"interval_unit",
|
||
"interval_anchor",
|
||
"due_date",
|
||
)
|
||
|
||
|
||
def normalize_task_storage(task: Mapping[str, Any]) -> dict[str, Any]:
|
||
"""Return a copy of ``task`` with its recurrence stored as nested ``schedule``.
|
||
|
||
The single flat→nested writer used by the migration and by every persist
|
||
path, so new and existing tasks converge on one storage shape. The flat
|
||
recurrence keys are dropped; every other field is preserved exactly.
|
||
Sensor-ness stays in ``trigger_config`` (the derived ``schedule_type`` is
|
||
reconstructed on read by :func:`read_legacy_fields`).
|
||
|
||
Overlays are resolved so an edit takes effect: when a caller copies a stored
|
||
(nested) task and overlays flat fields — the options edit flow does exactly
|
||
this — the present flat keys win, but any absent ones fall back to the
|
||
existing nested schedule. So changing only ``interval_days`` keeps the unit
|
||
(the issue #58 class) instead of silently resetting it to days.
|
||
|
||
Idempotent: a pure-nested task (no flat keys) is returned unchanged.
|
||
"""
|
||
out = dict(task)
|
||
nested = out.get("schedule")
|
||
if isinstance(nested, Mapping) and nested.get("kind") in _CALENDAR_KINDS:
|
||
# Calendar kinds can't be expressed via the flat fields, so the nested
|
||
# schedule is authoritative — keep it and drop any stray flat keys.
|
||
for key in FLAT_RECURRENCE_KEYS:
|
||
out.pop(key, None)
|
||
return out
|
||
has_flat = any(key in out for key in FLAT_RECURRENCE_KEYS)
|
||
has_nested = isinstance(nested, Mapping)
|
||
if has_nested and not has_flat:
|
||
return out
|
||
if not has_nested and not has_flat:
|
||
out["schedule"] = Schedule(kind=KIND_MANUAL).to_dict()
|
||
return out
|
||
|
||
# Effective flat view: nested-derived base, overridden by present flat keys.
|
||
merged = read_legacy_fields(out) # nested-derived (hybrid) or flat (pure)
|
||
for key in FLAT_RECURRENCE_KEYS:
|
||
if key in out:
|
||
merged[key] = out[key]
|
||
schedule = Schedule.from_legacy(
|
||
schedule_type=merged["schedule_type"],
|
||
interval_days=merged["interval_days"],
|
||
interval_unit=merged["interval_unit"],
|
||
interval_anchor=merged["interval_anchor"],
|
||
due_date=merged["due_date"],
|
||
).to_dict()
|
||
|
||
# The flat fields can't express the nested-only interval extras (seasonal
|
||
# window, finite-series end). When an edit overlays flat keys onto a task
|
||
# that had them, carry them over so they aren't silently dropped — same
|
||
# spirit as the #58 unit-preservation above. Only meaningful on recurring
|
||
# kinds (from_legacy never yields one of these for one_time/manual).
|
||
if has_nested and isinstance(nested, Mapping) and schedule.get("kind") not in (KIND_ONE_TIME, KIND_MANUAL):
|
||
for extra in ("season_months", "ends"):
|
||
if extra in nested and extra not in schedule:
|
||
schedule[extra] = nested[extra]
|
||
|
||
for key in FLAT_RECURRENCE_KEYS:
|
||
out.pop(key, None)
|
||
out["schedule"] = schedule
|
||
return out
|
||
|
||
|
||
def legacy_schedule_type(schedule: Schedule, *, has_trigger: bool) -> str:
|
||
"""The v2.6.x ``schedule_type`` string for a Schedule + trigger presence.
|
||
|
||
The calendar kinds have no v2.6.x equivalent, so they surface their own kind
|
||
(``nth_weekday`` etc.); consumers that understand them read the nested
|
||
``schedule`` directly, the rest get a coarse but honest label.
|
||
"""
|
||
if has_trigger:
|
||
return "sensor_based"
|
||
if schedule.kind == KIND_ONE_TIME:
|
||
return "one_time"
|
||
if schedule.kind == KIND_INTERVAL:
|
||
return "time_based"
|
||
if schedule.kind == KIND_MANUAL:
|
||
return "manual"
|
||
return schedule.kind
|
||
|
||
|
||
def read_legacy_fields(task: Mapping[str, Any]) -> dict[str, Any]:
|
||
"""The flat recurrence view of a task dict, accepting either storage shape.
|
||
|
||
A flat (v2.6.x) task is returned field-for-field as stored — so existing
|
||
readers are behaviour-identical until the data is actually migrated. A
|
||
task with a nested ``schedule`` is translated back to the flat view its
|
||
consumers expect (``interval_days`` carries the count, ``due_date`` the ISO
|
||
string, etc.). Missing values use the long-standing flat defaults.
|
||
"""
|
||
nested = task.get("schedule")
|
||
if not isinstance(nested, Mapping):
|
||
return {
|
||
"schedule_type": task.get("schedule_type", "time_based"),
|
||
"interval_days": task.get("interval_days"),
|
||
"interval_unit": task.get("interval_unit", "days"),
|
||
"interval_anchor": task.get("interval_anchor", "completion"),
|
||
"due_date": task.get("due_date"),
|
||
}
|
||
sched = Schedule.from_dict(nested)
|
||
return {
|
||
"schedule_type": legacy_schedule_type(sched, has_trigger=bool(task.get("trigger_config"))),
|
||
"interval_days": sched.every,
|
||
"interval_unit": sched.unit,
|
||
"interval_anchor": sched.anchor,
|
||
"due_date": sched.due_date.isoformat() if sched.due_date else None,
|
||
}
|