2025-10-12 21:54:49 -07:00
|
|
|
import { Form, Link, redirect } from "react-router";
|
|
|
|
|
import type { Route } from "./+types/admin.sports.new";
|
2026-03-10 12:10:52 -07:00
|
|
|
|
2026-03-21 13:41:39 -07:00
|
|
|
import { logger } from "~/lib/logger";
|
2026-05-08 21:19:09 -07:00
|
|
|
import { createSport, findAllSports } from "~/models/sport";
|
|
|
|
|
import { deleteSportIconIfUnused } from "~/lib/sport-icon-cleanup.server";
|
2026-05-07 16:07:34 -07:00
|
|
|
import { parseSportIconUrlInput } from "~/lib/sport-icon-url";
|
2025-10-12 21:54:49 -07:00
|
|
|
import { Button } from "~/components/ui/button";
|
|
|
|
|
import { Input } from "~/components/ui/input";
|
|
|
|
|
import { Label } from "~/components/ui/label";
|
|
|
|
|
import { Textarea } from "~/components/ui/textarea";
|
2026-05-07 16:07:34 -07:00
|
|
|
import { SportIconUploader } from "~/components/ui/SportIconUploader";
|
2025-10-12 21:54:49 -07:00
|
|
|
import {
|
|
|
|
|
Card,
|
|
|
|
|
CardContent,
|
|
|
|
|
CardDescription,
|
|
|
|
|
CardHeader,
|
|
|
|
|
CardTitle,
|
|
|
|
|
} from "~/components/ui/card";
|
Formalize simulator system with manifest, input-policy, runner, and admin UI
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:02:56 -07:00
|
|
|
|
|
|
|
|
const SELECT_CLASS = "h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm";
|
2025-10-12 21:54:49 -07:00
|
|
|
|
2026-03-10 12:10:52 -07:00
|
|
|
export function meta(): Route.MetaDescriptors {
|
|
|
|
|
return [{ title: "New Sport - Brackt Admin" }];
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 21:19:09 -07:00
|
|
|
export async function loader() {
|
|
|
|
|
const sports = await findAllSports();
|
|
|
|
|
return {
|
|
|
|
|
existingIconOptions: sports
|
|
|
|
|
.filter((sport) => Boolean(sport.iconUrl))
|
|
|
|
|
.map((sport) => ({ id: sport.id, name: sport.name, iconUrl: sport.iconUrl as string })),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-12 21:54:49 -07:00
|
|
|
export async function action({ request }: 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");
|
2026-05-07 16:07:34 -07:00
|
|
|
const iconUrlValue = formData.get("iconUrl");
|
2025-10-12 21:54:49 -07:00
|
|
|
|
|
|
|
|
// 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" };
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 16:07:34 -07:00
|
|
|
const iconUrl = parseSportIconUrlInput(iconUrlValue);
|
|
|
|
|
if (iconUrl === undefined) {
|
|
|
|
|
return { error: "Sport icon must be an uploaded SVG icon." };
|
2025-10-13 10:30:47 -07:00
|
|
|
}
|
|
|
|
|
|
2025-10-12 21:54:49 -07:00
|
|
|
try {
|
|
|
|
|
await createSport({
|
|
|
|
|
name: name.trim(),
|
|
|
|
|
type,
|
|
|
|
|
slug: slug.trim(),
|
|
|
|
|
description: typeof description === "string" ? description.trim() : null,
|
2025-10-13 10:30:47 -07:00
|
|
|
iconUrl,
|
2025-10-12 21:54:49 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return redirect("/admin/sports");
|
|
|
|
|
} catch (error) {
|
2026-03-21 13:41:39 -07:00
|
|
|
logger.error("Error creating sport:", error);
|
2026-05-07 16:07:34 -07:00
|
|
|
if (iconUrl) {
|
2026-05-08 21:19:09 -07:00
|
|
|
await deleteSportIconIfUnused({ iconUrl, reason: "unused" });
|
2026-05-07 16:07:34 -07:00
|
|
|
}
|
2025-10-12 21:54:49 -07:00
|
|
|
return { error: "Failed to create sport. The slug might already exist." };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 21:19:09 -07:00
|
|
|
export default function NewSport({ loaderData, actionData }: Route.ComponentProps) {
|
2025-10-12 21:54:49 -07:00
|
|
|
return (
|
|
|
|
|
<div className="p-8">
|
|
|
|
|
<div className="max-w-2xl">
|
|
|
|
|
<div className="mb-6">
|
|
|
|
|
<h1 className="text-3xl font-bold">Create New Sport</h1>
|
|
|
|
|
<p className="text-muted-foreground mt-1">
|
|
|
|
|
Add a new sport to the system
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle>Sport Details</CardTitle>
|
|
|
|
|
<CardDescription>
|
|
|
|
|
Enter the information for the new 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"
|
|
|
|
|
required
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="type">Type</Label>
|
Formalize simulator system with manifest, input-policy, runner, and admin UI
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:02:56 -07:00
|
|
|
<select id="type" name="type" required defaultValue="" className={SELECT_CLASS}>
|
|
|
|
|
<option value="" disabled>Select sport type</option>
|
|
|
|
|
<option value="team">Team Sport</option>
|
|
|
|
|
<option value="individual">Individual Sport</option>
|
|
|
|
|
</select>
|
2025-10-12 21:54:49 -07:00
|
|
|
<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-]+"
|
|
|
|
|
required
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
URL-friendly identifier (lowercase, hyphens only)
|
|
|
|
|
</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}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-10-13 10:30:47 -07:00
|
|
|
<div className="space-y-2">
|
2026-05-07 16:07:34 -07:00
|
|
|
<Label>Sport Icon (Optional)</Label>
|
2026-05-08 21:19:09 -07:00
|
|
|
<SportIconUploader
|
|
|
|
|
uploadUrl="/api/upload-sport-icon"
|
|
|
|
|
sportName="New sport"
|
|
|
|
|
existingIconOptions={loaderData.existingIconOptions}
|
|
|
|
|
/>
|
2025-10-13 10:30:47 -07:00
|
|
|
</div>
|
|
|
|
|
|
2025-10-12 21:54:49 -07:00
|
|
|
{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">
|
|
|
|
|
Create Sport
|
|
|
|
|
</Button>
|
|
|
|
|
<Button type="button" variant="outline" asChild>
|
|
|
|
|
<Link to="/admin/sports">Cancel</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</Form>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|