import { Link } from "react-router"; import type { Route } from "./+types/admin.sports-seasons.$id.expected-values"; import { loader } from "./admin.sports-seasons.$id.expected-values.server"; import { Button } from "~/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "~/components/ui/card"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "~/components/ui/table"; import { ArrowLeft, Calculator } from "lucide-react"; import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types"; import { calculateEV } from "~/services/ev-calculator"; export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { return [{ title: `Expected Values — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }]; } export { loader }; // EV is shown on the same reference scale the runner persists it with: a sports season // is shared across leagues with different scoring, so DEFAULT_SCORING_RULES is the // common scale and each league re-derives its own EV from the stored probabilities // (see getPersistenceContext in services/simulations/runner.ts). // // Scoring: 1st=100, 2nd=70, 3rd=50, 4th=40, 5th/6th=25 each, 7th/8th=15 each. // Sum = 100+70+50+40+25+25+15+15 = 340. // // The 5th–8th values must stay distinct rather than collapsing to a flat 20: templates // that split that zone into two tiers (llws_20, afl_10) put a team locked into 5th–6th // at probFifth=probSixth=0.5 (EV 25) and one locked into 7th–8th at // probSeventh=probEighth=0.5 (EV 15). A flat table reports both as 20. // // Total EV invariant: Σ EV across all participants = Σ scoring values = 340, // because each probability column sums to 1.0 across all participants. // If total drifts from 340, likely causes: // 1. Stale EV records from a prior simulation run (fix: re-run simulation, which now // zeros non-bracket participants automatically) // 2. DB precision truncation (numeric(6,4) = 4dp; max drift ≈ ±1 for 68 teams) export function evFromProbs(ev: { probFirst: string; probSecond: string; probThird: string; probFourth: string; probFifth: string; probSixth: string; probSeventh: string; probEighth: string; }): number { return calculateEV( { probFirst: parseFloat(ev.probFirst), probSecond: parseFloat(ev.probSecond), probThird: parseFloat(ev.probThird), probFourth: parseFloat(ev.probFourth), probFifth: parseFloat(ev.probFifth), probSixth: parseFloat(ev.probSixth), probSeventh: parseFloat(ev.probSeventh), probEighth: parseFloat(ev.probEighth), }, DEFAULT_SCORING_RULES ); } function fmt(val: string | number) { return (parseFloat(val as string) * 100).toFixed(1) + "%"; } export default function ExpectedValuesPage({ loaderData }: Route.ComponentProps) { const { sportsSeason, participants, existingEVs } = loaderData; // Compute total and sort from stored prob columns, not stored expectedValue. // The simulator normalizes per-position column sums to exactly 1.0 (step 10), // so the total EV should always equal the sum of scoring values (340). // Sum only over participants shown in the table — excludes orphan EV records // from prior simulation runs for participants no longer in this season. const sorted = [...participants].toSorted((a, b) => { const evDataA = existingEVs.get(a.id); const evA = evDataA ? evFromProbs(evDataA) : 0; const evDataB = existingEVs.get(b.id); const evB = evDataB ? evFromProbs(evDataB) : 0; return evB - evA; }); const totalEV = sorted.reduce((sum, p) => { const ev = existingEVs.get(p.id); return ev ? sum + evFromProbs(ev) : sum; }, 0); return (
No participants found.
) : existingEVs.size === 0 ? (No EV data yet. Run a simulation from the sports season page.
) : (