From 036411e9d8a0570c6830774f9aa3b08a719913b0 Mon Sep 17 00:00:00 2001 From: chrisp Date: Tue, 7 Jul 2026 06:00:15 +0000 Subject: [PATCH] Paginate and sport-tailor the simulator setup participant table (#132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What The participant preview on the simulator setup page (`admin/sports-seasons/:id/simulator`) was capped at the first 20 rows by a hard-coded `.slice(0, 20)`, so most of the field was invisible for seasons with up to ~300 participants (golf, tennis). All rows were already loaded — this was purely a UI limit. ## Changes - **Search + pagination** — name search box (accent-insensitive via `normalizeName`) plus Prev/Next paging at 50/page, over the already-loaded rows. Page resets on filter change. - **Sport-aware columns** — columns are derived from each simulator's `requiredInputs` + `optionalInputs` (from the manifest), labeled, with required columns marked. So F1 shows odds, NBA shows Elo, NCAA shows rating, etc. - **Missing-input filter** — "Only show participants missing a required input" toggle (shown only when the simulator has required inputs) plus a per-row amber marker so gaps are visible at a glance. - **Tennis/golf link-out** — for sports whose inputs live on a dedicated page (surface Elo, golf skills), a note links there instead of implying they're edited on this page. ## Notes Column resolution is the only manifest **runtime** call, and it runs in the **loader** — this keeps the simulator manifest/registry out of the client bundle. Importing it into the client component pulled in every simulator and their transitive server-only modules (`scoring-calculator.ts → qualifying-points-discord.server`, `cs2-major-stage.ts`), which broke the Champions League setup page with a "server-only module referenced by client" error. ## Verification `npm run typecheck`, `oxlint`, and `npm run build` (client + server) all pass with no server-only-leak error. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/132 --- .claude/settings.json | 15 +- .../admin.sports-seasons.$id.simulator.tsx | 212 ++++++++++++++++-- 2 files changed, 197 insertions(+), 30 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 84465da..8201cbd 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -9,17 +9,14 @@ "command": ".claude/hooks/lint-on-edit.sh", "timeout": 30, "statusMessage": "Linting..." - } - ] - } - ], - "Stop": [ - { - "hooks": [ + }, { "type": "command", - "command": "output=$(npm run typecheck 2>&1); rc=$?; if [ $rc -ne 0 ]; then printf '{\"systemMessage\":\"TypeCheck failed:\\n%s\"}' \"$(echo \"$output\" | tail -30 | sed 's/\"/\\\\\"/g; s/$/\\\\n/' | tr -d '\\n')\"; fi", - "timeout": 60 + "if": "Write(*.ts)|Write(*.tsx)|Edit(*.ts)|Edit(*.tsx)|MultiEdit(*.ts)|MultiEdit(*.tsx)", + "command": "output=$(npm run typecheck 2>&1); rc=$?; if [ $rc -ne 0 ]; then printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"TypeCheck failed:\\n%s\"}}' \"$(echo \"$output\" | tail -30 | sed 's/\"/\\\\\"/g; s/$/\\\\n/' | tr -d '\\n')\"; fi", + "timeout": 60, + "statusMessage": "Type-checking...", + "async": true } ] } diff --git a/app/routes/admin.sports-seasons.$id.simulator.tsx b/app/routes/admin.sports-seasons.$id.simulator.tsx index e6d2f87..b4e1f7d 100644 --- a/app/routes/admin.sports-seasons.$id.simulator.tsx +++ b/app/routes/admin.sports-seasons.$id.simulator.tsx @@ -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([ + ...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 (
@@ -636,29 +710,125 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone -
-
-
Participant
-
Elo
-
Odds
-
Rank
-
Rating
+ {externalInputsSection && ( +
+ + This simulator's participant inputs are managed on the{" "} + {externalInputsSection.label} page — the list below is a roster only. + +
- {inputRows.slice(0, 20).map(({ participant, input }) => ( -
-
{participant.name}
-
{input?.sourceElo ?? "—"}
-
{input?.sourceOdds ?? "—"}
-
{input?.worldRanking ?? "—"}
-
{input?.rating ?? "—"}
-
- ))} - {inputRows.length > 20 && ( -
- Showing 20 of {inputRows.length} participants -
+ )} + +
+ { + setSearch(event.target.value); + setPage(0); + }} + className="max-w-xs" + /> + {requiredInputs.length > 0 && ( + )}
+ +
+
+
Participant
+ {inputColumns.length > 0 ? ( + inputColumns.map((column) => ( +
+ {column.label} + {column.required && *} +
+ )) + ) : ( +
+ )} +
+ {pageRows.length === 0 ? ( +
+ No participants match. +
+ ) : ( + pageRows.map(({ participant, input }) => { + const incomplete = isRowIncomplete(input); + return ( +
+
+ {incomplete && } + {participant.name} +
+ {inputColumns.length > 0 ? ( + inputColumns.map((column) => { + const value = input?.[column.key]; + return
{typeof value === "number" || typeof value === "string" ? value : "—"}
; + }) + ) : ( +
+ )} +
+ ); + }) + )} +
+ + {filteredRows.length === 0 + ? "0 participants" + : `Showing ${pageStart + 1}–${pageStart + pageRows.length} of ${filteredRows.length}${ + filteredRows.length !== inputRows.length ? ` (${inputRows.length} total)` : "" + }`} + + {totalPages > 1 && ( +
+ + + Page {safePage + 1} of {totalPages} + + +
+ )} +
+