brackt/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx

463 lines
19 KiB
TypeScript
Raw Normal View History

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<Record<number, string>>({});
const [selectedWinners, setSelectedWinners] = useState<Record<string, string>>({});
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<number, typeof participants> = {};
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<string, typeof matches> = {};
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]);
// Check if all matches are complete
const allMatchesComplete = useMemo(() => {
return matches.length > 0 && matches.every((m: any) => m.isComplete);
}, [matches]);
return (
<div className="p-8">
<div className="max-w-6xl mx-auto">
<div className="mb-6">
<Button variant="ghost" size="sm" asChild className="mb-2">
<Link to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Event
</Link>
</Button>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Playoff Bracket</h1>
<p className="text-muted-foreground mt-1">
{event.name} - {sportsSeason.sport.name} {sportsSeason.name}
</p>
</div>
</div>
</div>
<div className="space-y-6">
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
{actionData?.success && (
<div className="bg-green-500/15 text-green-700 dark:text-green-400 px-4 py-3 rounded-md text-sm">
{actionData.success}
</div>
)}
{/* Generate Bracket Form */}
{matches.length === 0 && (
<Card>
<CardHeader>
<CardTitle>Generate Bracket</CardTitle>
<CardDescription>
Create the bracket structure for this playoff event
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<input type="hidden" name="intent" value="generate-bracket" />
<div className="space-y-2">
<Label htmlFor="templateId">Bracket Template</Label>
<Select
name="templateId"
value={selectedTemplate}
onValueChange={handleTemplateChange}
required
>
<SelectTrigger id="templateId">
<SelectValue />
</SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} - {t.totalTeams} teams
</SelectItem>
))}
</SelectContent>
</Select>
{template && (
<p className="text-sm text-muted-foreground">
Rounds: {template.rounds.map(r => r.name).join(" → ")}
</p>
)}
</div>
<div className="space-y-2">
<Label>Select Participants (in order)</Label>
<p className="text-sm text-muted-foreground">
Select {template?.totalTeams || 8} participants for the bracket. Use search to quickly find participants.
</p>
{[...Array(template?.totalTeams || 8)].map((_, i) => {
const availableParticipants = getAvailableParticipants(i);
return (
<div key={i} className="flex items-center gap-2">
<Label className="w-20 text-sm text-muted-foreground shrink-0">
Seed {i + 1}
</Label>
<div className="flex-1 min-w-0">
<input
type="hidden"
name={`participant${i}`}
value={selectedParticipants[i] || ""}
required
/>
<ParticipantSelector
participants={availableParticipants}
value={selectedParticipants[i] || ""}
onValueChange={(value) => handleParticipantChange(i, value)}
placeholder={`Select seed ${i + 1}...`}
/>
</div>
</div>
);
})}
</div>
<Button type="submit" className="w-full">
<Plus className="mr-2 h-4 w-4" />
Generate Bracket
</Button>
</Form>
</CardContent>
</Card>
)}
{/* Bracket Display */}
{availableRounds.map((round: string) => (
<Card key={round}>
<CardHeader>
<CardTitle>{round}</CardTitle>
<CardDescription>
{matchesByRound[round].length} {matchesByRound[round].length === 1 ? "match" : "matches"}
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Match</TableHead>
<TableHead>Participant 1</TableHead>
<TableHead>Score</TableHead>
<TableHead className="w-24 text-center">vs</TableHead>
<TableHead>Score</TableHead>
<TableHead>Participant 2</TableHead>
<TableHead>Winner</TableHead>
<TableHead className="w-32">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{matchesByRound[round].map((match) => (
<TableRow key={match.id}>
<TableCell className="font-semibold">
{match.matchNumber}
</TableCell>
<TableCell>
{match.participant1?.name || "TBD"}
</TableCell>
<TableCell>
{match.participant1Score ? parseFloat(match.participant1Score) : "-"}
</TableCell>
<TableCell className="text-center text-muted-foreground">
vs
</TableCell>
<TableCell>
{match.participant2Score ? parseFloat(match.participant2Score) : "-"}
</TableCell>
<TableCell>
{match.participant2?.name || "TBD"}
</TableCell>
<TableCell>
{match.winner?.name ? (
<div className="flex items-center gap-1">
<Trophy className="h-4 w-4 text-yellow-500" />
{match.winner.name}
</div>
) : (
"-"
)}
</TableCell>
<TableCell>
{!match.isComplete && match.participant1Id && match.participant2Id ? (
<Select
value={selectedWinners[match.id] || ""}
onValueChange={(value) => handleWinnerChange(match.id, value)}
>
<SelectTrigger className="h-8 w-full">
<SelectValue placeholder="Select winner" />
</SelectTrigger>
<SelectContent>
<SelectItem value={match.participant1Id}>
{match.participant1?.name}
</SelectItem>
<SelectItem value={match.participant2Id}>
{match.participant2?.name}
</SelectItem>
</SelectContent>
</Select>
) : match.isComplete ? (
<span className="text-sm text-muted-foreground">Complete</span>
) : (
<span className="text-sm text-muted-foreground">Waiting</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* Batch Submit Winners */}
{getPendingWinnersInRound(round).length > 0 && (
<Form method="post" className="mt-4">
<input type="hidden" name="intent" value="set-round-winners" />
<input type="hidden" name="round" value={round} />
{getPendingWinnersInRound(round).map(([matchId, winnerId]) => (
<input key={matchId} type="hidden" name={`winner-${matchId}`} value={winnerId} />
))}
<Button type="submit" className="w-full">
Save {getPendingWinnersInRound(round).length} Winner{getPendingWinnersInRound(round).length > 1 ? 's' : ''} for {round}
</Button>
</Form>
)}
</CardContent>
</Card>
))}
{/* Complete Round Button */}
{availableRounds.length > 0 && !event.isComplete && (
<Card>
<CardHeader>
<CardTitle>Complete Round</CardTitle>
<CardDescription>
Finalize the current round and calculate placements
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post">
<input type="hidden" name="intent" value="complete-round" />
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="round">Round to Complete</Label>
<Select name="round" required>
<SelectTrigger id="round">
<SelectValue placeholder="Select round" />
</SelectTrigger>
<SelectContent>
{availableRounds.map((round: string) => (
<SelectItem key={round} value={round}>
{round}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="submit">
Complete Round & Calculate Placements
</Button>
</div>
</Form>
</CardContent>
</Card>
)}
{/* Finalize Bracket Button */}
{allMatchesComplete && !event.isComplete && (
<Card className="border-green-500 bg-green-50 dark:bg-green-950">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-green-600" />
Finalize Bracket
</CardTitle>
<CardDescription>
All matches are complete! Process all rounds and assign final placements.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post">
<input type="hidden" name="intent" value="finalize-bracket" />
<div className="space-y-4">
<div className="text-sm space-y-2">
<p className="font-medium">This will:</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Process all playoff rounds in order</li>
<li>Assign fantasy placements (1st-8th) based on bracket results</li>
<li>Assign 0 points to participants not in the bracket</li>
<li>Mark the event as complete</li>
<li>Recalculate all team standings</li>
</ul>
</div>
<Button type="submit" className="w-full bg-green-600 hover:bg-green-700">
Finalize Bracket & Update Standings
</Button>
</div>
</Form>
</CardContent>
</Card>
)}
{/* Event Complete Badge */}
{event.isComplete && (
<Card className="border-blue-500 bg-blue-50 dark:bg-blue-950">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-blue-700 dark:text-blue-400">
<Trophy className="h-5 w-5" />
<span className="font-semibold">Event Complete</span>
<span className="text-sm text-muted-foreground ml-auto">
{event.completedAt && new Date(event.completedAt).toLocaleDateString()}
</span>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}