6 KiB
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
- 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. - 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.
- Batch Save (server) — "Confirm & Save All" posts all rows to a new
batch-add-resultsintent, which callscreateEventResultsBulk. Already-existing results for the event are skipped (idempotent). - Finalize (server, modified
completeintent) — Existing flow (mark complete →processQualifyingEvent) runs unchanged. Afterward, every season participant without an eventResult for this event gets aqualifyingPointsAwarded = 0row. 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.
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:
{
participants: { id: string; name: string }[];
eventId: string;
sportsSeasonId: string;
existingResultParticipantIds: Set<string>;
}
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
resultsas a JSON string:{ participantId: string, placement: number }[] - Loads existing eventResults for the event; filters out any participantId already present (idempotent)
- Calls existing
createEventResultsBulkwith remaining rows - Returns
{ success: "X results saved." }
New intent: create-participant
- Receives
name(string) andsportsSeasonId(string) - Calls
createParticipantfromapp/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:
- Load all participants for the sports season
- Load all existing eventResults for this event
- For each participant without an eventResult, call
createEventResultwithqualifyingPointsAwarded: 0and no placement - These 0-QP rows do not affect
recalculateParticipantQPtotals (that function only sums entries whereqp > 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
Tprefix 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 countcreate-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 |