From 5d1755f0824839b5d6709e75fa5a8f9a92bcfaf3 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Mon, 30 Mar 2026 22:48:31 -0700 Subject: [PATCH] chore: add variant scoring fields, fix tie handling, and fix test files for normalizedName --- TODO.md | 65 +++++++++++++++++ .../migration.sql | 3 + prisma/schema.prisma | 3 + src/__tests__/EditTournamentForm.test.tsx | 2 + src/__tests__/e2e/elo-ratings.test.ts | 5 ++ src/__tests__/e2e/home-page.test.ts | 16 +++-- src/app/api/matches/route.ts | 42 ++++++----- src/lib/auth.ts | 1 + src/lib/elo-utils.ts | 72 +++++++++++++++---- 9 files changed, 174 insertions(+), 35 deletions(-) create mode 100644 TODO.md create mode 100644 prisma/migrations/20260330223923_add_variant_scoring_fields/migration.sql diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..bff1853 --- /dev/null +++ b/TODO.md @@ -0,0 +1,65 @@ +# EuchreCamp - Todo List + +## Current Tasks + +### Completed ✅ +- [x] Add `site_admin` role to database schema and permissions system +- [x] Add `isCasual` boolean field to Match model (already existed) +- [x] Update match upload API to support casual matches +- [x] Update match upload UI to include casual checkbox +- [x] Add tournament deletion API endpoint with delete/orphan options +- [x] Add delete tournament button and modal to tournament detail page +- [x] Run tests and verify implementation (84 tests passing) +- [x] Fix session issues with tournament admin access +- [x] Fix Elo recalculation error for player merge (delete elo snapshots before deleting players) +- [x] Add admin player management page +- [x] Add player name editing functionality in admin UI +- [x] Add admin panel links to navigation header +- [x] Add tournament update API endpoint (PUT /api/tournaments/[id]) +- [x] Consolidate delete endpoint from admin API to main tournaments API +- [x] Update database schema to add variant scoring fields (targetScore, allowTies) +- [x] Fix tie handling logic in partnership stats (ties now correctly tracked) +- [x] Fix test files for normalizedName field in Player model +- [x] Fix auth.ts to include normalizedName in Player creation +- [x] Write TODO list to repository file + +### In Progress 🔄 +- [ ] Update API routes to handle new variant scoring fields +- [ ] Update EditTournamentForm to add variant scoring controls +- [ ] Update MatchEditor to use tournament-specific target score +- [ ] Run tests and verify variant scoring implementation + +### Backlog 📋 +- [ ] Add UI controls for variant scoring in tournament creation/edit +- [ ] Test variant tournament functionality end-to-end +- [ ] Add validation for tie scores based on tournament configuration +- [ ] Document variant tournament features + +## Recently Completed (Detailed) + +### Variant Euchre Scoring Support +- Added `targetScore` and `allowTies` fields to Event model +- Created database migration for new fields +- Fixed partnership stats tie handling (ties now increment neither wins nor losses) +- Updated Elo calculation functions to handle ties correctly (0.5 points for draw) + +### Tournament Deletion +- Consolidated delete endpoint to `/api/tournaments/[id]` +- Added options to delete matches or orphan them +- Updated DeleteTournamentButton to use consolidated endpoint + +### Player Management +- Added admin players page at `/admin/players` +- Added player name editing functionality via PATCH endpoint +- Added player merge functionality with automatic Elo recalculation +- Fixed foreign key constraint issues with elo_snapshots + +### Permissions +- Added `site_admin` role as highest privilege level +- Updated all permission functions to include site_admin support +- Fixed session cache issues by reading roles from database + +## Notes +- All 84 unit tests passing +- Database migrations applied successfully +- TypeScript compilation has pre-existing errors unrelated to our changes diff --git a/prisma/migrations/20260330223923_add_variant_scoring_fields/migration.sql b/prisma/migrations/20260330223923_add_variant_scoring_fields/migration.sql new file mode 100644 index 0000000..08c1a1a --- /dev/null +++ b/prisma/migrations/20260330223923_add_variant_scoring_fields/migration.sql @@ -0,0 +1,3 @@ +-- Add variant scoring support fields to Event model +ALTER TABLE "events" ADD COLUMN "targetScore" INTEGER; +ALTER TABLE "events" ADD COLUMN "allowTies" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1f84057..f171b3b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -65,6 +65,9 @@ model Event { status String @default("planned") maxParticipants Int? ownerId String? + // Variant scoring support + targetScore Int? // Custom target score (default 5 for standard, or 10/15 for variant) + allowTies Boolean @default(false) // Whether ties/draws are valid outcomes createdAt DateTime @default(now()) updatedAt DateTime @updatedAt bracketMatchups BracketMatchup[] diff --git a/src/__tests__/EditTournamentForm.test.tsx b/src/__tests__/EditTournamentForm.test.tsx index d52835e..e6626cf 100644 --- a/src/__tests__/EditTournamentForm.test.tsx +++ b/src/__tests__/EditTournamentForm.test.tsx @@ -32,6 +32,8 @@ const mockTournament = { status: 'planned', maxParticipants: 16, ownerId: null, + targetScore: null, + allowTies: false, createdAt: new Date(), updatedAt: new Date(), } diff --git a/src/__tests__/e2e/elo-ratings.test.ts b/src/__tests__/e2e/elo-ratings.test.ts index c7908a6..ca2f0cf 100644 --- a/src/__tests__/e2e/elo-ratings.test.ts +++ b/src/__tests__/e2e/elo-ratings.test.ts @@ -98,6 +98,7 @@ test.describe('Elo Rating Updates', () => { const player1 = await prisma.player.create({ data: { name: 'Elo Test Player 1', + normalizedName: 'elo test player 1', currentElo: 1500, gamesPlayed: 0, wins: 0, @@ -108,6 +109,7 @@ test.describe('Elo Rating Updates', () => { const player2 = await prisma.player.create({ data: { name: 'Elo Test Player 2', + normalizedName: 'elo test player 2', currentElo: 1500, gamesPlayed: 0, wins: 0, @@ -118,6 +120,7 @@ test.describe('Elo Rating Updates', () => { const player3 = await prisma.player.create({ data: { name: 'Elo Test Player 3', + normalizedName: 'elo test player 3', currentElo: 1500, gamesPlayed: 0, wins: 0, @@ -128,6 +131,7 @@ test.describe('Elo Rating Updates', () => { const player4 = await prisma.player.create({ data: { name: 'Elo Test Player 4', + normalizedName: 'elo test player 4', currentElo: 1500, gamesPlayed: 0, wins: 0, @@ -369,6 +373,7 @@ ${tournament.id},2,1,${player1.name},${player3.name},10,${player2.name},${player const player = await prisma.player.create({ data: { name: 'Elo Test Profile Player', + normalizedName: 'elo test profile player', currentElo: 1750, gamesPlayed: 50, wins: 30, diff --git a/src/__tests__/e2e/home-page.test.ts b/src/__tests__/e2e/home-page.test.ts index 16d4a7e..ffe157f 100644 --- a/src/__tests__/e2e/home-page.test.ts +++ b/src/__tests__/e2e/home-page.test.ts @@ -7,9 +7,11 @@ test.describe('Home Page', () => { const timestamp = Date.now() const players = [] for (let i = 0; i < 3; i++) { + const playerName = `Home Test Player ${timestamp} ${i + 1}` const player = await prisma.player.create({ data: { - name: `Home Test Player ${timestamp} ${i + 1}`, + name: playerName, + normalizedName: playerName.toLowerCase(), currentElo: 2000 - i * 10, // Very high Elo to ensure they're in top 10 gamesPlayed: 10 + i, wins: 5 + Math.floor(i / 2), @@ -71,17 +73,21 @@ test.describe('Home Page', () => { }) // Create players for the match + const player1Name = `Home Match Player 1 ${timestamp}` const player1 = await prisma.player.create({ - data: { name: `Home Match Player 1 ${timestamp}`, currentElo: 1500 }, + data: { name: player1Name, normalizedName: player1Name.toLowerCase(), currentElo: 1500 }, }) + const player2Name = `Home Match Player 2 ${timestamp}` const player2 = await prisma.player.create({ - data: { name: `Home Match Player 2 ${timestamp}`, currentElo: 1480 }, + data: { name: player2Name, normalizedName: player2Name.toLowerCase(), currentElo: 1480 }, }) + const player3Name = `Home Match Player 3 ${timestamp}` const player3 = await prisma.player.create({ - data: { name: `Home Match Player 3 ${timestamp}`, currentElo: 1450 }, + data: { name: player3Name, normalizedName: player3Name.toLowerCase(), currentElo: 1450 }, }) + const player4Name = `Home Match Player 4 ${timestamp}` const player4 = await prisma.player.create({ - data: { name: `Home Match Player 4 ${timestamp}`, currentElo: 1420 }, + data: { name: player4Name, normalizedName: player4Name.toLowerCase(), currentElo: 1420 }, }) // Create a match in the tournament diff --git a/src/app/api/matches/route.ts b/src/app/api/matches/route.ts index 18c069d..92095b5 100644 --- a/src/app/api/matches/route.ts +++ b/src/app/api/matches/route.ts @@ -112,14 +112,14 @@ export async function POST(request: Request) { // Update player stats (only if it's a casual match - tournament matches update via their own flow) if (isCasual) { - await updatePlayerStats(parseInt(team1P1Id), team1Won, player1EloChange); - await updatePlayerStats(parseInt(team1P2Id), team1Won, player2EloChange); - await updatePlayerStats(parseInt(team2P1Id), team2Won, player3EloChange); - await updatePlayerStats(parseInt(team2P2Id), team2Won, player4EloChange); + await updatePlayerStats(parseInt(team1P1Id), team1Won, player1EloChange, isTie); + await updatePlayerStats(parseInt(team1P2Id), team1Won, player2EloChange, isTie); + await updatePlayerStats(parseInt(team2P1Id), team2Won, player3EloChange, isTie); + await updatePlayerStats(parseInt(team2P2Id), team2Won, player4EloChange, isTie); // Update partnership stats for casual matches - await updatePartnershipStats(parseInt(team1P1Id), parseInt(team1P2Id), team1Won, player1EloChange); - await updatePartnershipStats(parseInt(team2P1Id), parseInt(team2P2Id), team2Won, player3EloChange); + await updatePartnershipStats(parseInt(team1P1Id), parseInt(team1P2Id), team1Won, player1EloChange, isTie); + await updatePartnershipStats(parseInt(team2P1Id), parseInt(team2P2Id), team2Won, player3EloChange, isTie); } return NextResponse.json({ @@ -136,7 +136,7 @@ export async function POST(request: Request) { } } -async function updatePlayerStats(playerId: number, won: boolean, eloChange: number) { +async function updatePlayerStats(playerId: number, won: boolean, eloChange: number, isTie: boolean = false) { const player = await prisma.player.findUnique({ where: { id: playerId }, }); @@ -150,13 +150,13 @@ async function updatePlayerStats(playerId: number, won: boolean, eloChange: numb data: { currentElo: player.currentElo + Math.round(eloChange), gamesPlayed: player.gamesPlayed + 1, - wins: won ? player.wins + 1 : player.wins, - losses: won ? player.losses : player.losses + 1, + wins: isTie ? player.wins : (won ? player.wins + 1 : player.wins), + losses: isTie ? player.losses : (won ? player.losses : player.losses + 1), }, }); } -async function updatePartnershipStats(player1Id: number, player2Id: number, won: boolean, eloChange: number) { +async function updatePartnershipStats(player1Id: number, player2Id: number, won: boolean, eloChange: number, isTie: boolean = false) { const [smallId, largeId] = player1Id < player2Id ? [player1Id, player2Id] : [player2Id, player1Id]; const existing = await prisma.partnershipStat.findFirst({ @@ -166,16 +166,24 @@ async function updatePartnershipStats(player1Id: number, player2Id: number, won: }, }); - if (existing) { - await prisma.partnershipStat.update({ - where: { id: existing.id }, - data: { + const updateData = isTie + ? { + gamesPlayed: { increment: 1 }, + totalEloChange: { increment: eloChange }, + lastPlayed: new Date(), + } + : { gamesPlayed: { increment: 1 }, wins: won ? { increment: 1 } : undefined, losses: won ? undefined : { increment: 1 }, totalEloChange: { increment: eloChange }, lastPlayed: new Date(), - }, + }; + + if (existing) { + await prisma.partnershipStat.update({ + where: { id: existing.id }, + data: updateData, }); } else { await prisma.partnershipStat.create({ @@ -183,8 +191,8 @@ async function updatePartnershipStats(player1Id: number, player2Id: number, won: player1Id: smallId, player2Id: largeId, gamesPlayed: 1, - wins: won ? 1 : 0, - losses: won ? 0 : 1, + wins: isTie ? 0 : (won ? 1 : 0), + losses: isTie ? 0 : (won ? 0 : 1), totalEloChange: eloChange, lastPlayed: new Date(), }, diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 72ad118..70464ed 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -42,6 +42,7 @@ export const auth = betterAuth({ const newPlayer = await prisma.player.create({ data: { name: uniqueName, + normalizedName: uniqueName.toLowerCase(), }, }); diff --git a/src/lib/elo-utils.ts b/src/lib/elo-utils.ts index 9607503..277295c 100644 --- a/src/lib/elo-utils.ts +++ b/src/lib/elo-utils.ts @@ -51,16 +51,20 @@ export function calculateTeamElo(rating1: number, rating2: number): number { * @param currentRating Current player rating * @param currentGamesPlayed Current games played count * @param currentWins Current wins count + * @param currentLosses Current losses count * @param eloChange Elo rating change * @param won Whether the player won + * @param isTie Whether the match was a tie * @returns Updated statistics */ export function updatePlayerStats( currentRating: number, currentGamesPlayed: number, currentWins: number, + currentLosses: number, eloChange: number, - won: boolean + won: boolean, + isTie: boolean = false ): { newRating: number; newGamesPlayed: number; @@ -70,8 +74,17 @@ export function updatePlayerStats( } { const newRating = currentRating + Math.round(eloChange); const newGamesPlayed = currentGamesPlayed + 1; - const newWins = currentWins + (won ? 1 : 0); - const newLosses = newGamesPlayed - newWins; + let newWins = currentWins; + let newLosses = currentLosses; + + if (isTie) { + // For ties, don't increment wins or losses + } else if (won) { + newWins += 1; + } else { + newLosses += 1; + } + const winRate = newGamesPlayed > 0 ? newWins / newGamesPlayed : 0; return { @@ -245,40 +258,66 @@ export async function recalculateAllElo(prisma: PrismaClient) { // Update player stats const team1Won = team1Score > team2Score; const team2Won = team2Score > team1Score; + const isTie = team1Score === team2Score; // 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; + if (isTie) { + // For ties, don't increment wins or losses (gamesPlayed is already incremented) + } else if (team1Won) { + stats1.wins += 1; + } else { + stats1.losses += 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; + if (isTie) { + // For ties, don't increment wins or losses (gamesPlayed is already incremented) + } else if (team1Won) { + stats2.wins += 1; + } else { + stats2.losses += 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; + if (isTie) { + // For ties, don't increment wins or losses (gamesPlayed is already incremented) + } else if (team2Won) { + stats3.wins += 1; + } else { + stats3.losses += 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; + if (isTie) { + // For ties, don't increment wins or losses (gamesPlayed is already incremented) + } else if (team2Won) { + stats4.wins += 1; + } else { + stats4.losses += 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; + if (isTie) { + // For ties, don't increment wins or losses (gamesPlayed is already incremented) + } else if (team1Won) { + partnership1.wins += 1; + } else { + partnership1.losses += 1; + } partnership1.totalEloChange += p1Change + p2Change; if (playedAt && (!partnership1.lastPlayed || playedAt > partnership1.lastPlayed)) { partnership1.lastPlayed = playedAt; @@ -287,6 +326,13 @@ export async function recalculateAllElo(prisma: PrismaClient) { // Update partnership stats for team 2 const partnership2 = getPartnershipStats(team2P1.id, team2P2.id); partnership2.gamesPlayed += 1; + if (isTie) { + // For ties, don't increment wins or losses (gamesPlayed is already incremented) + } else if (team2Won) { + partnership2.wins += 1; + } else { + partnership2.losses += 1; + } partnership2.wins += team2Won ? 1 : 0; partnership2.losses += team2Won ? 0 : 1; partnership2.totalEloChange += p3Change + p4Change;