From 17b025dce305df491d64f1fd34b53590138da17e Mon Sep 17 00:00:00 2001 From: alexey Date: Sat, 9 May 2026 20:05:16 +0300 Subject: [PATCH] feat(domain): add UserStats type for GraphQL-based stats (GIT-33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces api/internal/domain.UserStats — the target structure for the GraphQL stats refactor. Mirrors the GraphQL response: PR/issue counts by state, totalCommits/totalPRReviews from contributionsCollection, top-100 repos aggregates (TotalRepos/TotalStars/Languages), and a ContributionCalendar with a CurrentStreak helper. Legacy GithubUserStats/GithubUserData/GithubRepository remain in place and continue to drive the existing flow; they will be removed in PR 7 once the new banner template fully consumes UserStats. --- api/internal/domain/user_stats.go | 78 ++++++++++++++++++++++++++ api/internal/domain/user_stats_test.go | 64 +++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 api/internal/domain/user_stats.go create mode 100644 api/internal/domain/user_stats_test.go diff --git a/api/internal/domain/user_stats.go b/api/internal/domain/user_stats.go new file mode 100644 index 0000000..0640d0d --- /dev/null +++ b/api/internal/domain/user_stats.go @@ -0,0 +1,78 @@ +package domain + +import "time" + +// UserStats is the aggregated GitHub statistics for a user, populated from a +// single GraphQL query. Replaces the legacy GithubUserData + GithubUserStats +// pair as part of GIT-33. The legacy types remain in this package while the +// migration is in progress and will be removed once the new banner template +// fully consumes UserStats. +type UserStats struct { + Username string + FetchedAt time.Time + + TotalRepos int + TotalStars int + Languages map[string]int + + OpenPRs int + ClosedPRs int + MergedPRs int + + OpenIssues int + ClosedIssues int + + TotalCommits int + TotalPRsCreated int + TotalIssuesCreated int + TotalPRReviews int + + ContributionCalendar ContributionCalendar + CurrentStreak int +} + +type ContributionCalendar struct { + TotalContributions int + Weeks []ContributionWeek +} + +type ContributionWeek struct { + FirstDay time.Time + Days []ContributionDay +} + +type ContributionDay struct { + Date time.Time + Count int +} + +// CurrentStreak counts consecutive trailing days with at least one contribution. +// The most recent day is allowed to be zero (treated as "in progress today") +// without breaking the streak; any earlier zero day terminates the count. +func (c ContributionCalendar) CurrentStreak() int { + if len(c.Weeks) == 0 { + return 0 + } + + days := make([]ContributionDay, 0, len(c.Weeks)*7) + for _, w := range c.Weeks { + days = append(days, w.Days...) + } + if len(days) == 0 { + return 0 + } + + i := len(days) - 1 + if days[i].Count == 0 { + i-- + } + + streak := 0 + for ; i >= 0; i-- { + if days[i].Count == 0 { + break + } + streak++ + } + return streak +} diff --git a/api/internal/domain/user_stats_test.go b/api/internal/domain/user_stats_test.go new file mode 100644 index 0000000..1047adb --- /dev/null +++ b/api/internal/domain/user_stats_test.go @@ -0,0 +1,64 @@ +package domain + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func calendarFromCounts(counts ...int) ContributionCalendar { + weeks := make([]ContributionWeek, 0) + var current ContributionWeek + for i, c := range counts { + current.Days = append(current.Days, ContributionDay{Count: c}) + if len(current.Days) == 7 || i == len(counts)-1 { + weeks = append(weeks, current) + current = ContributionWeek{} + } + } + return ContributionCalendar{Weeks: weeks} +} + +func TestCurrentStreakEmpty(t *testing.T) { + require.Equal(t, 0, ContributionCalendar{}.CurrentStreak()) + require.Equal(t, 0, calendarFromCounts().CurrentStreak()) +} + +func TestCurrentStreakAllZero(t *testing.T) { + cal := calendarFromCounts(0, 0, 0, 0, 0, 0, 0) + require.Equal(t, 0, cal.CurrentStreak()) +} + +func TestCurrentStreakAllActive(t *testing.T) { + cal := calendarFromCounts(1, 2, 3, 4, 5, 6, 7) + require.Equal(t, 7, cal.CurrentStreak()) +} + +func TestCurrentStreakInterruptedByZero(t *testing.T) { + cal := calendarFromCounts(5, 5, 5, 0, 2, 3, 4) + require.Equal(t, 3, cal.CurrentStreak()) +} + +func TestCurrentStreakTodayZeroIgnored(t *testing.T) { + cal := calendarFromCounts(1, 2, 3, 0) + require.Equal(t, 3, cal.CurrentStreak()) +} + +func TestCurrentStreakTodayZeroAndYesterdayZero(t *testing.T) { + cal := calendarFromCounts(5, 5, 5, 0, 0) + require.Equal(t, 0, cal.CurrentStreak()) +} + +func TestCurrentStreakSpansMultipleWeeks(t *testing.T) { + cal := calendarFromCounts( + 0, 0, 0, 0, 0, 0, 0, + 2, 2, 2, 2, 2, 2, 2, + 3, 3, 3, 3, 3, 3, 3, + ) + require.Equal(t, 14, cal.CurrentStreak()) +} + +func TestCurrentStreakSingleDayActive(t *testing.T) { + cal := calendarFromCounts(7) + require.Equal(t, 1, cal.CurrentStreak()) +}