diff options
| author | Christian Cleberg <[email protected]> | 2026-04-09 19:45:45 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-09 19:45:45 -0500 |
| commit | acbff854f2da96bddcaede1385e7fefeba0fb34b (patch) | |
| tree | 48d4707cd3276d2825370f0793008a6ffcfff936 /src/srht_contrib/services/aggregator.py | |
| download | hutch-stats-acbff854f2da96bddcaede1385e7fefeba0fb34b.tar.gz hutch-stats-acbff854f2da96bddcaede1385e7fefeba0fb34b.tar.bz2 hutch-stats-acbff854f2da96bddcaede1385e7fefeba0fb34b.zip | |
initial commit
Diffstat (limited to 'src/srht_contrib/services/aggregator.py')
| -rw-r--r-- | src/srht_contrib/services/aggregator.py | 100 |
1 files changed, 100 insertions, 0 deletions
diff --git a/src/srht_contrib/services/aggregator.py b/src/srht_contrib/services/aggregator.py new file mode 100644 index 0000000..800329a --- /dev/null +++ b/src/srht_contrib/services/aggregator.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from srht_contrib.models import ContributionEvent +from srht_contrib.schemas import ContributionCalendarResponse, ContributionDay, ContributionStatsResponse +from srht_contrib.utils.dates import date_range, date_to_utc_bounds + + +@dataclass(slots=True) +class DailyAggregate: + date: date + count: int + score: float + + +class ContributionAggregator: + def build_calendar(self, db: Session, actor: str, start: date, end: date) -> ContributionCalendarResponse: + aggregates = self._query_daily_aggregates(db, actor, start, end) + by_day = {row.date: row for row in aggregates} + days = [ + ContributionDay( + date=day, + count=by_day.get(day, DailyAggregate(date=day, count=0, score=0.0)).count, + score=by_day.get(day, DailyAggregate(date=day, count=0, score=0.0)).score, + ) + for day in date_range(start, end) + ] + return ContributionCalendarResponse(actor=actor, from_date=start, to_date=end, days=days) + + def build_stats(self, db: Session, actor: str, start: date, end: date) -> ContributionStatsResponse: + calendar = self.build_calendar(db, actor, start, end) + active_days = [day for day in calendar.days if day.count > 0] + streaks = self._streak_lengths(calendar.days) + current_streak = self._current_streak(calendar.days) + + return ContributionStatsResponse( + actor=actor, + from_date=start, + to_date=end, + total_events=sum(day.count for day in calendar.days), + total_score=round(sum(day.score for day in calendar.days), 2), + active_days=len(active_days), + longest_streak=max(streaks, default=0), + current_streak=current_streak, + ) + + def _query_daily_aggregates(self, db: Session, actor: str, start: date, end: date) -> list[DailyAggregate]: + start_dt, _ = date_to_utc_bounds(start) + _, end_dt = date_to_utc_bounds(end) + + stmt = ( + select( + func.date(ContributionEvent.occurred_at).label("day"), + func.count(ContributionEvent.id).label("count"), + func.coalesce(func.sum(ContributionEvent.weight), 0.0).label("score"), + ) + .where(ContributionEvent.actor == actor) + .where(ContributionEvent.occurred_at >= start_dt) + .where(ContributionEvent.occurred_at <= end_dt) + .group_by(func.date(ContributionEvent.occurred_at)) + .order_by(func.date(ContributionEvent.occurred_at)) + ) + rows = db.execute(stmt).all() + return [ + DailyAggregate( + date=date.fromisoformat(str(row.day)), + count=int(row.count), + score=round(float(row.score), 2), + ) + for row in rows + ] + + @staticmethod + def _streak_lengths(days: list[ContributionDay]) -> list[int]: + streaks: list[int] = [] + current = 0 + for day in days: + if day.count > 0: + current += 1 + elif current > 0: + streaks.append(current) + current = 0 + if current > 0: + streaks.append(current) + return streaks + + @staticmethod + def _current_streak(days: list[ContributionDay]) -> int: + streak = 0 + for day in reversed(days): + if day.count > 0: + streak += 1 + else: + break + return streak |
