mirror of
https://github.com/tiennm99/ghstats.git
synced 2026-09-01 04:19:52 +00:00
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:
@@ -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`:
|
||||

|
||||

|
||||

|
||||

|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
@@ -36,6 +36,7 @@ var allCards = []Card{
|
||||
productiveWeekdayAllTimeCard{},
|
||||
contributionsAllTimeCard{},
|
||||
contributionsByYearCard{},
|
||||
recordsCard{},
|
||||
}
|
||||
|
||||
// RenderAll writes every card into outDir/<themeID>/.
|
||||
|
||||
@@ -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"/>`
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user