brackt/app/routes/admin.sports-seasons.$id.recalculate-probabilities.tsx
Chris Parsons 840212c4f8 feat: implement sports season expected value calculations and probability updates
- Added loader and action functions for managing expected values in sports seasons.
- Implemented UI for recalculating probabilities based on participant results.
- Created a service to update probabilities after results are finalized, including handling finished and unfinished participants.
- Developed tests for the probability updater service to ensure correct functionality.
- Introduced a preview feature to show potential changes before applying updates.
2025-11-21 22:05:50 -08:00

416 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Form, Link, useLoaderData, useActionData } from "react-router";
import type { LoaderFunctionArgs, ActionFunctionArgs } from "react-router";
import { findSportsSeasonById } from "~/models/sports-season";
import { findParticipantResultsBySportsSeasonId } from "~/models/participant-result";
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
import {
updateProbabilitiesAfterResult,
previewProbabilityUpdate,
type ProbabilityComparison,
} from "~/services/probability-updater";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Badge } from "~/components/ui/badge";
import { ArrowLeft, RefreshCw, CheckCircle, AlertTriangle } from "lucide-react";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
export async function loader({ params }: LoaderFunctionArgs) {
if (!params.id) {
throw new Response("Missing season ID", { status: 400 });
}
const sportsSeason = await findSportsSeasonById(params.id);
if (!sportsSeason) {
throw new Response("Sports season not found", { status: 404 });
}
const results = await findParticipantResultsBySportsSeasonId(params.id);
const existingEVs = await getAllParticipantEVsForSeason(params.id);
// Get preview of what would change
const preview = await previewProbabilityUpdate(params.id);
// Get participant names for display
const db = database();
const participants = await db.query.participants.findMany({
where: (participants, { inArray }) =>
inArray(
participants.id,
preview.map((p: ProbabilityComparison) => p.participantId)
),
});
const participantMap = new Map(participants.map((p: typeof participants[0]) => [p.id, p]));
return {
sportsSeason,
results,
existingEVs,
preview: preview.map((p: ProbabilityComparison) => ({
...p,
participantName: participantMap.get(p.participantId)?.name || "Unknown",
})),
};
}
export async function action({ params, request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "recalculate") {
const recalculateUnfinished = formData.get("recalculateUnfinished") === "true";
if (!params.id) {
return { success: false, error: "Missing season ID" };
}
const result = await updateProbabilitiesAfterResult(
params.id,
recalculateUnfinished
);
if (result.errors.length > 0) {
return {
success: false,
error: result.errors.join("; "),
result,
};
}
return {
success: true,
result,
};
}
return { success: false, error: "Invalid intent" };
}
export default function RecalculateProbabilities() {
const { sportsSeason, results, existingEVs, preview } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const finishedCount = results.filter((r) => r.finalPosition !== null).length;
const totalCount = existingEVs.length;
const unfinishedCount = totalCount - finishedCount;
const changesCount = preview.filter((p) => p.status === "finished").length;
// Check for invalid probabilities (> 1.0)
const invalidProbabilities = preview.filter((p) =>
p.before.some((prob) => prob > 1.0)
);
function formatProbability(prob: number): string {
return (prob * 100).toFixed(1) + "%";
}
function getProbabilityChange(before: number, after: number): string {
const diff = (after - before) * 100;
if (Math.abs(diff) < 0.1) return "—";
const sign = diff > 0 ? "+" : "";
return sign + diff.toFixed(1) + "%";
}
return (
<div className="container mx-auto py-8 px-4">
<div className="mb-6">
<Button variant="ghost" size="sm" asChild>
<Link to={`/admin/sports-seasons/${sportsSeason.id}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Sports Season
</Link>
</Button>
</div>
<div className="max-w-6xl mx-auto space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">Recalculate Probabilities</h1>
<p className="text-muted-foreground">
{sportsSeason.name} {sportsSeason.year}
</p>
</div>
{invalidProbabilities.length > 0 && (
<Card className="border-yellow-500 bg-yellow-500/10">
<CardHeader>
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-600" />
<CardTitle className="text-yellow-600">
Invalid Probability Data Detected
</CardTitle>
</div>
<CardDescription>
{invalidProbabilities.length} participant(s) have probabilities greater than 100%, which indicates corrupted data
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<p>
The following participants have invalid probabilities (marked with in the table below):
</p>
<ul className="list-disc list-inside">
{invalidProbabilities.slice(0, 5).map((p) => (
<li key={p.participantId}>
{p.participantName}: P(1st) = {formatProbability(p.before[0])}
</li>
))}
{invalidProbabilities.length > 5 && (
<li>... and {invalidProbabilities.length - 5} more</li>
)}
</ul>
<p className="mt-4 text-muted-foreground">
<strong>Solution:</strong> Go to the{" "}
<Link
to={`/admin/sports-seasons/${sportsSeason.id}/futures-odds`}
className="underline"
>
Futures Odds page
</Link>{" "}
and regenerate probabilities from current betting odds to fix this data.
</p>
</div>
</CardContent>
</Card>
)}
{actionData?.success ? (
<Card className="border-green-500 bg-green-500/10">
<CardHeader>
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<CardTitle className="text-green-600">
Probabilities Updated Successfully
</CardTitle>
</div>
<CardDescription>
{actionData.result?.updated} participant
{actionData.result?.updated !== 1 ? "s" : ""} updated
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div>
Finished participants: {actionData.result?.finishedParticipants}
</div>
<div>
Recalculated participants: {actionData.result?.unfishedParticipants}
</div>
</div>
<Button className="mt-4" asChild>
<Link to={`/admin/sports-seasons/${sportsSeason.id}/expected-values`}>
View Expected Values
</Link>
</Button>
</CardContent>
</Card>
) : actionData?.error ? (
<Card className="border-destructive bg-destructive/10">
<CardHeader>
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
<CardTitle className="text-destructive">Error</CardTitle>
</div>
<CardDescription className="text-destructive/80">
{actionData.error}
</CardDescription>
</CardHeader>
</Card>
) : null}
<Card>
<CardHeader>
<CardTitle>Summary</CardTitle>
<CardDescription>
Current state of participant results and probabilities
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<div>
<div className="text-2xl font-bold">{finishedCount}</div>
<div className="text-sm text-muted-foreground">
Finished Participants
</div>
</div>
<div>
<div className="text-2xl font-bold">{unfinishedCount}</div>
<div className="text-sm text-muted-foreground">
Unfinished Participants
</div>
</div>
<div>
<div className="text-2xl font-bold">{changesCount}</div>
<div className="text-sm text-muted-foreground">
Changes to Apply
</div>
</div>
</div>
</CardContent>
</Card>
{changesCount === 0 ? (
<Card>
<CardHeader>
<CardTitle>No Changes Needed</CardTitle>
<CardDescription>
All finished participants already have their probabilities set correctly.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Either no participants have finished yet, or their probabilities are
already up to date.
</p>
</CardContent>
</Card>
) : (
<>
<Card>
<CardHeader>
<CardTitle>Probability Changes</CardTitle>
<CardDescription>
Preview of changes that will be applied
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Participant</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">P(1st) Before</TableHead>
<TableHead className="text-right">P(1st) After</TableHead>
<TableHead className="text-right">P(2nd) After</TableHead>
<TableHead className="text-right">P(3rd) After</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{preview
.filter((p: ProbabilityComparison) => p.status === "finished")
.map((p: ProbabilityComparison) => {
const result = results.find(
(r: typeof results[0]) => r.participantId === p.participantId
);
const finalPosition = result?.finalPosition;
return (
<TableRow key={p.participantId}>
<TableCell className="font-medium">
{p.participantName}
</TableCell>
<TableCell>
<Badge variant="default">
Finished {finalPosition ? `${finalPosition}${
finalPosition === 1
? "st"
: finalPosition === 2
? "nd"
: finalPosition === 3
? "rd"
: "th"
}` : ""}
</Badge>
</TableCell>
<TableCell className="text-right font-mono text-sm">
<div className={p.before[0] > 1 ? "text-destructive" : ""}>
{formatProbability(p.before[0])}
{p.before[0] > 1 && " ⚠️"}
</div>
</TableCell>
<TableCell className="text-right font-mono text-sm">
<div>{formatProbability(p.after[0])}</div>
<div className="text-xs text-muted-foreground">
{getProbabilityChange(p.before[0], p.after[0])}
</div>
</TableCell>
<TableCell className="text-right font-mono text-sm">
<div>{formatProbability(p.after[1])}</div>
</TableCell>
<TableCell className="text-right font-mono text-sm">
<div>{formatProbability(p.after[2])}</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Apply Changes</CardTitle>
<CardDescription>
Update probabilities based on real results
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<input type="hidden" name="intent" value="recalculate" />
<input
type="hidden"
name="recalculateUnfinished"
value="true"
/>
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
<h4 className="font-semibold text-sm mb-2">What happens:</h4>
<ul className="text-sm space-y-1 text-muted-foreground">
<li>
Finished participants get 100% probability at their final
position
</li>
<li>
Unfinished participants get recalculated probabilities based
on remaining competition
</li>
<li>
Expected values will be automatically updated for all
affected leagues
</li>
</ul>
</div>
<div className="flex gap-4">
<Button
type="submit"
className="flex-1"
disabled={changesCount === 0}
>
<RefreshCw className="mr-2 h-4 w-4" />
Apply {changesCount} Change
{changesCount !== 1 ? "s" : ""}
</Button>
<Button type="button" variant="outline" asChild>
<Link to={`/admin/sports-seasons/${sportsSeason.id}`}>
Cancel
</Link>
</Button>
</div>
</Form>
</CardContent>
</Card>
</>
)}
</div>
</div>
);
}