import { Form, Link } from "react-router"; import { useState, useEffect, useMemo } from "react"; import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket"; import { loader, action } from "./admin.sports-seasons.$id.events.$eventId.bracket.server"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "~/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "~/components/ui/select"; import { ArrowLeft, Plus, Trophy } from "lucide-react"; import { getAllBracketTemplates, getBracketTemplate } from "~/lib/bracket-templates"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "~/components/ui/table"; import { ParticipantSelector } from "~/components/ParticipantSelector"; export { loader, action }; export default function EventBracket({ loaderData, actionData, }: Route.ComponentProps) { const { sportsSeason, event, participants, matches } = loaderData; const [selectedTemplate, setSelectedTemplate] = useState("simple_8"); const [selectedParticipants, setSelectedParticipants] = useState>({}); const [selectedWinners, setSelectedWinners] = useState>({}); const templates = getAllBracketTemplates(); const template = templates.find((t) => t.id === selectedTemplate); // Clear selected winners after successful batch submission useEffect(() => { if (actionData?.success) { setSelectedWinners({}); } }, [actionData?.success]); // Reset selected participants when template changes const handleTemplateChange = (newTemplateId: string) => { setSelectedTemplate(newTemplateId); setSelectedParticipants({}); }; // Update selected participant for a seed const handleParticipantChange = (seedIndex: number, participantId: string) => { setSelectedParticipants(prev => ({ ...prev, [seedIndex]: participantId })); }; // Memoized available participants calculation - only recalculates when dependencies change // This prevents 68 × 68 filter operations on every render const availableParticipantsMap = useMemo(() => { // Build a Set of selected IDs for O(1) lookup const selectedIdsSet = new Set(Object.values(selectedParticipants)); // Pre-calculate available participants for each seed slot const map: Record = {}; const totalSeeds = template?.totalTeams || 8; for (let i = 0; i < totalSeeds; i++) { // For this seed slot, allow its current selection but exclude all others const currentSelection = selectedParticipants[i]; map[i] = participants.filter((p: { id: string; name: string }) => { // Allow if not selected, OR if it's the current selection for this seed return !selectedIdsSet.has(p.id) || p.id === currentSelection; }); } return map; }, [selectedParticipants, participants, template?.totalTeams]); // Get available participants for a specific seed (now just a lookup) const getAvailableParticipants = (seedIndex: number) => { return availableParticipantsMap[seedIndex] || participants; }; // Update selected winner for a match const handleWinnerChange = (matchId: string, winnerId: string) => { setSelectedWinners(prev => ({ ...prev, [matchId]: winnerId })); }; // Get matches with pending winner selections in a round const getPendingWinnersInRound = (round: string) => { return Object.entries(selectedWinners) .filter(([matchId]) => { const match = matches.find((m: any) => m.id === matchId); return match && match.round === round && !match.isComplete; }); }; // Memoized: Group matches by round const matchesByRound = useMemo(() => { const grouped: Record = {}; for (const match of matches) { if (!grouped[match.round]) { grouped[match.round] = []; } grouped[match.round].push(match); } return grouped; }, [matches]); // Memoized: Get available rounds in chronological order (first round to finals) const availableRounds = useMemo(() => { const roundsInMatches = Array.from(new Set(matches.map(m => m.round))); // If event has a bracket template, use its round order if (event.bracketTemplateId) { const eventTemplate = getBracketTemplate(event.bracketTemplateId); if (eventTemplate) { // Filter template rounds to only those that have matches return eventTemplate.rounds .map((r) => r.name) .filter((roundName: string) => roundsInMatches.includes(roundName)); } } // Fallback: return rounds as they appear return roundsInMatches; }, [matches, event.bracketTemplateId]); // Memoized: Get participants not yet in any match const availableParticipants = useMemo(() => { const participantsInMatches = new Set( matches.flatMap(m => [m.participant1Id, m.participant2Id].filter(Boolean)) ); return participants.filter( (p: { id: string }) => !participantsInMatches.has(p.id) ); }, [matches, participants]); return (

Playoff Bracket

{event.name} - {sportsSeason.sport.name} {sportsSeason.name}

{actionData?.error && (
{actionData.error}
)} {actionData?.success && (
{actionData.success}
)} {/* Generate Bracket Form */} {matches.length === 0 && ( Generate Bracket Create the bracket structure for this playoff event
{template && (

Rounds: {template.rounds.map(r => r.name).join(" → ")}

)}

Select {template?.totalTeams || 8} participants for the bracket. Use search to quickly find participants.

{[...Array(template?.totalTeams || 8)].map((_, i) => { const availableParticipants = getAvailableParticipants(i); return (
handleParticipantChange(i, value)} placeholder={`Select seed ${i + 1}...`} />
); })}
)} {/* Bracket Display */} {availableRounds.map((round: string) => ( {round} {matchesByRound[round].length} {matchesByRound[round].length === 1 ? "match" : "matches"} Match Participant 1 Score vs Score Participant 2 Winner Actions {matchesByRound[round].map((match) => ( {match.matchNumber} {match.participant1?.name || "TBD"} {match.participant1Score ? parseFloat(match.participant1Score) : "-"} vs {match.participant2Score ? parseFloat(match.participant2Score) : "-"} {match.participant2?.name || "TBD"} {match.winner?.name ? (
{match.winner.name}
) : ( "-" )}
{!match.isComplete && match.participant1Id && match.participant2Id ? ( ) : match.isComplete ? ( Complete ) : ( Waiting )}
))}
{/* Batch Submit Winners */} {getPendingWinnersInRound(round).length > 0 && (
{getPendingWinnersInRound(round).map(([matchId, winnerId]) => ( ))}
)}
))} {/* Complete Round Button */} {availableRounds.length > 0 && ( Complete Round Finalize the current round and calculate placements
)}
); }