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 (
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 ( -
{value}
+{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: