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.
 |  |
 |  |
 |  |
- |
+ |  |
| Last year | All time |
 |  |
 |  |
@@ -106,6 +107,7 @@ Then embed the cards in your `README.md`:



+
```
### 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")
+ }
+}