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,62 +26,61 @@ export async function loader({ params }: Route.LoaderArgs) {
}; };
} }
const scoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 50,
pointsFor4th: 40,
pointsFor5th: 25,
pointsFor6th: 25,
pointsFor7th: 15,
pointsFor8th: 15,
};
export async function action({ request, params }: Route.ActionArgs) { export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData(); const formData = await request.formData();
const participantId = formData.get("participantId");
const source = (formData.get("source") || "manual") as ProbabilitySource;
if (typeof participantId !== "string") { const participantIdsRaw = formData.get("participantIds");
return { error: "Participant ID is required" }; if (typeof participantIdsRaw !== "string" || !participantIdsRaw) {
return { error: "No participant IDs provided" };
} }
// Get probability values (entered as percentages, convert to decimals) const participantIds = participantIdsRaw.split(",").filter(Boolean);
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 = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 50,
pointsFor4th: 40,
pointsFor5th: 25,
pointsFor6th: 25,
pointsFor7th: 15,
pointsFor8th: 15,
};
try { try {
const result = await upsertParticipantEV({ const results = await Promise.all(
participantId, participantIds.map(async (participantId) => {
sportsSeasonId: params.id, const probFirst = parseFloat(formData.get(`probFirst_${participantId}`) as string || "0") / 100;
probabilities: { const probSecond = parseFloat(formData.get(`probSecond_${participantId}`) as string || "0") / 100;
probFirst, const probThird = parseFloat(formData.get(`probThird_${participantId}`) as string || "0") / 100;
probSecond, const probFourth = parseFloat(formData.get(`probFourth_${participantId}`) as string || "0") / 100;
probThird, const probFifth = parseFloat(formData.get(`probFifth_${participantId}`) as string || "0") / 100;
probFourth, const probSixth = parseFloat(formData.get(`probSixth_${participantId}`) as string || "0") / 100;
probFifth, const probSeventh = parseFloat(formData.get(`probSeventh_${participantId}`) as string || "0") / 100;
probSixth, const probEighth = parseFloat(formData.get(`probEighth_${participantId}`) as string || "0") / 100;
probSeventh,
probEighth,
},
scoringRules,
source,
});
return { return upsertParticipantEV({
success: true, participantId,
participantId, sportsSeasonId: params.id,
expectedValue: parseFloat(result.expectedValue), probabilities: {
}; probFirst,
probSecond,
probThird,
probFourth,
probFifth,
probSixth,
probSeventh,
probEighth,
},
scoringRules,
source: "manual",
});
})
);
const totalEV = results.reduce((sum, r) => sum + parseFloat(r.expectedValue), 0);
return { success: true, totalEV };
} 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,150 +84,148 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
</p> </p>
</div> </div>
<Table> <Form method="post">
<TableHeader> <input type="hidden" name="participantIds" value={participantIds} />
<TableRow>
<TableHead>Participant</TableHead>
<TableHead className="text-center">1st %</TableHead>
<TableHead className="text-center">2nd %</TableHead>
<TableHead className="text-center">3rd %</TableHead>
<TableHead className="text-center">4th %</TableHead>
<TableHead className="text-center">5th %</TableHead>
<TableHead className="text-center">6th %</TableHead>
<TableHead className="text-center">7th %</TableHead>
<TableHead className="text-center">8th %</TableHead>
<TableHead className="text-center">EV</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{participants.map((participant: { id: string; name: string }) => {
const existingEV = existingEVs.get(participant.id);
const formId = `form-${participant.id}`;
return ( <Table>
<TableRow key={participant.id}> <TableHeader>
<TableCell className="font-medium">{participant.name}</TableCell> <TableRow>
<TableCell> <TableHead>Participant</TableHead>
<Input <TableHead className="text-center">1st %</TableHead>
form={formId} <TableHead className="text-center">2nd %</TableHead>
type="number" <TableHead className="text-center">3rd %</TableHead>
name="probFirst" <TableHead className="text-center">4th %</TableHead>
defaultValue={existingEV ? (parseFloat(existingEV.probFirst) * 100).toFixed(2) : "12.5"} <TableHead className="text-center">5th %</TableHead>
step="0.01" <TableHead className="text-center">6th %</TableHead>
min="0" <TableHead className="text-center">7th %</TableHead>
max="100" <TableHead className="text-center">8th %</TableHead>
className="w-20 text-center" <TableHead className="text-center">EV</TableHead>
/> </TableRow>
</TableCell> </TableHeader>
<TableCell> <TableBody>
<Input {participants.map((participant: { id: string; name: string }) => {
form={formId} const existingEV = existingEVs.get(participant.id);
type="number" const id = participant.id;
name="probSecond"
defaultValue={existingEV ? (parseFloat(existingEV.probSecond) * 100).toFixed(2) : "12.5"} return (
step="0.01" <TableRow key={id}>
min="0" <TableCell className="font-medium">{participant.name}</TableCell>
max="100" <TableCell>
className="w-20 text-center" <Input
/> type="number"
</TableCell> name={`probFirst_${id}`}
<TableCell> defaultValue={existingEV ? (parseFloat(existingEV.probFirst) * 100).toFixed(2) : "0"}
<Input step="0.01"
form={formId} min="0"
type="number" max="100"
name="probThird" className="w-20 text-center"
defaultValue={existingEV ? (parseFloat(existingEV.probThird) * 100).toFixed(2) : "12.5"} />
step="0.01" </TableCell>
min="0" <TableCell>
max="100" <Input
className="w-20 text-center" type="number"
/> name={`probSecond_${id}`}
</TableCell> defaultValue={existingEV ? (parseFloat(existingEV.probSecond) * 100).toFixed(2) : "0"}
<TableCell> step="0.01"
<Input min="0"
form={formId} max="100"
type="number" className="w-20 text-center"
name="probFourth" />
defaultValue={existingEV ? (parseFloat(existingEV.probFourth) * 100).toFixed(2) : "12.5"} </TableCell>
step="0.01" <TableCell>
min="0" <Input
max="100" type="number"
className="w-20 text-center" name={`probThird_${id}`}
/> defaultValue={existingEV ? (parseFloat(existingEV.probThird) * 100).toFixed(2) : "0"}
</TableCell> step="0.01"
<TableCell> min="0"
<Input max="100"
form={formId} className="w-20 text-center"
type="number" />
name="probFifth" </TableCell>
defaultValue={existingEV ? (parseFloat(existingEV.probFifth) * 100).toFixed(2) : "12.5"} <TableCell>
step="0.01" <Input
min="0" type="number"
max="100" name={`probFourth_${id}`}
className="w-20 text-center" defaultValue={existingEV ? (parseFloat(existingEV.probFourth) * 100).toFixed(2) : "0"}
/> step="0.01"
</TableCell> min="0"
<TableCell> max="100"
<Input className="w-20 text-center"
form={formId} />
type="number" </TableCell>
name="probSixth" <TableCell>
defaultValue={existingEV ? (parseFloat(existingEV.probSixth) * 100).toFixed(2) : "12.5"} <Input
step="0.01" type="number"
min="0" name={`probFifth_${id}`}
max="100" defaultValue={existingEV ? (parseFloat(existingEV.probFifth) * 100).toFixed(2) : "0"}
className="w-20 text-center" step="0.01"
/> min="0"
</TableCell> max="100"
<TableCell> className="w-20 text-center"
<Input />
form={formId} </TableCell>
type="number" <TableCell>
name="probSeventh" <Input
defaultValue={existingEV ? (parseFloat(existingEV.probSeventh) * 100).toFixed(2) : "12.5"} type="number"
step="0.01" name={`probSixth_${id}`}
min="0" defaultValue={existingEV ? (parseFloat(existingEV.probSixth) * 100).toFixed(2) : "0"}
max="100" step="0.01"
className="w-20 text-center" min="0"
/> max="100"
</TableCell> className="w-20 text-center"
<TableCell> />
<Input </TableCell>
form={formId} <TableCell>
type="number" <Input
name="probEighth" type="number"
defaultValue={existingEV ? (parseFloat(existingEV.probEighth) * 100).toFixed(2) : "12.5"} name={`probSeventh_${id}`}
step="0.01" defaultValue={existingEV ? (parseFloat(existingEV.probSeventh) * 100).toFixed(2) : "0"}
min="0" step="0.01"
max="100" min="0"
className="w-20 text-center" max="100"
/> className="w-20 text-center"
</TableCell> />
<TableCell className="text-center font-semibold"> </TableCell>
{existingEV ? parseFloat(existingEV.expectedValue).toFixed(2) : "-"} <TableCell>
</TableCell> <Input
<TableCell> type="number"
<Form method="post" id={formId}> name={`probEighth_${id}`}
<input type="hidden" name="participantId" value={participant.id} /> defaultValue={existingEV ? (parseFloat(existingEV.probEighth) * 100).toFixed(2) : "0"}
<input type="hidden" name="source" value="manual" /> step="0.01"
<Button type="submit" size="sm"> min="0"
<Save className="h-4 w-4 mr-1" /> max="100"
Save className="w-20 text-center"
</Button> />
</Form> </TableCell>
</TableCell> <TableCell className="text-center font-semibold">
{existingEV ? parseFloat(existingEV.expectedValue).toFixed(2) : "-"}
</TableCell>
</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> </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>