From 11b6542bdc4da650c32fb8efc6fe62499d1123ee Mon Sep 17 00:00:00 2001 From: saurabhhhcodes <157192462+saurabhhhcodes@users.noreply.github.com> Date: Tue, 19 May 2026 00:58:40 +0530 Subject: [PATCH] Add public contributor leaderboard --- src/app/api/leaderboard/route.ts | 230 ++++++++++++++++++ src/app/api/user/settings/route.ts | 38 ++- src/app/dashboard/settings/page.tsx | 72 +++++- src/app/leaderboard/page.tsx | 183 ++++++++++++++ src/lib/supabase.ts | 5 +- .../20260519000000_add_leaderboard_opt_in.sql | 7 + 6 files changed, 523 insertions(+), 12 deletions(-) create mode 100644 src/app/api/leaderboard/route.ts create mode 100644 src/app/leaderboard/page.tsx create mode 100644 supabase/migrations/20260519000000_add_leaderboard_opt_in.sql diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts new file mode 100644 index 0000000..8b597b6 --- /dev/null +++ b/src/app/api/leaderboard/route.ts @@ -0,0 +1,230 @@ +import { NextRequest, NextResponse } from "next/server"; +import { supabaseAdmin } from "@/lib/supabase"; + +export const dynamic = "force-dynamic"; + +const GITHUB_API = "https://api.github.com"; +const CACHE_TTL_MS = 60 * 60 * 1000; +const RATE_LIMIT_REQUESTS = 20; +const RATE_LIMIT_WINDOW_MS = 60 * 1000; + +type LeaderboardMetric = "streak" | "commits" | "prs"; + +interface PublicUser { + id: string; + github_login: string; +} + +interface LeaderboardEntry { + rank: number; + username: string; + avatarUrl: string; + profileUrl: string; + streak: number; + commits: number; + prs: number; + score: number; +} + +interface LeaderboardPayload { + generatedAt: string; + refreshSeconds: number; + leaders: Record; +} + +let leaderboardCache: { expiresAt: number; payload: LeaderboardPayload } | null = + null; + +const ipRateLimits = new Map(); + +function getRateLimitKey(req: NextRequest): string { + return ( + req.ip ?? + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + req.headers.get("x-real-ip") ?? + "unknown" + ); +} + +function checkRateLimit(ip: string): { allowed: boolean; retryAfter?: number } { + const now = Date.now(); + const record = ipRateLimits.get(ip); + + if (!record || now > record.resetAt) { + ipRateLimits.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); + return { allowed: true }; + } + + if (record.count < RATE_LIMIT_REQUESTS) { + record.count += 1; + return { allowed: true }; + } + + return { allowed: false, retryAfter: Math.ceil((record.resetAt - now) / 1000) }; +} + +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 toDateStr(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function dateDiffDays(a: string, b: string): number { + return (new Date(b).getTime() - new Date(a).getTime()) / 86400000; +} + +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") + .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(new Date(Date.now() - 90 * 86400000)); + + const rows = await Promise.all( + ((users ?? []) as PublicUser[]).map(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, + }; + }) + ); + + 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_TTL_MS / 1000, + leaders: { + streak: rankBy("streak"), + commits: rankBy("commits"), + prs: rankBy("prs"), + }, + }; +} + +export async function GET(req: NextRequest) { + const ip = getRateLimitKey(req); + const rateLimit = checkRateLimit(ip); + + if (!rateLimit.allowed) { + return NextResponse.json( + { error: "Rate limit exceeded" }, + { status: 429, headers: { "Retry-After": String(rateLimit.retryAfter) } } + ); + } + + if (leaderboardCache && Date.now() < leaderboardCache.expiresAt) { + return NextResponse.json(leaderboardCache.payload); + } + + try { + const payload = await buildLeaderboard(); + leaderboardCache = { payload, expiresAt: Date.now() + CACHE_TTL_MS }; + return NextResponse.json(payload); + } catch { + return NextResponse.json( + { error: "Failed to build leaderboard" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/user/settings/route.ts b/src/app/api/user/settings/route.ts index dc016c0..5339265 100644 --- a/src/app/api/user/settings/route.ts +++ b/src/app/api/user/settings/route.ts @@ -1,7 +1,7 @@ import { getServerSession } from "next-auth"; import { NextRequest, NextResponse } from "next/server"; import { authOptions } from "@/lib/auth"; -import { supabaseAdmin, updateUserPublicFlag } from "@/lib/supabase"; +import { supabaseAdmin } from "@/lib/supabase"; export const dynamic = "force-dynamic"; @@ -15,7 +15,7 @@ export async function GET(req: NextRequest) { // Fetch user from Supabase const { data, error } = await supabaseAdmin .from("users") - .select("id, github_login, is_public") + .select("id, github_login, is_public, leaderboard_opt_in") .eq("github_id", session.githubId) .single(); @@ -53,7 +53,7 @@ export async function PATCH(req: NextRequest) { } // Parse request body - let body: { is_public?: boolean }; + let body: { is_public?: boolean; leaderboard_opt_in?: boolean }; try { body = await req.json(); } catch { @@ -63,19 +63,38 @@ export async function PATCH(req: NextRequest) { ); } - const { is_public } = body; + const { is_public, leaderboard_opt_in } = body; - if (typeof is_public !== "boolean") { + if ( + typeof is_public !== "boolean" && + typeof leaderboard_opt_in !== "boolean" + ) { return NextResponse.json( - { error: "is_public must be a boolean" }, + { error: "At least one boolean setting is required" }, { status: 400 } ); } - // Update user public flag - const updated = await updateUserPublicFlag(user.id, is_public); + const updates: { is_public?: boolean; leaderboard_opt_in?: boolean } = {}; + if (typeof is_public === "boolean") { + updates.is_public = is_public; + } + if (typeof leaderboard_opt_in === "boolean") { + updates.leaderboard_opt_in = leaderboard_opt_in; + if (leaderboard_opt_in) { + updates.is_public = true; + } + } + + const { data: updated, error: updateError } = await supabaseAdmin + .from("users") + .update(updates) + .eq("id", user.id) + .select("id, github_login, is_public, leaderboard_opt_in") + .single(); - if (!updated) { + if (updateError || !updated) { + console.error("Error updating settings:", updateError); return NextResponse.json( { error: "Failed to update settings" }, { status: 500 } @@ -87,5 +106,6 @@ export async function PATCH(req: NextRequest) { id: updated.id, github_login: updated.github_login, is_public: updated.is_public, + leaderboard_opt_in: updated.leaderboard_opt_in ?? false, }); } diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index e1dd740..ab68ccb 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -9,6 +9,7 @@ interface UserSettings { id: string; github_login: string; is_public: boolean; + leaderboard_opt_in: boolean; } interface LinkedAccount { @@ -204,6 +205,30 @@ function SettingsPageContent() { } }; + const handleToggleLeaderboard = async (value: boolean) => { + if (!settings) return; + + setSaving(true); + try { + const res = await fetch("/api/user/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ leaderboard_opt_in: value }), + }); + + if (res.ok) { + const updated = await res.json(); + setSettings(updated); + } else { + console.error("Failed to update leaderboard setting"); + } + } catch (error) { + console.error("Error updating leaderboard setting:", error); + } finally { + setSaving(false); + } + }; + const copyShareLink = () => { if (!settings) return; const link = `${window.location.origin}/u/${settings.github_login}`; @@ -324,7 +349,7 @@ function SettingsPageContent() { }`} />
@@ -400,6 +425,51 @@ function SettingsPageContent() { )}
+
+
+
+

+ Public Leaderboard +

+

+ Appear on the public leaderboard for streaks, commits, and pull + requests. +

+
+ +