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
Kansas City Chiefs +450). Team names are fuzzy-matched to
+ participants.
+