adjust styling and store users profiles according to the leaderboard

This commit is contained in:
2025-04-04 15:29:09 +02:00
parent 04b3e68214
commit 5944a5418d
3 changed files with 168 additions and 182 deletions

View File

@@ -124,7 +124,7 @@ export function LeaderboardPage() {
return ( return (
<div className='flex h-screen flex-col overflow-hidden bg-gradient-to-b from-gray-50 to-gray-100 dark:from-zinc-900 dark:to-zinc-950'> <div className='flex h-screen flex-col overflow-hidden bg-gradient-to-b from-gray-50 to-gray-100 dark:from-zinc-900 dark:to-zinc-950'>
<div className='container mx-auto flex flex-1 flex-col'> <div className='container mx-auto flex flex-1 flex-col'>
<div className='flex flex-1 flex-col overflow-hidden border-none bg-white p-0 shadow-lg dark:bg-zinc-900'> <div className='flex flex-1 flex-col overflow-hidden border-none dark:bg-zinc-900'>
<div className='border-gray-200 border-b bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900'> <div className='border-gray-200 border-b bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900'>
<div className='flex flex-col items-center justify-between gap-4 md:flex-row'> <div className='flex flex-col items-center justify-between gap-4 md:flex-row'>
<div> <div>
@@ -147,16 +147,6 @@ export function LeaderboardPage() {
{currentLeaderboard.length} Players {currentLeaderboard.length} Players
</span> </span>
</Badge> </Badge>
<Button
variant='outline'
size='sm'
className='border-gray-200 dark:border-zinc-700'
>
<Info className='mr-1 h-4 w-4 text-gray-500 dark:text-zinc-400' />
<span className='text-gray-700 dark:text-zinc-300'>
How Rankings Work
</span>
</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -25,11 +25,12 @@ import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
Filter, Filter,
IceCreamCone,
MinusCircle, MinusCircle,
ShieldHalf,
Star, Star,
Trophy, Trophy,
} from 'lucide-react' } from 'lucide-react'
import { useFormatter } from 'next-intl'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { isNonNullish } from 'remeda' import { isNonNullish } from 'remeda'
@@ -71,6 +72,7 @@ export function UserInfo() {
channel_id: RANKED_CHANNEL, channel_id: RANKED_CHANNEL,
user_id: id, user_id: id,
}) })
console.log({ vanillaUserRank, rankedUserRank })
// Filter games by leaderboard if needed // Filter games by leaderboard if needed
const filteredGamesByLeaderboard = const filteredGamesByLeaderboard =
@@ -116,10 +118,10 @@ export function UserInfo() {
const firstGame = games.at(-1) const firstGame = games.at(-1)
// Get last games for each leaderboard // Get last games for each leaderboard
const lastGameLeaderboard1 = games const lastRankedGame = games
.filter((game) => game.gameType === 'ranked') .filter((game) => game.gameType === 'ranked')
.at(0) .at(0)
const lastGameLeaderboard2 = games const lastVanillaGame = games
.filter((game) => game.gameType.toLowerCase() === 'vanilla') .filter((game) => game.gameType.toLowerCase() === 'vanilla')
.at(0) .at(0)
@@ -183,110 +185,112 @@ export function UserInfo() {
)} )}
</div> </div>
</div> </div>
<div
<div className='flex flex-1 justify-end'> className={cn(
<div className='flex gap-3'> 'grid w-full flex-grow grid-cols-2 divide-gray-100 md:w-auto md:grid-cols-3 md:divide-y-0 dark:divide-zinc-800',
{lastGameLeaderboard1 && ( isNonNullish(rankedUserRank?.mmr) && 'lg:grid-cols-5',
<div className='hidden rounded-lg border border-gray-200 bg-gray-50 p-3 md:block dark:border-zinc-700 dark:bg-zinc-800'> isNonNullish(vanillaUserRank?.mmr) && 'lg:grid-cols-5',
<div className='font-medium text-gray-500 text-sm dark:text-zinc-400'> isNonNullish(rankedUserRank?.mmr) &&
Ranked Queue MMR isNonNullish(vanillaUserRank?.mmr) &&
</div> 'lg:grid-cols-6'
<div className='font-bold text-2xl text-gray-900 dark:text-white'> )}
{Math.trunc( >
lastGameLeaderboard1.playerMmr + <StatsCard
lastGameLeaderboard1.mmrChange title='Games'
)} value={profileData.games}
</div> icon={<BarChart3 className='h-5 w-5 text-violet-500' />}
<div className='text-gray-500 text-xs dark:text-zinc-400'> description='Total matches'
{lastGameLeaderboard1.mmrChange > 0 ? ( />
<span className='flex items-center text-emerald-500'> <StatsCard
title='Wins'
value={profileData.wins}
icon={<ArrowUpCircle className='h-5 w-5 text-emerald-500' />}
description={`${profileData.winRate}% win rate`}
accentColor='text-emerald-500'
/>
<StatsCard
title='Losses'
value={profileData.losses}
icon={<ArrowDownCircle className='h-5 w-5 text-rose-500' />}
description={`${profileData.games > 0 ? Math.round((profileData.losses / profileData.games) * 100) : 0}% loss rate`}
accentColor='text-rose-500'
/>
<StatsCard
title='Ties'
value={profileData.ties}
icon={<MinusCircle className='h-5 w-5 text-amber-500' />}
description={`${profileData.games > 0 ? Math.round((profileData.ties / profileData.games) * 100) : 0}% tie rate`}
accentColor='text-amber-500'
/>
{isNonNullish(rankedUserRank?.mmr) && (
<StatsCard
title='Ranked MMR'
value={Math.round(rankedUserRank.mmr)}
description={
lastRankedGame ? (
<span
className={cn(
'flex items-center',
lastRankedGame.mmrChange > 0
? 'text-emerald-500'
: 'text-rose-500'
)}
>
{lastRankedGame.mmrChange > 0 ? (
<ChevronUp className='h-3 w-3' /> <ChevronUp className='h-3 w-3' />
{numberFormatter.format( ) : (
Math.trunc(lastGameLeaderboard1.mmrChange)
)}{' '}
last match
</span>
) : (
<span className='flex items-center text-rose-500'>
<ChevronDown className='h-3 w-3' /> <ChevronDown className='h-3 w-3' />
{numberFormatter.format( )}
Math.trunc(lastGameLeaderboard1.mmrChange) {numberFormatter.format(
)}{' '} Math.trunc(lastRankedGame.mmrChange)
last match )}{' '}
</span> last match
)} </span>
</div> ) : null
</div> }
)} icon={
<ShieldHalf className='h-5 w-5 text-zink-800 dark:text-zink-200' />
{lastGameLeaderboard2 && ( }
<div className='hidden rounded-lg border border-gray-200 bg-gray-50 p-3 md:block dark:border-zinc-700 dark:bg-zinc-800'> accentColor='text-zink-800 dark:text-zink-200'
<div className='font-medium text-gray-500 text-sm dark:text-zinc-400'> />
Vanilla Queue MMR )}
</div> {isNonNullish(vanillaUserRank?.mmr) && (
<div className='font-bold text-2xl text-gray-900 dark:text-white'> <StatsCard
{Math.trunc( title='Vanilla MMR'
lastGameLeaderboard2.playerMmr + value={Math.round(vanillaUserRank.mmr)}
lastGameLeaderboard2.mmrChange icon={
)} <IceCreamCone className='h-5 w-5 text-zink-800 dark:text-zink-200' />
</div> }
<div className='text-gray-500 text-xs dark:text-zinc-400'> accentColor='text-zink-800 dark:text-zink-200'
{lastGameLeaderboard2.mmrChange > 0 ? ( description={
<span className='flex items-center text-emerald-500'> lastVanillaGame ? (
<span
className={cn(
'flex items-center',
lastVanillaGame.mmrChange > 0
? 'text-emerald-500'
: 'text-rose-500'
)}
>
{lastVanillaGame.mmrChange > 0 ? (
<ChevronUp className='h-3 w-3' /> <ChevronUp className='h-3 w-3' />
{numberFormatter.format( ) : (
Math.trunc(lastGameLeaderboard2.mmrChange)
)}{' '}
last match
</span>
) : (
<span className='flex items-center text-rose-500'>
<ChevronDown className='h-3 w-3' /> <ChevronDown className='h-3 w-3' />
{numberFormatter.format( )}
Math.trunc(lastGameLeaderboard2.mmrChange) {numberFormatter.format(
)}{' '} Math.trunc(lastVanillaGame.mmrChange)
last match )}{' '}
</span> last match
)} </span>
</div> ) : null
</div> }
)} />
</div> )}
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className='p-0'> <CardContent className='p-0'>
<div className='grid grid-cols-2 divide-x divide-y divide-gray-100 md:grid-cols-4 md:divide-y-0 dark:divide-zinc-800'>
<StatsCard
title='Games'
value={profileData.games}
icon={<BarChart3 className='h-5 w-5 text-violet-500' />}
description='Total matches'
/>
<StatsCard
title='Wins'
value={profileData.wins}
icon={<ArrowUpCircle className='h-5 w-5 text-emerald-500' />}
description={`${profileData.winRate}% win rate`}
accentColor='text-emerald-500'
/>
<StatsCard
title='Losses'
value={profileData.losses}
icon={<ArrowDownCircle className='h-5 w-5 text-rose-500' />}
description={`${profileData.games > 0 ? Math.round((profileData.losses / profileData.games) * 100) : 0}% loss rate`}
accentColor='text-rose-500'
/>
<StatsCard
title='Ties'
value={profileData.ties}
icon={<MinusCircle className='h-5 w-5 text-amber-500' />}
description={`${profileData.games > 0 ? Math.round((profileData.ties / profileData.games) * 100) : 0}% tie rate`}
accentColor='text-amber-500'
/>
</div>
<Tabs defaultValue='matches' className='p-6'> <Tabs defaultValue='matches' className='p-6'>
<div className='mb-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center'> <div className='mb-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center'>
<TabsList className='bg-gray-100 dark:bg-zinc-800'> <TabsList className='bg-gray-100 dark:bg-zinc-800'>
@@ -338,15 +342,15 @@ export function UserInfo() {
<TabsContent value='stats' className='m-0'> <TabsContent value='stats' className='m-0'>
<div className='grid grid-cols-1 gap-6 md:grid-cols-2'> <div className='grid grid-cols-1 gap-6 md:grid-cols-2'>
{(rankedLeaderboard || lastGameLeaderboard1) && ( {(rankedLeaderboard || lastRankedGame) && (
<LeaderboardStatsCard <LeaderboardStatsCard
title='Ranked Queue Stats' title='Ranked Queue Stats'
rank={rankedUserRank?.rank} rank={rankedUserRank?.rank}
mmr={ mmr={
lastGameLeaderboard1 lastRankedGame
? Math.trunc( ? Math.trunc(
lastGameLeaderboard1.playerMmr + lastRankedGame.playerMmr +
lastGameLeaderboard1.mmrChange lastRankedGame.mmrChange
) )
: undefined : undefined
} }
@@ -355,15 +359,15 @@ export function UserInfo() {
/> />
)} )}
{(vanillaLeaderboard || lastGameLeaderboard2) && ( {(vanillaLeaderboard || lastVanillaGame) && (
<LeaderboardStatsCard <LeaderboardStatsCard
title='Vanilla Queue Stats' title='Vanilla Queue Stats'
rank={vanillaUserRank?.rank} rank={vanillaUserRank?.rank}
mmr={ mmr={
lastGameLeaderboard2 lastVanillaGame
? Math.trunc( ? Math.trunc(
lastGameLeaderboard2.playerMmr + lastVanillaGame.playerMmr +
lastGameLeaderboard2.mmrChange lastVanillaGame.mmrChange
) )
: undefined : undefined
} }
@@ -374,8 +378,8 @@ export function UserInfo() {
{!rankedLeaderboard && {!rankedLeaderboard &&
!vanillaLeaderboard && !vanillaLeaderboard &&
!lastGameLeaderboard1 && !lastRankedGame &&
!lastGameLeaderboard2 && ( !lastVanillaGame && (
<div className='col-span-2 flex h-40 items-center justify-center rounded-lg border bg-gray-50 dark:bg-zinc-800/50'> <div className='col-span-2 flex h-40 items-center justify-center rounded-lg border bg-gray-50 dark:bg-zinc-800/50'>
<p className='text-gray-500 dark:text-zinc-400'> <p className='text-gray-500 dark:text-zinc-400'>
No leaderboard data available No leaderboard data available
@@ -404,7 +408,7 @@ interface StatsCardProps {
title: string title: string
value: number value: number
icon: React.ReactNode icon: React.ReactNode
description: string description: React.ReactNode
accentColor?: string accentColor?: string
} }
@@ -416,12 +420,14 @@ function StatsCard({
accentColor = 'text-violet-500', accentColor = 'text-violet-500',
}: StatsCardProps) { }: StatsCardProps) {
return ( return (
<div className='flex flex-col items-center p-6 text-center'> <div className='flex w-fit flex-col items-start justify-self-center p-2 text-center md:justify-self-auto'>
<div className='mb-2 flex items-center justify-center'>{icon}</div> <h3 className='mb-1 text-nowrap font-medium text-gray-500 text-sm dark:text-zinc-400'>
<h3 className='mb-1 font-medium text-gray-500 text-sm dark:text-zinc-400'>
{title} {title}
</h3> </h3>
<p className={cn('font-bold text-3xl', accentColor)}>{value}</p> <div className={'flex items-center gap-2'}>
<div className='flex items-center justify-center'>{icon}</div>
<p className={cn('font-bold text-3xl', accentColor)}>{value}</p>
</div>
<p className='mt-1 text-gray-500 text-xs dark:text-zinc-400'> <p className='mt-1 text-gray-500 text-xs dark:text-zinc-400'>
{description} {description}
</p> </p>

View File

@@ -1,5 +1,6 @@
import { redis } from '../redis' import { redis } from '../redis'
import { neatqueue_service } from './neatqueue.service' import { neatqueue_service } from './neatqueue.service'
export class LeaderboardService { export class LeaderboardService {
private getZSetKey(channel_id: string) { private getZSetKey(channel_id: string) {
return `zset:leaderboard:${channel_id}` return `zset:leaderboard:${channel_id}`
@@ -9,79 +10,68 @@ export class LeaderboardService {
return `raw:leaderboard:${channel_id}` return `raw:leaderboard:${channel_id}`
} }
private getUserKey(user_id: string, channel_id: string) {
return `user:${user_id}:${channel_id}`
}
async refreshLeaderboard(channel_id: string) { async refreshLeaderboard(channel_id: string) {
const fresh = await neatqueue_service.get_leaderboard(channel_id) try {
const zsetKey = this.getZSetKey(channel_id) const fresh = await neatqueue_service.get_leaderboard(channel_id)
const rawKey = this.getRawKey(channel_id) const zsetKey = this.getZSetKey(channel_id)
const rawKey = this.getRawKey(channel_id)
// store raw data for full queries const pipeline = redis.pipeline()
await redis.setex(rawKey, 180, JSON.stringify(fresh)) pipeline.setex(rawKey, 180, JSON.stringify(fresh))
pipeline.del(zsetKey)
// store sorted set for rank queries for (const entry of fresh) {
const pipeline = redis.pipeline() pipeline.zadd(zsetKey, entry.mmr, entry.id)
pipeline.del(zsetKey) // clear existing pipeline.hset(this.getUserKey(entry.id, channel_id), {
...entry,
channel_id,
})
}
for (const entry of fresh) { pipeline.expire(zsetKey, 180)
// store by mmr for ranking await pipeline.exec()
pipeline.zadd(zsetKey, entry.rank, entry.id)
// store user data separately for quick lookups return fresh
pipeline.hset(`user:${entry.id}`, entry) } catch (error) {
console.error('Error refreshing leaderboard:', error)
throw error
} }
pipeline.expire(zsetKey, 180)
await pipeline.exec()
} }
async getLeaderboard(channel_id: string) { async getLeaderboard(channel_id: string) {
const cached = await redis.get(this.getRawKey(channel_id)) try {
if (cached) return JSON.parse(cached) const cached = await redis.get(this.getRawKey(channel_id))
if (cached) return JSON.parse(cached)
// if not cached, refresh and return return await this.refreshLeaderboard(channel_id)
await this.refreshLeaderboard(channel_id) } catch (error) {
// @ts-ignore console.error('Error getting leaderboard:', error)
return redis.get(this.getRawKey(channel_id)).then(JSON.parse) throw error
}
async getUserRank(channel_id: string, user_id: string) {
const zsetKey = this.getZSetKey(channel_id)
// zrevrank because higher mmr = better rank
const rank = await redis.zrevrank(zsetKey, user_id)
if (rank === null) return null
// get user data
const userData = await redis.hgetall(`user:${user_id}`)
if (!userData) return null
return {
rank: rank + 1, // zero-based -> one-based
...userData,
} }
} }
// get users around a specific rank async getUserRank(channel_id: string, user_id: string) {
async getRankRange(channel_id: string, rank: number, range = 5) { try {
const zsetKey = this.getZSetKey(channel_id) const zsetKey = this.getZSetKey(channel_id)
const rank = await redis.zrevrank(zsetKey, user_id)
// get ids if (rank === null) return null
const ids = await redis.zrevrange(
zsetKey,
Math.max(0, rank - range),
rank + range
)
// get data for each id const userData = await redis.hgetall(this.getUserKey(user_id, channel_id))
const pipeline = redis.pipeline() if (!userData) return null
// biome-ignore lint/complexity/noForEach: <explanation>
ids.forEach((id) => pipeline.hgetall(`user:${id}`))
const results = await pipeline.exec() return {
return ids.map((id, i) => ({ rank: rank + 1,
id, ...userData,
rank: rank - range + i + 1, mmr: Number(userData.mmr),
// @ts-ignore }
...results[i][1], } catch (error) {
})) console.error('Error getting user rank:', error)
throw error
}
} }
} }