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

```typescript
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**

```bash
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`:

```typescript
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**

```bash
npm run test:run -- app/lib/__tests__/parse-results-text.test.ts
```

Expected: all tests pass.

- [ ] **Step 1.5: Commit**

```bash
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`:

```typescript
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**

```bash
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`:

```typescript
/**
 * 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**

```bash
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:

```typescript
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" }`:

```typescript
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**

```bash
npm run typecheck
```

Expected: no errors.

- [ ] **Step 2.7: Commit**

```bash
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:

```typescript
import { createParticipant } from "~/models/participant";
```

- [ ] **Step 3.2: Add `create-participant` intent handler**

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

```typescript
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**

```bash
npm run typecheck
```

Expected: no errors.

- [ ] **Step 3.4: Commit**

```bash
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`:

```typescript
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**

```bash
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:

```typescript
/**
 * 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**

```bash
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:

```typescript
// 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"`:

```typescript
// 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:

```typescript
// 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**

```bash
npm run typecheck
```

Expected: no errors.

- [ ] **Step 4.7: Commit**

```bash
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`:

```typescript
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**

```bash
npm run typecheck
```

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

```bash
npx shadcn@latest add textarea
```

- [ ] **Step 5.3: Commit**

```bash
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:

```typescript
import { BatchResultEntry } from "~/components/BatchResultEntry";
```

- [ ] **Step 6.2: Build the `existingResultParticipantIds` set and render the component**

Find the existing block (around line 416):

```typescript
{/* 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:

```typescript
{/* 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**

```bash
npm run typecheck
```

Expected: no errors.

- [ ] **Step 6.4: Run all tests**

```bash
npm run test:run
```

Expected: all tests pass.

- [ ] **Step 6.5: Commit**

```bash
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**

```bash
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**

```bash
git add -p  # review any remaining changes
git commit -m "feat: batch qualifying results entry complete"
```
