Paginate and sport-tailor the simulator setup participant table
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:
parent
32ba731793
commit
b261d013d3
1 changed files with 191 additions and 21 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Form, Link, redirect, useActionData, useNavigation } from "react-router";
|
||||
import type { Route } from "./+types/admin.sports-seasons.$id.simulator";
|
||||
|
||||
|
|
@ -25,6 +26,10 @@ import {
|
|||
type UpsertParticipantSimulatorInput,
|
||||
} from "~/models/simulator";
|
||||
import { normalizeName } from "~/lib/fuzzy-match";
|
||||
import {
|
||||
simulatorInputLabel,
|
||||
type SimulatorInputKey,
|
||||
} from "~/services/simulations/manifest";
|
||||
import {
|
||||
getSimulatorInputPolicy,
|
||||
type MissingEloStrategy,
|
||||
|
|
@ -61,7 +66,22 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
|
||||
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 {
|
||||
|
|
@ -69,6 +89,24 @@ interface ActionData {
|
|||
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
|
||||
* 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")
|
||||
.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 (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
|
|
@ -636,28 +710,124 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
</Button>
|
||||
</Form>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<div className="grid grid-cols-6 gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<div className="col-span-2">Participant</div>
|
||||
<div>Elo</div>
|
||||
<div>Odds</div>
|
||||
<div>Rank</div>
|
||||
<div>Rating</div>
|
||||
</div>
|
||||
{inputRows.slice(0, 20).map(({ participant, input }) => (
|
||||
<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
|
||||
{externalInputsSection && (
|
||||
<div className="rounded-md border border-sky-500/30 bg-sky-500/10 p-3 text-sm flex items-center justify-between gap-4">
|
||||
<span>
|
||||
This simulator's participant inputs are managed on the{" "}
|
||||
<strong>{externalInputsSection.label}</strong> page — the list below is a roster only.
|
||||
</span>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={externalInputsSection.to}>Go to {externalInputsSection.label}</Link>
|
||||
</Button>
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue