"""Recurring-reminder math — validate a recurrence interval and roll a reminder forward to its next occurrence (used when a recurring reminder is completed).""" from __future__ import annotations import calendar from datetime import datetime, timedelta REMINDER_RECURRENCES = {"daily", "weekly", "monthly", "yearly"} def normalize_recurrence(value: object) -> str | None: return value if value in REMINDER_RECURRENCES else None def _add_months(dt: datetime, months: int) -> datetime: """Shift a datetime by whole months, clamping the day to the target month's length (so Jan 31 + 1 month → Feb 28/29). Keeps the time-of-day.""" m = dt.month - 1 + months year = dt.year + m // 12 month = m % 12 + 1 day = min(dt.day, calendar.monthrange(year, month)[1]) return dt.replace(year=year, month=month, day=day) def _advance_once(dt: datetime, recurrence: str) -> datetime | None: if recurrence == "daily": return dt + timedelta(days=1) if recurrence == "weekly": return dt + timedelta(weeks=1) if recurrence == "monthly": return _add_months(dt, 1) if recurrence == "yearly": return _add_months(dt, 12) return None def next_occurrence(remind_at: datetime, recurrence: str, after: datetime) -> datetime | None: """The next reminder fire time strictly after `after`, rolling a recurring reminder forward past any missed occurrences. None if `recurrence` isn't a known interval.""" nxt = _advance_once(remind_at, recurrence) if nxt is None: return None while nxt <= after: step = _advance_once(nxt, recurrence) if step is None or step == nxt: break nxt = step return nxt