""" Python port of the frontend schedule logic in scheduleUtils.ts. These utilities determine whether a chore is scheduled on a given day and what its deadline time is. The frontend is the source of truth for schedule math; keep this file in sync with any changes to scheduleUtils.ts. """ from datetime import date def interval_hits_today(anchor_date: str, interval_days: int, local_date: date) -> bool: """Return True if an interval-mode schedule fires on local_date. anchor_date: ISO date string e.g. "2026-02-25", or "" (backward compat → treats local_date itself as anchor, so it always hits today and every interval_days days after). Dates before the anchor always return False (scheduling hasn't started yet). """ if anchor_date: parts = anchor_date.split('-') anchor = date(int(parts[0]), int(parts[1]), int(parts[2])) else: # Empty anchor = start from today (backward compat) anchor = local_date diff_days = (local_date - anchor).days if diff_days < 0: return False return diff_days % interval_days == 0 def is_scheduled_today(schedule: dict, local_date: date) -> bool: """Return True if the schedule applies on local_date. - days mode: today's weekday (0=Sun … 6=Sat) is in day_configs - interval mode: interval_hits_today - paused (enabled=False): always True (chore shows every day, like no schedule) """ if not schedule.get('enabled', True): return True # paused = show always, like an unscheduled chore mode = schedule.get('mode', 'days') if mode == 'days': # Python's weekday(): Mon=0..Sun=6. Our convention: Sun=0..Sat=6 (JS/TS). # Convert: js_day = (python_weekday + 1) % 7 js_weekday = (local_date.weekday() + 1) % 7 day_configs = schedule.get('day_configs', []) return any(dc.get('day') == js_weekday for dc in day_configs) else: return interval_hits_today( schedule.get('anchor_date', ''), schedule.get('interval_days', 2), local_date, ) def get_due_time_today(schedule: dict, local_date: date) -> tuple[int, int] | None: """Return (hour, minute) for the deadline today, or None if no deadline applies. Returns None when: - The schedule is paused (enabled=False) — no deadline, acts like Anytime - The day is not scheduled - The schedule has no deadline (default_has_deadline=False or interval_has_deadline=False → 'Anytime') Callers treat None as 'active all day with no expiry'. """ if not schedule.get('enabled', True): return None # paused = no deadline, no expiry mode = schedule.get('mode', 'days') if mode == 'days': if not schedule.get('default_has_deadline', True): return None # Anytime js_weekday = (local_date.weekday() + 1) % 7 day_configs = schedule.get('day_configs', []) dc = next((d for d in day_configs if d.get('day') == js_weekday), None) if dc is None: return None return (dc.get('hour', 0), dc.get('minute', 0)) else: if not interval_hits_today( schedule.get('anchor_date', ''), schedule.get('interval_days', 2), local_date, ): return None if not schedule.get('interval_has_deadline', True): return None # Anytime return (schedule.get('interval_hour', 0), schedule.get('interval_minute', 0))