diff --git a/src/app/_components/leaderboard.tsx b/src/app/_components/leaderboard.tsx index c22e876..7ed49f3 100644 --- a/src/app/_components/leaderboard.tsx +++ b/src/app/_components/leaderboard.tsx @@ -124,7 +124,7 @@ export function LeaderboardPage() { return (
-
+
@@ -147,16 +147,6 @@ export function LeaderboardPage() { {currentLeaderboard.length} Players -
diff --git a/src/app/players/[id]/user.tsx b/src/app/players/[id]/user.tsx index 85170c9..48648dd 100644 --- a/src/app/players/[id]/user.tsx +++ b/src/app/players/[id]/user.tsx @@ -25,11 +25,12 @@ import { ChevronDown, ChevronUp, Filter, + IceCreamCone, MinusCircle, + ShieldHalf, Star, Trophy, } from 'lucide-react' -import { useFormatter } from 'next-intl' import { useParams } from 'next/navigation' import { isNonNullish } from 'remeda' @@ -71,6 +72,7 @@ export function UserInfo() { channel_id: RANKED_CHANNEL, user_id: id, }) + console.log({ vanillaUserRank, rankedUserRank }) // Filter games by leaderboard if needed const filteredGamesByLeaderboard = @@ -116,10 +118,10 @@ export function UserInfo() { const firstGame = games.at(-1) // Get last games for each leaderboard - const lastGameLeaderboard1 = games + const lastRankedGame = games .filter((game) => game.gameType === 'ranked') .at(0) - const lastGameLeaderboard2 = games + const lastVanillaGame = games .filter((game) => game.gameType.toLowerCase() === 'vanilla') .at(0) @@ -183,110 +185,112 @@ export function UserInfo() { )}
- -
-
- {lastGameLeaderboard1 && ( -
-
- Ranked Queue MMR -
-
- {Math.trunc( - lastGameLeaderboard1.playerMmr + - lastGameLeaderboard1.mmrChange - )} -
-
- {lastGameLeaderboard1.mmrChange > 0 ? ( - +
+ } + description='Total matches' + /> + } + description={`${profileData.winRate}% win rate`} + accentColor='text-emerald-500' + /> + } + description={`${profileData.games > 0 ? Math.round((profileData.losses / profileData.games) * 100) : 0}% loss rate`} + accentColor='text-rose-500' + /> + } + description={`${profileData.games > 0 ? Math.round((profileData.ties / profileData.games) * 100) : 0}% tie rate`} + accentColor='text-amber-500' + /> + {isNonNullish(rankedUserRank?.mmr) && ( + 0 + ? 'text-emerald-500' + : 'text-rose-500' + )} + > + {lastRankedGame.mmrChange > 0 ? ( - {numberFormatter.format( - Math.trunc(lastGameLeaderboard1.mmrChange) - )}{' '} - last match - - ) : ( - + ) : ( - {numberFormatter.format( - Math.trunc(lastGameLeaderboard1.mmrChange) - )}{' '} - last match - - )} -
-
- )} - - {lastGameLeaderboard2 && ( -
-
- Vanilla Queue MMR -
-
- {Math.trunc( - lastGameLeaderboard2.playerMmr + - lastGameLeaderboard2.mmrChange - )} -
-
- {lastGameLeaderboard2.mmrChange > 0 ? ( - + )} + {numberFormatter.format( + Math.trunc(lastRankedGame.mmrChange) + )}{' '} + last match + + ) : null + } + icon={ + + } + accentColor='text-zink-800 dark:text-zink-200' + /> + )} + {isNonNullish(vanillaUserRank?.mmr) && ( + + } + accentColor='text-zink-800 dark:text-zink-200' + description={ + lastVanillaGame ? ( + 0 + ? 'text-emerald-500' + : 'text-rose-500' + )} + > + {lastVanillaGame.mmrChange > 0 ? ( - {numberFormatter.format( - Math.trunc(lastGameLeaderboard2.mmrChange) - )}{' '} - last match - - ) : ( - + ) : ( - {numberFormatter.format( - Math.trunc(lastGameLeaderboard2.mmrChange) - )}{' '} - last match - - )} -
-
- )} -
+ )} + {numberFormatter.format( + Math.trunc(lastVanillaGame.mmrChange) + )}{' '} + last match + + ) : null + } + /> + )}
-
- } - description='Total matches' - /> - } - description={`${profileData.winRate}% win rate`} - accentColor='text-emerald-500' - /> - } - description={`${profileData.games > 0 ? Math.round((profileData.losses / profileData.games) * 100) : 0}% loss rate`} - accentColor='text-rose-500' - /> - } - description={`${profileData.games > 0 ? Math.round((profileData.ties / profileData.games) * 100) : 0}% tie rate`} - accentColor='text-amber-500' - /> -
-
@@ -338,15 +342,15 @@ export function UserInfo() {
- {(rankedLeaderboard || lastGameLeaderboard1) && ( + {(rankedLeaderboard || lastRankedGame) && ( )} - {(vanillaLeaderboard || lastGameLeaderboard2) && ( + {(vanillaLeaderboard || lastVanillaGame) && (

No leaderboard data available @@ -404,7 +408,7 @@ interface StatsCardProps { title: string value: number icon: React.ReactNode - description: string + description: React.ReactNode accentColor?: string } @@ -416,12 +420,14 @@ function StatsCard({ accentColor = 'text-violet-500', }: StatsCardProps) { return ( -

-
{icon}
-

+
+

{title}

-

{value}

+
+
{icon}
+

{value}

+

{description}

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