brackt/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx
Chris Parsons 840212c4f8 feat: implement sports season expected value calculations and probability updates
- Added loader and action functions for managing expected values in sports seasons.
- Implemented UI for recalculating probabilities based on participant results.
- Created a service to update probabilities after results are finalized, including handling finished and unfinished participants.
- Developed tests for the probability updater service to ensure correct functionality.
- Introduced a preview feature to show potential changes before applying updates.
2025-11-21 22:05:50 -08:00

488 lines
20 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>
)}
{/* Reprocess Eliminations - For existing brackets */}
{matches.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Reprocess Eliminations</CardTitle>
<CardDescription>
Mark participants not in this bracket as eliminated (for brackets created before automatic elimination tracking)
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post">
<input type="hidden" name="intent" value="reprocess-eliminations" />
<div className="space-y-4">
<div className="text-sm text-muted-foreground">
This will set finalPosition = 0 for all participants who are not in any playoff match,
allowing probability recalculation to set their odds to 0%.
</div>
<Button type="submit" variant="outline">
Reprocess Eliminations
</Button>
</div>
</Form>
</CardContent>
</Card>
)}
{/* 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>
);
}