Compare commits

...
Sign in to create a new pull request.

2 commits

Author SHA1 Message Date
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
Claude
23904c9e84
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
2026-06-26 21:20:08 +00:00
5 changed files with 90 additions and 149 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,23 @@ 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");
});
it("keeps a near-extreme blend within 199% so it never reads as 0/100", () => {
expect(futuresBlendLabel(0.999)).toBe("1% Elo / 99% Futures");
expect(futuresBlendLabel(0.004)).toBe("99% Elo / 1% Futures");
});
});
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,14 @@ 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`; // 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) { function statusBadge(status: string) {

View file

@ -1,8 +1,9 @@
/** /**
* Admin: Simulate action endpoint (stub) * Admin: Simulate action endpoint (stub)
* *
* Simulation is now handled by intent="simulate" on the season admin page. * Simulation is now handled by the "Run Simulation" button on the Simulator
* This stub redirects any direct GET navigation to that page. * setup page (/admin/sports-seasons/:id/simulator). This stub redirects any
* direct GET navigation to the season admin page.
*/ */
import { redirect } from "react-router"; import { redirect } from "react-router";

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,29 @@ 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. `futuresPct` is the committed value (drives the hidden
// field and the complementary input); `draft` holds the raw text of whichever
// field is being typed in, so it can be cleared/edited freely and only
// re-syncs on a parseable number — then normalizes away on blur.
function clampPct(value: number): number {
return Math.min(100, Math.max(0, Math.round(value)));
}
const [futuresPct, setFuturesPct] = useState(() =>
clampPct(Math.min(1, Math.max(0, inputPolicy.oddsWeight)) * 100)
);
const [draft, setDraft] = useState<{ field: "base" | "futures"; text: string } | null>(null);
const basePct = 100 - futuresPct;
function handlePctChange(field: "base" | "futures", text: string) {
setDraft({ field, text });
const parsed = Number(text);
if (text.trim() !== "" && Number.isFinite(parsed)) {
setFuturesPct(field === "futures" ? clampPct(parsed) : 100 - clampPct(parsed));
}
}
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 +391,44 @@ 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={draft?.field === "base" ? draft.text : String(basePct)}
onChange={(e) => handlePctChange("base", e.target.value)}
onBlur={() => setDraft(null)}
/>
</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={draft?.field === "futures" ? draft.text : String(futuresPct)}
onChange={(e) => handlePctChange("futures", e.target.value)}
onBlur={() => setDraft(null)}
/>
</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">