brackt/app/routes/admin.sports.$id.tsx
Chris Parsons e5295812f6
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.

Key additions:
- manifest.ts: per-simulator display names, default configs, required/
  optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
  projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
  supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
  derived inputs, normalises result columns, zeroes omitted participants,
  snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
  summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
  falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
  only copied when explicitly requested

Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
  participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
  readiness failure, empty results, and error recovery with status reset

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00

246 lines
8.6 KiB
TypeScript

import { Form, Link, redirect } from "react-router";
import type { Route } from "./+types/admin.sports.$id";
import { logger } from "~/lib/logger";
import { findAllSports, findSportById, updateSport } from "~/models/sport";
import { deleteSportIconIfUnused } from "~/lib/sport-icon-cleanup.server";
import { parseSportIconUrlInput } from "~/lib/sport-icon-url";
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 { SportIconUploader } from "~/components/ui/SportIconUploader";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { SIMULATOR_TYPES, getSimulatorInfo } from "~/services/simulations/registry";
const SELECT_CLASS = "h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm";
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 });
}
const sports = await findAllSports();
return {
sport,
existingIconOptions: sports
.filter((option) => option.id !== sport.id && Boolean(option.iconUrl))
.map((option) => ({ id: option.id, name: option.name, iconUrl: option.iconUrl as string })),
};
}
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 iconUrlValue = formData.get("iconUrl");
// 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" };
}
const existingSport = await findSportById(params.id);
const iconUrl = parseSportIconUrlInput(iconUrlValue);
if (iconUrl === undefined) {
return { error: "Sport icon must be an uploaded SVG icon." };
}
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 {
const updatedSport = await updateSport(params.id, {
name: name.trim(),
type,
slug: slug.trim(),
description: typeof description === "string" ? description.trim() : null,
iconUrl,
simulatorType: parsedSimulatorType,
});
if (existingSport?.iconUrl && existingSport.iconUrl !== updatedSport.iconUrl) {
await deleteSportIconIfUnused({
iconUrl: existingSport.iconUrl,
excludeSportId: params.id,
reason: "replaced",
});
}
return redirect("/admin/sports");
} catch (error) {
logger.error("Error updating sport:", error);
if (iconUrl && iconUrl !== existingSport?.iconUrl) {
await deleteSportIconIfUnused({ iconUrl, excludeSportId: params.id, reason: "unused" });
}
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, existingIconOptions } = 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 id="type" name="type" defaultValue={sport.type} required className={SELECT_CLASS}>
<option value="team">Team Sport</option>
<option value="individual">Individual Sport</option>
</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
id="simulatorType"
name="simulatorType"
defaultValue={sport.simulatorType ?? "none"}
className={SELECT_CLASS}
>
<option value="none">No simulator</option>
{[...SIMULATOR_TYPES]
.toSorted((a, b) => {
const nameA = getSimulatorInfo(a)?.name ?? a;
const nameB = getSimulatorInfo(b)?.name ?? b;
return nameA.localeCompare(nameB);
})
.map((type) => (
<option key={type} value={type}>
{getSimulatorInfo(type)?.name ?? type}
</option>
))}
</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>Sport Icon (Optional)</Label>
<SportIconUploader
uploadUrl="/api/upload-sport-icon"
initialIconUrl={sport.iconUrl}
sportName={sport.name}
existingIconOptions={existingIconOptions}
/>
</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>
);
}