diff --git a/app/models/__tests__/bracket-integration.test.ts b/app/models/__tests__/bracket-integration.test.ts new file mode 100644 index 0000000..3b0aa6d --- /dev/null +++ b/app/models/__tests__/bracket-integration.test.ts @@ -0,0 +1,395 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { SIMPLE_16, SIMPLE_32, NCAA_68 } from "~/lib/bracket-templates"; +import { calculateFantasyPoints, calculateAveragedPoints, type ScoringRules } from "../scoring-rules"; + +const DEFAULT_SCORING: ScoringRules = { + pointsFor1st: 100, + pointsFor2nd: 70, + pointsFor3rd: 50, + pointsFor4th: 40, + pointsFor5th: 25, + pointsFor6th: 25, + pointsFor7th: 15, + pointsFor8th: 15, +}; + +describe("Bracket Integration Tests - Phase 2.7", () => { + describe("16-Team Bracket", () => { + it("has correct template structure", () => { + expect(SIMPLE_16.totalTeams).toBe(16); + expect(SIMPLE_16.rounds).toHaveLength(4); + expect(SIMPLE_16.scoringStartsAtRound).toBe("Quarterfinals"); + + // Round of 16 doesn't score + const round1 = SIMPLE_16.rounds[0]; + expect(round1.name).toBe("Round of 16"); + expect(round1.isScoring).toBe(false); + expect(round1.matchCount).toBe(8); + + // Quarterfinals and beyond score + const round2 = SIMPLE_16.rounds[1]; + expect(round2.name).toBe("Quarterfinals"); + expect(round2.isScoring).toBe(true); + expect(round2.matchCount).toBe(4); + + const round3 = SIMPLE_16.rounds[2]; + expect(round3.name).toBe("Semifinals"); + expect(round3.isScoring).toBe(true); + expect(round3.matchCount).toBe(2); + + const round4 = SIMPLE_16.rounds[3]; + expect(round4.name).toBe("Finals"); + expect(round4.isScoring).toBe(true); + expect(round4.matchCount).toBe(1); + }); + + it("calculates correct points for each placement", () => { + // Champion (1st) + const champion = calculateFantasyPoints(1, DEFAULT_SCORING); + expect(champion).toBe(100); + + // Runner-up (2nd) + const runnerUp = calculateFantasyPoints(2, DEFAULT_SCORING); + expect(runnerUp).toBe(70); + + // Semifinals losers (3rd/4th share) + const sfLosers = calculateAveragedPoints([3, 4], DEFAULT_SCORING); + expect(sfLosers).toBe(45); // (50 + 40) / 2 + + // Quarterfinals losers (5th-8th share) + const qfLosers = calculateAveragedPoints([5, 6, 7, 8], DEFAULT_SCORING); + expect(qfLosers).toBe(20); // (25 + 25 + 15 + 15) / 4 + + // Round of 16 losers (early elimination = 0 points per Q20) + const earlyElimination = calculateFantasyPoints(0, DEFAULT_SCORING); + expect(earlyElimination).toBe(0); + }); + + it("simulates complete 16-team bracket scoring", () => { + // Simulate a complete bracket + const results = { + // Finals + champion: 1, // 100 points + runnerUp: 2, // 70 points + + // Semifinals losers (2 teams share 3rd/4th) + sfLoser1: 3, // 45 points (avg of 3rd and 4th) + sfLoser2: 3, // 45 points + + // Quarterfinals losers (4 teams share 5th-8th) + qfLoser1: 5, // 20 points (avg of 5-8) + qfLoser2: 5, // 20 points + qfLoser3: 5, // 20 points + qfLoser4: 5, // 20 points + + // Round of 16 losers (8 teams get 0 points) + r16Loser1: 0, // 0 points + r16Loser2: 0, // 0 points + r16Loser3: 0, // 0 points + r16Loser4: 0, // 0 points + r16Loser5: 0, // 0 points + r16Loser6: 0, // 0 points + r16Loser7: 0, // 0 points + r16Loser8: 0, // 0 points + }; + + // Verify champion + expect(calculateFantasyPoints(results.champion, DEFAULT_SCORING)).toBe(100); + + // Verify runner-up + expect(calculateFantasyPoints(results.runnerUp, DEFAULT_SCORING)).toBe(70); + + // Verify SF losers + expect(calculateFantasyPoints(results.sfLoser1, DEFAULT_SCORING)).toBe(50); // Gets 3rd place points + expect(calculateFantasyPoints(results.sfLoser2, DEFAULT_SCORING)).toBe(50); + // When averaged: (50 + 40) / 2 = 45 + expect(calculateAveragedPoints([3, 4], DEFAULT_SCORING)).toBe(45); + + // Verify QF losers + expect(calculateFantasyPoints(results.qfLoser1, DEFAULT_SCORING)).toBe(25); // Gets 5th place points + // When averaged: (25 + 25 + 15 + 15) / 4 = 20 + expect(calculateAveragedPoints([5, 6, 7, 8], DEFAULT_SCORING)).toBe(20); + + // Verify Round of 16 losers get 0 points + expect(calculateFantasyPoints(results.r16Loser1, DEFAULT_SCORING)).toBe(0); + expect(calculateFantasyPoints(results.r16Loser8, DEFAULT_SCORING)).toBe(0); + + // Total points distributed (if one team drafted all participants) + // 100 + 70 + 45 + 45 + 20 + 20 + 20 + 20 + 0*8 = 340 + const totalPoints = + 100 + 70 + 45 + 45 + 20 + 20 + 20 + 20 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0; + expect(totalPoints).toBe(340); + }); + + it("verifies only top 8 finishers score fantasy points", () => { + // Winners and top 8 get points + expect(calculateFantasyPoints(1, DEFAULT_SCORING)).toBeGreaterThan(0); + expect(calculateFantasyPoints(8, DEFAULT_SCORING)).toBeGreaterThan(0); + + // Round of 16 losers (positions 9-16) get 0 points + expect(calculateFantasyPoints(0, DEFAULT_SCORING)).toBe(0); + expect(calculateFantasyPoints(9, DEFAULT_SCORING)).toBe(0); + expect(calculateFantasyPoints(16, DEFAULT_SCORING)).toBe(0); + }); + }); + + describe("32-Team Bracket", () => { + it("has correct template structure", () => { + expect(SIMPLE_32.totalTeams).toBe(32); + expect(SIMPLE_32.rounds).toHaveLength(5); + expect(SIMPLE_32.scoringStartsAtRound).toBe("Quarterfinals"); + + // Round of 32 and Round of 16 don't score + const round1 = SIMPLE_32.rounds[0]; + expect(round1.name).toBe("Round of 32"); + expect(round1.isScoring).toBe(false); + expect(round1.matchCount).toBe(16); + + const round2 = SIMPLE_32.rounds[1]; + expect(round2.name).toBe("Round of 16"); + expect(round2.isScoring).toBe(false); + expect(round2.matchCount).toBe(8); + + // Quarterfinals and beyond score + const round3 = SIMPLE_32.rounds[2]; + expect(round3.name).toBe("Quarterfinals"); + expect(round3.isScoring).toBe(true); + expect(round3.matchCount).toBe(4); + + const round4 = SIMPLE_32.rounds[3]; + expect(round4.name).toBe("Semifinals"); + expect(round4.isScoring).toBe(true); + + const round5 = SIMPLE_32.rounds[4]; + expect(round5.name).toBe("Finals"); + expect(round5.isScoring).toBe(true); + }); + + it("calculates correct points for each placement", () => { + // Same as 16-team for top 8 + expect(calculateFantasyPoints(1, DEFAULT_SCORING)).toBe(100); + expect(calculateFantasyPoints(2, DEFAULT_SCORING)).toBe(70); + expect(calculateAveragedPoints([3, 4], DEFAULT_SCORING)).toBe(45); + expect(calculateAveragedPoints([5, 6, 7, 8], DEFAULT_SCORING)).toBe(20); + + // Early elimination (9th-32nd) = 0 points + expect(calculateFantasyPoints(0, DEFAULT_SCORING)).toBe(0); + }); + + it("simulates complete 32-team bracket scoring", () => { + // Top 8 finishers + const topEight = { + champion: 100, + runnerUp: 70, + sfLoser1: 45, + sfLoser2: 45, + qfLoser1: 20, + qfLoser2: 20, + qfLoser3: 20, + qfLoser4: 20, + }; + + // 24 early eliminations (Round of 32 + Round of 16) + const earlyEliminations = Array(24).fill(0); + + // Total points + const topEightTotal = Object.values(topEight).reduce((sum, pts) => sum + pts, 0); + expect(topEightTotal).toBe(340); + + const earlyEliminationTotal = earlyEliminations.reduce((sum, pts) => sum + pts, 0); + expect(earlyEliminationTotal).toBe(0); + + const grandTotal = topEightTotal + earlyEliminationTotal; + expect(grandTotal).toBe(340); + }); + + it("verifies 24 teams eliminated early get 0 points", () => { + // Round of 32: 16 losers + // Round of 16: 8 losers + // Total: 24 teams get 0 points + + // Only top 8 score + const scoringTeams = 8; + const nonScoringTeams = 32 - scoringTeams; + expect(nonScoringTeams).toBe(24); + + // All non-scoring teams get 0 points + for (let i = 0; i < nonScoringTeams; i++) { + expect(calculateFantasyPoints(0, DEFAULT_SCORING)).toBe(0); + } + }); + }); + + describe("NCAA March Madness (68 Teams) - Elite Eight Scoring", () => { + it("has correct template structure", () => { + expect(NCAA_68.totalTeams).toBe(68); + expect(NCAA_68.rounds).toHaveLength(7); + expect(NCAA_68.scoringStartsAtRound).toBe("Elite Eight"); + + // First Four through Sweet Sixteen don't score + expect(NCAA_68.rounds[0].name).toBe("First Four"); + expect(NCAA_68.rounds[0].isScoring).toBe(false); + + expect(NCAA_68.rounds[1].name).toBe("Round of 64"); + expect(NCAA_68.rounds[1].isScoring).toBe(false); + + expect(NCAA_68.rounds[2].name).toBe("Round of 32"); + expect(NCAA_68.rounds[2].isScoring).toBe(false); + + expect(NCAA_68.rounds[3].name).toBe("Sweet Sixteen"); + expect(NCAA_68.rounds[3].isScoring).toBe(false); + + // Elite Eight and beyond score + expect(NCAA_68.rounds[4].name).toBe("Elite Eight"); + expect(NCAA_68.rounds[4].isScoring).toBe(true); + + expect(NCAA_68.rounds[5].name).toBe("Final Four"); + expect(NCAA_68.rounds[5].isScoring).toBe(true); + + expect(NCAA_68.rounds[6].name).toBe("Championship"); + expect(NCAA_68.rounds[6].isScoring).toBe(true); + }); + + it("verifies only Elite Eight and beyond score points (per Q18)", () => { + // Teams eliminated before Elite Eight get 0 points + // First Four: 4 losers + // Round of 64: 32 losers + // Round of 32: 16 losers + // Sweet Sixteen: 8 losers + // Total: 60 teams get 0 points + + const nonScoringTeams = 4 + 32 + 16 + 8; + expect(nonScoringTeams).toBe(60); + + // Only Elite Eight and beyond (8 teams) score + const scoringTeams = 8; + expect(scoringTeams + nonScoringTeams).toBe(68); + }); + + it("simulates complete March Madness bracket scoring", () => { + // Top 8 finishers (Elite Eight and beyond) + const topEight = { + champion: 100, + runnerUp: 70, + finalFourLoser1: 45, + finalFourLoser2: 45, + eliteEightLoser1: 20, + eliteEightLoser2: 20, + eliteEightLoser3: 20, + eliteEightLoser4: 20, + }; + + // 60 early eliminations + const earlyEliminations = Array(60).fill(0); + + // Total points + const topEightTotal = Object.values(topEight).reduce((sum, pts) => sum + pts, 0); + expect(topEightTotal).toBe(340); + + const earlyEliminationTotal = earlyEliminations.reduce((sum, pts) => sum + pts, 0); + expect(earlyEliminationTotal).toBe(0); + + const grandTotal = topEightTotal + earlyEliminationTotal; + expect(grandTotal).toBe(340); + }); + + it("verifies Elite Eight losers share 5th-8th placement", () => { + // Elite Eight has 4 matches (Quarterfinals round) + // Losers share 5th-8th = (25 + 25 + 15 + 15) / 4 = 20 points each + const eliteEightLosers = calculateAveragedPoints([5, 6, 7, 8], DEFAULT_SCORING); + expect(eliteEightLosers).toBe(20); + }); + + it("verifies Final Four losers share 3rd-4th placement", () => { + // Final Four has 2 matches (Semifinals round) + // Losers share 3rd-4th = (50 + 40) / 2 = 45 points each + const finalFourLosers = calculateAveragedPoints([3, 4], DEFAULT_SCORING); + expect(finalFourLosers).toBe(45); + }); + }); + + describe("Cross-Template Consistency", () => { + it("verifies all templates award same total points for top 8", () => { + // Regardless of bracket size, top 8 always get the same points + const expectedTotal = 100 + 70 + 45 + 45 + 20 + 20 + 20 + 20; + + // 16-team bracket + const team16Total = 100 + 70 + 45 + 45 + 20 + 20 + 20 + 20; + expect(team16Total).toBe(expectedTotal); + + // 32-team bracket + const team32Total = 100 + 70 + 45 + 45 + 20 + 20 + 20 + 20; + expect(team32Total).toBe(expectedTotal); + + // NCAA 68-team bracket + const ncaaTotal = 100 + 70 + 45 + 45 + 20 + 20 + 20 + 20; + expect(ncaaTotal).toBe(expectedTotal); + + // All should be 340 + expect(expectedTotal).toBe(340); + }); + + it("verifies scoring rounds are consistent across templates", () => { + // All templates should have exactly 3 scoring rounds + const simple16ScoringRounds = SIMPLE_16.rounds.filter((r) => r.isScoring); + expect(simple16ScoringRounds).toHaveLength(3); // QF, SF, Finals + + const simple32ScoringRounds = SIMPLE_32.rounds.filter((r) => r.isScoring); + expect(simple32ScoringRounds).toHaveLength(3); // QF, SF, Finals + + const ncaaScoringRounds = NCAA_68.rounds.filter((r) => r.isScoring); + expect(ncaaScoringRounds).toHaveLength(3); // Elite Eight, Final Four, Championship + }); + + it("verifies non-scoring rounds correctly identified", () => { + // 16-team: 1 non-scoring round + const simple16NonScoring = SIMPLE_16.rounds.filter((r) => !r.isScoring); + expect(simple16NonScoring).toHaveLength(1); + + // 32-team: 2 non-scoring rounds + const simple32NonScoring = SIMPLE_32.rounds.filter((r) => !r.isScoring); + expect(simple32NonScoring).toHaveLength(2); + + // NCAA 68: 4 non-scoring rounds + const ncaaNonScoring = NCAA_68.rounds.filter((r) => !r.isScoring); + expect(ncaaNonScoring).toHaveLength(4); + }); + }); + + describe("Placement 0 for Early Eliminations (Q20)", () => { + it("placement 0 awards 0 fantasy points", () => { + const points = calculateFantasyPoints(0, DEFAULT_SCORING); + expect(points).toBe(0); + }); + + it("placement 0 works with custom scoring rules", () => { + const customScoring: ScoringRules = { + pointsFor1st: 500, + pointsFor2nd: 400, + pointsFor3rd: 300, + pointsFor4th: 200, + pointsFor5th: 100, + pointsFor6th: 100, + pointsFor7th: 50, + pointsFor8th: 50, + }; + + const points = calculateFantasyPoints(0, customScoring); + expect(points).toBe(0); + }); + + it("early eliminations do not affect averaged points", () => { + // If we incorrectly averaged in 0-point placements, we'd get wrong results + // Correct: avg(5,6,7,8) = (25+25+15+15)/4 = 20 + // Wrong: avg(0,5,6,7,8) = (0+25+25+15+15)/5 = 16 + + const correct = calculateAveragedPoints([5, 6, 7, 8], DEFAULT_SCORING); + expect(correct).toBe(20); + + // Should NOT include 0 in averaging + const wrong = calculateAveragedPoints([0, 5, 6, 7, 8], DEFAULT_SCORING); + expect(wrong).not.toBe(20); + expect(wrong).toBe(16); // This would be incorrect scoring + }); + }); +}); diff --git a/app/models/__tests__/bracket-seeding.test.ts b/app/models/__tests__/bracket-seeding.test.ts new file mode 100644 index 0000000..3990187 --- /dev/null +++ b/app/models/__tests__/bracket-seeding.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect } from "vitest"; + +/** + * Tests for proper tournament bracket seeding + * Phase 2.7 Bug Fix: Ensure proper seeding (1v16, 8v9, etc.) instead of sequential (1v2, 3v4) + */ + +/** + * Helper to simulate the seeding algorithm from playoff-match.ts + */ +function generateStandardSeeding(teamCount: number): [number, number][] { + if (![4, 8, 16, 32].includes(teamCount)) { + const pairs: [number, number][] = []; + for (let i = 0; i < teamCount; i += 2) { + pairs.push([i, i + 1]); + } + return pairs; + } + + const rounds = Math.log2(teamCount); + let seeds = [0, 1]; + + for (let round = 1; round < rounds; round++) { + const newSeeds: number[] = []; + const maxSeed = Math.pow(2, round + 1) - 1; + + for (const seed of seeds) { + newSeeds.push(seed); + newSeeds.push(maxSeed - seed); + } + seeds = newSeeds; + } + + const pairs: [number, number][] = []; + for (let i = 0; i < seeds.length; i += 2) { + pairs.push([seeds[i], seeds[i + 1]]); + } + + return pairs; +} + +describe("Bracket Seeding", () => { + describe("4-Team Bracket", () => { + it("generates correct seeding pairs", () => { + const pairs = generateStandardSeeding(4); + + // Should be: 1v4, 2v3 (0-indexed: 0v3, 1v2) + expect(pairs).toEqual([ + [0, 3], // Seed 1 vs Seed 4 + [1, 2], // Seed 2 vs Seed 3 + ]); + }); + + it("balances the bracket", () => { + const pairs = generateStandardSeeding(4); + + // Top half: seeds 1,4 (sum = 5) + // Bottom half: seeds 2,3 (sum = 5) + const topSum = pairs[0][0] + pairs[0][1] + 2; // +2 for 1-indexing + const bottomSum = pairs[1][0] + pairs[1][1] + 2; + + expect(topSum).toBe(bottomSum); + }); + }); + + describe("8-Team Bracket", () => { + it("generates correct seeding pairs", () => { + const pairs = generateStandardSeeding(8); + + // Standard 8-team seeding: 1v8, 4v5, 2v7, 3v6 + expect(pairs).toEqual([ + [0, 7], // Seed 1 vs Seed 8 + [3, 4], // Seed 4 vs Seed 5 + [1, 6], // Seed 2 vs Seed 7 + [2, 5], // Seed 3 vs Seed 6 + ]); + }); + + it("ensures top seeds are separated", () => { + const pairs = generateStandardSeeding(8); + + // Seeds 1 and 2 should be in different halves + const match1Seeds = [pairs[0][0] + 1, pairs[0][1] + 1]; + const match2Seeds = [pairs[1][0] + 1, pairs[1][1] + 1]; + const topHalf = [...match1Seeds, ...match2Seeds]; + + const match3Seeds = [pairs[2][0] + 1, pairs[2][1] + 1]; + const match4Seeds = [pairs[3][0] + 1, pairs[3][1] + 1]; + const bottomHalf = [...match3Seeds, ...match4Seeds]; + + // Seed 1 should be in top half, seed 2 in bottom half (or vice versa) + expect(topHalf.includes(1) && bottomHalf.includes(2)).toBe(true); + }); + }); + + describe("16-Team Bracket", () => { + it("generates 8 matches", () => { + const pairs = generateStandardSeeding(16); + expect(pairs).toHaveLength(8); + }); + + it("uses all 16 seeds exactly once", () => { + const pairs = generateStandardSeeding(16); + const allSeeds = pairs.flat().map(s => s + 1).sort((a, b) => a - b); + + expect(allSeeds).toEqual(Array.from({ length: 16 }, (_, i) => i + 1)); + }); + + it("ensures seed 1 plays seed 16", () => { + const pairs = generateStandardSeeding(16); + const firstMatch = pairs[0]; + + // Convert to 1-indexed seeds + const seed1 = firstMatch[0] + 1; + const seed2 = firstMatch[1] + 1; + + expect([seed1, seed2].sort()).toEqual([1, 16]); + }); + + it("has proper matchup sums for balance", () => { + const pairs = generateStandardSeeding(16); + + // Each matchup should have a sum that indicates proper seeding + // 1v16 (sum=17), 2v15 (sum=17), etc. + const sums = pairs.map(([a, b]) => (a + 1) + (b + 1)); + + // All matchups should sum to 17 (n + 1 for 16 teams) + expect(sums.every(sum => sum === 17)).toBe(true); + }); + + it("balances each quarterfinal pod", () => { + const pairs = generateStandardSeeding(16); + + // Each pod of 4 teams should have balanced seeds + // Pod 1 (matches 0,1): 1,16,8,9 sum = 34 + // Pod 2 (matches 2,3): 5,12,4,13 sum = 34 + // Pod 3 (matches 4,5): 6,11,3,14 sum = 34 + // Pod 4 (matches 6,7): 7,10,2,15 sum = 34 + + const pod1 = pairs[0].concat(pairs[1]).map(s => s + 1); + const pod2 = pairs[2].concat(pairs[3]).map(s => s + 1); + const pod3 = pairs[4].concat(pairs[5]).map(s => s + 1); + const pod4 = pairs[6].concat(pairs[7]).map(s => s + 1); + + const sum1 = pod1.reduce((a, b) => a + b, 0); + const sum2 = pod2.reduce((a, b) => a + b, 0); + const sum3 = pod3.reduce((a, b) => a + b, 0); + const sum4 = pod4.reduce((a, b) => a + b, 0); + + expect(sum1).toBe(sum2); + expect(sum2).toBe(sum3); + expect(sum3).toBe(sum4); + expect(sum1).toBe(34); // (1+16+8+9) + }); + }); + + describe("32-Team Bracket", () => { + it("generates 16 matches", () => { + const pairs = generateStandardSeeding(32); + expect(pairs).toHaveLength(16); + }); + + it("ensures seed 1 plays seed 32", () => { + const pairs = generateStandardSeeding(32); + const firstMatch = pairs[0]; + + const seed1 = firstMatch[0] + 1; + const seed2 = firstMatch[1] + 1; + + expect([seed1, seed2].sort()).toEqual([1, 32]); + }); + + it("uses all 32 seeds exactly once", () => { + const pairs = generateStandardSeeding(32); + const allSeeds = pairs.flat().map(s => s + 1).sort((a, b) => a - b); + + expect(allSeeds).toEqual(Array.from({ length: 32 }, (_, i) => i + 1)); + }); + }); + + describe("Non-Standard Sizes", () => { + it("returns sequential pairs for non-power-of-2 sizes", () => { + const pairs = generateStandardSeeding(68); + + // For NCAA 68, should return sequential until custom logic is implemented + expect(pairs[0]).toEqual([0, 1]); + expect(pairs[1]).toEqual([2, 3]); + expect(pairs).toHaveLength(34); + }); + }); + + describe("Seed Info Display", () => { + it("formats seed info correctly for 16-team bracket", () => { + const pairs = generateStandardSeeding(16); + + // Verify seed info would be displayed correctly (1-indexed for users) + const match1SeedInfo = `${pairs[0][0] + 1} vs ${pairs[0][1] + 1}`; + expect(match1SeedInfo).toBe("1 vs 16"); + + // Check that all seed info strings are formatted correctly + const allSeedInfo = pairs.map(([a, b]) => `${a + 1} vs ${b + 1}`); + expect(allSeedInfo).toHaveLength(8); + + // Each should be "N vs M" format where N + M = 17 + allSeedInfo.forEach(info => { + expect(info).toMatch(/^\d+ vs \d+$/); + }); + }); + }); +}); diff --git a/app/models/playoff-match.ts b/app/models/playoff-match.ts index a918773..1e9c328 100644 --- a/app/models/playoff-match.ts +++ b/app/models/playoff-match.ts @@ -252,9 +252,56 @@ export async function advanceWinner( await updatePlayoffMatch(nextMatch.id, { [participantSlot]: winnerId }); } +/** + * Generate standard tournament seeding matchups + * Returns pairs of seed indices for proper bracket balance + * + * Examples: + * - 4 teams: [[0,3], [1,2]] = 1v4, 2v3 + * - 8 teams: [[0,7], [3,4], [1,6], [2,5]] = 1v8, 4v5, 2v7, 3v6 + * - 16 teams: [[0,15], [7,8], [4,11], [3,12], [5,10], [2,13], [6,9], [1,14]] + * = 1v16, 8v9, 5v12, 4v13, 6v11, 3v14, 7v10, 2v15 + */ +function generateStandardSeeding(teamCount: number): [number, number][] { + if (![4, 8, 16, 32].includes(teamCount)) { + // For non-standard sizes (like NCAA 68, NFL 14), return sequential for now + // Those will need custom seeding logic in their respective phases + const pairs: [number, number][] = []; + for (let i = 0; i < teamCount; i += 2) { + pairs.push([i, i + 1]); + } + return pairs; + } + + // Standard balanced bracket seeding + const rounds = Math.log2(teamCount); + let seeds = [0, 1]; // Start with 1 vs 2 + + // Build up seeding by adding rounds + for (let round = 1; round < rounds; round++) { + const newSeeds: number[] = []; + const maxSeed = Math.pow(2, round + 1) - 1; + + for (const seed of seeds) { + newSeeds.push(seed); + newSeeds.push(maxSeed - seed); + } + seeds = newSeeds; + } + + // Convert to pairs (odd indices vs even indices) + const pairs: [number, number][] = []; + for (let i = 0; i < seeds.length; i += 2) { + pairs.push([seeds[i], seeds[i + 1]]); + } + + return pairs; +} + /** * Generate a bracket from a template * Phase 2.6: Template-based bracket generation + * Phase 2.7: Added proper tournament seeding * * @param eventId - The scoring event ID * @param templateId - The bracket template ID (e.g., "simple_16", "ncaa_68") @@ -279,22 +326,29 @@ export async function generateBracketFromTemplate( } const matches: NewPlayoffMatch[] = []; - let participantIndex = 0; // Generate matches for each round in the template for (const round of template.rounds) { - const teamsInRound = round.matchCount * 2; + // Only assign participants to the first round if participantIds provided + let seedingPairs: [number, number][] = []; + + if (participantIds && round === template.rounds[0]) { + const firstRoundTeams = round.matchCount * 2; + seedingPairs = generateStandardSeeding(firstRoundTeams); + } for (let i = 0; i < round.matchCount; i++) { - // Determine if we should assign participants to this match let participant1Id: string | null = null; let participant2Id: string | null = null; + let seedInfo: string | null = null; - // Only assign participants to the first round if participantIds provided - if (participantIds && round === template.rounds[0]) { - participant1Id = participantIds[participantIndex] || null; - participant2Id = participantIds[participantIndex + 1] || null; - participantIndex += 2; + if (participantIds && round === template.rounds[0] && seedingPairs[i]) { + const [seed1Index, seed2Index] = seedingPairs[i]; + participant1Id = participantIds[seed1Index] || null; + participant2Id = participantIds[seed2Index] || null; + + // Store seed info for display (1-indexed for users) + seedInfo = `${seed1Index + 1} vs ${seed2Index + 1}`; } matches.push({ @@ -306,7 +360,7 @@ export async function generateBracketFromTemplate( isComplete: false, isScoring: round.isScoring, templateRound: round.name, - seedInfo: null, // Can be set manually later for display (e.g., "1 vs 16") + seedInfo, }); } } diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 5b4e649..f2ceb62 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -21,6 +21,8 @@ export type ScoringPattern = * system automatically shares positions * * Q20: Participants eliminated before Elite Eight get 0 points + * + * Phase 2.7: Updated to support template-based brackets with non-scoring rounds */ export async function processPlayoffEvent( eventId: string, @@ -52,15 +54,22 @@ export async function processPlayoffEvent( throw new Error(`Event ${eventId} is not a playoff event`); } - // Get all matches for this event + // Get all matches for this event in the specified round const matches = await db.query.playoffMatches.findMany({ - where: eq(schema.playoffMatches.scoringEventId, eventId), + where: and( + eq(schema.playoffMatches.scoringEventId, eventId), + eq(schema.playoffMatches.round, event.playoffRound) + ), orderBy: (matches, { asc }) => [asc(matches.matchNumber)], }); // Process based on round const round = event.playoffRound; + // Check if this is a scoring round + // First try to get isScoring from the match (template-based) + const isScoring = matches[0]?.isScoring ?? true; // Default to true for legacy brackets + // Get season scoring rules for the first affected season // (all seasons should have their own rules, but we use the event's sports season to find one) const seasonSport = event.sportsSeason.seasonSports[0]; @@ -73,7 +82,21 @@ export async function processPlayoffEvent( throw new Error(`Scoring rules not found for season ${seasonSport.seasonId}`); } - if (round === "Finals") { + // If this is a non-scoring round, award 0 points (Q20) + if (!isScoring) { + for (const match of matches) { + if (match.loserId) { + await upsertParticipantResult( + match.loserId, + event.sportsSeasonId, + 0, // 0 placement = 0 points for early elimination + db + ); + } + } + } + // Otherwise, process scoring rounds based on standard playoff logic + else if (round === "Finals" || round === "Championship" || round === "Super Bowl" || round === "NBA Finals") { // Winner gets 1st, loser gets 2nd const finalMatch = matches[0]; if (!finalMatch || !finalMatch.winnerId || !finalMatch.loserId) { @@ -95,7 +118,7 @@ export async function processPlayoffEvent( 2, db ); - } else if (round === "Semifinals") { + } else if (round === "Semifinals" || round === "Final Four" || round === "Conference Finals" || round === "Conference Championship") { // Losers share 3rd/4th for (const match of matches) { if (match.loserId) { @@ -107,7 +130,7 @@ export async function processPlayoffEvent( ); } } - } else if (round === "Quarterfinals") { + } else if (round === "Quarterfinals" || round === "Elite Eight" || round === "Divisional" || round === "Conference Semifinals") { // Losers share 5th-8th for (const match of matches) { if (match.loserId) { diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts index 0835a4a..240f488 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts @@ -2,15 +2,16 @@ import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.br import { redirect } from "react-router"; import { findSportsSeasonById } from "~/models/sports-season"; import { findParticipantsBySportsSeasonId } from "~/models/participant"; -import { getScoringEventById } from "~/models/scoring-event"; +import { getScoringEventById, updateScoringEvent } from "~/models/scoring-event"; import { findPlayoffMatchesByEventId, - generateSingleEliminationBracket, + generateBracketFromTemplate, setMatchWinner, - advanceWinner, + advanceWinnerTemplate, findPlayoffMatchById, } from "~/models/playoff-match"; import { processPlayoffEvent } from "~/models/scoring-calculator"; +import { getBracketTemplate } from "~/lib/bracket-templates"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq } from "drizzle-orm"; @@ -56,16 +57,20 @@ export async function action({ request, params }: Route.ActionArgs) { const intent = formData.get("intent"); if (intent === "generate-bracket") { - const bracketSize = formData.get("bracketSize"); + const templateId = formData.get("templateId"); - if (bracketSize !== "4" && bracketSize !== "8") { - return { error: "Invalid bracket size" }; + if (typeof templateId !== "string" || !templateId) { + return { error: "Template ID is required" }; + } + + const template = getBracketTemplate(templateId); + if (!template) { + return { error: "Invalid bracket template" }; } - const size = parseInt(bracketSize, 10); const participantIds: string[] = []; - for (let i = 0; i < size; i++) { + for (let i = 0; i < template.totalTeams; i++) { const participantId = formData.get(`participant${i}`); if (typeof participantId !== "string" || !participantId) { return { error: `Participant ${i + 1} is required` }; @@ -80,7 +85,14 @@ export async function action({ request, params }: Route.ActionArgs) { } try { - await generateSingleEliminationBracket(params.eventId, participantIds); + await generateBracketFromTemplate(params.eventId, templateId, participantIds); + + // Update the event to store the template ID and scoring start round + await updateScoringEvent(params.eventId, { + bracketTemplateId: templateId, + scoringStartsAtRound: template.scoringStartsAtRound, + }); + return { success: "Bracket generated successfully" }; } catch (error) { console.error("Error generating bracket:", error); @@ -111,6 +123,12 @@ export async function action({ request, params }: Route.ActionArgs) { return { error: "Match not found" }; } + // Get the event to determine the template + const event = await getScoringEventById(match.scoringEventId); + if (!event) { + return { error: "Event not found" }; + } + // Determine loser const loserId = match.participant1Id === winnerId @@ -124,19 +142,22 @@ export async function action({ request, params }: Route.ActionArgs) { // Set the winner await setMatchWinner(matchId, winnerId, loserId); - // Try to advance the winner to the next round (if not Finals) - if (match.round !== "Finals") { - try { - await advanceWinner(matchId, winnerId); - } catch (error) { - // Only ignore "already filled" errors, re-throw unexpected errors - if ( - error instanceof Error && - error.message.includes("already filled") - ) { - console.warn("Match already filled, skipping advancement"); - } else { - throw error; // Re-throw unexpected errors + // Try to advance the winner to the next round using template + if (event.bracketTemplateId) { + const template = getBracketTemplate(event.bracketTemplateId); + if (template) { + try { + await advanceWinnerTemplate(matchId, winnerId, template); + } catch (error) { + // Only ignore "already filled" errors, re-throw unexpected errors + if ( + error instanceof Error && + error.message.includes("already filled") + ) { + console.warn("Match already filled, skipping advancement"); + } else { + throw error; // Re-throw unexpected errors + } } } } @@ -151,15 +172,114 @@ export async function action({ request, params }: Route.ActionArgs) { } } + if (intent === "set-round-winners") { + const round = formData.get("round"); + + if (typeof round !== "string" || !round) { + return { error: "Round is required" }; + } + + try { + // Get all winner assignments from form data + const winnerAssignments: Array<{ matchId: string; winnerId: string }> = []; + + for (const [key, value] of formData.entries()) { + if (key.startsWith("winner-") && typeof value === "string") { + const matchId = key.replace("winner-", ""); + winnerAssignments.push({ matchId, winnerId: value }); + } + } + + if (winnerAssignments.length === 0) { + return { error: "No winners selected" }; + } + + // Get the event to determine the template + const event = await getScoringEventById(params.eventId); + if (!event) { + return { error: "Event not found" }; + } + + const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null; + + // Process each winner assignment + let successCount = 0; + const errors: string[] = []; + + for (const { matchId, winnerId } of winnerAssignments) { + try { + const match = await findPlayoffMatchById(matchId); + if (!match) { + errors.push(`Match ${matchId} not found`); + continue; + } + + // Determine loser + const loserId = + match.participant1Id === winnerId + ? match.participant2Id + : match.participant1Id; + + if (!loserId) { + errors.push(`Could not determine loser for match ${match.matchNumber}`); + continue; + } + + // Set the winner + await setMatchWinner(matchId, winnerId, loserId); + + // Try to advance the winner to the next round using template + if (template) { + try { + await advanceWinnerTemplate(matchId, winnerId, template); + } catch (error) { + // Only ignore "already filled" errors + if ( + error instanceof Error && + error.message.includes("already filled") + ) { + console.warn("Match already filled, skipping advancement"); + } else { + throw error; + } + } + } + + successCount++; + } catch (error) { + console.error(`Error setting winner for match ${matchId}:`, error); + errors.push( + `Match ${matchId}: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + if (errors.length > 0 && successCount === 0) { + return { error: `Failed to set winners: ${errors.join(", ")}` }; + } + + if (errors.length > 0) { + return { + success: `Set ${successCount} winner(s) successfully`, + error: `Some errors occurred: ${errors.join(", ")}`, + }; + } + + return { success: `Successfully set ${successCount} winner(s) for ${round}` }; + } catch (error) { + console.error("Error setting round winners:", error); + return { + error: + error instanceof Error ? error.message : "Failed to set winners", + }; + } + } + if (intent === "complete-round") { const round = formData.get("round"); - if ( - round !== "Quarterfinals" && - round !== "Semifinals" && - round !== "Finals" - ) { - return { error: "Invalid round" }; + if (typeof round !== "string" || !round) { + return { error: "Round is required" }; } try { @@ -171,7 +291,12 @@ export async function action({ request, params }: Route.ActionArgs) { // Verify all matches in this round are complete const matches = await findPlayoffMatchesByEventId(params.eventId); + + // Validate that the round exists in this event's matches const roundMatches = matches.filter((m) => m.round === round); + if (roundMatches.length === 0) { + return { error: `Round "${round}" not found in this bracket` }; + } const allComplete = roundMatches.every((m) => m.isComplete); if (!allComplete) { diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx index 4e94392..8d0a8c4 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx @@ -1,4 +1,5 @@ import { Form, Link } from "react-router"; +import { useState, useEffect } from "react"; import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket"; import { loader, action } from "./admin.sports-seasons.$id.events.$eventId.bracket.server"; import { Button } from "~/components/ui/button"; @@ -19,6 +20,7 @@ import { SelectValue, } from "~/components/ui/select"; import { ArrowLeft, Plus, Trophy } from "lucide-react"; +import { getAllBracketTemplates, getBracketTemplate } from "~/lib/bracket-templates"; import { Table, TableBody, @@ -35,6 +37,60 @@ export default function EventBracket({ actionData, }: Route.ComponentProps) { const { sportsSeason, event, participants, matches } = loaderData; + const [selectedTemplate, setSelectedTemplate] = useState("simple_8"); + const [selectedParticipants, setSelectedParticipants] = useState>({}); + const [selectedWinners, setSelectedWinners] = useState>({}); + const templates = getAllBracketTemplates(); + const template = templates.find((t) => t.id === selectedTemplate); + + // Clear selected winners after successful batch submission + useEffect(() => { + if (actionData?.success) { + setSelectedWinners({}); + } + }, [actionData?.success]); + + // Reset selected participants when template changes + const handleTemplateChange = (newTemplateId: string) => { + setSelectedTemplate(newTemplateId); + setSelectedParticipants({}); + }; + + // Update selected participant for a seed + const handleParticipantChange = (seedIndex: number, participantId: string) => { + setSelectedParticipants(prev => ({ + ...prev, + [seedIndex]: participantId + })); + }; + + // Get available participants for a specific seed (excluding already selected) + const getAvailableParticipants = (currentSeedIndex: number) => { + const selectedIds = Object.entries(selectedParticipants) + .filter(([seedIdx]) => Number(seedIdx) !== currentSeedIndex) + .map(([_, id]) => id); + + return participants.filter( + (p: { id: string; name: string }) => !selectedIds.includes(p.id) + ); + }; + + // Update selected winner for a match + const handleWinnerChange = (matchId: string, winnerId: string) => { + setSelectedWinners(prev => ({ + ...prev, + [matchId]: winnerId + })); + }; + + // Get matches with pending winner selections in a round + const getPendingWinnersInRound = (round: string) => { + return Object.entries(selectedWinners) + .filter(([matchId]) => { + const match = matches.find((m: any) => m.id === matchId); + return match && match.round === round && !match.isComplete; + }); + }; // Group matches by round const matchesByRound: Record = {}; @@ -45,8 +101,26 @@ export default function EventBracket({ matchesByRound[match.round].push(match); } - const rounds = ["Quarterfinals", "Semifinals", "Finals"]; - const availableRounds = rounds.filter(r => matchesByRound[r]); + // Get available rounds in chronological order (first round to finals) + const getOrderedRounds = () => { + const roundsInMatches = Array.from(new Set(matches.map(m => m.round))); + + // If event has a bracket template, use its round order + if (event.bracketTemplateId) { + const eventTemplate = getBracketTemplate(event.bracketTemplateId); + if (eventTemplate) { + // Filter template rounds to only those that have matches + return eventTemplate.rounds + .map((r) => r.name) + .filter((roundName: string) => roundsInMatches.includes(roundName)); + } + } + + // Fallback: return rounds as they appear + return roundsInMatches; + }; + + const availableRounds = getOrderedRounds(); // Get participants not yet in any match const participantsInMatches = new Set( @@ -103,37 +177,60 @@ export default function EventBracket({
- - + - 4 teams (Semifinals + Finals) - 8 teams (Quarterfinals + Semifinals + Finals) + {templates.map((t) => ( + + {t.name} - {t.totalTeams} teams + + ))} + {template && ( +

+ Rounds: {template.rounds.map(r => r.name).join(" → ")} +

+ )}

- Select {8} participants for the bracket + Select {template?.totalTeams || 8} participants for the bracket

- {[...Array(8)].map((_, i) => ( - - ))} + {[...Array(template?.totalTeams || 8)].map((_, i) => { + const availableParticipants = getAvailableParticipants(i); + + return ( + + ); + })}
- + ) : match.isComplete ? ( Complete ) : ( @@ -231,6 +324,20 @@ export default function EventBracket({ ))} + + {/* Batch Submit Winners */} + {getPendingWinnersInRound(round).length > 0 && ( +
+ + + {getPendingWinnersInRound(round).map(([matchId, winnerId]) => ( + + ))} + +
+ )} ))} @@ -255,7 +362,7 @@ export default function EventBracket({ - {availableRounds.map(round => ( + {availableRounds.map((round: string) => ( {round} diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts index 2ba8bb2..db7b58a 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts @@ -13,6 +13,7 @@ import { type CreateEventResultData, type UpdateEventResultData, } from "~/models/event-result"; +import { findParticipantResultsBySportsSeasonId } from "~/models/participant-result"; export async function loader({ params }: Route.LoaderArgs) { const sportsSeason = await findSportsSeasonById(params.id); @@ -29,6 +30,7 @@ export async function loader({ params }: Route.LoaderArgs) { const participants = await findParticipantsBySportsSeasonId(params.id); const results = await getEventResults(params.eventId); + const participantResults = await findParticipantResultsBySportsSeasonId(params.id); return { sportsSeason: sportsSeason as typeof sportsSeason & { @@ -37,6 +39,7 @@ export async function loader({ params }: Route.LoaderArgs) { event, participants, results, + participantResults, }; } diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.tsx index d59e294..17e0edb 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.tsx @@ -35,7 +35,7 @@ export default function EventResults({ loaderData, actionData, }: Route.ComponentProps) { - const { sportsSeason, event, participants, results } = loaderData; + const { sportsSeason, event, participants, results, participantResults } = loaderData; // Create a map of participants with results for easy lookup const participantResultsMap = new Map( @@ -288,6 +288,79 @@ export default function EventResults({ )} + + {/* Participant Results with Fantasy Points */} + {participantResults && participantResults.length > 0 && ( + + + Fantasy Points Awarded + + Points calculated from bracket placements (sorted by position) + + + + + + + Final Position + Participant + Fantasy Points + + + + {[...participantResults] + .sort((a: any, b: any) => { + const posA = a.finalPosition ?? 999; + const posB = b.finalPosition ?? 999; + return posA - posB; + }) + .map((result: any) => ( + + +
+ {result.finalPosition === 1 && ( + 🥇 + )} + {result.finalPosition === 2 && ( + 🥈 + )} + {result.finalPosition === 3 && ( + 🥉 + )} + + {result.finalPosition === 0 ? ( + Early Elimination + ) : ( + `${result.finalPosition}${ + result.finalPosition === 1 + ? "st" + : result.finalPosition === 2 + ? "nd" + : result.finalPosition === 3 + ? "rd" + : "th" + }` + )} + +
+
+ {result.participant.name} + + {result.qualifyingPoints ? ( + + {parseFloat(result.qualifyingPoints).toFixed(0)} pts + + ) : ( + - + )} + +
+ ))} +
+
+
+
+ )} diff --git a/plans/phase-2.7-testing-instructions.md b/plans/phase-2.7-testing-instructions.md new file mode 100644 index 0000000..0c8ce16 --- /dev/null +++ b/plans/phase-2.7-testing-instructions.md @@ -0,0 +1,431 @@ +# Phase 2.7 Testing Instructions + +**Phase:** 2.7 - Simple Template Expansion (16, 32 teams) +**Status:** Completed +**Date:** 2025-01-04 + +## Overview + +Phase 2.7 adds support for larger bracket templates (16-team and 32-team) with non-scoring rounds. Only participants who reach the Elite Eight (top 8) score fantasy points. Early eliminations receive 0 points per Q20. + +## Phase 2.7 Testing Checklist + +### 1. **Bracket Generation UI** (`/admin/sports-seasons/:id/events/:eventId/bracket`) + +**Test the template selector:** +- [ ] Navigate to a playoff event that has no bracket yet +- [ ] See a "Bracket Template" dropdown with all options: + - Simple 4-Team Bracket - 4 teams + - Simple 8-Team Bracket - 8 teams + - Simple 16-Team Bracket - 16 teams + - Simple 32-Team Bracket - 32 teams + - NCAA March Madness (68 teams) - 68 teams + - NFL Playoffs (14 teams) - 14 teams + - NBA Playoffs (16 teams) - 16 teams +- [ ] Change template selection - participant count should update dynamically +- [ ] See round progression text (e.g., "Rounds: Round of 16 → Quarterfinals → Semifinals → Finals") + +### 2. **16-Team Bracket Creation** + +**Generate a 16-team bracket:** +- [ ] Select "Simple 16-Team Bracket" +- [ ] Fill in 16 participants (seeds 1-16) +- [ ] Click "Generate Bracket" +- [ ] Verify success message + +**Check bracket structure:** +- [ ] See "Round of 16" section with 8 matches +- [ ] See "Quarterfinals" section with 4 matches (TBD participants) +- [ ] See "Semifinals" section with 2 matches (TBD participants) +- [ ] See "Finals" section with 1 match (TBD participants) + +**Complete Round of 16:** +- [ ] Set winners for all 8 matches +- [ ] Winners should auto-advance to Quarterfinals +- [ ] Click "Complete Round & Calculate Placements" +- [ ] Select "Round of 16" from dropdown +- [ ] Verify **8 losers get 0 fantasy points** (early elimination per Q20) + +**Complete Quarterfinals:** +- [ ] Set winners for all 4 matches +- [ ] Winners advance to Semifinals +- [ ] Complete the round +- [ ] Verify **4 losers share 5th-8th place** (20 points each with default scoring) + +**Complete Semifinals:** +- [ ] Set winners for 2 matches +- [ ] Winners advance to Finals +- [ ] Complete the round +- [ ] Verify **2 losers share 3rd-4th place** (45 points each) + +**Complete Finals:** +- [ ] Set winner +- [ ] Complete the round +- [ ] Verify **winner gets 1st** (100 points), **loser gets 2nd** (70 points) +- [ ] Navigate back to event detail page (click "Back to Event" or navigate to `/admin/sports-seasons/{id}/events/{eventId}`) +- [ ] See new "Fantasy Points Awarded" card showing all participant results with positions and points +- [ ] Verify early eliminations show "Early Elimination" with 0 points +- [ ] Verify top 8 finishers show correct positions (1st-8th) with correct point totals + +### 3. **Batch Winner Selection (NEW)** + +**Test the batch winner selection UI:** +- [ ] Navigate to any generated bracket (16 or 32-team) +- [ ] See winner dropdowns for each incomplete match +- [ ] Select a winner for one match - verify no submit button appears yet +- [ ] Select winners for 2-3 matches in a round +- [ ] Verify "Save X Winner(s) for [Round]" button appears +- [ ] Click the batch save button +- [ ] Verify success message shows correct count +- [ ] Verify all selected winners are set +- [ ] Verify winners auto-advance to next round +- [ ] Verify winner dropdowns are cleared after submission + +**Test error handling:** +- [ ] Try to submit with no winners selected - should not see button +- [ ] Select winners for matches in different rounds - each round has separate button +- [ ] Test partial success scenario (if possible to trigger) + +**Test workflow efficiency:** +- [ ] Set all 8 winners for Round of 16 at once +- [ ] Verify all 8 winners advance to Quarterfinals correctly +- [ ] Set all 4 Quarterfinals winners at once +- [ ] Verify advancement and placement calculation + +### 4. **32-Team Bracket Creation** + +**Generate a 32-team bracket:** +- [ ] Select "Simple 32-Team Bracket" +- [ ] Fill in 32 participants +- [ ] Generate bracket +- [ ] See 5 rounds: Round of 32 → Round of 16 → Quarterfinals → Semifinals → Finals + +**Test non-scoring rounds:** +- [ ] Complete Round of 32 (16 matches) +- [ ] Verify **16 losers get 0 points** (non-scoring round) +- [ ] Complete Round of 16 (8 matches) +- [ ] Verify **8 losers get 0 points** (non-scoring round) +- [ ] Complete Quarterfinals through Finals +- [ ] Verify scoring matches Q20: Only top 8 finishers get points + +**Final tally check:** +- [ ] Total participants: 32 +- [ ] Participants with 0 points: 24 (Round of 32 + Round of 16 losers) +- [ ] Participants scoring: 8 (QF losers through Champion) + +### 4. **Template-Based Advancement** + +**Verify auto-advancement works:** +- [ ] In any multi-round bracket, set a winner in Round 1 +- [ ] Check that winner appears in correct slot in Round 2 +- [ ] Match advancement pattern: + - Match 1 winner → Next round Match 1, Participant 1 + - Match 2 winner → Next round Match 1, Participant 2 + - Match 3 winner → Next round Match 2, Participant 1 + - Match 4 winner → Next round Match 2, Participant 2 + +### 5. **Scoring Calculator Integration** + +**Check participant results:** +- [ ] After completing a round, check database `participantResults` table +- [ ] Non-scoring round losers should have `finalPosition = 0` +- [ ] Scoring round participants should have correct placements (1-8) + +**Verify team standings update:** +- [ ] If you have teams that drafted these participants +- [ ] Check that standings recalculate after each round completion +- [ ] Early elimination participants (0 points) should not add to team score + +### 6. **Error Handling** + +**Test validation:** +- [ ] Try to generate bracket without selecting all participants → error +- [ ] Try to select same participant twice → "Each participant can only be selected once" +- [ ] Try to set winner when participants aren't assigned → should be disabled +- [ ] Try to complete round with incomplete matches → error + +### 7. **Backward Compatibility** + +**Test existing 4 and 8 team brackets:** +- [ ] Generate a "Simple 4-Team Bracket" → should work as before +- [ ] Generate a "Simple 8-Team Bracket" → should work as before +- [ ] All rounds should be scoring rounds for these templates +- [ ] No 0-point eliminations (all 4 or 8 participants score) + +## Viewing Fantasy Points (NEW) + +After completing rounds and calculating placements, you can view the fantasy points awarded: + +**Navigation:** +1. From the bracket management page, click "Back to Event" +2. Or navigate to `/admin/sports-seasons/{sportsSeasonId}/events/{eventId}` + +**Fantasy Points Awarded Card:** +- Shows all participants with calculated fantasy points +- Sorted by final position (1st place at top) +- Displays position with medals (🥇🥈🥉) for top 3 +- Shows "Early Elimination" for participants with 0 points (early knockout) +- Points displayed in green for scoring participants, gray for 0 points + +**Example Display:** +``` +Final Position | Participant | Fantasy Points +1st 🥇 | Arizona | 100 pts +2nd 🥈 | UConn | 70 pts +3rd 🥉 | Houston | 45 pts +4th | Duke | 45 pts +5th | Kansas | 20 pts +6th | Purdue | 20 pts +7th | Tennessee | 20 pts +8th | North Carolina | 20 pts +Early Elim. | UCLA | 0 pts +Early Elim. | Alabama | 0 pts +... +``` + +**Note:** Team standings (total points across all events) are not yet displayed in the UI but are being calculated and stored in the database. + +## Key Expected Behaviors + +### Non-Scoring Rounds (Q20) +- Round of 16 in 16-team bracket: 8 teams get 0 points +- Round of 32 + Round of 16 in 32-team bracket: 24 teams get 0 points +- Early eliminations don't count toward team standings + +### Scoring Rounds +- Only Quarterfinals and beyond score fantasy points +- Always exactly 8 participants score (1st through 8th) +- Point distribution per default scoring: + - 1st: 100, 2nd: 70 + - 3rd/4th (avg): 45 each + - 5th-8th (avg): 20 each + - Total: 340 points + +### Template Storage +- Check `scoringEvents` table has `bracketTemplateId` stored +- Check `scoringEvents` table has `scoringStartsAtRound` stored +- Check `playoffMatches` have `isScoring` flag set correctly + +## Things NOT to Test Yet (Phase 2.8+) + +- ❌ NCAA 68-team bracket generation (won't assign participants correctly) +- ❌ NFL 14-team with bye weeks +- ❌ First Four play-in games +- ❌ TBD slot handling + +## Database Verification Queries + +```sql +-- Check event has template ID stored +SELECT id, name, bracketTemplateId, scoringStartsAtRound +FROM scoring_events +WHERE id = 'your-event-id'; + +-- Check matches have isScoring flag +SELECT round, matchNumber, isScoring, participant1Id, participant2Id, winnerId +FROM playoff_matches +WHERE scoringEventId = 'your-event-id' +ORDER BY round, matchNumber; + +-- Check participant results after round completion +SELECT p.name, pr.finalPosition, pr.qualifyingPoints +FROM participant_results pr +JOIN participants p ON p.id = pr.participantId +WHERE pr.sportsSeasonId = 'your-sports-season-id' +ORDER BY pr.finalPosition; + +-- Check team standings +SELECT t.name, ts.totalPoints, ts.currentRank, + ts.firstPlaceCount, ts.secondPlaceCount, ts.thirdPlaceCount, ts.fourthPlaceCount, + ts.fifthPlaceCount, ts.sixthPlaceCount, ts.seventhPlaceCount, ts.eighthPlaceCount +FROM team_standings ts +JOIN teams t ON t.id = ts.teamId +WHERE ts.seasonId = 'your-season-id' +ORDER BY ts.currentRank; +``` + +## Bug Fixes + +### Bug #1: Round of 16 and other non-standard rounds not displaying (Fixed) +**Issue:** When creating a 16-team bracket, only Quarterfinals, Semifinals, and Finals were displayed. Round of 16 was missing. + +**Root Cause:** +- Client component had hardcoded rounds array: `["Quarterfinals", "Semifinals", "Finals"]` +- Server validation also had hardcoded round checking + +**Fix:** +- Changed to dynamically get rounds from actual matches: `Array.from(new Set(matches.map(m => m.round)))` +- Updated server validation to check if round exists in matches instead of hardcoding allowed values +- Files changed: + - `app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx` (line 54) + - `app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts` (lines 178-196) + +**Status:** ✅ Fixed and tested. + +### Bug #2: Sequential seeding instead of tournament-style seeding (Fixed) +**Issue:** When creating a 16-team bracket with participants seeded 1-16, the bracket showed: +- Match 1: Seed 1 vs Seed 2 (Arizona vs Atlanta) +- Match 2: Seed 3 vs Seed 4 +- etc. + +Instead of proper tournament seeding: +- Match 1: Seed 1 vs Seed 16 +- Match 2: Seed 8 vs Seed 9 +- etc. + +**Root Cause:** +- Bracket generation assigned participants sequentially instead of using standard tournament seeding algorithm + +**Fix:** +- Added `generateStandardSeeding()` function that creates balanced bracket matchups +- Algorithm ensures: + - All matchups sum to n+1 (e.g., 1v16, 2v15, 3v14 all sum to 17 for 16 teams) + - Top seeds are separated into different bracket regions + - Balanced pods for each quarterfinal section +- Added `seedInfo` field to store display text (e.g., "1 vs 16") +- Works for 4, 8, 16, and 32-team brackets +- Non-standard sizes (68, 14) fall back to sequential until custom logic is implemented + +**Files changed:** +- `app/models/playoff-match.ts` (lines 255-369) +- New test file: `app/models/__tests__/bracket-seeding.test.ts` (14 tests) + +**Status:** ✅ Fixed and tested. All 221 tests passing. + +### QOL Improvement: Duplicate participant prevention (Implemented) +**Feature:** When selecting participants for bracket seeds, once a participant is selected for one seed, they are automatically removed from the dropdown options for all other seeds. + +**How it works:** +- Each seed dropdown only shows participants that haven't been selected yet +- If you select "Arizona" as Seed 1, "Arizona" disappears from all other seed dropdowns +- Changing the bracket template resets all selections +- Real-time updates as you make selections + +**Benefits:** +- Prevents duplicate selections (the server already validated this, but now it's prevented in the UI) +- Clearer UX - you can see which participants are still available +- Faster bracket setup - no need to remember which teams you've already picked + +**Files changed:** +- `app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx` (lines 40-68, 141, 167-190) + - Added `selectedParticipants` state to track selections + - Added `handleTemplateChange` to reset selections on template change + - Added `handleParticipantChange` to update selections + - Added `getAvailableParticipants` to filter out selected participants + - Updated Select components to use controlled state and filtered options + +**Status:** ✅ Implemented and tested. All 221 tests passing. + +### QOL Improvement #2: Batch winner selection (Implemented) +**Feature:** Instead of having individual "Set" buttons for each match, users can now select winners for multiple matches in a round and submit them all with a single button click. + +**How it works:** +- Each match in a round has a winner dropdown (controlled component) +- Select winners for one or more matches +- A "Save X Winner(s) for [Round]" button appears at the bottom of the round card +- Click the button to submit all selected winners at once +- Winners are automatically advanced to the next round +- Selected winners are cleared after successful submission + +**Benefits:** +- Faster workflow - set multiple winners at once instead of one at a time +- Better UX - clear visual indication of pending selections +- Fewer page reloads - batch submission reduces server round trips +- Consistent advancement - all winners advance in a single operation + +**Implementation details:** +- Client-side state tracking with `selectedWinners` state +- Server-side batch processing with new "set-round-winners" intent +- Automatic state cleanup after successful submission via useEffect +- Error handling for partial success scenarios + +**Files changed:** +- `app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx` (lines 1-2, 40-51, 294-334) + - Added useEffect import + - Added batch winner state management + - Replaced Form-wrapped Select with controlled Select components + - Added batch submit button per round + - Added useEffect to clear selections after success +- `app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts` (lines 175-276) + - Added "set-round-winners" intent handler + - Processes multiple winner assignments in batch + - Handles partial success with detailed error messages + +**Status:** ✅ Implemented and tested. All 221 tests passing. + +### QOL Improvement #3: Fantasy points display on event page (Implemented) +**Feature:** After completing rounds and calculating placements, users can now view the fantasy points awarded to each participant on the event detail page. + +**How it works:** +- Navigate to the event detail page (`/admin/sports-seasons/{id}/events/{eventId}`) +- See "Fantasy Points Awarded" card with all participant results +- Sorted by final position (1st place at top) +- Shows position, participant name, and fantasy points +- Early eliminations (0 points) clearly marked + +**Benefits:** +- Instant verification of point calculations +- Visual confirmation of bracket completion +- Easy reference for standings verification +- Clear distinction between scoring and non-scoring participants + +**Implementation details:** +- Loader fetches `participant_results` from database +- Includes participant relation for name display +- Client sorts by `finalPosition` ascending +- Displays medals for top 3 finishers +- Color-codes points (green for scoring, gray for 0) + +**Files changed:** +- `app/routes/admin.sports-seasons.$id.events.$eventId.server.ts` (lines 1, 16, 31-43) + - Added import for `findParticipantResultsBySportsSeasonId` + - Added `participantResults` to loader return data +- `app/routes/admin.sports-seasons.$id.events.$eventId.tsx` (lines 38, 292-368) + - Added `participantResults` to destructured loader data + - Added "Fantasy Points Awarded" card with table + - Displays position, participant, and points with proper formatting + +**Status:** ✅ Implemented and tested. All 221 tests passing. + +### Bug #3: Round cards displaying in wrong order (Fixed) +**Issue:** When setting a winner in Round of 16, the round cards would shuffle and display in incorrect order (e.g., "Semifinals, Quarterfinals, Round of 16, Finals" instead of chronological order). + +**Root Cause:** +- Rounds were being displayed in the order they appeared from the database query +- Database ordering was based on match results, not chronological tournament progression +- As matches completed and were updated, the database result order could change + +**Fix:** +- Added `getOrderedRounds()` function that uses the bracket template's round order +- If event has a `bracketTemplateId`, uses the template's chronological round order +- Filters to only show rounds that have matches +- Falls back to database order if no template is available + +**How it works now:** +1. Get unique rounds from matches +2. If event has bracket template, get template +3. Use template's round order (first round → finals) +4. Filter to only rounds that exist in matches +5. Display cards in correct chronological order + +**Example:** +- **16-team bracket:** Round of 16 → Quarterfinals → Semifinals → Finals +- **32-team bracket:** Round of 32 → Round of 16 → Quarterfinals → Semifinals → Finals +- Order remains stable even as matches are completed + +**Files changed:** +- `app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx` (lines 23, 79-98, 221, 330) + - Imported `getBracketTemplate` + - Added `getOrderedRounds()` function + - Updated round mapping to include type annotations + +**Status:** ✅ Fixed and tested. All 221 tests passing. + +## Known Issues + +None at this time. All 221 tests passing (207 original + 14 seeding tests). + +## Notes + +- Phase 2.8 will add full NCAA 68-team bracket support with First Four play-in games +- NFL 14-team and NBA 16-team templates exist but need bye week logic (Phase 2.9) diff --git a/plans/scoring-system.md b/plans/scoring-system.md index 2d7cd77..8745daf 100644 --- a/plans/scoring-system.md +++ b/plans/scoring-system.md @@ -1111,13 +1111,14 @@ scoring_events { - [x] Add `advanceWinnerTemplate()` function for template-based advancement - [x] Test suite for template structures (16 tests passing) -- [ ] **2.7** Simple Template Expansion (16, 32 teams) ⏭️ **NEXT** +- [x] **2.7** Simple Template Expansion (16, 32 teams) ✅ *Completed* - [x] Create template definitions for 16 and 32 team brackets (already done in 2.6) - - [ ] Update bracket generation UI to use template selector - - [ ] Test with 16-team bracket (Round of 16 → QF → SF → Finals) - - [ ] Test with 32-team bracket (Round of 32 → Round of 16 → QF → SF → Finals) - - [ ] Update scoring calculator for non-scoring rounds (0 points per Q20) - - [ ] Verify Elite Eight scoring starts correctly + - [x] Update bracket generation UI to use template selector + - [x] Update server action to use `generateBracketFromTemplate` and `advanceWinnerTemplate` + - [x] Update scoring calculator for non-scoring rounds (0 points per Q20) + - [x] Create comprehensive integration tests (19 tests covering 16, 32, and NCAA 68 brackets) + - [x] Verify Elite Eight scoring starts correctly + - [x] All 207 tests passing including new bracket integration tests - [ ] **2.8** March Madness Template (68 teams) - [ ] Create NCAA_68 template definition