brackt/app/routes/admin.sports-seasons.$id.simulator.tsx
Claude dc4efe87cf
Unify futures odds with the simulation system
Make the futures-vs-Elo relationship an explicit, configurable rule and fix
futures odds failing to override stored Elo.

Root causes addressed:
- resolveSourceElos/resolveRatings used a hardcoded precedence (direct Elo ->
  projectedWins -> projectedTablePoints -> odds). The prior fix nulled sourceElo
  on futures entry but not projections, which still outranked odds.
- The override relied on a destructive null-on-save hack that also wiped
  manually entered Elo.
- Blended simulators buried their Elo/odds weight in module constants.
- Futures odds were not surfaced on the /admin/simulators inventory.

Changes:
- Add a configurable source policy: sourceEloPriority (ordered) and oddsWeight,
  parsed/clamped in getSimulatorInputPolicy. resolveSourceElos/resolveRatings now
  resolve each participant by the configured priority instead of a fixed order.
  Futures-centric simulators (ncaam, ncaaw, world_cup, ncaa_football,
  college_hockey) default to a futures-override priority.
- Drop the destructive nulling in batchSaveFuturesOddsForSimulator and
  batchSaveSourceOdds; override is now governed by policy, preserving stored Elo.
- Thread an optional SimulationContext (oddsWeight) through the Simulator
  interface so blended sims (UCL, World Cup, NCAA FB, MLB) read the blend weight
  from the season policy; defaults preserve prior calibration when no context is
  passed.
- Add a "Futures vs. Elo" strategy control and Odds Blend Weight input to the
  Simulator Setup input-policy card, persisted via save-input-policy.
- Surface futures on /admin/simulators: a source badge and a Futures Odds quick
  link; extend listSportsSeasonSimulatorSummaries with odds source info.
- Tests: configurable priority override (Elo/projections/rating), oddsWeight
  parsing/clamping, prefersFuturesOdds, manifest defaults, an NCAA Football
  context-blend behavioral test, and an updated non-destructive save test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT
2026-06-25 22:11:32 +00:00

552 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Form, Link, redirect, useActionData, useNavigation } from "react-router";
import type { Route } from "./+types/admin.sports-seasons.$id.simulator";
import { AlertCircle, ArrowLeft, CheckCircle2, Loader2, Play, Save, SlidersHorizontal } from "lucide-react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Textarea } from "~/components/ui/textarea";
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
import { findSportsSeasonById } from "~/models/sports-season";
import {
batchUpsertParticipantSimulatorInputs,
getParticipantSimulatorInputs,
getSportsSeasonSimulatorConfig,
upsertSportsSeasonSimulatorConfig,
validateSimulatorReadiness,
type UpsertParticipantSimulatorInput,
} from "~/models/simulator";
import { normalizeName } from "~/lib/fuzzy-match";
import {
getSimulatorInputPolicy,
prefersFuturesOdds,
ELO_FIRST_SOURCE_PRIORITY,
FUTURES_FIRST_SOURCE_PRIORITY,
type MissingEloStrategy,
type MissingRatingStrategy,
} from "~/services/simulations/input-policy";
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Simulator Setup - ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
}
export async function loader({ params }: Route.LoaderArgs) {
const sportsSeason = await findSportsSeasonById(params.id);
if (!sportsSeason) {
throw new Response("Sports season not found", { status: 404 });
}
const [participants, config, inputs, readiness] = await Promise.all([
findParticipantsBySportsSeasonId(params.id),
getSportsSeasonSimulatorConfig(params.id),
getParticipantSimulatorInputs(params.id),
validateSimulatorReadiness(params.id),
]);
if (!config) {
throw new Response("This sports season does not have a simulator configured.", { status: 404 });
}
const inputMap = new Map(inputs.map((input) => [input.participantId, input]));
const inputRows = participants.map((participant) => ({
participant,
input: inputMap.get(participant.id) ?? null,
}));
const inputPolicy = getSimulatorInputPolicy(config.config);
return { sportsSeason, participants, config, inputRows, readiness, inputPolicy };
}
interface ActionData {
success?: boolean;
message: string;
}
function parseOptionalNumber(value: string | undefined): number | null {
if (value === undefined || value.trim() === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parsePolicyNumber(formData: FormData, key: string, fallback: number): number {
const value = formData.get(key);
if (typeof value !== "string" || value.trim() === "") return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function parseMissingEloStrategy(value: FormDataEntryValue | null): MissingEloStrategy {
return value === "fallbackElo" || value === "averageKnown" || value === "worstKnownMinus"
? value
: "block";
}
function parseMissingRatingStrategy(value: FormDataEntryValue | null): MissingRatingStrategy {
return value === "fallbackRating" || value === "averageKnown" || value === "worstKnownMinus"
? value
: "block";
}
function findParticipantId(name: string, participants: Array<{ id: string; name: string }>): string | null {
const normalizedInput = normalizeName(name);
const normalized = participants.map((participant) => ({
participant,
normalized: normalizeName(participant.name),
}));
return (
normalized.find((candidate) => candidate.normalized === normalizedInput)?.participant.id ??
normalized.find((candidate) =>
candidate.normalized.includes(normalizedInput) || normalizedInput.includes(candidate.normalized)
)?.participant.id ??
null
);
}
function parseInputCsv(
text: string,
sportsSeasonId: string,
participants: Array<{ id: string; name: string }>
): { inputs: UpsertParticipantSimulatorInput[]; unmatched: string[] } {
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
if (lines.length === 0) return { inputs: [], unmatched: [] };
const header = lines[0].split(",").map((value) => value.trim());
const indexes = new Map(header.map((value, index) => [value, index]));
const nameIndex = indexes.get("name");
if (nameIndex === undefined) {
throw new Error("Bulk input CSV must include a `name` column.");
}
const inputs: UpsertParticipantSimulatorInput[] = [];
const unmatched: string[] = [];
for (const line of lines.slice(1)) {
const cols = line.split(",").map((value) => value.trim());
const name = cols[nameIndex];
if (!name) continue;
const participantId = findParticipantId(name, participants);
if (!participantId) {
unmatched.push(name);
continue;
}
inputs.push({
participantId,
sportsSeasonId,
sourceElo: parseOptionalNumber(cols[indexes.get("sourceElo") ?? -1]) ?? undefined,
sourceOdds: parseOptionalNumber(cols[indexes.get("sourceOdds") ?? -1]) ?? undefined,
worldRanking: parseOptionalNumber(cols[indexes.get("worldRanking") ?? -1]) ?? undefined,
rating: parseOptionalNumber(cols[indexes.get("rating") ?? -1]) ?? undefined,
projectedWins: parseOptionalNumber(cols[indexes.get("projectedWins") ?? -1]) ?? undefined,
projectedTablePoints: parseOptionalNumber(cols[indexes.get("projectedTablePoints") ?? -1]) ?? undefined,
seed: parseOptionalNumber(cols[indexes.get("seed") ?? -1]) ?? undefined,
region: cols[indexes.get("region") ?? -1] || undefined,
});
}
return { inputs, unmatched };
}
export async function action({ request, params }: Route.ActionArgs): Promise<ActionData | Response> {
const formData = await request.formData();
const intent = formData.get("intent");
const sportsSeasonId = params.id;
if (intent === "run") {
try {
await runSportsSeasonSimulation(sportsSeasonId);
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Simulation failed." };
}
}
if (intent === "save-config") {
const rawConfig = formData.get("config");
const currentConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
if (!currentConfig) return { success: false, message: "Simulator config not found." };
try {
const parsed = typeof rawConfig === "string" && rawConfig.trim()
? JSON.parse(rawConfig) as Record<string, unknown>
: {};
// Preserve an existing inputPolicy if the submitted JSON omits it, so
// editing other config keys doesn't silently wipe a previously configured
// missing-Elo strategy.
const config = "inputPolicy" in parsed
? parsed
: { ...parsed, ...(currentConfig.config.inputPolicy !== undefined ? { inputPolicy: currentConfig.config.inputPolicy } : {}) };
await upsertSportsSeasonSimulatorConfig({
sportsSeasonId,
simulatorType: currentConfig.simulatorType,
config,
});
return { success: true, message: "Simulator config saved." };
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Invalid config JSON." };
}
}
if (intent === "save-input-policy") {
const currentConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
if (!currentConfig) return { success: false, message: "Simulator config not found." };
const currentPolicy = getSimulatorInputPolicy(currentConfig.config);
await upsertSportsSeasonSimulatorConfig({
sportsSeasonId,
simulatorType: currentConfig.simulatorType,
config: {
...currentConfig.config,
inputPolicy: {
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
sourceEloPriority:
formData.get("sourceStrategy") === "futuresFirst"
? FUTURES_FIRST_SOURCE_PRIORITY
: ELO_FIRST_SOURCE_PRIORITY,
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
fallbackEloDelta: parsePolicyNumber(formData, "fallbackEloDelta", currentPolicy.fallbackEloDelta),
eloMin: parsePolicyNumber(formData, "eloMin", currentPolicy.eloMin),
eloMax: parsePolicyNumber(formData, "eloMax", currentPolicy.eloMax),
fallbackRating: parsePolicyNumber(formData, "fallbackRating", currentPolicy.fallbackRating),
fallbackRatingDelta: parsePolicyNumber(formData, "fallbackRatingDelta", currentPolicy.fallbackRatingDelta),
ratingMin: parsePolicyNumber(formData, "ratingMin", currentPolicy.ratingMin),
ratingMax: parsePolicyNumber(formData, "ratingMax", currentPolicy.ratingMax),
},
},
});
return { success: true, message: "Simulator input policy saved." };
}
if (intent === "save-inputs") {
const rawInputs = formData.get("bulkInputs");
if (typeof rawInputs !== "string" || rawInputs.trim() === "") {
return { success: false, message: "Paste at least one input row before saving." };
}
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
try {
const parsed = parseInputCsv(rawInputs, sportsSeasonId, participants);
if (parsed.inputs.length === 0) {
return { success: false, message: "No matching participants found in the pasted inputs." };
}
await batchUpsertParticipantSimulatorInputs(parsed.inputs);
const suffix = parsed.unmatched.length > 0
? ` ${parsed.unmatched.length} row(s) were unmatched: ${parsed.unmatched.join(", ")}.`
: "";
return { success: true, message: `Saved ${parsed.inputs.length} simulator input row(s).${suffix}` };
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to save simulator inputs." };
}
}
return { success: false, message: "Unknown simulator setup action." };
}
export default function AdminSportsSeasonSimulator({ loaderData }: Route.ComponentProps) {
const { sportsSeason, config, inputRows, readiness, inputPolicy } = loaderData;
const actionData = useActionData<ActionData>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const setupSections = config.profile.setupSections;
const sourceEloAlternatives = config.profile.derivableInputs?.sourceElo ?? [];
const ratingAlternatives = config.profile.derivableInputs?.rating ?? [];
const showsInputPolicy =
config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating");
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link to={`/admin/sports-seasons/${sportsSeason.id}`}>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Sports Season
</Link>
</Button>
<Button variant="ghost" size="sm" asChild>
<Link to="/admin/simulators">All Simulators</Link>
</Button>
</div>
<div>
<h1 className="text-3xl font-bold flex items-center gap-2">
<SlidersHorizontal className="h-7 w-7" />
Simulator Setup
</h1>
<p className="text-muted-foreground mt-1">
{sportsSeason.sport.name} - {sportsSeason.name}
</p>
</div>
{actionData && (
<Card className={actionData.success ? "border-emerald-500/40" : "border-destructive/40"}>
<CardContent className="py-4 text-sm">{actionData.message}</CardContent>
</Card>
)}
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-4">
<div>
<CardTitle>{config.profile.displayName}</CardTitle>
<CardDescription>{config.profile.description}</CardDescription>
</div>
{readiness.status === "ready" ? (
<Badge className="gap-1"><CheckCircle2 className="h-3 w-3" /> Ready</Badge>
) : (
<Badge variant="secondary" className="gap-1"><AlertCircle className="h-3 w-3" /> Needs setup</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-3">
<div className="rounded-md border p-3">
<div className="text-sm text-muted-foreground">Simulator Type</div>
<code className="text-sm">{config.simulatorType}</code>
</div>
<div className="rounded-md border p-3">
<div className="text-sm text-muted-foreground">Participant Inputs</div>
<div className="font-medium">{readiness.participantInputCount}/{readiness.participantCount}</div>
</div>
<div className="rounded-md border p-3">
<div className="text-sm text-muted-foreground">Simulation Status</div>
<div className="font-medium">{sportsSeason.simulationStatus}</div>
</div>
</div>
{readiness.missingInputs.length > 0 && (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3 text-sm">
Missing: {readiness.missingInputs.join(", ")}
</div>
)}
{readiness.warnings.length > 0 && (
<div className="rounded-md border border-sky-500/30 bg-sky-500/10 p-3 text-sm">
{readiness.warnings.join(" ")}
</div>
)}
<div className="flex flex-wrap gap-2">
{setupSections.includes("futuresOdds") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/futures-odds`}>Futures Odds</Link></Button>}
{setupSections.includes("eloRatings") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/elo-ratings`}>Elo Ratings</Link></Button>}
{setupSections.includes("surfaceElo") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/surface-elo`}>Surface Elo</Link></Button>}
{setupSections.includes("golfSkills") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/golf-skills`}>Golf Skills</Link></Button>}
{setupSections.includes("regularStandings") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/regular-standings`}>Regular Standings</Link></Button>}
{setupSections.includes("events") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/events`}>Events</Link></Button>}
<Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/expected-values`}>Expected Values</Link></Button>
</div>
<Form method="post">
<input type="hidden" name="intent" value="run" />
<Button type="submit" disabled={!readiness.canRun || sportsSeason.simulationStatus === "running" || isSubmitting}>
{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Play className="mr-2 h-4 w-4" />}
Run Simulation
</Button>
</Form>
</CardContent>
</Card>
{showsInputPolicy && (
<Card>
<CardHeader>
<CardTitle>Input Policy</CardTitle>
<CardDescription>
Direct inputs win. This simulator can derive Elo from{" "}
{sourceEloAlternatives.length > 0 ? sourceEloAlternatives.join(", ") : "no alternate Elo inputs"}
{ratingAlternatives.length > 0 ? ` and ratings from ${ratingAlternatives.join(", ")}` : ""}.
Tail fallbacks are explicit so low-impact missing participants do not silently get invented ratings.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="grid gap-4 md:grid-cols-5">
<input type="hidden" name="intent" value="save-input-policy" />
<div className="space-y-2 md:col-span-3">
<Label htmlFor="sourceStrategy">Futures vs. Elo</Label>
<select
id="sourceStrategy"
name="sourceStrategy"
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
defaultValue={prefersFuturesOdds(inputPolicy.sourceEloPriority) ? "futuresFirst" : "eloFirst"}
>
<option value="eloFirst">Elo / projections first (futures odds are fallback)</option>
<option value="futuresFirst">Futures odds first (override stored Elo / projections)</option>
</select>
<p className="text-xs text-muted-foreground">
Controls which signal wins when a participant has both. Futures-first makes freshly
entered odds drive the run without discarding stored Elo.
</p>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="oddsWeight">Odds Blend Weight (01)</Label>
<Input id="oddsWeight" name="oddsWeight" type="number" step="0.05" min="0" max="1" defaultValue={inputPolicy.oddsWeight} />
<p className="text-xs text-muted-foreground">
For simulators that blend Elo and odds per match. 1.0 = pure odds; 0 = pure Elo.
</p>
</div>
{config.profile.requiredInputs.includes("sourceElo") && (
<>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="missingEloStrategy">Missing Elo Strategy</Label>
<select
id="missingEloStrategy"
name="missingEloStrategy"
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
defaultValue={inputPolicy.missingEloStrategy}
>
<option value="block">Block until complete</option>
<option value="fallbackElo">Use fixed fallback Elo</option>
<option value="averageKnown">Use average known Elo</option>
<option value="worstKnownMinus">Use worst known Elo minus delta</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="fallbackElo">Fallback Elo</Label>
<Input id="fallbackElo" name="fallbackElo" type="number" defaultValue={inputPolicy.fallbackElo} />
</div>
<div className="space-y-2">
<Label htmlFor="fallbackEloDelta">Worst Elo Delta</Label>
<Input id="fallbackEloDelta" name="fallbackEloDelta" type="number" defaultValue={inputPolicy.fallbackEloDelta} />
</div>
<div className="space-y-2">
<Label htmlFor="eloMin">Elo Floor</Label>
<Input id="eloMin" name="eloMin" type="number" defaultValue={inputPolicy.eloMin} />
</div>
<div className="space-y-2">
<Label htmlFor="eloMax">Elo Ceiling</Label>
<Input id="eloMax" name="eloMax" type="number" defaultValue={inputPolicy.eloMax} />
</div>
</>
)}
{config.profile.requiredInputs.includes("rating") && (
<>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="missingRatingStrategy">Missing Rating Strategy</Label>
<select
id="missingRatingStrategy"
name="missingRatingStrategy"
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
defaultValue={inputPolicy.missingRatingStrategy}
>
<option value="block">Block until complete</option>
<option value="fallbackRating">Use fixed fallback rating</option>
<option value="averageKnown">Use average known rating</option>
<option value="worstKnownMinus">Use worst known rating minus delta</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="fallbackRating">Fallback Rating</Label>
<Input id="fallbackRating" name="fallbackRating" type="number" step="0.01" defaultValue={inputPolicy.fallbackRating} />
</div>
<div className="space-y-2">
<Label htmlFor="fallbackRatingDelta">Worst Rating Delta</Label>
<Input id="fallbackRatingDelta" name="fallbackRatingDelta" type="number" step="0.01" defaultValue={inputPolicy.fallbackRatingDelta} />
</div>
</>
)}
<div className="space-y-2">
<Label htmlFor="ratingMin">Rating Floor</Label>
<Input id="ratingMin" name="ratingMin" type="number" step="0.01" defaultValue={inputPolicy.ratingMin} />
</div>
<div className="space-y-2">
<Label htmlFor="ratingMax">Rating Ceiling</Label>
<Input id="ratingMax" name="ratingMax" type="number" step="0.01" defaultValue={inputPolicy.ratingMax} />
</div>
<div className="md:col-span-5">
<Button type="submit" disabled={isSubmitting}>
<Save className="mr-2 h-4 w-4" />
Save Input Policy
</Button>
</div>
</Form>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Season Config</CardTitle>
<CardDescription>
JSON overrides for this specific sports season. Defaults come from the simulator profile.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<input type="hidden" name="intent" value="save-config" />
<Textarea
name="config"
rows={10}
className="font-mono text-sm"
defaultValue={JSON.stringify(config.config, null, 2)}
/>
<Button type="submit" disabled={isSubmitting}>
<Save className="mr-2 h-4 w-4" />
Save Config
</Button>
</Form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Bulk Simulator Inputs</CardTitle>
<CardDescription>
Paste CSV with a header row. Supported columns: name, sourceElo, sourceOdds, worldRanking, rating,
projectedWins, projectedTablePoints, seed, region. Values must not contain commas.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Form method="post" className="space-y-4">
<input type="hidden" name="intent" value="save-inputs" />
<Textarea
name="bulkInputs"
rows={8}
className="font-mono text-sm"
placeholder={`name,sourceElo,sourceOdds,worldRanking,rating\n${inputRows[0]?.participant.name ?? "Team Name"},1500,+2500,12,28.5`}
/>
<Button type="submit" disabled={isSubmitting}>
<Save className="mr-2 h-4 w-4" />
Save Inputs
</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
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}