397 lines
17 KiB
TypeScript
397 lines
17 KiB
TypeScript
import { prisma } from "@/lib/prisma"
|
|
export const dynamic = "force-dynamic";
|
|
import Navigation from "@/components/Navigation"
|
|
import Link from "next/link"
|
|
import { notFound } from "next/navigation"
|
|
|
|
interface PageProps {
|
|
params: {
|
|
id: string
|
|
}
|
|
}
|
|
|
|
export default async function PlayerProfilePage({ params }: PageProps) {
|
|
// Next.js 16 requires awaiting params
|
|
const { id } = await params
|
|
const playerId = parseInt(id, 10)
|
|
|
|
if (isNaN(playerId)) {
|
|
notFound()
|
|
}
|
|
|
|
const player = await prisma.player.findUnique({
|
|
where: { id: playerId },
|
|
include: {
|
|
eloRating: true,
|
|
openSkillRating: true,
|
|
glicko2Rating: true,
|
|
},
|
|
})
|
|
|
|
if (!player) {
|
|
notFound()
|
|
}
|
|
|
|
// Get partnership stats separately
|
|
const partnershipStats = await prisma.partnershipStat.findMany({
|
|
where: {
|
|
OR: [
|
|
{ player1Id: playerId },
|
|
{ player2Id: playerId },
|
|
],
|
|
},
|
|
include: {
|
|
player1: true,
|
|
player2: true,
|
|
},
|
|
orderBy: { gamesPlayed: "desc" },
|
|
})
|
|
|
|
// Get tournaments participated in
|
|
const tournaments = await prisma.event.findMany({
|
|
where: {
|
|
participants: {
|
|
some: {
|
|
playerId: playerId,
|
|
},
|
|
},
|
|
},
|
|
orderBy: { eventDate: "desc" },
|
|
take: 10,
|
|
})
|
|
|
|
// Get recent matches (last 10 games played)
|
|
const recentMatches = await prisma.match.findMany({
|
|
where: {
|
|
OR: [
|
|
{ team1P1Id: playerId },
|
|
{ team1P2Id: playerId },
|
|
{ team2P1Id: playerId },
|
|
{ team2P2Id: playerId },
|
|
],
|
|
},
|
|
include: {
|
|
team1P1: true,
|
|
team1P2: true,
|
|
team2P1: true,
|
|
team2P2: true,
|
|
event: true,
|
|
},
|
|
orderBy: { playedAt: "desc" },
|
|
take: 10,
|
|
})
|
|
|
|
// Use direct player stats for overall statistics (more accurate and consistent with rankings page)
|
|
const totalGames = player.gamesPlayed
|
|
const totalWins = player.wins
|
|
const winRate = totalGames > 0 ? ((totalWins / totalGames) * 100).toFixed(1) : "0.0"
|
|
|
|
// Get best partnership based on ELO contribution
|
|
// Best partner is the one who contributed most to your rating (highest totalEloChange)
|
|
// Must have played at least 2 games together to be considered
|
|
const bestPartnership = partnershipStats.reduce((best, stat) => {
|
|
if (stat.gamesPlayed < 2) return best // Need at least 2 games for partnership to be meaningful
|
|
const currentEloChange = stat.totalEloChange
|
|
const bestEloChange = best ? best.totalEloChange : -Infinity
|
|
return currentEloChange > bestEloChange ? stat : best
|
|
}, null as (typeof partnershipStats[0] | null))
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
<Navigation />
|
|
|
|
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
|
<div className="px-4 py-6 sm:px-0">
|
|
{/* Player Header */}
|
|
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
|
<div className="flex items-center">
|
|
<div className="h-20 w-20 bg-green-100 rounded-full flex items-center justify-center text-green-800 text-2xl font-bold">
|
|
{player.name.charAt(0).toUpperCase()}
|
|
</div>
|
|
<div className="ml-6">
|
|
<h1 className="text-2xl font-bold text-gray-900">{player.name}</h1>
|
|
<p className="text-gray-500">
|
|
Member since {new Date(player.createdAt).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats Grid */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
|
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
|
<p className="text-sm text-gray-500">Current Elo</p>
|
|
<p className="text-2xl font-bold text-gray-900">{player.currentElo}</p>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
|
<p className="text-sm text-gray-500">OpenSkill Rating</p>
|
|
<p className="text-2xl font-bold text-gray-900">
|
|
{player.openSkillRating?.rating?.toFixed(1) || 'N/A'}
|
|
</p>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
|
<p className="text-sm text-gray-500">Glicko2 Rating</p>
|
|
<p className="text-2xl font-bold text-gray-900">
|
|
{player.glicko2Rating?.rating?.toFixed(1) || 'N/A'}
|
|
</p>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
|
<p className="text-sm text-gray-500">Best Partner</p>
|
|
<p className="text-lg font-bold text-gray-900">
|
|
{bestPartnership
|
|
? (bestPartnership.player1Id === player.id
|
|
? bestPartnership.player2.name
|
|
: bestPartnership.player1.name)
|
|
: "N/A"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tournaments Participated */}
|
|
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
|
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
|
Tournaments Participated
|
|
</h2>
|
|
|
|
{tournaments.length === 0 ? (
|
|
<p className="text-gray-500">No tournament data available yet.</p>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<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">
|
|
Tournament
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Date
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Format
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Status
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{tournaments.map((tournament) => (
|
|
<tr key={tournament.id} className="hover:bg-gray-50">
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<Link
|
|
href={`/players/${playerId}/schedule?tournament=${tournament.id}`}
|
|
className="text-green-600 hover:text-green-900"
|
|
>
|
|
{tournament.name}
|
|
</Link>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
{tournament.eventDate
|
|
? new Date(tournament.eventDate).toLocaleDateString()
|
|
: "N/A"}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
{tournament.format}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className={`inline-flex px-2 py-1 text-xs font-semibold leading-5 rounded-full ${
|
|
tournament.status === 'active' || tournament.status === 'in_progress'
|
|
? 'bg-green-100 text-green-800'
|
|
: tournament.status === 'completed'
|
|
? 'bg-gray-100 text-gray-800'
|
|
: 'bg-yellow-100 text-yellow-800'
|
|
}`}>
|
|
{tournament.status}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Recent Games */}
|
|
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
|
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
|
Recent Games
|
|
</h2>
|
|
|
|
{recentMatches.length === 0 ? (
|
|
<p className="text-gray-500">No game data available yet.</p>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<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">
|
|
Date
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Tournament
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Team
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Opponents
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Score
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Result
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{recentMatches.map((match) => {
|
|
const isTeam1 = match.team1P1Id === playerId || match.team1P2Id === playerId;
|
|
const teamWon = isTeam1 ? match.team1Score > match.team2Score : match.team2Score > match.team1Score;
|
|
const teamScore = isTeam1 ? match.team1Score : match.team2Score;
|
|
const opponentScore = isTeam1 ? match.team2Score : match.team1Score;
|
|
|
|
const teammate = isTeam1
|
|
? (match.team1P1Id === playerId ? match.team1P2 : match.team1P1)
|
|
: (match.team2P1Id === playerId ? match.team2P2 : match.team2P1);
|
|
|
|
const opponents = isTeam1
|
|
? [match.team2P1, match.team2P2]
|
|
: [match.team1P1, match.team1P2];
|
|
|
|
return (
|
|
<tr key={match.id} className="hover:bg-gray-50">
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
<Link
|
|
href={`/matches/${match.id}`}
|
|
className="text-green-600 hover:text-green-900"
|
|
>
|
|
{match.playedAt
|
|
? new Date(match.playedAt).toLocaleDateString()
|
|
: "N/A"}
|
|
</Link>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
{match.event?.name || "N/A"}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
<Link
|
|
href={`/players/${teammate.id}/profile`}
|
|
className="text-green-600 hover:text-green-900"
|
|
>
|
|
{teammate.name}
|
|
</Link>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
{opponents.map((opponent, index) => (
|
|
<span key={opponent.id}>
|
|
<Link
|
|
href={`/players/${opponent.id}/profile`}
|
|
className="text-green-600 hover:text-green-900"
|
|
>
|
|
{opponent.name}
|
|
</Link>
|
|
{index < opponents.length - 1 ? ", " : ""}
|
|
</span>
|
|
))}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
{teamScore} - {opponentScore}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className={`inline-flex px-2 py-1 text-xs font-semibold leading-5 rounded-full ${
|
|
teamWon
|
|
? 'bg-green-100 text-green-800'
|
|
: 'bg-red-100 text-red-800'
|
|
}`}>
|
|
{teamWon ? 'Win' : 'Loss'}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Partnership Performance */}
|
|
<div className="bg-white shadow rounded-lg p-6">
|
|
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
|
Partnership Performance
|
|
</h2>
|
|
|
|
{partnershipStats.length === 0 ? (
|
|
<p className="text-gray-500">No partnership data available yet.</p>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<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">
|
|
Partner
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Games
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Win Rate
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
ELO Change
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Last Played
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{partnershipStats.map((stat) => {
|
|
const partner =
|
|
stat.player1Id === player.id ? stat.player2 : stat.player1
|
|
const winRate = stat.gamesPlayed > 0
|
|
? ((stat.wins / stat.gamesPlayed) * 100).toFixed(1)
|
|
: "0.0"
|
|
|
|
return (
|
|
<tr key={stat.id} className="hover:bg-gray-50">
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<Link
|
|
href={`/players/${partner.id}/profile`}
|
|
className="text-green-600 hover:text-green-900"
|
|
>
|
|
{partner.name}
|
|
</Link>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
{stat.gamesPlayed}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
{winRate}%
|
|
</td>
|
|
<td className={`px-6 py-4 whitespace-nowrap text-sm ${
|
|
stat.totalEloChange >= 0 ? 'text-green-600' : 'text-red-600'
|
|
}`}>
|
|
{stat.totalEloChange >= 0 ? '+' : ''}{stat.totalEloChange}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
{stat.lastPlayed
|
|
? new Date(stat.lastPlayed).toLocaleDateString()
|
|
: "N/A"}
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|