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
24 changes: 8 additions & 16 deletions src/app/api/badge/streak-shield/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import {
checkBadgeRateLimit,
getBadgeClientIp,
} from "@/lib/badge-rate-limit";
import { calculateStreakFromDates } from "@/lib/streak";
import { logError } from "@/lib/error-handler";
import { normalizeGitHubUsername } from "@/lib/validate-github-username";
import { calculateStreak } from "@/lib/streak";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -73,25 +73,17 @@ async function fetchStreak(
items: Array<{ commit: { author: { date: string } } }>;
};

const daySet: Record<string, true> = {};
const activeDates = new Set<string>();
for (const item of data.items) {
daySet[item.commit.author.date.slice(0, 10)] = true;
activeDates.add(item.commit.author.date.slice(0, 10));
}
const commitDays = Object.keys(daySet).sort();

if (commitDays.length === 0) {
return { current: 0, longest: 0, lastCommitDate: null, totalActiveDays: 0, stale: undefined };
}
const { currentStreak, longestStreak } = calculateStreak(
commitDays.map((day) => new Date(day))
);
const lastDay = commitDays[commitDays.length - 1];

const result = calculateStreakFromDates(activeDates);
return {
current: currentStreak,
longest: longestStreak,
lastCommitDate: lastDay,
totalActiveDays: commitDays.length,
current: result.current,
longest: result.longest,
lastCommitDate: result.lastCommitDate,
totalActiveDays: result.totalActiveDays,
};
}

Expand Down
8 changes: 2 additions & 6 deletions src/app/api/metrics/compare/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
import { toDateStr } from "@/lib/dateUtils";
import { calculateCurrentStreak } from "@/lib/streak";
import { normalizeGitHubUsername } from "@/lib/validate-github-username";
import { supabaseAdmin } from "@/lib/supabase";
import { calculateStreak } from "@/lib/streak";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -119,11 +119,7 @@ export async function GET(req: NextRequest) {
weeklyMap[weekKey] = (weeklyMap[weekKey] ?? 0) + 1;
}

const commitDays = Object.keys(daySet).sort();

if (commitDays.length > 0) {
streak = calculateStreak(commitDays.map((day) => new Date(day))).currentStreak;
}
streak = calculateCurrentStreak(Object.keys(daySet));
}

// Build ordered weekly array (last 8 weeks) for the chart
Expand Down
98 changes: 1 addition & 97 deletions src/app/api/metrics/streak/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from "@/lib/metrics-cache";
import { supabaseAdmin } from "@/lib/supabase";
import { resolveAppUser } from "@/lib/resolve-user";
import { calculateStreak } from "@/lib/streak";
import { calculateStreakFromDates } from "@/lib/streak";
import { dispatchToAllWebhooks } from "@/lib/webhooks";

export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -115,102 +115,6 @@ async function fetchActiveDates(
return new Set(dates);
}

function calculateStreakFromDates(
activeDates: Set<string>,
freezeDates: Set<string>
, timeZone = "UTC"
): {
current: number;
longest: number;
lastCommitDate: string | null;
totalActiveDays: number;
freezeDates: string[];
} {
// Merge commit dates with streak freeze dates before calculating.
// A freeze date counts as an "active" day so it doesn't break the streak,
// even though no commits were made on that day.
const combinedDates = new Set<string>([
...Array.from(activeDates),
...Array.from(freezeDates),
]);
const commitDays = Array.from(combinedDates).sort(); // ascending "YYYY-MM-DD"

if (commitDays.length === 0) {
return {
current: 0,
longest: 0,
lastCommitDate: null,
totalActiveDays: 0,
freezeDates: Array.from(freezeDates),
};
}

// Helper: convert "YYYY-MM-DD" -> days since epoch (integer) using UTC
function dayKeyToDays(d: string): number {
const [y, m, day] = d.split("-").map((s) => Number(s));
return Date.UTC(y, m - 1, day) / 86400000;
}

// Compute runs of consecutive days (increasing by 1 day)
const daysNums = commitDays.map(dayKeyToDays).sort((a, b) => a - b);

let longestStreak = 1;
let currentRun = 1;
const runs: { end: string; length: number }[] = [];

for (let i = 1; i < daysNums.length; i += 1) {
const diff = daysNums[i] - daysNums[i - 1];
if (diff === 1) {
currentRun += 1;
longestStreak = Math.max(longestStreak, currentRun);
continue;
}
runs.push({ end: commitDays[i - 1], length: currentRun });
currentRun = 1;
}
runs.push({ end: commitDays[commitDays.length - 1], length: currentRun });

// Compute today/yesterday as YYYY-MM-DD in the user's timezone
const now = new Date();
const parts = new Intl.DateTimeFormat("en", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(now);
const y = parts.find((p) => p.type === "year")?.value ?? "0000";
const m = parts.find((p) => p.type === "month")?.value ?? "00";
const d = parts.find((p) => p.type === "day")?.value ?? "00";
const today = `${y}-${m}-${d}`;

// Yesterday computed by converting today's YYYY-MM-DD to UTC days and subtracting 1
const todayDays = dayKeyToDays(today);
const yesterdayDays = todayDays - 1;
const yesterdayDate = new Date(yesterdayDays * 86400000);
const yParts = new Intl.DateTimeFormat("en", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(yesterdayDate);
const yy = yParts.find((p) => p.type === "year")?.value ?? "0000";
const mm = yParts.find((p) => p.type === "month")?.value ?? "00";
const dd = yParts.find((p) => p.type === "day")?.value ?? "00";
const yesterday = `${yy}-${mm}-${dd}`;

const lastRun = runs[runs.length - 1];

return {
current:
lastRun.end === today || lastRun.end === yesterday ? lastRun.length : 0,
longest: longestStreak,
lastCommitDate: commitDays[commitDays.length - 1],
// totalActiveDays counts only days with real commits or freezes in the 90-day window,
// not the full streak length — useful for the "active days" stat on the dashboard.
totalActiveDays: commitDays.length,
freezeDates: Array.from(freezeDates),
};
}

async function checkAndRecordMilestone(
userId: string,
Expand Down
8 changes: 1 addition & 7 deletions src/app/api/metrics/weekly-summary/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib
import { getAccountToken } from "@/lib/github-accounts";
import { supabaseAdmin } from "@/lib/supabase";
import { resolveAppUser } from "@/lib/resolve-user";
import { calculateStreak } from "@/lib/streak";
import { toDateStr } from "@/lib/dateUtils";
import { calculateCurrentStreak } from "@/lib/streak";

export const dynamic = "force-dynamic";

Expand All @@ -24,12 +24,6 @@ function getCurrentWeekStartUtc(): Date {
return currentWeekStart;
}

function calculateCurrentStreak(activeDates: Set<string>): number {
const { currentStreak } = calculateStreak(
Array.from(activeDates).map((day) => new Date(day))
);
return currentStreak;
}

async function fetchActiveDates(githubLogin: string, token: string): Promise<Set<string>> {
// Look back 90 days — the maximum window the GitHub Commit Search API supports.
Expand Down
24 changes: 8 additions & 16 deletions src/lib/public-profile-data.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { calculateStreak } from "@/lib/streak";
import { calculateStreakFromDates } from "@/lib/streak";
import type { GitHubAchievement } from "@/lib/github-achievements";
import { syncGitHubAchievementsForUser } from "@/lib/github-achievements";
import { fetchPinnedRepoDetails, type PinnedRepoDetails } from "@/lib/pinned-repos";
Expand Down Expand Up @@ -148,25 +148,17 @@ export async function fetchPublicStreak(
items: Array<{ commit: { author: { date: string } } }>;
};

const daySet: Record<string, true> = {};
const activeDates = new Set<string>();
for (const item of data.items) {
daySet[item.commit.author.date.slice(0, 10)] = true;
activeDates.add(item.commit.author.date.slice(0, 10));
}
const commitDays = Object.keys(daySet).sort();

if (commitDays.length === 0) {
return { current: 0, longest: 0, lastCommitDate: null, totalActiveDays: 0 };
}
const { currentStreak, longestStreak } = calculateStreak(
commitDays.map((day) => new Date(day))
);
const lastDay = commitDays[commitDays.length - 1];

const result = calculateStreakFromDates(activeDates);
return {
current: currentStreak,
longest: longestStreak,
lastCommitDate: lastDay,
totalActiveDays: commitDays.length,
current: result.current,
longest: result.longest,
lastCommitDate: result.lastCommitDate,
totalActiveDays: result.totalActiveDays,
};
}

Expand Down
131 changes: 97 additions & 34 deletions src/lib/streak.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,122 @@
import { dateDiffDays, toDateStr } from "@/lib/dateUtils";

export interface StreakResult {
currentStreak: number;
longestStreak: number;
current: number;
longest: number;
lastCommitDate: string | null;
totalActiveDays: number;
freezeDates: string[];
}

function toUtcDayKey(date: Date): string | null {
if (!(date instanceof Date)) return null;
if (Number.isNaN(date.getTime())) return null;
return toDateStr(date);
function todayAndYesterday(timeZone: string): { today: string; yesterday: string } {
if (timeZone === "UTC") {
const today = toDateStr(new Date());
const yesterday = toDateStr(new Date(Date.now() - 86400000));
return { today, yesterday };
}

const fmt = new Intl.DateTimeFormat("en", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
});

const parts = (d: Date) => {
const p = fmt.formatToParts(d);
const y = p.find((x) => x.type === "year")?.value ?? "0000";
const m = p.find((x) => x.type === "month")?.value ?? "00";
const day = p.find((x) => x.type === "day")?.value ?? "00";
return `${y}-${m}-${day}`;
};

return {
today: parts(new Date()),
yesterday: parts(new Date(Date.now() - 86400000)),
};
}

/**
* Calculates current and longest streak from a list of commit dates.
*
* Notes:
* - Dates are deduplicated by UTC calendar day (YYYY-MM-DD).
* - A streak is considered "current" if the last active day is today or yesterday (UTC).
* Canonical streak calculation shared across all endpoints.
* freeze dates count as active days so they don't break the streak.
* The streak is alive when the last active day is today or yesterday —
* the yesterday grace window prevents a reset before the user's first
* commit of the new calendar day.
*/
export function calculateStreak(commitDates: Date[]): StreakResult {
const dayKeys = new Set<string>();
for (const d of commitDates) {
const key = toUtcDayKey(d);
if (key) dayKeys.add(key);
}
export function calculateStreakFromDates(
activeDates: Set<string>,
freezeDates: Set<string> = new Set(),
timeZone = "UTC"
): StreakResult {
const combinedDates = new Set<string>([
...Array.from(activeDates),
...Array.from(freezeDates),
]);
const commitDays = Array.from(combinedDates).sort(); // ascending "YYYY-MM-DD"

const days = Array.from(dayKeys).sort();
if (days.length === 0) {
return { currentStreak: 0, longestStreak: 0 };
if (commitDays.length === 0) {
return {
current: 0,
longest: 0,
lastCommitDate: null,
totalActiveDays: 0,
freezeDates: Array.from(freezeDates),
};
}

let longestStreak = 1;
let currentRun = 1;
const runs: { end: string; length: number }[] = [];
const runs: { start: string; end: string; length: number }[] = [];
let runStart = commitDays[0];

for (let i = 1; i < days.length; i += 1) {
const diff = dateDiffDays(days[i - 1], days[i]);
// Walk the sorted date list and split into consecutive runs.
// dateDiffDays returns 1 for adjacent calendar days — any gap > 1 breaks the streak.
for (let i = 1; i < commitDays.length; i++) {
const diff = dateDiffDays(commitDays[i - 1], commitDays[i]);
if (diff === 1) {
currentRun += 1;
longestStreak = Math.max(longestStreak, currentRun);
continue;
// Consecutive day — extend the current run.
currentRun++;
if (currentRun > longestStreak) longestStreak = currentRun;
} else {
// Gap detected — close the current run and start a new one.
runs.push({ start: runStart, end: commitDays[i - 1], length: currentRun });
runStart = commitDays[i];
currentRun = 1;
}
runs.push({ end: days[i - 1], length: currentRun });
currentRun = 1;
}
runs.push({ end: days[days.length - 1], length: currentRun });
// Push the final run.
runs.push({ start: runStart, end: commitDays[commitDays.length - 1], length: currentRun });

const { today, yesterday } = todayAndYesterday(timeZone);

const today = toDateStr(new Date());
const yesterday = toDateStr(new Date(Date.now() - 86400000));
// Current streak is alive if the last active day is today OR yesterday.
const lastRun = runs[runs.length - 1];
const currentStreak =
lastRun.end === today || lastRun.end === yesterday ? lastRun.length : 0;

return {
currentStreak:
lastRun.end === today || lastRun.end === yesterday ? lastRun.length : 0,
longestStreak,
current: currentStreak,
longest: longestStreak,
lastCommitDate: commitDays[commitDays.length - 1],
totalActiveDays: commitDays.length,
freezeDates: Array.from(freezeDates),
};
}

// Lightweight wrapper for callers that only need the current streak number.
// Accepts raw ISO timestamp strings or pre-deduplicated YYYY-MM-DD strings.
export function calculateCurrentStreak(dates: Set<string> | string[]): number {
const dateSet = Array.isArray(dates)
? new Set(dates.map((d) => d.slice(0, 10)))
: dates;
return calculateStreakFromDates(dateSet).current;
}

// Adapter for callers that pass Date objects and expect {currentStreak, longestStreak}.
export function calculateStreak(
commitDates: Date[]
): { currentStreak: number; longestStreak: number } {
const dateSet = new Set(commitDates.map((d) => toDateStr(d)));
const result = calculateStreakFromDates(dateSet);
return { currentStreak: result.current, longestStreak: result.longest };
}
Loading
Loading