From d0d386278094de3fcf46c4a64f98ea0033c28367 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 18 Apr 2026 21:20:52 +0700 Subject: [PATCH] feat(card): add contributions area chart card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New 5-contributions.svg renders the last year's contribution calendar as a monthly smooth-filled area chart. Pure Go SVG; no extra API calls — one additional contributionCalendar.weeks block in the existing profile GraphQL query carries the data. - Y-axis mirrored on both sides with nice ticks. - X-axis labels in YY/MM format, every other month to avoid overlap. - Smooth curve via Catmull-Rom interpolation converted to cubic Bezier (d3.curveCatmullRom default tension 0.5). - Missing months between first and last are inserted as zero-count so the chart stays time-continuous. --- internal/card/card.go | 1 + internal/card/contributions.go | 186 +++++++++++++++++++++++++++++++++ internal/github/model.go | 11 ++ internal/github/profile.go | 20 ++++ internal/github/queries.go | 10 +- 5 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 internal/card/contributions.go diff --git a/internal/card/card.go b/internal/card/card.go index d93ce021..a3a5ffe7 100644 --- a/internal/card/card.go +++ b/internal/card/card.go @@ -26,6 +26,7 @@ var allCards = []Card{ mostCommitLanguageCard{}, statsCard{}, productiveCard{}, + contributionsCard{}, } // RenderAll writes every card into outDir//. diff --git a/internal/card/contributions.go b/internal/card/contributions.go new file mode 100644 index 00000000..1246adad --- /dev/null +++ b/internal/card/contributions.go @@ -0,0 +1,186 @@ +package card + +import ( + "fmt" + "strings" + "time" + + "github.com/tiennm99/ghstats/internal/github" + "github.com/tiennm99/ghstats/internal/theme" +) + +type contributionsCard struct{} + +func (contributionsCard) Filename() string { return "5-contributions.svg" } + +// monthBucket holds a calendar month's aggregate contribution count. +type monthBucket struct { + Year int + Month time.Month + Count int +} + +func (contributionsCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) { + const ( + width = 500 + height = 220 + leftPad = 35 + rightPad = 35 + topPad = 60 + chartH = 120 + ) + chartW := width - leftPad - rightPad + + var b strings.Builder + b.WriteString(header(width, height, t.Background, t.Stroke, t.StrokeOpacity, t.Title, "Contributions (last year)")) + + buckets := aggregateByMonth(p.DailyContributions) + if len(buckets) < 2 { + fmt.Fprintf(&b, ` + No contribution data available.`, t.Muted) + b.WriteString(footer) + return []byte(b.String()), nil + } + + // Y scale based on max monthly count; nice ticks for labels. + var maxVal int + for _, bk := range buckets { + if bk.Count > maxVal { + maxVal = bk.Count + } + } + yMax := float64(maxVal) + if yMax == 0 { + yMax = 1 + } + ticks := niceTicks(yMax, 5) + if len(ticks) > 0 { + yMax = ticks[len(ticks)-1] + } + + // Map each bucket to an (x, y) point on the chart grid. + pts := make([][2]float64, len(buckets)) + for i, bk := range buckets { + x := float64(leftPad) + float64(chartW)*float64(i)/float64(len(buckets)-1) + y := float64(topPad+chartH) - float64(chartH)*float64(bk.Count)/yMax + pts[i] = [2]float64{x, y} + } + + // Y axis mirrored on both sides with matching nice ticks. + rightX := leftPad + chartW + fmt.Fprintf(&b, ` + + `, + leftPad, topPad, leftPad, topPad+chartH, t.Muted, + rightX, topPad, rightX, topPad+chartH, t.Muted) + for _, v := range ticks { + y := topPad + chartH - int(float64(chartH)*v/yMax) + label := escapeXML(formatTick(v)) + fmt.Fprintf(&b, ` + + %s + + %s`, + leftPad-4, y, leftPad, y, t.Muted, + leftPad-6, y+3, t.Muted, label, + rightX, y, rightX+4, y, t.Muted, + rightX+6, y+3, t.Muted, label) + } + + // X axis baseline + month labels (every other month to avoid overlap). + 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 { + continue + } + x := int(pts[i][0]) + label := fmt.Sprintf("%02d/%02d", bk.Year%100, int(bk.Month)) + fmt.Fprintf(&b, ` + + %s`, + x, topPad+chartH, x, topPad+chartH+4, t.Muted, + x, topPad+chartH+16, t.Muted, label) + } + + // Smooth filled area using Catmull-Rom → cubic Bezier segments. + path := catmullRomAreaPath(pts, float64(topPad+chartH)) + fmt.Fprintf(&b, ` + + `, + path, t.Accent, + catmullRomLinePath(pts), t.Accent) + + b.WriteString(footer) + return []byte(b.String()), nil +} + +// aggregateByMonth bins the daily series into consecutive month buckets +// sorted oldest→newest. Empty months between first and last are kept as +// zero-count rows so the area chart remains time-continuous. +func aggregateByMonth(days []github.DailyContribution) []monthBucket { + if len(days) == 0 { + return nil + } + counts := map[string]int{} + for _, d := range days { + key := fmt.Sprintf("%04d-%02d", d.Date.Year(), int(d.Date.Month())) + counts[key] += d.Count + } + + // Walk from the first to last month of the calendar inclusively. + first := days[0].Date + last := days[len(days)-1].Date + cur := time.Date(first.Year(), first.Month(), 1, 0, 0, 0, 0, time.UTC) + end := time.Date(last.Year(), last.Month(), 1, 0, 0, 0, 0, time.UTC) + + var out []monthBucket + for !cur.After(end) { + key := fmt.Sprintf("%04d-%02d", cur.Year(), int(cur.Month())) + out = append(out, monthBucket{ + Year: cur.Year(), + Month: cur.Month(), + Count: counts[key], + }) + cur = cur.AddDate(0, 1, 0) + } + return out +} + +// catmullRomLinePath produces an SVG path string that passes through each +// point using cubic Bezier segments derived from the Catmull-Rom spline +// (tension = 0.5, the classic d3.curveCatmullRom default). +func catmullRomLinePath(pts [][2]float64) string { + if len(pts) < 2 { + return "" + } + var b strings.Builder + fmt.Fprintf(&b, "M%.2f,%.2f", pts[0][0], pts[0][1]) + for i := 0; i < len(pts)-1; i++ { + p0 := pts[max(i-1, 0)] + p1 := pts[i] + p2 := pts[i+1] + p3 := pts[min(i+2, len(pts)-1)] + + c1x := p1[0] + (p2[0]-p0[0])/6 + c1y := p1[1] + (p2[1]-p0[1])/6 + c2x := p2[0] - (p3[0]-p1[0])/6 + c2y := p2[1] - (p3[1]-p1[1])/6 + + fmt.Fprintf(&b, " C%.2f,%.2f %.2f,%.2f %.2f,%.2f", + c1x, c1y, c2x, c2y, p2[0], p2[1]) + } + return b.String() +} + +// catmullRomAreaPath closes the smooth line path down to the baseline so it +// can be filled as an area under the curve. +func catmullRomAreaPath(pts [][2]float64, baseline float64) string { + line := catmullRomLinePath(pts) + if line == "" { + return "" + } + return fmt.Sprintf("%s L%.2f,%.2f L%.2f,%.2f Z", + line, pts[len(pts)-1][0], baseline, pts[0][0], baseline) +} diff --git a/internal/github/model.go b/internal/github/model.go index bd5a19a1..5787f06b 100644 --- a/internal/github/model.go +++ b/internal/github/model.go @@ -38,11 +38,22 @@ type Profile struct { // Commit counts grouped by hour-of-day (0-23) in the configured timezone. Productive [24]int + // 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 + // TopRepos are owned repos sorted by stargazer count desc. Populated by // FetchProfile and consumed by FetchProductive. TopRepos []RepoInfo } +// DailyContribution is a single day in the contributions calendar. +type DailyContribution struct { + Date time.Time + Count int +} + // LangStat is one row in a language breakdown card. Value is repo count or // commit count depending on which slice holds it. type LangStat struct { diff --git a/internal/github/profile.go b/internal/github/profile.go index 38676965..ca011bf4 100644 --- a/internal/github/profile.go +++ b/internal/github/profile.go @@ -36,6 +36,12 @@ type profileGQL struct { RestrictedContributionsCount int `json:"restrictedContributionsCount"` ContributionCalendar struct { TotalContributions int `json:"totalContributions"` + Weeks []struct { + ContributionDays []struct { + ContributionCount int `json:"contributionCount"` + Date string `json:"date"` + } `json:"contributionDays"` + } `json:"weeks"` } `json:"contributionCalendar"` } `json:"contributionsCollection"` @@ -101,6 +107,20 @@ func (c *Client) FetchProfile(login string) (*Profile, error) { p.TotalCommits = cc.TotalCommitContributions p.TotalReviews = cc.TotalPullRequestReviewContributions p.TotalContributions = cc.ContributionCalendar.TotalContributions + cc.RestrictedContributionsCount + + // Flatten week → day into a linear daily series sorted by date. + 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.DailyContributions = append(p.DailyContributions, DailyContribution{ + Date: t, + Count: d.ContributionCount, + }) + } + } } for _, r := range u.Repositories.Nodes { diff --git a/internal/github/queries.go b/internal/github/queries.go index ebbb193b..7418ba48 100644 --- a/internal/github/queries.go +++ b/internal/github/queries.go @@ -30,7 +30,15 @@ query($login: String!, $after: String) { totalPullRequestReviewContributions totalRepositoryContributions restrictedContributionsCount - contributionCalendar { totalContributions } + contributionCalendar { + totalContributions + weeks { + contributionDays { + contributionCount + date + } + } + } } repositories( first: 100