From 54fd6abb5e62f5d6a6942062ac2d9c2f8af45494 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Sun, 29 Mar 2026 19:58:15 -0700 Subject: [PATCH] feat: add tournament creation wizard with participant management and bulk game entry --- prisma/dev.db | Bin 126976 -> 126976 bytes .../migration.sql | 2 + prisma/prisma/dev.db | Bin 376832 -> 376832 bytes prisma/schema.prisma | 1 + src/app/admin/tournaments/[id]/entry/page.tsx | 299 +++++++++++++ src/app/admin/tournaments/new/page.tsx | 408 +++++++++++++----- src/app/api/matches/upload/route.ts | 17 + src/app/api/players/search/route.ts | 32 ++ .../api/tournaments/[id]/games/bulk/route.ts | 246 +++++++++++ .../tournaments/[id]/participants/route.ts | 76 ++++ 10 files changed, 984 insertions(+), 97 deletions(-) create mode 100644 prisma/migrations/20260330200000_add_event_participant_unique/migration.sql create mode 100644 src/app/admin/tournaments/[id]/entry/page.tsx create mode 100644 src/app/api/players/search/route.ts create mode 100644 src/app/api/tournaments/[id]/games/bulk/route.ts create mode 100644 src/app/api/tournaments/[id]/participants/route.ts diff --git a/prisma/dev.db b/prisma/dev.db index 10470f484a7eed6840083aeea6a844a499202473..9a3a28c7eeee3489a12b7ad81383a0a928158371 100644 GIT binary patch delta 248 zcmZp8z~1nHeS);04g&*&JP^Zx@D zZw^MOiH>6Rjau^T;?ahTjiH%&DXA5yWvO{3@db%RC7H>Y1&Mhj#qnSv&y@IroW#o1 zA|N9>wGvaAymOGNV~DFlsGnzGsH=jfpNngR0;V!_)AYc`Dfs(gQL6#9Sw{h8wdVHk N+>D%8mMJg_003-*QYQca delta 77 zcmZp8z~1nHeS);0HUk5LJQOnmX{Cue#*Eq<6PEmE9#{d8T diff --git a/prisma/migrations/20260330200000_add_event_participant_unique/migration.sql b/prisma/migrations/20260330200000_add_event_participant_unique/migration.sql new file mode 100644 index 0000000..cf32a20 --- /dev/null +++ b/prisma/migrations/20260330200000_add_event_participant_unique/migration.sql @@ -0,0 +1,2 @@ +-- Add unique constraint to EventParticipant table +CREATE UNIQUE INDEX "event_participants_eventId_playerId_key" ON "event_participants"("eventId", "playerId"); diff --git a/prisma/prisma/dev.db b/prisma/prisma/dev.db index 79a41ab60877b06dabff6ba6e06d08c00b8b7039..6b21707f1f5e31959033d0d1d74bf53dc113b5ec 100644 GIT binary patch delta 2372 zcmb7GTWlLu8lK~G#))0qlNL%V*rroAZBuB{xnI~`avLXsIJIj#anq{K-0Zk@95>Fj zh$b!)Xcs7yKFm`^xrhp>D6R?&P=pFnpUOkw0eIMzf{<45u&`+N0j&_?IEIxLR17mZ zzmDeo=ltJ)Ip06a%bS*$H$8jbdY&MNV|WbW;lYDGzlR6>)$x}pKMZXrs8Z-wX#2JG zEgM5il^xKZssu@sAV{BWo3@aZtd6WH8XbaZDjkL`Djlq?+ZNAeC5~ei!ITx-)Gd`2 z1xc`MNmn_`WNd-4Ra4?D$r1%lk>#u?2rMtDhHjcRBeR^LW)(vM7x%r=bbw zH^z;);hc^!2VhlEJFff#s?0tek&86d?_@MB`hA*wt3`VcYu!nWP7M#n zQyM)u+^3~4-0*JPyr%k#?P2=RFkZZSbGSYH(|ULh9sb!uc=r?O4IVeW0bbeP{Acny zLH&)q?)YP{>@LONKJu;&aF@r@=kivUHIeHqOcjWm4wHoUI#-^88{Ge`EQ-wX`{X1aPGd3~EjhNO*Z%j9)6LR;^z^Pm= zlH*RveSLGqxl!Uq8;kkvCyG*cvtq3mI0H1%+2cQBVarb3)0TmdY7iN5l>H zX&2771HmCz;dT$DIqj?Ptb1h@dV{T7;C|1YcH_j|{wM4J;9=*7uVMchxS`&GBdj*} zP}zzL@mV3+U!J$b(Wzc`*y!mk1R{-D)* zNa|K8G|PZJ4%vm`&B4F=&iDi$<&1ZsQTL55^m+iaIVYls-dPKELYtkG%>HC{EK(>O ziSc7vN{Z)u4o%M+3zIovf>?H|QIzxm#vx;9tA{%2w8c=wEyU28QYurc*O+z&3Hk{3f_q#?FmDXX9f8S5i&cCn!1hO=3#`2>RhbN2WhppsD zBsa=vW2K~d`mmfG8XP%=K^}M)eE@;~8d!!cm(gw10vTV>X<9`Gk&nl-$SNwjZ>^$K z^H2?6!;-*!`8%|hoiksdHuur5&=WiTFnF8@eHl6tJdSB0792m@Tmk+l870Z1mCxC_6>avk}_sdrDPwTxP^a&CA1e5%Sp{27sD_vAHx-DAc zZGt#^h$I{FN+T=gv~A&Tlh+H4GzD(SmRQBKEQ^zQLzg*IRdHWq4PE6eOV2*2iw4W` zs*uGk(ZFAAd^E$0n7~xgVtCw2Mag6tNyn|zFa=JsCDk$wLoh|r6tkAW@s?;Xyuh%A zsHg^`D7I?wSy2%f(NwZ~tZbHNSXt*)MlcM#7k6es5M^0qM269owZI;$%Qw47M8mti z-#Sg>MRdyl3onm@X$c{{OgR_6ZR*Ffxjcs>FXxVk+7s)C!bzfnKIo3_3tSrYUSY7*>7vnsVj+}C?q2JCZedx zH`j-iKbNm>j+s%3NU@42Ruvzu4|jj8IJ0_;TRnz`m68F)CSQugIjJ9gU4cHrZO)tT&k!O8)Xi6kK;~4KdV;S$;fAAps1ieqh(fhEM9}#~C z{}rP69hCa9d2Y#%9mhvBMw7}oooOYRS~>Gzs>&e0fGP_6I(+#lY#;ZpFg6gEn8XJ# zX=u;9$3Q;z~c#24fQe}k<<_UcRVnI* zMG`0{m*wNggsrrgWi0hdY0*gs=CVF#K1jE6m87?-X~o+X#}cGSKq*FFnEmJnTkx|e zP)gAk9{T0iwt5QH=K+LkGW>zr(bN5VRV>+wL>n5)u5Bk{5h6}@$}^TmyA`n-f+u~Q zVv-R2OlKpdaIg@nnwfbG-HN&``N&LKZSWV1R@*|yQ)5QIMlLwn>CLwHXmfGu1=lD-TeKQF>JGjeg0-fUzs-; znuV5g(UgrFn{K@+?xtD(t%o*aLSww%!Rb~;PWSDR7*Zea6tpgF%vEt}%1-_Z&}ZX8 zBA!gCk?3E1V8%?gR#amibfUPDB0?ZHeWhtQ)L}>qV8EB9brAiJH7Yj zjh^1|OYoz>_WT&KK`|k7md?c@^SPGRA958+4O>;CZItO|*`L)3B;+e%Q5BDu3(0Ij zmo3;W`9Q}(&7{c^!IaCbT)ZhTPJPwW-jIaoIzBqOhhoX*B2f>wGIpzBj?FeJom5U! z4!dS(Qbk5<`j9ZB4=;7$mmj0M>-pY9S1_FD?nWPb6WtvJa@P;uce%IJA9?|d-0W4o z`##{Sc)e3KyE5|qy&{HWjhbeaDA`=zG~o)X%8Z7Imi>gbk;=NJ|V)*n|Bs*tZQ%K=(GN!0t8x z+{QKtOFW$=;c7K@e2m+}_swceVKvQa!%VbFIqhn%!BW*0b*Bc5$j|Qe(qnM>b8v`T z`W!qlDMsZxh#DGe$f-pHU7SP1M_#p!{=`Oqo=|+kGfJ8XI|ya#&z|!FQ&`+ zsDP)X(&wvotBKGW0xP3ScS-Q}wcSlkJm(s@*EKFM-sa~is6bNQi*$7w2TE+D&jb4s b_+h@!u|Cd|!*3qBuy5!M?$6KQ-#qqTQV-4) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cec4d74..7ea9e25 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -100,6 +100,7 @@ model EventParticipant { player Player @relation(fields: [playerId], references: [id]) event Event @relation(fields: [eventId], references: [id]) + @@unique([eventId, playerId]) @@map("event_participants") } diff --git a/src/app/admin/tournaments/[id]/entry/page.tsx b/src/app/admin/tournaments/[id]/entry/page.tsx new file mode 100644 index 0000000..099cad6 --- /dev/null +++ b/src/app/admin/tournaments/[id]/entry/page.tsx @@ -0,0 +1,299 @@ +"use client" + +import { useState, useEffect } from "react" +import { useRouter } from "next/navigation" +import Navigation from "@/components/Navigation" + +interface Player { + id: number + name: string + currentElo: number +} + +interface Tournament { + id: number + name: string + eventDate: string | null + format: string + participants: { + player: Player + }[] +} + +interface GameEntry { + round: number + table: string + player1: string + player2: string + score1: number + player3: string + player4: string + score2: number +} + +export default function TournamentEntryPage({ params }: { params: { id: string } }) { + const router = useRouter() + const [tournament, setTournament] = useState(null) + const [gameText, setGameText] = useState("") + const [parsedGames, setParsedGames] = useState([]) + const [error, setError] = useState("") + const [success, setSuccess] = useState("") + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + loadTournament() + }, []) + + const loadTournament = async () => { + try { + const response = await fetch(`/api/tournaments/${params.id}`) + const data = await response.json() + if (response.ok) { + setTournament(data.tournament) + } + } catch (err) { + console.error("Failed to load tournament:", err) + } + } + + const parseGameText = (text: string): GameEntry[] => { + const lines = text.trim().split("\n") + const games: GameEntry[] = [] + + for (const line of lines) { + // Skip empty lines and comments + if (!line.trim() || line.trim().startsWith("#")) continue + + // Parse tab-separated or comma-separated values + const parts = line.split(/[,\t]/).map(p => p.trim()) + + if (parts.length >= 7) { + games.push({ + round: parseInt(parts[0]) || 1, + table: parts[1] || "", + player1: parts[2], + player2: parts[3], + score1: parseInt(parts[4]) || 0, + player3: parts[5], + player4: parts[6], + score2: parseInt(parts[7]) || 0, + }) + } + } + + return games + } + + const handleTextChange = (e: React.ChangeEvent) => { + const text = e.target.value + setGameText(text) + const games = parseGameText(text) + setParsedGames(games) + } + + const submitGames = async () => { + if (parsedGames.length === 0) { + setError("No valid games to submit") + return + } + + setError("") + setSuccess("") + setIsLoading(true) + + try { + const response = await fetch(`/api/tournaments/${params.id}/games/bulk`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + games: parsedGames, + }), + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || "Failed to submit games") + } + + setSuccess(`Successfully imported ${data.importedCount} games`) + setGameText("") + setParsedGames([]) + } catch (err: any) { + setError(err.message) + } finally { + setIsLoading(false) + } + } + + if (!tournament) { + return ( +
+ +
+
+
+
+
+
+ ) + } + + return ( +
+ + +
+
+
+ +

+ Game Entry: {tournament.name} +

+ {tournament.eventDate && ( +

+ {new Date(tournament.eventDate).toLocaleDateString()} +

+ )} +
+ + {error && ( +
+
{error}
+
+ )} + + {success && ( +
+
{success}
+
+ )} + +
+ {/* Participants Panel */} +
+
+

+ Participants ({tournament.participants.length}) +

+
+ {tournament.participants.map(({ player }) => ( +
+ {player.name} +
+ ))} +
+
+
+ + {/* Game Entry Panel */} +
+
+

+ Enter Games +

+ +
+ +
+

Tab or comma-separated format:

+ + Round Table Player1 Player2 Score1 Player3 Player4 Score2 + +

+ Example: 1 1 John Smith Jane Doe 10 Mike Johnson Sarah Brown 5 +

+

+ Lines starting with # are treated as comments +

+
+
+ +
+ +