Files
ghglance/internal/card/profile.go
T
tiennm99 55421ebe3d fix(card): adapt to profile magnitude — abbreviate ticks, truncate long text (#9)
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.
2026-04-19 09:34:38 +07:00

138 lines
3.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package card
import (
"fmt"
"strings"
"time"
"github.com/tiennm99/ghstats/internal/github"
"github.com/tiennm99/ghstats/internal/theme"
)
type profileCard struct{}
func (profileCard) Filename() string { return "profile-details.svg" }
// profileRow is one labeled-by-icon line in the profile card.
type profileRow struct {
icon string // raw SVG path(s) from icons.go
value string
}
func (profileCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
const (
width = 340
height = 200
rowX = 20
rowY0 = 60
rowDY = 20
iconSize = 12
maxRows = 7
)
var b strings.Builder
b.WriteString(header(width, height, t.Background, t.Stroke, t.StrokeOpacity, t.Title, cardTitle(p)))
rows := buildProfileRows(p)
if len(rows) > maxRows {
rows = rows[:maxRows]
}
// scale factor to fit 16x16 octicon into iconSize box
scale := float64(iconSize) / 16.0
// Row text starts at rowX+iconSize+8 = 40; the right edge safety margin
// is width-10 = 330. Space = 290 px; at 12 px font × 0.6 char width =
// 7.2 px/char, so ~40 characters fit. Clamping here means a 200-char
// company name can't push the frame open.
const rowMaxChars = 40
for i, r := range rows {
y := rowY0 + i*rowDY
// icon glyph: translate to row position, scale down, fill with muted.
fmt.Fprintf(&b, `
<g transform="translate(%d,%.2f) scale(%.3f)" fill="%s">%s</g>
<text x="%d" y="%d" font-size="12" fill="%s">%s</text>`,
rowX, float64(y-iconSize+2), scale, t.Muted, r.icon,
rowX+iconSize+8, y, t.Text, escapeXML(truncate(r.value, rowMaxChars)))
}
b.WriteString(footer)
return []byte(b.String()), nil
}
// cardTitle mirrors github-profile-summary-cards: "login (Name)" when name is
// set, "login" otherwise. Clamped to 34 runes — the title renders at 15 px
// weight 600, so ~35 chars is the max that reliably fits in 340−20−10=310
// px of title row.
func cardTitle(p *github.Profile) string {
raw := p.Login
if p.Name != "" {
raw = p.Login + " (" + p.Name + ")"
}
return truncate(raw, 34)
}
func buildProfileRows(p *github.Profile) []profileRow {
var rows []profileRow
if p.Company != "" {
rows = append(rows, profileRow{icon: iconCompany, value: p.Company})
}
if p.Location != "" {
rows = append(rows, profileRow{icon: iconLocation, value: p.Location})
}
if p.Website != "" {
rows = append(rows, profileRow{icon: iconLink, value: p.Website})
}
if !p.CreatedAt.IsZero() {
rows = append(rows, profileRow{
icon: iconClock,
value: fmt.Sprintf("%s (%s)", p.CreatedAt.Format("2006-01-02"), ageAgo(p.CreatedAt)),
})
}
rows = append(rows, profileRow{
icon: iconPeople,
value: fmt.Sprintf("%s followers · %s following", formatInt(p.Followers), formatInt(p.Following)),
})
rows = append(rows, profileRow{
icon: iconRepos,
value: fmt.Sprintf("%s public repos", formatInt(p.PublicRepos)),
})
return rows
}
// ageAgo returns "N years ago" (rounded down to whole years). Falls back to
// months or days for accounts under a year old. Matches
// github-profile-summary-cards' getProfileDateJoined.
func ageAgo(since time.Time) string {
now := time.Now()
y := now.Year() - since.Year()
m := int(now.Month()) - int(since.Month())
if m < 0 || (m == 0 && now.Day() < since.Day()) {
y--
}
if y > 0 {
return fmt.Sprintf("%d %s ago", y, plural(y, "year"))
}
months := (now.Year()-since.Year())*12 + int(now.Month()) - int(since.Month())
if now.Day() < since.Day() {
months--
}
if months > 0 {
return fmt.Sprintf("%d %s ago", months, plural(months, "month"))
}
days := int(now.Sub(since).Hours() / 24)
if days < 0 {
days = 0
}
return fmt.Sprintf("%d %s ago", days, plural(days, "day"))
}
func plural(n int, word string) string {
if n == 1 {
return word
}
return word + "s"
}