brackt/app/routes/admin.sports-seasons.$id.expected-values.tsx
Chris Parsons 2d6dd6ec9f
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>
2026-02-19 15:57:05 -08:00

228 lines
9 KiB
TypeScript

import { Form, Link } from "react-router";
import type { Route } from "./+types/admin.sports-seasons.$id.expected-values";
import { loader, action } from "./admin.sports-seasons.$id.expected-values.server";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { ArrowLeft, Save, Calculator } from "lucide-react";
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 (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-4">
<Link to={`/admin/sports-seasons/${sportsSeason.id}`}>
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Sports Season
</Button>
</Link>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calculator className="h-5 w-5" />
Expected Values: {sportsSeason.sport.name} {sportsSeason.year}
</CardTitle>
<CardDescription>
Manage probability distributions and expected values for participants
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{actionData?.error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-md text-red-700">
{actionData.error}
</div>
)}
{actionData?.success && (
<div className="p-4 bg-green-50 border border-green-200 rounded-md text-green-700">
All participants saved successfully!
</div>
)}
<div className="text-sm text-gray-600 space-y-2">
<p><strong>Default Scoring Rules (for EV calculation):</strong></p>
<div className="grid grid-cols-4 gap-2">
<span>1st: 100 pts</span>
<span>2nd: 70 pts</span>
<span>3rd: 50 pts</span>
<span>4th: 40 pts</span>
<span>5th: 25 pts</span>
<span>6th: 25 pts</span>
<span>7th: 15 pts</span>
<span>8th: 15 pts</span>
</div>
<p className="text-xs text-gray-500 italic">
Note: EVs will be recalculated with actual league scoring rules when used in fantasy leagues.
</p>
</div>
<Form method="post">
<Table>
<TableHeader>
<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>
</TableRow>
</TableHeader>
<TableBody>
{participants.map((participant: { id: string; name: string }) => {
const existingEV = existingEVs.get(participant.id);
const id = participant.id;
return (
<TableRow key={id}>
<TableCell className="font-medium">{participant.name}</TableCell>
<TableCell>
<Input
type="number"
name={`probFirst_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probFirst) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
className="w-20 text-center"
/>
</TableCell>
<TableCell>
<Input
type="number"
name={`probSecond_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probSecond) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
className="w-20 text-center"
/>
</TableCell>
<TableCell>
<Input
type="number"
name={`probThird_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probThird) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
className="w-20 text-center"
/>
</TableCell>
<TableCell>
<Input
type="number"
name={`probFourth_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probFourth) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
className="w-20 text-center"
/>
</TableCell>
<TableCell>
<Input
type="number"
name={`probFifth_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probFifth) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
className="w-20 text-center"
/>
</TableCell>
<TableCell>
<Input
type="number"
name={`probSixth_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probSixth) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
className="w-20 text-center"
/>
</TableCell>
<TableCell>
<Input
type="number"
name={`probSeventh_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probSeventh) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
className="w-20 text-center"
/>
</TableCell>
<TableCell>
<Input
type="number"
name={`probEighth_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probEighth) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
className="w-20 text-center"
/>
</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>
)}
</TableBody>
</Table>
{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>
);
}