From 1798020c747f701b7b577aae00135b3c1f9959b6 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Sun, 12 Apr 2026 17:52:22 -0400 Subject: [PATCH] feat: batch qualifying results entry (#290) Fixes #289 * docs: add batch qualifying results entry design spec Co-Authored-By: Claude Sonnet 4.6 * docs: add batch qualifying results entry implementation plan Co-Authored-By: Claude Sonnet 4.6 * chore: ignore .worktrees directory * feat: add qualifying results text parser with tests * fix: handle no-space after dot separator, tighten T-prefix to uppercase only * feat: add batch-add-results server intent with duplicate filtering Co-Authored-By: Claude Sonnet 4.6 * feat: add create-participant intent for inline participant creation * fix: trim sportsSeasonId validation in create-participant intent * feat: fill 0-QP rows for unplaced participants on qualifying event finalize Co-Authored-By: Claude Sonnet 4.6 * perf: use bulk insert for 0-QP rows on qualifying event finalize * feat: add BatchResultEntry component for paste-and-parse batch result import Co-Authored-By: Claude Sonnet 4.6 * fix: disable add participant button while create request is in flight * feat: render BatchResultEntry on qualifying event pages Add BatchResultEntry component import and render it on event pages when the event is a qualifying event, not a final_standings/playoff_game type, and not yet complete. Co-Authored-By: Claude Sonnet 4.6 * fix: address final review issues (sportsSeasonId authority, participant validation, CRLF, unused prop) Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .gitignore | 1 + app/components/BatchResultEntry.tsx | 400 ++++++ app/lib/__tests__/parse-results-text.test.ts | 110 ++ app/lib/parse-results-text.ts | 51 + .../__tests__/batch-results-helpers.test.ts | 48 + ...rts-seasons.$id.events.$eventId.helpers.ts | 22 + ...orts-seasons.$id.events.$eventId.server.ts | 101 +- ...min.sports-seasons.$id.events.$eventId.tsx | 12 + .../2026-04-12-batch-qualifying-results.md | 1096 +++++++++++++++++ ...6-04-12-batch-qualifying-results-design.md | 136 ++ 10 files changed, 1975 insertions(+), 2 deletions(-) create mode 100644 app/components/BatchResultEntry.tsx create mode 100644 app/lib/__tests__/parse-results-text.test.ts create mode 100644 app/lib/parse-results-text.ts create mode 100644 app/routes/__tests__/batch-results-helpers.test.ts create mode 100644 app/routes/admin.sports-seasons.$id.events.$eventId.helpers.ts create mode 100644 docs/superpowers/plans/2026-04-12-batch-qualifying-results.md create mode 100644 docs/superpowers/specs/2026-04-12-batch-qualifying-results-design.md diff --git a/.gitignore b/.gitignore index 62460a7..793c72e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ *storybook.log storybook-static .grepai/ +.worktrees/ diff --git a/app/components/BatchResultEntry.tsx b/app/components/BatchResultEntry.tsx new file mode 100644 index 0000000..9152639 --- /dev/null +++ b/app/components/BatchResultEntry.tsx @@ -0,0 +1,400 @@ +import { useState, useRef, useEffect } from "react"; +import { useFetcher, useRevalidator } from "react-router"; +import { Button } from "~/components/ui/button"; +import { Textarea } from "~/components/ui/textarea"; +import { Input } from "~/components/ui/input"; +import { Badge } from "~/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { CheckCircle2, Trophy } from "lucide-react"; +import { parseResultsText } from "~/lib/parse-results-text"; + +interface Participant { + id: string; + name: string; +} + +interface ResolvedRow { + placement: number; + rawName: string; + participantId: string | null; // null = unresolved +} + +type Stage = "idle" | "preview" | "saving"; + +interface BatchResultEntryProps { + participants: Participant[]; + sportsSeasonId: string; + existingResultParticipantIds: Set; +} + +export function BatchResultEntry({ + participants, + sportsSeasonId, + existingResultParticipantIds, +}: BatchResultEntryProps) { + const [stage, setStage] = useState("idle"); + const [pasteText, setPasteText] = useState(""); + const [rows, setRows] = useState([]); + const [parseError, setParseError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [addingNewFor, setAddingNewFor] = useState(null); + const [newParticipantName, setNewParticipantName] = useState(""); + const { revalidate } = useRevalidator(); + + const saveFetcher = useFetcher(); + const createParticipantFetcher = useFetcher(); + + // Mutable lookup built from the prop — updated when new participants are created + const participantByNameRef = useRef( + new Map(participants.map((p) => [p.name.toLowerCase(), p])) + ); + // Mutable list — grows when new participants are created inline + const participantsRef = useRef([...participants]); + + const pendingNewParticipantRowRef = useRef(null); + + const createData = createParticipantFetcher.data as + | { newParticipant?: { id: string; name: string }; error?: string } + | undefined; + + const saveData = saveFetcher.data as + | { success?: string; error?: string } + | undefined; + + // When createParticipantFetcher returns a new participant, auto-select it + useEffect(() => { + if ( + createData?.newParticipant && + pendingNewParticipantRowRef.current !== null + ) { + const newP = createData.newParticipant; + const rowIndex = pendingNewParticipantRowRef.current; + pendingNewParticipantRowRef.current = null; + // Update lookup and list + participantByNameRef.current.set(newP.name.toLowerCase(), newP); + participantsRef.current.push(newP); + // Auto-select for the row + setRows((prev) => + prev.map((row, i) => + i === rowIndex ? { ...row, participantId: newP.id } : row + ) + ); + } + }, [createData]); + + // Watch save fetcher for completion + useEffect(() => { + if (stage === "saving" && saveFetcher.state === "idle" && saveData) { + if (saveData.success) { + setSuccessMessage(saveData.success); + setStage("idle"); + setPasteText(""); + setRows([]); + revalidate(); + } else if (saveData.error) { + setStage("preview"); + } + } + }, [saveFetcher.state, saveData, stage, revalidate]); + + function handleParse() { + setParseError(null); + const parsed = parseResultsText(pasteText); + if (parsed.length === 0) { + setParseError("No results found. Check the format and try again."); + return; + } + + const resolved: ResolvedRow[] = parsed.map((line) => { + const match = participantByNameRef.current.get(line.rawName.toLowerCase()); + return { + placement: line.placement, + rawName: line.rawName, + participantId: match ? match.id : null, + }; + }); + + setRows(resolved); + setStage("preview"); + } + + function setRowParticipant(index: number, participantId: string) { + setRows((prev) => + prev.map((row, i) => (i === index ? { ...row, participantId } : row)) + ); + } + + function handleAddNewParticipant(index: number) { + setAddingNewFor(index); + setNewParticipantName(rows[index].rawName); + } + + function handleCreateParticipant(index: number) { + if (!newParticipantName.trim()) return; + const formData = new FormData(); + formData.set("intent", "create-participant"); + formData.set("name", newParticipantName.trim()); + formData.set("sportsSeasonId", sportsSeasonId); + createParticipantFetcher.submit(formData, { method: "post" }); + pendingNewParticipantRowRef.current = index; + setAddingNewFor(null); + setNewParticipantName(""); + } + + function handleConfirm() { + if (!allResolved || rows.length === 0) return; + setStage("saving"); + const results = rows + .filter((r) => r.participantId !== null) + .map((r) => ({ + participantId: r.participantId as string, + placement: r.placement, + })); + const formData = new FormData(); + formData.set("intent", "batch-add-results"); + formData.set("results", JSON.stringify(results)); + saveFetcher.submit(formData, { method: "post" }); + } + + const allResolved = rows.every((r) => r.participantId !== null); + + if (successMessage) { + return ( + + +
+ + {successMessage} + +
+
+
+ ); + } + + // Participants available for mapping — exclude those already in saved results + // (but include any that are currently resolved in this batch, to allow re-use) + const resolvedIds = new Set(rows.map((r) => r.participantId).filter(Boolean) as string[]); + const availableParticipants = participantsRef.current.filter( + (p) => !existingResultParticipantIds.has(p.id) || resolvedIds.has(p.id) + ); + + return ( + + + + + Batch Paste Results + + + Paste a ranked results list to import all placements at once. Format:{" "} + + 1. Player Name + {" "} + — use{" "} + T3 or + repeat a rank for ties. + + + + {stage === "idle" && ( +
+