From e5b2cbb43d7cf1c719a822c8c4c8cef79e397948 Mon Sep 17 00:00:00 2001 From: Aryanshravan Date: Wed, 27 May 2026 01:05:05 +0530 Subject: [PATCH 1/4] Fix: Add validation for LEADERBOARD_USER_CONCURRENCY environment variable --- .env.example | 8 ++++++ src/app/api/leaderboard/route.ts | 45 +++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 4b262154a..3b849ae01 100644 --- a/.env.example +++ b/.env.example @@ -60,3 +60,11 @@ UPSTASH_REDIS_REST_TOKEN=your_upstash_redis_rest_token # console.groq.com → API Keys GROQ_API_KEY=gsk_... +# ------------------------------------------------------- +# Leaderboard Configuration +# Controls concurrent user fetches during leaderboard builds +# Safe range: 1-100 (default: 5) +# Higher values = faster builds but more resource usage +# WARNING: Do not exceed 100 without load testing — risks memory exhaustion +LEADERBOARD_USER_CONCURRENCY=5 + diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index d2106f34e..912dd0edd 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -25,7 +25,50 @@ 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 USER_CONCURRENCY = Number(process.env.LEADERBOARD_USER_CONCURRENCY ?? 5); + +/** + * 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; + } + + // 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_CACHE_KEY = "leaderboard:v1"; const LEADERBOARD_BUILD_LOCK_KEY = "leaderboard:build-lock:v1"; From b54f249953eb6e57f09a26ee5e9df59c3d2884c0 Mon Sep 17 00:00:00 2001 From: Aryanshravan Date: Thu, 2 Jul 2026 13:15:37 +0530 Subject: [PATCH 2/4] feat(leaderboard): add advanced filters, sorting, and server-side pagination --- src/app/api/leaderboard/route.ts | 147 +++++++++++++------- src/app/leaderboard/page.tsx | 221 +++++++++++++++++++++++++++---- src/lib/leaderboard-query.ts | 128 ++++++++++++++++++ test/leaderboard-query.test.ts | 114 ++++++++++++++++ 4 files changed, 537 insertions(+), 73 deletions(-) create mode 100644 src/lib/leaderboard-query.ts create mode 100644 test/leaderboard-query.test.ts diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index 912dd0edd..2bb5f0e76 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -17,6 +17,17 @@ 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"; @@ -70,30 +81,26 @@ function validateUserConcurrency(value: string | undefined): number { const USER_CONCURRENCY = validateUserConcurrency(process.env.LEADERBOARD_USER_CONCURRENCY); -const LEADERBOARD_CACHE_KEY = "leaderboard:v1"; const LEADERBOARD_BUILD_LOCK_KEY = "leaderboard:build-lock:v1"; -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 { +interface LeaderboardCachePayload { generatedAt: string; refreshSeconds: number; + query: { + range: LeaderboardQuery["range"]; + scope: Scope; + }; + rows: LeaderboardEntry[]; +} + +interface LeaderboardPayload extends LeaderboardCachePayload { + items: LeaderboardEntry[]; + pagination: LeaderboardPagination; leaders: Record; } @@ -105,7 +112,10 @@ function getRateLimitKey(req: NextRequest): string { ); } -let memoryLeaderboardCache: LeaderboardCacheEntry | null = null; +const memoryLeaderboardCache = new Map< + string, + LeaderboardCacheEntry +>(); const memoryRateLimits = new Map(); function checkMemoryRateLimit(ip: string): { allowed: boolean; retryAfter?: number } { @@ -140,7 +150,7 @@ async function checkRateLimit( return checkMemoryRateLimit(ip); } -function isFresh(payload: LeaderboardPayload): boolean { +function isFresh(payload: LeaderboardCachePayload): boolean { const generatedAt = Date.parse(payload.generatedAt); if (!Number.isFinite(generatedAt)) { return false; @@ -248,7 +258,38 @@ async function fetchPrCount(username: string, since: string): Promise { return data?.total_count ?? 0; } -async function buildLeaderboard(): Promise { +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") @@ -262,8 +303,10 @@ async function buildLeaderboard(): Promise { } 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 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[]; @@ -272,9 +315,9 @@ async function buildLeaderboard(): Promise { 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), + fetchCommitStats(user.github_login, rangeStart), + fetchCommitStats(user.github_login, rangeStart), + fetchPrCount(user.github_login, rangeStart), ]); const streak = calculateCurrentStreak( @@ -296,24 +339,26 @@ async function buildLeaderboard(): Promise { } ); - 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 })); + 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, - leaders: { - streak: rankBy("streak"), - commits: rankBy("commits"), - prs: rankBy("prs"), + 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); @@ -326,32 +371,38 @@ export async function GET(req: NextRequest) { const bypass = isMetricsCacheBypassed(req); if (!bypass) { - memoryLeaderboardCache = pruneExpiredLeaderboardCache(memoryLeaderboardCache); - if (memoryLeaderboardCache && isFresh(memoryLeaderboardCache.payload)) { - return NextResponse.json(memoryLeaderboardCache.payload, { + 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(LEADERBOARD_CACHE_KEY); + const cached = await cacheGet(cacheKey); if (cached && isFresh(cached)) { - memoryLeaderboardCache = { + memoryLeaderboardCache.set(cacheKey, { payload: cached, expiresAt: Date.now() + CACHE_REFRESH_SECONDS * 1000, - }; - return NextResponse.json(cached); + }); + return NextResponse.json(formatLeaderboardResponse(cached, query)); } // Avoid thundering herd on cache misses across serverless instances. if (getUpstashConfig()) { const locked = await upstashTryAcquireLock({ - key: LEADERBOARD_BUILD_LOCK_KEY, + 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" }, }); } @@ -364,17 +415,17 @@ export async function GET(req: NextRequest) { } try { - const payload = await buildLeaderboard(); - await cacheSet(LEADERBOARD_CACHE_KEY, payload, CACHE_STALE_SECONDS); - memoryLeaderboardCache = { + 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(payload); + }); + return NextResponse.json(formatLeaderboardResponse(payload, query)); } catch { - const cached = await cacheGet(LEADERBOARD_CACHE_KEY); + const cached = await cacheGet(cacheKey); if (cached) { - return NextResponse.json(cached, { + return NextResponse.json(formatLeaderboardResponse(cached, query), { headers: { "x-devtrack-leaderboard-cache": "error-stale" }, }); } diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx index 7945bd752..23df02c6d 100644 --- a/src/app/leaderboard/page.tsx +++ b/src/app/leaderboard/page.tsx @@ -1,6 +1,8 @@ import Link from "next/link"; -type LeaderboardTab = "streak" | "commits" | "prs"; +type LeaderboardSort = "streak" | "commits" | "prs" | "score"; +type TimeRange = "7d" | "30d" | "90d" | "all"; +type Scope = "global" | "repositories" | "organizations"; interface LeaderboardEntry { rank: number; @@ -16,24 +18,73 @@ interface LeaderboardEntry { interface LeaderboardPayload { generatedAt: string; refreshSeconds: number; - leaders: Record; + query: { + range: TimeRange; + scope: Scope; + }; + items: LeaderboardEntry[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; } -const tabs: Array<{ id: LeaderboardTab; label: string; metric: string }> = [ +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: "this month" }, - { id: "prs", label: "PRs", metric: "this month" }, + { id: "commits", label: "Commits", metric: "in range" }, + { id: "prs", label: "PRs", metric: "in range" }, ]; -async function fetchLeaderboard(): Promise { +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" }, +]; + +const limits = [10, 25, 50]; + +type LeaderboardPageSearchParams = { + sort?: string; + range?: string; + scope?: string; + page?: string; + limit?: string; +}; + +function toQueryString(params: LeaderboardPageSearchParams): string { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value) { + query.set(key, value); + } + } + return query.toString(); +} + +function buildLeaderboardHref( + current: LeaderboardPageSearchParams, + updates: Partial +): string { + const nextParams: LeaderboardPageSearchParams = { ...current, ...updates }; + return `/leaderboard?${toQueryString(nextParams)}`; +} + +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); try { - const res = await fetch(`${baseUrl}/api/leaderboard`, { - next: { revalidate: 3600 }, + const res = await fetch(`${baseUrl}/api/leaderboard?${query}`, { + next: { revalidate: 300 }, }); if (!res.ok) { @@ -47,23 +98,50 @@ async function fetchLeaderboard(): Promise { } } -function getMetricValue(entry: LeaderboardEntry, tab: LeaderboardTab): number { - if (tab === "streak") return entry.streak; - if (tab === "commits") return entry.commits; +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: { tab?: string }; + searchParams: LeaderboardPageSearchParams; }) { - const activeTab = tabs.some((tab) => tab.id === searchParams.tab) - ? (searchParams.tab as LeaderboardTab) - : "streak"; - const leaderboard = await fetchLeaderboard(); - const activeMeta = tabs.find((tab) => tab.id === activeTab) ?? tabs[0]; - const rows = leaderboard?.leaders[activeTab] ?? []; + 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 (
@@ -80,8 +158,8 @@ export default async function LeaderboardPage({ Public Leaderboard

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

@@ -93,12 +171,15 @@ export default async function LeaderboardPage({
- {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
@@ -131,7 +268,7 @@ export default async function LeaderboardPage({ ) : ( rows.map((entry) => (
@@ -156,7 +293,7 @@ export default async function LeaderboardPage({
- {getMetricValue(entry, activeTab)} + {getMetricValue(entry, activeSort)}
{activeMeta.metric} @@ -177,6 +314,40 @@ export default async function LeaderboardPage({ )) )}
+ + {pagination && pagination.totalPages > 1 && ( +
+ {pagination.page > 1 ? ( + + Previous + + ) : ( + + Previous + + )} + + {pagination.page < pagination.totalPages ? ( + + Next + + ) : ( + + Next + + )} +
+ )}
); 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"); + }); +}); From 69f5b7e91c7ca78cdf6ef3098cbe7f7657c17d3b Mon Sep 17 00:00:00 2001 From: Aryanshravan Date: Thu, 2 Jul 2026 16:50:11 +0530 Subject: [PATCH 3/4] fix(leaderboard): update date utils import path --- src/app/api/leaderboard/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index 2bb5f0e76..35366131a 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { supabaseAdmin } from "@/lib/supabase"; -import { dateDiffDays, toDateStr } from "@/lib/dateUtils"; +import { dateDiffDays, toDateStr } from "@/lib/date-utils"; import { cacheGet, cacheSet, From 001e842537cb8544298448cd37fcfcdd292807a4 Mon Sep 17 00:00:00 2001 From: Aryanshravan Date: Fri, 3 Jul 2026 10:41:17 +0530 Subject: [PATCH 4/4] fix: streak badge now uses user's timezone instead of always UTC --- src/app/api/badge/streak-shield/route.ts | 28 +++++- test/streak-shield-timezone.test.ts | 112 +++++++++++++++++++++++ 2 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 test/streak-shield-timezone.test.ts 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/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