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

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

  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.

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: idlepreviewsaving

  • 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