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 { 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user