Files
euchre_camp/src/lib/schedule-generator.ts
T
david bb6be245b7
Release / release (push) Failing after 9s
Build CI Images / build-ci-base (push) Failing after 18s
feat: Implement tournament schedule tab and fix E2E tests (#27)
## Summary

This PR implements the tournament schedule tab functionality and fixes all remaining E2E test failures.

### Changes Included

1. **Tournament Schedule Feature**
   - Added tournament schedule page at `/admin/tournaments/[id]/schedule`
   - Implemented "Generate Schedule" button functionality
   - Added schedule generation logic for round-robin tournaments

2. **E2E Test Fixes**
   - Fixed database connection issues in production builds
   - Improved test reliability with better error handling and debugging
   - Updated test infrastructure to use environment variables instead of hardcoded values

3. **CI/CD Updates**
   - Added E2E test job to PR workflow
   - Configured tests to run against development database
   - Moved database password to Gitea secrets

4. **Code Quality**
   - Removed hardcoded passwords from codebase
   - Improved Prisma client configuration
   - Enhanced authentication and navigation components

### Test Results
All 16 E2E test scenarios are now passing:
- Authentication tests: 
- Registration tests: 
- Tournament schedule tests: 
- Player schedule tests: 
- Admin navigation tests: 

### Database Configuration
- Tests run against `euchre_camp_dev` database
- Production builds use environment variables for database configuration
- Database password stored in Gitea secrets as `DB_PASSWORD`

### CI Pipeline
The PR workflow now includes:
1. Unit tests
2. E2E tests (using production build)
3. Version bump analysis

E2E tests must pass before PR can be merged.

Reviewed-on: #27
Co-authored-by: David Gwilliam <dhgwilliam@gmail.com>
Co-committed-by: David Gwilliam <dhgwilliam@gmail.com>
2026-04-27 01:59:16 +00:00

189 lines
5.3 KiB
TypeScript

export interface MatchupPairing {
player1P1Id: number
player1P2Id: number
player2P1Id: number
player2P2Id: number
}
export interface RoundSchedule {
roundNumber: number
matchups: MatchupPairing[]
}
export interface TeamPairing {
player1Id: number
player2Id: number
}
/**
* Generate a round-robin schedule using the circle method.
*
* For N teams, produces N-1 rounds where each team plays every other
* team exactly once. If N is odd, a "bye" is added internally so one
* team sits out each round (the bye matchup is excluded from output).
*
* @param teamPairings - Array of player pairings (each with 2 player IDs)
* @returns Array of rounds, each containing matchup pairings
*/
export function generateRoundRobin(
teamPairings: TeamPairing[]
): RoundSchedule[] {
if (teamPairings.length < 2) {
return []
}
// Use circle method: fix first team, rotate the rest
// If odd number of teams, add a sentinel for byes
const hasOddTeams = teamPairings.length % 2 !== 0
const workingTeams = hasOddTeams
? [...teamPairings, { player1Id: -1, player2Id: -1 }]
: [...teamPairings]
const n = workingTeams.length
const numRounds = n - 1
const matchupsPerRound = n / 2
const rounds: RoundSchedule[] = []
for (let round = 0; round < numRounds; round++) {
const matchups: MatchupPairing[] = []
for (let i = 0; i < matchupsPerRound; i++) {
const team1Idx = i
const team2Idx = n - 1 - i
const team1 = workingTeams[team1Idx]
const team2 = workingTeams[team2Idx]
// Skip bye matchups (where either team is the sentinel)
if (team1.player1Id !== -1 && team2.player1Id !== -1) {
matchups.push({
player1P1Id: team1.player1Id,
player1P2Id: team1.player2Id,
player2P1Id: team2.player1Id,
player2P2Id: team2.player2Id,
})
}
}
rounds.push({ roundNumber: round + 1, matchups })
// Rotate all teams except the first one (clockwise)
// Move last element to position 1
const last = workingTeams.pop()!
workingTeams.splice(1, 0, last)
}
return rounds
}
/**
* Validate that a set of player pairings can be scheduled.
*/
export function validateScheduleInput(
teamPairings: { player1Id: number; player2Id: number }[]
): {
valid: boolean
error?: string
} {
if (teamPairings.length < 2) {
return { valid: false, error: "At least 2 teams are required to generate a schedule" }
}
// Check for duplicate teams
const teamKeys = teamPairings.map(
(t) => [t.player1Id, t.player2Id].sort().join('-')
)
const uniqueKeys = new Set(teamKeys)
if (uniqueKeys.size !== teamPairings.length) {
return { valid: false, error: "Duplicate team pairings found" }
}
return { valid: true }
}
/**
* Calculate the expected number of rounds for N teams.
*/
export function expectedRounds(teamCount: number): number {
if (teamCount < 2) return 0
return teamCount % 2 === 0 ? teamCount - 1 : teamCount
}
/**
* Calculate the expected number of total matchups for N teams.
*/
export function expectedMatchups(teamCount: number): number {
if (teamCount < 2) return 0
return (teamCount * (teamCount - 1)) / 2
}
/**
* Generate a schedule where teams are created fresh each round.
* This ensures every player gets different partners throughout the tournament.
*
* @param players - Array of players to be paired
* @param teamCount - Number of teams (player pairs) to create per round
* @param roundCount - Number of rounds to generate
* @param generateTeamFunction - Function to generate teams for a round
* @returns Array of rounds, each containing matchups with fresh team pairings
*/
export function generateVariableRoundRobin(
players: { id: number; name: string; currentElo: number }[],
teamCount: number,
roundCount: number,
generateTeamFunction: (players: { id: number; name: string; currentElo: number }[]) => TeamPairing[]
): RoundSchedule[] {
if (players.length < 4 || teamCount < 2) {
return []
}
const rounds: RoundSchedule[] = []
for (let roundNum = 1; roundNum <= roundCount; roundNum++) {
// Generate fresh teams for this round
const teams = generateTeamFunction(players)
// Validate we have enough teams
if (teams.length < 2) {
continue
}
// Apply round-robin pairing to the fresh teams
const teamPairings = teams.map((team) => ({
player1Id: team.player1Id,
player2Id: team.player2Id,
}))
// Use circle method for this round's matchups
const hasOddTeams = teamPairings.length % 2 !== 0
const workingTeams = hasOddTeams
? [...teamPairings, { player1Id: -1, player2Id: -1 }]
: [...teamPairings]
const n = workingTeams.length
const matchupsPerRound = n / 2
const matchups: MatchupPairing[] = []
for (let i = 0; i < matchupsPerRound; i++) {
const team1Idx = i
const team2Idx = n - 1 - i
const team1 = workingTeams[team1Idx]
const team2 = workingTeams[team2Idx]
// Skip bye matchups
if (team1.player1Id !== -1 && team2.player1Id !== -1) {
matchups.push({
player1P1Id: team1.player1Id,
player1P2Id: team1.player2Id,
player2P1Id: team2.player1Id,
player2P2Id: team2.player2Id,
})
}
}
rounds.push({ roundNumber: roundNum, matchups })
}
return rounds
}