Add bracket seeding tests and implement tournament-style seeding for 16 and 32-team brackets
- Introduced `generateStandardSeeding()` function to create proper tournament matchups (e.g., 1v16, 8v9). - Added comprehensive tests for 4, 8, 16, and 32-team brackets to ensure correct seeding and balance. - Updated testing instructions to reflect new features and improvements in bracket generation. - Fixed issues with sequential seeding and ensured all matchups sum to n+1 for balance. - Implemented batch winner selection and duplicate participant prevention for improved user experience. - Enhanced fantasy points display on event pages for better visibility of participant results.
This commit is contained in:
parent
da1a17f2d3
commit
dfe4b7b643
10 changed files with 1515 additions and 93 deletions
395
app/models/__tests__/bracket-integration.test.ts
Normal file
395
app/models/__tests__/bracket-integration.test.ts
Normal file
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
210
app/models/__tests__/bracket-seeding.test.ts
Normal file
210
app/models/__tests__/bracket-seeding.test.ts
Normal file
|
|
@ -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+$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<Record<number, string>>({});
|
||||
const [selectedWinners, setSelectedWinners] = useState<Record<string, string>>({});
|
||||
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<string, typeof matches> = {};
|
||||
|
|
@ -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({
|
|||
<input type="hidden" name="intent" value="generate-bracket" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bracketSize">Bracket Size</Label>
|
||||
<Select name="bracketSize" defaultValue="8" required>
|
||||
<SelectTrigger id="bracketSize">
|
||||
<Label htmlFor="templateId">Bracket Template</Label>
|
||||
<Select
|
||||
name="templateId"
|
||||
value={selectedTemplate}
|
||||
onValueChange={handleTemplateChange}
|
||||
required
|
||||
>
|
||||
<SelectTrigger id="templateId">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="4">4 teams (Semifinals + Finals)</SelectItem>
|
||||
<SelectItem value="8">8 teams (Quarterfinals + Semifinals + Finals)</SelectItem>
|
||||
{templates.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name} - {t.totalTeams} teams
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{template && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Rounds: {template.rounds.map(r => r.name).join(" → ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Select Participants (in order)</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select {8} participants for the bracket
|
||||
Select {template?.totalTeams || 8} participants for the bracket
|
||||
</p>
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<Select key={i} name={`participant${i}`} required>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`Seed ${i + 1}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{participants.map((participant: { id: string; name: string }) => (
|
||||
<SelectItem key={participant.id} value={participant.id}>
|
||||
{participant.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
))}
|
||||
{[...Array(template?.totalTeams || 8)].map((_, i) => {
|
||||
const availableParticipants = getAvailableParticipants(i);
|
||||
|
||||
return (
|
||||
<Select
|
||||
key={i}
|
||||
name={`participant${i}`}
|
||||
value={selectedParticipants[i] || ""}
|
||||
onValueChange={(value) => handleParticipantChange(i, value)}
|
||||
required
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`Seed ${i + 1}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableParticipants.map((participant: { id: string; name: string }) => (
|
||||
<SelectItem key={participant.id} value={participant.id}>
|
||||
{participant.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
|
|
@ -146,7 +243,7 @@ export default function EventBracket({
|
|||
)}
|
||||
|
||||
{/* Bracket Display */}
|
||||
{availableRounds.map(round => (
|
||||
{availableRounds.map((round: string) => (
|
||||
<Card key={round}>
|
||||
<CardHeader>
|
||||
<CardTitle>{round}</CardTitle>
|
||||
|
|
@ -201,26 +298,22 @@ export default function EventBracket({
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
{!match.isComplete && match.participant1Id && match.participant2Id ? (
|
||||
<Form method="post" className="inline">
|
||||
<input type="hidden" name="intent" value="set-winner" />
|
||||
<input type="hidden" name="matchId" value={match.id} />
|
||||
<Select name="winnerId" required>
|
||||
<SelectTrigger className="h-8 w-full">
|
||||
<SelectValue placeholder="Set winner" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={match.participant1Id}>
|
||||
{match.participant1?.name}
|
||||
</SelectItem>
|
||||
<SelectItem value={match.participant2Id}>
|
||||
{match.participant2?.name}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="submit" size="sm" className="ml-2 h-8">
|
||||
Set
|
||||
</Button>
|
||||
</Form>
|
||||
<Select
|
||||
value={selectedWinners[match.id] || ""}
|
||||
onValueChange={(value) => handleWinnerChange(match.id, value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full">
|
||||
<SelectValue placeholder="Select winner" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={match.participant1Id}>
|
||||
{match.participant1?.name}
|
||||
</SelectItem>
|
||||
<SelectItem value={match.participant2Id}>
|
||||
{match.participant2?.name}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : match.isComplete ? (
|
||||
<span className="text-sm text-muted-foreground">Complete</span>
|
||||
) : (
|
||||
|
|
@ -231,6 +324,20 @@ export default function EventBracket({
|
|||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Batch Submit Winners */}
|
||||
{getPendingWinnersInRound(round).length > 0 && (
|
||||
<Form method="post" className="mt-4">
|
||||
<input type="hidden" name="intent" value="set-round-winners" />
|
||||
<input type="hidden" name="round" value={round} />
|
||||
{getPendingWinnersInRound(round).map(([matchId, winnerId]) => (
|
||||
<input key={matchId} type="hidden" name={`winner-${matchId}`} value={winnerId} />
|
||||
))}
|
||||
<Button type="submit" className="w-full">
|
||||
Save {getPendingWinnersInRound(round).length} Winner{getPendingWinnersInRound(round).length > 1 ? 's' : ''} for {round}
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
|
@ -255,7 +362,7 @@ export default function EventBracket({
|
|||
<SelectValue placeholder="Select round" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableRounds.map(round => (
|
||||
{availableRounds.map((round: string) => (
|
||||
<SelectItem key={round} value={round}>
|
||||
{round}
|
||||
</SelectItem>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Participant Results with Fantasy Points */}
|
||||
{participantResults && participantResults.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Fantasy Points Awarded</CardTitle>
|
||||
<CardDescription>
|
||||
Points calculated from bracket placements (sorted by position)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-32">Final Position</TableHead>
|
||||
<TableHead>Participant</TableHead>
|
||||
<TableHead className="w-32 text-right">Fantasy Points</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...participantResults]
|
||||
.sort((a: any, b: any) => {
|
||||
const posA = a.finalPosition ?? 999;
|
||||
const posB = b.finalPosition ?? 999;
|
||||
return posA - posB;
|
||||
})
|
||||
.map((result: any) => (
|
||||
<TableRow key={result.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{result.finalPosition === 1 && (
|
||||
<span className="text-xl">🥇</span>
|
||||
)}
|
||||
{result.finalPosition === 2 && (
|
||||
<span className="text-xl">🥈</span>
|
||||
)}
|
||||
{result.finalPosition === 3 && (
|
||||
<span className="text-xl">🥉</span>
|
||||
)}
|
||||
<span className="font-semibold">
|
||||
{result.finalPosition === 0 ? (
|
||||
<span className="text-muted-foreground">Early Elimination</span>
|
||||
) : (
|
||||
`${result.finalPosition}${
|
||||
result.finalPosition === 1
|
||||
? "st"
|
||||
: result.finalPosition === 2
|
||||
? "nd"
|
||||
: result.finalPosition === 3
|
||||
? "rd"
|
||||
: "th"
|
||||
}`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{result.participant.name}</TableCell>
|
||||
<TableCell className="text-right font-semibold">
|
||||
{result.qualifyingPoints ? (
|
||||
<span className={result.qualifyingPoints === "0.00" ? "text-muted-foreground" : "text-green-600 dark:text-green-400"}>
|
||||
{parseFloat(result.qualifyingPoints).toFixed(0)} pts
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
431
plans/phase-2.7-testing-instructions.md
Normal file
431
plans/phase-2.7-testing-instructions.md
Normal file
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue