feat: implement profile summary cards with GraphQL fetch and Action wrapper

- Add GraphQL client fetching profile, stats, language aggregation, and
  per-repo commit histograms for the productive-time heatmap.
- Render real SVG cards (profile details, top languages, stats grid,
  weekday×hour heatmap) with XML escaping and thousands-formatted numbers.
- Expand theme palette to 30 built-ins ported from github-readme-stats;
  add -list-themes, multi-theme rendering, and 'all' shortcut.
- Package as Docker-based GitHub Action (action.yml, Dockerfile,
  entrypoint.sh) with optional auto-commit of generated cards.
- Release workflow publishes GHCR image and cross-platform binaries on
  v* tags.
- Unit tests cover rendering, XML escape, number formatting, language sort.
This commit is contained in:
tiennm99 committed 2026-04-18 18:39:51 +07:00
1 parent a91d964a1d
commit ff4975fae2
19 files changed
+1312 -135

No files matched your search

+67 -8
View File
@@ -2,6 +2,7 @@ package card
import (
"fmt"
"strings"
"github.com/tiennm99/ghstats/internal/github"
"github.com/tiennm99/ghstats/internal/theme"
@@ -11,13 +12,71 @@ type productiveCard struct{}
func (productiveCard) Filename() string { return "3-productive-time.svg" }
var weekdayLabels = [7]string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
func (productiveCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
// TODO: render the [7][24]int heatmap from p.Productive.
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200">
<rect width="100%%" height="100%%" fill="%s"/>
<text x="20" y="40" fill="%s" font-family="sans-serif" font-size="20">Productive Time</text>
<text x="20" y="80" fill="%s" font-family="sans-serif" font-size="12">Heatmap placeholder</text>
</svg>`, t.Background, t.Title, t.Text)
_ = p
return []byte(svg), nil
const (
width = 650
height = 240
cellSize = 18
cellGap = 3
gridX = 55
gridY = 60
)
var b strings.Builder
b.WriteString(header(width, height, t.Background, t.Title, "Productive Time (last year, by hour)"))
max := 0
for _, row := range p.Productive {
for _, v := range row {
if v > max {
max = v
}
}
}
// Weekday labels.
for i, d := range weekdayLabels {
y := gridY + i*(cellSize+cellGap) + cellSize - 4
fmt.Fprintf(&b, `
<text x="25" y="%d" font-size="11" fill="%s">%s</text>`,
y, t.Muted, d)
}
// Hour labels along top (every 3 hours).
for h := 0; h < 24; h += 3 {
x := gridX + h*(cellSize+cellGap)
fmt.Fprintf(&b, `
<text x="%d" y="55" font-size="10" fill="%s">%02dh</text>`,
x, t.Muted, h)
}
// Cells.
for d := 0; d < 7; d++ {
for h := 0; h < 24; h++ {
count := p.Productive[d][h]
opacity := heatOpacity(count, max)
x := gridX + h*(cellSize+cellGap)
y := gridY + d*(cellSize+cellGap)
fmt.Fprintf(&b, `
<rect x="%d" y="%d" width="%d" height="%d" rx="3" fill="%s" fill-opacity="%.2f"><title>%s %02d:00 — %d commits</title></rect>`,
x, y, cellSize, cellSize, t.Accent, opacity,
weekdayLabels[d], h, count)
}
}
b.WriteString(footer)
return []byte(b.String()), nil
}
// heatOpacity returns the fill-opacity for a cell. Zero is almost transparent
// so the grid is still visible; max-count maps to fully opaque.
func heatOpacity(count, max int) float64 {
if max == 0 {
return 0.08
}
const floor = 0.10
ratio := float64(count) / float64(max)
return floor + (1.0-floor)*ratio
}