9bfb890143
Adds a visual bracket display showing rounds as columns with matchup cards. Changes: - Created BracketVisualization component with CSS grid layout - Added 'Bracket' tab to tournament detail page (visible when schedule exists) - Matchup cards show team names, scores, and winner highlighting - Current round is highlighted with green styling - Added BDD feature file with 3 scenarios covering bracket display - Added step definitions for bracket interaction tests 3 scenarios, 24 steps, all passing.
169 lines
5.2 KiB
TypeScript
169 lines
5.2 KiB
TypeScript
"use client"
|
|
|
|
interface Player {
|
|
id: number
|
|
name: string
|
|
}
|
|
|
|
interface BracketMatchup {
|
|
id: number
|
|
roundId: number
|
|
player1P1: Player | null
|
|
player1P2: Player | null
|
|
player2P1: Player | null
|
|
player2P2: Player | null
|
|
match: { id: number; team1Score: number; team2Score: number } | null
|
|
bracketPosition: number | null
|
|
status: string
|
|
}
|
|
|
|
interface TournamentRound {
|
|
id: number
|
|
roundNumber: number
|
|
status: string
|
|
bracketMatchups: BracketMatchup[]
|
|
}
|
|
|
|
interface BracketVisualizationProps {
|
|
rounds: TournamentRound[]
|
|
}
|
|
|
|
export function BracketVisualization({ rounds }: BracketVisualizationProps) {
|
|
if (rounds.length === 0) {
|
|
return (
|
|
<div className="bg-white shadow rounded-lg p-6">
|
|
<p className="text-gray-500">No schedule generated yet. Generate a schedule to see the bracket.</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const currentRoundIdx = rounds.findIndex(r => r.status === "in_progress")
|
|
const completedRounds = rounds.filter(r => r.status === "completed").length
|
|
|
|
return (
|
|
<div className="bg-white shadow rounded-lg p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h2 className="text-lg font-semibold text-gray-900">Tournament Bracket</h2>
|
|
<div className="flex items-center space-x-4 text-sm text-gray-500">
|
|
<span>{rounds.length} rounds</span>
|
|
<span>{completedRounds} completed</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto pb-4">
|
|
<div
|
|
className="inline-grid gap-4"
|
|
style={{
|
|
gridTemplateColumns: `repeat(${rounds.length}, minmax(200px, 1fr))`,
|
|
minWidth: `${rounds.length * 220}px`,
|
|
}}
|
|
>
|
|
{rounds.map((round, roundIdx) => {
|
|
const isCurrent = roundIdx === currentRoundIdx
|
|
const isCompleted = round.status === "completed"
|
|
|
|
return (
|
|
<div key={round.id} className="flex flex-col">
|
|
{/* Round Header */}
|
|
<div
|
|
className={`text-center py-2 px-3 rounded-t-lg font-medium text-sm ${
|
|
isCurrent
|
|
? "bg-green-100 text-green-800 border border-green-300"
|
|
: isCompleted
|
|
? "bg-gray-100 text-gray-600 border border-gray-200"
|
|
: "bg-gray-50 text-gray-500 border border-gray-200"
|
|
}`}
|
|
>
|
|
Round {round.roundNumber}
|
|
{isCurrent && (
|
|
<span className="ml-1 text-xs">(current)</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Matchups */}
|
|
<div className="flex flex-col gap-2 mt-2">
|
|
{round.bracketMatchups
|
|
.sort((a, b) => (a.bracketPosition || 0) - (b.bracketPosition || 0))
|
|
.map((matchup) => (
|
|
<MatchupCard key={matchup.id} matchup={matchup} isCurrentRound={isCurrent} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function MatchupCard({ matchup, isCurrentRound }: { matchup: BracketMatchup; isCurrentRound: boolean }) {
|
|
const team1Name = matchup.player1P1 && matchup.player1P2
|
|
? `${matchup.player1P1.name.split(" ").pop()} & ${matchup.player1P2.name.split(" ").pop()}`
|
|
: "TBD"
|
|
const team2Name = matchup.player2P1 && matchup.player2P2
|
|
? `${matchup.player2P1.name.split(" ").pop()} & ${matchup.player2P2.name.split(" ").pop()}`
|
|
: "TBD"
|
|
|
|
const hasResult = matchup.match !== null
|
|
const team1Won = hasResult && matchup.match!.team1Score > matchup.match!.team2Score
|
|
const team2Won = hasResult && matchup.match!.team2Score > matchup.match!.team1Score
|
|
|
|
const borderColor = isCurrentRound
|
|
? "border-green-400"
|
|
: hasResult
|
|
? "border-gray-300"
|
|
: "border-gray-200"
|
|
|
|
return (
|
|
<div
|
|
className={`border rounded-md p-2 text-xs transition-colors ${borderColor} ${
|
|
isCurrentRound ? "shadow-sm" : ""
|
|
}`}
|
|
data-testid="bracket-matchup"
|
|
>
|
|
{/* Team 1 */}
|
|
<div
|
|
className={`flex justify-between items-center py-1 px-1 ${
|
|
team1Won ? "font-semibold" : ""
|
|
}`}
|
|
>
|
|
<span className={`truncate ${team1Won ? "text-green-700" : "text-gray-700"}`}>
|
|
{team1Name}
|
|
</span>
|
|
{hasResult && (
|
|
<span className={`ml-1 font-mono ${team1Won ? "text-green-700" : "text-gray-500"}`}>
|
|
{matchup.match!.team1Score}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Divider */}
|
|
<div className="border-t border-gray-200 my-0.5" />
|
|
|
|
{/* Team 2 */}
|
|
<div
|
|
className={`flex justify-between items-center py-1 px-1 ${
|
|
team2Won ? "font-semibold" : ""
|
|
}`}
|
|
>
|
|
<span className={`truncate ${team2Won ? "text-green-700" : "text-gray-700"}`}>
|
|
{team2Name}
|
|
</span>
|
|
{hasResult && (
|
|
<span className={`ml-1 font-mono ${team2Won ? "text-green-700" : "text-gray-500"}`}>
|
|
{matchup.match!.team2Score}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Status indicator */}
|
|
{!hasResult && matchup.status === "pending" && (
|
|
<div className="text-center text-gray-400 text-[10px] mt-1">
|
|
pending
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|