Files
2026-07-20 22:52:35 -04:00

644 lines
27 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Any
from .dates import (
add_interval,
interval_span_days,
next_day_of_month,
next_nth_weekday,
next_weekday_in_set,
parse_iso_date,
roll_back_to_business_day,
)
# 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
# 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)
# 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 = 15
def _sanitize_offset(raw: object) -> int:
if isinstance(raw, bool) or not isinstance(raw, int):
return 0
return max(-_MAX_OFFSET_DAYS, min(raw, _MAX_OFFSET_DAYS))
def _sanitize_day(raw: object) -> int | None:
"""day 1..31, or -1 = last day of the month; anything else -> None."""
if isinstance(raw, bool) or not isinstance(raw, int):
return None
if raw == -1 or 1 <= raw <= 31:
return raw
return None
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 m in raw if isinstance(m, int) and not isinstance(m, bool) 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 = raw.get("count")
valid_count = count if isinstance(count, int) and not isinstance(count, bool) 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
# 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))
if not interval_days or interval_days <= 0:
return cls(kind=KIND_MANUAL)
return cls(
kind=KIND_INTERVAL,
every=interval_days,
unit=interval_unit or "days",
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
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.
"""
# 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)
# 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:
return 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)
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).
"""
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
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
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:
return cls(
kind=KIND_INTERVAL,
every=d.get("every"),
unit=d.get("unit") or "days",
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=tuple(d.get("weekdays") or ()),
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=d.get("nth"),
weekday=d.get("weekday"),
months=tuple(d.get("months") or ()),
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=tuple(d.get("months") or ()),
business=d.get("business") is True,
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:
series_ended = True
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,
KIND_WEEKDAYS,
KIND_NTH_WEEKDAY,
KIND_DAY_OF_MONTH,
)
# --- 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,
}