fix: check response.ok before parsing JSON in fetch calls

Fix JSON parsing errors when server returns non-JSON responses:
- Check response.ok before calling response.json()
- Add fallback error messages using status text
- Apply fix to all fetch calls across 12 components

This prevents 'JSON.parse: unexpected character' errors when
server returns HTML error pages or other non-JSON responses.
This commit is contained in:
2026-04-03 19:27:10 -07:00
parent 9b15d7e61f
commit 37eb1f8e21
12 changed files with 812 additions and 226 deletions
+10
View File
@@ -58,6 +58,16 @@ export default function AdminMatchesPage() {
method: "DELETE", method: "DELETE",
}) })
if (!response.ok) {
try {
const errorData = await response.json()
alert(`Error: ${errorData.error || 'Failed to delete match'}`)
} catch {
alert(`Error: ${response.status} ${response.statusText}`)
}
return
}
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
+31 -13
View File
@@ -93,13 +93,15 @@ export default function UploadMatchesPage() {
eventDate: new Date().toISOString(), eventDate: new Date().toISOString(),
}), }),
}) })
const data = await response.json() if (response.ok) {
if (response.ok && data.tournament) { const data = await response.json()
const newTournaments = [data.tournament] if (data.tournament) {
setTournaments(newTournaments) const newTournaments = [data.tournament]
setSelectedTournament(data.tournament.id.toString()) setTournaments(newTournaments)
setManualTournament(data.tournament.id.toString()) setSelectedTournament(data.tournament.id.toString())
return data.tournament setManualTournament(data.tournament.id.toString())
return data.tournament
}
} }
return null return null
} catch (err) { } catch (err) {
@@ -155,12 +157,20 @@ export default function UploadMatchesPage() {
body: formData, body: formData,
}) })
const data = await response.json()
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || "Failed to upload CSV") try {
const errorData = await response.json()
throw new Error(errorData.error || "Failed to upload CSV")
} catch (jsonError) {
if (jsonError instanceof Error && jsonError.message !== "Failed to upload CSV") {
throw jsonError
}
throw new Error(`Failed to upload CSV: ${response.status} ${response.statusText}`)
}
} }
const data = await response.json()
setCsvSuccess( setCsvSuccess(
`Successfully imported ${data.importedCount} matches. ` + `Successfully imported ${data.importedCount} matches. ` +
`${data.errorCount || 0} errors occurred.` + `${data.errorCount || 0} errors occurred.` +
@@ -262,12 +272,20 @@ export default function UploadMatchesPage() {
body: JSON.stringify({ matches: matchesData }), body: JSON.stringify({ matches: matchesData }),
}) })
const data = await response.json()
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || "Failed to create matches") try {
const errorData = await response.json()
throw new Error(errorData.error || "Failed to create matches")
} catch (jsonError) {
if (jsonError instanceof Error && jsonError.message !== "Failed to create matches") {
throw jsonError
}
throw new Error(`Failed to create matches: ${response.status} ${response.statusText}`)
}
} }
const data = await response.json()
setManualSuccess( setManualSuccess(
`Successfully created ${data.importedCount} matches. ` + `Successfully created ${data.importedCount} matches. ` +
`${data.errorCount || 0} errors occurred.` + `${data.errorCount || 0} errors occurred.` +
+32 -2
View File
@@ -64,6 +64,16 @@ export default function AdminPlayersPage() {
body: JSON.stringify({ name: newName.trim() }), body: JSON.stringify({ name: newName.trim() }),
}) })
if (!response.ok) {
try {
const errorData = await response.json()
alert(`Error: ${errorData.error || 'Failed to update player'}`)
} catch {
alert(`Error: ${response.status} ${response.statusText}`)
}
return
}
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
@@ -105,6 +115,16 @@ export default function AdminPlayersPage() {
}), }),
}) })
if (!response.ok) {
try {
const errorData = await response.json()
alert(`Error: ${errorData.error || 'Failed to merge players'}`)
} catch {
alert(`Error: ${response.status} ${response.statusText}`)
}
return
}
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
@@ -117,7 +137,7 @@ export default function AdminPlayersPage() {
alert(`Error: ${data.error}`) alert(`Error: ${data.error}`)
} }
} catch (err: unknown) { } catch (err: unknown) {
alert(`Error: ${err instanceof Error ? err.message : 'Unknown error occurred'}`) alert(`Error: ${err instanceof Error ? err.message : "Unknown error occurred"}`)
} finally { } finally {
setIsMerging(false) setIsMerging(false)
} }
@@ -134,6 +154,16 @@ export default function AdminPlayersPage() {
method: "DELETE", method: "DELETE",
}) })
if (!response.ok) {
try {
const errorData = await response.json()
alert(`Error: ${errorData.error || 'Failed to delete player'}`)
} catch {
alert(`Error: ${response.status} ${response.statusText}`)
}
return
}
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
@@ -142,7 +172,7 @@ export default function AdminPlayersPage() {
alert(`Error: ${data.error}`) alert(`Error: ${data.error}`)
} }
} catch (err: unknown) { } catch (err: unknown) {
alert(`Error: ${err instanceof Error ? err.message : "Unknown error occurred"}`) alert(`Error: ${err instanceof Error ? err.message : 'Unknown error occurred'}`)
} finally { } finally {
setDeletingId(null) setDeletingId(null)
} }
+359 -150
View File
@@ -10,36 +10,68 @@ interface Player {
currentElo: number currentElo: number
} }
interface Team {
id: number
player1: Player
player2: Player
}
interface BracketMatchup {
id: number
roundId: number
team1Id: number | null
team2Id: number | null
tableNumber: number | null
status: string
team1: Team | null
team2: Team | null
matchId: number | null
}
interface TournamentRound {
id: number
roundNumber: number
status: string
matchups: BracketMatchup[]
}
interface Schedule {
rounds: TournamentRound[]
}
interface Match {
id: number
team1P1Id: number
team1P2Id: number
team2P1Id: number
team2P2Id: number
team1Score: number
team2Score: number
status: string
}
interface Tournament { interface Tournament {
id: number id: number
name: string name: string
eventDate: string | null eventDate: string | null
format: string format: string
participants: { tournamentType: string
player: Player participants: { player: Player }[]
}[]
}
interface GameEntry {
round: number
table: string
player1: string
player2: string
score1: number
player3: string
player4: string
score2: number
} }
export default function TournamentEntryPage({ params }: { params: Promise<{ id: string }> }) { export default function TournamentEntryPage({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter() const router = useRouter()
const [tournament, setTournament] = useState<Tournament | null>(null) const [tournament, setTournament] = useState<Tournament | null>(null)
const [tournamentId, setTournamentId] = useState<number | null>(null) const [tournamentId, setTournamentId] = useState<number | null>(null)
const [gameText, setGameText] = useState("") const [schedule, setSchedule] = useState<Schedule | null>(null)
const [parsedGames, setParsedGames] = useState<GameEntry[]>([]) const [matches, setMatches] = useState<Match[]>([])
const [error, setError] = useState("") const [error, setError] = useState("")
const [success, setSuccess] = useState("") const [success, setSuccess] = useState("")
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [selectedRoundId, setSelectedRoundId] = useState<number | null>(null)
const [selectedMatchupId, setSelectedMatchupId] = useState<number | null>(null)
const [team1Score, setTeam1Score] = useState("")
const [team2Score, setTeam2Score] = useState("")
// Parse params and validate tournamentId // Parse params and validate tournamentId
useEffect(() => { useEffect(() => {
@@ -55,10 +87,12 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id:
parseParams() parseParams()
}, [params, router]) }, [params, router])
// Load tournament when tournamentId is available // Load tournament, schedule, and matches
useEffect(() => { useEffect(() => {
if (tournamentId) { if (tournamentId) {
loadTournament() loadTournament()
loadSchedule()
loadMatches()
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [tournamentId]) }, [tournamentId])
@@ -66,8 +100,8 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id:
const loadTournament = async () => { const loadTournament = async () => {
try { try {
const response = await fetch(`/api/tournaments/${tournamentId}`) const response = await fetch(`/api/tournaments/${tournamentId}`)
const data = await response.json()
if (response.ok) { if (response.ok) {
const data = await response.json()
setTournament(data.tournament) setTournament(data.tournament)
} }
} catch (err) { } catch (err) {
@@ -75,44 +109,61 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id:
} }
} }
const parseGameText = (text: string): GameEntry[] => { const loadSchedule = async () => {
const lines = text.trim().split("\n") try {
const games: GameEntry[] = [] const response = await fetch(`/api/tournaments/${tournamentId}/schedule`)
if (response.ok) {
for (const line of lines) { const data = await response.json()
// Skip empty lines and comments // API returns { rounds: [...] }, wrap in schedule object
if (!line.trim() || line.trim().startsWith("#")) continue const rounds = data.rounds || []
setSchedule({ rounds })
// Parse tab-separated or comma-separated values if (rounds.length > 0) {
const parts = line.split(/[,\t]/).map(p => p.trim()) setSelectedRoundId(rounds[0].id)
}
if (parts.length >= 7) {
games.push({
round: parseInt(parts[0]) || 1,
table: parts[1] || "",
player1: parts[2],
player2: parts[3],
score1: parseInt(parts[4]) || 0,
player3: parts[5],
player4: parts[6],
score2: parseInt(parts[7]) || 0,
})
} }
} catch (err) {
console.error("Failed to load schedule:", err)
} }
return games
} }
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const loadMatches = async () => {
const text = e.target.value try {
setGameText(text) const response = await fetch(`/api/tournaments/${tournamentId}/matches`)
const games = parseGameText(text) if (response.ok) {
setParsedGames(games) const data = await response.json()
setMatches(data.matches || [])
}
} catch (err) {
console.error("Failed to load matches:", err)
}
} }
const submitGames = async () => { const selectedRound = schedule?.rounds.find(r => r.id === selectedRoundId)
if (parsedGames.length === 0) { const selectedMatchup = selectedRound?.matchups.find(m => m.id === selectedMatchupId)
setError("No valid games to submit")
const getMatchupMatch = (matchup: BracketMatchup): Match | undefined => {
if (!matchup.matchId) return undefined
return matches.find(m => m.id === matchup.matchId)
}
const isMatchupCompleted = (matchup: BracketMatchup): boolean => {
return getMatchupMatch(matchup) !== undefined
}
const handleSelectMatchup = (matchup: BracketMatchup) => {
setSelectedMatchupId(matchup.id)
setTeam1Score("")
setTeam2Score("")
}
const handleSubmitScore = async () => {
if (!selectedMatchup || !tournamentId) return
const score1 = parseInt(team1Score)
const score2 = parseInt(team2Score)
if (isNaN(score1) || isNaN(score2)) {
setError("Please enter valid scores for both teams")
return return
} }
@@ -121,25 +172,55 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id:
setIsLoading(true) setIsLoading(true)
try { try {
// Get team player IDs
const team1 = selectedMatchup.team1
const team2 = selectedMatchup.team2
if (!team1 || !team2) {
throw new Error("Teams not found for this matchup")
}
// Create match via bulk API
const matchData = {
round: selectedRound?.roundNumber || 1,
table: selectedMatchup.tableNumber || 1,
player1: team1.player1.name,
player2: team1.player2.name,
score1: score1,
player3: team2.player1.name,
player4: team2.player2.name,
score2: score2,
}
const response = await fetch(`/api/tournaments/${tournamentId}/games/bulk`, { const response = await fetch(`/api/tournaments/${tournamentId}/games/bulk`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
games: parsedGames, games: [matchData],
}), }),
}) })
const data = await response.json()
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || "Failed to submit games") try {
const errorData = await response.json()
throw new Error(errorData.error || "Failed to submit score")
} catch (jsonError) {
if (jsonError instanceof Error && jsonError.message !== "Failed to submit score") {
throw jsonError
}
throw new Error(`Failed to submit score: ${response.status} ${response.statusText}`)
}
} }
setSuccess(`Successfully imported ${data.importedCount} games`) const data = await response.json()
setGameText("")
setParsedGames([]) setSuccess(`Score recorded: ${team1.player1.name} & ${team1.player2.name} ${score1} - ${score2} ${team2.player1.name} & ${team2.player2.name}`)
setTeam1Score("")
setTeam2Score("")
setSelectedMatchupId(null)
loadMatches() // Refresh matches
} catch (err) { } catch (err) {
if (err instanceof Error) { if (err instanceof Error) {
setError(err.message) setError(err.message)
@@ -164,6 +245,9 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id:
) )
} }
const completedMatchups = schedule?.rounds.flatMap(r => r.matchups).filter(m => isMatchupCompleted(m)).length || 0
const totalMatchups = schedule?.rounds.flatMap(r => r.matchups).length || 0
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<Navigation /> <Navigation />
@@ -178,7 +262,7 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id:
Back to Tournament Back to Tournament
</button> </button>
<h1 className="text-3xl font-bold text-gray-900"> <h1 className="text-3xl font-bold text-gray-900">
Game Entry: {tournament.name} {tournament.name}
</h1> </h1>
{tournament.eventDate && ( {tournament.eventDate && (
<p className="text-gray-600"> <p className="text-gray-600">
@@ -199,19 +283,92 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id:
</div> </div>
)} )}
{/* Progress Summary */}
{schedule && (
<div className="bg-white shadow rounded-lg p-4 mb-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-medium text-gray-900">Tournament Progress</h2>
<p className="text-sm text-gray-500">
{completedMatchups} of {totalMatchups} games completed
</p>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-green-600">
{totalMatchups > 0 ? Math.round((completedMatchups / totalMatchups) * 100) : 0}%
</div>
<p className="text-sm text-gray-500">complete</p>
</div>
</div>
<div className="mt-3 bg-gray-200 rounded-full h-2">
<div
className="bg-green-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${totalMatchups > 0 ? (completedMatchups / totalMatchups) * 100 : 0}%` }}
/>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Participants Panel */} {/* Rounds Panel */}
<div className="lg:col-span-1"> <div className="lg:col-span-1">
<div className="bg-white shadow rounded-lg p-4"> <div className="bg-white shadow rounded-lg p-4">
<h2 className="text-lg font-medium text-gray-900 mb-3">Rounds</h2>
{schedule?.rounds ? (
<div className="space-y-2">
{schedule.rounds.map(round => {
const roundCompleted = round.matchups.every(m => isMatchupCompleted(m))
const roundInProgress = round.matchups.some(m => isMatchupCompleted(m)) && !roundCompleted
return (
<button
key={round.id}
onClick={() => setSelectedRoundId(round.id)}
className={`w-full text-left px-3 py-2 rounded-md border transition-colors ${
selectedRoundId === round.id
? 'border-green-500 bg-green-50'
: 'border-gray-200 hover:bg-gray-50'
}`}
>
<div className="flex items-center justify-between">
<span className="font-medium">Round {round.roundNumber}</span>
<span className={`text-xs px-2 py-1 rounded-full ${
roundCompleted
? 'bg-green-100 text-green-800'
: roundInProgress
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-600'
}`}>
{roundCompleted ? 'Completed' : roundInProgress ? 'In Progress' : 'Not Started'}
</span>
</div>
<p className="text-xs text-gray-500 mt-1">
{round.matchups.length} matchup{round.matchups.length !== 1 ? 's' : ''}
</p>
</button>
)
})}
</div>
) : (
<div className="text-center py-8">
<p className="text-gray-500">No schedule generated yet.</p>
<button
onClick={() => router.push(`/admin/tournaments/${tournamentId}`)}
className="mt-2 text-green-600 hover:text-green-800 text-sm"
>
Generate schedule from tournament page
</button>
</div>
)}
</div>
{/* Participants Panel */}
<div className="bg-white shadow rounded-lg p-4 mt-4">
<h2 className="text-lg font-medium text-gray-900 mb-3"> <h2 className="text-lg font-medium text-gray-900 mb-3">
Participants ({tournament.participants.length}) Participants ({tournament.participants.length})
</h2> </h2>
<div className="max-h-96 overflow-y-auto"> <div className="max-h-48 overflow-y-auto">
{tournament.participants.map(({ player }) => ( {tournament.participants.map(({ player }) => (
<div <div key={player.id} className="py-1 text-sm text-gray-700">
key={player.id}
className="py-1 text-sm text-gray-700"
>
{player.name} {player.name}
</div> </div>
))} ))}
@@ -219,99 +376,151 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id:
</div> </div>
</div> </div>
{/* Game Entry Panel */} {/* Round Detail Panel */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<div className="bg-white shadow rounded-lg p-4"> <div className="bg-white shadow rounded-lg p-4">
<h2 className="text-lg font-medium text-gray-900 mb-3"> <h2 className="text-lg font-medium text-gray-900 mb-3">
Enter Games {selectedRound ? `Round ${selectedRound.roundNumber} Matchups` : 'Select a Round'}
</h2> </h2>
<div className="mb-4"> {selectedRound ? (
<label className="block text-sm font-medium text-gray-700 mb-2"> <div className="space-y-3">
Format Instructions {selectedRound.matchups.map((matchup, index) => {
</label> const completed = isMatchupCompleted(matchup)
<div className="bg-gray-50 rounded-md p-3 text-sm text-gray-600"> const match = getMatchupMatch(matchup)
<p className="font-medium mb-1">Tab or comma-separated format:</p> const isSelected = selectedMatchupId === matchup.id
<code className="block bg-white p-2 rounded mb-2">
Round Table Player1 Player2 Score1 Player3 Player4 Score2 return (
</code> <div
<p className="text-xs text-gray-500"> key={matchup.id}
Example: 1 1 John Smith Jane Doe 10 Mike Johnson Sarah Brown 5 className={`border rounded-md p-4 transition-colors ${
</p> completed
<p className="text-xs text-gray-500 mt-2"> ? 'bg-green-50 border-green-200'
Lines starting with # are treated as comments : isSelected
</p> ? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300 cursor-pointer'
}`}
onClick={() => !completed && handleSelectMatchup(matchup)}
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-500">
Match {index + 1}
{matchup.tableNumber && ` • Table ${matchup.tableNumber}`}
</span>
{completed && (
<span className="text-xs px-2 py-1 rounded-full bg-green-100 text-green-800">
Completed
</span>
)}
</div>
{matchup.team1 && matchup.team2 ? (
<div className="grid grid-cols-7 gap-2 items-center">
<div className="col-span-3">
<p className="font-medium text-gray-900">
{matchup.team1.player1.name} & {matchup.team1.player2.name}
</p>
<p className="text-xs text-gray-500">
Team {matchup.team1.id}
</p>
</div>
<div className="col-span-1 text-center">
{completed && match ? (
<div className="flex items-center justify-center gap-1">
<span className={`text-lg font-bold ${match.team1Score > match.team2Score ? 'text-green-600' : 'text-gray-600'}`}>
{match.team1Score}
</span>
<span className="text-gray-400">-</span>
<span className={`text-lg font-bold ${match.team2Score > match.team1Score ? 'text-green-600' : 'text-gray-600'}`}>
{match.team2Score}
</span>
</div>
) : (
<span className="text-gray-400 text-sm">vs</span>
)}
</div>
<div className="col-span-3 text-right">
<p className="font-medium text-gray-900">
{matchup.team2.player1.name} & {matchup.team2.player2.name}
</p>
<p className="text-xs text-gray-500">
Team {matchup.team2.id}
</p>
</div>
</div>
) : (
<p className="text-gray-400 text-sm">Teams not assigned</p>
)}
{/* Score Entry Form */}
{isSelected && !completed && matchup.team1 && matchup.team2 && (
<div className="mt-4 pt-4 border-t border-gray-200">
<p className="text-sm font-medium text-gray-700 mb-3">Enter Score</p>
<div className="grid grid-cols-9 gap-2 items-end">
<div className="col-span-4">
<label className="block text-xs text-gray-500 mb-1">
{matchup.team1.player1.name} & {matchup.team1.player2.name}
</label>
<input
type="number"
min="0"
max="10"
className="w-full border border-gray-300 rounded-md py-2 px-3 text-sm focus:outline-none focus:ring-green-500 focus:border-green-500"
value={team1Score}
onChange={(e) => setTeam1Score(e.target.value)}
placeholder="0"
/>
</div>
<div className="col-span-1 flex items-center justify-center pb-2">
<span className="text-gray-400">-</span>
</div>
<div className="col-span-4">
<label className="block text-xs text-gray-500 mb-1">
{matchup.team2.player1.name} & {matchup.team2.player2.name}
</label>
<input
type="number"
min="0"
max="10"
className="w-full border border-gray-300 rounded-md py-2 px-3 text-sm focus:outline-none focus:ring-green-500 focus:border-green-500"
value={team2Score}
onChange={(e) => setTeam2Score(e.target.value)}
placeholder="0"
/>
</div>
</div>
<div className="mt-3 flex justify-end space-x-2">
<button
type="button"
onClick={() => {
setSelectedMatchupId(null)
setTeam1Score("")
setTeam2Score("")
}}
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-800"
>
Cancel
</button>
<button
type="button"
onClick={handleSubmitScore}
disabled={isLoading || !team1Score || !team2Score}
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50"
>
{isLoading ? "Saving..." : "Save Score"}
</button>
</div>
</div>
)}
</div>
)
})}
</div> </div>
</div> ) : (
<div className="text-center py-8 text-gray-500">
<div className="mb-4"> Select a round to view matchups
<label htmlFor="gameText" className="block text-sm font-medium text-gray-700">
Game Data
</label>
<textarea
id="gameText"
rows={15}
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 font-mono text-sm focus:outline-none focus:ring-green-500 focus:border-green-500"
placeholder="Round Table Player1 Player2 Score1 Player3 Player4 Score2&#10;1 1 John Smith Jane Doe 10 Mike Johnson Sarah Brown 5&#10;1 2 Alice Johnson Bob Smith 8 Charlie Brown Diana Davis 7"
value={gameText}
onChange={handleTextChange}
/>
</div>
{/* Parsed Games Preview */}
{parsedGames.length > 0 && (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Parsed Games ({parsedGames.length})
</label>
<div className="max-h-48 overflow-y-auto border border-gray-300 rounded-md">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Round</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Table</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Team 1</th>
<th className="px-3 py-2 text-center text-xs font-medium text-gray-500">Score</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Team 2</th>
<th className="px-3 py-2 text-center text-xs font-medium text-gray-500">Score</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{parsedGames.map((game, index) => (
<tr key={index}>
<td className="px-3 py-2 text-sm text-gray-900">{game.round}</td>
<td className="px-3 py-2 text-sm text-gray-900">{game.table}</td>
<td className="px-3 py-2 text-sm text-gray-900">
{game.player1} & {game.player2}
</td>
<td className="px-3 py-2 text-sm text-center font-medium">{game.score1}</td>
<td className="px-3 py-2 text-sm text-gray-900">
{game.player3} & {game.player4}
</td>
<td className="px-3 py-2 text-sm text-center font-medium">{game.score2}</td>
</tr>
))}
</tbody>
</table>
</div>
</div> </div>
)} )}
<div className="flex justify-end space-x-3">
<button
onClick={() => router.push(`/admin/tournaments/${tournamentId}`)}
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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
Cancel
</button>
<button
onClick={submitGames}
disabled={isLoading || parsedGames.length === 0}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? "Submitting..." : `Submit ${parsedGames.length} Games`}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
+314 -43
View File
@@ -1,8 +1,9 @@
"use client" "use client"
import { useState, useEffect } from "react" import { useState, useEffect, useMemo } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Navigation from "@/components/Navigation" import Navigation from "@/components/Navigation"
import { expectedRounds, expectedMatchups } from "@/lib/schedule-generator"
interface Player { interface Player {
id: number id: number
@@ -15,10 +16,12 @@ interface TournamentFormData {
description: string description: string
eventDate: string eventDate: string
format: string format: string
maxParticipants: string tournamentType: 'individual' | 'team'
participants: number[] // Array of player IDs participants: number[]
} }
type PairingMethod = 'elo' | 'manual' | 'random'
export default function NewTournamentPage() { export default function NewTournamentPage() {
const router = useRouter() const router = useRouter()
const [step, setStep] = useState(1) const [step, setStep] = useState(1)
@@ -27,7 +30,7 @@ export default function NewTournamentPage() {
description: "", description: "",
eventDate: "", eventDate: "",
format: "round_robin", format: "round_robin",
maxParticipants: "", tournamentType: "individual",
participants: [], participants: [],
}) })
const [error, setError] = useState("") const [error, setError] = useState("")
@@ -39,6 +42,48 @@ export default function NewTournamentPage() {
const [selectedPlayers, setSelectedPlayers] = useState<Player[]>([]) const [selectedPlayers, setSelectedPlayers] = useState<Player[]>([])
const [isSearching, setIsSearching] = useState(false) const [isSearching, setIsSearching] = useState(false)
// Sorting state
const [sortConfig, setSortConfig] = useState<{ key: 'name' | 'currentElo'; direction: 'asc' | 'desc' }>({
key: 'name',
direction: 'asc'
})
// Team pairing state
const [pairingMethod, setPairingMethod] = useState<PairingMethod>('elo')
// Dynamic round preview calculation
const scheduleInfo = useMemo(() => {
if (formData.tournamentType === 'team') {
const teamCount = Math.floor(selectedPlayers.length / 2)
if (teamCount < 2) return null
return {
rounds: expectedRounds(teamCount),
matchups: expectedMatchups(teamCount),
teams: teamCount,
playerCount: selectedPlayers.length,
}
} else {
// Individual: players form teams of 2 for Euchre
const teamCount = Math.floor(selectedPlayers.length / 2)
if (teamCount < 2) return null
return {
rounds: expectedRounds(teamCount),
matchups: expectedMatchups(teamCount),
teams: teamCount,
playerCount: selectedPlayers.length,
}
}
}, [selectedPlayers.length, formData.tournamentType])
// Minimum players by format
const getMinPlayers = () => {
if (formData.tournamentType === 'team') {
return 4 // At least 2 teams
}
// Individual tournaments still need pairs for Euchre
return 4 // At least 2 teams of 2
}
// Search for players as user types // Search for players as user types
useEffect(() => { useEffect(() => {
const searchPlayers = async () => { const searchPlayers = async () => {
@@ -50,9 +95,8 @@ export default function NewTournamentPage() {
setIsSearching(true) setIsSearching(true)
try { try {
const response = await fetch(`/api/players/search?q=${encodeURIComponent(searchQuery)}`) const response = await fetch(`/api/players/search?q=${encodeURIComponent(searchQuery)}`)
const data = await response.json()
if (response.ok) { if (response.ok) {
// Filter out already selected players const data = await response.json()
const availablePlayers = data.players.filter( const availablePlayers = data.players.filter(
(p: Player) => !selectedPlayers.find(sp => sp.id === p.id) (p: Player) => !selectedPlayers.find(sp => sp.id === p.id)
) )
@@ -79,6 +123,60 @@ export default function NewTournamentPage() {
setSelectedPlayers(selectedPlayers.filter(p => p.id !== playerId)) setSelectedPlayers(selectedPlayers.filter(p => p.id !== playerId))
} }
const handleSort = (key: 'name' | 'currentElo') => {
setSortConfig(prevConfig => ({
key,
direction: prevConfig.key === key && prevConfig.direction === 'asc' ? 'desc' : 'asc'
}))
}
const getSortedPlayers = () => {
const sorted = [...selectedPlayers].sort((a, b) => {
if (sortConfig.key === 'name') {
return sortConfig.direction === 'asc'
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name)
} else {
return sortConfig.direction === 'asc'
? a.currentElo - b.currentElo
: b.currentElo - a.currentElo
}
})
return sorted
}
// Generate team pairings based on method
const getTeamPairings = () => {
const sorted = [...selectedPlayers]
if (pairingMethod === 'elo') {
sorted.sort((a, b) => b.currentElo - a.currentElo)
const teams: { player1: Player; player2: Player }[] = []
for (let i = 0; i < sorted.length - 1; i += 2) {
teams.push({ player1: sorted[i], player2: sorted[i + 1] })
}
return teams
} else if (pairingMethod === 'random') {
// Shuffle using Fisher-Yates
for (let i = sorted.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[sorted[i], sorted[j]] = [sorted[j], sorted[i]]
}
const teams: { player1: Player; player2: Player }[] = []
for (let i = 0; i < sorted.length - 1; i += 2) {
teams.push({ player1: sorted[i], player2: sorted[i + 1] })
}
return teams
} else {
// Manual: just use current order
const teams: { player1: Player; player2: Player }[] = []
for (let i = 0; i < sorted.length - 1; i += 2) {
teams.push({ player1: sorted[i], player2: sorted[i + 1] })
}
return teams
}
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
setFormData({ setFormData({
...formData, ...formData,
@@ -92,10 +190,6 @@ export default function NewTournamentPage() {
setError("Tournament name is required") setError("Tournament name is required")
return return
} }
if (selectedPlayers.length < 2) {
setError("At least 2 participants are required")
return
}
} }
setError("") setError("")
setStep(step + 1) setStep(step + 1)
@@ -112,7 +206,19 @@ export default function NewTournamentPage() {
setIsLoading(true) setIsLoading(true)
try { try {
// First create the tournament const minPlayers = getMinPlayers()
if (selectedPlayers.length < minPlayers) {
setError(`At least ${minPlayers} participants are required (${minPlayers / 2} teams)`)
setIsLoading(false)
return
}
if (selectedPlayers.length % 2 !== 0) {
setError("An even number of participants is required to form teams")
setIsLoading(false)
return
}
// Create the tournament
const tournamentResponse = await fetch("/api/tournaments", { const tournamentResponse = await fetch("/api/tournaments", {
method: "POST", method: "POST",
headers: { headers: {
@@ -123,7 +229,7 @@ export default function NewTournamentPage() {
description: formData.description, description: formData.description,
eventDate: formData.eventDate || null, eventDate: formData.eventDate || null,
format: formData.format, format: formData.format,
maxParticipants: formData.maxParticipants ? parseInt(formData.maxParticipants) : null, tournamentType: formData.tournamentType,
}), }),
}) })
@@ -148,8 +254,18 @@ export default function NewTournamentPage() {
}) })
} }
// Redirect to game entry page // Auto-generate schedule for round_robin
router.push(`/admin/tournaments/${tournamentId}/entry`) if (formData.format === 'round_robin') {
await fetch(`/api/tournaments/${tournamentId}/schedule`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
})
}
// Redirect to schedule view
router.push(`/admin/tournaments/${tournamentId}/schedule`)
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : "An unexpected error occurred"; const message = err instanceof Error ? err.message : "An unexpected error occurred";
setError(message) setError(message)
@@ -260,18 +376,25 @@ export default function NewTournamentPage() {
</div> </div>
<div> <div>
<label htmlFor="maxParticipants" className="block text-sm font-medium text-gray-700"> <label htmlFor="tournamentType" className="block text-sm font-medium text-gray-700">
Max Participants Tournament Type *
</label> </label>
<input <select
type="number" name="tournamentType"
name="maxParticipants" id="tournamentType"
id="maxParticipants" required
min="2"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm" className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
value={formData.maxParticipants} value={formData.tournamentType}
onChange={handleChange} onChange={handleChange}
/> >
<option value="individual">Individual (players compete as individuals)</option>
<option value="team">Team (players compete in pairs/teams)</option>
</select>
<p className="mt-1 text-sm text-gray-500">
{formData.tournamentType === 'individual'
? 'Players register individually and compete on their own.'
: 'Players are paired into teams of two for competition.'}
</p>
</div> </div>
</> </>
)} )}
@@ -279,14 +402,50 @@ export default function NewTournamentPage() {
{/* Step 2: Participants */} {/* Step 2: Participants */}
{step === 2 && ( {step === 2 && (
<> <>
{/* Round Preview */}
{scheduleInfo && (
<div className="bg-green-50 border border-green-200 rounded-md p-4">
<h3 className="text-sm font-medium text-green-800 mb-2">Tournament Preview</h3>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-green-600 font-medium">{scheduleInfo.teams}</span>
<span className="text-green-700"> teams</span>
</div>
<div>
<span className="text-green-600 font-medium">{scheduleInfo.rounds}</span>
<span className="text-green-700"> rounds</span>
</div>
<div>
<span className="text-green-600 font-medium">{scheduleInfo.matchups}</span>
<span className="text-green-700"> matchups</span>
</div>
</div>
<p className="text-xs text-green-600 mt-2">
{formData.tournamentType === 'team'
? 'Players will be paired into teams below.'
: 'Players will be paired into teams of 2 for each round.'}
</p>
</div>
)}
{/* Minimum Players Warning */}
{selectedPlayers.length > 0 && selectedPlayers.length < getMinPlayers() && (
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-3">
<p className="text-sm text-yellow-700">
Need at least {getMinPlayers()} players ({getMinPlayers() / 2} teams).
Currently {selectedPlayers.length} player{selectedPlayers.length !== 1 ? 's' : ''}.
</p>
</div>
)}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Add Participants Search Players
</label> </label>
<div className="relative"> <div className="relative">
<input <input
type="text" type="text"
placeholder="Search for players..." placeholder="Type a name to search..."
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm" className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
@@ -313,32 +472,144 @@ export default function NewTournamentPage() {
)} )}
</div> </div>
{/* Team Pairing Options (only for team tournaments) */}
{formData.tournamentType === 'team' && selectedPlayers.length >= 4 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Team Pairing Method
</label>
<div className="flex gap-4">
<label className="flex items-center">
<input
type="radio"
name="pairingMethod"
value="elo"
checked={pairingMethod === 'elo'}
onChange={(e) => setPairingMethod(e.target.value as PairingMethod)}
className="mr-2"
/>
<span className="text-sm">By ELO (best + worst)</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="pairingMethod"
value="manual"
checked={pairingMethod === 'manual'}
onChange={(e) => setPairingMethod(e.target.value as PairingMethod)}
className="mr-2"
/>
<span className="text-sm">Manual order</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="pairingMethod"
value="random"
checked={pairingMethod === 'random'}
onChange={(e) => setPairingMethod(e.target.value as PairingMethod)}
className="mr-2"
/>
<span className="text-sm">Random</span>
</label>
</div>
</div>
)}
{/* Selected Players */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Selected Participants ({selectedPlayers.length}) {formData.tournamentType === 'team'
? `Selected Players (${selectedPlayers.length})`
: `Selected Participants (${selectedPlayers.length})`}
</label> </label>
{selectedPlayers.length > 0 ? ( {selectedPlayers.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2"> <div className="border border-gray-300 rounded-md overflow-hidden">
{selectedPlayers.map(player => ( {/* Column Headers */}
<div <div className="grid grid-cols-12 bg-gray-100 border-b border-gray-300">
key={player.id} <div
className="bg-green-50 border border-green-200 rounded-md px-3 py-2 flex justify-between items-center" className="col-span-6 px-3 py-2 text-xs font-medium text-gray-600 cursor-pointer hover:bg-gray-200 flex items-center"
onClick={() => handleSort('name')}
> >
<span className="text-sm">{player.name}</span> Name
<button {sortConfig.key === 'name' && (
type="button" <span className="ml-1">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
onClick={() => removePlayer(player.id)} )}
className="text-red-600 hover:text-red-800 ml-2"
>
×
</button>
</div> </div>
))} <div
className="col-span-4 px-3 py-2 text-xs font-medium text-gray-600 cursor-pointer hover:bg-gray-200 flex items-center"
onClick={() => handleSort('currentElo')}
>
ELO
{sortConfig.key === 'currentElo' && (
<span className="ml-1">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
)}
</div>
<div className="col-span-2 px-3 py-2 text-xs font-medium text-gray-600">
Actions
</div>
</div>
{/* Player Rows */}
<div className="max-h-48 overflow-y-auto">
{getSortedPlayers().map(player => (
<div
key={player.id}
className="grid grid-cols-12 bg-green-50 border-b border-green-100 last:border-b-0"
>
<div className="col-span-6 px-3 py-2 text-sm truncate">
{player.name}
</div>
<div className="col-span-4 px-3 py-2 text-sm">
{player.currentElo}
</div>
<div className="col-span-2 px-3 py-2 flex justify-center">
<button
type="button"
onClick={() => removePlayer(player.id)}
className="text-red-600 hover:text-red-800"
>
×
</button>
</div>
</div>
))}
</div>
</div> </div>
) : ( ) : (
<p className="text-gray-500 text-sm">No participants added yet</p> <p className="text-gray-500 text-sm">No participants added yet</p>
)} )}
</div> </div>
{/* Team Preview (for team tournaments) */}
{formData.tournamentType === 'team' && selectedPlayers.length >= 4 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Team Pairings Preview ({getTeamPairings().length} teams)
</label>
<div className="border border-gray-300 rounded-md overflow-hidden">
<div className="grid grid-cols-12 bg-gray-100 border-b border-gray-300">
<div className="col-span-2 px-3 py-2 text-xs font-medium text-gray-600">Team</div>
<div className="col-span-5 px-3 py-2 text-xs font-medium text-gray-600">Player 1</div>
<div className="col-span-5 px-3 py-2 text-xs font-medium text-gray-600">Player 2</div>
</div>
<div className="max-h-48 overflow-y-auto">
{getTeamPairings().map((team, index) => (
<div key={index} className="grid grid-cols-12 bg-blue-50 border-b border-blue-100 last:border-b-0">
<div className="col-span-2 px-3 py-2 text-sm font-medium text-blue-700">
Team {index + 1}
</div>
<div className="col-span-5 px-3 py-2 text-sm">
{team.player1.name} <span className="text-gray-400">({team.player1.currentElo})</span>
</div>
<div className="col-span-5 px-3 py-2 text-sm">
{team.player2.name} <span className="text-gray-400">({team.player2.currentElo})</span>
</div>
</div>
))}
</div>
</div>
</div>
)}
</> </>
)} )}
@@ -375,10 +646,10 @@ export default function NewTournamentPage() {
</button> </button>
<button <button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading || selectedPlayers.length < getMinPlayers()}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50" className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{isLoading ? "Creating..." : "Create Tournament & Enter Games"} {isLoading ? "Creating..." : "Create Tournament & Generate Schedule"}
</button> </button>
</> </>
)} )}
@@ -36,10 +36,16 @@ export default function CreateUserForm({ selectedPlayer, availablePlayers }: Cre
body: JSON.stringify(formData), body: JSON.stringify(formData),
}) })
const data = await response.json()
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || "Failed to create user") try {
const errorData = await response.json()
throw new Error(errorData.error || "Failed to create user")
} catch (jsonError) {
if (jsonError instanceof Error && jsonError.message !== "Failed to create user") {
throw jsonError
}
throw new Error(`Failed to create user: ${response.status} ${response.statusText}`)
}
} }
setSuccess("User created successfully!") setSuccess("User created successfully!")
@@ -35,10 +35,16 @@ export default function EditUserForm({ user, availablePlayers }: EditUserFormPro
body: JSON.stringify(formData), body: JSON.stringify(formData),
}) })
const data = await response.json()
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || "Failed to update user") try {
const errorData = await response.json()
throw new Error(errorData.error || "Failed to update user")
} catch (jsonError) {
if (jsonError instanceof Error && jsonError.message !== "Failed to update user") {
throw jsonError
}
throw new Error(`Failed to update user: ${response.status} ${response.statusText}`)
}
} }
setSuccess("User updated successfully!") setSuccess("User updated successfully!")
+10
View File
@@ -26,6 +26,16 @@ export function DeleteTournamentButton({ tournamentId, tournamentName, matchCoun
}), }),
}) })
if (!response.ok) {
try {
const errorData = await response.json()
alert(`Error: ${errorData.error || 'Failed to delete tournament'}`)
} catch {
alert(`Error: ${response.status} ${response.statusText}`)
}
return
}
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
+6 -3
View File
@@ -56,10 +56,13 @@ export default function EditTournamentForm({ tournament }: EditTournamentFormPro
}), }),
}) })
const data = await response.json()
if (!response.ok) { if (!response.ok) {
setError(data.error || "Failed to update tournament") try {
const data = await response.json()
setError(data.error || "Failed to update tournament")
} catch {
setError(`Failed to update tournament: ${response.status} ${response.statusText}`)
}
setIsLoading(false) setIsLoading(false)
return return
} }
+6 -3
View File
@@ -124,10 +124,13 @@ export default function MatchEditor({ tournamentId, players, targetScore, allowT
}), }),
}) })
const data = await response.json()
if (!response.ok) { if (!response.ok) {
setError(data.error || "Failed to record match") try {
const data = await response.json()
setError(data.error || "Failed to record match")
} catch {
setError(`Failed to record match: ${response.status} ${response.statusText}`)
}
setIsLoading(false) setIsLoading(false)
return return
} }
+11
View File
@@ -18,6 +18,17 @@ export function RecalculateEloButton() {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}) })
if (!response.ok) {
try {
const errorData = await response.json()
alert(`Error: ${errorData.error || 'Failed to recalculate'}`)
} catch {
alert(`Error: ${response.status} ${response.statusText}`)
}
return
}
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
+15 -6
View File
@@ -25,14 +25,20 @@ export function ScheduleGenerator({ tournamentId, teamCount }: ScheduleGenerator
method: "POST", method: "POST",
}) })
const data = await response.json() // Check response status first before parsing JSON
if (!response.ok) { if (!response.ok) {
setError(data.error || "Failed to generate schedule") try {
const data = await response.json()
setError(data.error || "Failed to generate schedule")
} catch {
setError(`Failed to generate schedule: ${response.status} ${response.statusText}`)
}
setIsGenerating(false) setIsGenerating(false)
return return
} }
const data = await response.json()
setResult({ setResult({
roundsCreated: data.roundsCreated, roundsCreated: data.roundsCreated,
matchupsCreated: data.matchupsCreated, matchupsCreated: data.matchupsCreated,
@@ -58,10 +64,13 @@ export function ScheduleGenerator({ tournamentId, teamCount }: ScheduleGenerator
method: "DELETE", method: "DELETE",
}) })
const data = await response.json()
if (!response.ok) { if (!response.ok) {
setError(data.error || "Failed to delete schedule") try {
const data = await response.json()
setError(data.error || "Failed to delete schedule")
} catch {
setError(`Failed to delete schedule: ${response.status} ${response.statusText}`)
}
setIsGenerating(false) setIsGenerating(false)
return return
} }