import { useEffect, useState } from "react"; import { createApiClient } from "../api/client"; import type { components } from "../api/schema"; import BorderedContainer from "../components/BorderedContainer"; import UserIcon from "../components/UserIcon"; import { APP_NAME } from "../config"; import { usePageTitle } from "../hooks/usePageTitle"; type Tournament = components["schemas"]["Tournament"]; type TournamentMatch = components["schemas"]["TournamentMatch"]; type TournamentEntry = components["schemas"]["TournamentEntry"]; function getBorderColor(match: TournamentMatch, userID?: number): string { if (!match.winner_user_id) { return "border-black"; } if (userID !== undefined && match.winner_user_id === userID) { return "border-brand-600"; } return "border-gray-400"; } function getBgColor(match: TournamentMatch, userID?: number): string { if (!match.winner_user_id) { return "bg-black"; } if (userID !== undefined && match.winner_user_id === userID) { return "bg-brand-600"; } return "bg-gray-400"; } function getWinnerBgColor(match: TournamentMatch | undefined): string { if (!match?.winner_user_id) { return "bg-black"; } return "bg-brand-600"; } function PlayerCard({ entry }: { entry: TournamentEntry | undefined }) { if (!entry) { return (
BYE
); } return (
{entry.user.display_name} {entry.user.icon_path && ( )}
); } function Connector({ position, colSpan, match, isTopRound, leftChildMatch, rightChildMatch, }: { position: number; colSpan: number; match: TournamentMatch | undefined; isTopRound: boolean; leftChildMatch?: TournamentMatch; rightChildMatch?: TournamentMatch; }) { const leftHalf = colSpan / 2; const rightHalf = colSpan - leftHalf; const leftBorderColor = match ? getBorderColor(match, match.player1?.user_id) : "border-black"; const rightBorderColor = match ? getBorderColor(match, match.player2?.user_id) : "border-black"; // Leg colors: use child match winner color so vertical lines don't change color midway const leftLegColor = leftChildMatch ? getWinnerBgColor(leftChildMatch) : match ? getBgColor(match, match.player1?.user_id) : "bg-black"; const rightLegColor = rightChildMatch ? getWinnerBgColor(rightChildMatch) : match ? getBgColor(match, match.player2?.user_id) : "bg-black"; const winnerBg = getWinnerBgColor(match); return (
{/* Center stem going UP to parent round */}
{/* Horizontal line */}
{/* Two legs going DOWN to child elements */}
); } function TournamentBracket({ tournament }: { tournament: Tournament }) { const { bracket_size, num_rounds, entries, matches } = tournament; const matchByKey = new Map(); for (const m of matches) { matchByKey.set(`${m.round}-${m.position}`, m); } const entryBySeed = new Map(); for (const e of entries) { entryBySeed.set(e.seed, e); } const bracketSeeds = standardBracketSeeds(bracket_size); // Build rows top-to-bottom: final → ... → round 0 → players const rows: React.ReactNode[] = []; // Rounds from top (final) to bottom (round 0) for (let round = num_rounds - 1; round >= 0; round--) { const numPositions = bracket_size / (1 << (round + 1)); const colSpan = bracket_size / numPositions; // Connectors for this round const connectors: React.ReactNode[] = []; for (let pos = 0; pos < numPositions; pos++) { const match = matchByKey.get(`${round}-${pos}`); const leftChildMatch = round > 0 ? matchByKey.get(`${round - 1}-${pos * 2}`) : undefined; const rightChildMatch = round > 0 ? matchByKey.get(`${round - 1}-${pos * 2 + 1}`) : undefined; connectors.push( , ); } rows.push(
{connectors}
, ); } // Player stems row (vertical lines from round-0 connectors to player cards) const playerStems: React.ReactNode[] = []; for (let slot = 0; slot < bracket_size; slot++) { const matchPos = Math.floor(slot / 2); const match = matchByKey.get(`0-${matchPos}`); const isPlayer1 = slot % 2 === 0; const stemColor = match ? getBgColor( match, isPlayer1 ? match.player1?.user_id : match.player2?.user_id, ) : "bg-black"; playerStems.push(
, ); } rows.push(
{playerStems}
, ); // Player cards row (bottom) const playerCards: React.ReactNode[] = []; for (let slot = 0; slot < bracket_size; slot++) { const seed = bracketSeeds[slot]!; const entry = entryBySeed.get(seed); playerCards.push(
, ); } rows.push(
{playerCards}
, ); return
{rows}
; } // Exported for testing as standardBracketSeedsForTest export { standardBracketSeeds as standardBracketSeedsForTest }; function standardBracketSeeds(bracketSize: number): number[] { const seeds = new Array(bracketSize).fill(0); seeds[0] = 1; for (let size = 2; size <= bracketSize; size *= 2) { const temp = new Array(size).fill(0); for (let i = 0; i < size / 2; i++) { temp[i * 2] = seeds[i]!; temp[i * 2 + 1] = size + 1 - seeds[i]!; } for (let i = 0; i < size; i++) { seeds[i] = temp[i]!; } } return seeds; } export default function TournamentPage({ tournamentId, }: { tournamentId: string; }) { usePageTitle(`Tournament | ${APP_NAME}`); const id = Number(tournamentId); const isValidId = id > 0; const [tournament, setTournament] = useState(null); const [loading, setLoading] = useState(isValidId); const [error, setError] = useState( isValidId ? null : "Invalid tournament ID", ); useEffect(() => { if (!isValidId) { return; } const apiClient = createApiClient(); apiClient .getTournament(id) .then(({ tournament }) => setTournament(tournament)) .catch(() => setError("Failed to load tournament")) .finally(() => setLoading(false)); }, [id, isValidId]); if (loading) { return (

Loading...

); } if (error || !tournament) { return (

{error || "Failed to load tournament"}

); } return (

{tournament.display_name}

); }