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, findParticipantByName, createParticipant } from '~/models/season-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'; import { normalizeName, diceCoefficient } from '~/lib/fuzzy-match'; 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 = {}; 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 existing = await findParticipantByName(sportsSeasonId, name); if (existing) { return { intent: 'create-participant', success: false, message: `"${name}" already exists in this season.` } 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; } type EloValues = Record; 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(); const actionData = useActionData(); const navigation = useNavigation(); const createFetcher = useFetcher(); const [localParticipants, setLocalParticipants] = useState(loaderParticipants); const [eloValues, setEloValues] = useState(() => { 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(null); // Store pending Elo values for participants being created const pendingElosByName = useRef>(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; // 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): 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(); 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 (

Surface Elo Ratings

{sportsSeason.sport.name} — {sportsSeason.name}

{/* Bulk Import */} Bulk Import Paste ratings one per line. Format:{' '} Player Name, ranking, hardElo, clayElo, grassElo. Player names are fuzzy-matched to participants. Rankings are ATP/WTA (1 = world #1). Typical tennis Elo ranges: 1400–2200.