diff --git a/app/models/__tests__/surface-elo.test.ts b/app/models/__tests__/surface-elo.test.ts new file mode 100644 index 0000000..307a87a --- /dev/null +++ b/app/models/__tests__/surface-elo.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock the database context before importing any model +vi.mock("~/database/context", () => ({ + database: vi.fn(), +})); + +import { database } from "~/database/context"; +import { getSurfaceEloMap, batchUpsertSurfaceElos, getSurfaceElosForSeason } from "../surface-elo"; + +const mockDb = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + insert: vi.fn().mockReturnThis(), + values: vi.fn().mockReturnThis(), + onConflictDoUpdate: vi.fn().mockReturnThis(), +}; + +beforeEach(() => { + vi.clearAllMocks(); + (database as ReturnType).mockReturnValue(mockDb); + + // Reset chain to return mock itself (supports fluent chaining) + mockDb.select.mockReturnValue(mockDb); + mockDb.from.mockReturnValue(mockDb); + mockDb.where.mockReturnValue(mockDb); + mockDb.innerJoin.mockReturnValue(mockDb); + mockDb.orderBy.mockReturnValue(mockDb); + mockDb.insert.mockReturnValue(mockDb); + mockDb.values.mockReturnValue(mockDb); + mockDb.onConflictDoUpdate.mockResolvedValue(undefined); +}); + +describe("getSurfaceEloMap", () => { + it("returns an empty Map when no records exist", async () => { + mockDb.where.mockResolvedValue([]); + const result = await getSurfaceEloMap("season-1"); + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(0); + }); + + it("returns a Map keyed by participantId", async () => { + mockDb.where.mockResolvedValue([ + { participantId: "p1", worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 }, + { participantId: "p2", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: null }, + ]); + const result = await getSurfaceEloMap("season-1"); + expect(result.size).toBe(2); + expect(result.get("p1")).toEqual({ worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 }); + expect(result.get("p2")).toEqual({ worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: null }); + }); + + it("preserves null surface Elos (does not coerce to 0)", async () => { + mockDb.where.mockResolvedValue([ + { participantId: "p1", worldRanking: null, eloHard: null, eloClay: null, eloGrass: null }, + ]); + const result = await getSurfaceEloMap("season-1"); + const entry = result.get("p1"); + expect(entry?.worldRanking).toBeNull(); + expect(entry?.eloHard).toBeNull(); + expect(entry?.eloClay).toBeNull(); + expect(entry?.eloGrass).toBeNull(); + }); +}); + +describe("batchUpsertSurfaceElos", () => { + it("does nothing when given an empty array", async () => { + await batchUpsertSurfaceElos([]); + expect(mockDb.insert).not.toHaveBeenCalled(); + }); + + it("calls insert with the correct values", async () => { + const inputs = [ + { participantId: "p1", sportsSeasonId: "s1", worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 }, + { participantId: "p2", sportsSeasonId: "s1", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: undefined }, + ]; + await batchUpsertSurfaceElos(inputs); + expect(mockDb.insert).toHaveBeenCalledOnce(); + expect(mockDb.values).toHaveBeenCalledOnce(); + + const passedValues = mockDb.values.mock.calls[0][0] as Array<{ + participantId: string; + eloHard: number | null; + eloClay: number | null; + eloGrass: number | null; + }>; + expect(passedValues).toHaveLength(2); + expect(passedValues[0].participantId).toBe("p1"); + expect(passedValues[0].eloHard).toBe(1800); + expect(passedValues[1].eloHard).toBeNull(); + expect(passedValues[1].eloGrass).toBeNull(); // undefined → null + }); + + it("uses onConflictDoUpdate for upsert behaviour", async () => { + await batchUpsertSurfaceElos([ + { participantId: "p1", sportsSeasonId: "s1", worldRanking: 5, eloHard: 2000 }, + ]); + expect(mockDb.onConflictDoUpdate).toHaveBeenCalledOnce(); + }); +}); + +describe("getSurfaceElosForSeason", () => { + it("returns records joined with participant names", async () => { + mockDb.orderBy.mockResolvedValue([ + { + id: "rec-1", + participantId: "p1", + sportsSeasonId: "s1", + worldRanking: 1, + eloHard: 1800, + eloClay: 1700, + eloGrass: 1600, + updatedAt: new Date("2025-01-01"), + participantName: "Carlos Alcaraz", + }, + ]); + const result = await getSurfaceElosForSeason("s1"); + expect(result).toHaveLength(1); + expect(result[0].participantName).toBe("Carlos Alcaraz"); + expect(result[0].eloHard).toBe(1800); + }); + + it("returns empty array when no Elo records exist", async () => { + mockDb.orderBy.mockResolvedValue([]); + const result = await getSurfaceElosForSeason("s1"); + expect(result).toEqual([]); + }); +}); diff --git a/app/models/surface-elo.ts b/app/models/surface-elo.ts new file mode 100644 index 0000000..eed451c --- /dev/null +++ b/app/models/surface-elo.ts @@ -0,0 +1,129 @@ +/** + * Model for Participant Surface Elos + * + * Manages surface-specific Elo ratings for tennis (and future surface-based sports). + * Each participant in a sports season can have separate Elo ratings for hard, + * clay, and grass courts. + */ + +import { database } from "~/database/context"; +import { participantSurfaceElos, participants } from "~/database/schema"; +import { eq, sql } from "drizzle-orm"; + +export type CourtSurface = "hard" | "clay" | "grass"; + +export interface SurfaceEloRecord { + id: string; + participantId: string; + sportsSeasonId: string; + worldRanking: number | null; + eloHard: number | null; + eloClay: number | null; + eloGrass: number | null; + updatedAt: Date; +} + +export interface SurfaceEloWithName extends SurfaceEloRecord { + participantName: string; +} + +export interface SurfaceEloInput { + participantId: string; + sportsSeasonId: string; + worldRanking?: number | null; + eloHard?: number | null; + eloClay?: number | null; + eloGrass?: number | null; +} + +/** + * Get all surface Elo records for a sports season, joined with participant names. + * Returns one record per participant (participants with no Elo record are excluded). + */ +export async function getSurfaceElosForSeason( + sportsSeasonId: string +): Promise { + const db = database(); + const rows = await db + .select({ + id: participantSurfaceElos.id, + participantId: participantSurfaceElos.participantId, + sportsSeasonId: participantSurfaceElos.sportsSeasonId, + worldRanking: participantSurfaceElos.worldRanking, + eloHard: participantSurfaceElos.eloHard, + eloClay: participantSurfaceElos.eloClay, + eloGrass: participantSurfaceElos.eloGrass, + updatedAt: participantSurfaceElos.updatedAt, + participantName: participants.name, + }) + .from(participantSurfaceElos) + .innerJoin(participants, eq(participantSurfaceElos.participantId, participants.id)) + .where(eq(participantSurfaceElos.sportsSeasonId, sportsSeasonId)) + .orderBy(participants.name); + + return rows; +} + +/** + * Upsert surface Elo ratings for a batch of participants. + * Uses INSERT ... ON CONFLICT DO UPDATE so all three surface columns are + * overwritten atomically — the admin always submits all three values. + */ +export async function batchUpsertSurfaceElos( + inputs: SurfaceEloInput[] +): Promise { + if (inputs.length === 0) return; + const db = database(); + const now = new Date(); + + await db + .insert(participantSurfaceElos) + .values( + inputs.map(({ participantId, sportsSeasonId, worldRanking, eloHard, eloClay, eloGrass }) => ({ + participantId, + sportsSeasonId, + worldRanking: worldRanking ?? null, + eloHard: eloHard ?? null, + eloClay: eloClay ?? null, + eloGrass: eloGrass ?? null, + updatedAt: now, + })) + ) + .onConflictDoUpdate({ + target: [participantSurfaceElos.participantId, participantSurfaceElos.sportsSeasonId], + set: { + worldRanking: sql`excluded.world_ranking`, + eloHard: sql`excluded.elo_hard`, + eloClay: sql`excluded.elo_clay`, + eloGrass: sql`excluded.elo_grass`, + updatedAt: sql`excluded.updated_at`, + }, + }); +} + +/** + * Returns a Map from participantId to surface Elos for use in the simulator. + * Participants with no record are absent from the map (simulator falls back to 1500). + */ +export async function getSurfaceEloMap( + sportsSeasonId: string +): Promise> { + const db = database(); + const rows = await db + .select({ + participantId: participantSurfaceElos.participantId, + worldRanking: participantSurfaceElos.worldRanking, + eloHard: participantSurfaceElos.eloHard, + eloClay: participantSurfaceElos.eloClay, + eloGrass: participantSurfaceElos.eloGrass, + }) + .from(participantSurfaceElos) + .where(eq(participantSurfaceElos.sportsSeasonId, sportsSeasonId)); + + return new Map(rows.map((r) => [r.participantId, { + worldRanking: r.worldRanking, + eloHard: r.eloHard, + eloClay: r.eloClay, + eloGrass: r.eloGrass, + }])); +} diff --git a/app/routes.ts b/app/routes.ts index 4226c7c..93efbed 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -85,6 +85,10 @@ export default [ "sports-seasons/:id/elo-ratings", "routes/admin.sports-seasons.$id.elo-ratings.tsx" ), + route( + "sports-seasons/:id/surface-elo", + "routes/admin.sports-seasons.$id.surface-elo.tsx" + ), route( "sports-seasons/:id/standings", "routes/admin.sports-seasons.$id.standings.tsx" diff --git a/app/routes/admin.sports-seasons.$id.surface-elo.tsx b/app/routes/admin.sports-seasons.$id.surface-elo.tsx new file mode 100644 index 0000000..5e893b7 --- /dev/null +++ b/app/routes/admin.sports-seasons.$id.surface-elo.tsx @@ -0,0 +1,686 @@ +import { Form, redirect, useLoaderData, useActionData, useNavigation, useFetcher } from 'react-router'; +import type { Route } from './+types/admin.sports-seasons.$id.surface-elo'; + +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 { getSurfaceElosForSeason, batchUpsertSurfaceElos } from '~/models/surface-elo'; +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'; + +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: `Surface Elo — ${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 = await findParticipantsBySportsSeasonId(sportsSeasonId); + const existingElos = await getSurfaceElosForSeason(sportsSeasonId); + + const eloMap: Record = {}; + for (const r of existingElos) { + eloMap[r.participantId] = { + ranking: r.worldRanking, + hard: r.eloHard, + clay: r.eloClay, + grass: r.eloGrass, + }; + } + + return { sportsSeason, participants, eloMap }; +} + +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'); + + // Handle participant creation separately + 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 surface Elos from form fields: ranking_{id}, hard_{id}, clay_{id}, grass_{id} + const surfaceEloInputs = participants + .map((p) => { + const ranking = parseIntOrNull(formData.get(`ranking_${p.id}`) as string); + const hard = parseIntOrNull(formData.get(`hard_${p.id}`) as string); + const clay = parseIntOrNull(formData.get(`clay_${p.id}`) as string); + const grass = parseIntOrNull(formData.get(`grass_${p.id}`) as string); + return { participantId: p.id, sportsSeasonId, worldRanking: ranking, eloHard: hard, eloClay: clay, eloGrass: grass }; + }) + .filter((r) => r.worldRanking !== null || r.eloHard !== null || r.eloClay !== null || r.eloGrass !== null); + + if (surfaceEloInputs.length === 0) { + return { success: false, message: 'Please enter at least one Elo rating.' }; + } + + await batchUpsertSurfaceElos(surfaceEloInputs); + + // Auto-run simulation + 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: 'elo_simulation' as const, + })), + ...participants + .filter((p) => !simulatedIds.has(p.id)) + .map((p) => ({ + participantId: p.id, + sportsSeasonId, + probabilities: ZERO_PROBS, + scoringRules: DEFAULT_SCORING_RULES, + source: 'elo_simulation' 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 tennis 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 = Math.round(parseFloat(val.trim())); + return isNaN(n) || n <= 0 ? null : n; +} + +function normalizeName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim(); +} + +// Bigram Dice coefficient for fuzzy name matching +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; +} + +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); +} + +type EloValues = Record; + +interface MatchedItem { + participantId: string; + name: string; + ranking: number; + hard: number; + clay: number; + grass: number; + inputName: string; +} + +interface Suggestion { + participantId: string; + name: string; + score: number; +} + +interface UnmatchedItem { + inputName: string; + ranking: number; + hard: number; + clay: number; + grass: number; + suggestions: Suggestion[]; +} + +interface ParseResults { + matched: MatchedItem[]; + unmatched: UnmatchedItem[]; +} + +type LocalParticipant = { id: string; name: string }; + +export default function AdminSportsSeasonSurfaceElo() { + const { sportsSeason, participants: loaderParticipants, eloMap } = useLoaderData(); + const actionData = useActionData(); + const navigation = useNavigation(); + const createFetcher = useFetcher(); + + const [localParticipants, setLocalParticipants] = useState(loaderParticipants); + + const [eloValues, setEloValues] = useState(() => { + const initial: EloValues = {}; + loaderParticipants.forEach((p) => { + const existing = eloMap[p.id]; + initial[p.id] = { + ranking: existing !== undefined && existing.ranking !== null ? String(existing.ranking) : '', + hard: existing !== undefined && existing.hard !== null ? String(existing.hard) : '', + clay: existing !== undefined && existing.clay !== null ? String(existing.clay) : '', + grass: existing !== undefined && existing.grass !== null ? String(existing.grass) : '', + }; + }); + return initial; + }); + + // Bulk import state: "Player Name, ranking, hardElo, clayElo, grassElo" one per line + const [bulkText, setBulkText] = useState(''); + const [parseResults, setParseResults] = useState(null); + + // Store pending Elo values for participants being created + const pendingElosByName = useRef>(new Map()); + + // When a participant is created, apply their Elos and remove from unmatched + 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; + + // Add to local participants list + setLocalParticipants((prev) => { + if (prev.some((p) => p.id === participant.id)) return prev; + return [...prev, participant]; + }); + + // Apply pending Elo values + const pending = pendingElosByName.current.get(participant.name); + if (pending) { + setEloValues((prev) => ({ + ...prev, + [participant.id]: { + ranking: String(pending.ranking), + hard: String(pending.hard), + clay: String(pending.clay), + grass: String(pending.grass), + }, + })); + pendingElosByName.current.delete(participant.name); + } + + // Remove from unmatched + 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); + } + + 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 match = /^(.+?)[,\t]\s*(\d{1,4})[,\t]\s*(\d{3,5}(?:\.\d+)?)[,\t]\s*(\d{3,5}(?:\.\d+)?)[,\t]\s*(\d{3,5}(?:\.\d+)?)\s*$/.exec(trimmed); + if (!match) continue; + + const inputName = match[1].trim(); + const ranking = parseInt(match[2], 10); + const hard = Math.round(parseFloat(match[3])); + const clay = Math.round(parseFloat(match[4])); + const grass = Math.round(parseFloat(match[5])); + + const participant = findParticipantMatch(inputName, localParticipants); + if (participant && !seen.has(participant.id)) { + seen.add(participant.id); + matched.push({ participantId: participant.id, name: participant.name, ranking, hard, clay, grass, inputName }); + } else { + // No match found, or the matched participant was already claimed by an earlier line + const suggestions = getFuzzySuggestions(inputName, localParticipants, seen); + unmatched.push({ inputName, ranking, hard, clay, grass, suggestions }); + } + } + + 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, + ranking: item.ranking, + hard: item.hard, + clay: item.clay, + grass: item.grass, + inputName: item.inputName, + }, + ], + unmatched: prev.unmatched.filter((u) => u.inputName !== item.inputName), + }; + }); + } + + function handleCreateParticipant(item: UnmatchedItem) { + pendingElosByName.current.set(item.inputName, { + ranking: item.ranking, + hard: item.hard, + clay: item.clay, + grass: item.grass, + }); + 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 = { ...eloValues }; + for (const m of parseResults.matched) { + newValues[m.participantId] = { + ranking: String(m.ranking), + hard: String(m.hard), + clay: String(m.clay), + grass: String(m.grass), + }; + } + setEloValues(newValues); + setParseResults(null); + setBulkText(''); + } + + const setField = (participantId: string, field: 'ranking' | 'hard' | 'clay' | 'grass', value: string) => { + setEloValues((prev) => ({ + ...prev, + [participantId]: { ...prev[participantId], [field]: value }, + })); + }; + + const isSubmitting = navigation.state === 'submitting'; + + return ( +
+
+

Surface Elo Ratings

+

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

+
+ + {/* Bulk Import */} + + + Bulk Import + + Paste ratings one per line. Format:{' '} + Player Name, ranking, hardElo, clayElo, grassElo. Player names are + fuzzy-matched to participants. Rankings are ATP/WTA (1 = world #1). Typical tennis + Elo ranges: 1400–2200. + + + +