feat(card): add records card with six lifetime personal-bests

New records.svg surfaces extremes the existing aggregate cards don't:
peak day (count + date), peak month, first contribution, lifetime
active days, account age in fractional years, and distinct languages
used. All values derive from data already fetched — no new GraphQL
queries. Three new octicon paths (calendar, history, globe) added to
icons.go; existing icons reused where they fit.
This commit is contained in:
2026-05-09 09:47:38 +07:00
parent ed15d29d1f
commit 01c812158f
5 changed files with 411 additions and 1 deletions
+4 -1
View File
@@ -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.
<tr><td><img src="./demo/dracula/profile-details.svg" alt="profile-details" /></td><td><img src="./demo/dracula/stats.svg" alt="stats" /></td></tr>
<tr><td><img src="./demo/dracula/repos-per-language.svg" alt="repos-per-language" /></td><td><img src="./demo/dracula/top-starred-repos.svg" alt="top-starred-repos" /></td></tr>
<tr><td><img src="./demo/dracula/contributions-by-year.svg" alt="contributions-by-year" /></td><td><img src="./demo/dracula/contributions-heatmap.svg" alt="contributions-heatmap" /></td></tr>
<tr><td colspan="2" align="center"><img src="./demo/dracula/streak.svg" alt="streak" /></td></tr>
<tr><td><img src="./demo/dracula/records.svg" alt="records" /></td><td><img src="./demo/dracula/streak.svg" alt="streak" /></td></tr>
<tr><th>Last year</th><th>All time</th></tr>
<tr><td><img src="./demo/dracula/most-commit-language.svg" alt="most-commit-language" /></td><td><img src="./demo/dracula/most-commit-language-all-time.svg" alt="most-commit-language-all-time" /></td></tr>
<tr><td><img src="./demo/dracula/contributions.svg" alt="contributions" /></td><td><img src="./demo/dracula/contributions-all-time.svg" alt="contributions-all-time" /></td></tr>
@@ -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
+1
View File
@@ -36,6 +36,7 @@ var allCards = []Card{
productiveWeekdayAllTimeCard{},
contributionsAllTimeCard{},
contributionsByYearCard{},
recordsCard{},
}
// RenderAll writes every card into outDir/<themeID>/.
+3
View File
@@ -15,4 +15,7 @@ const (
iconPR = `<path fill-rule="evenodd" d="M7.177 3.073L9.573.677A.25.25 0 0110 .854v4.792a.25.25 0 01-.427.177L7.177 3.427a.25.25 0 010-.354zM3.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122v5.256a2.251 2.251 0 11-1.5 0V5.372A2.25 2.25 0 011.5 3.25zM11 2.5h-1V4h1a1 1 0 011 1v5.628a2.251 2.251 0 101.5 0V5A2.5 2.5 0 0011 2.5zm1 10.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0zM3.75 12a.75.75 0 100 1.5.75.75 0 000-1.5z"/>`
iconIssue = `<path fill-rule="evenodd" d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm9 3a1 1 0 11-2 0 1 1 0 012 0zm-.25-6.25a.75.75 0 00-1.5 0v3.5a.75.75 0 001.5 0v-3.5z"/>`
iconReview = `<path fill-rule="evenodd" d="M1.5 2.75a.25.25 0 01.25-.25h12.5a.25.25 0 01.25.25v8.5a.25.25 0 01-.25.25h-6.5a.75.75 0 00-.53.22L4.5 14.44v-2.19a.75.75 0 00-.75-.75h-2a.25.25 0 01-.25-.25v-8.5zM1.75 1A1.75 1.75 0 000 2.75v8.5C0 12.216.784 13 1.75 13H3v1.543a1.457 1.457 0 002.487 1.03L8.061 13h6.189A1.75 1.75 0 0016 11.25v-8.5A1.75 1.75 0 0014.25 1H1.75zm5.03 3.47a.75.75 0 010 1.06L5.31 7l1.47 1.47a.75.75 0 01-1.06 1.06l-2-2a.75.75 0 010-1.06l2-2a.75.75 0 011.06 0zm2.44 0a.75.75 0 000 1.06L10.69 7 9.22 8.47a.75.75 0 001.06 1.06l2-2a.75.75 0 000-1.06l-2-2a.75.75 0 00-1.06 0z"/>`
iconCalendar = `<path fill-rule="evenodd" d="M4.75 0a.75.75 0 01.75.75V2h5V.75a.75.75 0 011.5 0V2h1.25c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0113.25 16H2.75A1.75 1.75 0 011 14.25V3.75C1 2.784 1.784 2 2.75 2H4V.75A.75.75 0 014.75 0zm0 3.5h8.5a.25.25 0 01.25.25V6h-11V3.75a.25.25 0 01.25-.25h2zM2.5 7.5v6.75c0 .138.112.25.25.25h10.5a.25.25 0 00.25-.25V7.5h-11z"/>`
iconHistory = `<path d="M1.643 3.143L.427 1.927A.25.25 0 000 2.104V5.75c0 .138.112.25.25.25h3.646a.25.25 0 00.177-.427L2.715 4.215a6.5 6.5 0 11-1.18 4.456.75.75 0 10-1.493.154 8.001 8.001 0 101.6-5.682zM7.75 4a.75.75 0 01.75.75v2.992l2.028.812a.75.75 0 01-.557 1.392l-2.5-1A.75.75 0 017 8.25v-3.5A.75.75 0 017.75 4z"/>`
iconGlobe = `<path fill-rule="evenodd" d="M1.543 7.25h2.733c.144-2.074.866-3.756 1.58-4.948.12-.197.237-.381.353-.552a6.506 6.506 0 00-4.666 5.5zm2.733 1.5H1.543a6.506 6.506 0 004.666 5.5 11.13 11.13 0 01-.352-.552c-.715-1.192-1.437-2.874-1.581-4.948zm1.504 0h4.44a9.637 9.637 0 01-1.363 4.177c-.306.51-.612.919-.857 1.215-.245-.296-.55-.705-.857-1.215A9.638 9.638 0 015.78 8.75zM10.22 7.25H5.78a9.637 9.637 0 011.363-4.177c.306-.51.612-.919.857-1.215.245.296.55.705.857 1.215A9.638 9.638 0 0110.22 7.25zm1.504 1.5c-.144 2.074-.866 3.756-1.58 4.948-.12.197-.237.381-.353.552a6.506 6.506 0 004.666-5.5h-2.733zm2.733-1.5h-2.733c-.144-2.074-.866-3.756-1.58-4.948a11.13 11.13 0 00-.353-.552 6.506 6.506 0 014.666 5.5zM8 0a8 8 0 100 16A8 8 0 008 0z"/>`
)
+197
View File
@@ -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, `
<g transform="translate(%d,%.2f) scale(%.3f)" fill="%s">%s</g>
<text x="%d" y="%d" font-size="%d" fill="%s">%s</text>
<text x="%d" y="%d" font-size="%d" font-weight="600" fill="%s" text-anchor="end">%s</text>`,
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)
}
+206
View File
@@ -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")
}
}