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>
This commit is contained in:
parent
c68ff85429
commit
1798020c74
10 changed files with 1975 additions and 2 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -20,3 +20,4 @@
|
|||
*storybook.log
|
||||
storybook-static
|
||||
.grepai/
|
||||
.worktrees/
|
||||
|
|
|
|||
400
app/components/BatchResultEntry.tsx
Normal file
400
app/components/BatchResultEntry.tsx
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
import { useState, useRef, useEffect } 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 { 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 } 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[];
|
||||
sportsSeasonId: string;
|
||||
existingResultParticipantIds: Set<string>;
|
||||
}
|
||||
|
||||
export function BatchResultEntry({
|
||||
participants,
|
||||
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);
|
||||
const [newParticipantName, setNewParticipantName] = useState("");
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const saveFetcher = useFetcher();
|
||||
const createParticipantFetcher = useFetcher();
|
||||
|
||||
// Mutable lookup built from the prop — updated when new participants are created
|
||||
const participantByNameRef = useRef(
|
||||
new Map(participants.map((p) => [p.name.toLowerCase(), p]))
|
||||
);
|
||||
// Mutable list — grows when new participants are created inline
|
||||
const participantsRef = useRef([...participants]);
|
||||
|
||||
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;
|
||||
// Update lookup and list
|
||||
participantByNameRef.current.set(newP.name.toLowerCase(), newP);
|
||||
participantsRef.current.push(newP);
|
||||
// Auto-select for the row
|
||||
setRows((prev) =>
|
||||
prev.map((row, i) =>
|
||||
i === rowIndex ? { ...row, participantId: newP.id } : row
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [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");
|
||||
}
|
||||
}
|
||||
}, [saveFetcher.state, saveData, stage, revalidate]);
|
||||
|
||||
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 = participantByNameRef.current.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" });
|
||||
pendingNewParticipantRowRef.current = index;
|
||||
setAddingNewFor(null);
|
||||
setNewParticipantName("");
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!allResolved || rows.length === 0) return;
|
||||
setStage("saving");
|
||||
const results = rows
|
||||
.filter((r) => r.participantId !== null)
|
||||
.map((r) => ({
|
||||
participantId: r.participantId as string,
|
||||
placement: r.placement,
|
||||
}));
|
||||
const formData = new FormData();
|
||||
formData.set("intent", "batch-add-results");
|
||||
formData.set("results", JSON.stringify(results));
|
||||
saveFetcher.submit(formData, { method: "post" });
|
||||
}
|
||||
|
||||
const allResolved = rows.every((r) => r.participantId !== null);
|
||||
|
||||
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 — exclude those already in saved results
|
||||
// (but include any that are currently resolved in this batch, to allow re-use)
|
||||
const resolvedIds = new Set(rows.map((r) => r.participantId).filter(Boolean) as string[]);
|
||||
const availableParticipants = participantsRef.current.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={`${row.placement}-${row.rawName}`}>
|
||||
<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">
|
||||
{participantsRef.current.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() || createParticipantFetcher.state !== "idle"}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8"
|
||||
onClick={() => setAddingNewFor(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
110
app/lib/__tests__/parse-results-text.test.ts
Normal file
110
app/lib/__tests__/parse-results-text.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
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" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses '1.Player Name' (no space after dot)", () => {
|
||||
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" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles \\r\\n line endings", () => {
|
||||
const input = "1. Gerwyn Price\r\n2. Luke Littler";
|
||||
expect(parseResultsText(input)).toEqual([
|
||||
{ placement: 1, rawName: "Gerwyn Price" },
|
||||
{ placement: 2, rawName: "Luke Littler" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
51
app/lib/parse-results-text.ts
Normal file
51
app/lib/parse-results-text.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
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(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
|
||||
// Try to match: optional T, digits, optional separator (. or ,), then whitespace and the name
|
||||
// Pattern 1: "1. Name", "1, Name", "T3. Name", "T3, Name"
|
||||
const match = line.match(/^T?(\d+)[.,]\s*(.+)$/);
|
||||
if (match) {
|
||||
results.push({
|
||||
placement: parseInt(match[1], 10),
|
||||
rawName: match[2].trim(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pattern 2: "1 Name", "T3 Name" (space-only separator)
|
||||
const spaceMatch = line.match(/^T?(\d+)\s+(.+)$/);
|
||||
if (spaceMatch) {
|
||||
results.push({
|
||||
placement: parseInt(spaceMatch[1], 10),
|
||||
rawName: spaceMatch[2].trim(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
48
app/routes/__tests__/batch-results-helpers.test.ts
Normal file
48
app/routes/__tests__/batch-results-helpers.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { filterNewResults, findParticipantsWithoutResults } 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([]);
|
||||
});
|
||||
});
|
||||
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { findSportsSeasonById } from "~/models/sports-season";
|
||||
import { findParticipantsBySportsSeasonId } from "~/models/participant";
|
||||
import { findParticipantsBySportsSeasonId, createParticipant } from "~/models/participant";
|
||||
import {
|
||||
getScoringEventById,
|
||||
completeScoringEvent,
|
||||
|
|
@ -10,11 +10,13 @@ import {
|
|||
import {
|
||||
getEventResults,
|
||||
createEventResult,
|
||||
createEventResultsBulk,
|
||||
updateEventResult,
|
||||
deleteEventResult,
|
||||
type CreateEventResultData,
|
||||
type UpdateEventResultData,
|
||||
} from "~/models/event-result";
|
||||
import { filterNewResults, findParticipantsWithoutResults } from "./admin.sports-seasons.$id.events.$eventId.helpers";
|
||||
import { findParticipantResultsBySportsSeasonId } from "~/models/participant-result";
|
||||
import {
|
||||
upsertParticipantSeasonResult,
|
||||
|
|
@ -124,9 +126,30 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
return { success: "Event completed and fantasy placements assigned to top 8!" };
|
||||
}
|
||||
|
||||
// If this is a qualifying event, process qualifying points
|
||||
// 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
|
||||
);
|
||||
|
||||
if (missingIds.length > 0) {
|
||||
await createEventResultsBulk(
|
||||
missingIds.map((participantId) => ({
|
||||
scoringEventId: params.eventId,
|
||||
participantId,
|
||||
qualifyingPointsAwarded: 0,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return { success: "Event completed and qualifying points awarded!" };
|
||||
}
|
||||
|
||||
|
|
@ -323,5 +346,79 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
}
|
||||
|
||||
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" };
|
||||
}
|
||||
|
||||
// Validate that all participant IDs belong to this sports season
|
||||
const allSeasonParticipants = await findParticipantsBySportsSeasonId(params.id);
|
||||
const validParticipantIds = new Set(allSeasonParticipants.map((p) => p.id));
|
||||
const invalidEntries = incoming.filter((r) => !validParticipantIds.has(r.participantId));
|
||||
if (invalidEntries.length > 0) {
|
||||
return { error: "Some participant IDs do not belong to this sports season" };
|
||||
}
|
||||
|
||||
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" };
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "create-participant") {
|
||||
const name = formData.get("name");
|
||||
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
return { error: "Participant name is required" };
|
||||
}
|
||||
|
||||
try {
|
||||
const participant = await createParticipant({
|
||||
name: name.trim(),
|
||||
sportsSeasonId: params.id,
|
||||
});
|
||||
return {
|
||||
success: `Participant "${participant.name}" created.`,
|
||||
newParticipant: { id: participant.id, name: participant.name },
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Error creating participant:", error);
|
||||
if (error instanceof Error && error.message.includes("participants_sports_season_name_unique")) {
|
||||
return { error: `A participant named "${name.trim()}" already exists in this sports season.` };
|
||||
}
|
||||
return { error: "Failed to create participant" };
|
||||
}
|
||||
}
|
||||
|
||||
return { error: "Invalid action" };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
import { useState, useEffect } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { localDateTimeToUtcIso } from "~/lib/date-utils";
|
||||
import { BatchResultEntry } from "~/components/BatchResultEntry";
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `${data?.event?.name ?? "Event"} — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||
|
|
@ -412,6 +413,17 @@ export default function EventResults({
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* Batch Paste Results — primary entry for qualifying events */}
|
||||
{event.isQualifyingEvent && event.eventType !== "final_standings" && event.eventType !== "playoff_game" && !event.isComplete && (
|
||||
<BatchResultEntry
|
||||
participants={participants}
|
||||
sportsSeasonId={sportsSeason.id}
|
||||
existingResultParticipantIds={
|
||||
new Set(results.map((r: { participant: { id: string } }) => r.participant.id))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Regular Result Entry for other event types */}
|
||||
{event.eventType !== "final_standings" && event.eventType !== "playoff_game" && !event.isComplete && (
|
||||
<Card>
|
||||
|
|
|
|||
1096
docs/superpowers/plans/2026-04-12-batch-qualifying-results.md
Normal file
1096
docs/superpowers/plans/2026-04-12-batch-qualifying-results.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,136 @@
|
|||
# Batch Qualifying Results Entry
|
||||
|
||||
**Date:** 2026-04-12
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the one-at-a-time "Add Result" form on qualifying events with a paste-and-parse batch entry flow. Admins paste a ranked results list, resolve any unmatched names, confirm, and save. On finalize, all remaining season participants automatically receive 0-QP rows so simulations can correctly project remaining events.
|
||||
|
||||
The existing single-entry form stays as a fallback for individual corrections after batch import.
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. **Paste & Parse (client-side)** — Admin pastes a ranked text list into a textarea. A pure utility function parses it into `{ placement, rawName }` pairs. No server call.
|
||||
2. **Preview & Resolve (client-side)** — Each parsed name is matched against the participants already in loader data (case-insensitive exact match). Unresolved names show a dropdown to map to an existing participant or an inline form to create a new one.
|
||||
3. **Batch Save (server)** — "Confirm & Save All" posts all rows to a new `batch-add-results` intent, which calls `createEventResultsBulk`. Already-existing results for the event are skipped (idempotent).
|
||||
4. **Finalize (server, modified `complete` intent)** — Existing flow (mark complete → `processQualifyingEvent`) runs unchanged. Afterward, every season participant without an eventResult for this event gets a `qualifyingPointsAwarded = 0` row. These rows signal "competed, earned nothing" for simulations without affecting QP totals.
|
||||
|
||||
---
|
||||
|
||||
## Parser (`app/lib/parse-results-text.ts`)
|
||||
|
||||
Pure function, no React or DB dependencies.
|
||||
|
||||
```typescript
|
||||
interface ParsedLine {
|
||||
placement: number;
|
||||
rawName: string;
|
||||
}
|
||||
|
||||
function parseResultsText(text: string): ParsedLine[]
|
||||
```
|
||||
|
||||
**Supported formats:**
|
||||
|
||||
| Input | Placement |
|
||||
|---|---|
|
||||
| `1. Gerwyn Price` | 1 |
|
||||
| `1, Gerwyn Price` | 1 |
|
||||
| `1 Gerwyn Price` | 1 |
|
||||
| `T3. Luke Littler` | 3 (tied) |
|
||||
| `T3, Luke Littler` | 3 (tied) |
|
||||
| `3. Player A` then `3. Player B` | 3 for both (tied) |
|
||||
| blank lines | ignored |
|
||||
|
||||
Tie detection: `T` prefix OR repeated rank numbers on consecutive lines. Both surfaces as a `T` badge in the preview UI — no change to QP calculation logic (ties already handled correctly in `processQualifyingEvent`).
|
||||
|
||||
---
|
||||
|
||||
## `BatchResultEntry` Component (`app/components/BatchResultEntry.tsx`)
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
{
|
||||
participants: { id: string; name: string }[];
|
||||
eventId: string;
|
||||
sportsSeasonId: string;
|
||||
existingResultParticipantIds: Set<string>;
|
||||
}
|
||||
```
|
||||
|
||||
**State stages:** `idle` → `preview` → `saving`
|
||||
|
||||
- **`idle`**: Textarea with example format placeholder + "Parse Results" button.
|
||||
- **`preview`**: Table of rows (see below) + "Back" link + "Confirm & Save All" button (disabled until all rows resolved).
|
||||
- **`saving`**: Spinner on button. On success: inline "✓ X results imported" message + `revalidate()` to refresh loader data.
|
||||
|
||||
**Preview table row states:**
|
||||
|
||||
| State | Visual | Behavior |
|
||||
|---|---|---|
|
||||
| Matched | Green badge | Read-only — name matched case-insensitively |
|
||||
| Unresolved — map | Amber | Dropdown of unplaced participants; selecting resolves the row |
|
||||
| Unresolved — add new | Red | "Add new participant…" option expands inline form with name pre-filled; on save, new participant is auto-selected |
|
||||
|
||||
**Placement in page:** Rendered above the existing "Add Result" card. Only shown for qualifying events (`event.isQualifyingEvent`) that are not yet complete.
|
||||
|
||||
---
|
||||
|
||||
## Server Changes (`admin.sports-seasons.$id.events.$eventId.server.ts`)
|
||||
|
||||
### New intent: `batch-add-results`
|
||||
|
||||
- Receives hidden field `results` as a JSON string: `{ participantId: string, placement: number }[]`
|
||||
- Loads existing eventResults for the event; filters out any participantId already present (idempotent)
|
||||
- Calls existing `createEventResultsBulk` with remaining rows
|
||||
- Returns `{ success: "X results saved." }`
|
||||
|
||||
### New intent: `create-participant`
|
||||
|
||||
- Receives `name` (string) and `sportsSeasonId` (string)
|
||||
- Calls `createParticipant` from `app/models/participant.ts`
|
||||
- Links new participant to the sports season via the existing join table (verify exact table/column during implementation by reading `findParticipantsBySportsSeasonId`)
|
||||
- Returns `{ participantId, participantName }` so the component can auto-select the new row
|
||||
|
||||
### Modified intent: `complete` (qualifying events)
|
||||
|
||||
Existing flow unchanged. This step only runs when `event.isQualifyingEvent` is true (already the existing gate). After `processQualifyingEvent`:
|
||||
|
||||
1. Load all participants for the sports season
|
||||
2. Load all existing eventResults for this event
|
||||
3. For each participant without an eventResult, call `createEventResult` with `qualifyingPointsAwarded: 0` and no placement
|
||||
4. These 0-QP rows do not affect `recalculateParticipantQP` totals (that function only sums entries where `qp > 0`) but mark participation for simulation logic
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests — parser (`app/lib/__tests__/parse-results-text.test.ts`)
|
||||
- Dot, comma, and space separators parse correctly
|
||||
- `T` prefix is detected as a tie
|
||||
- Repeated rank numbers on consecutive lines both get the same placement
|
||||
- Blank lines and extra whitespace are ignored
|
||||
- Mixed formats in one paste work correctly
|
||||
|
||||
### Unit tests — server actions
|
||||
- `batch-add-results`: skips existing result participantIds, saves new rows, returns correct count
|
||||
- `create-participant`: creates participant and season link, returns id and name
|
||||
- Modified `complete`: after processing, all season participants have an eventResult row; 0-QP rows do not change QP totals
|
||||
|
||||
### No E2E tests
|
||||
Admin flows are not covered by Cypress; this feature does not touch user-facing pages.
|
||||
|
||||
---
|
||||
|
||||
## Files Touched
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `app/lib/parse-results-text.ts` | New — parser utility |
|
||||
| `app/lib/__tests__/parse-results-text.test.ts` | New — parser unit tests |
|
||||
| `app/components/BatchResultEntry.tsx` | New — batch paste component |
|
||||
| `app/routes/admin.sports-seasons.$id.events.$eventId.tsx` | Add `BatchResultEntry` above single-entry form |
|
||||
| `app/routes/admin.sports-seasons.$id.events.$eventId.server.ts` | Add `batch-add-results`, `create-participant` intents; modify `complete` |
|
||||
Loading…
Add table
Reference in a new issue