brackt/app/routes/admin.sports-seasons.$id.recalculate-probabilities.tsx
Chris Parsons 1584d34b89
Redesign to dark-mode-only with navy palette and accent colors (#13)
Removes light mode entirely in favour of a permanent dark theme with a
navy-tinted background and three signature accents (electric blue,
amber/gold, coral) exposed as CSS custom properties and Tailwind
utilities (bg-electric, text-amber-accent, text-coral-accent).

- Set class="dark" on <html> and apply Clerk dark base theme
- Rewrite app.css: single :root palette (oklch navy values), custom
  --electric / --amber-accent / --coral-accent variables, remove
  duplicate .dark block and light-mode bg-white/bg-gray-950 rule
- Install @clerk/themes for Clerk dark modal support
- Replace hardcoded Tailwind colors across 30+ files:
  - Draft grid cells: blue-50/blue-950 → electric/15, green-50/950 → emerald/10
  - Timer: green-600/yellow-600/red-600 → emerald-400/amber-accent/coral-accent
  - Status badges: blue-50/green-50/gray-50 → electric/emerald/muted variants
  - Success messages: green-500/15 text-green-700 dark:text-green-400 → emerald-500/15 text-emerald-400
  - Info cards: blue-50 dark:bg-blue-950 → electric/10
  - Warning cards: yellow-500 → amber-accent variants
  - Medal/placement badges: yellow-500/orange-600 → amber-accent/coral-accent
  - Movement indicators: green-600/red-600 → emerald-400/coral-accent
  - Connection dots: green-500/red-500 → emerald-500/coral-accent
- Remove dark:hidden/dark:block logo toggle in welcome.tsx (always dark)
- Update DraftGrid test assertions to match new class names

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 19:26:11 -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-amber-accent/30 bg-amber-accent/10">
<CardHeader>
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-amber-accent" />
<CardTitle className="text-amber-accent">
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-emerald-500/30 bg-emerald-500/10">
<CardHeader>
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-emerald-400" />
<CardTitle className="text-emerald-400">
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-electric/10 border border-electric/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>
);
}