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(),
}));
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,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", () => {
it("runs one simulator", async () => {
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 <= 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) {

View file

@ -1,8 +1,9 @@
/**
* Admin: Simulate action endpoint (stub)
*
* Simulation is now handled by intent="simulate" on the season admin page.
* This stub redirects any direct GET navigation to that page.
* Simulation is now handled by the "Run Simulation" button on the Simulator
* setup page (/admin/sports-seasons/:id/simulator). This stub redirects any
* direct GET navigation to the season admin page.
*/
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 type { Route } from "./+types/admin.sports-seasons.$id.simulator";
@ -261,6 +262,29 @@ 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` (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 (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-4">
@ -367,14 +391,44 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
<CardContent>
<Form method="post" className="grid gap-4 md:grid-cols-5">
<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">
<Label htmlFor="oddsWeight">Futures vs. Elo Odds Blend Weight (01)</Label>
<Input id="oddsWeight" name="oddsWeight" type="number" step="0.05" min="0" max="1" defaultValue={inputPolicy.oddsWeight} />
<Label>Blend Weights Base Elo vs. Futures (must total 100%)</Label>
<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">
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:
<strong> 0</strong> = Elo / projections only, <strong>1</strong> = 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): <strong>0% futures</strong> = Elo / projections only,
<strong> 100% futures</strong> = futures fully override, in between = blend
(e.g. 70% Elo / 30% futures).
</p>
</div>
{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 { 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
</CardContent>
</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>
<CardHeader>
<div className="flex items-center justify-between">