# steward/core/weekly.py """A weekly slot: a weekday and a time in a time zone, with a window after it. A slot fires once, and only while its window is open, so a Steward that was down over the slot starts late rather than a day late. A schedule is anything with weekday, at_time, time_zone, window_minutes, enabled, last_slot_at and created_at.""" from __future__ import annotations from datetime import datetime, time, timedelta, timezone from steward.core.formatting import zone WEEKDAYS = ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday") def parse_at(at_time: str) -> time: h, m = (int(x) for x in at_time.split(":", 1)) if not (0 <= h < 24 and 0 <= m < 60): raise ValueError(at_time) return time(h, m) def last_slot(plan, now: datetime) -> datetime: """The most recent scheduled time at or before `now`, in UTC.""" tz = zone(plan.time_zone) local = now.astimezone(tz) day = local.date() - timedelta(days=(local.weekday() - plan.weekday) % 7) slot = datetime.combine(day, parse_at(plan.at_time), tzinfo=tz) if slot > local: slot -= timedelta(days=7) return slot.astimezone(timezone.utc) def fired_slot(plan) -> datetime | None: """The last slot that fired, or None while none has. A new schedule's last_slot_at holds the moment it was made, so its first slot is the next one rather than one already past; that moment is no slot.""" at = plan.last_slot_at if at is None: return None return at if last_slot(plan, at) == at else None def next_slot(plan, now: datetime) -> datetime: return last_slot(plan, now) + timedelta(days=7) def is_due(plan, now: datetime) -> datetime | None: """The slot to fire now, or None.""" if not plan.enabled: return None slot = last_slot(plan, now) since = plan.last_slot_at or plan.created_at if since is not None and slot <= since: return None if now - slot >= timedelta(minutes=plan.window_minutes): return None return slot def describe(plan) -> str: """"Sundays at 03:00 (Europe/London), up to 4 h" for a page.""" hours = plan.window_minutes / 60 span = f"{hours:g} h" if plan.window_minutes % 60 == 0 else f"{plan.window_minutes} min" return f"{WEEKDAYS[plan.weekday]}s at {plan.at_time} ({plan.time_zone}), up to {span}"