From 75a0877fcbf6a1f7294adf7d33243ac5e7945cdf Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 2 May 2026 09:13:34 +0700 Subject: [PATCH] fix(contributions): trim last-year series to clean 13-month span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/card/contributions.go | 23 ++++++++++- internal/card/contributions_trim_test.go | 52 ++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 internal/card/contributions_trim_test.go diff --git a/internal/card/contributions.go b/internal/card/contributions.go index 9f3dc0d7..2c4ca767 100644 --- a/internal/card/contributions.go +++ b/internal/card/contributions.go @@ -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{} diff --git a/internal/card/contributions_trim_test.go b/internal/card/contributions_trim_test.go new file mode 100644 index 00000000..e1ecddd5 --- /dev/null +++ b/internal/card/contributions_trim_test.go @@ -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) + } +}