Files
euchre_camp/src/app/admin/tournaments/[id]/page.tsx
T
david 603cc238fa feat: implement variable team matchups with partner rotation
- Add manual team entry for permanent teams in Matchups tab
- Rename 'Teams' tab to 'Matchups' for clarity
- Implement partner rotation strategies (minimize_repeat, maximize_even, elo_based)
- Track partnerships across rounds to minimize repeat pairings
- Fix unit tests for team configuration and ELO calculations
- Add E2E test for 9+ participant tournaments with variable matchups

Key changes:
- TeamsSection.tsx: Added router.refresh(), manual team entry, and matchup display
- schedule-generator.ts: Enhanced generateVariableRoundRobin to track partnerships
- team-generator.ts: Fixed partnership frequency tracking across rounds
- API routes: Updated to support variable team durability with rotation strategies
2026-04-04 00:24:57 -07:00

573 lines
22 KiB
TypeScript

"use client"
import { useState, useEffect } from "react"
import { use } from "react"
import Link from "next/link"
import Navigation from "@/components/Navigation"
import TeamsSection from "@/components/TeamsSection"
import { DeleteTournamentButton } from "@/components/DeleteTournamentButton"
import { ScheduleGenerator } from "@/components/ScheduleGenerator"
import MatchEditor from "@/components/MatchEditor"
interface PageProps {
params: Promise<{
id: string
}>
}
export default function TournamentDetailPage({ params }: PageProps) {
const resolvedParams = use(params)
const tournamentId = resolvedParams.id
const [activeTab, setActiveTab] = useState<string>("overview")
const [tournament, setTournament] = useState<any>(null)
const [matches, setMatches] = useState<any[]>([])
const [participants, setParticipants] = useState<any[]>([])
const [rounds, setRounds] = useState<any[]>([])
const [allPlayers, setAllPlayers] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
// Load tournament data
useEffect(() => {
const loadTournament = async () => {
try {
setLoading(true)
const response = await fetch(`/api/tournaments/${tournamentId}`)
if (!response.ok) {
throw new Error("Failed to load tournament")
}
const data = await response.json()
// API returns { tournament: { ... } } or direct tournament object
const tournamentData = data.tournament || data
setTournament(tournamentData)
} catch (err) {
setError("Failed to load tournament")
} finally {
setLoading(false)
}
}
loadTournament()
}, [tournamentId])
// Load all related data when tournament loads
useEffect(() => {
if (!tournament) return
const loadRelatedData = async () => {
try {
// Load participants
const pResponse = await fetch(`/api/tournaments/${tournamentId}/participants`)
if (pResponse.ok) {
const pData = await pResponse.json()
setParticipants(pData.participants || [])
}
// Load matches
const mResponse = await fetch(`/api/tournaments/${tournamentId}/matches`)
if (mResponse.ok) {
const mData = await mResponse.json()
setMatches(mData.matches || [])
}
// Load schedule/rounds
const sResponse = await fetch(`/api/tournaments/${tournamentId}/schedule`)
if (sResponse.ok) {
const sData = await sResponse.json()
setRounds(sData.rounds || [])
}
// Load all players for selection
const playersResponse = await fetch("/api/players")
if (playersResponse.ok) {
const playersData = await playersResponse.json()
setAllPlayers(playersData || [])
}
} catch (err) {
console.error("Failed to load related data:", err)
}
}
loadRelatedData()
}, [tournamentId, tournament])
if (loading) {
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">
<div className="bg-white shadow rounded-lg p-6">
<p className="text-gray-500">Loading...</p>
</div>
</div>
</main>
</div>
)
}
if (error || !tournament) {
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">
<div className="bg-white shadow rounded-lg p-6">
<p className="text-red-600">{error || "Tournament not found"}</p>
</div>
</div>
</main>
</div>
)
}
const hasSchedule = rounds.length > 0
const statusColors: Record<string, string> = {
pending: "bg-gray-100 text-gray-700",
in_progress: "bg-yellow-100 text-yellow-800",
completed: "bg-green-100 text-green-800",
}
// Tab content renderer
const renderTabContent = () => {
switch (activeTab) {
case "overview":
return (
<>
{/* Participants Section */}
<div className="bg-white shadow rounded-lg p-6 mb-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">
Participants ({participants.length})
</h2>
{participants.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{participants.map((participant) => (
<div
key={participant.id}
className="bg-gray-50 rounded p-2 text-center"
>
<Link
href={`/players/${participant.player.id}/profile`}
className="text-green-600 hover:text-green-900 text-sm"
>
{participant.player.name}
</Link>
</div>
))}
</div>
) : (
<p className="text-gray-500">No participants registered yet.</p>
)}
</div>
{/* Recent Matches Section */}
<div className="bg-white shadow rounded-lg p-6 mt-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">
Recent Matches ({matches.length})
</h2>
{matches.length > 0 ? (
<div className="space-y-3">
{matches.slice(0, 10).map((match) => (
<div
key={match.id}
className="border border-gray-200 rounded p-3"
>
<div className="flex justify-between items-center">
<div className="flex-1">
<p className="text-sm text-gray-500">
{match.playedAt ? new Date(match.playedAt).toLocaleDateString() : ''}
</p>
<p className="font-medium">
{match.player1P1?.name} + {match.player1P2?.name} vs{" "}
{match.player2P1?.name} + {match.player2P2?.name}
</p>
</div>
<div className="text-right">
<span className={`font-bold ${
match.team1Score > match.team2Score
? 'text-green-600'
: match.team1Score < match.team2Score
? 'text-red-600'
: 'text-gray-600'
}`}>
{match.team1Score}
</span>
<span className="text-gray-400 mx-2">-</span>
<span className={`font-bold ${
match.team2Score > match.team1Score
? 'text-green-600'
: match.team2Score < match.team1Score
? 'text-red-600'
: 'text-gray-600'
}`}>
{match.team2Score}
</span>
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-gray-500">No matches recorded yet.</p>
)}
</div>
</>
)
case "participants":
return (
<div className="bg-white shadow rounded-lg p-6 space-y-6">
<div>
<h2 className="text-lg font-medium text-gray-900 mb-4">
Add Participants
</h2>
{/* Player Search */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Search for existing players
</label>
<div className="relative">
<input
type="text"
placeholder="Type a name to search..."
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
/>
</div>
</div>
</div>
{/* Current Participants */}
<div>
<h2 className="text-lg font-medium text-gray-900 mb-4">
Current Participants ({participants.length})
</h2>
{participants.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{participants.map((participant) => (
<div
key={participant.id}
className="flex items-center justify-between bg-gray-50 rounded p-3"
>
<div className="flex items-center gap-3">
<Link
href={`/players/${participant.player.id}/profile`}
className="text-green-600 hover:text-green-900 font-medium"
>
{participant.player.name}
</Link>
<span className="text-sm text-gray-500">
Elo: {participant.player.currentElo}
</span>
</div>
</div>
))}
</div>
) : (
<p className="text-gray-500">No participants registered yet.</p>
)}
</div>
</div>
)
case "matchups":
return (
<TeamsSection
tournamentId={parseInt(tournamentId)}
participants={participants.map(p => ({
id: p.player.id,
name: p.player.name,
currentElo: p.player.currentElo,
}))}
teamDurability={tournament.teamDurability || "permanent"}
partnerRotation={tournament.partnerRotation || "none"}
allowByes={tournament.allowByes ?? true}
/>
)
case "schedule":
return (
<>
{!hasSchedule && (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">
No Schedule Generated
</h2>
<ScheduleGenerator
tournamentId={parseInt(tournamentId)}
teamCount={Math.floor(participants.length / 2)}
existingRounds={rounds.length}
/>
</div>
)}
{rounds.map((round) => (
<div key={round.id} className="bg-white shadow rounded-lg p-6 mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-medium text-gray-900">
Round {round.roundNumber}
</h2>
<span className={`px-2 py-1 text-xs font-medium rounded-full ${statusColors[round.status] || statusColors.pending}`}>
{round.status.replace("_", " ")}
</span>
</div>
{round.bracketMatchups?.length === 0 ? (
<p className="text-gray-500">No matchups in this round.</p>
) : (
<div className="space-y-3">
{round.bracketMatchups?.map((matchup: any) => {
const team1Name = matchup.player1P1 && matchup.player1P2
? `${matchup.player1P1.name} + ${matchup.player1P2.name}`
: "TBD"
const team2Name = matchup.player2P1 && matchup.player2P2
? `${matchup.player2P1.name} + ${matchup.player2P2.name}`
: "TBD"
return (
<div
key={matchup.id}
className="border border-gray-200 rounded p-3"
>
<div className="flex justify-between items-center">
<div className="flex-1">
<div className="flex items-center space-x-2">
{matchup.tableNumber && (
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
Table {matchup.tableNumber}
</span>
)}
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${statusColors[matchup.status] || statusColors.pending}`}>
{matchup.status.replace("_", " ")}
</span>
</div>
<p className="font-medium mt-1">
{team1Name} <span className="text-gray-400">vs</span> {team2Name}
</p>
</div>
<div className="flex items-center space-x-3">
{matchup.match ? (
<div className="flex items-center space-x-2">
<span className={`font-bold ${
matchup.match.team1Score > matchup.match.team2Score
? 'text-green-600'
: 'text-gray-900'
}`}>
{matchup.match.team1Score}
</span>
<span className="text-gray-400">-</span>
<span className={`font-bold ${
matchup.match.team2Score > matchup.match.team1Score
? 'text-green-600'
: 'text-gray-900'
}`}>
{matchup.match.team2Score}
</span>
</div>
) : (
<button
onClick={() => {
setActiveTab("results")
// Optionally pass matchup data to results tab
}}
className="px-3 py-1 border border-green-300 rounded text-sm font-medium text-green-700 hover:bg-green-50"
>
Enter Result
</button>
)}
</div>
</div>
</div>
)
})}
</div>
)}
</div>
))}
{hasSchedule && (
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">
Schedule Actions
</h2>
<ScheduleGenerator
tournamentId={parseInt(tournamentId)}
teamCount={Math.floor(participants.length / 2)}
existingRounds={rounds.length}
/>
</div>
)}
</>
)
case "results":
return (
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">
Enter Match Results
</h2>
<MatchEditor
tournamentId={parseInt(tournamentId)}
players={allPlayers}
matches={matches}
targetScore={tournament.targetScore}
allowTies={tournament.allowTies}
/>
</div>
)
default:
return 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">
{/* Breadcrumb */}
<nav className="mb-4">
<ol className="flex items-center space-x-2">
<li>
<Link href="/admin/tournaments" className="text-green-600 hover:text-green-900">
Tournaments
</Link>
</li>
<li className="text-gray-400">/</li>
<li className="text-gray-600">{tournament.name}</li>
</ol>
</nav>
{/* Tournament Header */}
<div className="bg-white shadow rounded-lg p-6 mb-6">
<div className="flex justify-between items-start">
<div>
<h1 className="text-2xl font-bold text-gray-900">{tournament.name}</h1>
<p className="text-gray-500 mt-1">
{tournament.format} - {tournament.status}
</p>
{tournament.eventDate && (
<p className="text-sm text-gray-400 mt-1">
{new Date(tournament.eventDate).toLocaleDateString()}
</p>
)}
</div>
<div className="flex space-x-2">
<Link
href={`/admin/tournaments/${tournament.id}/edit`}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
Edit
</Link>
<a
href={`/api/tournaments/${tournament.id}/export`}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
Export CSV
</a>
<DeleteTournamentButton
tournamentId={tournament.id}
tournamentName={tournament.name}
matchCount={matches.length}
/>
</div>
</div>
{/* Quick Stats */}
<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">Participants</p>
<p className="text-2xl font-bold text-gray-900">
{participants.length}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-4 text-center">
<p className="text-sm text-gray-500">Rounds</p>
<p className="text-2xl font-bold text-gray-900">
{rounds.length}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-4 text-center">
<p className="text-sm text-gray-500">Matchups</p>
<p className="text-2xl font-bold text-gray-900">
{matches.length}
</p>
</div>
</div>
</div>
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab("overview")}
className={`whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === "overview"
? "border-green-500 text-green-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Overview
</button>
<button
onClick={() => setActiveTab("participants")}
className={`whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === "participants"
? "border-green-500 text-green-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Participants
</button>
<button
onClick={() => setActiveTab("matchups")}
className={`whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === "matchups"
? "border-green-500 text-green-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Matchups
</button>
<button
onClick={() => setActiveTab("schedule")}
className={`whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === "schedule"
? "border-green-500 text-green-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Schedule
</button>
<button
onClick={() => setActiveTab("results")}
className={`whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === "results"
? "border-green-500 text-green-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Results
</button>
<span className="border-transparent text-gray-400 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm cursor-not-allowed">
Analytics
</span>
</nav>
</div>
{/* Content */}
<div className="mt-6">
{renderTabContent()}
</div>
</div>
</main>
</div>
)
}