Compare commits
6 Commits
v0.1.20
...
ab50ae0bdf
| Author | SHA1 | Date | |
|---|---|---|---|
| ab50ae0bdf | |||
| 90fb0f223e | |||
| a49c8e01ac | |||
| 19402be375 | |||
| 84afa88ca4 | |||
| df856c62df |
+1
-1
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string>();
|
||||
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<string>();
|
||||
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<number>();
|
||||
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<number>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -227,24 +227,30 @@ export default async function TournamentDetailPage({ params }: PageProps) {
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button className="border-green-500 text-green-600 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||
<span className="border-green-500 text-green-600 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||
Overview
|
||||
</button>
|
||||
<button className="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||
</span>
|
||||
<span className="border-transparent text-gray-400 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm cursor-not-allowed">
|
||||
Participants
|
||||
</button>
|
||||
<button className="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||
</span>
|
||||
<span className="border-transparent text-gray-400 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm cursor-not-allowed">
|
||||
Teams
|
||||
</button>
|
||||
<button className="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||
</span>
|
||||
<Link
|
||||
href={`/admin/tournaments/${tournament.id}/schedule`}
|
||||
className="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
|
||||
>
|
||||
Schedule
|
||||
</button>
|
||||
<button className="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||
</Link>
|
||||
<Link
|
||||
href={`/admin/tournaments/${tournament.id}/results`}
|
||||
className="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
|
||||
>
|
||||
Results
|
||||
</button>
|
||||
<button className="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||
</Link>
|
||||
<span className="border-transparent text-gray-400 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm cursor-not-allowed">
|
||||
Analytics
|
||||
</button>
|
||||
</span>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navigation />
|
||||
|
||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-4">
|
||||
<ol className="flex items-center space-x-2">
|
||||
<li>
|
||||
<Link href="/admin/tournaments" className="text-green-600 hover:text-green-900">
|
||||
Tournaments
|
||||
</Link>
|
||||
</li>
|
||||
<li className="text-gray-400">/</li>
|
||||
<li>
|
||||
<Link href={`/admin/tournaments/${tournament.id}`} className="text-green-600 hover:text-green-900">
|
||||
{tournament.name}
|
||||
</Link>
|
||||
</li>
|
||||
<li className="text-gray-400">/</li>
|
||||
<li className="text-gray-600">Schedule</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Tournament Schedule</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
{tournament.name} — {tournament.teams.length} teams
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Schedule Generator (when no schedule exists) */}
|
||||
{!hasSchedule && (
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
||||
No Schedule Generated
|
||||
</h2>
|
||||
<ScheduleGenerator
|
||||
tournamentId={tournamentId}
|
||||
teamCount={tournament.teams.length}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Schedule Rounds */}
|
||||
{hasSchedule && tournament.rounds.map((round) => (
|
||||
<div key={round.id} className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-900">
|
||||
Round {round.roundNumber}
|
||||
</h2>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${statusColors[round.status] || statusColors.pending}`}>
|
||||
{round.status.replace("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{round.bracketMatchups.length === 0 ? (
|
||||
<p className="text-gray-500">No matchups in this round.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{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 (
|
||||
<div
|
||||
key={matchup.id}
|
||||
className="border border-gray-200 rounded p-3"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
{matchup.tableNumber && (
|
||||
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
|
||||
Table {matchup.tableNumber}
|
||||
</span>
|
||||
)}
|
||||
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${statusColors[matchup.status] || statusColors.pending}`}>
|
||||
{matchup.status.replace("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-medium mt-1">
|
||||
{team1Name} <span className="text-gray-400">vs</span> {team2Name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{matchup.match ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={`font-bold ${
|
||||
matchup.match.team1Score > matchup.match.team2Score
|
||||
? 'text-green-600'
|
||||
: 'text-gray-900'
|
||||
}`}>
|
||||
{matchup.match.team1Score}
|
||||
</span>
|
||||
<span className="text-gray-400">-</span>
|
||||
<span className={`font-bold ${
|
||||
matchup.match.team2Score > matchup.match.team1Score
|
||||
? 'text-green-600'
|
||||
: 'text-gray-900'
|
||||
}`}>
|
||||
{matchup.match.team2Score}
|
||||
</span>
|
||||
<Link
|
||||
href={`/matches/${matchup.match.id}`}
|
||||
className="text-green-600 hover:text-green-900 text-sm"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href={`/admin/tournaments/${tournament.id}/results`}
|
||||
className="px-3 py-1 border border-green-300 rounded text-sm font-medium text-green-700 hover:bg-green-50"
|
||||
>
|
||||
Enter Result
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Actions when schedule exists */}
|
||||
{hasSchedule && (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Schedule Actions
|
||||
</h2>
|
||||
<ScheduleGenerator
|
||||
tournamentId={tournamentId}
|
||||
teamCount={tournament.teams.length}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="rounded-md bg-green-50 p-4">
|
||||
<div className="text-sm text-green-700">
|
||||
Generated {result.roundsCreated} rounds with {result.matchupsCreated} matchups!
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
Generate a round-robin schedule for <strong>{teamCount} teams</strong>.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
This will create {expectedRounds} rounds with {expectedMatchups} total matchups.
|
||||
Each team plays every other team exactly once.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || teamCount < 2}
|
||||
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{isGenerating ? "Generating..." : "Generate Schedule"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={isGenerating}
|
||||
className="px-4 py-2 border border-red-300 rounded-md shadow-sm text-sm font-medium text-red-700 bg-white hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
Delete Schedule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user