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>
1096 lines
34 KiB
Markdown
1096 lines
34 KiB
Markdown
# 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"
|
|
```
|