diff --git a/src/app/api/leaderboard/rebuild/route.ts b/src/app/api/leaderboard/rebuild/route.ts index 8f84afd8f..2097c84b5 100644 --- a/src/app/api/leaderboard/rebuild/route.ts +++ b/src/app/api/leaderboard/rebuild/route.ts @@ -1,8 +1,6 @@ // @ts-nocheck import { NextRequest, NextResponse } from "next/server"; -import { buildLeaderboard, setMemoryCachedLeaderboard, CACHE_STALE_SECONDS, LEADERBOARD_CACHE_KEY } from "@/lib/leaderboard"; -import { cacheSet } from "@/lib/metrics-cache"; -import { supabaseAdmin, isSupabaseAdminAvailable } from "@/lib/supabase"; +import { refreshAllLeaderboardCaches } from "@/lib/leaderboard"; export async function POST(req: NextRequest) { const token = req.headers.get("x-devtrack-rebuild-token") ?? req.nextUrl.searchParams.get("token"); @@ -12,31 +10,12 @@ export async function POST(req: NextRequest) { } try { - const payload = await buildLeaderboard(); - await cacheSet(LEADERBOARD_CACHE_KEY, payload, CACHE_STALE_SECONDS); - setMemoryCachedLeaderboard(payload); - - if (isSupabaseAdminAvailable) { - try { - const now = new Date().toISOString(); - const expiresAt = new Date(Date.now() + CACHE_STALE_SECONDS * 1000).toISOString(); - await supabaseAdmin.from("leaderboard_cache").upsert( - { - key: LEADERBOARD_CACHE_KEY, - payload, - generated_at: now, - expires_at: expiresAt, - building_until: null, - updated_at: now, - }, - { onConflict: "key" } - ); - } catch (err) { - console.warn("[Leaderboard] Failed to persist cache to Supabase during rebuild:", err); - } - } - - return NextResponse.json({ ok: true, generatedAt: payload.generatedAt }); + const payloads = await refreshAllLeaderboardCaches(); + return NextResponse.json({ + ok: true, + generatedAt: payloads.weekly.generatedAt, + timeframes: Object.keys(payloads), + }); } catch (err) { console.error("[Leaderboard] Rebuild failed:", err); return NextResponse.json({ error: "Rebuild failed" }, { status: 500 }); diff --git a/src/app/api/leaderboard/refresh/route.ts b/src/app/api/leaderboard/refresh/route.ts index 85a396bde..9c34fcddc 100644 --- a/src/app/api/leaderboard/refresh/route.ts +++ b/src/app/api/leaderboard/refresh/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { refreshLeaderboardCache } from "@/lib/leaderboard"; +import { refreshAllLeaderboardCaches } from "@/lib/leaderboard"; export const dynamic = "force-dynamic"; @@ -16,8 +16,12 @@ export async function GET(req: Request) { } try { - const payload = await refreshLeaderboardCache(); - return NextResponse.json({ success: true, generatedAt: payload.generatedAt }); + const payloads = await refreshAllLeaderboardCaches(); + return NextResponse.json({ + success: true, + generatedAt: payloads.weekly.generatedAt, + timeframes: Object.keys(payloads), + }); } catch (err) { return NextResponse.json({ error: "Failed to refresh leaderboard cache" }, { status: 500 }); } diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index a85063a12..2b0b2ae62 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -6,9 +6,10 @@ import { getLeaderboardData, isFresh, LEADERBOARD_BUILD_LOCK_KEY, + normalizeLeaderboardTimeframe, type LeaderboardPayload, - type LeaderboardPeriod, filterLeaderboardByLanguage, + type LeaderboardTimeframe, } from "@/lib/leaderboard"; import { pruneExpiredRateLimits, @@ -75,19 +76,32 @@ function normalizeLanguage(value: string | null): string | undefined { return normalized || undefined; } -function normalizePeriod(value: string | null): LeaderboardPeriod { - if (value === "week" || value === "month" || value === "all") { - return value; +function normalizeTimeframe( + timeframe: string | null, + period: string | null +): LeaderboardTimeframe | null { + const normalizedTimeframe = normalizeLeaderboardTimeframe(timeframe); + if (timeframe !== null && normalizedTimeframe === null) { + return null; } - return "all"; + if (normalizedTimeframe) { + return normalizedTimeframe; + } + + const normalizedPeriod = normalizeLeaderboardTimeframe(period); + if (period !== null && normalizedPeriod === null) { + return null; + } + + return normalizedPeriod ?? "weekly"; } function getLanguageCacheKey(filters: { language: string; - period: LeaderboardPeriod; + timeframe: LeaderboardTimeframe; }): string { - return `${getBaseLeaderboardCacheKey(filters.period)}:${filters.language}`; + return `${getBaseLeaderboardCacheKey(filters.timeframe)}:${filters.language}`; } function getLeaderboardBuildLockCacheKey(cacheKey: string): string { @@ -98,10 +112,21 @@ export async function GET(req: NextRequest) { 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 timeframe = normalizeTimeframe( + req.nextUrl.searchParams.get("timeframe"), + req.nextUrl.searchParams.get("period") + ); + + if (!timeframe) { + return NextResponse.json( + { error: "Invalid timeframe. Supported values are weekly, monthly, and all_time." }, + { status: 400 } + ); + } + const cacheKey = language - ? getLanguageCacheKey({ language, period }) - : getBaseLeaderboardCacheKey(period); + ? getLanguageCacheKey({ language, timeframe }) + : getBaseLeaderboardCacheKey(timeframe); if (!rateLimit.allowed) { return NextResponse.json( @@ -145,7 +170,7 @@ export async function GET(req: NextRequest) { } try { - const baseLeaderboard = await getLeaderboardData(bypass, { period }); + const baseLeaderboard = await getLeaderboardData(bypass, { timeframe }); if (!baseLeaderboard) { const stale = await cacheGet(cacheKey); diff --git a/src/app/leaderboard/LeaderboardSkeleton.tsx b/src/app/leaderboard/LeaderboardSkeleton.tsx index 888128ba0..ab08b204c 100644 --- a/src/app/leaderboard/LeaderboardSkeleton.tsx +++ b/src/app/leaderboard/LeaderboardSkeleton.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Skeleton } from "@/components/ui/skeleton"; const ROWS = 8; diff --git a/src/app/leaderboard/loading.tsx b/src/app/leaderboard/loading.tsx index 5100cd6de..d3d3c7b97 100644 --- a/src/app/leaderboard/loading.tsx +++ b/src/app/leaderboard/loading.tsx @@ -17,7 +17,7 @@ export default function Loading() { - {/* Tabs Skeleton */} + {/* Metric Tabs Skeleton */}
{[1, 2, 3].map((i) => (
@@ -25,9 +25,9 @@ export default function Loading() {
{/* Filters Skeleton */} -
-
-
+
+
+
{/* Table Skeleton */} diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx index b78b24560..74849088e 100644 --- a/src/app/leaderboard/page.tsx +++ b/src/app/leaderboard/page.tsx @@ -1,27 +1,16 @@ 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"; +import LeaderboardBrowser from "@/components/leaderboard/LeaderboardBrowser"; +import { + getLeaderboardData, + filterLeaderboardByLanguage, + normalizeLeaderboardTimeframe, + type LeaderboardPayload, + type LeaderboardTimeframe, +} from "@/lib/leaderboard"; type LeaderboardTab = "streak" | "commits" | "prs"; -type LeaderboardPeriod = "week" | "month" | "all"; - -interface LeaderboardEntry { - id: string; - rank: number; - username: string; - avatarUrl: string; - profileUrl: string; - streak: number; - commits: number; - prs: number; - score: number; - isSponsor: boolean; -} const tabs: Array<{ id: LeaderboardTab; label: string; metric: string }> = [ { id: "streak", label: "Streak", metric: "days" }, @@ -29,19 +18,9 @@ const tabs: Array<{ id: LeaderboardTab; label: string; metric: string }> = [ { id: "prs", label: "PRs", metric: "pull requests" }, ]; -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"; -} - function leaderboardHref( tab: LeaderboardTab, - filters: { lang?: string; period: LeaderboardPeriod } + filters: { lang?: string; timeframe: LeaderboardTimeframe } ): string { const params = new URLSearchParams({ tab }); @@ -49,136 +28,35 @@ function leaderboardHref( params.set("lang", filters.lang); } - if (filters.period !== "all") { - params.set("period", filters.period); + if (filters.timeframe !== "weekly") { + params.set("timeframe", filters.timeframe); } return `/leaderboard?${params.toString()}`; } -function getMetricValue(entry: LeaderboardEntry, tab: LeaderboardTab): number { - if (tab === "streak") return entry.streak; - if (tab === "commits") return entry.commits; - return entry.prs; -} - -// 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"; - - let leaderboard = await getLeaderboardData(false, { period: filters.period }); - if (leaderboard && filters.lang) { - leaderboard = await filterLeaderboardByLanguage(leaderboard, filters.lang); - } - - const activeMeta = tabs.find((tab) => tab.id === activeTab) ?? tabs[0]; - const rows = leaderboard?.leaders[activeTab] ?? []; - const metricLabel = activeTab === "streak" ? activeMeta.metric : periods[filters.period]; - - 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 - -
-
- ))} - - )} -
- - ); -} - export default async function LeaderboardPage({ searchParams, }: { - searchParams: Promise<{ tab?: string; lang?: string; period?: string }>; + searchParams: Promise<{ tab?: string; lang?: string; timeframe?: string; period?: string }>; }) { 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 timeframe = + normalizeLeaderboardTimeframe(resolvedSearchParams.timeframe) ?? + normalizeLeaderboardTimeframe(resolvedSearchParams.period) ?? + "weekly"; + const filters = { lang: resolvedSearchParams.lang, timeframe }; + + let leaderboard: LeaderboardPayload | null = await getLeaderboardData(false, { + timeframe, + }); + + if (leaderboard && resolvedSearchParams.lang) { + leaderboard = await filterLeaderboardByLanguage(leaderboard, resolvedSearchParams.lang); + } return (
@@ -218,9 +96,12 @@ export default async function LeaderboardPage({ - }> - - +
); diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx index cd1479ed9..ed6d6c63d 100644 --- a/src/components/EmptyState.tsx +++ b/src/components/EmptyState.tsx @@ -1,3 +1,5 @@ +"use client"; + import Link from "next/link"; interface EmptyStateProps { diff --git a/src/components/SponsorBadge.tsx b/src/components/SponsorBadge.tsx index 4bba117f3..b492f7c29 100644 --- a/src/components/SponsorBadge.tsx +++ b/src/components/SponsorBadge.tsx @@ -1,3 +1,5 @@ +"use client"; + export default function SponsorBadge({ className = "" }: { className?: string }) { return ( = [ + { value: "weekly", label: "Weekly" }, + { value: "monthly", label: "Monthly" }, + { value: "all_time", label: "All-Time" }, +]; + +const timeframeLabels: Record = { + weekly: "this week", + monthly: "this month", + all_time: "all time", +}; + +function getMetricValue(entry: LeaderboardEntry, tab: "streak" | "commits" | "prs"): number { + if (tab === "streak") return entry.streak; + if (tab === "commits") return entry.commits; + return entry.prs; +} + +function syncTimeframeToUrl(timeframe: LeaderboardTimeframe): void { + if (typeof window === "undefined") { + return; + } + + const url = new URL(window.location.href); + if (timeframe === "weekly") { + url.searchParams.delete("timeframe"); + } else { + url.searchParams.set("timeframe", timeframe); + } + + const nextUrl = `${url.pathname}${url.searchParams.toString() ? `?${url.searchParams.toString()}` : ""}${url.hash}`; + window.history.replaceState(null, "", nextUrl); +} + +export default function LeaderboardBrowser({ + activeTab, + language, + initialLeaderboard, + initialTimeframe, +}: { + activeTab: "streak" | "commits" | "prs"; + language?: string; + initialLeaderboard: LeaderboardPayload | null; + initialTimeframe: LeaderboardTimeframe; +}) { + const [timeframe, setTimeframe] = useState(initialTimeframe); + const [leaderboard, setLeaderboard] = useState(initialLeaderboard); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const requestIdRef = useRef(0); + + useEffect(() => { + setTimeframe(initialTimeframe); + setLeaderboard(initialLeaderboard); + setLoading(false); + setError(null); + requestIdRef.current += 1; + }, [initialTimeframe, initialLeaderboard, language]); + + async function loadTimeframe(nextTimeframe: LeaderboardTimeframe, force = false) { + if (!force && nextTimeframe === timeframe) { + return; + } + + const requestId = ++requestIdRef.current; + setTimeframe(nextTimeframe); + setLoading(true); + setError(null); + syncTimeframeToUrl(nextTimeframe); + + try { + const params = new URLSearchParams({ timeframe: nextTimeframe }); + if (language) { + params.set("lang", language); + } + + const response = await fetch(`/api/leaderboard?${params.toString()}`, { + headers: { Accept: "application/json" }, + }); + const payload = (await response.json()) as LeaderboardPayload & { error?: string }; + + if (!response.ok) { + throw new Error(payload.error ?? "Failed to load leaderboard"); + } + + if (requestIdRef.current === requestId) { + setLeaderboard(payload); + } + } catch { + if (requestIdRef.current === requestId) { + setLeaderboard(null); + setError("Leaderboard data is temporarily unavailable."); + } + } finally { + if (requestIdRef.current === requestId) { + setLoading(false); + } + } + } + + const rows = leaderboard?.leaders[activeTab] ?? []; + const hasFilters = Boolean(language) || timeframe !== "weekly"; + const metricLabel = activeTab === "streak" ? "days" : timeframeLabels[timeframe]; + + return ( +
+
+ loadTimeframe(value as LeaderboardTimeframe)} + > + + {timeframeOptions.map((item) => ( + + {item.label} + + ))} + + +
+ + {error ? ( +
+
+

{error}

+ +
+
+ ) : loading ? ( + + ) : !leaderboard ? ( +
+
+

Leaderboard data is temporarily unavailable.

+ +
+
+ ) : rows.length === 0 ? ( + + ) : ( + <> +
+ Updated {new Date(leaderboard.generatedAt).toLocaleString()} +
+ +
+
+
Rank
+
Contributor
+
{activeTab === "streak" ? "Streak" : activeTab === "commits" ? "Commits" : "PRs"}
+
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 + +
+
+ ))} +
+ + )} +
+ ); +} diff --git a/src/components/leaderboard/LeaderboardFilters.tsx b/src/components/leaderboard/LeaderboardFilters.tsx index 2a2516f78..441417161 100644 --- a/src/components/leaderboard/LeaderboardFilters.tsx +++ b/src/components/leaderboard/LeaderboardFilters.tsx @@ -3,8 +3,6 @@ import { useEffect, useMemo } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -type Period = "week" | "month" | "all"; - const STORAGE_KEY = "leaderboard-filters"; const languages = [ @@ -25,35 +23,21 @@ const languages = [ { label: "Shell", value: "shell" }, ]; -const periods: Array<{ label: string; value: Period }> = [ - { label: "This Week", value: "week" }, - { label: "This Month", value: "month" }, - { label: "All Time", value: "all" }, -]; - -function isPeriod(value: string | null): value is Period { - return value === "week" || value === "month" || value === "all"; -} - export default function LeaderboardFilters() { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const language = searchParams.get("lang") ?? ""; - const rawPeriod = searchParams.get("period"); - const period: Period = isPeriod(rawPeriod) ? rawPeriod : "all"; - - const hasFilters = searchParams.has("lang") || searchParams.has("period"); + const hasFilters = searchParams.has("lang"); const currentFilters = useMemo( - () => ({ lang: language, period }), - [language, period] + () => ({ lang: language }), + [language] ); useEffect(() => { - const hasUrlFilters = - searchParams.has("lang") || searchParams.has("period"); + const hasUrlFilters = searchParams.has("lang"); if (!hasUrlFilters) { const stored = window.localStorage.getItem(STORAGE_KEY); @@ -62,21 +46,13 @@ export default function LeaderboardFilters() { } try { - const parsed = JSON.parse(stored) as { - lang?: string; - period?: string; - }; + const parsed = JSON.parse(stored) as { lang?: string }; const nextParams = new URLSearchParams(searchParams.toString()); if (parsed.lang) { nextParams.set("lang", parsed.lang); } - const storedPeriod = parsed.period ?? null; - if (isPeriod(storedPeriod) && storedPeriod !== "all") { - nextParams.set("period", storedPeriod); - } - const query = nextParams.toString(); if (query) { router.replace(`${pathname}?${query}`, { scroll: false }); @@ -90,7 +66,7 @@ export default function LeaderboardFilters() { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(currentFilters)); }, [currentFilters, pathname, router, searchParams]); - function updateFilters(next: Partial<{ lang: string; period: Period }>) { + function updateFilters(next: Partial<{ lang: string }>) { const params = new URLSearchParams(searchParams.toString()); if (next.lang !== undefined) { @@ -101,14 +77,6 @@ export default function LeaderboardFilters() { } } - if (next.period !== undefined) { - if (next.period === "all") { - params.delete("period"); - } else { - params.set("period", next.period); - } - } - const query = params.toString(); router.push(query ? `${pathname}?${query}` : pathname, { scroll: false }); } @@ -116,7 +84,6 @@ export default function LeaderboardFilters() { function clearFilters() { const params = new URLSearchParams(searchParams.toString()); params.delete("lang"); - params.delete("period"); window.localStorage.removeItem(STORAGE_KEY); const query = params.toString(); @@ -142,30 +109,6 @@ export default function LeaderboardFilters() { -
- Time range -
- {periods.map((item) => { - const active = item.value === period; - - return ( - - ); - })} -
-
-