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 }