feat: add player merge functionality with automatic Elo recalculation

This commit is contained in:
2026-03-30 22:04:31 -07:00
parent 6001eda9aa
commit fa7a422ce3
2 changed files with 415 additions and 55 deletions
+152 -55
View File
@@ -23,6 +23,11 @@ export default function AdminPlayersPage() {
const [newName, setNewName] = useState("") const [newName, setNewName] = useState("")
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
// Merge player state
const [mergeMode, setMergeMode] = useState(false)
const [selectedPlayers, setSelectedPlayers] = useState<number[]>([])
const [isMerging, setIsMerging] = useState(false)
useEffect(() => { useEffect(() => {
fetchPlayers() fetchPlayers()
}, []) }, [])
@@ -76,6 +81,47 @@ export default function AdminPlayersPage() {
} }
} }
const togglePlayerSelection = (playerId: number) => {
setSelectedPlayers(prev =>
prev.includes(playerId)
? prev.filter(id => id !== playerId)
: [...prev, playerId]
)
}
const handleMerge = async () => {
if (selectedPlayers.length < 2) return
setIsMerging(true)
try {
const response = await fetch('/api/admin/players/merge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
playerIds: selectedPlayers,
}),
})
const data = await response.json()
if (data.success) {
alert(`Successfully merged ${selectedPlayers.length} players! Elo ratings have been recalculated.`)
// Refresh the player list
fetchPlayers()
setSelectedPlayers([])
setMergeMode(false)
} else {
alert(`Error: ${data.error}`)
}
} catch (err: unknown) {
alert(`Error: ${err instanceof Error ? err.message : 'Unknown error occurred'}`)
} finally {
setIsMerging(false)
}
}
const playerCount = players.length const playerCount = players.length
return ( return (
@@ -92,8 +138,37 @@ export default function AdminPlayersPage() {
Manage {playerCount} player{playerCount !== 1 ? 's' : ''} in the system Manage {playerCount} player{playerCount !== 1 ? 's' : ''} in the system
</p> </p>
</div> </div>
<div className="flex space-x-2">
<button
onClick={() => setMergeMode(!mergeMode)}
className={`px-4 py-2 rounded-md shadow-sm text-sm font-medium ${
mergeMode
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{mergeMode ? 'Cancel Merge' : 'Merge Players'}
</button>
</div>
</div> </div>
{mergeMode && selectedPlayers.length > 0 && (
<div className="mb-4 p-4 bg-purple-50 rounded-lg">
<div className="flex items-center justify-between">
<span className="text-purple-700">
{selectedPlayers.length} player{selectedPlayers.length !== 1 ? 's' : ''} selected for merging
</span>
<button
onClick={handleMerge}
disabled={isMerging || selectedPlayers.length < 2}
className="px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700 disabled:opacity-50"
>
{isMerging ? 'Merging...' : `Merge ${selectedPlayers.length} Players`}
</button>
</div>
</div>
)}
{error && ( {error && (
<div className="mb-4 p-4 bg-red-100 text-red-700 rounded"> <div className="mb-4 p-4 bg-red-100 text-red-700 rounded">
{error} {error}
@@ -129,63 +204,85 @@ export default function AdminPlayersPage() {
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
{players.map((player) => ( {players.map((player) => {
<tr key={player.id} className="hover:bg-gray-50"> const isSelected = selectedPlayers.includes(player.id)
<td className="px-6 py-4 whitespace-nowrap"> return (
<div className="flex items-center"> <tr
<div className="flex-shrink-0 h-10 w-10 bg-gray-200 rounded-full flex items-center justify-center"> key={player.id}
<span className="text-sm font-medium text-gray-600"> className={`${isSelected ? 'bg-purple-50' : 'hover:bg-gray-50'} ${
{player.name.charAt(0).toUpperCase()} mergeMode ? 'cursor-pointer' : ''
</span> }`}
</div> onClick={() => mergeMode && togglePlayerSelection(player.id)}
<div className="ml-4"> >
<div className="text-sm font-medium text-gray-900"> <td className="px-6 py-4 whitespace-nowrap">
{player.name} <div className="flex items-center">
{mergeMode && (
<input
type="checkbox"
checked={isSelected}
onChange={() => togglePlayerSelection(player.id)}
className="mr-3 h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded"
/>
)}
<div className="flex-shrink-0 h-10 w-10 bg-gray-200 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-gray-600">
{player.name.charAt(0).toUpperCase()}
</span>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{player.name}
</div>
</div> </div>
</div> </div>
</div> </td>
</td> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"> {player.currentElo}
{player.currentElo} </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"> {player.gamesPlayed}
{player.gamesPlayed} </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"> {player.wins}-{player.losses}
{player.wins}-{player.losses} </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"> {player.gamesPlayed > 0
{player.gamesPlayed > 0 ? ((player.wins / player.gamesPlayed) * 100).toFixed(1) + '%'
? ((player.wins / player.gamesPlayed) * 100).toFixed(1) + '%' : 'N/A'}
: 'N/A'} </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"> {player.user ? (
{player.user ? ( <span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800"> {player.user.email}
{player.user.email} </span>
</span> ) : (
) : ( <span className="text-gray-400 italic">No account</span>
<span className="text-gray-400 italic">No account</span> )}
)} </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"> <div className="flex space-x-2">
<div className="flex space-x-2"> <Link
<Link href={`/players/${player.id}/profile`}
href={`/players/${player.id}/profile`} className="text-green-600 hover:text-green-900"
className="text-green-600 hover:text-green-900" >
> View
View </Link>
</Link> {!mergeMode && (
<button <button
onClick={() => handleEditClick(player)} onClick={(e) => {
className="text-blue-600 hover:text-blue-900" e.stopPropagation()
> handleEditClick(player)
Edit }}
</button> className="text-blue-600 hover:text-blue-900"
</div> >
</td> Edit
</tr> </button>
))} )}
</div>
</td>
</tr>
)
})}
</tbody> </tbody>
</table> </table>
</div> </div>
+263
View File
@@ -0,0 +1,263 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { hasRole } from "@/lib/permissions";
import { recalculateAllElo } from "@/lib/elo-utils";
/**
* POST /api/admin/players/merge
*
* Merge multiple players into a single player
* Requires: club_admin or site_admin role
*
* Body:
* {
* playerIds: number[] // IDs of players to merge (first one becomes canonical)
* }
*
* Notes:
* - First player ID in the array becomes the canonical player
* - All other players will be merged into the canonical player
* - After merging, all Elo ratings are recalculated from scratch
*/
export async function POST(request: Request) {
try {
// Check permissions
const permission = await hasRole('club_admin');
if (!permission.allowed) {
return NextResponse.json(
{ error: permission.reason || "Not authorized to merge players" },
{ status: 403 }
);
}
const body = await request.json();
const { playerIds } = body;
// Validate input
if (!playerIds || !Array.isArray(playerIds) || playerIds.length < 2) {
return NextResponse.json(
{ error: "At least 2 player IDs are required for merging" },
{ status: 400 }
);
}
// Fetch all players
const players = await prisma.player.findMany({
where: { id: { in: playerIds } },
});
if (players.length !== playerIds.length) {
return NextResponse.json(
{ error: "One or more players not found" },
{ status: 404 }
);
}
// First player becomes the canonical player (target)
const [canonicalPlayer, ...duplicatePlayers] = players.sort((a, b) => a.id - b.id);
console.log(`Merging ${duplicatePlayers.length} players into ${canonicalPlayer.name}`);
// Merge stats from duplicates into canonical player
let totalGamesPlayed = canonicalPlayer.gamesPlayed;
let totalWins = canonicalPlayer.wins;
let totalLosses = canonicalPlayer.losses;
for (const dupPlayer of duplicatePlayers) {
totalGamesPlayed += dupPlayer.gamesPlayed;
totalWins += dupPlayer.wins;
totalLosses += dupPlayer.losses;
}
// Update canonical player with merged stats
await prisma.player.update({
where: { id: canonicalPlayer.id },
data: {
gamesPlayed: totalGamesPlayed,
wins: totalWins,
losses: totalLosses,
},
});
// Update all foreign key references to point to canonical player
const updates = [];
// Update matches - team1P1Id
updates.push(
prisma.match.updateMany({
where: { team1P1Id: { in: duplicatePlayers.map(p => p.id) } },
data: { team1P1Id: canonicalPlayer.id },
})
);
// Update matches - team1P2Id
updates.push(
prisma.match.updateMany({
where: { team1P2Id: { in: duplicatePlayers.map(p => p.id) } },
data: { team1P2Id: canonicalPlayer.id },
})
);
// Update matches - team2P1Id
updates.push(
prisma.match.updateMany({
where: { team2P1Id: { in: duplicatePlayers.map(p => p.id) } },
data: { team2P1Id: canonicalPlayer.id },
})
);
// Update matches - team2P2Id
updates.push(
prisma.match.updateMany({
where: { team2P2Id: { in: duplicatePlayers.map(p => p.id) } },
data: { team2P2Id: canonicalPlayer.id },
})
);
// Update event participants - handle unique constraint
for (const dupPlayer of duplicatePlayers) {
const dupParticipants = await prisma.eventParticipant.findMany({
where: { playerId: dupPlayer.id },
});
for (const dupParticipant of dupParticipants) {
// Check if canonical player already participates in this event
const existingParticipant = await prisma.eventParticipant.findFirst({
where: {
eventId: dupParticipant.eventId,
playerId: canonicalPlayer.id,
},
});
if (existingParticipant) {
// Delete the duplicate participant
await prisma.eventParticipant.delete({
where: { id: dupParticipant.id },
});
} else {
// Update the participant to use canonical player
await prisma.eventParticipant.update({
where: { id: dupParticipant.id },
data: { playerId: canonicalPlayer.id },
});
}
}
}
// Update partnership games
updates.push(
prisma.partnershipGame.updateMany({
where: { player1Id: { in: duplicatePlayers.map(p => p.id) } },
data: { player1Id: canonicalPlayer.id },
})
);
updates.push(
prisma.partnershipGame.updateMany({
where: { player2Id: { in: duplicatePlayers.map(p => p.id) } },
data: { player2Id: canonicalPlayer.id },
})
);
// Update partnership stats - merge them
for (const dupPlayer of duplicatePlayers) {
const partnerships = await prisma.partnershipStat.findMany({
where: {
OR: [
{ player1Id: dupPlayer.id },
{ player2Id: dupPlayer.id },
],
},
});
for (const partnership of partnerships) {
const otherPlayerId = partnership.player1Id === dupPlayer.id
? partnership.player2Id
: partnership.player1Id;
// Check if a partnership already exists between canonical and other player
const existingPartnership = await prisma.partnershipStat.findFirst({
where: {
OR: [
{
player1Id: canonicalPlayer.id,
player2Id: otherPlayerId
},
{
player1Id: otherPlayerId,
player2Id: canonicalPlayer.id
},
],
},
});
if (existingPartnership) {
// Merge stats into existing partnership and delete the old one
await prisma.partnershipStat.update({
where: { id: existingPartnership.id },
data: {
gamesPlayed: { increment: partnership.gamesPlayed },
wins: { increment: partnership.wins },
losses: { increment: partnership.losses },
totalEloChange: { increment: partnership.totalEloChange },
lastPlayed: partnership.lastPlayed && existingPartnership.lastPlayed
? (partnership.lastPlayed > existingPartnership.lastPlayed
? partnership.lastPlayed
: existingPartnership.lastPlayed)
: partnership.lastPlayed || existingPartnership.lastPlayed,
},
});
// Delete the duplicate partnership
await prisma.partnershipStat.delete({
where: { id: partnership.id },
});
} else {
// Update partnership to use canonical player
const isPlayer1 = partnership.player1Id === dupPlayer.id;
await prisma.partnershipStat.update({
where: { id: partnership.id },
data: isPlayer1
? { player1Id: canonicalPlayer.id }
: { player2Id: canonicalPlayer.id },
});
}
}
}
// Execute all updates
await Promise.all(updates);
console.log(`Updated foreign key references`);
// Delete duplicate players
for (const dupPlayer of duplicatePlayers) {
await prisma.player.delete({
where: { id: dupPlayer.id },
});
}
console.log(`Deleted ${duplicatePlayers.length} duplicate players`);
// RECALCULATE ALL ELO FROM SCRATCH
// This is critical - after merging players, all match data has changed
// so we need to recalculate everyone's ELO from the beginning
console.log('Recalculating all Elo ratings from scratch...');
const recalcResult = await recalculateAllElo(prisma);
console.log(`Elo recalculation complete:`, recalcResult);
return NextResponse.json({
success: true,
message: `Successfully merged ${duplicatePlayers.length} players into "${canonicalPlayer.name}"`,
canonicalPlayer: {
id: canonicalPlayer.id,
name: canonicalPlayer.name,
},
recalculation: recalcResult,
});
} catch (error: unknown) {
console.error("Error merging players:", error);
const message = error instanceof Error ? error.message : "Failed to merge players";
return NextResponse.json(
{ error: message },
{ status: 500 }
);
}
}