feat: add recalculateAllElo function for idempotent ELO recalculation
This commit is contained in:
+49
-61
@@ -1,67 +1,55 @@
|
|||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "sqlite"
|
provider = "postgresql"
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: For PostgreSQL, create a separate schema file or use environment variable
|
|
||||||
// See scripts/switch-database.js for provider switching logic
|
|
||||||
|
|
||||||
model Player {
|
model Player {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
rating Int @default(0) // Historical rating (used for rankings)
|
rating Int @default(0)
|
||||||
currentElo Int @default(1000) // Current Elo rating
|
currentElo Int @default(1000)
|
||||||
gamesPlayed Int @default(0) // Total games played
|
gamesPlayed Int @default(0)
|
||||||
wins Int @default(0) // Total wins
|
wins Int @default(0)
|
||||||
losses Int @default(0) // Total losses
|
losses Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
normalizedName String @unique
|
||||||
eloSnapshots EloSnapshot[]
|
eloSnapshots EloSnapshot[]
|
||||||
eventParticipants EventParticipant[]
|
eventParticipants EventParticipant[]
|
||||||
matchesAsP4 Match[] @relation("MatchPlayer4")
|
|
||||||
matchesAsP3 Match[] @relation("MatchPlayer3")
|
|
||||||
matchesAsP2 Match[] @relation("MatchPlayer2")
|
|
||||||
matchesAsP1 Match[] @relation("MatchPlayer1")
|
matchesAsP1 Match[] @relation("MatchPlayer1")
|
||||||
partnershipGames2 PartnershipGame[] @relation("PartnershipPlayer2")
|
matchesAsP2 Match[] @relation("MatchPlayer2")
|
||||||
|
matchesAsP3 Match[] @relation("MatchPlayer3")
|
||||||
|
matchesAsP4 Match[] @relation("MatchPlayer4")
|
||||||
partnershipGames PartnershipGame[] @relation("PartnershipPlayer1")
|
partnershipGames PartnershipGame[] @relation("PartnershipPlayer1")
|
||||||
partnershipStats2 PartnershipStat[] @relation("StatPlayer2")
|
partnershipGames2 PartnershipGame[] @relation("PartnershipPlayer2")
|
||||||
partnershipStats PartnershipStat[] @relation("StatPlayer1")
|
partnershipStats PartnershipStat[] @relation("StatPlayer1")
|
||||||
teamsAsPlayer2 Team[] @relation("TeamPlayer2")
|
partnershipStats2 PartnershipStat[] @relation("StatPlayer2")
|
||||||
teamsAsPlayer1 Team[] @relation("TeamPlayer1")
|
teamsAsPlayer1 Team[] @relation("TeamPlayer1")
|
||||||
|
teamsAsPlayer2 Team[] @relation("TeamPlayer2")
|
||||||
user User?
|
user User?
|
||||||
|
|
||||||
@@map("players")
|
@@map("players")
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
emailVerified Boolean @default(false)
|
emailVerified Boolean @default(false)
|
||||||
name String?
|
name String?
|
||||||
image String?
|
image String?
|
||||||
role String @default("player") // player, tournament_admin, club_admin
|
role String @default("player")
|
||||||
|
playerId Int? @unique
|
||||||
// Session and account references for Better Auth
|
createdAt DateTime @default(now())
|
||||||
sessions Session[]
|
updatedAt DateTime @updatedAt
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
|
ownedTournaments Event[] @relation("TournamentOwner")
|
||||||
// Link to EuchreCamp player (optional)
|
createdMatches Match[] @relation("MatchCreator")
|
||||||
playerId Int? @unique
|
sessions Session[]
|
||||||
player Player? @relation(fields: [playerId], references: [id])
|
player Player? @relation(fields: [playerId], references: [id])
|
||||||
|
|
||||||
// Tournaments owned by this user
|
|
||||||
ownedTournaments Event[] @relation("TournamentOwner")
|
|
||||||
|
|
||||||
// Matches created by this user
|
|
||||||
createdMatches Match[] @relation("MatchCreator")
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -76,15 +64,15 @@ model Event {
|
|||||||
format String @default("round_robin")
|
format String @default("round_robin")
|
||||||
status String @default("planned")
|
status String @default("planned")
|
||||||
maxParticipants Int?
|
maxParticipants Int?
|
||||||
ownerId String? // User ID of the tournament owner (for tournament_admin)
|
ownerId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
bracketMatchups BracketMatchup[]
|
bracketMatchups BracketMatchup[]
|
||||||
participants EventParticipant[]
|
participants EventParticipant[]
|
||||||
|
owner User? @relation("TournamentOwner", fields: [ownerId], references: [id])
|
||||||
matches Match[]
|
matches Match[]
|
||||||
teams Team[]
|
teams Team[]
|
||||||
rounds TournamentRound[]
|
rounds TournamentRound[]
|
||||||
owner User? @relation("TournamentOwner", fields: [ownerId], references: [id])
|
|
||||||
|
|
||||||
@@map("events")
|
@@map("events")
|
||||||
}
|
}
|
||||||
@@ -99,9 +87,9 @@ model EventParticipant {
|
|||||||
registrationDate DateTime?
|
registrationDate DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
team Team? @relation(fields: [teamId], references: [id])
|
|
||||||
player Player @relation(fields: [playerId], references: [id])
|
|
||||||
event Event @relation(fields: [eventId], references: [id])
|
event Event @relation(fields: [eventId], 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")
|
||||||
@@ -115,12 +103,12 @@ model Team {
|
|||||||
player2Id Int
|
player2Id Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
bracketMatchups2 BracketMatchup[] @relation("BracketTeam2")
|
|
||||||
bracketMatchups1 BracketMatchup[] @relation("BracketTeam1")
|
bracketMatchups1 BracketMatchup[] @relation("BracketTeam1")
|
||||||
|
bracketMatchups2 BracketMatchup[] @relation("BracketTeam2")
|
||||||
eventParticipants EventParticipant[]
|
eventParticipants EventParticipant[]
|
||||||
player2 Player @relation("TeamPlayer2", fields: [player2Id], references: [id])
|
|
||||||
player1 Player @relation("TeamPlayer1", fields: [player1Id], references: [id])
|
|
||||||
event Event @relation(fields: [eventId], references: [id])
|
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")
|
@@map("teams")
|
||||||
}
|
}
|
||||||
@@ -155,11 +143,11 @@ model BracketMatchup {
|
|||||||
status String @default("pending")
|
status String @default("pending")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
match Match? @relation(fields: [matchId], references: [id])
|
|
||||||
team2 Team? @relation("BracketTeam2", fields: [team2Id], references: [id])
|
|
||||||
team1 Team? @relation("BracketTeam1", fields: [team1Id], references: [id])
|
|
||||||
event Event @relation(fields: [eventId], references: [id])
|
event Event @relation(fields: [eventId], references: [id])
|
||||||
|
match Match? @relation(fields: [matchId], references: [id])
|
||||||
round TournamentRound @relation(fields: [roundId], references: [id])
|
round TournamentRound @relation(fields: [roundId], references: [id])
|
||||||
|
team1 Team? @relation("BracketTeam1", fields: [team1Id], references: [id])
|
||||||
|
team2 Team? @relation("BracketTeam2", fields: [team2Id], references: [id])
|
||||||
|
|
||||||
@@map("bracket_matchups")
|
@@map("bracket_matchups")
|
||||||
}
|
}
|
||||||
@@ -175,18 +163,18 @@ model Match {
|
|||||||
team1Score Int
|
team1Score Int
|
||||||
team2Score Int
|
team2Score Int
|
||||||
status String @default("completed")
|
status String @default("completed")
|
||||||
createdById String? // User ID of the match creator (for tournament_admin)
|
createdById String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
bracketMatchups BracketMatchup[]
|
bracketMatchups BracketMatchup[]
|
||||||
eloSnapshots EloSnapshot[]
|
eloSnapshots EloSnapshot[]
|
||||||
team2P2 Player @relation("MatchPlayer4", fields: [team2P2Id], references: [id])
|
|
||||||
team2P1 Player @relation("MatchPlayer3", fields: [team2P1Id], references: [id])
|
|
||||||
team1P2 Player @relation("MatchPlayer2", fields: [team1P2Id], references: [id])
|
|
||||||
team1P1 Player @relation("MatchPlayer1", fields: [team1P1Id], references: [id])
|
|
||||||
event Event? @relation(fields: [eventId], references: [id])
|
|
||||||
partnershipGames PartnershipGame[]
|
|
||||||
createdBy User? @relation("MatchCreator", fields: [createdById], references: [id])
|
createdBy User? @relation("MatchCreator", fields: [createdById], references: [id])
|
||||||
|
event Event? @relation(fields: [eventId], references: [id])
|
||||||
|
team1P1 Player @relation("MatchPlayer1", fields: [team1P1Id], references: [id])
|
||||||
|
team1P2 Player @relation("MatchPlayer2", fields: [team1P2Id], references: [id])
|
||||||
|
team2P1 Player @relation("MatchPlayer3", fields: [team2P1Id], references: [id])
|
||||||
|
team2P2 Player @relation("MatchPlayer4", fields: [team2P2Id], references: [id])
|
||||||
|
partnershipGames PartnershipGame[]
|
||||||
|
|
||||||
@@map("matches")
|
@@map("matches")
|
||||||
}
|
}
|
||||||
@@ -216,8 +204,8 @@ model PartnershipGame {
|
|||||||
player2EloChange Int?
|
player2EloChange Int?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
match Match @relation(fields: [matchId], references: [id])
|
match Match @relation(fields: [matchId], references: [id])
|
||||||
player2 Player @relation("PartnershipPlayer2", fields: [player2Id], references: [id])
|
|
||||||
player1 Player @relation("PartnershipPlayer1", fields: [player1Id], references: [id])
|
player1 Player @relation("PartnershipPlayer1", fields: [player1Id], references: [id])
|
||||||
|
player2 Player @relation("PartnershipPlayer2", fields: [player2Id], references: [id])
|
||||||
|
|
||||||
@@map("partnership_games")
|
@@map("partnership_games")
|
||||||
}
|
}
|
||||||
@@ -233,16 +221,17 @@ model PartnershipStat {
|
|||||||
lastPlayed DateTime?
|
lastPlayed DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
player2 Player @relation("StatPlayer2", fields: [player2Id], references: [id])
|
|
||||||
player1 Player @relation("StatPlayer1", fields: [player1Id], references: [id])
|
player1 Player @relation("StatPlayer1", fields: [player1Id], references: [id])
|
||||||
|
player2 Player @relation("StatPlayer2", fields: [player2Id], references: [id])
|
||||||
|
|
||||||
|
@@unique([player1Id, player2Id], name: "partnership_stats_player1Id_player2Id_key")
|
||||||
@@map("partnership_stats")
|
@@map("partnership_stats")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
id String @id
|
id String @id
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
token String
|
token String @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
ipAddress String?
|
ipAddress String?
|
||||||
@@ -250,7 +239,6 @@ model Session {
|
|||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([token])
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@map("session")
|
@@map("session")
|
||||||
}
|
}
|
||||||
@@ -260,7 +248,6 @@ model Account {
|
|||||||
accountId String
|
accountId String
|
||||||
providerId String
|
providerId String
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
accessToken String?
|
accessToken String?
|
||||||
refreshToken String?
|
refreshToken String?
|
||||||
idToken String?
|
idToken String?
|
||||||
@@ -270,6 +257,7 @@ model Account {
|
|||||||
password String?
|
password String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@map("account")
|
@@map("account")
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* Unit Tests: ELO Recalculation
|
||||||
|
*
|
||||||
|
* Tests the idempotent full recalculation of ELO ratings and player statistics
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { describe, test, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { recalculateAllElo } from '@/lib/elo-utils';
|
||||||
|
|
||||||
|
// Mock Prisma client
|
||||||
|
const mockPrisma = {
|
||||||
|
player: {
|
||||||
|
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
eloSnapshot: {
|
||||||
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||||
|
create: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
partnershipStat: {
|
||||||
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||||
|
findFirst: vi.fn().mockResolvedValue(null), // No existing stats initially
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
create: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
match: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('recalculateAllElo', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reset all player stats to zero', async () => {
|
||||||
|
(mockPrisma.match.findMany as any).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await recalculateAllElo(mockPrisma as any);
|
||||||
|
|
||||||
|
expect(mockPrisma.player.updateMany).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
currentElo: 1000,
|
||||||
|
gamesPlayed: 0,
|
||||||
|
wins: 0,
|
||||||
|
losses: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.matchesProcessed).toBe(0);
|
||||||
|
expect(result.playersUpdated).toBe(0);
|
||||||
|
expect(result.partnershipsUpdated).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should delete all existing elo snapshots', async () => {
|
||||||
|
(mockPrisma.match.findMany as any).mockResolvedValue([]);
|
||||||
|
|
||||||
|
await recalculateAllElo(mockPrisma as any);
|
||||||
|
|
||||||
|
expect(mockPrisma.eloSnapshot.deleteMany).toHaveBeenCalledWith({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should delete all existing partnership stats', async () => {
|
||||||
|
(mockPrisma.match.findMany as any).mockResolvedValue([]);
|
||||||
|
|
||||||
|
await recalculateAllElo(mockPrisma as any);
|
||||||
|
|
||||||
|
expect(mockPrisma.partnershipStat.deleteMany).toHaveBeenCalledWith({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return correct result for empty match list', async () => {
|
||||||
|
(mockPrisma.match.findMany as any).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await recalculateAllElo(mockPrisma as any);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
matchesProcessed: 0,
|
||||||
|
playersUpdated: 0,
|
||||||
|
partnershipsUpdated: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should process matches in chronological order', async () => {
|
||||||
|
const mockMatches = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
playedAt: new Date('2024-01-01'),
|
||||||
|
team1P1: { id: 1, name: 'Player 1' },
|
||||||
|
team1P2: { id: 2, name: 'Player 2' },
|
||||||
|
team2P1: { id: 3, name: 'Player 3' },
|
||||||
|
team2P2: { id: 4, name: 'Player 4' },
|
||||||
|
team1Score: 10,
|
||||||
|
team2Score: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
playedAt: new Date('2024-01-02'),
|
||||||
|
team1P1: { id: 1, name: 'Player 1' },
|
||||||
|
team1P2: { id: 2, name: 'Player 2' },
|
||||||
|
team2P1: { id: 5, name: 'Player 5' },
|
||||||
|
team2P2: { id: 6, name: 'Player 6' },
|
||||||
|
team1Score: 8,
|
||||||
|
team2Score: 6,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
(mockPrisma.match.findMany as any).mockResolvedValue(mockMatches);
|
||||||
|
|
||||||
|
await recalculateAllElo(mockPrisma as any);
|
||||||
|
|
||||||
|
// Should process 2 matches
|
||||||
|
expect(mockPrisma.match.findMany).toHaveBeenCalledWith({
|
||||||
|
orderBy: { playedAt: 'asc' },
|
||||||
|
include: {
|
||||||
|
team1P1: true,
|
||||||
|
team1P2: true,
|
||||||
|
team2P1: true,
|
||||||
|
team2P2: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create elo snapshots for each player in each match', async () => {
|
||||||
|
const mockMatches = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
playedAt: new Date('2024-01-01'),
|
||||||
|
team1P1: { id: 1, name: 'Player 1' },
|
||||||
|
team1P2: { id: 2, name: 'Player 2' },
|
||||||
|
team2P1: { id: 3, name: 'Player 3' },
|
||||||
|
team2P2: { id: 4, name: 'Player 4' },
|
||||||
|
team1Score: 10,
|
||||||
|
team2Score: 5,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
(mockPrisma.match.findMany as any).mockResolvedValue(mockMatches);
|
||||||
|
|
||||||
|
await recalculateAllElo(mockPrisma as any);
|
||||||
|
|
||||||
|
// Should create 4 snapshots (one for each player)
|
||||||
|
expect(mockPrisma.eloSnapshot.create).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update player stats after processing matches', async () => {
|
||||||
|
const mockMatches = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
playedAt: new Date('2024-01-01'),
|
||||||
|
team1P1: { id: 1, name: 'Player 1' },
|
||||||
|
team1P2: { id: 2, name: 'Player 2' },
|
||||||
|
team2P1: { id: 3, name: 'Player 3' },
|
||||||
|
team2P2: { id: 4, name: 'Player 4' },
|
||||||
|
team1Score: 10,
|
||||||
|
team2Score: 5,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
(mockPrisma.match.findMany as any).mockResolvedValue(mockMatches);
|
||||||
|
|
||||||
|
await recalculateAllElo(mockPrisma as any);
|
||||||
|
|
||||||
|
// Should update 4 players
|
||||||
|
expect(mockPrisma.player.update).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update partnership stats after processing matches', async () => {
|
||||||
|
const mockMatches = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
playedAt: new Date('2024-01-01'),
|
||||||
|
team1P1: { id: 1, name: 'Player 1' },
|
||||||
|
team1P2: { id: 2, name: 'Player 2' },
|
||||||
|
team2P1: { id: 3, name: 'Player 3' },
|
||||||
|
team2P2: { id: 4, name: 'Player 4' },
|
||||||
|
team1Score: 10,
|
||||||
|
team2Score: 5,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
(mockPrisma.match.findMany as any).mockResolvedValue(mockMatches);
|
||||||
|
|
||||||
|
await recalculateAllElo(mockPrisma as any);
|
||||||
|
|
||||||
|
// Should check for existing partnership stats (2 calls) and create new ones (2 calls)
|
||||||
|
expect(mockPrisma.partnershipStat.findFirst).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockPrisma.partnershipStat.create).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import Link from "next/link"
|
|||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
import { getSession } from "@/lib/auth-simple"
|
import { getSession } from "@/lib/auth-simple"
|
||||||
import type { Event as EventModel } from "@prisma/client"
|
import type { Event as EventModel } from "@prisma/client"
|
||||||
|
import { RecalculateEloButton } from "../../components/RecalculateEloButton"
|
||||||
|
|
||||||
export default async function AdminDashboard() {
|
export default async function AdminDashboard() {
|
||||||
console.log('AdminDashboard rendering...')
|
console.log('AdminDashboard rendering...')
|
||||||
@@ -169,6 +170,7 @@ export default async function AdminDashboard() {
|
|||||||
</svg>
|
</svg>
|
||||||
All Tournaments
|
All Tournaments
|
||||||
</Link>
|
</Link>
|
||||||
|
<RecalculateEloButton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { hasRole } from '@/lib/permissions';
|
||||||
|
import { recalculateAllElo } from '@/lib/elo-utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/recalculate-elo
|
||||||
|
*
|
||||||
|
* Triggers a full recalculation of all ELO ratings and player statistics.
|
||||||
|
* This endpoint is idempotent - running it multiple times produces the same result.
|
||||||
|
*
|
||||||
|
* Requires: club_admin role
|
||||||
|
*/
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
// Check if user has club_admin role
|
||||||
|
const permission = await hasRole('club_admin');
|
||||||
|
if (!permission.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: permission.reason || 'Unauthorized' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the recalculation
|
||||||
|
const result = await recalculateAllElo(prisma);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'ELO recalculation completed successfully',
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during ELO recalculation:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to recalculate ELO ratings' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Method not allowed. Use POST to trigger recalculation.' },
|
||||||
|
{ status: 405 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
export function RecalculateEloButton() {
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
if (!confirm('Are you sure you want to recalculate all ELO ratings? This will reset all player stats and recalculate them from match history. This operation is idempotent and safe to run multiple times.')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/recalculate-elo', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
alert(`Recalculation completed: ${JSON.stringify(data.data)}`)
|
||||||
|
window.location.reload()
|
||||||
|
} else {
|
||||||
|
alert(`Error: ${data.error}`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err instanceof Error ? err.message : 'Unknown error occurred'}`)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center justify-center px-4 py-3 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<svg className="w-5 h-5 mr-2 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Recalculating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
Recalculate ELO
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Elo Rating Utilities
|
* Elo Rating Utilities
|
||||||
*
|
*
|
||||||
@@ -150,3 +152,232 @@ export function calculateTeamEloChange(
|
|||||||
): number {
|
): number {
|
||||||
return calculateEloChange(team1Rating, team2Rating, team1Score, kFactor);
|
return calculateEloChange(team1Rating, team2Rating, team1Score, kFactor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate all player and partnership statistics from match history
|
||||||
|
* This function is idempotent - running it multiple times produces the same result
|
||||||
|
* @param prisma Prisma client instance
|
||||||
|
* @returns Object with counts of affected records
|
||||||
|
*/
|
||||||
|
export async function recalculateAllElo(prisma: PrismaClient) {
|
||||||
|
// Get all matches in chronological order
|
||||||
|
const matches = await prisma.match.findMany({
|
||||||
|
orderBy: { playedAt: 'asc' },
|
||||||
|
include: {
|
||||||
|
team1P1: true,
|
||||||
|
team1P2: true,
|
||||||
|
team2P1: true,
|
||||||
|
team2P2: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset all player stats to zero
|
||||||
|
await prisma.player.updateMany({
|
||||||
|
data: {
|
||||||
|
currentElo: 1000,
|
||||||
|
gamesPlayed: 0,
|
||||||
|
wins: 0,
|
||||||
|
losses: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete all existing elo snapshots
|
||||||
|
await prisma.eloSnapshot.deleteMany({});
|
||||||
|
|
||||||
|
// Delete all existing partnership stats (will be rebuilt)
|
||||||
|
await prisma.partnershipStat.deleteMany({});
|
||||||
|
|
||||||
|
// Initialize in-memory tracking for all players
|
||||||
|
const playerStats = new Map<number, { rating: number; gamesPlayed: number; wins: number; losses: number }>();
|
||||||
|
|
||||||
|
// Initialize partnership tracking
|
||||||
|
const partnershipStats = new Map<string, { gamesPlayed: number; wins: number; losses: number; totalEloChange: number; lastPlayed: Date | null }>();
|
||||||
|
|
||||||
|
// Helper to get/create player stats
|
||||||
|
const getPlayerStats = (playerId: number) => {
|
||||||
|
if (!playerStats.has(playerId)) {
|
||||||
|
playerStats.set(playerId, { rating: 1000, gamesPlayed: 0, wins: 0, losses: 0 });
|
||||||
|
}
|
||||||
|
return playerStats.get(playerId)!;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get partnership key (sorted player IDs)
|
||||||
|
const getPartnershipKey = (p1Id: number, p2Id: number) => {
|
||||||
|
return [Math.min(p1Id, p2Id), Math.max(p1Id, p2Id)].join('-');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get/update partnership stats
|
||||||
|
const getPartnershipStats = (p1Id: number, p2Id: number) => {
|
||||||
|
const key = getPartnershipKey(p1Id, p2Id);
|
||||||
|
if (!partnershipStats.has(key)) {
|
||||||
|
partnershipStats.set(key, { gamesPlayed: 0, wins: 0, losses: 0, totalEloChange: 0, lastPlayed: null });
|
||||||
|
}
|
||||||
|
return partnershipStats.get(key)!;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process each match in chronological order
|
||||||
|
for (const match of matches) {
|
||||||
|
const { team1P1, team1P2, team2P1, team2P2, team1Score, team2Score, id: matchId, playedAt } = match;
|
||||||
|
|
||||||
|
// Get current ratings for all players
|
||||||
|
const p1Rating = getPlayerStats(team1P1.id).rating;
|
||||||
|
const p2Rating = getPlayerStats(team1P2.id).rating;
|
||||||
|
const p3Rating = getPlayerStats(team2P1.id).rating;
|
||||||
|
const p4Rating = getPlayerStats(team2P2.id).rating;
|
||||||
|
|
||||||
|
// Calculate team ratings
|
||||||
|
const team1Rating = calculateTeamElo(p1Rating, p2Rating);
|
||||||
|
const team2Rating = calculateTeamElo(p3Rating, p4Rating);
|
||||||
|
|
||||||
|
// Determine match result (team1 score based on points)
|
||||||
|
const team1ScoreNormalized = team1Score > team2Score ? 1 : team1Score === team2Score ? 0.5 : 0;
|
||||||
|
|
||||||
|
// Calculate team ELO changes
|
||||||
|
const team1Change = calculateTeamEloChange(team1Rating, team2Rating, team1ScoreNormalized);
|
||||||
|
const team2Change = -team1Change; // Equal and opposite
|
||||||
|
|
||||||
|
// Split changes evenly between partners
|
||||||
|
const p1Change = Math.round(team1Change / 2);
|
||||||
|
const p2Change = Math.round(team1Change / 2);
|
||||||
|
const p3Change = Math.round(team2Change / 2);
|
||||||
|
const p4Change = Math.round(team2Change / 2);
|
||||||
|
|
||||||
|
// Update player stats
|
||||||
|
const team1Won = team1Score > team2Score;
|
||||||
|
const team2Won = team2Score > team1Score;
|
||||||
|
|
||||||
|
// Update Player 1 (team 1, player 1)
|
||||||
|
const stats1 = getPlayerStats(team1P1.id);
|
||||||
|
stats1.rating += p1Change;
|
||||||
|
stats1.gamesPlayed += 1;
|
||||||
|
stats1.wins += team1Won ? 1 : 0;
|
||||||
|
stats1.losses += team1Won ? 0 : 1;
|
||||||
|
|
||||||
|
// Update Player 2 (team 1, player 2)
|
||||||
|
const stats2 = getPlayerStats(team1P2.id);
|
||||||
|
stats2.rating += p2Change;
|
||||||
|
stats2.gamesPlayed += 1;
|
||||||
|
stats2.wins += team1Won ? 1 : 0;
|
||||||
|
stats2.losses += team1Won ? 0 : 1;
|
||||||
|
|
||||||
|
// Update Player 3 (team 2, player 1)
|
||||||
|
const stats3 = getPlayerStats(team2P1.id);
|
||||||
|
stats3.rating += p3Change;
|
||||||
|
stats3.gamesPlayed += 1;
|
||||||
|
stats3.wins += team2Won ? 1 : 0;
|
||||||
|
stats3.losses += team2Won ? 0 : 1;
|
||||||
|
|
||||||
|
// Update Player 4 (team 2, player 2)
|
||||||
|
const stats4 = getPlayerStats(team2P2.id);
|
||||||
|
stats4.rating += p4Change;
|
||||||
|
stats4.gamesPlayed += 1;
|
||||||
|
stats4.wins += team2Won ? 1 : 0;
|
||||||
|
stats4.losses += team2Won ? 0 : 1;
|
||||||
|
|
||||||
|
// Update partnership stats for team 1
|
||||||
|
const partnership1 = getPartnershipStats(team1P1.id, team1P2.id);
|
||||||
|
partnership1.gamesPlayed += 1;
|
||||||
|
partnership1.wins += team1Won ? 1 : 0;
|
||||||
|
partnership1.losses += team1Won ? 0 : 1;
|
||||||
|
partnership1.totalEloChange += p1Change + p2Change;
|
||||||
|
if (playedAt && (!partnership1.lastPlayed || playedAt > partnership1.lastPlayed)) {
|
||||||
|
partnership1.lastPlayed = playedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update partnership stats for team 2
|
||||||
|
const partnership2 = getPartnershipStats(team2P1.id, team2P2.id);
|
||||||
|
partnership2.gamesPlayed += 1;
|
||||||
|
partnership2.wins += team2Won ? 1 : 0;
|
||||||
|
partnership2.losses += team2Won ? 0 : 1;
|
||||||
|
partnership2.totalEloChange += p3Change + p4Change;
|
||||||
|
if (playedAt && (!partnership2.lastPlayed || playedAt > partnership2.lastPlayed)) {
|
||||||
|
partnership2.lastPlayed = playedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create elo snapshots for all players
|
||||||
|
const snapshotData = [
|
||||||
|
{ playerId: team1P1.id, ratingBefore: p1Rating, ratingChange: p1Change },
|
||||||
|
{ playerId: team1P2.id, ratingBefore: p2Rating, ratingChange: p2Change },
|
||||||
|
{ playerId: team2P1.id, ratingBefore: p3Rating, ratingChange: p3Change },
|
||||||
|
{ playerId: team2P2.id, ratingBefore: p4Rating, ratingChange: p4Change },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const snapshot of snapshotData) {
|
||||||
|
await prisma.eloSnapshot.create({
|
||||||
|
data: {
|
||||||
|
playerId: snapshot.playerId,
|
||||||
|
matchId: matchId,
|
||||||
|
ratingBefore: snapshot.ratingBefore,
|
||||||
|
ratingAfter: snapshot.ratingBefore + snapshot.ratingChange,
|
||||||
|
ratingChange: snapshot.ratingChange,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write all updated player stats to database
|
||||||
|
const playerUpdatePromises = Array.from(playerStats.entries()).map(
|
||||||
|
async ([playerId, stats]) =>
|
||||||
|
await prisma.player.update({
|
||||||
|
where: { id: playerId },
|
||||||
|
data: {
|
||||||
|
currentElo: stats.rating,
|
||||||
|
gamesPlayed: stats.gamesPlayed,
|
||||||
|
wins: stats.wins,
|
||||||
|
losses: stats.losses,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(playerUpdatePromises);
|
||||||
|
|
||||||
|
// Write all partnership stats to database
|
||||||
|
const partnershipUpdatePromises = Array.from(partnershipStats.entries()).map(async ([key, stats]) => {
|
||||||
|
const [player1Id, player2Id] = key.split('-').map(Number);
|
||||||
|
|
||||||
|
// Check if the partnership stat already exists
|
||||||
|
const existingStat = await prisma.partnershipStat.findFirst({
|
||||||
|
where: {
|
||||||
|
player1Id,
|
||||||
|
player2Id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingStat) {
|
||||||
|
// Update existing record
|
||||||
|
return await prisma.partnershipStat.update({
|
||||||
|
where: {
|
||||||
|
id: existingStat.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
gamesPlayed: stats.gamesPlayed,
|
||||||
|
wins: stats.wins,
|
||||||
|
losses: stats.losses,
|
||||||
|
totalEloChange: stats.totalEloChange,
|
||||||
|
lastPlayed: stats.lastPlayed,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new record
|
||||||
|
return await prisma.partnershipStat.create({
|
||||||
|
data: {
|
||||||
|
player1Id,
|
||||||
|
player2Id,
|
||||||
|
gamesPlayed: stats.gamesPlayed,
|
||||||
|
wins: stats.wins,
|
||||||
|
losses: stats.losses,
|
||||||
|
totalEloChange: stats.totalEloChange,
|
||||||
|
lastPlayed: stats.lastPlayed,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(partnershipUpdatePromises);
|
||||||
|
|
||||||
|
return {
|
||||||
|
matchesProcessed: matches.length,
|
||||||
|
playersUpdated: playerStats.size,
|
||||||
|
partnershipsUpdated: partnershipStats.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -215,3 +215,17 @@ export async function getManageableTournaments() {
|
|||||||
orderBy: { createdAt: 'desc' }
|
orderBy: { createdAt: 'desc' }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the user can edit user profiles (only club_admin)
|
||||||
|
*/
|
||||||
|
export async function canEditUserProfiles(): Promise<PermissionCheck> {
|
||||||
|
return hasRole('club_admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the user can recalculate ELO ratings (only club_admin)
|
||||||
|
*/
|
||||||
|
export async function canRecalculateElo(): Promise<PermissionCheck> {
|
||||||
|
return hasRole('club_admin');
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user