Refactor expected values form to batch save all participants (#4)

* fix: remove probability sum validation and add batch save with total EV

- Remove the requirement that manual probabilities sum to 1.0, allowing
  partial distributions (e.g. when a participant has <100% chance of top 8)
- Replace per-row save buttons with a single "Save All" button that submits
  all participants at once
- Add a Total EV row at the bottom of the table summing all participant EVs
- Update action to batch process all participants from a single form submission

https://claude.ai/code/session_01WHRynBCcugSK7HHEmN6Yuy

* fix: validate participantIds server-side instead of trusting form input

Fetch participants from the DB in the action using the season ID from
the URL params, eliminating the untrusted participantIds hidden field.
Remove the now-unused hidden input from the frontend form.

https://claude.ai/code/session_01WHRynBCcugSK7HHEmN6Yuy

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-02-19 15:57:05 -08:00 committed by GitHub
parent eb260649b1
commit 2d6dd6ec9f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 190 additions and 203 deletions

View file

@ -9,7 +9,7 @@ import { database } from "~/database/context";
import { participantExpectedValues, participants } from "~/database/schema"; import { participantExpectedValues, participants } from "~/database/schema";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator"; import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
import { calculateEV, validateProbabilities, normalizeProbabilities } from "~/services/ev-calculator"; import { calculateEV, normalizeProbabilities } from "~/services/ev-calculator";
export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model"; export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model";
@ -59,15 +59,6 @@ export async function upsertParticipantEV(
): Promise<ParticipantEV> { ): Promise<ParticipantEV> {
const { participantId, sportsSeasonId, probabilities, scoringRules, source = "manual", sourceOdds } = input; const { participantId, sportsSeasonId, probabilities, scoringRules, source = "manual", sourceOdds } = input;
// Validate probabilities (only for manual entry)
// For futures_odds/ICM, probabilities may sum to <1.0 for teams unlikely to finish top 8
if (source === "manual" && !validateProbabilities(probabilities)) {
const sum = Object.values(probabilities).reduce((a, b) => a + b, 0);
throw new Error(
`Probabilities must sum to 1.0 (±0.01). Current sum: ${sum.toFixed(4)} (${(sum * 100).toFixed(1)}%)`
);
}
// Always validate probabilities don't exceed 1.0 // Always validate probabilities don't exceed 1.0
const sum = Object.values(probabilities).reduce((a, b) => a + b, 0); const sum = Object.values(probabilities).reduce((a, b) => a + b, 0);
if (sum > 1.01) { if (sum > 1.01) {

View file

@ -5,7 +5,6 @@ import {
upsertParticipantEV, upsertParticipantEV,
getAllParticipantEVsForSeason getAllParticipantEVsForSeason
} from "~/models/participant-expected-value"; } from "~/models/participant-expected-value";
import type { ProbabilitySource } from "~/models/participant-expected-value";
export async function loader({ params }: Route.LoaderArgs) { export async function loader({ params }: Route.LoaderArgs) {
const sportsSeason = await findSportsSeasonById(params.id); const sportsSeason = await findSportsSeasonById(params.id);
@ -27,28 +26,6 @@ export async function loader({ params }: Route.LoaderArgs) {
}; };
} }
export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData();
const participantId = formData.get("participantId");
const source = (formData.get("source") || "manual") as ProbabilitySource;
if (typeof participantId !== "string") {
return { error: "Participant ID is required" };
}
// Get probability values (entered as percentages, convert to decimals)
const probFirst = parseFloat(formData.get("probFirst") as string || "0") / 100;
const probSecond = parseFloat(formData.get("probSecond") as string || "0") / 100;
const probThird = parseFloat(formData.get("probThird") as string || "0") / 100;
const probFourth = parseFloat(formData.get("probFourth") as string || "0") / 100;
const probFifth = parseFloat(formData.get("probFifth") as string || "0") / 100;
const probSixth = parseFloat(formData.get("probSixth") as string || "0") / 100;
const probSeventh = parseFloat(formData.get("probSeventh") as string || "0") / 100;
const probEighth = parseFloat(formData.get("probEighth") as string || "0") / 100;
// Use default scoring rules
// Note: Actual league seasons may have different scoring rules
// EVs will be recalculated when used in a specific league
const scoringRules = { const scoringRules = {
pointsFor1st: 100, pointsFor1st: 100,
pointsFor2nd: 70, pointsFor2nd: 70,
@ -60,8 +37,25 @@ export async function action({ request, params }: Route.ActionArgs) {
pointsFor8th: 15, pointsFor8th: 15,
}; };
export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData();
const participants = await findParticipantsBySportsSeasonId(params.id);
const participantIds = participants.map((p: { id: string }) => p.id);
try { try {
const result = await upsertParticipantEV({ const results = await Promise.all(
participantIds.map(async (participantId) => {
const probFirst = parseFloat(formData.get(`probFirst_${participantId}`) as string || "0") / 100;
const probSecond = parseFloat(formData.get(`probSecond_${participantId}`) as string || "0") / 100;
const probThird = parseFloat(formData.get(`probThird_${participantId}`) as string || "0") / 100;
const probFourth = parseFloat(formData.get(`probFourth_${participantId}`) as string || "0") / 100;
const probFifth = parseFloat(formData.get(`probFifth_${participantId}`) as string || "0") / 100;
const probSixth = parseFloat(formData.get(`probSixth_${participantId}`) as string || "0") / 100;
const probSeventh = parseFloat(formData.get(`probSeventh_${participantId}`) as string || "0") / 100;
const probEighth = parseFloat(formData.get(`probEighth_${participantId}`) as string || "0") / 100;
return upsertParticipantEV({
participantId, participantId,
sportsSeasonId: params.id, sportsSeasonId: params.id,
probabilities: { probabilities: {
@ -75,14 +69,14 @@ export async function action({ request, params }: Route.ActionArgs) {
probEighth, probEighth,
}, },
scoringRules, scoringRules,
source, source: "manual",
}); });
})
);
return { const totalEV = results.reduce((sum, r) => sum + parseFloat(r.expectedValue), 0);
success: true,
participantId, return { success: true, totalEV };
expectedValue: parseFloat(result.expectedValue),
};
} catch (error) { } catch (error) {
console.error("Error saving probabilities:", error); console.error("Error saving probabilities:", error);
return { return {

View file

@ -25,6 +25,11 @@ export { loader, action };
export default function ExpectedValuesPage({ loaderData, actionData }: Route.ComponentProps) { export default function ExpectedValuesPage({ loaderData, actionData }: Route.ComponentProps) {
const { sportsSeason, participants, existingEVs } = loaderData; const { sportsSeason, participants, existingEVs } = loaderData;
const totalEV = Array.from(existingEVs.values()).reduce(
(sum, ev) => sum + parseFloat(ev.expectedValue),
0
);
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">
@ -54,7 +59,7 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
)} )}
{actionData?.success && ( {actionData?.success && (
<div className="p-4 bg-green-50 border border-green-200 rounded-md text-green-700"> <div className="p-4 bg-green-50 border border-green-200 rounded-md text-green-700">
Successfully saved! Expected Value: {actionData.expectedValue?.toFixed(2)} points All participants saved successfully!
</div> </div>
)} )}
@ -75,6 +80,8 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
</p> </p>
</div> </div>
<Form method="post">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@ -88,23 +95,21 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
<TableHead className="text-center">7th %</TableHead> <TableHead className="text-center">7th %</TableHead>
<TableHead className="text-center">8th %</TableHead> <TableHead className="text-center">8th %</TableHead>
<TableHead className="text-center">EV</TableHead> <TableHead className="text-center">EV</TableHead>
<TableHead></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{participants.map((participant: { id: string; name: string }) => { {participants.map((participant: { id: string; name: string }) => {
const existingEV = existingEVs.get(participant.id); const existingEV = existingEVs.get(participant.id);
const formId = `form-${participant.id}`; const id = participant.id;
return ( return (
<TableRow key={participant.id}> <TableRow key={id}>
<TableCell className="font-medium">{participant.name}</TableCell> <TableCell className="font-medium">{participant.name}</TableCell>
<TableCell> <TableCell>
<Input <Input
form={formId}
type="number" type="number"
name="probFirst" name={`probFirst_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probFirst) * 100).toFixed(2) : "12.5"} defaultValue={existingEV ? (parseFloat(existingEV.probFirst) * 100).toFixed(2) : "0"}
step="0.01" step="0.01"
min="0" min="0"
max="100" max="100"
@ -113,10 +118,9 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
</TableCell> </TableCell>
<TableCell> <TableCell>
<Input <Input
form={formId}
type="number" type="number"
name="probSecond" name={`probSecond_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probSecond) * 100).toFixed(2) : "12.5"} defaultValue={existingEV ? (parseFloat(existingEV.probSecond) * 100).toFixed(2) : "0"}
step="0.01" step="0.01"
min="0" min="0"
max="100" max="100"
@ -125,10 +129,9 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
</TableCell> </TableCell>
<TableCell> <TableCell>
<Input <Input
form={formId}
type="number" type="number"
name="probThird" name={`probThird_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probThird) * 100).toFixed(2) : "12.5"} defaultValue={existingEV ? (parseFloat(existingEV.probThird) * 100).toFixed(2) : "0"}
step="0.01" step="0.01"
min="0" min="0"
max="100" max="100"
@ -137,10 +140,9 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
</TableCell> </TableCell>
<TableCell> <TableCell>
<Input <Input
form={formId}
type="number" type="number"
name="probFourth" name={`probFourth_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probFourth) * 100).toFixed(2) : "12.5"} defaultValue={existingEV ? (parseFloat(existingEV.probFourth) * 100).toFixed(2) : "0"}
step="0.01" step="0.01"
min="0" min="0"
max="100" max="100"
@ -149,10 +151,9 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
</TableCell> </TableCell>
<TableCell> <TableCell>
<Input <Input
form={formId}
type="number" type="number"
name="probFifth" name={`probFifth_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probFifth) * 100).toFixed(2) : "12.5"} defaultValue={existingEV ? (parseFloat(existingEV.probFifth) * 100).toFixed(2) : "0"}
step="0.01" step="0.01"
min="0" min="0"
max="100" max="100"
@ -161,10 +162,9 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
</TableCell> </TableCell>
<TableCell> <TableCell>
<Input <Input
form={formId}
type="number" type="number"
name="probSixth" name={`probSixth_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probSixth) * 100).toFixed(2) : "12.5"} defaultValue={existingEV ? (parseFloat(existingEV.probSixth) * 100).toFixed(2) : "0"}
step="0.01" step="0.01"
min="0" min="0"
max="100" max="100"
@ -173,10 +173,9 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
</TableCell> </TableCell>
<TableCell> <TableCell>
<Input <Input
form={formId}
type="number" type="number"
name="probSeventh" name={`probSeventh_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probSeventh) * 100).toFixed(2) : "12.5"} defaultValue={existingEV ? (parseFloat(existingEV.probSeventh) * 100).toFixed(2) : "0"}
step="0.01" step="0.01"
min="0" min="0"
max="100" max="100"
@ -185,10 +184,9 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
</TableCell> </TableCell>
<TableCell> <TableCell>
<Input <Input
form={formId}
type="number" type="number"
name="probEighth" name={`probEighth_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probEighth) * 100).toFixed(2) : "12.5"} defaultValue={existingEV ? (parseFloat(existingEV.probEighth) * 100).toFixed(2) : "0"}
step="0.01" step="0.01"
min="0" min="0"
max="100" max="100"
@ -198,27 +196,31 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
<TableCell className="text-center font-semibold"> <TableCell className="text-center font-semibold">
{existingEV ? parseFloat(existingEV.expectedValue).toFixed(2) : "-"} {existingEV ? parseFloat(existingEV.expectedValue).toFixed(2) : "-"}
</TableCell> </TableCell>
<TableCell>
<Form method="post" id={formId}>
<input type="hidden" name="participantId" value={participant.id} />
<input type="hidden" name="source" value="manual" />
<Button type="submit" size="sm">
<Save className="h-4 w-4 mr-1" />
Save
</Button>
</Form>
</TableCell>
</TableRow> </TableRow>
); );
})} })}
{existingEVs.size > 0 && (
<TableRow className="border-t-2 font-bold bg-muted/50">
<TableCell colSpan={9} className="text-right">Total EV</TableCell>
<TableCell className="text-center">{totalEV.toFixed(2)}</TableCell>
</TableRow>
)}
</TableBody> </TableBody>
</Table> </Table>
{participants.length === 0 && ( {participants.length === 0 ? (
<p className="text-center text-gray-500 py-8"> <p className="text-center text-gray-500 py-8">
No participants found. Please add participants to this sports season first. No participants found. Please add participants to this sports season first.
</p> </p>
) : (
<div className="flex justify-end pt-4">
<Button type="submit">
<Save className="h-4 w-4 mr-2" />
Save All
</Button>
</div>
)} )}
</Form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>