Clarify simulator blend weights and remove EV card from season detail

Show the futures blend as two synced weights that total 100% (Base Elo /
Projections % and Futures %) on the simulator setup page, driven by the
single stored oddsWeight. Update the /admin/simulators badge to show the
full split (e.g. "70% Elo / 30% Futures") instead of just "30% blend".

Remove the redundant "Expected Values" card from admin/sports-seasons/<id>:
its setup links and Run Simulation action already live on the Simulator
page. Drop the now-unused loader query, simulate action, and imports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RvJnQPEJNRxGipSad7q2T
This commit is contained in:
Claude 2026-06-26 21:20:08 +00:00
parent 120056b0bd
commit 23904c9e84
No known key found for this signature in database
4 changed files with 66 additions and 147 deletions

View file

@ -8,7 +8,7 @@ vi.mock("~/services/simulations/runner", () => ({
runSportsSeasonSimulation: vi.fn(), runSportsSeasonSimulation: vi.fn(),
})); }));
import { action, loader } from "../admin.simulators"; import { action, futuresBlendLabel, loader } from "../admin.simulators";
import { listSportsSeasonSimulatorSummaries } from "~/models/simulator"; import { listSportsSeasonSimulatorSummaries } from "~/models/simulator";
import { runSportsSeasonSimulation } from "~/services/simulations/runner"; import { runSportsSeasonSimulation } from "~/services/simulations/runner";
@ -102,6 +102,18 @@ describe("admin simulators loader", () => {
}); });
}); });
describe("futuresBlendLabel", () => {
it("shows the full Elo/Futures split for an in-between weight", () => {
expect(futuresBlendLabel(0.3)).toBe("70% Elo / 30% Futures");
expect(futuresBlendLabel(0.5)).toBe("50% Elo / 50% Futures");
});
it("labels the extremes without percentages", () => {
expect(futuresBlendLabel(0)).toBe("Elo only");
expect(futuresBlendLabel(1)).toBe("overrides Elo");
});
});
describe("admin simulators action", () => { describe("admin simulators action", () => {
it("runs one simulator", async () => { it("runs one simulator", async () => {
vi.mocked(runSportsSeasonSimulation).mockResolvedValue({ vi.mocked(runSportsSeasonSimulation).mockResolvedValue({

View file

@ -77,10 +77,11 @@ export async function action({ request }: Route.ActionArgs): Promise<ActionData>
}; };
} }
function futuresBlendLabel(oddsWeight: number): string { export function futuresBlendLabel(oddsWeight: number): string {
if (oddsWeight >= 1) return "overrides Elo"; if (oddsWeight >= 1) return "overrides Elo";
if (oddsWeight <= 0) return "Elo only"; if (oddsWeight <= 0) return "Elo only";
return `${Math.round(oddsWeight * 100)}% blend`; const futuresPct = Math.round(oddsWeight * 100);
return `${100 - futuresPct}% Elo / ${futuresPct}% Futures`;
} }
function statusBadge(status: string) { function statusBadge(status: string) {

View file

@ -1,3 +1,4 @@
import { useState } from "react";
import { Form, Link, redirect, useActionData, useNavigation } from "react-router"; import { Form, Link, redirect, useActionData, useNavigation } from "react-router";
import type { Route } from "./+types/admin.sports-seasons.$id.simulator"; import type { Route } from "./+types/admin.sports-seasons.$id.simulator";
@ -261,6 +262,18 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
const showsInputPolicy = const showsInputPolicy =
config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating"); config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating");
// Two synced blend weights that always total 100%. `oddsWeight` (01) stays
// the single stored source of truth; we present it (and its complement) as
// percentages here. Editing either field updates the other.
const [futuresPct, setFuturesPct] = useState(() =>
Math.round(Math.min(1, Math.max(0, inputPolicy.oddsWeight)) * 100)
);
const basePct = 100 - futuresPct;
function clampPct(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(100, Math.max(0, Math.round(value)));
}
return ( return (
<div className="container mx-auto p-6 space-y-6"> <div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -367,14 +380,42 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
<CardContent> <CardContent>
<Form method="post" className="grid gap-4 md:grid-cols-5"> <Form method="post" className="grid gap-4 md:grid-cols-5">
<input type="hidden" name="intent" value="save-input-policy" /> <input type="hidden" name="intent" value="save-input-policy" />
{/* oddsWeight (01) is the stored value; the two percentage fields below drive it. */}
<input type="hidden" name="oddsWeight" value={futuresPct / 100} />
<div className="space-y-2 md:col-span-5"> <div className="space-y-2 md:col-span-5">
<Label htmlFor="oddsWeight">Futures vs. Elo Odds Blend Weight (01)</Label> <Label>Blend Weights Base Elo vs. Futures (must total 100%)</Label>
<Input id="oddsWeight" name="oddsWeight" type="number" step="0.05" min="0" max="1" defaultValue={inputPolicy.oddsWeight} /> <div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor="baseEloPct" className="text-xs font-normal text-muted-foreground">Base Elo / Projections weight (%)</Label>
<Input
id="baseEloPct"
type="number"
step={5}
min={0}
max={100}
value={basePct}
onChange={(e) => setFuturesPct(100 - clampPct(e.target.valueAsNumber))}
/>
</div>
<div className="space-y-1">
<Label htmlFor="futuresPct" className="text-xs font-normal text-muted-foreground">Futures Odds weight (%)</Label>
<Input
id="futuresPct"
type="number"
step={5}
min={0}
max={100}
value={futuresPct}
onChange={(e) => setFuturesPct(clampPct(e.target.valueAsNumber))}
/>
</div>
</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Every source (raw Elo, projections, futures odds) becomes an Elo, then they blend into Every source (raw Elo, projections, futures odds) becomes an Elo, then they blend into
the single Elo that feeds the simulator. This is the weight given to futures odds: the single Elo that feeds the simulator. The two weights always total 100% (editing one
<strong> 0</strong> = Elo / projections only, <strong>1</strong> = futures fully override, updates the other): <strong>0% futures</strong> = Elo / projections only,
in between = blend (e.g. 0.3 = 70% Elo / 30% futures). <strong> 100% futures</strong> = futures fully override, in between = blend
(e.g. 70% Elo / 30% futures).
</p> </p>
</div> </div>
{config.profile.requiredInputs.includes("sourceElo") && ( {config.profile.requiredInputs.includes("sourceElo") && (

View file

@ -8,10 +8,8 @@ import { findSportsSeasonById, updateSportsSeason, deleteSportsSeason, type NewS
import { processSeasonStandings, recalculateStandings } from "~/models/scoring-calculator"; import { processSeasonStandings, recalculateStandings } from "~/models/scoring-calculator";
import { createDailySnapshot } from "~/models/standings"; import { createDailySnapshot } from "~/models/standings";
import { database } from "~/database/context"; import { database } from "~/database/context";
import { participantEvSnapshots, seasonSports } from "~/database/schema"; import { seasonSports } from "~/database/schema";
import { eq, desc } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { getSimulatorInfo, type SimulatorType } from "~/services/simulations/registry";
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
import { syncStandings } from "~/services/standings-sync/index"; import { syncStandings } from "~/services/standings-sync/index";
import { import {
getPendingStandingsMappings, getPendingStandingsMappings,
@ -48,7 +46,7 @@ import {
} from "~/components/ui/alert-dialog"; } from "~/components/ui/alert-dialog";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "~/components/ui/select"; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Trash2, Users, Trophy, Calculator, CheckCircle2, Zap, AlertTriangle, Loader2, RefreshCw, Copy } from "lucide-react"; import { Trash2, Users, Trophy, CheckCircle2, AlertTriangle, Loader2, RefreshCw, Copy } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
const SELECT_CLASS = "h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"; const SELECT_CLASS = "h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm";
@ -121,21 +119,6 @@ export async function loader({ params }: Route.LoaderArgs) {
const participants = await findParticipantsBySportsSeasonId(params.id); const participants = await findParticipantsBySportsSeasonId(params.id);
// Get the most recent snapshot date (if any)
const db = database();
const lastSnapshot = await db
.select({ snapshotDate: participantEvSnapshots.snapshotDate })
.from(participantEvSnapshots)
.where(eq(participantEvSnapshots.sportsSeasonId, params.id))
.orderBy(desc(participantEvSnapshots.snapshotDate))
.limit(1);
const lastSimulatedDate = lastSnapshot[0]?.snapshotDate ?? null;
const simulatorInfo = sportsSeason.sport?.simulatorType
? getSimulatorInfo(sportsSeason.sport.simulatorType as SimulatorType)
: null;
const lastStandingsSyncedAt = const lastStandingsSyncedAt =
sportsSeason.sport?.type === "team" sportsSeason.sport?.type === "team"
? await getLastSyncedAt(params.id) ? await getLastSyncedAt(params.id)
@ -148,8 +131,6 @@ export async function loader({ params }: Route.LoaderArgs) {
return { return {
sportsSeason, sportsSeason,
participants, participants,
lastSimulatedDate,
simulatorInfo,
lastStandingsSyncedAt: lastStandingsSyncedAt?.toISOString() ?? null, lastStandingsSyncedAt: lastStandingsSyncedAt?.toISOString() ?? null,
pendingMappings, pendingMappings,
}; };
@ -293,17 +274,6 @@ export async function action(args: Route.ActionArgs) {
} }
} }
if (intent === "simulate") {
try {
await runSportsSeasonSimulation(params.id);
return redirect(`/admin/sports-seasons/${params.id}/expected-values`);
} catch (error) {
return {
simulateError: error instanceof Error ? error.message : "Simulation failed",
};
}
}
// Update // Update
const name = formData.get("name"); const name = formData.get("name");
const year = formData.get("year"); const year = formData.get("year");
@ -393,7 +363,7 @@ export async function action(args: Route.ActionArgs) {
} }
export default function EditSportsSeason({ loaderData, actionData }: Route.ComponentProps) { export default function EditSportsSeason({ loaderData, actionData }: Route.ComponentProps) {
const { sportsSeason, participants, lastSimulatedDate, simulatorInfo, lastStandingsSyncedAt, pendingMappings } = loaderData; const { sportsSeason, participants, lastStandingsSyncedAt, pendingMappings } = loaderData;
const navigate = useNavigate(); const navigate = useNavigate();
const navigation = useNavigation(); const navigation = useNavigation();
const isSyncingStandings = const isSyncingStandings =
@ -649,111 +619,6 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Expected Values</CardTitle>
<CardDescription>
{simulatorInfo
? simulatorInfo.name
: "Manage probability distributions and projected points"}
</CardDescription>
</div>
<div className="flex items-center gap-2">
{sportsSeason.simulationStatus === "failed" && (
<Badge variant="outline" className="bg-destructive/15 text-destructive border-destructive/30">
<AlertTriangle className="mr-1 h-3 w-3" />
Last run failed
</Badge>
)}
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/simulator`)}
>
<Calculator className="mr-2 h-4 w-4" />
Simulator Setup
</Button>
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/futures-odds`)}
>
<Calculator className="mr-2 h-4 w-4" />
Futures Odds
</Button>
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/elo-ratings`)}
>
<Calculator className="mr-2 h-4 w-4" />
Elo Ratings
</Button>
{sportsSeason.sport?.simulatorType === "tennis_qualifying_points" && (
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/surface-elo`)}
>
<Calculator className="mr-2 h-4 w-4" />
Surface Elo
</Button>
)}
{sportsSeason.sport?.simulatorType === "golf_qualifying_points" && (
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/golf-skills`)}
>
<Calculator className="mr-2 h-4 w-4" />
Golf Skills
</Button>
)}
{simulatorInfo && (
<Form method="post">
<input type="hidden" name="intent" value="simulate" />
<Button
type="submit"
size="sm"
disabled={sportsSeason.simulationStatus === "running"}
>
{sportsSeason.simulationStatus === "running" ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Running...
</>
) : (
<>
<Zap className="mr-2 h-4 w-4" />
Run Simulation
</>
)}
</Button>
</Form>
)}
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{simulatorInfo
? <>
{simulatorInfo.description}.{" "}
{lastSimulatedDate ? `Last simulated: ${lastSimulatedDate}.` : "No simulation has been run yet."}
{" "}Import futures odds first, then run the simulation to update EVs and save a snapshot.
</>
: "Import futures odds to set probability distributions."}
</p>
{"simulateError" in (actionData ?? {}) && actionData?.simulateError && (
<div className="mt-3 bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.simulateError}
</div>
)}
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">