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 { eq, and } from "drizzle-orm";
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";
@ -59,15 +59,6 @@ export async function upsertParticipantEV(
): Promise<ParticipantEV> {
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
const sum = Object.values(probabilities).reduce((a, b) => a + b, 0);
if (sum > 1.01) {

View file

@ -5,7 +5,6 @@ import {
upsertParticipantEV,
getAllParticipantEVsForSeason
} from "~/models/participant-expected-value";
import type { ProbabilitySource } from "~/models/participant-expected-value";
export async function loader({ params }: Route.LoaderArgs) {
const sportsSeason = await findSportsSeasonById(params.id);
@ -27,29 +26,7 @@ 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,
pointsFor2nd: 70,
pointsFor3rd: 50,
@ -58,10 +35,31 @@ export async function action({ request, params }: Route.ActionArgs) {
pointsFor6th: 25,
pointsFor7th: 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 {
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,
sportsSeasonId: params.id,
probabilities: {
@ -75,14 +73,14 @@ export async function action({ request, params }: Route.ActionArgs) {
probEighth,
},
scoringRules,
source,
source: "manual",
});
})
);
return {
success: true,
participantId,
expectedValue: parseFloat(result.expectedValue),
};
const totalEV = results.reduce((sum, r) => sum + parseFloat(r.expectedValue), 0);
return { success: true, totalEV };
} catch (error) {
console.error("Error saving probabilities:", error);
return {

View file

@ -25,6 +25,15 @@ export { loader, action };
export default function ExpectedValuesPage({ loaderData, actionData }: Route.ComponentProps) {
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 (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-4">
@ -54,7 +63,7 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
)}
{actionData?.success && (
<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>
)}
@ -75,6 +84,9 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
</p>
</div>
<Form method="post">
<input type="hidden" name="participantIds" value={participantIds} />
<Table>
<TableHeader>
<TableRow>
@ -88,23 +100,21 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
<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}`;
const id = participant.id;
return (
<TableRow key={participant.id}>
<TableRow key={id}>
<TableCell className="font-medium">{participant.name}</TableCell>
<TableCell>
<Input
form={formId}
type="number"
name="probFirst"
defaultValue={existingEV ? (parseFloat(existingEV.probFirst) * 100).toFixed(2) : "12.5"}
name={`probFirst_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probFirst) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
@ -113,10 +123,9 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
</TableCell>
<TableCell>
<Input
form={formId}
type="number"
name="probSecond"
defaultValue={existingEV ? (parseFloat(existingEV.probSecond) * 100).toFixed(2) : "12.5"}
name={`probSecond_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probSecond) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
@ -125,10 +134,9 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
</TableCell>
<TableCell>
<Input
form={formId}
type="number"
name="probThird"
defaultValue={existingEV ? (parseFloat(existingEV.probThird) * 100).toFixed(2) : "12.5"}
name={`probThird_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probThird) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
@ -137,10 +145,9 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
</TableCell>
<TableCell>
<Input
form={formId}
type="number"
name="probFourth"
defaultValue={existingEV ? (parseFloat(existingEV.probFourth) * 100).toFixed(2) : "12.5"}
name={`probFourth_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probFourth) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
@ -149,10 +156,9 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
</TableCell>
<TableCell>
<Input
form={formId}
type="number"
name="probFifth"
defaultValue={existingEV ? (parseFloat(existingEV.probFifth) * 100).toFixed(2) : "12.5"}
name={`probFifth_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probFifth) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
@ -161,10 +167,9 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
</TableCell>
<TableCell>
<Input
form={formId}
type="number"
name="probSixth"
defaultValue={existingEV ? (parseFloat(existingEV.probSixth) * 100).toFixed(2) : "12.5"}
name={`probSixth_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probSixth) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
@ -173,10 +178,9 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
</TableCell>
<TableCell>
<Input
form={formId}
type="number"
name="probSeventh"
defaultValue={existingEV ? (parseFloat(existingEV.probSeventh) * 100).toFixed(2) : "12.5"}
name={`probSeventh_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probSeventh) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
@ -185,10 +189,9 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
</TableCell>
<TableCell>
<Input
form={formId}
type="number"
name="probEighth"
defaultValue={existingEV ? (parseFloat(existingEV.probEighth) * 100).toFixed(2) : "12.5"}
name={`probEighth_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probEighth) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
@ -198,27 +201,31 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
<TableCell className="text-center font-semibold">
{existingEV ? parseFloat(existingEV.expectedValue).toFixed(2) : "-"}
</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>
);
})}
{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>
</Table>
{participants.length === 0 && (
{participants.length === 0 ? (
<p className="text-center text-gray-500 py-8">
No participants found. Please add participants to this sports season first.
</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>
</Card>
</div>