From fd4c70e53eaa16acff229dc51053d43e2eef12b4 Mon Sep 17 00:00:00 2001 From: Tien Nguyen Minh Date: Sun, 19 Apr 2026 08:58:59 +0700 Subject: [PATCH] =?UTF-8?q?feat(card):=20add=20S-tier=20cards=20=E2=80=94?= =?UTF-8?q?=20heatmap,=20streak,=20by-year,=20weekday,=20top-starred=20(#3?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 26 +++- docs/codebase-summary.md | 9 +- docs/project-overview-pdr.md | 4 +- docs/project-roadmap.md | 12 ++ internal/card/card.go | 6 + internal/card/card_test.go | 39 +++++ internal/card/contributions_by_year.go | 151 +++++++++++++++++++ internal/card/contributions_heatmap.go | 199 +++++++++++++++++++++++++ internal/card/productive_weekday.go | 111 ++++++++++++++ internal/card/streak.go | 130 ++++++++++++++++ internal/card/top_starred_repos.go | 116 ++++++++++++++ internal/github/model.go | 8 + internal/github/productive.go | 2 + 13 files changed, 805 insertions(+), 8 deletions(-) create mode 100644 internal/card/contributions_by_year.go create mode 100644 internal/card/contributions_heatmap.go create mode 100644 internal/card/productive_weekday.go create mode 100644 internal/card/streak.go create mode 100644 internal/card/top_starred_repos.go diff --git a/README.md b/README.md index 0f99c02d..597bb8cb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 54e5132e..e966f0e4 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -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. diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index 717b86ac..5440b95b 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -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 | diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 1e067cc7..41c3dab4 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -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`. diff --git a/internal/card/card.go b/internal/card/card.go index 47715a17..02fc526e 100644 --- a/internal/card/card.go +++ b/internal/card/card.go @@ -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//. diff --git a/internal/card/card_test.go b/internal/card/card_test.go index 91be3e37..84c90ab8 100644 --- a/internal/card/card_test.go +++ b/internal/card/card_test.go @@ -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 & ", 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)) diff --git a/internal/card/contributions_by_year.go b/internal/card/contributions_by_year.go new file mode 100644 index 00000000..4ba4479a --- /dev/null +++ b/internal/card/contributions_by_year.go @@ -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, ` + No contribution data available.`, 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, ` + `, + leftAxis, topPad, leftAxis, topPad+chartH, t.Muted) + for _, v := range ticks { + y := topPad + chartH - int(float64(chartH)*v/yMax) + fmt.Fprintf(&b, ` + + %s`, + leftAxis-4, y, leftAxis, y, t.Muted, + leftAxis-6, y+3, t.Muted, escapeXML(formatTick(v))) + } + + // X axis baseline. + fmt.Fprintf(&b, ` + `, + 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, ` + %d — %d commits`, + x, y, barW, barH, fill, bk.Year, bk.Count) + + if i%labelStride == 0 || i == len(buckets)-1 { + cx := x + barW/2 + fmt.Fprintf(&b, ` + %d`, + 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 + } +} diff --git a/internal/card/contributions_heatmap.go b/internal/card/contributions_heatmap.go new file mode 100644 index 00000000..8d5d3375 --- /dev/null +++ b/internal/card/contributions_heatmap.go @@ -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, ` + No contribution data available.`, 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, ` + %s`, + 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, ` + %s`, + 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, ` + %s — %d`, + 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, ` + Less`, legendX, legendY, t.Muted) + for i, c := range ramp { + fmt.Fprintf(&b, ` + `, + legendX+28+i*(cellSize+2), legendY-cellSize+2, cellSize, cellSize, c) + } + fmt.Fprintf(&b, ` + More`, + 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 +} diff --git a/internal/card/productive_weekday.go b/internal/card/productive_weekday.go new file mode 100644 index 00000000..11429f33 --- /dev/null +++ b/internal/card/productive_weekday.go @@ -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, ` + `, + leftAxis, topPad, leftAxis, topPad+chartH, t.Muted) + for _, v := range ticks { + y := topPad + chartH - int(float64(chartH)*v/yMax) + fmt.Fprintf(&b, ` + + %s`, + leftAxis-4, y, leftAxis, y, t.Muted, + leftAxis-6, y+3, t.Muted, escapeXML(formatTick(v))) + } + + // X axis baseline + weekday labels. + fmt.Fprintf(&b, ` + `, + 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, ` + %s — %d commits + %s`, + 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()) +} diff --git a/internal/card/streak.go b/internal/card/streak.go new file mode 100644 index 00000000..2a34afe6 --- /dev/null +++ b/internal/card/streak.go @@ -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, ` + %s + %s`, + cx, 95, t.Accent, escapeXML(c.value), + cx, 120, t.Text, escapeXML(c.label)) + if c.end != "" { + fmt.Fprintf(&b, ` + %s`, + 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") +} diff --git a/internal/card/top_starred_repos.go b/internal/card/top_starred_repos.go new file mode 100644 index 00000000..edae83ef --- /dev/null +++ b/internal/card/top_starred_repos.go @@ -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, ` + No public repos with stars.`, 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, ` + + %s`, + rowX+4, y-4, langColor, + rowX+14, y, t.Text, escapeXML(name)) + + bw := float64(barW) * float64(r.Stars) / float64(maxStars) + fmt.Fprintf(&b, ` + + `, + barX, y-barH+2, barW, barH, t.Accent, + barX, y-barH+2, bw, barH, t.Accent) + + fmt.Fprintf(&b, ` + %s + %s`, + 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]), ".") + "…" +} diff --git a/internal/github/model.go b/internal/github/model.go index 5ee54e67..497902d5 100644 --- a/internal/github/model.go +++ b/internal/github/model.go @@ -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, } diff --git a/internal/github/productive.go b/internal/github/productive.go index 3903e28f..c5749f25 100644 --- a/internal/github/productive.go +++ b/internal/github/productive.go @@ -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++