From 409316776637c03f6c9dc6136c52a8863968f450 Mon Sep 17 00:00:00 2001 From: Isaac Onyemaechi Date: Thu, 29 Jan 2026 23:17:47 +0100 Subject: [PATCH 1/4] feat: implement leaderboard api layer --- app/api/leaderboard/route.ts | 29 +++++++++ app/api/leaderboard/top/route.ts | 15 +++++ app/api/leaderboard/user/[userId]/route.ts | 26 ++++++++ hooks/use-leaderboard.ts | 55 ++++++++++++++++ lib/api/leaderboard.ts | 35 ++++++++++ lib/mock-leaderboard.ts | 74 ++++++++++++++++++++++ types/bounty.ts | 2 +- types/leaderboard.ts | 59 +++++++++++++++++ 8 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 app/api/leaderboard/route.ts create mode 100644 app/api/leaderboard/top/route.ts create mode 100644 app/api/leaderboard/user/[userId]/route.ts create mode 100644 hooks/use-leaderboard.ts create mode 100644 lib/api/leaderboard.ts create mode 100644 lib/mock-leaderboard.ts create mode 100644 types/leaderboard.ts diff --git a/app/api/leaderboard/route.ts b/app/api/leaderboard/route.ts new file mode 100644 index 0000000..9b47e64 --- /dev/null +++ b/app/api/leaderboard/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; +import { getMockLeaderboard } from '@/lib/mock-leaderboard'; +import { LeaderboardResponse, ReputationTier } from '@/types/leaderboard'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const limit = parseInt(searchParams.get('limit') || '10'); + const tier = searchParams.get('tier') as ReputationTier | null; + + // Simulate network delay + await new Promise(resolve => setTimeout(resolve, 300)); + + const { data, total } = getMockLeaderboard(page, limit, tier || undefined); + + const response: LeaderboardResponse = { + entries: data.map((contributor, index) => ({ + rank: (page - 1) * limit + index + 1, + previousRank: null, // Mock data doesn't track history yet + rankChange: 0, + contributor, + })), + totalCount: total, + currentUserRank: null, // Only relevant if user context is provided + lastUpdatedAt: new Date().toISOString(), + }; + + return NextResponse.json(response); +} diff --git a/app/api/leaderboard/top/route.ts b/app/api/leaderboard/top/route.ts new file mode 100644 index 0000000..5aa70b1 --- /dev/null +++ b/app/api/leaderboard/top/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; +import { getMockLeaderboard } from '@/lib/mock-leaderboard'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const count = parseInt(searchParams.get('count') || '5'); + + // Simulate network delay + await new Promise(resolve => setTimeout(resolve, 200)); + + // Get top N contributors + const { data } = getMockLeaderboard(1, count); + + return NextResponse.json(data); +} diff --git a/app/api/leaderboard/user/[userId]/route.ts b/app/api/leaderboard/user/[userId]/route.ts new file mode 100644 index 0000000..1386335 --- /dev/null +++ b/app/api/leaderboard/user/[userId]/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server'; +import { getMockUserRank } from '@/lib/mock-leaderboard'; + +interface Params { + params: Promise<{ userId: string }>; +} + +export async function GET(request: Request, { params }: Params) { + // Await params as per Next.js 15+ requirements if applicable, or good practice for future + const { userId } = await params; + + // Simulate network delay + await new Promise(resolve => setTimeout(resolve, 200)); + + const result = getMockUserRank(userId); + + if (!result) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + return NextResponse.json({ + rank: result.rank, + contributor: result.contributor, + paramsUserId: userId + }); +} diff --git a/hooks/use-leaderboard.ts b/hooks/use-leaderboard.ts new file mode 100644 index 0000000..37e689d --- /dev/null +++ b/hooks/use-leaderboard.ts @@ -0,0 +1,55 @@ +import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; +import { leaderboardApi } from '@/lib/api/leaderboard'; +import { LeaderboardFilters } from '@/types/leaderboard'; + +export const LEADERBOARD_KEYS = { + all: ['leaderboard'] as const, + lists: () => [...LEADERBOARD_KEYS.all, 'list'] as const, + list: (filters: LeaderboardFilters) => [...LEADERBOARD_KEYS.lists(), filters] as const, + user: (userId: string) => [...LEADERBOARD_KEYS.all, 'user', userId] as const, + top: (count: number) => [...LEADERBOARD_KEYS.all, 'top', count] as const, +}; + +export const useLeaderboard = (filters: LeaderboardFilters, limit: number = 20) => { + return useInfiniteQuery({ + queryKey: LEADERBOARD_KEYS.list(filters), + queryFn: ({ pageParam = 1 }) => + leaderboardApi.fetchLeaderboard(filters, { page: pageParam, limit }), + getNextPageParam: (lastPage, allPages) => { + const loadedCount = allPages.flatMap(p => p.entries).length; + if (loadedCount < lastPage.totalCount) { + return allPages.length + 1; + } + return undefined; + }, + initialPageParam: 1, + staleTime: 1000 * 60 * 5, // 5 minutes + }); +}; + +export const useUserRank = (userId?: string) => { + return useQuery({ + queryKey: LEADERBOARD_KEYS.user(userId || ''), + queryFn: () => leaderboardApi.fetchUserRank(userId!), + enabled: !!userId, + }); +}; + +export const useTopContributors = (count: number = 5) => { + return useQuery({ + queryKey: LEADERBOARD_KEYS.top(count), + queryFn: () => leaderboardApi.fetchTopContributors(count), + staleTime: 1000 * 60 * 15, // 15 minutes + }); +}; + +export const usePrefetchLeaderboardPage = () => { + const queryClient = useQueryClient(); + + return (filters: LeaderboardFilters, page: number, limit: number) => { + queryClient.prefetchQuery({ + queryKey: LEADERBOARD_KEYS.list(filters), + queryFn: () => leaderboardApi.fetchLeaderboard(filters, { page, limit }), + }); + }; +}; diff --git a/lib/api/leaderboard.ts b/lib/api/leaderboard.ts new file mode 100644 index 0000000..fb64492 --- /dev/null +++ b/lib/api/leaderboard.ts @@ -0,0 +1,35 @@ +import { get } from './client'; +import { + LeaderboardResponse, + LeaderboardFilters, + LeaderboardPagination, + LeaderboardContributor +} from '@/types/leaderboard'; + +const LEADERBOARD_ENDPOINT = '/api/leaderboard'; + +export const leaderboardApi = { + fetchLeaderboard: async ( + filters: LeaderboardFilters, + pagination: LeaderboardPagination + ): Promise => { + const params: Record = { + page: pagination.page, + limit: pagination.limit, + timeframe: filters.timeframe, + }; + + if (filters.tier) params.tier = filters.tier; + if (filters.tags?.length) params.tags = filters.tags.join(','); + + return get(LEADERBOARD_ENDPOINT, { params }); + }, + + fetchUserRank: async (userId: string): Promise<{ rank: number, contributor: LeaderboardContributor }> => { + return get<{ rank: number, contributor: LeaderboardContributor }>(`${LEADERBOARD_ENDPOINT}/user/${userId}`); + }, + + fetchTopContributors: async (count: number = 5): Promise => { + return get(`${LEADERBOARD_ENDPOINT}/top`, { params: { count } }); + } +}; diff --git a/lib/mock-leaderboard.ts b/lib/mock-leaderboard.ts new file mode 100644 index 0000000..7c93149 --- /dev/null +++ b/lib/mock-leaderboard.ts @@ -0,0 +1,74 @@ +import { LeaderboardContributor, ReputationTier } from "@/types/leaderboard"; + +const generateMockContributor = ( + id: string, + rank: number, + tier: ReputationTier, + score: number +): LeaderboardContributor => ({ + id: `contributor-${id}`, + userId: `user-${id}`, + walletAddress: `0x${Math.random().toString(16).substr(2, 40)}`, + displayName: `Contributor ${id}`, + avatarUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=${id}`, + totalScore: score, + tier, + stats: { + totalCompleted: Math.floor(Math.random() * 50) + 1, + totalEarnings: Math.floor(Math.random() * 10000) + 100, + earningsCurrency: "USDC", + completionRate: 0.85 + Math.random() * 0.15, + averageCompletionTime: Math.floor(Math.random() * 72) + 24, + currentStreak: Math.floor(Math.random() * 5), + longestStreak: Math.floor(Math.random() * 10) + 5, + }, + topTags: ["DeFi", "Smart Contracts", "Frontend", "Auditing"].sort(() => 0.5 - Math.random()).slice(0, 3), + lastActiveAt: new Date(Date.now() - Math.floor(Math.random() * 7) * 24 * 60 * 60 * 1000).toISOString(), +}); + +export const mockLeaderboardData: LeaderboardContributor[] = [ + generateMockContributor("1", 1, "LEGEND", 15000), + generateMockContributor("2", 2, "LEGEND", 14500), + generateMockContributor("3", 3, "EXPERT", 12000), + generateMockContributor("4", 4, "EXPERT", 11500), + generateMockContributor("5", 5, "ESTABLISHED", 9000), + generateMockContributor("6", 6, "ESTABLISHED", 8500), + generateMockContributor("7", 7, "CONTRIBUTOR", 5000), + generateMockContributor("8", 8, "CONTRIBUTOR", 4500), + generateMockContributor("9", 9, "NEWCOMER", 1000), + generateMockContributor("10", 10, "NEWCOMER", 800), + // Generate some more for pagination testing + ...Array.from({ length: 40 }).map((_, i) => + generateMockContributor(`${i + 11}`, i + 11, "NEWCOMER", 500 - i * 10) + ) +]; + +export const getMockLeaderboard = ( + page: number = 1, + limit: number = 10, + filterTier?: ReputationTier +) => { + let filtered = [...mockLeaderboardData]; + + if (filterTier) { + filtered = filtered.filter(c => c.tier === filterTier); + } + + const sorted = filtered.sort((a, b) => b.totalScore - a.totalScore); + const start = (page - 1) * limit; + const paginated = sorted.slice(start, start + limit); + + return { + data: paginated, + total: filtered.length + }; +}; + +export const getMockUserRank = (userId: string) => { + const index = mockLeaderboardData.findIndex(u => u.userId === userId); + if (index === -1) return null; + return { + rank: index + 1, + contributor: mockLeaderboardData[index] + }; +}; diff --git a/types/bounty.ts b/types/bounty.ts index 23b70ac..9e367ee 100644 --- a/types/bounty.ts +++ b/types/bounty.ts @@ -52,7 +52,7 @@ export interface Bounty { // Let's add them as optional to be safe and backward compatible with existing components. requirements?: string[] scope?: string - milestones?: any[] // Optional milestone definition + milestones?: unknown[] // Optional milestone definition } export type BountyStatus = Bounty['status'] diff --git a/types/leaderboard.ts b/types/leaderboard.ts new file mode 100644 index 0000000..0a4118c --- /dev/null +++ b/types/leaderboard.ts @@ -0,0 +1,59 @@ +export type ReputationTier = + | 'NEWCOMER' + | 'CONTRIBUTOR' + | 'ESTABLISHED' + | 'EXPERT' + | 'LEGEND'; + +export type LeaderboardTimeframe = + | 'ALL_TIME' + | 'THIS_MONTH' + | 'THIS_WEEK'; + +export interface ContributorStats { + totalCompleted: number; + totalEarnings: number; + earningsCurrency: string; + completionRate: number; + averageCompletionTime: number; // in hours + currentStreak: number; // consecutive days/weeks depending on logic, usually completion streak + longestStreak: number; +} + +export interface LeaderboardContributor { + id: string; + userId: string; + walletAddress: string | null; + displayName: string; + avatarUrl: string | null; + totalScore: number; + tier: ReputationTier; + stats: ContributorStats; + topTags: string[]; + lastActiveAt: string; // ISO Date string +} + +export interface LeaderboardEntry { + rank: number; + previousRank: number | null; + rankChange: number | null; + contributor: LeaderboardContributor; +} + +export interface LeaderboardResponse { + entries: LeaderboardEntry[]; + totalCount: number; + currentUserRank: number | null; + lastUpdatedAt: string; // ISO Date string +} + +export interface LeaderboardFilters { + timeframe: LeaderboardTimeframe; + tier?: ReputationTier; + tags?: string[]; +} + +export interface LeaderboardPagination { + page: number; + limit: number; +} From 7669ca5f9cc25bdb5c1e65fb41f33796629a7f8c Mon Sep 17 00:00:00 2001 From: Isaac Onyemaechi Date: Thu, 29 Jan 2026 23:21:53 +0100 Subject: [PATCH 2/4] fix --- app/api/bounties/[id]/milestones/advance/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/bounties/[id]/milestones/advance/route.ts b/app/api/bounties/[id]/milestones/advance/route.ts index e9229b3..d37a0fb 100644 --- a/app/api/bounties/[id]/milestones/advance/route.ts +++ b/app/api/bounties/[id]/milestones/advance/route.ts @@ -33,7 +33,7 @@ export async function POST( return NextResponse.json({ error: 'Bounty not found' }, { status: 404 }); } - let updates: Partial = { + const updates: Partial = { lastUpdatedAt: new Date().toISOString() }; From 2ff9dec68638b7aac1aa6b0d90828f8afac8f40b Mon Sep 17 00:00:00 2001 From: Isaac Onyemaechi Date: Thu, 29 Jan 2026 23:24:51 +0100 Subject: [PATCH 3/4] fix --- hooks/use-leaderboard.ts | 5 +++-- lib/api/leaderboard.ts | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/hooks/use-leaderboard.ts b/hooks/use-leaderboard.ts index 37e689d..6d63db0 100644 --- a/hooks/use-leaderboard.ts +++ b/hooks/use-leaderboard.ts @@ -47,9 +47,10 @@ export const usePrefetchLeaderboardPage = () => { const queryClient = useQueryClient(); return (filters: LeaderboardFilters, page: number, limit: number) => { - queryClient.prefetchQuery({ + queryClient.prefetchInfiniteQuery({ queryKey: LEADERBOARD_KEYS.list(filters), - queryFn: () => leaderboardApi.fetchLeaderboard(filters, { page, limit }), + queryFn: ({ pageParam }) => leaderboardApi.fetchLeaderboard(filters, { page: pageParam as number, limit }), + initialPageParam: 1, }); }; }; diff --git a/lib/api/leaderboard.ts b/lib/api/leaderboard.ts index fb64492..51e17df 100644 --- a/lib/api/leaderboard.ts +++ b/lib/api/leaderboard.ts @@ -16,7 +16,6 @@ export const leaderboardApi = { const params: Record = { page: pagination.page, limit: pagination.limit, - timeframe: filters.timeframe, }; if (filters.tier) params.tier = filters.tier; From a7e5b4f3e2902bb272b449e9c7e3ffa9d135645e Mon Sep 17 00:00:00 2001 From: Isaac Onyemaechi Date: Fri, 30 Jan 2026 01:18:47 +0100 Subject: [PATCH 4/4] feat: Add infinite scroll pagination logic to `useLeaderboard` hook. --- hooks/use-leaderboard.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/hooks/use-leaderboard.ts b/hooks/use-leaderboard.ts index 6d63db0..2532507 100644 --- a/hooks/use-leaderboard.ts +++ b/hooks/use-leaderboard.ts @@ -51,6 +51,14 @@ export const usePrefetchLeaderboardPage = () => { queryKey: LEADERBOARD_KEYS.list(filters), queryFn: ({ pageParam }) => leaderboardApi.fetchLeaderboard(filters, { page: pageParam as number, limit }), initialPageParam: 1, + getNextPageParam: (lastPage, allPages) => { + const loadedCount = allPages.flatMap(p => p.entries).length; + if (loadedCount < lastPage.totalCount) { + return allPages.length + 1; + } + return undefined; + }, + pages: page // Prefetch up to this many pages if needed }); }; };