# Batch Qualifying Results Entry Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a paste-and-parse batch entry flow to qualifying event pages so admins can enter all results at once instead of one at a time. **Architecture:** A new pure parser utility handles text → `{ placement, rawName }[]`. A new `BatchResultEntry` React component handles the paste/preview/resolve/confirm UI entirely client-side, posting to a new `batch-add-results` server action. On finalize, the existing `complete` intent is extended to write 0-QP rows for all season participants who have no result. **Tech Stack:** React Router 7 (SSR), TypeScript, Vitest, Drizzle ORM, TailwindCSS + ShadCN UI --- ## File Map | File | Change | Responsibility | |---|---|---| | `app/lib/parse-results-text.ts` | Create | Pure parser: text → `ParsedLine[]` | | `app/lib/__tests__/parse-results-text.test.ts` | Create | Unit tests for parser | | `app/components/BatchResultEntry.tsx` | Create | Paste/preview/resolve/confirm UI component | | `app/routes/admin.sports-seasons.$id.events.$eventId.server.ts` | Modify | Add `batch-add-results`, `create-participant` intents; extend `complete` | | `app/routes/admin.sports-seasons.$id.events.$eventId.tsx` | Modify | Render `BatchResultEntry` above single-entry form | --- ## Task 1: Parser Utility (TDD) **Files:** - Create: `app/lib/parse-results-text.ts` - Create: `app/lib/__tests__/parse-results-text.test.ts` - [ ] **Step 1.1: Create the test file with failing tests** Create `app/lib/__tests__/parse-results-text.test.ts`: ```typescript import { describe, it, expect } from "vitest"; import { parseResultsText } from "../parse-results-text"; describe("parseResultsText", () => { describe("dot separator", () => { it("parses '1. Player Name'", () => { expect(parseResultsText("1. Gerwyn Price")).toEqual([ { placement: 1, rawName: "Gerwyn Price" }, ]); }); }); describe("comma separator", () => { it("parses '1, Player Name'", () => { expect(parseResultsText("1, Gerwyn Price")).toEqual([ { placement: 1, rawName: "Gerwyn Price" }, ]); }); }); describe("space-only separator", () => { it("parses '1 Player Name'", () => { expect(parseResultsText("1 Gerwyn Price")).toEqual([ { placement: 1, rawName: "Gerwyn Price" }, ]); }); }); describe("T-prefix ties", () => { it("parses 'T3. Player Name' as placement 3", () => { expect(parseResultsText("T3. Luke Littler")).toEqual([ { placement: 3, rawName: "Luke Littler" }, ]); }); it("parses 'T3, Player Name' as placement 3", () => { expect(parseResultsText("T3, Luke Littler")).toEqual([ { placement: 3, rawName: "Luke Littler" }, ]); }); it("parses two T-prefix lines with same rank as both placement 3", () => { const input = "T3. Luke Littler\nT3. Michael van Gerwen"; expect(parseResultsText(input)).toEqual([ { placement: 3, rawName: "Luke Littler" }, { placement: 3, rawName: "Michael van Gerwen" }, ]); }); }); describe("repeated-rank ties", () => { it("parses two lines with the same rank number as both tied", () => { const input = "3. Luke Littler\n3. Michael van Gerwen"; expect(parseResultsText(input)).toEqual([ { placement: 3, rawName: "Luke Littler" }, { placement: 3, rawName: "Michael van Gerwen" }, ]); }); }); describe("blank lines and whitespace", () => { it("ignores blank lines", () => { const input = "1. Gerwyn Price\n\n2. Luke Littler"; expect(parseResultsText(input)).toEqual([ { placement: 1, rawName: "Gerwyn Price" }, { placement: 2, rawName: "Luke Littler" }, ]); }); it("trims leading and trailing whitespace from names", () => { expect(parseResultsText("1. Gerwyn Price ")).toEqual([ { placement: 1, rawName: "Gerwyn Price" }, ]); }); }); describe("multi-line mixed formats", () => { it("parses a realistic paste with multiple formats", () => { const input = [ "1. Gerwyn Price", "2, Luke Littler", "T3. Michael van Gerwen", "T3. Peter Wright", "", "5 Damon Heta", ].join("\n"); expect(parseResultsText(input)).toEqual([ { placement: 1, rawName: "Gerwyn Price" }, { placement: 2, rawName: "Luke Littler" }, { placement: 3, rawName: "Michael van Gerwen" }, { placement: 3, rawName: "Peter Wright" }, { placement: 5, rawName: "Damon Heta" }, ]); }); }); }); ``` - [ ] **Step 1.2: Run tests to confirm they fail** ```bash npm run test:run -- app/lib/__tests__/parse-results-text.test.ts ``` Expected: fail with "Cannot find module '../parse-results-text'" - [ ] **Step 1.3: Implement the parser** Create `app/lib/parse-results-text.ts`: ```typescript export interface ParsedLine { placement: number; rawName: string; } /** * Parse a pasted results text into placement + name pairs. * * Supported formats per line: * 1. Name (dot separator) * 1, Name (comma separator) * 1 Name (space separator) * T3. Name (T-prefix tie, dot separator) * T3, Name (T-prefix tie, comma separator) * T3 Name (T-prefix tie, space separator) * * Repeated rank numbers across consecutive lines are both emitted * with the same placement (treated as ties automatically). * Blank lines are ignored. */ export function parseResultsText(text: string): ParsedLine[] { const results: ParsedLine[] = []; for (const rawLine of text.split("\n")) { const line = rawLine.trim(); if (!line) continue; // Match optional T prefix, then a number, then an optional separator (. or ,), then the name const match = line.match(/^[Tt]?(\d+)[.,]?\s+(.+)$/); if (!match) { // Try space-only separator: "1 Name" or "T3 Name" const spaceMatch = line.match(/^[Tt]?(\d+)\s+(.+)$/); if (spaceMatch) { results.push({ placement: parseInt(spaceMatch[1], 10), rawName: spaceMatch[2].trim(), }); } continue; } results.push({ placement: parseInt(match[1], 10), rawName: match[2].trim(), }); } return results; } ``` - [ ] **Step 1.4: Run tests to confirm they pass** ```bash npm run test:run -- app/lib/__tests__/parse-results-text.test.ts ``` Expected: all tests pass. - [ ] **Step 1.5: Commit** ```bash git add app/lib/parse-results-text.ts app/lib/__tests__/parse-results-text.test.ts git commit -m "feat: add qualifying results text parser with tests" ``` --- ## Task 2: `batch-add-results` Server Intent **Files:** - Modify: `app/routes/admin.sports-seasons.$id.events.$eventId.server.ts` The duplicate-skipping logic is a pure function — test it in isolation before wiring it up. - [ ] **Step 2.1: Add the pure helper and its test** Create `app/routes/__tests__/batch-results-helpers.test.ts`: ```typescript import { describe, it, expect } from "vitest"; import { filterNewResults } from "../admin.sports-seasons.$id.events.$eventId.helpers"; describe("filterNewResults", () => { it("returns all rows when no existing results", () => { const incoming = [ { participantId: "p1", placement: 1 }, { participantId: "p2", placement: 2 }, ]; expect(filterNewResults(incoming, new Set())).toEqual(incoming); }); it("skips participantIds already in existingIds", () => { const incoming = [ { participantId: "p1", placement: 1 }, { participantId: "p2", placement: 2 }, ]; const existing = new Set(["p1"]); expect(filterNewResults(incoming, existing)).toEqual([ { participantId: "p2", placement: 2 }, ]); }); it("returns empty array when all are duplicates", () => { const incoming = [{ participantId: "p1", placement: 1 }]; const existing = new Set(["p1"]); expect(filterNewResults(incoming, existing)).toEqual([]); }); }); ``` - [ ] **Step 2.2: Run test to confirm it fails** ```bash npm run test:run -- app/routes/__tests__/batch-results-helpers.test.ts ``` Expected: fail with "Cannot find module" - [ ] **Step 2.3: Create the helpers file** Create `app/routes/admin.sports-seasons.$id.events.$eventId.helpers.ts`: ```typescript /** * Filter incoming batch results to only those whose participantId * is not already in the set of existing result participantIds. */ export function filterNewResults( incoming: { participantId: string; placement: number }[], existingParticipantIds: Set ): { participantId: string; placement: number }[] { return incoming.filter((r) => !existingParticipantIds.has(r.participantId)); } ``` - [ ] **Step 2.4: Run test to confirm it passes** ```bash npm run test:run -- app/routes/__tests__/batch-results-helpers.test.ts ``` Expected: all tests pass. - [ ] **Step 2.5: Add `batch-add-results` intent to server action** In `app/routes/admin.sports-seasons.$id.events.$eventId.server.ts`, add after the existing imports: ```typescript import { filterNewResults } from "./admin.sports-seasons.$id.events.$eventId.helpers"; ``` Then add the following intent handler inside the `action` function, before the final `return { error: "Invalid action" }`: ```typescript if (intent === "batch-add-results") { const resultsJson = formData.get("results"); if (typeof resultsJson !== "string" || !resultsJson) { return { error: "Results data is required" }; } let incoming: { participantId: string; placement: number }[]; try { incoming = JSON.parse(resultsJson); } catch { return { error: "Invalid results format" }; } if (!Array.isArray(incoming) || incoming.length === 0) { return { error: "No results provided" }; } try { const existingResults = await getEventResults(params.eventId); const existingIds = new Set(existingResults.map((r) => r.participantId)); const newResults = filterNewResults(incoming, existingIds); if (newResults.length > 0) { await createEventResultsBulk( newResults.map((r) => ({ scoringEventId: params.eventId, participantId: r.participantId, placement: r.placement, })) ); } return { success: `${newResults.length} result${newResults.length === 1 ? "" : "s"} saved${incoming.length - newResults.length > 0 ? ` (${incoming.length - newResults.length} duplicate${incoming.length - newResults.length === 1 ? "" : "s"} skipped)` : ""}.`, }; } catch (error) { logger.error("Error saving batch results:", error); return { error: "Failed to save results" }; } } ``` - [ ] **Step 2.6: Verify TypeScript compiles** ```bash npm run typecheck ``` Expected: no errors. - [ ] **Step 2.7: Commit** ```bash git add app/routes/admin.sports-seasons.$id.events.$eventId.server.ts \ app/routes/admin.sports-seasons.$id.events.$eventId.helpers.ts \ app/routes/__tests__/batch-results-helpers.test.ts git commit -m "feat: add batch-add-results server intent with duplicate filtering" ``` --- ## Task 3: `create-participant` Server Intent **Files:** - Modify: `app/routes/admin.sports-seasons.$id.events.$eventId.server.ts` - [ ] **Step 3.1: Add import for createParticipant** In `app/routes/admin.sports-seasons.$id.events.$eventId.server.ts`, add to the existing imports: ```typescript import { createParticipant } from "~/models/participant"; ``` - [ ] **Step 3.2: Add `create-participant` intent handler** Inside the `action` function, before the final `return { error: "Invalid action" }`: ```typescript if (intent === "create-participant") { const name = formData.get("name"); const sportsSeasonId = formData.get("sportsSeasonId"); if (typeof name !== "string" || !name.trim()) { return { error: "Participant name is required" }; } if (typeof sportsSeasonId !== "string" || !sportsSeasonId) { return { error: "Sports season ID is required" }; } try { const participant = await createParticipant({ name: name.trim(), sportsSeasonId, }); return { success: `Participant "${participant.name}" created.`, newParticipant: { id: participant.id, name: participant.name }, }; } catch (error) { logger.error("Error creating participant:", error); return { error: "Failed to create participant" }; } } ``` - [ ] **Step 3.3: Verify TypeScript compiles** ```bash npm run typecheck ``` Expected: no errors. - [ ] **Step 3.4: Commit** ```bash git add app/routes/admin.sports-seasons.$id.events.$eventId.server.ts git commit -m "feat: add create-participant intent for inline participant creation" ``` --- ## Task 4: Extend `complete` Intent to Write 0-QP Rows **Files:** - Modify: `app/routes/admin.sports-seasons.$id.events.$eventId.server.ts` The gap-filling logic is a pure function — test it first. - [ ] **Step 4.1: Add test for gap-filling helper** Add to `app/routes/__tests__/batch-results-helpers.test.ts`: ```typescript import { filterNewResults, findParticipantsWithoutResults } from "../admin.sports-seasons.$id.events.$eventId.helpers"; // ... existing tests above ... describe("findParticipantsWithoutResults", () => { it("returns participantIds not in existingResultIds", () => { const allIds = ["p1", "p2", "p3"]; const existingIds = new Set(["p1"]); expect(findParticipantsWithoutResults(allIds, existingIds)).toEqual(["p2", "p3"]); }); it("returns empty array when all participants have results", () => { const allIds = ["p1", "p2"]; const existingIds = new Set(["p1", "p2"]); expect(findParticipantsWithoutResults(allIds, existingIds)).toEqual([]); }); it("returns all when none have results", () => { const allIds = ["p1", "p2"]; expect(findParticipantsWithoutResults(allIds, new Set())).toEqual(["p1", "p2"]); }); }); ``` - [ ] **Step 4.2: Run tests to confirm new tests fail** ```bash npm run test:run -- app/routes/__tests__/batch-results-helpers.test.ts ``` Expected: the new `findParticipantsWithoutResults` tests fail. - [ ] **Step 4.3: Add the helper function** In `app/routes/admin.sports-seasons.$id.events.$eventId.helpers.ts`, add: ```typescript /** * Return participantIds from allParticipantIds that have no entry * in existingResultParticipantIds. Used to find participants who * need a 0-QP row after a qualifying event is finalized. */ export function findParticipantsWithoutResults( allParticipantIds: string[], existingResultParticipantIds: Set ): string[] { return allParticipantIds.filter((id) => !existingResultParticipantIds.has(id)); } ``` - [ ] **Step 4.4: Run tests to confirm they pass** ```bash npm run test:run -- app/routes/__tests__/batch-results-helpers.test.ts ``` Expected: all tests pass. - [ ] **Step 4.5: Update helper import and extend the `complete` intent** In `admin.sports-seasons.$id.events.$eventId.server.ts`, find the import added in Task 2 and update it to include the new helper: ```typescript // Before (from Task 2): import { filterNewResults } from "./admin.sports-seasons.$id.events.$eventId.helpers"; // After: import { filterNewResults, findParticipantsWithoutResults } from "./admin.sports-seasons.$id.events.$eventId.helpers"; ``` Find the existing block inside `intent === "complete"`: ```typescript // If this is a qualifying event, process qualifying points if (event.isQualifyingEvent) { await processQualifyingEvent(params.eventId); return { success: "Event completed and qualifying points awarded!" }; } ``` Replace it with: ```typescript // If this is a qualifying event, process qualifying points then fill 0-QP rows if (event.isQualifyingEvent) { await processQualifyingEvent(params.eventId); // Write 0-QP rows for all season participants who have no result for this event. // This marks them as "competed, earned nothing" so simulations skip this event. const allParticipants = await findParticipantsBySportsSeasonId(params.id); const existingResults = await getEventResults(params.eventId); const existingIds = new Set(existingResults.map((r) => r.participantId)); const missingIds = findParticipantsWithoutResults( allParticipants.map((p) => p.id), existingIds ); for (const participantId of missingIds) { await createEventResult({ scoringEventId: params.eventId, participantId, qualifyingPointsAwarded: 0, }); } return { success: "Event completed and qualifying points awarded!" }; } ``` Also ensure `findParticipantsBySportsSeasonId` and `createEventResult` are in the imports at the top of the file. `findParticipantsBySportsSeasonId` is likely already imported (it's used in the loader). `createEventResult` is already imported. - [ ] **Step 4.6: Verify TypeScript compiles** ```bash npm run typecheck ``` Expected: no errors. - [ ] **Step 4.7: Commit** ```bash git add app/routes/admin.sports-seasons.$id.events.$eventId.server.ts \ app/routes/admin.sports-seasons.$id.events.$eventId.helpers.ts \ app/routes/__tests__/batch-results-helpers.test.ts git commit -m "feat: fill 0-QP rows for unplaced participants on qualifying event finalize" ``` --- ## Task 5: `BatchResultEntry` Component **Files:** - Create: `app/components/BatchResultEntry.tsx` This component is entirely client-side state — no tests needed beyond what manual use will cover (the parsing logic is already unit-tested in Task 1). - [ ] **Step 5.1: Create the component** Create `app/components/BatchResultEntry.tsx`: ```typescript import { useState, useRef } 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 { Label } from "~/components/ui/label"; 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, ChevronDown, ChevronUp } 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[]; eventId: string; sportsSeasonId: string; existingResultParticipantIds: Set; } export function BatchResultEntry({ participants, eventId, 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); // row index const [newParticipantName, setNewParticipantName] = useState(""); const { revalidate } = useRevalidator(); const saveFetcher = useFetcher(); const createParticipantFetcher = useFetcher(); // Build lookup: lowercase name → participant const participantByName = new Map( participants.map((p) => [p.name.toLowerCase(), p]) ); 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 = participantByName.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" }); // Watch for response in effect below setAddingNewFor(null); setNewParticipantName(""); // Store which row we were adding for so we can update it pendingNewParticipantRowRef.current = index; } 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; setRows((prev) => prev.map((row, i) => i === rowIndex ? { ...row, participantId: newP.id } : row ) ); // Add new participant to lookup for future rows (note: mutates prop array for immediate lookup) participants.push(newP); participantByName.set(newP.name.toLowerCase(), newP); } }, [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"); // allow retry } } }, [saveFetcher.state, saveData]); const allResolved = rows.every((r) => r.participantId !== null); function handleConfirm() { if (!allResolved) return; setStage("saving"); const results = rows.map((r) => ({ participantId: r.participantId!, placement: r.placement, })); const formData = new FormData(); formData.set("intent", "batch-add-results"); formData.set("results", JSON.stringify(results)); saveFetcher.submit(formData, { method: "post" }); } if (successMessage) { return (
{successMessage}
); } // Participants available for mapping (not already in results, not already resolved in another row) const resolvedIds = new Set(rows.map((r) => r.participantId).filter(Boolean)); const availableParticipants = participants.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" && (