This commit is contained in:
2025-04-03 22:15:54 +02:00
parent d00b5e28b6
commit d60d5fe5af
8 changed files with 520 additions and 14 deletions

View File

@@ -22,7 +22,7 @@ RUN SKIP_ENV_VALIDATION=1 bun run build
##### RUNNER
FROM --platform=linux/amd64 gcr.io/distroless/nodejs20-debian12 AS runner
FROM --platform=linux/amd64 imbios/bun-node:latest-current-debian AS runner
WORKDIR /app
ENV NODE_ENV production

View File

@@ -34,6 +34,7 @@
"@radix-ui/react-tooltip": "^1.1.8",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.69.0",
"@tanstack/react-virtual": "^3.13.6",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
@@ -391,6 +392,10 @@
"@tanstack/react-query": ["@tanstack/react-query@5.71.5", "", { "dependencies": { "@tanstack/query-core": "5.71.5" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-WpxZWy4fDASjY+iAaXB+aY+LC95PQ34W6EWVkjJ0hdzWWbczFnr9nHvHkVDpwdR18I1NO8igNGQJFrLrgyzI8Q=="],
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.6", "", { "dependencies": { "@tanstack/virtual-core": "3.13.6" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WT7nWs8ximoQ0CDx/ngoFP7HbQF9Q2wQe4nh2NB+u2486eX3nZRE40P9g6ccCVq7ZfTSH5gFOuCoVH5DLNS/aA=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.6", "", {}, "sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg=="],
"@trpc/client": ["@trpc/client@11.0.1", "", { "peerDependencies": { "@trpc/server": "11.0.1", "typescript": ">=5.7.2" } }, "sha512-HvOrvWAXbGBwt4om+NfuxrK4f+ik2aaNIXq7WLrJbEp7U+YXfh5++1a5p4JDaikrvSaObJ389DhYAGWz90xSGw=="],
"@trpc/react-query": ["@trpc/react-query@11.0.1", "", { "peerDependencies": { "@tanstack/react-query": "^5.67.1", "@trpc/client": "11.0.1", "@trpc/server": "11.0.1", "react": ">=18.2.0", "react-dom": ">=18.2.0", "typescript": ">=5.7.2" } }, "sha512-astHk2cchLjyXx4cScUZj4zOM5um10zdX4ExVUllBI+kU5sUTbztigDZCGDz8WbdsOiSzYHL4KINdERaQrXWAQ=="],

View File

@@ -49,6 +49,7 @@
"@radix-ui/react-tooltip": "^1.1.8",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.69.0",
"@tanstack/react-virtual": "^3.13.6",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",

View File

@@ -0,0 +1,485 @@
'use client'
import type React from 'react'
import { useRef } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { cn } from '@/lib/utils'
import { RANKED_CHANNEL, VANILLA_CHANNEL } from '@/shared/constants'
import { api } from '@/trpc/react'
import { useVirtualizer } from '@tanstack/react-virtual'
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
Flame,
Info,
Medal,
Search,
TrendingUp,
Trophy,
Users,
} from 'lucide-react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
export function LeaderboardPage() {
const router = useRouter()
const searchParams = useSearchParams()
// Get the leaderboard type from URL or default to 'ranked'
const leaderboardType = searchParams.get('type') || 'ranked'
// State for search and sorting
const [searchQuery, setSearchQuery] = useState('')
const [sortColumn, setSortColumn] = useState('rank')
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
// Fetch leaderboard data
const [rankedLeaderboard] = api.leaderboard.get_leaderboard.useSuspenseQuery({
channel_id: RANKED_CHANNEL,
})
const [vanillaLeaderboard] = api.leaderboard.get_leaderboard.useSuspenseQuery(
{
channel_id: VANILLA_CHANNEL,
}
)
// Handle tab change
const handleTabChange = (value: string) => {
const params = new URLSearchParams(searchParams)
params.set('type', value)
router.push(`?${params.toString()}`)
}
// Get the current leaderboard based on selected tab
const currentLeaderboard =
leaderboardType === 'ranked'
? rankedLeaderboard.alltime
: vanillaLeaderboard.alltime
// Filter leaderboard by search query
const filteredLeaderboard = currentLeaderboard.filter((entry) =>
entry.name.toLowerCase().includes(searchQuery.toLowerCase())
)
// Sort leaderboard
const sortedLeaderboard = [...filteredLeaderboard].sort((a, b) => {
// biome-ignore lint/style/useSingleVarDeclarator: <explanation>
// biome-ignore lint/suspicious/noImplicitAnyLet: <explanation>
let valueA, valueB
// Handle special case for rank which is already sorted
if (sortColumn === 'rank') {
valueA = a.data.rank
valueB = b.data.rank
} else if (sortColumn === 'name') {
valueA = a.name.toLowerCase()
valueB = b.name.toLowerCase()
return sortDirection === 'asc'
? valueA.localeCompare(valueB)
: valueB.localeCompare(valueA)
} else {
valueA = a.data[sortColumn as keyof typeof a.data] as number
valueB = b.data[sortColumn as keyof typeof b.data] as number
}
return sortDirection === 'asc' ? valueA - valueB : valueB - valueA
})
// Handle column sort
const handleSort = (column: string) => {
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
} else {
setSortColumn(column)
setSortDirection('asc')
}
}
// Get medal for top 3 players
const getMedal = (rank: number) => {
if (rank === 1) return <Medal className='h-5 w-5 text-yellow-500' />
if (rank === 2) return <Medal className='h-5 w-5 text-slate-400' />
if (rank === 3) return <Medal className='h-5 w-5 text-amber-700' />
return null
}
console.log(currentLeaderboard)
return (
<div className='min-h-screen bg-gradient-to-b from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900'>
<div className='container mx-auto px-4 py-8'>
<Card className='overflow-hidden border-none bg-white py-0 shadow-lg dark:bg-slate-900'>
<CardHeader className='bg-gradient-to-r from-violet-500 to-purple-600 p-6'>
<div className='flex flex-col items-center justify-between gap-4 md:flex-row'>
<div>
<h1 className='flex items-center gap-2 font-bold text-3xl text-white'>
<Trophy className='h-7 w-7' />
Leaderboards
</h1>
<p className='mt-1 text-violet-200'>
View player rankings and statistics
</p>
</div>
<div className='flex items-center gap-3'>
<Badge
variant='secondary'
className='bg-white/20 text-white hover:bg-white/30'
>
<Users className='mr-1 h-3 w-3' />
{currentLeaderboard.length} Players
</Badge>
<Button
variant='secondary'
size='sm'
className='bg-white/20 text-white hover:bg-white/30'
>
<Info className='mr-1 h-4 w-4' />
How Rankings Work
</Button>
</div>
</div>
</CardHeader>
<CardContent className='p-0'>
<Tabs
defaultValue={leaderboardType}
value={leaderboardType}
onValueChange={handleTabChange}
className='p-6'
>
<div className='mb-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center'>
<TabsList className='bg-slate-100 dark:bg-slate-800'>
<TabsTrigger value='ranked'>Ranked Leaderboard</TabsTrigger>
<TabsTrigger value='vanilla'>Vanilla Leaderboard</TabsTrigger>
</TabsList>
<div className='relative w-full sm:w-auto'>
<Search className='absolute top-2.5 left-2.5 h-4 w-4 text-slate-400' />
<Input
placeholder='Search players...'
className='w-full pl-9 sm:w-[250px]'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<TabsContent value='ranked' className='m-0'>
<LeaderboardTable
leaderboard={sortedLeaderboard}
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={handleSort}
getMedal={getMedal}
type='ranked'
/>
</TabsContent>
<TabsContent value='vanilla' className='m-0'>
<LeaderboardTable
leaderboard={sortedLeaderboard}
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={handleSort}
getMedal={getMedal}
type='vanilla'
/>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
</div>
)
}
interface LeaderboardTableProps {
leaderboard: any[]
sortColumn: string
sortDirection: 'asc' | 'desc'
onSort: (column: string) => void
getMedal: (rank: number) => React.ReactNode
type: string
}
function LeaderboardTable({
leaderboard,
sortColumn,
sortDirection,
onSort,
getMedal,
type,
}: LeaderboardTableProps) {
const tableContainerRef = useRef<HTMLDivElement>(null)
// Set a fixed row height for virtualization
const ROW_HEIGHT = 56 // Adjust based on your actual row height
// Create virtualizer instance
const rowVirtualizer = useVirtualizer({
count: leaderboard.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 10, // Number of items to render before/after the visible area
})
// Get the virtualized rows
const virtualRows = rowVirtualizer.getVirtualItems()
const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start : 0
const paddingBottom =
virtualRows.length > 0
? rowVirtualizer.getTotalSize() -
(virtualRows?.[virtualRows.length - 1]?.end ?? 0)
: 0
return (
<div className='overflow-hidden rounded-lg border'>
<div
ref={tableContainerRef}
className='overflow-auto'
style={{ height: '500px', maxHeight: '70vh' }}
>
<Table>
<TableHeader className='sticky top-0 z-10 bg-white dark:bg-slate-900'>
<TableRow className='bg-slate-50 dark:bg-slate-800/50'>
<TableHead className='w-[80px]'>
<SortableHeader
column='rank'
label='Rank'
currentSort={sortColumn}
direction={sortDirection}
onSort={onSort}
/>
</TableHead>
<TableHead>
<SortableHeader
column='name'
label='Player'
currentSort={sortColumn}
direction={sortDirection}
onSort={onSort}
/>
</TableHead>
<TableHead className='text-right'>
<SortableHeader
column='mmr'
label='MMR'
currentSort={sortColumn}
direction={sortDirection}
onSort={onSort}
/>
</TableHead>
<TableHead className='text-right'>
<SortableHeader
column='peak_mmr'
label='Peak MMR'
currentSort={sortColumn}
direction={sortDirection}
onSort={onSort}
/>
</TableHead>
<TableHead className='text-right'>
<SortableHeader
column='winrate'
label='Win Rate'
currentSort={sortColumn}
direction={sortDirection}
onSort={onSort}
/>
</TableHead>
<TableHead className='text-right'>
<SortableHeader
column='wins'
label='Wins'
currentSort={sortColumn}
direction={sortDirection}
onSort={onSort}
/>
</TableHead>
<TableHead className='text-right'>
<SortableHeader
column='losses'
label='Losses'
currentSort={sortColumn}
direction={sortDirection}
onSort={onSort}
/>
</TableHead>
<TableHead className='text-right'>
<SortableHeader
column='totalgames'
label='Games'
currentSort={sortColumn}
direction={sortDirection}
onSort={onSort}
/>
</TableHead>
<TableHead className='text-right'>
<SortableHeader
column='streak'
label='Streak'
currentSort={sortColumn}
direction={sortDirection}
onSort={onSort}
/>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{leaderboard.length > 0 ? (
leaderboard.map((entry) => {
const winrate = entry.data.winrate * 100
return (
<TableRow
key={entry.id}
className={cn(
'transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/70',
entry.data.rank <= 3 &&
'bg-amber-50/50 dark:bg-amber-950/20'
)}
>
<TableCell className='font-medium'>
<div className='flex items-center gap-1.5'>
{getMedal(entry.data.rank)}
<span>{entry.data.rank}</span>
</div>
</TableCell>
<TableCell>
<Link
href={`/players/${entry.id}`}
className='flex items-center gap-2 hover:underline'
>
{/*<Avatar className='h-8 w-8'>*/}
{/* <AvatarImage*/}
{/* src={`https://cdn.discordapp.com/avatars/${entry.id}/avatar.png`}*/}
{/* alt={entry.name}*/}
{/* />*/}
{/* <AvatarFallback className='bg-violet-100 text-violet-700 text-xs dark:bg-violet-900 dark:text-violet-300'>*/}
{/* {entry.name.slice(0, 2).toUpperCase()}*/}
{/* </AvatarFallback>*/}
{/*</Avatar>*/}
<span className='font-medium'>{entry.name}</span>
{entry.data.streak >= 3 && (
<Badge className='bg-orange-500 text-white'>
<Flame className='mr-1 h-3 w-3' />
Hot Streak
</Badge>
)}
</Link>
</TableCell>
<TableCell className='text-right font-medium'>
{Math.round(entry.data.mmr)}
</TableCell>
<TableCell className='text-right'>
<div className='flex items-center justify-end gap-1'>
<TrendingUp className='h-3.5 w-3.5 text-violet-500' />
{Math.round(entry.data.peak_mmr)}
</div>
</TableCell>
<TableCell className='text-right'>
<Badge
variant='outline'
className={cn(
'font-normal',
winrate > 60
? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300'
: winrate < 40
? 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-800 dark:bg-rose-950 dark:text-rose-300'
: 'border-slate-200 bg-slate-50 text-slate-700 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300'
)}
>
{Math.round(winrate)}%
</Badge>
</TableCell>
<TableCell className='text-right text-emerald-600 dark:text-emerald-400'>
{entry.data.wins}
</TableCell>
<TableCell className='text-right text-rose-600 dark:text-rose-400'>
{entry.data.losses}
</TableCell>
<TableCell className='text-right text-slate-600 dark:text-slate-400'>
{entry.data.totalgames}
</TableCell>
<TableCell className='text-right'>
{entry.data.streak > 0 ? (
<span className='flex items-center justify-end text-emerald-600 dark:text-emerald-400'>
<ArrowUp className='mr-1 h-3.5 w-3.5' />
{entry.data.streak}
</span>
) : entry.data.streak < 0 ? (
<span className='flex items-center justify-end text-rose-600 dark:text-rose-400'>
<ArrowDown className='mr-1 h-3.5 w-3.5' />
{Math.abs(entry.data.streak)}
</span>
) : (
<span>0</span>
)}
</TableCell>
</TableRow>
)
})
) : (
<TableRow>
<TableCell colSpan={9} className='h-24 text-center'>
No players found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
)
}
interface SortableHeaderProps {
column: string
label: string
currentSort: string
direction: 'asc' | 'desc'
onSort: (column: string) => void
}
function SortableHeader({
column,
label,
currentSort,
direction,
onSort,
}: SortableHeaderProps) {
const isActive = currentSort === column
return (
<button
className='flex items-center gap-1 transition-colors hover:text-violet-600 dark:hover:text-violet-400'
onClick={() => onSort(column)}
>
{label}
{isActive ? (
direction === 'asc' ? (
<ArrowUp className='h-3.5 w-3.5' />
) : (
<ArrowDown className='h-3.5 w-3.5' />
)
) : (
<ArrowUpDown className='h-3.5 w-3.5 opacity-50' />
)}
</button>
)
}

View File

@@ -1,10 +1,20 @@
import { LeaderboardPage } from '@/app/_components/leaderboard'
import { UserStats } from '@/app/_components/user-stats'
import { auth } from '@/server/auth'
import { HydrateClient } from '@/trpc/server'
import { RANKED_CHANNEL, VANILLA_CHANNEL } from '@/shared/constants'
import { HydrateClient, api } from '@/trpc/server'
import { Suspense } from 'react'
export default async function Home() {
const session = await auth()
await Promise.all([
api.leaderboard.get_leaderboard.prefetch({
channel_id: RANKED_CHANNEL,
}),
api.leaderboard.get_leaderboard.prefetch({
channel_id: VANILLA_CHANNEL,
}),
])
if (session?.user) {
console.log('user', session.user)
// void api.post.getLatest.prefetch()
@@ -12,7 +22,10 @@ export default async function Home() {
return (
<HydrateClient>
<UserStats />
<Suspense>
<UserStats />
<LeaderboardPage />
</Suspense>
</HydrateClient>
)
}

View File

@@ -186,8 +186,8 @@ export function UserInfo() {
>
<Star className='mr-1 h-3 w-3' />
Vanilla Queue:{' '}
{isNonNullish(vanillaLeaderboard?.rank)
? `#${vanillaLeaderboard.rank}`
{isNonNullish(vanillaUserRank?.rank)
? `#${vanillaUserRank.rank}`
: 'N/A'}
</Badge>
)}
@@ -451,7 +451,7 @@ export function UserInfo() {
{(rankedLeaderboard || lastGameLeaderboard1) && (
<LeaderboardStatsCard
title='Leaderboard 1 Stats'
rank={rankedLeaderboard?.rank}
rank={rankedUserRank?.rank}
mmr={
lastGameLeaderboard1
? Math.trunc(
@@ -468,7 +468,7 @@ export function UserInfo() {
{(vanillaLeaderboard || lastGameLeaderboard2) && (
<LeaderboardStatsCard
title='Leaderboard 2 Stats'
rank={vanillaLeaderboard?.rank}
rank={vanillaUserRank?.rank}
mmr={
lastGameLeaderboard2
? Math.trunc(

View File

@@ -107,8 +107,5 @@ export async function insertGameHistory(entries: any[]) {
return processGameEntry(id, game_num, entry)
})
await db.insert(player_games).values(playerGameRows).onConflictDoUpdate({
target: player_games.gameNum,
set: {},
})
await db.insert(player_games).values(playerGameRows).onConflictDoNothing()
}

View File

@@ -1,6 +1,9 @@
import { createTRPCRouter, publicProcedure } from '@/server/api/trpc'
import { LeaderboardService } from '@/server/services/leaderboard'
import { neatqueue_service } from '@/server/services/neatqueue.service'
import {
type LeaderboardResponse,
neatqueue_service,
} from '@/server/services/neatqueue.service'
import { z } from 'zod'
const service = new LeaderboardService()
@@ -12,7 +15,9 @@ export const leaderboard_router = createTRPCRouter({
})
)
.query(async ({ input }) => {
return await service.getLeaderboard(input.channel_id)
return (await service.getLeaderboard(
input.channel_id
)) as LeaderboardResponse
}),
get_user_rank: publicProcedure
.input(