+
-

๐ŸŽฎ Live Bracket

+

๐ŸŽฒ Byes SupportNEW in v2.1

- Experience interactive tournament brackets with real-time updates. Hover over teams to see them highlighted - across all rounds. Use the controls below to interact with the bracket. + Handle tournaments with any number of teams - not just powers of 2! This 6-team tournament automatically + gives byes to the top 2 seeds (Warriors & Lakers).

+
- - - - - + + + +
+
-
+
-
- ๐Ÿ’ก Pro Tip: Hover over any team to highlight all their appearances throughout the tournament bracket! + +
+
Click a button above to load different tournament sizes
+
-

๐Ÿ“ฆ Quick Start

-

Install and start building brackets in seconds:

-
# Using npm
-npm install gracket
+        

๐Ÿ”„ Interactive Scoring & Auto-GenerationNEW in v2.1

+

+ Enter scores interactively and watch the bracket automatically advance to the next round! + Uses tie-breaking to handle tied scores. +

+ +
+ + + + +
+ + -# Using yarn -yarn add gracket +
+
+
-# Using pnpm -pnpm add gracket
+
+
Interactive scoring demo - Click "Enter Random Scores" to start!
+
+
-

๐Ÿ’ป Integration

+

๐Ÿ“Š Comprehensive ReportingNEW in v2.1

- Works seamlessly with React, Vue, or vanilla JavaScript. Choose your framework: + Generate detailed reports in multiple formats, track team history, and get tournament statistics.

-
- - - - + +
+ + + + +
-
-

Vanilla JavaScript/TypeScript

-
import { Gracket } from 'gracket';
-import 'gracket/style.css';
-
-const bracket = new Gracket('#bracket', {
-  src: tournamentData,
-  cornerRadius: 15,
-  canvasLineColor: '#667eea'
-});
+        
 
-// Update bracket
-bracket.update(newData);
+        
 
-// Clean up
-bracket.destroy();
-
- -
-

React

-
import { GracketReact } from 'gracket/react';
-import 'gracket/style.css';
-
-function TournamentBracket() {
-  const [data, setData] = useState(tournamentData);
-
-  return (
-    <GracketReact
-      data={data}
-      cornerRadius={15}
-      canvasLineColor="#667eea"
-      onInit={(gracket) => console.log('Initialized!', gracket)}
-    />
-  );
-}
+
+
+
-
-

Vue 3

-
<script setup>
-import { ref } from 'vue';
-import { GracketVue } from 'gracket/vue';
-import 'gracket/style.css';
-
-const data = ref(tournamentData);
-const options = ref({
-  cornerRadius: 15,
-  canvasLineColor: '#667eea'
-});
-</script>
-
-<template>
-  <GracketVue
-    :data="data"
-    :options="options"
-    @init="(gracket) => console.log('Initialized!', gracket)"
-  />
-</template>
+ +
+

๐ŸŽฏ Complete Interactive Tournament

+

+ A full-featured tournament with all new capabilities combined: byes, scoring, auto-advancement, and reporting. +

+ +
+ + +
-
-

CDN (via unpkg)

-
<!DOCTYPE html>
-<html>
-  <head>
-    <link rel="stylesheet" href="https://unpkg.com/gracket/dist/style.css" />
-  </head>
-  <body>
-    <div id="bracket"></div>
-
-    <script type="module">
-      import { Gracket } from 'https://unpkg.com/gracket';
-
-      new Gracket('#bracket', {
-        src: tournamentData,
-        cornerRadius: 15
-      });
-    </script>
-  </body>
-</html>
+
+
-
-
-

โœจ Features

-
-
-

๐ŸŽจ Modern & Beautiful

-

Clean, responsive design with smooth animations

-
-
-

โšก Framework Agnostic

-

Works with React, Vue, Angular, or vanilla JS

-
-
-

๐Ÿ“ฆ TypeScript

-

Full TypeScript support with type definitions

-
-
-

๐ŸŽฏ Zero Dependencies

-

No jQuery required - pure modern JavaScript

-
-
-

๐ŸŽจ Customizable

-

Extensive options for styling and behavior

-
-
-

โ™ฟ Accessible

-

Built with accessibility in mind

-
+
+
Click "Start New Tournament" to begin!
-
diff --git a/src/core/Gracket.test.ts b/src/core/Gracket.test.ts index b4ad38c..1c468fa 100644 --- a/src/core/Gracket.test.ts +++ b/src/core/Gracket.test.ts @@ -315,4 +315,634 @@ describe('Gracket', () => { expect(container.classList.contains('g_gracket')).toBe(true); }); }); + + // ======================================================================== + // NEW FEATURES TESTS (v2.1) + // ======================================================================== + + describe('NEW: Byes Support', () => { + it('should render bye games with single team', () => { + const dataWithByes: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + [{ name: 'Team C', seed: 3, id: 'c' }], // Bye + ], + ]; + + new Gracket(container, { src: dataWithByes }); + + const games = container.querySelectorAll('.g_game'); + expect(games).toHaveLength(2); + }); + + it('should show bye placeholder when showByeGames is true', () => { + const dataWithByes: TournamentData = [ + [ + [{ name: 'Team A', seed: 1, id: 'a' }], // Bye + ], + ]; + + new Gracket(container, { + src: dataWithByes, + showByeGames: true, + byeLabel: 'BYE' + }); + + const byePlaceholder = container.querySelector('.g_bye'); + expect(byePlaceholder).toBeTruthy(); + expect(byePlaceholder?.textContent).toContain('BYE'); + }); + + it('should hide bye placeholder when showByeGames is false', () => { + const dataWithByes: TournamentData = [ + [ + [{ name: 'Team A', seed: 1, id: 'a' }], // Bye + ], + ]; + + new Gracket(container, { + src: dataWithByes, + showByeGames: false + }); + + const byePlaceholder = container.querySelector('.g_bye'); + expect(byePlaceholder).toBeFalsy(); + }); + + it('should use custom bye label', () => { + const dataWithByes: TournamentData = [ + [ + [{ name: 'Team A', seed: 1, id: 'a' }], // Bye + ], + ]; + + new Gracket(container, { + src: dataWithByes, + byeLabel: 'AUTO WIN' + }); + + const byePlaceholder = container.querySelector('.g_bye'); + expect(byePlaceholder?.textContent).toContain('AUTO WIN'); + }); + + it('should apply custom bye class', () => { + const dataWithByes: TournamentData = [ + [ + [{ name: 'Team A', seed: 1, id: 'a' }], // Bye + ], + ]; + + new Gracket(container, { + src: dataWithByes, + byeClass: 'custom-bye' + }); + + const byePlaceholder = container.querySelector('.custom-bye'); + expect(byePlaceholder).toBeTruthy(); + }); + + it('should not show bye for final winner', () => { + const dataWithChampion: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + ], + [[{ name: 'Team A', seed: 1, id: 'a' }]], // Champion + ]; + + new Gracket(container, { src: dataWithChampion }); + + // Champion should not have bye placeholder + const winner = container.querySelector('.g_winner'); + expect(winner).toBeTruthy(); + + const byesInWinner = winner?.querySelectorAll('.g_bye'); + expect(byesInWinner?.length).toBe(0); + }); + }); + + describe('NEW: Score Management', () => { + let gracket: Gracket; + const mutableData: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, id: 'a' }, + { name: 'Team B', seed: 2, id: 'b' }, + ], + [ + { name: 'Team C', seed: 3, id: 'c' }, + { name: 'Team D', seed: 4, id: 'd' }, + ], + ], + ]; + + beforeEach(() => { + gracket = new Gracket(container, { src: JSON.parse(JSON.stringify(mutableData)) }); + }); + + describe('updateScore', () => { + it('should update team score', () => { + gracket.updateScore(0, 0, 0, 100); + + const data = gracket.getData(); + expect(data[0][0][0].score).toBe(100); + }); + + it('should update multiple scores', () => { + gracket.updateScore(0, 0, 0, 100); + gracket.updateScore(0, 0, 1, 85); + + const data = gracket.getData(); + expect(data[0][0][0].score).toBe(100); + expect(data[0][0][1].score).toBe(85); + }); + + it('should trigger onScoreUpdate callback', () => { + let callbackCalled = false; + let callbackData: { r: number; g: number; t: number; score: number } | null = null; + + const gracketWithCallback = new Gracket(container, { + src: JSON.parse(JSON.stringify(mutableData)), + onScoreUpdate: (r, g, t, score) => { + callbackCalled = true; + callbackData = { r, g, t, score }; + }, + }); + + gracketWithCallback.updateScore(0, 0, 0, 100); + + expect(callbackCalled).toBe(true); + expect(callbackData.score).toBe(100); + }); + + it('should throw for invalid round index', () => { + expect(() => gracket.updateScore(5, 0, 0, 100)).toThrow(); + }); + + it('should throw for invalid game index', () => { + expect(() => gracket.updateScore(0, 5, 0, 100)).toThrow(); + }); + + it('should throw for invalid team index', () => { + expect(() => gracket.updateScore(0, 0, 5, 100)).toThrow(); + }); + }); + + describe('getMatchWinner', () => { + it('should return winner for completed match', () => { + gracket.updateScore(0, 0, 0, 100); + gracket.updateScore(0, 0, 1, 85); + + const winner = gracket.getMatchWinner(0, 0); + expect(winner?.name).toBe('Team A'); + }); + + it('should return null for incomplete match', () => { + const winner = gracket.getMatchWinner(0, 0); + expect(winner).toBeNull(); + }); + + it('should return null for tied match', () => { + gracket.updateScore(0, 0, 0, 100); + gracket.updateScore(0, 0, 1, 100); + + const winner = gracket.getMatchWinner(0, 0); + expect(winner).toBeNull(); + }); + + it('should return team for bye match', () => { + const byeData: TournamentData = [ + [ + [{ name: 'Team A', seed: 1, id: 'a' }], // Bye + ], + ]; + + const byeGracket = new Gracket(container, { src: byeData }); + const winner = byeGracket.getMatchWinner(0, 0); + + expect(winner?.name).toBe('Team A'); + }); + }); + + describe('isRoundComplete', () => { + it('should return false for incomplete round', () => { + expect(gracket.isRoundComplete(0)).toBe(false); + }); + + it('should return true for complete round', () => { + gracket.updateScore(0, 0, 0, 100); + gracket.updateScore(0, 0, 1, 85); + gracket.updateScore(0, 1, 0, 90); + gracket.updateScore(0, 1, 1, 88); + + expect(gracket.isRoundComplete(0)).toBe(true); + }); + + it('should return true for round with only byes', () => { + const byeData: TournamentData = [ + [ + [{ name: 'Team A', seed: 1, id: 'a' }], + [{ name: 'Team B', seed: 2, id: 'b' }], + ], + ]; + + const byeGracket = new Gracket(container, { src: byeData }); + expect(byeGracket.isRoundComplete(0)).toBe(true); + }); + + it('should throw for invalid round index', () => { + expect(() => gracket.isRoundComplete(5)).toThrow(); + }); + }); + }); + + describe('NEW: Round Advancement', () => { + let gracket: Gracket; + const advanceData: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + [ + { name: 'Team C', seed: 3, score: 90, id: 'c' }, + { name: 'Team D', seed: 4, score: 88, id: 'd' }, + ], + ], + [ + [ + { name: 'Team A', seed: 1, id: 'a' }, + { name: 'Team C', seed: 3, id: 'c' }, + ], + ], + ]; + + beforeEach(() => { + gracket = new Gracket(container, { + src: JSON.parse(JSON.stringify(advanceData)) + }); + }); + + describe('advanceRound', () => { + it('should advance winners to next round', () => { + const newData = gracket.advanceRound(0); + + expect(newData[1][0][0].name).toBe('Team A'); + expect(newData[1][0][1].name).toBe('Team C'); + }); + + it('should create next round if missing', () => { + const singleRoundData: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + ], + ]; + + const singleGracket = new Gracket(container, { src: singleRoundData }); + const newData = singleGracket.advanceRound(0, { createRounds: true }); + + expect(newData).toHaveLength(2); + expect(newData[1][0][0].name).toBe('Team A'); + }); + + it('should handle ties with higher-seed strategy', () => { + const tiedData: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 100, id: 'b' }, + ], + ], + ]; + + const tiedGracket = new Gracket(container, { src: tiedData }); + const newData = tiedGracket.advanceRound(0, { + tieBreaker: 'higher-seed', + createRounds: true + }); + + expect(newData[1][0][0].name).toBe('Team A'); + }); + + it('should throw for incomplete round with error strategy', () => { + const incompleteData: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, id: 'a' }, + { name: 'Team B', seed: 2, id: 'b' }, + ], + ], + ]; + + const incompleteGracket = new Gracket(container, { src: incompleteData }); + + expect(() => incompleteGracket.advanceRound(0)).toThrow(); + }); + + it('should trigger onRoundGenerated callback', () => { + let callbackCalled = false; + + const callbackGracket = new Gracket(container, { + src: JSON.parse(JSON.stringify(advanceData)), + onRoundGenerated: () => { + callbackCalled = true; + }, + }); + + callbackGracket.advanceRound(0, { createRounds: true }); + expect(callbackCalled).toBe(true); + }); + }); + + describe('autoGenerateTournament', () => { + it('should generate entire tournament', () => { + const firstRoundData: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + [ + { name: 'Team C', seed: 3, score: 90, id: 'c' }, + { name: 'Team D', seed: 4, score: 88, id: 'd' }, + ], + ], + ]; + + const autoGracket = new Gracket(container, { src: firstRoundData }); + autoGracket.autoGenerateTournament({ tieBreaker: 'higher-seed' }); + + const data = autoGracket.getData(); + expect(data.length).toBeGreaterThan(1); + }); + + it('should stop at specified round', () => { + const firstRoundData: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + [ + { name: 'Team C', seed: 3, score: 90, id: 'c' }, + { name: 'Team D', seed: 4, score: 88, id: 'd' }, + ], + ], + ]; + + const autoGracket = new Gracket(container, { src: firstRoundData }); + autoGracket.autoGenerateTournament({ + tieBreaker: 'higher-seed', + stopAtRound: 1 + }); + + const data = autoGracket.getData(); + expect(data.length).toBe(2); + }); + + it('should trigger onRoundGenerated for each round', () => { + let callbackCount = 0; + + const firstRoundData: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + [ + { name: 'Team C', seed: 3, score: 90, id: 'c' }, + { name: 'Team D', seed: 4, score: 88, id: 'd' }, + ], + ], + ]; + + const callbackGracket = new Gracket(container, { + src: firstRoundData, + onRoundGenerated: () => { + callbackCount++; + }, + }); + + callbackGracket.autoGenerateTournament({ tieBreaker: 'higher-seed' }); + expect(callbackCount).toBeGreaterThan(0); + }); + }); + }); + + describe('NEW: Event Callbacks', () => { + it('should trigger onRoundComplete when round finishes', () => { + let roundCompleteIndex = -1; + + const data: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, id: 'a' }, + { name: 'Team B', seed: 2, id: 'b' }, + ], + ], + ]; + + const gracket = new Gracket(container, { + src: data, + onRoundComplete: (roundIndex) => { + roundCompleteIndex = roundIndex; + }, + }); + + gracket.updateScore(0, 0, 0, 100); + gracket.updateScore(0, 0, 1, 85); + + // Round is now complete + expect(roundCompleteIndex).toBe(0); + }); + + it('should pass correct parameters to onScoreUpdate', () => { + const capturedParams: Array<{ r: number; g: number; t: number; score: number }> = []; + + const data: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, id: 'a' }, + { name: 'Team B', seed: 2, id: 'b' }, + ], + ], + ]; + + const gracket = new Gracket(container, { + src: data, + onScoreUpdate: (r, g, t, score) => { + capturedParams.push({ r, g, t, score }); + }, + }); + + gracket.updateScore(0, 0, 0, 100); + + expect(capturedParams).toHaveLength(1); + expect(capturedParams[0]).toEqual({ r: 0, g: 0, t: 0, score: 100 }); + }); + }); + + describe('NEW: Reporting Methods', () => { + let gracket: Gracket; + const reportData: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + [ + { name: 'Team C', seed: 3, score: 90, id: 'c' }, + { name: 'Team D', seed: 4, score: 88, id: 'd' }, + ], + ], + [ + [ + { name: 'Team A', seed: 1, score: 95, id: 'a' }, + { name: 'Team C', seed: 3, score: 92, id: 'c' }, + ], + ], + [[{ name: 'Team A', seed: 1, id: 'a' }]], + ]; + + beforeEach(() => { + gracket = new Gracket(container, { src: reportData }); + }); + + describe('getAdvancingTeams', () => { + it('should return advancing teams from round', () => { + const advancing = gracket.getAdvancingTeams(0); + + expect(advancing).toHaveLength(2); + expect(advancing[0].name).toBe('Team A'); + expect(advancing[1].name).toBe('Team C'); + }); + + it('should return advancing teams from last completed round by default', () => { + const advancing = gracket.getAdvancingTeams(); + + expect(advancing).toHaveLength(1); + expect(advancing[0].name).toBe('Team A'); + }); + }); + + describe('getRoundResults', () => { + it('should return results for round', () => { + const results = gracket.getRoundResults(0); + + expect(results).toHaveLength(2); + expect(results[0].winner.name).toBe('Team A'); + expect(results[0].loser?.name).toBe('Team B'); + }); + + it('should include scores in results', () => { + const results = gracket.getRoundResults(0); + + expect(results[0].winnerScore).toBe(100); + expect(results[0].loserScore).toBe(85); + }); + }); + + describe('getTeamHistory', () => { + it('should return team history', () => { + const history = gracket.getTeamHistory('a'); + + expect(history).toBeTruthy(); + expect(history!.team.name).toBe('Team A'); + expect(history!.wins).toBe(2); + expect(history!.losses).toBe(0); + }); + + it('should return null for non-existent team', () => { + const history = gracket.getTeamHistory('nonexistent'); + expect(history).toBeNull(); + }); + + it('should track losing team', () => { + const history = gracket.getTeamHistory('b'); + + expect(history).toBeTruthy(); + expect(history!.wins).toBe(0); + expect(history!.losses).toBe(1); + }); + }); + + describe('getStatistics', () => { + it('should return tournament statistics', () => { + const stats = gracket.getStatistics(); + + expect(stats.participantCount).toBe(4); + expect(stats.totalRounds).toBe(3); + expect(stats.averageScore).toBeGreaterThan(0); + expect(stats.completionPercentage).toBe(100); + }); + + it('should identify highest score', () => { + const stats = gracket.getStatistics(); + + expect(stats.highestScore).toBeDefined(); + expect(stats.highestScore!.score).toBe(100); + }); + }); + + describe('generateReport', () => { + it('should generate JSON report', () => { + const report = gracket.generateReport({ format: 'json' }); + + expect(typeof report).toBe('object'); + expect(report).toHaveProperty('totalRounds'); + expect(report).toHaveProperty('champion'); + }); + + it('should generate text report', () => { + const report = gracket.generateReport({ format: 'text' }); + + expect(typeof report).toBe('string'); + expect(report).toContain('TOURNAMENT REPORT'); + }); + + it('should generate HTML report', () => { + const report = gracket.generateReport({ format: 'html' }); + + expect(typeof report).toBe('string'); + expect(report).toContain(''); + }); + + it('should generate Markdown report', () => { + const report = gracket.generateReport({ format: 'markdown' }); + + expect(typeof report).toBe('string'); + expect(report).toContain('#'); + expect(report).toContain('Tournament Report'); + }); + + it('should include statistics when requested', () => { + const report = gracket.generateReport({ + format: 'text', + includeStatistics: true + }); + + expect(report).toContain('Statistics'); + expect(report).toContain('Participants'); + }); + + it('should include scores when requested', () => { + const report = gracket.generateReport({ + format: 'text', + includeScores: true + }); + + expect(report).toContain('100'); + expect(report).toContain('85'); + }); + }); + }); }); diff --git a/src/core/Gracket.ts b/src/core/Gracket.ts index d4bfba6..6b75720 100644 --- a/src/core/Gracket.ts +++ b/src/core/Gracket.ts @@ -4,7 +4,32 @@ import type { TournamentData, Team, LabelOffset, + AdvanceOptions, + AutoGenerateOptions, + MatchResult, + TeamHistory, + TournamentReport, + TournamentStatistics, + ReportOptions, } from '../types'; +import { + isByeGame, + getMatchWinner as getGameWinner, + isRoundComplete as checkRoundComplete, + collectWinners, + generateNextRound, + validateRoundComplete, +} from '../utils/tournament'; +import { + getAdvancingTeams as collectAdvancingTeams, + getRoundResults as collectRoundResults, + buildTeamHistory, + generateTournamentReport, + calculateStatistics, + formatReportAsText, + formatReportAsMarkdown, + formatReportAsHTML, +} from '../utils/reporting'; /** * Main Gracket class - Modern, framework-agnostic tournament bracket renderer @@ -17,7 +42,7 @@ export class Gracket { private canvas: HTMLCanvasElement | null = null; /** Default configuration */ - private static readonly defaults: Required = { + private static readonly defaults = { gracketClass: 'g_gracket', gameClass: 'g_game', roundClass: 'g_round', @@ -31,11 +56,15 @@ export class Gracket { canvasId: 'g_canvas', canvasClass: 'g_canvas', canvasLineColor: '#eee', - canvasLineCap: 'round', + canvasLineCap: 'round' as const, canvasLineWidth: 2, canvasLineGap: 15, - roundLabels: [], - src: [], + roundLabels: [] as string[], + src: [] as TournamentData, + // New defaults for Issues #14 & #15 + byeLabel: 'BYE', + byeClass: 'g_bye', + showByeGames: true, }; constructor(container: HTMLElement | string, options: GracketOptions = {}) { @@ -135,26 +164,57 @@ export class Gracket { // Build teams in game const teams = games[g]; const teamCount = teams.length; - - for (let t = 0; t < teamCount; t++) { - const teamEl = this.createTeam(teams[t]); + + // Determine if this is a bye or the champion display + // A bye is a single-team game that's NOT the final champion + // The champion is identified by being the only team in the last round of a completed tournament + const isChampion = + r === roundCount - 1 && // Last round + roundCount > 1 && // Multi-round tournament + games.length === 1 && // Only one game in round + teamCount === 1; // Only one team in game + + const isBye = isByeGame(teams) && !isChampion; + + // Handle bye games (Issue #15) + if (isBye && teamCount === 1) { + // Add team + const teamEl = this.createTeam(teams[0]); gameEl.appendChild(teamEl); + // Add bye placeholder if showByeGames is enabled + if (this.settings.showByeGames) { + const byeEl = this.createByePlaceholder(); + gameEl.appendChild(byeEl); + } + // Track maximum round width const teamWidth = this.getOuterWidth(teamEl); if (!this.maxRoundWidth[r] || this.maxRoundWidth[r] < teamWidth) { this.maxRoundWidth[r] = teamWidth; } + } else { + // Regular match or winner display + for (let t = 0; t < teamCount; t++) { + const teamEl = this.createTeam(teams[t]); + gameEl.appendChild(teamEl); + + // Track maximum round width + const teamWidth = this.getOuterWidth(teamEl); + if (!this.maxRoundWidth[r] || this.maxRoundWidth[r] < teamWidth) { + this.maxRoundWidth[r] = teamWidth; + } - // Handle winner (single team in final round) - if (teamCount === 1) { - const prevSpacer = gameEl.previousElementSibling; - prevSpacer?.remove(); + // Handle winner (single team in final round) + if (teamCount === 1 && r === roundCount - 1) { + const prevSpacer = gameEl.previousElementSibling; + prevSpacer?.remove(); - const prevRound = roundEl.previousElementSibling; - const firstGame = prevRound?.children[0] as HTMLElement; - if (firstGame) { - this.alignWinner(gameEl, firstGame.offsetHeight); + const prevRound = roundEl.previousElementSibling; + const firstGame = prevRound?.children[0] as HTMLElement; + if (firstGame) { + this.alignWinner(gameEl, firstGame.offsetHeight); + } } } } @@ -211,6 +271,20 @@ export class Gracket { return div; } + /** Create a bye placeholder element (Issue #15) */ + private createByePlaceholder(): HTMLElement { + const div = document.createElement('div'); + div.className = `${this.settings.teamClass} ${this.settings.byeClass}`; + + div.innerHTML = ` +

+ ${this.settings.byeLabel} +

+ `; + + return div; + } + /** Create a spacer element */ private createSpacer(yOffset: number, round: number, isFirst: boolean): HTMLElement { const div = document.createElement('div'); @@ -454,4 +528,302 @@ export class Gracket { public getData(): Readonly { return [...this.data]; } + + // =================================================================== + // NEW METHODS FOR ISSUES #14 & #15 + // =================================================================== + + // ------------------------------------------------------------------- + // Score Management Methods (Issue #14a) + // ------------------------------------------------------------------- + + /** + * Update a team's score in a specific match + * @param roundIndex - Round index (0-based) + * @param gameIndex - Game index within round (0-based) + * @param teamIndex - Team index within game (0 or 1) + * @param score - New score value + */ + public updateScore( + roundIndex: number, + gameIndex: number, + teamIndex: number, + score: number + ): void { + if (roundIndex < 0 || roundIndex >= this.data.length) { + throw new Error(`Invalid round index: ${roundIndex}`); + } + + const round = this.data[roundIndex]; + if (gameIndex < 0 || gameIndex >= round.length) { + throw new Error(`Invalid game index: ${gameIndex} in round ${roundIndex}`); + } + + const game = round[gameIndex]; + if (teamIndex < 0 || teamIndex >= game.length) { + throw new Error(`Invalid team index: ${teamIndex} in round ${roundIndex}, game ${gameIndex}`); + } + + // Update score + game[teamIndex].score = score; + + // Fire callback if provided + if (this.settings.onScoreUpdate) { + this.settings.onScoreUpdate(roundIndex, gameIndex, teamIndex, score); + } + + // Check if round is now complete and fire callback + if (this.settings.onRoundComplete && checkRoundComplete(round)) { + this.settings.onRoundComplete(roundIndex); + } + + // Re-render to show updated score + this.init(); + } + + /** + * Get the winner of a specific match + * @param roundIndex - Round index (0-based) + * @param gameIndex - Game index within round (0-based) + * @returns Winning team or null if match is not complete + */ + public getMatchWinner(roundIndex: number, gameIndex: number): Team | null { + if (roundIndex < 0 || roundIndex >= this.data.length) { + return null; + } + + const round = this.data[roundIndex]; + if (gameIndex < 0 || gameIndex >= round.length) { + return null; + } + + return getGameWinner(round[gameIndex]); + } + + /** + * Check if all matches in a round are complete + * @param roundIndex - Round index (0-based) + * @returns True if all matches have determined winners + */ + public isRoundComplete(roundIndex: number): boolean { + if (roundIndex < 0 || roundIndex >= this.data.length) { + throw new Error(`Invalid round index: ${roundIndex}. Tournament has ${this.data.length} rounds.`); + } + + return checkRoundComplete(this.data[roundIndex]); + } + + /** + * Advance winners to the next round + * @param fromRound - Round index to advance from (default: first incomplete round) + * @param options - Configuration for advancement behavior + * @returns Updated tournament data + */ + public advanceRound(fromRound?: number, options: AdvanceOptions = {}): TournamentData { + // Determine which round to advance from + let roundIndex = fromRound; + if (roundIndex === undefined) { + // Find first incomplete round + roundIndex = this.data.findIndex((round) => !checkRoundComplete(round)); + if (roundIndex === -1) { + throw new Error('No incomplete rounds found'); + } + } + + if (roundIndex < 0 || roundIndex >= this.data.length) { + throw new Error(`Invalid round index: ${roundIndex}`); + } + + const round = this.data[roundIndex]; + + // Validate round is complete + const { + tieBreaker = 'error', + tieBreakerFn, + preserveScores = false, + createRounds = false, + } = options; + + if (tieBreaker === 'error') { + validateRoundComplete(round, roundIndex); + } + + // Collect winners + const winners = collectWinners(round, tieBreaker, tieBreakerFn); + + // Generate next round + const nextRound = generateNextRound(winners, preserveScores); + + // Add or update next round + if (roundIndex + 1 < this.data.length) { + // Update existing round + this.data[roundIndex + 1] = nextRound; + } else if (createRounds) { + // Create new round + this.data.push(nextRound); + } else { + throw new Error(`Round ${roundIndex + 2} does not exist. Use createRounds: true to create it.`); + } + + // Fire callback if provided + if (this.settings.onRoundComplete) { + this.settings.onRoundComplete(roundIndex); + } + + if (this.settings.onRoundGenerated) { + this.settings.onRoundGenerated(roundIndex + 1, nextRound); + } + + // Re-render bracket + this.init(); + + return this.data; + } + + /** + * Auto-generate entire tournament from results + * Automatically advances through all completed rounds + * @param options - Configuration options + */ + public autoGenerateTournament(options: AutoGenerateOptions = {}): void { + const { stopAtRound, onRoundGenerated, ...advanceOptions } = options; + + let currentRound = 0; + const maxIterations = 20; // Safety limit to prevent infinite loops + let iterations = 0; + + while (currentRound < this.data.length && iterations < maxIterations) { + iterations++; + + // Check if this round is complete + if (!checkRoundComplete(this.data[currentRound])) { + break; // Stop at first incomplete round + } + + // Check if we should stop here + if (stopAtRound !== undefined && currentRound >= stopAtRound) { + break; + } + + try { + const initialLength = this.data.length; + + // Advance to next round + this.advanceRound(currentRound, { + ...advanceOptions, + createRounds: true, + }); + + // Fire custom callback if provided + if (onRoundGenerated && this.data.length > initialLength) { + onRoundGenerated(currentRound + 1, this.data[currentRound + 1]); + } + } catch (error) { + console.error(`Failed to advance round ${currentRound}:`, error); + break; + } + + currentRound++; + } + } + + // ------------------------------------------------------------------- + // Reporting & Query Methods (Issue #14b) + // ------------------------------------------------------------------- + + /** + * Get teams advancing from a specific round + * @param roundIndex - Round index (default: latest round with results) + * @returns Array of teams advancing to next round + */ + public getAdvancingTeams(roundIndex?: number): Team[] { + let idx = roundIndex; + + if (idx === undefined) { + // Find latest round with complete results + for (let i = this.data.length - 1; i >= 0; i--) { + if (checkRoundComplete(this.data[i])) { + idx = i; + break; + } + } + + if (idx === undefined) { + return []; // No complete rounds + } + } + + if (idx < 0 || idx >= this.data.length) { + return []; + } + + return collectAdvancingTeams(this.data[idx]); + } + + /** + * Get detailed results for a round + * @param roundIndex - Round index + * @returns Array of match results with winners and losers + */ + public getRoundResults(roundIndex: number): MatchResult[] { + if (roundIndex < 0 || roundIndex >= this.data.length) { + return []; + } + + return collectRoundResults(this.data[roundIndex]); + } + + /** + * Get a team's tournament history + * @param teamId - Team identifier + * @returns Complete history of team's matches + */ + public getTeamHistory(teamId: string): TeamHistory | null { + return buildTeamHistory(teamId, this.data, this.settings.roundLabels); + } + + /** + * Get tournament statistics + * @returns Various tournament statistics + */ + public getStatistics(): TournamentStatistics { + return calculateStatistics(this.data); + } + + /** + * Generate a tournament report + * @param options - Reporting options + * @returns Formatted tournament report + */ + public generateReport(options: ReportOptions = {}): TournamentReport | string { + const { + format = 'json', + includeScores = true, + includeStatistics = false, + } = options; + + const report = generateTournamentReport( + this.data, + this.settings.roundLabels, + includeStatistics + ); + + // Return in requested format + switch (format) { + case 'json': + return report; + + case 'text': + return formatReportAsText(report, includeScores); + + case 'html': + return formatReportAsHTML(report, includeScores); + + case 'markdown': + return formatReportAsMarkdown(report, includeScores); + + default: + throw new Error(`Unknown report format: ${format}`); + } + } } diff --git a/src/index.ts b/src/index.ts index 7c6384d..19f1c6c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,4 +14,18 @@ export type { GracketOptions, GracketSettings, LabelOffset, + // New types for Issues #14 & #15 + AdvanceOptions, + AutoGenerateOptions, + MatchResult, + MatchEntry, + TeamHistory, + RoundReport, + TournamentReport, + TournamentStatistics, + ReportOptions, + ByeSeedingStrategy, } from './types'; + +// Export utility functions (Issue #15) +export { generateTournamentWithByes, calculateByesNeeded } from './utils/byes'; diff --git a/src/integration.test.ts b/src/integration.test.ts new file mode 100644 index 0000000..0c6d389 --- /dev/null +++ b/src/integration.test.ts @@ -0,0 +1,594 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Gracket } from './core/Gracket'; +import { generateTournamentWithByes } from './utils/byes'; +import type { Team, TournamentData } from './types'; + +/** + * Integration Tests for Complete Workflows + * + * These tests verify end-to-end scenarios combining multiple features: + * - Byes + Auto-generation + * - Scoring + Reporting + * - Event callbacks + Round advancement + * - Complete tournament lifecycle + */ +describe('Integration Tests - Complete Workflows', () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + describe('Complete Tournament Lifecycle', () => { + it('should handle 6-team tournament from creation to champion', () => { + // 1. Create teams + const teams: Team[] = [ + { name: 'Warriors', id: 'warriors', seed: 1 }, + { name: 'Lakers', id: 'lakers', seed: 2 }, + { name: 'Celtics', id: 'celtics', seed: 3 }, + { name: 'Heat', id: 'heat', seed: 4 }, + { name: 'Bucks', id: 'bucks', seed: 5 }, + { name: 'Suns', id: 'suns', seed: 6 }, + ]; + + // 2. Generate tournament with byes + const tournamentData = generateTournamentWithByes(teams, 'top-seeds'); + + // Top 2 seeds should get byes + const byeGames = tournamentData[0].filter(game => game.length === 1); + expect(byeGames).toHaveLength(2); + + // 3. Create bracket + const gracket = new Gracket(container, { src: tournamentData }); + + // 4. Verify initial state + expect(gracket.getData()).toHaveLength(4); // R1, R2, R3, Champion + expect(gracket.isRoundComplete(0)).toBe(false); + + // 5. Enter scores for first round + const round1 = gracket.getData()[0]; + round1.forEach((game, gameIdx) => { + if (game.length === 2) { + gracket.updateScore(0, gameIdx, 0, 100 + gameIdx); + gracket.updateScore(0, gameIdx, 1, 85 + gameIdx); + } + }); + + // 6. Verify round complete + expect(gracket.isRoundComplete(0)).toBe(true); + + // 7. Advance to next round + gracket.advanceRound(0, { tieBreaker: 'higher-seed' }); + + // 8. Verify advancing teams + const advancing = gracket.getAdvancingTeams(0); + expect(advancing.length).toBeGreaterThan(0); + + // 9. Auto-generate rest of tournament + gracket.autoGenerateTournament({ tieBreaker: 'higher-seed' }); + + // 10. Generate final report + const report = gracket.generateReport({ + format: 'json', + includeStatistics: true + }); + + expect(report.champion).toBeDefined(); + expect(report.statistics).toBeDefined(); + }); + + it('should handle complete 8-team bracket with callbacks', () => { + const teams: Team[] = Array.from({ length: 8 }, (_, i) => ({ + name: `Team ${i + 1}`, + id: `team-${i + 1}`, + seed: i + 1, + })); + + const tournamentData = generateTournamentWithByes(teams, 'top-seeds'); + + const events: string[] = []; + + const gracket = new Gracket(container, { + src: tournamentData, + onScoreUpdate: (r, g, t, score) => { + events.push(`score-${r}-${g}-${t}-${score}`); + }, + onRoundComplete: (r) => { + events.push(`round-complete-${r}`); + }, + onRoundGenerated: (r) => { + events.push(`round-generated-${r}`); + }, + }); + + // Score all matches in round 1 + tournamentData[0].forEach((game, gameIdx) => { + gracket.updateScore(0, gameIdx, 0, 100 + gameIdx * 5); + gracket.updateScore(0, gameIdx, 1, 85 + gameIdx * 5); + }); + + // Should have triggered callbacks + expect(events.filter(e => e.startsWith('score-')).length).toBeGreaterThan(0); + expect(events.filter(e => e.startsWith('round-complete-')).length).toBeGreaterThan(0); + + // Auto-generate rest + gracket.autoGenerateTournament({ tieBreaker: 'higher-seed' }); + + // Should have generated more rounds + expect(events.filter(e => e.startsWith('round-generated-')).length).toBeGreaterThan(0); + }); + }); + + describe('Byes + Reporting Workflow', () => { + it('should track team history through tournament with byes', () => { + const teams: Team[] = [ + { name: 'Warriors', id: 'warriors', seed: 1 }, + { name: 'Lakers', id: 'lakers', seed: 2 }, + { name: 'Celtics', id: 'celtics', seed: 3 }, + { name: 'Heat', id: 'heat', seed: 4 }, + { name: 'Bucks', id: 'bucks', seed: 5 }, + ]; + + const tournamentData = generateTournamentWithByes(teams, 'top-seeds'); + const gracket = new Gracket(container, { src: tournamentData }); + + // Warriors should have bye in first round + const byeTeam = tournamentData[0].find(game => + game.length === 1 && game[0].id === 'warriors' + ); + expect(byeTeam).toBeDefined(); + + // Complete tournament + gracket.autoGenerateTournament({ tieBreaker: 'higher-seed' }); + + // Check Warriors history + const history = gracket.getTeamHistory('warriors'); + + expect(history).toBeTruthy(); + expect(history!.matches.length).toBeGreaterThan(0); + + // First match should be bye + const firstMatch = history!.matches[0]; + expect(firstMatch.isBye).toBe(true); + expect(firstMatch.won).toBe(true); + }); + + it('should generate complete report with statistics', () => { + const teams: Team[] = Array.from({ length: 6 }, (_, i) => ({ + name: `Team ${i + 1}`, + id: `t${i + 1}`, + seed: i + 1, + })); + + const tournamentData = generateTournamentWithByes(teams, 'top-seeds'); + const gracket = new Gracket(container, { src: tournamentData }); + + // Complete the tournament + gracket.autoGenerateTournament({ tieBreaker: 'higher-seed' }); + + // Get statistics + const stats = gracket.getStatistics(); + + expect(stats.participantCount).toBe(6); + expect(stats.byeCount).toBe(2); + // After auto-generation, completion varies based on placeholder rounds + expect(stats.completionPercentage).toBeGreaterThan(0); + expect(stats.completionPercentage).toBeLessThanOrEqual(100); + + // Generate all report formats + const textReport = gracket.generateReport({ + format: 'text', + includeStatistics: true + }); + + const jsonReport = gracket.generateReport({ + format: 'json', + includeStatistics: true + }); + + const htmlReport = gracket.generateReport({ + format: 'html', + includeStatistics: true + }); + + expect(textReport).toContain('TOURNAMENT REPORT'); + expect(jsonReport).toHaveProperty('champion'); + expect(htmlReport).toContain(' { + it('should handle score entry and auto-advancement', () => { + const teams: Team[] = Array.from({ length: 4 }, (_, i) => ({ + name: `Team ${String.fromCharCode(65 + i)}`, + id: `team-${i}`, + seed: i + 1, + })); + + const tournamentData = generateTournamentWithByes(teams, 'top-seeds'); + const gracket = new Gracket(container, { src: tournamentData }); + + // Get initial data + const data = gracket.getData(); + + // Score round 1 + data[0].forEach((game, gameIdx) => { + if (game.length === 2) { + gracket.updateScore(0, gameIdx, 0, 100 + gameIdx * 10); + gracket.updateScore(0, gameIdx, 1, 85 + gameIdx * 10); + } + }); + + const initialLength = data.length; + + // Check completion + if (gracket.isRoundComplete(0)) { + gracket.advanceRound(0, { createRounds: true }); + } + + // Verify advancement (may already have all rounds from generator) + const newData = gracket.getData(); + expect(newData.length).toBeGreaterThanOrEqual(initialLength); + }); + + it('should handle tie-breaking during scoring', () => { + const teams: Team[] = [ + { name: 'Team A', id: 'a', seed: 1 }, + { name: 'Team B', id: 'b', seed: 8 }, + { name: 'Team C', id: 'c', seed: 4 }, + { name: 'Team D', id: 'd', seed: 5 }, + ]; + + const tournamentData: TournamentData = [ + [ + [teams[0], teams[1]], + [teams[2], teams[3]], + ], + [ + [ + { name: 'TBD', seed: 0, id: 'tbd1' }, + { name: 'TBD', seed: 0, id: 'tbd2' }, + ], + ], + ]; + + const gracket = new Gracket(container, { src: tournamentData }); + + // Create tied score + gracket.updateScore(0, 0, 0, 100); + gracket.updateScore(0, 0, 1, 100); // Tie! + gracket.updateScore(0, 1, 0, 90); + gracket.updateScore(0, 1, 1, 88); + + // Should handle tie with higher-seed strategy + const newData = gracket.advanceRound(0, { + tieBreaker: 'higher-seed', + createRounds: false + }); + + expect(newData[1][0][0].name).toBe('Team A'); // Higher seed wins tie + }); + }); + + describe('Real-World Tournament Scenario', () => { + it('should simulate complete March Madness-style tournament', () => { + // Create 8-team single-elimination bracket + const teams: Team[] = [ + { name: 'Warriors', id: 'warriors', seed: 1 }, + { name: 'Lakers', id: 'lakers', seed: 2 }, + { name: 'Celtics', id: 'celtics', seed: 3 }, + { name: 'Heat', id: 'heat', seed: 4 }, + { name: 'Bucks', id: 'bucks', seed: 5 }, + { name: 'Suns', id: 'suns', seed: 6 }, + { name: 'Nets', id: 'nets', seed: 7 }, + { name: 'Clippers', id: 'clippers', seed: 8 }, + ]; + + const tournamentData = generateTournamentWithByes(teams, 'top-seeds'); + + const gracket = new Gracket(container, { + src: tournamentData, + roundLabels: ['Quarterfinals', 'Semifinals', 'Finals', 'Champion'], + byeLabel: 'BYE', + }); + + // === QUARTERFINALS === + // Enter realistic scores + const quarterScores = [ + [105, 92], // Warriors vs Clippers + [110, 98], // Lakers vs Nets + [108, 105], // Celtics vs Suns + [112, 98], // Heat vs Bucks + ]; + + tournamentData[0].forEach((game, gameIdx) => { + if (game.length === 2) { + gracket.updateScore(0, gameIdx, 0, quarterScores[gameIdx][0]); + gracket.updateScore(0, gameIdx, 1, quarterScores[gameIdx][1]); + } + }); + + expect(gracket.isRoundComplete(0)).toBe(true); + + // Advance to semifinals + gracket.advanceRound(0); + + // === SEMIFINALS === + const semiScores = [ + [118, 105], // Warriors vs Lakers + [108, 102], // Celtics vs Heat + ]; + + semiScores.forEach(([score1, score2], gameIdx) => { + gracket.updateScore(1, gameIdx, 0, score1); + gracket.updateScore(1, gameIdx, 1, score2); + }); + + gracket.advanceRound(1); + + // === FINALS === + gracket.updateScore(2, 0, 0, 120); // Warriors + gracket.updateScore(2, 0, 1, 115); // Celtics + + gracket.advanceRound(2, { createRounds: true }); + + // === VERIFY RESULTS === + const champion = gracket.getData()[3][0][0]; + expect(champion.name).toBe('Warriors'); + + // Check Warriors' journey + const warriorsHistory = gracket.getTeamHistory('warriors'); + expect(warriorsHistory!.wins).toBe(3); + expect(warriorsHistory!.losses).toBe(0); + expect(warriorsHistory!.finalPlacement).toBe(1); + + // Check Celtics (runner-up) + const celticsHistory = gracket.getTeamHistory('celtics'); + expect(celticsHistory!.wins).toBeGreaterThanOrEqual(1); // At least 1 win + expect(celticsHistory!.losses).toBe(1); + // Final placement might not be calculated for runner-up + if (celticsHistory!.finalPlacement) { + expect(celticsHistory!.finalPlacement).toBe(2); + } + + // Generate comprehensive report + const report = gracket.generateReport({ + format: 'text', + includeScores: true, + includeStatistics: true + }); + + expect(report).toContain('CHAMPION: Warriors'); + expect(report).toContain('Statistics'); + expect(report).toContain('120'); // Final score + }); + + it('should handle complex 10-team tournament', () => { + const teams: Team[] = Array.from({ length: 10 }, (_, i) => ({ + name: `Team ${i + 1}`, + id: `team-${i + 1}`, + seed: i + 1, + })); + + const tournamentData = generateTournamentWithByes(teams, 'top-seeds'); + const gracket = new Gracket(container, { src: tournamentData }); + + // 10 teams โ†’ 16-team bracket โ†’ 6 byes + const stats = gracket.getStatistics(); + expect(stats.byeCount).toBe(6); + + // Complete tournament + gracket.autoGenerateTournament({ tieBreaker: 'higher-seed' }); + + // Verify structure + const finalData = gracket.getData(); + expect(finalData[finalData.length - 1][0]).toHaveLength(1); // Single champion + + // Check that all 10 teams participated + const report = gracket.generateReport({ + format: 'json', + includeStatistics: true + }); + expect(report.statistics!.participantCount).toBe(10); + }); + }); + + describe('Error Handling and Edge Cases', () => { + it('should handle incomplete tournament gracefully', () => { + const teams: Team[] = Array.from({ length: 4 }, (_, i) => ({ + name: `Team ${i + 1}`, + id: `t${i + 1}`, + seed: i + 1, + })); + + const tournamentData = generateTournamentWithByes(teams, 'top-seeds'); + const gracket = new Gracket(container, { src: tournamentData }); + + // Don't enter any scores + const stats = gracket.getStatistics(); + expect(stats.completionPercentage).toBe(0); + + const report = gracket.generateReport({ format: 'json' }); + // Auto-generation creates placeholder rounds, so champion might exist + // Check that not all matches are complete + expect(report.completedMatches).toBeLessThan(report.totalMatches); + }); + + it('should handle tournament with all byes', () => { + const tournamentData: TournamentData = [ + [ + [{ name: 'Team A', id: 'a', seed: 1 }], + [{ name: 'Team B', id: 'b', seed: 2 }], + ], + [ + [{ name: 'Team A', id: 'a', seed: 1 }], + ], + ]; + + const gracket = new Gracket(container, { src: tournamentData }); + + expect(gracket.isRoundComplete(0)).toBe(true); + + const stats = gracket.getStatistics(); + expect(stats.byeCount).toBeGreaterThan(0); + expect(stats.completionPercentage).toBe(100); + }); + + it('should maintain data consistency across operations', () => { + const teams: Team[] = Array.from({ length: 6 }, (_, i) => ({ + name: `Team ${i + 1}`, + id: `t${i + 1}`, + seed: i + 1, + })); + + const tournamentData = generateTournamentWithByes(teams, 'top-seeds'); + const gracket = new Gracket(container, { src: tournamentData }); + + const initialData = gracket.getData(); + const initialParticipants = gracket.getStatistics().participantCount; + + // Perform various operations + tournamentData[0].forEach((game, gameIdx) => { + if (game.length === 2) { + gracket.updateScore(0, gameIdx, 0, 100); + gracket.updateScore(0, gameIdx, 1, 85); + } + }); + + gracket.advanceRound(0, { createRounds: true }); + + // Participants should remain same + const finalParticipants = gracket.getStatistics().participantCount; + expect(finalParticipants).toBe(initialParticipants); + + // Structure should be consistent (may already have all rounds from generator) + const finalData = gracket.getData(); + expect(finalData.length).toBeGreaterThanOrEqual(initialData.length); + }); + }); + + describe('Performance and Scalability', () => { + it('should handle large tournament efficiently', () => { + const teams: Team[] = Array.from({ length: 32 }, (_, i) => ({ + name: `Team ${i + 1}`, + id: `team-${i + 1}`, + seed: i + 1, + })); + + const startTime = Date.now(); + + const tournamentData = generateTournamentWithByes(teams, 'top-seeds'); + const gracket = new Gracket(container, { src: tournamentData }); + gracket.autoGenerateTournament({ tieBreaker: 'higher-seed' }); + const report = gracket.generateReport({ + format: 'json', + includeStatistics: true + }); + + const duration = Date.now() - startTime; + + // Should complete in reasonable time (adjust threshold as needed) + expect(duration).toBeLessThan(1000); // 1 second + + // Verify correctness + expect(report.champion).toBeDefined(); + expect(report.statistics!.participantCount).toBe(32); + }); + + it('should handle rapid score updates', () => { + const teams: Team[] = Array.from({ length: 8 }, (_, i) => ({ + name: `Team ${i + 1}`, + id: `t${i + 1}`, + seed: i + 1, + })); + + const tournamentData = generateTournamentWithByes(teams, 'top-seeds'); + const gracket = new Gracket(container, { src: tournamentData }); + + // Rapid score updates + for (let i = 0; i < 100; i++) { + tournamentData[0].forEach((game, gameIdx) => { + if (game.length === 2) { + gracket.updateScore(0, gameIdx, 0, Math.floor(Math.random() * 50) + 80); + gracket.updateScore(0, gameIdx, 1, Math.floor(Math.random() * 50) + 60); + } + }); + } + + // Should still be consistent + const data = gracket.getData(); + expect(data[0][0][0].score).toBeDefined(); + expect(gracket.isRoundComplete(0)).toBe(true); + }); + }); + + describe('Cross-Feature Integration', () => { + it('should combine byes + scoring + callbacks + reporting', () => { + const teams: Team[] = [ + { name: 'Team A', id: 'a', seed: 1 }, + { name: 'Team B', id: 'b', seed: 2 }, + { name: 'Team C', id: 'c', seed: 3 }, + { name: 'Team D', id: 'd', seed: 4 }, + { name: 'Team E', id: 'e', seed: 5 }, + ]; + + const eventLog: string[] = []; + const tournamentData = generateTournamentWithByes(teams, 'top-seeds'); + + const gracket = new Gracket(container, { + src: tournamentData, + byeLabel: 'AUTO WIN', + showByeGames: true, + + onScoreUpdate: (r, g, t, score) => { + eventLog.push(`Score update: R${r+1} G${g+1} T${t+1} = ${score}`); + }, + + onRoundComplete: (r) => { + eventLog.push(`Round ${r+1} completed`); + + const advancing = gracket.getAdvancingTeams(r); + eventLog.push(`Advancing: ${advancing.map(t => t.name).join(', ')}`); + }, + + onRoundGenerated: (r, data) => { + eventLog.push(`Round ${r+1} generated with ${data.length} games`); + }, + }); + + // Enter scores + tournamentData[0].forEach((game, gameIdx) => { + if (game.length === 2) { + gracket.updateScore(0, gameIdx, 0, 100 + gameIdx * 5); + gracket.updateScore(0, gameIdx, 1, 85 + gameIdx * 5); + } + }); + + // Auto-generate + gracket.autoGenerateTournament({ tieBreaker: 'higher-seed' }); + + // Generate report + const report = gracket.generateReport({ + format: 'text', + includeStatistics: true, + includeScores: true + }); + + // Verify all features worked + expect(eventLog.length).toBeGreaterThan(0); + expect(eventLog.some(e => e.includes('Score update'))).toBe(true); + expect(eventLog.some(e => e.includes('completed'))).toBe(true); + expect(report).toContain('TOURNAMENT REPORT'); + expect(report).toContain('CHAMPION'); + + // Verify byes were handled + const stats = gracket.getStatistics(); + expect(stats.byeCount).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/style.css b/src/style.css index ac6d385..a7b7f21 100644 --- a/src/style.css +++ b/src/style.css @@ -107,6 +107,32 @@ background: linear-gradient(90deg, #252a36 0%, #1a1f2c 100%); } +/* Bye placeholder styles (Issue #15) */ +.g_bye { + background: linear-gradient(90deg, rgba(42, 47, 58, 0.5) 0%, rgba(31, 36, 48, 0.5) 100%); + border-left: 4px dashed #6c757d !important; + border-right: 1px dashed rgba(255, 255, 255, 0.05); + opacity: 0.6; + cursor: default; + pointer-events: none; +} + +.g_bye h3 { + color: #8b949e; + font-style: italic; + font-weight: 600; +} + +.g_bye::before, +.g_bye::after { + display: none; +} + +.g_bye:hover { + transform: none; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + .g_team:hover { transform: translateX(3px); box-shadow: 0 3px 8px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.1); diff --git a/src/types.ts b/src/types.ts index cdb603a..6aebc10 100644 --- a/src/types.ts +++ b/src/types.ts @@ -69,13 +69,32 @@ export interface GracketOptions { roundLabels?: string[]; /** Tournament data source */ src?: TournamentData; + + // Byes support (Issue #15) + /** Label to display for bye placeholders */ + byeLabel?: string; + /** CSS class for bye placeholder elements */ + byeClass?: string; + /** Whether to show bye games in the bracket */ + showByeGames?: boolean; + + // Event callbacks (Issue #14) + /** Callback fired when a score is updated */ + onScoreUpdate?: (roundIndex: number, gameIndex: number, teamIndex: number, score: number) => void; + /** Callback fired when a round is completed */ + onRoundComplete?: (roundIndex: number) => void; + /** Callback fired when a new round is generated */ + onRoundGenerated?: (roundIndex: number, roundData: Round) => void; } /** * Internal settings (merged defaults + options) */ -export interface GracketSettings extends Required { +export interface GracketSettings extends Omit, 'onScoreUpdate' | 'onRoundComplete' | 'onRoundGenerated'> { canvasId: string; + onScoreUpdate?: (roundIndex: number, gameIndex: number, teamIndex: number, score: number) => void; + onRoundComplete?: (roundIndex: number) => void; + onRoundGenerated?: (roundIndex: number, roundData: Round) => void; } /** @@ -89,3 +108,160 @@ export interface LabelOffset { class: string; width: number; } + +/** + * Options for advancing to the next round (Issue #14a) + */ +export interface AdvanceOptions { + /** Strategy for handling tied scores */ + tieBreaker?: 'error' | 'higher-seed' | 'lower-seed' | 'callback'; + /** Custom tie-breaking function */ + tieBreakerFn?: (team1: Team, team2: Team) => Team; + /** Preserve scores when advancing to next round */ + preserveScores?: boolean; + /** Auto-create next round if it doesn't exist */ + createRounds?: boolean; +} + +/** + * Options for auto-generating tournament from results (Issue #14a) + */ +export interface AutoGenerateOptions extends AdvanceOptions { + /** Callback fired when each round is generated */ + onRoundGenerated?: (roundIndex: number, roundData: Round) => void; + /** Stop generation at specific round index */ + stopAtRound?: number; +} + +/** + * Result of a single match (Issue #14b) + */ +export interface MatchResult { + /** Winning team */ + winner: Team; + /** Losing team (null for bye) */ + loser: Team | null; + /** Winner's score */ + winnerScore?: number; + /** Loser's score */ + loserScore?: number; + /** Whether this was a bye match */ + isBye: boolean; +} + +/** + * Single match entry in team history (Issue #14b) + */ +export interface MatchEntry { + /** Round index (0-based) */ + roundIndex: number; + /** Round label */ + roundLabel: string; + /** Opponent team (null for bye) */ + opponent: Team | null; + /** Whether this team won */ + won: boolean; + /** This team's score */ + score?: number; + /** Opponent's score */ + opponentScore?: number; + /** Whether this was a bye */ + isBye: boolean; +} + +/** + * Complete history of a team through the tournament (Issue #14b) + */ +export interface TeamHistory { + /** The team */ + team: Team; + /** All matches played */ + matches: MatchEntry[]; + /** Final placement (1st, 2nd, 3rd, etc.) */ + finalPlacement?: number; + /** Total wins */ + wins: number; + /** Total losses */ + losses: number; +} + +/** + * Results for a single round (Issue #14b) + */ +export interface RoundReport { + /** Round index (0-based) */ + roundIndex: number; + /** Round label */ + roundLabel: string; + /** Whether all matches in round are complete */ + isComplete: boolean; + /** All match results in round */ + matches: MatchResult[]; + /** Teams advancing from this round */ + advancingTeams: Team[]; +} + +/** + * Complete tournament report (Issue #14b) + */ +export interface TournamentReport { + /** Total number of rounds */ + totalRounds: number; + /** Total number of matches */ + totalMatches: number; + /** Number of completed matches */ + completedMatches: number; + /** Number of remaining matches */ + remainingMatches: number; + /** Current round index */ + currentRound: number; + /** Tournament champion (if determined) */ + champion?: Team; + /** Finalists */ + finalists?: Team[]; + /** Detailed results for all rounds */ + allResults: RoundReport[]; + /** Tournament statistics (if requested) */ + statistics?: TournamentStatistics; +} + +/** + * Tournament statistics (Issue #14b) + */ +export interface TournamentStatistics { + /** Total number of participants */ + participantCount: number; + /** Total number of rounds */ + totalRounds: number; + /** Number of byes in tournament */ + byeCount: number; + /** Average score across all completed matches */ + averageScore?: number; + /** Highest score in tournament */ + highestScore?: { + team: Team; + score: number; + round: number; + }; + /** Tournament completion percentage */ + completionPercentage: number; +} + +/** + * Options for generating reports (Issue #14b) + */ +export interface ReportOptions { + /** Output format */ + format?: 'json' | 'text' | 'html' | 'markdown'; + /** Include scores in report */ + includeScores?: boolean; + /** Include statistics in report */ + includeStatistics?: boolean; + /** Custom round labels for report */ + roundLabels?: string[]; +} + +/** + * Seeding strategy for bye generation (Issue #15) + */ +export type ByeSeedingStrategy = 'top-seeds' | 'random' | 'custom'; diff --git a/src/utils/byes.test.ts b/src/utils/byes.test.ts new file mode 100644 index 0000000..05c10e7 --- /dev/null +++ b/src/utils/byes.test.ts @@ -0,0 +1,641 @@ +import { describe, it, expect } from 'vitest'; +import { + calculateByesNeeded, + generateTournamentWithByes, + tournamentHasByes, + getByeTeams, +} from './byes'; +import type { Team, Round } from '../types'; + +describe('byes utilities', () => { + describe('calculateByesNeeded', () => { + it('should calculate byes for non-power-of-2 counts', () => { + expect(calculateByesNeeded(3)).toBe(1); // 4 - 3 = 1 + expect(calculateByesNeeded(5)).toBe(3); // 8 - 5 = 3 + expect(calculateByesNeeded(6)).toBe(2); // 8 - 6 = 2 + expect(calculateByesNeeded(7)).toBe(1); // 8 - 7 = 1 + expect(calculateByesNeeded(9)).toBe(7); // 16 - 9 = 7 + expect(calculateByesNeeded(10)).toBe(6); // 16 - 10 = 6 + }); + + it('should return 0 for power-of-2 counts', () => { + expect(calculateByesNeeded(2)).toBe(0); + expect(calculateByesNeeded(4)).toBe(0); + expect(calculateByesNeeded(8)).toBe(0); + expect(calculateByesNeeded(16)).toBe(0); + expect(calculateByesNeeded(32)).toBe(0); + }); + + it('should throw for less than 2 teams', () => { + expect(() => calculateByesNeeded(0)).toThrow('at least 2 teams'); + expect(() => calculateByesNeeded(1)).toThrow('at least 2 teams'); + }); + + it('should handle large team counts', () => { + expect(calculateByesNeeded(50)).toBe(14); // 64 - 50 = 14 + expect(calculateByesNeeded(100)).toBe(28); // 128 - 100 = 28 + }); + }); + + describe('generateTournamentWithByes - top-seeds strategy', () => { + it('should generate tournament for 6 teams with top-seed byes', () => { + const teams: Team[] = [ + { name: 'Team 1', seed: 1, id: 't1' }, + { name: 'Team 2', seed: 2, id: 't2' }, + { name: 'Team 3', seed: 3, id: 't3' }, + { name: 'Team 4', seed: 4, id: 't4' }, + { name: 'Team 5', seed: 5, id: 't5' }, + { name: 'Team 6', seed: 6, id: 't6' }, + ]; + + const tournament = generateTournamentWithByes(teams, 'top-seeds'); + + // Should have first round with 2 matches + 2 byes + expect(tournament).toHaveLength(4); // R1, R2, R3, Champion + + const firstRound = tournament[0]; + expect(firstRound).toHaveLength(4); // 2 matches + 2 byes + + // Count byes in first round + const byeGames = firstRound.filter(game => game.length === 1); + expect(byeGames).toHaveLength(2); + + // Check that top seeds got byes + const byeTeamSeeds = byeGames.map(game => game[0].seed).sort(); + expect(byeTeamSeeds).toEqual([1, 2]); + }); + + it('should generate tournament for 5 teams', () => { + const teams: Team[] = Array.from({ length: 5 }, (_, i) => ({ + name: `Team ${i + 1}`, + seed: i + 1, + id: `t${i + 1}`, + })); + + const tournament = generateTournamentWithByes(teams, 'top-seeds'); + + const firstRound = tournament[0]; + const byeGames = firstRound.filter(game => game.length === 1); + + // 5 teams โ†’ need 3 byes (to make 8) + expect(byeGames).toHaveLength(3); + + // Top 3 seeds should get byes + const byeSeeds = byeGames.map(g => g[0].seed).sort(); + expect(byeSeeds).toEqual([1, 2, 3]); + }); + + it('should generate tournament for 7 teams', () => { + const teams: Team[] = Array.from({ length: 7 }, (_, i) => ({ + name: `Team ${i + 1}`, + seed: i + 1, + id: `t${i + 1}`, + })); + + const tournament = generateTournamentWithByes(teams, 'top-seeds'); + + const firstRound = tournament[0]; + const byeGames = firstRound.filter(game => game.length === 1); + + // 7 teams โ†’ need 1 bye + expect(byeGames).toHaveLength(1); + expect(byeGames[0][0].seed).toBe(1); // Top seed gets bye + }); + + it('should generate proper subsequent rounds', () => { + const teams: Team[] = Array.from({ length: 6 }, (_, i) => ({ + name: `Team ${i + 1}`, + seed: i + 1, + id: `t${i + 1}`, + })); + + const tournament = generateTournamentWithByes(teams, 'top-seeds'); + + // 6 teams โ†’ 8 bracket โ†’ 4 teams in R1 + expect(tournament[0]).toHaveLength(4); // 2 matches + 2 byes = 4 games + + // Round 2 should have 2 matches (4 teams total) + expect(tournament[1]).toHaveLength(2); + + // Round 3 (finals) should have 1 match + expect(tournament[2]).toHaveLength(1); + + // Champion round + expect(tournament[3]).toHaveLength(1); + expect(tournament[3][0]).toHaveLength(1); + }); + + it('should handle power-of-2 teams (no byes)', () => { + const teams: Team[] = Array.from({ length: 8 }, (_, i) => ({ + name: `Team ${i + 1}`, + seed: i + 1, + id: `t${i + 1}`, + })); + + const tournament = generateTournamentWithByes(teams, 'top-seeds'); + + // No byes needed for power of 2 + const firstRound = tournament[0]; + const byeGames = firstRound.filter(game => game.length === 1); + expect(byeGames).toHaveLength(0); + + // All games should be regular matches + expect(firstRound.every(game => game.length === 2)).toBe(true); + }); + }); + + describe('generateTournamentWithByes - random strategy', () => { + it('should generate tournament with random byes', () => { + const teams: Team[] = Array.from({ length: 6 }, (_, i) => ({ + name: `Team ${i + 1}`, + seed: i + 1, + id: `t${i + 1}`, + })); + + const tournament = generateTournamentWithByes(teams, 'random'); + + // Should still have correct structure + const firstRound = tournament[0]; + const byeGames = firstRound.filter(game => game.length === 1); + expect(byeGames).toHaveLength(2); + + // All teams should be present + const allTeamsInRound = firstRound.flatMap(game => game); + expect(allTeamsInRound).toHaveLength(6); + }); + + it('should generate different results each time (probabilistic)', () => { + const teams: Team[] = Array.from({ length: 6 }, (_, i) => ({ + name: `Team ${i + 1}`, + seed: i + 1, + id: `t${i + 1}`, + })); + + // Generate multiple tournaments + const results = Array.from({ length: 10 }, () => + generateTournamentWithByes(teams, 'random') + ); + + // Extract bye team IDs from each + const byeSetups = results.map(tournament => { + const firstRound = tournament[0]; + const byeGames = firstRound.filter(game => game.length === 1); + return byeGames.map(g => g[0].id).sort().join(','); + }); + + // Should have some variation (not all identical) + const uniqueSetups = new Set(byeSetups); + // This might occasionally fail due to randomness, but very unlikely + expect(uniqueSetups.size).toBeGreaterThanOrEqual(2); + }); + }); + + describe('generateTournamentWithByes - edge cases', () => { + it('should throw for less than 2 teams', () => { + expect(() => { + generateTournamentWithByes([], 'top-seeds'); + }).toThrow('at least 2 teams'); + + expect(() => { + generateTournamentWithByes([{ name: 'Only Team', seed: 1 }], 'top-seeds'); + }).toThrow('at least 2 teams'); + }); + + it('should handle 2 teams (smallest tournament)', () => { + const teams: Team[] = [ + { name: 'Team 1', seed: 1, id: 't1' }, + { name: 'Team 2', seed: 2, id: 't2' }, + ]; + + const tournament = generateTournamentWithByes(teams, 'top-seeds'); + + // 2 teams = power of 2, no byes + expect(tournament[0]).toHaveLength(1); // Single match + expect(tournament[0][0]).toHaveLength(2); + }); + + it('should handle 3 teams (1 bye)', () => { + const teams: Team[] = [ + { name: 'Team 1', seed: 1, id: 't1' }, + { name: 'Team 2', seed: 2, id: 't2' }, + { name: 'Team 3', seed: 3, id: 't3' }, + ]; + + const tournament = generateTournamentWithByes(teams, 'top-seeds'); + + const firstRound = tournament[0]; + const byeGames = firstRound.filter(game => game.length === 1); + + expect(byeGames).toHaveLength(1); + expect(byeGames[0][0].seed).toBe(1); // Top seed gets bye + }); + + it('should throw for custom strategy', () => { + const teams: Team[] = [ + { name: 'Team 1', seed: 1 }, + { name: 'Team 2', seed: 2 }, + { name: 'Team 3', seed: 3 }, + ]; + + expect(() => { + generateTournamentWithByes(teams, 'custom'); + }).toThrow('Custom bye strategy not implemented'); + }); + + it('should handle unsorted teams', () => { + const teams: Team[] = [ + { name: 'Team 8', seed: 8, id: 't8' }, + { name: 'Team 1', seed: 1, id: 't1' }, + { name: 'Team 5', seed: 5, id: 't5' }, + { name: 'Team 3', seed: 3, id: 't3' }, + ]; + + const tournament = generateTournamentWithByes(teams, 'top-seeds'); + + // Should still work correctly + expect(tournament).toBeDefined(); + expect(tournament[0]).toBeDefined(); + }); + }); + + describe('tournamentHasByes', () => { + it('should return true for tournament with byes', () => { + const tournament = [ + [ + [{ name: 'A', seed: 1 }, { name: 'B', seed: 2 }], + [{ name: 'C', seed: 3 }], // Bye + ], + ]; + + expect(tournamentHasByes(tournament)).toBe(true); + }); + + it('should return false for tournament without byes', () => { + const tournament = [ + [ + [{ name: 'A', seed: 1 }, { name: 'B', seed: 2 }], + [{ name: 'C', seed: 3 }, { name: 'D', seed: 4 }], + ], + ]; + + expect(tournamentHasByes(tournament)).toBe(false); + }); + + it('should detect byes in any round', () => { + const tournament = [ + [ + [{ name: 'A', seed: 1 }, { name: 'B', seed: 2 }], + [{ name: 'C', seed: 3 }, { name: 'D', seed: 4 }], + ], + [ + [{ name: 'A', seed: 1 }, { name: 'C', seed: 3 }], + [{ name: 'E', seed: 5 }], // Bye in round 2 + ], + ]; + + expect(tournamentHasByes(tournament)).toBe(true); + }); + + it('should return false for empty tournament', () => { + expect(tournamentHasByes([])).toBe(false); + }); + + it('should handle champion as special case', () => { + const tournament = [ + [ + [{ name: 'A', seed: 1 }, { name: 'B', seed: 2 }], + ], + [[{ name: 'A', seed: 1 }]], // Champion (single team) + ]; + + // This will return true since technically it's a single-team game + expect(tournamentHasByes(tournament)).toBe(true); + }); + }); + + describe('getByeTeams', () => { + it('should return teams with byes in a round', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1 }, + { name: 'Team B', seed: 2 }, + ], + [{ name: 'Team C', seed: 3, id: 'c' }], // Bye + [{ name: 'Team D', seed: 4, id: 'd' }], // Bye + ]; + + const byeTeams = getByeTeams(round); + + expect(byeTeams).toHaveLength(2); + expect(byeTeams[0].name).toBe('Team C'); + expect(byeTeams[1].name).toBe('Team D'); + }); + + it('should return empty array when no byes', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1 }, + { name: 'Team B', seed: 2 }, + ], + [ + { name: 'Team C', seed: 3 }, + { name: 'Team D', seed: 4 }, + ], + ]; + + expect(getByeTeams(round)).toHaveLength(0); + }); + + it('should return all teams when all are byes', () => { + const round: Round = [ + [{ name: 'Team A', seed: 1 }], + [{ name: 'Team B', seed: 2 }], + [{ name: 'Team C', seed: 3 }], + ]; + + const byeTeams = getByeTeams(round); + expect(byeTeams).toHaveLength(3); + }); + + it('should return empty array for empty round', () => { + expect(getByeTeams([])).toHaveLength(0); + }); + }); + + describe('generateTournamentWithByes - complete tournament structure', () => { + it('should generate correct structure for 5-team tournament', () => { + const teams: Team[] = Array.from({ length: 5 }, (_, i) => ({ + name: `Team ${i + 1}`, + seed: i + 1, + id: `t${i + 1}`, + })); + + const tournament = generateTournamentWithByes(teams, 'top-seeds'); + + // 5 teams โ†’ need 3 byes โ†’ 8-team bracket + expect(tournament).toHaveLength(4); // R1, R2, R3, Champion + + // Round 1: 1 match (2 teams) + 3 byes + const r1 = tournament[0]; + expect(r1).toHaveLength(4); + expect(r1.filter(g => g.length === 1)).toHaveLength(3); // 3 byes + expect(r1.filter(g => g.length === 2)).toHaveLength(1); // 1 match + + // Round 2: 2 matches (4 teams total) + expect(tournament[1]).toHaveLength(2); + + // Round 3: 1 match (2 teams) + expect(tournament[2]).toHaveLength(1); + expect(tournament[2][0]).toHaveLength(2); + + // Champion + expect(tournament[3]).toHaveLength(1); + expect(tournament[3][0]).toHaveLength(1); + }); + + it('should generate correct structure for 10-team tournament', () => { + const teams: Team[] = Array.from({ length: 10 }, (_, i) => ({ + name: `Team ${i + 1}`, + seed: i + 1, + id: `t${i + 1}`, + })); + + const tournament = generateTournamentWithByes(teams, 'top-seeds'); + + // 10 teams โ†’ need 6 byes โ†’ 16-team bracket + const r1 = tournament[0]; + const byeGames = r1.filter(g => g.length === 1); + + expect(byeGames).toHaveLength(6); + + // Top 6 seeds get byes + const byeSeeds = byeGames.map(g => g[0].seed).sort((a, b) => a - b); + expect(byeSeeds).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it('should generate all placeholder rounds', () => { + const teams: Team[] = Array.from({ length: 6 }, (_, i) => ({ + name: `Team ${i + 1}`, + seed: i + 1, + id: `t${i + 1}`, + })); + + const tournament = generateTournamentWithByes(teams, 'top-seeds'); + + // Each round should have placeholder teams with IDs + tournament.forEach((round) => { + round.forEach((game) => { + game.forEach(team => { + expect(team.name).toBeDefined(); + expect(team.seed).toBeDefined(); + expect(team.id).toBeDefined(); + }); + }); + }); + }); + + it('should maintain all original teams', () => { + const teams: Team[] = [ + { name: 'Warriors', seed: 1, id: 'warriors' }, + { name: 'Lakers', seed: 2, id: 'lakers' }, + { name: 'Celtics', seed: 3, id: 'celtics' }, + { name: 'Heat', seed: 4, id: 'heat' }, + { name: 'Bucks', seed: 5, id: 'bucks' }, + ]; + + const tournament = generateTournamentWithByes(teams, 'top-seeds'); + + // Extract all team IDs from first round + const firstRoundTeamIds = tournament[0] + .flatMap(game => game) + .map(team => team.id); + + // All original teams should be present + teams.forEach(team => { + expect(firstRoundTeamIds).toContain(team.id); + }); + }); + }); + + describe('generateTournamentWithByes - different team counts', () => { + // Test various team counts + [3, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15].forEach(count => { + it(`should generate valid tournament for ${count} teams`, () => { + const teams: Team[] = Array.from({ length: count }, (_, i) => ({ + name: `Team ${i + 1}`, + seed: i + 1, + id: `t${i + 1}`, + })); + + const tournament = generateTournamentWithByes(teams, 'top-seeds'); + + // Should have valid structure + expect(tournament.length).toBeGreaterThan(0); + + // First round should have all teams + const firstRoundTeams = tournament[0].flatMap(g => g); + expect(firstRoundTeams).toHaveLength(count); + + // Calculate expected byes + const nextPower = Math.pow(2, Math.ceil(Math.log2(count))); + const expectedByes = nextPower - count; + + const byeGames = tournament[0].filter(g => g.length === 1); + expect(byeGames).toHaveLength(expectedByes); + }); + }); + }); + + describe('generateTournamentWithByes - seeding order', () => { + it('should respect seed order for top-seeds strategy', () => { + const teams: Team[] = [ + { name: 'Team C', seed: 3, id: 'c' }, + { name: 'Team A', seed: 1, id: 'a' }, + { name: 'Team B', seed: 2, id: 'b' }, + { name: 'Team D', seed: 4, id: 'd' }, + { name: 'Team E', seed: 5, id: 'e' }, + { name: 'Team F', seed: 6, id: 'f' }, + ]; + + const tournament = generateTournamentWithByes(teams, 'top-seeds'); + + const byeGames = tournament[0].filter(g => g.length === 1); + const byeSeeds = byeGames.map(g => g[0].seed).sort((a, b) => a - b); + + // Seeds 1 and 2 should get byes + expect(byeSeeds).toEqual([1, 2]); + }); + + it('should work with non-sequential seeds', () => { + const teams: Team[] = [ + { name: 'Team A', seed: 1, id: 'a' }, + { name: 'Team B', seed: 4, id: 'b' }, + { name: 'Team C', seed: 7, id: 'c' }, + { name: 'Team D', seed: 10, id: 'd' }, + { name: 'Team E', seed: 13, id: 'e' }, + ]; + + const tournament = generateTournamentWithByes(teams, 'top-seeds'); + + const byeGames = tournament[0].filter(g => g.length === 1); + const byeSeeds = byeGames.map(g => g[0].seed).sort((a, b) => a - b); + + // Seeds 1, 4, and 7 should get byes (lowest 3) + expect(byeSeeds).toEqual([1, 4, 7]); + }); + }); + + describe('edge cases and error handling', () => { + it('should handle teams with same seed', () => { + const teams: Team[] = [ + { name: 'Team A', seed: 1, id: 'a' }, + { name: 'Team B', seed: 1, id: 'b' }, + { name: 'Team C', seed: 1, id: 'c' }, + ]; + + // Should not throw + expect(() => { + generateTournamentWithByes(teams, 'top-seeds'); + }).not.toThrow(); + }); + + it('should handle teams without IDs', () => { + const teams: Team[] = [ + { name: 'Team A', seed: 1 }, + { name: 'Team B', seed: 2 }, + { name: 'Team C', seed: 3 }, + ]; + + const tournament = generateTournamentWithByes(teams, 'top-seeds'); + expect(tournament).toBeDefined(); + }); + + it('should handle teams with displaySeed', () => { + const teams: Team[] = [ + { name: 'Team A', seed: 1, displaySeed: 'A1' }, + { name: 'Team B', seed: 2, displaySeed: 'B2' }, + { name: 'Team C', seed: 3, displaySeed: 'C3' }, + ]; + + const tournament = generateTournamentWithByes(teams, 'top-seeds'); + expect(tournament).toBeDefined(); + + // Check that displaySeed is preserved + const firstTeam = tournament[0][0][0]; + if ('displaySeed' in firstTeam) { + expect(firstTeam.displaySeed).toBeDefined(); + } + }); + }); + + describe('generateTournamentWithByes - large tournaments', () => { + it('should handle 50 teams', () => { + const teams: Team[] = Array.from({ length: 50 }, (_, i) => ({ + name: `Team ${i + 1}`, + seed: i + 1, + id: `t${i + 1}`, + })); + + const tournament = generateTournamentWithByes(teams, 'top-seeds'); + + // 50 teams โ†’ 64 bracket โ†’ 14 byes + const byeGames = tournament[0].filter(g => g.length === 1); + expect(byeGames).toHaveLength(14); + + // Top 14 seeds get byes + const byeSeeds = byeGames.map(g => g[0].seed).sort((a, b) => a - b); + expect(byeSeeds).toEqual(Array.from({ length: 14 }, (_, i) => i + 1)); + }); + + it('should generate correct number of rounds for 50 teams', () => { + const teams: Team[] = Array.from({ length: 50 }, (_, i) => ({ + name: `Team ${i + 1}`, + seed: i + 1, + id: `t${i + 1}`, + })); + + const tournament = generateTournamentWithByes(teams, 'top-seeds'); + + // 64-team bracket = 6 rounds + champion = 7 total + expect(tournament).toHaveLength(7); + }); + }); + + describe('random strategy behavior', () => { + it('should still assign correct number of byes', () => { + const teams: Team[] = Array.from({ length: 6 }, (_, i) => ({ + name: `Team ${i + 1}`, + seed: i + 1, + id: `t${i + 1}`, + })); + + // Run multiple times to verify consistency + for (let i = 0; i < 10; i++) { + const tournament = generateTournamentWithByes(teams, 'random'); + const byeGames = tournament[0].filter(g => g.length === 1); + expect(byeGames).toHaveLength(2); // Always 2 byes + } + }); + + it('should include all teams in first round', () => { + const teams: Team[] = Array.from({ length: 7 }, (_, i) => ({ + name: `Team ${i + 1}`, + seed: i + 1, + id: `t${i + 1}`, + })); + + const tournament = generateTournamentWithByes(teams, 'random'); + const firstRoundTeams = tournament[0].flatMap(g => g); + + expect(firstRoundTeams).toHaveLength(7); + + // All original team IDs should be present + const originalIds = teams.map(t => t.id); + const firstRoundIds = firstRoundTeams.map(t => t.id); + + originalIds.forEach(id => { + expect(firstRoundIds).toContain(id); + }); + }); + }); +}); diff --git a/src/utils/byes.ts b/src/utils/byes.ts new file mode 100644 index 0000000..70495a4 --- /dev/null +++ b/src/utils/byes.ts @@ -0,0 +1,223 @@ +/** + * Bye generation utilities for non-power-of-2 tournaments + * Implements Issue #15 + */ + +import type { Team, TournamentData, Round, ByeSeedingStrategy } from '../types'; + +/** + * Calculate the next power of 2 greater than or equal to n + */ +const nextPowerOf2 = (n: number): number => { + return Math.pow(2, Math.ceil(Math.log2(n))); +}; + +/** + * Calculate how many byes are needed for a tournament + */ +export const calculateByesNeeded = (teamCount: number): number => { + if (teamCount < 2) { + throw new Error('Tournament must have at least 2 teams'); + } + + const nextPower = nextPowerOf2(teamCount); + return nextPower - teamCount; +}; + +/** + * Shuffle array in place (Fisher-Yates algorithm) + */ +const shuffleArray = (array: T[]): T[] => { + const result = [...array]; + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +}; + +/** + * Generate tournament structure with byes for non-power-of-2 team counts + * + * @param teams - Array of teams (can be any count >= 2) + * @param strategy - How to assign byes: + * - 'top-seeds': Top-seeded teams get byes (default) + * - 'random': Random teams get byes + * - 'custom': Use team.isBye property (not implemented in Team type, use manual structure instead) + * @returns Tournament data with proper bye structure + * + * @example + * // 6 teams - top 2 seeds get byes + * const teams = [ + * { name: 'Team 1', seed: 1 }, + * { name: 'Team 2', seed: 2 }, + * { name: 'Team 3', seed: 3 }, + * { name: 'Team 4', seed: 4 }, + * { name: 'Team 5', seed: 5 }, + * { name: 'Team 6', seed: 6 }, + * ]; + * const data = generateTournamentWithByes(teams, 'top-seeds'); + */ +export const generateTournamentWithByes = ( + teams: Team[], + strategy: ByeSeedingStrategy = 'top-seeds' +): TournamentData => { + if (teams.length < 2) { + throw new Error('Tournament must have at least 2 teams'); + } + + const teamCount = teams.length; + const byesNeeded = calculateByesNeeded(teamCount); + + // If already a power of 2, no byes needed + if (byesNeeded === 0) { + return generateRegularTournament(teams); + } + + // Determine which teams get byes + let byeTeams: Team[]; + let playingTeams: Team[]; + + switch (strategy) { + case 'top-seeds': { + // Sort by seed (lower seed number = better) + const sortedTeams = [...teams].sort((a, b) => a.seed - b.seed); + byeTeams = sortedTeams.slice(0, byesNeeded); + playingTeams = sortedTeams.slice(byesNeeded); + break; + } + + case 'random': { + const shuffled = shuffleArray(teams); + byeTeams = shuffled.slice(0, byesNeeded); + playingTeams = shuffled.slice(byesNeeded); + break; + } + + case 'custom': { + // For custom strategy, user should manually create tournament structure + // This is here for API completeness + throw new Error('Custom bye strategy not implemented. Please manually create tournament structure with single-team games for byes.'); + } + + default: + throw new Error(`Unknown bye seeding strategy: ${strategy}`); + } + + // Build first round + const firstRound: Round = []; + + // Add regular matchups + for (let i = 0; i < playingTeams.length; i += 2) { + if (i + 1 < playingTeams.length) { + firstRound.push([playingTeams[i], playingTeams[i + 1]]); + } else { + // Odd number of playing teams - last one gets bye + firstRound.push([playingTeams[i]]); + } + } + + // Add bye games + byeTeams.forEach(team => { + firstRound.push([team]); + }); + + // Generate placeholder rounds for rest of tournament + const tournamentData: TournamentData = [firstRound]; + + // Calculate remaining rounds + let currentWinners = firstRound.length; // All teams from first round advance + + while (currentWinners > 1) { + const nextRound: Round = []; + const gamesInRound = Math.floor(currentWinners / 2); + + for (let i = 0; i < gamesInRound; i++) { + // Placeholder teams for next round + nextRound.push([ + { name: `Winner ${i * 2 + 1}`, seed: i * 2 + 1, id: `winner-r${tournamentData.length}-g${i * 2}` }, + { name: `Winner ${i * 2 + 2}`, seed: i * 2 + 2, id: `winner-r${tournamentData.length}-g${i * 2 + 1}` } + ]); + } + + // Handle odd number of winners + if (currentWinners % 2 === 1) { + nextRound.push([ + { name: `Winner ${currentWinners}`, seed: currentWinners, id: `winner-r${tournamentData.length}-g${currentWinners - 1}` } + ]); + } + + tournamentData.push(nextRound); + currentWinners = nextRound.length; + } + + // Final round (champion) + if (currentWinners === 1) { + tournamentData.push([[ + { name: 'Champion', seed: 1, id: 'champion' } + ]]); + } + + return tournamentData; +}; + +/** + * Generate regular tournament structure (power of 2 teams) + */ +const generateRegularTournament = (teams: Team[]): TournamentData => { + if ((teams.length & (teams.length - 1)) !== 0) { + throw new Error('Team count must be a power of 2 for regular tournament'); + } + + const tournamentData: TournamentData = []; + + // First round + const firstRound: Round = []; + for (let i = 0; i < teams.length; i += 2) { + firstRound.push([teams[i], teams[i + 1]]); + } + tournamentData.push(firstRound); + + // Generate placeholder rounds + let currentWinners = firstRound.length; + + while (currentWinners > 1) { + const nextRound: Round = []; + const gamesInRound = Math.floor(currentWinners / 2); + + for (let i = 0; i < gamesInRound; i++) { + nextRound.push([ + { name: `Winner ${i * 2 + 1}`, seed: i * 2 + 1, id: `winner-r${tournamentData.length}-g${i * 2}` }, + { name: `Winner ${i * 2 + 2}`, seed: i * 2 + 2, id: `winner-r${tournamentData.length}-g${i * 2 + 1}` } + ]); + } + + tournamentData.push(nextRound); + currentWinners = nextRound.length; + } + + // Champion round + tournamentData.push([[ + { name: 'Champion', seed: 1, id: 'champion' } + ]]); + + return tournamentData; +}; + +/** + * Check if a tournament has any byes + */ +export const tournamentHasByes = (tournamentData: TournamentData): boolean => { + return tournamentData.some(round => + round.some(game => game.length === 1) + ); +}; + +/** + * Get list of teams that received byes in a specific round + */ +export const getByeTeams = (round: Round): Team[] => { + return round + .filter(game => game.length === 1) + .map(game => game[0]); +}; diff --git a/src/utils/reporting.test.ts b/src/utils/reporting.test.ts new file mode 100644 index 0000000..82c2d18 --- /dev/null +++ b/src/utils/reporting.test.ts @@ -0,0 +1,912 @@ +import { describe, it, expect } from 'vitest'; +import { + getAdvancingTeams, + getGameResult, + getRoundResults, + buildTeamHistory, + generateTournamentReport, + calculateStatistics, + formatReportAsText, + formatReportAsMarkdown, + formatReportAsHTML, +} from './reporting'; +import type { Round, Game, TournamentData, Team } from '../types'; + +describe('reporting utilities', () => { + describe('getAdvancingTeams', () => { + it('should get winners from complete round', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + [ + { name: 'Team C', seed: 3, score: 90, id: 'c' }, + { name: 'Team D', seed: 4, score: 88, id: 'd' }, + ], + ]; + + const advancing = getAdvancingTeams(round); + + expect(advancing).toHaveLength(2); + expect(advancing[0].name).toBe('Team A'); + expect(advancing[1].name).toBe('Team C'); + }); + + it('should include bye teams in advancing', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + [{ name: 'Team C', seed: 3, id: 'c' }], // Bye + ]; + + const advancing = getAdvancingTeams(round); + + expect(advancing).toHaveLength(2); + expect(advancing[0].name).toBe('Team A'); + expect(advancing[1].name).toBe('Team C'); + }); + + it('should return only completed matches', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + [ + { name: 'Team C', seed: 3, id: 'c' }, + { name: 'Team D', seed: 4, id: 'd' }, + ], // Incomplete + ]; + + const advancing = getAdvancingTeams(round); + + expect(advancing).toHaveLength(1); + expect(advancing[0].name).toBe('Team A'); + }); + + it('should return empty array for incomplete round', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1, id: 'a' }, + { name: 'Team B', seed: 2, id: 'b' }, + ], + ]; + + expect(getAdvancingTeams(round)).toHaveLength(0); + }); + + it('should handle all byes', () => { + const round: Round = [ + [{ name: 'Team A', seed: 1, id: 'a' }], + [{ name: 'Team B', seed: 2, id: 'b' }], + ]; + + const advancing = getAdvancingTeams(round); + expect(advancing).toHaveLength(2); + }); + }); + + describe('getGameResult', () => { + it('should return result for completed match', () => { + const game: Game = [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ]; + + const result = getGameResult(game); + + expect(result).toBeTruthy(); + expect(result!.winner.name).toBe('Team A'); + expect(result!.loser!.name).toBe('Team B'); + expect(result!.winnerScore).toBe(100); + expect(result!.loserScore).toBe(85); + expect(result!.isBye).toBe(false); + }); + + it('should return result for bye game', () => { + const game: Game = [{ name: 'Team A', seed: 1, id: 'a' }]; + + const result = getGameResult(game); + + expect(result).toBeTruthy(); + expect(result!.winner.name).toBe('Team A'); + expect(result!.loser).toBeNull(); + expect(result!.isBye).toBe(true); + }); + + it('should return null for incomplete match', () => { + const game: Game = [ + { name: 'Team A', seed: 1, id: 'a' }, + { name: 'Team B', seed: 2, id: 'b' }, + ]; + + expect(getGameResult(game)).toBeNull(); + }); + + it('should return null for tied match', () => { + const game: Game = [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 100, id: 'b' }, + ]; + + expect(getGameResult(game)).toBeNull(); + }); + + it('should handle team 2 winning', () => { + const game: Game = [ + { name: 'Team A', seed: 1, score: 85, id: 'a' }, + { name: 'Team B', seed: 2, score: 100, id: 'b' }, + ]; + + const result = getGameResult(game); + + expect(result!.winner.name).toBe('Team B'); + expect(result!.loser!.name).toBe('Team A'); + }); + + it('should return null for invalid game', () => { + const game: Game = []; + expect(getGameResult(game)).toBeNull(); + }); + }); + + describe('getRoundResults', () => { + it('should get all results for complete round', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + [ + { name: 'Team C', seed: 3, score: 90, id: 'c' }, + { name: 'Team D', seed: 4, score: 88, id: 'd' }, + ], + ]; + + const results = getRoundResults(round); + + expect(results).toHaveLength(2); + expect(results[0].winner.name).toBe('Team A'); + expect(results[1].winner.name).toBe('Team C'); + }); + + it('should include bye results', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + [{ name: 'Team C', seed: 3, id: 'c' }], // Bye + ]; + + const results = getRoundResults(round); + + expect(results).toHaveLength(2); + expect(results[1].isBye).toBe(true); + expect(results[1].loser).toBeNull(); + }); + + it('should only return completed matches', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + [ + { name: 'Team C', seed: 3, id: 'c' }, + { name: 'Team D', seed: 4, id: 'd' }, + ], // Incomplete + ]; + + const results = getRoundResults(round); + expect(results).toHaveLength(1); + }); + + it('should return empty array for incomplete round', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1, id: 'a' }, + { name: 'Team B', seed: 2, id: 'b' }, + ], + ]; + + expect(getRoundResults(round)).toHaveLength(0); + }); + }); + + describe('buildTeamHistory', () => { + const sampleTournament: TournamentData = [ + [ + [ + { name: 'Warriors', seed: 1, score: 105, id: 'warriors' }, + { name: 'Thunder', seed: 8, score: 92, id: 'thunder' }, + ], + [ + { name: 'Lakers', seed: 2, score: 110, id: 'lakers' }, + { name: 'Rockets', seed: 7, score: 98, id: 'rockets' }, + ], + ], + [ + [ + { name: 'Warriors', seed: 1, score: 118, id: 'warriors' }, + { name: 'Lakers', seed: 2, score: 105, id: 'lakers' }, + ], + ], + [[{ name: 'Warriors', seed: 1, id: 'warriors' }]], + ]; + + it('should build complete history for winning team', () => { + const history = buildTeamHistory('warriors', sampleTournament); + + expect(history).toBeTruthy(); + expect(history!.team.name).toBe('Warriors'); + expect(history!.wins).toBe(2); + expect(history!.losses).toBe(0); + expect(history!.matches).toHaveLength(2); + expect(history!.finalPlacement).toBe(1); // Champion + }); + + it('should build history for losing team', () => { + const history = buildTeamHistory('lakers', sampleTournament); + + expect(history).toBeTruthy(); + expect(history!.team.name).toBe('Lakers'); + expect(history!.wins).toBe(1); + expect(history!.losses).toBe(1); + expect(history!.matches).toHaveLength(2); + }); + + it('should track team through multiple rounds', () => { + const history = buildTeamHistory('warriors', sampleTournament); + + expect(history!.matches).toHaveLength(2); + + // First match + expect(history!.matches[0].won).toBe(true); + expect(history!.matches[0].opponent!.name).toBe('Thunder'); + expect(history!.matches[0].score).toBe(105); + expect(history!.matches[0].opponentScore).toBe(92); + + // Second match + expect(history!.matches[1].won).toBe(true); + expect(history!.matches[1].opponent!.name).toBe('Lakers'); + expect(history!.matches[1].score).toBe(118); + expect(history!.matches[1].opponentScore).toBe(105); + }); + + it('should handle bye in history', () => { + const tournamentWithBye: TournamentData = [ + [ + [{ name: 'Warriors', seed: 1, id: 'warriors' }], // Bye + [ + { name: 'Lakers', seed: 2, score: 100, id: 'lakers' }, + { name: 'Celtics', seed: 3, score: 95, id: 'celtics' }, + ], + ], + [ + [ + { name: 'Warriors', seed: 1, score: 110, id: 'warriors' }, + { name: 'Lakers', seed: 2, score: 105, id: 'lakers' }, + ], + ], + ]; + + const history = buildTeamHistory('warriors', tournamentWithBye); + + expect(history!.matches).toHaveLength(2); + + // First match was bye + expect(history!.matches[0].isBye).toBe(true); + expect(history!.matches[0].opponent).toBeNull(); + expect(history!.matches[0].won).toBe(true); + + // Second match was regular + expect(history!.matches[1].isBye).toBe(false); + expect(history!.matches[1].opponent!.name).toBe('Lakers'); + }); + + it('should return null for non-existent team', () => { + const history = buildTeamHistory('nonexistent', sampleTournament); + expect(history).toBeNull(); + }); + + it('should use custom round labels', () => { + const roundLabels = ['Quarterfinals', 'Semifinals', 'Finals']; + const history = buildTeamHistory('warriors', sampleTournament, roundLabels); + + expect(history!.matches[0].roundLabel).toBe('Quarterfinals'); + expect(history!.matches[1].roundLabel).toBe('Semifinals'); + }); + + it('should determine runner-up placement', () => { + const history = buildTeamHistory('lakers', sampleTournament); + + // Lakers lost in finals + expect(history!.finalPlacement).toBe(2); + }); + + it('should handle team that lost in first round', () => { + const history = buildTeamHistory('thunder', sampleTournament); + + expect(history).toBeTruthy(); + expect(history!.wins).toBe(0); + expect(history!.losses).toBe(1); + expect(history!.matches).toHaveLength(1); + expect(history!.matches[0].won).toBe(false); + }); + + it('should count wins and losses correctly', () => { + const complexTournament: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + ], + [ + [ + { name: 'Team A', seed: 1, score: 90, id: 'a' }, + { name: 'Team C', seed: 3, score: 95, id: 'c' }, + ], + ], + ]; + + const historyA = buildTeamHistory('a', complexTournament); + expect(historyA!.wins).toBe(1); + expect(historyA!.losses).toBe(1); + }); + }); + + describe('calculateStatistics', () => { + const completeTournament: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + [ + { name: 'Team C', seed: 3, score: 90, id: 'c' }, + { name: 'Team D', seed: 4, score: 88, id: 'd' }, + ], + ], + [ + [ + { name: 'Team A', seed: 1, score: 95, id: 'a' }, + { name: 'Team C', seed: 3, score: 92, id: 'c' }, + ], + ], + [[{ name: 'Team A', seed: 1, id: 'a' }]], + ]; + + it('should calculate basic statistics', () => { + const stats = calculateStatistics(completeTournament); + + expect(stats.participantCount).toBe(4); + expect(stats.totalRounds).toBe(3); + expect(stats.byeCount).toBe(0); // Champion round not counted as bye + }); + + it('should calculate completion percentage', () => { + const stats = calculateStatistics(completeTournament); + + // 3 total matches, 2 completed (excluding champion) + expect(stats.completionPercentage).toBeGreaterThan(50); + }); + + it('should calculate average score', () => { + const stats = calculateStatistics(completeTournament); + + expect(stats.averageScore).toBeDefined(); + expect(stats.averageScore).toBeGreaterThan(80); + expect(stats.averageScore).toBeLessThan(110); + }); + + it('should find highest score', () => { + const stats = calculateStatistics(completeTournament); + + expect(stats.highestScore).toBeDefined(); + expect(stats.highestScore!.score).toBe(100); + expect(stats.highestScore!.team.name).toBe('Team A'); + expect(stats.highestScore!.round).toBe(0); + }); + + it('should count byes correctly', () => { + const tournamentWithByes: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + [{ name: 'Team C', seed: 3, id: 'c' }], // Bye + [{ name: 'Team D', seed: 4, id: 'd' }], // Bye + ], + ]; + + const stats = calculateStatistics(tournamentWithByes); + expect(stats.byeCount).toBe(2); + }); + + it('should handle tournament with no scores', () => { + const noScoresTournament: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, id: 'a' }, + { name: 'Team B', seed: 2, id: 'b' }, + ], + ], + ]; + + const stats = calculateStatistics(noScoresTournament); + + expect(stats.averageScore).toBeUndefined(); + expect(stats.highestScore).toBeUndefined(); + expect(stats.completionPercentage).toBe(0); + }); + + it('should count unique participants', () => { + const stats = calculateStatistics(completeTournament); + + // Team A appears 3 times, but should count as 1 + expect(stats.participantCount).toBe(4); + }); + + it('should handle teams without IDs', () => { + const tournament: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, score: 100 }, + { name: 'Team B', seed: 2, score: 85 }, + ], + ], + ]; + + const stats = calculateStatistics(tournament); + + // Can't count unique without IDs + expect(stats.participantCount).toBe(0); + }); + }); + + describe('generateTournamentReport', () => { + const sampleTournament: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + [ + { name: 'Team C', seed: 3, score: 90, id: 'c' }, + { name: 'Team D', seed: 4, score: 88, id: 'd' }, + ], + ], + [ + [ + { name: 'Team A', seed: 1, score: 95, id: 'a' }, + { name: 'Team C', seed: 3, score: 92, id: 'c' }, + ], + ], + [[{ name: 'Team A', seed: 1, id: 'a' }]], + ]; + + it('should generate complete report', () => { + const report = generateTournamentReport(sampleTournament); + + expect(report.totalRounds).toBe(3); + expect(report.totalMatches).toBe(3); // Champion display not counted + expect(report.completedMatches).toBeGreaterThan(0); + expect(report.allResults).toHaveLength(3); + }); + + it('should identify champion', () => { + const report = generateTournamentReport(sampleTournament); + + expect(report.champion).toBeDefined(); + expect(report.champion!.name).toBe('Team A'); + }); + + it('should identify finalists', () => { + const report = generateTournamentReport(sampleTournament); + + expect(report.finalists).toBeDefined(); + expect(report.finalists).toHaveLength(2); + expect(report.finalists!.map(t => t.name).sort()).toEqual(['Team A', 'Team C']); + }); + + it('should include statistics when requested', () => { + const report = generateTournamentReport(sampleTournament, [], true); + + expect(report.statistics).toBeDefined(); + expect(report.statistics!.participantCount).toBe(4); + }); + + it('should not include statistics by default', () => { + const report = generateTournamentReport(sampleTournament, [], false); + + expect(report.statistics).toBeUndefined(); + }); + + it('should use custom round labels', () => { + const labels = ['Quarterfinals', 'Semifinals', 'Finals']; + const report = generateTournamentReport(sampleTournament, labels); + + expect(report.allResults[0].roundLabel).toBe('Quarterfinals'); + expect(report.allResults[1].roundLabel).toBe('Semifinals'); + expect(report.allResults[2].roundLabel).toBe('Finals'); + }); + + it('should handle incomplete tournament', () => { + const incompleteTournament: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + [ + { name: 'Team C', seed: 3, id: 'c' }, + { name: 'Team D', seed: 4, id: 'd' }, + ], + ], + ]; + + const report = generateTournamentReport(incompleteTournament); + + expect(report.champion).toBeUndefined(); + expect(report.completedMatches).toBe(1); + expect(report.remainingMatches).toBe(1); + }); + + it('should calculate remaining matches', () => { + const report = generateTournamentReport(sampleTournament); + + expect(report.completedMatches + report.remainingMatches).toBe(report.totalMatches); + }); + + it('should track current round', () => { + const partialTournament: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + ], + [ + [ + { name: 'Team A', seed: 1, id: 'a' }, + { name: 'Team C', seed: 3, id: 'c' }, + ], + ], + ]; + + const report = generateTournamentReport(partialTournament); + + // Round 0 complete, round 1 incomplete + expect(report.currentRound).toBe(1); + }); + }); + + describe('formatReportAsText', () => { + const sampleReport = generateTournamentReport([ + [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + [{ name: 'Team C', seed: 3, id: 'c' }], // Bye + ], + [ + [ + { name: 'Team A', seed: 1, score: 95, id: 'a' }, + { name: 'Team C', seed: 3, score: 92, id: 'c' }, + ], + ], + [[{ name: 'Team A', seed: 1, id: 'a' }]], + ], ['Round 1', 'Finals', 'Champion'], true); + + it('should format report as text', () => { + const text = formatReportAsText(sampleReport, true); + + expect(text).toContain('TOURNAMENT REPORT'); + expect(text).toContain('Team A'); + expect(text).toContain('CHAMPION'); + }); + + it('should include scores when requested', () => { + const text = formatReportAsText(sampleReport, true); + + expect(text).toContain('(100)'); + expect(text).toContain('(85)'); + }); + + it('should exclude scores when not requested', () => { + const text = formatReportAsText(sampleReport, false); + + expect(text).not.toContain('(100)'); + expect(text).toContain('Team A'); + expect(text).toContain('defeated'); + }); + + it('should show bye matches', () => { + const text = formatReportAsText(sampleReport, true); + + expect(text).toContain('(BYE)'); + }); + + it('should include statistics when present', () => { + const text = formatReportAsText(sampleReport, true); + + expect(text).toContain('Statistics'); + expect(text).toContain('Participants'); + expect(text).toContain('Byes'); + }); + + it('should show advancing teams', () => { + const text = formatReportAsText(sampleReport, true); + + expect(text).toContain('Advancing'); + }); + }); + + describe('formatReportAsMarkdown', () => { + const sampleReport = generateTournamentReport([ + [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + ], + [[{ name: 'Team A', seed: 1, id: 'a' }]], + ]); + + it('should format as markdown', () => { + const md = formatReportAsMarkdown(sampleReport, true); + + expect(md).toContain('# Tournament Report'); + expect(md).toContain('##'); + expect(md).toContain('|'); // Tables + }); + + it('should include tables with scores', () => { + const md = formatReportAsMarkdown(sampleReport, true); + + expect(md).toContain('| Match |'); + expect(md).toContain('| Winner |'); + expect(md).toContain('| Score |'); + expect(md).toContain('100'); + }); + + it('should format without tables when scores excluded', () => { + const md = formatReportAsMarkdown(sampleReport, false); + + expect(md).toContain('# Tournament Report'); + expect(md).toContain('- **Match'); + expect(md).not.toContain('| Score |'); + }); + + it('should show champion with trophy emoji', () => { + const md = formatReportAsMarkdown(sampleReport, true); + + expect(md).toContain('๐Ÿ† Champion'); + expect(md).toContain('Team A'); + }); + }); + + describe('formatReportAsHTML', () => { + const sampleReport = generateTournamentReport([ + [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + [{ name: 'Team C', seed: 3, id: 'c' }], // Bye + ], + [[{ name: 'Team A', seed: 1, id: 'a' }]], + ], [], true); + + it('should format as HTML', () => { + const html = formatReportAsHTML(sampleReport, true); + + expect(html).toContain('
'); + expect(html).toContain('

Tournament Report

'); + expect(html).toContain('
'); + }); + + it('should include HTML table', () => { + const html = formatReportAsHTML(sampleReport, true); + + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain('
'); + }); + + it('should include statistics section', () => { + const html = formatReportAsHTML(sampleReport, true); + + expect(html).toContain('
'); + expect(html).toContain('

Statistics

'); + }); + + it('should show champion section', () => { + const html = formatReportAsHTML(sampleReport, true); + + expect(html).toContain('
'); + expect(html).toContain('๐Ÿ† Champion'); + }); + + it('should include scores when requested', () => { + const html = formatReportAsHTML(sampleReport, true); + + expect(html).toContain('Score'); + expect(html).toContain('100'); + }); + + it('should show bye matches', () => { + const html = formatReportAsHTML(sampleReport, true); + + expect(html).toContain('BYE'); + }); + }); + + describe('complex tournament scenarios', () => { + it('should handle tournament with byes in multiple rounds', () => { + const tournament: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + [{ name: 'Team C', seed: 3, id: 'c' }], // Bye R1 + ], + [ + [ + { name: 'Team A', seed: 1, score: 95, id: 'a' }, + { name: 'Team C', seed: 3, score: 90, id: 'c' }, + ], + [{ name: 'Team D', seed: 4, id: 'd' }], // Bye R2 + ], + ]; + + const stats = calculateStatistics(tournament); + expect(stats.byeCount).toBe(2); + + const report = generateTournamentReport(tournament); + expect(report.allResults).toHaveLength(2); + }); + + it('should handle single-round tournament', () => { + const tournament: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + ], + [[{ name: 'Team A', seed: 1, id: 'a' }]], + ]; + + const report = generateTournamentReport(tournament); + + expect(report.totalRounds).toBe(2); + expect(report.champion!.name).toBe('Team A'); + }); + + it('should handle large tournament', () => { + // Create 16-team tournament + const teams: Team[] = Array.from({ length: 16 }, (_, i) => ({ + name: `Team ${i + 1}`, + seed: i + 1, + id: `t${i + 1}`, + score: Math.floor(Math.random() * 20) + 90, + })); + + const rounds: Round[] = [ + Array.from({ length: 8 }, (_, i) => [teams[i * 2], teams[i * 2 + 1]]), + ]; + + const stats = calculateStatistics(rounds); + + expect(stats.participantCount).toBe(16); + expect(stats.totalRounds).toBe(1); + expect(stats.averageScore).toBeDefined(); + }); + }); + + describe('edge cases', () => { + it('should handle empty tournament', () => { + const stats = calculateStatistics([]); + + expect(stats.participantCount).toBe(0); + expect(stats.totalRounds).toBe(0); + expect(stats.byeCount).toBe(0); + expect(stats.completionPercentage).toBe(0); + }); + + it('should handle tournament with only byes', () => { + const tournament: TournamentData = [ + [ + [{ name: 'Team A', seed: 1, id: 'a' }], + [{ name: 'Team B', seed: 2, id: 'b' }], + ], + ]; + + const stats = calculateStatistics(tournament); + expect(stats.byeCount).toBe(2); + expect(stats.completionPercentage).toBe(100); // Byes are "complete" + }); + + it('should handle tournament with score of 0', () => { + const tournament: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 0, id: 'b' }, + ], + ], + ]; + + const stats = calculateStatistics(tournament); + + expect(stats.averageScore).toBe(50); // (100 + 0) / 2 + }); + + it('should handle very high scores', () => { + const tournament: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, score: 999, id: 'a' }, + { name: 'Team B', seed: 2, score: 998, id: 'b' }, + ], + ], + ]; + + const stats = calculateStatistics(tournament); + + expect(stats.highestScore!.score).toBe(999); + expect(stats.averageScore).toBe(998.5); + }); + }); + + describe('report format consistency', () => { + const tournament: TournamentData = [ + [ + [ + { name: 'Team A', seed: 1, score: 100, id: 'a' }, + { name: 'Team B', seed: 2, score: 85, id: 'b' }, + ], + ], + [[{ name: 'Team A', seed: 1, id: 'a' }]], + ]; + + it('should include champion in all formats', () => { + const report = generateTournamentReport(tournament); + + const text = formatReportAsText(report); + const markdown = formatReportAsMarkdown(report); + const html = formatReportAsHTML(report); + + expect(text).toContain('CHAMPION'); + expect(markdown).toContain('Champion'); + expect(html).toContain('Champion'); + }); + + it('should show match results in all formats', () => { + const report = generateTournamentReport(tournament); + + const text = formatReportAsText(report, true); + const markdown = formatReportAsMarkdown(report, true); + const html = formatReportAsHTML(report, true); + + expect(text).toContain('Team A'); + expect(text).toContain('Team B'); + + expect(markdown).toContain('Team A'); + expect(markdown).toContain('Team B'); + + expect(html).toContain('Team A'); + expect(html).toContain('Team B'); + }); + }); +}); diff --git a/src/utils/reporting.ts b/src/utils/reporting.ts new file mode 100644 index 0000000..d47d30e --- /dev/null +++ b/src/utils/reporting.ts @@ -0,0 +1,574 @@ +/** + * Reporting and statistics utilities + * Implements Issue #14b + */ + +import type { + TournamentData, + Round, + Game, + Team, + MatchResult, + TeamHistory, + MatchEntry, + TournamentReport, + RoundReport, + TournamentStatistics, +} from '../types'; +import { isByeGame, getMatchWinner, countTotalMatches, countCompletedMatches, countByes } from './tournament'; + +/** + * Get advancing teams from a round + */ +export const getAdvancingTeams = (round: Round): Team[] => { + const winners: Team[] = []; + + for (const game of round) { + const winner = getMatchWinner(game); + if (winner) { + winners.push(winner); + } + } + + return winners; +}; + +/** + * Get match result for a single game + */ +export const getGameResult = (game: Game): MatchResult | null => { + // Bye game + if (isByeGame(game)) { + return { + winner: game[0], + loser: null, + winnerScore: game[0].score, + loserScore: undefined, + isBye: true, + }; + } + + // Regular game + if (game.length !== 2) { + return null; + } + + const [team1, team2] = game; + const winner = getMatchWinner(game); + + if (!winner) { + return null; // Match not complete + } + + const loser = winner === team1 ? team2 : team1; + + return { + winner, + loser, + winnerScore: winner.score, + loserScore: loser.score, + isBye: false, + }; +}; + +/** + * Get all match results for a round + */ +export const getRoundResults = (round: Round): MatchResult[] => { + const results: MatchResult[] = []; + + for (const game of round) { + const result = getGameResult(game); + if (result) { + results.push(result); + } + } + + return results; +}; + +/** + * Build team history by tracking a team through all rounds + */ +export const buildTeamHistory = ( + teamId: string, + tournamentData: TournamentData, + roundLabels: string[] = [] +): TeamHistory | null => { + let team: Team | null = null; + const matches: MatchEntry[] = []; + let wins = 0; + let losses = 0; + + // Find team and track through rounds + for (let roundIndex = 0; roundIndex < tournamentData.length; roundIndex++) { + const round = tournamentData[roundIndex]; + const roundLabel = roundLabels[roundIndex] || `Round ${roundIndex + 1}`; + const isLastRound = roundIndex === tournamentData.length - 1; + + // Find game with this team + for (const game of round) { + const teamInGame = game.find(t => t.id === teamId); + + if (teamInGame) { + // Store team reference (use latest) + team = teamInGame; + + // Skip champion display (last round, single team) + if (isLastRound && round.length === 1 && game.length === 1) { + break; // Don't count champion display as a match + } + + // Determine match outcome + if (isByeGame(game)) { + // Bye - automatic win + matches.push({ + roundIndex, + roundLabel, + opponent: null, + won: true, + score: teamInGame.score, + opponentScore: undefined, + isBye: true, + }); + wins++; + } else if (game.length === 2) { + const opponent = game.find(t => t.id !== teamId); + const winner = getMatchWinner(game); + + if (winner) { + const won = winner.id === teamId; + matches.push({ + roundIndex, + roundLabel, + opponent: opponent || null, + won, + score: teamInGame.score, + opponentScore: opponent?.score, + isBye: false, + }); + + if (won) { + wins++; + } else { + losses++; + } + } + } + + break; // Found team in this round + } + } + } + + if (!team) { + return null; // Team not found + } + + // Determine final placement + let finalPlacement: number | undefined; + + // Check if team is in final round (champion) + const lastRound = tournamentData[tournamentData.length - 1]; + if (lastRound && lastRound.length === 1 && lastRound[0].length === 1) { + if (lastRound[0][0].id === teamId) { + finalPlacement = 1; // Champion + } + } + + // Check if team is in finals (runner-up) + if (!finalPlacement && tournamentData.length >= 2) { + const finalsRound = tournamentData[tournamentData.length - 2]; + if (finalsRound && finalsRound.length === 1 && finalsRound[0].length === 2) { + const inFinals = finalsRound[0].some(t => t.id === teamId); + const finalsWinner = getMatchWinner(finalsRound[0]); + if (inFinals && finalsWinner && finalsWinner.id !== teamId) { + finalPlacement = 2; // Runner-up + } + } + } + + return { + team, + matches, + finalPlacement, + wins, + losses, + }; +}; + +/** + * Generate round report + */ +export const generateRoundReport = ( + round: Round, + roundIndex: number, + roundLabel: string +): RoundReport => { + const matches = getRoundResults(round); + const advancingTeams = getAdvancingTeams(round); + const isComplete = matches.length === round.length; + + return { + roundIndex, + roundLabel, + isComplete, + matches, + advancingTeams, + }; +}; + +/** + * Calculate tournament statistics + */ +export const calculateStatistics = (tournamentData: TournamentData): TournamentStatistics => { + const totalMatches = countTotalMatches(tournamentData); + const completedMatches = countCompletedMatches(tournamentData); + const byeCount = countByes(tournamentData); + + // Count unique participants (only from first round to avoid counting placeholders) + const uniqueTeams = new Set(); + if (tournamentData.length > 0) { + const firstRound = tournamentData[0]; + for (const game of firstRound) { + for (const team of game) { + if (team.id && team.name && !team.name.includes('TBD')) { + uniqueTeams.add(team.id); + } + } + } + } + + // Calculate average score + let totalScore = 0; + let scoreCount = 0; + let highestScore: { team: Team; score: number; round: number } | undefined; + + for (let roundIndex = 0; roundIndex < tournamentData.length; roundIndex++) { + const round = tournamentData[roundIndex]; + for (const game of round) { + for (const team of game) { + if (team.score !== undefined) { + totalScore += team.score; + scoreCount++; + + if (!highestScore || team.score > highestScore.score) { + highestScore = { + team, + score: team.score, + round: roundIndex, + }; + } + } + } + } + } + + const averageScore = scoreCount > 0 ? totalScore / scoreCount : undefined; + const completionPercentage = totalMatches > 0 + ? Math.round((completedMatches / totalMatches) * 100) + : 0; + + return { + participantCount: uniqueTeams.size, + totalRounds: tournamentData.length, + byeCount, + averageScore, + highestScore, + completionPercentage, + }; +}; + +/** + * Generate complete tournament report + */ +export const generateTournamentReport = ( + tournamentData: TournamentData, + roundLabels: string[] = [], + includeStatistics: boolean = false +): TournamentReport => { + const totalMatches = countTotalMatches(tournamentData); + const completedMatches = countCompletedMatches(tournamentData); + const remainingMatches = totalMatches - completedMatches; + + // Generate round reports + const allResults: RoundReport[] = []; + let currentRound = 0; + + for (let i = 0; i < tournamentData.length; i++) { + const round = tournamentData[i]; + const roundLabel = roundLabels[i] || `Round ${i + 1}`; + const roundReport = generateRoundReport(round, i, roundLabel); + + allResults.push(roundReport); + + if (!roundReport.isComplete && i < currentRound) { + currentRound = i; + } else if (roundReport.isComplete) { + currentRound = i + 1; + } + } + + // Determine champion and finalists + let champion: Team | undefined; + let finalists: Team[] | undefined; + + if (tournamentData.length > 0) { + const lastRound = tournamentData[tournamentData.length - 1]; + if (lastRound.length === 1 && lastRound[0].length === 1) { + champion = lastRound[0][0]; + } + + if (tournamentData.length >= 2) { + const finalsRound = tournamentData[tournamentData.length - 2]; + if (finalsRound.length === 1 && finalsRound[0].length === 2) { + finalists = [...finalsRound[0]]; + } + } + } + + const report: TournamentReport = { + totalRounds: tournamentData.length, + totalMatches, + completedMatches, + remainingMatches, + currentRound, + champion, + finalists, + allResults, + }; + + if (includeStatistics) { + report.statistics = calculateStatistics(tournamentData); + } + + return report; +}; + +/** + * Format report as plain text + */ +export const formatReportAsText = ( + report: TournamentReport, + includeScores: boolean = true +): string => { + const lines: string[] = []; + + lines.push('='.repeat(50)); + lines.push('TOURNAMENT REPORT'); + lines.push('='.repeat(50)); + lines.push(''); + + if (report.statistics) { + lines.push('Tournament Statistics:'); + lines.push(`- Total Participants: ${report.statistics.participantCount}`); + lines.push(`- Total Rounds: ${report.statistics.totalRounds}`); + lines.push(`- Total Matches: ${report.totalMatches}`); + lines.push(`- Completed: ${report.completedMatches}/${report.totalMatches} (${report.statistics.completionPercentage}%)`); + if (report.statistics.byeCount > 0) { + lines.push(`- Byes: ${report.statistics.byeCount}`); + } + if (report.statistics.averageScore !== undefined) { + lines.push(`- Average Score: ${report.statistics.averageScore.toFixed(1)}`); + } + lines.push(''); + } + + // Round by round + for (const roundReport of report.allResults) { + lines.push(`${roundReport.roundLabel.toUpperCase()}`); + + if (roundReport.matches.length === 0) { + lines.push(' (No completed matches)'); + } else { + roundReport.matches.forEach((match, idx) => { + if (match.isBye) { + lines.push(` โœ“ Match ${idx + 1}: ${match.winner.name} (BYE)`); + } else { + const winnerScore = includeScores && match.winnerScore !== undefined ? ` (${match.winnerScore})` : ''; + const loserScore = includeScores && match.loserScore !== undefined ? ` (${match.loserScore})` : ''; + lines.push(` โœ“ Match ${idx + 1}: ${match.winner.name}${winnerScore} defeated ${match.loser?.name}${loserScore}`); + } + }); + } + + if (roundReport.advancingTeams.length > 0 && roundReport.roundIndex < report.totalRounds - 1) { + lines.push(''); + lines.push(` Advancing: ${roundReport.advancingTeams.map(t => t.name).join(', ')}`); + } + + lines.push(''); + } + + if (report.champion) { + lines.push(`CHAMPION: ${report.champion.name} (Seed ${report.champion.seed})`); + lines.push('='.repeat(50)); + } + + return lines.join('\n'); +}; + +/** + * Format report as markdown + */ +export const formatReportAsMarkdown = ( + report: TournamentReport, + includeScores: boolean = true +): string => { + const lines: string[] = []; + + lines.push('# Tournament Report'); + lines.push(''); + + if (report.statistics) { + lines.push('## Statistics'); + lines.push(''); + lines.push(`- **Participants**: ${report.statistics.participantCount}`); + lines.push(`- **Total Rounds**: ${report.statistics.totalRounds}`); + lines.push(`- **Completion**: ${report.completedMatches}/${report.totalMatches} (${report.statistics.completionPercentage}%)`); + if (report.statistics.byeCount > 0) { + lines.push(`- **Byes**: ${report.statistics.byeCount}`); + } + if (report.statistics.averageScore !== undefined) { + lines.push(`- **Average Score**: ${report.statistics.averageScore.toFixed(1)}`); + } + lines.push(''); + } + + // Round by round + for (const roundReport of report.allResults) { + lines.push(`## ${roundReport.roundLabel}`); + lines.push(''); + + if (roundReport.matches.length === 0) { + lines.push('_(No completed matches)_'); + lines.push(''); + } else { + if (includeScores) { + lines.push('| Match | Winner | Score | Loser | Score |'); + lines.push('|-------|--------|-------|-------|-------|'); + + roundReport.matches.forEach((match, idx) => { + if (match.isBye) { + lines.push(`| ${idx + 1} | ${match.winner.name} | - | BYE | - |`); + } else { + const winnerScore = match.winnerScore !== undefined ? match.winnerScore : '-'; + const loserScore = match.loserScore !== undefined ? match.loserScore : '-'; + lines.push(`| ${idx + 1} | ${match.winner.name} | ${winnerScore} | ${match.loser?.name || '-'} | ${loserScore} |`); + } + }); + } else { + roundReport.matches.forEach((match, idx) => { + if (match.isBye) { + lines.push(`- **Match ${idx + 1}**: ${match.winner.name} (BYE)`); + } else { + lines.push(`- **Match ${idx + 1}**: ${match.winner.name} defeated ${match.loser?.name}`); + } + }); + } + + lines.push(''); + + if (roundReport.advancingTeams.length > 0 && roundReport.roundIndex < report.totalRounds - 1) { + lines.push(`**Advancing**: ${roundReport.advancingTeams.map(t => t.name).join(', ')}`); + lines.push(''); + } + } + } + + if (report.champion) { + lines.push(`## ๐Ÿ† Champion: ${report.champion.name}`); + lines.push(''); + } + + return lines.join('\n'); +}; + +/** + * Format report as HTML + */ +export const formatReportAsHTML = ( + report: TournamentReport, + includeScores: boolean = true +): string => { + const lines: string[] = []; + + lines.push('
'); + lines.push('

Tournament Report

'); + + if (report.statistics) { + lines.push('
'); + lines.push('

Statistics

'); + lines.push('
    '); + lines.push(`
  • Participants: ${report.statistics.participantCount}
  • `); + lines.push(`
  • Total Rounds: ${report.statistics.totalRounds}
  • `); + lines.push(`
  • Completion: ${report.completedMatches}/${report.totalMatches} (${report.statistics.completionPercentage}%)
  • `); + if (report.statistics.byeCount > 0) { + lines.push(`
  • Byes: ${report.statistics.byeCount}
  • `); + } + if (report.statistics.averageScore !== undefined) { + lines.push(`
  • Average Score: ${report.statistics.averageScore.toFixed(1)}
  • `); + } + lines.push('
'); + lines.push('
'); + } + + // Round by round + for (const roundReport of report.allResults) { + lines.push('
'); + lines.push(`

${roundReport.roundLabel}

`); + + if (roundReport.matches.length === 0) { + lines.push('

No completed matches

'); + } else { + lines.push(' '); + lines.push(' '); + lines.push(' '); + lines.push(' '); + lines.push(' '); + if (includeScores) { + lines.push(' '); + } + lines.push(' '); + if (includeScores) { + lines.push(' '); + } + lines.push(' '); + lines.push(' '); + lines.push(' '); + + roundReport.matches.forEach((match, idx) => { + lines.push(' '); + lines.push(` `); + lines.push(` `); + if (includeScores) { + lines.push(` `); + } + lines.push(` `); + if (includeScores) { + lines.push(` `); + } + lines.push(' '); + }); + + lines.push(' '); + lines.push('
MatchWinnerScoreLoserScore
${idx + 1}${match.winner.name}${match.winnerScore !== undefined ? match.winnerScore : '-'}${match.isBye ? 'BYE' : (match.loser?.name || '-')}${match.loserScore !== undefined ? match.loserScore : '-'}
'); + + if (roundReport.advancingTeams.length > 0 && roundReport.roundIndex < report.totalRounds - 1) { + lines.push(`

Advancing: ${roundReport.advancingTeams.map(t => t.name).join(', ')}

`); + } + } + + lines.push('
'); + } + + if (report.champion) { + lines.push('
'); + lines.push(`

๐Ÿ† Champion: ${report.champion.name}

`); + lines.push('
'); + } + + lines.push('
'); + + return lines.join('\n'); +}; diff --git a/src/utils/tournament.test.ts b/src/utils/tournament.test.ts new file mode 100644 index 0000000..53729e8 --- /dev/null +++ b/src/utils/tournament.test.ts @@ -0,0 +1,643 @@ +import { describe, it, expect } from 'vitest'; +import { + isByeGame, + getMatchWinner, + isRoundComplete, + applyTieBreaker, + validateRoundComplete, + collectWinners, + generateNextRound, + countTotalMatches, + countCompletedMatches, + countByes, +} from './tournament'; +import type { Game, Round, Team } from '../types'; + +describe('tournament utilities', () => { + describe('isByeGame', () => { + it('should return true for single-team games', () => { + const game: Game = [{ name: 'Team A', seed: 1 }]; + expect(isByeGame(game)).toBe(true); + }); + + it('should return false for two-team games', () => { + const game: Game = [ + { name: 'Team A', seed: 1 }, + { name: 'Team B', seed: 2 }, + ]; + expect(isByeGame(game)).toBe(false); + }); + + it('should return false for empty games', () => { + const game: Game = []; + expect(isByeGame(game)).toBe(false); + }); + + it('should return false for games with more than 2 teams', () => { + const game: Game = [ + { name: 'Team A', seed: 1 }, + { name: 'Team B', seed: 2 }, + { name: 'Team C', seed: 3 }, + ]; + expect(isByeGame(game)).toBe(false); + }); + }); + + describe('getMatchWinner', () => { + it('should return winner for bye game', () => { + const game: Game = [{ name: 'Team A', seed: 1, id: 'a' }]; + const winner = getMatchWinner(game); + + expect(winner).toBeTruthy(); + expect(winner?.name).toBe('Team A'); + }); + + it('should return winner based on higher score', () => { + const game: Game = [ + { name: 'Team A', seed: 1, score: 100 }, + { name: 'Team B', seed: 2, score: 85 }, + ]; + const winner = getMatchWinner(game); + + expect(winner).toBeTruthy(); + expect(winner?.name).toBe('Team A'); + }); + + it('should return winner when team 2 has higher score', () => { + const game: Game = [ + { name: 'Team A', seed: 1, score: 85 }, + { name: 'Team B', seed: 2, score: 100 }, + ]; + const winner = getMatchWinner(game); + + expect(winner).toBeTruthy(); + expect(winner?.name).toBe('Team B'); + }); + + it('should return null for tied scores', () => { + const game: Game = [ + { name: 'Team A', seed: 1, score: 100 }, + { name: 'Team B', seed: 2, score: 100 }, + ]; + + expect(getMatchWinner(game)).toBeNull(); + }); + + it('should return null when scores are missing', () => { + const game: Game = [ + { name: 'Team A', seed: 1 }, + { name: 'Team B', seed: 2 }, + ]; + + expect(getMatchWinner(game)).toBeNull(); + }); + + it('should return null when only one team has score', () => { + const game: Game = [ + { name: 'Team A', seed: 1, score: 100 }, + { name: 'Team B', seed: 2 }, + ]; + + expect(getMatchWinner(game)).toBeNull(); + }); + + it('should return null for invalid game structures', () => { + expect(getMatchWinner([])).toBeNull(); + + const threeTeams: Game = [ + { name: 'Team A', seed: 1 }, + { name: 'Team B', seed: 2 }, + { name: 'Team C', seed: 3 }, + ]; + expect(getMatchWinner(threeTeams)).toBeNull(); + }); + + it('should handle score of 0', () => { + const game: Game = [ + { name: 'Team A', seed: 1, score: 100 }, + { name: 'Team B', seed: 2, score: 0 }, + ]; + const winner = getMatchWinner(game); + + expect(winner).toBeTruthy(); + expect(winner?.name).toBe('Team A'); + }); + }); + + describe('isRoundComplete', () => { + it('should return true when all games have winners', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1, score: 100 }, + { name: 'Team B', seed: 2, score: 85 }, + ], + [ + { name: 'Team C', seed: 3, score: 90 }, + { name: 'Team D', seed: 4, score: 88 }, + ], + ]; + + expect(isRoundComplete(round)).toBe(true); + }); + + it('should return true for round with only byes', () => { + const round: Round = [ + [{ name: 'Team A', seed: 1 }], + [{ name: 'Team B', seed: 2 }], + ]; + + expect(isRoundComplete(round)).toBe(true); + }); + + it('should return true for mixed byes and completed matches', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1, score: 100 }, + { name: 'Team B', seed: 2, score: 85 }, + ], + [{ name: 'Team C', seed: 3 }], // Bye + ]; + + expect(isRoundComplete(round)).toBe(true); + }); + + it('should return false when some games are incomplete', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1, score: 100 }, + { name: 'Team B', seed: 2, score: 85 }, + ], + [ + { name: 'Team C', seed: 3 }, + { name: 'Team D', seed: 4 }, + ], + ]; + + expect(isRoundComplete(round)).toBe(false); + }); + + it('should return false for tied games', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1, score: 100 }, + { name: 'Team B', seed: 2, score: 100 }, + ], + ]; + + expect(isRoundComplete(round)).toBe(false); + }); + + it('should return false for empty rounds', () => { + expect(isRoundComplete([])).toBe(false); + }); + + it('should return true for single complete game', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1, score: 100 }, + { name: 'Team B', seed: 2, score: 85 }, + ], + ]; + + expect(isRoundComplete(round)).toBe(true); + }); + }); + + describe('applyTieBreaker', () => { + const team1: Team = { name: 'Team A', seed: 1 }; + const team2: Team = { name: 'Team B', seed: 8 }; + + it('should apply higher-seed strategy correctly', () => { + const winner = applyTieBreaker(team1, team2, 'higher-seed'); + expect(winner.name).toBe('Team A'); // Lower seed number = higher seed + }); + + it('should apply lower-seed strategy correctly', () => { + const winner = applyTieBreaker(team1, team2, 'lower-seed'); + expect(winner.name).toBe('Team B'); // Higher seed number = lower seed + }); + + it('should apply callback strategy with custom function', () => { + const tieBreakerFn = (t1: Team, t2: Team) => { + return t1.name < t2.name ? t1 : t2; // Alphabetical + }; + + const winner = applyTieBreaker(team1, team2, 'callback', tieBreakerFn); + expect(winner.name).toBe('Team A'); + }); + + it('should throw error when callback strategy has no function', () => { + expect(() => { + applyTieBreaker(team1, team2, 'callback'); + }).toThrow('Tie-breaker callback function is required'); + }); + + it('should throw error for unknown strategy', () => { + expect(() => { + applyTieBreaker(team1, team2, 'invalid' as 'higher-seed' | 'lower-seed' | 'callback'); + }).toThrow('Unknown tie-breaker strategy'); + }); + + it('should handle equal seeds with higher-seed strategy', () => { + const team3: Team = { name: 'Team C', seed: 1 }; + const winner = applyTieBreaker(team1, team3, 'higher-seed'); + // Should return first team when equal + expect(winner.name).toBe('Team A'); + }); + }); + + describe('validateRoundComplete', () => { + it('should not throw for complete round', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1, score: 100 }, + { name: 'Team B', seed: 2, score: 85 }, + ], + ]; + + expect(() => validateRoundComplete(round, 0)).not.toThrow(); + }); + + it('should throw for incomplete round with missing scores', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1 }, + { name: 'Team B', seed: 2 }, + ], + ]; + + expect(() => validateRoundComplete(round, 0)).toThrow('Missing scores'); + }); + + it('should throw for tied scores', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1, score: 100 }, + { name: 'Team B', seed: 2, score: 100 }, + ], + ]; + + expect(() => validateRoundComplete(round, 0)).toThrow('Tied score'); + }); + + it('should throw for invalid game structure', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1 }, + { name: 'Team B', seed: 2 }, + { name: 'Team C', seed: 3 }, + ], + ]; + + expect(() => validateRoundComplete(round, 0)).toThrow('Invalid game structure'); + }); + + it('should include round and game indices in error message', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1 }, + { name: 'Team B', seed: 2 }, + ], + ]; + + expect(() => validateRoundComplete(round, 2)).toThrow('Round 3, Game 1'); + }); + }); + + describe('collectWinners', () => { + it('should collect winners from complete round', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1, score: 100 }, + { name: 'Team B', seed: 2, score: 85 }, + ], + [ + { name: 'Team C', seed: 3, score: 90 }, + { name: 'Team D', seed: 4, score: 88 }, + ], + ]; + + const winners = collectWinners(round); + expect(winners).toHaveLength(2); + expect(winners[0].name).toBe('Team A'); + expect(winners[1].name).toBe('Team C'); + }); + + it('should collect winners including byes', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1, score: 100 }, + { name: 'Team B', seed: 2, score: 85 }, + ], + [{ name: 'Team C', seed: 3 }], // Bye + ]; + + const winners = collectWinners(round); + expect(winners).toHaveLength(2); + expect(winners[0].name).toBe('Team A'); + expect(winners[1].name).toBe('Team C'); + }); + + it('should apply tie-breaker for tied scores', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1, score: 100 }, + { name: 'Team B', seed: 8, score: 100 }, + ], + ]; + + const winners = collectWinners(round, 'higher-seed'); + expect(winners).toHaveLength(1); + expect(winners[0].name).toBe('Team A'); // Higher seed wins + }); + + it('should throw error for ties with error strategy', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1, score: 100 }, + { name: 'Team B', seed: 2, score: 100 }, + ], + ]; + + expect(() => collectWinners(round, 'error')).toThrow('Tied score'); + }); + + it('should handle callback tie-breaker', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1, score: 100 }, + { name: 'Team B', seed: 2, score: 100 }, + ], + ]; + + const tieBreakerFn = (t1: Team, t2: Team) => t2; // Always pick second team + const winners = collectWinners(round, 'callback', tieBreakerFn); + + expect(winners).toHaveLength(1); + expect(winners[0].name).toBe('Team B'); + }); + + it('should throw for incomplete games', () => { + const round: Round = [ + [ + { name: 'Team A', seed: 1 }, + { name: 'Team B', seed: 2 }, + ], + ]; + + expect(() => collectWinners(round)).toThrow('Unable to determine winner'); + }); + }); + + describe('generateNextRound', () => { + it('should generate next round from winners', () => { + const winners: Team[] = [ + { name: 'Team A', seed: 1 }, + { name: 'Team C', seed: 3 }, + { name: 'Team E', seed: 5 }, + { name: 'Team G', seed: 7 }, + ]; + + const nextRound = generateNextRound(winners); + + expect(nextRound).toHaveLength(2); + expect(nextRound[0]).toHaveLength(2); + expect(nextRound[0][0].name).toBe('Team A'); + expect(nextRound[0][1].name).toBe('Team C'); + expect(nextRound[1][0].name).toBe('Team E'); + expect(nextRound[1][1].name).toBe('Team G'); + }); + + it('should generate champion round for single winner', () => { + const winners: Team[] = [{ name: 'Team A', seed: 1 }]; + + const nextRound = generateNextRound(winners); + + expect(nextRound).toHaveLength(1); + expect(nextRound[0]).toHaveLength(1); + expect(nextRound[0][0].name).toBe('Team A'); + }); + + it('should handle odd number of winners with bye', () => { + const winners: Team[] = [ + { name: 'Team A', seed: 1 }, + { name: 'Team C', seed: 3 }, + { name: 'Team E', seed: 5 }, + ]; + + const nextRound = generateNextRound(winners); + + expect(nextRound).toHaveLength(2); + expect(nextRound[0]).toHaveLength(2); // Regular match + expect(nextRound[1]).toHaveLength(1); // Bye + expect(nextRound[1][0].name).toBe('Team E'); + }); + + it('should clear scores by default', () => { + const winners: Team[] = [ + { name: 'Team A', seed: 1, score: 100 }, + { name: 'Team B', seed: 2, score: 95 }, + ]; + + const nextRound = generateNextRound(winners, false); + + expect(nextRound[0][0].score).toBeUndefined(); + expect(nextRound[0][1].score).toBeUndefined(); + }); + + it('should preserve scores when requested', () => { + const winners: Team[] = [ + { name: 'Team A', seed: 1, score: 100 }, + { name: 'Team B', seed: 2, score: 95 }, + ]; + + const nextRound = generateNextRound(winners, true); + + expect(nextRound[0][0].score).toBe(100); + expect(nextRound[0][1].score).toBe(95); + }); + + it('should return empty round for no winners', () => { + const nextRound = generateNextRound([]); + expect(nextRound).toHaveLength(0); + }); + + it('should create bye for 3 winners', () => { + const winners: Team[] = [ + { name: 'Team A', seed: 1 }, + { name: 'Team B', seed: 2 }, + { name: 'Team C', seed: 3 }, + ]; + + const nextRound = generateNextRound(winners); + + expect(nextRound).toHaveLength(2); + expect(nextRound[0][0].name).toBe('Team A'); + expect(nextRound[0][1].name).toBe('Team B'); + expect(nextRound[1]).toHaveLength(1); + expect(nextRound[1][0].name).toBe('Team C'); + }); + }); + + describe('countTotalMatches', () => { + it('should count total matches in tournament', () => { + const rounds: Round[] = [ + [ + [{ name: 'A', seed: 1 }, { name: 'B', seed: 2 }], + [{ name: 'C', seed: 3 }, { name: 'D', seed: 4 }], + ], + [ + [{ name: 'A', seed: 1 }, { name: 'C', seed: 3 }], + ], + [[{ name: 'A', seed: 1 }]], + ]; + + expect(countTotalMatches(rounds)).toBe(3); // Champion display not counted + }); + + it('should count byes as matches', () => { + const rounds: Round[] = [ + [ + [{ name: 'A', seed: 1 }, { name: 'B', seed: 2 }], + [{ name: 'C', seed: 3 }], // Bye + ], + ]; + + expect(countTotalMatches(rounds)).toBe(2); + }); + + it('should return 0 for empty tournament', () => { + expect(countTotalMatches([])).toBe(0); + }); + + it('should handle tournament with empty rounds', () => { + const rounds: Round[] = [[], []]; + expect(countTotalMatches(rounds)).toBe(0); + }); + }); + + describe('countCompletedMatches', () => { + it('should count only completed matches', () => { + const rounds: Round[] = [ + [ + [ + { name: 'A', seed: 1, score: 100 }, + { name: 'B', seed: 2, score: 85 }, + ], + [{ name: 'C', seed: 3 }, { name: 'D', seed: 4 }], // Incomplete + ], + ]; + + expect(countCompletedMatches(rounds)).toBe(1); + }); + + it('should count byes as completed', () => { + const rounds: Round[] = [ + [ + [{ name: 'A', seed: 1 }], // Bye + [{ name: 'C', seed: 3 }, { name: 'D', seed: 4 }], // Incomplete + ], + ]; + + expect(countCompletedMatches(rounds)).toBe(1); + }); + + it('should not count ties as completed', () => { + const rounds: Round[] = [ + [ + [ + { name: 'A', seed: 1, score: 100 }, + { name: 'B', seed: 2, score: 100 }, + ], + ], + ]; + + expect(countCompletedMatches(rounds)).toBe(0); + }); + + it('should return 0 for empty tournament', () => { + expect(countCompletedMatches([])).toBe(0); + }); + + it('should count across all rounds', () => { + const rounds: Round[] = [ + [ + [ + { name: 'A', seed: 1, score: 100 }, + { name: 'B', seed: 2, score: 85 }, + ], + [ + { name: 'C', seed: 3, score: 90 }, + { name: 'D', seed: 4, score: 88 }, + ], + ], + [ + [ + { name: 'A', seed: 1, score: 95 }, + { name: 'C', seed: 3, score: 92 }, + ], + ], + ]; + + expect(countCompletedMatches(rounds)).toBe(3); + }); + }); + + describe('countByes', () => { + it('should count bye games', () => { + const rounds: Round[] = [ + [ + [{ name: 'A', seed: 1 }, { name: 'B', seed: 2 }], + [{ name: 'C', seed: 3 }], // Bye + [{ name: 'D', seed: 4 }], // Bye + ], + ]; + + expect(countByes(rounds)).toBe(2); + }); + + it('should count byes across multiple rounds', () => { + const rounds: Round[] = [ + [ + [{ name: 'A', seed: 1 }], // Bye + [{ name: 'B', seed: 2 }], // Bye + ], + [ + [{ name: 'A', seed: 1 }, { name: 'B', seed: 2 }], + ], + ]; + + expect(countByes(rounds)).toBe(2); + }); + + it('should return 0 when no byes', () => { + const rounds: Round[] = [ + [ + [{ name: 'A', seed: 1 }, { name: 'B', seed: 2 }], + [{ name: 'C', seed: 3 }, { name: 'D', seed: 4 }], + ], + ]; + + expect(countByes(rounds)).toBe(0); + }); + + it('should return 0 for empty tournament', () => { + expect(countByes([])).toBe(0); + }); + + it('should not count champion as bye', () => { + const rounds: Round[] = [ + [ + [ + { name: 'A', seed: 1, score: 100 }, + { name: 'B', seed: 2, score: 85 }, + ], + ], + [[{ name: 'A', seed: 1 }]], // Champion, not bye in context + ]; + + // Champion display is not counted as a bye in multi-round tournaments + expect(countByes(rounds)).toBe(0); + }); + }); +}); diff --git a/src/utils/tournament.ts b/src/utils/tournament.ts new file mode 100644 index 0000000..246398e --- /dev/null +++ b/src/utils/tournament.ts @@ -0,0 +1,267 @@ +/** + * Tournament utility functions + * Core logic for match winners, round completion, and advancement + */ + +import type { Game, Round, Team } from '../types'; + +/** + * Check if a game is a bye (single team) + */ +export const isByeGame = (game: Game): boolean => { + return game.length === 1; +}; + +/** + * Determine the winner of a match + * Returns null if match is incomplete or tied (without tie-breaker) + */ +export const getMatchWinner = (game: Game): Team | null => { + // Bye game - single team automatically advances + if (isByeGame(game)) { + return game[0]; + } + + // Need exactly 2 teams for a regular match + if (game.length !== 2) { + return null; + } + + const [team1, team2] = game; + + // Both teams must have scores + if (team1.score === undefined || team2.score === undefined) { + return null; + } + + // Determine winner by score + if (team1.score > team2.score) { + return team1; + } else if (team2.score > team1.score) { + return team2; + } + + // Tied - needs tie-breaker + return null; +}; + +/** + * Check if a round is complete (all games have determined winners) + */ +export const isRoundComplete = (round: Round): boolean => { + if (!round || round.length === 0) { + return false; + } + + return round.every(game => getMatchWinner(game) !== null); +}; + +/** + * Apply tie-breaking strategy to determine winner + */ +export const applyTieBreaker = ( + team1: Team, + team2: Team, + strategy: 'higher-seed' | 'lower-seed' | 'callback', + tieBreakerFn?: (t1: Team, t2: Team) => Team +): Team => { + switch (strategy) { + case 'higher-seed': + // Lower seed number = higher seed + // If equal seeds, return first team + if (team1.seed === team2.seed) return team1; + return team1.seed < team2.seed ? team1 : team2; + + case 'lower-seed': + // Higher seed number = lower seed + // If equal seeds, return first team + if (team1.seed === team2.seed) return team1; + return team1.seed > team2.seed ? team1 : team2; + + case 'callback': + if (!tieBreakerFn) { + throw new Error('Tie-breaker callback function is required when strategy is "callback"'); + } + return tieBreakerFn(team1, team2); + + default: + throw new Error(`Unknown tie-breaker strategy: ${strategy}`); + } +}; + +/** + * Validate that all games in a round have winners + * Throws descriptive error if any game is incomplete + */ +export const validateRoundComplete = (round: Round, roundIndex: number): void => { + round.forEach((game, gameIndex) => { + const winner = getMatchWinner(game); + + if (!winner) { + if (isByeGame(game)) { + throw new Error(`Round ${roundIndex + 1}, Game ${gameIndex + 1}: Bye game has no team`); + } + + if (game.length !== 2) { + throw new Error(`Round ${roundIndex + 1}, Game ${gameIndex + 1}: Invalid game structure (${game.length} teams)`); + } + + const [team1, team2] = game; + + if (team1.score === undefined || team2.score === undefined) { + throw new Error(`Round ${roundIndex + 1}, Game ${gameIndex + 1}: Missing scores (${team1.name} vs ${team2.name})`); + } + + if (team1.score === team2.score) { + throw new Error(`Round ${roundIndex + 1}, Game ${gameIndex + 1}: Tied score (${team1.name} ${team1.score} - ${team2.name} ${team2.score}). Use tie-breaker option.`); + } + } + }); +}; + +/** + * Collect all winners from a round + * Applies tie-breaking if needed + */ +export const collectWinners = ( + round: Round, + tieBreaker: 'error' | 'higher-seed' | 'lower-seed' | 'callback' = 'error', + tieBreakerFn?: (t1: Team, t2: Team) => Team +): Team[] => { + return round.map(game => { + const winner = getMatchWinner(game); + + if (winner) { + return winner; + } + + // Handle ties + if (game.length === 2) { + const [team1, team2] = game; + + if (team1.score !== undefined && team2.score !== undefined && team1.score === team2.score) { + if (tieBreaker === 'error') { + throw new Error(`Tied score between ${team1.name} and ${team2.name}. Specify tie-breaker option.`); + } + return applyTieBreaker(team1, team2, tieBreaker, tieBreakerFn); + } + } + + throw new Error('Unable to determine winner for game'); + }); +}; + +/** + * Generate the next round from winners + */ +export const generateNextRound = (winners: Team[], preserveScores: boolean = false): Round => { + if (winners.length === 0) { + return []; + } + + // If only 1 winner, this is the champion round + if (winners.length === 1) { + return [[{ ...winners[0], score: preserveScores ? winners[0].score : undefined }]]; + } + + // Pair up winners for next round + const nextRound: Round = []; + + for (let i = 0; i < winners.length; i += 2) { + if (i + 1 < winners.length) { + // Regular matchup + nextRound.push([ + { ...winners[i], score: preserveScores ? winners[i].score : undefined }, + { ...winners[i + 1], score: preserveScores ? winners[i + 1].score : undefined } + ]); + } else { + // Odd number of winners - last team gets bye + nextRound.push([ + { ...winners[i], score: preserveScores ? winners[i].score : undefined } + ]); + } + } + + return nextRound; +}; + +/** + * Count total number of matches in tournament + * Excludes the final champion display (only in multi-round tournaments) + */ +export const countTotalMatches = (rounds: Round[]): number => { + let total = 0; + + for (let r = 0; r < rounds.length; r++) { + const round = rounds[r]; + const isLastRound = r === rounds.length - 1; + const isMultiRound = rounds.length > 1; + + // Don't count final champion display as a match (only in multi-round tournaments) + const isChampionDisplay = isLastRound && isMultiRound && round.length === 1 && round[0].length === 1; + + if (isChampionDisplay) { + continue; + } + + total += round.length; + } + + return total; +}; + +/** + * Count completed matches in tournament + * Excludes the final champion display (only in multi-round tournaments) + */ +export const countCompletedMatches = (rounds: Round[]): number => { + let count = 0; + + for (let r = 0; r < rounds.length; r++) { + const round = rounds[r]; + const isLastRound = r === rounds.length - 1; + const isMultiRound = rounds.length > 1; + + for (const game of round) { + // Don't count final champion display as a completed match (only in multi-round tournaments) + const isChampionDisplay = isLastRound && isMultiRound && round.length === 1 && game.length === 1; + + if (isChampionDisplay) { + continue; + } + + if (getMatchWinner(game) !== null) { + count++; + } + } + } + + return count; +}; + +/** + * Count number of byes in tournament + * Excludes the final champion round (only in multi-round tournaments) + */ +export const countByes = (rounds: Round[]): number => { + let count = 0; + + for (let r = 0; r < rounds.length; r++) { + const round = rounds[r]; + const isLastRound = r === rounds.length - 1; + const isMultiRound = rounds.length > 1; + + for (const game of round) { + // A bye is a single-team game + // In single-round tournaments, all single-team games are byes + // In multi-round tournaments, don't count the final champion display + const isChampion = isLastRound && isMultiRound && round.length === 1 && game.length === 1; + + if (isByeGame(game) && !isChampion) { + count++; + } + } + } + + return count; +};