krz/hutch-stats

Server-side utility for calculating contributions for sourcehut users.

clone: git clone https://gitbay.org/krz/hutch-stats.git

main: src/srht_contrib/services/aggregator.py · raw

  1from __future__ import annotations
  2
  3from dataclasses import dataclass
  4from datetime import date
  5
  6from sqlalchemy import func, select
  7from sqlalchemy.orm import Session
  8
  9from srht_contrib.models import ContributionEvent, TrackedActor
 10from srht_contrib.schemas import (
 11    ContributionCalendarResponse,
 12    ContributionDay,
 13    ContributionIndexMetadata,
 14    ContributionStatsResponse,
 15)
 16from srht_contrib.utils.dates import date_range, date_to_utc_bounds
 17
 18
 19@dataclass(slots=True)
 20class DailyAggregate:
 21    date: date
 22    count: int
 23    score: float
 24
 25
 26class ContributionAggregator:
 27    def build_calendar(self, db: Session, actor: str, start: date, end: date) -> ContributionCalendarResponse:
 28        aggregates = self._query_daily_aggregates(db, actor, start, end)
 29        by_day = {row.date: row for row in aggregates}
 30        days = [
 31            ContributionDay(
 32                date=day,
 33                count=by_day.get(day, DailyAggregate(date=day, count=0, score=0.0)).count,
 34                score=by_day.get(day, DailyAggregate(date=day, count=0, score=0.0)).score,
 35            )
 36            for day in date_range(start, end)
 37        ]
 38        metadata = self._index_metadata(db, actor)
 39        return ContributionCalendarResponse(
 40            actor=actor,
 41            from_date=start,
 42            to_date=end,
 43            days=days,
 44            **metadata.model_dump(),
 45        )
 46
 47    def build_stats(self, db: Session, actor: str, start: date, end: date) -> ContributionStatsResponse:
 48        calendar = self.build_calendar(db, actor, start, end)
 49        active_days = [day for day in calendar.days if day.count > 0]
 50        streaks = self._streak_lengths(calendar.days)
 51        current_streak = self._current_streak(calendar.days)
 52
 53        return ContributionStatsResponse(
 54            actor=actor,
 55            from_date=start,
 56            to_date=end,
 57            total_events=sum(day.count for day in calendar.days),
 58            total_score=round(sum(day.score for day in calendar.days), 2),
 59            active_days=len(active_days),
 60            longest_streak=max(streaks, default=0),
 61            current_streak=current_streak,
 62            is_indexed=calendar.is_indexed,
 63            last_polled_at=calendar.last_polled_at,
 64            indexing_state=calendar.indexing_state,
 65            is_recent_window_backfilled=calendar.is_recent_window_backfilled,
 66            recent_backfill_state=calendar.recent_backfill_state,
 67            recent_backfill_completed_at=calendar.recent_backfill_completed_at,
 68        )
 69
 70    def _index_metadata(self, db: Session, actor: str) -> ContributionIndexMetadata:
 71        tracked_actor = db.scalar(select(TrackedActor).where(TrackedActor.actor == actor))
 72        has_indexed_events = db.scalar(select(ContributionEvent.id).where(ContributionEvent.actor == actor).limit(1)) is not None
 73        last_poll_status = tracked_actor.last_poll_status if tracked_actor is not None else None
 74        is_indexed = has_indexed_events or (tracked_actor is not None and tracked_actor.last_polled_at is not None)
 75
 76        if last_poll_status == "error":
 77            indexing_state = "error"
 78        elif is_indexed:
 79            indexing_state = "indexed"
 80        else:
 81            indexing_state = "pending"
 82
 83        return ContributionIndexMetadata(
 84            is_indexed=is_indexed,
 85            last_polled_at=tracked_actor.last_polled_at if tracked_actor is not None else None,
 86            indexing_state=indexing_state,
 87            is_recent_window_backfilled=(tracked_actor.recent_backfill_status == "completed") if tracked_actor is not None else False,
 88            recent_backfill_state=(tracked_actor.recent_backfill_status if tracked_actor is not None else "pending"),
 89            recent_backfill_completed_at=tracked_actor.recent_backfill_completed_at if tracked_actor is not None else None,
 90        )
 91
 92    def _query_daily_aggregates(self, db: Session, actor: str, start: date, end: date) -> list[DailyAggregate]:
 93        start_dt, _ = date_to_utc_bounds(start)
 94        _, end_dt = date_to_utc_bounds(end)
 95
 96        stmt = (
 97            select(
 98                func.date(ContributionEvent.occurred_at).label("day"),
 99                func.count(ContributionEvent.id).label("count"),
100                func.coalesce(func.sum(ContributionEvent.weight), 0.0).label("score"),
101            )
102            .where(ContributionEvent.actor == actor)
103            .where(ContributionEvent.occurred_at >= start_dt)
104            .where(ContributionEvent.occurred_at <= end_dt)
105            .group_by(func.date(ContributionEvent.occurred_at))
106            .order_by(func.date(ContributionEvent.occurred_at))
107        )
108        rows = db.execute(stmt).all()
109        return [
110            DailyAggregate(
111                date=date.fromisoformat(str(row.day)),
112                count=int(row.count),
113                score=round(float(row.score), 2),
114            )
115            for row in rows
116        ]
117
118    @staticmethod
119    def _streak_lengths(days: list[ContributionDay]) -> list[int]:
120        streaks: list[int] = []
121        current = 0
122        for day in days:
123            if day.count > 0:
124                current += 1
125            elif current > 0:
126                streaks.append(current)
127                current = 0
128        if current > 0:
129            streaks.append(current)
130        return streaks
131
132    @staticmethod
133    def _current_streak(days: list[ContributionDay]) -> int:
134        streak = 0
135        for day in reversed(days):
136            if day.count > 0:
137                streak += 1
138            else:
139                break
140        return streak