brackt/app/routes/admin.sports-seasons.$id.expected-values.tsx
Chris Parsons e2b178221a
Add oxlint linting setup with zero errors (#194)
* Add oxlint and fix all lint errors

- Install oxlint, add .oxlintrc.json with rules for TypeScript/React
- Add npm run lint / lint:fix scripts
- Add Claude PostToolUse hook to run oxlint on every edited file
- Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array
- Fix no-array-index-key (use stable keys or suppress positional cases)
- Fix exhaustive-deps missing dependency in useEffect
- Promote exhaustive-deps and no-array-index-key to errors
- Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix no-explicit-any warnings and upgrade tsconfig to ES2023

- Replace all `any` types with proper types or `unknown` across ~20 files
- Add typed socket payload interfaces in draft route and useDraftSocket
- Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch)
- Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed
- Fix cascading type errors uncovered by removing any: Map.get narrowing,
  participant relation types, ChartDataPoint, Partial<NewSeason> indexing
- Add ParticipantResultWithParticipant type to participant-result model
- Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult)
- Fix duplicate getQPStandings import in sportsSeasonId.server.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Promote no-explicit-any to error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00

162 lines
7.1 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 { 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";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Expected Values — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
}
export { loader };
// DEFAULT scoring values — must match DEFAULT_SCORING_RULES in the simulate route.
// Scoring: 1st=100, 2nd=70, 3rd/4th (FF losers)=45 each, 5th8th (E8 losers)=20 each.
// Sum = 100+70+45+45+20+20+20+20 = 340.
//
// 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)
const SCORING = [100, 70, 45, 45, 20, 20, 20, 20] as const;
function evFromProbs(ev: {
probFirst: string; probSecond: string; probThird: string; probFourth: string;
probFifth: string; probSixth: string; probSeventh: string; probEighth: string;
}): number {
return parseFloat(ev.probFirst) * SCORING[0]
+ parseFloat(ev.probSecond) * SCORING[1]
+ parseFloat(ev.probThird) * SCORING[2]
+ parseFloat(ev.probFourth) * SCORING[3]
+ parseFloat(ev.probFifth) * SCORING[4]
+ parseFloat(ev.probSixth) * SCORING[5]
+ parseFloat(ev.probSeventh) * SCORING[6]
+ parseFloat(ev.probEighth) * SCORING[7];
}
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 evA = existingEVs.has(a.id) ? evFromProbs(existingEVs.get(a.id)!) : 0;
const evB = existingEVs.has(b.id) ? evFromProbs(existingEVs.get(b.id)!) : 0;
return evB - evA;
});
const totalEV = sorted.reduce((sum, p) => {
const ev = existingEVs.get(p.id);
return ev ? sum + evFromProbs(ev) : sum;
}, 0);
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-4">
<Link to={`/admin/sports-seasons/${sportsSeason.id}`}>
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Sports Season
</Button>
</Link>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calculator className="h-5 w-5" />
Expected Values: {sportsSeason.sport.name} {sportsSeason.year}
</CardTitle>
<CardDescription>
Probability distributions from the last simulation run. Sorted by EV descending.
</CardDescription>
</CardHeader>
<CardContent>
{participants.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
No participants found.
</p>
) : existingEVs.size === 0 ? (
<p className="text-center text-muted-foreground py-8">
No EV data yet. Run a simulation from the sports season page.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Participant</TableHead>
<TableHead className="text-center">1st</TableHead>
<TableHead className="text-center">2nd</TableHead>
<TableHead className="text-center">3rd</TableHead>
<TableHead className="text-center">4th</TableHead>
<TableHead className="text-center">5th</TableHead>
<TableHead className="text-center">6th</TableHead>
<TableHead className="text-center">7th</TableHead>
<TableHead className="text-center">8th</TableHead>
<TableHead className="text-center">EV</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sorted.map((participant) => {
const ev = existingEVs.get(participant.id);
return (
<TableRow key={participant.id}>
<TableCell className="font-medium">{participant.name}</TableCell>
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probFirst) : "—"}</TableCell>
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probSecond) : "—"}</TableCell>
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probThird) : "—"}</TableCell>
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probFourth) : "—"}</TableCell>
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probFifth) : "—"}</TableCell>
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probSixth) : "—"}</TableCell>
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probSeventh) : "—"}</TableCell>
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probEighth) : "—"}</TableCell>
<TableCell className="text-center font-semibold">{ev ? evFromProbs(ev).toFixed(2) : "—"}</TableCell>
</TableRow>
);
})}
{existingEVs.size > 0 && (
<TableRow className="border-t-2 font-bold bg-muted/50">
<TableCell colSpan={9} className="text-right">
Total EV
{Math.abs(totalEV - 340) > 1 && (
<span className="ml-2 text-xs font-normal text-destructive">
(expected ~340; re-run simulation to fix stale data)
</span>
)}
</TableCell>
<TableCell className="text-center">{totalEV.toFixed(2)}</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}