diff --git a/docs/superpowers/specs/2026-04-12-batch-qualifying-results-design.md b/docs/superpowers/specs/2026-04-12-batch-qualifying-results-design.md new file mode 100644 index 0000000..23a039d --- /dev/null +++ b/docs/superpowers/specs/2026-04-12-batch-qualifying-results-design.md @@ -0,0 +1,136 @@ +# 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` |