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 { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '~/components/ui/card'; import { useState } from 'react'; import { calculateICMFromOdds, icmResultToArray, } from '~/services/icm-calculator'; import { upsertParticipantEV, upsertParticipantEVWithNormalization, } from '~/models/participant-expected-value'; import { Loader2, Info } 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) { // Debug: check what's in form data const probFirstRaw = formData.get(`prob_first_${participantId}`); const probSecondRaw = formData.get(`prob_second_${participantId}`); console.log(`[Futures Odds Save DEBUG] Looking for participant ${participantId}`); console.log(` prob_first_${participantId} = ${probFirstRaw} (type: ${typeof probFirstRaw})`); console.log(` prob_second_${participantId} = ${probSecondRaw} (type: ${typeof probSecondRaw})`); // 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; console.log(`[Futures Odds Save] ${participantId}: P(1st)=${probFirst}, P(2nd)=${probSecond}, P(3rd)=${probThird}, P(4th)=${probFourth}, P(5th)=${probFifth}, P(6th)=${probSixth}, P(7th)=${probSeventh}, P(8th)=${probEighth}, sum=${probFirst+probSecond+probThird+probFourth+probFifth+probSixth+probSeventh+probEighth}`); // Use upsertParticipantEV (not WithNormalization) because ICM probabilities // are already correct - they sum to <1.0 for teams unlikely to finish top 8 const result = await upsertParticipantEV({ participantId, sportsSeasonId: sportsSeasonId, probabilities: { probFirst, probSecond, probThird, probFourth, probFifth, probSixth, probSeventh, probEighth, }, scoringRules, source: 'futures_odds', sourceOdds: odds, }); console.log(`[Futures Odds Save] Saved ${participantId}: DB probFirst=${result.probFirst}, EV=${result.expectedValue}`); } console.log('[Futures Odds Save] All probabilities saved, redirecting...'); 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', }; } } export default function AdminSportsSeasonFuturesOdds() { const { sportsSeason, participants, existingOdds } = useLoaderData(); const actionData = useActionData(); const navigation = useNavigation(); // Initialize odds values from existing data const [oddsValues, setOddsValues] = useState>(() => { const initial: Record = {}; participants.forEach(p => { const existingOdd = existingOdds.get(p.id); if (existingOdd !== undefined && existingOdd !== null) { initial[p.id] = existingOdd.toString(); } }); return initial; }); const isSubmitting = navigation.state === 'submitting'; const isGenerating = isSubmitting && navigation.formData?.get('intent') === 'preview'; const isSaving = isSubmitting && navigation.formData?.get('intent') === 'save'; return (

Futures Odds Entry

{sportsSeason.sport.name} - {sportsSeason.name}

Championship Futures Odds Enter American odds (e.g., +550, -200) for each participant's championship probability. Generates probabilities for all participants using ICM (Independent Chip Model).
{participants.map((participant) => (
setOddsValues((prev) => ({ ...prev, [participant.id]: e.target.value, })) } />
))}
ICM Calculation
Uses Independent Chip Model from poker tournaments
Works with any number of participants
Every team gets probabilities for 1st-8th place, even longshots.
How It Works
  1. Converts American odds to championship win probabilities
  2. Removes bookmaker vig (normalizes to 100%)
  3. Uses ICM algorithm to distribute probabilities across all placements
  4. Generates probability distribution (1st through 8th place) for ALL participants
  5. Even teams with +100000 odds get non-zero probabilities
{actionData?.preview && ( ICM Results Probability distributions calculated using Independent Chip Model
{actionData.preview .sort((a, b) => b.probabilities[0] - a.probabilities[0]) .map((result) => ( ))}
Team Odds P(1st) P(2nd) P(3rd) P(4th) P(5th) P(6th) P(7th) P(8th)
{result.participantName} {result.odds > 0 ? '+' : ''} {result.odds} {(result.probabilities[0] * 100).toFixed(1)}% {(result.probabilities[1] * 100).toFixed(1)}% {(result.probabilities[2] * 100).toFixed(1)}% {(result.probabilities[3] * 100).toFixed(1)}% {(result.probabilities[4] * 100).toFixed(1)}% {(result.probabilities[5] * 100).toFixed(1)}% {(result.probabilities[6] * 100).toFixed(1)}% {(result.probabilities[7] * 100).toFixed(1)}%
{/* Pass through odds and preview probabilities */} {actionData.preview.map((result) => (
))}
)} {actionData && !actionData.success && actionData.message && (
Error
{actionData.message}
)} {actionData && actionData.success && !actionData.preview && (
Success
{actionData.message}
)}
); }