"""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, }