diff --git a/lib/calculate.test.ts b/lib/calculate.test.ts index c3467c7d4..3e53dc954 100644 --- a/lib/calculate.test.ts +++ b/lib/calculate.test.ts @@ -198,6 +198,7 @@ describe('calculateStreak', () => { const result = calculateStreak(calendar); expect(result.totalContributions).toBe(1); expect(result.longestStreak).toBe(1); + expect(result.currentStreak).toBe(1); }); it('does not walk past the start of a 1-day calendar when grace is larger than the available days', () => { diff --git a/lib/calculate.ts b/lib/calculate.ts index ddb942c9b..6bbd8fe99 100644 --- a/lib/calculate.ts +++ b/lib/calculate.ts @@ -5,6 +5,33 @@ import type { ContributionCalendar, StreakStats, MonthlyStats } from '../types'; * STREAK & CALENDAR CALCULATIONS * ========================================================================== */ +/** + * Determines whether the user's contribution streak is still alive. + * + * A streak survives as long as at least one of these is true: + * - Today has contributions, OR + * - Yesterday had contributions (grace period: covers timezone edge cases + * where the GitHub API may not have updated yet for the current day) + * + * @param today - The contribution day object for today. + * @param today.contributionCount - Number of contributions made today. + * @param yesterday - The contribution day object for yesterday, or null if + * today is the very first day in the calendar (no prior day exists). + * @returns `true` if the streak should be considered alive, `false` otherwise. + * + * @example + * // Today has contributions — streak alive + * isStreakAlive({ contributionCount: 3 }, { contributionCount: 0 }); // true + * + * @example + * // Today is empty but yesterday had activity — grace period keeps it alive + * isStreakAlive({ contributionCount: 0 }, { contributionCount: 5 }); // true + * + * @example + * // Both days are empty — streak is dead + * isStreakAlive({ contributionCount: 0 }, { contributionCount: 0 }); // false + */ + export function isStreakAlive( today: { contributionCount: number }, yesterday: { contributionCount: number } | null @@ -12,6 +39,48 @@ export function isStreakAlive( return today.contributionCount > 0 || (yesterday?.contributionCount ?? 0) > 0; } +/** + * Calculates both the current and longest contribution streaks from a + * GitHub contribution calendar, with support for timezones and a grace period. + * + * ### Algorithm — Longest Streak + * A single forward pass over all contribution days. A counter increments for + * each consecutive day with contributions > 0 and resets to 0 on any gap day. + * + * ### Algorithm — Current Streak (with Grace Period) + * Works **backwards** from today's index in the flat day array: + * 1. Resolve "today" using `Intl.DateTimeFormat` in the caller's timezone so + * the badge reflects the user's local date, not UTC. + * 2. Check the `grace` window (default 1 day) — if any day within that window + * has contributions, the streak is considered alive. This handles the common + * case where a developer contributes before midnight in their timezone but + * GitHub's UTC-based API hasn't reflected it yet. + * 3. If alive, skip any trailing grace-period zeros, then walk backwards + * counting consecutive contribution days. + * 4. If not alive, current streak is 0. + * + * ### Edge Cases Handled + * - Today's date does not appear in the calendar (e.g. GitHub lag): falls back + * to the last day in the array. + * - Empty calendar (todayIndex < 0): returns zeroed-out stats immediately. + * - Grace period overlap: trailing zeros within the grace window are skipped + * before the backwards count begins, so they are not mistakenly included. + * + * @param calendar - The full GitHub contribution calendar for one user/year. + * @param timezone - IANA timezone string used to resolve "today" locally, + * e.g. `"America/New_York"`. Defaults to `"UTC"`. + * @param now - The current date/time. Injectable for deterministic testing; + * defaults to `new Date()` in production. + * @param grace - Number of days to look back when checking if the streak is + * still alive. Defaults to `1` (yesterday counts as grace). Set to `0` to + * disable grace entirely. + * @returns A {@link StreakStats} object containing: + * - `currentStreak` — consecutive active days up to and including today + * - `longestStreak` — the all-time longest streak in the calendar + * - `totalContributions` — total from the calendar object + * - `todayDate` — the resolved local date string (`YYYY-MM-DD`) + */ + export function calculateStreak( calendar: ContributionCalendar, timezone: string = 'UTC', @@ -82,6 +151,32 @@ export function calculateStreak( }; } +/** + * Computes contribution statistics for the current and previous calendar months. + * + * Resolves month boundaries using the caller's local timezone so that "this month" + * matches what the user sees on their GitHub profile — not the server's UTC month. + * + * ### Delta Percentage Edge Case + * When `previousMonthTotal` is 0, percentage change is mathematically undefined + * (division by zero). The field is typed as `number | null` and returns `null` + * in that case, signalling the renderer to display "N/A" rather than a + * misleading hardcoded "+100%". + * + * @param calendar - The full GitHub contribution calendar for one user/year. + * @param timezone - IANA timezone string used to determine the current month, + * e.g. `"Asia/Kolkata"`. Defaults to `"UTC"`. + * @param now - The current date/time. Injectable for deterministic testing; + * defaults to `new Date()` in production. + * @returns A {@link MonthlyStats} object containing: + * - `currentMonthTotal` — total contributions in the current calendar month + * - `previousMonthTotal` — total contributions in the immediately preceding month + * - `deltaAbsolute` — raw difference (`currentMonthTotal - previousMonthTotal`) + * - `deltaPercentage` — percentage change rounded to the nearest integer, + * or `null` if `previousMonthTotal` is 0 (undefined baseline) + * - `currentMonthName` — localised full month name, e.g. `"May"` + */ + export function calculateMonthlyStats( calendar: ContributionCalendar, timezone: string = 'UTC',