feat: add all-time variants of productive, language, contribution cards

Four time-bounded cards ("last year") now have all-time counterparts, and
the stats card gains a lifetime commits row.

New cards:
- 6-most-commit-language-all-time.svg (byte-weighted, all lifetime commits)
- 7-productive-time-all-time.svg       (hour histogram over all lifetime commits)
- 8-contributions-all-time.svg         (area chart spanning every active year)

Data pipeline:
- Drop the "since" filter from commitHistoryQuery; FetchProductive now
  paginates unbounded commits and splits each commit into last-year and
  all-time buckets in a single pass — no extra API calls.
- New contributionYearQuery iterates user.contributionYears to
  concatenate calendar data and accumulate TotalCommitsAllTime.
- -commits-per-repo default bumped 100 → 500 to give all-time depth.

Polish:
- Productive-time title embeds the configured tz as "UTC±N.NN" (e.g.
  UTC+7.00) on both last-year and all-time cards.
- Contribution x-axis flipped to mm/yy with an "mm/yy" footer caption
  paralleling productive-time's "hour of day".
- Contribution x-axis label stride now targets ~6 labels regardless of
  bucket count so the all-time chart (~100 months) stays readable while
  the underlying curve still samples every month.
This commit is contained in:
2026-04-18 21:40:46 +07:00
parent 01e97627f5
commit 208629cf8f
11 changed files with 280 additions and 58 deletions
+72
View File
@@ -0,0 +1,72 @@
package github
import (
"sort"
"time"
)
// contributionYearGQL mirrors contributionYearQuery.
type contributionYearGQL struct {
User *struct {
ContributionsCollection struct {
TotalCommitContributions int `json:"totalCommitContributions"`
ContributionCalendar struct {
Weeks []struct {
ContributionDays []struct {
ContributionCount int `json:"contributionCount"`
Date string `json:"date"`
} `json:"contributionDays"`
} `json:"weeks"`
} `json:"contributionCalendar"`
} `json:"contributionsCollection"`
} `json:"user"`
}
// FetchContributionsAllTime iterates p.ContributionYears and issues one
// contributionsCollection query per year, concatenating the daily calendar
// into p.DailyContributionsAllTime and accumulating commit counts into
// p.TotalCommitsAllTime.
//
// Cost: one GraphQL call per active year (typically 110 per user).
func (c *Client) FetchContributionsAllTime(p *Profile) error {
years := append([]int(nil), p.ContributionYears...)
sort.Ints(years) // ascending so the concatenated series is oldest→newest
for _, y := range years {
from := time.Date(y, 1, 1, 0, 0, 0, 0, time.UTC)
to := time.Date(y, 12, 31, 23, 59, 59, 0, time.UTC)
// Clamp the current year's window to now so GitHub doesn't reject it.
if now := time.Now().UTC(); to.After(now) {
to = now
}
vars := map[string]any{
"login": p.Login,
"from": from.Format(time.RFC3339),
"to": to.Format(time.RFC3339),
}
var resp contributionYearGQL
if err := c.query(contributionYearQuery, vars, &resp); err != nil {
return err
}
if resp.User == nil {
continue
}
cc := resp.User.ContributionsCollection
p.TotalCommitsAllTime += cc.TotalCommitContributions
for _, w := range cc.ContributionCalendar.Weeks {
for _, d := range w.ContributionDays {
t, err := time.Parse("2006-01-02", d.Date)
if err != nil {
continue
}
p.DailyContributionsAllTime = append(p.DailyContributionsAllTime, DailyContribution{
Date: t,
Count: d.ContributionCount,
})
}
}
}
return nil
}
+33 -9
View File
@@ -19,14 +19,15 @@ type Profile struct {
PublicRepos int
// Totals for the stats card.
TotalStars int
TotalForks int
TotalCommits int
TotalPRs int
TotalIssues int
TotalReviews int
TotalContributedTo int
TotalContributions int // lifetime contributions from calendar + restricted
TotalStars int
TotalForks int
TotalCommits int // last year, from contributionsCollection
TotalCommitsAllTime int // sum across contributionYears
TotalPRs int
TotalIssues int
TotalReviews int
TotalContributedTo int
TotalContributions int // lifetime contributions from calendar + restricted
// Count of owned repos grouped by primary language, sorted desc by Value.
ReposByLanguage []LangStat
@@ -36,16 +37,39 @@ type Profile struct {
CommitsByLanguage []LangStat
// Commit counts grouped by hour-of-day (0-23) in the configured timezone.
Productive [24]int
// Productive is the last-year slice; ProductiveAllTime is the lifetime
// slice derived from the same paginated commit history so we pay for
// pagination once.
Productive [24]int
ProductiveAllTime [24]int
// CommitsByLanguageAllTime is the lifetime counterpart of
// CommitsByLanguage, computed from the same commit stream.
CommitsByLanguageAllTime []LangStat
// DailyContributions is the raw per-day contribution calendar covering
// the most recent year. The area chart aggregates it into monthly
// buckets; kept granular here so any downstream card can re-bin freely.
DailyContributions []DailyContribution
// DailyContributionsAllTime concatenates contribution calendars across
// every year the user has been active (user.contributionYears), so the
// all-time area chart can show history beyond the default 1-year window.
DailyContributionsAllTime []DailyContribution
// TopRepos are owned repos sorted by stargazer count desc. Populated by
// FetchProfile and consumed by FetchProductive.
TopRepos []RepoInfo
// ContributionYears lists every calendar year the user has been active
// on GitHub, newest first. Used by FetchContributionsAllTime to iterate
// per-year contributionsCollection queries.
ContributionYears []int
// UTCOffsetLabel is the configured timezone rendered as "UTC±N.NN" for
// display on time-based cards (e.g. "UTC+7.00" for Asia/Saigon). Filled
// by the CLI after loading -tz.
UTCOffsetLabel string
}
// DailyContribution is a single day in the contributions calendar.
+20 -20
View File
@@ -29,29 +29,24 @@ type productiveGQL struct {
// magnitude is irrelevant because the card renders percentages.
const scaleFactor = 10_000
// FetchProductive fills p.Productive with a 24-hour commit histogram over the
// last year and p.CommitsByLanguage with commit counts distributed across each
// repo's language byte breakdown. Commits are gathered from the given repos
// (usually p.TopRepos[:N]); each repo is sampled up to maxPerRepo commits to
// keep the cost bounded.
// FetchProductive paginates the default-branch commit history (authored by
// the target user) for each repo up to maxPerRepo commits, and fills two
// parallel sets of aggregates on the Profile:
//
// Attribution model: each commit contributes a whole scaleFactor unit,
// partitioned across the repo's languages proportional to linguist byte
// counts. A repo that is 60% Go / 40% Python credits 0.6 to Go and 0.4 to
// Python per commit — a strict upgrade over the previous primary-language-
// only model. Prose languages (Markdown, AsciiDoc, …) remain excluded by
// linguist itself, so blog-style repos still skew toward their detected
// code fraction; fixing that requires per-commit file classification.
// - Last-year: p.Productive (24h histogram) and p.CommitsByLanguage
// - All-time: p.ProductiveAllTime and p.CommitsByLanguageAllTime
//
// The timezone loc is applied to CommittedDate so the heatmap reflects when
// the user actually commits, not UTC.
// One pagination pass populates both, so the all-time cards come at no extra
// API cost beyond the pages already required for the last-year bucket.
// loc is applied to CommittedDate so the heatmap reflects the user's tz.
func (c *Client) FetchProductive(p *Profile, repos []RepoInfo, loc *time.Location, maxPerRepo int) error {
if loc == nil {
loc = time.UTC
}
since := time.Now().AddDate(-1, 0, 0).UTC().Format(time.RFC3339)
yearAgo := time.Now().AddDate(-1, 0, 0)
commitsByLang := map[string]int64{}
lastYearLang := map[string]int64{}
allTimeLang := map[string]int64{}
langColor := map[string]string{}
for _, repo := range repos {
@@ -65,7 +60,6 @@ func (c *Client) FetchProductive(p *Profile, repos []RepoInfo, loc *time.Locatio
"login": p.Login,
"repo": repo.Name,
"userId": p.ID,
"since": since,
}
if cursor != nil {
vars["after"] = *cursor
@@ -85,8 +79,13 @@ func (c *Client) FetchProductive(p *Profile, repos []RepoInfo, loc *time.Locatio
if err != nil {
continue
}
p.Productive[t.In(loc).Hour()]++
attributeCommit(repo, commitsByLang, langColor)
tl := t.In(loc)
p.ProductiveAllTime[tl.Hour()]++
attributeCommit(repo, allTimeLang, langColor)
if tl.After(yearAgo) {
p.Productive[tl.Hour()]++
attributeCommit(repo, lastYearLang, langColor)
}
seen++
}
if !h.PageInfo.HasNextPage {
@@ -97,7 +96,8 @@ func (c *Client) FetchProductive(p *Profile, repos []RepoInfo, loc *time.Locatio
}
}
p.CommitsByLanguage = sortLangStats(commitsByLang, langColor)
p.CommitsByLanguage = sortLangStats(lastYearLang, langColor)
p.CommitsByLanguageAllTime = sortLangStats(allTimeLang, langColor)
return nil
}
+8 -6
View File
@@ -28,12 +28,13 @@ type profileGQL struct {
RepositoriesContributedTo struct{ TotalCount int } `json:"repositoriesContributedTo"`
ContributionsCollection struct {
TotalCommitContributions int `json:"totalCommitContributions"`
TotalIssueContributions int `json:"totalIssueContributions"`
TotalPullRequestContributions int `json:"totalPullRequestContributions"`
TotalPullRequestReviewContributions int `json:"totalPullRequestReviewContributions"`
TotalRepositoryContributions int `json:"totalRepositoryContributions"`
RestrictedContributionsCount int `json:"restrictedContributionsCount"`
ContributionYears []int `json:"contributionYears"`
TotalCommitContributions int `json:"totalCommitContributions"`
TotalIssueContributions int `json:"totalIssueContributions"`
TotalPullRequestContributions int `json:"totalPullRequestContributions"`
TotalPullRequestReviewContributions int `json:"totalPullRequestReviewContributions"`
TotalRepositoryContributions int `json:"totalRepositoryContributions"`
RestrictedContributionsCount int `json:"restrictedContributionsCount"`
ContributionCalendar struct {
TotalContributions int `json:"totalContributions"`
Weeks []struct {
@@ -107,6 +108,7 @@ func (c *Client) FetchProfile(login string) (*Profile, error) {
p.TotalCommits = cc.TotalCommitContributions
p.TotalReviews = cc.TotalPullRequestReviewContributions
p.TotalContributions = cc.ContributionCalendar.TotalContributions + cc.RestrictedContributionsCount
p.ContributionYears = append([]int(nil), cc.ContributionYears...)
// Flatten week → day into a linear daily series sorted by date.
for _, w := range cc.ContributionCalendar.Weeks {
+23 -2
View File
@@ -24,6 +24,7 @@ query($login: String!, $after: String) {
contributionTypes: [COMMIT, PULL_REQUEST, ISSUE, PULL_REQUEST_REVIEW]
) { totalCount }
contributionsCollection {
contributionYears
totalCommitContributions
totalIssueContributions
totalPullRequestContributions
@@ -69,12 +70,12 @@ query($login: String!, $after: String) {
// repo, filtered to commits authored by the target user. Used to build the
// productive-time heatmap.
const commitHistoryQuery = `
query($login: String!, $repo: String!, $userId: ID!, $since: GitTimestamp!, $after: String) {
query($login: String!, $repo: String!, $userId: ID!, $after: String) {
repository(owner: $login, name: $repo) {
defaultBranchRef {
target {
... on Commit {
history(first: 100, after: $after, author: { id: $userId }, since: $since) {
history(first: 100, after: $after, author: { id: $userId }) {
pageInfo { hasNextPage endCursor }
nodes { committedDate }
}
@@ -83,3 +84,23 @@ query($login: String!, $repo: String!, $userId: ID!, $since: GitTimestamp!, $aft
}
}
}`
// contributionYearQuery fetches a single year's contribution calendar days
// plus the commit total for that year. Looped in Go over user.contributionYears
// to build the all-time contribution series and lifetime commit count.
const contributionYearQuery = `
query($login: String!, $from: DateTime!, $to: DateTime!) {
user(login: $login) {
contributionsCollection(from: $from, to: $to) {
totalCommitContributions
contributionCalendar {
weeks {
contributionDays {
contributionCount
date
}
}
}
}
}
}`