From eb260649b10fa2ea8e59727957847560c1514e22 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:34:45 -0800 Subject: [PATCH] Add bulk import feature for futures odds with fuzzy matching (#3) * Add bulk import to futures odds page Adds a textarea where admins can paste odds from FanDuel, DraftKings, OddsChecker, or any site. A client-side parser extracts American odds from each line and fuzzy-matches team names to participants (exact, substring, and word-overlap matching). Shows matched/unmatched results before applying to the individual input fields. https://claude.ai/code/session_01JvgcqKSJZ36bEztfg6CkWP * Code review fixes for futures-odds page - Remove unused upsertParticipantEVWithNormalization import - Remove debug console.log statements left in save action - Move normalizeName to module level (pure function, no component deps) - Pre-compute normalized participant names once in findParticipantMatch instead of re-normalizing across three separate passes - Fix array index key on unmatched list to use composite string key - Drop unused result variable from upsertParticipantEV call https://claude.ai/code/session_01JvgcqKSJZ36bEztfg6CkWP --------- Co-authored-by: Claude --- .../admin.sports-seasons.$id.futures-odds.tsx | 174 ++++++++++++++++-- 1 file changed, 156 insertions(+), 18 deletions(-) diff --git a/app/routes/admin.sports-seasons.$id.futures-odds.tsx b/app/routes/admin.sports-seasons.$id.futures-odds.tsx index 2e3a99c..f3bef04 100644 --- a/app/routes/admin.sports-seasons.$id.futures-odds.tsx +++ b/app/routes/admin.sports-seasons.$id.futures-odds.tsx @@ -6,6 +6,7 @@ import { getAllParticipantEVsForSeason } from '~/models/participant-expected-val 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, @@ -18,11 +19,8 @@ import { calculateICMFromOdds, icmResultToArray, } from '~/services/icm-calculator'; -import { - upsertParticipantEV, - upsertParticipantEVWithNormalization, -} from '~/models/participant-expected-value'; -import { Loader2, Info } from 'lucide-react'; +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; @@ -132,13 +130,6 @@ export async function action({ request, params }: Route.ActionArgs) { // 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; @@ -149,11 +140,9 @@ export async function action({ request, params }: Route.ActionArgs) { 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({ + await upsertParticipantEV({ participantId, sportsSeasonId: sportsSeasonId, probabilities: { @@ -170,11 +159,8 @@ export async function action({ request, params }: Route.ActionArgs) { 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`); } @@ -188,6 +174,10 @@ export async function action({ request, params }: Route.ActionArgs) { } } +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(); const actionData = useActionData(); @@ -205,6 +195,80 @@ export default function AdminSportsSeasonFuturesOdds() { 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 2–6 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(); + + 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'; @@ -218,6 +282,80 @@ export default function AdminSportsSeasonFuturesOdds() {

+ {/* Bulk Import */} + + + Bulk Import + + Paste odds from FanDuel, DraftKings, OddsChecker, or any site. Each line should end with + American odds (e.g. Kansas City Chiefs +450). Team names are fuzzy-matched to + participants. + + + +