Skip to content

Commit 596a96c

Browse files
authored
merge: fix: resolve streak metrics truncated by days parameter (#7941)
## Description Fixes #7919 **Problem:** When a user supplied the `days` parameter (e.g., `?days=30` to visually slice the contribution graph), the calendar object was mutated *before* the streak and monthly statistics were calculated. As a result, the calculated `currentStreak` and `longestStreak` metrics were computed only over the sliced subset, capping them at 30 days and ignoring any historical streak continuation. **Solution:** 1. Pre-calculated the user's streak stats (`fullStats`) and monthly stats (`fullMonthlyStats`) using the full, unsliced calendar immediately after fetching it. 2. In `versus` mode, calculated the comparator's streak stats (`fullVersusStats`) on the full timezone-normalized calendars prior to slicing. 3. Applied the `days` parameter slicing to the calendar arrays *after* statistics were computed, so the visual graph reflects the sliced timeframe while displaying the correct overall streak metrics. 4. Added a new unit test in `app/api/streak/route.test.ts` to assert this behavior and prevent future regressions. ## Pillar - [ ] 🎨 Pillar 1 — New Theme Design - [ ] 📐 Pillar 2 — Geometric SVG Improvement - [ ] 🕐 Pillar 3 — Timezone Logic Optimization - [x] 🛠️ Other (Bug fix, refactoring, docs) ## Visual Preview *No layout/visual changes to the SVG presets. Slicing with `?days=N` continues to render the requested subset of days but now displays the true, uncapped streak numbers inside the stats section.* ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally (`localhost:3000/api/streak?user=YOUR_USERNAME`). - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors (CI will fail otherwise). - [x] My commits follow the Conventional Commits format (e.g., `feat(themes): ...`, `fix(calculate): ...`). - [ ] I have updated `README.md` if I added a new theme or URL parameter. - [x] I have starred the repo. - [x] I have made sure that i have only one commit to merge in this PR. - [x] The SVG output matches the CommitPulse "premium quality" aesthetic standard (no raw elements, smooth animations, correct fonts). - [ ] (Recommended) I joined the CommitPulse Discord community for contributor discussions, mentorship, and faster PR support.
2 parents 06bb8e1 + f72fdb7 commit 596a96c

2 files changed

Lines changed: 68 additions & 41 deletions

File tree

app/api/streak/route.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,18 @@ describe('GET /api/streak', () => {
152152
expect(weeks.every((w) => w.contributionDays.length <= 7)).toBe(true);
153153
});
154154

155+
it('does not truncate calculated streak stats when the days parameter is set', async () => {
156+
const response = await GET(makeRequest({ user: 'octocat', days: '3', format: 'json' }));
157+
expect(response.status).toBe(200);
158+
const body = await response.json();
159+
160+
// Slicing to the last 3 days (all 0 contributions in mockCalendar)
161+
expect(body.calendar.totalContributions).toBe(0);
162+
163+
// But streak statistics must still reflect the full 14 days calendar contributions (total 10)
164+
expect(body.stats.totalContributions).toBe(10);
165+
});
166+
155167
it('returns 400 Bad Request when ?layout= is set to an unsupported format', async () => {
156168
const response = await GET(makeRequest({ user: 'octocat', layout: 'unsupported_layout' }));
157169
expect(response.status).toBe(400);

app/api/streak/route.ts

Lines changed: 56 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import type {
4444
RepoContribution,
4545
ExtendedContributionData,
4646
ContributionCalendar,
47+
StreakStats,
4748
} from '@/types';
4849
import { getNormalizedThemeKey, themes } from '@/lib/svg/themes';
4950
import { streakParamsSchema, coerceQueryParams } from '@/lib/validations';
@@ -550,6 +551,31 @@ export async function GET(request: Request) {
550551
});
551552
clearTimeout(timeoutId);
552553
}
554+
// Pre-calculate full, unsliced statistics first
555+
let fullStats: StreakStats;
556+
let fullVersusStats: StreakStats | undefined;
557+
let fullWeekdayStats: StreakStats | undefined;
558+
559+
if (versus && versusCalendar) {
560+
// Normalize both calendars to the target timezone for accurate comparison
561+
const normalizedCalendar = normalizeCalendarToTimezone(calendar, timezone);
562+
const normalizedVersusCalendar = normalizeCalendarToTimezone(versusCalendar, timezone);
563+
564+
fullStats = calculateStreak(normalizedCalendar, timezone, undefined, grace);
565+
fullVersusStats = calculateStreak(normalizedVersusCalendar, timezone, undefined, grace);
566+
} else {
567+
fullStats = calculateStreak(calendar, timezone, undefined, grace);
568+
if (normalizedView === 'weekday') {
569+
const normalizedCalendar = normalizeCalendarToTimezone(calendar, timezone);
570+
fullWeekdayStats = calculateStreak(normalizedCalendar, timezone, undefined, grace);
571+
}
572+
}
573+
574+
const fullMonthlyStats = calculateMonthlyStats(
575+
calendar,
576+
timezone,
577+
getMonthlyReferenceDate(year, timezone)
578+
);
553579
if (normalizedView !== 'monthly') {
554580
let effectiveDays = days;
555581

@@ -567,18 +593,20 @@ export async function GET(request: Request) {
567593
totalContributions: filteredDays.reduce((sum, d) => sum + d.contributionCount, 0),
568594
weeks: chunkDaysIntoWeeks(filteredDays),
569595
};
596+
597+
if (versusCalendar) {
598+
const versusDays = versusCalendar.weeks.flatMap((w) => w.contributionDays);
599+
const filteredVersusDays = versusDays.slice(-effectiveDays);
600+
versusCalendar = {
601+
totalContributions: filteredVersusDays.reduce((sum, d) => sum + d.contributionCount, 0),
602+
weeks: chunkDaysIntoWeeks(filteredVersusDays),
603+
};
604+
}
570605
}
571606
}
572607

573608
// ─── JSON output mode ──────────────────────────────────────────────────
574609
if (format === 'json') {
575-
const stats = calculateStreak(calendar, timezone, undefined, grace);
576-
const monthlyStats = calculateMonthlyStats(
577-
calendar,
578-
timezone,
579-
getMonthlyReferenceDate(year, timezone)
580-
);
581-
582610
const secondsToMidnight = tzParam
583611
? getSecondsUntilMidnightInTimezone(timezone)
584612
: getSecondsUntilUTCMidnight();
@@ -592,8 +620,8 @@ export async function GET(request: Request) {
592620

593621
const jsonPayload = JSON.stringify({
594622
user: targetEntity,
595-
stats,
596-
monthlyStats,
623+
stats: fullStats,
624+
monthlyStats: fullMonthlyStats,
597625
calendar: {
598626
totalContributions: calendar.totalContributions,
599627
weeks: calendar.weeks,
@@ -632,63 +660,50 @@ export async function GET(request: Request) {
632660
// ─── SVG output mode (default) ──────────────────────────────────────────
633661
let svg = '';
634662
if (normalizedView === 'monthly') {
635-
const stats = calculateMonthlyStats(
636-
calendar,
637-
timezone,
638-
getMonthlyReferenceDate(year, timezone)
639-
);
640-
svg = generateMonthlySVG(stats, params);
663+
svg = generateMonthlySVG(fullMonthlyStats, params);
641664
} else if (normalizedView === 'languages') {
642-
const stats = calculateStreak(calendar, timezone, undefined, grace);
643-
svg = generateLanguagesSVG(stats, params, repoContributions);
665+
svg = generateLanguagesSVG(fullStats, params, repoContributions);
644666
} else if (normalizedView === 'heatmap') {
645-
const stats = calculateStreak(calendar, timezone, undefined, grace);
646-
svg = generateHeatmapSVG(stats, params, calendar);
667+
svg = generateHeatmapSVG(fullStats, params, calendar);
647668
} else if (normalizedView === 'pulse') {
648669
// We still use calculateStreak here to efficiently parse totalContributions for the stat display,
649670
// even though the sparkline generator will extract its own daily 30-day timeline below.
650-
const stats = calculateStreak(calendar, timezone, undefined, grace);
651-
svg = generatePulseSVG(stats, params, calendar);
671+
svg = generatePulseSVG(fullStats, params, calendar);
652672
} else if (normalizedView === 'skyline') {
653-
const stats = calculateStreak(calendar, timezone, undefined, grace);
654-
svg = generateSkylineSVG(stats, params, calendar);
673+
svg = generateSkylineSVG(fullStats, params, calendar);
655674
} else if (normalizedView === 'constellation') {
656-
const stats = calculateStreak(calendar, timezone, undefined, grace);
657-
svg = generateConstellationSVG(stats, params, calendar);
675+
svg = generateConstellationSVG(fullStats, params, calendar);
658676
} else if (normalizedView === 'radar') {
659-
const stats = calculateStreak(calendar, timezone, undefined, grace);
660677
const hourCounts = await fetchCommitHourDistribution(user, undefined, timezone).catch(
661678
() => undefined
662679
);
663-
svg = generateRadarSVG(stats, params, calendar, hourCounts);
680+
svg = generateRadarSVG(fullStats, params, calendar, hourCounts);
664681
} else if (normalizedView === 'doughnut' || normalizedView === 'pie') {
665-
const stats = calculateStreak(calendar, timezone, undefined, grace);
666-
svg = generateDoughnutSVG(stats, params, calendar);
682+
svg = generateDoughnutSVG(fullStats, params, calendar);
667683
} else if (normalizedView === 'activity_graph') {
668-
const stats = calculateStreak(calendar, timezone, undefined, grace);
669-
svg = generateActivityGraphSVG(stats, params, calendar);
684+
svg = generateActivityGraphSVG(fullStats, params, calendar);
670685
} else if (normalizedView === 'commit_clock') {
671-
const stats = calculateStreak(calendar, timezone, undefined, grace);
672686
const hourCounts = await fetchCommitHourDistribution(user, undefined, timezone).catch(() =>
673687
new Array(24).fill(0)
674688
);
675-
svg = generateCommitClockSVG(hourCounts, stats, params);
689+
svg = generateCommitClockSVG(hourCounts, fullStats, params);
676690
} else if (normalizedView === 'weekday') {
677-
// ← INSERT YOUR NEW BLOCK HERE
678691
const normalizedCalendar = normalizeCalendarToTimezone(calendar, timezone);
679-
const stats = calculateStreak(normalizedCalendar, timezone, undefined, grace);
680-
svg = generateWeekdaySVG(stats, params, normalizedCalendar);
692+
svg = generateWeekdaySVG(fullWeekdayStats || fullStats, params, normalizedCalendar);
681693
} else if (versus && versusCalendar) {
682694
// Normalize both calendars to the target timezone for accurate comparison
683695
const normalizedCalendar = normalizeCalendarToTimezone(calendar, timezone);
684696
const normalizedVersusCalendar = normalizeCalendarToTimezone(versusCalendar, timezone);
685697

686-
const stats1 = calculateStreak(normalizedCalendar, timezone, undefined, grace);
687-
const stats2 = calculateStreak(normalizedVersusCalendar, timezone, undefined, grace);
688-
svg = generateVersusSVG(stats1, stats2, params, normalizedCalendar, normalizedVersusCalendar);
698+
svg = generateVersusSVG(
699+
fullStats,
700+
fullVersusStats!,
701+
params,
702+
normalizedCalendar,
703+
normalizedVersusCalendar
704+
);
689705
} else {
690-
const stats = calculateStreak(calendar, timezone, undefined, grace);
691-
svg = generateSVG(stats, params, calendar, individualCalendars);
706+
svg = generateSVG(fullStats, params, calendar, individualCalendars);
692707
}
693708

694709
if (servedFromStaleCache) {

0 commit comments

Comments
 (0)