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
+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 { getSession } from "@/lib/auth-simple"
import type { Event as EventModel } from "@prisma/client"
import { RecalculateEloButton } from "../../components/RecalculateEloButton"
export default async function AdminDashboard() {
console.log('AdminDashboard rendering...')
@@ -169,6 +170,7 @@ export default async function AdminDashboard() {
</svg>
All Tournaments
</Link>
<RecalculateEloButton />
</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
*
@@ -150,3 +152,232 @@ export function calculateTeamEloChange(
): number {
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' }
});
}
/**
* 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');
}