feat: add recalculateAllElo function for idempotent ELO recalculation

This commit is contained in:
2026-03-30 21:28:09 -07:00
parent 04a42c903b
commit 3a78f354ad
7 changed files with 594 additions and 61 deletions
+41 -53
View File
@@ -1,38 +1,35 @@
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")
@@ -44,24 +41,15 @@ model User {
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")
// Session and account references for Better Auth
sessions Session[]
accounts Account[]
// Link to EuchreCamp player (optional)
playerId Int? @unique playerId Int? @unique
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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
accounts Account[]
ownedTournaments Event[] @relation("TournamentOwner")
createdMatches Match[] @relation("MatchCreator")
sessions Session[]
player Player? @relation(fields: [playerId], references: [id])
@@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")
+190
View File
@@ -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);
});
});
+2
View File
@@ -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 }
);
}
+61
View File
@@ -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>
)
}
+231
View File
@@ -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,
};
}
+14
View File
@@ -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');
}