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 (
);
}
if (error || !tournament) {
return (
{error || "Failed to load tournament"}
);
}
return (
{tournament.display_name}
);
}