diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index cbe7b604..ece23083 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -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 revalidate = 3600; @@ -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( + items: T[], + concurrency: number, + mapper: (item: T, index: number) => Promise +): Promise { + 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(path: string): Promise { + const token = process.env.GITHUB_TOKEN; + const headers: Record = { + 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 { + 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 { + 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); diff --git a/src/app/api/metrics/streak/route.ts b/src/app/api/metrics/streak/route.ts index e0bbb1bb..f84d59f5 100644 --- a/src/app/api/metrics/streak/route.ts +++ b/src/app/api/metrics/streak/route.ts @@ -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"; @@ -36,11 +36,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(); let page = 1; @@ -50,7 +48,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) { @@ -255,12 +253,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(); if (appUserId) { diff --git a/src/lib/public-profile-data.ts b/src/lib/public-profile-data.ts index 0fe047a1..7c32dd99 100644 --- a/src/lib/public-profile-data.ts +++ b/src/lib/public-profile-data.ts @@ -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"; @@ -133,9 +134,7 @@ export async function fetchPublicStreak( username: string, token?: string ): Promise { - 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`, diff --git a/src/lib/streak.ts b/src/lib/streak.ts index 2cf706a2..6d71efc2 100644 --- a/src/lib/streak.ts +++ b/src/lib/streak.ts @@ -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(); - 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, - }; -} -