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
186 changes: 175 additions & 11 deletions src/app/api/leaderboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,7 @@ import {
upstashRateLimitFixedWindow,
upstashTryAcquireLock,
} from "@/lib/upstash-rest";
import {
buildLeaderboard,
getMemoryCachedLeaderboard,
setMemoryCachedLeaderboard,
isFresh,
LEADERBOARD_CACHE_KEY,
LEADERBOARD_BUILD_LOCK_KEY,
CACHE_STALE_SECONDS,
type LeaderboardPayload,
} from "@/lib/leaderboard";
import { cacheSet } from "@/lib/metrics-cache";
import { getStreakLookbackStart } from "@/lib/streak";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -65,6 +55,180 @@ async function checkRateLimit(
return checkMemoryRateLimit(ip);
}

function isFresh(payload: LeaderboardPayload): boolean {
const generatedAt = Date.parse(payload.generatedAt);
if (!Number.isFinite(generatedAt)) {
return false;
}
return Date.now() - generatedAt < CACHE_REFRESH_SECONDS * 1000;
}

async function mapWithConcurrency<T, R>(
items: T[],
concurrency: number,
mapper: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const safeConcurrency =
Number.isFinite(concurrency) && concurrency > 0 ? Math.floor(concurrency) : 1;
const results: R[] = new Array(items.length);
let cursor = 0;

async function worker() {
while (true) {
const index = cursor;
cursor += 1;
if (index >= items.length) {
return;
}
results[index] = await mapper(items[index], index);
}
}

const workers = Array.from(
{ length: Math.min(safeConcurrency, items.length) },
() => worker()
);

await Promise.all(workers);
return results;
}

async function fetchGitHubJson<T>(path: string): Promise<T | null> {
const token = process.env.GITHUB_TOKEN;
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}

const res = await fetch(`${GITHUB_API}${path}`, {
headers,
next: { revalidate: 3600 },
});

if (!res.ok) {
console.error("GitHub leaderboard request failed:", path, res.status);
return null;
}

return (await res.json()) as T;
}

function calculateCurrentStreak(commitDates: string[]): number {
const days = Array.from(new Set(commitDates.map((date) => date.slice(0, 10)))).sort();
if (days.length === 0) {
return 0;
}

let runLength = 1;
const runs: { end: string; length: number }[] = [];
for (let i = 1; i < days.length; i += 1) {
if (dateDiffDays(days[i - 1], days[i]) === 1) {
runLength += 1;
} else {
runs.push({ end: days[i - 1], length: runLength });
runLength = 1;
}
}
runs.push({ end: days[days.length - 1], length: runLength });

const today = toDateStr(new Date());
const yesterday = toDateStr(new Date(Date.now() - 86400000));
const latest = runs[runs.length - 1];
return latest.end === today || latest.end === yesterday ? latest.length : 0;
}

async function fetchCommitStats(username: string, since: string) {
const query = new URLSearchParams({
q: `author:${username} author-date:>=${since}`,
per_page: "100",
sort: "author-date",
order: "desc",
});
return fetchGitHubJson<{
total_count: number;
items: Array<{ commit: { author: { date: string } } }>;
}>(`/search/commits?${query.toString()}`);
}

async function fetchPrCount(username: string, since: string): Promise<number> {
const query = new URLSearchParams({
q: `author:${username} type:pr created:>=${since}`,
per_page: "1",
});
const data = await fetchGitHubJson<{ total_count: number }>(
`/search/issues?${query.toString()}`
);
return data?.total_count ?? 0;
}

async function buildLeaderboard(): Promise<LeaderboardPayload> {
const { data: users, error } = await supabaseAdmin
.from("users")
.select("id, github_login, is_sponsor")
.eq("is_public", true)
.eq("leaderboard_opt_in", true)
.limit(50);

if (error) {
console.error("Failed to fetch leaderboard users:", error);
throw new Error("Failed to load leaderboard users");
}

const now = new Date();
const monthStart = toDateStr(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)));
const streakStart = toDateStr(getStreakLookbackStart());

const safeUsers = (users ?? []) as PublicUser[];

const rows = await mapWithConcurrency(
safeUsers,
USER_CONCURRENCY,
async (user) => {
const [monthlyCommits, streakCommits, prs] = await Promise.all([
fetchCommitStats(user.github_login, monthStart),
fetchCommitStats(user.github_login, streakStart),
fetchPrCount(user.github_login, monthStart),
]);

const streak = calculateCurrentStreak(
streakCommits?.items.map((item) => item.commit.author.date) ?? []
);
const commits = monthlyCommits?.total_count ?? 0;
const score = streak * 5 + commits + prs * 3;

return {
rank: 0,
username: user.github_login,
avatarUrl: `https://github.com/${user.github_login}.png?size=96`,
profileUrl: `/u/${user.github_login}`,
streak,
commits,
prs,
score,
isSponsor: user.is_sponsor ?? false,
};
}
);

const rankBy = (metric: LeaderboardMetric) =>
[...rows]
.sort((a, b) => b[metric] - a[metric] || b.score - a.score)
.slice(0, 50)
.map((entry, index) => ({ ...entry, rank: index + 1 }));

return {
generatedAt: now.toISOString(),
refreshSeconds: CACHE_REFRESH_SECONDS,
leaders: {
streak: rankBy("streak"),
commits: rankBy("commits"),
prs: rankBy("prs"),
},
};
}

export async function GET(req: NextRequest) {
const ip = getRateLimitKey(req);
const rateLimit = await checkRateLimit(ip);
Expand Down
24 changes: 10 additions & 14 deletions src/app/api/metrics/streak/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import {
} from "@/lib/metrics-cache";
import { supabaseAdmin } from "@/lib/supabase";
import { resolveAppUser } from "@/lib/resolve-user";
import { calculateStreak } from "@/lib/streak";
import { dispatchToAllWebhooks } from "@/lib/webhooks";
import { dateDiffDays, toDateStr } from "@/lib/dateUtils";
import { getStreakLookbackStart } from "@/lib/streak";

export const dynamic = "force-dynamic";

Expand All @@ -35,11 +35,9 @@ async function fetchActiveDates(
ttlSeconds: METRICS_CACHE_TTL_SECONDS.streak,
},
async () => {
// Look back 90 days — the maximum window GitHub's Commit Search supports.
// Requesting beyond 90 days will silently return fewer results.
const since = new Date();
since.setDate(since.getDate() - 90);
const sinceStr = since.toISOString().slice(0, 10); // "YYYY-MM-DD"
// Look back far enough to cover realistic streaks without truncating
// long-running runs. GitHub's commit search supports date filters up to a year.
const sinceStr = getStreakLookbackStart().toISOString().slice(0, 10); // "YYYY-MM-DD"

const activeDates = new Set<string>();
let page = 1;
Expand All @@ -49,7 +47,7 @@ async function fetchActiveDates(
// • Unauthenticated: 10 requests/minute
//
// This loop pages through up to 10 pages (1,000 commits max) to cover
// the full 90-day window. Each page = 1 request against the 30 req/min quota.
// the configured look-back window. Each page = 1 request against the 30 req/min quota.
// Most users need only 1–2 pages; the cap of 10 prevents runaway API usage
// for extremely active accounts.
while (true) {
Expand Down Expand Up @@ -138,8 +136,8 @@ function calculateStreakFromDates(
current: currentStreak,
longest: longestStreak,
lastCommitDate: lastDay,
// 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 counts only days with real commits or freezes in the configured
// look-back window, not the full streak length — useful for the "active days" stat.
totalActiveDays: commitDays.length,
freezeDates: Array.from(freezeDates),
};
Expand Down Expand Up @@ -188,12 +186,10 @@ export async function GET(req: NextRequest) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

// Fetch streak freeze dates from Supabase for the past 90 days.
// Fetch streak freeze dates from Supabase for the same look-back window as commits.
// These are merged with commit dates so a freeze day doesn't break the streak.
// Only fetched when the user has a Supabase row (appUserId is non-null).
const since = new Date();
since.setDate(since.getDate() - 90);
const sinceStr = since.toISOString().slice(0, 10);
const sinceStr = getStreakLookbackStart().toISOString().slice(0, 10);

const freezeDates = new Set<string>();
if (appUserId) {
Expand Down
7 changes: 3 additions & 4 deletions src/lib/public-profile-data.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { calculateStreak } from "@/lib/streak";
import { dateDiffDays, toDateStr } from "@/lib/dateUtils";
import { getStreakLookbackStart } 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 @@ -120,9 +121,7 @@ export async function fetchPublicStreak(
username: string,
token?: string
): Promise<StreakData> {
const since = new Date();
since.setDate(since.getDate() - 90);
const sinceStr = since.toISOString().slice(0, 10);
const sinceStr = getStreakLookbackStart().toISOString().slice(0, 10);

const res = await ghFetch(
`${GITHUB_API}/search/commits?q=author:${username}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`,
Expand Down
62 changes: 5 additions & 57 deletions src/lib/streak.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,7 @@
import { dateDiffDays, toDateStr } from "@/lib/dateUtils";
export const STREAK_LOOKBACK_DAYS = 365;

export interface StreakResult {
currentStreak: number;
longestStreak: number;
export function getStreakLookbackStart(referenceDate = new Date()): Date {
const since = new Date(referenceDate);
since.setDate(since.getDate() - STREAK_LOOKBACK_DAYS);
return since;
}

function toUtcDayKey(date: Date): string | null {
if (!(date instanceof Date)) return null;
if (Number.isNaN(date.getTime())) return null;
return toDateStr(date);
}

/**
* 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).
*/
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);
}

const days = Array.from(dayKeys).sort();
if (days.length === 0) {
return { currentStreak: 0, longestStreak: 0 };
}

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

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

const today = toDateStr(new Date());
const yesterday = toDateStr(new Date(Date.now() - 86400000));
const lastRun = runs[runs.length - 1];

return {
currentStreak:
lastRun.end === today || lastRun.end === yesterday ? lastRun.length : 0,
longestStreak,
};
}

Loading