From 23904c9e843d973541b14bb944cf8c1128b6a8f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 21:20:08 +0000 Subject: [PATCH] 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/: 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 Claude-Session: https://claude.ai/code/session_017RvJnQPEJNRxGipSad7q2T --- app/routes/__tests__/admin.simulators.test.ts | 14 +- app/routes/admin.simulators.tsx | 5 +- .../admin.sports-seasons.$id.simulator.tsx | 51 ++++++- app/routes/admin.sports-seasons.$id.tsx | 143 +----------------- 4 files changed, 66 insertions(+), 147 deletions(-) diff --git a/app/routes/__tests__/admin.simulators.test.ts b/app/routes/__tests__/admin.simulators.test.ts index 625c060..52f5df1 100644 --- a/app/routes/__tests__/admin.simulators.test.ts +++ b/app/routes/__tests__/admin.simulators.test.ts @@ -8,7 +8,7 @@ vi.mock("~/services/simulations/runner", () => ({ runSportsSeasonSimulation: vi.fn(), })); -import { action, loader } from "../admin.simulators"; +import { action, futuresBlendLabel, loader } from "../admin.simulators"; import { listSportsSeasonSimulatorSummaries } from "~/models/simulator"; 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", () => { it("runs one simulator", async () => { vi.mocked(runSportsSeasonSimulation).mockResolvedValue({ diff --git a/app/routes/admin.simulators.tsx b/app/routes/admin.simulators.tsx index c1d50ac..a702363 100644 --- a/app/routes/admin.simulators.tsx +++ b/app/routes/admin.simulators.tsx @@ -77,10 +77,11 @@ export async function action({ request }: Route.ActionArgs): Promise }; } -function futuresBlendLabel(oddsWeight: number): string { +export function futuresBlendLabel(oddsWeight: number): string { if (oddsWeight >= 1) return "overrides Elo"; 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) { diff --git a/app/routes/admin.sports-seasons.$id.simulator.tsx b/app/routes/admin.sports-seasons.$id.simulator.tsx index c9f4417..885b160 100644 --- a/app/routes/admin.sports-seasons.$id.simulator.tsx +++ b/app/routes/admin.sports-seasons.$id.simulator.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Form, Link, redirect, useActionData, useNavigation } from "react-router"; import type { Route } from "./+types/admin.sports-seasons.$id.simulator"; @@ -261,6 +262,18 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone const showsInputPolicy = config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating"); + // Two synced blend weights that always total 100%. `oddsWeight` (0–1) 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 (
@@ -367,14 +380,42 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
+ {/* oddsWeight (0–1) is the stored value; the two percentage fields below drive it. */} +
- - + +
+
+ + setFuturesPct(100 - clampPct(e.target.valueAsNumber))} + /> +
+
+ + setFuturesPct(clampPct(e.target.valueAsNumber))} + /> +
+

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: - 0 = Elo / projections only, 1 = futures fully override, - in between = blend (e.g. 0.3 = 70% Elo / 30% futures). + the single Elo that feeds the simulator. The two weights always total 100% (editing one + updates the other): 0% futures = Elo / projections only, + 100% futures = futures fully override, in between = blend + (e.g. 70% Elo / 30% futures).

{config.profile.requiredInputs.includes("sourceElo") && ( diff --git a/app/routes/admin.sports-seasons.$id.tsx b/app/routes/admin.sports-seasons.$id.tsx index cf056e8..b7d24bd 100644 --- a/app/routes/admin.sports-seasons.$id.tsx +++ b/app/routes/admin.sports-seasons.$id.tsx @@ -8,10 +8,8 @@ import { findSportsSeasonById, updateSportsSeason, deleteSportsSeason, type NewS import { processSeasonStandings, recalculateStandings } from "~/models/scoring-calculator"; import { createDailySnapshot } from "~/models/standings"; import { database } from "~/database/context"; -import { participantEvSnapshots, seasonSports } from "~/database/schema"; -import { eq, desc } from "drizzle-orm"; -import { getSimulatorInfo, type SimulatorType } from "~/services/simulations/registry"; -import { runSportsSeasonSimulation } from "~/services/simulations/runner"; +import { seasonSports } from "~/database/schema"; +import { eq } from "drizzle-orm"; import { syncStandings } from "~/services/standings-sync/index"; import { getPendingStandingsMappings, @@ -48,7 +46,7 @@ import { } from "~/components/ui/alert-dialog"; import { Badge } from "~/components/ui/badge"; 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"; 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); - // 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 = sportsSeason.sport?.type === "team" ? await getLastSyncedAt(params.id) @@ -148,8 +131,6 @@ export async function loader({ params }: Route.LoaderArgs) { return { sportsSeason, participants, - lastSimulatedDate, - simulatorInfo, lastStandingsSyncedAt: lastStandingsSyncedAt?.toISOString() ?? null, 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 const name = formData.get("name"); const year = formData.get("year"); @@ -393,7 +363,7 @@ export async function action(args: Route.ActionArgs) { } 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 navigation = useNavigation(); const isSyncingStandings = @@ -649,111 +619,6 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo - - -
-
- Expected Values - - {simulatorInfo - ? simulatorInfo.name - : "Manage probability distributions and projected points"} - -
-
- {sportsSeason.simulationStatus === "failed" && ( - - - Last run failed - - )} - - - - {sportsSeason.sport?.simulatorType === "tennis_qualifying_points" && ( - - )} - {sportsSeason.sport?.simulatorType === "golf_qualifying_points" && ( - - )} - {simulatorInfo && ( - - - - - )} -
-
-
- -

- {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."} -

- {"simulateError" in (actionData ?? {}) && actionData?.simulateError && ( -
- {actionData.simulateError} -
- )} -
-
-