brackt/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx
Chris Parsons a3b8fa6309 feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00

278 lines
11 KiB
TypeScript

import { Form, Link } from "react-router";
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
export { loader, action };
export default function EventBracket({
loaderData,
actionData,
}: Route.ComponentProps) {
const { sportsSeason, event, participants, matches } = loaderData;
// Group matches by round
const matchesByRound: Record<string, typeof matches> = {};
for (const match of matches) {
if (!matchesByRound[match.round]) {
matchesByRound[match.round] = [];
}
matchesByRound[match.round].push(match);
}
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
const availableRounds = rounds.filter(r => matchesByRound[r]);
// 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)
);
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="bracketSize">Bracket Size</Label>
<Select name="bracketSize" defaultValue="8" required>
<SelectTrigger id="bracketSize">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="4">4 teams (Semifinals + Finals)</SelectItem>
<SelectItem value="8">8 teams (Quarterfinals + Semifinals + Finals)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Select Participants (in order)</Label>
<p className="text-sm text-muted-foreground">
Select {8} participants for the bracket
</p>
{[...Array(8)].map((_, i) => (
<Select key={i} name={`participant${i}`} required>
<SelectTrigger>
<SelectValue placeholder={`Seed ${i + 1}`} />
</SelectTrigger>
<SelectContent>
{participants.map((participant: { id: string; name: string }) => (
<SelectItem key={participant.id} value={participant.id}>
{participant.name}
</SelectItem>
))}
</SelectContent>
</Select>
))}
</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 => (
<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 ? (
<Form method="post" className="inline">
<input type="hidden" name="intent" value="set-winner" />
<input type="hidden" name="matchId" value={match.id} />
<Select name="winnerId" required>
<SelectTrigger className="h-8 w-full">
<SelectValue placeholder="Set winner" />
</SelectTrigger>
<SelectContent>
<SelectItem value={match.participant1Id}>
{match.participant1?.name}
</SelectItem>
<SelectItem value={match.participant2Id}>
{match.participant2?.name}
</SelectItem>
</SelectContent>
</Select>
<Button type="submit" size="sm" className="ml-2 h-8">
Set
</Button>
</Form>
) : match.isComplete ? (
<span className="text-sm text-muted-foreground">Complete</span>
) : (
<span className="text-sm text-muted-foreground">Waiting</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
))}
{/* Complete Round Button */}
{availableRounds.length > 0 && (
<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 => (
<SelectItem key={round} value={round}>
{round}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="submit">
Complete Round & Calculate Placements
</Button>
</div>
</Form>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}