From fb30badb7c72274cbfc7bc0d4f518143a3266180 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Sun, 12 Apr 2026 14:10:29 -0700 Subject: [PATCH] docs: add batch qualifying results entry implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../2026-04-12-batch-qualifying-results.md | 1096 +++++++++++++++++ 1 file changed, 1096 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-12-batch-qualifying-results.md diff --git a/docs/superpowers/plans/2026-04-12-batch-qualifying-results.md b/docs/superpowers/plans/2026-04-12-batch-qualifying-results.md new file mode 100644 index 0000000..a0f4541 --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-batch-qualifying-results.md @@ -0,0 +1,1096 @@ +# 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" && ( +
+