mirror of
https://github.com/tiennm99/ghstats.git
synced 2026-08-31 22:23:52 +00:00
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:
@@ -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`:
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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,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 |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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>/.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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]), ".") + "…"
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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++
|
||||
|
||||
Reference in New Issue
Block a user