feat(card): add S-tier cards — heatmap, streak, by-year, weekday, top-starred (#3)

Five new cards, all derived from data FetchProductive / FetchProfile already
pull, so zero additional API calls:

- contributions-heatmap: 7×53 calendar grid with a 5-bucket intensity ramp
  mixed from each theme's Background→Accent so palettes with no dedicated
  heat ramp still render sensibly.
- streak: current streak, longest streak with date ranges, active/total days.
- contributions-by-year: one bar per active year, peak year highlighted.
- productive-weekday + -all-time: 7-bar day-of-week mirror of the hour-of-day
  cards; FetchProductive now also fills Weekday / WeekdayAllTime histograms
  during the same commit-history pass.
- top-starred-repos: top 5 owned non-fork repos by stargazer count; threads
  Stars through RepoInfo.

Card count: 9 → 14. Registered in allCards grouped by recency (last-year
block, then all-time block). Render test extended to cover all new files
and realistic daily-series inputs.
This commit is contained in:
2026-04-19 08:58:59 +07:00
committed by GitHub
parent 9dffc2234b
commit fd4c70e53e
13 changed files with 805 additions and 8 deletions
+22 -4
View File
@@ -21,10 +21,16 @@ Cards rendered:
| 2 | Most commit language (last year) | Donut + legend: last-year commits byte-weighted across each repo's language breakdown |
| 3 | Stats | Star, commit (lifetime + last-year), PR, issue, PR-review, contributed-to totals |
| 4 | Productive time (last year) | 24-hour bar chart with axes, title includes `UTC±N.NN` |
| 5 | Contributions (last year) | Smooth monthly area chart, Y-axis mirrored both sides, `mm/yy` labels |
| 6 | **Most commit language (all time)** | Same as #2 but over lifetime commits |
| 7 | **Productive time (all time)** | Same as #4 but over lifetime commits |
| 8 | **Contributions (all time)** | Area chart across every active year, auto-thinned x-axis labels |
| 5 | Productive weekday (last year) | 7-bar day-of-week chart, peak day highlighted |
| 6 | Contributions (last year) | Smooth monthly area chart, Y-axis mirrored both sides, `mm/yy` labels |
| 7 | Contributions heatmap | Classic 7×53 calendar grid with theme-derived intensity ramp and legend |
| 8 | Top starred repos | Top 5 owned non-fork repos by ⭐, language dot + proportional bar |
| 9 | Streak | Current streak, longest streak, active days / total days with date ranges |
| 10 | **Most commit language (all time)** | Same as #2 but over lifetime commits |
| 11 | **Productive time (all time)** | Same as #4 but over lifetime commits |
| 12 | **Productive weekday (all time)** | Same as #5 but over lifetime commits |
| 13 | **Contributions (all time)** | Area chart across every active year, auto-thinned x-axis labels |
| 14 | **Contributions by year** | One bar per active year, peak year highlighted |
Live `dracula` sample ships in [`output/dracula/`](./output/dracula). Every available theme rendered against the author's profile — profile details, stats, language donuts, productive-time, contributions — is browsable in the auto-generated [**demo gallery**](./demo). Regenerated on every push to `main` by [`.github/workflows/demo.yml`](./.github/workflows/demo.yml).
@@ -72,10 +78,16 @@ Then embed the cards in your `README.md`:
![most-commit-language](./output/dracula/most-commit-language.svg)
![stats](./output/dracula/stats.svg)
![productive-time](./output/dracula/productive-time.svg)
![productive-weekday](./output/dracula/productive-weekday.svg)
![contributions](./output/dracula/contributions.svg)
![contributions-heatmap](./output/dracula/contributions-heatmap.svg)
![top-starred-repos](./output/dracula/top-starred-repos.svg)
![streak](./output/dracula/streak.svg)
![most-commit-language-all-time](./output/dracula/most-commit-language-all-time.svg)
![productive-time-all-time](./output/dracula/productive-time-all-time.svg)
![productive-weekday-all-time](./output/dracula/productive-weekday-all-time.svg)
![contributions-all-time](./output/dracula/contributions-all-time.svg)
![contributions-by-year](./output/dracula/contributions-by-year.svg)
```
### Action inputs
@@ -163,10 +175,16 @@ output/
most-commit-language.svg
stats.svg
productive-time.svg
productive-weekday.svg
contributions.svg
contributions-heatmap.svg
top-starred-repos.svg
streak.svg
most-commit-language-all-time.svg
productive-time-all-time.svg
productive-weekday-all-time.svg
contributions-all-time.svg
contributions-by-year.svg
```
Only the `dracula` theme is tracked in git as a reference sample; other
+7 -2
View File
@@ -29,7 +29,12 @@ ghstats/
│ │ ├── most_commit_language_all_time.go # most-commit-language-all-time
│ │ ├── stats.go # stats
│ │ ├── productive.go # productive-time (+ all-time)
│ │ ├── productive_weekday.go # productive-weekday (+ all-time)
│ │ ├── contributions.go # contributions (+ all-time)
│ │ ├── contributions_heatmap.go # contributions-heatmap (7×53 calendar grid)
│ │ ├── contributions_by_year.go # contributions-by-year bar chart
│ │ ├── streak.go # streak (current/longest/active days)
│ │ ├── top_starred_repos.go # top-starred-repos bar list
│ │ ├── donut_chart.go # renderDonutCard — shared by language cards
│ │ └── card_test.go # Rendering + escape + format tests
│ └── theme/
@@ -94,12 +99,12 @@ contributionYearQuery ─┬──► SeedRepos + DailyContributionsAllTime + To
commitHistoryQuery ──► Productive + CommitsByLanguage (+ AllTime variants)
9 SVG files per theme
14 SVG files per theme
```
## Test coverage
- `internal/card/card_test.go``RenderAll` produces 9 valid SVGs; XML escape through real render pipeline; `formatInt` cases; `TestDonutSingleSlice` (guards the empty-arc regression); `TestDonutEmpty` (no-data fallback).
- `internal/card/card_test.go``RenderAll` produces 14 valid SVGs; XML escape through real render pipeline; `formatInt` cases; `TestDonutSingleSlice` (guards the empty-arc regression); `TestDonutEmpty` (no-data fallback).
- `internal/github/profile_test.go``sortLangStats` ordering and tiebreak.
- `main_test.go``TestUTCOffsetLabel` covers UTC, Asia/Saigon, half-hour (Kolkata), quarter-hour (Kathmandu) zones.
+2 -2
View File
@@ -2,7 +2,7 @@
## One-liner
Single-binary Go CLI + GitHub Action that renders 9 themed SVG cards summarising a GitHub user's public (and optionally private) profile, for embedding in a profile README.
Single-binary Go CLI + GitHub Action that renders 14 themed SVG cards summarising a GitHub user's public (and optionally private) profile, for embedding in a profile README.
## Users
@@ -34,7 +34,7 @@ Distinguishing traits:
| # | Requirement |
| --- | --- |
| F1 | Render 9 cards per selected theme (see `docs/system-architecture.md`) |
| F1 | Render 14 cards per selected theme (see `docs/system-architecture.md`) |
| F2 | Support 65 themes ported from github-profile-summary-cards |
| F3 | Handle the full username→profile→cards flow in a single invocation |
| F4 | Package as GitHub Action with `commit_changes` auto-commit of output |
+12
View File
@@ -62,6 +62,18 @@ Follow-up after the full-project review (`plans/reports/code-review-260418-2223-
- Repo topics expanded for Marketplace discoverability (`ghstats-cards`, `profile-readme`, `stats-cards`, etc.).
- An attempted repo rename to `tiennm99/ghstats-cards` was committed and reverted (commits `399a3dc` + `8bd2128` on record) — GHCR path immutability and the cost of breaking pinned consumers outweighed the Marketplace-name cosmetic benefit.
## Phase 7.6 — S-tier breadth cards (✅ done)
Five new cards that ride on data already fetched — zero extra API calls:
- `contributions-heatmap` — canonical 7×53 calendar grid with a theme-derived 5-bucket intensity ramp.
- `contributions-by-year` — one bar per active year, peak year highlighted.
- `productive-weekday` + `productive-weekday-all-time` — mirror the hour-of-day pair; `FetchProductive` now also fills `Weekday` / `WeekdayAllTime` histograms.
- `top-starred-repos` — top 5 owned non-fork repos by ⭐; required threading `Stars` through `RepoInfo`.
- `streak` — current + longest streak + active days/total. Pure post-processing of `DailyContributionsAllTime`.
Card count: 9 → 14. `FetchProductive` still pays for commit-history pagination once; the new cards are pure renderers.
## Phase 7.5 — Demo gallery for theme discovery (✅ done)
- New `.github/workflows/demo.yml` renders every card for every theme against the repo owner's profile on each push to `main`.
+6
View File
@@ -26,10 +26,16 @@ var allCards = []Card{
mostCommitLanguageCard{},
statsCard{},
productiveCard{},
productiveWeekdayCard{},
contributionsCard{},
contributionsHeatmapCard{},
topStarredReposCard{},
streakCard{},
mostCommitLanguageAllTimeCard{},
productiveAllTimeCard{},
productiveWeekdayAllTimeCard{},
contributionsAllTimeCard{},
contributionsByYearCard{},
}
// RenderAll writes every card into outDir/<themeID>/.
+39
View File
@@ -5,6 +5,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/tiennm99/ghstats/internal/github"
"github.com/tiennm99/ghstats/internal/theme"
@@ -31,9 +32,41 @@ func TestRenderAll(t *testing.T) {
{Name: "Go", Color: "#00ADD8", Value: 420},
{Name: "Python", Color: "#3572A5", Value: 150},
},
TopRepos: []github.RepoInfo{
{Owner: "tiennm99", Name: "ghstats", Stars: 42, PrimaryLanguage: "Go", PrimaryColor: "#00ADD8"},
{Owner: "tiennm99", Name: "some-app & <tool>", Stars: 17, PrimaryLanguage: "TypeScript", PrimaryColor: "#3178c6"},
{Owner: "tiennm99", Name: "fork-only", Stars: 99, IsFork: true},
},
}
p.Productive[9] = 3
p.Productive[14] = 7
p.Weekday[time.Tuesday] = 12
p.Weekday[time.Thursday] = 5
p.WeekdayAllTime[time.Monday] = 30
p.WeekdayAllTime[time.Friday] = 42
// Last-year daily series — one contribution every Monday, plus a burst
// covering a three-day streak so computeStreak has something to find.
base := time.Date(2025, 4, 1, 0, 0, 0, 0, time.UTC)
for i := 0; i < 365; i++ {
d := github.DailyContribution{Date: base.AddDate(0, 0, i)}
if d.Date.Weekday() == time.Monday {
d.Count = 2
}
p.DailyContributions = append(p.DailyContributions, d)
}
p.DailyContributions[100].Count = 7
p.DailyContributions[101].Count = 5
p.DailyContributions[102].Count = 3
// All-time series covers 3 full years so the by-year card has ≥3 bars.
allBase := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
for i := 0; i < 365*3; i++ {
d := github.DailyContribution{Date: allBase.AddDate(0, 0, i)}
if i%5 == 0 {
d.Count = 1
}
p.DailyContributionsAllTime = append(p.DailyContributionsAllTime, d)
}
th, ok := theme.Lookup("dracula")
if !ok {
@@ -50,10 +83,16 @@ func TestRenderAll(t *testing.T) {
"most-commit-language.svg",
"stats.svg",
"productive-time.svg",
"productive-weekday.svg",
"contributions.svg",
"contributions-heatmap.svg",
"top-starred-repos.svg",
"streak.svg",
"most-commit-language-all-time.svg",
"productive-time-all-time.svg",
"productive-weekday-all-time.svg",
"contributions-all-time.svg",
"contributions-by-year.svg",
}
for _, name := range want {
data, err := os.ReadFile(filepath.Join(dir, "dracula", name))
+151
View File
@@ -0,0 +1,151 @@
package card
import (
"fmt"
"sort"
"strings"
"github.com/tiennm99/ghstats/internal/github"
"github.com/tiennm99/ghstats/internal/theme"
)
type contributionsByYearCard struct{}
func (contributionsByYearCard) Filename() string { return "contributions-by-year.svg" }
func (contributionsByYearCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
const (
width = 340
height = 200
leftAxis = 35
rightPad = 15
topPad = 45
chartH = 110
barGap = 2
)
buckets := aggregateByYear(p.DailyContributionsAllTime)
var b strings.Builder
b.WriteString(header(width, height, t.Background, t.Stroke, t.StrokeOpacity, t.Title, "Contributions by Year"))
if len(buckets) == 0 {
fmt.Fprintf(&b, `
<text x="25" y="100" font-size="13" fill="%s">No contribution data available.</text>`, t.Muted)
b.WriteString(footer)
return []byte(b.String()), nil
}
chartW := width - leftAxis - rightPad
barW := float64(chartW-barGap*(len(buckets)-1)) / float64(len(buckets))
var maxVal int
peakIdx := 0
for i, bk := range buckets {
if bk.Count > maxVal {
maxVal = bk.Count
peakIdx = i
}
}
yMax := float64(maxVal)
if yMax == 0 {
yMax = 1
}
ticks := niceTicks(yMax, 5)
if len(ticks) > 0 {
yMax = ticks[len(ticks)-1]
}
// Y axis + ticks.
fmt.Fprintf(&b, `
<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s"/>`,
leftAxis, topPad, leftAxis, topPad+chartH, t.Muted)
for _, v := range ticks {
y := topPad + chartH - int(float64(chartH)*v/yMax)
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="end">%s</text>`,
leftAxis-4, y, leftAxis, y, t.Muted,
leftAxis-6, y+3, t.Muted, escapeXML(formatTick(v)))
}
// X axis baseline.
fmt.Fprintf(&b, `
<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s"/>`,
leftAxis, topPad+chartH, leftAxis+chartW, topPad+chartH, t.Muted)
// Bars + year labels. Peak year uses Accent; others use a muted Accent
// mix so the eye snaps to the year that matters most.
dim := mixHex(t.Background, t.Accent, 0.55)
labelStride := yearLabelStride(len(buckets))
for i, bk := range buckets {
barH := float64(chartH) * float64(bk.Count) / yMax
x := float64(leftAxis) + (barW+float64(barGap))*float64(i)
y := float64(topPad+chartH) - barH
fill := dim
if i == peakIdx {
fill = t.Accent
}
fmt.Fprintf(&b, `
<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" rx="2" fill="%s"><title>%d — %d commits</title></rect>`,
x, y, barW, barH, fill, bk.Year, bk.Count)
if i%labelStride == 0 || i == len(buckets)-1 {
cx := x + barW/2
fmt.Fprintf(&b, `
<text x="%.2f" y="%d" font-size="10" fill="%s" text-anchor="middle">%d</text>`,
cx, topPad+chartH+14, t.Muted, bk.Year)
}
}
b.WriteString(footer)
return []byte(b.String()), nil
}
// yearBucket holds a calendar-year aggregate count.
type yearBucket struct {
Year int
Count int
}
// aggregateByYear bins the daily series into year totals, ascending. Missing
// years between first and last (rare but possible) become zero rows so the
// x-axis stays chronologically continuous.
func aggregateByYear(days []github.DailyContribution) []yearBucket {
if len(days) == 0 {
return nil
}
counts := map[int]int{}
var minY, maxY int
minY, maxY = days[0].Date.Year(), days[0].Date.Year()
for _, d := range days {
y := d.Date.Year()
counts[y] += d.Count
if y < minY {
minY = y
}
if y > maxY {
maxY = y
}
}
out := make([]yearBucket, 0, maxY-minY+1)
for y := minY; y <= maxY; y++ {
out = append(out, yearBucket{Year: y, Count: counts[y]})
}
// Defensive sort in case caller ever passes an unordered slice.
sort.Slice(out, func(i, j int) bool { return out[i].Year < out[j].Year })
return out
}
// yearLabelStride picks how many years between printed x-axis labels so the
// axis stays legible when the user has a long GitHub history.
func yearLabelStride(n int) int {
switch {
case n <= 8:
return 1
case n <= 16:
return 2
default:
return 3
}
}
+199
View File
@@ -0,0 +1,199 @@
package card
import (
"fmt"
"strings"
"time"
"github.com/tiennm99/ghstats/internal/github"
"github.com/tiennm99/ghstats/internal/theme"
)
type contributionsHeatmapCard struct{}
func (contributionsHeatmapCard) Filename() string { return "contributions-heatmap.svg" }
func (contributionsHeatmapCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
return renderHeatmap("Contributions (last year)", p.DailyContributions, t), nil
}
// renderHeatmap draws the classic 7×N week grid. Sunday at top, Saturday at
// bottom, oldest week on the left. Cell color mixes theme.Background with
// theme.Accent in four intensity buckets so every palette inherits a usable
// heatmap without a separate color ramp in the theme schema.
func renderHeatmap(title string, days []github.DailyContribution, t theme.Theme) []byte {
const (
width = 340
height = 200
cellSize = 9
cellGap = 2
leftPad = 28
topPad = 55
dayLabelDX = 22 // where weekday labels anchor (right of grid start)
)
var b strings.Builder
b.WriteString(header(width, height, t.Background, t.Stroke, t.StrokeOpacity, t.Title, title))
if len(days) == 0 {
fmt.Fprintf(&b, `
<text x="25" y="100" font-size="13" fill="%s">No contribution data available.</text>`, t.Muted)
b.WriteString(footer)
return []byte(b.String())
}
cells := padToWeekGrid(days)
weeks := len(cells) / 7
// Determine intensity buckets from non-zero percentiles so sparse users
// still get visible cells and prolific users don't saturate the top bucket.
buckets := intensityThresholds(cells)
ramp := [5]string{
mixHex(t.Background, t.Accent, 0.00),
mixHex(t.Background, t.Accent, 0.25),
mixHex(t.Background, t.Accent, 0.50),
mixHex(t.Background, t.Accent, 0.75),
mixHex(t.Background, t.Accent, 1.00),
}
// Weekday labels (Mon, Wed, Fri) printed only on alternating rows to
// avoid visual clutter; matches GitHub's own layout.
for i, label := range [7]string{"", "Mon", "", "Wed", "", "Fri", ""} {
if label == "" {
continue
}
y := topPad + i*(cellSize+cellGap) + cellSize - 1
fmt.Fprintf(&b, `
<text x="%d" y="%d" font-size="9" fill="%s" text-anchor="end">%s</text>`,
leftPad-4, y, t.Muted, label)
}
// Month labels across the top. We print each month the first time its
// first day appears in a week column, skipping consecutive duplicates.
lastMonth := time.Month(0)
for w := 0; w < weeks; w++ {
first := cells[w*7].Date
if first.Day() > 7 {
continue // the 1st of the month falls in an earlier week
}
if first.Month() == lastMonth {
continue
}
lastMonth = first.Month()
x := leftPad + w*(cellSize+cellGap)
fmt.Fprintf(&b, `
<text x="%d" y="%d" font-size="9" fill="%s">%s</text>`,
x, topPad-4, t.Muted, first.Month().String()[:3])
}
// Cells.
for w := 0; w < weeks; w++ {
for d := 0; d < 7; d++ {
cell := cells[w*7+d]
if cell.Date.IsZero() {
continue // padding slot before the first real day
}
fill := ramp[bucketFor(cell.Count, buckets)]
x := leftPad + w*(cellSize+cellGap)
y := topPad + d*(cellSize+cellGap)
fmt.Fprintf(&b, `
<rect x="%d" y="%d" width="%d" height="%d" rx="2" fill="%s"><title>%s — %d</title></rect>`,
x, y, cellSize, cellSize, fill,
cell.Date.Format("2006-01-02"), cell.Count)
}
}
// Legend: "Less ▢▢▢▢▢ More" at bottom right.
legendX := width - 110
legendY := height - 15
fmt.Fprintf(&b, `
<text x="%d" y="%d" font-size="9" fill="%s">Less</text>`, legendX, legendY, t.Muted)
for i, c := range ramp {
fmt.Fprintf(&b, `
<rect x="%d" y="%d" width="%d" height="%d" rx="2" fill="%s"/>`,
legendX+28+i*(cellSize+2), legendY-cellSize+2, cellSize, cellSize, c)
}
fmt.Fprintf(&b, `
<text x="%d" y="%d" font-size="9" fill="%s">More</text>`,
legendX+28+5*(cellSize+2)+2, legendY, t.Muted)
b.WriteString(footer)
return []byte(b.String())
}
// padToWeekGrid prepends zero-date slots so the returned slice is a clean
// weeks×7 grid starting on Sunday (index 0 = Sun, 6 = Sat).
func padToWeekGrid(days []github.DailyContribution) []github.DailyContribution {
if len(days) == 0 {
return nil
}
offset := int(days[0].Date.Weekday())
grid := make([]github.DailyContribution, offset+len(days))
copy(grid[offset:], days)
// Round trailing remainder up to a full week so the grid is rectangular.
if rem := len(grid) % 7; rem != 0 {
grid = append(grid, make([]github.DailyContribution, 7-rem)...)
}
return grid
}
// intensityThresholds picks four cutoffs from the non-zero counts so cells
// distribute across the 5-bucket ramp. Quartile-ish without a sort cost.
func intensityThresholds(cells []github.DailyContribution) [4]int {
var max int
for _, c := range cells {
if c.Count > max {
max = c.Count
}
}
if max == 0 {
return [4]int{1, 2, 3, 4}
}
// Simple linear split — works well for the common case. Power users with
// a long right tail still fall into bucket 4 without being clipped.
return [4]int{
1,
max / 4,
max / 2,
(3 * max) / 4,
}
}
func bucketFor(count int, thresholds [4]int) int {
switch {
case count <= 0:
return 0
case count < thresholds[1]:
return 1
case count < thresholds[2]:
return 2
case count < thresholds[3]:
return 3
default:
return 4
}
}
// mixHex blends two "#rrggbb" colors at the given ratio (0 returns a, 1 returns b).
// Non-hex or short inputs fall back to b so a misconfigured theme still renders.
func mixHex(a, b string, ratio float64) string {
ar, ag, ab, ok := parseHex(a)
br, bg, bb, okb := parseHex(b)
if !ok || !okb {
return b
}
r := int(float64(ar)*(1-ratio) + float64(br)*ratio)
g := int(float64(ag)*(1-ratio) + float64(bg)*ratio)
bl := int(float64(ab)*(1-ratio) + float64(bb)*ratio)
return fmt.Sprintf("#%02x%02x%02x", r, g, bl)
}
func parseHex(s string) (r, g, b int, ok bool) {
if len(s) != 7 || s[0] != '#' {
return 0, 0, 0, false
}
if _, err := fmt.Sscanf(s[1:], "%02x%02x%02x", &r, &g, &b); err != nil {
return 0, 0, 0, false
}
return r, g, b, true
}
+111
View File
@@ -0,0 +1,111 @@
package card
import (
"fmt"
"strings"
"github.com/tiennm99/ghstats/internal/github"
"github.com/tiennm99/ghstats/internal/theme"
)
type productiveWeekdayCard struct{}
func (productiveWeekdayCard) Filename() string { return "productive-weekday.svg" }
func (productiveWeekdayCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
return renderWeekday(weekdayTitle("last year", p.UTCOffsetLabel), p.Weekday, t), nil
}
type productiveWeekdayAllTimeCard struct{}
func (productiveWeekdayAllTimeCard) Filename() string { return "productive-weekday-all-time.svg" }
func (productiveWeekdayAllTimeCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
return renderWeekday(weekdayTitle("all time", p.UTCOffsetLabel), p.WeekdayAllTime, t), nil
}
func weekdayTitle(window, utcLabel string) string {
if utcLabel == "" {
return "Commits by Weekday (" + window + ")"
}
return "Commits by Weekday (" + window + ", " + utcLabel + ")"
}
// Index 0 = Sunday to match time.Weekday (which is what FetchProductive stores).
var weekdayLabels = [7]string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
// renderWeekday draws a 7-bar chart: one bar per weekday. Reuses the same
// axis math as the hour-of-day card so the two feel like a matched pair.
func renderWeekday(title string, data [7]int, t theme.Theme) []byte {
const (
width = 340
height = 200
leftAxis = 35
rightPad = 15
topPad = 45
chartH = 110
barGap = 6
)
chartW := width - leftAxis - rightPad
barW := float64(chartW-barGap*6) / 7.0
var b strings.Builder
b.WriteString(header(width, height, t.Background, t.Stroke, t.StrokeOpacity, t.Title, title))
max := 0
peak := 0
for i, v := range data {
if v > max {
max = v
peak = i
}
}
yMax := float64(max)
if yMax == 0 {
yMax = 1
}
ticks := niceTicks(yMax, 5)
if len(ticks) > 0 {
yMax = ticks[len(ticks)-1]
}
// Y axis + ticks.
fmt.Fprintf(&b, `
<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s"/>`,
leftAxis, topPad, leftAxis, topPad+chartH, t.Muted)
for _, v := range ticks {
y := topPad + chartH - int(float64(chartH)*v/yMax)
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="end">%s</text>`,
leftAxis-4, y, leftAxis, y, t.Muted,
leftAxis-6, y+3, t.Muted, escapeXML(formatTick(v)))
}
// X axis baseline + weekday labels.
fmt.Fprintf(&b, `
<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s"/>`,
leftAxis, topPad+chartH, leftAxis+chartW, topPad+chartH, t.Muted)
// Bars. Peak weekday gets full Accent; others the dimmed variant so the
// busiest day reads at a glance.
dim := mixHex(t.Background, t.Accent, 0.55)
for i := 0; i < 7; i++ {
count := data[i]
barH := float64(chartH) * float64(count) / yMax
x := float64(leftAxis) + (barW+float64(barGap))*float64(i)
y := float64(topPad+chartH) - barH
fill := dim
if i == peak && max > 0 {
fill = t.Accent
}
fmt.Fprintf(&b, `
<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" rx="2" fill="%s"><title>%s — %d commits</title></rect>
<text x="%.2f" y="%d" font-size="10" fill="%s" text-anchor="middle">%s</text>`,
x, y, barW, barH, fill, weekdayLabels[i], count,
x+barW/2, topPad+chartH+14, t.Muted, weekdayLabels[i])
}
b.WriteString(footer)
return []byte(b.String())
}
+130
View File
@@ -0,0 +1,130 @@
package card
import (
"fmt"
"strings"
"time"
"github.com/tiennm99/ghstats/internal/github"
"github.com/tiennm99/ghstats/internal/theme"
)
type streakCard struct{}
func (streakCard) Filename() string { return "streak.svg" }
func (streakCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
const (
width = 340
height = 200
)
stats := computeStreak(p.DailyContributionsAllTime)
var b strings.Builder
b.WriteString(header(width, height, t.Background, t.Stroke, t.StrokeOpacity, t.Title, "Streak"))
// Three large stat columns (current / longest / active-days) side by side,
// each with a big number on top and a smaller label underneath. Mirrors
// the classic "streak" card layout so embedders recognise it instantly.
cols := []struct {
value string
label string
end string // optional date annotation beneath the label
}{
{formatInt(stats.Current), "Current streak", streakRange(stats.CurrentStart, stats.CurrentEnd)},
{formatInt(stats.Longest), "Longest streak", streakRange(stats.LongestStart, stats.LongestEnd)},
{fmt.Sprintf("%d / %d", stats.Active, stats.Total), "Active days", ""},
}
colW := width / len(cols)
for i, c := range cols {
cx := colW*i + colW/2
fmt.Fprintf(&b, `
<text x="%d" y="%d" font-size="28" font-weight="700" fill="%s" text-anchor="middle">%s</text>
<text x="%d" y="%d" font-size="12" fill="%s" text-anchor="middle">%s</text>`,
cx, 95, t.Accent, escapeXML(c.value),
cx, 120, t.Text, escapeXML(c.label))
if c.end != "" {
fmt.Fprintf(&b, `
<text x="%d" y="%d" font-size="10" fill="%s" text-anchor="middle">%s</text>`,
cx, 140, t.Muted, escapeXML(c.end))
}
}
b.WriteString(footer)
return []byte(b.String()), nil
}
// streakStats is the post-processed daily series summarised for the card.
type streakStats struct {
Current int
CurrentStart, CurrentEnd time.Time
Longest int
LongestStart, LongestEnd time.Time
Active int // days with ≥1 contribution
Total int // total days observed
}
// computeStreak walks the daily series once. The "current streak" runs
// backwards from the most recent day; if today has 0 contributions we still
// count yesterday as current (a single-day grace) so the card doesn't reset
// the moment a user hasn't pushed yet today.
func computeStreak(days []github.DailyContribution) streakStats {
var s streakStats
if len(days) == 0 {
return s
}
s.Total = len(days)
// Longest streak + active day count: single forward pass.
var run int
var runStart time.Time
for _, d := range days {
if d.Count > 0 {
s.Active++
if run == 0 {
runStart = d.Date
}
run++
if run > s.Longest {
s.Longest = run
s.LongestStart = runStart
s.LongestEnd = d.Date
}
} else {
run = 0
}
}
// Current streak: walk backwards from the end. Skip at most one trailing
// zero-day (today-not-pushed-yet) before aborting.
tail := len(days) - 1
if days[tail].Count == 0 && tail > 0 {
tail--
}
for i := tail; i >= 0; i-- {
if days[i].Count == 0 {
break
}
s.Current++
s.CurrentEnd = days[tail].Date
s.CurrentStart = days[i].Date
}
return s
}
// streakRange formats the open/close dates of a streak as "Mon 2 — Wed 11"
// when both are present. Returns "" when the streak is zero-length so the
// card renders cleanly.
func streakRange(start, end time.Time) string {
if start.IsZero() || end.IsZero() {
return ""
}
if start.Equal(end) {
return start.Format("Jan 2, 2006")
}
if start.Year() == end.Year() {
return start.Format("Jan 2") + " — " + end.Format("Jan 2, 2006")
}
return start.Format("Jan 2006") + " — " + end.Format("Jan 2006")
}
+116
View File
@@ -0,0 +1,116 @@
package card
import (
"fmt"
"strings"
"github.com/tiennm99/ghstats/internal/github"
"github.com/tiennm99/ghstats/internal/theme"
)
type topStarredReposCard struct{}
func (topStarredReposCard) Filename() string { return "top-starred-repos.svg" }
// maxTopRepoRows is how many repos we show. Matches the legend density of the
// other list-style cards (donut top-5).
const maxTopRepoRows = 5
func (topStarredReposCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
const (
width = 340
height = 200
rowX = 20
rowY0 = 60
rowDY = 22
iconSize = 12
barX = 160
barW = 140 // max bar width; the top repo fills this
barH = 10
nameMax = 18 // truncate long repo names at this many characters
)
repos := ownedNonForkRepos(p.TopRepos)
if len(repos) > maxTopRepoRows {
repos = repos[:maxTopRepoRows]
}
var b strings.Builder
b.WriteString(header(width, height, t.Background, t.Stroke, t.StrokeOpacity, t.Title, "Top Starred Repos"))
if len(repos) == 0 {
fmt.Fprintf(&b, `
<text x="25" y="100" font-size="13" fill="%s">No public repos with stars.</text>`, t.Muted)
b.WriteString(footer)
return []byte(b.String()), nil
}
maxStars := repos[0].Stars
if maxStars <= 0 {
maxStars = 1
}
scale := float64(iconSize) / 16.0
for i, r := range repos {
y := rowY0 + i*rowDY
lang := r.PrimaryLanguage
if lang == "" {
lang = "—"
}
langColor := r.PrimaryColor
if langColor == "" {
langColor = t.Accent
}
name := truncateName(r.Name, nameMax)
// Language swatch + repo name on the left; horizontal bar + star count on the right.
fmt.Fprintf(&b, `
<circle cx="%d" cy="%d" r="4" fill="%s"/>
<text x="%d" y="%d" font-size="12" fill="%s">%s</text>`,
rowX+4, y-4, langColor,
rowX+14, y, t.Text, escapeXML(name))
bw := float64(barW) * float64(r.Stars) / float64(maxStars)
fmt.Fprintf(&b, `
<rect x="%d" y="%d" width="%d" height="%d" rx="3" fill="%s" fill-opacity="0.15"/>
<rect x="%d" y="%d" width="%.2f" height="%d" rx="3" fill="%s"/>`,
barX, y-barH+2, barW, barH, t.Accent,
barX, y-barH+2, bw, barH, t.Accent)
fmt.Fprintf(&b, `
<g transform="translate(%d,%.2f) scale(%.3f)" fill="%s">%s</g>
<text x="%d" y="%d" font-size="11" font-weight="600" fill="%s" text-anchor="end">%s</text>`,
barX+barW+6, float64(y-iconSize+2), scale, t.Muted, iconStar,
width-6, y, t.Accent, escapeXML(formatInt(r.Stars)))
}
b.WriteString(footer)
return []byte(b.String()), nil
}
// ownedNonForkRepos filters out forks so the card highlights the user's own
// work. TopRepos is already sorted by stargazer count desc at fetch time, so
// we just skim the prefix.
func ownedNonForkRepos(repos []github.RepoInfo) []github.RepoInfo {
out := make([]github.RepoInfo, 0, len(repos))
for _, r := range repos {
if r.IsFork {
continue
}
if r.Stars <= 0 {
continue
}
out = append(out, r)
}
return out
}
// truncateName trims to n runes and appends an ellipsis. Operates on runes
// so a multi-byte name (e.g. emoji) doesn't mid-cut.
func truncateName(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return strings.TrimRight(string(r[:n-1]), ".") + "…"
}
+8
View File
@@ -43,6 +43,12 @@ type Profile struct {
Productive [24]int
ProductiveAllTime [24]int
// Same pagination also feeds day-of-week histograms (index 0 = Sunday
// to match time.Weekday). Last-year and all-time kept separately so the
// weekday cards mirror the hour-of-day pair.
Weekday [7]int
WeekdayAllTime [7]int
// CommitsByLanguageAllTime is the lifetime counterpart of
// CommitsByLanguage, computed from the same commit stream.
CommitsByLanguageAllTime []LangStat
@@ -98,6 +104,7 @@ type LangStat struct {
type RepoInfo struct {
Owner string
Name string
Stars int
IsPrivate bool
IsFork bool
PrimaryLanguage string
@@ -148,6 +155,7 @@ func (r repoNode) toRepoInfo(defaultOwner string) RepoInfo {
info := RepoInfo{
Owner: defaultOwner,
Name: r.Name,
Stars: r.StargazerCount,
IsPrivate: r.IsPrivate,
IsFork: r.IsFork,
}
+2
View File
@@ -94,9 +94,11 @@ func (c *Client) FetchProductive(ctx context.Context, p *Profile, repos []RepoIn
}
tl := t.In(loc)
p.ProductiveAllTime[tl.Hour()]++
p.WeekdayAllTime[tl.Weekday()]++
attributeCommit(repo, repoTotal, allTimeLang, langColor)
if tl.After(yearAgo) {
p.Productive[tl.Hour()]++
p.Weekday[tl.Weekday()]++
attributeCommit(repo, repoTotal, lastYearLang, langColor)
}
seen++