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