refactor: split language card into repos-per-language and most-commit-language

Align card set with github-profile-summary-cards' 5-card layout:

  0-profile-details.svg       (unchanged)
  1-repos-per-language.svg    (new) owned repos grouped by primary language
  2-most-commit-language.svg  (new) last-year commits attributed to each repo's primary language
  3-stats.svg                 (renumbered)
  4-productive-time.svg       (renumbered)

- FetchProductive now fills p.CommitsByLanguage from the same commit history
  it uses for the heatmap, so no extra API calls are introduced.
- TopRepos carries primary language so productive-time can aggregate by lang.
- LangStat.Bytes renamed to Value (repo count or commit count, context-dependent).
- Shared bar+legend renderer extracted to language_bar.go.
- Ignore generated output/ directory.
This commit is contained in:
2026-04-18 18:57:42 +07:00
parent ff4975fae2
commit 7eb83be9de
14 changed files with 151 additions and 74 deletions
+3
View File
@@ -27,6 +27,9 @@ go.work.sum
# env file
.env
# Generated SVG cards from local runs
output/
# Editor/IDE
# .idea/
# .vscode/
+10 -7
View File
@@ -7,7 +7,8 @@ public data for a GitHub user and writes a themed set of SVGs you can embed in
your profile README:
- Profile details
- Top languages
- Repos per language (how many owned repos use each language as primary)
- Most commit language (last year's commits attributed to each repo's primary language)
- Stats (stars, commits, PRs, issues, PR reviews, contributed-to)
- Productive time heatmap (weekday × hour)
@@ -45,9 +46,10 @@ Then embed the cards in your `README.md`:
```md
![profile](./output/dracula/0-profile-details.svg)
![languages](./output/dracula/1-languages.svg)
![stats](./output/dracula/2-stats.svg)
![productive-time](./output/dracula/3-productive-time.svg)
![repos-per-language](./output/dracula/1-repos-per-language.svg)
![most-commit-language](./output/dracula/2-most-commit-language.svg)
![stats](./output/dracula/3-stats.svg)
![productive-time](./output/dracula/4-productive-time.svg)
```
### Action inputs
@@ -112,9 +114,10 @@ Run `ghstats -list-themes` for the full list. Built-ins include `default`,
output/
dracula/
0-profile-details.svg
1-languages.svg
2-stats.svg
3-productive-time.svg
1-repos-per-language.svg
2-most-commit-language.svg
3-stats.svg
4-productive-time.svg
```
## Tokens & permissions
+2 -1
View File
@@ -22,7 +22,8 @@ type Card interface {
// Keep filename prefixes numeric so the output directory lists in a predictable order.
var allCards = []Card{
profileCard{},
languagesCard{},
reposPerLanguageCard{},
mostCommitLanguageCard{},
statsCard{},
productiveCard{},
}
+12 -7
View File
@@ -19,10 +19,14 @@ func TestRenderAll(t *testing.T) {
Following: 7,
PublicRepos: 42,
TotalStars: 1234,
Languages: []github.LangStat{
{Name: "Go", Color: "#00ADD8", Bytes: 5000},
{Name: "TypeScript", Color: "#3178c6", Bytes: 3000},
{Name: "Python", Color: "", Bytes: 2000},
ReposByLanguage: []github.LangStat{
{Name: "Go", Color: "#00ADD8", Value: 5},
{Name: "TypeScript", Color: "#3178c6", Value: 3},
{Name: "Python", Color: "", Value: 2},
},
CommitsByLanguage: []github.LangStat{
{Name: "Go", Color: "#00ADD8", Value: 420},
{Name: "Python", Color: "#3572A5", Value: 150},
},
}
p.Productive[2][14] = 7
@@ -39,9 +43,10 @@ func TestRenderAll(t *testing.T) {
want := []string{
"0-profile-details.svg",
"1-languages.svg",
"2-stats.svg",
"3-productive-time.svg",
"1-repos-per-language.svg",
"2-most-commit-language.svg",
"3-stats.svg",
"4-productive-time.svg",
}
for _, name := range want {
data, err := os.ReadFile(filepath.Join(dir, "dracula", name))
@@ -8,11 +8,11 @@ import (
"github.com/tiennm99/ghstats/internal/theme"
)
type languagesCard struct{}
func (languagesCard) Filename() string { return "1-languages.svg" }
func (languagesCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
// renderLanguageCard draws a horizontal stacked bar + legend from a list of
// LangStats. Shared by the repos-per-language and most-commit-language cards.
//
// title is the card heading; empty is rendered as the "no data" fallback.
func renderLanguageCard(title string, stats []github.LangStat, t theme.Theme) []byte {
const (
width = 500
height = 220
@@ -25,58 +25,55 @@ func (languagesCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
)
var b strings.Builder
b.WriteString(header(width, height, t.Background, t.Title, "Top Languages"))
b.WriteString(header(width, height, t.Background, t.Title, title))
langs := p.Languages
if len(langs) > topN {
langs = langs[:topN]
if len(stats) > topN {
stats = stats[:topN]
}
if len(langs) == 0 {
if len(stats) == 0 {
fmt.Fprintf(&b, `
<text x="25" y="90" font-size="13" fill="%s">No language data available.</text>`, t.Muted)
<text x="25" y="90" font-size="13" fill="%s">No data available.</text>`, t.Muted)
b.WriteString(footer)
return []byte(b.String()), nil
return []byte(b.String())
}
var total int64
for _, l := range langs {
total += l.Bytes
for _, s := range stats {
total += s.Value
}
// Stacked bar.
fmt.Fprintf(&b, `
<rect x="%d" y="%d" width="%d" height="%d" rx="5" fill="%s"/>
<g>`,
barX, barY, barW, barH, t.Muted)
offset := float64(barX)
for _, l := range langs {
w := float64(barW) * float64(l.Bytes) / float64(total)
for _, s := range stats {
w := float64(barW) * float64(s.Value) / float64(total)
fmt.Fprintf(&b, `
<rect x="%.2f" y="%d" width="%.2f" height="%d" fill="%s"/>`,
offset, barY, w, barH, colorOrAccent(l.Color, t.Accent))
offset, barY, w, barH, colorOrAccent(s.Color, t.Accent))
offset += w
}
b.WriteString(`
</g>`)
// Legend: two columns of up to 3 rows.
for i, l := range langs {
for i, s := range stats {
col := i % 2
row := i / 2
x := legendX0 + col*230
y := 110 + row*24
pct := 100 * float64(l.Bytes) / float64(total)
pct := 100 * float64(s.Value) / float64(total)
fmt.Fprintf(&b, `
<circle cx="%d" cy="%d" r="6" fill="%s"/>
<text x="%d" y="%d" font-size="13" fill="%s">%s %.2f%%</text>`,
x+6, y-4, colorOrAccent(l.Color, t.Accent),
x+20, y, t.Text, escapeXML(l.Name), pct)
x+6, y-4, colorOrAccent(s.Color, t.Accent),
x+20, y, t.Text, escapeXML(s.Name), pct)
}
b.WriteString(footer)
return []byte(b.String()), nil
return []byte(b.String())
}
func colorOrAccent(c, fallback string) string {
+14
View File
@@ -0,0 +1,14 @@
package card
import (
"github.com/tiennm99/ghstats/internal/github"
"github.com/tiennm99/ghstats/internal/theme"
)
type mostCommitLanguageCard struct{}
func (mostCommitLanguageCard) Filename() string { return "2-most-commit-language.svg" }
func (mostCommitLanguageCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
return renderLanguageCard("Most Commit Language (last year)", p.CommitsByLanguage, t), nil
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
type productiveCard struct{}
func (productiveCard) Filename() string { return "3-productive-time.svg" }
func (productiveCard) Filename() string { return "4-productive-time.svg" }
var weekdayLabels = [7]string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
+14
View File
@@ -0,0 +1,14 @@
package card
import (
"github.com/tiennm99/ghstats/internal/github"
"github.com/tiennm99/ghstats/internal/theme"
)
type reposPerLanguageCard struct{}
func (reposPerLanguageCard) Filename() string { return "1-repos-per-language.svg" }
func (reposPerLanguageCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
return renderLanguageCard("Repos Per Language", p.ReposByLanguage, t), nil
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
type statsCard struct{}
func (statsCard) Filename() string { return "2-stats.svg" }
func (statsCard) Filename() string { return "3-stats.svg" }
func (statsCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
const (
+19 -7
View File
@@ -28,22 +28,34 @@ type Profile struct {
TotalContributedTo int
TotalContributions int // lifetime contributions from calendar + restricted
// Sorted desc by bytes. Color is GitHub's linguist color or "" if absent.
Languages []LangStat
// Count of owned repos grouped by primary language, sorted desc by Value.
ReposByLanguage []LangStat
// Count of commits (last year, by this user) attributed to each repo's
// primary language, sorted desc. Populated by FetchProductive.
CommitsByLanguage []LangStat
// Commit-count histogram indexed by [day-of-week 0=Sunday][hour-of-day 0-23].
Productive [7][24]int
// TopRepos is the list of owned repo names sorted by stargazer count desc,
// populated by FetchProfile. Used as the seed set for FetchProductive.
TopRepos []string
// TopRepos are owned repos sorted by stargazer count desc. Populated by
// FetchProfile and consumed by FetchProductive.
TopRepos []RepoInfo
}
// LangStat is one row in the top-languages card.
// LangStat is one row in a language breakdown card. Value is repo count or
// commit count depending on which slice holds it.
type LangStat struct {
Name string
Color string
Bytes int64
Value int64
}
// RepoInfo is the minimal owned-repo summary used for downstream fetches.
type RepoInfo struct {
Name string
PrimaryLanguage string
PrimaryColor string
}
// repoNode is the GraphQL shape of one repository node; kept here because
+19 -6
View File
@@ -24,17 +24,22 @@ type productiveGQL struct {
}
// FetchProductive fills p.Productive with a [7][24] commit histogram over the
// last year, gathered from the user's top-starred owned repos. Each repo is
// sampled up to maxPerRepo commits to keep the cost bounded.
// last year and p.CommitsByLanguage with commit counts attributed to each
// repo's primary language. Commits are gathered from the given repos (usually
// p.TopRepos[:N]); each repo is sampled up to maxPerRepo commits to keep the
// cost bounded.
//
// The timezone loc is applied to CommittedDate so the heatmap reflects when the
// user actually commits, not UTC.
func (c *Client) FetchProductive(p *Profile, repos []string, loc *time.Location, maxPerRepo int) error {
// The timezone loc is applied to CommittedDate so the heatmap reflects when
// the user actually commits, not UTC.
func (c *Client) FetchProductive(p *Profile, repos []RepoInfo, loc *time.Location, maxPerRepo int) error {
if loc == nil {
loc = time.UTC
}
since := time.Now().AddDate(-1, 0, 0).UTC().Format(time.RFC3339)
commitsByLang := map[string]int64{}
langColor := map[string]string{}
for _, repo := range repos {
var cursor *string
seen := 0
@@ -44,7 +49,7 @@ func (c *Client) FetchProductive(p *Profile, repos []string, loc *time.Location,
}
vars := map[string]any{
"login": p.Login,
"repo": repo,
"repo": repo.Name,
"userId": p.ID,
"since": since,
}
@@ -68,6 +73,12 @@ func (c *Client) FetchProductive(p *Profile, repos []string, loc *time.Location,
}
tl := t.In(loc)
p.Productive[int(tl.Weekday())][tl.Hour()]++
if repo.PrimaryLanguage != "" {
commitsByLang[repo.PrimaryLanguage]++
if _, ok := langColor[repo.PrimaryLanguage]; !ok {
langColor[repo.PrimaryLanguage] = repo.PrimaryColor
}
}
seen++
}
if !h.PageInfo.HasNextPage {
@@ -77,5 +88,7 @@ func (c *Client) FetchProductive(p *Profile, repos []string, loc *time.Location,
cursor = &end
}
}
p.CommitsByLanguage = sortLangStats(commitsByLang, langColor)
return nil
}
+27 -12
View File
@@ -50,15 +50,16 @@ type profileGQL struct {
} `json:"user"`
}
// FetchProfile collects profile, stats and language data for the given user.
// Repositories are paginated up to 10 pages (1000 owned repos) as a safety cap.
// FetchProfile collects profile, stats and repos-per-language data for the
// given user. Owned repos are paginated up to 10 pages (1000 repos) as a
// safety cap.
func (c *Client) FetchProfile(login string) (*Profile, error) {
if login == "" {
return nil, errors.New("empty user")
}
p := &Profile{Login: login}
langBytes := map[string]int64{}
reposPerLang := map[string]int64{}
langColor := map[string]string{}
var cursor *string
@@ -105,9 +106,22 @@ func (c *Client) FetchProfile(login string) (*Profile, error) {
for _, r := range u.Repositories.Nodes {
p.TotalStars += r.StargazerCount
p.TotalForks += r.ForkCount
p.TopRepos = append(p.TopRepos, r.Name)
info := RepoInfo{Name: r.Name}
if r.PrimaryLanguage != nil {
info.PrimaryLanguage = r.PrimaryLanguage.Name
info.PrimaryColor = r.PrimaryLanguage.Color
reposPerLang[r.PrimaryLanguage.Name]++
if _, ok := langColor[r.PrimaryLanguage.Name]; !ok {
langColor[r.PrimaryLanguage.Name] = r.PrimaryLanguage.Color
}
}
p.TopRepos = append(p.TopRepos, info)
// Capture secondary language colors so productive-time's
// per-language aggregation can color them even if that language
// isn't the primary of any other repo.
for _, e := range r.Languages.Edges {
langBytes[e.Node.Name] += e.Size
if _, ok := langColor[e.Node.Name]; !ok {
langColor[e.Node.Name] = e.Node.Color
}
@@ -121,18 +135,19 @@ func (c *Client) FetchProfile(login string) (*Profile, error) {
cursor = &end
}
p.Languages = sortLanguages(langBytes, langColor)
p.ReposByLanguage = sortLangStats(reposPerLang, langColor)
return p, nil
}
func sortLanguages(bytes map[string]int64, color map[string]string) []LangStat {
out := make([]LangStat, 0, len(bytes))
for name, b := range bytes {
out = append(out, LangStat{Name: name, Color: color[name], Bytes: b})
// sortLangStats returns a slice sorted desc by value; ties break alphabetically.
func sortLangStats(values map[string]int64, color map[string]string) []LangStat {
out := make([]LangStat, 0, len(values))
for name, v := range values {
out = append(out, LangStat{Name: name, Color: color[name], Value: v})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Bytes != out[j].Bytes {
return out[i].Bytes > out[j].Bytes
if out[i].Value != out[j].Value {
return out[i].Value > out[j].Value
}
return out[i].Name < out[j].Name
})
+7 -7
View File
@@ -2,19 +2,19 @@ package github
import "testing"
func TestSortLanguages(t *testing.T) {
bytes := map[string]int64{
"Go": 500,
"Python": 300,
"TypeScript": 500, // tie with Go → alphabetical wins
"HTML": 100,
func TestSortLangStats(t *testing.T) {
values := map[string]int64{
"Go": 5,
"Python": 3,
"TypeScript": 5, // tie with Go → alphabetical wins
"HTML": 1,
}
colors := map[string]string{
"Go": "#00ADD8",
"Python": "#3572A5",
"TypeScript": "#3178c6",
}
got := sortLanguages(bytes, colors)
got := sortLangStats(values, colors)
wantOrder := []string{"Go", "TypeScript", "Python", "HTML"}
if len(got) != len(wantOrder) {
+1 -1
View File
@@ -64,7 +64,7 @@ func main() {
repos = repos[:*topRepos]
}
if err := client.FetchProductive(profile, repos, loc, *perRepo); err != nil {
fmt.Fprintf(os.Stderr, "warn: productive-time fetch: %v\n", err)
fmt.Fprintf(os.Stderr, "warn: productive-time + commits-per-language fetch: %v\n", err)
}
}