From 8efa4aab9edb3deb13791534bf936fc67f9f4778 Mon Sep 17 00:00:00 2001 From: chrisp Date: Tue, 30 Jun 2026 17:05:52 +0000 Subject: [PATCH] Centralize futures odds onto the simulator page and fix bulk import (#117) Futures odds entry was split across two unrelated places: a standalone /futures-odds page (which auto-ran the simulation on save and surfaced a "Simulator is not ready" run failure as if the save itself had failed) and the Bulk Simulator Inputs CSV importer on the simulator setup page. Consolidate everything onto the Bulk Simulator Inputs card: - batchUpsertParticipantSimulatorInputs now COALESCE-wraps every conflict update column, so a partial paste (e.g. odds-only) updates just the columns it provides instead of nulling out previously stored Elo/rating/etc. - The bulk importer accepts sportsbook-style paste (one team per line ending in American odds) when no CSV header is present, reusing the existing fuzzy matcher, and auto-runs the simulation on save. A not-ready run is reported as a successful save plus the readiness gap, never as a failed save. - Retire the standalone /futures-odds page (now redirects to the simulator setup page) and drop the redundant Futures links. - Remove the now-dead futures-only model paths (batchSaveSourceOdds, clearSourceOddsForParticipants, batchSaveFuturesOddsForSimulator, batchSaveParticipantSimulatorSourceOdds); the EV-table bridge is preserved by batchUpsertParticipantSimulatorInputs. - Repoint the test to the consolidated path and assert the non-destructive COALESCE behaviour. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BHnoQ7myY3PzNdTnd7iGNE Co-authored-by: Claude Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/117 --- .../__tests__/futures-odds-simulator.test.ts | 65 ++- app/models/participant-expected-value.ts | 100 +---- app/models/simulator.ts | 123 ++---- app/routes/admin.simulators.tsx | 5 - .../admin.sports-seasons.$id.futures-odds.tsx | 395 +----------------- .../admin.sports-seasons.$id.simulator.tsx | 72 +++- app/routes/admin.sports-seasons.$id.tsx | 8 - 7 files changed, 150 insertions(+), 618 deletions(-) diff --git a/app/models/__tests__/futures-odds-simulator.test.ts b/app/models/__tests__/futures-odds-simulator.test.ts index aed3c1f..ba6604e 100644 --- a/app/models/__tests__/futures-odds-simulator.test.ts +++ b/app/models/__tests__/futures-odds-simulator.test.ts @@ -6,7 +6,7 @@ vi.mock("~/database/context", () => ({ })); import { database } from "~/database/context"; -import { batchSaveFuturesOddsForSimulator } from "../simulator"; +import { batchUpsertParticipantSimulatorInputs } from "../simulator"; type SetPayload = Record; @@ -17,36 +17,71 @@ const onConflictDoUpdate = vi.fn((arg: { set: SetPayload }) => { return Promise.resolve(undefined); }); -const mockDb = { +const tx = { insert: vi.fn(() => ({ values: vi.fn(() => ({ onConflictDoUpdate })), })), }; +const mockDb = { + transaction: vi.fn((cb: (t: typeof tx) => Promise) => cb(tx)), +}; + beforeEach(() => { vi.clearAllMocks(); conflictSetCalls.length = 0; (database as ReturnType).mockReturnValue(mockDb); }); -describe("batchSaveFuturesOddsForSimulator", () => { - it("persists odds without destroying a stored Elo/rating", async () => { - await batchSaveFuturesOddsForSimulator([ +// Recursively flatten a Drizzle `sql` template into its static text so we can +// assert how a conflict-update column is built (e.g. wrapped in COALESCE). +function sqlToText(value: unknown): string { + if (value === null || value === undefined) return ""; + const chunks = (value as { queryChunks?: unknown[] }).queryChunks; + if (Array.isArray(chunks)) return chunks.map(sqlToText).join(""); + const stringValue = (value as { value?: unknown }).value; + if (Array.isArray(stringValue)) return stringValue.join(""); + if (typeof stringValue === "string") return stringValue; + return ""; +} + +describe("batchUpsertParticipantSimulatorInputs", () => { + it("updates only the columns it is given, preserving the rest (non-destructive)", async () => { + await batchUpsertParticipantSimulatorInputs([ + // Odds-only import (e.g. from the bulk futures paste): no Elo/rating supplied. { participantId: "team-1", sportsSeasonId: "season-1", sourceOdds: 550 }, ]); - // The upsert writes the odds and leaves Elo/rating untouched — whether the - // odds override them is now decided by the configurable source priority, - // not by nulling stored values here. - expect(mockDb.insert).toHaveBeenCalledTimes(1); - expect(conflictSetCalls).toHaveLength(1); - expect(conflictSetCalls[0]).toHaveProperty("sourceOdds"); - expect(conflictSetCalls[0]).not.toHaveProperty("sourceElo"); - expect(conflictSetCalls[0]).not.toHaveProperty("rating"); + // Runs in a transaction, writing the simulator-inputs row first then the + // legacy EV bridge row. + expect(mockDb.transaction).toHaveBeenCalledTimes(1); + expect(conflictSetCalls.length).toBeGreaterThanOrEqual(1); + + const simulatorSet = conflictSetCalls[0]; + // Every input column participates in the conflict update so partial pastes + // can target any field... + for (const column of ["sourceOdds", "sourceElo", "worldRanking", "rating", "seed", "region"]) { + expect(simulatorSet).toHaveProperty(column); + } + // ...but each is COALESCE-wrapped, so a null incoming value keeps the stored + // one instead of clobbering it. A bare `excluded.*` here would be the bug. + const sourceEloSql = sqlToText(simulatorSet.sourceElo).toLowerCase(); + expect(sourceEloSql).toContain("coalesce"); + const ratingSql = sqlToText(simulatorSet.rating).toLowerCase(); + expect(ratingSql).toContain("coalesce"); + + // Metadata must drop the per-column method flag whenever that column gets a + // fresh direct value, so a stale "generated" flag can't hide a newly entered + // Elo/rating. Blindly preserving metadata (e.g. COALESCE) would be the bug. + const metadataSql = sqlToText(simulatorSet.metadata).toLowerCase(); + expect(metadataSql).toContain("sourceelomethod"); + expect(metadataSql).toContain("ratingmethod"); + expect(metadataSql).toContain("excluded.source_elo"); + expect(metadataSql).toContain("excluded.rating"); }); it("is a no-op when given no inputs", async () => { - await batchSaveFuturesOddsForSimulator([]); - expect(mockDb.insert).not.toHaveBeenCalled(); + await batchUpsertParticipantSimulatorInputs([]); + expect(mockDb.transaction).not.toHaveBeenCalled(); }); }); diff --git a/app/models/participant-expected-value.ts b/app/models/participant-expected-value.ts index 132fcce..5034690 100644 --- a/app/models/participant-expected-value.ts +++ b/app/models/participant-expected-value.ts @@ -6,11 +6,11 @@ */ import { database } from "~/database/context"; -import { seasonParticipantExpectedValues, seasonParticipants, seasonParticipantSimulatorInputs } from "~/database/schema"; -import { eq, and, count, inArray, sql } from "drizzle-orm"; +import { seasonParticipantExpectedValues, seasonParticipants } from "~/database/schema"; +import { eq, and, count, sql } from "drizzle-orm"; import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator"; import { calculateEV, normalizeProbabilities, calculateReplacementLevel, calculateVORP } from "~/services/ev-calculator"; -import { batchSaveParticipantSimulatorSourceOdds, batchUpsertParticipantSimulatorInputs } from "~/models/simulator"; +import { batchUpsertParticipantSimulatorInputs } from "~/models/simulator"; export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model"; @@ -366,100 +366,6 @@ export async function batchUpsertParticipantEVs( return results; } -/** - * Save American odds for a batch of seasonParticipants without touching probabilities or EV. - * Used by the futures-odds admin page to persist odds before running the full simulation. - */ -export async function batchSaveSourceOdds( - inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }> -): Promise { - const db = database(); - - await db.transaction(async (tx) => { - const now = new Date(); - - for (const { participantId, sportsSeasonId, sourceOdds } of inputs) { - const existing = await tx - .select({ id: seasonParticipantExpectedValues.id }) - .from(seasonParticipantExpectedValues) - .where( - and( - eq(seasonParticipantExpectedValues.participantId, participantId), - eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId) - ) - ) - .limit(1); - - if (existing.length > 0) { - await tx - .update(seasonParticipantExpectedValues) - // Persist the odds and label the source as odds-driven for display. - // We no longer null a stored Elo here: how much these odds move it is - // decided at run time by the season's blend weight - // (SimulatorInputPolicy.oddsWeight), and prepareSimulatorInputsForRun - // overwrites the resolved Elo before the simulator reads it. - .set({ sourceOdds, source: "futures_odds", updatedAt: now }) - .where(eq(seasonParticipantExpectedValues.id, existing[0].id)); - } else { - // Insert a stub record — probabilities/EV will be filled in by the simulator - await tx.insert(seasonParticipantExpectedValues).values({ - participantId, - sportsSeasonId, - probFirst: "0", - probSecond: "0", - probThird: "0", - probFourth: "0", - probFifth: "0", - probSixth: "0", - probSeventh: "0", - probEighth: "0", - expectedValue: "0", - source: "futures_odds", - sourceOdds, - calculatedAt: now, - updatedAt: now, - }); - } - } - }); - - await batchSaveParticipantSimulatorSourceOdds(inputs); -} - -export async function clearSourceOddsForParticipants( - sportsSeasonId: string, - participantIds: string[] -): Promise { - if (participantIds.length === 0) return; - const db = database(); - const now = new Date(); - await db.transaction(async (tx) => { - await tx - .update(seasonParticipantExpectedValues) - .set({ sourceOdds: null, updatedAt: now }) - .where( - and( - eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId), - inArray(seasonParticipantExpectedValues.participantId, participantIds) - ) - ); - await tx - .update(seasonParticipantSimulatorInputs) - .set({ - sourceOdds: null, - rating: null, - metadata: sql`coalesce(${seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`, - updatedAt: now, - }) - .where( - and( - eq(seasonParticipantSimulatorInputs.sportsSeasonId, sportsSeasonId), - inArray(seasonParticipantSimulatorInputs.participantId, participantIds) - ) - ); - }); -} - /** * Persist raw Elo ratings (and optional world rankings) for a batch of seasonParticipants. * Used by Elo-based simulators like snooker_bracket and darts_bracket. diff --git a/app/models/simulator.ts b/app/models/simulator.ts index 3f35f87..b41469f 100644 --- a/app/models/simulator.ts +++ b/app/models/simulator.ts @@ -307,93 +307,6 @@ export async function getParticipantSimulatorInputs( }); } -function generatedRatingMethodSql() { - return sql`${schema.seasonParticipantSimulatorInputs.metadata}->>'ratingMethod' in ('sourceOdds', 'fallbackRating', 'averageKnown', 'worstKnownMinus')`; -} - -export async function batchSaveParticipantSimulatorSourceOdds( - inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }> -): Promise { - if (inputs.length === 0) return; - const db = database(); - const now = new Date(); - const seasonIds = [...new Set(inputs.map((input) => input.sportsSeasonId))]; - - await db.transaction(async (tx) => { - await tx - .update(schema.seasonParticipantSimulatorInputs) - .set({ - rating: null, - metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`, - updatedAt: now, - }) - .where( - and( - inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds), - generatedRatingMethodSql() - ) - ); - - await tx - .insert(schema.seasonParticipantSimulatorInputs) - .values( - inputs.map((input) => ({ - participantId: input.participantId, - sportsSeasonId: input.sportsSeasonId, - sourceOdds: input.sourceOdds, - createdAt: now, - updatedAt: now, - })) - ) - .onConflictDoUpdate({ - target: [ - schema.seasonParticipantSimulatorInputs.participantId, - schema.seasonParticipantSimulatorInputs.sportsSeasonId, - ], - set: { - sourceOdds: sql`excluded.source_odds`, - rating: sql`case when ${generatedRatingMethodSql()} then null else ${schema.seasonParticipantSimulatorInputs.rating} end`, - metadata: sql`case when ${generatedRatingMethodSql()} then coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod' else ${schema.seasonParticipantSimulatorInputs.metadata} end`, - updatedAt: now, - }, - }); - }); -} - -export async function batchSaveFuturesOddsForSimulator( - inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }> -): Promise { - if (inputs.length === 0) return; - const db = database(); - const now = new Date(); - - // Persist the odds non-destructively. How much these odds move a stored - // Elo/rating is now governed by the season's blend weight (see resolveSourceElos - // / SimulatorInputPolicy.oddsWeight), so we no longer null out manually entered - // Elo here — that policy decides at run time. - await db - .insert(schema.seasonParticipantSimulatorInputs) - .values( - inputs.map((input) => ({ - participantId: input.participantId, - sportsSeasonId: input.sportsSeasonId, - sourceOdds: input.sourceOdds, - createdAt: now, - updatedAt: now, - })) - ) - .onConflictDoUpdate({ - target: [ - schema.seasonParticipantSimulatorInputs.participantId, - schema.seasonParticipantSimulatorInputs.sportsSeasonId, - ], - set: { - sourceOdds: sql`excluded.source_odds`, - updatedAt: now, - }, - }); -} - export async function batchUpsertParticipantSimulatorInputs( inputs: UpsertParticipantSimulatorInput[] ): Promise { @@ -426,16 +339,34 @@ export async function batchUpsertParticipantSimulatorInputs( schema.seasonParticipantSimulatorInputs.participantId, schema.seasonParticipantSimulatorInputs.sportsSeasonId, ], + // Non-destructive: only overwrite a column when the incoming value is + // non-null. This lets a partial import (e.g. odds-only) update just the + // columns it provides without clobbering previously stored inputs like a + // manually entered Elo or rating. Mirrors the COALESCE bridge used for the + // legacy EV table below. set: { - sourceOdds: sql`excluded.source_odds`, - sourceElo: sql`excluded.source_elo`, - worldRanking: sql`excluded.world_ranking`, - rating: sql`excluded.rating`, - projectedWins: sql`excluded.projected_wins`, - projectedTablePoints: sql`excluded.projected_table_points`, - seed: sql`excluded.seed`, - region: sql`excluded.region`, - metadata: sql`excluded.metadata`, + sourceOdds: sql`COALESCE(excluded.source_odds, ${schema.seasonParticipantSimulatorInputs.sourceOdds})`, + sourceElo: sql`COALESCE(excluded.source_elo, ${schema.seasonParticipantSimulatorInputs.sourceElo})`, + worldRanking: sql`COALESCE(excluded.world_ranking, ${schema.seasonParticipantSimulatorInputs.worldRanking})`, + rating: sql`COALESCE(excluded.rating, ${schema.seasonParticipantSimulatorInputs.rating})`, + projectedWins: sql`COALESCE(excluded.projected_wins, ${schema.seasonParticipantSimulatorInputs.projectedWins})`, + projectedTablePoints: sql`COALESCE(excluded.projected_table_points, ${schema.seasonParticipantSimulatorInputs.projectedTablePoints})`, + seed: sql`COALESCE(excluded.seed, ${schema.seasonParticipantSimulatorInputs.seed})`, + region: sql`COALESCE(excluded.region, ${schema.seasonParticipantSimulatorInputs.region})`, + // Metadata carries the method flags (sourceEloMethod/ratingMethod) that + // tell readers whether the stored Elo/rating is generated vs. a trusted + // direct value. When a caller supplies explicit metadata, use it as-is + // (prepareSimulatorInputsForRun and the projection importer set the + // correct flags). Otherwise preserve existing metadata, but drop the + // method flag for any column receiving a fresh direct value — otherwise a + // stale "generated" flag would cause that newly-entered Elo/rating to be + // filtered out as derived (see getParticipantSimulatorInputs). + metadata: sql`CASE + WHEN excluded.metadata IS NOT NULL THEN excluded.metadata + ELSE COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) + - (CASE WHEN excluded.source_elo IS NOT NULL THEN 'sourceEloMethod' ELSE '' END) + - (CASE WHEN excluded.rating IS NOT NULL THEN 'ratingMethod' ELSE '' END) + END`, updatedAt: sql`excluded.updated_at`, }, }); diff --git a/app/routes/admin.simulators.tsx b/app/routes/admin.simulators.tsx index c1d50ac..2bb6b40 100644 --- a/app/routes/admin.simulators.tsx +++ b/app/routes/admin.simulators.tsx @@ -271,11 +271,6 @@ export default function AdminSimulators({ loaderData }: Route.ComponentProps) { Setup - {sim.supportsFuturesOdds && ( - - )} diff --git a/app/routes/admin.sports-seasons.$id.futures-odds.tsx b/app/routes/admin.sports-seasons.$id.futures-odds.tsx index e1ed62b..f133f5e 100644 --- a/app/routes/admin.sports-seasons.$id.futures-odds.tsx +++ b/app/routes/admin.sports-seasons.$id.futures-odds.tsx @@ -1,394 +1,9 @@ -import { Form, redirect, useLoaderData, useActionData, useNavigation } from 'react-router'; +import { redirect } from 'react-router'; import type { Route } from './+types/admin.sports-seasons.$id.futures-odds'; -import { logger } from '~/lib/logger'; -import { findSportsSeasonById } from '~/models/sports-season'; -import { findParticipantsBySportsSeasonId } from '~/models/season-participant'; -import { getAllParticipantEVsForSeason, batchSaveSourceOdds, clearSourceOddsForParticipants } from '~/models/participant-expected-value'; -import { batchSaveFuturesOddsForSimulator } from '~/models/simulator'; -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, - CardDescription, - CardHeader, - CardTitle, -} from '~/components/ui/card'; -import { useState } from 'react'; -import { Loader2, Info, CheckCircle2, AlertCircle } from 'lucide-react'; -import { runSportsSeasonSimulation } from '~/services/simulations/runner'; - -export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { - return [{ title: `Futures Odds — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }]; -} - +// Futures odds entry has been consolidated into the Bulk Simulator Inputs card on +// the simulator setup page (paste sportsbook lines like `Chiefs +450` or a CSV with +// a sourceOdds column). This route now just redirects old bookmarks/links there. 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); - - // Show any saved odds regardless of what simulation ran afterwards - const existingOdds = new Map( - existingEVs - .filter(ev => ev.sourceOdds !== null) - .map(ev => [ev.participantId, ev.sourceOdds]) - ); - - return { - sportsSeason, - participants, - existingOdds, - }; -} - -interface ActionData { - success?: boolean; - message?: string; -} - -export async function action({ request, params }: Route.ActionArgs) { - const sportsSeasonId = params.id; - const formData = await request.formData(); - - 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' }; - } - - if (!sportsSeason.sport?.simulatorType) { - return { success: false, message: 'This sport has no simulator type configured. Set one on the sport in the admin panel.' }; - } - - if (sportsSeason.simulationStatus === 'running') { - return { success: false, message: 'A simulation is already running. Please wait.' }; - } - - const shouldClearExisting = formData.get('clearExisting') === '1'; - - try { - const oddsInputs = futuresOdds.map(({ participantId, odds }) => ({ participantId, sportsSeasonId, sourceOdds: odds })); - - // Save to legacy table (EV-page display), then clear all ratings and save - // sourceOdds to the simulator inputs table so odds always drive the run. - // Sequential: batchSaveFuturesOddsForSimulator must win on the inputs table. - await batchSaveSourceOdds(oddsInputs); - await batchSaveFuturesOddsForSimulator(oddsInputs); - - if (shouldClearExisting) { - const keptIds = new Set(futuresOdds.map(f => f.participantId)); - const clearedIds = participants.filter(p => !keptIds.has(p.id)).map(p => p.id); - await clearSourceOddsForParticipants(sportsSeasonId, clearedIds); - } - await runSportsSeasonSimulation(sportsSeasonId); - } catch (error) { - logger.error('Error running simulation:', error); - return { - success: false, - message: error instanceof Error ? error.message : 'Simulation failed', - }; - } - - return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`); -} - -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(); - 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; - }); - - // Bulk import state - const [clearExisting, setClearExisting] = useState(false); - 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); - const normalized = participants.map(p => ({ p, n: normalizeName(p.name) })); - - const exact = normalized.find(({ n }) => n === normalizedInput); - if (exact) return exact.p; - - const contains = normalized.find(({ n }) => n.includes(normalizedInput) || normalizedInput.includes(n)); - if (contains) return contains.p; - - 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'); - 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 = clearExisting ? {} : { ...oddsValues }; - for (const m of parseResults.matched) { - newOdds[m.participantId] = m.odds.toString(); - } - setOddsValues(newOdds); - setParseResults(null); - setBulkText(''); - setClearExisting(false); - } - - const isSubmitting = navigation.state === 'submitting'; - - return ( -
-
-

Futures Odds Entry

-

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

-
- - {/* 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. - - - -