feat: implement leaderboard page with top players, teams, and high scores

- Added a new leaderboard route with server-side loading for top players, teams, and recent high scores.
- Created a new Svelte component for the leaderboard UI, including tabs for players, teams, and scores.
- Implemented ELO ranking display and win/loss statistics for players.
- Added functionality to display team wins and member counts.
- Included recent high scores with player context and map details.
- Enhanced team management features in the teams route, including renaming and member management with permission checks.
This commit is contained in:
2026-02-28 18:18:11 +01:00
parent fb9140a03f
commit b506dba0eb
285 changed files with 1184 additions and 47362 deletions

View File

@@ -0,0 +1,133 @@
import { db } from '$lib/server/db';
import {
playerRating,
team,
teamMember,
matchParticipant,
matchGameScore,
matchGame,
matchParticipantPlayer,
match
} from '$lib/server/db/schema';
import { user } from '$lib/server/db/auth.schema';
import { eq, desc, sql, and } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
// ── Top players by ELO ──
const topPlayers = await db.query.playerRating.findMany({
orderBy: desc(playerRating.elo),
limit: 25
});
const playersWithUsers = await Promise.all(
topPlayers.map(async (r, i) => {
const u = await db.query.user.findFirst({
where: eq(user.id, r.userId)
});
return {
rank: i + 1,
userId: r.userId,
name: u?.name ?? 'Unknown',
image: u?.image ?? null,
elo: r.elo,
wins: r.wins,
losses: r.losses,
winRate:
r.wins + r.losses > 0
? ((r.wins / (r.wins + r.losses)) * 100).toFixed(1)
: '—'
};
})
);
// ── Top teams by match wins ──
// Count wins for each team from matchParticipant scores
const teamWins = await db
.select({
teamId: match.winnerId,
wins: sql<number>`count(*)`.as('wins')
})
.from(match)
.where(and(
sql`${match.winnerId} IS NOT NULL`,
eq(match.state, 'FINISHED')
))
.groupBy(match.winnerId)
.orderBy(sql`count(*) DESC`)
.limit(15);
const teamsWithDetails = await Promise.all(
teamWins.map(async (tw, i) => {
if (!tw.teamId) return null;
const t = await db.query.team.findFirst({
where: eq(team.id, tw.teamId),
with: { members: true }
});
if (!t) return null;
// Count total matches for this team
const totalMatches = await db
.select({ count: sql<number>`count(DISTINCT ${matchParticipant.matchId})` })
.from(matchParticipant)
.where(eq(matchParticipant.teamId, tw.teamId));
return {
rank: i + 1,
teamId: t.id,
name: t.name,
avatarUrl: t.avatarUrl,
isPersonal: t.isPersonal,
memberCount: t.members.length,
wins: Number(tw.wins),
totalMatches: Number(totalMatches[0]?.count ?? 0)
};
})
);
const topTeams = teamsWithDetails.filter(Boolean);
// ── Recent high scores (individual map scores) ──
const recentHighScores = await db.query.matchGameScore.findMany({
orderBy: desc(matchGameScore.score),
limit: 15,
with: {
player: true,
game: {
with: {
slot: true,
match: true
}
}
}
});
const highScoresWithNames = await Promise.all(
recentHighScores.map(async (s, i) => {
const u = await db.query.user.findFirst({
where: eq(user.id, s.player.userId)
});
return {
rank: i + 1,
playerName: u?.name ?? 'Unknown',
playerImage: u?.image ?? null,
score: s.score,
accuracy: s.accuracy,
maxCombo: s.maxCombo,
mods: s.mods,
passed: s.passed,
beatmapId: s.game.slot?.beatmapId ?? null,
mapCategory: s.game.slot?.category ?? '',
mapOrder: s.game.slot?.orderInCategory ?? 0,
matchName: s.game.match?.name ?? 'Match',
matchId: s.game.matchId
};
})
);
return {
topPlayers: playersWithUsers,
topTeams: topTeams,
highScores: highScoresWithNames
};
};

View File

@@ -0,0 +1,263 @@
<script lang="ts">
let { data } = $props();
let activeTab = $state<'players' | 'teams' | 'scores'>('players');
const tabs = [
{ id: 'players' as const, label: 'Top Players', icon: '👤' },
{ id: 'teams' as const, label: 'Top Teams', icon: '⚑' },
{ id: 'scores' as const, label: 'High Scores', icon: '🎯' }
];
function eloColor(elo: number): string {
if (elo >= 1400) return 'text-yellow-400';
if (elo >= 1200) return 'text-purple-400';
if (elo >= 1000) return 'text-blue-400';
return 'text-text-secondary';
}
function rankBadge(rank: number): { emoji: string; color: string } | null {
if (rank === 1) return { emoji: '🥇', color: 'text-yellow-400' };
if (rank === 2) return { emoji: '🥈', color: 'text-gray-300' };
if (rank === 3) return { emoji: '🥉', color: 'text-amber-600' };
return null;
}
const catColors: Record<string, string> = {
NM: 'text-blue-400',
HD: 'text-yellow-400',
HR: 'text-red-400',
DT: 'text-purple-400',
FM: 'text-green-400',
TB: 'text-pink-400'
};
</script>
<div class="mx-auto max-w-4xl">
<div>
<h1 class="text-2xl font-700 tracking-tight">Leaderboard</h1>
<p class="mt-1 text-sm text-text-secondary">Rankings and top performances</p>
</div>
<!-- Tabs -->
<div class="mt-6 flex gap-1 rounded-lg border border-border bg-surface-800 p-1">
{#each tabs as tab}
<button
onclick={() => (activeTab = tab.id)}
class="flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-600 transition-colors {activeTab ===
tab.id
? 'bg-accent text-surface-900'
: 'text-text-secondary hover:text-text-primary'}"
>
<span>{tab.icon}</span>
{tab.label}
</button>
{/each}
</div>
<!-- Top Players -->
{#if activeTab === 'players'}
<div class="mt-6 rounded-lg border border-border bg-surface-800">
{#if data.topPlayers.length === 0}
<div class="py-12 text-center">
<p class="text-sm text-text-secondary">No ranked players yet. Play some matches!</p>
</div>
{:else}
<div
class="grid grid-cols-[auto_auto_1fr_auto_auto_auto] gap-4 border-b border-border px-4 py-3"
>
<span class="text-xs font-600 text-text-secondary">#</span>
<span class="text-xs font-600 text-text-secondary"></span>
<span class="text-xs font-600 text-text-secondary">Player</span>
<span class="text-xs font-600 text-text-secondary">ELO</span>
<span class="text-xs font-600 text-text-secondary">W/L</span>
<span class="text-xs font-600 text-text-secondary">Win %</span>
</div>
{#each data.topPlayers as p}
{@const medal = rankBadge(p.rank)}
<div
class="grid grid-cols-[auto_auto_1fr_auto_auto_auto] items-center gap-4 border-b border-border/50 px-4 py-3 last:border-b-0 {p.rank <=
3
? 'bg-accent/[0.02]'
: ''}"
>
<!-- Rank -->
<span class="w-6 text-center text-sm font-700 tabular-nums {medal?.color ?? 'text-text-secondary'}">
{medal ? medal.emoji : p.rank}
</span>
<!-- Avatar -->
{#if p.image}
<img src={p.image} alt="" class="h-8 w-8 rounded-full" />
{:else}
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs font-700 text-text-secondary"
>
{p.name.charAt(0).toUpperCase()}
</div>
{/if}
<!-- Name -->
<span class="truncate text-sm font-600">{p.name}</span>
<!-- ELO -->
<span class="text-sm font-700 tabular-nums {eloColor(p.elo)}">{p.elo}</span>
<!-- W/L -->
<span class="text-xs tabular-nums text-text-secondary">
<span class="text-green-400">{p.wins}</span>/<span class="text-red-400">{p.losses}</span>
</span>
<!-- Win Rate -->
<span class="text-xs font-600 tabular-nums {p.winRate !== '—' && parseFloat(p.winRate) >= 50 ? 'text-green-400' : 'text-text-secondary'}">
{p.winRate}{p.winRate !== '—' ? '%' : ''}
</span>
</div>
{/each}
{/if}
</div>
{/if}
<!-- Top Teams -->
{#if activeTab === 'teams'}
<div class="mt-6 rounded-lg border border-border bg-surface-800">
{#if data.topTeams.length === 0}
<div class="py-12 text-center">
<p class="text-sm text-text-secondary">No team results yet.</p>
</div>
{:else}
<div class="grid grid-cols-[auto_auto_1fr_auto_auto] gap-4 border-b border-border px-4 py-3">
<span class="text-xs font-600 text-text-secondary">#</span>
<span class="text-xs font-600 text-text-secondary"></span>
<span class="text-xs font-600 text-text-secondary">Team</span>
<span class="text-xs font-600 text-text-secondary">Wins</span>
<span class="text-xs font-600 text-text-secondary">Matches</span>
</div>
{#each data.topTeams as t}
{@const medal = rankBadge(t.rank)}
<div
class="grid grid-cols-[auto_auto_1fr_auto_auto] items-center gap-4 border-b border-border/50 px-4 py-3 last:border-b-0 {t.rank <=
3
? 'bg-accent/[0.02]'
: ''}"
>
<!-- Rank -->
<span class="w-6 text-center text-sm font-700 tabular-nums {medal?.color ?? 'text-text-secondary'}">
{medal ? medal.emoji : t.rank}
</span>
<!-- Avatar -->
{#if t.avatarUrl}
<img src={t.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
{:else}
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs font-700 text-text-secondary"
>
{t.name.charAt(0).toUpperCase()}
</div>
{/if}
<!-- Name -->
<div class="min-w-0">
<span class="truncate text-sm font-600">{t.name}</span>
{#if t.isPersonal}
<span class="ml-1 text-[10px] text-text-secondary">(solo)</span>
{/if}
<p class="text-xs text-text-secondary">{t.memberCount} member{t.memberCount !== 1 ? 's' : ''}</p>
</div>
<!-- Wins -->
<span class="text-sm font-700 tabular-nums text-green-400">{t.wins}</span>
<!-- Total Matches -->
<span class="text-xs tabular-nums text-text-secondary">{t.totalMatches}</span>
</div>
{/each}
{/if}
</div>
{/if}
<!-- High Scores -->
{#if activeTab === 'scores'}
<div class="mt-6 rounded-lg border border-border bg-surface-800">
{#if data.highScores.length === 0}
<div class="py-12 text-center">
<p class="text-sm text-text-secondary">No scores recorded yet.</p>
</div>
{:else}
<div
class="grid grid-cols-[auto_auto_1fr_auto_auto_auto] gap-4 border-b border-border px-4 py-3"
>
<span class="text-xs font-600 text-text-secondary">#</span>
<span class="text-xs font-600 text-text-secondary"></span>
<span class="text-xs font-600 text-text-secondary">Player</span>
<span class="text-xs font-600 text-text-secondary">Map</span>
<span class="text-xs font-600 text-text-secondary">Score</span>
<span class="text-xs font-600 text-text-secondary">Acc</span>
</div>
{#each data.highScores as s}
{@const medal = rankBadge(s.rank)}
<div
class="grid grid-cols-[auto_auto_1fr_auto_auto_auto] items-center gap-4 border-b border-border/50 px-4 py-3 last:border-b-0 {s.rank <=
3
? 'bg-accent/[0.02]'
: ''}"
>
<!-- Rank -->
<span class="w-6 text-center text-sm font-700 tabular-nums {medal?.color ?? 'text-text-secondary'}">
{medal ? medal.emoji : s.rank}
</span>
<!-- Avatar -->
{#if s.playerImage}
<img src={s.playerImage} alt="" class="h-8 w-8 rounded-full" />
{:else}
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs font-700 text-text-secondary"
>
{s.playerName.charAt(0).toUpperCase()}
</div>
{/if}
<!-- Player + match context -->
<div class="min-w-0">
<span class="truncate text-sm font-600">{s.playerName}</span>
<p class="truncate text-xs text-text-secondary">
<a href="/matches/{s.matchId}" class="hover:text-accent">{s.matchName}</a>
</p>
</div>
<!-- Map slot -->
<span class="text-xs font-600 {catColors[s.mapCategory] ?? 'text-text-secondary'}">
{s.mapCategory}{s.mapOrder}
</span>
<!-- Score -->
<span class="text-sm font-700 tabular-nums text-text-primary">
{s.score.toLocaleString()}
</span>
<!-- Accuracy -->
<span class="text-xs tabular-nums text-text-secondary">
{s.accuracy > 0 ? `${(s.accuracy * 100).toFixed(1)}%` : '—'}
</span>
</div>
{/each}
{/if}
</div>
{/if}
<!-- ELO Explanation -->
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-5">
<h2 class="text-sm font-600">How ELO Works</h2>
<p class="mt-2 text-xs text-text-secondary leading-relaxed">
Every player starts at 1000 ELO. Win a match to gain points, lose to drop.
The system uses K=32, so new players' ratings shift quickly. As you play more,
your rating stabilizes around your true skill level. Queue matchmaking pairs players
with similar ELO for fair matches.
</p>
</div>
</div>