mirror of
https://github.com/tiennm99/ghstats.git
synced 2026-09-03 18:16:46 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user