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 { 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,29 +710,125 @@ 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>
|
||||||
|
<Link to={externalInputsSection.to}>Go to {externalInputsSection.label}</Link>
|
||||||
|
</Button>
|
||||||
</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 className="flex flex-wrap items-center gap-3">
|
||||||
<div>{input?.sourceElo ?? "—"}</div>
|
<Input
|
||||||
<div>{input?.sourceOdds ?? "—"}</div>
|
type="search"
|
||||||
<div>{input?.worldRanking ?? "—"}</div>
|
placeholder="Search participants…"
|
||||||
<div>{input?.rating ?? "—"}</div>
|
value={search}
|
||||||
</div>
|
onChange={(event) => {
|
||||||
))}
|
setSearch(event.target.value);
|
||||||
{inputRows.length > 20 && (
|
setPage(0);
|
||||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
}}
|
||||||
Showing 20 of {inputRows.length} participants
|
className="max-w-xs"
|
||||||
</div>
|
/>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue