Compare commits
6 Commits
v0.1.19
...
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/.next ./.next
|
||||||
COPY --from=builder --chown=euchre:euchre /app/public ./public
|
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/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
|
COPY --from=builder --chown=euchre:euchre /app/prisma ./prisma
|
||||||
|
|
||||||
# Install only production dependencies
|
# 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 */}
|
{/* Tabs */}
|
||||||
<div className="border-b border-gray-200">
|
<div className="border-b border-gray-200">
|
||||||
<nav className="-mb-px flex space-x-8">
|
<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
|
Overview
|
||||||
</button>
|
</span>
|
||||||
<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 className="border-transparent text-gray-400 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm cursor-not-allowed">
|
||||||
Participants
|
Participants
|
||||||
</button>
|
</span>
|
||||||
<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 className="border-transparent text-gray-400 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm cursor-not-allowed">
|
||||||
Teams
|
Teams
|
||||||
</button>
|
</span>
|
||||||
<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
|
||||||
|
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
|
Schedule
|
||||||
</button>
|
</Link>
|
||||||
<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
|
||||||
|
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
|
Results
|
||||||
</button>
|
</Link>
|
||||||
<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 className="border-transparent text-gray-400 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm cursor-not-allowed">
|
||||||
Analytics
|
Analytics
|
||||||
</button>
|
</span>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</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