soulsync/core/automation
Broque Thomas ec4a55c104 Add next_run_at pure function for Auto-Sync schedule types (PR 1/4)
Backend plumbing for upcoming weekly + monthly Auto-Sync schedules.
PR 1 of 4 in the schedule-types feature — see
``memory/project_auto_sync_schedule_types.md`` for the full plan.

Net behaviour change in this PR: zero. The automation engine still
computes next_run via its existing inline ``_calc_delay_seconds`` /
``_next_weekly_occurrence`` helpers; this module is unused until PR 2
wires the engine through. Lands separately so the foundation can sit
on dev for a beat before the engine change.

``core/automation/schedule.py:next_run_at(trigger_type, trigger_config,
now_utc, default_tz)``:
- Pure function. ``now_utc`` injected (tests freeze time without
  monkeypatching ``datetime.now``); ``default_tz`` injected (so daily /
  weekly / monthly schedules compute against the USER's timezone, not
  the server's — the same class of bug that produced the May 2026
  "Auto-Sync next in 8h" timezone fix).
- Returns aware-UTC ``datetime`` ready to serialise to the DB
  ``next_run`` column, or ``None`` for unrecognised / event-based
  triggers (callers should not write a next_run for those).
- Naive ``now_utc`` inputs are assumed UTC for defensive symmetry
  with the engine's DB-string parser convention.

Trigger types covered:
- ``schedule``: ``{interval: N, unit: 'minutes'|'hours'|'days'|'weeks'}``
  — matches engine's existing ``_calc_delay_seconds``. Unknown unit
  defaults to hours; zero/negative interval clamps to 1 (preserves
  the engine's guard against scheduling for the past); non-numeric
  interval falls back to 1.
- ``daily_time``: ``{time: 'HH:MM', tz: '<IANA>'}`` — DST-aware via
  ``zoneinfo``; ``tz`` falls back to ``default_tz``; unknown IANA
  string falls back to UTC; garbage ``time`` falls back to 00:00.
- ``weekly_time``: ``{time, days: ['mon',...], tz}`` — empty / all-
  invalid ``days`` list means "every day" (matches engine fallback);
  abbreviations case-insensitive; 8-day scan finds the next match.
- ``monthly_time``: ``{time, day_of_month: 1-31, tz}`` — NEW shape.
  Day clamped to [1, 31]. Months too short for the target day clamp
  to the LAST valid day rather than skipping a month (standard cron
  convention; running a day early in February is less surprising
  than missing the whole month). 12-iteration loop cap so a
  pathological config can't infinite-loop.

Tests (36 cases, all passing):
- Interval: every unit, unknown-unit fallback, zero/negative/garbage
  interval clamp, tz field ignored on interval (wall-clock-independent).
- Daily: today-at-future-time runs today, today-at-past-time rolls to
  tomorrow, exact-match rolls to tomorrow (no schedule-now-then-schedule-
  again-immediately), user-tz vs server-tz, default_tz fallback,
  garbage time / unknown tz defensive returns.
- Weekly: same-day-still-future qualifies, same-day-past rolls to next
  allowed day, wraps across week boundary, empty days = every day,
  garbage abbreviations dropped, case-insensitive, tz across day
  boundary (LA Wednesday evening is Thursday UTC).
- Monthly: target day this month, rolls to next month when passed,
  Feb 31 → Feb 28 / Feb 29 leap year, day_of_month above 31 / below
  1 clamp, Dec → Jan year roll, user-tz pre-midnight edge case.
- Result-shape contract: every returned datetime is aware UTC at
  offset zero (engine relies on this when serialising to the
  ``next_run`` string column).

Added ``tzdata==2026.2`` to requirements.txt. Windows ``zoneinfo`` and
minimal Docker base images ship without the system tz database;
without ``tzdata`` ``ZoneInfo('America/Los_Angeles')`` raises
``ZoneInfoNotFoundError`` and the helper silently falls back to UTC.

No WHATS_NEW entry — no user-visible behaviour change in this PR.
PR 2 (engine wire-through) will land the user-facing changelog entry
when ``monthly_time`` becomes a real schedulable trigger.
2026-05-27 11:15:47 -07:00
..
handlers Fold provider-matching into PlaylistSource contract (Phase 1b) 2026-05-26 13:07:01 -07:00
__init__.py Extract automation handlers (1/N): infrastructure + 3 simple handlers 2026-05-15 10:25:41 -07:00
api.py Add owned_by column for Auto-Sync schedule ownership 2026-05-24 23:40:22 -07:00
blocks.py Personalized playlist pipeline: auto-sync discover-page playlists 2026-05-15 18:41:08 -07:00
deps.py Refactor refresh_mirrored to use unified PlaylistSource registry 2026-05-26 12:52:39 -07:00
progress.py Surface silent exceptions across remaining modules — ~70 sites 2026-05-07 10:28:58 -07:00
schedule.py Add next_run_at pure function for Auto-Sync schedule types (PR 1/4) 2026-05-27 11:15:47 -07:00
signals.py Add module logger + surface silent exceptions in 7 logger-less files — 12 sites 2026-05-07 10:27:04 -07:00