Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/calculate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
95 changes: 95 additions & 0 deletions lib/calculate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,82 @@ 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
): boolean {
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',
Expand Down Expand Up @@ -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',
Expand Down
Loading