Compare commits
20 Commits
ab50ae0bdf
...
c222e55a52
| Author | SHA1 | Date | |
|---|---|---|---|
| c222e55a52 | |||
| e0c986f594 | |||
| 1f7d589698 | |||
| e5f679e54c | |||
| 803b79f03c | |||
| aa98600147 | |||
| ad7724cda6 | |||
| ada163f538 | |||
| 4d685558aa | |||
| 07283d0334 | |||
| dca35ec0bf | |||
| c3b0466092 | |||
| a75d7d3cc6 | |||
| 37eb1f8e21 | |||
| 9b15d7e61f | |||
| 322ab2a5fa | |||
| c4d2130d5b | |||
| 051b729451 | |||
| 443a0f460e | |||
| 7367fce4a6 |
@@ -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
|
||||||
|
|||||||
@@ -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() } },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,42 +12,71 @@ import {
|
|||||||
expectedMatchups,
|
expectedMatchups,
|
||||||
} from '@/lib/schedule-generator';
|
} 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('Round-Robin Schedule Generator', () => {
|
||||||
describe('generateRoundRobin', () => {
|
describe('generateRoundRobin', () => {
|
||||||
test('should return empty array for fewer than 2 teams', () => {
|
test('should return empty array for fewer than 2 teams', () => {
|
||||||
expect(generateRoundRobin([])).toEqual([]);
|
expect(generateRoundRobin([])).toEqual([]);
|
||||||
expect(generateRoundRobin([1])).toEqual([]);
|
expect(generateRoundRobin([{ player1Id: 1, player2Id: 2 }])).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should generate correct schedule for 2 teams', () => {
|
test('should generate correct schedule for 2 teams', () => {
|
||||||
const rounds = generateRoundRobin([1, 2]);
|
const rounds = generateRoundRobin([
|
||||||
|
{ player1Id: 1, player2Id: 2 },
|
||||||
|
{ player1Id: 3, player2Id: 4 },
|
||||||
|
]);
|
||||||
expect(rounds).toHaveLength(1);
|
expect(rounds).toHaveLength(1);
|
||||||
expect(rounds[0].roundNumber).toBe(1);
|
expect(rounds[0].roundNumber).toBe(1);
|
||||||
expect(rounds[0].matchups).toHaveLength(1);
|
expect(rounds[0].matchups).toHaveLength(1);
|
||||||
expect(rounds[0].matchups[0]).toEqual({ team1Id: 1, team2Id: 2 });
|
expect(rounds[0].matchups[0]).toEqual({
|
||||||
|
player1P1Id: 1,
|
||||||
|
player1P2Id: 2,
|
||||||
|
player2P1Id: 3,
|
||||||
|
player2P2Id: 4,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should generate N-1 rounds for N even teams', () => {
|
test('should generate N-1 rounds for N even teams', () => {
|
||||||
const teams = [1, 2, 3, 4];
|
const teams = createTeams(4);
|
||||||
const rounds = generateRoundRobin(teams);
|
const rounds = generateRoundRobin(teams);
|
||||||
expect(rounds).toHaveLength(3);
|
expect(rounds).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should generate N rounds for N odd teams (with bye)', () => {
|
test('should generate N rounds for N odd teams (with bye)', () => {
|
||||||
const teams = [1, 2, 3];
|
const teams = createTeams(3);
|
||||||
const rounds = generateRoundRobin(teams);
|
const rounds = generateRoundRobin(teams);
|
||||||
expect(rounds).toHaveLength(3);
|
expect(rounds).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('each team plays every other team exactly once (even)', () => {
|
test('each team plays every other team exactly once (even)', () => {
|
||||||
const teams = [1, 2, 3, 4];
|
const teams = createTeams(4);
|
||||||
const rounds = generateRoundRobin(teams);
|
const rounds = generateRoundRobin(teams);
|
||||||
|
|
||||||
// Collect all pairings as sorted tuples
|
// Collect all pairings as sorted tuples
|
||||||
const pairings = new Set<string>();
|
const pairings = new Set<string>();
|
||||||
for (const round of rounds) {
|
for (const round of rounds) {
|
||||||
for (const matchup of round.matchups) {
|
for (const matchup of round.matchups) {
|
||||||
const key = [matchup.team1Id, matchup.team2Id].sort().join('-');
|
const key = [
|
||||||
|
matchup.player1P1Id,
|
||||||
|
matchup.player1P2Id,
|
||||||
|
matchup.player2P1Id,
|
||||||
|
matchup.player2P2Id,
|
||||||
|
].sort().join('-');
|
||||||
pairings.add(key);
|
pairings.add(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,13 +86,18 @@ describe('Round-Robin Schedule Generator', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('each team plays every other team exactly once (odd)', () => {
|
test('each team plays every other team exactly once (odd)', () => {
|
||||||
const teams = [1, 2, 3, 4, 5];
|
const teams = createTeams(5);
|
||||||
const rounds = generateRoundRobin(teams);
|
const rounds = generateRoundRobin(teams);
|
||||||
|
|
||||||
const pairings = new Set<string>();
|
const pairings = new Set<string>();
|
||||||
for (const round of rounds) {
|
for (const round of rounds) {
|
||||||
for (const matchup of round.matchups) {
|
for (const matchup of round.matchups) {
|
||||||
const key = [matchup.team1Id, matchup.team2Id].sort().join('-');
|
const key = [
|
||||||
|
matchup.player1P1Id,
|
||||||
|
matchup.player1P2Id,
|
||||||
|
matchup.player2P1Id,
|
||||||
|
matchup.player2P2Id,
|
||||||
|
].sort().join('-');
|
||||||
pairings.add(key);
|
pairings.add(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,14 +107,14 @@ describe('Round-Robin Schedule Generator', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('each team plays exactly once per round (even teams)', () => {
|
test('each team plays exactly once per round (even teams)', () => {
|
||||||
const teams = [1, 2, 3, 4, 5, 6];
|
const teams = createTeams(6);
|
||||||
const rounds = generateRoundRobin(teams);
|
const rounds = generateRoundRobin(teams);
|
||||||
|
|
||||||
for (const round of rounds) {
|
for (const round of rounds) {
|
||||||
const teamsInRound = new Set<number>();
|
const teamsInRound = new Set<string>();
|
||||||
for (const matchup of round.matchups) {
|
for (const matchup of round.matchups) {
|
||||||
teamsInRound.add(matchup.team1Id);
|
teamsInRound.add([matchup.player1P1Id, matchup.player1P2Id].sort().join('-'));
|
||||||
teamsInRound.add(matchup.team2Id);
|
teamsInRound.add([matchup.player2P1Id, matchup.player2P2Id].sort().join('-'));
|
||||||
}
|
}
|
||||||
// Each team appears exactly once
|
// Each team appears exactly once
|
||||||
expect(teamsInRound.size).toBe(6);
|
expect(teamsInRound.size).toBe(6);
|
||||||
@@ -88,14 +122,14 @@ describe('Round-Robin Schedule Generator', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('each team plays at most once per round (odd teams)', () => {
|
test('each team plays at most once per round (odd teams)', () => {
|
||||||
const teams = [1, 2, 3, 4, 5];
|
const teams = createTeams(5);
|
||||||
const rounds = generateRoundRobin(teams);
|
const rounds = generateRoundRobin(teams);
|
||||||
|
|
||||||
for (const round of rounds) {
|
for (const round of rounds) {
|
||||||
const teamsInRound = new Set<number>();
|
const teamsInRound = new Set<string>();
|
||||||
for (const matchup of round.matchups) {
|
for (const matchup of round.matchups) {
|
||||||
teamsInRound.add(matchup.team1Id);
|
teamsInRound.add([matchup.player1P1Id, matchup.player1P2Id].sort().join('-'));
|
||||||
teamsInRound.add(matchup.team2Id);
|
teamsInRound.add([matchup.player2P1Id, matchup.player2P2Id].sort().join('-'));
|
||||||
}
|
}
|
||||||
// 5 teams, one has bye each round, so 4 play
|
// 5 teams, one has bye each round, so 4 play
|
||||||
expect(teamsInRound.size).toBe(4);
|
expect(teamsInRound.size).toBe(4);
|
||||||
@@ -103,7 +137,7 @@ describe('Round-Robin Schedule Generator', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should handle 8 teams (typical euchre tournament)', () => {
|
test('should handle 8 teams (typical euchre tournament)', () => {
|
||||||
const teams = [1, 2, 3, 4, 5, 6, 7, 8];
|
const teams = createTeams(8);
|
||||||
const rounds = generateRoundRobin(teams);
|
const rounds = generateRoundRobin(teams);
|
||||||
|
|
||||||
expect(rounds).toHaveLength(7);
|
expect(rounds).toHaveLength(7);
|
||||||
@@ -113,7 +147,7 @@ describe('Round-Robin Schedule Generator', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('round numbers should be sequential starting from 1', () => {
|
test('round numbers should be sequential starting from 1', () => {
|
||||||
const teams = [1, 2, 3, 4, 5, 6];
|
const teams = createTeams(6);
|
||||||
const rounds = generateRoundRobin(teams);
|
const rounds = generateRoundRobin(teams);
|
||||||
|
|
||||||
rounds.forEach((round, idx) => {
|
rounds.forEach((round, idx) => {
|
||||||
@@ -130,18 +164,22 @@ describe('Round-Robin Schedule Generator', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should reject single team', () => {
|
test('should reject single team', () => {
|
||||||
const result = validateScheduleInput([1]);
|
const result = validateScheduleInput([{ player1Id: 1, player2Id: 2 }]);
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject duplicate team IDs', () => {
|
test('should reject duplicate team pairings', () => {
|
||||||
const result = validateScheduleInput([1, 2, 2]);
|
const result = validateScheduleInput([
|
||||||
|
{ player1Id: 1, player2Id: 2 },
|
||||||
|
{ player1Id: 3, player2Id: 4 },
|
||||||
|
{ player1Id: 1, player2Id: 2 }, // Duplicate
|
||||||
|
]);
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.error).toContain('Duplicate');
|
expect(result.error).toContain('Duplicate');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should accept valid team list', () => {
|
test('should accept valid team list', () => {
|
||||||
const result = validateScheduleInput([1, 2, 3, 4]);
|
const result = validateScheduleInput(createTeams(4));
|
||||||
expect(result.valid).toBe(true);
|
expect(result.valid).toBe(true);
|
||||||
expect(result.error).toBeUndefined();
|
expect(result.error).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.` +
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 1 1 John Smith Jane Doe 10 Mike Johnson Sarah Brown 5 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -284,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">
|
||||||
@@ -329,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">
|
||||||
|
|||||||
@@ -34,23 +34,15 @@ export default async function TournamentSchedulePage({ params }: PageProps) {
|
|||||||
const tournament = await prisma.event.findUnique({
|
const tournament = await prisma.event.findUnique({
|
||||||
where: { id: tournamentId },
|
where: { id: tournamentId },
|
||||||
include: {
|
include: {
|
||||||
teams: {
|
|
||||||
include: {
|
|
||||||
player1: true,
|
|
||||||
player2: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rounds: {
|
rounds: {
|
||||||
orderBy: { roundNumber: "asc" },
|
orderBy: { roundNumber: "asc" },
|
||||||
include: {
|
include: {
|
||||||
bracketMatchups: {
|
bracketMatchups: {
|
||||||
include: {
|
include: {
|
||||||
team1: {
|
player1P1: true,
|
||||||
include: { player1: true, player2: true },
|
player1P2: true,
|
||||||
},
|
player2P1: true,
|
||||||
team2: {
|
player2P2: true,
|
||||||
include: { player1: true, player2: true },
|
|
||||||
},
|
|
||||||
match: true,
|
match: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -94,7 +86,7 @@ export default async function TournamentSchedulePage({ params }: PageProps) {
|
|||||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Tournament Schedule</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Tournament Schedule</h1>
|
||||||
<p className="text-gray-500 mt-1">
|
<p className="text-gray-500 mt-1">
|
||||||
{tournament.name} — {tournament.teams.length} teams
|
{tournament.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -106,7 +98,7 @@ export default async function TournamentSchedulePage({ params }: PageProps) {
|
|||||||
</h2>
|
</h2>
|
||||||
<ScheduleGenerator
|
<ScheduleGenerator
|
||||||
tournamentId={tournamentId}
|
tournamentId={tournamentId}
|
||||||
teamCount={tournament.teams.length}
|
teamCount={0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -128,11 +120,11 @@ export default async function TournamentSchedulePage({ params }: PageProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{round.bracketMatchups.map((matchup) => {
|
{round.bracketMatchups.map((matchup) => {
|
||||||
const team1Name = matchup.team1
|
const team1Name = matchup.player1P1 && matchup.player1P2
|
||||||
? `${matchup.team1.player1.name} + ${matchup.team1.player2.name}`
|
? `${matchup.player1P1.name} + ${matchup.player1P2.name}`
|
||||||
: "TBD"
|
: "TBD"
|
||||||
const team2Name = matchup.team2
|
const team2Name = matchup.player2P1 && matchup.player2P2
|
||||||
? `${matchup.team2.player1.name} + ${matchup.team2.player2.name}`
|
? `${matchup.player2P1.name} + ${matchup.player2P2.name}`
|
||||||
: "TBD"
|
: "TBD"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -208,7 +200,7 @@ export default async function TournamentSchedulePage({ params }: PageProps) {
|
|||||||
</h2>
|
</h2>
|
||||||
<ScheduleGenerator
|
<ScheduleGenerator
|
||||||
tournamentId={tournamentId}
|
tournamentId={tournamentId}
|
||||||
teamCount={tournament.teams.length}
|
teamCount={0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 className="grid grid-cols-12 bg-gray-100 border-b border-gray-300">
|
||||||
<div
|
<div
|
||||||
key={player.id}
|
className="col-span-6 px-3 py-2 text-xs font-medium text-gray-600 cursor-pointer hover:bg-gray-200 flex items-center"
|
||||||
className="bg-green-50 border border-green-200 rounded-md px-3 py-2 flex justify-between 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!")
|
||||||
|
|||||||
@@ -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 },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { canManageTournament } from "@/lib/permissions";
|
import { canManageTournament } from "@/lib/permissions";
|
||||||
import { generateRoundRobin, validateScheduleInput } from "@/lib/schedule-generator";
|
import { generateRoundRobin, validateScheduleInput } from "@/lib/schedule-generator";
|
||||||
|
import { generateTeams, generateTeamsWithRotation, type Player, type Team as TeamPairing } from "@/lib/team-generator";
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -42,12 +43,10 @@ export async function GET(_request: Request, { params }: RouteParams) {
|
|||||||
include: {
|
include: {
|
||||||
bracketMatchups: {
|
bracketMatchups: {
|
||||||
include: {
|
include: {
|
||||||
team1: {
|
player1P1: true,
|
||||||
include: { player1: true, player2: true },
|
player1P2: true,
|
||||||
},
|
player2P1: true,
|
||||||
team2: {
|
player2P2: true,
|
||||||
include: { player1: true, player2: true },
|
|
||||||
},
|
|
||||||
match: true,
|
match: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -102,7 +101,11 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||||||
const tournament = await prisma.event.findUnique({
|
const tournament = await prisma.event.findUnique({
|
||||||
where: { id: tournamentId },
|
where: { id: tournamentId },
|
||||||
include: {
|
include: {
|
||||||
teams: true,
|
participants: {
|
||||||
|
include: {
|
||||||
|
player: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
rounds: true,
|
rounds: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -125,10 +128,48 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get team IDs
|
// Get participants as players
|
||||||
const teamIds = tournament.teams.map((t) => t.id);
|
const participants: Player[] = tournament.participants.map((p) => ({
|
||||||
|
id: p.player.id,
|
||||||
|
name: p.player.name,
|
||||||
|
currentElo: p.player.currentElo,
|
||||||
|
}));
|
||||||
|
|
||||||
const validation = validateScheduleInput(teamIds);
|
// 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) {
|
if (!validation.valid) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: validation.error },
|
{ error: validation.error },
|
||||||
@@ -137,7 +178,7 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate schedule
|
// Generate schedule
|
||||||
const schedule = generateRoundRobin(teamIds);
|
const schedule = generateRoundRobin(teamPairings);
|
||||||
|
|
||||||
// Create rounds and matchups in a transaction
|
// Create rounds and matchups in a transaction
|
||||||
const created = await prisma.$transaction(
|
const created = await prisma.$transaction(
|
||||||
@@ -150,8 +191,10 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||||||
bracketMatchups: {
|
bracketMatchups: {
|
||||||
create: round.matchups.map((matchup, idx) => ({
|
create: round.matchups.map((matchup, idx) => ({
|
||||||
eventId: tournamentId,
|
eventId: tournamentId,
|
||||||
team1Id: matchup.team1Id,
|
player1P1Id: matchup.player1P1Id,
|
||||||
team2Id: matchup.team2Id,
|
player1P2Id: matchup.player1P2Id,
|
||||||
|
player2P1Id: matchup.player2P1Id,
|
||||||
|
player2P2Id: matchup.player2P2Id,
|
||||||
bracketPosition: idx + 1,
|
bracketPosition: idx + 1,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
})),
|
})),
|
||||||
@@ -160,12 +203,10 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||||||
include: {
|
include: {
|
||||||
bracketMatchups: {
|
bracketMatchups: {
|
||||||
include: {
|
include: {
|
||||||
team1: {
|
player1P1: true,
|
||||||
include: { player1: true, player2: true },
|
player1P2: true,
|
||||||
},
|
player2P1: true,
|
||||||
team2: {
|
player2P2: true,
|
||||||
include: { player1: true, player2: true },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -25,14 +25,20 @@ export function ScheduleGenerator({ tournamentId, teamCount }: ScheduleGenerator
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await response.json()
|
// Check response status first before parsing JSON
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setError(data.error || "Failed to generate schedule")
|
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)
|
setIsGenerating(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
setResult({
|
setResult({
|
||||||
roundsCreated: data.roundsCreated,
|
roundsCreated: data.roundsCreated,
|
||||||
matchupsCreated: data.matchupsCreated,
|
matchupsCreated: data.matchupsCreated,
|
||||||
@@ -58,10 +64,13 @@ export function ScheduleGenerator({ tournamentId, teamCount }: ScheduleGenerator
|
|||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setError(data.error || "Failed to delete schedule")
|
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)
|
setIsGenerating(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export interface MatchupPairing {
|
export interface MatchupPairing {
|
||||||
team1Id: number
|
player1P1Id: number
|
||||||
team2Id: number
|
player1P2Id: number
|
||||||
|
player2P1Id: number
|
||||||
|
player2P2Id: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoundSchedule {
|
export interface RoundSchedule {
|
||||||
@@ -15,18 +17,22 @@ export interface RoundSchedule {
|
|||||||
* team exactly once. If N is odd, a "bye" is added internally so one
|
* 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).
|
* team sits out each round (the bye matchup is excluded from output).
|
||||||
*
|
*
|
||||||
* @param teamIds - Array of team IDs to schedule
|
* @param teamPairings - Array of player pairings (each with 2 player IDs)
|
||||||
* @returns Array of rounds, each containing matchup pairings
|
* @returns Array of rounds, each containing matchup pairings
|
||||||
*/
|
*/
|
||||||
export function generateRoundRobin(teamIds: number[]): RoundSchedule[] {
|
export function generateRoundRobin(
|
||||||
if (teamIds.length < 2) {
|
teamPairings: { player1Id: number; player2Id: number }[]
|
||||||
|
): RoundSchedule[] {
|
||||||
|
if (teamPairings.length < 2) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use circle method: fix first team, rotate the rest
|
// Use circle method: fix first team, rotate the rest
|
||||||
// If odd number of teams, add a sentinel for byes
|
// If odd number of teams, add a sentinel for byes
|
||||||
const hasOddTeams = teamIds.length % 2 !== 0
|
const hasOddTeams = teamPairings.length % 2 !== 0
|
||||||
const workingTeams = hasOddTeams ? [...teamIds, -1] : [...teamIds]
|
const workingTeams = hasOddTeams
|
||||||
|
? [...teamPairings, { player1Id: -1, player2Id: -1 }]
|
||||||
|
: [...teamPairings]
|
||||||
const n = workingTeams.length
|
const n = workingTeams.length
|
||||||
const numRounds = n - 1
|
const numRounds = n - 1
|
||||||
const matchupsPerRound = n / 2
|
const matchupsPerRound = n / 2
|
||||||
@@ -40,12 +46,17 @@ export function generateRoundRobin(teamIds: number[]): RoundSchedule[] {
|
|||||||
const team1Idx = i
|
const team1Idx = i
|
||||||
const team2Idx = n - 1 - i
|
const team2Idx = n - 1 - i
|
||||||
|
|
||||||
const team1Id = workingTeams[team1Idx]
|
const team1 = workingTeams[team1Idx]
|
||||||
const team2Id = workingTeams[team2Idx]
|
const team2 = workingTeams[team2Idx]
|
||||||
|
|
||||||
// Skip bye matchups (where either team is the sentinel -1)
|
// Skip bye matchups (where either team is the sentinel)
|
||||||
if (team1Id !== -1 && team2Id !== -1) {
|
if (team1.player1Id !== -1 && team2.player1Id !== -1) {
|
||||||
matchups.push({ team1Id, team2Id })
|
matchups.push({
|
||||||
|
player1P1Id: team1.player1Id,
|
||||||
|
player1P2Id: team1.player2Id,
|
||||||
|
player2P1Id: team2.player1Id,
|
||||||
|
player2P2Id: team2.player2Id,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,19 +72,25 @@ export function generateRoundRobin(teamIds: number[]): RoundSchedule[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that a set of team IDs can be scheduled.
|
* Validate that a set of player pairings can be scheduled.
|
||||||
*/
|
*/
|
||||||
export function validateScheduleInput(teamIds: number[]): {
|
export function validateScheduleInput(
|
||||||
|
teamPairings: { player1Id: number; player2Id: number }[]
|
||||||
|
): {
|
||||||
valid: boolean
|
valid: boolean
|
||||||
error?: string
|
error?: string
|
||||||
} {
|
} {
|
||||||
if (teamIds.length < 2) {
|
if (teamPairings.length < 2) {
|
||||||
return { valid: false, error: "At least 2 teams are required to generate a schedule" }
|
return { valid: false, error: "At least 2 teams are required to generate a schedule" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueIds = new Set(teamIds)
|
// Check for duplicate teams
|
||||||
if (uniqueIds.size !== teamIds.length) {
|
const teamKeys = teamPairings.map(
|
||||||
return { valid: false, error: "Duplicate team IDs found" }
|
(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 }
|
return { valid: true }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user