mirror of
https://github.com/tiennm99/ghglance.git
synced 2026-10-11 03:13:20 +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:
1 parent
ed15d29d1f
commit
01c812158f
5 files changed
+411
-1
No files matched your search
@@ -31,6 +31,7 @@ Cards rendered:
|
|||||||
| 12 | **Productive weekday (all time)** | Same as #5 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 |
|
| 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 |
|
| 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
|
## 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/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/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><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><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/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>
|
<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
|
### Action inputs
|
||||||
@@ -205,6 +207,7 @@ output/
|
|||||||
productive-weekday-all-time.svg
|
productive-weekday-all-time.svg
|
||||||
contributions-all-time.svg
|
contributions-all-time.svg
|
||||||
contributions-by-year.svg
|
contributions-by-year.svg
|
||||||
|
records.svg
|
||||||
```
|
```
|
||||||
|
|
||||||
`output/` is entirely gitignored — it's regenerated on each run. For a
|
`output/` is entirely gitignored — it's regenerated on each run. For a
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ var allCards = []Card{
|
|||||||
productiveWeekdayAllTimeCard{},
|
productiveWeekdayAllTimeCard{},
|
||||||
contributionsAllTimeCard{},
|
contributionsAllTimeCard{},
|
||||||
contributionsByYearCard{},
|
contributionsByYearCard{},
|
||||||
|
recordsCard{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderAll writes every card into outDir/<themeID>/.
|
// 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"/>`
|
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"/>`
|
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"/>`
|
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