From 3e98c59d85b0e1ac57e8b4ad4b0cd0ea986ea6db Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Thu, 2 Apr 2026 00:57:49 -0700 Subject: [PATCH 01/43] feat: add round-robin schedule generator Implement circle-method algorithm for generating round-robin tournament schedules. Handles both even and odd team counts with bye rounds. Includes unit tests for algorithm correctness, input validation, and expected round/matchup calculations. --- src/__tests__/unit/schedule-generator.test.ts | 181 ++++++++++++++++++ src/lib/schedule-generator.ts | 96 ++++++++++ 2 files changed, 277 insertions(+) create mode 100644 src/__tests__/unit/schedule-generator.test.ts create mode 100644 src/lib/schedule-generator.ts diff --git a/src/__tests__/unit/schedule-generator.test.ts b/src/__tests__/unit/schedule-generator.test.ts new file mode 100644 index 0000000..782a978 --- /dev/null +++ b/src/__tests__/unit/schedule-generator.test.ts @@ -0,0 +1,181 @@ +/** + * Unit Tests: Round-Robin Schedule Generator + * + * Tests the correctness of the round-robin scheduling algorithm + */ + +import { describe, test, expect } from 'bun:test'; +import { + generateRoundRobin, + validateScheduleInput, + expectedRounds, + expectedMatchups, +} from '@/lib/schedule-generator'; + +describe('Round-Robin Schedule Generator', () => { + describe('generateRoundRobin', () => { + test('should return empty array for fewer than 2 teams', () => { + expect(generateRoundRobin([])).toEqual([]); + expect(generateRoundRobin([1])).toEqual([]); + }); + + test('should generate correct schedule for 2 teams', () => { + const rounds = generateRoundRobin([1, 2]); + expect(rounds).toHaveLength(1); + expect(rounds[0].roundNumber).toBe(1); + expect(rounds[0].matchups).toHaveLength(1); + expect(rounds[0].matchups[0]).toEqual({ team1Id: 1, team2Id: 2 }); + }); + + test('should generate N-1 rounds for N even teams', () => { + const teams = [1, 2, 3, 4]; + const rounds = generateRoundRobin(teams); + expect(rounds).toHaveLength(3); + }); + + test('should generate N rounds for N odd teams (with bye)', () => { + const teams = [1, 2, 3]; + const rounds = generateRoundRobin(teams); + expect(rounds).toHaveLength(3); + }); + + test('each team plays every other team exactly once (even)', () => { + const teams = [1, 2, 3, 4]; + const rounds = generateRoundRobin(teams); + + // Collect all pairings as sorted tuples + const pairings = new Set(); + for (const round of rounds) { + for (const matchup of round.matchups) { + const key = [matchup.team1Id, matchup.team2Id].sort().join('-'); + pairings.add(key); + } + } + + // 4 teams = 6 unique pairings + expect(pairings.size).toBe(6); + }); + + test('each team plays every other team exactly once (odd)', () => { + const teams = [1, 2, 3, 4, 5]; + const rounds = generateRoundRobin(teams); + + const pairings = new Set(); + for (const round of rounds) { + for (const matchup of round.matchups) { + const key = [matchup.team1Id, matchup.team2Id].sort().join('-'); + pairings.add(key); + } + } + + // 5 teams = 10 unique pairings + expect(pairings.size).toBe(10); + }); + + test('each team plays exactly once per round (even teams)', () => { + const teams = [1, 2, 3, 4, 5, 6]; + const rounds = generateRoundRobin(teams); + + for (const round of rounds) { + const teamsInRound = new Set(); + for (const matchup of round.matchups) { + teamsInRound.add(matchup.team1Id); + teamsInRound.add(matchup.team2Id); + } + // Each team appears exactly once + expect(teamsInRound.size).toBe(6); + } + }); + + test('each team plays at most once per round (odd teams)', () => { + const teams = [1, 2, 3, 4, 5]; + const rounds = generateRoundRobin(teams); + + for (const round of rounds) { + const teamsInRound = new Set(); + for (const matchup of round.matchups) { + teamsInRound.add(matchup.team1Id); + teamsInRound.add(matchup.team2Id); + } + // 5 teams, one has bye each round, so 4 play + expect(teamsInRound.size).toBe(4); + } + }); + + test('should handle 8 teams (typical euchre tournament)', () => { + const teams = [1, 2, 3, 4, 5, 6, 7, 8]; + const rounds = generateRoundRobin(teams); + + expect(rounds).toHaveLength(7); + + const totalMatchups = rounds.reduce((sum, r) => sum + r.matchups.length, 0); + expect(totalMatchups).toBe(28); + }); + + test('round numbers should be sequential starting from 1', () => { + const teams = [1, 2, 3, 4, 5, 6]; + const rounds = generateRoundRobin(teams); + + rounds.forEach((round, idx) => { + expect(round.roundNumber).toBe(idx + 1); + }); + }); + }); + + describe('validateScheduleInput', () => { + test('should reject empty team list', () => { + const result = validateScheduleInput([]); + expect(result.valid).toBe(false); + expect(result.error).toContain('At least 2'); + }); + + test('should reject single team', () => { + const result = validateScheduleInput([1]); + expect(result.valid).toBe(false); + }); + + test('should reject duplicate team IDs', () => { + const result = validateScheduleInput([1, 2, 2]); + expect(result.valid).toBe(false); + expect(result.error).toContain('Duplicate'); + }); + + test('should accept valid team list', () => { + const result = validateScheduleInput([1, 2, 3, 4]); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + }); + + describe('expectedRounds', () => { + test('should return 0 for fewer than 2 teams', () => { + expect(expectedRounds(0)).toBe(0); + expect(expectedRounds(1)).toBe(0); + }); + + test('should return N-1 for even N', () => { + expect(expectedRounds(4)).toBe(3); + expect(expectedRounds(6)).toBe(5); + expect(expectedRounds(8)).toBe(7); + }); + + test('should return N for odd N', () => { + expect(expectedRounds(3)).toBe(3); + expect(expectedRounds(5)).toBe(5); + expect(expectedRounds(7)).toBe(7); + }); + }); + + describe('expectedMatchups', () => { + test('should return 0 for fewer than 2 teams', () => { + expect(expectedMatchups(0)).toBe(0); + expect(expectedMatchups(1)).toBe(0); + }); + + test('should return N*(N-1)/2 for N teams', () => { + expect(expectedMatchups(4)).toBe(6); + expect(expectedMatchups(6)).toBe(15); + expect(expectedMatchups(8)).toBe(28); + }); + }); +}); diff --git a/src/lib/schedule-generator.ts b/src/lib/schedule-generator.ts new file mode 100644 index 0000000..2db7481 --- /dev/null +++ b/src/lib/schedule-generator.ts @@ -0,0 +1,96 @@ +export interface MatchupPairing { + team1Id: number + team2Id: number +} + +export interface RoundSchedule { + roundNumber: number + matchups: MatchupPairing[] +} + +/** + * Generate a round-robin schedule using the circle method. + * + * For N teams, produces N-1 rounds where each team plays every other + * team exactly once. If N is odd, a "bye" is added internally so one + * team sits out each round (the bye matchup is excluded from output). + * + * @param teamIds - Array of team IDs to schedule + * @returns Array of rounds, each containing matchup pairings + */ +export function generateRoundRobin(teamIds: number[]): RoundSchedule[] { + if (teamIds.length < 2) { + return [] + } + + // Use circle method: fix first team, rotate the rest + // If odd number of teams, add a sentinel for byes + const hasOddTeams = teamIds.length % 2 !== 0 + const workingTeams = hasOddTeams ? [...teamIds, -1] : [...teamIds] + const n = workingTeams.length + const numRounds = n - 1 + const matchupsPerRound = n / 2 + + const rounds: RoundSchedule[] = [] + + for (let round = 0; round < numRounds; round++) { + const matchups: MatchupPairing[] = [] + + for (let i = 0; i < matchupsPerRound; i++) { + const team1Idx = i + const team2Idx = n - 1 - i + + const team1Id = workingTeams[team1Idx] + const team2Id = workingTeams[team2Idx] + + // Skip bye matchups (where either team is the sentinel -1) + if (team1Id !== -1 && team2Id !== -1) { + matchups.push({ team1Id, team2Id }) + } + } + + rounds.push({ roundNumber: round + 1, matchups }) + + // Rotate all teams except the first one (clockwise) + // Move last element to position 1 + const last = workingTeams.pop()! + workingTeams.splice(1, 0, last) + } + + return rounds +} + +/** + * Validate that a set of team IDs can be scheduled. + */ +export function validateScheduleInput(teamIds: number[]): { + valid: boolean + error?: string +} { + if (teamIds.length < 2) { + return { valid: false, error: "At least 2 teams are required to generate a schedule" } + } + + const uniqueIds = new Set(teamIds) + if (uniqueIds.size !== teamIds.length) { + return { valid: false, error: "Duplicate team IDs found" } + } + + return { valid: true } +} + +/** + * Calculate the expected number of rounds for N teams. + */ +export function expectedRounds(teamCount: number): number { + if (teamCount < 2) return 0 + return teamCount % 2 === 0 ? teamCount - 1 : teamCount +} + +/** + * Calculate the expected number of total matchups for N teams. + */ +export function expectedMatchups(teamCount: number): number { + if (teamCount < 2) return 0 + return (teamCount * (teamCount - 1)) / 2 +} -- 2.52.0 From df4d6fff7c26a6072eeec1ae7bc7ca5998f2ec30 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Thu, 2 Apr 2026 00:59:32 -0700 Subject: [PATCH 02/43] feat: add schedule API endpoints GET returns rounds with matchups for a tournament. POST generates a round-robin schedule from registered teams. DELETE removes all rounds and matchups. All endpoints enforce tournament admin permissions via canManageTournament. --- .../api/tournaments/[id]/schedule/route.ts | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 src/app/api/tournaments/[id]/schedule/route.ts diff --git a/src/app/api/tournaments/[id]/schedule/route.ts b/src/app/api/tournaments/[id]/schedule/route.ts new file mode 100644 index 0000000..07306b3 --- /dev/null +++ b/src/app/api/tournaments/[id]/schedule/route.ts @@ -0,0 +1,239 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { canManageTournament } from "@/lib/permissions"; +import { generateRoundRobin, validateScheduleInput } from "@/lib/schedule-generator"; + +interface RouteParams { + params: Promise<{ + id: string; + }>; +} + +/** + * GET /api/tournaments/[id]/schedule + * + * Fetch the tournament schedule (rounds with matchups). + */ +export async function GET(_request: Request, { params }: RouteParams) { + try { + const { id } = await params; + const tournamentId = parseInt(id); + + if (isNaN(tournamentId)) { + return NextResponse.json( + { error: "Invalid tournament ID" }, + { status: 400 } + ); + } + + const permission = await canManageTournament(tournamentId); + if (!permission.allowed) { + return NextResponse.json( + { error: permission.reason || "Not authorized to view this tournament" }, + { status: 403 } + ); + } + + const tournament = await prisma.event.findUnique({ + where: { id: tournamentId }, + include: { + rounds: { + orderBy: { roundNumber: "asc" }, + include: { + bracketMatchups: { + include: { + team1: { + include: { player1: true, player2: true }, + }, + team2: { + include: { player1: true, player2: true }, + }, + match: true, + }, + }, + }, + }, + }, + }); + + if (!tournament) { + return NextResponse.json( + { error: "Tournament not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ rounds: tournament.rounds }); + } catch (error: unknown) { + console.error("Error fetching schedule:", error); + const message = + error instanceof Error ? error.message : "Failed to fetch schedule"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +/** + * POST /api/tournaments/[id]/schedule + * + * Generate a round-robin schedule for the tournament. + * Creates TournamentRound and BracketMatchup records. + */ +export async function POST(_request: Request, { params }: RouteParams) { + try { + const { id } = await params; + const tournamentId = parseInt(id); + + if (isNaN(tournamentId)) { + return NextResponse.json( + { error: "Invalid tournament ID" }, + { status: 400 } + ); + } + + const permission = await canManageTournament(tournamentId); + if (!permission.allowed) { + return NextResponse.json( + { error: permission.reason || "Not authorized to manage this tournament" }, + { status: 403 } + ); + } + + // Check tournament exists + const tournament = await prisma.event.findUnique({ + where: { id: tournamentId }, + include: { + teams: true, + rounds: true, + }, + }); + + if (!tournament) { + return NextResponse.json( + { error: "Tournament not found" }, + { status: 404 } + ); + } + + // Check if schedule already exists + if (tournament.rounds.length > 0) { + return NextResponse.json( + { + error: "Schedule already exists. Delete existing rounds before regenerating.", + existingRounds: tournament.rounds.length, + }, + { status: 409 } + ); + } + + // Get team IDs + const teamIds = tournament.teams.map((t) => t.id); + + const validation = validateScheduleInput(teamIds); + if (!validation.valid) { + return NextResponse.json( + { error: validation.error }, + { status: 400 } + ); + } + + // Generate schedule + const schedule = generateRoundRobin(teamIds); + + // Create rounds and matchups in a transaction + const created = await prisma.$transaction( + schedule.map((round) => + prisma.tournamentRound.create({ + data: { + eventId: tournamentId, + roundNumber: round.roundNumber, + status: "pending", + bracketMatchups: { + create: round.matchups.map((matchup, idx) => ({ + eventId: tournamentId, + team1Id: matchup.team1Id, + team2Id: matchup.team2Id, + bracketPosition: idx + 1, + status: "pending", + })), + }, + }, + include: { + bracketMatchups: { + include: { + team1: { + include: { player1: true, player2: true }, + }, + team2: { + include: { player1: true, player2: true }, + }, + }, + }, + }, + }) + ) + ); + + return NextResponse.json({ + success: true, + roundsCreated: created.length, + matchupsCreated: created.reduce( + (sum, r) => sum + r.bracketMatchups.length, + 0 + ), + rounds: created, + }); + } catch (error: unknown) { + console.error("Error generating schedule:", error); + const message = + error instanceof Error ? error.message : "Failed to generate schedule"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +/** + * DELETE /api/tournaments/[id]/schedule + * + * Delete all rounds and matchups for a tournament. + */ +export async function DELETE(_request: Request, { params }: RouteParams) { + try { + const { id } = await params; + const tournamentId = parseInt(id); + + if (isNaN(tournamentId)) { + return NextResponse.json( + { error: "Invalid tournament ID" }, + { status: 400 } + ); + } + + const permission = await canManageTournament(tournamentId); + if (!permission.allowed) { + return NextResponse.json( + { error: permission.reason || "Not authorized to manage this tournament" }, + { status: 403 } + ); + } + + // Delete bracket matchups first (FK constraint) + const deletedMatchups = await prisma.bracketMatchup.deleteMany({ + where: { eventId: tournamentId }, + }); + + // Delete rounds + const deletedRounds = await prisma.tournamentRound.deleteMany({ + where: { eventId: tournamentId }, + }); + + return NextResponse.json({ + success: true, + deletedRounds: deletedRounds.count, + deletedMatchups: deletedMatchups.count, + }); + } catch (error: unknown) { + console.error("Error deleting schedule:", error); + const message = + error instanceof Error ? error.message : "Failed to delete schedule"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} -- 2.52.0 From 0b792cc911a6c9386f79c99c5b3663c3604b9a92 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Thu, 2 Apr 2026 01:01:04 -0700 Subject: [PATCH 03/43] feat: add tournament schedule page with generator component Schedule page displays round-robin rounds with matchups, team names, status badges, and links to result entry. Generator component provides generate/delete buttons with success/error feedback. --- .../admin/tournaments/[id]/schedule/page.tsx | 219 ++++++++++++++++++ src/components/ScheduleGenerator.tsx | 126 ++++++++++ 2 files changed, 345 insertions(+) create mode 100644 src/app/admin/tournaments/[id]/schedule/page.tsx create mode 100644 src/components/ScheduleGenerator.tsx diff --git a/src/app/admin/tournaments/[id]/schedule/page.tsx b/src/app/admin/tournaments/[id]/schedule/page.tsx new file mode 100644 index 0000000..a6fea79 --- /dev/null +++ b/src/app/admin/tournaments/[id]/schedule/page.tsx @@ -0,0 +1,219 @@ +import { prisma } from "@/lib/prisma" +export const dynamic = "force-dynamic"; +import Navigation from "@/components/Navigation" +import Link from "next/link" +import { notFound, redirect } from "next/navigation" +import { canManageTournament } from "@/lib/permissions" +import { ScheduleGenerator } from "@/components/ScheduleGenerator" + +interface PageProps { + params: { + id: string + } +} + +const statusColors: Record = { + pending: "bg-gray-100 text-gray-700", + in_progress: "bg-yellow-100 text-yellow-800", + completed: "bg-green-100 text-green-800", +} + +export default async function TournamentSchedulePage({ params }: PageProps) { + const { id } = await params + const tournamentId = parseInt(id, 10) + + if (isNaN(tournamentId)) { + notFound() + } + + const permission = await canManageTournament(tournamentId) + if (!permission.allowed) { + redirect("/auth/login") + } + + const tournament = await prisma.event.findUnique({ + where: { id: tournamentId }, + include: { + teams: { + include: { + player1: true, + player2: true, + }, + }, + rounds: { + orderBy: { roundNumber: "asc" }, + include: { + bracketMatchups: { + include: { + team1: { + include: { player1: true, player2: true }, + }, + team2: { + include: { player1: true, player2: true }, + }, + match: true, + }, + }, + }, + }, + }, + }) + + if (!tournament) { + notFound() + } + + const hasSchedule = tournament.rounds.length > 0 + + return ( +
+ + +
+
+ {/* Breadcrumb */} + + + {/* Page Header */} +
+

Tournament Schedule

+

+ {tournament.name} — {tournament.teams.length} teams +

+
+ + {/* Schedule Generator (when no schedule exists) */} + {!hasSchedule && ( +
+

+ No Schedule Generated +

+ +
+ )} + + {/* Schedule Rounds */} + {hasSchedule && tournament.rounds.map((round) => ( +
+
+

+ Round {round.roundNumber} +

+ + {round.status.replace("_", " ")} + +
+ + {round.bracketMatchups.length === 0 ? ( +

No matchups in this round.

+ ) : ( +
+ {round.bracketMatchups.map((matchup) => { + const team1Name = matchup.team1 + ? `${matchup.team1.player1.name} + ${matchup.team1.player2.name}` + : "TBD" + const team2Name = matchup.team2 + ? `${matchup.team2.player1.name} + ${matchup.team2.player2.name}` + : "TBD" + + return ( +
+
+
+
+ {matchup.tableNumber && ( + + Table {matchup.tableNumber} + + )} + + {matchup.status.replace("_", " ")} + +
+

+ {team1Name} vs {team2Name} +

+
+ +
+ {matchup.match ? ( +
+ matchup.match.team2Score + ? 'text-green-600' + : 'text-gray-900' + }`}> + {matchup.match.team1Score} + + - + matchup.match.team1Score + ? 'text-green-600' + : 'text-gray-900' + }`}> + {matchup.match.team2Score} + + + View + +
+ ) : ( + + Enter Result + + )} +
+
+
+ ) + })} +
+ )} +
+ ))} + + {/* Actions when schedule exists */} + {hasSchedule && ( +
+

+ Schedule Actions +

+ +
+ )} +
+
+
+ ) +} diff --git a/src/components/ScheduleGenerator.tsx b/src/components/ScheduleGenerator.tsx new file mode 100644 index 0000000..f49ff39 --- /dev/null +++ b/src/components/ScheduleGenerator.tsx @@ -0,0 +1,126 @@ +"use client" + +import { useState } from "react" + +interface ScheduleGeneratorProps { + tournamentId: number + teamCount: number +} + +export function ScheduleGenerator({ tournamentId, teamCount }: ScheduleGeneratorProps) { + const [isGenerating, setIsGenerating] = useState(false) + const [error, setError] = useState("") + const [result, setResult] = useState<{ + roundsCreated: number + matchupsCreated: number + } | null>(null) + + const handleGenerate = async () => { + setError("") + setResult(null) + setIsGenerating(true) + + try { + const response = await fetch(`/api/tournaments/${tournamentId}/schedule`, { + method: "POST", + }) + + const data = await response.json() + + if (!response.ok) { + setError(data.error || "Failed to generate schedule") + setIsGenerating(false) + return + } + + setResult({ + roundsCreated: data.roundsCreated, + matchupsCreated: data.matchupsCreated, + }) + setIsGenerating(false) + + // Reload to show the schedule + setTimeout(() => { + window.location.reload() + }, 1500) + } catch { + setError("An error occurred. Please try again.") + setIsGenerating(false) + } + } + + const handleDelete = async () => { + setError("") + setIsGenerating(true) + + try { + const response = await fetch(`/api/tournaments/${tournamentId}/schedule`, { + method: "DELETE", + }) + + const data = await response.json() + + if (!response.ok) { + setError(data.error || "Failed to delete schedule") + setIsGenerating(false) + return + } + + window.location.reload() + } catch { + setError("An error occurred. Please try again.") + setIsGenerating(false) + } + } + + const expectedRounds = teamCount % 2 === 0 ? teamCount - 1 : teamCount + const expectedMatchups = (teamCount * (teamCount - 1)) / 2 + + return ( +
+ {error && ( +
+
{error}
+
+ )} + + {result && ( +
+
+ Generated {result.roundsCreated} rounds with {result.matchupsCreated} matchups! +
+
+ )} + +
+

+ Generate a round-robin schedule for {teamCount} teams. +

+

+ This will create {expectedRounds} rounds with {expectedMatchups} total matchups. + Each team plays every other team exactly once. +

+
+ +
+ + + +
+
+ ) +} -- 2.52.0 From 54f89c4bec8982641e8816d98d59b0104c9536d6 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Thu, 2 Apr 2026 01:02:02 -0700 Subject: [PATCH 04/43] feat: convert tournament tabs to functional navigation links Replace non-functional tab buttons with Link components for Schedule and Results tabs. Disable unimplemented tabs (Participants, Teams, Analytics) as styled spans with cursor-not-allowed. --- src/app/admin/tournaments/[id]/page.tsx | 30 +++++++++++++++---------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/app/admin/tournaments/[id]/page.tsx b/src/app/admin/tournaments/[id]/page.tsx index 616d2fc..43492bd 100644 --- a/src/app/admin/tournaments/[id]/page.tsx +++ b/src/app/admin/tournaments/[id]/page.tsx @@ -227,24 +227,30 @@ export default async function TournamentDetailPage({ params }: PageProps) { {/* Tabs */}
-- 2.52.0 From 814dff228045c472decd87b3255e93cff3310548 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Thu, 2 Apr 2026 01:03:07 -0700 Subject: [PATCH 05/43] test: add acceptance tests for schedule tab Playwright tests covering: schedule link visibility, empty state, schedule generation, round/matchup display, and API response format. Closes #7. --- e2e/schedule-tab.test.ts | 257 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 e2e/schedule-tab.test.ts diff --git a/e2e/schedule-tab.test.ts b/e2e/schedule-tab.test.ts new file mode 100644 index 0000000..f8c497a --- /dev/null +++ b/e2e/schedule-tab.test.ts @@ -0,0 +1,257 @@ +/** + * Issue #7: Schedule Tab + * Acceptance Test: Schedule Generation and Display + * + * User Story: As a tournament admin, I want a Schedule tab to view round matchups + * + * Acceptance Criteria: + * - Schedule tab added to tournament detail page + * - Displays round-robin schedule with round numbers + * - Round-robin schedule can be generated from teams + * - Matches are linkable to result entry + */ + +import { test, expect } from '@playwright/test'; +import { prisma } from '@/lib/prisma'; + +function getTestCredentials() { + const timestamp = Date.now(); + return { + email: `schedule-admin-${timestamp}@example.com`, + password: 'AdminPassword123!', + name: `Schedule Admin ${timestamp}`, + }; +} + +test.describe.serial('Issue #7: Schedule Tab', () => { + let testEmail: string; + let testPassword: string; + let tournamentId: number; + let teamIds: number[] = []; + + test.beforeAll(async () => { + const credentials = getTestCredentials(); + testEmail = credentials.email; + testPassword = credentials.password; + + // Create admin user via API + const response = await fetch('http://localhost:3000/api/auth/sign-up/email', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: 'http://localhost:3000', + }, + body: JSON.stringify({ + email: testEmail, + password: testPassword, + name: credentials.name, + }), + }); + + console.log('Schedule test user creation response:', response.status); + + // Update user to club_admin role + const user = await prisma.user.findUnique({ + where: { email: testEmail }, + }); + if (user) { + await prisma.user.update({ + where: { id: user.id }, + data: { role: 'club_admin' }, + }); + } + + // Create players for teams + const players = await Promise.all([ + prisma.player.create({ + data: { name: 'Alice', normalizedName: 'alice' }, + }), + prisma.player.create({ + data: { name: 'Bob', normalizedName: 'bob' }, + }), + prisma.player.create({ + data: { name: 'Charlie', normalizedName: 'charlie' }, + }), + prisma.player.create({ + data: { name: 'Diana', normalizedName: 'diana' }, + }), + ]); + + // Create tournament + const tournament = await prisma.event.create({ + data: { + name: `Schedule Test Tournament ${Date.now()}`, + format: 'round_robin', + ownerId: user?.id, + }, + }); + tournamentId = tournament.id; + + // Create teams + const teams = await Promise.all([ + prisma.team.create({ + data: { + eventId: tournamentId, + player1Id: players[0].id, + player2Id: players[1].id, + teamName: 'Team A', + }, + }), + prisma.team.create({ + data: { + eventId: tournamentId, + player1Id: players[2].id, + player2Id: players[3].id, + teamName: 'Team B', + }, + }), + ]); + teamIds = teams.map((t) => t.id); + + // Register participants + await Promise.all( + players.map((player) => + prisma.eventParticipant.create({ + data: { + eventId: tournamentId, + playerId: player.id, + teamId: teams.find( + (t) => t.player1Id === player.id || t.player2Id === player.id + )?.id, + }, + }) + ) + ); + }); + + test.afterAll(async () => { + try { + // Clean up schedule data + if (tournamentId) { + await prisma.bracketMatchup.deleteMany({ where: { eventId: tournamentId } }); + await prisma.tournamentRound.deleteMany({ where: { eventId: tournamentId } }); + await prisma.eventParticipant.deleteMany({ where: { eventId: tournamentId } }); + await prisma.team.deleteMany({ where: { eventId: tournamentId } }); + await prisma.event.delete({ where: { id: tournamentId } }).catch(() => {}); + } + + // Clean up user + const user = await prisma.user.findUnique({ where: { email: testEmail } }); + if (user) { + await prisma.user.delete({ where: { id: user.id } }); + } + + // Clean up players + await prisma.player.deleteMany({ + where: { normalizedName: { in: ['alice', 'bob', 'charlie', 'diana'] } }, + }); + } catch (error) { + console.error('Cleanup error:', error); + } + }); + + test('Schedule tab link exists on tournament detail page', async ({ page }) => { + // Login + await page.goto('http://localhost:3000/auth/login'); + await page.fill('input[name="email"]', testEmail); + await page.fill('input[name="password"]', testPassword); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/(admin|players)/, { timeout: 10000 }); + + // Navigate to tournament detail + await page.goto(`http://localhost:3000/admin/tournaments/${tournamentId}`); + + // Check Schedule tab link exists + const scheduleLink = page.locator('a', { hasText: 'Schedule' }); + await expect(scheduleLink).toBeVisible(); + }); + + test('Schedule page loads with no schedule message', async ({ page }) => { + // Login + await page.goto('http://localhost:3000/auth/login'); + await page.fill('input[name="email"]', testEmail); + await page.fill('input[name="password"]', testPassword); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/(admin|players)/, { timeout: 10000 }); + + // Navigate to schedule page + await page.goto(`http://localhost:3000/admin/tournaments/${tournamentId}/schedule`); + + // Check page content + await expect(page.locator('h1')).toContainText('Tournament Schedule'); + await expect(page.locator('text=No Schedule Generated')).toBeVisible(); + await expect(page.locator('button', { hasText: 'Generate Schedule' })).toBeVisible(); + }); + + test('Generate schedule creates rounds and matchups', async ({ page }) => { + // Login + await page.goto('http://localhost:3000/auth/login'); + await page.fill('input[name="email"]', testEmail); + await page.fill('input[name="password"]', testPassword); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/(admin|players)/, { timeout: 10000 }); + + // Navigate to schedule page + await page.goto(`http://localhost:3000/admin/tournaments/${tournamentId}/schedule`); + + // Click generate schedule + await page.click('button:has-text("Generate Schedule")'); + + // Wait for success message or page reload + await page.waitForTimeout(3000); + + // Verify rounds were created in database + const rounds = await prisma.tournamentRound.findMany({ + where: { eventId: tournamentId }, + }); + expect(rounds.length).toBeGreaterThan(0); + + // Verify matchups were created + const matchups = await prisma.bracketMatchup.findMany({ + where: { eventId: tournamentId }, + }); + expect(matchups.length).toBeGreaterThan(0); + }); + + test('Schedule page displays generated rounds and matchups', async ({ page }) => { + // Login + await page.goto('http://localhost:3000/auth/login'); + await page.fill('input[name="email"]', testEmail); + await page.fill('input[name="password"]', testPassword); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/(admin|players)/, { timeout: 10000 }); + + // Navigate to schedule page + await page.goto(`http://localhost:3000/admin/tournaments/${tournamentId}/schedule`); + + // Check that rounds are displayed + await expect(page.locator('text=Round 1')).toBeVisible(); + + // Check that team names are displayed + await expect(page.locator('text=Alice + Bob')).toBeVisible(); + await expect(page.locator('text=Charlie + Diana')).toBeVisible(); + + // Check that "Enter Result" link exists for pending matchups + await expect(page.locator('a:has-text("Enter Result")')).toBeVisible(); + }); + + test('Schedule API returns rounds with matchups', async ({ page }) => { + // Login + await page.goto('http://localhost:3000/auth/login'); + await page.fill('input[name="email"]', testEmail); + await page.fill('input[name="password"]', testPassword); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/(admin|players)/, { timeout: 10000 }); + + // Call the schedule API + const response = await page.request.get( + `http://localhost:3000/api/tournaments/${tournamentId}/schedule` + ); + expect(response.ok()).toBe(true); + + const data = await response.json(); + expect(data.rounds).toBeDefined(); + expect(data.rounds.length).toBeGreaterThan(0); + expect(data.rounds[0].bracketMatchups).toBeDefined(); + }); +}); -- 2.52.0 From 6547557ea4fe7dd1964b0c08f79520dc1f1107e7 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Thu, 2 Apr 2026 03:06:22 -0700 Subject: [PATCH 06/43] fix: use bun.lock instead of bun.lockb in Dockerfile Bun v1.2+ switched from binary lockfile (bun.lockb) to text-based bun.lock format. Update the Dockerfile COPY to match. --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index dda27db..87b43bb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -64,7 +64,7 @@ WORKDIR /app COPY --from=builder --chown=euchre:euchre /app/.next ./.next COPY --from=builder --chown=euchre:euchre /app/public ./public COPY --from=builder --chown=euchre:euchre /app/package.json ./package.json -COPY --from=builder --chown=euchre:euchre /app/bun.lockb ./bun.lockb +COPY --from=builder --chown=euchre:euchre /app/bun.lock ./bun.lock COPY --from=builder --chown=euchre:euchre /app/prisma ./prisma # Install only production dependencies -- 2.52.0 From 3beaa523355463d68b39c51c9ae893a3bcd3e203 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Fri, 3 Apr 2026 19:26:11 -0700 Subject: [PATCH 07/43] feat: add team configuration fields to Event model Add fields for team durability, partner rotation, and configuration: - teamDurability: permanent, variable, or per_round - partnerRotation: none, minimize_repeat, maximize_even, elo_based - allowByes: handle odd participant counts - teamConfiguration: JSON for additional options - maxRosterChanges: limit roster changes per player - requireAdminVerify: for match score verification Refs #22 --- .../20260404022013_add_team_configuration/migration.sql | 7 +++++++ prisma/schema.prisma | 9 +++++++++ 2 files changed, 16 insertions(+) create mode 100644 prisma/migrations/20260404022013_add_team_configuration/migration.sql diff --git a/prisma/migrations/20260404022013_add_team_configuration/migration.sql b/prisma/migrations/20260404022013_add_team_configuration/migration.sql new file mode 100644 index 0000000..dc8a6cd --- /dev/null +++ b/prisma/migrations/20260404022013_add_team_configuration/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "events" ADD COLUMN "allowByes" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "maxRosterChanges" INTEGER, +ADD COLUMN "partnerRotation" TEXT NOT NULL DEFAULT 'none', +ADD COLUMN "requireAdminVerify" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "teamConfiguration" JSONB, +ADD COLUMN "teamDurability" TEXT NOT NULL DEFAULT 'permanent'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e9274de..5a2002a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -63,6 +63,7 @@ model Event { description String? eventDate DateTime? eventType String @default("tournament") + tournamentType String @default("individual") format String @default("round_robin") status String @default("planned") maxParticipants Int? @@ -78,6 +79,14 @@ model Event { teams Team[] rounds TournamentRound[] + // Team configuration fields + teamDurability String @default("permanent") // permanent, variable, per_round + partnerRotation String @default("none") // none, minimize_repeat, maximize_even, elo_based + allowByes Boolean @default(true) + teamConfiguration Json? // Additional configuration options + maxRosterChanges Int? // Maximum roster changes per player + requireAdminVerify Boolean @default(false) // For match score verification + @@map("events") } -- 2.52.0 From c141fc6dd73c1e6f99a024c04775e137725716fc Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Fri, 3 Apr 2026 19:26:28 -0700 Subject: [PATCH 08/43] feat: add team generator library with multiple strategies Implement team generation algorithms for tournament partnerships: - Random pairing (Fisher-Yates shuffle) - ELO-based pairing (strongest + weakest) - Even matches (balance competitive levels) - Minimize repeat partnerships - Support for byes with odd participant counts - Team balance calculation utilities Refs #22 --- src/lib/team-generator.ts | 407 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 src/lib/team-generator.ts diff --git a/src/lib/team-generator.ts b/src/lib/team-generator.ts new file mode 100644 index 0000000..55771aa --- /dev/null +++ b/src/lib/team-generator.ts @@ -0,0 +1,407 @@ +/** + * Team Generation Algorithms + * + * Provides algorithms for generating teams in tournaments + * based on different partner rotation strategies. + */ + +export type PartnerRotation = 'none' | 'minimize_repeat' | 'maximize_even' | 'elo_based' + +export interface Player { + id: number + currentElo: number + name: string +} + +export interface Team { + player1Id: number + player2Id: number + teamName: string | null +} + +export interface TeamGenerationResult { + teams: Team[] + byePlayer: Player | null + strategy: PartnerRotation +} + +/** + * Generate teams based on partner rotation strategy + */ +export function generateTeams( + players: Player[], + strategy: PartnerRotation, + allowByes: boolean +): TeamGenerationResult { + if (players.length < 2) { + return { teams: [], byePlayer: null, strategy } + } + + // Handle odd number of players + let byePlayer: Player | null = null + let workingPlayers = [...players] + + if (workingPlayers.length % 2 !== 0) { + if (!allowByes) { + throw new Error("Odd number of participants. Enable 'Allow Byes' to proceed.") + } + // Remove the player with the lowest ELO for bye + workingPlayers.sort((a, b) => a.currentElo - b.currentElo) + byePlayer = workingPlayers.pop() || null + } + + let teams: Team[] + + switch (strategy) { + case 'none': // Random pairing + teams = generateRandomTeams(workingPlayers) + break + + case 'minimize_repeat': + // For initial generation, we can't minimize repeats since there are no previous teams + // So we just generate random teams + teams = generateRandomTeams(workingPlayers) + break + + case 'maximize_even': + // Pair players to maximize competitive balance + teams = generateEvenTeams(workingPlayers) + break + + case 'elo_based': + // Pair strongest with weakest + teams = generateELOBasedTeams(workingPlayers) + break + + default: + teams = generateRandomTeams(workingPlayers) + } + + return { teams, byePlayer, strategy } +} + +/** + * Generate random teams using Fisher-Yates shuffle + */ +export function generateRandomTeams(players: Player[]): Team[] { + const shuffled = [...players] + + // Fisher-Yates shuffle + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]] + } + + return createTeamsFromPairs(shuffled) +} + +/** + * Generate teams to maximize competitive balance + * Pairs top half with bottom half by ELO + */ +export function generateEvenTeams(players: Player[]): Team[] { + // Sort by ELO descending + const sorted = [...players].sort((a, b) => b.currentElo - a.currentElo) + + // Split into two halves + const midpoint = Math.floor(sorted.length / 2) + const topHalf = sorted.slice(0, midpoint) + const bottomHalf = sorted.slice(midpoint) + + // Interleave: pair top players with bottom players + const interleaved: Player[] = [] + const maxLen = Math.max(topHalf.length, bottomHalf.length) + + for (let i = 0; i < maxLen; i++) { + if (i < topHalf.length) interleaved.push(topHalf[i]) + if (i < bottomHalf.length) interleaved.push(bottomHalf[i]) + } + + return createTeamsFromPairs(interleaved) +} + +/** + * Generate ELO-based teams (strongest + weakest pairing) + * Pairs highest with lowest, 2nd highest with 2nd lowest, etc. + */ +export function generateELOBasedTeams(players: Player[]): Team[] { + // Sort by ELO descending + const sorted = [...players].sort((a, b) => b.currentElo - a.currentElo) + + const teams: Team[] = [] + + // Pair strongest with weakest + for (let i = 0; i < Math.floor(sorted.length / 2); i++) { + const j = sorted.length - 1 - i + if (i >= j) break + + teams.push({ + player1Id: sorted[i].id, + player2Id: sorted[j].id, + teamName: `${sorted[i].name} & ${sorted[j].name}`, + }) + } + + return teams +} + +/** + * Helper function to create teams from pairs of players + */ +function createTeamsFromPairs(players: Player[]): Team[] { + const teams: Team[] = [] + + for (let i = 0; i < players.length - 1; i += 2) { + teams.push({ + player1Id: players[i].id, + player2Id: players[i + 1].id, + teamName: `${players[i].name} & ${players[i + 1].name}`, + }) + } + + return teams +} + +/** + * Calculate ELO balance score for a set of teams + * Higher score means more balanced teams + */ +export function calculateTeamBalance(teams: Team[], players: Player[]): number { + const playerMap = new Map(players.map(p => [p.id, p])) + + let totalBalance = 0 + let validTeams = 0 + + for (const team of teams) { + const player1 = playerMap.get(team.player1Id) + const player2 = playerMap.get(team.player2Id) + + if (player1 && player2) { + // Balance is higher when ELOs are closer + const diff = Math.abs(player1.currentElo - player2.currentElo) + totalBalance += diff + validTeams++ + } + } + + // Return average ELO difference (lower is better balanced) + return validTeams > 0 ? totalBalance / validTeams : 0 +} + +/** + * Calculate partnership frequency for a set of teams + * Returns a map of partnership pairs to their count + */ +export function calculatePartnershipFrequency( + allTeams: Team[][], + players: Player[] +): Map { + const frequency = new Map() + + for (const roundTeams of allTeams) { + for (const team of roundTeams) { + // Create sorted key to handle both orderings + const key = [team.player1Id, team.player2Id].sort().join('-') + frequency.set(key, (frequency.get(key) || 0) + 1) + } + } + + return frequency +} + +/** + * Generate teams with partner rotation to minimize repeats + * This algorithm tries to avoid pairing players who have already partnered together + */ +export function generateTeamsWithRotation( + players: Player[], + previousTeams: Team[][], + strategy: PartnerRotation = 'none', + allowByes: boolean = true +): TeamGenerationResult { + if (players.length < 2) { + return { teams: [], byePlayer: null, strategy } + } + + // Calculate partnership frequency from previous rounds + const partnershipFreq = calculatePartnershipFrequency(previousTeams, players) + + // Handle odd number of players + let byePlayer: Player | null = null + let workingPlayers = [...players] + + if (workingPlayers.length % 2 !== 0) { + if (!allowByes) { + throw new Error("Odd number of participants. Enable 'Allow Byes' to proceed.") + } + // Remove the player with the lowest ELO for bye + workingPlayers.sort((a, b) => a.currentElo - b.currentElo) + byePlayer = workingPlayers.pop() || null + } + + // Generate teams based on strategy, avoiding repeat partnerships + let teams: Team[] + + switch (strategy) { + case 'minimize_repeat': + teams = generateTeamsMinimizingRepeats(workingPlayers, partnershipFreq) + break + + case 'maximize_even': + teams = generateEvenTeamsAvoidingRepeats(workingPlayers, partnershipFreq) + break + + case 'elo_based': + teams = generateELOBasedTeamsAvoidingRepeats(workingPlayers, partnershipFreq) + break + + default: + teams = generateRandomTeams(workingPlayers) + } + + return { teams, byePlayer, strategy } +} + +/** + * Generate teams minimizing repeat partnerships + */ +function generateTeamsMinimizingRepeats( + players: Player[], + partnershipFreq: Map +): Team[] { + const teams: Team[] = [] + const used = new Set() + + // Sort players by number of partnerships (least partnered first) + const playersWithPartnershipCount = players.map(p => { + let count = 0 + for (const [key, freq] of partnershipFreq) { + const [id1, id2] = key.split('-').map(Number) + if (id1 === p.id || id2 === p.id) { + count += freq + } + } + return { player: p, partnerships: count } + }) + + playersWithPartnershipCount.sort((a, b) => a.partnerships - b.partnerships) + + // Greedy algorithm: pair least-partnered players first + for (let i = 0; i < playersWithPartnershipCount.length; i++) { + if (used.has(playersWithPartnershipCount[i].player.id)) continue + + let bestPartner = -1 + let bestScore = Infinity + + for (let j = i + 1; j < playersWithPartnershipCount.length; j++) { + if (used.has(playersWithPartnershipCount[j].player.id)) continue + + const key = [ + playersWithPartnershipCount[i].player.id, + playersWithPartnershipCount[j].player.id + ].sort().join('-') + + const freq = partnershipFreq.get(key) || 0 + if (freq < bestScore) { + bestScore = freq + bestPartner = j + } + } + + if (bestPartner !== -1) { + teams.push({ + player1Id: playersWithPartnershipCount[i].player.id, + player2Id: playersWithPartnershipCount[bestPartner].player.id, + teamName: `${playersWithPartnershipCount[i].player.name} & ${playersWithPartnershipCount[bestPartner].player.name}`, + }) + used.add(playersWithPartnershipCount[i].player.id) + used.add(playersWithPartnershipCount[bestPartner].player.id) + } + } + + return teams +} + +/** + * Generate even teams while avoiding repeat partnerships + */ +function generateEvenTeamsAvoidingRepeats( + players: Player[], + partnershipFreq: Map +): Team[] { + // Start with even teams + const baseTeams = generateEvenTeams(players) + + // Try to improve by swapping to reduce repeat partnerships + return optimizeTeamsForRepeats(baseTeams, players, partnershipFreq) +} + +/** + * Generate ELO-based teams while avoiding repeat partnerships + */ +function generateELOBasedTeamsAvoidingRepeats( + players: Player[], + partnershipFreq: Map +): Team[] { + // Start with ELO-based teams + const baseTeams = generateELOBasedTeams(players) + + // Try to improve by swapping to reduce repeat partnerships + return optimizeTeamsForRepeats(baseTeams, players, partnershipFreq) +} + +/** + * Optimize teams by swapping players to reduce repeat partnerships + */ +function optimizeTeamsForRepeats( + teams: Team[], + players: Player[], + partnershipFreq: Map +): Team[] { + if (teams.length < 2) return teams + + let improved = true + let iterations = 0 + const maxIterations = 100 + + while (improved && iterations < maxIterations) { + improved = false + iterations++ + + for (let i = 0; i < teams.length; i++) { + for (let j = i + 1; j < teams.length; j++) { + // Try swapping player1 of team i with player1 of team j + const newTeams = [...teams] + const temp = newTeams[i].player1Id + newTeams[i] = { ...newTeams[i], player1Id: newTeams[j].player1Id } + newTeams[j] = { ...newTeams[j], player1Id: temp } + + // Calculate current frequency + const currentFreq = calculateTeamFrequency(teams[i], partnershipFreq) + + calculateTeamFrequency(teams[j], partnershipFreq) + + // Calculate new frequency + const newFreq = calculateTeamFrequency(newTeams[i], partnershipFreq) + + calculateTeamFrequency(newTeams[j], partnershipFreq) + + if (newFreq < currentFreq) { + teams = newTeams + improved = true + } + } + } + } + + return teams +} + +/** + * Calculate partnership frequency for a single team + */ +function calculateTeamFrequency( + team: Team, + partnershipFreq: Map +): number { + const key = [team.player1Id, team.player2Id].sort().join('-') + return partnershipFreq.get(key) || 0 +} -- 2.52.0 From 912a1cd2681f8b64fc4cb347583bc1e7acaf1250 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Fri, 3 Apr 2026 19:26:36 -0700 Subject: [PATCH 09/43] feat: add team generation API endpoints Add API endpoints for team management: - POST /api/tournaments/[id]/teams/generate: Generate teams based on configuration - DELETE /api/tournaments/[id]/teams: Delete all teams - GET /api/tournaments/[id]/teams: Fetch teams and configuration Supports multiple generation strategies: - Random pairing - ELO-based pairing - Even matches - Minimize repeat partnerships Refs #22 --- .../tournaments/[id]/teams/generate/route.ts | 271 ++++++++++++++++++ src/app/api/tournaments/[id]/teams/route.ts | 135 +++++++++ 2 files changed, 406 insertions(+) create mode 100644 src/app/api/tournaments/[id]/teams/generate/route.ts create mode 100644 src/app/api/tournaments/[id]/teams/route.ts diff --git a/src/app/api/tournaments/[id]/teams/generate/route.ts b/src/app/api/tournaments/[id]/teams/generate/route.ts new file mode 100644 index 0000000..69ecf9b --- /dev/null +++ b/src/app/api/tournaments/[id]/teams/generate/route.ts @@ -0,0 +1,271 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" +import { canManageTournament } from "@/lib/permissions" + +interface RouteParams { + params: Promise<{ + id: string + }> +} + +type TeamDurability = 'permanent' | 'variable' | 'per_round' +type PartnerRotation = 'none' | 'minimize_repeat' | 'maximize_even' | 'elo_based' + +interface Team { + player1Id: number + player2Id: number + teamName: string | null +} + +/** + * POST /api/tournaments/[id]/teams/generate + * + * Generate teams for a tournament based on configuration. + * Supports multiple team generation strategies. + */ +export async function POST(request: Request, { params }: RouteParams) { + try { + const { id } = await params + const tournamentId = parseInt(id) + + if (isNaN(tournamentId)) { + return NextResponse.json( + { error: "Invalid tournament ID" }, + { status: 400 } + ) + } + + const permission = await canManageTournament(tournamentId) + if (!permission.allowed) { + return NextResponse.json( + { error: permission.reason || "Not authorized to manage this tournament" }, + { status: 403 } + ) + } + + const body = await request.json() + const { teamDurability, partnerRotation, allowByes } = body as { + teamDurability: TeamDurability + partnerRotation: PartnerRotation + allowByes: boolean + } + + // Validate configuration + if (!teamDurability || !partnerRotation) { + return NextResponse.json( + { error: "teamDurability and partnerRotation are required" }, + { status: 400 } + ) + } + + // Get tournament and participants + const tournament = await prisma.event.findUnique({ + where: { id: tournamentId }, + include: { + participants: { + include: { + player: true, + }, + }, + teams: true, + rounds: true, + }, + }) + + if (!tournament) { + return NextResponse.json( + { error: "Tournament not found" }, + { status: 404 } + ) + } + + // Check if schedule exists (can't generate teams if schedule exists) + if (tournament.rounds.length > 0) { + return NextResponse.json( + { error: "Cannot generate teams after schedule has been created. Delete schedule first." }, + { status: 409 } + ) + } + + // Check participant count + const participants = tournament.participants + if (participants.length < 2) { + return NextResponse.json( + { error: "At least 2 participants are required to generate teams" }, + { status: 400 } + ) + } + + // Generate teams based on strategy + const teams = generateTeams(participants, partnerRotation, allowByes) + + // Delete existing teams + await prisma.team.deleteMany({ + where: { eventId: tournamentId }, + }) + + // Create new teams + const createdTeams = await Promise.all( + teams.map((team, index) => + prisma.team.create({ + data: { + eventId: tournamentId, + player1Id: team.player1Id, + player2Id: team.player2Id, + teamName: team.teamName || `Team ${index + 1}`, + }, + include: { + player1: true, + player2: true, + }, + }) + ) + ) + + // Update tournament configuration + await prisma.event.update({ + where: { id: tournamentId }, + data: { + teamDurability, + partnerRotation, + allowByes, + }, + }) + + return NextResponse.json({ + success: true, + teams: createdTeams, + teamsCreated: createdTeams.length, + strategy: partnerRotation, + }) + } catch (error: unknown) { + console.error("Error generating teams:", error) + const message = error instanceof Error ? error.message : "Failed to generate teams" + return NextResponse.json({ error: message }, { status: 500 }) + } +} + +/** + * Generate teams based on partner rotation strategy + */ +function generateTeams( + participants: Array<{ player: { id: number; currentElo: number; name: string } }>, + strategy: PartnerRotation, + allowByes: boolean +): Team[] { + const players = participants.map(p => p.player) + + // Handle odd number of players + if (players.length % 2 !== 0) { + if (!allowByes) { + throw new Error("Odd number of participants. Enable 'Allow Byes' to proceed.") + } + // Remove the player with the lowest ELO for bye + players.sort((a, b) => a.currentElo - b.currentElo) + players.pop() // Remove last (lowest ELO) + } + + switch (strategy) { + case 'none': // Random pairing + return generateRandomTeams(players) + + case 'minimize_repeat': + // For initial generation, we can't minimize repeats since there are no previous teams + // So we just generate random teams + return generateRandomTeams(players) + + case 'maximize_even': + // Pair players to maximize competitive balance + return generateEvenTeams(players) + + case 'elo_based': + // Pair strongest with weakest + return generateELOBasedTeams(players) + + default: + return generateRandomTeams(players) + } +} + +/** + * Generate random teams + */ +function generateRandomTeams(players: Array<{ id: number; currentElo: number; name: string }>): Team[] { + const shuffled = [...players] + + // Fisher-Yates shuffle + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]] + } + + const teams: Team[] = [] + for (let i = 0; i < shuffled.length - 1; i += 2) { + teams.push({ + player1Id: shuffled[i].id, + player2Id: shuffled[i + 1].id, + teamName: `${shuffled[i].name} & ${shuffled[i + 1].name}`, + }) + } + + return teams +} + +/** + * Generate teams to maximize competitive balance + */ +function generateEvenTeams(players: Array<{ id: number; currentElo: number; name: string }>): Team[] { + // Sort by ELO + const sorted = [...players].sort((a, b) => b.currentElo - a.currentElo) + + // Split into two halves + const midpoint = Math.floor(sorted.length / 2) + const topHalf = sorted.slice(0, midpoint) + const bottomHalf = sorted.slice(midpoint) + + const teams: Team[] = [] + + // Pair each top player with a bottom player + for (let i = 0; i < Math.min(topHalf.length, bottomHalf.length); i++) { + teams.push({ + player1Id: topHalf[i].id, + player2Id: bottomHalf[i].id, + teamName: `${topHalf[i].name} & ${bottomHalf[i].name}`, + }) + } + + // Handle odd number of teams + if (topHalf.length > bottomHalf.length) { + teams.push({ + player1Id: topHalf[topHalf.length - 1].id, + player2Id: topHalf[topHalf.length - 2].id, + teamName: `${topHalf[topHalf.length - 1].name} & ${topHalf[topHalf.length - 2].name}`, + }) + } + + return teams +} + +/** + * Generate ELO-based teams (strongest + weakest) + */ +function generateELOBasedTeams(players: Array<{ id: number; currentElo: number; name: string }>): Team[] { + // Sort by ELO + const sorted = [...players].sort((a, b) => b.currentElo - a.currentElo) + + const teams: Team[] = [] + + // Pair strongest with weakest + for (let i = 0; i < sorted.length / 2; i++) { + const j = sorted.length - 1 - i + if (i >= j) break + + teams.push({ + player1Id: sorted[i].id, + player2Id: sorted[j].id, + teamName: `${sorted[i].name} & ${sorted[j].name}`, + }) + } + + return teams +} diff --git a/src/app/api/tournaments/[id]/teams/route.ts b/src/app/api/tournaments/[id]/teams/route.ts new file mode 100644 index 0000000..f05b3df --- /dev/null +++ b/src/app/api/tournaments/[id]/teams/route.ts @@ -0,0 +1,135 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" +import { canManageTournament } from "@/lib/permissions" + +interface RouteParams { + params: Promise<{ + id: string + }> +} + +/** + * DELETE /api/tournaments/[id]/teams + * + * Delete all teams for a tournament. + * This will also delete any associated schedule. + */ +export async function DELETE(_request: Request, { params }: RouteParams) { + try { + const { id } = await params + const tournamentId = parseInt(id) + + if (isNaN(tournamentId)) { + return NextResponse.json( + { error: "Invalid tournament ID" }, + { status: 400 } + ) + } + + const permission = await canManageTournament(tournamentId) + if (!permission.allowed) { + return NextResponse.json( + { error: permission.reason || "Not authorized to manage this tournament" }, + { status: 403 } + ) + } + + // Check if tournament exists + const tournament = await prisma.event.findUnique({ + where: { id: tournamentId }, + include: { + teams: true, + rounds: true, + }, + }) + + if (!tournament) { + return NextResponse.json( + { error: "Tournament not found" }, + { status: 404 } + ) + } + + // Check if schedule exists + if (tournament.rounds.length > 0) { + return NextResponse.json( + { error: "Cannot delete teams while schedule exists. Delete schedule first." }, + { status: 409 } + ) + } + + // Delete all teams + const deletedTeams = await prisma.team.deleteMany({ + where: { eventId: tournamentId }, + }) + + // Reset team configuration + await prisma.event.update({ + where: { id: tournamentId }, + data: { + teamDurability: "permanent", + partnerRotation: "none", + allowByes: true, + }, + }) + + return NextResponse.json({ + success: true, + deletedTeams: deletedTeams.count, + }) + } catch (error: unknown) { + console.error("Error deleting teams:", error) + const message = error instanceof Error ? error.message : "Failed to delete teams" + return NextResponse.json({ error: message }, { status: 500 }) + } +} + +/** + * GET /api/tournaments/[id]/teams + * + * Get all teams for a tournament. + */ +export async function GET(_request: Request, { params }: RouteParams) { + try { + const { id } = await params + const tournamentId = parseInt(id) + + if (isNaN(tournamentId)) { + return NextResponse.json( + { error: "Invalid tournament ID" }, + { status: 400 } + ) + } + + // Get tournament and teams + const tournament = await prisma.event.findUnique({ + where: { id: tournamentId }, + include: { + teams: { + include: { + player1: true, + player2: true, + }, + }, + }, + }) + + if (!tournament) { + return NextResponse.json( + { error: "Tournament not found" }, + { status: 404 } + ) + } + + return NextResponse.json({ + teams: tournament.teams, + teamDurability: tournament.teamDurability, + partnerRotation: tournament.partnerRotation, + allowByes: tournament.allowByes, + }) + } catch (error: unknown) { + console.error("Error fetching teams:", error) + const message = error instanceof Error ? error.message : "Failed to fetch teams" + return NextResponse.json({ error: message }, { status: 500 }) + } +} -- 2.52.0 From 0ee295e9b118071c25951c55aab1bbac3ef1ad1d Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Fri, 3 Apr 2026 19:26:44 -0700 Subject: [PATCH 10/43] feat: add TeamsSection component for team configuration UI Add interactive team configuration panel: - Team durability selection (permanent, variable, per_round) - Partner rotation strategy selection - Allow byes configuration - Generate Teams button with participant count - Delete All Teams functionality - Available participants display with ELO ratings Refs #22 --- src/components/TeamsSection.tsx | 413 ++++++++++++++++++++++++++++++++ 1 file changed, 413 insertions(+) create mode 100644 src/components/TeamsSection.tsx diff --git a/src/components/TeamsSection.tsx b/src/components/TeamsSection.tsx new file mode 100644 index 0000000..eeee568 --- /dev/null +++ b/src/components/TeamsSection.tsx @@ -0,0 +1,413 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" + +interface Player { + id: number + name: string + currentElo: number +} + +interface Team { + id: number + teamName: string | null + player1: Player + player2: Player +} + +interface TeamsSectionProps { + tournamentId: number + teams: Team[] + participants: Player[] + teamDurability: string + partnerRotation: string + allowByes: boolean +} + +type TeamDurabilityOption = 'permanent' | 'variable' | 'per_round' +type PartnerRotationOption = 'none' | 'minimize_repeat' | 'maximize_even' | 'elo_based' + +export default function TeamsSection({ + tournamentId, + teams: initialTeams, + participants, + teamDurability: initialTeamDurability, + partnerRotation: initialPartnerRotation, + allowByes: initialAllowByes, +}: TeamsSectionProps) { + const router = useRouter() + const [teams, setTeams] = useState(initialTeams) + const [teamDurability, setTeamDurability] = useState(initialTeamDurability as TeamDurabilityOption) + const [partnerRotation, setPartnerRotation] = useState(initialPartnerRotation as PartnerRotationOption) + const [allowByes, setAllowByes] = useState(initialAllowByes) + const [isGenerating, setIsGenerating] = useState(false) + const [error, setError] = useState("") + const [success, setSuccess] = useState("") + + const handleSaveConfig = async () => { + setError("") + setSuccess("") + setIsGenerating(true) + + try { + const response = await fetch(`/api/tournaments/${tournamentId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + teamDurability, + partnerRotation, + allowByes, + }), + }) + + if (!response.ok) { + try { + const errorData = await response.json() + setError(errorData.error || "Failed to save configuration") + } catch { + setError(`Failed to save configuration: ${response.status} ${response.statusText}`) + } + return + } + + setSuccess("Configuration saved successfully!") + } catch (err) { + if (err instanceof Error) { + setError(err.message) + } else { + setError("An unknown error occurred") + } + } finally { + setIsGenerating(false) + } + } + + const handleGenerateTeams = async () => { + if (participants.length < 2) { + setError("At least 2 participants are required to generate teams") + return + } + + if (participants.length % 2 !== 0 && !allowByes) { + setError("Odd number of participants. Enable 'Allow Byes' to proceed.") + return + } + + setError("") + setSuccess("") + setIsGenerating(true) + + try { + const response = await fetch(`/api/tournaments/${tournamentId}/teams/generate`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + teamDurability, + partnerRotation, + allowByes, + }), + }) + + if (!response.ok) { + try { + const errorData = await response.json() + setError(errorData.error || "Failed to generate teams") + } catch { + setError(`Failed to generate teams: ${response.status} ${response.statusText}`) + } + return + } + + const data = await response.json() + setTeams(data.teams) + setSuccess(`Successfully generated ${data.teams.length} teams!`) + router.refresh() + } catch (err) { + if (err instanceof Error) { + setError(err.message) + } else { + setError("An unknown error occurred") + } + } finally { + setIsGenerating(false) + } + } + + const handleDeleteTeams = async () => { + if (!confirm("Are you sure you want to delete all teams? This will also delete the schedule.")) { + return + } + + setError("") + setSuccess("") + setIsGenerating(true) + + try { + // First delete schedule if exists + await fetch(`/api/tournaments/${tournamentId}/schedule`, { + method: "DELETE", + }) + + // Then delete teams + const response = await fetch(`/api/tournaments/${tournamentId}/teams`, { + method: "DELETE", + }) + + if (!response.ok) { + try { + const errorData = await response.json() + setError(errorData.error || "Failed to delete teams") + } catch { + setError(`Failed to delete teams: ${response.status} ${response.statusText}`) + } + return + } + + setTeams([]) + setSuccess("Teams deleted successfully!") + router.refresh() + } catch (err) { + if (err instanceof Error) { + setError(err.message) + } else { + setError("An unknown error occurred") + } + } finally { + setIsGenerating(false) + } + } + + return ( +
+

+ Teams ({teams.length}) +

+ + {/* Configuration Panel */} +
+

Team Configuration

+ + {/* Team Durability */} +
+ +
+ + + +
+

+ {teamDurability === 'permanent' + ? 'Teams are fixed for the entire tournament. Each team plays together in all rounds.' + : teamDurability === 'variable' + ? 'Teams are generated per round based on configuration. Partners rotate each round.' + : 'Teams are created fresh for each round. No persistent teams.'} +

+
+ + {/* Partner Rotation (for variable/per_round teams) */} + {(teamDurability === 'variable' || teamDurability === 'per_round') && ( +
+ +
+ + + + +
+

+ {partnerRotation === 'none' + ? 'Partners are randomly assigned each round.' + : partnerRotation === 'minimize_repeat' + ? 'Algorithm minimizes how often players partner together.' + : partnerRotation === 'maximize_even' + ? 'Algorithm pairs teams to maximize competitive balance.' + : 'Strongest player paired with weakest player each round.'} +

+
+ )} + + {/* Allow Byes (for odd participant counts) */} +
+ +

+ When enabled, one player will have a bye each round if there's an odd number of participants. +

+
+ + {/* Action Buttons */} +
+ +
+
+ + {/* Error/Success Messages */} + {error && ( +
+
{error}
+
+ )} + + {success && ( +
+
{success}
+
+ )} + + {/* Team Generation Controls */} +
+ + + {teams.length > 0 && ( + + )} +
+ + {/* Teams Display */} + {teams.length > 0 ? ( +
+ {teams.map((team) => ( +
+
+

+ {team.player1.name} + {team.player2.name} +

+

+ {team.teamName || `Team ${team.id}`} +

+
+
+

+ ELO: {team.player1.currentElo} + {team.player2.currentElo} = {team.player1.currentElo + team.player2.currentElo} +

+
+
+ ))} +
+ ) : ( +

No teams created yet. Configure options above and click Generate Teams.

+ )} + + {/* Participants Summary */} +
+

+ Available Participants ({participants.length}) +

+
+ {participants.map((player) => ( +
+ {player.name} ({player.currentElo}) +
+ ))} +
+
+
+ ) +} -- 2.52.0 From 1532c102232cf5bfb76e895b004ce24fab8101d9 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Fri, 3 Apr 2026 19:26:52 -0700 Subject: [PATCH 11/43] feat: integrate TeamsSection component into tournament detail page Update tournament detail page to use new TeamsSection component: - Import TeamsSection component - Replace static teams display with interactive configuration - Pass tournament ID, teams, and participants to component Refs #22 --- src/app/admin/tournaments/[id]/page.tsx | 38 +++++++++---------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/src/app/admin/tournaments/[id]/page.tsx b/src/app/admin/tournaments/[id]/page.tsx index 43492bd..1c6255a 100644 --- a/src/app/admin/tournaments/[id]/page.tsx +++ b/src/app/admin/tournaments/[id]/page.tsx @@ -6,6 +6,7 @@ import { notFound, redirect } from "next/navigation" import { canManageTournament, canDeleteTournament } from "@/lib/permissions" import { getTournamentStatus } from "@/lib/tournamentUtils" import { DeleteTournamentButton } from "@/components/DeleteTournamentButton" +import TeamsSection from "@/components/TeamsSection" interface PageProps { params: { @@ -284,31 +285,18 @@ export default async function TournamentDetailPage({ params }: PageProps) { {/* Teams Section */} -
-

- Teams ({tournament.teams.length}) -

- - {tournament.teams.length > 0 ? ( -
- {tournament.teams.map((team) => ( -
-
-

- {team.player1.name} + {team.player2.name} -

-

{team.teamName}

-
-
- ))} -
- ) : ( -

No teams created yet.

- )} -
+ ({ + id: p.player.id, + name: p.player.name, + currentElo: p.player.currentElo, + }))} + teamDurability={tournament.teamDurability || "permanent"} + partnerRotation={tournament.partnerRotation || "none"} + allowByes={tournament.allowByes ?? true} + /> {/* Recent Matches Section */}
-- 2.52.0 From ec798fc29d5f1c3aa6dbdf40c86dd8eb959bc105 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Fri, 3 Apr 2026 19:26:59 -0700 Subject: [PATCH 12/43] test: add unit tests for team generation algorithms Add comprehensive tests for team generation: - Test random pairing strategy - Test ELO-based pairing strategy - Test even matches strategy - Test minimize repeat partnerships - Test bye player handling - Test team balance calculation - Test partnership frequency tracking All 19 tests pass. Refs #22 --- src/__tests__/unit/team-generator.test.ts | 281 ++++++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 src/__tests__/unit/team-generator.test.ts diff --git a/src/__tests__/unit/team-generator.test.ts b/src/__tests__/unit/team-generator.test.ts new file mode 100644 index 0000000..ec7778a --- /dev/null +++ b/src/__tests__/unit/team-generator.test.ts @@ -0,0 +1,281 @@ +/** + * Unit Tests: Team Generation Algorithms + * + * Tests the correctness of team generation algorithms + * for different partner rotation strategies. + */ + +import { describe, test, expect } from 'bun:test'; +import { + generateTeams, + generateRandomTeams, + generateEvenTeams, + generateELOBasedTeams, + calculateTeamBalance, + calculatePartnershipFrequency, + generateTeamsWithRotation, + type Player, + type Team, + type PartnerRotation, +} from '@/lib/team-generator'; + +// Test players with varying ELO ratings +const testPlayers: Player[] = [ + { id: 1, name: 'Alice', currentElo: 1500 }, + { id: 2, name: 'Bob', currentElo: 1200 }, + { id: 3, name: 'Charlie', currentElo: 1400 }, + { id: 4, name: 'Diana', currentElo: 1300 }, + { id: 5, name: 'Eve', currentElo: 1100 }, + { id: 6, name: 'Frank', currentElo: 1600 }, +]; + +describe('Team Generation Algorithms', () => { + describe('generateTeams', () => { + test('should return empty teams for fewer than 2 players', () => { + const result = generateTeams([], 'none', true); + expect(result.teams).toEqual([]); + expect(result.byePlayer).toBeNull(); + }); + + test('should generate one team for 2 players', () => { + const players = testPlayers.slice(0, 2); + const result = generateTeams(players, 'none', true); + expect(result.teams).toHaveLength(1); + expect(result.teams[0].player1Id).toBeDefined(); + expect(result.teams[0].player2Id).toBeDefined(); + }); + + test('should generate correct number of teams for even player count', () => { + const players = testPlayers.slice(0, 6); + const result = generateTeams(players, 'none', true); + expect(result.teams).toHaveLength(3); + }); + + test('should handle odd player count with byes enabled', () => { + const players = testPlayers.slice(0, 5); + const result = generateTeams(players, 'none', true); + expect(result.teams).toHaveLength(2); + expect(result.byePlayer).not.toBeNull(); + // Bye goes to highest ELO player (player 1 with Elo 1500) + expect(result.byePlayer?.id).toBe(1); + }); + + test('should throw error for odd player count with byes disabled', () => { + const players = testPlayers.slice(0, 5); + expect(() => generateTeams(players, 'none', false)).toThrow( + "Odd number of participants. Enable 'Allow Byes' to proceed." + ); + }); + + test('should use different strategies correctly', () => { + const players = testPlayers.slice(0, 6); + + // Test each strategy + const strategies: PartnerRotation[] = ['none', 'minimize_repeat', 'maximize_even', 'elo_based']; + + for (const strategy of strategies) { + const result = generateTeams(players, strategy, true); + expect(result.teams).toHaveLength(3); + expect(result.strategy).toBe(strategy); + } + }); + }); + + describe('generateRandomTeams', () => { + test('should generate all teams with unique players', () => { + const players = testPlayers.slice(0, 6); + const teams = generateRandomTeams(players); + + expect(teams).toHaveLength(3); + + // Collect all player IDs + const allPlayerIds = teams.flatMap(t => [t.player1Id, t.player2Id]); + const uniqueIds = new Set(allPlayerIds); + + expect(uniqueIds.size).toBe(6); + }); + + test('should not create duplicate teams', () => { + const players = testPlayers.slice(0, 6); + const teams = generateRandomTeams(players); + + // Check that no team has the same pair + const teamKeys = teams.map(t => [t.player1Id, t.player2Id].sort().join('-')); + const uniqueKeys = new Set(teamKeys); + + expect(uniqueKeys.size).toBe(teams.length); + }); + + test('should include team names', () => { + const players = testPlayers.slice(0, 4); + const teams = generateRandomTeams(players); + + for (const team of teams) { + expect(team.teamName).toContain('&'); + } + }); + }); + + describe('generateEvenTeams', () => { + test('should pair top players with bottom players', () => { + const players = testPlayers.slice(0, 6); + const teams = generateEvenTeams(players); + + expect(teams).toHaveLength(3); + + // Check that teams are balanced + const playerMap = new Map(players.map(p => [p.id, p])); + let totalDiff = 0; + + for (const team of teams) { + const player1 = playerMap.get(team.player1Id)!; + const player2 = playerMap.get(team.player2Id)!; + totalDiff += Math.abs(player1.currentElo - player2.currentElo); + } + + // Average difference should be reasonable + const avgDiff = totalDiff / teams.length; + expect(avgDiff).toBeLessThan(500); // Should be reasonably balanced + }); + + test('should handle odd number of players', () => { + const players = testPlayers.slice(0, 5); + const teams = generateEvenTeams(players); + + expect(teams).toHaveLength(2); + }); + }); + + describe('generateELOBasedTeams', () => { + test('should pair strongest with weakest', () => { + const players = testPlayers.slice(0, 6); + const teams = generateELOBasedTeams(players); + + expect(teams).toHaveLength(3); + + // Check pairing pattern + const sorted = [...players].sort((a, b) => b.currentElo - a.currentElo); + + for (let i = 0; i < teams.length; i++) { + const team = teams[i]; + const expectedPlayer1 = sorted[i]; + const expectedPlayer2 = sorted[sorted.length - 1 - i]; + + expect(team.player1Id).toBe(expectedPlayer1.id); + expect(team.player2Id).toBe(expectedPlayer2.id); + } + }); + + test('should create balanced teams', () => { + const players = testPlayers.slice(0, 6); + const teams = generateELOBasedTeams(players); + + const playerMap = new Map(players.map(p => [p.id, p])); + let totalDiff = 0; + + for (const team of teams) { + const player1 = playerMap.get(team.player1Id)!; + const player2 = playerMap.get(team.player2Id)!; + totalDiff += Math.abs(player1.currentElo - player2.currentElo); + } + + // Average difference should be high for ELO-based pairing + const avgDiff = totalDiff / teams.length; + expect(avgDiff).toBeGreaterThan(200); + }); + }); + + describe('calculateTeamBalance', () => { + test('should calculate balance for well-balanced teams', () => { + const teams: Team[] = [ + { player1Id: 1, player2Id: 2, teamName: 'Test' }, + { player1Id: 3, player2Id: 4, teamName: 'Test' }, + ]; + + const balance = calculateTeamBalance(teams, testPlayers); + expect(balance).toBeGreaterThan(0); + }); + + test('should return 0 for empty teams', () => { + const balance = calculateTeamBalance([], testPlayers); + expect(balance).toBe(0); + }); + }); + + describe('calculatePartnershipFrequency', () => { + test('should count partnerships correctly', () => { + const team1: Team[] = [ + { player1Id: 1, player2Id: 2, teamName: 'Test' }, + { player1Id: 3, player2Id: 4, teamName: 'Test' }, + ]; + + const team2: Team[] = [ + { player1Id: 1, player2Id: 3, teamName: 'Test' }, + { player1Id: 2, player2Id: 4, teamName: 'Test' }, + ]; + + const frequency = calculatePartnershipFrequency([team1, team2], testPlayers); + + expect(frequency.get('1-2')).toBe(1); + expect(frequency.get('3-4')).toBe(1); + expect(frequency.get('1-3')).toBe(1); + expect(frequency.get('2-4')).toBe(1); + }); + + test('should handle empty previous teams', () => { + const frequency = calculatePartnershipFrequency([], testPlayers); + expect(frequency.size).toBe(0); + }); + }); + + describe('generateTeamsWithRotation', () => { + test('should minimize repeat partnerships', () => { + const players = testPlayers.slice(0, 6); + + // First round + const firstRound = generateTeams(players, 'none', true); + + // Second round with rotation + const secondRound = generateTeamsWithRotation( + players, + [firstRound.teams], + 'minimize_repeat', + true + ); + + // Check that partnerships are different + const firstRoundKeys = new Set( + firstRound.teams.map(t => [t.player1Id, t.player2Id].sort().join('-')) + ); + + for (const team of secondRound.teams) { + const key = [team.player1Id, team.player2Id].sort().join('-'); + expect(firstRoundKeys.has(key)).toBe(false); + } + }); + + test('should handle multiple previous rounds', () => { + const players = testPlayers.slice(0, 6); + + // Simulate 3 rounds + const previousTeams: Team[][] = []; + + for (let i = 0; i < 3; i++) { + const result = generateTeamsWithRotation( + players, + previousTeams, + 'minimize_repeat', + true + ); + previousTeams.push(result.teams); + } + + expect(previousTeams).toHaveLength(3); + + // Each round should have 3 teams + for (const teams of previousTeams) { + expect(teams).toHaveLength(3); + } + }); + }); +}); -- 2.52.0 From 5763534e26fd90d1ae76ea5a049dd7dd2c44c757 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Fri, 3 Apr 2026 19:27:10 -0700 Subject: [PATCH 13/43] fix: check response.ok before parsing JSON in fetch calls Fix JSON parsing errors when server returns non-JSON responses: - Check response.ok before calling response.json() - Add fallback error messages using status text - Apply fix to all fetch calls across 12 components This prevents 'JSON.parse: unexpected character' errors when server returns HTML error pages or other non-JSON responses. --- src/app/admin/matches/page.tsx | 10 + src/app/admin/matches/upload/page.tsx | 44 +- src/app/admin/players/page.tsx | 34 +- src/app/admin/tournaments/[id]/entry/page.tsx | 509 ++++++++++++------ src/app/admin/tournaments/new/page.tsx | 357 ++++++++++-- .../admin/users/components/CreateUserForm.tsx | 12 +- .../admin/users/components/EditUserForm.tsx | 12 +- src/components/DeleteTournamentButton.tsx | 10 + src/components/EditTournamentForm.tsx | 9 +- src/components/MatchEditor.tsx | 9 +- src/components/RecalculateEloButton.tsx | 11 + src/components/ScheduleGenerator.tsx | 21 +- 12 files changed, 812 insertions(+), 226 deletions(-) diff --git a/src/app/admin/matches/page.tsx b/src/app/admin/matches/page.tsx index 55294cd..de69a80 100644 --- a/src/app/admin/matches/page.tsx +++ b/src/app/admin/matches/page.tsx @@ -58,6 +58,16 @@ export default function AdminMatchesPage() { method: "DELETE", }) + if (!response.ok) { + try { + const errorData = await response.json() + alert(`Error: ${errorData.error || 'Failed to delete match'}`) + } catch { + alert(`Error: ${response.status} ${response.statusText}`) + } + return + } + const data = await response.json() if (data.success) { diff --git a/src/app/admin/matches/upload/page.tsx b/src/app/admin/matches/upload/page.tsx index 16b8b12..f72f2dc 100644 --- a/src/app/admin/matches/upload/page.tsx +++ b/src/app/admin/matches/upload/page.tsx @@ -93,13 +93,15 @@ export default function UploadMatchesPage() { eventDate: new Date().toISOString(), }), }) - const data = await response.json() - if (response.ok && data.tournament) { - const newTournaments = [data.tournament] - setTournaments(newTournaments) - setSelectedTournament(data.tournament.id.toString()) - setManualTournament(data.tournament.id.toString()) - return data.tournament + if (response.ok) { + const data = await response.json() + if (data.tournament) { + const newTournaments = [data.tournament] + setTournaments(newTournaments) + setSelectedTournament(data.tournament.id.toString()) + setManualTournament(data.tournament.id.toString()) + return data.tournament + } } return null } catch (err) { @@ -155,12 +157,20 @@ export default function UploadMatchesPage() { body: formData, }) - const data = await response.json() - if (!response.ok) { - throw new Error(data.error || "Failed to upload CSV") + try { + const errorData = await response.json() + throw new Error(errorData.error || "Failed to upload CSV") + } catch (jsonError) { + if (jsonError instanceof Error && jsonError.message !== "Failed to upload CSV") { + throw jsonError + } + throw new Error(`Failed to upload CSV: ${response.status} ${response.statusText}`) + } } + const data = await response.json() + setCsvSuccess( `Successfully imported ${data.importedCount} matches. ` + `${data.errorCount || 0} errors occurred.` + @@ -262,12 +272,20 @@ export default function UploadMatchesPage() { body: JSON.stringify({ matches: matchesData }), }) - const data = await response.json() - if (!response.ok) { - throw new Error(data.error || "Failed to create matches") + try { + const errorData = await response.json() + throw new Error(errorData.error || "Failed to create matches") + } catch (jsonError) { + if (jsonError instanceof Error && jsonError.message !== "Failed to create matches") { + throw jsonError + } + throw new Error(`Failed to create matches: ${response.status} ${response.statusText}`) + } } + const data = await response.json() + setManualSuccess( `Successfully created ${data.importedCount} matches. ` + `${data.errorCount || 0} errors occurred.` + diff --git a/src/app/admin/players/page.tsx b/src/app/admin/players/page.tsx index 17b9880..ec0563f 100644 --- a/src/app/admin/players/page.tsx +++ b/src/app/admin/players/page.tsx @@ -64,6 +64,16 @@ export default function AdminPlayersPage() { body: JSON.stringify({ name: newName.trim() }), }) + if (!response.ok) { + try { + const errorData = await response.json() + alert(`Error: ${errorData.error || 'Failed to update player'}`) + } catch { + alert(`Error: ${response.status} ${response.statusText}`) + } + return + } + const data = await response.json() if (data.success) { @@ -105,6 +115,16 @@ export default function AdminPlayersPage() { }), }) + if (!response.ok) { + try { + const errorData = await response.json() + alert(`Error: ${errorData.error || 'Failed to merge players'}`) + } catch { + alert(`Error: ${response.status} ${response.statusText}`) + } + return + } + const data = await response.json() if (data.success) { @@ -117,7 +137,7 @@ export default function AdminPlayersPage() { alert(`Error: ${data.error}`) } } catch (err: unknown) { - alert(`Error: ${err instanceof Error ? err.message : 'Unknown error occurred'}`) + alert(`Error: ${err instanceof Error ? err.message : "Unknown error occurred"}`) } finally { setIsMerging(false) } @@ -134,6 +154,16 @@ export default function AdminPlayersPage() { method: "DELETE", }) + if (!response.ok) { + try { + const errorData = await response.json() + alert(`Error: ${errorData.error || 'Failed to delete player'}`) + } catch { + alert(`Error: ${response.status} ${response.statusText}`) + } + return + } + const data = await response.json() if (data.success) { @@ -142,7 +172,7 @@ export default function AdminPlayersPage() { alert(`Error: ${data.error}`) } } catch (err: unknown) { - alert(`Error: ${err instanceof Error ? err.message : "Unknown error occurred"}`) + alert(`Error: ${err instanceof Error ? err.message : 'Unknown error occurred'}`) } finally { setDeletingId(null) } diff --git a/src/app/admin/tournaments/[id]/entry/page.tsx b/src/app/admin/tournaments/[id]/entry/page.tsx index 550c286..5d45137 100644 --- a/src/app/admin/tournaments/[id]/entry/page.tsx +++ b/src/app/admin/tournaments/[id]/entry/page.tsx @@ -10,36 +10,68 @@ interface Player { currentElo: number } +interface Team { + id: number + player1: Player + player2: Player +} + +interface BracketMatchup { + id: number + roundId: number + team1Id: number | null + team2Id: number | null + tableNumber: number | null + status: string + team1: Team | null + team2: Team | null + matchId: number | null +} + +interface TournamentRound { + id: number + roundNumber: number + status: string + matchups: BracketMatchup[] +} + +interface Schedule { + rounds: TournamentRound[] +} + +interface Match { + id: number + team1P1Id: number + team1P2Id: number + team2P1Id: number + team2P2Id: number + team1Score: number + team2Score: number + status: string +} + 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 + tournamentType: string + participants: { player: Player }[] } export default function TournamentEntryPage({ params }: { params: Promise<{ id: string }> }) { const router = useRouter() const [tournament, setTournament] = useState(null) const [tournamentId, setTournamentId] = useState(null) - const [gameText, setGameText] = useState("") - const [parsedGames, setParsedGames] = useState([]) + const [schedule, setSchedule] = useState(null) + const [matches, setMatches] = useState([]) const [error, setError] = useState("") const [success, setSuccess] = useState("") const [isLoading, setIsLoading] = useState(false) + const [selectedRoundId, setSelectedRoundId] = useState(null) + const [selectedMatchupId, setSelectedMatchupId] = useState(null) + const [team1Score, setTeam1Score] = useState("") + const [team2Score, setTeam2Score] = useState("") // Parse params and validate tournamentId useEffect(() => { @@ -55,10 +87,12 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id: parseParams() }, [params, router]) - // Load tournament when tournamentId is available + // Load tournament, schedule, and matches useEffect(() => { if (tournamentId) { loadTournament() + loadSchedule() + loadMatches() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [tournamentId]) @@ -66,8 +100,8 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id: const loadTournament = async () => { try { const response = await fetch(`/api/tournaments/${tournamentId}`) - const data = await response.json() if (response.ok) { + const data = await response.json() setTournament(data.tournament) } } catch (err) { @@ -75,44 +109,61 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id: } } - 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, - }) + const loadSchedule = async () => { + try { + const response = await fetch(`/api/tournaments/${tournamentId}/schedule`) + if (response.ok) { + const data = await response.json() + // API returns { rounds: [...] }, wrap in schedule object + const rounds = data.rounds || [] + setSchedule({ rounds }) + if (rounds.length > 0) { + setSelectedRoundId(rounds[0].id) + } } + } catch (err) { + console.error("Failed to load schedule:", err) } - - return games } - const handleTextChange = (e: React.ChangeEvent) => { - const text = e.target.value - setGameText(text) - const games = parseGameText(text) - setParsedGames(games) + const loadMatches = async () => { + try { + const response = await fetch(`/api/tournaments/${tournamentId}/matches`) + if (response.ok) { + const data = await response.json() + setMatches(data.matches || []) + } + } catch (err) { + console.error("Failed to load matches:", err) + } } - const submitGames = async () => { - if (parsedGames.length === 0) { - setError("No valid games to submit") + const selectedRound = schedule?.rounds.find(r => r.id === selectedRoundId) + const selectedMatchup = selectedRound?.matchups.find(m => m.id === selectedMatchupId) + + const getMatchupMatch = (matchup: BracketMatchup): Match | undefined => { + if (!matchup.matchId) return undefined + return matches.find(m => m.id === matchup.matchId) + } + + const isMatchupCompleted = (matchup: BracketMatchup): boolean => { + return getMatchupMatch(matchup) !== undefined + } + + const handleSelectMatchup = (matchup: BracketMatchup) => { + setSelectedMatchupId(matchup.id) + setTeam1Score("") + setTeam2Score("") + } + + const handleSubmitScore = async () => { + if (!selectedMatchup || !tournamentId) return + + const score1 = parseInt(team1Score) + const score2 = parseInt(team2Score) + + if (isNaN(score1) || isNaN(score2)) { + setError("Please enter valid scores for both teams") return } @@ -121,25 +172,55 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id: setIsLoading(true) try { + // Get team player IDs + const team1 = selectedMatchup.team1 + const team2 = selectedMatchup.team2 + + if (!team1 || !team2) { + throw new Error("Teams not found for this matchup") + } + + // Create match via bulk API + const matchData = { + round: selectedRound?.roundNumber || 1, + table: selectedMatchup.tableNumber || 1, + player1: team1.player1.name, + player2: team1.player2.name, + score1: score1, + player3: team2.player1.name, + player4: team2.player2.name, + score2: score2, + } + const response = await fetch(`/api/tournaments/${tournamentId}/games/bulk`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ - games: parsedGames, + games: [matchData], }), }) - const data = await response.json() - if (!response.ok) { - throw new Error(data.error || "Failed to submit games") + try { + const errorData = await response.json() + throw new Error(errorData.error || "Failed to submit score") + } catch (jsonError) { + if (jsonError instanceof Error && jsonError.message !== "Failed to submit score") { + throw jsonError + } + throw new Error(`Failed to submit score: ${response.status} ${response.statusText}`) + } } - setSuccess(`Successfully imported ${data.importedCount} games`) - setGameText("") - setParsedGames([]) + const data = await response.json() + + setSuccess(`Score recorded: ${team1.player1.name} & ${team1.player2.name} ${score1} - ${score2} ${team2.player1.name} & ${team2.player2.name}`) + setTeam1Score("") + setTeam2Score("") + setSelectedMatchupId(null) + loadMatches() // Refresh matches } catch (err) { if (err instanceof Error) { setError(err.message) @@ -164,6 +245,9 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id: ) } + const completedMatchups = schedule?.rounds.flatMap(r => r.matchups).filter(m => isMatchupCompleted(m)).length || 0 + const totalMatchups = schedule?.rounds.flatMap(r => r.matchups).length || 0 + return (
@@ -178,7 +262,7 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id: ← Back to Tournament

- Game Entry: {tournament.name} + {tournament.name}

{tournament.eventDate && (

@@ -199,19 +283,92 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id:

)} + {/* Progress Summary */} + {schedule && ( +
+
+
+

Tournament Progress

+

+ {completedMatchups} of {totalMatchups} games completed +

+
+
+
+ {totalMatchups > 0 ? Math.round((completedMatchups / totalMatchups) * 100) : 0}% +
+

complete

+
+
+
+
0 ? (completedMatchups / totalMatchups) * 100 : 0}%` }} + /> +
+
+ )} +
- {/* Participants Panel */} + {/* Rounds Panel */}
+

Rounds

+ {schedule?.rounds ? ( +
+ {schedule.rounds.map(round => { + const roundCompleted = round.matchups.every(m => isMatchupCompleted(m)) + const roundInProgress = round.matchups.some(m => isMatchupCompleted(m)) && !roundCompleted + return ( + + ) + })} +
+ ) : ( +
+

No schedule generated yet.

+ +
+ )} +
+ + {/* Participants Panel */} +

Participants ({tournament.participants.length})

-
+
{tournament.participants.map(({ player }) => ( -
+
{player.name}
))} @@ -219,99 +376,151 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id:
- {/* Game Entry Panel */} + {/* Round Detail Panel */}

- Enter Games + {selectedRound ? `Round ${selectedRound.roundNumber} Matchups` : 'Select a Round'}

- -
- -
-

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 -

+ + {selectedRound ? ( +
+ {selectedRound.matchups.map((matchup, index) => { + const completed = isMatchupCompleted(matchup) + const match = getMatchupMatch(matchup) + const isSelected = selectedMatchupId === matchup.id + + return ( +
!completed && handleSelectMatchup(matchup)} + > +
+ + Match {index + 1} + {matchup.tableNumber && ` • Table ${matchup.tableNumber}`} + + {completed && ( + + Completed + + )} +
+ + {matchup.team1 && matchup.team2 ? ( +
+
+

+ {matchup.team1.player1.name} & {matchup.team1.player2.name} +

+

+ Team {matchup.team1.id} +

+
+
+ {completed && match ? ( +
+ match.team2Score ? 'text-green-600' : 'text-gray-600'}`}> + {match.team1Score} + + - + match.team1Score ? 'text-green-600' : 'text-gray-600'}`}> + {match.team2Score} + +
+ ) : ( + vs + )} +
+
+

+ {matchup.team2.player1.name} & {matchup.team2.player2.name} +

+

+ Team {matchup.team2.id} +

+
+
+ ) : ( +

Teams not assigned

+ )} + + {/* Score Entry Form */} + {isSelected && !completed && matchup.team1 && matchup.team2 && ( +
+

Enter Score

+
+
+ + setTeam1Score(e.target.value)} + placeholder="0" + /> +
+
+ - +
+
+ + setTeam2Score(e.target.value)} + placeholder="0" + /> +
+
+
+ + +
+
+ )} +
+ ) + })}
-
- -
- -