diff --git a/src/app/api/badge/streak-shield/route.ts b/src/app/api/badge/streak-shield/route.ts index bf6835119..0d35a55bb 100644 --- a/src/app/api/badge/streak-shield/route.ts +++ b/src/app/api/badge/streak-shield/route.ts @@ -4,6 +4,26 @@ 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 { supabaseAdmin } from "@/lib/supabase"; + +/** + * Resolves the stored IANA timezone for a GitHub user so the badge's streak + * calculation agrees with the dashboard's (see /api/metrics/streak). Falls + * back to "UTC" if the user has no DevTrack account or no timezone set. + */ +async function resolveUserTimeZone(username: string): Promise { + try { + const { data } = await supabaseAdmin + .from("users") + .select("timezone") + .ilike("github_login", username) + .maybeSingle(); + + return data?.timezone || "UTC"; + } catch { + return "UTC"; + } +} export const dynamic = "force-dynamic"; @@ -34,7 +54,8 @@ async function fetchGitHubWithToken( async function fetchStreak( username: string, - token?: string + token?: string, + timeZone = "UTC" ): Promise { const since = new Date(); since.setDate(since.getDate() - 90); @@ -75,7 +96,7 @@ async function fetchStreak( activeDates.add(item.commit.author.date.slice(0, 10)); } - const result = calculateStreakFromDates(activeDates); +const result = calculateStreakFromDates(activeDates, new Set(), timeZone); return { current: result.current, longest: result.longest, @@ -115,7 +136,8 @@ export async function GET(req: NextRequest) { } const githubToken = process.env.GITHUB_TOKEN; - const streak = await fetchStreak(username, githubToken); + const timeZone = await resolveUserTimeZone(username); + const streak = await fetchStreak(username, githubToken, timeZone); const svg = generateBadgeSVG({ label: "DevTrack", diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index a85063a12..35366131a 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -1,17 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; -import { cacheGet, cacheSet, isMetricsCacheBypassed } from "@/lib/metrics-cache"; +import { supabaseAdmin } from "@/lib/supabase"; +import { dateDiffDays, toDateStr } from "@/lib/date-utils"; import { - CACHE_STALE_SECONDS, - getLeaderboardCacheKey as getBaseLeaderboardCacheKey, - getLeaderboardData, - isFresh, - LEADERBOARD_BUILD_LOCK_KEY, - type LeaderboardPayload, - type LeaderboardPeriod, - filterLeaderboardByLanguage, -} from "@/lib/leaderboard"; + cacheGet, + cacheSet, + isMetricsCacheBypassed, +} from "@/lib/metrics-cache"; import { + pruneExpiredLeaderboardCache, pruneExpiredRateLimits, + type LeaderboardCacheEntry, type RateLimitEntry, } from "@/lib/leaderboard-cache"; import { @@ -19,21 +17,108 @@ import { upstashRateLimitFixedWindow, upstashTryAcquireLock, } from "@/lib/upstash-rest"; +import { + applyLeaderboardQuery, + buildLeaderboardCacheKey, + getRangeStartDate, + parseLeaderboardQueryFromSearchParams, + type LeaderboardEntry, + type LeaderboardMetric, + type LeaderboardPagination, + type LeaderboardQuery, + type Scope, +} from "@/lib/leaderboard-query"; export const dynamic = "force-dynamic"; +const GITHUB_API = "https://api.github.com"; +const CACHE_REFRESH_SECONDS = 60 * 60; // 1 hour +const CACHE_STALE_SECONDS = 6 * 60 * 60; // 6 hours const RATE_LIMIT_REQUESTS = 20; const RATE_LIMIT_WINDOW_MS = 60 * 1000; -const memoryRateLimits = new Map(); +/** + * Validates and sanitizes the LEADERBOARD_USER_CONCURRENCY environment variable + * Ensures the value is within safe operational bounds [1, 100] + */ +function validateUserConcurrency(value: string | undefined): number { + const DEFAULT_CONCURRENCY = 5; + const MIN_CONCURRENCY = 1; + const MAX_CONCURRENCY = 100; + + // No value provided: use default + if (!value) { + return DEFAULT_CONCURRENCY; + } + + // Parse the value + const parsed = Number(value); + + // Validate: must be a finite integer + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { + console.warn( + `[Leaderboard] Invalid LEADERBOARD_USER_CONCURRENCY value: "${value}". Using default: ${DEFAULT_CONCURRENCY}` + ); + return DEFAULT_CONCURRENCY; + } + + // Validate: must be within bounds + if (parsed < MIN_CONCURRENCY || parsed > MAX_CONCURRENCY) { + const clamped = Math.max(MIN_CONCURRENCY, Math.min(MAX_CONCURRENCY, parsed)); + console.warn( + `[Leaderboard] LEADERBOARD_USER_CONCURRENCY ${parsed} is outside safe range [${MIN_CONCURRENCY}, ${MAX_CONCURRENCY}]. Clamping to ${clamped}` + ); + return clamped; + } -type RateLimitResult = { allowed: boolean; retryAfter?: number }; + // Log when using non-default value + if (parsed !== DEFAULT_CONCURRENCY) { + console.info(`[Leaderboard] Using custom concurrency: ${parsed}`); + } + + return parsed; +} + +const USER_CONCURRENCY = validateUserConcurrency(process.env.LEADERBOARD_USER_CONCURRENCY); + +const LEADERBOARD_BUILD_LOCK_KEY = "leaderboard:build-lock:v1"; + +interface PublicUser { + id: string; + github_login: string; +} + +interface LeaderboardCachePayload { + generatedAt: string; + refreshSeconds: number; + query: { + range: LeaderboardQuery["range"]; + scope: Scope; + }; + rows: LeaderboardEntry[]; +} + +interface LeaderboardPayload extends LeaderboardCachePayload { + items: LeaderboardEntry[]; + pagination: LeaderboardPagination; + leaders: Record; +} function getRateLimitKey(req: NextRequest): string { - return req.headers.get("cf-connecting-ip") ?? req.headers.get("x-real-ip") ?? req.headers.get("x-forwarded-for")?.split(",")[0] ?? "unknown"; + return ( + req.ip ?? + req.headers.get("x-real-ip") ?? + "unknown" + ); } -function checkMemoryRateLimit(ip: string): RateLimitResult { +const memoryLeaderboardCache = new Map< + string, + LeaderboardCacheEntry +>(); +const memoryRateLimits = new Map(); + +function checkMemoryRateLimit(ip: string): { allowed: boolean; retryAfter?: number } { const now = Date.now(); pruneExpiredRateLimits(memoryRateLimits, now); const record = memoryRateLimits.get(ip); @@ -48,13 +133,12 @@ function checkMemoryRateLimit(ip: string): RateLimitResult { return { allowed: true }; } - return { - allowed: false, - retryAfter: Math.ceil((record.resetAt - now) / 1000), - }; + return { allowed: false, retryAfter: Math.ceil((record.resetAt - now) / 1000) }; } -async function checkRateLimit(ip: string): Promise { +async function checkRateLimit( + ip: string +): Promise<{ allowed: boolean; retryAfter?: number }> { if (getUpstashConfig()) { return upstashRateLimitFixedWindow({ key: `leaderboard-rate-limit:${ip}`, @@ -66,76 +150,262 @@ async function checkRateLimit(ip: string): Promise { return checkMemoryRateLimit(ip); } -function normalizeLanguage(value: string | null): string | undefined { - if (!value) { - return undefined; +function isFresh(payload: LeaderboardCachePayload): 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 normalized = value.trim().toLowerCase(); - return normalized || undefined; + const workers = Array.from( + { length: Math.min(safeConcurrency, items.length) }, + () => worker() + ); + + await Promise.all(workers); + return results; } -function normalizePeriod(value: string | null): LeaderboardPeriod { - if (value === "week" || value === "month" || value === "all") { - return value; +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}`; } - return "all"; + 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 getLanguageCacheKey(filters: { - language: string; - period: LeaderboardPeriod; -}): string { - return `${getBaseLeaderboardCacheKey(filters.period)}:${filters.language}`; +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; } -function getLeaderboardBuildLockCacheKey(cacheKey: string): string { - return `${LEADERBOARD_BUILD_LOCK_KEY}:${cacheKey}`; +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; +} + +function buildLeadersByMetric(rows: LeaderboardEntry[]): Record { + 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 { + streak: rankBy("streak"), + commits: rankBy("commits"), + prs: rankBy("prs"), + }; +} + +function formatLeaderboardResponse( + cached: LeaderboardCachePayload, + query: LeaderboardQuery +): LeaderboardPayload { + const { items, pagination } = applyLeaderboardQuery(cached.rows, query); + + return { + generatedAt: cached.generatedAt, + refreshSeconds: cached.refreshSeconds, + query: cached.query, + rows: cached.rows, + items, + pagination, + leaders: buildLeadersByMetric(cached.rows), + }; +} + +async function buildLeaderboard(query: LeaderboardQuery): Promise { + const { data: users, error } = await supabaseAdmin + .from("users") + .select("id, github_login") + .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 rangeStart = getRangeStartDate(query.range, now); + + // Scope is currently reserved for repository/organization-level ranking support. + const appliedScope: Scope = query.scope; + + 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, rangeStart), + fetchCommitStats(user.github_login, rangeStart), + fetchPrCount(user.github_login, rangeStart), + ]); + + 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, + }; + } + ); + + const sortedByScore = [...rows].sort( + (a, b) => b.score - a.score || b.commits - a.commits || b.streak - a.streak + ); + + return { + generatedAt: now.toISOString(), + refreshSeconds: CACHE_REFRESH_SECONDS, + query: { + range: query.range, + scope: appliedScope, + }, + rows: sortedByScore.map((entry) => ({ ...entry, rank: 0 })), + }; } export async function GET(req: NextRequest) { + const query = parseLeaderboardQueryFromSearchParams(req.nextUrl.searchParams); + const cacheKey = buildLeaderboardCacheKey(query); + const lockKey = `${LEADERBOARD_BUILD_LOCK_KEY}:${query.range}:${query.scope}`; + const ip = getRateLimitKey(req); const rateLimit = await checkRateLimit(ip); - const language = normalizeLanguage(req.nextUrl.searchParams.get("lang")); - const period = normalizePeriod(req.nextUrl.searchParams.get("period")); - const cacheKey = language - ? getLanguageCacheKey({ language, period }) - : getBaseLeaderboardCacheKey(period); if (!rateLimit.allowed) { return NextResponse.json( { error: "Rate limit exceeded" }, - { - status: 429, - headers: { "Retry-After": String(rateLimit.retryAfter) }, - } + { status: 429, headers: { "Retry-After": String(rateLimit.retryAfter) } } ); } const bypass = isMetricsCacheBypassed(req); - if (!bypass) { - const cached = await cacheGet(cacheKey); - if (cached && isFresh(cached)) { - return NextResponse.json(cached, { + const memoryEntry = pruneExpiredLeaderboardCache(memoryLeaderboardCache.get(cacheKey) ?? null); + if (memoryEntry) { + memoryLeaderboardCache.set(cacheKey, memoryEntry); + } else { + memoryLeaderboardCache.delete(cacheKey); + } + + if (memoryEntry && isFresh(memoryEntry.payload)) { + return NextResponse.json(formatLeaderboardResponse(memoryEntry.payload, query), { headers: { "x-devtrack-leaderboard-cache": "memory" }, }); } + const cached = await cacheGet(cacheKey); + if (cached && isFresh(cached)) { + memoryLeaderboardCache.set(cacheKey, { + payload: cached, + expiresAt: Date.now() + CACHE_REFRESH_SECONDS * 1000, + }); + return NextResponse.json(formatLeaderboardResponse(cached, query)); + } + + // Avoid thundering herd on cache misses across serverless instances. if (getUpstashConfig()) { const locked = await upstashTryAcquireLock({ - key: getLeaderboardBuildLockCacheKey(cacheKey), + key: lockKey, ttlSeconds: 5 * 60, }); if (!locked) { if (cached) { - return NextResponse.json(cached, { + return NextResponse.json(formatLeaderboardResponse(cached, query), { headers: { "x-devtrack-leaderboard-cache": "stale" }, }); } - return NextResponse.json( { error: "Leaderboard is rebuilding. Please retry shortly." }, { status: 503, headers: { "Retry-After": "5" } } @@ -145,45 +415,23 @@ export async function GET(req: NextRequest) { } try { - const baseLeaderboard = await getLeaderboardData(bypass, { period }); - - if (!baseLeaderboard) { - const stale = await cacheGet(cacheKey); - if (stale) { - return NextResponse.json(stale, { - headers: { "x-devtrack-leaderboard-cache": "error-stale" }, - }); - } - - return NextResponse.json( - { error: "Failed to build leaderboard" }, - { status: 500 } - ); - } - - const payload = language - ? await filterLeaderboardByLanguage(baseLeaderboard, language) - : baseLeaderboard; - - if (!bypass) { - await cacheSet(cacheKey, payload, CACHE_STALE_SECONDS); - } - - return NextResponse.json(payload); - } catch (error) { - console.error("Leaderboard API Error:", error); - console.error("Stack:", error instanceof Error ? error.stack : error); - - const cached = await cacheGet(cacheKey); + const payload = await buildLeaderboard(query); + await cacheSet(cacheKey, payload, CACHE_STALE_SECONDS); + memoryLeaderboardCache.set(cacheKey, { + payload, + expiresAt: Date.now() + CACHE_REFRESH_SECONDS * 1000, + }); + return NextResponse.json(formatLeaderboardResponse(payload, query)); + } catch { + const cached = await cacheGet(cacheKey); if (cached) { - return NextResponse.json(cached, { + return NextResponse.json(formatLeaderboardResponse(cached, query), { headers: { "x-devtrack-leaderboard-cache": "error-stale" }, }); } - return NextResponse.json( { error: "Failed to build leaderboard" }, { status: 500 } ); } -} \ No newline at end of file +} diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx index b78b24560..23df02c6d 100644 --- a/src/app/leaderboard/page.tsx +++ b/src/app/leaderboard/page.tsx @@ -1,17 +1,10 @@ import Link from "next/link"; -import Image from "next/image"; -import { Suspense } from "react"; -import EmptyState from "@/components/EmptyState"; -import LeaderboardFilters from "@/components/leaderboard/LeaderboardFilters"; -import LeaderboardSkeleton from "@/app/leaderboard/LeaderboardSkeleton"; -import SponsorBadge from "@/components/SponsorBadge"; -import { getLeaderboardData, filterLeaderboardByLanguage, type LeaderboardPayload } from "@/lib/leaderboard"; -type LeaderboardTab = "streak" | "commits" | "prs"; -type LeaderboardPeriod = "week" | "month" | "all"; +type LeaderboardSort = "streak" | "commits" | "prs" | "score"; +type TimeRange = "7d" | "30d" | "90d" | "all"; +type Scope = "global" | "repositories" | "organizations"; interface LeaderboardEntry { - id: string; rank: number; username: string; avatarUrl: string; @@ -20,188 +13,173 @@ interface LeaderboardEntry { commits: number; prs: number; score: number; - isSponsor: boolean; } -const tabs: Array<{ id: LeaderboardTab; label: string; metric: string }> = [ +interface LeaderboardPayload { + generatedAt: string; + refreshSeconds: number; + query: { + range: TimeRange; + scope: Scope; + }; + items: LeaderboardEntry[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +} + +const sortTabs: Array<{ id: LeaderboardSort; label: string; metric: string }> = [ + { id: "score", label: "Score", metric: "overall" }, { id: "streak", label: "Streak", metric: "days" }, - { id: "commits", label: "Commits", metric: "commits" }, - { id: "prs", label: "PRs", metric: "pull requests" }, + { id: "commits", label: "Commits", metric: "in range" }, + { id: "prs", label: "PRs", metric: "in range" }, ]; -const periods: Record = { - week: "this week", - month: "this month", - all: "all time", -}; - -function isLeaderboardPeriod(value: string | undefined): value is LeaderboardPeriod { - return value === "week" || value === "month" || value === "all"; -} +const ranges: Array<{ id: TimeRange; label: string }> = [ + { id: "7d", label: "7 days" }, + { id: "30d", label: "30 days" }, + { id: "90d", label: "90 days" }, + { id: "all", label: "All time" }, +]; -function leaderboardHref( - tab: LeaderboardTab, - filters: { lang?: string; period: LeaderboardPeriod } -): string { - const params = new URLSearchParams({ tab }); +const limits = [10, 25, 50]; - if (filters.lang) { - params.set("lang", filters.lang); - } +type LeaderboardPageSearchParams = { + sort?: string; + range?: string; + scope?: string; + page?: string; + limit?: string; +}; - if (filters.period !== "all") { - params.set("period", filters.period); +function toQueryString(params: LeaderboardPageSearchParams): string { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value) { + query.set(key, value); + } } - - return `/leaderboard?${params.toString()}`; + return query.toString(); } -function getMetricValue(entry: LeaderboardEntry, tab: LeaderboardTab): number { - if (tab === "streak") return entry.streak; - if (tab === "commits") return entry.commits; - return entry.prs; +function buildLeaderboardHref( + current: LeaderboardPageSearchParams, + updates: Partial +): string { + const nextParams: LeaderboardPageSearchParams = { ...current, ...updates }; + return `/leaderboard?${toQueryString(nextParams)}`; } -// New: async sub-component that does the actual data fetching + rendering. -// This is the part that gets suspended while data loads. -async function LeaderboardTable({ - activeTab, - filters, -}: { - activeTab: LeaderboardTab; - filters: { lang?: string; period: LeaderboardPeriod }; -}) { - const hasFilters = Boolean(filters.lang) || filters.period !== "all"; +async function fetchLeaderboard( + queryParams: LeaderboardPageSearchParams +): Promise { + const baseUrl = + process.env.NEXT_PUBLIC_APP_URL || + process.env.NEXTAUTH_URL || + "http://localhost:3000"; + const query = toQueryString(queryParams); - let leaderboard = await getLeaderboardData(false, { period: filters.period }); - if (leaderboard && filters.lang) { - leaderboard = await filterLeaderboardByLanguage(leaderboard, filters.lang); - } + try { + const res = await fetch(`${baseUrl}/api/leaderboard?${query}`, { + next: { revalidate: 300 }, + }); - const activeMeta = tabs.find((tab) => tab.id === activeTab) ?? tabs[0]; - const rows = leaderboard?.leaders[activeTab] ?? []; - const metricLabel = activeTab === "streak" ? activeMeta.metric : periods[filters.period]; + if (!res.ok) { + return null; + } - return ( - <> - {leaderboard && ( -
- Updated {new Date(leaderboard.generatedAt).toLocaleString()} -
- )} - -
- {!leaderboard ? ( -
-

Leaderboard data is temporarily unavailable.

- - Retry - -
- ) : rows.length === 0 ? ( - - ) : ( - <> -
-
Rank
-
Contributor
-
{activeMeta.label}
-
Score
-
Profile
-
- {rows.map((entry) => ( -
-
#{entry.rank}
-
- {`${entry.username} -
-
- @{entry.username} {entry.isSponsor && } -
-
- {entry.commits} commits, {entry.prs} PRs, {entry.streak}d streak -
-
-
-
-
{getMetricValue(entry, activeTab)}
-
{metricLabel}
-
-
{entry.score}
-
- - View - -
-
- ))} - - )} -
- - ); + return (await res.json()) as LeaderboardPayload; + } catch (error) { + console.error("Failed to fetch leaderboard:", error); + return null; + } +} + +function getMetricValue(entry: LeaderboardEntry, sort: LeaderboardSort): number { + if (sort === "score") return entry.score; + if (sort === "streak") return entry.streak; + if (sort === "commits") return entry.commits; + return entry.prs; } export default async function LeaderboardPage({ searchParams, }: { - searchParams: Promise<{ tab?: string; lang?: string; period?: string }>; + searchParams: LeaderboardPageSearchParams; }) { - const resolvedSearchParams = await searchParams; - const activeTab = tabs.some((tab) => tab.id === resolvedSearchParams.tab) - ? (resolvedSearchParams.tab as LeaderboardTab) - : "streak"; - const period = isLeaderboardPeriod(resolvedSearchParams.period) - ? resolvedSearchParams.period - : "all"; - const filters = { lang: resolvedSearchParams.lang, period }; + const activeSort = sortTabs.some((tab) => tab.id === searchParams.sort) + ? (searchParams.sort as LeaderboardSort) + : "score"; + const activeRange = ranges.some((range) => range.id === searchParams.range) + ? (searchParams.range as TimeRange) + : "30d"; + const requestedPage = Number(searchParams.page ?? "1"); + const requestedLimit = Number(searchParams.limit ?? "25"); + const activePage = Number.isFinite(requestedPage) && requestedPage > 0 + ? String(Math.floor(requestedPage)) + : "1"; + const activeLimit = limits.includes(requestedLimit) + ? String(requestedLimit) + : "25"; + const activeScope: Scope = searchParams.scope === "repositories" || searchParams.scope === "organizations" + ? searchParams.scope + : "global"; + + const currentParams: LeaderboardPageSearchParams = { + sort: activeSort, + range: activeRange, + scope: activeScope, + page: activePage, + limit: activeLimit, + }; + + const leaderboard = await fetchLeaderboard(currentParams); + const activeMeta = sortTabs.find((tab) => tab.id === activeSort) ?? sortTabs[0]; + const rows = leaderboard?.items ?? []; + const pagination = leaderboard?.pagination; + const startRow = pagination && rows.length > 0 ? (pagination.page - 1) * pagination.limit + 1 : 0; + const endRow = pagination && rows.length > 0 ? startRow + rows.length - 1 : 0; return (
- + DevTrack -

Public Leaderboard

+

+ Public Leaderboard +

- Opted-in developers ranked by current streak, commits, and pull request activity. + Opted-in developers ranked by score, streak, commits, and pull + request activity with query-based filtering.

+ + {leaderboard && ( +
+ Updated {new Date(leaderboard.generatedAt).toLocaleString()} +
+ )}
- {tabs.map((tab) => { - const active = tab.id === activeTab; + {sortTabs.map((tab) => { + const active = tab.id === activeSort; return ( - - - +
+ {ranges.map((range) => { + const active = range.id === activeRange; + return ( + + {range.label} + + ); + })} + +
+ Rows + {limits.map((value) => { + const active = Number(activeLimit) === value; + return ( + + {value} + + ); + })} +
+
+ + {pagination && ( +
+ + Showing {startRow}-{endRow} of {pagination.total} + + + Page {pagination.page} of {pagination.totalPages} + +
+ )} + +
+
+
Rank
+
Contributor
+
{activeMeta.label}
+
Score
+
Profile
+
+ + {!leaderboard ? ( +
+ Leaderboard data is temporarily unavailable. +
+ ) : rows.length === 0 ? ( +
+ No opted-in public profiles yet. +
+ ) : ( + rows.map((entry) => ( +
+
+ #{entry.rank} +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+
+ @{entry.username} +
+
+ {entry.commits} commits · {entry.prs} PRs · {entry.streak}d + streak +
+
+
+
+
+ {getMetricValue(entry, activeSort)} +
+
+ {activeMeta.metric} +
+
+
+ {entry.score} +
+
+ + View + +
+
+ )) + )} +
- }> - - + {pagination && pagination.totalPages > 1 && ( +
+ {pagination.page > 1 ? ( + + Previous + + ) : ( + + Previous + + )} + + {pagination.page < pagination.totalPages ? ( + + Next + + ) : ( + + Next + + )} +
+ )}
); -} \ No newline at end of file +} + diff --git a/src/lib/leaderboard-query.ts b/src/lib/leaderboard-query.ts new file mode 100644 index 000000000..13575211c --- /dev/null +++ b/src/lib/leaderboard-query.ts @@ -0,0 +1,128 @@ +export type LeaderboardMetric = "streak" | "commits" | "prs"; + +export type TimeRange = "7d" | "30d" | "90d" | "all"; +export type Scope = "global" | "repositories" | "organizations"; + +export interface LeaderboardQuery { + page: number; + limit: number; + sort: LeaderboardMetric | "score"; + range: TimeRange; + scope: Scope; +} + +export interface LeaderboardEntry { + rank: number; + username: string; + avatarUrl: string; + profileUrl: string; + streak: number; + commits: number; + prs: number; + score: number; +} + +export interface LeaderboardPagination { + page: number; + limit: number; + total: number; + totalPages: number; +} + +const DEFAULT_QUERY: LeaderboardQuery = { + page: 1, + limit: 25, + sort: "score", + range: "30d", + scope: "global", +}; + +const MIN_LIMIT = 10; +const MAX_LIMIT = 100; +const MIN_PAGE = 1; + +export function parseLeaderboardQueryFromSearchParams( + params: URLSearchParams +): LeaderboardQuery { + const pageInput = Number(params.get("page") ?? DEFAULT_QUERY.page); + const limitInput = Number(params.get("limit") ?? DEFAULT_QUERY.limit); + + const sortParam = params.get("sort"); + const rangeParam = params.get("range"); + const scopeParam = params.get("scope"); + + return { + page: + Number.isFinite(pageInput) && pageInput >= MIN_PAGE + ? Math.floor(pageInput) + : DEFAULT_QUERY.page, + limit: + Number.isFinite(limitInput) && limitInput >= MIN_LIMIT + ? Math.min(MAX_LIMIT, Math.floor(limitInput)) + : DEFAULT_QUERY.limit, + sort: + sortParam === "streak" || + sortParam === "commits" || + sortParam === "prs" || + sortParam === "score" + ? sortParam + : DEFAULT_QUERY.sort, + range: + rangeParam === "7d" || + rangeParam === "30d" || + rangeParam === "90d" || + rangeParam === "all" + ? rangeParam + : DEFAULT_QUERY.range, + scope: + scopeParam === "repositories" || scopeParam === "organizations" + ? scopeParam + : DEFAULT_QUERY.scope, + }; +} + +export function getRangeStartDate(range: TimeRange, now = new Date()): string { + const MS_PER_DAY = 86400000; + if (range === "all") { + return "2008-01-01"; + } + + const dayCount = range === "7d" ? 7 : range === "30d" ? 30 : 90; + const since = new Date(now.getTime() - dayCount * MS_PER_DAY); + return since.toISOString().slice(0, 10); +} + +export function applyLeaderboardQuery( + entries: LeaderboardEntry[], + query: LeaderboardQuery +): { items: LeaderboardEntry[]; pagination: LeaderboardPagination } { + const ranked = [...entries].sort((a, b) => { + if (query.sort === "score") { + return b.score - a.score || b.commits - a.commits || b.streak - a.streak; + } + return b[query.sort] - a[query.sort] || b.score - a.score; + }); + + const total = ranked.length; + const totalPages = Math.max(1, Math.ceil(total / query.limit)); + const page = Math.min(query.page, totalPages); + const start = (page - 1) * query.limit; + const items = ranked.slice(start, start + query.limit).map((entry, index) => ({ + ...entry, + rank: start + index + 1, + })); + + return { + items, + pagination: { + page, + limit: query.limit, + total, + totalPages, + }, + }; +} + +export function buildLeaderboardCacheKey(query: Pick): string { + return `leaderboard:v2:${query.range}:${query.scope}`; +} diff --git a/test/leaderboard-query.test.ts b/test/leaderboard-query.test.ts new file mode 100644 index 000000000..ea7ee4754 --- /dev/null +++ b/test/leaderboard-query.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { + applyLeaderboardQuery, + getRangeStartDate, + parseLeaderboardQueryFromSearchParams, + type LeaderboardEntry, +} from "@/lib/leaderboard-query"; + +function makeEntry( + username: string, + streak: number, + commits: number, + prs: number, + score: number +): LeaderboardEntry { + return { + rank: 0, + username, + avatarUrl: `https://example.com/${username}.png`, + profileUrl: `/u/${username}`, + streak, + commits, + prs, + score, + }; +} + +describe("parseLeaderboardQueryFromSearchParams", () => { + it("parses valid query values", () => { + const params = new URLSearchParams({ + page: "2", + limit: "50", + sort: "commits", + range: "90d", + scope: "organizations", + }); + + expect(parseLeaderboardQueryFromSearchParams(params)).toEqual({ + page: 2, + limit: 50, + sort: "commits", + range: "90d", + scope: "organizations", + }); + }); + + it("falls back to defaults for invalid query values", () => { + const params = new URLSearchParams({ + page: "0", + limit: "5", + sort: "bogus", + range: "bogus", + scope: "bogus", + }); + + expect(parseLeaderboardQueryFromSearchParams(params)).toEqual({ + page: 1, + limit: 25, + sort: "score", + range: "30d", + scope: "global", + }); + }); +}); + +describe("applyLeaderboardQuery", () => { + const entries = [ + makeEntry("alpha", 9, 40, 6, 103), + makeEntry("beta", 4, 80, 3, 109), + makeEntry("charlie", 12, 30, 2, 96), + makeEntry("delta", 7, 20, 8, 79), + ]; + + it("sorts by score and paginates", () => { + const result = applyLeaderboardQuery(entries, { + page: 1, + limit: 2, + sort: "score", + range: "30d", + scope: "global", + }); + + expect(result.items.map((entry) => entry.username)).toEqual(["beta", "alpha"]); + expect(result.items.map((entry) => entry.rank)).toEqual([1, 2]); + expect(result.pagination.total).toBe(4); + expect(result.pagination.totalPages).toBe(2); + }); + + it("clamps page to total pages", () => { + const result = applyLeaderboardQuery(entries, { + page: 10, + limit: 3, + sort: "streak", + range: "30d", + scope: "global", + }); + + expect(result.pagination.page).toBe(2); + expect(result.items.length).toBe(1); + expect(result.items[0].rank).toBe(4); + }); +}); + +describe("getRangeStartDate", () => { + const now = new Date("2026-07-02T00:00:00.000Z"); + + it("returns expected 7 day start", () => { + expect(getRangeStartDate("7d", now)).toBe("2026-06-25"); + }); + + it("returns expected all-time start", () => { + expect(getRangeStartDate("all", now)).toBe("2008-01-01"); + }); +}); diff --git a/test/streak-shield-timezone.test.ts b/test/streak-shield-timezone.test.ts new file mode 100644 index 000000000..ec352d28c --- /dev/null +++ b/test/streak-shield-timezone.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +// Mock Supabase admin client so we control the stored timezone lookup. +vi.mock("@/lib/supabase", () => ({ + supabaseAdmin: { + from: vi.fn(), + }, +})); + +// Mock badge rate limiting so tests always pass through. +vi.mock("@/lib/badge-rate-limit", () => ({ + checkBadgeRateLimit: vi.fn(() => ({ + allowed: true, + remaining: 19, + reset: Math.floor(Date.now() / 1000) + 60, + })), + getBadgeClientIp: vi.fn(() => "127.0.0.1"), +})); + +// Mock the canonical streak calculator so we can assert on the timeZone +// argument it receives, without needing real commit data. +vi.mock("@/lib/streak", () => ({ + calculateStreakFromDates: vi.fn(() => ({ + current: 3, + longest: 5, + lastCommitDate: "2026-07-01", + totalActiveDays: 10, + freezeDates: [], + })), +})); + +import { supabaseAdmin } from "@/lib/supabase"; +import { calculateStreakFromDates } from "@/lib/streak"; +import { GET } from "@/app/api/badge/streak-shield/route"; + +function mockFetchCommits() { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + items: [{ commit: { author: { date: "2026-07-01T09:00:00Z" } } }], + }), + }) as unknown as typeof fetch; +} + +describe("streak-shield badge timezone handling", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchCommits(); + }); + + it("passes the user's stored timezone to calculateStreakFromDates instead of defaulting to UTC", async () => { + (supabaseAdmin.from as any).mockReturnValue({ + select: vi.fn().mockReturnThis(), + ilike: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ + data: { timezone: "America/Los_Angeles" }, + error: null, + }), + }); + + const req = new NextRequest( + "https://devtrack.example/api/badge/streak-shield?user=octocat" + ); + await GET(req); + + expect(calculateStreakFromDates).toHaveBeenCalledWith( + expect.any(Set), + expect.any(Set), + "America/Los_Angeles" + ); + }); + + it("falls back to UTC when the user has no DevTrack account or timezone set", async () => { + (supabaseAdmin.from as any).mockReturnValue({ + select: vi.fn().mockReturnThis(), + ilike: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), + }); + + const req = new NextRequest( + "https://devtrack.example/api/badge/streak-shield?user=unknown-user" + ); + await GET(req); + + expect(calculateStreakFromDates).toHaveBeenCalledWith( + expect.any(Set), + expect.any(Set), + "UTC" + ); + }); + + it("falls back to UTC gracefully when the timezone lookup throws", async () => { + (supabaseAdmin.from as any).mockReturnValue({ + select: vi.fn().mockReturnThis(), + ilike: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockRejectedValue(new Error("db unavailable")), + }); + + const req = new NextRequest( + "https://devtrack.example/api/badge/streak-shield?user=octocat" + ); + const res = await GET(req); + + expect(res.status).toBe(200); + expect(calculateStreakFromDates).toHaveBeenCalledWith( + expect.any(Set), + expect.any(Set), + "UTC" + ); + }); +}); \ No newline at end of file