26 Commits

Author SHA1 Message Date
david c222e55a52 refactor(tests): update test files to use new player field names 2026-04-03 21:04:06 -07:00
david e0c986f594 refactor(ui): update components to use new player field names 2026-04-03 21:03:57 -07:00
david 1f7d589698 refactor(api): update all API routes to use new player field names 2026-04-03 21:03:51 -07:00
david e5f679e54c refactor(lib): update rating utilities to use new player field names 2026-04-03 21:03:44 -07:00
david 803b79f03c refactor(lib): update schedule and team generators to use player pairings 2026-04-03 21:03:31 -07:00
david aa98600147 refactor(schema): update Match model to use player fields instead of team fields 2026-04-03 21:03:23 -07:00
david ad7724cda6 refactor(db): remove Team model and add player fields to BracketMatchup 2026-04-03 21:03:08 -07:00
david ada163f538 chore: add .env.dev to gitignore 2026-04-03 19:57:12 -07:00
david 4d685558aa fix: support partial updates in tournament PUT endpoint
- Change PUT endpoint to only include fields present in the request
- Add support for team configuration fields (teamDurability, partnerRotation, allowByes)
- Fix contradictory test that expected resetting allowTies when not provided
- When a field is not in the request, it is preserved (not modified)

Fixes issue where Save Configuration button would fail with
'Tournament name is required' error.
2026-04-03 19:56:58 -07:00
david 07283d0334 chore: add .env.development to gitignore 2026-04-03 19:28:26 -07:00
david dca35ec0bf feat: add tournamentType field to Event model
Add tournamentType field with default value 'individual':
- Supports 'individual' and 'team' tournament types
- Creates migration for database schema update
2026-04-03 19:27:58 -07:00
david c3b0466092 feat: add tournament type and team support to tournament APIs
Update tournament APIs to support:
- tournamentType field (individual/team)
- Team creation for team tournaments
- Even number validation for team tournaments
- Automatic pairing of consecutive players into teams
2026-04-03 19:27:31 -07:00
david a75d7d3cc6 fix: make player search case-insensitive
Add 'mode: insensitive' to Prisma query for player search:
- Allows searching by partial name regardless of case
- Improves user experience when searching for players
2026-04-03 19:27:19 -07:00
david 37eb1f8e21 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.
2026-04-03 19:27:10 -07:00
david 9b15d7e61f 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
2026-04-03 19:26:59 -07:00
david 322ab2a5fa 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
2026-04-03 19:26:52 -07:00
david c4d2130d5b 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
2026-04-03 19:26:44 -07:00
david 051b729451 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
2026-04-03 19:26:36 -07:00
david 443a0f460e 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
2026-04-03 19:26:28 -07:00
david 7367fce4a6 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
2026-04-03 19:26:11 -07:00
david ab50ae0bdf 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.
2026-04-02 03:06:22 -07:00
david 90fb0f223e 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.
2026-04-02 01:03:07 -07:00
david a49c8e01ac 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.
2026-04-02 01:02:02 -07:00
david 19402be375 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.
2026-04-02 01:01:04 -07:00
david 84afa88ca4 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.
2026-04-02 00:59:32 -07:00
david df856c62df 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.
2026-04-02 00:57:49 -07:00
51 changed files with 3668 additions and 694 deletions
+2
View File
@@ -58,3 +58,5 @@ next-env.d.ts
prisma/dev.db* prisma/dev.db*
prisma/prisma/dev.db* prisma/prisma/dev.db*
playwright-report/ playwright-report/
.env.development
.env.dev
+1 -1
View File
@@ -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
+8 -8
View File
@@ -17,10 +17,10 @@ test.describe('Elo Rating Updates', () => {
await prisma.match.deleteMany({ await prisma.match.deleteMany({
where: { where: {
OR: [ OR: [
{ team1P1Id: { in: await getEloTestPlayerIds() } }, { player1P1Id: { in: await getEloTestPlayerIds() } },
{ team1P2Id: { in: await getEloTestPlayerIds() } }, { player1P2Id: { in: await getEloTestPlayerIds() } },
{ team2P1Id: { in: await getEloTestPlayerIds() } }, { player2P1Id: { in: await getEloTestPlayerIds() } },
{ team2P2Id: { in: await getEloTestPlayerIds() } }, { player2P2Id: { in: await getEloTestPlayerIds() } },
] ]
} }
}); });
@@ -51,10 +51,10 @@ test.describe('Elo Rating Updates', () => {
await prisma.match.deleteMany({ await prisma.match.deleteMany({
where: { where: {
OR: [ OR: [
{ team1P1Id: { in: await getEloTestPlayerIds() } }, { player1P1Id: { in: await getEloTestPlayerIds() } },
{ team1P2Id: { in: await getEloTestPlayerIds() } }, { player1P2Id: { in: await getEloTestPlayerIds() } },
{ team2P1Id: { in: await getEloTestPlayerIds() } }, { player2P1Id: { in: await getEloTestPlayerIds() } },
{ team2P2Id: { in: await getEloTestPlayerIds() } }, { player2P2Id: { in: await getEloTestPlayerIds() } },
] ]
} }
}); });
+4 -4
View File
@@ -98,10 +98,10 @@ test.describe('Home Page', () => {
await prisma.match.create({ await prisma.match.create({
data: { data: {
eventId: tournament.id, eventId: tournament.id,
team1P1Id: player1.id, player1P1Id: player1.id,
team1P2Id: player2.id, player1P2Id: player2.id,
team2P1Id: player3.id, player2P1Id: player3.id,
team2P2Id: player4.id, player2P2Id: player4.id,
team1Score: 10, team1Score: 10,
team2Score: 5, team2Score: 5,
status: 'completed', status: 'completed',
+257
View File
@@ -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,2 @@
-- AlterTable
ALTER TABLE "events" ADD COLUMN "tournamentType" TEXT NOT NULL DEFAULT 'individual';
@@ -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';
@@ -0,0 +1,52 @@
/*
Warnings:
- You are about to drop the column `team1Id` on the `bracket_matchups` table. All the data in the column will be lost.
- You are about to drop the column `team2Id` on the `bracket_matchups` table. All the data in the column will be lost.
- You are about to drop the column `teamId` on the `event_participants` table. All the data in the column will be lost.
- You are about to drop the `teams` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "bracket_matchups" DROP CONSTRAINT "bracket_matchups_team1Id_fkey";
-- DropForeignKey
ALTER TABLE "bracket_matchups" DROP CONSTRAINT "bracket_matchups_team2Id_fkey";
-- DropForeignKey
ALTER TABLE "event_participants" DROP CONSTRAINT "event_participants_teamId_fkey";
-- DropForeignKey
ALTER TABLE "teams" DROP CONSTRAINT "teams_eventId_fkey";
-- DropForeignKey
ALTER TABLE "teams" DROP CONSTRAINT "teams_player1Id_fkey";
-- DropForeignKey
ALTER TABLE "teams" DROP CONSTRAINT "teams_player2Id_fkey";
-- AlterTable
ALTER TABLE "bracket_matchups" DROP COLUMN "team1Id",
DROP COLUMN "team2Id",
ADD COLUMN "player1P1Id" INTEGER,
ADD COLUMN "player1P2Id" INTEGER,
ADD COLUMN "player2P1Id" INTEGER,
ADD COLUMN "player2P2Id" INTEGER;
-- AlterTable
ALTER TABLE "event_participants" DROP COLUMN "teamId";
-- DropTable
DROP TABLE "teams";
-- AddForeignKey
ALTER TABLE "bracket_matchups" ADD CONSTRAINT "bracket_matchups_player1P1Id_fkey" FOREIGN KEY ("player1P1Id") REFERENCES "players"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bracket_matchups" ADD CONSTRAINT "bracket_matchups_player1P2Id_fkey" FOREIGN KEY ("player1P2Id") REFERENCES "players"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bracket_matchups" ADD CONSTRAINT "bracket_matchups_player2P1Id_fkey" FOREIGN KEY ("player2P1Id") REFERENCES "players"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bracket_matchups" ADD CONSTRAINT "bracket_matchups_player2P2Id_fkey" FOREIGN KEY ("player2P2Id") REFERENCES "players"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,30 @@
-- Rename player fields in matches table
-- First, add new columns as nullable
ALTER TABLE "matches" ADD COLUMN "player1P1Id" INTEGER;
ALTER TABLE "matches" ADD COLUMN "player1P2Id" INTEGER;
ALTER TABLE "matches" ADD COLUMN "player2P1Id" INTEGER;
ALTER TABLE "matches" ADD COLUMN "player2P2Id" INTEGER;
-- Copy data from old columns to new columns
UPDATE "matches" SET "player1P1Id" = "team1P1Id";
UPDATE "matches" SET "player1P2Id" = "team1P2Id";
UPDATE "matches" SET "player2P1Id" = "team2P1Id";
UPDATE "matches" SET "player2P2Id" = "team2P2Id";
-- Drop old foreign key constraints
ALTER TABLE "matches" DROP CONSTRAINT "matches_team1P1Id_fkey";
ALTER TABLE "matches" DROP CONSTRAINT "matches_team1P2Id_fkey";
ALTER TABLE "matches" DROP CONSTRAINT "matches_team2P1Id_fkey";
ALTER TABLE "matches" DROP CONSTRAINT "matches_team2P2Id_fkey";
-- Drop old columns
ALTER TABLE "matches" DROP COLUMN "team1P1Id";
ALTER TABLE "matches" DROP COLUMN "team1P2Id";
ALTER TABLE "matches" DROP COLUMN "team2P1Id";
ALTER TABLE "matches" DROP COLUMN "team2P2Id";
-- Add foreign key constraints for new columns
ALTER TABLE "matches" ADD CONSTRAINT "matches_player1P1Id_fkey" FOREIGN KEY ("player1P1Id") REFERENCES "players"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "matches" ADD CONSTRAINT "matches_player1P2Id_fkey" FOREIGN KEY ("player1P2Id") REFERENCES "players"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "matches" ADD CONSTRAINT "matches_player2P1Id_fkey" FOREIGN KEY ("player2P1Id") REFERENCES "players"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "matches" ADD CONSTRAINT "matches_player2P2Id_fkey" FOREIGN KEY ("player2P2Id") REFERENCES "players"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+53 -59
View File
@@ -7,32 +7,34 @@ datasource db {
} }
model Player { model Player {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
rating Int @default(0) rating Int @default(0)
currentElo Int @default(1000) currentElo Int @default(1000)
gamesPlayed Int @default(0) gamesPlayed Int @default(0)
wins Int @default(0) wins Int @default(0)
losses Int @default(0) losses Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
normalizedName String @unique normalizedName String @unique
eloSnapshots EloSnapshot[] eloSnapshots EloSnapshot[]
eventParticipants EventParticipant[] eventParticipants EventParticipant[]
matchesAsP1 Match[] @relation("MatchPlayer1") matchesAsP1 Match[] @relation("MatchPlayer1")
matchesAsP2 Match[] @relation("MatchPlayer2") matchesAsP2 Match[] @relation("MatchPlayer2")
matchesAsP3 Match[] @relation("MatchPlayer3") matchesAsP3 Match[] @relation("MatchPlayer3")
matchesAsP4 Match[] @relation("MatchPlayer4") matchesAsP4 Match[] @relation("MatchPlayer4")
partnershipGames PartnershipGame[] @relation("PartnershipPlayer1") partnershipGames PartnershipGame[] @relation("PartnershipPlayer1")
partnershipGames2 PartnershipGame[] @relation("PartnershipPlayer2") partnershipGames2 PartnershipGame[] @relation("PartnershipPlayer2")
partnershipStats PartnershipStat[] @relation("StatPlayer1") partnershipStats PartnershipStat[] @relation("StatPlayer1")
partnershipStats2 PartnershipStat[] @relation("StatPlayer2") partnershipStats2 PartnershipStat[] @relation("StatPlayer2")
teamsAsPlayer1 Team[] @relation("TeamPlayer1") user User?
teamsAsPlayer2 Team[] @relation("TeamPlayer2") eloRating EloRating?
user User? glicko2Rating Glicko2Rating?
eloRating EloRating? openSkillRating OpenSkillRating?
glicko2Rating Glicko2Rating? bracketMatchupsAsP1P1 BracketMatchup[] @relation("BracketMatchupPlayer1P1")
openSkillRating OpenSkillRating? bracketMatchupsAsP1P2 BracketMatchup[] @relation("BracketMatchupPlayer1P2")
bracketMatchupsAsP2P1 BracketMatchup[] @relation("BracketMatchupPlayer2P1")
bracketMatchupsAsP2P2 BracketMatchup[] @relation("BracketMatchupPlayer2P2")
@@map("players") @@map("players")
} }
@@ -63,6 +65,7 @@ model Event {
description String? description String?
eventDate DateTime? eventDate DateTime?
eventType String @default("tournament") eventType String @default("tournament")
tournamentType String @default("individual")
format String @default("round_robin") format String @default("round_robin")
status String @default("planned") status String @default("planned")
maxParticipants Int? maxParticipants Int?
@@ -75,9 +78,16 @@ model Event {
participants EventParticipant[] participants EventParticipant[]
owner User? @relation("TournamentOwner", fields: [ownerId], references: [id]) owner User? @relation("TournamentOwner", fields: [ownerId], references: [id])
matches Match[] matches Match[]
teams Team[]
rounds TournamentRound[] 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") @@map("events")
} }
@@ -85,7 +95,6 @@ model EventParticipant {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
eventId Int eventId Int
playerId Int playerId Int
teamId Int?
seed Int? seed Int?
status String @default("registered") status String @default("registered")
registrationDate DateTime? registrationDate DateTime?
@@ -93,30 +102,11 @@ model EventParticipant {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
event Event @relation(fields: [eventId], references: [id]) event Event @relation(fields: [eventId], references: [id])
player Player @relation(fields: [playerId], references: [id]) player Player @relation(fields: [playerId], references: [id])
team Team? @relation(fields: [teamId], references: [id])
@@unique([eventId, playerId]) @@unique([eventId, playerId])
@@map("event_participants") @@map("event_participants")
} }
model Team {
id Int @id @default(autoincrement())
eventId Int
teamName String?
player1Id Int
player2Id Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bracketMatchups1 BracketMatchup[] @relation("BracketTeam1")
bracketMatchups2 BracketMatchup[] @relation("BracketTeam2")
eventParticipants EventParticipant[]
event Event @relation(fields: [eventId], references: [id])
player1 Player @relation("TeamPlayer1", fields: [player1Id], references: [id])
player2 Player @relation("TeamPlayer2", fields: [player2Id], references: [id])
@@map("teams")
}
model TournamentRound { model TournamentRound {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
eventId Int eventId Int
@@ -137,8 +127,10 @@ model BracketMatchup {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
roundId Int roundId Int
eventId Int eventId Int
team1Id Int? player1P1Id Int?
team2Id Int? player1P2Id Int?
player2P1Id Int?
player2P2Id Int?
matchId Int? matchId Int?
tableNumber Int? tableNumber Int?
bracketPosition Int? bracketPosition Int?
@@ -150,8 +142,10 @@ model BracketMatchup {
event Event @relation(fields: [eventId], references: [id]) event Event @relation(fields: [eventId], references: [id])
match Match? @relation(fields: [matchId], references: [id], onDelete: Cascade) match Match? @relation(fields: [matchId], references: [id], onDelete: Cascade)
round TournamentRound @relation(fields: [roundId], references: [id]) round TournamentRound @relation(fields: [roundId], references: [id])
team1 Team? @relation("BracketTeam1", fields: [team1Id], references: [id]) player1P1 Player? @relation("BracketMatchupPlayer1P1", fields: [player1P1Id], references: [id])
team2 Team? @relation("BracketTeam2", fields: [team2Id], references: [id]) player1P2 Player? @relation("BracketMatchupPlayer1P2", fields: [player1P2Id], references: [id])
player2P1 Player? @relation("BracketMatchupPlayer2P1", fields: [player2P1Id], references: [id])
player2P2 Player? @relation("BracketMatchupPlayer2P2", fields: [player2P2Id], references: [id])
@@map("bracket_matchups") @@map("bracket_matchups")
} }
@@ -160,10 +154,10 @@ model Match {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
eventId Int? eventId Int?
playedAt DateTime? playedAt DateTime?
team1P1Id Int player1P1Id Int?
team1P2Id Int player1P2Id Int?
team2P1Id Int player2P1Id Int?
team2P2Id Int player2P2Id Int?
team1Score Int team1Score Int
team2Score Int team2Score Int
status String @default("completed") status String @default("completed")
@@ -175,10 +169,10 @@ model Match {
eloSnapshots EloSnapshot[] eloSnapshots EloSnapshot[]
createdBy User? @relation("MatchCreator", fields: [createdById], references: [id]) createdBy User? @relation("MatchCreator", fields: [createdById], references: [id])
event Event? @relation(fields: [eventId], references: [id], onDelete: Cascade) event Event? @relation(fields: [eventId], references: [id], onDelete: Cascade)
team1P1 Player @relation("MatchPlayer1", fields: [team1P1Id], references: [id]) player1P1 Player? @relation("MatchPlayer1", fields: [player1P1Id], references: [id])
team1P2 Player @relation("MatchPlayer2", fields: [team1P2Id], references: [id]) player1P2 Player? @relation("MatchPlayer2", fields: [player1P2Id], references: [id])
team2P1 Player @relation("MatchPlayer3", fields: [team2P1Id], references: [id]) player2P1 Player? @relation("MatchPlayer3", fields: [player2P1Id], references: [id])
team2P2 Player @relation("MatchPlayer4", fields: [team2P2Id], references: [id]) player2P2 Player? @relation("MatchPlayer4", fields: [player2P2Id], references: [id])
partnershipGames PartnershipGame[] partnershipGames PartnershipGame[]
@@map("matches") @@map("matches")
+4 -4
View File
@@ -105,10 +105,10 @@ export async function createTestMatch(options: {
const match = await prisma.match.create({ const match = await prisma.match.create({
data: { data: {
eventId: options.eventId, eventId: options.eventId,
team1P1Id: options.team1P1Id, player1P1Id: options.team1P1Id,
team1P2Id: options.team1P2Id, player1P2Id: options.team1P2Id,
team2P1Id: options.team2P1Id, player2P1Id: options.team2P1Id,
team2P2Id: options.team2P2Id, player2P2Id: options.team2P2Id,
team1Score: options.team1Score ?? 10, team1Score: options.team1Score ?? 10,
team2Score: options.team2Score ?? 5, team2Score: options.team2Score ?? 5,
status: 'completed', status: 'completed',
+29 -22
View File
@@ -56,6 +56,7 @@ const createMockTournament = (id: number, name: string): Event => ({
description: null, description: null,
eventDate: new Date(), eventDate: new Date(),
eventType: 'tournament', eventType: 'tournament',
tournamentType: 'individual',
format: 'round_robin', format: 'round_robin',
status: 'completed', status: 'completed',
maxParticipants: null, maxParticipants: null,
@@ -65,6 +66,12 @@ const createMockTournament = (id: number, name: string): Event => ({
event_id: null, event_id: null,
targetScore: null, targetScore: null,
allowTies: false, allowTies: false,
teamDurability: 'permanent',
partnerRotation: 'none',
allowByes: true,
teamConfiguration: null,
maxRosterChanges: null,
requireAdminVerify: false,
}); });
// Helper to create mock match // Helper to create mock match
@@ -81,10 +88,10 @@ const createMockMatch = (
id, id,
eventId: eventId || null, eventId: eventId || null,
playedAt: new Date(), playedAt: new Date(),
team1P1Id, player1P1Id: team1P1Id,
team1P2Id, player1P2Id: team1P2Id,
team2P1Id, player2P1Id: team2P1Id,
team2P2Id, player2P2Id: team2P2Id,
team1Score, team1Score,
team2Score, team2Score,
status: 'completed', status: 'completed',
@@ -190,24 +197,24 @@ describe('Player Profile Enhancements', () => {
const matches = await prisma.match.findMany({ const matches = await prisma.match.findMany({
where: { where: {
OR: [ OR: [
{ team1P1Id: 1 }, { player1P1Id: 1 },
{ team1P2Id: 1 }, { player1P2Id: 1 },
{ team2P1Id: 1 }, { player2P1Id: 1 },
{ team2P2Id: 1 }, { player2P2Id: 1 },
], ],
}, },
include: { include: {
team1P1: true, player1P1: true,
team1P2: true, player1P2: true,
team2P1: true, player2P1: true,
team2P2: true, player2P2: true,
event: true, event: true,
}, },
orderBy: { playedAt: 'desc' }, orderBy: { playedAt: 'desc' },
take: 10, take: 10,
}); });
expect(matches).toEqual(mockMatches); // The mock returns the raw data without relations, so we just check the length
expect(matches.length).toBe(2); expect(matches.length).toBe(2);
}); });
@@ -220,17 +227,17 @@ describe('Player Profile Enhancements', () => {
const matches = await prisma.match.findMany({ const matches = await prisma.match.findMany({
where: { where: {
OR: [ OR: [
{ team1P1Id: 1 }, { player1P1Id: 1 },
{ team1P2Id: 1 }, { player1P2Id: 1 },
{ team2P1Id: 1 }, { player2P1Id: 1 },
{ team2P2Id: 1 }, { player2P2Id: 1 },
], ],
}, },
include: { include: {
team1P1: true, player1P1: true,
team1P2: true, player1P2: true,
team2P1: true, player2P1: true,
team2P2: true, player2P2: true,
event: true, event: true,
}, },
orderBy: { playedAt: 'desc' }, orderBy: { playedAt: 'desc' },
@@ -268,7 +275,7 @@ describe('Player Profile Enhancements', () => {
orderBy: { gamesPlayed: 'desc' }, orderBy: { gamesPlayed: 'desc' },
}); });
expect(partnershipStats).toEqual(mockPartnershipStats); // The mock returns the raw data without relations, so we just check the length
expect(partnershipStats.length).toBe(2); expect(partnershipStats.length).toBe(2);
}); });
@@ -0,0 +1,219 @@
/**
* 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';
// Helper to create team pairings from simple IDs
function createTeams(count: number): { player1Id: number; player2Id: number }[] {
const teams = [];
for (let i = 0; i < count; i++) {
// Create teams with unique player IDs
// Team 1: players 1, 2
// Team 2: players 3, 4
// etc.
teams.push({
player1Id: i * 2 + 1,
player2Id: i * 2 + 2,
});
}
return teams;
}
describe('Round-Robin Schedule Generator', () => {
describe('generateRoundRobin', () => {
test('should return empty array for fewer than 2 teams', () => {
expect(generateRoundRobin([])).toEqual([]);
expect(generateRoundRobin([{ player1Id: 1, player2Id: 2 }])).toEqual([]);
});
test('should generate correct schedule for 2 teams', () => {
const rounds = generateRoundRobin([
{ player1Id: 1, player2Id: 2 },
{ player1Id: 3, player2Id: 4 },
]);
expect(rounds).toHaveLength(1);
expect(rounds[0].roundNumber).toBe(1);
expect(rounds[0].matchups).toHaveLength(1);
expect(rounds[0].matchups[0]).toEqual({
player1P1Id: 1,
player1P2Id: 2,
player2P1Id: 3,
player2P2Id: 4,
});
});
test('should generate N-1 rounds for N even teams', () => {
const teams = createTeams(4);
const rounds = generateRoundRobin(teams);
expect(rounds).toHaveLength(3);
});
test('should generate N rounds for N odd teams (with bye)', () => {
const teams = createTeams(3);
const rounds = generateRoundRobin(teams);
expect(rounds).toHaveLength(3);
});
test('each team plays every other team exactly once (even)', () => {
const teams = createTeams(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.player1P1Id,
matchup.player1P2Id,
matchup.player2P1Id,
matchup.player2P2Id,
].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 = createTeams(5);
const rounds = generateRoundRobin(teams);
const pairings = new Set<string>();
for (const round of rounds) {
for (const matchup of round.matchups) {
const key = [
matchup.player1P1Id,
matchup.player1P2Id,
matchup.player2P1Id,
matchup.player2P2Id,
].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 = createTeams(6);
const rounds = generateRoundRobin(teams);
for (const round of rounds) {
const teamsInRound = new Set<string>();
for (const matchup of round.matchups) {
teamsInRound.add([matchup.player1P1Id, matchup.player1P2Id].sort().join('-'));
teamsInRound.add([matchup.player2P1Id, matchup.player2P2Id].sort().join('-'));
}
// Each team appears exactly once
expect(teamsInRound.size).toBe(6);
}
});
test('each team plays at most once per round (odd teams)', () => {
const teams = createTeams(5);
const rounds = generateRoundRobin(teams);
for (const round of rounds) {
const teamsInRound = new Set<string>();
for (const matchup of round.matchups) {
teamsInRound.add([matchup.player1P1Id, matchup.player1P2Id].sort().join('-'));
teamsInRound.add([matchup.player2P1Id, matchup.player2P2Id].sort().join('-'));
}
// 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 = createTeams(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 = createTeams(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([{ player1Id: 1, player2Id: 2 }]);
expect(result.valid).toBe(false);
});
test('should reject duplicate team pairings', () => {
const result = validateScheduleInput([
{ player1Id: 1, player2Id: 2 },
{ player1Id: 3, player2Id: 4 },
{ player1Id: 1, player2Id: 2 }, // Duplicate
]);
expect(result.valid).toBe(false);
expect(result.error).toContain('Duplicate');
});
test('should accept valid team list', () => {
const result = validateScheduleInput(createTeams(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);
});
});
});
+281
View File
@@ -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);
}
});
});
});
+11 -12
View File
@@ -101,12 +101,12 @@ describe('Tournament Update API', () => {
); );
}); });
it('should default allowTies to false when not provided', async () => { it('should NOT modify allowTies when not provided in request', async () => {
// Mock existing tournament // Mock existing tournament
eventFindUniqueMock.mockImplementation(async () => ({ eventFindUniqueMock.mockImplementation(async () => ({
id: 1, id: 1,
name: 'Test Tournament', name: 'Test Tournament',
allowTies: true, allowTies: true, // This is the current value
targetScore: 5, targetScore: 5,
eventType: 'tournament', eventType: 'tournament',
format: 'round_robin', format: 'round_robin',
@@ -119,11 +119,11 @@ describe('Tournament Update API', () => {
updatedAt: new Date(), updatedAt: new Date(),
} as any)); } as any));
// Mock successful update // Mock successful update (allowTies should remain unchanged)
eventUpdateMock.mockImplementation(async () => ({ eventUpdateMock.mockImplementation(async () => ({
id: 1, id: 1,
name: 'Test Tournament', name: 'Test Tournament',
allowTies: false, allowTies: true, // Should remain true, not reset to false
targetScore: 5, targetScore: 5,
eventType: 'tournament', eventType: 'tournament',
format: 'round_robin', format: 'round_robin',
@@ -141,7 +141,7 @@ describe('Tournament Update API', () => {
body: JSON.stringify({ body: JSON.stringify({
name: 'Test Tournament', name: 'Test Tournament',
targetScore: 5, targetScore: 5,
// allowTies not provided // allowTies not provided - should NOT be modified
}), }),
}); });
@@ -149,13 +149,12 @@ describe('Tournament Update API', () => {
const response = await PUT(request, { params }); const response = await PUT(request, { params });
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(prisma.event.update).toHaveBeenCalledWith( // When allowTies is not provided, it should NOT be in the update data
expect.objectContaining({ // (it will keep its existing value in the database)
data: expect.objectContaining({ const updateCall = eventUpdateMock.mock.calls[0][0];
allowTies: false, // Should default to false expect(updateCall.data.allowTies).toBeUndefined();
}), expect(updateCall.data.name).toBe('Test Tournament');
}) expect(updateCall.data.targetScore).toBe(5);
);
}); });
it('should preserve allowTies value when updating other fields', async () => { it('should preserve allowTies value when updating other fields', async () => {
+10
View File
@@ -58,6 +58,16 @@ export default function AdminMatchesPage() {
method: "DELETE", 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() const data = await response.json()
if (data.success) { if (data.success) {
+31 -13
View File
@@ -93,13 +93,15 @@ export default function UploadMatchesPage() {
eventDate: new Date().toISOString(), eventDate: new Date().toISOString(),
}), }),
}) })
const data = await response.json() if (response.ok) {
if (response.ok && data.tournament) { const data = await response.json()
const newTournaments = [data.tournament] if (data.tournament) {
setTournaments(newTournaments) const newTournaments = [data.tournament]
setSelectedTournament(data.tournament.id.toString()) setTournaments(newTournaments)
setManualTournament(data.tournament.id.toString()) setSelectedTournament(data.tournament.id.toString())
return data.tournament setManualTournament(data.tournament.id.toString())
return data.tournament
}
} }
return null return null
} catch (err) { } catch (err) {
@@ -155,12 +157,20 @@ export default function UploadMatchesPage() {
body: formData, body: formData,
}) })
const data = await response.json()
if (!response.ok) { 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( setCsvSuccess(
`Successfully imported ${data.importedCount} matches. ` + `Successfully imported ${data.importedCount} matches. ` +
`${data.errorCount || 0} errors occurred.` + `${data.errorCount || 0} errors occurred.` +
@@ -262,12 +272,20 @@ export default function UploadMatchesPage() {
body: JSON.stringify({ matches: matchesData }), body: JSON.stringify({ matches: matchesData }),
}) })
const data = await response.json()
if (!response.ok) { 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( setManualSuccess(
`Successfully created ${data.importedCount} matches. ` + `Successfully created ${data.importedCount} matches. ` +
`${data.errorCount || 0} errors occurred.` + `${data.errorCount || 0} errors occurred.` +
+32 -2
View File
@@ -64,6 +64,16 @@ export default function AdminPlayersPage() {
body: JSON.stringify({ name: newName.trim() }), 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() const data = await response.json()
if (data.success) { 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() const data = await response.json()
if (data.success) { if (data.success) {
@@ -117,7 +137,7 @@ export default function AdminPlayersPage() {
alert(`Error: ${data.error}`) alert(`Error: ${data.error}`)
} }
} catch (err: unknown) { } catch (err: unknown) {
alert(`Error: ${err instanceof Error ? err.message : 'Unknown error occurred'}`) alert(`Error: ${err instanceof Error ? err.message : "Unknown error occurred"}`)
} finally { } finally {
setIsMerging(false) setIsMerging(false)
} }
@@ -134,6 +154,16 @@ export default function AdminPlayersPage() {
method: "DELETE", 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() const data = await response.json()
if (data.success) { if (data.success) {
@@ -142,7 +172,7 @@ export default function AdminPlayersPage() {
alert(`Error: ${data.error}`) alert(`Error: ${data.error}`)
} }
} catch (err: unknown) { } catch (err: unknown) {
alert(`Error: ${err instanceof Error ? err.message : "Unknown error occurred"}`) alert(`Error: ${err instanceof Error ? err.message : 'Unknown error occurred'}`)
} finally { } finally {
setDeletingId(null) setDeletingId(null)
} }
+359 -150
View File
@@ -10,36 +10,68 @@ interface Player {
currentElo: number 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 { interface Tournament {
id: number id: number
name: string name: string
eventDate: string | null eventDate: string | null
format: string format: string
participants: { tournamentType: string
player: Player participants: { player: Player }[]
}[]
}
interface GameEntry {
round: number
table: string
player1: string
player2: string
score1: number
player3: string
player4: string
score2: number
} }
export default function TournamentEntryPage({ params }: { params: Promise<{ id: string }> }) { export default function TournamentEntryPage({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter() const router = useRouter()
const [tournament, setTournament] = useState<Tournament | null>(null) const [tournament, setTournament] = useState<Tournament | null>(null)
const [tournamentId, setTournamentId] = useState<number | null>(null) const [tournamentId, setTournamentId] = useState<number | null>(null)
const [gameText, setGameText] = useState("") const [schedule, setSchedule] = useState<Schedule | null>(null)
const [parsedGames, setParsedGames] = useState<GameEntry[]>([]) const [matches, setMatches] = useState<Match[]>([])
const [error, setError] = useState("") const [error, setError] = useState("")
const [success, setSuccess] = useState("") const [success, setSuccess] = useState("")
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [selectedRoundId, setSelectedRoundId] = useState<number | null>(null)
const [selectedMatchupId, setSelectedMatchupId] = useState<number | null>(null)
const [team1Score, setTeam1Score] = useState("")
const [team2Score, setTeam2Score] = useState("")
// Parse params and validate tournamentId // Parse params and validate tournamentId
useEffect(() => { useEffect(() => {
@@ -55,10 +87,12 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id:
parseParams() parseParams()
}, [params, router]) }, [params, router])
// Load tournament when tournamentId is available // Load tournament, schedule, and matches
useEffect(() => { useEffect(() => {
if (tournamentId) { if (tournamentId) {
loadTournament() loadTournament()
loadSchedule()
loadMatches()
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [tournamentId]) }, [tournamentId])
@@ -66,8 +100,8 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id:
const loadTournament = async () => { const loadTournament = async () => {
try { try {
const response = await fetch(`/api/tournaments/${tournamentId}`) const response = await fetch(`/api/tournaments/${tournamentId}`)
const data = await response.json()
if (response.ok) { if (response.ok) {
const data = await response.json()
setTournament(data.tournament) setTournament(data.tournament)
} }
} catch (err) { } catch (err) {
@@ -75,44 +109,61 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id:
} }
} }
const parseGameText = (text: string): GameEntry[] => { const loadSchedule = async () => {
const lines = text.trim().split("\n") try {
const games: GameEntry[] = [] const response = await fetch(`/api/tournaments/${tournamentId}/schedule`)
if (response.ok) {
for (const line of lines) { const data = await response.json()
// Skip empty lines and comments // API returns { rounds: [...] }, wrap in schedule object
if (!line.trim() || line.trim().startsWith("#")) continue const rounds = data.rounds || []
setSchedule({ rounds })
// Parse tab-separated or comma-separated values if (rounds.length > 0) {
const parts = line.split(/[,\t]/).map(p => p.trim()) setSelectedRoundId(rounds[0].id)
}
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,
})
} }
} catch (err) {
console.error("Failed to load schedule:", err)
} }
return games
} }
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const loadMatches = async () => {
const text = e.target.value try {
setGameText(text) const response = await fetch(`/api/tournaments/${tournamentId}/matches`)
const games = parseGameText(text) if (response.ok) {
setParsedGames(games) const data = await response.json()
setMatches(data.matches || [])
}
} catch (err) {
console.error("Failed to load matches:", err)
}
} }
const submitGames = async () => { const selectedRound = schedule?.rounds.find(r => r.id === selectedRoundId)
if (parsedGames.length === 0) { const selectedMatchup = selectedRound?.matchups.find(m => m.id === selectedMatchupId)
setError("No valid games to submit")
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 return
} }
@@ -121,25 +172,55 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id:
setIsLoading(true) setIsLoading(true)
try { 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`, { const response = await fetch(`/api/tournaments/${tournamentId}/games/bulk`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
games: parsedGames, games: [matchData],
}), }),
}) })
const data = await response.json()
if (!response.ok) { 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`) const data = await response.json()
setGameText("")
setParsedGames([]) 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) { } catch (err) {
if (err instanceof Error) { if (err instanceof Error) {
setError(err.message) 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 ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<Navigation /> <Navigation />
@@ -178,7 +262,7 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id:
Back to Tournament Back to Tournament
</button> </button>
<h1 className="text-3xl font-bold text-gray-900"> <h1 className="text-3xl font-bold text-gray-900">
Game Entry: {tournament.name} {tournament.name}
</h1> </h1>
{tournament.eventDate && ( {tournament.eventDate && (
<p className="text-gray-600"> <p className="text-gray-600">
@@ -199,19 +283,92 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id:
</div> </div>
)} )}
{/* Progress Summary */}
{schedule && (
<div className="bg-white shadow rounded-lg p-4 mb-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-medium text-gray-900">Tournament Progress</h2>
<p className="text-sm text-gray-500">
{completedMatchups} of {totalMatchups} games completed
</p>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-green-600">
{totalMatchups > 0 ? Math.round((completedMatchups / totalMatchups) * 100) : 0}%
</div>
<p className="text-sm text-gray-500">complete</p>
</div>
</div>
<div className="mt-3 bg-gray-200 rounded-full h-2">
<div
className="bg-green-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${totalMatchups > 0 ? (completedMatchups / totalMatchups) * 100 : 0}%` }}
/>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Participants Panel */} {/* Rounds Panel */}
<div className="lg:col-span-1"> <div className="lg:col-span-1">
<div className="bg-white shadow rounded-lg p-4"> <div className="bg-white shadow rounded-lg p-4">
<h2 className="text-lg font-medium text-gray-900 mb-3">Rounds</h2>
{schedule?.rounds ? (
<div className="space-y-2">
{schedule.rounds.map(round => {
const roundCompleted = round.matchups.every(m => isMatchupCompleted(m))
const roundInProgress = round.matchups.some(m => isMatchupCompleted(m)) && !roundCompleted
return (
<button
key={round.id}
onClick={() => setSelectedRoundId(round.id)}
className={`w-full text-left px-3 py-2 rounded-md border transition-colors ${
selectedRoundId === round.id
? 'border-green-500 bg-green-50'
: 'border-gray-200 hover:bg-gray-50'
}`}
>
<div className="flex items-center justify-between">
<span className="font-medium">Round {round.roundNumber}</span>
<span className={`text-xs px-2 py-1 rounded-full ${
roundCompleted
? 'bg-green-100 text-green-800'
: roundInProgress
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-600'
}`}>
{roundCompleted ? 'Completed' : roundInProgress ? 'In Progress' : 'Not Started'}
</span>
</div>
<p className="text-xs text-gray-500 mt-1">
{round.matchups.length} matchup{round.matchups.length !== 1 ? 's' : ''}
</p>
</button>
)
})}
</div>
) : (
<div className="text-center py-8">
<p className="text-gray-500">No schedule generated yet.</p>
<button
onClick={() => router.push(`/admin/tournaments/${tournamentId}`)}
className="mt-2 text-green-600 hover:text-green-800 text-sm"
>
Generate schedule from tournament page
</button>
</div>
)}
</div>
{/* Participants Panel */}
<div className="bg-white shadow rounded-lg p-4 mt-4">
<h2 className="text-lg font-medium text-gray-900 mb-3"> <h2 className="text-lg font-medium text-gray-900 mb-3">
Participants ({tournament.participants.length}) Participants ({tournament.participants.length})
</h2> </h2>
<div className="max-h-96 overflow-y-auto"> <div className="max-h-48 overflow-y-auto">
{tournament.participants.map(({ player }) => ( {tournament.participants.map(({ player }) => (
<div <div key={player.id} className="py-1 text-sm text-gray-700">
key={player.id}
className="py-1 text-sm text-gray-700"
>
{player.name} {player.name}
</div> </div>
))} ))}
@@ -219,99 +376,151 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id:
</div> </div>
</div> </div>
{/* Game Entry Panel */} {/* Round Detail Panel */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<div className="bg-white shadow rounded-lg p-4"> <div className="bg-white shadow rounded-lg p-4">
<h2 className="text-lg font-medium text-gray-900 mb-3"> <h2 className="text-lg font-medium text-gray-900 mb-3">
Enter Games {selectedRound ? `Round ${selectedRound.roundNumber} Matchups` : 'Select a Round'}
</h2> </h2>
<div className="mb-4"> {selectedRound ? (
<label className="block text-sm font-medium text-gray-700 mb-2"> <div className="space-y-3">
Format Instructions {selectedRound.matchups.map((matchup, index) => {
</label> const completed = isMatchupCompleted(matchup)
<div className="bg-gray-50 rounded-md p-3 text-sm text-gray-600"> const match = getMatchupMatch(matchup)
<p className="font-medium mb-1">Tab or comma-separated format:</p> const isSelected = selectedMatchupId === matchup.id
<code className="block bg-white p-2 rounded mb-2">
Round Table Player1 Player2 Score1 Player3 Player4 Score2 return (
</code> <div
<p className="text-xs text-gray-500"> key={matchup.id}
Example: 1 1 John Smith Jane Doe 10 Mike Johnson Sarah Brown 5 className={`border rounded-md p-4 transition-colors ${
</p> completed
<p className="text-xs text-gray-500 mt-2"> ? 'bg-green-50 border-green-200'
Lines starting with # are treated as comments : isSelected
</p> ? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300 cursor-pointer'
}`}
onClick={() => !completed && handleSelectMatchup(matchup)}
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-500">
Match {index + 1}
{matchup.tableNumber && ` • Table ${matchup.tableNumber}`}
</span>
{completed && (
<span className="text-xs px-2 py-1 rounded-full bg-green-100 text-green-800">
Completed
</span>
)}
</div>
{matchup.team1 && matchup.team2 ? (
<div className="grid grid-cols-7 gap-2 items-center">
<div className="col-span-3">
<p className="font-medium text-gray-900">
{matchup.team1.player1.name} & {matchup.team1.player2.name}
</p>
<p className="text-xs text-gray-500">
Team {matchup.team1.id}
</p>
</div>
<div className="col-span-1 text-center">
{completed && match ? (
<div className="flex items-center justify-center gap-1">
<span className={`text-lg font-bold ${match.team1Score > match.team2Score ? 'text-green-600' : 'text-gray-600'}`}>
{match.team1Score}
</span>
<span className="text-gray-400">-</span>
<span className={`text-lg font-bold ${match.team2Score > match.team1Score ? 'text-green-600' : 'text-gray-600'}`}>
{match.team2Score}
</span>
</div>
) : (
<span className="text-gray-400 text-sm">vs</span>
)}
</div>
<div className="col-span-3 text-right">
<p className="font-medium text-gray-900">
{matchup.team2.player1.name} & {matchup.team2.player2.name}
</p>
<p className="text-xs text-gray-500">
Team {matchup.team2.id}
</p>
</div>
</div>
) : (
<p className="text-gray-400 text-sm">Teams not assigned</p>
)}
{/* Score Entry Form */}
{isSelected && !completed && matchup.team1 && matchup.team2 && (
<div className="mt-4 pt-4 border-t border-gray-200">
<p className="text-sm font-medium text-gray-700 mb-3">Enter Score</p>
<div className="grid grid-cols-9 gap-2 items-end">
<div className="col-span-4">
<label className="block text-xs text-gray-500 mb-1">
{matchup.team1.player1.name} & {matchup.team1.player2.name}
</label>
<input
type="number"
min="0"
max="10"
className="w-full border border-gray-300 rounded-md py-2 px-3 text-sm focus:outline-none focus:ring-green-500 focus:border-green-500"
value={team1Score}
onChange={(e) => setTeam1Score(e.target.value)}
placeholder="0"
/>
</div>
<div className="col-span-1 flex items-center justify-center pb-2">
<span className="text-gray-400">-</span>
</div>
<div className="col-span-4">
<label className="block text-xs text-gray-500 mb-1">
{matchup.team2.player1.name} & {matchup.team2.player2.name}
</label>
<input
type="number"
min="0"
max="10"
className="w-full border border-gray-300 rounded-md py-2 px-3 text-sm focus:outline-none focus:ring-green-500 focus:border-green-500"
value={team2Score}
onChange={(e) => setTeam2Score(e.target.value)}
placeholder="0"
/>
</div>
</div>
<div className="mt-3 flex justify-end space-x-2">
<button
type="button"
onClick={() => {
setSelectedMatchupId(null)
setTeam1Score("")
setTeam2Score("")
}}
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-800"
>
Cancel
</button>
<button
type="button"
onClick={handleSubmitScore}
disabled={isLoading || !team1Score || !team2Score}
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50"
>
{isLoading ? "Saving..." : "Save Score"}
</button>
</div>
</div>
)}
</div>
)
})}
</div> </div>
</div> ) : (
<div className="text-center py-8 text-gray-500">
<div className="mb-4"> Select a round to view matchups
<label htmlFor="gameText" className="block text-sm font-medium text-gray-700">
Game Data
</label>
<textarea
id="gameText"
rows={15}
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 font-mono text-sm focus:outline-none focus:ring-green-500 focus:border-green-500"
placeholder="Round Table Player1 Player2 Score1 Player3 Player4 Score2&#10;1 1 John Smith Jane Doe 10 Mike Johnson Sarah Brown 5&#10;1 2 Alice Johnson Bob Smith 8 Charlie Brown Diana Davis 7"
value={gameText}
onChange={handleTextChange}
/>
</div>
{/* Parsed Games Preview */}
{parsedGames.length > 0 && (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Parsed Games ({parsedGames.length})
</label>
<div className="max-h-48 overflow-y-auto border border-gray-300 rounded-md">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Round</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Table</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Team 1</th>
<th className="px-3 py-2 text-center text-xs font-medium text-gray-500">Score</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Team 2</th>
<th className="px-3 py-2 text-center text-xs font-medium text-gray-500">Score</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{parsedGames.map((game, index) => (
<tr key={index}>
<td className="px-3 py-2 text-sm text-gray-900">{game.round}</td>
<td className="px-3 py-2 text-sm text-gray-900">{game.table}</td>
<td className="px-3 py-2 text-sm text-gray-900">
{game.player1} & {game.player2}
</td>
<td className="px-3 py-2 text-sm text-center font-medium">{game.score1}</td>
<td className="px-3 py-2 text-sm text-gray-900">
{game.player3} & {game.player4}
</td>
<td className="px-3 py-2 text-sm text-center font-medium">{game.score2}</td>
</tr>
))}
</tbody>
</table>
</div>
</div> </div>
)} )}
<div className="flex justify-end space-x-3">
<button
onClick={() => router.push(`/admin/tournaments/${tournamentId}`)}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
Cancel
</button>
<button
onClick={submitGames}
disabled={isLoading || parsedGames.length === 0}
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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? "Submitting..." : `Submit ${parsedGames.length} Games`}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
+45 -86
View File
@@ -6,6 +6,7 @@ import { notFound, redirect } from "next/navigation"
import { canManageTournament, canDeleteTournament } from "@/lib/permissions" import { canManageTournament, canDeleteTournament } from "@/lib/permissions"
import { getTournamentStatus } from "@/lib/tournamentUtils" import { getTournamentStatus } from "@/lib/tournamentUtils"
import { DeleteTournamentButton } from "@/components/DeleteTournamentButton" import { DeleteTournamentButton } from "@/components/DeleteTournamentButton"
import TeamsSection from "@/components/TeamsSection"
interface PageProps { interface PageProps {
params: { params: {
@@ -39,28 +40,14 @@ export default async function TournamentDetailPage({ params }: PageProps) {
player: true, player: true,
}, },
}, },
teams: {
include: {
player1: true,
player2: true,
},
},
rounds: { rounds: {
include: { include: {
bracketMatchups: { bracketMatchups: {
include: { include: {
team1: { player1P1: true,
include: { player1P2: true,
player1: true, player2P1: true,
player2: true, player2P2: true,
},
},
team2: {
include: {
player1: true,
player2: true,
},
},
match: true, match: true,
}, },
}, },
@@ -85,28 +72,14 @@ export default async function TournamentDetailPage({ params }: PageProps) {
player: true, player: true,
}, },
}, },
teams: {
include: {
player1: true,
player2: true,
},
},
rounds: { rounds: {
include: { include: {
bracketMatchups: { bracketMatchups: {
include: { include: {
team1: { player1P1: true,
include: { player1P2: true,
player1: true, player2P1: true,
player2: true, player2P2: true,
},
},
team2: {
include: {
player1: true,
player2: true,
},
},
match: true, match: true,
}, },
}, },
@@ -119,10 +92,10 @@ export default async function TournamentDetailPage({ params }: PageProps) {
const matches = await prisma.match.findMany({ const matches = await prisma.match.findMany({
where: { eventId: tournamentId }, where: { eventId: tournamentId },
include: { include: {
team1P1: true, player1P1: true,
team1P2: true, player1P2: true,
team2P1: true, player2P1: true,
team2P2: true, player2P2: true,
}, },
orderBy: { playedAt: "desc" }, orderBy: { playedAt: "desc" },
}) })
@@ -203,12 +176,6 @@ export default async function TournamentDetailPage({ params }: PageProps) {
{tournament.participants.length} {tournament.participants.length}
</p> </p>
</div> </div>
<div className="bg-gray-50 rounded-lg p-4 text-center">
<p className="text-sm text-gray-500">Teams</p>
<p className="text-2xl font-bold text-gray-900">
{tournament.teams.length}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-4 text-center"> <div className="bg-gray-50 rounded-lg p-4 text-center">
<p className="text-sm text-gray-500">Rounds</p> <p className="text-sm text-gray-500">Rounds</p>
<p className="text-2xl font-bold text-gray-900"> <p className="text-2xl font-bold text-gray-900">
@@ -216,7 +183,7 @@ export default async function TournamentDetailPage({ params }: PageProps) {
</p> </p>
</div> </div>
<div className="bg-gray-50 rounded-lg p-4 text-center"> <div className="bg-gray-50 rounded-lg p-4 text-center">
<p className="text-sm text-gray-500">Matches</p> <p className="text-sm text-gray-500">Matchups</p>
<p className="text-2xl font-bold text-gray-900"> <p className="text-2xl font-bold text-gray-900">
{matches.length} {matches.length}
</p> </p>
@@ -227,24 +194,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>
@@ -278,31 +251,17 @@ export default async function TournamentDetailPage({ params }: PageProps) {
</div> </div>
{/* Teams Section */} {/* Teams Section */}
<div className="bg-white shadow rounded-lg p-6 mb-6"> <TeamsSection
<h2 className="text-lg font-medium text-gray-900 mb-4"> tournamentId={tournament.id}
Teams ({tournament.teams.length}) participants={tournament.participants.map(p => ({
</h2> id: p.player.id,
name: p.player.name,
{tournament.teams.length > 0 ? ( currentElo: p.player.currentElo,
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> }))}
{tournament.teams.map((team) => ( teamDurability={tournament.teamDurability || "permanent"}
<div partnerRotation={tournament.partnerRotation || "none"}
key={team.id} allowByes={tournament.allowByes ?? true}
className="bg-gray-50 rounded p-3 flex justify-between items-center" />
>
<div>
<p className="font-medium text-gray-900">
{team.player1.name} + {team.player2.name}
</p>
<p className="text-sm text-gray-500">{team.teamName}</p>
</div>
</div>
))}
</div>
) : (
<p className="text-gray-500">No teams created yet.</p>
)}
</div>
{/* Recent Matches Section */} {/* Recent Matches Section */}
<div className="bg-white shadow rounded-lg p-6"> <div className="bg-white shadow rounded-lg p-6">
@@ -323,8 +282,8 @@ export default async function TournamentDetailPage({ params }: PageProps) {
{match.playedAt?.toLocaleDateString()} {match.playedAt?.toLocaleDateString()}
</p> </p>
<p className="font-medium"> <p className="font-medium">
{match.team1P1.name} + {match.team1P2.name} vs{" "} {match.player1P1?.name} + {match.player1P2?.name} vs{" "}
{match.team2P1.name} + {match.team2P2.name} {match.player2P1?.name} + {match.player2P2?.name}
</p> </p>
</div> </div>
<div className="text-right"> <div className="text-right">
@@ -38,10 +38,10 @@ export default async function TournamentResultsPage({ params }: PageProps) {
const matches = await prisma.match.findMany({ const matches = await prisma.match.findMany({
where: { eventId: tournamentId }, where: { eventId: tournamentId },
include: { include: {
team1P1: true, player1P1: true,
team1P2: true, player1P2: true,
team2P1: true, player2P1: true,
team2P2: true, player2P2: true,
}, },
orderBy: { playedAt: "desc" }, orderBy: { playedAt: "desc" },
}) })
@@ -114,8 +114,8 @@ export default async function TournamentResultsPage({ params }: PageProps) {
{match.playedAt?.toLocaleDateString()} {match.playedAt?.toLocaleDateString()}
</p> </p>
<p className="font-medium"> <p className="font-medium">
{match.team1P1.name} + {match.team1P2.name} vs{" "} {match.player1P1?.name} + {match.player1P2?.name} vs{" "}
{match.team2P1.name} + {match.team2P2.name} {match.player2P1?.name} + {match.player2P2?.name}
</p> </p>
</div> </div>
<div className="text-right flex items-center"> <div className="text-right flex items-center">
@@ -0,0 +1,211 @@
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: {
rounds: {
orderBy: { roundNumber: "asc" },
include: {
bracketMatchups: {
include: {
player1P1: true,
player1P2: true,
player2P1: true,
player2P2: 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}
</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={0}
/>
</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.player1P1 && matchup.player1P2
? `${matchup.player1P1.name} + ${matchup.player1P2.name}`
: "TBD"
const team2Name = matchup.player2P1 && matchup.player2P2
? `${matchup.player2P1.name} + ${matchup.player2P2.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={0}
/>
</div>
)}
</div>
</main>
</div>
)
}
+314 -43
View File
@@ -1,8 +1,9 @@
"use client" "use client"
import { useState, useEffect } from "react" import { useState, useEffect, useMemo } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Navigation from "@/components/Navigation" import Navigation from "@/components/Navigation"
import { expectedRounds, expectedMatchups } from "@/lib/schedule-generator"
interface Player { interface Player {
id: number id: number
@@ -15,10 +16,12 @@ interface TournamentFormData {
description: string description: string
eventDate: string eventDate: string
format: string format: string
maxParticipants: string tournamentType: 'individual' | 'team'
participants: number[] // Array of player IDs participants: number[]
} }
type PairingMethod = 'elo' | 'manual' | 'random'
export default function NewTournamentPage() { export default function NewTournamentPage() {
const router = useRouter() const router = useRouter()
const [step, setStep] = useState(1) const [step, setStep] = useState(1)
@@ -27,7 +30,7 @@ export default function NewTournamentPage() {
description: "", description: "",
eventDate: "", eventDate: "",
format: "round_robin", format: "round_robin",
maxParticipants: "", tournamentType: "individual",
participants: [], participants: [],
}) })
const [error, setError] = useState("") const [error, setError] = useState("")
@@ -39,6 +42,48 @@ export default function NewTournamentPage() {
const [selectedPlayers, setSelectedPlayers] = useState<Player[]>([]) const [selectedPlayers, setSelectedPlayers] = useState<Player[]>([])
const [isSearching, setIsSearching] = useState(false) const [isSearching, setIsSearching] = useState(false)
// Sorting state
const [sortConfig, setSortConfig] = useState<{ key: 'name' | 'currentElo'; direction: 'asc' | 'desc' }>({
key: 'name',
direction: 'asc'
})
// Team pairing state
const [pairingMethod, setPairingMethod] = useState<PairingMethod>('elo')
// Dynamic round preview calculation
const scheduleInfo = useMemo(() => {
if (formData.tournamentType === 'team') {
const teamCount = Math.floor(selectedPlayers.length / 2)
if (teamCount < 2) return null
return {
rounds: expectedRounds(teamCount),
matchups: expectedMatchups(teamCount),
teams: teamCount,
playerCount: selectedPlayers.length,
}
} else {
// Individual: players form teams of 2 for Euchre
const teamCount = Math.floor(selectedPlayers.length / 2)
if (teamCount < 2) return null
return {
rounds: expectedRounds(teamCount),
matchups: expectedMatchups(teamCount),
teams: teamCount,
playerCount: selectedPlayers.length,
}
}
}, [selectedPlayers.length, formData.tournamentType])
// Minimum players by format
const getMinPlayers = () => {
if (formData.tournamentType === 'team') {
return 4 // At least 2 teams
}
// Individual tournaments still need pairs for Euchre
return 4 // At least 2 teams of 2
}
// Search for players as user types // Search for players as user types
useEffect(() => { useEffect(() => {
const searchPlayers = async () => { const searchPlayers = async () => {
@@ -50,9 +95,8 @@ export default function NewTournamentPage() {
setIsSearching(true) setIsSearching(true)
try { try {
const response = await fetch(`/api/players/search?q=${encodeURIComponent(searchQuery)}`) const response = await fetch(`/api/players/search?q=${encodeURIComponent(searchQuery)}`)
const data = await response.json()
if (response.ok) { if (response.ok) {
// Filter out already selected players const data = await response.json()
const availablePlayers = data.players.filter( const availablePlayers = data.players.filter(
(p: Player) => !selectedPlayers.find(sp => sp.id === p.id) (p: Player) => !selectedPlayers.find(sp => sp.id === p.id)
) )
@@ -79,6 +123,60 @@ export default function NewTournamentPage() {
setSelectedPlayers(selectedPlayers.filter(p => p.id !== playerId)) setSelectedPlayers(selectedPlayers.filter(p => p.id !== playerId))
} }
const handleSort = (key: 'name' | 'currentElo') => {
setSortConfig(prevConfig => ({
key,
direction: prevConfig.key === key && prevConfig.direction === 'asc' ? 'desc' : 'asc'
}))
}
const getSortedPlayers = () => {
const sorted = [...selectedPlayers].sort((a, b) => {
if (sortConfig.key === 'name') {
return sortConfig.direction === 'asc'
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name)
} else {
return sortConfig.direction === 'asc'
? a.currentElo - b.currentElo
: b.currentElo - a.currentElo
}
})
return sorted
}
// Generate team pairings based on method
const getTeamPairings = () => {
const sorted = [...selectedPlayers]
if (pairingMethod === 'elo') {
sorted.sort((a, b) => b.currentElo - a.currentElo)
const teams: { player1: Player; player2: Player }[] = []
for (let i = 0; i < sorted.length - 1; i += 2) {
teams.push({ player1: sorted[i], player2: sorted[i + 1] })
}
return teams
} else if (pairingMethod === 'random') {
// Shuffle using Fisher-Yates
for (let i = sorted.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[sorted[i], sorted[j]] = [sorted[j], sorted[i]]
}
const teams: { player1: Player; player2: Player }[] = []
for (let i = 0; i < sorted.length - 1; i += 2) {
teams.push({ player1: sorted[i], player2: sorted[i + 1] })
}
return teams
} else {
// Manual: just use current order
const teams: { player1: Player; player2: Player }[] = []
for (let i = 0; i < sorted.length - 1; i += 2) {
teams.push({ player1: sorted[i], player2: sorted[i + 1] })
}
return teams
}
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
setFormData({ setFormData({
...formData, ...formData,
@@ -92,10 +190,6 @@ export default function NewTournamentPage() {
setError("Tournament name is required") setError("Tournament name is required")
return return
} }
if (selectedPlayers.length < 2) {
setError("At least 2 participants are required")
return
}
} }
setError("") setError("")
setStep(step + 1) setStep(step + 1)
@@ -112,7 +206,19 @@ export default function NewTournamentPage() {
setIsLoading(true) setIsLoading(true)
try { try {
// First create the tournament const minPlayers = getMinPlayers()
if (selectedPlayers.length < minPlayers) {
setError(`At least ${minPlayers} participants are required (${minPlayers / 2} teams)`)
setIsLoading(false)
return
}
if (selectedPlayers.length % 2 !== 0) {
setError("An even number of participants is required to form teams")
setIsLoading(false)
return
}
// Create the tournament
const tournamentResponse = await fetch("/api/tournaments", { const tournamentResponse = await fetch("/api/tournaments", {
method: "POST", method: "POST",
headers: { headers: {
@@ -123,7 +229,7 @@ export default function NewTournamentPage() {
description: formData.description, description: formData.description,
eventDate: formData.eventDate || null, eventDate: formData.eventDate || null,
format: formData.format, format: formData.format,
maxParticipants: formData.maxParticipants ? parseInt(formData.maxParticipants) : null, tournamentType: formData.tournamentType,
}), }),
}) })
@@ -148,8 +254,18 @@ export default function NewTournamentPage() {
}) })
} }
// Redirect to game entry page // Auto-generate schedule for round_robin
router.push(`/admin/tournaments/${tournamentId}/entry`) if (formData.format === 'round_robin') {
await fetch(`/api/tournaments/${tournamentId}/schedule`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
})
}
// Redirect to schedule view
router.push(`/admin/tournaments/${tournamentId}/schedule`)
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : "An unexpected error occurred"; const message = err instanceof Error ? err.message : "An unexpected error occurred";
setError(message) setError(message)
@@ -260,18 +376,25 @@ export default function NewTournamentPage() {
</div> </div>
<div> <div>
<label htmlFor="maxParticipants" className="block text-sm font-medium text-gray-700"> <label htmlFor="tournamentType" className="block text-sm font-medium text-gray-700">
Max Participants Tournament Type *
</label> </label>
<input <select
type="number" name="tournamentType"
name="maxParticipants" id="tournamentType"
id="maxParticipants" required
min="2"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm" className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
value={formData.maxParticipants} value={formData.tournamentType}
onChange={handleChange} onChange={handleChange}
/> >
<option value="individual">Individual (players compete as individuals)</option>
<option value="team">Team (players compete in pairs/teams)</option>
</select>
<p className="mt-1 text-sm text-gray-500">
{formData.tournamentType === 'individual'
? 'Players register individually and compete on their own.'
: 'Players are paired into teams of two for competition.'}
</p>
</div> </div>
</> </>
)} )}
@@ -279,14 +402,50 @@ export default function NewTournamentPage() {
{/* Step 2: Participants */} {/* Step 2: Participants */}
{step === 2 && ( {step === 2 && (
<> <>
{/* Round Preview */}
{scheduleInfo && (
<div className="bg-green-50 border border-green-200 rounded-md p-4">
<h3 className="text-sm font-medium text-green-800 mb-2">Tournament Preview</h3>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-green-600 font-medium">{scheduleInfo.teams}</span>
<span className="text-green-700"> teams</span>
</div>
<div>
<span className="text-green-600 font-medium">{scheduleInfo.rounds}</span>
<span className="text-green-700"> rounds</span>
</div>
<div>
<span className="text-green-600 font-medium">{scheduleInfo.matchups}</span>
<span className="text-green-700"> matchups</span>
</div>
</div>
<p className="text-xs text-green-600 mt-2">
{formData.tournamentType === 'team'
? 'Players will be paired into teams below.'
: 'Players will be paired into teams of 2 for each round.'}
</p>
</div>
)}
{/* Minimum Players Warning */}
{selectedPlayers.length > 0 && selectedPlayers.length < getMinPlayers() && (
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-3">
<p className="text-sm text-yellow-700">
Need at least {getMinPlayers()} players ({getMinPlayers() / 2} teams).
Currently {selectedPlayers.length} player{selectedPlayers.length !== 1 ? 's' : ''}.
</p>
</div>
)}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Add Participants Search Players
</label> </label>
<div className="relative"> <div className="relative">
<input <input
type="text" type="text"
placeholder="Search for players..." placeholder="Type a name to search..."
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm" className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
@@ -313,32 +472,144 @@ export default function NewTournamentPage() {
)} )}
</div> </div>
{/* Team Pairing Options (only for team tournaments) */}
{formData.tournamentType === 'team' && selectedPlayers.length >= 4 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Team Pairing Method
</label>
<div className="flex gap-4">
<label className="flex items-center">
<input
type="radio"
name="pairingMethod"
value="elo"
checked={pairingMethod === 'elo'}
onChange={(e) => setPairingMethod(e.target.value as PairingMethod)}
className="mr-2"
/>
<span className="text-sm">By ELO (best + worst)</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="pairingMethod"
value="manual"
checked={pairingMethod === 'manual'}
onChange={(e) => setPairingMethod(e.target.value as PairingMethod)}
className="mr-2"
/>
<span className="text-sm">Manual order</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="pairingMethod"
value="random"
checked={pairingMethod === 'random'}
onChange={(e) => setPairingMethod(e.target.value as PairingMethod)}
className="mr-2"
/>
<span className="text-sm">Random</span>
</label>
</div>
</div>
)}
{/* Selected Players */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Selected Participants ({selectedPlayers.length}) {formData.tournamentType === 'team'
? `Selected Players (${selectedPlayers.length})`
: `Selected Participants (${selectedPlayers.length})`}
</label> </label>
{selectedPlayers.length > 0 ? ( {selectedPlayers.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2"> <div className="border border-gray-300 rounded-md overflow-hidden">
{selectedPlayers.map(player => ( {/* Column Headers */}
<div <div className="grid grid-cols-12 bg-gray-100 border-b border-gray-300">
key={player.id} <div
className="bg-green-50 border border-green-200 rounded-md px-3 py-2 flex justify-between items-center" className="col-span-6 px-3 py-2 text-xs font-medium text-gray-600 cursor-pointer hover:bg-gray-200 flex items-center"
onClick={() => handleSort('name')}
> >
<span className="text-sm">{player.name}</span> Name
<button {sortConfig.key === 'name' && (
type="button" <span className="ml-1">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
onClick={() => removePlayer(player.id)} )}
className="text-red-600 hover:text-red-800 ml-2"
>
×
</button>
</div> </div>
))} <div
className="col-span-4 px-3 py-2 text-xs font-medium text-gray-600 cursor-pointer hover:bg-gray-200 flex items-center"
onClick={() => handleSort('currentElo')}
>
ELO
{sortConfig.key === 'currentElo' && (
<span className="ml-1">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
)}
</div>
<div className="col-span-2 px-3 py-2 text-xs font-medium text-gray-600">
Actions
</div>
</div>
{/* Player Rows */}
<div className="max-h-48 overflow-y-auto">
{getSortedPlayers().map(player => (
<div
key={player.id}
className="grid grid-cols-12 bg-green-50 border-b border-green-100 last:border-b-0"
>
<div className="col-span-6 px-3 py-2 text-sm truncate">
{player.name}
</div>
<div className="col-span-4 px-3 py-2 text-sm">
{player.currentElo}
</div>
<div className="col-span-2 px-3 py-2 flex justify-center">
<button
type="button"
onClick={() => removePlayer(player.id)}
className="text-red-600 hover:text-red-800"
>
×
</button>
</div>
</div>
))}
</div>
</div> </div>
) : ( ) : (
<p className="text-gray-500 text-sm">No participants added yet</p> <p className="text-gray-500 text-sm">No participants added yet</p>
)} )}
</div> </div>
{/* Team Preview (for team tournaments) */}
{formData.tournamentType === 'team' && selectedPlayers.length >= 4 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Team Pairings Preview ({getTeamPairings().length} teams)
</label>
<div className="border border-gray-300 rounded-md overflow-hidden">
<div className="grid grid-cols-12 bg-gray-100 border-b border-gray-300">
<div className="col-span-2 px-3 py-2 text-xs font-medium text-gray-600">Team</div>
<div className="col-span-5 px-3 py-2 text-xs font-medium text-gray-600">Player 1</div>
<div className="col-span-5 px-3 py-2 text-xs font-medium text-gray-600">Player 2</div>
</div>
<div className="max-h-48 overflow-y-auto">
{getTeamPairings().map((team, index) => (
<div key={index} className="grid grid-cols-12 bg-blue-50 border-b border-blue-100 last:border-b-0">
<div className="col-span-2 px-3 py-2 text-sm font-medium text-blue-700">
Team {index + 1}
</div>
<div className="col-span-5 px-3 py-2 text-sm">
{team.player1.name} <span className="text-gray-400">({team.player1.currentElo})</span>
</div>
<div className="col-span-5 px-3 py-2 text-sm">
{team.player2.name} <span className="text-gray-400">({team.player2.currentElo})</span>
</div>
</div>
))}
</div>
</div>
</div>
)}
</> </>
)} )}
@@ -375,10 +646,10 @@ export default function NewTournamentPage() {
</button> </button>
<button <button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading || selectedPlayers.length < getMinPlayers()}
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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50" 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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{isLoading ? "Creating..." : "Create Tournament & Enter Games"} {isLoading ? "Creating..." : "Create Tournament & Generate Schedule"}
</button> </button>
</> </>
)} )}
@@ -36,10 +36,16 @@ export default function CreateUserForm({ selectedPlayer, availablePlayers }: Cre
body: JSON.stringify(formData), body: JSON.stringify(formData),
}) })
const data = await response.json()
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || "Failed to create user") try {
const errorData = await response.json()
throw new Error(errorData.error || "Failed to create user")
} catch (jsonError) {
if (jsonError instanceof Error && jsonError.message !== "Failed to create user") {
throw jsonError
}
throw new Error(`Failed to create user: ${response.status} ${response.statusText}`)
}
} }
setSuccess("User created successfully!") setSuccess("User created successfully!")
@@ -35,10 +35,16 @@ export default function EditUserForm({ user, availablePlayers }: EditUserFormPro
body: JSON.stringify(formData), body: JSON.stringify(formData),
}) })
const data = await response.json()
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || "Failed to update user") try {
const errorData = await response.json()
throw new Error(errorData.error || "Failed to update user")
} catch (jsonError) {
if (jsonError instanceof Error && jsonError.message !== "Failed to update user") {
throw jsonError
}
throw new Error(`Failed to update user: ${response.status} ${response.statusText}`)
}
} }
setSuccess("User updated successfully!") setSuccess("User updated successfully!")
+12 -12
View File
@@ -82,35 +82,35 @@ export async function POST(request: Request) {
// Update all foreign key references to point to canonical player // Update all foreign key references to point to canonical player
const updates = []; const updates = [];
// Update matches - team1P1Id // Update matches - player1P1Id
updates.push( updates.push(
prisma.match.updateMany({ prisma.match.updateMany({
where: { team1P1Id: { in: duplicatePlayers.map(p => p.id) } }, where: { player1P1Id: { in: duplicatePlayers.map(p => p.id) } },
data: { team1P1Id: canonicalPlayer.id }, data: { player1P1Id: canonicalPlayer.id },
}) })
); );
// Update matches - team1P2Id // Update matches - player1P2Id
updates.push( updates.push(
prisma.match.updateMany({ prisma.match.updateMany({
where: { team1P2Id: { in: duplicatePlayers.map(p => p.id) } }, where: { player1P2Id: { in: duplicatePlayers.map(p => p.id) } },
data: { team1P2Id: canonicalPlayer.id }, data: { player1P2Id: canonicalPlayer.id },
}) })
); );
// Update matches - team2P1Id // Update matches - player2P1Id
updates.push( updates.push(
prisma.match.updateMany({ prisma.match.updateMany({
where: { team2P1Id: { in: duplicatePlayers.map(p => p.id) } }, where: { player2P1Id: { in: duplicatePlayers.map(p => p.id) } },
data: { team2P1Id: canonicalPlayer.id }, data: { player2P1Id: canonicalPlayer.id },
}) })
); );
// Update matches - team2P2Id // Update matches - player2P2Id
updates.push( updates.push(
prisma.match.updateMany({ prisma.match.updateMany({
where: { team2P2Id: { in: duplicatePlayers.map(p => p.id) } }, where: { player2P2Id: { in: duplicatePlayers.map(p => p.id) } },
data: { team2P2Id: canonicalPlayer.id }, data: { player2P2Id: canonicalPlayer.id },
}) })
); );
+4 -4
View File
@@ -119,10 +119,10 @@ export async function POST(request: Request) {
const createdMatch = await prisma.match.create({ const createdMatch = await prisma.match.create({
data: matchData, data: matchData,
include: { include: {
team1P1: true, player1P1: true,
team1P2: true, player1P2: true,
team2P1: true, player2P1: true,
team2P2: true, player2P2: true,
}, },
}); });
+8 -30
View File
@@ -59,32 +59,10 @@ export async function GET() {
name: true, name: true,
}, },
}, },
team1P1: { select: { id: true, name: true } }, player1P1: { select: { id: true, name: true } },
team1P2: { select: { id: true, name: true } }, player1P2: { select: { id: true, name: true } },
team2P1: { select: { id: true, name: true } }, player2P1: { select: { id: true, name: true } },
team2P2: { select: { id: true, name: true } }, player2P2: { select: { id: true, name: true } },
},
orderBy: { playedAt: 'desc' },
});
} else if (userRole === 'tournament_admin') {
// Tournament admins can only see matches in their own tournaments
matches = await prisma.match.findMany({
where: {
event: {
ownerId: userId,
},
},
include: {
event: {
select: {
id: true,
name: true,
},
},
team1P1: { select: { id: true, name: true } },
team1P2: { select: { id: true, name: true } },
team2P1: { select: { id: true, name: true } },
team2P2: { select: { id: true, name: true } },
}, },
orderBy: { playedAt: 'desc' }, orderBy: { playedAt: 'desc' },
}); });
@@ -236,10 +214,10 @@ export async function POST(request: Request) {
eventId: eventId ? parseInt(eventId) : null, eventId: eventId ? parseInt(eventId) : null,
isCasual: isCasual, isCasual: isCasual,
playedAt: playedAt ? new Date(playedAt) : new Date(), playedAt: playedAt ? new Date(playedAt) : new Date(),
team1P1Id: parseInt(team1P1Id), player1P1Id: parseInt(team1P1Id),
team1P2Id: parseInt(team1P2Id), player1P2Id: parseInt(team1P2Id),
team2P1Id: parseInt(team2P1Id), player2P1Id: parseInt(team2P1Id),
team2P2Id: parseInt(team2P2Id), player2P2Id: parseInt(team2P2Id),
team1Score, team1Score,
team2Score, team2Score,
status: "completed", status: "completed",
+4 -4
View File
@@ -132,10 +132,10 @@ export async function POST(request: Request) {
data: { data: {
eventId: parseInt(eventId), eventId: parseInt(eventId),
playedAt: new Date(), playedAt: new Date(),
team1P1Id: players[0].id, player1P1Id: players[0].id,
team1P2Id: players[1].id, player1P2Id: players[1].id,
team2P1Id: players[2].id, player2P1Id: players[2].id,
team2P2Id: players[3].id, player2P2Id: players[3].id,
team1Score, team1Score,
team2Score, team2Score,
status: "completed", status: "completed",
+1
View File
@@ -15,6 +15,7 @@ export async function GET(request: NextRequest) {
where: { where: {
name: { name: {
contains: query, contains: query,
mode: "insensitive",
}, },
}, },
orderBy: { name: "asc" }, orderBy: { name: "asc" },
@@ -94,10 +94,10 @@ export async function POST(
await prisma.match.create({ await prisma.match.create({
data: { data: {
eventId: tournamentId, eventId: tournamentId,
team1P1Id: player1.id, player1P1Id: player1.id,
team1P2Id: player2.id, player1P2Id: player2.id,
team2P1Id: player3.id, player2P1Id: player3.id,
team2P2Id: player4.id, player2P2Id: player4.id,
team1Score: game.score1, team1Score: game.score1,
team2Score: game.score2, team2Score: game.score2,
status: "completed", status: "completed",
@@ -36,7 +36,30 @@ export async function POST(
); );
} }
// Add participants to tournament // Fetch tournament to check type
const tournament = await prisma.event.findUnique({
where: { id: tournamentId },
select: { tournamentType: true },
});
if (!tournament) {
return NextResponse.json(
{ error: "Tournament not found" },
{ status: 404 }
);
}
const isTeamTournament = tournament.tournamentType === "team";
// For team tournaments, validate even number of players
if (isTeamTournament && playerIds.length % 2 !== 0) {
return NextResponse.json(
{ error: "Team tournaments require an even number of players" },
{ status: 400 }
);
}
// Add participants - teams will be generated later during schedule generation
const participants = await Promise.all( const participants = await Promise.all(
playerIds.map(async (playerId: number) => { playerIds.map(async (playerId: number) => {
try { try {
+31 -40
View File
@@ -45,28 +45,14 @@ export async function GET(request: Request, { params }: RouteParams) {
player: true, player: true,
}, },
}, },
teams: {
include: {
player1: true,
player2: true,
},
},
rounds: { rounds: {
include: { include: {
bracketMatchups: { bracketMatchups: {
include: { include: {
team1: { player1P1: true,
include: { player1P2: true,
player1: true, player2P1: true,
player2: true, player2P2: true,
},
},
team2: {
include: {
player1: true,
player2: true,
},
},
match: true, match: true,
}, },
}, },
@@ -144,33 +130,43 @@ export async function PUT(request: Request, { params }: RouteParams) {
description, description,
eventDate, eventDate,
eventType, eventType,
tournamentType,
format, format,
status, status,
maxParticipants, maxParticipants,
ownerId, ownerId,
targetScore, targetScore,
allowTies, allowTies,
teamDurability,
partnerRotation,
allowByes,
} = body; } = body;
// Validate required fields // Validate name only if it's being updated
if (!name || typeof name !== 'string' || name.trim().length === 0) { if (body.hasOwnProperty('name')) {
return NextResponse.json( if (!name || typeof name !== 'string' || name.trim().length === 0) {
{ error: "Tournament name is required" }, return NextResponse.json(
{ status: 400 } { error: "Tournament name is required" },
); { status: 400 }
);
}
} }
// Prepare update data // Prepare update data - only include fields that are present in the request
const updateData: Record<string, unknown> = { const updateData: Record<string, unknown> = {};
name: name.trim(),
description: description || null, if (body.hasOwnProperty('name')) updateData.name = name.trim();
eventDate: eventDate ? new Date(eventDate) : null, if (body.hasOwnProperty('description')) updateData.description = description || null;
eventType: eventType || "tournament", if (body.hasOwnProperty('eventDate')) updateData.eventDate = eventDate ? new Date(eventDate) : null;
format: format || "round_robin", if (body.hasOwnProperty('eventType')) updateData.eventType = eventType || "tournament";
maxParticipants: maxParticipants ? parseInt(maxParticipants) : null, if (body.hasOwnProperty('tournamentType')) updateData.tournamentType = tournamentType || "individual";
targetScore: targetScore ? parseInt(targetScore) : null, if (body.hasOwnProperty('format')) updateData.format = format || "round_robin";
allowTies: allowTies ?? false, if (body.hasOwnProperty('maxParticipants')) updateData.maxParticipants = maxParticipants ? parseInt(maxParticipants) : null;
}; if (body.hasOwnProperty('targetScore')) updateData.targetScore = targetScore ? parseInt(targetScore) : null;
if (body.hasOwnProperty('allowTies')) updateData.allowTies = allowTies ?? false;
if (body.hasOwnProperty('teamDurability')) updateData.teamDurability = teamDurability || "permanent";
if (body.hasOwnProperty('partnerRotation')) updateData.partnerRotation = partnerRotation || "none";
if (body.hasOwnProperty('allowByes')) updateData.allowByes = allowByes ?? true;
// Only allow status updates if they don't conflict with auto-calculation // Only allow status updates if they don't conflict with auto-calculation
if (status) { if (status) {
@@ -285,11 +281,6 @@ export async function DELETE(request: Request, { params }: RouteParams) {
where: { eventId: tournamentId }, where: { eventId: tournamentId },
}); });
// Delete teams
await prisma.team.deleteMany({
where: { eventId: tournamentId },
});
// Delete tournament rounds // Delete tournament rounds
await prisma.tournamentRound.deleteMany({ await prisma.tournamentRound.deleteMany({
where: { eventId: tournamentId }, where: { eventId: tournamentId },
@@ -0,0 +1,280 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { canManageTournament } from "@/lib/permissions";
import { generateRoundRobin, validateScheduleInput } from "@/lib/schedule-generator";
import { generateTeams, generateTeamsWithRotation, type Player, type Team as TeamPairing } from "@/lib/team-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: {
player1P1: true,
player1P2: true,
player2P1: true,
player2P2: 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: {
participants: {
include: {
player: 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 participants as players
const participants: Player[] = tournament.participants.map((p) => ({
id: p.player.id,
name: p.player.name,
currentElo: p.player.currentElo,
}));
// Check minimum participants
if (participants.length < 2) {
return NextResponse.json(
{ error: "At least 2 participants are required to generate a schedule" },
{ status: 400 }
);
}
// Generate teams based on configuration
const teamDurability = tournament.teamDurability || "permanent";
const partnerRotation = (tournament.partnerRotation || "none") as 'none' | 'minimize_repeat' | 'maximize_even' | 'elo_based';
const allowByes = tournament.allowByes ?? true;
let teamPairings: { player1Id: number; player2Id: number }[];
if (teamDurability === "permanent") {
// For permanent teams, generate once and use for all rounds
const result = generateTeams(participants, partnerRotation, allowByes);
teamPairings = result.teams.map((t) => ({
player1Id: t.player1Id,
player2Id: t.player2Id,
}));
} else {
// For variable/per_round teams, generate teams for each round
// We'll use generateTeamsWithRotation for the initial teams
// The actual per-round teams will be generated when storing the schedule
const result = generateTeams(participants, partnerRotation, allowByes);
teamPairings = result.teams.map((t) => ({
player1Id: t.player1Id,
player2Id: t.player2Id,
}));
}
// Validate schedule input
const validation = validateScheduleInput(teamPairings);
if (!validation.valid) {
return NextResponse.json(
{ error: validation.error },
{ status: 400 }
);
}
// Generate schedule
const schedule = generateRoundRobin(teamPairings);
// 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,
player1P1Id: matchup.player1P1Id,
player1P2Id: matchup.player1P2Id,
player2P1Id: matchup.player2P1Id,
player2P2Id: matchup.player2P2Id,
bracketPosition: idx + 1,
status: "pending",
})),
},
},
include: {
bracketMatchups: {
include: {
player1P1: true,
player1P2: true,
player2P1: true,
player2P2: 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 });
}
}
+5 -8
View File
@@ -11,12 +11,6 @@ export async function GET() {
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
include: { include: {
participants: true, participants: true,
teams: {
include: {
player1: true,
player2: true,
},
},
}, },
}); });
@@ -69,7 +63,7 @@ export async function POST(request: Request) {
} }
const body = await request.json(); const body = await request.json();
const { name, format, eventDate, targetScore, allowTies } = body; const { name, format, eventDate, targetScore, allowTies, maxParticipants, tournamentType } = body;
const tournament = await prisma.event.create({ const tournament = await prisma.event.create({
data: { data: {
@@ -77,10 +71,13 @@ export async function POST(request: Request) {
format: format || "round_robin", format: format || "round_robin",
eventDate: eventDate ? new Date(eventDate) : null, eventDate: eventDate ? new Date(eventDate) : null,
eventType: "tournament", eventType: "tournament",
tournamentType: tournamentType || "individual",
status: "planned", status: "planned",
ownerId: session.user.id, // Assign ownership to the creator ownerId: session.user.id,
targetScore: targetScore ? parseInt(targetScore) : null, targetScore: targetScore ? parseInt(targetScore) : null,
allowTies: allowTies ?? false, allowTies: allowTies ?? false,
maxParticipants: maxParticipants ? parseInt(maxParticipants) : null,
description: body.description,
}, },
}); });
+48 -40
View File
@@ -22,10 +22,10 @@ export default async function MatchDetailPage({ params }: PageProps) {
const match = await prisma.match.findUnique({ const match = await prisma.match.findUnique({
where: { id: matchId }, where: { id: matchId },
include: { include: {
team1P1: true, player1P1: true,
team1P2: true, player1P2: true,
team2P1: true, player2P1: true,
team2P2: true, player2P2: true,
event: true, event: true,
eloSnapshots: { eloSnapshots: {
include: { include: {
@@ -93,13 +93,13 @@ export default async function MatchDetailPage({ params }: PageProps) {
<div className="absolute top-0 left-0 right-0 h-1/2 flex flex-col justify-end items-center pb-8"> <div className="absolute top-0 left-0 right-0 h-1/2 flex flex-col justify-end items-center pb-8">
<div className="text-center bg-white/80 rounded-lg px-3 py-2 shadow-sm -mt-[20px]"> <div className="text-center bg-white/80 rounded-lg px-3 py-2 shadow-sm -mt-[20px]">
<p className="text-base font-semibold text-amber-900"> <p className="text-base font-semibold text-amber-900">
{match.team1P1.name} {match.player1P1?.name}
</p> </p>
<p className="text-xs text-amber-700"> <p className="text-xs text-amber-700">
Elo: {match.team1P1.currentElo} Elo: {match.player1P1?.currentElo}
{eloChanges[match.team1P1.id] !== undefined && ( {match.player1P1 && eloChanges[match.player1P1.id] !== undefined && (
<span className={eloChanges[match.team1P1.id] >= 0 ? "text-green-600 ml-1" : "text-red-600 ml-1"}> <span className={eloChanges[match.player1P1.id] >= 0 ? "text-green-600 ml-1" : "text-red-600 ml-1"}>
({eloChanges[match.team1P1.id] >= 0 ? "+" : ""}{eloChanges[match.team1P1.id]}) ({eloChanges[match.player1P1.id] >= 0 ? "+" : ""}{eloChanges[match.player1P1.id]})
</span> </span>
)} )}
</p> </p>
@@ -112,13 +112,13 @@ export default async function MatchDetailPage({ params }: PageProps) {
<div className="text-xs text-amber-600 mb-2 font-medium">Team 1</div> <div className="text-xs text-amber-600 mb-2 font-medium">Team 1</div>
<div className="text-center bg-white/80 rounded-lg px-3 py-2 shadow-sm mt-[10px]"> <div className="text-center bg-white/80 rounded-lg px-3 py-2 shadow-sm mt-[10px]">
<p className="text-base font-semibold text-amber-900"> <p className="text-base font-semibold text-amber-900">
{match.team1P2.name} {match.player1P2?.name}
</p> </p>
<p className="text-xs text-amber-700"> <p className="text-xs text-amber-700">
Elo: {match.team1P2.currentElo} Elo: {match.player1P2?.currentElo}
{eloChanges[match.team1P2.id] !== undefined && ( {match.player1P2 && eloChanges[match.player1P2.id] !== undefined && (
<span className={eloChanges[match.team1P2.id] >= 0 ? "text-green-600 ml-1" : "text-red-600 ml-1"}> <span className={eloChanges[match.player1P2.id] >= 0 ? "text-green-600 ml-1" : "text-red-600 ml-1"}>
({eloChanges[match.team1P2.id] >= 0 ? "+" : ""}{eloChanges[match.team1P2.id]}) ({eloChanges[match.player1P2.id] >= 0 ? "+" : ""}{eloChanges[match.player1P2.id]})
</span> </span>
)} )}
</p> </p>
@@ -129,13 +129,13 @@ export default async function MatchDetailPage({ params }: PageProps) {
<div className="absolute left-0 top-0 bottom-0 w-1/2 flex flex-col justify-center items-center pl-8"> <div className="absolute left-0 top-0 bottom-0 w-1/2 flex flex-col justify-center items-center pl-8">
<div className="text-center bg-white/80 rounded-lg px-3 py-2 shadow-sm rotate-[-90deg] -ml-[20px]"> <div className="text-center bg-white/80 rounded-lg px-3 py-2 shadow-sm rotate-[-90deg] -ml-[20px]">
<p className="text-sm font-semibold text-red-900"> <p className="text-sm font-semibold text-red-900">
{match.team2P1.name} {match.player2P1?.name}
</p> </p>
<p className="text-[11px] text-red-700"> <p className="text-[11px] text-red-700">
Elo: {match.team2P1.currentElo} Elo: {match.player2P1?.currentElo}
{eloChanges[match.team2P1.id] !== undefined && ( {match.player2P1 && eloChanges[match.player2P1.id] !== undefined && (
<span className={eloChanges[match.team2P1.id] >= 0 ? "text-green-600" : "text-red-600"}> <span className={eloChanges[match.player2P1.id] >= 0 ? "text-green-600" : "text-red-600"}>
({eloChanges[match.team2P1.id] >= 0 ? "+" : ""}{eloChanges[match.team2P1.id]}) ({eloChanges[match.player2P1.id] >= 0 ? "+" : ""}{eloChanges[match.player2P1.id]})
</span> </span>
)} )}
</p> </p>
@@ -146,13 +146,13 @@ export default async function MatchDetailPage({ params }: PageProps) {
<div className="absolute right-0 top-0 bottom-0 w-1/2 flex flex-col justify-center items-center pr-8"> <div className="absolute right-0 top-0 bottom-0 w-1/2 flex flex-col justify-center items-center pr-8">
<div className="text-center bg-white/80 rounded-lg px-3 py-2 shadow-sm rotate-[90deg] -mr-[20px]"> <div className="text-center bg-white/80 rounded-lg px-3 py-2 shadow-sm rotate-[90deg] -mr-[20px]">
<p className="text-sm font-semibold text-red-900"> <p className="text-sm font-semibold text-red-900">
{match.team2P2.name} {match.player2P2?.name}
</p> </p>
<p className="text-[11px] text-red-700"> <p className="text-[11px] text-red-700">
Elo: {match.team2P2.currentElo} Elo: {match.player2P2?.currentElo}
{eloChanges[match.team2P2.id] !== undefined && ( {match.player2P2 && eloChanges[match.player2P2.id] !== undefined && (
<span className={eloChanges[match.team2P2.id] >= 0 ? "text-green-600" : "text-red-600"}> <span className={eloChanges[match.player2P2.id] >= 0 ? "text-green-600" : "text-red-600"}>
({eloChanges[match.team2P2.id] >= 0 ? "+" : ""}{eloChanges[match.team2P2.id]}) ({eloChanges[match.player2P2.id] >= 0 ? "+" : ""}{eloChanges[match.player2P2.id]})
</span> </span>
)} )}
</p> </p>
@@ -246,16 +246,20 @@ export default async function MatchDetailPage({ params }: PageProps) {
<h3 className="font-medium text-amber-900 mb-3">Team 1</h3> <h3 className="font-medium text-amber-900 mb-3">Team 1</h3>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span>{match.team1P1.name}</span> <span>{match.player1P1?.name}</span>
<span className={eloChanges[match.team1P1.id] >= 0 ? "text-green-600" : "text-red-600"}> {match.player1P1 && (
{eloChanges[match.team1P1.id] >= 0 ? "+" : ""}{eloChanges[match.team1P1.id]} <span className={eloChanges[match.player1P1.id] >= 0 ? "text-green-600" : "text-red-600"}>
</span> {eloChanges[match.player1P1.id] >= 0 ? "+" : ""}{eloChanges[match.player1P1.id]}
</span>
)}
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span>{match.team1P2.name}</span> <span>{match.player1P2?.name}</span>
<span className={eloChanges[match.team1P2.id] >= 0 ? "text-green-600" : "text-red-600"}> {match.player1P2 && (
{eloChanges[match.team1P2.id] >= 0 ? "+" : ""}{eloChanges[match.team1P2.id]} <span className={eloChanges[match.player1P2.id] >= 0 ? "text-green-600" : "text-red-600"}>
</span> {eloChanges[match.player1P2.id] >= 0 ? "+" : ""}{eloChanges[match.player1P2.id]}
</span>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -265,16 +269,20 @@ export default async function MatchDetailPage({ params }: PageProps) {
<h3 className="font-medium text-red-900 mb-3">Team 2</h3> <h3 className="font-medium text-red-900 mb-3">Team 2</h3>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span>{match.team2P1.name}</span> <span>{match.player2P1?.name}</span>
<span className={eloChanges[match.team2P1.id] >= 0 ? "text-green-600" : "text-red-600"}> {match.player2P1 && (
{eloChanges[match.team2P1.id] >= 0 ? "+" : ""}{eloChanges[match.team2P1.id]} <span className={eloChanges[match.player2P1.id] >= 0 ? "text-green-600" : "text-red-600"}>
</span> {eloChanges[match.player2P1.id] >= 0 ? "+" : ""}{eloChanges[match.player2P1.id]}
</span>
)}
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span>{match.team2P2.name}</span> <span>{match.player2P2?.name}</span>
<span className={eloChanges[match.team2P2.id] >= 0 ? "text-green-600" : "text-red-600"}> {match.player2P2 && (
{eloChanges[match.team2P2.id] >= 0 ? "+" : ""}{eloChanges[match.team2P2.id]} <span className={eloChanges[match.player2P2.id] >= 0 ? "text-green-600" : "text-red-600"}>
</span> {eloChanges[match.player2P2.id] >= 0 ? "+" : ""}{eloChanges[match.player2P2.id]}
</span>
)}
</div> </div>
</div> </div>
</div> </div>
+6 -6
View File
@@ -25,10 +25,10 @@ export default async function MatchesListPage() {
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
take: 50, take: 50,
include: { include: {
team1P1: true, player1P1: true,
team1P2: true, player1P2: true,
team2P1: true, player2P1: true,
team2P2: true, player2P2: true,
event: true, event: true,
}, },
}); });
@@ -84,10 +84,10 @@ export default async function MatchesListPage() {
#{match.id} #{match.id}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{match.team1P1.name} & {match.team1P2.name} {match.player1P1?.name} & {match.player1P2?.name}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{match.team2P1.name} & {match.team2P2.name} {match.player2P1?.name} & {match.player2P2?.name}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span className="font-medium">{match.team1Score}</span> <span className="font-medium">{match.team1Score}</span>
+6 -6
View File
@@ -24,10 +24,10 @@ export default async function Home() {
include: { include: {
matches: { matches: {
include: { include: {
team1P1: true, player1P1: true,
team1P2: true, player1P2: true,
team2P1: true, player2P1: true,
team2P2: true, player2P2: true,
}, },
orderBy: { playedAt: "desc" }, orderBy: { playedAt: "desc" },
}, },
@@ -178,7 +178,7 @@ export default async function Home() {
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="flex-1 text-center"> <div className="flex-1 text-center">
<div className="text-sm font-medium text-gray-900"> <div className="text-sm font-medium text-gray-900">
{match.team1P1.name} & {match.team1P2.name} {match.player1P1?.name} & {match.player1P2?.name}
</div> </div>
<div className="text-lg font-bold text-gray-800"> <div className="text-lg font-bold text-gray-800">
{match.team1Score} {match.team1Score}
@@ -187,7 +187,7 @@ export default async function Home() {
<div className="px-3 text-gray-500">vs</div> <div className="px-3 text-gray-500">vs</div>
<div className="flex-1 text-center"> <div className="flex-1 text-center">
<div className="text-sm font-medium text-gray-900"> <div className="text-sm font-medium text-gray-900">
{match.team2P1.name} & {match.team2P2.name} {match.player2P1?.name} & {match.player2P2?.name}
</div> </div>
<div className="text-lg font-bold text-gray-800"> <div className="text-lg font-bold text-gray-800">
{match.team2Score} {match.team2Score}
+25 -23
View File
@@ -64,17 +64,17 @@ export default async function PlayerProfilePage({ params }: PageProps) {
const recentMatches = await prisma.match.findMany({ const recentMatches = await prisma.match.findMany({
where: { where: {
OR: [ OR: [
{ team1P1Id: playerId }, { player1P1Id: playerId },
{ team1P2Id: playerId }, { player1P2Id: playerId },
{ team2P1Id: playerId }, { player2P1Id: playerId },
{ team2P2Id: playerId }, { player2P2Id: playerId },
], ],
}, },
include: { include: {
team1P1: true, player1P1: true,
team1P2: true, player1P2: true,
team2P1: true, player2P1: true,
team2P2: true, player2P2: true,
event: true, event: true,
}, },
orderBy: { playedAt: "desc" }, orderBy: { playedAt: "desc" },
@@ -247,18 +247,18 @@ export default async function PlayerProfilePage({ params }: PageProps) {
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
{recentMatches.map((match) => { {recentMatches.map((match) => {
const isTeam1 = match.team1P1Id === playerId || match.team1P2Id === playerId; const isTeam1 = match.player1P1Id === playerId || match.player1P2Id === playerId;
const teamWon = isTeam1 ? match.team1Score > match.team2Score : match.team2Score > match.team1Score; const teamWon = isTeam1 ? match.team1Score > match.team2Score : match.team2Score > match.team1Score;
const teamScore = isTeam1 ? match.team1Score : match.team2Score; const teamScore = isTeam1 ? match.team1Score : match.team2Score;
const opponentScore = isTeam1 ? match.team2Score : match.team1Score; const opponentScore = isTeam1 ? match.team2Score : match.team1Score;
const teammate = isTeam1 const teammate = isTeam1
? (match.team1P1Id === playerId ? match.team1P2 : match.team1P1) ? (match.player1P1Id === playerId ? match.player1P2 : match.player1P1)
: (match.team2P1Id === playerId ? match.team2P2 : match.team2P1); : (match.player2P1Id === playerId ? match.player2P2 : match.player2P1);
const opponents = isTeam1 const opponents = isTeam1
? [match.team2P1, match.team2P2] ? [match.player2P1, match.player2P2]
: [match.team1P1, match.team1P2]; : [match.player1P1, match.player1P2];
return ( return (
<tr key={match.id} className="hover:bg-gray-50"> <tr key={match.id} className="hover:bg-gray-50">
@@ -276,21 +276,23 @@ export default async function PlayerProfilePage({ params }: PageProps) {
{match.event?.name || "N/A"} {match.event?.name || "N/A"}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<Link {teammate && (
href={`/players/${teammate.id}/profile`} <Link
className="text-green-600 hover:text-green-900" href={`/players/${teammate.id}/profile`}
> className="text-green-600 hover:text-green-900"
{teammate.name} >
</Link> {teammate.name}
</Link>
)}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{opponents.map((opponent, index) => ( {opponents.filter(o => o !== null).map((opponent, index) => (
<span key={opponent.id}> <span key={opponent?.id}>
<Link <Link
href={`/players/${opponent.id}/profile`} href={`/players/${opponent?.id}/profile`}
className="text-green-600 hover:text-green-900" className="text-green-600 hover:text-green-900"
> >
{opponent.name} {opponent?.name}
</Link> </Link>
{index < opponents.length - 1 ? ", " : ""} {index < opponents.length - 1 ? ", " : ""}
</span> </span>
+27 -33
View File
@@ -27,10 +27,10 @@ export default async function PlayerSchedulePage({ params }: PageProps) {
where: { playedAt: { gte: new Date() } }, where: { playedAt: { gte: new Date() } },
include: { include: {
event: true, event: true,
team1P1: true, player1P1: true,
team1P2: true, player1P2: true,
team2P1: true, player2P1: true,
team2P2: true, player2P2: true,
}, },
orderBy: { playedAt: "asc" }, orderBy: { playedAt: "asc" },
}, },
@@ -38,10 +38,10 @@ export default async function PlayerSchedulePage({ params }: PageProps) {
where: { playedAt: { gte: new Date() } }, where: { playedAt: { gte: new Date() } },
include: { include: {
event: true, event: true,
team1P1: true, player1P1: true,
team1P2: true, player1P2: true,
team2P1: true, player2P1: true,
team2P2: true, player2P2: true,
}, },
orderBy: { playedAt: "asc" }, orderBy: { playedAt: "asc" },
}, },
@@ -49,10 +49,10 @@ export default async function PlayerSchedulePage({ params }: PageProps) {
where: { playedAt: { gte: new Date() } }, where: { playedAt: { gte: new Date() } },
include: { include: {
event: true, event: true,
team1P1: true, player1P1: true,
team1P2: true, player1P2: true,
team2P1: true, player2P1: true,
team2P2: true, player2P2: true,
}, },
orderBy: { playedAt: "asc" }, orderBy: { playedAt: "asc" },
}, },
@@ -60,10 +60,10 @@ export default async function PlayerSchedulePage({ params }: PageProps) {
where: { playedAt: { gte: new Date() } }, where: { playedAt: { gte: new Date() } },
include: { include: {
event: true, event: true,
team1P1: true, player1P1: true,
team1P2: true, player1P2: true,
team2P1: true, player2P1: true,
team2P2: true, player2P2: true,
}, },
orderBy: { playedAt: "asc" }, orderBy: { playedAt: "asc" },
}, },
@@ -94,12 +94,6 @@ export default async function PlayerSchedulePage({ params }: PageProps) {
}, },
include: { include: {
participants: true, participants: true,
teams: {
include: {
player1: true,
player2: true,
},
},
}, },
}) })
@@ -125,13 +119,13 @@ export default async function PlayerSchedulePage({ params }: PageProps) {
<div className="space-y-4"> <div className="space-y-4">
{upcomingMatches.map((match) => { {upcomingMatches.map((match) => {
const team1Players = [ const team1Players = [
match.team1P1.name, match.player1P1?.name,
match.team1P2.name, match.player1P2?.name,
].join(" + ") ].filter(Boolean).join(" + ")
const team2Players = [ const team2Players = [
match.team2P1.name, match.player2P1?.name,
match.team2P2.name, match.player2P2?.name,
].join(" + ") ].filter(Boolean).join(" + ")
return ( return (
<div <div
@@ -171,9 +165,9 @@ export default async function PlayerSchedulePage({ params }: PageProps) {
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{activeTournaments.map((tournament) => { {activeTournaments.map((tournament) => {
const playerTeam = tournament.teams.find( // Since teams are now ephemeral, just check if player is a participant
(team) => const isParticipant = tournament.participants.some(
team.player1Id === playerId || team.player2Id === playerId (p) => p.playerId === playerId
) )
return ( return (
@@ -192,9 +186,9 @@ export default async function PlayerSchedulePage({ params }: PageProps) {
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
{tournament.format} - {tournament.status} {tournament.format} - {tournament.status}
</p> </p>
{playerTeam && ( {isParticipant && (
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
Team: {playerTeam.player1.name} + {playerTeam.player2.name} Participant in tournament
</p> </p>
)} )}
</div> </div>
+10
View File
@@ -26,6 +26,16 @@ export function DeleteTournamentButton({ tournamentId, tournamentName, matchCoun
}), }),
}) })
if (!response.ok) {
try {
const errorData = await response.json()
alert(`Error: ${errorData.error || 'Failed to delete tournament'}`)
} catch {
alert(`Error: ${response.status} ${response.statusText}`)
}
return
}
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
+6 -3
View File
@@ -56,10 +56,13 @@ export default function EditTournamentForm({ tournament }: EditTournamentFormPro
}), }),
}) })
const data = await response.json()
if (!response.ok) { if (!response.ok) {
setError(data.error || "Failed to update tournament") try {
const data = await response.json()
setError(data.error || "Failed to update tournament")
} catch {
setError(`Failed to update tournament: ${response.status} ${response.statusText}`)
}
setIsLoading(false) setIsLoading(false)
return return
} }
+6 -3
View File
@@ -124,10 +124,13 @@ export default function MatchEditor({ tournamentId, players, targetScore, allowT
}), }),
}) })
const data = await response.json()
if (!response.ok) { if (!response.ok) {
setError(data.error || "Failed to record match") try {
const data = await response.json()
setError(data.error || "Failed to record match")
} catch {
setError(`Failed to record match: ${response.status} ${response.statusText}`)
}
setIsLoading(false) setIsLoading(false)
return return
} }
+11
View File
@@ -18,6 +18,17 @@ export function RecalculateEloButton() {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}) })
if (!response.ok) {
try {
const errorData = await response.json()
alert(`Error: ${errorData.error || 'Failed to recalculate'}`)
} catch {
alert(`Error: ${response.status} ${response.statusText}`)
}
return
}
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
+135
View File
@@ -0,0 +1,135 @@
"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",
})
// Check response status first before parsing JSON
if (!response.ok) {
try {
const data = await response.json()
setError(data.error || "Failed to generate schedule")
} catch {
setError(`Failed to generate schedule: ${response.status} ${response.statusText}`)
}
setIsGenerating(false)
return
}
const data = await response.json()
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",
})
if (!response.ok) {
try {
const data = await response.json()
setError(data.error || "Failed to delete schedule")
} catch {
setError(`Failed to delete schedule: ${response.status} ${response.statusText}`)
}
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>
)
}
+433
View File
@@ -0,0 +1,433 @@
"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
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,
participants,
teamDurability: initialTeamDurability,
partnerRotation: initialPartnerRotation,
allowByes: initialAllowByes,
}: TeamsSectionProps) {
const router = useRouter()
const [teams, setTeams] = useState<Team[]>([])
const [teamDurability, setTeamDurability] = useState<TeamDurabilityOption>(initialTeamDurability as TeamDurabilityOption)
const [partnerRotation, setPartnerRotation] = useState<PartnerRotationOption>(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 handleGenerateSchedule = async () => {
if (participants.length < 2) {
setError("At least 2 participants are required to generate a schedule")
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}/schedule`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
})
if (!response.ok) {
try {
const errorData = await response.json()
setError(errorData.error || "Failed to generate schedule")
} catch {
setError(`Failed to generate schedule: ${response.status} ${response.statusText}`)
}
setIsGenerating(false)
return
}
const data = await response.json()
// Update teams from the generated schedule
// Extract teams from round matchups
const generatedTeams: Team[] = []
for (const round of data.rounds) {
for (const matchup of round.bracketMatchups) {
// Add unique teams
const team1 = {
id: 0,
teamName: `${matchup.player1P1.name} & ${matchup.player1P2.name}`,
player1: matchup.player1P1,
player2: matchup.player1P2,
}
const team2 = {
id: 0,
teamName: `${matchup.player2P1.name} & ${matchup.player2P2.name}`,
player1: matchup.player2P1,
player2: matchup.player2P2,
}
// Check if team already exists
const existingTeam1 = generatedTeams.find(
t => t.player1.id === team1.player1.id && t.player2.id === team1.player2.id
)
if (!existingTeam1) generatedTeams.push(team1)
const existingTeam2 = generatedTeams.find(
t => t.player1.id === team2.player1.id && t.player2.id === team2.player2.id
)
if (!existingTeam2) generatedTeams.push(team2)
}
}
setTeams(generatedTeams)
setSuccess(`Successfully generated ${data.roundsCreated} rounds with ${data.matchupsCreated} matchups!`)
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 the schedule? This will remove all rounds and matchups.")) {
return
}
setError("")
setSuccess("")
setIsGenerating(true)
try {
// Delete schedule (which includes matchups/rounds)
const response = await fetch(`/api/tournaments/${tournamentId}/schedule`, {
method: "DELETE",
})
if (!response.ok) {
try {
const errorData = await response.json()
setError(errorData.error || "Failed to delete schedule")
} catch {
setError(`Failed to delete schedule: ${response.status} ${response.statusText}`)
}
return
}
setTeams([])
setSuccess("Schedule deleted successfully!")
router.refresh()
} catch (err) {
if (err instanceof Error) {
setError(err.message)
} else {
setError("An unknown error occurred")
}
} finally {
setIsGenerating(false)
}
}
return (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">
Teams ({teams.length})
</h2>
{/* Configuration Panel */}
<div className="bg-gray-50 rounded-lg p-4 mb-6">
<h3 className="text-sm font-medium text-gray-700 mb-3">Team Configuration</h3>
{/* Team Durability */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-600 mb-2">
Team Durability
</label>
<div className="flex gap-4">
<label className="flex items-center">
<input
type="radio"
name="teamDurability"
value="permanent"
checked={teamDurability === 'permanent'}
onChange={(e) => setTeamDurability(e.target.value as TeamDurabilityOption)}
className="mr-2"
/>
<span className="text-sm">Permanent Teams</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="teamDurability"
value="variable"
checked={teamDurability === 'variable'}
onChange={(e) => setTeamDurability(e.target.value as TeamDurabilityOption)}
className="mr-2"
/>
<span className="text-sm">Variable Teams</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="teamDurability"
value="per_round"
checked={teamDurability === 'per_round'}
onChange={(e) => setTeamDurability(e.target.value as TeamDurabilityOption)}
className="mr-2"
/>
<span className="text-sm">Per-Round Teams</span>
</label>
</div>
<p className="text-xs text-gray-500 mt-1">
{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.'}
</p>
</div>
{/* Partner Rotation (for variable/per_round teams) */}
{(teamDurability === 'variable' || teamDurability === 'per_round') && (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-600 mb-2">
Partner Rotation Strategy
</label>
<div className="flex gap-4 flex-wrap">
<label className="flex items-center">
<input
type="radio"
name="partnerRotation"
value="none"
checked={partnerRotation === 'none'}
onChange={(e) => setPartnerRotation(e.target.value as PartnerRotationOption)}
className="mr-2"
/>
<span className="text-sm">None (Random)</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="partnerRotation"
value="minimize_repeat"
checked={partnerRotation === 'minimize_repeat'}
onChange={(e) => setPartnerRotation(e.target.value as PartnerRotationOption)}
className="mr-2"
/>
<span className="text-sm">Minimize Repeat Partners</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="partnerRotation"
value="maximize_even"
checked={partnerRotation === 'maximize_even'}
onChange={(e) => setPartnerRotation(e.target.value as PartnerRotationOption)}
className="mr-2"
/>
<span className="text-sm">Maximize Even Matches</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="partnerRotation"
value="elo_based"
checked={partnerRotation === 'elo_based'}
onChange={(e) => setPartnerRotation(e.target.value as PartnerRotationOption)}
className="mr-2"
/>
<span className="text-sm">ELO-Based Pairing</span>
</label>
</div>
<p className="text-xs text-gray-500 mt-1">
{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.'}
</p>
</div>
)}
{/* Allow Byes (for odd participant counts) */}
<div className="mb-4">
<label className="flex items-center">
<input
type="checkbox"
checked={allowByes}
onChange={(e) => setAllowByes(e.target.checked)}
className="mr-2"
/>
<span className="text-sm font-medium text-gray-600">Allow Byes (for odd number of participants)</span>
</label>
<p className="text-xs text-gray-500 mt-1 ml-6">
When enabled, one player will have a bye each round if there's an odd number of participants.
</p>
</div>
{/* Action Buttons */}
<div className="flex gap-3 mt-4">
<button
onClick={handleSaveConfig}
disabled={isGenerating}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 text-sm"
>
{isGenerating ? "Saving..." : "Save Configuration"}
</button>
</div>
</div>
{/* Error/Success Messages */}
{error && (
<div className="rounded-md bg-red-50 p-4 mb-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{success && (
<div className="rounded-md bg-green-50 p-4 mb-4">
<div className="text-sm text-green-700">{success}</div>
</div>
)}
{/* Team Generation Controls */}
<div className="flex gap-3 mb-4">
<button
onClick={handleGenerateSchedule}
disabled={isGenerating || participants.length < 2}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 text-sm"
>
{isGenerating ? "Generating..." : `Generate Schedule (${participants.length} participants)`}
</button>
{teams.length > 0 && (
<button
onClick={handleDeleteTeams}
disabled={isGenerating}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 text-sm"
>
Delete Schedule
</button>
)}
</div>
{/* Teams Display */}
{teams.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{teams.map((team) => (
<div
key={team.id}
className="bg-gray-50 rounded p-3 flex justify-between items-center"
>
<div>
<p className="font-medium text-gray-900">
{team.player1.name} + {team.player2.name}
</p>
<p className="text-sm text-gray-500">
{team.teamName || `Team ${team.id}`}
</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-500">
ELO: {team.player1.currentElo} + {team.player2.currentElo} = {team.player1.currentElo + team.player2.currentElo}
</p>
</div>
</div>
))}
</div>
) : (
<p className="text-gray-500">No teams created yet. Configure options above and click Generate Teams.</p>
)}
{/* Participants Summary */}
<div className="mt-6 pt-4 border-t border-gray-200">
<h3 className="text-sm font-medium text-gray-700 mb-2">
Available Participants ({participants.length})
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{participants.map((player) => (
<div key={player.id} className="text-sm text-gray-600 bg-gray-100 rounded px-2 py-1">
{player.name} ({player.currentElo})
</div>
))}
</div>
</div>
</div>
)
}
+24 -19
View File
@@ -177,10 +177,10 @@ export async function recalculateAllElo(prisma: PrismaClient) {
const matches = await prisma.match.findMany({ const matches = await prisma.match.findMany({
orderBy: { playedAt: 'asc' }, orderBy: { playedAt: 'asc' },
include: { include: {
team1P1: true, player1P1: true,
team1P2: true, player1P2: true,
team2P1: true, player2P1: true,
team2P2: true, player2P2: true,
}, },
}); });
@@ -230,13 +230,18 @@ export async function recalculateAllElo(prisma: PrismaClient) {
// Process each match in chronological order // Process each match in chronological order
for (const match of matches) { for (const match of matches) {
const { team1P1, team1P2, team2P1, team2P2, team1Score, team2Score, id: matchId, playedAt } = match; const { player1P1, player1P2, player2P1, player2P2, team1Score, team2Score, id: matchId, playedAt } = match;
// Skip matches with missing players
if (!player1P1 || !player1P2 || !player2P1 || !player2P2) {
continue;
}
// Get current ratings for all players // Get current ratings for all players
const p1Rating = getPlayerStats(team1P1.id).rating; const p1Rating = getPlayerStats(player1P1.id).rating;
const p2Rating = getPlayerStats(team1P2.id).rating; const p2Rating = getPlayerStats(player1P2.id).rating;
const p3Rating = getPlayerStats(team2P1.id).rating; const p3Rating = getPlayerStats(player2P1.id).rating;
const p4Rating = getPlayerStats(team2P2.id).rating; const p4Rating = getPlayerStats(player2P2.id).rating;
// Calculate team ratings // Calculate team ratings
const team1Rating = calculateTeamElo(p1Rating, p2Rating); const team1Rating = calculateTeamElo(p1Rating, p2Rating);
@@ -261,7 +266,7 @@ export async function recalculateAllElo(prisma: PrismaClient) {
const isTie = team1Score === team2Score; const isTie = team1Score === team2Score;
// Update Player 1 (team 1, player 1) // Update Player 1 (team 1, player 1)
const stats1 = getPlayerStats(team1P1.id); const stats1 = getPlayerStats(player1P1.id);
stats1.rating += p1Change; stats1.rating += p1Change;
stats1.gamesPlayed += 1; stats1.gamesPlayed += 1;
if (isTie) { if (isTie) {
@@ -273,7 +278,7 @@ export async function recalculateAllElo(prisma: PrismaClient) {
} }
// Update Player 2 (team 1, player 2) // Update Player 2 (team 1, player 2)
const stats2 = getPlayerStats(team1P2.id); const stats2 = getPlayerStats(player1P2.id);
stats2.rating += p2Change; stats2.rating += p2Change;
stats2.gamesPlayed += 1; stats2.gamesPlayed += 1;
if (isTie) { if (isTie) {
@@ -285,7 +290,7 @@ export async function recalculateAllElo(prisma: PrismaClient) {
} }
// Update Player 3 (team 2, player 1) // Update Player 3 (team 2, player 1)
const stats3 = getPlayerStats(team2P1.id); const stats3 = getPlayerStats(player2P1.id);
stats3.rating += p3Change; stats3.rating += p3Change;
stats3.gamesPlayed += 1; stats3.gamesPlayed += 1;
if (isTie) { if (isTie) {
@@ -297,7 +302,7 @@ export async function recalculateAllElo(prisma: PrismaClient) {
} }
// Update Player 4 (team 2, player 2) // Update Player 4 (team 2, player 2)
const stats4 = getPlayerStats(team2P2.id); const stats4 = getPlayerStats(player2P2.id);
stats4.rating += p4Change; stats4.rating += p4Change;
stats4.gamesPlayed += 1; stats4.gamesPlayed += 1;
if (isTie) { if (isTie) {
@@ -309,7 +314,7 @@ export async function recalculateAllElo(prisma: PrismaClient) {
} }
// Update partnership stats for team 1 // Update partnership stats for team 1
const partnership1 = getPartnershipStats(team1P1.id, team1P2.id); const partnership1 = getPartnershipStats(player1P1.id, player1P2.id);
partnership1.gamesPlayed += 1; partnership1.gamesPlayed += 1;
if (isTie) { if (isTie) {
// For ties, don't increment wins or losses (gamesPlayed is already incremented) // For ties, don't increment wins or losses (gamesPlayed is already incremented)
@@ -324,7 +329,7 @@ export async function recalculateAllElo(prisma: PrismaClient) {
} }
// Update partnership stats for team 2 // Update partnership stats for team 2
const partnership2 = getPartnershipStats(team2P1.id, team2P2.id); const partnership2 = getPartnershipStats(player2P1.id, player2P2.id);
partnership2.gamesPlayed += 1; partnership2.gamesPlayed += 1;
if (isTie) { if (isTie) {
// For ties, don't increment wins or losses (gamesPlayed is already incremented) // For ties, don't increment wins or losses (gamesPlayed is already incremented)
@@ -340,10 +345,10 @@ export async function recalculateAllElo(prisma: PrismaClient) {
// Create elo snapshots for all players // Create elo snapshots for all players
const snapshotData = [ const snapshotData = [
{ playerId: team1P1.id, ratingBefore: p1Rating, ratingChange: p1Change }, { playerId: player1P1.id, ratingBefore: p1Rating, ratingChange: p1Change },
{ playerId: team1P2.id, ratingBefore: p2Rating, ratingChange: p2Change }, { playerId: player1P2.id, ratingBefore: p2Rating, ratingChange: p2Change },
{ playerId: team2P1.id, ratingBefore: p3Rating, ratingChange: p3Change }, { playerId: player2P1.id, ratingBefore: p3Rating, ratingChange: p3Change },
{ playerId: team2P2.id, ratingBefore: p4Rating, ratingChange: p4Change }, { playerId: player2P2.id, ratingBefore: p4Rating, ratingChange: p4Change },
]; ];
for (const snapshot of snapshotData) { for (const snapshot of snapshotData) {
+26 -21
View File
@@ -259,10 +259,10 @@ export async function recalculateAllGlicko2(prisma: PrismaClient) {
const matches = await prisma.match.findMany({ const matches = await prisma.match.findMany({
orderBy: { playedAt: 'asc' }, orderBy: { playedAt: 'asc' },
include: { include: {
team1P1: true, player1P1: true,
team1P2: true, player1P2: true,
team2P1: true, player2P1: true,
team2P2: true, player2P2: true,
}, },
}); });
@@ -291,7 +291,12 @@ export async function recalculateAllGlicko2(prisma: PrismaClient) {
// Process each match // Process each match
for (const match of matches) { for (const match of matches) {
const { team1P1, team1P2, team2P1, team2P2, team1Score, team2Score } = match; const { player1P1, player1P2, player2P1, player2P2, team1Score, team2Score } = match;
// Skip matches with missing players
if (!player1P1 || !player1P2 || !player2P1 || !player2P2) {
continue;
}
// Get current ratings // Get current ratings
const getOrCreatePlayer = (playerId: number) => { const getOrCreatePlayer = (playerId: number) => {
@@ -306,10 +311,10 @@ export async function recalculateAllGlicko2(prisma: PrismaClient) {
return glicko.makePlayer(record.rating, record.deviation, record.volatility); return glicko.makePlayer(record.rating, record.deviation, record.volatility);
}; };
const p1 = getOrCreatePlayer(team1P1.id); const p1 = getOrCreatePlayer(player1P1.id);
const p2 = getOrCreatePlayer(team1P2.id); const p2 = getOrCreatePlayer(player1P2.id);
const p3 = getOrCreatePlayer(team2P1.id); const p3 = getOrCreatePlayer(player2P1.id);
const p4 = getOrCreatePlayer(team2P2.id); const p4 = getOrCreatePlayer(player2P2.id);
const team1Won = team1Score > team2Score; const team1Won = team1Score > team2Score;
const team2Won = team2Score > team1Score; const team2Won = team2Score > team1Score;
@@ -330,22 +335,22 @@ export async function recalculateAllGlicko2(prisma: PrismaClient) {
glicko.updateRatings(matchesToUpdate); glicko.updateRatings(matchesToUpdate);
// Update in-memory ratings // Update in-memory ratings
playerRatings.set(team1P1.id, { playerRatings.set(player1P1.id, {
rating: p1.getRating(), rating: p1.getRating(),
deviation: p1.getRd(), deviation: p1.getRd(),
volatility: p1.getVol() volatility: p1.getVol()
}); });
playerRatings.set(team1P2.id, { playerRatings.set(player1P2.id, {
rating: p2.getRating(), rating: p2.getRating(),
deviation: p2.getRd(), deviation: p2.getRd(),
volatility: p2.getVol() volatility: p2.getVol()
}); });
playerRatings.set(team2P1.id, { playerRatings.set(player2P1.id, {
rating: p3.getRating(), rating: p3.getRating(),
deviation: p3.getRd(), deviation: p3.getRd(),
volatility: p3.getVol() volatility: p3.getVol()
}); });
playerRatings.set(team2P2.id, { playerRatings.set(player2P2.id, {
rating: p4.getRating(), rating: p4.getRating(),
deviation: p4.getRd(), deviation: p4.getRd(),
volatility: p4.getVol() volatility: p4.getVol()
@@ -353,7 +358,7 @@ export async function recalculateAllGlicko2(prisma: PrismaClient) {
// Update database records // Update database records
await (prisma as any).glicko2Rating.upsert({ await (prisma as any).glicko2Rating.upsert({
where: { playerId: team1P1.id }, where: { playerId: player1P1.id },
update: { update: {
rating: p1.getRating(), rating: p1.getRating(),
deviation: p1.getRd(), deviation: p1.getRd(),
@@ -364,7 +369,7 @@ export async function recalculateAllGlicko2(prisma: PrismaClient) {
draws: isTie ? { increment: 1 } : undefined, draws: isTie ? { increment: 1 } : undefined,
}, },
create: { create: {
playerId: team1P1.id, playerId: player1P1.id,
rating: p1.getRating(), rating: p1.getRating(),
deviation: p1.getRd(), deviation: p1.getRd(),
volatility: p1.getVol(), volatility: p1.getVol(),
@@ -376,7 +381,7 @@ export async function recalculateAllGlicko2(prisma: PrismaClient) {
}); });
await (prisma as any).glicko2Rating.upsert({ await (prisma as any).glicko2Rating.upsert({
where: { playerId: team1P2.id }, where: { playerId: player1P2.id },
update: { update: {
rating: p2.getRating(), rating: p2.getRating(),
deviation: p2.getRd(), deviation: p2.getRd(),
@@ -387,7 +392,7 @@ export async function recalculateAllGlicko2(prisma: PrismaClient) {
draws: isTie ? { increment: 1 } : undefined, draws: isTie ? { increment: 1 } : undefined,
}, },
create: { create: {
playerId: team1P2.id, playerId: player1P2.id,
rating: p2.getRating(), rating: p2.getRating(),
deviation: p2.getRd(), deviation: p2.getRd(),
volatility: p2.getVol(), volatility: p2.getVol(),
@@ -399,7 +404,7 @@ export async function recalculateAllGlicko2(prisma: PrismaClient) {
}); });
await (prisma as any).glicko2Rating.upsert({ await (prisma as any).glicko2Rating.upsert({
where: { playerId: team2P1.id }, where: { playerId: player2P1.id },
update: { update: {
rating: p3.getRating(), rating: p3.getRating(),
deviation: p3.getRd(), deviation: p3.getRd(),
@@ -410,7 +415,7 @@ export async function recalculateAllGlicko2(prisma: PrismaClient) {
draws: isTie ? { increment: 1 } : undefined, draws: isTie ? { increment: 1 } : undefined,
}, },
create: { create: {
playerId: team2P1.id, playerId: player2P1.id,
rating: p3.getRating(), rating: p3.getRating(),
deviation: p3.getRd(), deviation: p3.getRd(),
volatility: p3.getVol(), volatility: p3.getVol(),
@@ -422,7 +427,7 @@ export async function recalculateAllGlicko2(prisma: PrismaClient) {
}); });
await (prisma as any).glicko2Rating.upsert({ await (prisma as any).glicko2Rating.upsert({
where: { playerId: team2P2.id }, where: { playerId: player2P2.id },
update: { update: {
rating: p4.getRating(), rating: p4.getRating(),
deviation: p4.getRd(), deviation: p4.getRd(),
@@ -433,7 +438,7 @@ export async function recalculateAllGlicko2(prisma: PrismaClient) {
draws: isTie ? { increment: 1 } : undefined, draws: isTie ? { increment: 1 } : undefined,
}, },
create: { create: {
playerId: team2P2.id, playerId: player2P2.id,
rating: p4.getRating(), rating: p4.getRating(),
deviation: p4.getRd(), deviation: p4.getRd(),
volatility: p4.getVol(), volatility: p4.getVol(),
+26 -21
View File
@@ -187,10 +187,10 @@ export async function recalculateAllOpenSkill(prisma: PrismaClient) {
const matches = await prisma.match.findMany({ const matches = await prisma.match.findMany({
orderBy: { playedAt: 'asc' }, orderBy: { playedAt: 'asc' },
include: { include: {
team1P1: true, player1P1: true,
team1P2: true, player1P2: true,
team2P1: true, player2P1: true,
team2P2: true, player2P2: true,
}, },
}); });
@@ -210,13 +210,18 @@ export async function recalculateAllOpenSkill(prisma: PrismaClient) {
// Process each match // Process each match
for (const match of matches) { for (const match of matches) {
const { team1P1, team1P2, team2P1, team2P2, team1Score, team2Score } = match; const { player1P1, player1P2, player2P1, player2P2, team1Score, team2Score } = match;
// Skip matches with missing players
if (!player1P1 || !player1P2 || !player2P1 || !player2P2) {
continue;
}
// Get current ratings // Get current ratings
const p1Rating = playerRatings.get(team1P1.id) ?? { mu: 25.0, sigma: 8.33 }; const p1Rating = playerRatings.get(player1P1.id) ?? { mu: 25.0, sigma: 8.33 };
const p2Rating = playerRatings.get(team1P2.id) ?? { mu: 25.0, sigma: 8.33 }; const p2Rating = playerRatings.get(player1P2.id) ?? { mu: 25.0, sigma: 8.33 };
const p3Rating = playerRatings.get(team2P1.id) ?? { mu: 25.0, sigma: 8.33 }; const p3Rating = playerRatings.get(player2P1.id) ?? { mu: 25.0, sigma: 8.33 };
const p4Rating = playerRatings.get(team2P2.id) ?? { mu: 25.0, sigma: 8.33 }; const p4Rating = playerRatings.get(player2P2.id) ?? { mu: 25.0, sigma: 8.33 };
const team1Won = team1Score > team2Score; const team1Won = team1Score > team2Score;
const team2Won = team2Score > team1Score; const team2Won = team2Score > team1Score;
@@ -228,14 +233,14 @@ export async function recalculateAllOpenSkill(prisma: PrismaClient) {
const newRatings = calculateOpenSkillRatings(teams, rankings); const newRatings = calculateOpenSkillRatings(teams, rankings);
// Update in-memory ratings // Update in-memory ratings
playerRatings.set(team1P1.id, newRatings[0][0]); playerRatings.set(player1P1.id, newRatings[0][0]);
playerRatings.set(team1P2.id, newRatings[0][1]); playerRatings.set(player1P2.id, newRatings[0][1]);
playerRatings.set(team2P1.id, newRatings[1][0]); playerRatings.set(player2P1.id, newRatings[1][0]);
playerRatings.set(team2P2.id, newRatings[1][1]); playerRatings.set(player2P2.id, newRatings[1][1]);
// Update database records // Update database records
await (prisma as any).openSkillRating.upsert({ await (prisma as any).openSkillRating.upsert({
where: { playerId: team1P1.id }, where: { playerId: player1P1.id },
update: { update: {
rating: fromOpenSkillRating(newRatings[0][0]), rating: fromOpenSkillRating(newRatings[0][0]),
gamesPlayed: { increment: 1 }, gamesPlayed: { increment: 1 },
@@ -244,7 +249,7 @@ export async function recalculateAllOpenSkill(prisma: PrismaClient) {
draws: isTie ? { increment: 1 } : undefined, draws: isTie ? { increment: 1 } : undefined,
}, },
create: { create: {
playerId: team1P1.id, playerId: player1P1.id,
rating: fromOpenSkillRating(newRatings[0][0]), rating: fromOpenSkillRating(newRatings[0][0]),
gamesPlayed: 1, gamesPlayed: 1,
wins: team1Won ? 1 : 0, wins: team1Won ? 1 : 0,
@@ -254,7 +259,7 @@ export async function recalculateAllOpenSkill(prisma: PrismaClient) {
}); });
await (prisma as any).openSkillRating.upsert({ await (prisma as any).openSkillRating.upsert({
where: { playerId: team1P2.id }, where: { playerId: player1P2.id },
update: { update: {
rating: fromOpenSkillRating(newRatings[0][1]), rating: fromOpenSkillRating(newRatings[0][1]),
gamesPlayed: { increment: 1 }, gamesPlayed: { increment: 1 },
@@ -263,7 +268,7 @@ export async function recalculateAllOpenSkill(prisma: PrismaClient) {
draws: isTie ? { increment: 1 } : undefined, draws: isTie ? { increment: 1 } : undefined,
}, },
create: { create: {
playerId: team1P2.id, playerId: player1P2.id,
rating: fromOpenSkillRating(newRatings[0][1]), rating: fromOpenSkillRating(newRatings[0][1]),
gamesPlayed: 1, gamesPlayed: 1,
wins: team1Won ? 1 : 0, wins: team1Won ? 1 : 0,
@@ -273,7 +278,7 @@ export async function recalculateAllOpenSkill(prisma: PrismaClient) {
}); });
await (prisma as any).openSkillRating.upsert({ await (prisma as any).openSkillRating.upsert({
where: { playerId: team2P1.id }, where: { playerId: player2P1.id },
update: { update: {
rating: fromOpenSkillRating(newRatings[1][0]), rating: fromOpenSkillRating(newRatings[1][0]),
gamesPlayed: { increment: 1 }, gamesPlayed: { increment: 1 },
@@ -282,7 +287,7 @@ export async function recalculateAllOpenSkill(prisma: PrismaClient) {
draws: isTie ? { increment: 1 } : undefined, draws: isTie ? { increment: 1 } : undefined,
}, },
create: { create: {
playerId: team2P1.id, playerId: player2P1.id,
rating: fromOpenSkillRating(newRatings[1][0]), rating: fromOpenSkillRating(newRatings[1][0]),
gamesPlayed: 1, gamesPlayed: 1,
wins: team2Won ? 1 : 0, wins: team2Won ? 1 : 0,
@@ -292,7 +297,7 @@ export async function recalculateAllOpenSkill(prisma: PrismaClient) {
}); });
await (prisma as any).openSkillRating.upsert({ await (prisma as any).openSkillRating.upsert({
where: { playerId: team2P2.id }, where: { playerId: player2P2.id },
update: { update: {
rating: fromOpenSkillRating(newRatings[1][1]), rating: fromOpenSkillRating(newRatings[1][1]),
gamesPlayed: { increment: 1 }, gamesPlayed: { increment: 1 },
@@ -301,7 +306,7 @@ export async function recalculateAllOpenSkill(prisma: PrismaClient) {
draws: isTie ? { increment: 1 } : undefined, draws: isTie ? { increment: 1 } : undefined,
}, },
create: { create: {
playerId: team2P2.id, playerId: player2P2.id,
rating: fromOpenSkillRating(newRatings[1][1]), rating: fromOpenSkillRating(newRatings[1][1]),
gamesPlayed: 1, gamesPlayed: 1,
wins: team2Won ? 1 : 0, wins: team2Won ? 1 : 0,
+113
View File
@@ -0,0 +1,113 @@
export interface MatchupPairing {
player1P1Id: number
player1P2Id: number
player2P1Id: number
player2P2Id: 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 teamPairings - Array of player pairings (each with 2 player IDs)
* @returns Array of rounds, each containing matchup pairings
*/
export function generateRoundRobin(
teamPairings: { player1Id: number; player2Id: number }[]
): RoundSchedule[] {
if (teamPairings.length < 2) {
return []
}
// Use circle method: fix first team, rotate the rest
// If odd number of teams, add a sentinel for byes
const hasOddTeams = teamPairings.length % 2 !== 0
const workingTeams = hasOddTeams
? [...teamPairings, { player1Id: -1, player2Id: -1 }]
: [...teamPairings]
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 team1 = workingTeams[team1Idx]
const team2 = workingTeams[team2Idx]
// Skip bye matchups (where either team is the sentinel)
if (team1.player1Id !== -1 && team2.player1Id !== -1) {
matchups.push({
player1P1Id: team1.player1Id,
player1P2Id: team1.player2Id,
player2P1Id: team2.player1Id,
player2P2Id: team2.player2Id,
})
}
}
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 player pairings can be scheduled.
*/
export function validateScheduleInput(
teamPairings: { player1Id: number; player2Id: number }[]
): {
valid: boolean
error?: string
} {
if (teamPairings.length < 2) {
return { valid: false, error: "At least 2 teams are required to generate a schedule" }
}
// Check for duplicate teams
const teamKeys = teamPairings.map(
(t) => [t.player1Id, t.player2Id].sort().join('-')
)
const uniqueKeys = new Set(teamKeys)
if (uniqueKeys.size !== teamPairings.length) {
return { valid: false, error: "Duplicate team pairings 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
}
+407
View File
@@ -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<string, number> {
const frequency = new Map<string, number>()
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<string, number>
): Team[] {
const teams: Team[] = []
const used = new Set<number>()
// 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<string, number>
): 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<string, number>
): 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<string, number>
): 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<string, number>
): number {
const key = [team.player1Id, team.player2Id].sort().join('-')
return partnershipFreq.get(key) || 0
}