mirror of
https://github.com/tiennm99/ghstats.git
synced 2026-10-03 05:19:59 +00:00
The stress test caught real overflows from profiles the author doesn't
have:
- Y-axis tick labels were raw integers. A user with 10,000+ yearly
contributions or 1000+ monthly commits would render "10000" / "1000000"
at text-anchor="end" against a ~28 px gutter — the digits spilled
leftward past x=0. formatTick now abbreviates: 999→"999", 1500→"1.5k",
12345→"12k", 1234567→"1.2M". No label exceeds 4 chars, all fit the
gutter for every card that uses niceTicks.
- Profile details dumped Company / Location / Website / title verbatim,
which works for the author (VNG, Ho Chi Minh, miti99.com) but not for
40-char strings. Each row truncates at 40 runes; the title truncates at
34. Uses a rune-aware truncate() helper hoisted out of top-starred-repos
into svg.go so every list-style card can share it.
- Streak date range collapses to a single-year form ("Jan 2 — Dec 31")
when start.Year() == end.Year() and to "YYYY — YYYY" across years. The
previous "Jan 2 — Dec 31, 2025" format at 10 px × 21 chars pushed past
the ~113 px column width.
The TestCardsFitFrame stress test was reading text-anchor and font-size
with a non-greedy regex that missed attributes whose position varied. It
now parses the opening <text> tag as a block and extracts each attribute
with its own regex, so text-anchor="end" / "middle" elements are no
longer false negatives. The check also estimates rendered width
(0.6 × font-size × len) and asserts the implied left/right edges stay in
the frame — catching exactly the class of bug the axis-tick case
represents.
148 lines
4.3 KiB
Go
148 lines
4.3 KiB
Go
package card
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tiennm99/ghstats/internal/github"
|
|
"github.com/tiennm99/ghstats/internal/theme"
|
|
)
|
|
|
|
type streakCard struct{}
|
|
|
|
func (streakCard) Filename() string { return "streak.svg" }
|
|
|
|
func (streakCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
|
|
const (
|
|
width = 340
|
|
height = 200
|
|
)
|
|
|
|
stats := computeStreak(p.DailyContributionsAllTime)
|
|
|
|
var b strings.Builder
|
|
b.WriteString(header(width, height, t.Background, t.Stroke, t.StrokeOpacity, t.Title, "Streak"))
|
|
|
|
// Three large stat columns (current / longest / active-days) side by side,
|
|
// each with a big number on top, a label underneath, and a small detail
|
|
// line. Keeping the big number to a single formatted integer per column
|
|
// means no column can overflow regardless of magnitude (formatInt adds
|
|
// thousands separators and even 10-digit counts fit ≤113 px at 28 px).
|
|
cols := []struct {
|
|
value string
|
|
label string
|
|
detail string
|
|
}{
|
|
{formatInt(stats.Current), "Current streak", streakRange(stats.CurrentStart, stats.CurrentEnd)},
|
|
{formatInt(stats.Longest), "Longest streak", streakRange(stats.LongestStart, stats.LongestEnd)},
|
|
{formatInt(stats.Active), "Active days", activeDaysDetail(stats.Active, stats.Total)},
|
|
}
|
|
colW := width / len(cols)
|
|
for i, c := range cols {
|
|
cx := colW*i + colW/2
|
|
fmt.Fprintf(&b, `
|
|
<text x="%d" y="%d" font-size="28" font-weight="700" fill="%s" text-anchor="middle">%s</text>
|
|
<text x="%d" y="%d" font-size="12" fill="%s" text-anchor="middle">%s</text>`,
|
|
cx, 95, t.Accent, escapeXML(c.value),
|
|
cx, 120, t.Text, escapeXML(c.label))
|
|
if c.detail != "" {
|
|
fmt.Fprintf(&b, `
|
|
<text x="%d" y="%d" font-size="10" fill="%s" text-anchor="middle">%s</text>`,
|
|
cx, 140, t.Muted, escapeXML(c.detail))
|
|
}
|
|
}
|
|
|
|
b.WriteString(footer)
|
|
return []byte(b.String()), nil
|
|
}
|
|
|
|
// streakStats is the post-processed daily series summarised for the card.
|
|
type streakStats struct {
|
|
Current int
|
|
CurrentStart, CurrentEnd time.Time
|
|
Longest int
|
|
LongestStart, LongestEnd time.Time
|
|
Active int // days with ≥1 contribution
|
|
Total int // total days observed
|
|
}
|
|
|
|
// computeStreak walks the daily series once. The "current streak" runs
|
|
// backwards from the most recent day; if today has 0 contributions we still
|
|
// count yesterday as current (a single-day grace) so the card doesn't reset
|
|
// the moment a user hasn't pushed yet today.
|
|
func computeStreak(days []github.DailyContribution) streakStats {
|
|
var s streakStats
|
|
if len(days) == 0 {
|
|
return s
|
|
}
|
|
s.Total = len(days)
|
|
|
|
// Longest streak + active day count: single forward pass.
|
|
var run int
|
|
var runStart time.Time
|
|
for _, d := range days {
|
|
if d.Count > 0 {
|
|
s.Active++
|
|
if run == 0 {
|
|
runStart = d.Date
|
|
}
|
|
run++
|
|
if run > s.Longest {
|
|
s.Longest = run
|
|
s.LongestStart = runStart
|
|
s.LongestEnd = d.Date
|
|
}
|
|
} else {
|
|
run = 0
|
|
}
|
|
}
|
|
|
|
// Current streak: walk backwards from the end. Skip at most one trailing
|
|
// zero-day (today-not-pushed-yet) before aborting.
|
|
tail := len(days) - 1
|
|
if days[tail].Count == 0 && tail > 0 {
|
|
tail--
|
|
}
|
|
for i := tail; i >= 0; i-- {
|
|
if days[i].Count == 0 {
|
|
break
|
|
}
|
|
s.Current++
|
|
s.CurrentEnd = days[tail].Date
|
|
s.CurrentStart = days[i].Date
|
|
}
|
|
return s
|
|
}
|
|
|
|
// activeDaysDetail renders the "/ total" denominator as a small sub-line so
|
|
// the big number in the column stays a single formatted integer — that way
|
|
// a user with 10,000+ active days never squeezes against the column edges.
|
|
func activeDaysDetail(active, total int) string {
|
|
if total <= 0 {
|
|
return ""
|
|
}
|
|
pct := 100 * active / total
|
|
return fmt.Sprintf("of %s total (%d%%)", formatInt(total), pct)
|
|
}
|
|
|
|
// streakRange formats the open/close dates of a streak for the small detail
|
|
// line. Each column is ~113 px wide at font-size 10 (≤ ~18 chars), so we
|
|
// aggressively drop year and redundant parts when they'd blow the budget.
|
|
//
|
|
// same day -> "Jan 2 2025"
|
|
// same year -> "Jan 2 — Dec 31"
|
|
// different -> "2024 — 2026"
|
|
func streakRange(start, end time.Time) string {
|
|
if start.IsZero() || end.IsZero() {
|
|
return ""
|
|
}
|
|
if start.Equal(end) {
|
|
return start.Format("Jan 2 2006")
|
|
}
|
|
if start.Year() != end.Year() {
|
|
return fmt.Sprintf("%d — %d", start.Year(), end.Year())
|
|
}
|
|
return start.Format("Jan 2") + " — " + end.Format("Jan 2")
|
|
}
|