From 7ce7b04fca4ca5222f2ab2f37438ec8131391fdd Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 19 Feb 2026 22:26:28 +0000
Subject: [PATCH] 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
---
.../admin.sports-seasons.$id.futures-odds.tsx | 156 +++++++++++++++++-
1 file changed, 155 insertions(+), 1 deletion(-)
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.
+
+
+
+
+
+