Skip to content
Merged
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
41 changes: 41 additions & 0 deletions lib/calculate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1498,6 +1498,47 @@ describe('calculateStreak — timezone awareness', () => {
expect(istResult.todayDate).toBe('2024-06-15');
expect(jstResult.todayDate).toBe('2024-06-15');
});

it('credits contribution at exactly local midnight to the correct day (Issue #5258)', () => {
const calendar = {
totalContributions: 1,
weeks: [
{
contributionDays: [
{ contributionCount: 0, date: '2026-06-10' },
{ contributionCount: 1, date: '2026-06-11' },
{ contributionCount: 0, date: '2026-06-12' },
],
},
],
};
// 2026-06-11T00:00:00.000+05:30 is 2026-06-10T18:30:00.000Z
const nowInKolkataMidnight = new Date('2026-06-10T18:30:00.000Z');
const result = calculateStreak(calendar, 'Asia/Kolkata', nowInKolkataMidnight);
expect(result.todayDate).toBe('2026-06-11');
expect(result.currentStreak).toBe(1);
});

it('keeps streak active during the current day before any contributions are made (Issue #5260)', () => {
const calendar = {
totalContributions: 2,
weeks: [
{
contributionDays: [
{ contributionCount: 1, date: '2026-06-10' },
{ contributionCount: 1, date: '2026-06-11' },
{ contributionCount: 0, date: '2026-06-12' },
],
},
],
};
// At 09:00 AM local time on 2026-06-12, the user has not committed yet today.
// The streak should still be 2.
const now = new Date('2026-06-12T09:00:00.000Z');
const result = calculateStreak(calendar, 'UTC', now, 0); // grace = 0
expect(result.todayDate).toBe('2026-06-12');
expect(result.currentStreak).toBe(2);
});
});

describe('isStreakAlive', () => {
Expand Down
112 changes: 104 additions & 8 deletions lib/calculate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,86 @@ import type { ContributionCalendar, ContributionDay, StreakStats, MonthlyStats }
* STREAK & CALENDAR CALCULATIONS
* ========================================================================== */

export function convertLocalToUtc(
year: number,
month: number,
day: number,
hour: number,
minute: number,
second: number,
timeZone: string
): string {
try {
const utcDate = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hour12: false,
});
const parts = formatter.formatToParts(utcDate);
const partMap = Object.fromEntries(parts.map((p) => [p.type, p.value]));
const tzYear = parseInt(partMap.year, 10);
const tzMonth = parseInt(partMap.month, 10);
const tzDay = parseInt(partMap.day, 10);
let tzHour = parseInt(partMap.hour, 10);
if (tzHour === 24) tzHour = 0;
const tzMin = parseInt(partMap.minute, 10);
const tzSec = parseInt(partMap.second, 10);

const tzUtcTime = Date.UTC(tzYear, tzMonth - 1, tzDay, tzHour, tzMin, tzSec);
const offsetMs = tzUtcTime - utcDate.getTime();
const targetUtcTime = Date.UTC(year, month - 1, day, hour, minute, second) - offsetMs;
return new Date(targetUtcTime).toISOString().replace('.000Z', 'Z');
} catch (error) {
// Fallback to UTC if timezone is invalid or Intl throws
return new Date(Date.UTC(year, month - 1, day, hour, minute, second))
.toISOString()
.replace('.000Z', 'Z');
}
}

export function getLocalTodayStr(now: Date, timezone: string): string {
// Candidate dates are around the UTC date of now
const utcYear = now.getUTCFullYear();
const utcMonth = now.getUTCMonth(); // 0-indexed
const utcDate = now.getUTCDate();

// We check candidates from 1 day before to 1 day after the UTC date
for (let offset = -1; offset <= 1; offset++) {
const candidateDate = new Date(Date.UTC(utcYear, utcMonth, utcDate + offset));
const y = candidateDate.getUTCFullYear();
const m = candidateDate.getUTCMonth() + 1;
const d = candidateDate.getUTCDate();

const dateStr = `${y}-${m.toString().padStart(2, '0')}-${d.toString().padStart(2, '0')}`;

// Get the UTC time for local midnight (00:00:00) and next midnight (24:00:00 / 00:00:00 of next day)
const midnightUtcStr = convertLocalToUtc(y, m, d, 0, 0, 0, timezone);
const nextMidnightUtcStr = convertLocalToUtc(y, m, d + 1, 0, 0, 0, timezone);

const midnightTime = new Date(midnightUtcStr).getTime();
const nextMidnightTime = new Date(nextMidnightUtcStr).getTime();

const nowTime = now.getTime();
// Inclusive start, exclusive end: [midnight, next_midnight)
if (nowTime >= midnightTime && nowTime < nextMidnightTime) {
return dateStr;
}
}

// Fallback to standard Intl.DateTimeFormat if logic doesn't match
try {
return new Intl.DateTimeFormat('en-CA', { timeZone: timezone }).format(now);
} catch {
return now.toISOString().split('T')[0];
}
}

export function isStreakAlive(
today: { contributionCount: number },
yesterday: { contributionCount: number } | null
Expand All @@ -13,9 +93,7 @@ export function isStreakAlive(
}

export function findTodayIndex(days: ContributionDay[], timezone: string, now: Date): number {
const localTodayStr = new Intl.DateTimeFormat('en-CA', {
timeZone: timezone,
}).format(now);
const localTodayStr = getLocalTodayStr(now, timezone);

const localTodayIndex = days.findIndex((d) => d.date === localTodayStr);

Expand Down Expand Up @@ -50,7 +128,7 @@ export function calculateStreak(
}

// 2. Calculate Current Streak (Backwards loop with Grace Period)
const localTodayStr = new Intl.DateTimeFormat('en-CA', { timeZone: timezone }).format(now);
const localTodayStr = getLocalTodayStr(now, timezone);
let todayIndex = findTodayIndex(days, timezone, now);

// If the calendar doesn't contain today's date, only fall back to the
Expand Down Expand Up @@ -87,18 +165,36 @@ export function calculateStreak(
}
}

let consecutiveZeroDays = 0;
if (todayIndex >= 0) {
let idx = todayIndex - 1;
while (idx >= 0 && days[idx].contributionCount === 0) {
consecutiveZeroDays++;
idx--;
}
}

const isActualToday = todayIndex >= 0 && days[todayIndex].date === localTodayStr;
const todayHasCommits = todayIndex >= 0 && days[todayIndex].contributionCount > 0;

// If we are looking at the actual today, and it has no commits,
const evaluationIndex =
isActualToday && !todayHasCommits && consecutiveZeroDays < Math.max(1, grace)
? todayIndex - 1
: todayIndex;

let isStreakAlive = false;
for (let i = 0; i <= grace; i++) {
const checkIndex = todayIndex - i;
const checkIndex = evaluationIndex - i;
if (checkIndex >= 0 && days[checkIndex].contributionCount > 0) {
isStreakAlive = true;
break;
}
}

if (isStreakAlive) {
let i = todayIndex;
while (i >= todayIndex - grace && i >= 0 && days[i].contributionCount === 0) {
let i = evaluationIndex;
while (i >= evaluationIndex - grace && i >= 0 && days[i].contributionCount === 0) {
i--;
}
while (i >= 0 && days[i].contributionCount > 0) {
Expand Down Expand Up @@ -127,7 +223,7 @@ export function calculateMonthlyStats(
const weeks = calendar?.weeks || [];
const days = weeks.flatMap((week) => week?.contributionDays || []);

const localTodayStr = new Intl.DateTimeFormat('en-CA', { timeZone: timezone }).format(now);
const localTodayStr = getLocalTodayStr(now, timezone);
const [currentYearStr, currentMonthStr] = localTodayStr.split('-');
const currentYear = parseInt(currentYearStr, 10);
const currentMonth = parseInt(currentMonthStr, 10);
Expand Down
4 changes: 2 additions & 2 deletions lib/calculate.year-boundary-v4.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@ describe('calculateStreak — year boundary transition timeline (Variation 4)',
expect(resultGrace1.currentStreak).toBe(3); // Dec 29, Dec 30, Dec 31
expect(resultGrace1.todayDate).toBe('2025-01-01');

// Grace period of 0 should reset current streak on Jan 1
// Grace period of 0 should keep current streak active on Jan 1 since it is not over yet
const resultGrace0 = calculateStreak(calendar, 'UTC', new Date('2025-01-01T12:00:00Z'), 0);
expect(resultGrace0.currentStreak).toBe(0);
expect(resultGrace0.currentStreak).toBe(3);
});

it('evaluates current streak correctly when today is in the new year but contributions end on Dec 31 (stale calendar)', () => {
Expand Down
Loading