diff --git a/internal/card/donut_chart.go b/internal/card/donut_chart.go
index 7b222cd4..2723cce7 100644
--- a/internal/card/donut_chart.go
+++ b/internal/card/donut_chart.go
@@ -57,25 +57,36 @@ func renderDonutCard(title string, stats []github.LangStat, t theme.Theme) []byt
escapeXML(s.Name), pct)
}
- // Donut slices.
- start := -math.Pi / 2 // 12 o'clock start
- for _, s := range stats {
- angle := 2 * math.Pi * float64(s.Value) / float64(total)
- end := start + angle
- large := 0
- if angle > math.Pi {
- large = 1
- }
- sx, sy := polar(cx, cy, outerR, start)
- ex, ey := polar(cx, cy, outerR, end)
- isx, isy := polar(cx, cy, innerR, end)
- iex, iey := polar(cx, cy, innerR, start)
+ // Donut slices. Single-slice case renders as concentric circles because
+ // an SVG arc from P back to P draws nothing — a full ring needs a
+ // different primitive.
+ if len(stats) == 1 {
+ s := stats[0]
fmt.Fprintf(&b, `
+
+ `,
+ cx, cy, outerR, colorOrAccent(s.Color, t.Accent), t.Background,
+ cx, cy, innerR, t.Background)
+ } else {
+ start := -math.Pi / 2 // 12 o'clock start
+ for _, s := range stats {
+ angle := 2 * math.Pi * float64(s.Value) / float64(total)
+ end := start + angle
+ large := 0
+ if angle > math.Pi {
+ large = 1
+ }
+ sx, sy := polar(cx, cy, outerR, start)
+ ex, ey := polar(cx, cy, outerR, end)
+ isx, isy := polar(cx, cy, innerR, end)
+ iex, iey := polar(cx, cy, innerR, start)
+ fmt.Fprintf(&b, `
`,
- sx, sy, outerR, outerR, large, ex, ey,
- isx, isy, innerR, innerR, large, iex, iey,
- colorOrAccent(s.Color, t.Accent), t.Background)
- start = end
+ sx, sy, outerR, outerR, large, ex, ey,
+ isx, isy, innerR, innerR, large, iex, iey,
+ colorOrAccent(s.Color, t.Accent), t.Background)
+ start = end
+ }
}
b.WriteString(footer)
diff --git a/internal/github/contributions_all_time.go b/internal/github/contributions_all_time.go
index c7cfed9e..b033e952 100644
--- a/internal/github/contributions_all_time.go
+++ b/internal/github/contributions_all_time.go
@@ -1,6 +1,9 @@
package github
import (
+ "context"
+ "fmt"
+ "os"
"sort"
"time"
)
@@ -37,7 +40,7 @@ type contributionYearGQL struct {
//
// Fork and private repos are filtered client-side per opts so the caller can
// run the same pipeline with different visibility policies.
-func (c *Client) FetchContributionsAllTime(p *Profile, opts FetchOptions) error {
+func (c *Client) FetchContributionsAllTime(ctx context.Context, p *Profile, opts FetchOptions) error {
years := append([]int(nil), p.ContributionYears...)
sort.Ints(years) // ascending so the concatenated series is oldest→newest
@@ -56,10 +59,14 @@ func (c *Client) FetchContributionsAllTime(p *Profile, opts FetchOptions) error
"to": to.Format(time.RFC3339),
}
var resp contributionYearGQL
- if err := c.query(contributionYearQuery, vars, &resp); err != nil {
+ if err := c.query(ctx, contributionYearQuery, vars, &resp); err != nil {
return err
}
if resp.User == nil {
+ // Don't abort the run — other years may still yield data — but
+ // make the partial-data case visible instead of rendering an
+ // empty all-time card silently.
+ fmt.Fprintf(os.Stderr, "warn: contribution year %d returned no user data\n", y)
continue
}
diff --git a/internal/github/productive.go b/internal/github/productive.go
index 1b54cea0..3903e28f 100644
--- a/internal/github/productive.go
+++ b/internal/github/productive.go
@@ -1,6 +1,7 @@
package github
import (
+ "context"
"time"
)
@@ -39,7 +40,7 @@ const scaleFactor = 10_000
// One pagination pass populates both, so the all-time cards come at no extra
// API cost beyond the pages already required for the last-year bucket.
// loc is applied to CommittedDate so the heatmap reflects the user's tz.
-func (c *Client) FetchProductive(p *Profile, repos []RepoInfo, loc *time.Location, maxPerRepo int) error {
+func (c *Client) FetchProductive(ctx context.Context, p *Profile, repos []RepoInfo, loc *time.Location, maxPerRepo int) error {
if loc == nil {
loc = time.UTC
}
@@ -50,6 +51,14 @@ func (c *Client) FetchProductive(p *Profile, repos []RepoInfo, loc *time.Locatio
langColor := map[string]string{}
for _, repo := range repos {
+ // Precompute the repo's total language bytes once; attributeCommit
+ // reuses it for every commit instead of re-summing ~10 edges per
+ // call in the inner loop.
+ var repoTotal int64
+ for _, l := range repo.Languages {
+ repoTotal += l.Bytes
+ }
+
var cursor *string
seen := 0
for {
@@ -70,7 +79,7 @@ func (c *Client) FetchProductive(p *Profile, repos []RepoInfo, loc *time.Locatio
}
var resp productiveGQL
- if err := c.query(commitHistoryQuery, vars, &resp); err != nil {
+ if err := c.query(ctx, commitHistoryQuery, vars, &resp); err != nil {
return err
}
if resp.Repository == nil || resp.Repository.DefaultBranchRef == nil ||
@@ -85,10 +94,10 @@ func (c *Client) FetchProductive(p *Profile, repos []RepoInfo, loc *time.Locatio
}
tl := t.In(loc)
p.ProductiveAllTime[tl.Hour()]++
- attributeCommit(repo, allTimeLang, langColor)
+ attributeCommit(repo, repoTotal, allTimeLang, langColor)
if tl.After(yearAgo) {
p.Productive[tl.Hour()]++
- attributeCommit(repo, lastYearLang, langColor)
+ attributeCommit(repo, repoTotal, lastYearLang, langColor)
}
seen++
}
@@ -106,14 +115,12 @@ func (c *Client) FetchProductive(p *Profile, repos []RepoInfo, loc *time.Locatio
}
// attributeCommit distributes a single commit across the repo's languages
-// proportional to byte share. Falls back to the primary language when no
-// byte breakdown is available (empty repo or linguist-free repo).
-func attributeCommit(repo RepoInfo, commitsByLang map[string]int64, langColor map[string]string) {
- var total int64
- for _, l := range repo.Languages {
- total += l.Bytes
- }
- if total == 0 {
+// proportional to byte share. Callers pass in the precomputed per-repo byte
+// total so this hot-path loop doesn't re-sum language edges every commit.
+// Falls back to the primary language when no byte breakdown is available
+// (empty repo or linguist-free repo).
+func attributeCommit(repo RepoInfo, repoTotal int64, commitsByLang map[string]int64, langColor map[string]string) {
+ if repoTotal == 0 {
if repo.PrimaryLanguage != "" {
commitsByLang[repo.PrimaryLanguage] += scaleFactor
if _, ok := langColor[repo.PrimaryLanguage]; !ok {
@@ -123,7 +130,7 @@ func attributeCommit(repo RepoInfo, commitsByLang map[string]int64, langColor ma
return
}
for _, l := range repo.Languages {
- share := int64(scaleFactor) * l.Bytes / total
+ share := int64(scaleFactor) * l.Bytes / repoTotal
if share == 0 {
continue
}