brackt/docs/superpowers/specs/2026-04-12-batch-qualifying-results-design.md
Chris Parsons 1798020c74
feat: batch qualifying results entry (#290)
Fixes #289

* docs: add batch qualifying results entry design spec

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add batch qualifying results entry implementation plan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: ignore .worktrees directory

* feat: add qualifying results text parser with tests

* fix: handle no-space after dot separator, tighten T-prefix to uppercase only

* feat: add batch-add-results server intent with duplicate filtering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add create-participant intent for inline participant creation

* fix: trim sportsSeasonId validation in create-participant intent

* feat: fill 0-QP rows for unplaced participants on qualifying event finalize

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf: use bulk insert for 0-QP rows on qualifying event finalize

* feat: add BatchResultEntry component for paste-and-parse batch result import

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: disable add participant button while create request is in flight

* feat: render BatchResultEntry on qualifying event pages

Add BatchResultEntry component import and render it on event pages when
the event is a qualifying event, not a final_standings/playoff_game type,
and not yet complete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address final review issues (sportsSeasonId authority, participant validation, CRLF, unused prop)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 14:52:22 -07:00

136 lines
6 KiB
Markdown

# 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<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 `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` |