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
This commit is contained in:
Claude 2026-02-19 22:54:08 +00:00
parent eb260649b1
commit 5dd6d90193
No known key found for this signature in database
3 changed files with 197 additions and 201 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,29 @@ 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 participantIdsRaw = formData.get("participantIds");
if (typeof participantIdsRaw !== "string" || !participantIdsRaw) {
return { error: "No participant IDs provided" };
}
const participantIds = participantIdsRaw.split(",").filter(Boolean);
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 +73,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,15 @@ 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
);
const participantIds = participants
.map((p: { id: string; name: string }) => p.id)
.join(",");
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 +63,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 +84,9 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
</p> </p>
</div> </div>
<Form method="post">
<input type="hidden" name="participantIds" value={participantIds} />
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@ -88,23 +100,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 +123,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 +134,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 +145,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 +156,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 +167,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 +178,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 +189,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 +201,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>