"""Shared day-axis helper for analytics time-series. Round-62 review surfaced THREE independent re-introductions of the same off-by-one bug (audit / scheduled_jobs / approval daily charts silently drop today). The original fix (PR #574) lived inline in each service and regressed in PR #598 because no shared contract kept the sites in lock-step. This module is the single source of truth. Use it for every ``daily_series`` style chart. Contract: * SQL filter: ``created_at >= window_start`` (or analogous column). * Bucket labels: ``[first_day, first_day+1d, …, today]`` — exactly ``window_days`` entries, ending on TODAY (inclusive), all aligned to start-of-day in the caller's timezone (default UTC). * ``window_start`` is the same start-of-day as ``first_day`` so the SQL filter and the bucket loop cover the same N days. Usage:: from app.core.timeseries import day_axis axis = day_axis(window_days=7, now=datetime.now(UTC)) rows = await db.execute( select(...).where(SomeModel.created_at >= axis.window_start) .group_by(func.date(SomeModel.created_at), ...) ) daily_map = {…} series = [ {"date": d.isoformat(), "value": daily_map.get(d.isoformat(), 0)} for d in axis.days ] """ from __future__ import annotations from dataclasses import dataclass from datetime import UTC, date, datetime, time, timedelta, tzinfo @dataclass(frozen=True) class DayAxis: """Aligned day-axis for a trailing-window chart. Attributes: window_days: number of days the axis covers (e.g. 7 for a week trend). Must be >= 1. days: ordered list of N ``date`` values — ``[today-N+1, …, today]``. window_start: ``datetime`` at start-of-day of ``days[0]``, with ``tzinfo`` attached. SQL filters MUST use this exact value so the WHERE clause and the bucket loop describe the same interval. """ window_days: int days: list[date] window_start: datetime @property def first_day(self) -> date: return self.days[0] @property def today(self) -> date: return self.days[-1] def day_axis( *, window_days: int, now: datetime, tz: tzinfo | None = None, ) -> DayAxis: """Build an N-day axis ending on TODAY (inclusive). ``now`` is the moment defining "today" (caller passes ``datetime.now(UTC)`` or ``datetime.now(tz)``). ``tz`` overrides the timezone attached to ``window_start``; defaults to ``now.tzinfo`` or UTC if ``now`` is naive. Raises: ValueError: ``window_days < 1``. """ if window_days < 1: raise ValueError(f"window_days must be >= 1, got {window_days!r}") resolved_tz = tz or now.tzinfo or UTC today = now.date() first_day = today - timedelta(days=window_days - 1) days = [first_day + timedelta(days=i) for i in range(window_days)] window_start = datetime.combine(first_day, time.min, tzinfo=resolved_tz) return DayAxis(window_days=window_days, days=days, window_start=window_start) __all__ = ["DayAxis", "day_axis"]