diff --git a/app/routes/admin.sports-seasons.$id.elo-ratings.tsx b/app/routes/admin.sports-seasons.$id.elo-ratings.tsx index 7c0416d..385e911 100644 --- a/app/routes/admin.sports-seasons.$id.elo-ratings.tsx +++ b/app/routes/admin.sports-seasons.$id.elo-ratings.tsx @@ -31,6 +31,8 @@ import { import { useState } from 'react'; import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react'; import { normalizeName } from '~/lib/fuzzy-match'; +import { getSimulatorConfig, supportsProjectedWins } from '~/services/simulations/simulator-config'; +import { eloToProjectedWins, projectedWinsToElo } from '~/services/probability-engine'; // Simulator types that use worldRanking in addition to sourceElo const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points']); @@ -66,7 +68,14 @@ export async function loader({ params }: Route.LoaderArgs) { const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? ''); - return { sportsSeason, participants, existingData, usesRanking }; + const simulatorConfig = sportsSeason.sport?.simulatorType + ? getSimulatorConfig(sportsSeason.sport.simulatorType as SimulatorType) + : null; + const canUseProjectedWins = sportsSeason.sport?.simulatorType + ? supportsProjectedWins(sportsSeason.sport.simulatorType as SimulatorType) + : false; + + return { sportsSeason, participants, existingData, usesRanking, simulatorConfig, canUseProjectedWins }; } interface ActionData { @@ -85,32 +94,55 @@ export async function action({ request, params }: Route.ActionArgs) { const participants = await findParticipantsBySportsSeasonId(sportsSeasonId); const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? ''); + const rawMode = formData.get('inputMode'); + const inputMode = rawMode === 'projectedWins' ? 'projectedWins' : 'elo'; const eloInputs: Array<{ participantId: string; sportsSeasonId: string; sourceElo: number; worldRanking?: number | null }> = []; - for (const participant of participants) { - const eloVal = formData.get(`elo_${participant.id}`) as string; - const rankVal = formData.get(`rank_${participant.id}`) as string; - if (eloVal && eloVal.trim() !== '') { - const elo = Number(eloVal); - if (!isNaN(elo) && elo > 0) { - if (usesRanking) { - const ranking = rankVal && rankVal.trim() !== '' ? Number(rankVal) : null; - eloInputs.push({ - participantId: participant.id, - sportsSeasonId, - sourceElo: elo, - worldRanking: ranking && !isNaN(ranking) && ranking > 0 ? ranking : null, - }); - } else { + if (inputMode === 'projectedWins' && sportsSeason.sport?.simulatorType) { + const config = getSimulatorConfig(sportsSeason.sport.simulatorType as SimulatorType); + if (!config) { + return { success: false, message: 'This sport does not support projected wins input.' }; + } + + for (const participant of participants) { + const winsVal = formData.get(`wins_${participant.id}`) as string; + if (winsVal && winsVal.trim() !== '') { + const projectedWins = parseFloat(winsVal); + if (!isNaN(projectedWins) && projectedWins >= 0 && projectedWins <= config.seasonGames) { + const elo = projectedWinsToElo(projectedWins, config.seasonGames, config.parityFactor, config.averageOpponentElo); eloInputs.push({ participantId: participant.id, sportsSeasonId, sourceElo: elo }); } } } + } else { + for (const participant of participants) { + const eloVal = formData.get(`elo_${participant.id}`) as string; + const rankVal = formData.get(`rank_${participant.id}`) as string; + + if (eloVal && eloVal.trim() !== '') { + const elo = Number(eloVal); + if (!isNaN(elo) && elo > 0) { + if (usesRanking) { + const ranking = rankVal && rankVal.trim() !== '' ? Number(rankVal) : null; + eloInputs.push({ + participantId: participant.id, + sportsSeasonId, + sourceElo: elo, + worldRanking: ranking && !isNaN(ranking) && ranking > 0 ? ranking : null, + }); + } else { + eloInputs.push({ participantId: participant.id, sportsSeasonId, sourceElo: elo }); + } + } + } + } } if (eloInputs.length === 0) { - return { success: false, message: 'Please enter an Elo rating for at least one participant' }; + return { success: false, message: inputMode === 'projectedWins' + ? 'Please enter projected wins for at least one participant' + : 'Please enter an Elo rating for at least one participant' }; } if (!sportsSeason.sport?.simulatorType) { @@ -197,10 +229,12 @@ export async function action({ request, params }: Route.ActionArgs) { } export default function AdminSportsSeasonEloRatings() { - const { sportsSeason, participants, existingData, usesRanking } = useLoaderData(); + const { sportsSeason, participants, existingData, usesRanking, simulatorConfig, canUseProjectedWins } = useLoaderData(); const actionData = useActionData(); const navigation = useNavigation(); + const [inputMode, setInputMode] = useState<'elo' | 'projectedWins'>('elo'); + const [eloValues, setEloValues] = useState>(() => { const initial: Record = {}; participants.forEach(p => { @@ -219,6 +253,21 @@ export default function AdminSportsSeasonEloRatings() { return initial; }); + const [winsValues, setWinsValues] = useState>(() => { + const initial: Record = {}; + if (simulatorConfig) { + participants.forEach(p => { + const d = existingData[p.id]; + if (d?.elo !== null && d?.elo !== undefined) { + initial[p.id] = eloToProjectedWins( + d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo + ).toFixed(1); + } + }); + } + return initial; + }); + const [bulkText, setBulkText] = useState(''); const [parseResults, setParseResults] = useState<{ matched: Array<{ participantId: string; name: string; elo: number; ranking: number | null; inputName: string }>; @@ -255,24 +304,43 @@ export default function AdminSportsSeasonEloRatings() { const trimmed = line.trim(); if (!trimmed) continue; - // Match "Name, Elo" or "Name, Elo, Ranking" (comma/colon/tab separated) - const match = usesRanking - ? /^(.+?)[\s,:\t]+(\d{3,5})(?:[\s,:\t]+(\d{1,3}))?\s*$/.exec(trimmed) - : /^(.+?)[\s,:\t]+(\d{3,5})\s*$/.exec(trimmed); - if (!match) continue; + if (inputMode === 'projectedWins' && simulatorConfig) { + const match = /^(.+?)[\s,:\t]+(\d+(?:\.\d+)?)\s*$/.exec(trimmed); + if (!match) continue; - const inputName = match[1].trim(); - const elo = parseInt(match[2], 10); - const ranking = usesRanking && match[3] ? parseInt(match[3], 10) : null; + const inputName = match[1].trim(); + const projectedWins = parseFloat(match[2]); - if (isNaN(elo) || elo < 500 || elo > 5000) continue; + if (isNaN(projectedWins) || projectedWins < 0 || projectedWins > simulatorConfig.seasonGames) continue; - const participant = findParticipantMatch(inputName); - if (participant && !seen.has(participant.id)) { - seen.add(participant.id); - matched.push({ participantId: participant.id, name: participant.name, elo, ranking, inputName }); - } else if (!participant) { - unmatched.push({ inputName, elo, ranking }); + const elo = projectedWinsToElo(projectedWins, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo); + + const participant = findParticipantMatch(inputName); + if (participant && !seen.has(participant.id)) { + seen.add(participant.id); + matched.push({ participantId: participant.id, name: participant.name, elo, ranking: null, inputName }); + } else if (!participant) { + unmatched.push({ inputName, elo, ranking: null }); + } + } else { + const match = usesRanking + ? /^(.+?)[\s,:\t]+(\d{3,5})(?:[\s,:\t]+(\d{1,3}))?\s*$/.exec(trimmed) + : /^(.+?)[\s,:\t]+(\d{3,5})\s*$/.exec(trimmed); + if (!match) continue; + + const inputName = match[1].trim(); + const elo = parseInt(match[2], 10); + const ranking = usesRanking && match[3] ? parseInt(match[3], 10) : null; + + if (isNaN(elo) || elo < 500 || elo > 5000) continue; + + const participant = findParticipantMatch(inputName); + if (participant && !seen.has(participant.id)) { + seen.add(participant.id); + matched.push({ participantId: participant.id, name: participant.name, elo, ranking, inputName }); + } else if (!participant) { + unmatched.push({ inputName, elo, ranking }); + } } } @@ -283,12 +351,19 @@ export default function AdminSportsSeasonEloRatings() { if (!parseResults) return; const newElos = { ...eloValues }; const newRanks = { ...rankValues }; + const newWins = { ...winsValues }; for (const m of parseResults.matched) { newElos[m.participantId] = m.elo.toString(); if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString(); + if (inputMode === 'projectedWins' && simulatorConfig) { + newWins[m.participantId] = eloToProjectedWins( + m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo + ).toFixed(1); + } } setEloValues(newElos); setRankValues(newRanks); + setWinsValues(newWins); setParseResults(null); setBulkText(''); } @@ -318,12 +393,38 @@ export default function AdminSportsSeasonEloRatings() {

+ {/* Input Mode Toggle */} + {canUseProjectedWins && !usesRanking && ( +
+ + +
+ )} + {/* Bulk Import */} Bulk Import - {usesRanking ? ( + {inputMode === 'projectedWins' && simulatorConfig ? ( + <> + Paste projected season wins one per line. Format: Team Name, 15.6. + Wins are automatically converted to Elo ratings using {simulatorConfig.seasonGames} total games + and parity factor {simulatorConfig.parityFactor}. Names are fuzzy-matched to participants. + + ) : usesRanking ? ( <> Paste one entry per line. Format:{' '} Name, Elo, {rankLabel} (ranking is optional — omit it and the simulator @@ -340,11 +441,13 @@ export default function AdminSportsSeasonEloRatings() {