Paginate and sport-tailor the simulator setup participant table
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m51s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m23s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped

The participant preview capped at 20 rows via a hard-coded slice, hiding
most of the field for seasons with up to ~300 participants. Add a name
search box and client-side pagination (50/page) over the already-loaded
rows.

Make the columns sport-aware: derive them from each simulator's required
and optional inputs (manifest), label them, mark required ones, and add a
"missing a required input" filter plus a per-row amber marker. For sports
whose inputs live on a dedicated page (tennis surface Elo, golf skills),
show a note linking there instead of implying they are edited here.

Column resolution (the only manifest runtime call) happens in the loader
so the client bundle never imports the simulator manifest/registry, which
transitively pulls in server-only modules.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-07-06 22:53:31 -07:00
parent 32ba731793
commit b261d013d3

View file

@ -1,3 +1,4 @@
import { useMemo, useState } from "react";
import { Form, Link, redirect, useActionData, useNavigation } from "react-router"; import { Form, Link, redirect, useActionData, useNavigation } from "react-router";
import type { Route } from "./+types/admin.sports-seasons.$id.simulator"; import type { Route } from "./+types/admin.sports-seasons.$id.simulator";
@ -25,6 +26,10 @@ import {
type UpsertParticipantSimulatorInput, type UpsertParticipantSimulatorInput,
} from "~/models/simulator"; } from "~/models/simulator";
import { normalizeName } from "~/lib/fuzzy-match"; import { normalizeName } from "~/lib/fuzzy-match";
import {
simulatorInputLabel,
type SimulatorInputKey,
} from "~/services/simulations/manifest";
import { import {
getSimulatorInputPolicy, getSimulatorInputPolicy,
type MissingEloStrategy, type MissingEloStrategy,
@ -61,7 +66,22 @@ export async function loader({ params }: Route.LoaderArgs) {
const inputPolicy = getSimulatorInputPolicy(config.config); const inputPolicy = getSimulatorInputPolicy(config.config);
return { sportsSeason, participants, config, inputRows, readiness, inputPolicy }; // Sport-aware preview columns: the intersection of the displayable numeric keys
// with this simulator's required + optional inputs, so each season shows exactly
// the inputs its simulator consumes (F1 = odds, NBA = Elo, NCAA = rating, ...).
// Resolved here (server-only) so the client bundle never imports the simulator
// manifest/registry, which transitively pulls in `.server` modules.
const relevantInputs = new Set<SimulatorInputKey>([
...config.profile.requiredInputs,
...config.profile.optionalInputs,
]);
const inputColumns = DISPLAY_INPUT_ORDER.filter((key) => relevantInputs.has(key)).map((key) => ({
key,
label: simulatorInputLabel(key),
required: config.profile.requiredInputs.includes(key),
}));
return { sportsSeason, participants, config, inputRows, readiness, inputPolicy, inputColumns };
} }
interface ActionData { interface ActionData {
@ -69,6 +89,24 @@ interface ActionData {
message: string; message: string;
} }
/**
* Input keys the participant preview can render as a numeric column, in the order
* they appear. Keys not listed (e.g. `region`, `metadata`) are not shown as
* columns; the visible columns for a season are the intersection of this order
* with the simulator's required + optional inputs.
*/
const DISPLAY_INPUT_ORDER: SimulatorInputKey[] = [
"sourceElo",
"sourceOdds",
"worldRanking",
"rating",
"projectedWins",
"projectedTablePoints",
"seed",
];
const PARTICIPANT_PAGE_SIZE = 50;
/** /**
* Engine knobs that a simulator (or the shared input-policy resolver) actually * Engine knobs that a simulator (or the shared input-policy resolver) actually
* reads from config. The structured Engine fields are limited to these so the UI * reads from config. The structured Engine fields are limited to these so the UI
@ -357,6 +395,42 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
.filter(([key, value]) => HONORED_ENGINE_KNOBS.has(key) && typeof value === "number") .filter(([key, value]) => HONORED_ENGINE_KNOBS.has(key) && typeof value === "number")
.map(([key, value]) => [key, value as unknown as number] as [string, number]); .map(([key, value]) => [key, value as unknown as number] as [string, number]);
// Preview columns are resolved server-side in the loader (see note there) and
// arrive as plain data, so this client component never imports the manifest.
const { inputColumns } = loaderData;
const requiredInputs = config.profile.requiredInputs;
const gridTemplate = `2fr repeat(${Math.max(inputColumns.length, 1)}, 1fr)`;
// For this sport the inputs live on a dedicated page, not the shared bulk paste.
const externalInputsSection = requiredInputs.length === 0
? (setupSections.includes("surfaceElo")
? { label: "Surface Elo", to: `/admin/sports-seasons/${sportsSeason.id}/surface-elo` }
: setupSections.includes("golfSkills")
? { label: "Golf Skills", to: `/admin/sports-seasons/${sportsSeason.id}/golf-skills` }
: null)
: null;
const isRowIncomplete = (input: (typeof inputRows)[number]["input"]) =>
requiredInputs.some((key) => input?.[key] === null || input?.[key] === undefined);
const [search, setSearch] = useState("");
const [onlyMissing, setOnlyMissing] = useState(false);
const [page, setPage] = useState(0);
const filteredRows = useMemo(() => {
const normalizedSearch = normalizeName(search);
return inputRows.filter(({ participant, input }) => {
if (normalizedSearch && !normalizeName(participant.name).includes(normalizedSearch)) return false;
if (onlyMissing && !isRowIncomplete(input)) return false;
return true;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputRows, search, onlyMissing, requiredInputs]);
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PARTICIPANT_PAGE_SIZE));
const safePage = Math.min(page, totalPages - 1);
const pageStart = safePage * PARTICIPANT_PAGE_SIZE;
const pageRows = filteredRows.slice(pageStart, pageStart + PARTICIPANT_PAGE_SIZE);
return ( return (
<div className="container mx-auto p-6 space-y-6"> <div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -636,28 +710,124 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
</Button> </Button>
</Form> </Form>
<div className="rounded-md border"> {externalInputsSection && (
<div className="grid grid-cols-6 gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground"> <div className="rounded-md border border-sky-500/30 bg-sky-500/10 p-3 text-sm flex items-center justify-between gap-4">
<div className="col-span-2">Participant</div> <span>
<div>Elo</div> This simulator's participant inputs are managed on the{" "}
<div>Odds</div> <strong>{externalInputsSection.label}</strong> page the list below is a roster only.
<div>Rank</div> </span>
<div>Rating</div> <Button variant="outline" size="sm" asChild>
</div> <Link to={externalInputsSection.to}>Go to {externalInputsSection.label}</Link>
{inputRows.slice(0, 20).map(({ participant, input }) => ( </Button>
<div key={participant.id} className="grid grid-cols-6 gap-2 border-b last:border-b-0 px-3 py-2 text-sm">
<div className="col-span-2 font-medium">{participant.name}</div>
<div>{input?.sourceElo ?? "—"}</div>
<div>{input?.sourceOdds ?? "—"}</div>
<div>{input?.worldRanking ?? "—"}</div>
<div>{input?.rating ?? "—"}</div>
</div>
))}
{inputRows.length > 20 && (
<div className="px-3 py-2 text-xs text-muted-foreground">
Showing 20 of {inputRows.length} participants
</div> </div>
)} )}
<div className="flex flex-wrap items-center gap-3">
<Input
type="search"
placeholder="Search participants…"
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(0);
}}
className="max-w-xs"
/>
{requiredInputs.length > 0 && (
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={onlyMissing}
onChange={(event) => {
setOnlyMissing(event.target.checked);
setPage(0);
}}
/>
Only show participants missing a required input
</label>
)}
</div>
<div className="rounded-md border">
<div
className="grid gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground"
style={{ gridTemplateColumns: gridTemplate }}
>
<div>Participant</div>
{inputColumns.length > 0 ? (
inputColumns.map((column) => (
<div key={column.key} className="capitalize">
{column.label}
{column.required && <span className="text-amber-500"> *</span>}
</div>
))
) : (
<div></div>
)}
</div>
{pageRows.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
No participants match.
</div>
) : (
pageRows.map(({ participant, input }) => {
const incomplete = isRowIncomplete(input);
return (
<div
key={participant.id}
className="grid gap-2 border-b last:border-b-0 px-3 py-2 text-sm"
style={{ gridTemplateColumns: gridTemplate }}
>
<div className="font-medium flex items-center gap-2">
{incomplete && <span className="h-2 w-2 shrink-0 rounded-full bg-amber-500" title="Missing a required input" />}
{participant.name}
</div>
{inputColumns.length > 0 ? (
inputColumns.map((column) => {
const value = input?.[column.key];
return <div key={column.key}>{typeof value === "number" || typeof value === "string" ? value : "—"}</div>;
})
) : (
<div></div>
)}
</div>
);
})
)}
<div className="flex items-center justify-between gap-4 px-3 py-2 text-xs text-muted-foreground">
<span>
{filteredRows.length === 0
? "0 participants"
: `Showing ${pageStart + 1}${pageStart + pageRows.length} of ${filteredRows.length}${
filteredRows.length !== inputRows.length ? ` (${inputRows.length} total)` : ""
}`}
</span>
{totalPages > 1 && (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={safePage === 0}
onClick={() => setPage(safePage - 1)}
>
Previous
</Button>
<span>
Page {safePage + 1} of {totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={safePage >= totalPages - 1}
onClick={() => setPage(safePage + 1)}
>
Next
</Button>
</div>
)}
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>