brackt/app/routes/admin.sports-seasons.$id.expected-values.tsx
Chris Parsons 4bffa40606
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196)
* Fix no-shadow and consistent-function-scoping lint violations

Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.

no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).

consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.

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

* Fix no-non-null-assertion lint violations and promote to error

Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.

Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default

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

* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers

Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.

- prefer-add-event-listener: converted onchange/onclick/onload
  assignments to addEventListener in useDraftNotifications.ts and
  admin.data-sync.tsx; stored changeHandler ref for proper cleanup
  with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
  side-effect imports (*.css, @testing-library/jest-dom,
  @testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
  cypress/support/e2e.ts (file already has an import)

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

* Fix TypeScript errors from no-non-null-assertion fixes

Two fixes introduced by the non-null assertion cleanup produced type
errors:

- scoring-event.ts: `?? ""` was wrong type for a participant object map;
  restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
  truthy guarantee, causing TS18047 on the write-back block; added
  `participant &&` guard before accessing its properties

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

* Add npm run typecheck as Stop hook in Claude settings

Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00

164 lines
7.1 KiB
TypeScript
Raw Permalink 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 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 (
<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>
);
}