diff --git a/app/routes/admin.sports-seasons.$id.futures-odds.tsx b/app/routes/admin.sports-seasons.$id.futures-odds.tsx index 2e3a99c..d15cda3 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, @@ -22,7 +23,7 @@ import { upsertParticipantEV, upsertParticipantEVWithNormalization, } from '~/models/participant-expected-value'; -import { Loader2, Info } from 'lucide-react'; +import { Loader2, Info, CheckCircle2, AlertCircle } from 'lucide-react'; export async function loader({ params }: Route.LoaderArgs) { const sportsSeasonId = params.id; @@ -205,6 +206,85 @@ 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 normalizeName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim(); + } + + function findParticipantMatch(inputName: string) { + const normalizedInput = normalizeName(inputName); + + // Exact match + let match = participants.find(p => normalizeName(p.name) === normalizedInput); + if (match) return match; + + // Contains match (one contains the other) + match = participants.find(p => { + const normalizedP = normalizeName(p.name); + return normalizedP.includes(normalizedInput) || normalizedInput.includes(normalizedP); + }); + if (match) return match; + + // Word-overlap match (at least half the significant words overlap) + const inputWords = normalizedInput.split(' ').filter(w => w.length > 2); + match = participants.find(p => { + const pWords = normalizeName(p.name).split(' ').filter(w => w.length > 2); + const overlap = inputWords.filter(w => pWords.includes(w)); + return overlap.length > 0 && overlap.length >= Math.min(inputWords.length, pWords.length) * 0.5; + }); + + return match || 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 +298,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. + + + +