diff --git a/internal/card/card.go b/internal/card/card.go index a3a5ffe7..993eea1a 100644 --- a/internal/card/card.go +++ b/internal/card/card.go @@ -27,6 +27,9 @@ var allCards = []Card{ statsCard{}, productiveCard{}, contributionsCard{}, + mostCommitLanguageAllTimeCard{}, + productiveAllTimeCard{}, + contributionsAllTimeCard{}, } // RenderAll writes every card into outDir//. diff --git a/internal/card/contributions.go b/internal/card/contributions.go index 1246adad..9d2d79cc 100644 --- a/internal/card/contributions.go +++ b/internal/card/contributions.go @@ -13,6 +13,18 @@ type contributionsCard struct{} func (contributionsCard) Filename() string { return "5-contributions.svg" } +func (contributionsCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) { + return renderContributions("Contributions (last year)", p.DailyContributions, t), nil +} + +type contributionsAllTimeCard struct{} + +func (contributionsAllTimeCard) Filename() string { return "8-contributions-all-time.svg" } + +func (contributionsAllTimeCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) { + return renderContributions("Contributions (all time)", p.DailyContributionsAllTime, t), nil +} + // monthBucket holds a calendar month's aggregate contribution count. type monthBucket struct { Year int @@ -20,7 +32,10 @@ type monthBucket struct { Count int } -func (contributionsCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) { +// renderContributions draws a smooth filled area chart with y-axis labels on +// both sides and YY/MM labels on the x-axis. Shared by last-year and all-time +// contribution cards; only title + data differ. +func renderContributions(title string, days []github.DailyContribution, t theme.Theme) []byte { const ( width = 500 height = 220 @@ -32,14 +47,14 @@ func (contributionsCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) { chartW := width - leftPad - rightPad var b strings.Builder - b.WriteString(header(width, height, t.Background, t.Stroke, t.StrokeOpacity, t.Title, "Contributions (last year)")) + b.WriteString(header(width, height, t.Background, t.Stroke, t.StrokeOpacity, t.Title, title)) - buckets := aggregateByMonth(p.DailyContributions) + buckets := aggregateByMonth(days) if len(buckets) < 2 { fmt.Fprintf(&b, ` No contribution data available.`, t.Muted) b.WriteString(footer) - return []byte(b.String()), nil + return []byte(b.String()) } // Y scale based on max monthly count; nice ticks for labels. @@ -87,16 +102,18 @@ func (contributionsCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) { rightX+6, y+3, t.Muted, label) } - // X axis baseline + month labels (every other month to avoid overlap). + // X axis baseline + month labels. Stride is chosen so roughly xLabelTarget + // labels span the full width regardless of bucket count — keeps the axis + // readable whether we plot 12 months (last year) or 100+ months (all time). fmt.Fprintf(&b, ` `, leftPad, topPad+chartH, leftPad+chartW, topPad+chartH, t.Muted) for i, bk := range buckets { - if i%2 != 0 && i != len(buckets)-1 { + if !xAxisLabelVisible(i, len(buckets)) { continue } x := int(pts[i][0]) - label := fmt.Sprintf("%02d/%02d", bk.Year%100, int(bk.Month)) + label := fmt.Sprintf("%02d/%02d", int(bk.Month), bk.Year%100) fmt.Fprintf(&b, ` %s`, @@ -112,8 +129,36 @@ func (contributionsCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) { path, t.Accent, catmullRomLinePath(pts), t.Accent) + // Axis caption — documents the tick label format. + fmt.Fprintf(&b, ` + mm/yy`, + leftPad+chartW/2, topPad+chartH+34, t.Muted) + b.WriteString(footer) - return []byte(b.String()), nil + return []byte(b.String()) +} + +// xAxisLabelVisible returns true when the bucket at position i should get a +// printed month label. Targets ~xLabelTarget labels evenly distributed across +// the axis, always pinning the first and last so the span is obvious. +const xLabelTarget = 6 + +func xAxisLabelVisible(i, n int) bool { + if n <= xLabelTarget { + return true // few enough points — label all of them + } + if i == 0 || i == n-1 { + return true + } + stride := (n - 1) / (xLabelTarget - 1) + if stride < 1 { + stride = 1 + } + // Skip labels that are too close to the pinned last point. + if n-1-i < stride/2 { + return false + } + return i%stride == 0 } // aggregateByMonth bins the daily series into consecutive month buckets diff --git a/internal/card/most_commit_language_all_time.go b/internal/card/most_commit_language_all_time.go new file mode 100644 index 00000000..d7c1fe9c --- /dev/null +++ b/internal/card/most_commit_language_all_time.go @@ -0,0 +1,16 @@ +package card + +import ( + "github.com/tiennm99/ghstats/internal/github" + "github.com/tiennm99/ghstats/internal/theme" +) + +type mostCommitLanguageAllTimeCard struct{} + +func (mostCommitLanguageAllTimeCard) Filename() string { + return "6-most-commit-language-all-time.svg" +} + +func (mostCommitLanguageAllTimeCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) { + return renderDonutCard("Most Commit Language (all time)", p.CommitsByLanguageAllTime, t), nil +} diff --git a/internal/card/productive.go b/internal/card/productive.go index eb62f96b..3b6697c1 100644 --- a/internal/card/productive.go +++ b/internal/card/productive.go @@ -12,27 +12,50 @@ type productiveCard struct{} func (productiveCard) Filename() string { return "4-productive-time.svg" } +func (productiveCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) { + return renderProductiveTime(productiveTitle("last year", p.UTCOffsetLabel), p.Productive, t), nil +} + +type productiveAllTimeCard struct{} + +func (productiveAllTimeCard) Filename() string { return "7-productive-time-all-time.svg" } + +func (productiveAllTimeCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) { + return renderProductiveTime(productiveTitle("all time", p.UTCOffsetLabel), p.ProductiveAllTime, t), nil +} + +// productiveTitle formats the window qualifier together with the UTC offset. +// Omits the offset when unknown so the card still renders from a raw Profile. +func productiveTitle(window, utcLabel string) string { + if utcLabel == "" { + return "Commits by Hour (" + window + ")" + } + return "Commits by Hour (" + window + ", " + utcLabel + ")" +} + // Hour ticks to label on the x-axis; same set the reference project uses. var xTickHours = [...]int{0, 6, 12, 18, 23} -func (productiveCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) { +// renderProductiveTime draws a 24-hour bar chart with two-sided axes and +// hover titles. Shared by the last-year and all-time productive-time cards. +func renderProductiveTime(title string, data [24]int, t theme.Theme) []byte { const ( - width = 500 - height = 220 - leftAxis = 50 - rightPad = 25 - topPad = 60 - chartH = 110 - barGap = 2 + width = 500 + height = 220 + leftAxis = 50 + rightPad = 25 + topPad = 60 + chartH = 110 + barGap = 2 ) chartW := width - leftAxis - rightPad barW := float64(chartW-barGap*23) / 24.0 var b strings.Builder - b.WriteString(header(width, height, t.Background, t.Stroke, t.StrokeOpacity, t.Title, "Commits by Hour (last year)")) + b.WriteString(header(width, height, t.Background, t.Stroke, t.StrokeOpacity, t.Title, title)) max := 0 - for _, v := range p.Productive { + for _, v := range data { if v > max { max = v } @@ -74,7 +97,7 @@ func (productiveCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) { // Bars. for h := 0; h < 24; h++ { - count := p.Productive[h] + count := data[h] barH := float64(chartH) * float64(count) / yMax x := float64(leftAxis) + barW*float64(h) + float64(barGap*h) y := float64(topPad+chartH) - barH @@ -89,5 +112,5 @@ func (productiveCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) { leftAxis+chartW/2, topPad+chartH+34, t.Muted) b.WriteString(footer) - return []byte(b.String()), nil + return []byte(b.String()) } diff --git a/internal/card/stats.go b/internal/card/stats.go index 8c32a8d1..75208bde 100644 --- a/internal/card/stats.go +++ b/internal/card/stats.go @@ -32,6 +32,7 @@ func (statsCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) { rows := []statRow{ {iconStar, "Total Stars", formatInt(p.TotalStars)}, + {iconCommit, "Total Commits (all time)", formatInt(p.TotalCommitsAllTime)}, {iconCommit, "Total Commits (last year)", formatInt(p.TotalCommits)}, {iconPR, "Total PRs", formatInt(p.TotalPRs)}, {iconIssue, "Total Issues", formatInt(p.TotalIssues)}, diff --git a/internal/github/contributions_all_time.go b/internal/github/contributions_all_time.go new file mode 100644 index 00000000..922a4e05 --- /dev/null +++ b/internal/github/contributions_all_time.go @@ -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 1–10 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 +} diff --git a/internal/github/model.go b/internal/github/model.go index 5787f06b..6cc11af1 100644 --- a/internal/github/model.go +++ b/internal/github/model.go @@ -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. diff --git a/internal/github/productive.go b/internal/github/productive.go index 22270961..7347568e 100644 --- a/internal/github/productive.go +++ b/internal/github/productive.go @@ -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 } diff --git a/internal/github/profile.go b/internal/github/profile.go index ca011bf4..efd25cd2 100644 --- a/internal/github/profile.go +++ b/internal/github/profile.go @@ -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 { diff --git a/internal/github/queries.go b/internal/github/queries.go index 7418ba48..dffd0125 100644 --- a/internal/github/queries.go +++ b/internal/github/queries.go @@ -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 + } + } + } + } + } +}` diff --git a/main.go b/main.go index dbd0f693..35976566 100644 --- a/main.go +++ b/main.go @@ -21,7 +21,7 @@ func main() { themesFlag = flag.String("themes", "dracula", "comma-separated theme ids, or 'all'") tzName = flag.String("tz", "Local", "timezone for productive-time card (IANA name, e.g. Asia/Saigon)") topRepos = flag.Int("top-repos", 10, "owned repos to sample for productive-time heatmap (0 to skip)") - perRepo = flag.Int("commits-per-repo", 100, "max commits sampled per repo") + perRepo = flag.Int("commits-per-repo", 500, "max commits sampled per repo (covers both last-year and all-time aggregates)") listThemes = flag.Bool("list-themes", false, "print available theme ids and exit") ) flag.Parse() @@ -57,6 +57,7 @@ func main() { fmt.Fprintf(os.Stderr, "error: fetch profile: %v\n", err) os.Exit(1) } + profile.UTCOffsetLabel = utcOffsetLabel(loc) if *topRepos > 0 && profile.ID != "" { repos := profile.TopRepos @@ -67,6 +68,11 @@ func main() { fmt.Fprintf(os.Stderr, "warn: productive-time + commits-per-language fetch: %v\n", err) } } + if len(profile.ContributionYears) > 0 { + if err := client.FetchContributionsAllTime(profile); err != nil { + fmt.Fprintf(os.Stderr, "warn: all-time contributions fetch: %v\n", err) + } + } for _, t := range selected { if err := card.RenderAll(profile, t, *out); err != nil { @@ -77,6 +83,15 @@ func main() { } } +// utcOffsetLabel formats the location's current offset from UTC as "UTC±N.NN" +// (two-decimal hours) so half-hour zones like India (UTC+5.30) or Nepal +// (UTC+5.75) render cleanly. Matches github-profile-summary-cards' style. +func utcOffsetLabel(loc *time.Location) string { + _, offsetSec := time.Now().In(loc).Zone() + hours := float64(offsetSec) / 3600.0 + return fmt.Sprintf("UTC%+.2f", hours) +} + func resolveThemes(spec string) ([]theme.Theme, error) { spec = strings.TrimSpace(spec) if spec == "" {