brackt/app/routes/admin.simulators.tsx
Claude 5f70852998
Address review: free-typing blend inputs, honest blend label, fix stale comment
- Simulator setup blend fields now keep a draft text buffer per field so an
  admin can clear/retype a weight without it snapping to 0/100; the committed
  futuresPct only re-syncs on a parseable number and normalizes on blur.
- futuresBlendLabel clamps a genuine blend to 1–99% so a near-extreme weight
  (e.g. 0.999) never reads as "0% Elo / 100% Futures"; the 0/1 extremes stay
  reserved for the "Elo only" / "overrides Elo" labels. Added test coverage.
- Refresh the stale comment in the simulate stub route, which referenced the
  removed intent="simulate".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RvJnQPEJNRxGipSad7q2T
2026-06-26 23:17:54 +00:00

304 lines
13 KiB
TypeScript

import { Form, Link, useActionData, useNavigation, useSearchParams } from "react-router";
import type { Route } from "./+types/admin.simulators";
import { Badge } from "~/components/ui/badge";
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 { listSportsSeasonSimulatorSummaries } from "~/models/simulator";
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
import { Activity, CheckCircle2, Loader2, Play, SlidersHorizontal, XCircle } from "lucide-react";
export function meta(): Route.MetaDescriptors {
return [{ title: "Simulators - Brackt Admin" }];
}
export async function loader() {
const simulators = (await listSportsSeasonSimulatorSummaries())
.filter((simulator) => simulator.seasonStatus !== "completed");
const sports = [...new Set(simulators.map((sim) => sim.sportName))].toSorted();
const simulatorTypes = [...new Set(simulators.map((sim) => sim.simulatorType))].toSorted();
return { simulators, sports, simulatorTypes };
}
interface ActionData {
success?: boolean;
message: string;
results?: Array<{ sportsSeasonId: string; ok: boolean; message: string }>;
}
export async function action({ request }: Route.ActionArgs): Promise<ActionData> {
const formData = await request.formData();
const intent = formData.get("intent");
const ids = intent === "run-selected"
? formData.getAll("sportsSeasonId").filter((id): id is string => typeof id === "string")
: [formData.get("sportsSeasonId")].filter((id): id is string => typeof id === "string");
if (ids.length === 0) {
return { success: false, message: "Select at least one simulator to run." };
}
const results: ActionData["results"] = [];
for (const sportsSeasonId of ids) {
try {
const result = await runSportsSeasonSimulation(sportsSeasonId);
results.push({
sportsSeasonId,
ok: true,
message: `Simulated ${result.simulatedParticipants} participant(s).`,
});
} catch (error) {
results.push({
sportsSeasonId,
ok: false,
message: error instanceof Error ? error.message : "Simulation failed.",
});
}
}
const succeeded = results.filter((result) => result.ok).length;
return {
success: succeeded > 0 && succeeded === results.length,
message: `Simulation run complete: ${succeeded}/${results.length} succeeded.`,
results,
};
}
export function futuresBlendLabel(oddsWeight: number): string {
if (oddsWeight >= 1) return "overrides Elo";
if (oddsWeight <= 0) return "Elo only";
// A genuine blend (0 < oddsWeight < 1) should never read as a 0/100 split:
// keep the displayed futures share within [1, 99] so the extremes stay
// reserved for the "Elo only" / "overrides Elo" labels above.
const futuresPct = Math.min(99, Math.max(1, Math.round(oddsWeight * 100)));
return `${100 - futuresPct}% Elo / ${futuresPct}% Futures`;
}
function statusBadge(status: string) {
if (status === "ready") return <Badge className="gap-1"><CheckCircle2 className="h-3 w-3" /> Ready</Badge>;
return <Badge variant="secondary" className="gap-1"><XCircle className="h-3 w-3" /> Needs setup</Badge>;
}
export default function AdminSimulators({ loaderData }: Route.ComponentProps) {
const { simulators, sports, simulatorTypes } = loaderData;
const actionData = useActionData<ActionData>();
const navigation = useNavigation();
const [searchParams, setSearchParams] = useSearchParams();
const selectedSport = searchParams.get("sport") ?? "all";
const selectedType = searchParams.get("type") ?? "all";
const selectedReadiness = searchParams.get("readiness") ?? "all";
const selectedStatus = searchParams.get("status") ?? "all";
const isSubmitting = navigation.state === "submitting";
const filtered = simulators.filter((sim) => {
if (selectedSport !== "all" && sim.sportName !== selectedSport) return false;
if (selectedType !== "all" && sim.simulatorType !== selectedType) return false;
if (selectedReadiness !== "all" && sim.readiness.status !== selectedReadiness) return false;
if (selectedStatus !== "all" && sim.seasonStatus !== selectedStatus) return false;
return true;
});
function updateFilter(key: string, value: string) {
const next = new URLSearchParams(searchParams);
if (value === "all") next.delete(key);
else next.set(key, value);
setSearchParams(next);
}
return (
<div className="p-8 space-y-6">
<div>
<h1 className="text-3xl font-bold flex items-center gap-2">
<Activity className="h-7 w-7" />
Simulators
</h1>
<p className="text-muted-foreground mt-1">
Inventory, validate, and run every sports-season EV simulator from one place.
</p>
</div>
{actionData && (
<Card className={actionData.success ? "border-emerald-500/40" : "border-amber-500/40"}>
<CardHeader>
<CardTitle className="text-base">{actionData.message}</CardTitle>
</CardHeader>
{actionData.results && (
<CardContent className="space-y-1 text-sm">
{actionData.results.map((result) => (
<div key={result.sportsSeasonId} className={result.ok ? "text-emerald-600" : "text-destructive"}>
{result.sportsSeasonId}: {result.message}
</div>
))}
</CardContent>
)}
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Filters</CardTitle>
<CardDescription>{filtered.length} of {simulators.length} simulator seasons shown</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-4">
<select className="h-9 rounded-md border bg-background px-3 text-sm" value={selectedSport} onChange={(e) => updateFilter("sport", e.target.value)}>
<option value="all">All sports</option>
{sports.map((sport) => <option key={sport} value={sport}>{sport}</option>)}
</select>
<select className="h-9 rounded-md border bg-background px-3 text-sm" value={selectedType} onChange={(e) => updateFilter("type", e.target.value)}>
<option value="all">All simulator types</option>
{simulatorTypes.map((type) => <option key={type} value={type}>{type}</option>)}
</select>
<select className="h-9 rounded-md border bg-background px-3 text-sm" value={selectedReadiness} onChange={(e) => updateFilter("readiness", e.target.value)}>
<option value="all">All readiness</option>
<option value="ready">Ready</option>
<option value="not_ready">Needs setup</option>
</select>
<select className="h-9 rounded-md border bg-background px-3 text-sm" value={selectedStatus} onChange={(e) => updateFilter("status", e.target.value)}>
<option value="all">All season statuses</option>
<option value="upcoming">Upcoming</option>
<option value="active">Active</option>
</select>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-4">
<div>
<CardTitle>Sports-Season Simulators</CardTitle>
<CardDescription>
Bulk runs execute sequentially and skip nothing silently: failures are reported per season.
</CardDescription>
</div>
<Form method="post" id="bulk-run-simulators">
<input type="hidden" name="intent" value="run-selected" />
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Play className="mr-2 h-4 w-4" />}
Run Selected
</Button>
</Form>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10" />
<TableHead>Season</TableHead>
<TableHead>Simulator</TableHead>
<TableHead>Status</TableHead>
<TableHead>Readiness</TableHead>
<TableHead>Inputs</TableHead>
<TableHead>Last Run</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((sim) => {
const canRun = sim.readiness.canRun && sim.simulationStatus !== "running";
return (
<TableRow key={sim.sportsSeasonId}>
<TableCell>
<input
form="bulk-run-simulators"
type="checkbox"
name="sportsSeasonId"
value={sim.sportsSeasonId}
disabled={!canRun}
/>
</TableCell>
<TableCell>
<div className="font-medium">{sim.sportName} {sim.year}</div>
<div className="text-xs text-muted-foreground">{sim.seasonName}</div>
{sim.leagueName && (
<div className="text-xs text-muted-foreground">League: {sim.leagueName}</div>
)}
</TableCell>
<TableCell>
<div>{sim.simulatorName}</div>
<code className="text-xs text-muted-foreground">{sim.simulatorType}</code>
</TableCell>
<TableCell>
<div className="space-y-1">
<Badge variant={sim.seasonStatus === "active" ? "default" : "outline"}>{sim.seasonStatus}</Badge>
<Badge variant={sim.simulationStatus === "failed" ? "destructive" : "secondary"}>{sim.simulationStatus}</Badge>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
{statusBadge(sim.readiness.status)}
{sim.readiness.missingInputs.length > 0 && (
<div className="text-xs text-muted-foreground max-w-64">
Missing {sim.readiness.missingInputs.join(", ")}
</div>
)}
{sim.readiness.warnings.some((warning) => warning.includes("Derived") || warning.includes("fallback")) && (
<div className="text-xs text-muted-foreground max-w-64">
{sim.readiness.warnings
.filter((warning) => warning.includes("Derived") || warning.includes("fallback"))
.join(" ")}
</div>
)}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
<div>{sim.participantInputCount}/{sim.participantCount}</div>
{sim.supportsFuturesOdds && (
<Badge variant={sim.oddsWeight > 0 ? "default" : "outline"} className="mt-1 text-xs font-normal">
{sim.oddsParticipantCount > 0
? `Futures: ${sim.oddsParticipantCount} · ${futuresBlendLabel(sim.oddsWeight)}`
: "No futures odds"}
</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{sim.lastSimulatedDate ?? "Never"}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" asChild>
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/simulator`}>
<SlidersHorizontal className="mr-2 h-4 w-4" />
Setup
</Link>
</Button>
{sim.supportsFuturesOdds && (
<Button variant="ghost" size="sm" asChild>
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/futures-odds`}>Futures</Link>
</Button>
)}
<Button variant="ghost" size="sm" asChild>
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/expected-values`}>EVs</Link>
</Button>
<Form method="post">
<input type="hidden" name="intent" value="run-one" />
<input type="hidden" name="sportsSeasonId" value={sim.sportsSeasonId} />
<Button type="submit" size="sm" disabled={!canRun || isSubmitting}>
Run
</Button>
</Form>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}