Skip to content

Commit bb48f2c

Browse files
committed
feat(calculate): add configurable grace period for streaks
1 parent c2b038f commit bb48f2c

5 files changed

Lines changed: 61 additions & 12 deletions

File tree

app/api/streak/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export async function GET(request: Request) {
5151
delta_format,
5252
width,
5353
height,
54+
grace,
5455
} = parseResult.data;
5556

5657
const themeName = theme || 'dark';
@@ -99,6 +100,7 @@ export async function GET(request: Request) {
99100
width: width ? parseInt(width, 10) : undefined,
100101
height: height ? parseInt(height, 10) : undefined,
101102
size,
103+
grace,
102104
};
103105

104106
const calendar = await fetchGitHubContributions(user, {
@@ -112,7 +114,7 @@ export async function GET(request: Request) {
112114
const stats = calculateMonthlyStats(calendar, timezone);
113115
svg = generateMonthlySVG(stats, params);
114116
} else {
115-
const stats = calculateStreak(calendar, timezone);
117+
const stats = calculateStreak(calendar, timezone, undefined, grace);
116118
svg = generateSVG(stats, params, calendar);
117119
}
118120

lib/calculate.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,36 @@ describe('calculateStreak', () => {
153153
expect(result.longestStreak).toBe(2);
154154
});
155155

156+
it('keeps the streak alive with a grace period > 1 (e.g. grace=2)', () => {
157+
// Today is 0, yesterday is 0, but 2 days ago is 1.
158+
// With grace=1 (default), streak is broken. With grace=2, streak is alive.
159+
const calendar = buildCalendar([
160+
0,
161+
0,
162+
0,
163+
0,
164+
0,
165+
0,
166+
0, // week 1
167+
0,
168+
0,
169+
0,
170+
0,
171+
1,
172+
0,
173+
0, // week 2 — today=0, yesterday=0, day before=1
174+
]);
175+
176+
// Using default grace (1)
177+
const resultGrace1 = calculateStreak(calendar, 'UTC', undefined, 1);
178+
expect(resultGrace1.currentStreak).toBe(0);
179+
180+
// Using grace = 2
181+
const resultGrace2 = calculateStreak(calendar, 'UTC', undefined, 2);
182+
expect(resultGrace2.currentStreak).toBe(1);
183+
expect(resultGrace2.longestStreak).toBe(1);
184+
});
185+
156186
it('handles a single active day without crashing (edge case: no "yesterday")', () => {
157187
// A calendar with only one day could make `days[todayIndex - 1]` undefined.
158188
// The function should survive this gracefully and return currentStreak = 1.

lib/calculate.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ const stats = calculateStreak(
5454
export function calculateStreak(
5555
calendar: ContributionCalendar,
5656
timezone: string = 'UTC',
57-
now: Date = new Date()
57+
now: Date = new Date(),
58+
grace: number = 1
5859
): StreakStats {
5960
const weeks = calendar.weeks;
6061
const days = weeks.flatMap((week) => week.contributionDays);
@@ -92,19 +93,25 @@ export function calculateStreak(
9293
};
9394
}
9495

95-
const today = days[todayIndex];
96-
const yesterday = todayIndex > 0 ? days[todayIndex - 1] : null;
97-
98-
// If I committed today, the streak is alive.
99-
// If I haven't committed today, but I committed yesterday,
100-
// the streak is STILL alive (Grace Period).
101-
const isStreakAlive = today.contributionCount > 0 || (yesterday?.contributionCount ?? 0) > 0;
96+
// If I committed today, or any day within the grace period (e.g. yesterday for grace=1),
97+
// the streak is STILL alive.
98+
let isStreakAlive = false;
99+
for (let i = 0; i <= grace; i++) {
100+
const checkIndex = todayIndex - i;
101+
if (checkIndex >= 0 && days[checkIndex].contributionCount > 0) {
102+
isStreakAlive = true;
103+
break;
104+
}
105+
}
102106

103107
if (isStreakAlive) {
104-
// Count backwards from the first day that has a contribution
105-
// starting from either today or yesterday.
106-
let i = today.contributionCount > 0 ? todayIndex : todayIndex - 1;
108+
// Find the most recent day with a contribution within the grace period
109+
let i = todayIndex;
110+
while (i >= todayIndex - grace && i >= 0 && days[i].contributionCount === 0) {
111+
i--;
112+
}
107113

114+
// Count backwards from the first day that has a contribution
108115
while (i >= 0 && days[i].contributionCount > 0) {
109116
currentStreak++;
110117
i--;

lib/validations.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,15 @@ export const streakParamsSchema = z.object({
7575
delta_format: z.enum(['percent', 'absolute', 'both']).catch('percent').default('percent'),
7676
width: z.string().optional(),
7777
height: z.string().optional(),
78+
grace: z
79+
.string()
80+
.optional()
81+
.transform((val) => {
82+
if (!val) return 1;
83+
const parsed = Number(val);
84+
return isNaN(parsed) ? 1 : Math.max(0, Math.min(parsed, 7));
85+
})
86+
.default(1),
7887
});
7988

8089
export const githubParamsSchema = z.object({

types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,5 @@ export interface BadgeParams {
5252
width?: number;
5353
height?: number;
5454
size?: 'small' | 'medium' | 'large';
55+
grace?: number;
5556
}

0 commit comments

Comments
 (0)