mirror of
https://github.com/tiennm99/ghstats.git
synced 2026-09-03 06:19:07 +00:00
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:
@@ -27,6 +27,9 @@ var allCards = []Card{
|
||||
statsCard{},
|
||||
productiveCard{},
|
||||
contributionsCard{},
|
||||
mostCommitLanguageAllTimeCard{},
|
||||
productiveAllTimeCard{},
|
||||
contributionsAllTimeCard{},
|
||||
}
|
||||
|
||||
// RenderAll writes every card into outDir/<themeID>/.
|
||||
|
||||
@@ -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, `
|
||||
<text x="25" y="90" font-size="13" fill="%s">No contribution data available.</text>`, 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, `
|
||||
<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s"/>`,
|
||||
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, `
|
||||
<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s"/>
|
||||
<text x="%d" y="%d" font-size="10" fill="%s" text-anchor="middle">%s</text>`,
|
||||
@@ -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, `
|
||||
<text x="%d" y="%d" font-size="11" fill="%s" text-anchor="middle">mm/yy</text>`,
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+35
-12
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user