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
+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) {