From e62e9554c902db468341b4e9c5e343b2c76fb833 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:46:02 -0700 Subject: [PATCH] Add golf qualifying points simulator (Plackett-Luce Monte Carlo) (#223) * Add golf QP simulator with Plackett-Luce model, fixes #120 - New `participant_golf_skills` table (migration 0061) for SG: Total and per-major American odds per player/season - New `app/models/golf-skills.ts` with getGolfSkillsMap, getGolfSkillsForSeason, batchUpsertGolfSkills - Full `GolfSimulator` implementation replacing the TODO stub: Plackett-Luce ranking model (PL_BETA=1.5, FIELD_SIZE=156), 10k Monte Carlo iterations, awards QP by finishing position, ranks by total QP across all 4 majors - New admin route `sports-seasons/:id/golf-skills` with bulk CSV import, fuzzy name matching, per-player SG + per-major odds inputs; saves skills and auto-runs simulation on submit - Simulator dropdown on sport admin sorted alphabetically; renamed to "Golf Qualifying Points Monte Carlo" - Golf Skills button shown on sports season admin when simulator type is golf_qualifying_points - Extract normalizeName/diceCoefficient to shared `app/lib/fuzzy-match.ts`, removing duplication from surface-elo and golf-skills routes - Parallelize 4 DB queries in GolfSimulator.simulate() with Promise.all - O(1) field array removal via swap-to-end + pop (was O(N) splice) - Fix source tag: performance_model (not elo_simulation) for SG-based model - 23 unit tests covering americanToImplied, getMajorOddsKey, resolveSkill, simulateMajor, and Monte Carlo calibration properties Co-Authored-By: Claude Sonnet 4.6 * Fix oxlint errors: no-non-null-assertion and eqeqeq Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- app/lib/fuzzy-match.ts | 20 + app/models/golf-skills.ts | 144 + app/routes.ts | 4 + .../admin.sports-seasons.$id.golf-skills.tsx | 699 +++ .../admin.sports-seasons.$id.surface-elo.tsx | 20 +- app/routes/admin.sports-seasons.$id.tsx | 10 + app/routes/admin.sports.$id.tsx | 16 +- .../__tests__/golf-simulator.test.ts | 261 ++ app/services/simulations/golf-simulator.ts | 303 +- app/services/simulations/registry.ts | 2 +- database/schema.ts | 40 + drizzle/0061_violet_mephistopheles.sql | 26 + drizzle/meta/0061_snapshot.json | 4156 +++++++++++++++++ drizzle/meta/_journal.json | 7 + 14 files changed, 5665 insertions(+), 43 deletions(-) create mode 100644 app/lib/fuzzy-match.ts create mode 100644 app/models/golf-skills.ts create mode 100644 app/routes/admin.sports-seasons.$id.golf-skills.tsx create mode 100644 app/services/simulations/__tests__/golf-simulator.test.ts create mode 100644 drizzle/0061_violet_mephistopheles.sql create mode 100644 drizzle/meta/0061_snapshot.json diff --git a/app/lib/fuzzy-match.ts b/app/lib/fuzzy-match.ts new file mode 100644 index 0000000..84d7590 --- /dev/null +++ b/app/lib/fuzzy-match.ts @@ -0,0 +1,20 @@ +/** Normalize a display name for comparison: lowercase, strip punctuation, collapse whitespace. */ +export function normalizeName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim(); +} + +function bigrams(s: string): string[] { + const result: string[] = []; + for (let i = 0; i < s.length - 1; i++) result.push(s.slice(i, i + 2)); + return result; +} + +/** Bigram Dice coefficient — returns similarity in [0, 1]. */ +export function diceCoefficient(a: string, b: string): number { + if (a === b) return 1; + if (a.length < 2 || b.length < 2) return 0; + const bigA = bigrams(a); + const bigB = new Set(bigrams(b)); + const intersection = bigA.filter((bg) => bigB.has(bg)).length; + return (2 * intersection) / (bigA.length + bigB.size); +} diff --git a/app/models/golf-skills.ts b/app/models/golf-skills.ts new file mode 100644 index 0000000..7f08591 --- /dev/null +++ b/app/models/golf-skills.ts @@ -0,0 +1,144 @@ +/** + * Model for Participant Golf Skills + * + * Manages golf-specific skill data for qualifying-points golf seasons. + * Primary metric: SG: Total (strokes gained per round vs. field average). + * Optional per-major American odds allow major-specific probability blending. + */ + +import { database } from "~/database/context"; +import { participantGolfSkills, participants } from "~/database/schema"; +import { eq, sql } from "drizzle-orm"; + +export interface GolfSkillsRecord { + id: string; + participantId: string; + sportsSeasonId: string; + sgTotal: number | null; + datagolfRank: number | null; + mastersOdds: number | null; + usOpenOdds: number | null; + openChampionshipOdds: number | null; + pgaChampionshipOdds: number | null; + updatedAt: Date; +} + +export interface GolfSkillsWithName extends GolfSkillsRecord { + participantName: string; +} + +export interface GolfSkillsInput { + participantId: string; + sportsSeasonId: string; + sgTotal?: number | null; + datagolfRank?: number | null; + mastersOdds?: number | null; + usOpenOdds?: number | null; + openChampionshipOdds?: number | null; + pgaChampionshipOdds?: number | null; +} + +/** + * Get all golf skill records for a sports season, joined with participant names. + * Returns one record per participant (participants with no record are excluded). + */ +export async function getGolfSkillsForSeason( + sportsSeasonId: string +): Promise { + const db = database(); + const rows = await db + .select({ + id: participantGolfSkills.id, + participantId: participantGolfSkills.participantId, + sportsSeasonId: participantGolfSkills.sportsSeasonId, + sgTotal: participantGolfSkills.sgTotal, + datagolfRank: participantGolfSkills.datagolfRank, + mastersOdds: participantGolfSkills.mastersOdds, + usOpenOdds: participantGolfSkills.usOpenOdds, + openChampionshipOdds: participantGolfSkills.openChampionshipOdds, + pgaChampionshipOdds: participantGolfSkills.pgaChampionshipOdds, + updatedAt: participantGolfSkills.updatedAt, + participantName: participants.name, + }) + .from(participantGolfSkills) + .innerJoin(participants, eq(participantGolfSkills.participantId, participants.id)) + .where(eq(participantGolfSkills.sportsSeasonId, sportsSeasonId)) + .orderBy(participants.name); + + return rows.map((r) => ({ + ...r, + sgTotal: r.sgTotal !== null ? Number(r.sgTotal) : null, + })); +} + +/** + * Returns a Map from participantId → GolfSkillsRecord for use in the simulator. + * Participants with no record are absent from the map (simulator falls back to SG = 0). + */ +export async function getGolfSkillsMap( + sportsSeasonId: string +): Promise> { + const db = database(); + const rows = await db + .select() + .from(participantGolfSkills) + .where(eq(participantGolfSkills.sportsSeasonId, sportsSeasonId)); + + return new Map(rows.map((r) => [ + r.participantId, + { + ...r, + sgTotal: r.sgTotal !== null ? Number(r.sgTotal) : null, + }, + ])); +} + +/** + * Upsert golf skill ratings for a batch of participants. + * Uses INSERT … ON CONFLICT DO UPDATE so all columns are overwritten atomically. + */ +export async function batchUpsertGolfSkills( + inputs: GolfSkillsInput[] +): Promise { + if (inputs.length === 0) return; + const db = database(); + const now = new Date(); + + await db + .insert(participantGolfSkills) + .values( + inputs.map(({ + participantId, + sportsSeasonId, + sgTotal, + datagolfRank, + mastersOdds, + usOpenOdds, + openChampionshipOdds, + pgaChampionshipOdds, + }) => ({ + participantId, + sportsSeasonId, + // decimal columns are stored/passed as strings in Drizzle + sgTotal: sgTotal !== null && sgTotal !== undefined ? String(sgTotal) : null, + datagolfRank: datagolfRank ?? null, + mastersOdds: mastersOdds ?? null, + usOpenOdds: usOpenOdds ?? null, + openChampionshipOdds: openChampionshipOdds ?? null, + pgaChampionshipOdds: pgaChampionshipOdds ?? null, + updatedAt: now, + })) + ) + .onConflictDoUpdate({ + target: [participantGolfSkills.participantId, participantGolfSkills.sportsSeasonId], + set: { + sgTotal: sql`excluded.sg_total`, + datagolfRank: sql`excluded.datagolf_rank`, + mastersOdds: sql`excluded.masters_odds`, + usOpenOdds: sql`excluded.us_open_odds`, + openChampionshipOdds: sql`excluded.open_championship_odds`, + pgaChampionshipOdds: sql`excluded.pga_championship_odds`, + updatedAt: sql`excluded.updated_at`, + }, + }); +} diff --git a/app/routes.ts b/app/routes.ts index 93efbed..9e18e0f 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -89,6 +89,10 @@ export default [ "sports-seasons/:id/surface-elo", "routes/admin.sports-seasons.$id.surface-elo.tsx" ), + route( + "sports-seasons/:id/golf-skills", + "routes/admin.sports-seasons.$id.golf-skills.tsx" + ), route( "sports-seasons/:id/standings", "routes/admin.sports-seasons.$id.standings.tsx" diff --git a/app/routes/admin.sports-seasons.$id.golf-skills.tsx b/app/routes/admin.sports-seasons.$id.golf-skills.tsx new file mode 100644 index 0000000..f2bb12c --- /dev/null +++ b/app/routes/admin.sports-seasons.$id.golf-skills.tsx @@ -0,0 +1,699 @@ +import { Form, redirect, useLoaderData, useActionData, useNavigation, useFetcher } from 'react-router'; +import type { Route } from './+types/admin.sports-seasons.$id.golf-skills'; + +import { logger } from '~/lib/logger'; +import { findSportsSeasonById, updateSportsSeason } from '~/models/sports-season'; +import { findParticipantsBySportsSeasonId, createParticipant } from '~/models/participant'; +import { batchUpsertParticipantEVs } from '~/models/participant-expected-value'; +import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot'; +import { getGolfSkillsForSeason, batchUpsertGolfSkills } from '~/models/golf-skills'; +import { getSimulator, type SimulatorType } from '~/services/simulations/registry'; +import { calculateEV, type ScoringRules } from '~/services/ev-calculator'; +import { recalculateStandings } from '~/models/scoring-calculator'; +import { database } from '~/database/context'; +import * as schema from '~/database/schema'; +import { eq } from 'drizzle-orm'; +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 { useEffect, useRef, useState } from 'react'; +import { Loader2, CheckCircle2, AlertCircle, UserPlus } from 'lucide-react'; +import { normalizeName, diceCoefficient } from '~/lib/fuzzy-match'; + +const DEFAULT_SCORING_RULES: ScoringRules = { + pointsFor1st: 100, + pointsFor2nd: 70, + pointsFor3rd: 45, + pointsFor4th: 45, + pointsFor5th: 20, + pointsFor6th: 20, + pointsFor7th: 20, + pointsFor8th: 20, +}; + +export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { + return [{ title: `Golf Skills — ${data?.sportsSeason?.name ?? 'Sports Season'} - Brackt Admin` }]; +} + +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, existingSkills] = await Promise.all([ + findParticipantsBySportsSeasonId(sportsSeasonId), + getGolfSkillsForSeason(sportsSeasonId), + ]); + + const skillsMap: Record< + string, + { sgTotal: string; datagolfRank: string; mastersOdds: string; usOpenOdds: string; openChampionshipOdds: string; pgaChampionshipOdds: string } + > = {}; + for (const r of existingSkills) { + skillsMap[r.participantId] = { + sgTotal: r.sgTotal !== null ? String(r.sgTotal) : '', + datagolfRank: r.datagolfRank !== null ? String(r.datagolfRank) : '', + mastersOdds: r.mastersOdds !== null ? String(r.mastersOdds) : '', + usOpenOdds: r.usOpenOdds !== null ? String(r.usOpenOdds) : '', + openChampionshipOdds: r.openChampionshipOdds !== null ? String(r.openChampionshipOdds) : '', + pgaChampionshipOdds: r.pgaChampionshipOdds !== null ? String(r.pgaChampionshipOdds) : '', + }; + } + + return { sportsSeason, participants, skillsMap }; +} + +type ActionData = + | { intent: 'create-participant'; success: true; participant: { id: string; name: string } } + | { intent: 'create-participant'; success: false; message: string } + | { success?: boolean; message?: string }; + +export async function action({ request, params }: Route.ActionArgs) { + const sportsSeasonId = params.id; + const formData = await request.formData(); + const intent = formData.get('intent'); + + if (intent === 'create-participant') { + const name = (formData.get('name') as string)?.trim(); + if (!name) { + return { intent: 'create-participant', success: false, message: 'Name is required' } satisfies ActionData; + } + const participant = await createParticipant({ sportsSeasonId, name }); + return { + intent: 'create-participant', + success: true, + participant: { id: participant.id, name: participant.name }, + } satisfies ActionData; + } + + const sportsSeason = await findSportsSeasonById(sportsSeasonId); + if (!sportsSeason) { + return { success: false, message: 'Sports season not found' }; + } + + if (!sportsSeason.sport?.simulatorType) { + return { success: false, message: 'This sport has no simulator type configured.' }; + } + + if (sportsSeason.simulationStatus === 'running') { + return { success: false, message: 'A simulation is already running. Please wait.' }; + } + + const participants = await findParticipantsBySportsSeasonId(sportsSeasonId); + + // Parse golf skill fields: sgTotal_{id}, datagolfRank_{id}, mastersOdds_{id}, etc. + const skillInputs = participants + .map((p) => ({ + participantId: p.id, + sportsSeasonId, + sgTotal: parseDecimalOrNull(formData.get(`sgTotal_${p.id}`) as string), + datagolfRank: parseIntOrNull(formData.get(`datagolfRank_${p.id}`) as string), + mastersOdds: parseIntOrNull(formData.get(`mastersOdds_${p.id}`) as string), + usOpenOdds: parseIntOrNull(formData.get(`usOpenOdds_${p.id}`) as string), + openChampionshipOdds: parseIntOrNull(formData.get(`openChampionshipOdds_${p.id}`) as string), + pgaChampionshipOdds: parseIntOrNull(formData.get(`pgaChampionshipOdds_${p.id}`) as string), + })) + .filter((r) => + r.sgTotal !== null || + r.datagolfRank !== null || + r.mastersOdds !== null || + r.usOpenOdds !== null || + r.openChampionshipOdds !== null || + r.pgaChampionshipOdds !== null + ); + + if (skillInputs.length === 0) { + return { success: false, message: 'Please enter at least one skill rating.' }; + } + + await batchUpsertGolfSkills(skillInputs); + + // Auto-run simulation after saving + await updateSportsSeason(sportsSeasonId, { simulationStatus: 'running' }); + + try { + const simulator = getSimulator(sportsSeason.sport.simulatorType as SimulatorType); + const results = await simulator.simulate(sportsSeasonId); + + if (results.length === 0) { + throw new Error('Simulation returned no results.'); + } + + const simulatedIds = new Set(results.map((r) => r.participantId)); + const ZERO_PROBS = { + probFirst: 0, probSecond: 0, probThird: 0, probFourth: 0, + probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0, + }; + const evInputs = [ + ...results.map((r) => ({ + participantId: r.participantId, + sportsSeasonId, + probabilities: r.probabilities, + scoringRules: DEFAULT_SCORING_RULES, + source: 'performance_model' as const, + })), + ...participants + .filter((p) => !simulatedIds.has(p.id)) + .map((p) => ({ + participantId: p.id, + sportsSeasonId, + probabilities: ZERO_PROBS, + scoringRules: DEFAULT_SCORING_RULES, + source: 'performance_model' as const, + })), + ]; + + await batchUpsertParticipantEVs(evInputs); + + // Refresh projected points in team standings + const seasonSports = await database().query.seasonSports.findMany({ + where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId), + }); + await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId))); + + // EV snapshot + const today = new Date().toISOString().slice(0, 10); + await batchUpsertParticipantEvSnapshots( + results.map((r) => ({ + participantId: r.participantId, + sportsSeasonId, + snapshotDate: today, + probFirst: r.probabilities.probFirst, + probSecond: r.probabilities.probSecond, + probThird: r.probabilities.probThird, + probFourth: r.probabilities.probFourth, + probFifth: r.probabilities.probFifth, + probSixth: r.probabilities.probSixth, + probSeventh: r.probabilities.probSeventh, + probEighth: r.probabilities.probEighth, + calculatedEV: calculateEV(r.probabilities, DEFAULT_SCORING_RULES), + source: r.source, + })) + ); + + await updateSportsSeason(sportsSeasonId, { simulationStatus: 'idle' }); + } catch (error) { + await updateSportsSeason(sportsSeasonId, { simulationStatus: 'failed' }); + logger.error('Error running golf simulation:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Simulation failed', + }; + } + + return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`); +} + +function parseIntOrNull(val: string | null | undefined): number | null { + if (!val || val.trim() === '') return null; + const n = parseInt(val.trim(), 10); + return isNaN(n) ? null : n; +} + +function parseDecimalOrNull(val: string | null | undefined): number | null { + if (!val || val.trim() === '') return null; + const n = parseFloat(val.trim()); + return isNaN(n) ? null : n; +} + + +type SkillValues = Record< + string, + { sgTotal: string; datagolfRank: string; mastersOdds: string; usOpenOdds: string; openChampionshipOdds: string; pgaChampionshipOdds: string } +>; + +interface ParsedSkill { + sgTotal: number | null; + datagolfRank: number | null; + mastersOdds: number | null; + usOpenOdds: number | null; + openChampionshipOdds: number | null; + pgaChampionshipOdds: number | null; +} + +interface MatchedItem extends ParsedSkill { + participantId: string; + name: string; + inputName: string; +} + +interface Suggestion { + participantId: string; + name: string; + score: number; +} + +interface UnmatchedItem extends ParsedSkill { + inputName: string; + suggestions: Suggestion[]; +} + +interface ParseResults { + matched: MatchedItem[]; + unmatched: UnmatchedItem[]; +} + +type LocalParticipant = { id: string; name: string }; + +export default function AdminSportsSeasonGolfSkills() { + const { sportsSeason, participants: loaderParticipants, skillsMap } = useLoaderData(); + const actionData = useActionData(); + const navigation = useNavigation(); + const createFetcher = useFetcher(); + + const [localParticipants, setLocalParticipants] = useState(loaderParticipants); + + const [skillValues, setSkillValues] = useState(() => { + const initial: SkillValues = {}; + loaderParticipants.forEach((p) => { + const existing = skillsMap[p.id]; + initial[p.id] = existing ?? { + sgTotal: '', datagolfRank: '', mastersOdds: '', usOpenOdds: '', + openChampionshipOdds: '', pgaChampionshipOdds: '', + }; + }); + return initial; + }); + + const [bulkText, setBulkText] = useState(''); + const [parseResults, setParseResults] = useState(null); + + const pendingSkillsByName = useRef>(new Map()); + + useEffect(() => { + if ( + createFetcher.state !== 'idle' || + !createFetcher.data || + !('intent' in createFetcher.data) || + createFetcher.data.intent !== 'create-participant' || + !createFetcher.data.success + ) return; + + const { participant } = createFetcher.data as Extract; + + setLocalParticipants((prev) => { + if (prev.some((p) => p.id === participant.id)) return prev; + return [...prev, participant]; + }); + + const pending = pendingSkillsByName.current.get(participant.name); + if (pending) { + setSkillValues((prev) => ({ + ...prev, + [participant.id]: { + sgTotal: pending.sgTotal !== null ? String(pending.sgTotal) : '', + datagolfRank: pending.datagolfRank !== null ? String(pending.datagolfRank) : '', + mastersOdds: pending.mastersOdds !== null ? String(pending.mastersOdds) : '', + usOpenOdds: pending.usOpenOdds !== null ? String(pending.usOpenOdds) : '', + openChampionshipOdds: pending.openChampionshipOdds !== null ? String(pending.openChampionshipOdds) : '', + pgaChampionshipOdds: pending.pgaChampionshipOdds !== null ? String(pending.pgaChampionshipOdds) : '', + }, + })); + pendingSkillsByName.current.delete(participant.name); + } + + setParseResults((prev) => { + if (!prev) return prev; + return { ...prev, unmatched: prev.unmatched.filter((u) => u.inputName !== participant.name) }; + }); + }, [createFetcher.state, createFetcher.data]); + + function findParticipantMatch(inputName: string, pool: LocalParticipant[]) { + const normalizedInput = normalizeName(inputName); + const normalized = pool.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 getFuzzySuggestions(inputName: string, pool: LocalParticipant[], exclude: Set): Suggestion[] { + const normalizedInput = normalizeName(inputName); + return pool + .filter((p) => !exclude.has(p.id)) + .map((p) => ({ participantId: p.id, name: p.name, score: diceCoefficient(normalizedInput, normalizeName(p.name)) })) + .filter((s) => s.score >= 0.3) + .toSorted((a, b) => b.score - a.score) + .slice(0, 3); + } + + /** + * Parse bulk import text. + * Format (CSV, one per line): Player Name, SG_Total + * Optional additional columns: Player Name, SG_Total, Masters, USOpen, TheOpen, PGA + */ + function parseBulkText() { + const lines = bulkText.split('\n'); + const matched: MatchedItem[] = []; + const unmatched: UnmatchedItem[] = []; + const seen = new Set(); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + const parts = trimmed.split(/[,\t]/).map((s) => s.trim()); + if (parts.length < 2) continue; + + const inputName = parts[0]; + const sgTotal = parts[1] ? parseDecimalOrNull(parts[1]) : null; + const mastersOdds = parts[2] ? parseIntOrNull(parts[2]) : null; + const usOpenOdds = parts[3] ? parseIntOrNull(parts[3]) : null; + const openChampionshipOdds = parts[4] ? parseIntOrNull(parts[4]) : null; + const pgaChampionshipOdds = parts[5] ? parseIntOrNull(parts[5]) : null; + + if (sgTotal === null && mastersOdds === null) continue; // no useful data + + const skill: ParsedSkill = { + sgTotal, + datagolfRank: null, + mastersOdds, + usOpenOdds, + openChampionshipOdds, + pgaChampionshipOdds, + }; + + const participant = findParticipantMatch(inputName, localParticipants); + if (participant && !seen.has(participant.id)) { + seen.add(participant.id); + matched.push({ participantId: participant.id, name: participant.name, inputName, ...skill }); + } else { + const suggestions = getFuzzySuggestions(inputName, localParticipants, seen); + unmatched.push({ inputName, suggestions, ...skill }); + } + } + + setParseResults({ matched, unmatched }); + } + + function assignSuggestion(item: UnmatchedItem, suggestion: Suggestion) { + setParseResults((prev) => { + if (!prev) return prev; + return { + matched: [ + ...prev.matched, + { participantId: suggestion.participantId, name: suggestion.name, inputName: item.inputName, + sgTotal: item.sgTotal, datagolfRank: item.datagolfRank, mastersOdds: item.mastersOdds, + usOpenOdds: item.usOpenOdds, openChampionshipOdds: item.openChampionshipOdds, + pgaChampionshipOdds: item.pgaChampionshipOdds }, + ], + unmatched: prev.unmatched.filter((u) => u.inputName !== item.inputName), + }; + }); + } + + function handleCreateParticipant(item: UnmatchedItem) { + pendingSkillsByName.current.set(item.inputName, { + sgTotal: item.sgTotal, datagolfRank: item.datagolfRank, mastersOdds: item.mastersOdds, + usOpenOdds: item.usOpenOdds, openChampionshipOdds: item.openChampionshipOdds, + pgaChampionshipOdds: item.pgaChampionshipOdds, + }); + const fd = new FormData(); + fd.set('intent', 'create-participant'); + fd.set('name', item.inputName); + createFetcher.submit(fd, { method: 'post' }); + } + + function applyMatches() { + if (!parseResults) return; + const newValues = { ...skillValues }; + for (const m of parseResults.matched) { + newValues[m.participantId] = { + sgTotal: m.sgTotal !== null ? String(m.sgTotal) : '', + datagolfRank: m.datagolfRank !== null ? String(m.datagolfRank) : '', + mastersOdds: m.mastersOdds !== null ? String(m.mastersOdds) : '', + usOpenOdds: m.usOpenOdds !== null ? String(m.usOpenOdds) : '', + openChampionshipOdds: m.openChampionshipOdds !== null ? String(m.openChampionshipOdds) : '', + pgaChampionshipOdds: m.pgaChampionshipOdds !== null ? String(m.pgaChampionshipOdds) : '', + }; + } + setSkillValues(newValues); + setParseResults(null); + setBulkText(''); + } + + const setField = (participantId: string, field: keyof SkillValues[string], value: string) => { + setSkillValues((prev) => ({ + ...prev, + [participantId]: { ...prev[participantId], [field]: value }, + })); + }; + + const isSubmitting = navigation.state === 'submitting'; + + return ( +
+
+

Golf Skills

+

+ {sportsSeason.sport.name} — {sportsSeason.name} +

+
+ + {/* Bulk Import */} + + + Bulk Import + + Paste one player per line. Format:{' '} + Player Name, SG_Total + {' '}(optional extras: Masters odds, US Open odds, Open Championship odds, PGA odds). + American odds format (e.g. 400 for +400). Player names are fuzzy-matched to participants. + + + +