From 2d6dd6ec9f500490e564f62c89c57c86106f5625 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:57:05 -0800 Subject: [PATCH] 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 --- app/models/participant-expected-value.ts | 11 +- ...orts-seasons.$id.expected-values.server.ts | 96 +++--- ...min.sports-seasons.$id.expected-values.tsx | 286 +++++++++--------- 3 files changed, 190 insertions(+), 203 deletions(-) diff --git a/app/models/participant-expected-value.ts b/app/models/participant-expected-value.ts index a4f3296..e3f4ada 100644 --- a/app/models/participant-expected-value.ts +++ b/app/models/participant-expected-value.ts @@ -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 { 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) { diff --git a/app/routes/admin.sports-seasons.$id.expected-values.server.ts b/app/routes/admin.sports-seasons.$id.expected-values.server.ts index bafd46a..7435dcc 100644 --- a/app/routes/admin.sports-seasons.$id.expected-values.server.ts +++ b/app/routes/admin.sports-seasons.$id.expected-values.server.ts @@ -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,62 +26,57 @@ 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) { 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 = { - pointsFor1st: 100, - pointsFor2nd: 70, - pointsFor3rd: 50, - pointsFor4th: 40, - pointsFor5th: 25, - pointsFor6th: 25, - pointsFor7th: 15, - pointsFor8th: 15, - }; + const participants = await findParticipantsBySportsSeasonId(params.id); + const participantIds = participants.map((p: { id: string }) => p.id); try { - const result = await upsertParticipantEV({ - participantId, - sportsSeasonId: params.id, - probabilities: { - probFirst, - probSecond, - probThird, - probFourth, - probFifth, - probSixth, - probSeventh, - probEighth, - }, - scoringRules, - source, - }); + 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 { - success: true, - participantId, - expectedValue: parseFloat(result.expectedValue), - }; + return upsertParticipantEV({ + participantId, + sportsSeasonId: params.id, + 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) { console.error("Error saving probabilities:", error); return { diff --git a/app/routes/admin.sports-seasons.$id.expected-values.tsx b/app/routes/admin.sports-seasons.$id.expected-values.tsx index b95833f..5358883 100644 --- a/app/routes/admin.sports-seasons.$id.expected-values.tsx +++ b/app/routes/admin.sports-seasons.$id.expected-values.tsx @@ -25,6 +25,11 @@ 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 + ); + return (
@@ -54,7 +59,7 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com )} {actionData?.success && (
- Successfully saved! Expected Value: {actionData.expectedValue?.toFixed(2)} points + All participants saved successfully!
)} @@ -75,150 +80,147 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com

- - - - Participant - 1st % - 2nd % - 3rd % - 4th % - 5th % - 6th % - 7th % - 8th % - EV - - - - - {participants.map((participant: { id: string; name: string }) => { - const existingEV = existingEVs.get(participant.id); - const formId = `form-${participant.id}`; +
- return ( - - {participant.name} - - - - - - - - - - - - - - - - - - - - - - - - - - {existingEV ? parseFloat(existingEV.expectedValue).toFixed(2) : "-"} - - - - - - - - +
+ + + Participant + 1st % + 2nd % + 3rd % + 4th % + 5th % + 6th % + 7th % + 8th % + EV + + + + {participants.map((participant: { id: string; name: string }) => { + const existingEV = existingEVs.get(participant.id); + const id = participant.id; + + return ( + + {participant.name} + + + + + + + + + + + + + + + + + + + + + + + + + + {existingEV ? parseFloat(existingEV.expectedValue).toFixed(2) : "-"} + + + ); + })} + {existingEVs.size > 0 && ( + + Total EV + {totalEV.toFixed(2)} - ); - })} - -
+ )} + + - {participants.length === 0 && ( -

- No participants found. Please add participants to this sports season first. -

- )} + {participants.length === 0 ? ( +

+ No participants found. Please add participants to this sports season first. +

+ ) : ( +
+ +
+ )} +