summaryrefslogtreecommitdiff
path: root/src/srht_contrib/services/aggregator.py
blob: 90ef93bb739ab2349158ae443daa46e1faf5ba9f (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
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, TrackedActor
from srht_contrib.schemas import (
    ContributionCalendarResponse,
    ContributionDay,
    ContributionIndexMetadata,
    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)
        ]
        metadata = self._index_metadata(db, actor)
        return ContributionCalendarResponse(
            actor=actor,
            from_date=start,
            to_date=end,
            days=days,
            **metadata.model_dump(),
        )

    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,
            is_indexed=calendar.is_indexed,
            last_polled_at=calendar.last_polled_at,
            indexing_state=calendar.indexing_state,
        )

    def _index_metadata(self, db: Session, actor: str) -> ContributionIndexMetadata:
        tracked_actor = db.scalar(select(TrackedActor).where(TrackedActor.actor == actor))
        has_indexed_events = db.scalar(select(ContributionEvent.id).where(ContributionEvent.actor == actor).limit(1)) is not None
        last_poll_status = tracked_actor.last_poll_status if tracked_actor is not None else None
        is_indexed = has_indexed_events or (tracked_actor is not None and tracked_actor.last_polled_at is not None)

        if last_poll_status == "error":
            indexing_state = "error"
        elif is_indexed:
            indexing_state = "indexed"
        else:
            indexing_state = "pending"

        return ContributionIndexMetadata(
            is_indexed=is_indexed,
            last_polled_at=tracked_actor.last_polled_at if tracked_actor is not None else None,
            indexing_state=indexing_state,
        )

    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