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, findParticipantByName, createParticipant } from '~/models/season-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 existing = await findParticipantByName(sportsSeasonId, name); if (existing) { return { intent: 'create-participant', success: false, message: `"${name}" already exists in this season.` } 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, 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.