diff --git a/README.md b/README.md index 215448b8..94a2e243 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Cards rendered: | 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 | +| 15 | **Records (all time)** | Six personal-best rows: peak day, peak month, first contribution, lifetime active days, account age, languages used | ## Preview — dracula theme @@ -42,7 +43,7 @@ Live render against the author's profile, committed by [`.github/workflows/demo. profile-detailsstats repos-per-languagetop-starred-repos contributions-by-yearcontributions-heatmap -streak +recordsstreak Last yearAll time most-commit-languagemost-commit-language-all-time contributionscontributions-all-time @@ -106,6 +107,7 @@ Then embed the cards in your `README.md`: ![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) +![records](./output/dracula/records.svg) ``` ### Action inputs @@ -205,6 +207,7 @@ output/ productive-weekday-all-time.svg contributions-all-time.svg contributions-by-year.svg + records.svg ``` `output/` is entirely gitignored — it's regenerated on each run. For a diff --git a/internal/card/card.go b/internal/card/card.go index 02fc526e..a2360fd2 100644 --- a/internal/card/card.go +++ b/internal/card/card.go @@ -36,6 +36,7 @@ var allCards = []Card{ productiveWeekdayAllTimeCard{}, contributionsAllTimeCard{}, contributionsByYearCard{}, + recordsCard{}, } // RenderAll writes every card into outDir//. diff --git a/internal/card/icons.go b/internal/card/icons.go index 921db765..9d7c4274 100644 --- a/internal/card/icons.go +++ b/internal/card/icons.go @@ -15,4 +15,7 @@ const ( iconPR = `` iconIssue = `` iconReview = `` + iconCalendar = `` + iconHistory = `` + iconGlobe = `` ) diff --git a/internal/card/records.go b/internal/card/records.go new file mode 100644 index 00000000..af18ef84 --- /dev/null +++ b/internal/card/records.go @@ -0,0 +1,197 @@ +package card + +import ( + "fmt" + "strings" + "time" + + "github.com/tiennm99/ghstats/internal/github" + "github.com/tiennm99/ghstats/internal/theme" +) + +type recordsCard struct{} + +func (recordsCard) Filename() string { return "records.svg" } + +// SVG renders six lifetime "personal-best" records mirroring the stats card's +// row layout. Records focus on extremes (peaks, firsts, lifetime totals) +// rather than the cumulative aggregates already shown by stats.svg, so the +// two cards complement instead of duplicating each other. +func (recordsCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) { + const ( + width = 340 + height = 200 + rowX = 20 + rowY0 = 55 + rowDY = 20 + iconSize = 12 + valueX = 320 + ) + + rows := buildRecordRows(p, time.Now()) + + var b strings.Builder + b.WriteString(header(width, height, t.Background, t.Stroke, t.StrokeOpacity, t.Title, "Records (all time)")) + + scale := float64(iconSize) / 16.0 + for i, r := range rows { + y := rowY0 + i*rowDY + fmt.Fprintf(&b, ` + %s + %s + %s`, + rowX, float64(y-iconSize+2), scale, t.Muted, r.icon, + rowX+iconSize+8, y, fontBody, t.Text, escapeXML(r.label), + valueX, y, fontBody, t.Accent, escapeXML(r.value)) + } + + b.WriteString(footer) + return []byte(b.String()), nil +} + +// recordRow mirrors statRow — icon + label + accent value rendered right-aligned. +type recordRow struct { + icon string + label string + value string +} + +// buildRecordRows derives the six record values from the Profile. Empty data +// yields an em-dash placeholder so the card always renders six rows. +func buildRecordRows(p *github.Profile, now time.Time) []recordRow { + const dash = "—" + + bestCount, bestDate := peakDay(p.DailyContributionsAllTime) + bestDayValue := dash + if !bestDate.IsZero() { + bestDayValue = fmt.Sprintf("%s on %s", formatInt(bestCount), bestDate.Format("2006-01-02")) + } + + monthCount, monthDate := peakMonth(p.DailyContributionsAllTime) + bestMonthValue := dash + if !monthDate.IsZero() { + bestMonthValue = fmt.Sprintf("%s in %s", formatInt(monthCount), monthDate.Format("Jan 2006")) + } + + firstDate := firstActiveDay(p.DailyContributionsAllTime) + firstValue := dash + if !firstDate.IsZero() { + firstValue = firstDate.Format("2006-01-02") + } + + activeValue := formatInt(activeDaysCount(p.DailyContributionsAllTime)) + + ageValue := dash + if !p.CreatedAt.IsZero() { + ageValue = fmt.Sprintf("%.1f years", accountAgeYears(p.CreatedAt, now)) + } + + langValue := formatInt(languagesUsed(p.CommitsByLanguageAllTime)) + + return []recordRow{ + {iconCommit, "Best day", bestDayValue}, + {iconStar, "Best month", bestMonthValue}, + {iconCalendar, "First contribution", firstValue}, + {iconHistory, "Active days", activeValue}, + {iconClock, "On GitHub", ageValue}, + {iconGlobe, "Languages used", langValue}, + } +} + +// peakDay returns the highest single-day count and its date. Ties resolve to +// the earliest date because the loop only overwrites on strictly-greater +// counts and the input is chronological. +func peakDay(days []github.DailyContribution) (int, time.Time) { + var maxCount int + var maxDate time.Time + for _, d := range days { + if d.Date.IsZero() { + continue + } + if d.Count > maxCount { + maxCount = d.Count + maxDate = d.Date + } + } + return maxCount, maxDate +} + +// peakMonth aggregates contributions by calendar month and returns the +// busiest month's total + the first-of-month date for that bucket. Ties +// resolve to the earliest month — `firstSeen` records the input index where +// each bucket was opened so we don't depend on Go map iteration order. +func peakMonth(days []github.DailyContribution) (int, time.Time) { + if len(days) == 0 { + return 0, time.Time{} + } + type monthKey struct { + year int + month time.Month + } + totals := make(map[monthKey]int) + firstSeen := make(map[monthKey]int) + for i, d := range days { + if d.Date.IsZero() { + continue + } + k := monthKey{d.Date.Year(), d.Date.Month()} + if _, ok := totals[k]; !ok { + firstSeen[k] = i + } + totals[k] += d.Count + } + var bestKey monthKey + bestCount := 0 + bestSeen := -1 + for k, c := range totals { + if c > bestCount || (c == bestCount && (bestSeen < 0 || firstSeen[k] < bestSeen)) { + bestCount = c + bestKey = k + bestSeen = firstSeen[k] + } + } + if bestCount == 0 { + return 0, time.Time{} + } + return bestCount, time.Date(bestKey.year, bestKey.month, 1, 0, 0, 0, 0, time.UTC) +} + +// firstActiveDay returns the first day with Count > 0. Zero time when none. +func firstActiveDay(days []github.DailyContribution) time.Time { + for _, d := range days { + if d.Count > 0 { + return d.Date + } + } + return time.Time{} +} + +// activeDaysCount counts days where Count > 0. +func activeDaysCount(days []github.DailyContribution) int { + var n int + for _, d := range days { + if d.Count > 0 { + n++ + } + } + return n +} + +// accountAgeYears returns now − createdAt in fractional Julian years. The +// caller renders to one decimal place, so leap-year noise stays invisible. +func accountAgeYears(createdAt, now time.Time) float64 { + if createdAt.IsZero() { + return 0 + } + hours := now.Sub(createdAt).Hours() + if hours < 0 { + return 0 + } + return hours / 24.0 / 365.25 +} + +// languagesUsed is a thin wrapper for symmetry with the other helpers. +// Upstream guarantees the slice is already deduped per language. +func languagesUsed(stats []github.LangStat) int { + return len(stats) +} diff --git a/internal/card/records_test.go b/internal/card/records_test.go new file mode 100644 index 00000000..640dba8a --- /dev/null +++ b/internal/card/records_test.go @@ -0,0 +1,206 @@ +package card + +import ( + "strings" + "testing" + "time" + + "github.com/tiennm99/ghstats/internal/github" + "github.com/tiennm99/ghstats/internal/theme" +) + +// dayAt is a tiny helper so fixtures stay readable. +func dayAt(y int, m time.Month, d, count int) github.DailyContribution { + return github.DailyContribution{ + Date: time.Date(y, m, d, 0, 0, 0, 0, time.UTC), + Count: count, + } +} + +func TestPeakDay(t *testing.T) { + cases := []struct { + name string + days []github.DailyContribution + wantCount int + wantDate time.Time + }{ + {"empty", nil, 0, time.Time{}}, + {"all zero", []github.DailyContribution{dayAt(2025, 1, 1, 0), dayAt(2025, 1, 2, 0)}, 0, time.Time{}}, + {"single peak", []github.DailyContribution{dayAt(2025, 1, 1, 5), dayAt(2025, 1, 2, 12), dayAt(2025, 1, 3, 3)}, 12, time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)}, + {"tie picks earliest", []github.DailyContribution{dayAt(2025, 1, 1, 7), dayAt(2025, 1, 2, 7)}, 7, time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)}, + {"skip zero-date pad", []github.DailyContribution{{}, dayAt(2025, 1, 5, 9)}, 9, time.Date(2025, 1, 5, 0, 0, 0, 0, time.UTC)}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + gotCount, gotDate := peakDay(c.days) + if gotCount != c.wantCount || !gotDate.Equal(c.wantDate) { + t.Errorf("peakDay = (%d, %v), want (%d, %v)", gotCount, gotDate, c.wantCount, c.wantDate) + } + }) + } +} + +func TestPeakMonth(t *testing.T) { + cases := []struct { + name string + days []github.DailyContribution + wantCount int + wantDate time.Time + }{ + {"empty", nil, 0, time.Time{}}, + {"all zero", []github.DailyContribution{dayAt(2025, 1, 1, 0)}, 0, time.Time{}}, + { + "two months, second wins", + []github.DailyContribution{ + dayAt(2025, 1, 1, 5), dayAt(2025, 1, 2, 5), + dayAt(2025, 2, 1, 100), + }, + 100, time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC), + }, + { + "tie picks earliest month", + []github.DailyContribution{ + dayAt(2025, 1, 5, 10), + dayAt(2025, 2, 5, 10), + }, + 10, time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + }, + { + "month total sums correctly", + []github.DailyContribution{ + dayAt(2025, 3, 1, 4), dayAt(2025, 3, 15, 6), dayAt(2025, 3, 31, 2), + dayAt(2025, 4, 1, 11), + }, + 12, time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC), + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + gotCount, gotDate := peakMonth(c.days) + if gotCount != c.wantCount || !gotDate.Equal(c.wantDate) { + t.Errorf("peakMonth = (%d, %v), want (%d, %v)", gotCount, gotDate, c.wantCount, c.wantDate) + } + }) + } +} + +func TestFirstActiveDay(t *testing.T) { + cases := []struct { + name string + days []github.DailyContribution + want time.Time + }{ + {"empty", nil, time.Time{}}, + {"all zero", []github.DailyContribution{dayAt(2025, 1, 1, 0), dayAt(2025, 1, 2, 0)}, time.Time{}}, + {"first nonzero", []github.DailyContribution{dayAt(2025, 1, 1, 0), dayAt(2025, 1, 2, 3), dayAt(2025, 1, 3, 7)}, time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := firstActiveDay(c.days) + if !got.Equal(c.want) { + t.Errorf("firstActiveDay = %v, want %v", got, c.want) + } + }) + } +} + +func TestActiveDaysCount(t *testing.T) { + days := []github.DailyContribution{ + dayAt(2025, 1, 1, 0), + dayAt(2025, 1, 2, 1), + dayAt(2025, 1, 3, 0), + dayAt(2025, 1, 4, 99), + } + if got := activeDaysCount(days); got != 2 { + t.Errorf("activeDaysCount = %d, want 2", got) + } + if got := activeDaysCount(nil); got != 0 { + t.Errorf("activeDaysCount(nil) = %d, want 0", got) + } +} + +func TestAccountAgeYears(t *testing.T) { + now := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC) + cases := []struct { + name string + createdAt time.Time + want float64 // tolerance 0.05 + }{ + {"zero", time.Time{}, 0}, + {"future", now.Add(48 * time.Hour), 0}, + {"two years prior", time.Date(2024, 5, 9, 0, 0, 0, 0, time.UTC), 2.0}, + {"~8.7 years", time.Date(2017, 9, 1, 0, 0, 0, 0, time.UTC), 8.7}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := accountAgeYears(c.createdAt, now) + diff := got - c.want + if diff < -0.05 || diff > 0.05 { + t.Errorf("accountAgeYears = %.3f, want ~%.2f", got, c.want) + } + }) + } +} + +func TestLanguagesUsed(t *testing.T) { + if got := languagesUsed(nil); got != 0 { + t.Errorf("languagesUsed(nil) = %d, want 0", got) + } + stats := []github.LangStat{{Name: "Go"}, {Name: "TypeScript"}, {Name: "Python"}} + if got := languagesUsed(stats); got != 3 { + t.Errorf("languagesUsed = %d, want 3", got) + } +} + +func TestRecordsCardSVG(t *testing.T) { + th, _ := theme.Lookup("dracula") + p := &github.Profile{ + CreatedAt: time.Date(2017, 9, 1, 0, 0, 0, 0, time.UTC), + DailyContributionsAllTime: []github.DailyContribution{ + dayAt(2017, 9, 5, 1), + dayAt(2017, 9, 6, 0), + dayAt(2026, 4, 18, 88), + dayAt(2026, 5, 1, 13), + }, + CommitsByLanguageAllTime: []github.LangStat{{Name: "Go"}, {Name: "Rust"}}, + } + + svg, err := recordsCard{}.SVG(p, th) + if err != nil { + t.Fatalf("SVG err: %v", err) + } + out := string(svg) + + mustContain := []string{ + "Records (all time)", + "Best day", "88 on 2026-04-18", + "Best month", "in Apr 2026", + "First contribution", "2017-09-05", + "Active days", + "On GitHub", + "Languages used", + } + for _, s := range mustContain { + if !strings.Contains(out, s) { + t.Errorf("missing %q in SVG", s) + } + } +} + +func TestRecordsCardEmptyProfile(t *testing.T) { + th, _ := theme.Lookup("dracula") + svg, err := recordsCard{}.SVG(&github.Profile{}, th) + if err != nil { + t.Fatalf("empty SVG err: %v", err) + } + out := string(svg) + // All six labels still present; values fall back to em-dash. + for _, label := range []string{"Best day", "Best month", "First contribution", "Active days", "On GitHub", "Languages used"} { + if !strings.Contains(out, label) { + t.Errorf("empty profile missing label %q", label) + } + } + if !strings.Contains(out, "—") { + t.Error("empty profile should emit em-dash placeholder") + } +}