From 37eb1f8e218635c18e5ece6c2680c3dec7dfa563 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Fri, 3 Apr 2026 19:27:10 -0700 Subject: [PATCH] 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. --- src/app/admin/matches/page.tsx | 10 + src/app/admin/matches/upload/page.tsx | 44 +- src/app/admin/players/page.tsx | 34 +- src/app/admin/tournaments/[id]/entry/page.tsx | 509 ++++++++++++------ src/app/admin/tournaments/new/page.tsx | 357 ++++++++++-- .../admin/users/components/CreateUserForm.tsx | 12 +- .../admin/users/components/EditUserForm.tsx | 12 +- src/components/DeleteTournamentButton.tsx | 10 + src/components/EditTournamentForm.tsx | 9 +- src/components/MatchEditor.tsx | 9 +- src/components/RecalculateEloButton.tsx | 11 + src/components/ScheduleGenerator.tsx | 21 +- 12 files changed, 812 insertions(+), 226 deletions(-) diff --git a/src/app/admin/matches/page.tsx b/src/app/admin/matches/page.tsx index 55294cd..de69a80 100644 --- a/src/app/admin/matches/page.tsx +++ b/src/app/admin/matches/page.tsx @@ -58,6 +58,16 @@ export default function AdminMatchesPage() { 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() if (data.success) { diff --git a/src/app/admin/matches/upload/page.tsx b/src/app/admin/matches/upload/page.tsx index 16b8b12..f72f2dc 100644 --- a/src/app/admin/matches/upload/page.tsx +++ b/src/app/admin/matches/upload/page.tsx @@ -93,13 +93,15 @@ export default function UploadMatchesPage() { eventDate: new Date().toISOString(), }), }) - const data = await response.json() - if (response.ok && data.tournament) { - const newTournaments = [data.tournament] - setTournaments(newTournaments) - setSelectedTournament(data.tournament.id.toString()) - setManualTournament(data.tournament.id.toString()) - return data.tournament + if (response.ok) { + const data = await response.json() + if (data.tournament) { + const newTournaments = [data.tournament] + setTournaments(newTournaments) + setSelectedTournament(data.tournament.id.toString()) + setManualTournament(data.tournament.id.toString()) + return data.tournament + } } return null } catch (err) { @@ -155,12 +157,20 @@ export default function UploadMatchesPage() { body: formData, }) - const data = await response.json() - 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( `Successfully imported ${data.importedCount} matches. ` + `${data.errorCount || 0} errors occurred.` + @@ -262,12 +272,20 @@ export default function UploadMatchesPage() { body: JSON.stringify({ matches: matchesData }), }) - const data = await response.json() - 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( `Successfully created ${data.importedCount} matches. ` + `${data.errorCount || 0} errors occurred.` + diff --git a/src/app/admin/players/page.tsx b/src/app/admin/players/page.tsx index 17b9880..ec0563f 100644 --- a/src/app/admin/players/page.tsx +++ b/src/app/admin/players/page.tsx @@ -64,6 +64,16 @@ export default function AdminPlayersPage() { 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() 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() if (data.success) { @@ -117,7 +137,7 @@ export default function AdminPlayersPage() { alert(`Error: ${data.error}`) } } catch (err: unknown) { - alert(`Error: ${err instanceof Error ? err.message : 'Unknown error occurred'}`) + alert(`Error: ${err instanceof Error ? err.message : "Unknown error occurred"}`) } finally { setIsMerging(false) } @@ -134,6 +154,16 @@ export default function AdminPlayersPage() { 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() if (data.success) { @@ -142,7 +172,7 @@ export default function AdminPlayersPage() { alert(`Error: ${data.error}`) } } catch (err: unknown) { - alert(`Error: ${err instanceof Error ? err.message : "Unknown error occurred"}`) + alert(`Error: ${err instanceof Error ? err.message : 'Unknown error occurred'}`) } finally { setDeletingId(null) } diff --git a/src/app/admin/tournaments/[id]/entry/page.tsx b/src/app/admin/tournaments/[id]/entry/page.tsx index 550c286..5d45137 100644 --- a/src/app/admin/tournaments/[id]/entry/page.tsx +++ b/src/app/admin/tournaments/[id]/entry/page.tsx @@ -10,36 +10,68 @@ interface Player { 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 { id: number name: string eventDate: string | null format: string - participants: { - player: Player - }[] -} - -interface GameEntry { - round: number - table: string - player1: string - player2: string - score1: number - player3: string - player4: string - score2: number + tournamentType: string + participants: { player: Player }[] } export default function TournamentEntryPage({ params }: { params: Promise<{ id: string }> }) { const router = useRouter() const [tournament, setTournament] = useState(null) const [tournamentId, setTournamentId] = useState(null) - const [gameText, setGameText] = useState("") - const [parsedGames, setParsedGames] = useState([]) + const [schedule, setSchedule] = useState(null) + const [matches, setMatches] = useState([]) const [error, setError] = useState("") const [success, setSuccess] = useState("") const [isLoading, setIsLoading] = useState(false) + const [selectedRoundId, setSelectedRoundId] = useState(null) + const [selectedMatchupId, setSelectedMatchupId] = useState(null) + const [team1Score, setTeam1Score] = useState("") + const [team2Score, setTeam2Score] = useState("") // Parse params and validate tournamentId useEffect(() => { @@ -55,10 +87,12 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id: parseParams() }, [params, router]) - // Load tournament when tournamentId is available + // Load tournament, schedule, and matches useEffect(() => { if (tournamentId) { loadTournament() + loadSchedule() + loadMatches() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [tournamentId]) @@ -66,8 +100,8 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id: const loadTournament = async () => { try { const response = await fetch(`/api/tournaments/${tournamentId}`) - const data = await response.json() if (response.ok) { + const data = await response.json() setTournament(data.tournament) } } catch (err) { @@ -75,44 +109,61 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id: } } - const parseGameText = (text: string): GameEntry[] => { - const lines = text.trim().split("\n") - const games: GameEntry[] = [] - - for (const line of lines) { - // Skip empty lines and comments - if (!line.trim() || line.trim().startsWith("#")) continue - - // Parse tab-separated or comma-separated values - const parts = line.split(/[,\t]/).map(p => p.trim()) - - 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, - }) + const loadSchedule = async () => { + try { + const response = await fetch(`/api/tournaments/${tournamentId}/schedule`) + if (response.ok) { + const data = await response.json() + // API returns { rounds: [...] }, wrap in schedule object + const rounds = data.rounds || [] + setSchedule({ rounds }) + if (rounds.length > 0) { + setSelectedRoundId(rounds[0].id) + } } + } catch (err) { + console.error("Failed to load schedule:", err) } - - return games } - const handleTextChange = (e: React.ChangeEvent) => { - const text = e.target.value - setGameText(text) - const games = parseGameText(text) - setParsedGames(games) + const loadMatches = async () => { + try { + const response = await fetch(`/api/tournaments/${tournamentId}/matches`) + if (response.ok) { + const data = await response.json() + setMatches(data.matches || []) + } + } catch (err) { + console.error("Failed to load matches:", err) + } } - const submitGames = async () => { - if (parsedGames.length === 0) { - setError("No valid games to submit") + const selectedRound = schedule?.rounds.find(r => r.id === selectedRoundId) + const selectedMatchup = selectedRound?.matchups.find(m => m.id === selectedMatchupId) + + 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 } @@ -121,25 +172,55 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id: setIsLoading(true) 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`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ - games: parsedGames, + games: [matchData], }), }) - const data = await response.json() - 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`) - setGameText("") - setParsedGames([]) + const data = await response.json() + + 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) { if (err instanceof Error) { 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 (
@@ -178,7 +262,7 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id: ← Back to Tournament

- Game Entry: {tournament.name} + {tournament.name}

{tournament.eventDate && (

@@ -199,19 +283,92 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id:

)} + {/* Progress Summary */} + {schedule && ( +
+
+
+

Tournament Progress

+

+ {completedMatchups} of {totalMatchups} games completed +

+
+
+
+ {totalMatchups > 0 ? Math.round((completedMatchups / totalMatchups) * 100) : 0}% +
+

complete

+
+
+
+
0 ? (completedMatchups / totalMatchups) * 100 : 0}%` }} + /> +
+
+ )} +
- {/* Participants Panel */} + {/* Rounds Panel */}
+

Rounds

+ {schedule?.rounds ? ( +
+ {schedule.rounds.map(round => { + const roundCompleted = round.matchups.every(m => isMatchupCompleted(m)) + const roundInProgress = round.matchups.some(m => isMatchupCompleted(m)) && !roundCompleted + return ( + + ) + })} +
+ ) : ( +
+

No schedule generated yet.

+ +
+ )} +
+ + {/* Participants Panel */} +

Participants ({tournament.participants.length})

-
+
{tournament.participants.map(({ player }) => ( -
+
{player.name}
))} @@ -219,99 +376,151 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id:
- {/* Game Entry Panel */} + {/* Round Detail Panel */}

- Enter Games + {selectedRound ? `Round ${selectedRound.roundNumber} Matchups` : 'Select a Round'}

- -
- -
-

Tab or comma-separated format:

- - Round Table Player1 Player2 Score1 Player3 Player4 Score2 - -

- Example: 1 1 John Smith Jane Doe 10 Mike Johnson Sarah Brown 5 -

-

- Lines starting with # are treated as comments -

+ + {selectedRound ? ( +
+ {selectedRound.matchups.map((matchup, index) => { + const completed = isMatchupCompleted(matchup) + const match = getMatchupMatch(matchup) + const isSelected = selectedMatchupId === matchup.id + + return ( +
!completed && handleSelectMatchup(matchup)} + > +
+ + Match {index + 1} + {matchup.tableNumber && ` • Table ${matchup.tableNumber}`} + + {completed && ( + + Completed + + )} +
+ + {matchup.team1 && matchup.team2 ? ( +
+
+

+ {matchup.team1.player1.name} & {matchup.team1.player2.name} +

+

+ Team {matchup.team1.id} +

+
+
+ {completed && match ? ( +
+ match.team2Score ? 'text-green-600' : 'text-gray-600'}`}> + {match.team1Score} + + - + match.team1Score ? 'text-green-600' : 'text-gray-600'}`}> + {match.team2Score} + +
+ ) : ( + vs + )} +
+
+

+ {matchup.team2.player1.name} & {matchup.team2.player2.name} +

+

+ Team {matchup.team2.id} +

+
+
+ ) : ( +

Teams not assigned

+ )} + + {/* Score Entry Form */} + {isSelected && !completed && matchup.team1 && matchup.team2 && ( +
+

Enter Score

+
+
+ + setTeam1Score(e.target.value)} + placeholder="0" + /> +
+
+ - +
+
+ + setTeam2Score(e.target.value)} + placeholder="0" + /> +
+
+
+ + +
+
+ )} +
+ ) + })}
-
- -
- -