diff --git a/lib/calculate.test.ts b/lib/calculate.test.ts index 2f672604c..7d68d2587 100644 --- a/lib/calculate.test.ts +++ b/lib/calculate.test.ts @@ -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', () => { diff --git a/lib/calculate.ts b/lib/calculate.ts index 18e83551f..6c32dae19 100644 --- a/lib/calculate.ts +++ b/lib/calculate.ts @@ -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 @@ -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); @@ -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 @@ -87,9 +165,27 @@ 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; @@ -97,8 +193,8 @@ export function calculateStreak( } 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) { @@ -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); diff --git a/lib/calculate.year-boundary-v4.test.ts b/lib/calculate.year-boundary-v4.test.ts index 78b823104..cdf3b6815 100644 --- a/lib/calculate.year-boundary-v4.test.ts +++ b/lib/calculate.year-boundary-v4.test.ts @@ -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)', () => {