# Batch Qualifying Results Entry **Date:** 2026-04-12 **Status:** Approved ## Overview Replace the one-at-a-time "Add Result" form on qualifying events with a paste-and-parse batch entry flow. Admins paste a ranked results list, resolve any unmatched names, confirm, and save. On finalize, all remaining season participants automatically receive 0-QP rows so simulations can correctly project remaining events. The existing single-entry form stays as a fallback for individual corrections after batch import. --- ## Data Flow 1. **Paste & Parse (client-side)** — Admin pastes a ranked text list into a textarea. A pure utility function parses it into `{ placement, rawName }` pairs. No server call. 2. **Preview & Resolve (client-side)** — Each parsed name is matched against the participants already in loader data (case-insensitive exact match). Unresolved names show a dropdown to map to an existing participant or an inline form to create a new one. 3. **Batch Save (server)** — "Confirm & Save All" posts all rows to a new `batch-add-results` intent, which calls `createEventResultsBulk`. Already-existing results for the event are skipped (idempotent). 4. **Finalize (server, modified `complete` intent)** — Existing flow (mark complete → `processQualifyingEvent`) runs unchanged. Afterward, every season participant without an eventResult for this event gets a `qualifyingPointsAwarded = 0` row. These rows signal "competed, earned nothing" for simulations without affecting QP totals. --- ## Parser (`app/lib/parse-results-text.ts`) Pure function, no React or DB dependencies. ```typescript interface ParsedLine { placement: number; rawName: string; } function parseResultsText(text: string): ParsedLine[] ``` **Supported formats:** | Input | Placement | |---|---| | `1. Gerwyn Price` | 1 | | `1, Gerwyn Price` | 1 | | `1 Gerwyn Price` | 1 | | `T3. Luke Littler` | 3 (tied) | | `T3, Luke Littler` | 3 (tied) | | `3. Player A` then `3. Player B` | 3 for both (tied) | | blank lines | ignored | Tie detection: `T` prefix OR repeated rank numbers on consecutive lines. Both surfaces as a `T` badge in the preview UI — no change to QP calculation logic (ties already handled correctly in `processQualifyingEvent`). --- ## `BatchResultEntry` Component (`app/components/BatchResultEntry.tsx`) **Props:** ```typescript { participants: { id: string; name: string }[]; eventId: string; sportsSeasonId: string; existingResultParticipantIds: Set; } ``` **State stages:** `idle` → `preview` → `saving` - **`idle`**: Textarea with example format placeholder + "Parse Results" button. - **`preview`**: Table of rows (see below) + "Back" link + "Confirm & Save All" button (disabled until all rows resolved). - **`saving`**: Spinner on button. On success: inline "✓ X results imported" message + `revalidate()` to refresh loader data. **Preview table row states:** | State | Visual | Behavior | |---|---|---| | Matched | Green badge | Read-only — name matched case-insensitively | | Unresolved — map | Amber | Dropdown of unplaced participants; selecting resolves the row | | Unresolved — add new | Red | "Add new participant…" option expands inline form with name pre-filled; on save, new participant is auto-selected | **Placement in page:** Rendered above the existing "Add Result" card. Only shown for qualifying events (`event.isQualifyingEvent`) that are not yet complete. --- ## Server Changes (`admin.sports-seasons.$id.events.$eventId.server.ts`) ### New intent: `batch-add-results` - Receives hidden field `results` as a JSON string: `{ participantId: string, placement: number }[]` - Loads existing eventResults for the event; filters out any participantId already present (idempotent) - Calls existing `createEventResultsBulk` with remaining rows - Returns `{ success: "X results saved." }` ### New intent: `create-participant` - Receives `name` (string) and `sportsSeasonId` (string) - Calls `createParticipant` from `app/models/participant.ts` - Links new participant to the sports season via the existing join table (verify exact table/column during implementation by reading `findParticipantsBySportsSeasonId`) - Returns `{ participantId, participantName }` so the component can auto-select the new row ### Modified intent: `complete` (qualifying events) Existing flow unchanged. This step only runs when `event.isQualifyingEvent` is true (already the existing gate). After `processQualifyingEvent`: 1. Load all participants for the sports season 2. Load all existing eventResults for this event 3. For each participant without an eventResult, call `createEventResult` with `qualifyingPointsAwarded: 0` and no placement 4. These 0-QP rows do not affect `recalculateParticipantQP` totals (that function only sums entries where `qp > 0`) but mark participation for simulation logic --- ## Testing ### Unit tests — parser (`app/lib/__tests__/parse-results-text.test.ts`) - Dot, comma, and space separators parse correctly - `T` prefix is detected as a tie - Repeated rank numbers on consecutive lines both get the same placement - Blank lines and extra whitespace are ignored - Mixed formats in one paste work correctly ### Unit tests — server actions - `batch-add-results`: skips existing result participantIds, saves new rows, returns correct count - `create-participant`: creates participant and season link, returns id and name - Modified `complete`: after processing, all season participants have an eventResult row; 0-QP rows do not change QP totals ### No E2E tests Admin flows are not covered by Cypress; this feature does not touch user-facing pages. --- ## Files Touched | File | Change | |---|---| | `app/lib/parse-results-text.ts` | New — parser utility | | `app/lib/__tests__/parse-results-text.test.ts` | New — parser unit tests | | `app/components/BatchResultEntry.tsx` | New — batch paste component | | `app/routes/admin.sports-seasons.$id.events.$eventId.tsx` | Add `BatchResultEntry` above single-entry form | | `app/routes/admin.sports-seasons.$id.events.$eventId.server.ts` | Add `batch-add-results`, `create-participant` intents; modify `complete` |