Entering projected wins for an in-progress MLB season did not behave as expected: the entered numbers came back changed, and the simulation appeared to ignore them in favour of whatever Elo was already stored. Four separate defects were involved. Projections are now stored and shown verbatim. The Elo Ratings page never kept the number typed into it — the field was a display derived from Elo, so a pasted 95 rendered as 95.1 the moment it was applied (wins to Elo rounds to an integer Elo) and drifted again after each run, because a run re-resolves that Elo through the input policy. The loader now reads back the stored projection and the paste flow keeps the pasted value as-is; the derived round-trip survives only as a prefill for seasons that have never had a projection saved. A stale Elo no longer silently outranks a projection. baseEloPriority takes the first available base source, and the simulator page's bulk CSV wrote projectedWins without stamping metadata.sourceEloMethod, so the non-destructive upsert left the old Elo in place as a trusted direct value and it won the race — the projection was stored and then ignored on every run. The CSV path now stamps the flag like the Elo Ratings page does, the metadata upsert merges rather than replaces so a flag-only write keeps unrelated keys, and Base Elo Source is editable per season for the case where a genuine hand-entered Elo should still lose to projections. Projected wins now act as a projected final total. The value was baked into a flat season-long rate (projectedWins / 162) applied to every remaining game, so a team at 60-50 projected for 95 finished around 90.5 and the projection was never reached mid-season. seedingWinRateFor spreads the difference over the games still to play, which is a no-op pre-season where the two rates coincide; projectedWinsWeight blends it back toward the Elo-implied rate. Playoff-parity compression is restored for Elo-rated teams. eloToRDif scaled by RDIF_DIVISOR, making it the exact algebraic inverse of winRateFromRDif, so any team with an Elo skipped the compression every hardcoded-rdif team gets: a 95-win projection became RDif +686 and played playoff games at .586 instead of the documented ~.517. It now scales by SEEDING_RDIF_SCALE, landing at ~+140 alongside the Dodgers' hardcoded +137. Also fixes the preview table's "missing a required input" marker, which flagged every projection-configured participant because a generated Elo or rating is deliberately hidden from getParticipantSimulatorInputs. It now consults the resolved values, so it agrees with readiness. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQSEmmojmqmGdJttgzqCWK
717 lines
32 KiB
TypeScript
717 lines
32 KiB
TypeScript
import { Form, redirect, useLoaderData, useActionData, useNavigation } from 'react-router';
|
||
import type { Route } from './+types/admin.sports-seasons.$id.elo-ratings';
|
||
|
||
import { logger } from '~/lib/logger';
|
||
import { findSportsSeasonById } from '~/models/sports-season';
|
||
import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
|
||
import {
|
||
getAllParticipantEVsForSeason,
|
||
batchSaveSourceElos,
|
||
} from '~/models/participant-expected-value';
|
||
import type { SimulatorType } from '~/services/simulations/registry';
|
||
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 { useState } from 'react';
|
||
import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||
import { normalizeName } from '~/lib/fuzzy-match';
|
||
import { getSimulatorConfig, supportsProjectedWins, type SimulatorConfig } from '~/services/simulations/simulator-config';
|
||
import {
|
||
eloToProjectedTablePoints,
|
||
eloToProjectedWins,
|
||
projectedTablePointsToElo,
|
||
projectedWinsToElo,
|
||
} from '~/services/probability-engine';
|
||
import { runSportsSeasonSimulation } from '~/services/simulations/runner';
|
||
import { getParticipantSimulatorInputs, getSportsSeasonSimulatorConfig } from '~/models/simulator';
|
||
|
||
// Simulator types that use worldRanking in addition to sourceElo
|
||
const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points', 'college_hockey_bracket']);
|
||
const RANK_ONLY_SIMULATOR_TYPES = new Set(['college_hockey_bracket']);
|
||
|
||
function rankingLabel(simulatorType: string | null | undefined): string {
|
||
if (simulatorType === 'cs2_major_qualifying_points') return 'HLTV Rank';
|
||
if (simulatorType === 'college_hockey_bracket') return 'NPI Rank';
|
||
return 'World Rank';
|
||
}
|
||
|
||
function configNumber(config: Record<string, unknown> | undefined, key: string, fallback: number): number {
|
||
const value = config?.[key];
|
||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||
}
|
||
|
||
async function getProjectionSimulatorConfig(
|
||
sportsSeasonId: string,
|
||
simulatorType: SimulatorType
|
||
): Promise<SimulatorConfig | null> {
|
||
const baseConfig = getSimulatorConfig(simulatorType);
|
||
if (!baseConfig) return null;
|
||
|
||
const runtimeConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
|
||
const config = runtimeConfig?.config;
|
||
return {
|
||
...baseConfig,
|
||
seasonGames: configNumber(config, 'seasonGames', baseConfig.seasonGames),
|
||
parityFactor: configNumber(config, 'parityFactor', baseConfig.parityFactor),
|
||
averageOpponentElo: configNumber(config, 'averageOpponentElo', baseConfig.averageOpponentElo),
|
||
};
|
||
}
|
||
|
||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||
return [{ title: `Elo Ratings — ${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 existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
||
const simulatorInputs = await getParticipantSimulatorInputs(sportsSeasonId);
|
||
|
||
// The projection a participant was actually saved with. Read it back verbatim:
|
||
// deriving the field from the stored Elo instead (as this page used to) shows the
|
||
// admin a different number than they typed, because wins → Elo rounds to an
|
||
// integer Elo and a simulation run then re-resolves that Elo through the input
|
||
// policy (clamping, and blending in futures odds when a season has them).
|
||
const projectionsByParticipant = new Map(
|
||
simulatorInputs.map((input) => [
|
||
input.participantId,
|
||
{ projectedWins: input.projectedWins, projectedTablePoints: input.projectedTablePoints },
|
||
])
|
||
);
|
||
|
||
const existingData: Record<
|
||
string,
|
||
{ elo: number | null; ranking: number | null; projectedWins: number | null; projectedTablePoints: number | null }
|
||
> = {};
|
||
for (const participant of participants) {
|
||
const projection = projectionsByParticipant.get(participant.id);
|
||
existingData[participant.id] = {
|
||
elo: null,
|
||
ranking: null,
|
||
projectedWins: projection?.projectedWins ?? null,
|
||
projectedTablePoints: projection?.projectedTablePoints ?? null,
|
||
};
|
||
}
|
||
for (const ev of existingEVs) {
|
||
const existing = existingData[ev.participantId];
|
||
if (!existing) continue;
|
||
existing.elo = ev.sourceElo ?? null;
|
||
existing.ranking = ev.worldRanking ?? null;
|
||
}
|
||
|
||
const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? '');
|
||
|
||
const simulatorConfig = sportsSeason.sport?.simulatorType
|
||
? await getProjectionSimulatorConfig(sportsSeasonId, 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 {
|
||
success?: boolean;
|
||
message?: string;
|
||
}
|
||
|
||
export async function action({ request, params }: Route.ActionArgs) {
|
||
const sportsSeasonId = params.id;
|
||
const formData = await request.formData();
|
||
|
||
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
||
if (!sportsSeason) {
|
||
return { success: false, message: 'Sports season not found' };
|
||
}
|
||
|
||
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
||
const simulatorType = sportsSeason.sport?.simulatorType ?? '';
|
||
const usesRanking = RANKING_SIMULATOR_TYPES.has(simulatorType);
|
||
const allowsRankOnly = RANK_ONLY_SIMULATOR_TYPES.has(simulatorType);
|
||
const rawMode = formData.get('inputMode');
|
||
const inputMode = rawMode === 'projectedWins' ? 'projectedWins' : 'elo';
|
||
|
||
const eloInputs: Array<{
|
||
participantId: string;
|
||
sportsSeasonId: string;
|
||
sourceElo: number | null;
|
||
worldRanking?: number | null;
|
||
projectedWins?: number | null;
|
||
projectedTablePoints?: number | null;
|
||
metadata?: Record<string, unknown> | null;
|
||
}> = [];
|
||
|
||
if (inputMode === 'projectedWins' && sportsSeason.sport?.simulatorType) {
|
||
const config = await getProjectionSimulatorConfig(sportsSeasonId, sportsSeason.sport.simulatorType as SimulatorType);
|
||
if (!config) {
|
||
return { success: false, message: 'This sport does not support projection input.' };
|
||
}
|
||
|
||
for (const participant of participants) {
|
||
const winsVal = formData.get(`wins_${participant.id}`) as string;
|
||
if (winsVal && winsVal.trim() !== '') {
|
||
const projectedWins = parseFloat(winsVal);
|
||
const projectionMax = config.projectionInput === 'tablePoints' ? config.seasonGames * 3 : config.seasonGames;
|
||
if (!isNaN(projectedWins) && projectedWins >= 0 && projectedWins <= projectionMax) {
|
||
const elo = config.projectionInput === 'tablePoints'
|
||
? projectedTablePointsToElo(projectedWins, config.seasonGames, config.parityFactor, config.averageOpponentElo)
|
||
: projectedWinsToElo(projectedWins, config.seasonGames, config.parityFactor, config.averageOpponentElo);
|
||
eloInputs.push({
|
||
participantId: participant.id,
|
||
sportsSeasonId,
|
||
sourceElo: elo,
|
||
projectedWins: config.projectionInput === 'tablePoints' ? null : projectedWins,
|
||
projectedTablePoints: config.projectionInput === 'tablePoints' ? projectedWins : null,
|
||
metadata: {
|
||
sourceEloMethod: config.projectionInput === 'tablePoints' ? 'projectedTablePoints' : 'projectedWins',
|
||
},
|
||
});
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
for (const participant of participants) {
|
||
const eloVal = formData.get(`elo_${participant.id}`) as string;
|
||
const rankVal = formData.get(`rank_${participant.id}`) as string;
|
||
|
||
const elo = eloVal && eloVal.trim() !== '' ? Number(eloVal) : null;
|
||
const ranking = rankVal && rankVal.trim() !== '' ? Number(rankVal) : null;
|
||
const validElo = elo !== null && !isNaN(elo) && elo > 0;
|
||
const validRanking = ranking !== null && !isNaN(ranking) && ranking > 0;
|
||
|
||
if (validElo || (usesRanking && allowsRankOnly && validRanking)) {
|
||
if (usesRanking) {
|
||
eloInputs.push({
|
||
participantId: participant.id,
|
||
sportsSeasonId,
|
||
sourceElo: validElo ? elo : null,
|
||
worldRanking: validRanking ? ranking : null,
|
||
});
|
||
} else if (validElo) {
|
||
eloInputs.push({ participantId: participant.id, sportsSeasonId, sourceElo: elo });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (eloInputs.length === 0) {
|
||
return { success: false, message: inputMode === 'projectedWins'
|
||
? 'Please enter projections for at least one participant'
|
||
: allowsRankOnly
|
||
? 'Please enter an Elo rating or NPI rank for at least one participant'
|
||
: 'Please enter an Elo rating for at least one participant' };
|
||
}
|
||
|
||
if (!sportsSeason.sport?.simulatorType) {
|
||
return { success: false, message: 'This sport has no simulator type configured. Set one on the sport in the admin panel.' };
|
||
}
|
||
|
||
if (sportsSeason.simulationStatus === 'running') {
|
||
return { success: false, message: 'A simulation is already running. Please wait.' };
|
||
}
|
||
|
||
try {
|
||
await batchSaveSourceElos(eloInputs);
|
||
await runSportsSeasonSimulation(sportsSeasonId);
|
||
} catch (error) {
|
||
logger.error('Error running simulation:', error);
|
||
return {
|
||
success: false,
|
||
message: error instanceof Error ? error.message : 'Simulation failed',
|
||
};
|
||
}
|
||
|
||
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
|
||
}
|
||
|
||
export default function AdminSportsSeasonEloRatings() {
|
||
const { sportsSeason, participants, existingData, usesRanking, simulatorConfig, canUseProjectedWins } = useLoaderData<typeof loader>();
|
||
const actionData = useActionData<ActionData>();
|
||
const navigation = useNavigation();
|
||
|
||
const [inputMode, setInputMode] = useState<'elo' | 'projectedWins'>('elo');
|
||
const projectionLabel = simulatorConfig?.projectionInput === 'tablePoints' ? 'Projected Points' : 'Projected Wins';
|
||
const projectionUnit = simulatorConfig?.projectionInput === 'tablePoints' ? 'points' : 'wins';
|
||
const projectionMax = simulatorConfig
|
||
? simulatorConfig.projectionInput === 'tablePoints'
|
||
? simulatorConfig.seasonGames * 3
|
||
: simulatorConfig.seasonGames
|
||
: 0;
|
||
|
||
const [eloValues, setEloValues] = useState<Record<string, string>>(() => {
|
||
const initial: Record<string, string> = {};
|
||
participants.forEach(p => {
|
||
const d = existingData[p.id];
|
||
if (d?.elo !== null && d?.elo !== undefined) initial[p.id] = d.elo.toString();
|
||
});
|
||
return initial;
|
||
});
|
||
|
||
const [rankValues, setRankValues] = useState<Record<string, string>>(() => {
|
||
const initial: Record<string, string> = {};
|
||
participants.forEach(p => {
|
||
const d = existingData[p.id];
|
||
if (d?.ranking !== null && d?.ranking !== undefined) initial[p.id] = d.ranking.toString();
|
||
});
|
||
return initial;
|
||
});
|
||
|
||
const [winsValues, setWinsValues] = useState<Record<string, string>>(() => {
|
||
const initial: Record<string, string> = {};
|
||
if (simulatorConfig) {
|
||
participants.forEach(p => {
|
||
const d = existingData[p.id];
|
||
// A stored projection is shown exactly as it was entered. Only fall back to
|
||
// deriving it from the Elo when this season has no projection saved (a
|
||
// season that has only ever had Elos entered still gets a useful starting
|
||
// point) — that derived value is lossy and must never overwrite a real one.
|
||
const stored = simulatorConfig.projectionInput === 'tablePoints'
|
||
? d?.projectedTablePoints
|
||
: d?.projectedWins;
|
||
if (stored !== null && stored !== undefined) {
|
||
initial[p.id] = stored.toString();
|
||
} else if (d?.elo !== null && d?.elo !== undefined) {
|
||
initial[p.id] = (simulatorConfig.projectionInput === 'tablePoints'
|
||
? eloToProjectedTablePoints(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||
: 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 | null; ranking: number | null; projection: number | null; inputName: string }>;
|
||
unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }>;
|
||
} | null>(null);
|
||
|
||
function findParticipantMatch(inputName: string) {
|
||
const normalizedInput = normalizeName(inputName);
|
||
const normalized = participants.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 parseBulkText() {
|
||
const lines = bulkText.split('\n');
|
||
const matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; projection: number | null; inputName: string }> = [];
|
||
const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }> = [];
|
||
const seen = new Set<string>();
|
||
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed) continue;
|
||
|
||
if (inputMode === 'projectedWins' && simulatorConfig) {
|
||
const match = /^(.+?)[\s,:\t]+(\d+(?:\.\d+)?)\s*$/.exec(trimmed);
|
||
if (!match) continue;
|
||
|
||
const inputName = match[1].trim();
|
||
const projectedWins = parseFloat(match[2]);
|
||
|
||
if (isNaN(projectedWins) || projectedWins < 0 || projectedWins > projectionMax) continue;
|
||
|
||
const elo = simulatorConfig.projectionInput === 'tablePoints'
|
||
? projectedTablePointsToElo(projectedWins, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||
: 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, projection: projectedWins, inputName });
|
||
} else if (!participant) {
|
||
unmatched.push({ inputName, elo, ranking: null, projection: projectedWins });
|
||
}
|
||
} else {
|
||
const match = usesRanking
|
||
? /^(.+?)[\s,:\t]+(\d{1,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 firstNumber = parseInt(match[2], 10);
|
||
const parsedAsRankOnly = usesRanking && allowsRankOnly && !match[3] && firstNumber < 500;
|
||
const elo = parsedAsRankOnly ? null : firstNumber;
|
||
const ranking = parsedAsRankOnly
|
||
? firstNumber
|
||
: usesRanking && match[3]
|
||
? parseInt(match[3], 10)
|
||
: null;
|
||
|
||
const validElo = elo !== null && !isNaN(elo) && elo >= 500 && elo <= 5000;
|
||
const validRanking = ranking !== null && !isNaN(ranking) && ranking > 0;
|
||
if (!validElo && !(allowsRankOnly && validRanking)) 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, projection: null, inputName });
|
||
} else if (!participant) {
|
||
unmatched.push({ inputName, elo, ranking, projection: null });
|
||
}
|
||
}
|
||
}
|
||
|
||
setParseResults({ matched, unmatched });
|
||
}
|
||
|
||
function applyMatches() {
|
||
if (!parseResults) return;
|
||
const newElos = { ...eloValues };
|
||
const newRanks = { ...rankValues };
|
||
const newWins = { ...winsValues };
|
||
for (const m of parseResults.matched) {
|
||
if (m.elo !== null) newElos[m.participantId] = m.elo.toString();
|
||
if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString();
|
||
// The pasted number goes in as typed. Round-tripping it through the derived
|
||
// Elo (as this used to) drifts it by up to half an Elo point — a pasted 95
|
||
// came back as 95.1 before anything was even saved.
|
||
if (inputMode === 'projectedWins' && m.projection !== null) {
|
||
newWins[m.participantId] = m.projection.toString();
|
||
}
|
||
}
|
||
setEloValues(newElos);
|
||
setRankValues(newRanks);
|
||
setWinsValues(newWins);
|
||
setParseResults(null);
|
||
setBulkText('');
|
||
}
|
||
|
||
const isSubmitting = navigation.state === 'submitting';
|
||
const simulatorType = sportsSeason.sport?.simulatorType ?? null;
|
||
const rankLabel = rankingLabel(simulatorType);
|
||
const allowsRankOnly = RANK_ONLY_SIMULATOR_TYPES.has(simulatorType ?? '');
|
||
const participantLabel = simulatorType === 'college_hockey_bracket' ? 'Team' : 'Player';
|
||
|
||
// When ranking is available, sort by rank ascending, then unranked alphabetically
|
||
const sortedParticipants = usesRanking
|
||
? [...participants].toSorted((a, b) => {
|
||
const rankA = rankValues[a.id] ? parseInt(rankValues[a.id], 10) : null;
|
||
const rankB = rankValues[b.id] ? parseInt(rankValues[b.id], 10) : null;
|
||
if (rankA !== null && rankB !== null) return rankA - rankB;
|
||
if (rankA !== null) return -1;
|
||
if (rankB !== null) return 1;
|
||
return a.name.localeCompare(b.name);
|
||
})
|
||
: participants;
|
||
|
||
return (
|
||
<div className="container mx-auto py-8">
|
||
<div className="mb-8">
|
||
<h1 className="text-3xl font-bold mb-2">Elo Ratings</h1>
|
||
<p className="text-muted-foreground">
|
||
{sportsSeason.sport.name} — {sportsSeason.name}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Input Mode Toggle */}
|
||
{canUseProjectedWins && !usesRanking && (
|
||
<div className="mb-6 flex gap-2">
|
||
<Button
|
||
type="button"
|
||
variant={inputMode === 'elo' ? 'default' : 'outline'}
|
||
onClick={() => setInputMode('elo')}
|
||
>
|
||
Elo Ratings
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant={inputMode === 'projectedWins' ? 'default' : 'outline'}
|
||
onClick={() => setInputMode('projectedWins')}
|
||
>
|
||
{projectionLabel}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Bulk Import */}
|
||
<Card className="mb-6">
|
||
<CardHeader>
|
||
<CardTitle>Bulk Import</CardTitle>
|
||
<CardDescription>
|
||
{inputMode === 'projectedWins' && simulatorConfig ? (
|
||
<>
|
||
Paste projected season {projectionUnit} one per line. Format: <code>Team Name, {simulatorConfig.projectionInput === 'tablePoints' ? '76.5' : '15.6'}</code>.
|
||
Projections 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:{' '}
|
||
{allowsRankOnly ? (
|
||
<>Use <code>Name, {rankLabel}</code> or <code>Name, Elo, {rankLabel}</code>. Elo is optional when NPI rank is present.</>
|
||
) : (
|
||
<>Use <code>Name, Elo, {rankLabel}</code> (ranking is optional — omit it and the simulator will use Elo order for seeding).</>
|
||
)}{' '}
|
||
Names are fuzzy-matched to participants.
|
||
</>
|
||
) : (
|
||
<>
|
||
Paste Elo ratings one per line. Format: <code>Player Name, 2450</code>. Player names
|
||
are fuzzy-matched to participants.
|
||
</>
|
||
)}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<Textarea
|
||
placeholder={
|
||
inputMode === 'projectedWins'
|
||
? simulatorConfig?.projectionInput === 'tablePoints'
|
||
? `Team Name, 76.5\nTeam Name, 68.0\nTeam Name, 54.5`
|
||
: `Team Name, 15.6\nTeam Name, 14.5\nTeam Name, 13.8`
|
||
: usesRanking
|
||
? simulatorType === 'college_hockey_bracket'
|
||
? `Denver, 1\nBoston College, 2\nMichigan State, 1650, 3`
|
||
: simulatorType === 'cs2_major_qualifying_points'
|
||
? `Natus Vincere, 1850, 1\nFaZe Clan, 1820, 2\nVitality, 1810, 3`
|
||
: `Luke Littler, 2099, 1\nMichael van Gerwen, 1950, 2\nLuke Humphries, 1947, 3`
|
||
: `Judd Trump, 2594
|
||
Ronnie O'Sullivan, 2441
|
||
Mark Selby, 2432`
|
||
}
|
||
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">
|
||
<span className="text-muted-foreground">{m.inputName}</span>
|
||
<span className="font-medium">
|
||
{m.name} →{' '}
|
||
{m.projection !== null
|
||
? `${m.projection} ${projectionUnit} (Elo ${m.elo})`
|
||
: m.elo !== null ? `Elo ${m.elo}` : 'No Elo'}
|
||
{usesRanking && m.ranking !== null ? `, ${rankLabel} #${m.ranking}` : ''}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{parseResults.unmatched.length > 0 && (
|
||
<div>
|
||
<div className="flex items-center gap-2 text-sm font-medium text-amber-700 mb-2">
|
||
<AlertCircle className="h-4 w-4" />
|
||
Not matched ({parseResults.unmatched.length}) — enter manually below
|
||
</div>
|
||
<div className="rounded-md border border-amber-200 bg-amber-50 divide-y divide-amber-100 text-sm">
|
||
{parseResults.unmatched.map(u => (
|
||
<div key={u.inputName} className="flex justify-between px-3 py-1.5">
|
||
<span>{u.inputName}</span>
|
||
<span className="font-medium">
|
||
{u.projection !== null
|
||
? `${u.projection} ${projectionUnit} (Elo ${u.elo})`
|
||
: u.elo !== null ? `Elo ${u.elo}` : 'No Elo'}
|
||
{usesRanking && u.ranking !== null ? `, ${rankLabel} #${u.ranking}` : ''}
|
||
</span>
|
||
</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-2">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>
|
||
{inputMode === 'projectedWins'
|
||
? `${projectionLabel} (out of ${projectionMax || '?'})`
|
||
: usesRanking
|
||
? `${participantLabel} Elo${allowsRankOnly ? ' (optional)' : ''} & ${rankLabel}s`
|
||
: `${participantLabel} Elo Ratings`}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{inputMode === 'projectedWins'
|
||
? `Enter each team's projected total season ${projectionUnit} — the number you enter is stored as-is and re-derives the Elo on every run. Mid-season it is treated as a projected final total, so the simulation spreads the difference over the games still to play. Saving will run the simulation and update expected values.`
|
||
: usesRanking
|
||
? `Enter each ${participantLabel.toLowerCase()}'s Elo${allowsRankOnly ? ' (optional)' : ''} and ${rankLabel}. Saving will automatically run the simulation and update expected values.`
|
||
: `Enter each ${participantLabel.toLowerCase()}'s current Elo rating. Saving will automatically run the simulation and update expected values.`}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Form method="post" className="space-y-4">
|
||
<input type="hidden" name="inputMode" value={inputMode} />
|
||
{usesRanking && inputMode === 'elo' && (
|
||
<div className="grid grid-cols-[1fr_100px_90px] gap-x-3 gap-y-1 items-center text-xs font-medium text-muted-foreground">
|
||
<span>{participantLabel}</span>
|
||
<span>Elo</span>
|
||
<span>{rankLabel} #</span>
|
||
</div>
|
||
)}
|
||
{inputMode === 'projectedWins' && (
|
||
<div className="grid grid-cols-[1fr_100px] gap-x-3 gap-y-1 items-center text-xs font-medium text-muted-foreground">
|
||
<span>Team</span>
|
||
<span>{simulatorConfig?.projectionInput === 'tablePoints' ? 'Proj Pts' : 'Proj Wins'}</span>
|
||
</div>
|
||
)}
|
||
<div className="space-y-2">
|
||
{sortedParticipants.map(participant => {
|
||
if (inputMode === 'projectedWins' && simulatorConfig) {
|
||
return (
|
||
<div key={participant.id} className="grid grid-cols-[1fr_100px] gap-x-3 items-center">
|
||
<Label htmlFor={`wins_${participant.id}`} className="truncate text-sm">
|
||
{participant.name}
|
||
</Label>
|
||
<Input
|
||
type="number"
|
||
step="0.1"
|
||
id={`wins_${participant.id}`}
|
||
name={`wins_${participant.id}`}
|
||
placeholder={`${(projectionMax / 2).toFixed(1)}`}
|
||
value={winsValues[participant.id] ?? ''}
|
||
onChange={e =>
|
||
setWinsValues(prev => ({ ...prev, [participant.id]: e.target.value }))
|
||
}
|
||
className="h-8 text-sm"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
key={participant.id}
|
||
className={usesRanking
|
||
? 'grid grid-cols-[1fr_100px_90px] gap-x-3 items-center'
|
||
: 'grid grid-cols-2 gap-4 items-center'}
|
||
>
|
||
<Label htmlFor={`elo_${participant.id}`} className="truncate text-sm">
|
||
{participant.name}
|
||
</Label>
|
||
<Input
|
||
type="number"
|
||
id={`elo_${participant.id}`}
|
||
name={`elo_${participant.id}`}
|
||
placeholder={usesRanking ? (allowsRankOnly ? 'optional' : '1800') : '2450'}
|
||
value={eloValues[participant.id] ?? ''}
|
||
onChange={e =>
|
||
setEloValues(prev => ({ ...prev, [participant.id]: e.target.value }))
|
||
}
|
||
className={usesRanking ? 'h-8 text-sm' : undefined}
|
||
/>
|
||
{usesRanking && (
|
||
<Input
|
||
type="number"
|
||
name={`rank_${participant.id}`}
|
||
placeholder="—"
|
||
value={rankValues[participant.id] ?? ''}
|
||
onChange={e =>
|
||
setRankValues(prev => ({ ...prev, [participant.id]: e.target.value }))
|
||
}
|
||
className="h-8 text-sm"
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{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>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>How It Works</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="text-sm space-y-2">
|
||
{inputMode === 'projectedWins' ? (
|
||
<ol className="list-decimal list-inside space-y-2">
|
||
<li>Enter each team's projected total season {projectionUnit}</li>
|
||
<li>Projections are converted to Elo ratings using the inverse Elo formula</li>
|
||
<li>Elo ratings are saved and the simulation runs automatically</li>
|
||
<li>Results: probability distributions across 1st–8th place buckets</li>
|
||
<li>Expected fantasy value is calculated per team</li>
|
||
</ol>
|
||
) : (
|
||
<ol className="list-decimal list-inside space-y-2">
|
||
<li>Save Elo ratings{usesRanking ? ` and ${rankLabel}s` : ''} for all participants</li>
|
||
<li>Compute per-game win probability from Elo difference</li>
|
||
<li>Compute match win probability using the Bernoulli model for each round's format</li>
|
||
<li>Run Monte Carlo simulations of the full event</li>
|
||
<li>Distribute probabilities across 1st–8th place buckets</li>
|
||
<li>Calculate expected fantasy value per player</li>
|
||
</ol>
|
||
)}
|
||
{usesRanking && inputMode === 'elo' && (
|
||
<div className="mt-4 text-muted-foreground text-xs">
|
||
{rankLabel} is optional — if omitted, the simulator uses Elo order for seeding. {allowsRankOnly ? 'For college hockey, Elo is optional when an NPI rank is entered.' : ''}
|
||
</div>
|
||
)}
|
||
{inputMode === 'projectedWins' && simulatorConfig && (
|
||
<div className="mt-4 text-muted-foreground text-xs">
|
||
Conversion: Elo = {simulatorConfig.averageOpponentElo} − {simulatorConfig.parityFactor} × log₁₀((1 − rate) / rate),
|
||
where rate = projection / {projectionMax}.
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|