feat: add tabbed rankings page for Elo, OpenSkill, and Glicko2
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import Navigation from "@/components/Navigation"
|
||||
import Link from "next/link"
|
||||
import RankingsClient from "./RankingsClient"
|
||||
|
||||
export default async function RankingsPage() {
|
||||
// Fetch players ordered by Elo rating
|
||||
// Fetch players with all rating systems
|
||||
const players = await prisma.player.findMany({
|
||||
orderBy: { currentElo: "desc" },
|
||||
take: 50,
|
||||
include: {
|
||||
partnershipStats: true,
|
||||
partnershipStats2: true,
|
||||
eloRating: true,
|
||||
glicko2Rating: true,
|
||||
openSkillRating: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -23,57 +26,7 @@ export default async function RankingsPage() {
|
||||
Player Rankings
|
||||
</h1>
|
||||
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<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>
|
||||
<RankingsClient players={players} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user