# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """The daily tally report. Time is injected so the tests assert on the computed interval instead of sleeping through it. """ from __future__ import annotations import datetime as dt import logging from collections.abc import Awaitable, Callable logger = logging.getLogger(__name__) Clock = Callable[[], dt.datetime] Sleeper = Callable[[float], Awaitable[None]] Action = Callable[[], Awaitable[None]] SECONDS_PER_DAY = 24 * 60 * 60 class DailyScheduler: """Runs one action at a fixed local wall-clock time, every day.""" def __init__( self, hour: int, minute: int, action: Action, clock: Clock, sleeper: Sleeper, ) -> None: """Store the target time, the action, and the injected time sources.""" self._hour = hour self._minute = minute self._action = action self._clock = clock self._sleep = sleeper def seconds_until_next(self, now: dt.datetime) -> float: """Seconds from now until the next occurrence of the target time. Rolls over to tomorrow when today's target has already passed, so the result is always strictly positive. """ target = now.replace(hour=self._hour, minute=self._minute, second=0, microsecond=0) if target <= now: target += dt.timedelta(days=1) return (target - now).total_seconds() async def run(self, iterations: int | None = None) -> None: """Sleep until the target time and run the action, forever by default. Args: iterations: Stop after this many runs. None means run forever. The tests use a finite count; production passes None. """ completed = 0 while iterations is None or completed < iterations: await self._sleep(self.seconds_until_next(self._clock())) try: await self._action() except Exception: logger.exception("daily report failed") completed += 1