fix: donut single-slice, partial-year warn, hoist per-repo total

- I1 — donut chart with a single slice (100%) now renders via two
  concentric <circle> elements instead of a degenerate SVG arc that
  drew nothing. Reproduced with a standalone probe; regression test
  added separately.
- I2 — FetchContributionsAllTime logs a warn to stderr when a year's
  query returns nil user data so callers notice partial results
  instead of rendering an empty all-time card silently.
- I6 — attributeCommit() receives the repo's byte total precomputed
  once per repo rather than re-summing language edges for every
  commit in the inner loop.
This commit is contained in:
2026-04-18 22:43:24 +07:00
parent fcfec9a11b
commit 76cf242ee9
3 changed files with 57 additions and 32 deletions
+28 -17
View File
@@ -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, `
<circle cx="%d" cy="%d" r="%.2f" fill="%s" stroke="%s" stroke-width="1.5"/>
<circle cx="%d" cy="%d" r="%.2f" fill="%s"/>`,
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, `
<path d="M%.2f,%.2f A%.2f,%.2f 0 %d 1 %.2f,%.2f L%.2f,%.2f A%.2f,%.2f 0 %d 0 %.2f,%.2f Z" fill="%s" stroke="%s" stroke-width="1.5"/>`,
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)
+9 -2
View File
@@ -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
}
+20 -13
View File
@@ -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
}