From 7eb83be9de735acdd90685bae4f79ff3bd31190b Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 18 Apr 2026 18:57:42 +0700 Subject: [PATCH] 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. --- .gitignore | 3 ++ README.md | 17 ++++--- internal/card/card.go | 3 +- internal/card/card_test.go | 19 +++++--- .../card/{languages.go => language_bar.go} | 45 +++++++++---------- internal/card/most_commit_language.go | 14 ++++++ internal/card/productive.go | 2 +- internal/card/repos_per_language.go | 14 ++++++ internal/card/stats.go | 2 +- internal/github/model.go | 26 ++++++++--- internal/github/productive.go | 25 ++++++++--- internal/github/profile.go | 39 +++++++++++----- internal/github/profile_test.go | 14 +++--- main.go | 2 +- 14 files changed, 151 insertions(+), 74 deletions(-) rename internal/card/{languages.go => language_bar.go} (50%) create mode 100644 internal/card/most_commit_language.go create mode 100644 internal/card/repos_per_language.go diff --git a/.gitignore b/.gitignore index aaadf736..08d10687 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ go.work.sum # env file .env +# Generated SVG cards from local runs +output/ + # Editor/IDE # .idea/ # .vscode/ diff --git a/README.md b/README.md index c6229020..569d6074 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/card/card.go b/internal/card/card.go index b268b338..d93ce021 100644 --- a/internal/card/card.go +++ b/internal/card/card.go @@ -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{}, } diff --git a/internal/card/card_test.go b/internal/card/card_test.go index 563a504a..8b49139d 100644 --- a/internal/card/card_test.go +++ b/internal/card/card_test.go @@ -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)) diff --git a/internal/card/languages.go b/internal/card/language_bar.go similarity index 50% rename from internal/card/languages.go rename to internal/card/language_bar.go index e9f2e3e3..42a66506 100644 --- a/internal/card/languages.go +++ b/internal/card/language_bar.go @@ -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, ` - No language data available.`, t.Muted) + No data available.`, 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, ` `, 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, ` `, - offset, barY, w, barH, colorOrAccent(l.Color, t.Accent)) + offset, barY, w, barH, colorOrAccent(s.Color, t.Accent)) offset += w } b.WriteString(` `) - // 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, ` %s %.2f%%`, - 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 { diff --git a/internal/card/most_commit_language.go b/internal/card/most_commit_language.go new file mode 100644 index 00000000..684a2e4a --- /dev/null +++ b/internal/card/most_commit_language.go @@ -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 +} diff --git a/internal/card/productive.go b/internal/card/productive.go index 135d7b19..15dbdf64 100644 --- a/internal/card/productive.go +++ b/internal/card/productive.go @@ -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"} diff --git a/internal/card/repos_per_language.go b/internal/card/repos_per_language.go new file mode 100644 index 00000000..d63745ab --- /dev/null +++ b/internal/card/repos_per_language.go @@ -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 +} diff --git a/internal/card/stats.go b/internal/card/stats.go index 5438612e..5cf25647 100644 --- a/internal/card/stats.go +++ b/internal/card/stats.go @@ -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 ( diff --git a/internal/github/model.go b/internal/github/model.go index fdce7493..fb61db82 100644 --- a/internal/github/model.go +++ b/internal/github/model.go @@ -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 diff --git a/internal/github/productive.go b/internal/github/productive.go index d87a6c84..82ef3e1c 100644 --- a/internal/github/productive.go +++ b/internal/github/productive.go @@ -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 } diff --git a/internal/github/profile.go b/internal/github/profile.go index 0562e0f6..10a94032 100644 --- a/internal/github/profile.go +++ b/internal/github/profile.go @@ -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 }) diff --git a/internal/github/profile_test.go b/internal/github/profile_test.go index 41b77496..0a963336 100644 --- a/internal/github/profile_test.go +++ b/internal/github/profile_test.go @@ -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) { diff --git a/main.go b/main.go index 0c54811a..dbd0f693 100644 --- a/main.go +++ b/main.go @@ -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) } }