brackt/app/routes/admin.sports-seasons.$id.surface-elo.tsx
Chris Parsons d7ba20d9ea
Add tennis Grand Slam simulator with surface Elo ratings, fixes #116 (#216)
Implements a Monte Carlo simulator for men's/women's tennis seasons scored
on the qualifying_points pattern. Simulates all 4 Grand Slam majors
(Australian Open, French Open, Wimbledon, US Open) using surface-specific
Elo ratings and ATP/WTA world rankings for seeding.

New table: participant_surface_elos — one row per (participant, season)
storing worldRanking, eloHard, eloClay, eloGrass.

Key design decisions:
- Seeding uses ATP/WTA world ranking (not Elo), matching real draw procedure
- Top 32 seeded with standard slot placement (1→0, 2→64, 3-4→quarters, etc.)
- QP per round with tie-splitting pre-applied: W=20, F=14, SF=9, QF=4, R16=1.5
- Completed majors read actual qualifyingPointsAwarded from eventResults
- 10,000 Monte Carlo simulations; column sums naturally 1.0 (no normalization)

Admin UI at /admin/sports-seasons/:id/surface-elo:
- 5-column grid (Player | Rank | Hard | Clay | Grass)
- Bulk import: "Name, ranking, hardElo, clayElo, grassElo" one per line
- Fuzzy name matching (bigram Dice coefficient) with "Did you mean?" suggestions
- Inline participant creation for unmatched names via useFetcher
- Saves Elos and auto-runs simulation on submit

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:59:35 -07:00

686 lines
27 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, redirect, useLoaderData, useActionData, useNavigation, useFetcher } from 'react-router';
import type { Route } from './+types/admin.sports-seasons.$id.surface-elo';
import { logger } from '~/lib/logger';
import { findSportsSeasonById, updateSportsSeason } from '~/models/sports-season';
import { findParticipantsBySportsSeasonId, createParticipant } from '~/models/participant';
import {
batchUpsertParticipantEVs,
} from '~/models/participant-expected-value';
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
import { getSurfaceElosForSeason, batchUpsertSurfaceElos } from '~/models/surface-elo';
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
import { calculateEV, type ScoringRules } from '~/services/ev-calculator';
import { recalculateStandings } from '~/models/scoring-calculator';
import { database } from '~/database/context';
import * as schema from '~/database/schema';
import { eq } from 'drizzle-orm';
import { Button } from '~/components/ui/button';
import { Input } from '~/components/ui/input';
import { Label } from '~/components/ui/label';
import { Textarea } from '~/components/ui/textarea';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '~/components/ui/card';
import { useEffect, useRef, useState } from 'react';
import { Loader2, CheckCircle2, AlertCircle, UserPlus } from 'lucide-react';
const DEFAULT_SCORING_RULES: ScoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 45,
pointsFor4th: 45,
pointsFor5th: 20,
pointsFor6th: 20,
pointsFor7th: 20,
pointsFor8th: 20,
};
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Surface Elo — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
}
export async function loader({ params }: Route.LoaderArgs) {
const sportsSeasonId = params.id;
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
if (!sportsSeason) {
throw new Response('Sports season not found', { status: 404 });
}
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const existingElos = await getSurfaceElosForSeason(sportsSeasonId);
const eloMap: Record<string, { ranking: number | null; hard: number | null; clay: number | null; grass: number | null }> = {};
for (const r of existingElos) {
eloMap[r.participantId] = {
ranking: r.worldRanking,
hard: r.eloHard,
clay: r.eloClay,
grass: r.eloGrass,
};
}
return { sportsSeason, participants, eloMap };
}
type ActionData =
| { intent: 'create-participant'; success: true; participant: { id: string; name: string } }
| { intent: 'create-participant'; success: false; message: string }
| { success?: boolean; message?: string };
export async function action({ request, params }: Route.ActionArgs) {
const sportsSeasonId = params.id;
const formData = await request.formData();
const intent = formData.get('intent');
// Handle participant creation separately
if (intent === 'create-participant') {
const name = (formData.get('name') as string)?.trim();
if (!name) {
return { intent: 'create-participant', success: false, message: 'Name is required' } satisfies ActionData;
}
const participant = await createParticipant({ sportsSeasonId, name });
return {
intent: 'create-participant',
success: true,
participant: { id: participant.id, name: participant.name },
} satisfies ActionData;
}
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
if (!sportsSeason) {
return { success: false, message: 'Sports season not found' };
}
if (!sportsSeason.sport?.simulatorType) {
return { success: false, message: 'This sport has no simulator type configured.' };
}
if (sportsSeason.simulationStatus === 'running') {
return { success: false, message: 'A simulation is already running. Please wait.' };
}
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
// Parse surface Elos from form fields: ranking_{id}, hard_{id}, clay_{id}, grass_{id}
const surfaceEloInputs = participants
.map((p) => {
const ranking = parseIntOrNull(formData.get(`ranking_${p.id}`) as string);
const hard = parseIntOrNull(formData.get(`hard_${p.id}`) as string);
const clay = parseIntOrNull(formData.get(`clay_${p.id}`) as string);
const grass = parseIntOrNull(formData.get(`grass_${p.id}`) as string);
return { participantId: p.id, sportsSeasonId, worldRanking: ranking, eloHard: hard, eloClay: clay, eloGrass: grass };
})
.filter((r) => r.worldRanking !== null || r.eloHard !== null || r.eloClay !== null || r.eloGrass !== null);
if (surfaceEloInputs.length === 0) {
return { success: false, message: 'Please enter at least one Elo rating.' };
}
await batchUpsertSurfaceElos(surfaceEloInputs);
// Auto-run simulation
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'running' });
try {
const simulator = getSimulator(sportsSeason.sport.simulatorType as SimulatorType);
const results = await simulator.simulate(sportsSeasonId);
if (results.length === 0) {
throw new Error('Simulation returned no results.');
}
const simulatedIds = new Set(results.map((r) => r.participantId));
const ZERO_PROBS = {
probFirst: 0, probSecond: 0, probThird: 0, probFourth: 0,
probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0,
};
const evInputs = [
...results.map((r) => ({
participantId: r.participantId,
sportsSeasonId,
probabilities: r.probabilities,
scoringRules: DEFAULT_SCORING_RULES,
source: 'elo_simulation' as const,
})),
...participants
.filter((p) => !simulatedIds.has(p.id))
.map((p) => ({
participantId: p.id,
sportsSeasonId,
probabilities: ZERO_PROBS,
scoringRules: DEFAULT_SCORING_RULES,
source: 'elo_simulation' as const,
})),
];
await batchUpsertParticipantEVs(evInputs);
// Refresh projected points in team standings
const seasonSports = await database().query.seasonSports.findMany({
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
});
await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId)));
// EV snapshot
const today = new Date().toISOString().slice(0, 10);
await batchUpsertParticipantEvSnapshots(
results.map((r) => ({
participantId: r.participantId,
sportsSeasonId,
snapshotDate: today,
probFirst: r.probabilities.probFirst,
probSecond: r.probabilities.probSecond,
probThird: r.probabilities.probThird,
probFourth: r.probabilities.probFourth,
probFifth: r.probabilities.probFifth,
probSixth: r.probabilities.probSixth,
probSeventh: r.probabilities.probSeventh,
probEighth: r.probabilities.probEighth,
calculatedEV: calculateEV(r.probabilities, DEFAULT_SCORING_RULES),
source: r.source,
}))
);
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'idle' });
} catch (error) {
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'failed' });
logger.error('Error running tennis simulation:', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Simulation failed',
};
}
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
}
function parseIntOrNull(val: string | null | undefined): number | null {
if (!val || val.trim() === '') return null;
const n = Math.round(parseFloat(val.trim()));
return isNaN(n) || n <= 0 ? null : n;
}
function normalizeName(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim();
}
// Bigram Dice coefficient for fuzzy name matching
function bigrams(s: string): string[] {
const result: string[] = [];
for (let i = 0; i < s.length - 1; i++) result.push(s.slice(i, i + 2));
return result;
}
function diceCoefficient(a: string, b: string): number {
if (a === b) return 1;
if (a.length < 2 || b.length < 2) return 0;
const bigA = bigrams(a);
const bigB = new Set(bigrams(b));
const intersection = bigA.filter((bg) => bigB.has(bg)).length;
return (2 * intersection) / (bigA.length + bigB.size);
}
type EloValues = Record<string, { ranking: string; hard: string; clay: string; grass: string }>;
interface MatchedItem {
participantId: string;
name: string;
ranking: number;
hard: number;
clay: number;
grass: number;
inputName: string;
}
interface Suggestion {
participantId: string;
name: string;
score: number;
}
interface UnmatchedItem {
inputName: string;
ranking: number;
hard: number;
clay: number;
grass: number;
suggestions: Suggestion[];
}
interface ParseResults {
matched: MatchedItem[];
unmatched: UnmatchedItem[];
}
type LocalParticipant = { id: string; name: string };
export default function AdminSportsSeasonSurfaceElo() {
const { sportsSeason, participants: loaderParticipants, eloMap } = useLoaderData<typeof loader>();
const actionData = useActionData<ActionData>();
const navigation = useNavigation();
const createFetcher = useFetcher<ActionData>();
const [localParticipants, setLocalParticipants] = useState<LocalParticipant[]>(loaderParticipants);
const [eloValues, setEloValues] = useState<EloValues>(() => {
const initial: EloValues = {};
loaderParticipants.forEach((p) => {
const existing = eloMap[p.id];
initial[p.id] = {
ranking: existing !== undefined && existing.ranking !== null ? String(existing.ranking) : '',
hard: existing !== undefined && existing.hard !== null ? String(existing.hard) : '',
clay: existing !== undefined && existing.clay !== null ? String(existing.clay) : '',
grass: existing !== undefined && existing.grass !== null ? String(existing.grass) : '',
};
});
return initial;
});
// Bulk import state: "Player Name, ranking, hardElo, clayElo, grassElo" one per line
const [bulkText, setBulkText] = useState('');
const [parseResults, setParseResults] = useState<ParseResults | null>(null);
// Store pending Elo values for participants being created
const pendingElosByName = useRef<Map<string, { ranking: number; hard: number; clay: number; grass: number }>>(new Map());
// When a participant is created, apply their Elos and remove from unmatched
useEffect(() => {
if (
createFetcher.state !== 'idle' ||
!createFetcher.data ||
!('intent' in createFetcher.data) ||
createFetcher.data.intent !== 'create-participant' ||
!createFetcher.data.success
) return;
const { participant } = createFetcher.data as Extract<ActionData, { intent: 'create-participant'; success: true }>;
// Add to local participants list
setLocalParticipants((prev) => {
if (prev.some((p) => p.id === participant.id)) return prev;
return [...prev, participant];
});
// Apply pending Elo values
const pending = pendingElosByName.current.get(participant.name);
if (pending) {
setEloValues((prev) => ({
...prev,
[participant.id]: {
ranking: String(pending.ranking),
hard: String(pending.hard),
clay: String(pending.clay),
grass: String(pending.grass),
},
}));
pendingElosByName.current.delete(participant.name);
}
// Remove from unmatched
setParseResults((prev) => {
if (!prev) return prev;
return {
...prev,
unmatched: prev.unmatched.filter((u) => u.inputName !== participant.name),
};
});
}, [createFetcher.state, createFetcher.data]);
function findParticipantMatch(inputName: string, pool: LocalParticipant[]) {
const normalizedInput = normalizeName(inputName);
const normalized = pool.map((p) => ({ p, n: normalizeName(p.name) }));
const exact = normalized.find(({ n }) => n === normalizedInput);
if (exact) return exact.p;
const contains = normalized.find(({ n }) => n.includes(normalizedInput) || normalizedInput.includes(n));
if (contains) return contains.p;
const inputWords = normalizedInput.split(' ').filter((w) => w.length > 2);
const overlap = normalized.find(({ n }) => {
const pWords = n.split(' ').filter((w) => w.length > 2);
const shared = inputWords.filter((w) => pWords.includes(w));
return shared.length > 0 && shared.length >= Math.min(inputWords.length, pWords.length) * 0.5;
});
return overlap?.p ?? null;
}
function getFuzzySuggestions(inputName: string, pool: LocalParticipant[], exclude: Set<string>): Suggestion[] {
const normalizedInput = normalizeName(inputName);
return pool
.filter((p) => !exclude.has(p.id))
.map((p) => ({ participantId: p.id, name: p.name, score: diceCoefficient(normalizedInput, normalizeName(p.name)) }))
.filter((s) => s.score >= 0.3)
.toSorted((a, b) => b.score - a.score)
.slice(0, 3);
}
function parseBulkText() {
const lines = bulkText.split('\n');
const matched: MatchedItem[] = [];
const unmatched: UnmatchedItem[] = [];
const seen = new Set<string>();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = /^(.+?)[,\t]\s*(\d{1,4})[,\t]\s*(\d{3,5}(?:\.\d+)?)[,\t]\s*(\d{3,5}(?:\.\d+)?)[,\t]\s*(\d{3,5}(?:\.\d+)?)\s*$/.exec(trimmed);
if (!match) continue;
const inputName = match[1].trim();
const ranking = parseInt(match[2], 10);
const hard = Math.round(parseFloat(match[3]));
const clay = Math.round(parseFloat(match[4]));
const grass = Math.round(parseFloat(match[5]));
const participant = findParticipantMatch(inputName, localParticipants);
if (participant && !seen.has(participant.id)) {
seen.add(participant.id);
matched.push({ participantId: participant.id, name: participant.name, ranking, hard, clay, grass, inputName });
} else {
// No match found, or the matched participant was already claimed by an earlier line
const suggestions = getFuzzySuggestions(inputName, localParticipants, seen);
unmatched.push({ inputName, ranking, hard, clay, grass, suggestions });
}
}
setParseResults({ matched, unmatched });
}
function assignSuggestion(item: UnmatchedItem, suggestion: Suggestion) {
setParseResults((prev) => {
if (!prev) return prev;
return {
matched: [
...prev.matched,
{
participantId: suggestion.participantId,
name: suggestion.name,
ranking: item.ranking,
hard: item.hard,
clay: item.clay,
grass: item.grass,
inputName: item.inputName,
},
],
unmatched: prev.unmatched.filter((u) => u.inputName !== item.inputName),
};
});
}
function handleCreateParticipant(item: UnmatchedItem) {
pendingElosByName.current.set(item.inputName, {
ranking: item.ranking,
hard: item.hard,
clay: item.clay,
grass: item.grass,
});
const fd = new FormData();
fd.set('intent', 'create-participant');
fd.set('name', item.inputName);
createFetcher.submit(fd, { method: 'post' });
}
function applyMatches() {
if (!parseResults) return;
const newValues = { ...eloValues };
for (const m of parseResults.matched) {
newValues[m.participantId] = {
ranking: String(m.ranking),
hard: String(m.hard),
clay: String(m.clay),
grass: String(m.grass),
};
}
setEloValues(newValues);
setParseResults(null);
setBulkText('');
}
const setField = (participantId: string, field: 'ranking' | 'hard' | 'clay' | 'grass', value: string) => {
setEloValues((prev) => ({
...prev,
[participantId]: { ...prev[participantId], [field]: value },
}));
};
const isSubmitting = navigation.state === 'submitting';
return (
<div className="container mx-auto py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Surface Elo Ratings</h1>
<p className="text-muted-foreground">
{sportsSeason.sport.name} {sportsSeason.name}
</p>
</div>
{/* Bulk Import */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Bulk Import</CardTitle>
<CardDescription>
Paste ratings one per line. Format:{' '}
<code>Player Name, ranking, hardElo, clayElo, grassElo</code>. Player names are
fuzzy-matched to participants. Rankings are ATP/WTA (1 = world #1). Typical tennis
Elo ranges: 14002200.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Textarea
placeholder={`Carlos Alcaraz, 1, 2180, 2200, 2100\nJannik Sinner, 2, 2190, 2050, 2080\nNovak Djokovic, 3, 2120, 2150, 2090`}
value={bulkText}
onChange={(e) => { setBulkText(e.target.value); setParseResults(null); }}
rows={8}
className="font-mono text-sm"
/>
<Button type="button" variant="outline" onClick={parseBulkText} disabled={!bulkText.trim()}>
Parse Ratings
</Button>
{parseResults && (
<div className="space-y-3">
{parseResults.matched.length > 0 && (
<div>
<div className="flex items-center gap-2 text-sm font-medium text-emerald-400 mb-2">
<CheckCircle2 className="h-4 w-4" />
Matched ({parseResults.matched.length})
</div>
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/10 divide-y divide-emerald-500/20 text-sm">
{parseResults.matched.map((m) => (
<div key={m.participantId} className="flex justify-between px-3 py-1.5 gap-4">
<span className="text-muted-foreground">{m.inputName}</span>
<span className="font-medium">
{m.name} &rarr; #{m.ranking} H:{m.hard} / C:{m.clay} / G:{m.grass}
</span>
</div>
))}
</div>
</div>
)}
{parseResults.unmatched.length > 0 && (
<div>
<div className="flex items-center gap-2 text-sm font-medium text-amber-400 mb-2">
<AlertCircle className="h-4 w-4" />
Not matched ({parseResults.unmatched.length})
</div>
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 divide-y divide-amber-500/20 text-sm">
{parseResults.unmatched.map((u) => {
const isCreating =
createFetcher.state !== 'idle' &&
pendingElosByName.current.has(u.inputName);
return (
<div key={u.inputName} className="px-3 py-2 space-y-2">
<div className="font-medium text-amber-300">{u.inputName}</div>
{u.suggestions.length > 0 ? (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Did you mean</div>
{u.suggestions.map((s) => (
<div key={s.participantId} className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-6 text-xs px-2"
onClick={() => assignSuggestion(u, s)}
>
Use this
</Button>
<span className="text-muted-foreground">{s.name}</span>
<span className="text-xs text-muted-foreground/60">
({Math.round(s.score * 100)}% match)
</span>
</div>
))}
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 text-xs px-2 text-amber-400 hover:text-amber-300"
disabled={isCreating}
onClick={() => handleCreateParticipant(u)}
>
{isCreating ? (
<><Loader2 className="mr-1 h-3 w-3 animate-spin" /> Creating</>
) : (
<><UserPlus className="mr-1 h-3 w-3" /> Create new participant</>
)}
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">No close matches found.</span>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 text-xs px-2"
disabled={isCreating}
onClick={() => handleCreateParticipant(u)}
>
{isCreating ? (
<><Loader2 className="mr-1 h-3 w-3 animate-spin" /> Creating</>
) : (
<><UserPlus className="mr-1 h-3 w-3" /> Create participant</>
)}
</Button>
</div>
)}
</div>
);
})}
</div>
</div>
)}
{parseResults.matched.length > 0 && (
<Button type="button" onClick={applyMatches}>
Apply {parseResults.matched.length} matched ratings to form
</Button>
)}
</div>
)}
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<Card>
<CardHeader>
<CardTitle>Player Surface Elo Ratings</CardTitle>
<CardDescription>
Enter Elo ratings per surface. Saving will run the simulation and update expected
values. Leave blank for any surface if you don't have a rating — a fallback of
1500 will be used.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<div className="space-y-1">
{/* Header row */}
<div className="grid grid-cols-5 gap-3 text-xs font-semibold text-muted-foreground uppercase tracking-wide pb-1 border-b">
<span>Player</span>
<span>Rank</span>
<span>Hard</span>
<span>Clay</span>
<span>Grass</span>
</div>
{localParticipants.map((p) => (
<div key={p.id} className="grid grid-cols-5 gap-3 items-center py-1">
<Label htmlFor={`ranking_${p.id}`} className="truncate text-sm">
{p.name}
</Label>
<Input
type="number"
id={`ranking_${p.id}`}
name={`ranking_${p.id}`}
placeholder="1"
value={eloValues[p.id]?.ranking ?? ''}
onChange={(e) => setField(p.id, 'ranking', e.target.value)}
className="h-8 text-sm"
/>
{(['hard', 'clay', 'grass'] as const).map((surface) => (
<Input
key={surface}
type="number"
name={`${surface}_${p.id}`}
placeholder="1750"
value={eloValues[p.id]?.[surface] ?? ''}
onChange={(e) => setField(p.id, surface, e.target.value)}
className="h-8 text-sm"
/>
))}
</div>
))}
</div>
{actionData && !('intent' in actionData) && !actionData.success && actionData.message && (
<div className="text-sm text-destructive">{actionData.message}</div>
)}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isSubmitting ? 'Saving & Running Simulation...' : 'Save Ratings & Run Simulation'}
</Button>
</Form>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>How It Works</CardTitle>
</CardHeader>
<CardContent className="text-sm space-y-2 text-muted-foreground">
<ol className="list-decimal list-inside space-y-2">
<li>Enter surface-specific Elo ratings for all players</li>
<li>For each of 10,000 simulations, simulate all 4 Grand Slams using the appropriate surface Elo</li>
<li>Top 32 by world ranking are seeded; remaining 96 are drawn randomly</li>
<li>Per-match win probability: p = 1 / (1 + 10^(ΔElo/400))</li>
<li>QP per round: Winner 20, Finalist 14, SF 9, QF 4, R16 1.5</li>
<li>Players are ranked by total QP across all 4 majors</li>
<li>Placement probabilities (1st8th) determine expected fantasy value</li>
</ol>
<div className="mt-4 text-xs space-y-1">
<div className="font-medium text-foreground">Grand Slam surfaces:</div>
<div>Australian Open Hard</div>
<div>French Open Clay</div>
<div>Wimbledon Grass</div>
<div>US Open Hard</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}