brackt/app/routes/admin.sports-seasons.$id.futures-odds.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

552 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Form, redirect, useLoaderData, useActionData, useNavigation } from 'react-router';
import type { Route } from './+types/admin.sports-seasons.$id.futures-odds';
import { findSportsSeasonById } from '~/models/sports-season';
import { findParticipantsBySportsSeasonId } from '~/models/participant';
import { getAllParticipantEVsForSeason } from '~/models/participant-expected-value';
import { Button } from '~/components/ui/button';
import { Input } from '~/components/ui/input';
import { Label } from '~/components/ui/label';
import { Textarea } from '~/components/ui/textarea';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '~/components/ui/card';
import { useState } from 'react';
import {
calculateICMFromOdds,
icmResultToArray,
} from '~/services/icm-calculator';
import { upsertParticipantEV } from '~/models/participant-expected-value';
import { Loader2, Info, CheckCircle2, AlertCircle } from 'lucide-react';
export async function loader({ params }: Route.LoaderArgs) {
const sportsSeasonId = params.id;
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
if (!sportsSeason) {
throw new Response('Sports season not found', { status: 404 });
}
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
// Create map of participant ID to existing odds (if source is futures_odds)
const existingOdds = new Map(
existingEVs
.filter(ev => ev.source === 'futures_odds' && ev.sourceOdds !== null)
.map(ev => [ev.participantId, ev.sourceOdds])
);
return {
sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string; type: string; slug: string } },
participants,
existingOdds,
};
}
interface ActionData {
success?: boolean;
message?: string;
preview?: {
participantId: string;
participantName: string;
odds: number;
probabilities: number[];
}[];
}
export async function action({ request, params }: Route.ActionArgs) {
const sportsSeasonId = params.id;
const formData = await request.formData();
const intent = formData.get('intent') as string;
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
if (!sportsSeason) {
return { success: false, message: 'Sports season not found' };
}
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
// Parse odds from form
const futuresOdds: Array<{ participantId: string; odds: number; name: string }> = [];
for (const participant of participants) {
const oddsValue = formData.get(`odds_${participant.id}`) as string;
if (oddsValue && oddsValue.trim() !== '') {
const odds = Number(oddsValue);
if (!isNaN(odds)) {
futuresOdds.push({
participantId: participant.id,
odds,
name: participant.name,
});
}
}
}
if (futuresOdds.length === 0) {
return { success: false, message: 'Please enter odds for at least one participant' };
}
try {
// Calculate ICM probabilities from odds
const icmResults = calculateICMFromOdds(
futuresOdds.map(({ participantId, odds }) => ({ participantId, odds }))
);
if (intent === 'preview') {
// Return preview data
const preview = futuresOdds.map(({ participantId, odds, name }) => {
const icmResult = icmResults.get(participantId)!;
return {
participantId,
participantName: name,
odds,
probabilities: icmResultToArray(icmResult),
};
});
return { success: true, preview };
}
if (intent === 'save') {
// Use default scoring rules (EVs will be recalculated per league)
const scoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 50,
pointsFor4th: 40,
pointsFor5th: 25,
pointsFor6th: 25,
pointsFor7th: 15,
pointsFor8th: 15,
};
// Save probabilities from preview (passed through form)
// This ensures saved values match exactly what user saw in preview
for (const { participantId, odds } of futuresOdds) {
// Read probabilities from form data (preview results)
const probFirst = parseFloat(formData.get(`prob_first_${participantId}`) as string) || 0;
const probSecond = parseFloat(formData.get(`prob_second_${participantId}`) as string) || 0;
const probThird = parseFloat(formData.get(`prob_third_${participantId}`) as string) || 0;
const probFourth = parseFloat(formData.get(`prob_fourth_${participantId}`) as string) || 0;
const probFifth = parseFloat(formData.get(`prob_fifth_${participantId}`) as string) || 0;
const probSixth = parseFloat(formData.get(`prob_sixth_${participantId}`) as string) || 0;
const probSeventh = parseFloat(formData.get(`prob_seventh_${participantId}`) as string) || 0;
const probEighth = parseFloat(formData.get(`prob_eighth_${participantId}`) as string) || 0;
// Use upsertParticipantEV (not WithNormalization) because ICM probabilities
// are already correct - they sum to <1.0 for teams unlikely to finish top 8
await upsertParticipantEV({
participantId,
sportsSeasonId: sportsSeasonId,
probabilities: {
probFirst,
probSecond,
probThird,
probFourth,
probFifth,
probSixth,
probSeventh,
probEighth,
},
scoringRules,
source: 'futures_odds',
sourceOdds: odds,
});
}
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
}
return { success: false, message: 'Invalid intent' };
} catch (error) {
console.error('Error generating probabilities:', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to generate probabilities',
};
}
}
function normalizeName(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim();
}
export default function AdminSportsSeasonFuturesOdds() {
const { sportsSeason, participants, existingOdds } = useLoaderData<typeof loader>();
const actionData = useActionData<ActionData>();
const navigation = useNavigation();
// Initialize odds values from existing data
const [oddsValues, setOddsValues] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
participants.forEach(p => {
const existingOdd = existingOdds.get(p.id);
if (existingOdd !== undefined && existingOdd !== null) {
initial[p.id] = existingOdd.toString();
}
});
return initial;
});
// Bulk import state
const [bulkText, setBulkText] = useState('');
const [parseResults, setParseResults] = useState<{
matched: Array<{ participantId: string; name: string; odds: number; inputName: string }>;
unmatched: Array<{ inputName: string; odds: number }>;
} | null>(null);
function findParticipantMatch(inputName: string) {
const normalizedInput = normalizeName(inputName);
// Pre-compute normalized names once for all three passes
const normalized = participants.map(p => ({ p, n: normalizeName(p.name) }));
// Exact match
const exact = normalized.find(({ n }) => n === normalizedInput);
if (exact) return exact.p;
// Contains match (one contains the other)
const contains = normalized.find(({ n }) => n.includes(normalizedInput) || normalizedInput.includes(n));
if (contains) return contains.p;
// Word-overlap match (at least half the significant words overlap)
const inputWords = normalizedInput.split(' ').filter(w => w.length > 2);
const overlap = normalized.find(({ n }) => {
const pWords = n.split(' ').filter(w => w.length > 2);
const shared = inputWords.filter(w => pWords.includes(w));
return shared.length > 0 && shared.length >= Math.min(inputWords.length, pWords.length) * 0.5;
});
return overlap?.p ?? null;
}
function parseBulkText() {
const lines = bulkText.split('\n');
// Match lines that end with an American-style odds number (+/- then 26 digits)
const oddsPattern = /^(.+?)\s+([+-]\d{2,6})\s*$/;
const matched: Array<{ participantId: string; name: string; odds: number; inputName: string }> = [];
const unmatched: Array<{ inputName: string; odds: number }> = [];
const seenParticipants = new Set<string>();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.length < 3) continue;
const match = oddsPattern.exec(trimmed);
if (!match) continue;
const inputName = match[1].trim();
const odds = parseInt(match[2], 10);
if (isNaN(odds)) continue;
const participant = findParticipantMatch(inputName);
if (participant && !seenParticipants.has(participant.id)) {
seenParticipants.add(participant.id);
matched.push({ participantId: participant.id, name: participant.name, odds, inputName });
} else if (!participant) {
unmatched.push({ inputName, odds });
}
}
setParseResults({ matched, unmatched });
}
function applyMatches() {
if (!parseResults) return;
const newOdds = { ...oddsValues };
for (const m of parseResults.matched) {
newOdds[m.participantId] = m.odds.toString();
}
setOddsValues(newOdds);
setParseResults(null);
setBulkText('');
}
const isSubmitting = navigation.state === 'submitting';
const isGenerating = isSubmitting && navigation.formData?.get('intent') === 'preview';
const isSaving = isSubmitting && navigation.formData?.get('intent') === 'save';
return (
<div className="container mx-auto py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Futures Odds Entry</h1>
<p className="text-muted-foreground">
{sportsSeason.sport.name} - {sportsSeason.name}
</p>
</div>
{/* Bulk Import */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Bulk Import</CardTitle>
<CardDescription>
Paste odds from FanDuel, DraftKings, OddsChecker, or any site. Each line should end with
American odds (e.g. <code>Kansas City Chiefs +450</code>). Team names are fuzzy-matched to
participants.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Textarea
placeholder={`Paste odds here, one team per line:\nKansas City Chiefs +450\nSan Francisco 49ers +600\nBaltimore Ravens +700`}
value={bulkText}
onChange={e => { setBulkText(e.target.value); setParseResults(null); }}
rows={8}
className="font-mono text-sm"
/>
<Button type="button" variant="outline" onClick={parseBulkText} disabled={!bulkText.trim()}>
Parse Odds
</Button>
{parseResults && (
<div className="space-y-3">
{parseResults.matched.length > 0 && (
<div>
<div className="flex items-center gap-2 text-sm font-medium text-emerald-400 mb-2">
<CheckCircle2 className="h-4 w-4" />
Matched ({parseResults.matched.length})
</div>
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/10 divide-y divide-emerald-500/20 text-sm">
{parseResults.matched.map(m => (
<div key={m.participantId} className="flex justify-between px-3 py-1.5">
<span className="text-muted-foreground">{m.inputName}</span>
<span className="font-medium">
{m.name} &rarr; {m.odds > 0 ? '+' : ''}{m.odds}
</span>
</div>
))}
</div>
</div>
)}
{parseResults.unmatched.length > 0 && (
<div>
<div className="flex items-center gap-2 text-sm font-medium text-amber-700 mb-2">
<AlertCircle className="h-4 w-4" />
Not matched ({parseResults.unmatched.length}) enter manually below
</div>
<div className="rounded-md border border-amber-200 bg-amber-50 divide-y divide-amber-100 text-sm">
{parseResults.unmatched.map((u, i) => (
<div key={`${u.inputName}-${i}`} className="flex justify-between px-3 py-1.5">
<span>{u.inputName}</span>
<span className="font-medium">{u.odds > 0 ? '+' : ''}{u.odds}</span>
</div>
))}
</div>
</div>
)}
{parseResults.matched.length === 0 && parseResults.unmatched.length === 0 && (
<p className="text-sm text-muted-foreground">No odds found. Make sure each line ends with a value like +450 or -200.</p>
)}
{parseResults.matched.length > 0 && (
<Button type="button" onClick={applyMatches}>
Apply {parseResults.matched.length} matched odds to form
</Button>
)}
</div>
)}
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<div>
<Card>
<CardHeader>
<CardTitle>Championship Futures Odds</CardTitle>
<CardDescription>
Enter American odds (e.g., +550, -200) for each participant's championship probability.
Generates probabilities for all participants using ICM (Independent Chip Model).
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<div className="space-y-3">
{participants.map((participant) => (
<div key={participant.id} className="grid grid-cols-2 gap-4 items-center">
<Label htmlFor={`odds_${participant.id}`}>{participant.name}</Label>
<Input
type="number"
id={`odds_${participant.id}`}
name={`odds_${participant.id}`}
placeholder="+550"
value={oddsValues[participant.id] ?? ''}
onChange={(e) =>
setOddsValues((prev) => ({
...prev,
[participant.id]: e.target.value,
}))
}
/>
</div>
))}
</div>
<div className="bg-muted p-4 rounded-lg space-y-2">
<div className="flex items-center gap-2 font-medium">
<Info className="h-4 w-4" />
ICM Calculation
</div>
<div className="space-y-1 text-sm">
<div>Uses Independent Chip Model from poker tournaments</div>
<div>Works with any number of participants</div>
<div className="text-xs text-muted-foreground mt-2">
Every team gets probabilities for 1st-8th place, even longshots.
</div>
</div>
</div>
<div className="flex gap-2">
<Button
type="submit"
name="intent"
value="preview"
variant="outline"
disabled={isSubmitting}
>
{isGenerating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Generate Preview
</Button>
</div>
</Form>
</CardContent>
</Card>
<Card className="mt-4">
<CardHeader>
<CardTitle>How It Works</CardTitle>
</CardHeader>
<CardContent className="text-sm space-y-2">
<ol className="list-decimal list-inside space-y-2">
<li>Converts American odds to championship win probabilities</li>
<li>Removes bookmaker vig (normalizes to 100%)</li>
<li>Uses ICM algorithm to distribute probabilities across all placements</li>
<li>Generates probability distribution (1st through 8th place) for ALL participants</li>
<li>Even teams with +100000 odds get non-zero probabilities</li>
</ol>
</CardContent>
</Card>
</div>
<div>
{actionData?.preview && (
<Card>
<CardHeader>
<CardTitle>ICM Results</CardTitle>
<CardDescription>
Probability distributions calculated using Independent Chip Model
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2">Team</th>
<th className="text-right py-2">Odds</th>
<th className="text-right py-2">P(1st)</th>
<th className="text-right py-2">P(2nd)</th>
<th className="text-right py-2">P(3rd)</th>
<th className="text-right py-2">P(4th)</th>
<th className="text-right py-2">P(5th)</th>
<th className="text-right py-2">P(6th)</th>
<th className="text-right py-2">P(7th)</th>
<th className="text-right py-2">P(8th)</th>
</tr>
</thead>
<tbody>
{actionData.preview
.sort((a, b) => b.probabilities[0] - a.probabilities[0])
.map((result) => (
<tr key={result.participantId} className="border-b">
<td className="py-2">{result.participantName}</td>
<td className="text-right">
{result.odds > 0 ? '+' : ''}
{result.odds}
</td>
<td className="text-right">
{(result.probabilities[0] * 100).toFixed(1)}%
</td>
<td className="text-right">
{(result.probabilities[1] * 100).toFixed(1)}%
</td>
<td className="text-right">
{(result.probabilities[2] * 100).toFixed(1)}%
</td>
<td className="text-right">
{(result.probabilities[3] * 100).toFixed(1)}%
</td>
<td className="text-right">
{(result.probabilities[4] * 100).toFixed(1)}%
</td>
<td className="text-right">
{(result.probabilities[5] * 100).toFixed(1)}%
</td>
<td className="text-right">
{(result.probabilities[6] * 100).toFixed(1)}%
</td>
<td className="text-right">
{(result.probabilities[7] * 100).toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
<Form method="post">
{/* Pass through odds and preview probabilities */}
{actionData.preview.map((result) => (
<div key={result.participantId}>
<input type="hidden" name={`odds_${result.participantId}`} value={result.odds} />
<input type="hidden" name={`prob_first_${result.participantId}`} value={result.probabilities[0]} />
<input type="hidden" name={`prob_second_${result.participantId}`} value={result.probabilities[1]} />
<input type="hidden" name={`prob_third_${result.participantId}`} value={result.probabilities[2]} />
<input type="hidden" name={`prob_fourth_${result.participantId}`} value={result.probabilities[3]} />
<input type="hidden" name={`prob_fifth_${result.participantId}`} value={result.probabilities[4]} />
<input type="hidden" name={`prob_sixth_${result.participantId}`} value={result.probabilities[5]} />
<input type="hidden" name={`prob_seventh_${result.participantId}`} value={result.probabilities[6]} />
<input type="hidden" name={`prob_eighth_${result.participantId}`} value={result.probabilities[7]} />
</div>
))}
<Button type="submit" name="intent" value="save" className="w-full" disabled={isSaving}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isSaving ? 'Saving...' : 'Save Probabilities'}
</Button>
</Form>
</div>
</CardContent>
</Card>
)}
{actionData && !actionData.success && actionData.message && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="text-destructive font-medium">Error</div>
<div className="text-sm mt-2">{actionData.message}</div>
</CardContent>
</Card>
)}
{actionData && actionData.success && !actionData.preview && (
<Card className="border-emerald-500/30 bg-emerald-500/10">
<CardContent className="pt-6">
<div className="text-emerald-400 font-medium">Success</div>
<div className="text-sm mt-2">{actionData.message}</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}