feat: add tabbed rankings page for Elo, OpenSkill, and Glicko2

This commit is contained in:
2026-03-31 03:38:47 -07:00
parent 2f9e2f0dc5
commit e19c292cfb
2 changed files with 247 additions and 53 deletions
+241
View File
@@ -0,0 +1,241 @@
"use client"
import { useState } from "react"
import Link from "next/link"
// Define the Player type with all rating relationships
interface PlayerWithRatings {
id: number
name: string
currentElo: number
gamesPlayed: number
wins: number
losses: number
eloRating: { rating: number; gamesPlayed: number; wins: number; losses: number } | null
glicko2Rating: { rating: number; gamesPlayed: number; wins: number; losses: number } | null
openSkillRating: { rating: number; gamesPlayed: number; wins: number; losses: number } | null
}
export default function RankingsClient({ players }: { players: PlayerWithRatings[] }) {
const [activeTab, setActiveTab] = useState<"elo" | "openskill" | "glicko2">("elo")
return (
<>
{/* Tab Navigation */}
<div className="border-b border-gray-200 mb-6">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab("elo")}
className={`${
activeTab === "elo"
? "border-green-500 text-green-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm`}
>
Elo Rating
</button>
<button
onClick={() => setActiveTab("openskill")}
className={`${
activeTab === "openskill"
? "border-green-500 text-green-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm`}
>
OpenSkill
</button>
<button
onClick={() => setActiveTab("glicko2")}
className={`${
activeTab === "glicko2"
? "border-green-500 text-green-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm`}
>
Glicko2
</button>
</nav>
</div>
{/* Elo Rating Tab */}
{activeTab === "elo" && (
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<h2 className="text-xl font-semibold text-gray-900 px-6 py-4 border-b">
Elo Rating Rankings
</h2>
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Rank
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Player
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Elo Rating
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Games Played
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Win Rate
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{players.map((player, index) => (
<tr key={player.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{index + 1}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<Link
href={`/players/${player.id}/profile`}
className="text-green-600 hover:text-green-900"
>
{player.name}
</Link>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{player.eloRating?.rating ?? player.currentElo}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{player.gamesPlayed}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{player.gamesPlayed > 0
? `${((player.wins / player.gamesPlayed) * 100).toFixed(1)}%`
: "N/A"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* OpenSkill Rating Tab */}
{activeTab === "openskill" && (
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<h2 className="text-xl font-semibold text-gray-900 px-6 py-4 border-b">
OpenSkill Rating Rankings
</h2>
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Rank
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Player
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
OpenSkill Rating
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Games Played
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Win Rate
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{players
.filter(p => p.openSkillRating)
.sort((a, b) => (b.openSkillRating?.rating ?? 0) - (a.openSkillRating?.rating ?? 0))
.map((player, index) => (
<tr key={player.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{index + 1}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<Link
href={`/players/${player.id}/profile`}
className="text-green-600 hover:text-green-900"
>
{player.name}
</Link>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{player.openSkillRating?.rating?.toFixed(1) ?? 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{player.openSkillRating?.gamesPlayed ?? 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{player.openSkillRating && player.openSkillRating.gamesPlayed > 0
? `${((player.openSkillRating.wins / player.openSkillRating.gamesPlayed) * 100).toFixed(1)}%`
: "N/A"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Glicko2 Rating Tab */}
{activeTab === "glicko2" && (
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<h2 className="text-xl font-semibold text-gray-900 px-6 py-4 border-b">
Glicko2 Rating Rankings
</h2>
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Rank
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Player
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Glicko2 Rating
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Games Played
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Win Rate
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{players
.filter(p => p.glicko2Rating)
.sort((a, b) => (b.glicko2Rating?.rating ?? 0) - (a.glicko2Rating?.rating ?? 0))
.map((player, index) => (
<tr key={player.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{index + 1}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<Link
href={`/players/${player.id}/profile`}
className="text-green-600 hover:text-green-900"
>
{player.name}
</Link>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{player.glicko2Rating?.rating?.toFixed(1) ?? 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{player.glicko2Rating?.gamesPlayed ?? 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{player.glicko2Rating && player.glicko2Rating.gamesPlayed > 0
? `${((player.glicko2Rating.wins / player.glicko2Rating.gamesPlayed) * 100).toFixed(1)}%`
: "N/A"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)
}
+6 -53
View File
@@ -1,15 +1,18 @@
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import Navigation from "@/components/Navigation" import Navigation from "@/components/Navigation"
import Link from "next/link" import RankingsClient from "./RankingsClient"
export default async function RankingsPage() { export default async function RankingsPage() {
// Fetch players ordered by Elo rating // Fetch players with all rating systems
const players = await prisma.player.findMany({ const players = await prisma.player.findMany({
orderBy: { currentElo: "desc" }, orderBy: { currentElo: "desc" },
take: 50, take: 50,
include: { include: {
partnershipStats: true, partnershipStats: true,
partnershipStats2: true, partnershipStats2: true,
eloRating: true,
glicko2Rating: true,
openSkillRating: true,
}, },
}) })
@@ -23,57 +26,7 @@ export default async function RankingsPage() {
Player Rankings Player Rankings
</h1> </h1>
<div className="bg-white shadow overflow-hidden sm:rounded-lg"> <RankingsClient players={players} />
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Rank
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Player
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Elo Rating
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Games Played
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Win Rate
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{players.map((player, index) => (
<tr key={player.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{index + 1}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<Link
href={`/players/${player.id}/profile`}
className="text-green-600 hover:text-green-900"
>
{player.name}
</Link>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{player.currentElo}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{player.gamesPlayed}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{player.gamesPlayed > 0
? `${((player.wins / player.gamesPlayed) * 100).toFixed(1)}%`
: "N/A"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div> </div>
</main> </main>
</div> </div>