diff --git a/app/components/ParticipantSelector.tsx b/app/components/ParticipantSelector.tsx new file mode 100644 index 0000000..74c770f --- /dev/null +++ b/app/components/ParticipantSelector.tsx @@ -0,0 +1,148 @@ +import { useState, useMemo } from "react"; +import { Input } from "~/components/ui/input"; +import { Button } from "~/components/ui/button"; +import { Check, ChevronsUpDown, X } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "~/components/ui/popover"; +import { cn } from "~/lib/utils"; + +interface Participant { + id: string; + name: string; +} + +interface ParticipantSelectorProps { + participants: Participant[]; + value: string; + onValueChange: (value: string) => void; + placeholder?: string; + disabled?: boolean; +} + +/** + * Searchable participant selector optimized for large lists (305+ items) + * Only renders filtered results to maintain performance + */ +export function ParticipantSelector({ + participants, + value, + onValueChange, + placeholder = "Select participant...", + disabled = false, +}: ParticipantSelectorProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + + // Find selected participant + const selectedParticipant = useMemo( + () => participants.find((p) => p.id === value), + [participants, value] + ); + + // Filter participants based on search (case-insensitive) + const filteredParticipants = useMemo(() => { + if (!search) return participants; + + const searchLower = search.toLowerCase(); + return participants.filter((p) => + p.name.toLowerCase().includes(searchLower) + ); + }, [participants, search]); + + // Limit displayed results to prevent DOM overload + const displayedParticipants = useMemo( + () => filteredParticipants.slice(0, 50), + [filteredParticipants] + ); + + const handleSelect = (participantId: string) => { + onValueChange(participantId); + setOpen(false); + setSearch(""); + }; + + const handleClear = (e: React.MouseEvent) => { + e.stopPropagation(); + onValueChange(""); + setSearch(""); + }; + + return ( + + + + + +
+ {/* Search input */} +
+ setSearch(e.target.value)} + className="h-9" + autoFocus + /> +
+ + {/* Results */} +
+ {displayedParticipants.length === 0 ? ( +
+ No participants found. +
+ ) : ( + <> + {displayedParticipants.map((participant) => ( + + ))} + {filteredParticipants.length > 50 && ( +
+ Showing 50 of {filteredParticipants.length} results. Keep typing to refine... +
+ )} + + )} +
+
+
+
+ ); +} diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx index 8d0a8c4..11f160c 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx @@ -1,5 +1,5 @@ import { Form, Link } from "react-router"; -import { useState, useEffect } from "react"; +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"; @@ -29,6 +29,7 @@ import { TableHeader, TableRow, } from "~/components/ui/table"; +import { ParticipantSelector } from "~/components/ParticipantSelector"; export { loader, action }; @@ -64,15 +65,32 @@ export default function EventBracket({ })); }; - // Get available participants for a specific seed (excluding already selected) - const getAvailableParticipants = (currentSeedIndex: number) => { - const selectedIds = Object.entries(selectedParticipants) - .filter(([seedIdx]) => Number(seedIdx) !== currentSeedIndex) - .map(([_, id]) => id); + // 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)); - return participants.filter( - (p: { id: string; name: string }) => !selectedIds.includes(p.id) - ); + // 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 @@ -92,17 +110,20 @@ export default function EventBracket({ }); }; - // Group matches by round - const matchesByRound: Record = {}; - for (const match of matches) { - if (!matchesByRound[match.round]) { - matchesByRound[match.round] = []; + // 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); } - matchesByRound[match.round].push(match); - } + return grouped; + }, [matches]); - // Get available rounds in chronological order (first round to finals) - const getOrderedRounds = () => { + // 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 @@ -118,17 +139,17 @@ export default function EventBracket({ // Fallback: return rounds as they appear return roundsInMatches; - }; + }, [matches, event.bracketTemplateId]); - const availableRounds = getOrderedRounds(); - - // Get participants not yet in any match - const participantsInMatches = new Set( - matches.flatMap(m => [m.participant1Id, m.participant2Id].filter(Boolean)) - ); - const availableParticipants = participants.filter( - (p: { id: string }) => !participantsInMatches.has(p.id) - ); + // 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 (
@@ -205,30 +226,31 @@ export default function EventBracket({

- Select {template?.totalTeams || 8} participants for the bracket + 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}...`} + /> +
+
); })}