brackt/app/routes/admin.sports.$id.tsx
Chris Parsons e62e9554c9
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 <noreply@anthropic.com>

* Fix oxlint errors: no-non-null-assertion and eqeqeq

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:46:02 -07:00

288 lines
10 KiB
TypeScript

import { Form, Link, redirect } from "react-router";
import type { Route } from "./+types/admin.sports.$id";
import { logger } from "~/lib/logger";
import { findSportById, updateSport } from "~/models/sport";
import { writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { SIMULATOR_TYPES, getSimulatorInfo } from "~/services/simulations/registry";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `${data?.sport?.name ?? "Sport"} - Brackt Admin` }];
}
export async function loader({ params }: Route.LoaderArgs) {
const sport = await findSportById(params.id);
if (!sport) {
throw new Response("Sport not found", { status: 404 });
}
return { sport };
}
export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData();
const name = formData.get("name");
const type = formData.get("type");
const slug = formData.get("slug");
const description = formData.get("description");
const simulatorType = formData.get("simulatorType");
const iconFile = formData.get("iconFile");
const keepExistingIcon = formData.get("keepExistingIcon");
// Validation
if (typeof name !== "string" || !name.trim()) {
return { error: "Sport name is required" };
}
if (type !== "team" && type !== "individual") {
return { error: "Sport type must be either 'team' or 'individual'" };
}
if (typeof slug !== "string" || !slug.trim()) {
return { error: "Slug is required" };
}
// Validate slug format (lowercase, hyphens only)
if (!/^[a-z0-9-]+$/.test(slug)) {
return { error: "Slug must contain only lowercase letters, numbers, and hyphens" };
}
// Get existing sport to preserve icon if needed
const existingSport = await findSportById(params.id);
let iconUrl: string | null | undefined = existingSport?.iconUrl;
// Handle file upload
if (iconFile instanceof File && iconFile.size > 0) {
const fileExtension = iconFile.name.split(".").pop()?.toLowerCase();
// Validate file type
if (!fileExtension || !["svg", "png", "jpg", "jpeg"].includes(fileExtension)) {
return { error: "Icon must be an SVG, PNG, or JPG file" };
}
// Create filename using slug
const filename = `${slug.trim()}.${fileExtension}`;
iconUrl = filename;
try {
// Ensure directory exists
const iconsDir = join(process.cwd(), "public", "sports-icons");
await mkdir(iconsDir, { recursive: true });
// Save file
const filepath = join(iconsDir, filename);
const bytes = await iconFile.arrayBuffer();
const buffer = Buffer.from(bytes);
await writeFile(filepath, buffer);
} catch (error) {
logger.error("Error saving icon file:", error);
return { error: "Failed to save icon file" };
}
} else if (keepExistingIcon !== "true") {
// If no file uploaded and not keeping existing, set to null
iconUrl = null;
}
type ValidSimulatorType = typeof SIMULATOR_TYPES[number];
const parsedSimulatorType: ValidSimulatorType | null =
typeof simulatorType === "string" && (SIMULATOR_TYPES as readonly string[]).includes(simulatorType)
? (simulatorType as ValidSimulatorType)
: null;
try {
await updateSport(params.id, {
name: name.trim(),
type,
slug: slug.trim(),
description: typeof description === "string" ? description.trim() : null,
iconUrl,
simulatorType: parsedSimulatorType,
});
return redirect("/admin/sports");
} catch (error) {
logger.error("Error updating sport:", error);
const message = error instanceof Error ? error.message : String(error);
const isUniqueViolation = message.includes("unique") || message.includes("duplicate");
return {
error: isUniqueViolation
? "A sport with that slug already exists. Please choose a different slug."
: `Failed to update sport: ${message}`,
};
}
}
export default function EditSport({ loaderData, actionData }: Route.ComponentProps) {
const { sport } = loaderData;
return (
<div className="p-8">
<div className="max-w-2xl">
<div className="mb-6">
<h1 className="text-3xl font-bold">Edit Sport</h1>
<p className="text-muted-foreground mt-1">
Update the sport information
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Sport Details</CardTitle>
<CardDescription>
Modify the information for this sport
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name">Sport Name</Label>
<Input
id="name"
name="name"
type="text"
placeholder="e.g., NFL, NBA, Golf - Men's"
defaultValue={sport.name}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="type">Type</Label>
<Select name="type" defaultValue={sport.type} required>
<SelectTrigger id="type">
<SelectValue placeholder="Select sport type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="team">Team Sport</SelectItem>
<SelectItem value="individual">Individual Sport</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Team sports have teams, individual sports have players
</p>
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug</Label>
<Input
id="slug"
name="slug"
type="text"
placeholder="e.g., nfl, nba, golf-mens"
pattern="[a-z0-9-]+"
defaultValue={sport.slug}
required
/>
<p className="text-sm text-muted-foreground">
URL-friendly identifier (lowercase, hyphens only)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="simulatorType">Simulator Type (Optional)</Label>
<Select name="simulatorType" defaultValue={sport.simulatorType ?? "none"}>
<SelectTrigger id="simulatorType">
<SelectValue placeholder="No simulator" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No simulator</SelectItem>
{[...SIMULATOR_TYPES]
.toSorted((a, b) => {
const nameA = getSimulatorInfo(a)?.name ?? a;
const nameB = getSimulatorInfo(b)?.name ?? b;
return nameA.localeCompare(nameB);
})
.map((type) => (
<SelectItem key={type} value={type}>
{getSimulatorInfo(type)?.name ?? type}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Algorithm used when running EV simulations for this sport
</p>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description (Optional)</Label>
<Textarea
id="description"
name="description"
placeholder="Brief description of the sport"
rows={3}
defaultValue={sport.description || ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor="iconFile">Icon File (Optional)</Label>
{sport.iconUrl && (
<div className="mb-2 p-3 bg-muted rounded-md">
<p className="text-sm font-medium mb-1">Current icon:</p>
<div className="flex items-center gap-3">
<img
src={`/sports-icons/${sport.iconUrl}`}
alt={sport.name}
className="w-12 h-12 object-contain"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
<code className="text-xs bg-background px-2 py-1 rounded">{sport.iconUrl}</code>
</div>
</div>
)}
<Input
id="iconFile"
name="iconFile"
type="file"
accept=".svg,.png,.jpg,.jpeg"
/>
<input type="hidden" name="keepExistingIcon" value={sport.iconUrl ? "true" : "false"} />
<p className="text-sm text-muted-foreground">
Upload a new SVG, PNG, or JPG file to replace the current icon. Leave empty to keep the existing icon.
</p>
</div>
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
<div className="flex gap-4">
<Button type="submit" className="flex-1">
Update Sport
</Button>
<Button type="button" variant="outline" asChild>
<Link to="/admin/sports">Cancel</Link>
</Button>
</div>
</Form>
</CardContent>
</Card>
</div>
</div>
);
}