From 68b2e070e6d7e3ce96049ab4ee695dbc310bfc33 Mon Sep 17 00:00:00 2001
From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com>
Date: Thu, 9 Apr 2026 09:51:16 -0400
Subject: [PATCH] fix: infer LLWS bracket side from name when externalId is
null (#285)
Participants are created with externalId=null by default, causing the
LLWS simulator to crash at runtime. Fix in two parts:
1. Name-prefix inference: if externalId is null, participants whose
name starts with "US " or equals "US" are assigned to the US side;
all others are assigned to Intl. Pools are always randomized when
inferred (no pool suffix).
2. Admin UI: add an inline-editable "Group" column to the Manage
Participants page so admins can explicitly set externalId for any
simulator that uses it (LLWS, and future cases like NHL conferences).
Co-authored-by: Claude Sonnet 4.6
---
.../admin.sports-seasons.$id.participants.tsx | 68 +++++++--
.../sports-seasons-participants.test.ts | 138 ++++++++++++++++++
.../__tests__/llws-simulator.test.ts | 71 +++++----
app/services/simulations/llws-simulator.ts | 22 ++-
4 files changed, 257 insertions(+), 42 deletions(-)
create mode 100644 app/routes/admin/__tests__/sports-seasons-participants.test.ts
diff --git a/app/routes/admin.sports-seasons.$id.participants.tsx b/app/routes/admin.sports-seasons.$id.participants.tsx
index 2143e5f..b2d4cb1 100644
--- a/app/routes/admin.sports-seasons.$id.participants.tsx
+++ b/app/routes/admin.sports-seasons.$id.participants.tsx
@@ -64,6 +64,39 @@ export async function action({ request, params }: Route.ActionArgs) {
return { success: true };
}
+ if (intent === "update-participant") {
+ const participantId = formData.get("participantId");
+ const newName = formData.get("newName");
+ const newExternalId = formData.get("newExternalId");
+
+ if (typeof participantId !== "string") {
+ return { error: "Invalid participant", intent: "update-participant" };
+ }
+
+ if (typeof newName !== "string" || !newName.trim()) {
+ return { error: "Name is required", intent: "update-participant" };
+ }
+
+ const trimmedName = newName.trim();
+ const existing = await findParticipantByName(params.id, trimmedName);
+ if (existing && existing.id !== participantId) {
+ return { error: `"${trimmedName}" already exists in this season.`, intent: "update-participant" };
+ }
+
+ const externalIdValue =
+ typeof newExternalId === "string" && newExternalId.trim()
+ ? newExternalId.trim()
+ : null;
+
+ try {
+ await updateParticipant(participantId, { name: trimmedName, externalId: externalIdValue });
+ return { success: true, intent: "update-participant" };
+ } catch (error) {
+ logger.error("Error updating participant:", error);
+ return { error: "Failed to update participant. Please try again.", intent: "update-participant" };
+ }
+ }
+
if (intent === "update-name") {
const participantId = formData.get("participantId");
const newName = formData.get("newName");
@@ -171,7 +204,7 @@ export async function action({ request, params }: Route.ActionArgs) {
export default function ManageParticipants({ loaderData, actionData }: Route.ComponentProps) {
const { sportsSeason, participants } = loaderData;
- const [editing, setEditing] = useState<{ id: string; name: string } | null>(null);
+ const [editing, setEditing] = useState<{ id: string; name: string; externalId: string } | null>(null);
const cancelEdit = () => setEditing(null);
@@ -179,7 +212,7 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
const formKey = actionData?.success ? Date.now() : 'static';
useEffect(() => {
- if (actionData?.success && actionData?.intent === "update-name") {
+ if (actionData?.success && (actionData?.intent === "update-name" || actionData?.intent === "update-participant")) {
cancelEdit();
}
}, [actionData]);
@@ -221,13 +254,13 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
/>
- {actionData?.error && !actionData?.count && actionData?.intent !== "update-name" && (
+ {actionData?.error && !actionData?.count && actionData?.intent !== "update-name" && actionData?.intent !== "update-participant" && (
{actionData.error}
)}
- {actionData?.success && !actionData?.count && (
+ {actionData?.success && !actionData?.count && actionData?.intent !== "update-participant" && (
Participant added successfully!
@@ -267,7 +300,7 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
- {actionData?.error && actionData?.count === undefined && actionData?.intent !== "update-name" && (
+ {actionData?.error && actionData?.count === undefined && actionData?.intent !== "update-name" && actionData?.intent !== "update-participant" && (
{actionData.error}
@@ -311,6 +344,7 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
Name
+ Group
@@ -322,12 +356,12 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
setEditing({ id: participant.id, name: e.target.value })}
+ onChange={(e) => setEditing({ ...editing, name: e.target.value })}
className="h-8"
autoFocus
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
/>
- {actionData?.error && actionData?.intent === "update-name" && (
+ {actionData?.error && (actionData?.intent === "update-name" || actionData?.intent === "update-participant") && (
{actionData.error}
)}
@@ -335,13 +369,29 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
participant.name
)}
+
+ {editing?.id === participant.id ? (
+ setEditing({ ...editing, externalId: e.target.value })}
+ className="h-8 font-mono text-xs"
+ placeholder="US, Intl, US:A…"
+ onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
+ />
+ ) : (
+
+ {participant.externalId ?? "—"}
+
+ )}
+
{editing?.id === participant.id ? (