brackt/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx
Chris Parsons f17699e4c5 feat: Implement team breakdown pages with detailed score breakdown
- Created `TeamScoreBreakdown` component to display all drafted participants, their placements, and points.
- Added new route for team breakdown at `/leagues/:leagueId/standings/:seasonId/teams/:teamId`.
- Enhanced `getTeamScoreBreakdown` model to include `sportsSeasonId` for grouping.
- Updated `StandingsTable` to include clickable team name links.
- Added functionality to finalize brackets in playoff events, including error handling and recalculating standings.
- Implemented tests for `TeamScoreBreakdown` component to ensure proper rendering and functionality.
- Updated routing to support new team breakdown feature and ensure seamless navigation.
2025-11-14 20:01:21 -08:00

462 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}