[aidan] feat/schedule: pin monthly last-day-of-month

This commit is contained in:
abccodes
2026-06-22 23:46:05 -07:00
parent 087f531c9c
commit e09dad24e8
4 changed files with 36 additions and 3 deletions
+3
View File
@@ -29,6 +29,9 @@ class ScheduleConfig(BaseModel):
# Monthly schedules can pin a day-of-month explicitly. None preserves the
# legacy "same day as the current reference" behavior for older records.
day_of_month: Optional[int] = Field(default=None, ge=1, le=31)
# When true, monthly schedules fire on the calendar's last day (28-31)
# regardless of day_of_month, so "end of month" survives short months.
last_day_of_month: bool = False
# IANA zone name (e.g. "America/Los_Angeles") or "local" for legacy
# records that predate explicit tz. storage._load_all_from_disk coerces
# "local" to the host zone in memory; we leave it on disk until the
+7 -3
View File
@@ -145,13 +145,17 @@ def _next_fire_after(sched: ScheduleConfig, ref_utc: datetime) -> Optional[datet
if sched.repeat_unit == "month":
target_day = sched.day_of_month or ref_local.day
step = max(1, sched.repeat_every)
c = candidate.replace(day=min(target_day, calendar.monthrange(candidate.year, candidate.month)[1]))
def month_day(year: int, month: int) -> int:
last = calendar.monthrange(year, month)[1]
return last if sched.last_day_of_month else min(target_day, last)
c = candidate.replace(day=month_day(candidate.year, candidate.month))
while c <= ref_local:
total = c.month - 1 + step
year = c.year + total // 12
month = total % 12 + 1
day = min(target_day, calendar.monthrange(year, month)[1])
c = c.replace(year=year, month=month, day=day)
c = c.replace(year=year, month=month, day=month_day(year, month))
return c.astimezone(timezone.utc)
if candidate <= ref_local: