brackt/app/routes/admin.sports-seasons.$id.expected-values.tsx
Claude 2c2df277d1
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
2026-02-19 23:40:38 +00: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>
);
}