brackt/docs/superpowers/plans/2026-04-12-batch-qualifying-results.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

34 KiB

Batch Qualifying Results Entry Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a paste-and-parse batch entry flow to qualifying event pages so admins can enter all results at once instead of one at a time.

Architecture: A new pure parser utility handles text → { placement, rawName }[]. A new BatchResultEntry React component handles the paste/preview/resolve/confirm UI entirely client-side, posting to a new batch-add-results server action. On finalize, the existing complete intent is extended to write 0-QP rows for all season participants who have no result.

Tech Stack: React Router 7 (SSR), TypeScript, Vitest, Drizzle ORM, TailwindCSS + ShadCN UI


File Map

File Change Responsibility
app/lib/parse-results-text.ts Create Pure parser: text → ParsedLine[]
app/lib/__tests__/parse-results-text.test.ts Create Unit tests for parser
app/components/BatchResultEntry.tsx Create Paste/preview/resolve/confirm UI component
app/routes/admin.sports-seasons.$id.events.$eventId.server.ts Modify Add batch-add-results, create-participant intents; extend complete
app/routes/admin.sports-seasons.$id.events.$eventId.tsx Modify Render BatchResultEntry above single-entry form

Task 1: Parser Utility (TDD)

Files:

  • Create: app/lib/parse-results-text.ts

  • Create: app/lib/__tests__/parse-results-text.test.ts

  • Step 1.1: Create the test file with failing tests

Create app/lib/__tests__/parse-results-text.test.ts:

import { describe, it, expect } from "vitest";
import { parseResultsText } from "../parse-results-text";

describe("parseResultsText", () => {
  describe("dot separator", () => {
    it("parses '1. Player Name'", () => {
      expect(parseResultsText("1. Gerwyn Price")).toEqual([
        { placement: 1, rawName: "Gerwyn Price" },
      ]);
    });
  });

  describe("comma separator", () => {
    it("parses '1, Player Name'", () => {
      expect(parseResultsText("1, Gerwyn Price")).toEqual([
        { placement: 1, rawName: "Gerwyn Price" },
      ]);
    });
  });

  describe("space-only separator", () => {
    it("parses '1 Player Name'", () => {
      expect(parseResultsText("1 Gerwyn Price")).toEqual([
        { placement: 1, rawName: "Gerwyn Price" },
      ]);
    });
  });

  describe("T-prefix ties", () => {
    it("parses 'T3. Player Name' as placement 3", () => {
      expect(parseResultsText("T3. Luke Littler")).toEqual([
        { placement: 3, rawName: "Luke Littler" },
      ]);
    });

    it("parses 'T3, Player Name' as placement 3", () => {
      expect(parseResultsText("T3, Luke Littler")).toEqual([
        { placement: 3, rawName: "Luke Littler" },
      ]);
    });

    it("parses two T-prefix lines with same rank as both placement 3", () => {
      const input = "T3. Luke Littler\nT3. Michael van Gerwen";
      expect(parseResultsText(input)).toEqual([
        { placement: 3, rawName: "Luke Littler" },
        { placement: 3, rawName: "Michael van Gerwen" },
      ]);
    });
  });

  describe("repeated-rank ties", () => {
    it("parses two lines with the same rank number as both tied", () => {
      const input = "3. Luke Littler\n3. Michael van Gerwen";
      expect(parseResultsText(input)).toEqual([
        { placement: 3, rawName: "Luke Littler" },
        { placement: 3, rawName: "Michael van Gerwen" },
      ]);
    });
  });

  describe("blank lines and whitespace", () => {
    it("ignores blank lines", () => {
      const input = "1. Gerwyn Price\n\n2. Luke Littler";
      expect(parseResultsText(input)).toEqual([
        { placement: 1, rawName: "Gerwyn Price" },
        { placement: 2, rawName: "Luke Littler" },
      ]);
    });

    it("trims leading and trailing whitespace from names", () => {
      expect(parseResultsText("1.  Gerwyn Price  ")).toEqual([
        { placement: 1, rawName: "Gerwyn Price" },
      ]);
    });
  });

  describe("multi-line mixed formats", () => {
    it("parses a realistic paste with multiple formats", () => {
      const input = [
        "1. Gerwyn Price",
        "2, Luke Littler",
        "T3. Michael van Gerwen",
        "T3. Peter Wright",
        "",
        "5 Damon Heta",
      ].join("\n");
      expect(parseResultsText(input)).toEqual([
        { placement: 1, rawName: "Gerwyn Price" },
        { placement: 2, rawName: "Luke Littler" },
        { placement: 3, rawName: "Michael van Gerwen" },
        { placement: 3, rawName: "Peter Wright" },
        { placement: 5, rawName: "Damon Heta" },
      ]);
    });
  });
});
  • Step 1.2: Run tests to confirm they fail
npm run test:run -- app/lib/__tests__/parse-results-text.test.ts

Expected: fail with "Cannot find module '../parse-results-text'"

  • Step 1.3: Implement the parser

Create app/lib/parse-results-text.ts:

export interface ParsedLine {
  placement: number;
  rawName: string;
}

/**
 * Parse a pasted results text into placement + name pairs.
 *
 * Supported formats per line:
 *   1. Name        (dot separator)
 *   1, Name        (comma separator)
 *   1 Name         (space separator)
 *   T3. Name       (T-prefix tie, dot separator)
 *   T3, Name       (T-prefix tie, comma separator)
 *   T3 Name        (T-prefix tie, space separator)
 *
 * Repeated rank numbers across consecutive lines are both emitted
 * with the same placement (treated as ties automatically).
 * Blank lines are ignored.
 */
export function parseResultsText(text: string): ParsedLine[] {
  const results: ParsedLine[] = [];

  for (const rawLine of text.split("\n")) {
    const line = rawLine.trim();
    if (!line) continue;

    // Match optional T prefix, then a number, then an optional separator (. or ,), then the name
    const match = line.match(/^[Tt]?(\d+)[.,]?\s+(.+)$/);
    if (!match) {
      // Try space-only separator: "1 Name" or "T3 Name"
      const spaceMatch = line.match(/^[Tt]?(\d+)\s+(.+)$/);
      if (spaceMatch) {
        results.push({
          placement: parseInt(spaceMatch[1], 10),
          rawName: spaceMatch[2].trim(),
        });
      }
      continue;
    }

    results.push({
      placement: parseInt(match[1], 10),
      rawName: match[2].trim(),
    });
  }

  return results;
}
  • Step 1.4: Run tests to confirm they pass
npm run test:run -- app/lib/__tests__/parse-results-text.test.ts

Expected: all tests pass.

  • Step 1.5: Commit
git add app/lib/parse-results-text.ts app/lib/__tests__/parse-results-text.test.ts
git commit -m "feat: add qualifying results text parser with tests"

Task 2: batch-add-results Server Intent

Files:

  • Modify: app/routes/admin.sports-seasons.$id.events.$eventId.server.ts

The duplicate-skipping logic is a pure function — test it in isolation before wiring it up.

  • Step 2.1: Add the pure helper and its test

Create app/routes/__tests__/batch-results-helpers.test.ts:

import { describe, it, expect } from "vitest";
import { filterNewResults } from "../admin.sports-seasons.$id.events.$eventId.helpers";

describe("filterNewResults", () => {
  it("returns all rows when no existing results", () => {
    const incoming = [
      { participantId: "p1", placement: 1 },
      { participantId: "p2", placement: 2 },
    ];
    expect(filterNewResults(incoming, new Set())).toEqual(incoming);
  });

  it("skips participantIds already in existingIds", () => {
    const incoming = [
      { participantId: "p1", placement: 1 },
      { participantId: "p2", placement: 2 },
    ];
    const existing = new Set(["p1"]);
    expect(filterNewResults(incoming, existing)).toEqual([
      { participantId: "p2", placement: 2 },
    ]);
  });

  it("returns empty array when all are duplicates", () => {
    const incoming = [{ participantId: "p1", placement: 1 }];
    const existing = new Set(["p1"]);
    expect(filterNewResults(incoming, existing)).toEqual([]);
  });
});
  • Step 2.2: Run test to confirm it fails
npm run test:run -- app/routes/__tests__/batch-results-helpers.test.ts

Expected: fail with "Cannot find module"

  • Step 2.3: Create the helpers file

Create app/routes/admin.sports-seasons.$id.events.$eventId.helpers.ts:

/**
 * Filter incoming batch results to only those whose participantId
 * is not already in the set of existing result participantIds.
 */
export function filterNewResults(
  incoming: { participantId: string; placement: number }[],
  existingParticipantIds: Set<string>
): { participantId: string; placement: number }[] {
  return incoming.filter((r) => !existingParticipantIds.has(r.participantId));
}
  • Step 2.4: Run test to confirm it passes
npm run test:run -- app/routes/__tests__/batch-results-helpers.test.ts

Expected: all tests pass.

  • Step 2.5: Add batch-add-results intent to server action

In app/routes/admin.sports-seasons.$id.events.$eventId.server.ts, add after the existing imports:

import { filterNewResults } from "./admin.sports-seasons.$id.events.$eventId.helpers";

Then add the following intent handler inside the action function, before the final return { error: "Invalid action" }:

if (intent === "batch-add-results") {
  const resultsJson = formData.get("results");
  if (typeof resultsJson !== "string" || !resultsJson) {
    return { error: "Results data is required" };
  }

  let incoming: { participantId: string; placement: number }[];
  try {
    incoming = JSON.parse(resultsJson);
  } catch {
    return { error: "Invalid results format" };
  }

  if (!Array.isArray(incoming) || incoming.length === 0) {
    return { error: "No results provided" };
  }

  try {
    const existingResults = await getEventResults(params.eventId);
    const existingIds = new Set(existingResults.map((r) => r.participantId));
    const newResults = filterNewResults(incoming, existingIds);

    if (newResults.length > 0) {
      await createEventResultsBulk(
        newResults.map((r) => ({
          scoringEventId: params.eventId,
          participantId: r.participantId,
          placement: r.placement,
        }))
      );
    }

    return {
      success: `${newResults.length} result${newResults.length === 1 ? "" : "s"} saved${incoming.length - newResults.length > 0 ? ` (${incoming.length - newResults.length} duplicate${incoming.length - newResults.length === 1 ? "" : "s"} skipped)` : ""}.`,
    };
  } catch (error) {
    logger.error("Error saving batch results:", error);
    return { error: "Failed to save results" };
  }
}
  • Step 2.6: Verify TypeScript compiles
npm run typecheck

Expected: no errors.

  • Step 2.7: Commit
git add app/routes/admin.sports-seasons.$id.events.$eventId.server.ts \
        app/routes/admin.sports-seasons.$id.events.$eventId.helpers.ts \
        app/routes/__tests__/batch-results-helpers.test.ts
git commit -m "feat: add batch-add-results server intent with duplicate filtering"

Task 3: create-participant Server Intent

Files:

  • Modify: app/routes/admin.sports-seasons.$id.events.$eventId.server.ts

  • Step 3.1: Add import for createParticipant

In app/routes/admin.sports-seasons.$id.events.$eventId.server.ts, add to the existing imports:

import { createParticipant } from "~/models/participant";
  • Step 3.2: Add create-participant intent handler

Inside the action function, before the final return { error: "Invalid action" }:

if (intent === "create-participant") {
  const name = formData.get("name");
  const sportsSeasonId = formData.get("sportsSeasonId");

  if (typeof name !== "string" || !name.trim()) {
    return { error: "Participant name is required" };
  }

  if (typeof sportsSeasonId !== "string" || !sportsSeasonId) {
    return { error: "Sports season ID is required" };
  }

  try {
    const participant = await createParticipant({
      name: name.trim(),
      sportsSeasonId,
    });
    return {
      success: `Participant "${participant.name}" created.`,
      newParticipant: { id: participant.id, name: participant.name },
    };
  } catch (error) {
    logger.error("Error creating participant:", error);
    return { error: "Failed to create participant" };
  }
}
  • Step 3.3: Verify TypeScript compiles
npm run typecheck

Expected: no errors.

  • Step 3.4: Commit
git add app/routes/admin.sports-seasons.$id.events.$eventId.server.ts
git commit -m "feat: add create-participant intent for inline participant creation"

Task 4: Extend complete Intent to Write 0-QP Rows

Files:

  • Modify: app/routes/admin.sports-seasons.$id.events.$eventId.server.ts

The gap-filling logic is a pure function — test it first.

  • Step 4.1: Add test for gap-filling helper

Add to app/routes/__tests__/batch-results-helpers.test.ts:

import { filterNewResults, findParticipantsWithoutResults } from "../admin.sports-seasons.$id.events.$eventId.helpers";

// ... existing tests above ...

describe("findParticipantsWithoutResults", () => {
  it("returns participantIds not in existingResultIds", () => {
    const allIds = ["p1", "p2", "p3"];
    const existingIds = new Set(["p1"]);
    expect(findParticipantsWithoutResults(allIds, existingIds)).toEqual(["p2", "p3"]);
  });

  it("returns empty array when all participants have results", () => {
    const allIds = ["p1", "p2"];
    const existingIds = new Set(["p1", "p2"]);
    expect(findParticipantsWithoutResults(allIds, existingIds)).toEqual([]);
  });

  it("returns all when none have results", () => {
    const allIds = ["p1", "p2"];
    expect(findParticipantsWithoutResults(allIds, new Set())).toEqual(["p1", "p2"]);
  });
});
  • Step 4.2: Run tests to confirm new tests fail
npm run test:run -- app/routes/__tests__/batch-results-helpers.test.ts

Expected: the new findParticipantsWithoutResults tests fail.

  • Step 4.3: Add the helper function

In app/routes/admin.sports-seasons.$id.events.$eventId.helpers.ts, add:

/**
 * Return participantIds from allParticipantIds that have no entry
 * in existingResultParticipantIds. Used to find participants who
 * need a 0-QP row after a qualifying event is finalized.
 */
export function findParticipantsWithoutResults(
  allParticipantIds: string[],
  existingResultParticipantIds: Set<string>
): string[] {
  return allParticipantIds.filter((id) => !existingResultParticipantIds.has(id));
}
  • Step 4.4: Run tests to confirm they pass
npm run test:run -- app/routes/__tests__/batch-results-helpers.test.ts

Expected: all tests pass.

  • Step 4.5: Update helper import and extend the complete intent

In admin.sports-seasons.$id.events.$eventId.server.ts, find the import added in Task 2 and update it to include the new helper:

// Before (from Task 2):
import { filterNewResults } from "./admin.sports-seasons.$id.events.$eventId.helpers";

// After:
import { filterNewResults, findParticipantsWithoutResults } from "./admin.sports-seasons.$id.events.$eventId.helpers";

Find the existing block inside intent === "complete":

// If this is a qualifying event, process qualifying points
if (event.isQualifyingEvent) {
  await processQualifyingEvent(params.eventId);
  return { success: "Event completed and qualifying points awarded!" };
}

Replace it with:

// If this is a qualifying event, process qualifying points then fill 0-QP rows
if (event.isQualifyingEvent) {
  await processQualifyingEvent(params.eventId);

  // Write 0-QP rows for all season participants who have no result for this event.
  // This marks them as "competed, earned nothing" so simulations skip this event.
  const allParticipants = await findParticipantsBySportsSeasonId(params.id);
  const existingResults = await getEventResults(params.eventId);
  const existingIds = new Set(existingResults.map((r) => r.participantId));
  const missingIds = findParticipantsWithoutResults(
    allParticipants.map((p) => p.id),
    existingIds
  );

  for (const participantId of missingIds) {
    await createEventResult({
      scoringEventId: params.eventId,
      participantId,
      qualifyingPointsAwarded: 0,
    });
  }

  return { success: "Event completed and qualifying points awarded!" };
}

Also ensure findParticipantsBySportsSeasonId and createEventResult are in the imports at the top of the file. findParticipantsBySportsSeasonId is likely already imported (it's used in the loader). createEventResult is already imported.

  • Step 4.6: Verify TypeScript compiles
npm run typecheck

Expected: no errors.

  • Step 4.7: Commit
git add app/routes/admin.sports-seasons.$id.events.$eventId.server.ts \
        app/routes/admin.sports-seasons.$id.events.$eventId.helpers.ts \
        app/routes/__tests__/batch-results-helpers.test.ts
git commit -m "feat: fill 0-QP rows for unplaced participants on qualifying event finalize"

Task 5: BatchResultEntry Component

Files:

  • Create: app/components/BatchResultEntry.tsx

This component is entirely client-side state — no tests needed beyond what manual use will cover (the parsing logic is already unit-tested in Task 1).

  • Step 5.1: Create the component

Create app/components/BatchResultEntry.tsx:

import { useState, useRef } from "react";
import { useFetcher, useRevalidator } from "react-router";
import { Button } from "~/components/ui/button";
import { Textarea } from "~/components/ui/textarea";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Badge } from "~/components/ui/badge";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "~/components/ui/select";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "~/components/ui/table";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { CheckCircle2, Trophy, ChevronDown, ChevronUp } from "lucide-react";
import { parseResultsText } from "~/lib/parse-results-text";

interface Participant {
  id: string;
  name: string;
}

interface ResolvedRow {
  placement: number;
  rawName: string;
  participantId: string | null; // null = unresolved
}

type Stage = "idle" | "preview" | "saving";

interface BatchResultEntryProps {
  participants: Participant[];
  eventId: string;
  sportsSeasonId: string;
  existingResultParticipantIds: Set<string>;
}

export function BatchResultEntry({
  participants,
  eventId,
  sportsSeasonId,
  existingResultParticipantIds,
}: BatchResultEntryProps) {
  const [stage, setStage] = useState<Stage>("idle");
  const [pasteText, setPasteText] = useState("");
  const [rows, setRows] = useState<ResolvedRow[]>([]);
  const [parseError, setParseError] = useState<string | null>(null);
  const [successMessage, setSuccessMessage] = useState<string | null>(null);
  const [addingNewFor, setAddingNewFor] = useState<number | null>(null); // row index
  const [newParticipantName, setNewParticipantName] = useState("");
  const { revalidate } = useRevalidator();

  const saveFetcher = useFetcher();
  const createParticipantFetcher = useFetcher();

  // Build lookup: lowercase name → participant
  const participantByName = new Map(
    participants.map((p) => [p.name.toLowerCase(), p])
  );

  function handleParse() {
    setParseError(null);
    const parsed = parseResultsText(pasteText);
    if (parsed.length === 0) {
      setParseError("No results found. Check the format and try again.");
      return;
    }

    const resolved: ResolvedRow[] = parsed.map((line) => {
      const match = participantByName.get(line.rawName.toLowerCase());
      return {
        placement: line.placement,
        rawName: line.rawName,
        participantId: match ? match.id : null,
      };
    });

    setRows(resolved);
    setStage("preview");
  }

  function setRowParticipant(index: number, participantId: string) {
    setRows((prev) =>
      prev.map((row, i) => (i === index ? { ...row, participantId } : row))
    );
  }

  function handleAddNewParticipant(index: number) {
    setAddingNewFor(index);
    setNewParticipantName(rows[index].rawName);
  }

  function handleCreateParticipant(index: number) {
    if (!newParticipantName.trim()) return;

    const formData = new FormData();
    formData.set("intent", "create-participant");
    formData.set("name", newParticipantName.trim());
    formData.set("sportsSeasonId", sportsSeasonId);

    createParticipantFetcher.submit(formData, { method: "post" });
    // Watch for response in effect below
    setAddingNewFor(null);
    setNewParticipantName("");
    // Store which row we were adding for so we can update it
    pendingNewParticipantRowRef.current = index;
  }

  const pendingNewParticipantRowRef = useRef<number | null>(null);

  const createData = createParticipantFetcher.data as
    | { newParticipant?: { id: string; name: string }; error?: string }
    | undefined;

  const saveData = saveFetcher.data as
    | { success?: string; error?: string }
    | undefined;

  // When createParticipantFetcher returns a new participant, auto-select it
  useEffect(() => {
    if (
      createData?.newParticipant &&
      pendingNewParticipantRowRef.current !== null
    ) {
      const newP = createData.newParticipant;
      const rowIndex = pendingNewParticipantRowRef.current;
      pendingNewParticipantRowRef.current = null;
      setRows((prev) =>
        prev.map((row, i) =>
          i === rowIndex ? { ...row, participantId: newP.id } : row
        )
      );
      // Add new participant to lookup for future rows (note: mutates prop array for immediate lookup)
      participants.push(newP);
      participantByName.set(newP.name.toLowerCase(), newP);
    }
  }, [createData]);

  // Watch save fetcher for completion
  useEffect(() => {
    if (stage === "saving" && saveFetcher.state === "idle" && saveData) {
      if (saveData.success) {
        setSuccessMessage(saveData.success);
        setStage("idle");
        setPasteText("");
        setRows([]);
        revalidate();
      } else if (saveData.error) {
        setStage("preview"); // allow retry
      }
    }
  }, [saveFetcher.state, saveData]);

  const allResolved = rows.every((r) => r.participantId !== null);

  function handleConfirm() {
    if (!allResolved) return;
    setStage("saving");

    const results = rows.map((r) => ({
      participantId: r.participantId!,
      placement: r.placement,
    }));

    const formData = new FormData();
    formData.set("intent", "batch-add-results");
    formData.set("results", JSON.stringify(results));
    saveFetcher.submit(formData, { method: "post" });
  }

  if (successMessage) {
    return (
      <Card className="border-emerald-500/30">
        <CardContent className="pt-6">
          <div className="flex items-center gap-2 text-emerald-500">
            <CheckCircle2 className="h-4 w-4" />
            <span className="text-sm font-medium">{successMessage}</span>
            <Button
              variant="ghost"
              size="sm"
              className="ml-auto"
              onClick={() => setSuccessMessage(null)}
            >
              Import more
            </Button>
          </div>
        </CardContent>
      </Card>
    );
  }

  // Participants available for mapping (not already in results, not already resolved in another row)
  const resolvedIds = new Set(rows.map((r) => r.participantId).filter(Boolean));
  const availableParticipants = participants.filter(
    (p) =>
      !existingResultParticipantIds.has(p.id) || resolvedIds.has(p.id)
  );

  return (
    <Card>
      <CardHeader>
        <CardTitle>
          <Trophy className="inline mr-2 h-4 w-4" />
          Batch Paste Results
        </CardTitle>
        <CardDescription>
          Paste a ranked results list to import all placements at once.
          Format: <code className="text-xs bg-muted px-1 py-0.5 rounded">1. Player Name</code>{" "}
           use <code className="text-xs bg-muted px-1 py-0.5 rounded">T3</code> or repeat a rank for ties.
        </CardDescription>
      </CardHeader>
      <CardContent>
        {stage === "idle" && (
          <div className="space-y-4">
            <Textarea
              value={pasteText}
              onChange={(e) => setPasteText(e.target.value)}
              placeholder={`1. Gerwyn Price\n2. Luke Littler\nT3. Michael van Gerwen\nT3. Peter Wright\n5. Damon Heta`}
              rows={10}
              className="font-mono text-sm"
            />
            {parseError && (
              <p className="text-sm text-destructive">{parseError}</p>
            )}
            <Button
              onClick={handleParse}
              disabled={!pasteText.trim()}
              className="w-full"
            >
              Parse Results
            </Button>
          </div>
        )}

        {(stage === "preview" || stage === "saving") && (
          <div className="space-y-4">
            <Table>
              <TableHeader>
                <TableRow>
                  <TableHead className="w-24">Placement</TableHead>
                  <TableHead>Parsed Name</TableHead>
                  <TableHead>Matched Participant</TableHead>
                  <TableHead className="w-20">Status</TableHead>
                </TableRow>
              </TableHeader>
              <TableBody>
                {rows.map((row, index) => {
                  const isTied =
                    rows.filter((r) => r.placement === row.placement).length > 1;

                  return (
                    <TableRow key={index}>
                      <TableCell>
                        <div className="flex items-center gap-1">
                          <span className="font-semibold">{row.placement}</span>
                          {isTied && (
                            <Badge variant="outline" className="text-xs px-1 py-0">
                              T
                            </Badge>
                          )}
                        </div>
                      </TableCell>
                      <TableCell className="text-muted-foreground text-sm">
                        {row.rawName}
                      </TableCell>
                      <TableCell>
                        {row.participantId ? (
                          <span className="text-sm text-emerald-600 dark:text-emerald-400 font-medium">
                            {participants.find((p) => p.id === row.participantId)
                              ?.name ?? row.rawName}
                          </span>
                        ) : addingNewFor === index ? (
                          <div className="flex items-center gap-2">
                            <Input
                              value={newParticipantName}
                              onChange={(e) =>
                                setNewParticipantName(e.target.value)
                              }
                              className="h-8 text-sm"
                              placeholder="Participant name"
                            />
                            <Button
                              size="sm"
                              className="h-8"
                              onClick={() => handleCreateParticipant(index)}
                              disabled={!newParticipantName.trim()}
                            >
                              Add
                            </Button>
                            <Button
                              size="sm"
                              variant="ghost"
                              className="h-8"
                              onClick={() => setAddingNewFor(null)}
                            >
                              Cancel
                            </Button>
                          </div>
                        ) : (
                          <div className="flex items-center gap-2">
                            <Select
                              onValueChange={(val) => {
                                if (val === "__add_new__") {
                                  handleAddNewParticipant(index);
                                } else {
                                  setRowParticipant(index, val);
                                }
                              }}
                            >
                              <SelectTrigger className="h-8 text-sm">
                                <SelectValue placeholder="Select participant…" />
                              </SelectTrigger>
                              <SelectContent>
                                {availableParticipants.map((p) => (
                                  <SelectItem key={p.id} value={p.id}>
                                    {p.name}
                                  </SelectItem>
                                ))}
                                <SelectItem value="__add_new__">
                                  + Add new participant
                                </SelectItem>
                              </SelectContent>
                            </Select>
                          </div>
                        )}
                      </TableCell>
                      <TableCell>
                        {row.participantId ? (
                          <Badge className="bg-emerald-500 text-white text-xs">
                            
                          </Badge>
                        ) : (
                          <Badge variant="destructive" className="text-xs">
                            !
                          </Badge>
                        )}
                      </TableCell>
                    </TableRow>
                  );
                })}
              </TableBody>
            </Table>

            {saveData?.error && (
              <p className="text-sm text-destructive">{saveData.error}</p>
            )}
            {createData?.error && (
              <p className="text-sm text-destructive">{createData.error}</p>
            )}

            <div className="flex gap-2">
              <Button
                variant="outline"
                onClick={() => setStage("idle")}
                disabled={stage === "saving"}
              >
                Back
              </Button>
              <Button
                onClick={handleConfirm}
                disabled={!allResolved || stage === "saving"}
                className="flex-1"
              >
                {stage === "saving" ? (
                  "Saving…"
                ) : (
                  <>
                    <CheckCircle2 className="mr-2 h-4 w-4" />
                    Confirm & Save All ({rows.length} result
                    {rows.length === 1 ? "" : "s"})
                  </>
                )}
              </Button>
            </div>
          </div>
        )}
      </CardContent>
    </Card>
  );
}
  • Step 5.2: Verify TypeScript compiles
npm run typecheck

Expected: no errors. If ShadCN's Textarea component doesn't exist yet, install it:

npx shadcn@latest add textarea
  • Step 5.3: Commit
git add app/components/BatchResultEntry.tsx
git commit -m "feat: add BatchResultEntry component for paste-and-parse batch result import"

Task 6: Wire Up Component on Event Page

Files:

  • Modify: app/routes/admin.sports-seasons.$id.events.$eventId.tsx

  • Step 6.1: Add the import

In app/routes/admin.sports-seasons.$id.events.$eventId.tsx, add to the existing import block:

import { BatchResultEntry } from "~/components/BatchResultEntry";
  • Step 6.2: Build the existingResultParticipantIds set and render the component

Find the existing block (around line 416):

{/* Regular Result Entry for other event types */}
{event.eventType !== "final_standings" && event.eventType !== "playoff_game" && !event.isComplete && (
  <Card>
    <CardHeader>
      <CardTitle>Add Result</CardTitle>

Insert the following immediately before that block:

{/* Batch Paste Results — primary entry for qualifying events */}
{event.isQualifyingEvent && event.eventType !== "final_standings" && event.eventType !== "playoff_game" && !event.isComplete && (
  <BatchResultEntry
    participants={participants}
    eventId={event.id}
    sportsSeasonId={sportsSeason.id}
    existingResultParticipantIds={
      new Set(results.map((r: { participant: { id: string } }) => r.participant.id))
    }
  />
)}
  • Step 6.3: Verify TypeScript compiles
npm run typecheck

Expected: no errors.

  • Step 6.4: Run all tests
npm run test:run

Expected: all tests pass.

  • Step 6.5: Commit
git add app/routes/admin.sports-seasons.$id.events.$eventId.tsx
git commit -m "feat: render BatchResultEntry on qualifying event pages"

Task 7: Manual Smoke Test

Start the dev server and navigate to a qualifying event page to verify the feature end to end.

  • Step 7.1: Start the dev server
npm run dev
  • Step 7.2: Navigate to a qualifying event

Go to /admin/sports-seasons/<id>/events/<eventId> for a major_tournament event with isQualifyingEvent = true that is not yet complete.

  • Step 7.3: Test the happy path
  1. Paste a list of results in the textarea (use real participant names from the season)
  2. Click "Parse Results" — preview table should appear with green matches
  3. Click "Confirm & Save All" — success message appears, results table below updates
  4. Click "Mark Event Complete" — qualifying points are awarded and 0-QP rows exist for all remaining participants
  • Step 7.4: Test unresolved names
  1. Paste a name that doesn't match any participant
  2. Row should show amber status and a Select dropdown
  3. Test mapping to an existing participant — row turns green
  4. Test "Add new participant…" — inline form appears, submit creates and auto-selects the new participant
  • Step 7.5: Test idempotency
  1. After some results are saved, paste the same list again
  2. Click Confirm — success message should show "X duplicates skipped"
  • Step 7.6: Final commit
git add -p  # review any remaining changes
git commit -m "feat: batch qualifying results entry complete"