Updated apps

This commit is contained in:
2026-07-20 22:52:35 -04:00
parent 28a8cb98f6
commit a0c3271743
1164 changed files with 94781 additions and 6892 deletions
@@ -198,17 +198,34 @@ class Schedule:
return result
def _roll_to_season(self, d: date | None) -> date | None:
"""Roll a due date outside the seasonal window to the 1st of the next
active month; a no-op when there's no window or the date is in season."""
"""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(12):
for _ in range(24):
month += 1
if month > 12:
month, year = 1, year + 1
if month in self.season_months:
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(
@@ -441,6 +458,50 @@ class Schedule:
)
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).