diff --git a/packages/server/src/notify_bridge_server/services/scheduled_dispatch.py b/packages/server/src/notify_bridge_server/services/scheduled_dispatch.py index b2ce193..cdf8ef7 100644 --- a/packages/server/src/notify_bridge_server/services/scheduled_dispatch.py +++ b/packages/server/src/notify_bridge_server/services/scheduled_dispatch.py @@ -22,7 +22,9 @@ the test-dispatch path in ``manual_dispatch``. from __future__ import annotations import logging +from datetime import datetime from typing import Literal +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession @@ -64,6 +66,43 @@ _SKIP_REASON_NO_LINKS = "no_enabled_links" _SKIP_REASON_NO_EVENT = "provider_returned_no_event" _SKIP_REASON_EMPTY_PAYLOAD = "zero_assets_matched" _SKIP_REASON_NO_TARGETS = "no_targets_after_filtering" +_SKIP_REASON_INTERVAL_NOT_DUE = "interval_not_due" + + +def _periodic_due_today(default_tc: TrackingConfig, app_tz: str) -> bool: + """Return True iff today (in app tz) falls on the periodic schedule. + + The schedule is ``start_date + N * interval_days`` for non-negative N. + The cron job fires every day at the configured ``periodic_times`` — this + gate skips the dispatch on days that aren't a multiple of the interval + from the start date, so a user-configured ``interval_days=3`` fires every + third day rather than every day. + + Defensive: an interval ≤ 1 is treated as "fire daily"; an unparseable + ``periodic_start_date`` is treated as "fire" so a bad config doesn't + silently stop all summaries (operators see notifications and can fix it). + """ + interval = max(1, int(getattr(default_tc, "periodic_interval_days", 1) or 1)) + if interval <= 1: + return True + raw_start = getattr(default_tc, "periodic_start_date", "") or "" + try: + start = datetime.strptime(raw_start, "%Y-%m-%d").date() + except (ValueError, TypeError): + _LOGGER.warning( + "Invalid periodic_start_date %r; treating periodic as due today", + raw_start, + ) + return True + try: + tz = ZoneInfo(app_tz) + except (ZoneInfoNotFoundError, ValueError): + tz = ZoneInfo("UTC") + today = datetime.now(tz).date() + delta_days = (today - start).days + if delta_days < 0: + return False + return delta_days % interval == 0 async def _log_skip( @@ -181,6 +220,28 @@ async def dispatch_scheduled_for_tracker( collection_ids = list(tracker.collection_ids or []) app_tz = await get_app_timezone(session) + + # Periodic summary respects an N-day interval starting from + # ``periodic_start_date`` — the cron job fires every day at + # ``periodic_times``, so without this gate a configured + # ``interval_days=3`` would still dispatch daily. Other kinds + # (scheduled/memory) don't have an interval setting. + if kind == "periodic" and not _periodic_due_today(default_tc, app_tz): + _LOGGER.info( + "Periodic for tracker %d: not due today (interval=%s, start=%s)", + tracker_id, + getattr(default_tc, "periodic_interval_days", None), + getattr(default_tc, "periodic_start_date", None), + ) + await _log_skip( + tracker_id, kind, _SKIP_REASON_INTERVAL_NOT_DUE, + tracker_user_id=tracker.user_id, + tracker_name=tracker.name or "", + provider_id=provider.id, + provider_name=provider.name or provider.type, + ) + return + link_data = await load_link_data(session, tracker_id) if not link_data: