diff --git a/.gitignore b/.gitignore index 62460a7..793c72e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ *storybook.log storybook-static .grepai/ +.worktrees/ diff --git a/app/components/BatchResultEntry.tsx b/app/components/BatchResultEntry.tsx new file mode 100644 index 0000000..9152639 --- /dev/null +++ b/app/components/BatchResultEntry.tsx @@ -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; +} + +export function BatchResultEntry({ + participants, + sportsSeasonId, + existingResultParticipantIds, +}: BatchResultEntryProps) { + const [stage, setStage] = useState("idle"); + const [pasteText, setPasteText] = useState(""); + const [rows, setRows] = useState([]); + const [parseError, setParseError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [addingNewFor, setAddingNewFor] = useState(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(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 ( + + +
+ + {successMessage} + +
+
+
+ ); + } + + // 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 ( + + + + + Batch Paste Results + + + Paste a ranked results list to import all placements at once. Format:{" "} + + 1. Player Name + {" "} + — use{" "} + T3 or + repeat a rank for ties. + + + + {stage === "idle" && ( +
+