fix(contributions): trim last-year series to clean 13-month span

GitHub's contributionCalendar is week-aligned, so a "last year" query on
2026-05-02 returns days back to ~2025-04-27. That extra partial month
inflated aggregateByMonth to 14 buckets and the x-axis stride logic placed
04/26 and 05/26 next to each other (x=290 / x=312), overlapping.

Trim to start-of-(last.Month - 1y) so the chart spans a clean 13 months
ending at the current month. Heatmap path is untouched — it needs the
full week-aligned series.
This commit is contained in:
2026-05-02 09:13:34 +07:00
parent a423428917
commit 75a0877fcb
2 changed files with 74 additions and 1 deletions
+22 -1
View File
@@ -14,7 +14,28 @@ type contributionsCard struct{}
func (contributionsCard) Filename() string { return "contributions.svg" }
func (contributionsCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
return renderContributions("Contributions (last year)", p.DailyContributions, t), nil
return renderContributions("Contributions (last year)", trimToLastYear(p.DailyContributions), t), nil
}
// trimToLastYear keeps only the trailing 13-month window ending at the
// series' last day. GitHub's contributionCalendar is week-aligned, so a
// "last year" query bleeds into the calendar month before today minus one
// year (e.g. asking on 2026-05-02 returns days back to ~2025-04-27). That
// extra partial month inflates the bucket count to 14 and forces the x-axis
// stride logic to place the final two month labels right next to each other.
// Trimming to start-of-(today.Month - 1y) restores a clean 13-bucket span.
func trimToLastYear(days []github.DailyContribution) []github.DailyContribution {
if len(days) == 0 {
return days
}
last := days[len(days)-1].Date
cutoff := time.Date(last.Year()-1, last.Month(), 1, 0, 0, 0, 0, time.UTC)
for i, d := range days {
if !d.Date.Before(cutoff) {
return days[i:]
}
}
return nil
}
type contributionsAllTimeCard struct{}
+52
View File
@@ -0,0 +1,52 @@
package card
import (
"testing"
"time"
"github.com/tiennm99/ghstats/internal/github"
)
// GitHub's contributionCalendar is week-aligned, so a "last year" series for a
// query made on 2026-05-02 (Saturday) starts on 2025-04-27 (Sunday). Without
// trimming the chart shows 14 calendar months and the x-axis label stride
// places 04/26 and 05/26 next to each other, overlapping. Trimming should
// drop everything before the first day of last.Month minus one year.
func TestTrimToLastYearProducesCleanThirteenMonthSpan(t *testing.T) {
start := time.Date(2025, 4, 27, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC)
var days []github.DailyContribution
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
days = append(days, github.DailyContribution{Date: d, Count: 1})
}
trimmed := trimToLastYear(days)
if len(trimmed) == 0 {
t.Fatal("trimToLastYear returned no days")
}
first := trimmed[0].Date
wantFirst := time.Date(2025, 5, 1, 0, 0, 0, 0, time.UTC)
if !first.Equal(wantFirst) {
t.Fatalf("first day = %s, want %s", first, wantFirst)
}
if !trimmed[len(trimmed)-1].Date.Equal(end) {
t.Fatalf("last day = %s, want %s", trimmed[len(trimmed)-1].Date, end)
}
buckets := aggregateByMonth(trimmed)
if len(buckets) != 13 {
t.Fatalf("bucket count = %d, want 13 (May 2025 .. May 2026)", len(buckets))
}
if buckets[0].Year != 2025 || buckets[0].Month != time.May {
t.Fatalf("first bucket = %d/%d, want 2025/May", buckets[0].Year, buckets[0].Month)
}
if buckets[12].Year != 2026 || buckets[12].Month != time.May {
t.Fatalf("last bucket = %d/%d, want 2026/May", buckets[12].Year, buckets[12].Month)
}
}
func TestTrimToLastYearEmptyInput(t *testing.T) {
if got := trimToLastYear(nil); got != nil {
t.Fatalf("trimToLastYear(nil) = %v, want nil", got)
}
}