Futures odds entry was split across two unrelated places: a standalone /futures-odds page (which auto-ran the simulation on save and surfaced a "Simulator is not ready" run failure as if the save itself had failed) and the Bulk Simulator Inputs CSV importer on the simulator setup page. Consolidate everything onto the Bulk Simulator Inputs card: - batchUpsertParticipantSimulatorInputs now COALESCE-wraps every conflict update column, so a partial paste (e.g. odds-only) updates just the columns it provides instead of nulling out previously stored Elo/rating/etc. - The bulk importer accepts sportsbook-style paste (one team per line ending in American odds) when no CSV header is present, reusing the existing fuzzy matcher, and auto-runs the simulation on save. A not-ready run is reported as a successful save plus the readiness gap, never as a failed save. - Retire the standalone /futures-odds page (now redirects to the simulator setup page) and drop the redundant Futures links. - Remove the now-dead futures-only model paths (batchSaveSourceOdds, clearSourceOddsForParticipants, batchSaveFuturesOddsForSimulator, batchSaveParticipantSimulatorSourceOdds); the EV-table bridge is preserved by batchUpsertParticipantSimulatorInputs. - Repoint the test to the consolidated path and assert the non-destructive COALESCE behaviour. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BHnoQ7myY3PzNdTnd7iGNE Co-authored-by: Claude <noreply@anthropic.com> Reviewed-on: #117
295 lines
13 KiB
TypeScript
295 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,
|
|
};
|
|
}
|
|
|
|
function futuresBlendLabel(oddsWeight: number): string {
|
|
if (oddsWeight >= 1) return "overrides Elo";
|
|
if (oddsWeight <= 0) return "Elo only";
|
|
return `${Math.round(oddsWeight * 100)}% blend`;
|
|
}
|
|
|
|
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>
|
|
<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>
|
|
);
|
|
}
|