From e09dad24e8697db56b964320876bc2ebb503590c Mon Sep 17 00:00:00 2001 From: abccodes Date: Mon, 22 Jun 2026 23:46:05 -0700 Subject: [PATCH] [aidan] feat/schedule: pin monthly last-day-of-month --- backend/apps/workflows/models.py | 3 +++ backend/apps/workflows/scheduler.py | 10 ++++++--- backend/tests/test_schedule_e2e.py | 24 +++++++++++++++++++++ frontend/src/shared/state/workflowsSlice.ts | 2 ++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/backend/apps/workflows/models.py b/backend/apps/workflows/models.py index e0f7edc5..7f89a5b7 100644 --- a/backend/apps/workflows/models.py +++ b/backend/apps/workflows/models.py @@ -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 diff --git a/backend/apps/workflows/scheduler.py b/backend/apps/workflows/scheduler.py index 7450ca00..6fd0386e 100644 --- a/backend/apps/workflows/scheduler.py +++ b/backend/apps/workflows/scheduler.py @@ -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: diff --git a/backend/tests/test_schedule_e2e.py b/backend/tests/test_schedule_e2e.py index 6e50afc0..e2256f91 100644 --- a/backend/tests/test_schedule_e2e.py +++ b/backend/tests/test_schedule_e2e.py @@ -345,3 +345,27 @@ async def test_kick_wakes_loop_before_timeout(monkeypatch): await asyncio.wait_for(fired.wait(), timeout=3.0) finally: await scheduler.stop() + + +async def test_last_day_of_month_fires_on_month_end(): + """last_day_of_month ignores day_of_month and lands on the calendar's + final day, so it survives short months (Feb) instead of clamping.""" + from backend.apps.workflows.models import ScheduleConfig + from backend.apps.workflows import scheduler + sched = ScheduleConfig( + enabled=True, repeat_unit="month", repeat_every=1, + day_of_month=15, last_day_of_month=True, hour=9, minute=0, + timezone="America/Los_Angeles", + ) + wf = _make_wf(schedule=sched) + tz = ZoneInfo("America/Los_Angeles") + # From mid-February, the next fire is Feb 28 (or 29 on a leap year), NOT the 15th. + ref = datetime(2026, 2, 10, 12, 0, tzinfo=tz).astimezone(timezone.utc) + nxt = scheduler.compute_next_fire(wf, ref=ref) + local = nxt.astimezone(tz) + assert local.month == 2 and local.day == 28 + assert local.hour == 9 + # From end of Feb, the following fire rolls to Mar 31 (last day again). + nxt2 = scheduler.compute_next_fire(wf, ref=nxt) + local2 = nxt2.astimezone(tz) + assert local2.month == 3 and local2.day == 31 diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index aecc2f1f..bf7d5b30 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -19,6 +19,8 @@ export interface ScheduleConfig { hour: number; minute: number; day_of_month?: number | null; + /** Monthly schedules fire on the calendar's last day (28-31) when true. */ + last_day_of_month?: boolean; timezone: string; /** End conditions; null on both = forever. Scheduler auto-disables on threshold. */ ends_at: string | null;