feat: Implement tournament schedule tab and fix E2E tests (#27)
Release / release (push) Failing after 9s
Build CI Images / build-ci-base (push) Failing after 18s

## 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>
This commit was merged in pull request #27.
This commit is contained in:
2026-04-27 01:59:16 +00:00
committed by david
parent 75358bf20d
commit bb6be245b7
97 changed files with 7950 additions and 1341 deletions
+10
View File
@@ -26,6 +26,16 @@ export function DeleteTournamentButton({ tournamentId, tournamentName, matchCoun
}),
})
if (!response.ok) {
try {
const errorData = await response.json()
alert(`Error: ${errorData.error || 'Failed to delete tournament'}`)
} catch {
alert(`Error: ${response.status} ${response.statusText}`)
}
return
}
const data = await response.json()
if (data.success) {
+134 -3
View File
@@ -21,6 +21,9 @@ export default function EditTournamentForm({ tournament }: EditTournamentFormPro
maxParticipants: tournament.maxParticipants?.toString() || "",
targetScore: tournament.targetScore?.toString() || "",
allowTies: tournament.allowTies || false,
teamDurability: tournament.teamDurability || "permanent",
partnerRotation: tournament.partnerRotation || "none",
allowByes: tournament.allowByes ?? true,
})
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
@@ -56,10 +59,13 @@ export default function EditTournamentForm({ tournament }: EditTournamentFormPro
}),
})
const data = await response.json()
if (!response.ok) {
setError(data.error || "Failed to update tournament")
try {
const data = await response.json()
setError(data.error || "Failed to update tournament")
} catch {
setError(`Failed to update tournament: ${response.status} ${response.statusText}`)
}
setIsLoading(false)
return
}
@@ -237,6 +243,131 @@ export default function EditTournamentForm({ tournament }: EditTournamentFormPro
</label>
</div>
{/* Team Configuration - shown for round_robin format */}
{formData.format === 'round_robin' && (
<div className="bg-gray-50 rounded-lg p-4 space-y-4">
<h3 className="text-sm font-medium text-gray-700">Team Configuration</h3>
{/* Team Durability */}
<div>
<label className="block text-sm font-medium text-gray-600 mb-2">
Team Formation Strategy
</label>
<div className="flex gap-4 flex-wrap">
<label className="flex items-center">
<input
type="radio"
name="teamDurability"
value="permanent"
checked={formData.teamDurability === 'permanent'}
onChange={handleChange}
className="mr-2"
/>
<span className="text-sm">Fixed Teams</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="teamDurability"
value="variable"
checked={formData.teamDurability === 'variable'}
onChange={handleChange}
className="mr-2"
/>
<span className="text-sm">Pre-Planned Variable</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="teamDurability"
value="per_round"
checked={formData.teamDurability === 'per_round'}
onChange={handleChange}
className="mr-2"
/>
<span className="text-sm">Dynamic/Progressive</span>
</label>
</div>
<p className="text-xs text-gray-500 mt-1">
{formData.teamDurability === 'permanent'
? 'Teams formed once and stay fixed throughout the tournament.'
: formData.teamDurability === 'variable'
? 'Fresh teams each round with partner rotation. Schedule is pre-planned.'
: 'Teams formed based on results. Schedule progresses as rounds complete.'}
</p>
</div>
{/* Partner Rotation - only for variable/per_round teams */}
{(formData.teamDurability === 'variable' || formData.teamDurability === 'per_round') && (
<div>
<label className="block text-sm font-medium text-gray-600 mb-2">
Partner Rotation Strategy
</label>
<div className="flex gap-4 flex-wrap">
<label className="flex items-center">
<input
type="radio"
name="partnerRotation"
value="none"
checked={formData.partnerRotation === 'none'}
onChange={handleChange}
className="mr-2"
/>
<span className="text-sm">None (Random)</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="partnerRotation"
value="minimize_repeat"
checked={formData.partnerRotation === 'minimize_repeat'}
onChange={handleChange}
className="mr-2"
/>
<span className="text-sm">Minimize Repeat</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="partnerRotation"
value="maximize_even"
checked={formData.partnerRotation === 'maximize_even'}
onChange={handleChange}
className="mr-2"
/>
<span className="text-sm">Maximize Even</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="partnerRotation"
value="elo_based"
checked={formData.partnerRotation === 'elo_based'}
onChange={handleChange}
className="mr-2"
/>
<span className="text-sm">ELO-Based</span>
</label>
</div>
</div>
)}
{/* Allow Byes */}
<div>
<label className="flex items-center">
<input
type="checkbox"
name="allowByes"
checked={formData.allowByes}
onChange={(e) => setFormData(prev => ({ ...prev, allowByes: e.target.checked }))}
className="mr-2"
/>
<span className="text-sm font-medium text-gray-600">Allow Byes (for odd number of participants)</span>
</label>
</div>
</div>
)}
<div className="flex justify-end space-x-4">
<Link
href={`/admin/tournaments/${tournament.id}`}
+103 -57
View File
@@ -9,6 +9,12 @@ interface MatchEditorProps {
matches: Match[]
targetScore: number | null
allowTies: boolean
// Optional pre-filled player IDs from URL params
prefilledP1?: number
prefilledP2?: number
prefilledP3?: number
prefilledP4?: number
prefilledRound?: number
}
interface MatchData {
@@ -23,15 +29,28 @@ interface MatchData {
isCasual: boolean
}
export default function MatchEditor({ tournamentId, players, targetScore, allowTies }: MatchEditorProps) {
export default function MatchEditor({
tournamentId,
players,
targetScore,
allowTies,
prefilledP1,
prefilledP2,
prefilledP3,
prefilledP4,
prefilledRound,
}: MatchEditorProps) {
// Check if players are prefilled from URL params
const hasPrefilledPlayers = prefilledP1 && prefilledP2 && prefilledP3 && prefilledP4;
const [formData, setFormData] = useState<MatchData>({
team1P1Id: null,
team1P2Id: null,
team2P1Id: null,
team2P2Id: null,
team1P1Id: hasPrefilledPlayers ? prefilledP1 : null,
team1P2Id: hasPrefilledPlayers ? prefilledP2 : null,
team2P1Id: hasPrefilledPlayers ? prefilledP3 : null,
team2P2Id: hasPrefilledPlayers ? prefilledP4 : null,
team1Score: 0,
team2Score: 0,
round: 1,
round: prefilledRound || 1,
table: "Clubs",
isCasual: false,
})
@@ -124,10 +143,13 @@ export default function MatchEditor({ tournamentId, players, targetScore, allowT
}),
})
const data = await response.json()
if (!response.ok) {
setError(data.error || "Failed to record match")
try {
const data = await response.json()
setError(data.error || "Failed to record match")
} catch {
setError(`Failed to record match: ${response.status} ${response.statusText}`)
}
setIsLoading(false)
return
}
@@ -214,35 +236,47 @@ export default function MatchEditor({ tournamentId, players, targetScore, allowT
<label htmlFor="team1P1Id" className="block text-sm font-medium text-gray-700">
Player 1
</label>
<select
id="team1P1Id"
name="team1P1Id"
value={formData.team1P1Id || ""}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
>
<option value="">Select player...</option>
{players.map((player) => (
<option key={player.id} value={player.id}>{player.name}</option>
))}
</select>
{hasPrefilledPlayers ? (
<p className="mt-1 text-sm text-gray-600 bg-gray-50 px-3 py-2 rounded-md">
{players.find(p => p.id === prefilledP1)?.name || "Unknown Player"}
</p>
) : (
<select
id="team1P1Id"
name="team1P1Id"
value={formData.team1P1Id || ""}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
>
<option value="">Select player...</option>
{players.map((player) => (
<option key={player.id} value={player.id}>{player.name}</option>
))}
</select>
)}
</div>
<div>
<label htmlFor="team1P2Id" className="block text-sm font-medium text-gray-700">
Player 2
</label>
<select
id="team1P2Id"
name="team1P2Id"
value={formData.team1P2Id || ""}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
>
<option value="">Select player...</option>
{players.map((player) => (
<option key={player.id} value={player.id}>{player.name}</option>
))}
</select>
{hasPrefilledPlayers ? (
<p className="mt-1 text-sm text-gray-600 bg-gray-50 px-3 py-2 rounded-md">
{players.find(p => p.id === prefilledP2)?.name || "Unknown Player"}
</p>
) : (
<select
id="team1P2Id"
name="team1P2Id"
value={formData.team1P2Id || ""}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
>
<option value="">Select player...</option>
{players.map((player) => (
<option key={player.id} value={player.id}>{player.name}</option>
))}
</select>
)}
</div>
</div>
<div className="mt-4">
@@ -270,35 +304,47 @@ export default function MatchEditor({ tournamentId, players, targetScore, allowT
<label htmlFor="team2P1Id" className="block text-sm font-medium text-gray-700">
Player 1
</label>
<select
id="team2P1Id"
name="team2P1Id"
value={formData.team2P1Id || ""}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
>
<option value="">Select player...</option>
{players.map((player) => (
<option key={player.id} value={player.id}>{player.name}</option>
))}
</select>
{hasPrefilledPlayers ? (
<p className="mt-1 text-sm text-gray-600 bg-gray-50 px-3 py-2 rounded-md">
{players.find(p => p.id === prefilledP3)?.name || "Unknown Player"}
</p>
) : (
<select
id="team2P1Id"
name="team2P1Id"
value={formData.team2P1Id || ""}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
>
<option value="">Select player...</option>
{players.map((player) => (
<option key={player.id} value={player.id}>{player.name}</option>
))}
</select>
)}
</div>
<div>
<label htmlFor="team2P2Id" className="block text-sm font-medium text-gray-700">
Player 2
</label>
<select
id="team2P2Id"
name="team2P2Id"
value={formData.team2P2Id || ""}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
>
<option value="">Select player...</option>
{players.map((player) => (
<option key={player.id} value={player.id}>{player.name}</option>
))}
</select>
{hasPrefilledPlayers ? (
<p className="mt-1 text-sm text-gray-600 bg-gray-50 px-3 py-2 rounded-md">
{players.find(p => p.id === prefilledP4)?.name || "Unknown Player"}
</p>
) : (
<select
id="team2P2Id"
name="team2P2Id"
value={formData.team2P2Id || ""}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
>
<option value="">Select player...</option>
{players.map((player) => (
<option key={player.id} value={player.id}>{player.name}</option>
))}
</select>
)}
</div>
</div>
<div className="mt-4">
+12 -1
View File
@@ -38,12 +38,23 @@ export default function Navigation() {
window.location.href = '/auth/login'
}
// Determine wordmark href based on session and role
// If session exists but role is not yet loaded, use /rankings as default for players
const wordmarkHref = session
? (userRole === "club_admin" || userRole === "site_admin")
? "/admin"
: "/rankings"
: "/";
return (
<nav className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
<Link href="/" className="text-xl font-bold text-gray-900">
<Link
href="/wordmark-redirect"
className="text-xl font-bold text-gray-900 no-underline"
>
EuchreCamp
</Link>
<div className="hidden md:ml-6 md:flex md:space-x-8">
+11
View File
@@ -18,6 +18,17 @@ export function RecalculateEloButton() {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
try {
const errorData = await response.json()
alert(`Error: ${errorData.error || 'Failed to recalculate'}`)
} catch {
alert(`Error: ${response.status} ${response.statusText}`)
}
return
}
const data = await response.json()
if (data.success) {
+185
View File
@@ -0,0 +1,185 @@
"use client"
import { useState } from "react"
interface ScheduleGeneratorProps {
tournamentId: number
teamCount: number
existingRounds?: number
}
export function ScheduleGenerator({ tournamentId, teamCount, existingRounds }: ScheduleGeneratorProps) {
const [isGenerating, setIsGenerating] = useState(false)
const [error, setError] = useState("")
const [result, setResult] = useState<{
roundsCreated: number
matchupsCreated: number
} | null>(null)
const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false)
const handleGenerate = async () => {
setError("")
setResult(null)
setIsGenerating(true)
setShowOverwriteConfirm(false)
try {
const response = await fetch(`/api/tournaments/${tournamentId}/schedule`, {
method: "POST",
})
// Check response status first before parsing JSON
if (!response.ok) {
try {
const data = await response.json()
setError(data.error || "Failed to generate schedule")
} catch {
setError(`Failed to generate schedule: ${response.status} ${response.statusText}`)
}
setIsGenerating(false)
return
}
const data = await response.json()
setResult({
roundsCreated: data.roundsCreated,
matchupsCreated: data.matchupsCreated,
})
setIsGenerating(false)
// Reload to show the schedule
setTimeout(() => {
window.location.reload()
}, 1500)
} catch {
setError("An error occurred. Please try again.")
setIsGenerating(false)
}
}
const handleGenerateClick = () => {
if (existingRounds && existingRounds > 0) {
setShowOverwriteConfirm(true)
} else {
handleGenerate()
}
}
const handleDelete = async () => {
setError("")
setIsGenerating(true)
try {
const response = await fetch(`/api/tournaments/${tournamentId}/schedule`, {
method: "DELETE",
})
if (!response.ok) {
try {
const data = await response.json()
setError(data.error || "Failed to delete schedule")
} catch {
setError(`Failed to delete schedule: ${response.status} ${response.statusText}`)
}
setIsGenerating(false)
return
}
window.location.reload()
} catch {
setError("An error occurred. Please try again.")
setIsGenerating(false)
}
}
const expectedRounds = teamCount % 2 === 0 ? teamCount - 1 : teamCount
const expectedMatchups = (teamCount * (teamCount - 1)) / 2
return (
<div className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{result && (
<div className="rounded-md bg-green-50 p-4">
<div className="text-sm text-green-700">
Generated {result.roundsCreated} rounds with {result.matchupsCreated} matchups!
</div>
</div>
)}
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-600 mb-2">
Generate a round-robin schedule for <strong>{teamCount} teams</strong>.
</p>
<p className="text-sm text-gray-500">
This will create {expectedRounds} rounds with {expectedMatchups} total matchups.
Each team plays every other team exactly once.
</p>
</div>
{/* Overwrite Confirmation */}
{showOverwriteConfirm && (
<div className="rounded-md bg-yellow-50 p-4 mb-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-yellow-800">
Overwrite existing schedule?
</h3>
<div className="mt-2 text-sm text-yellow-700">
<p>A schedule with {existingRounds} round(s) already exists. This will delete the existing schedule and create a new one.</p>
</div>
<div className="mt-4 flex space-x-3">
<button
type="button"
onClick={handleGenerate}
disabled={isGenerating}
className="px-3 py-1.5 bg-yellow-600 text-white text-sm rounded-md hover:bg-yellow-700 disabled:opacity-50"
>
{isGenerating ? "Overwriting..." : "Yes, Overwrite"}
</button>
<button
type="button"
onClick={() => setShowOverwriteConfirm(false)}
disabled={isGenerating}
className="px-3 py-1.5 bg-gray-300 text-gray-700 text-sm rounded-md hover:bg-gray-400 disabled:opacity-50"
>
Cancel
</button>
</div>
</div>
</div>
</div>
)}
<div className="flex space-x-3">
<button
type="button"
onClick={handleGenerateClick}
disabled={isGenerating || teamCount < 2}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 disabled:opacity-50"
>
{isGenerating ? "Generating..." : (existingRounds && existingRounds > 0 ? "Regenerate Schedule" : "Generate Schedule")}
</button>
<button
type="button"
onClick={handleDelete}
disabled={isGenerating}
className="px-4 py-2 border border-red-300 rounded-md shadow-sm text-sm font-medium text-red-700 bg-white hover:bg-red-50 disabled:opacity-50"
>
Delete Schedule
</button>
</div>
</div>
)
}
+660
View File
@@ -0,0 +1,660 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
interface Player {
id: number
name: string
currentElo: number
}
interface Team {
id: number
teamName: string | null
player1: Player
player2: Player
}
interface TeamsSectionProps {
tournamentId: number
participants: Player[]
teamDurability: string
partnerRotation: string
allowByes: boolean
}
type TeamDurabilityOption = 'permanent' | 'variable' | 'per_round'
type PartnerRotationOption = 'none' | 'minimize_repeat' | 'maximize_even' | 'elo_based'
export default function TeamsSection({
tournamentId,
participants,
teamDurability: initialTeamDurability,
partnerRotation: initialPartnerRotation,
allowByes: initialAllowByes,
}: TeamsSectionProps) {
const router = useRouter()
const [teams, setTeams] = useState<Team[]>([])
const [teamDurability, setTeamDurability] = useState<TeamDurabilityOption>(initialTeamDurability as TeamDurabilityOption)
const [partnerRotation, setPartnerRotation] = useState<PartnerRotationOption>(initialPartnerRotation as PartnerRotationOption)
const [allowByes, setAllowByes] = useState(initialAllowByes)
const [isGenerating, setIsGenerating] = useState(false)
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
// Manual team entry state
const [manualTeamMode, setManualTeamMode] = useState(false)
const [selectedPlayer1, setSelectedPlayer1] = useState<number | null>(null)
const [selectedPlayer2, setSelectedPlayer2] = useState<number | null>(null)
const [newTeamName, setNewTeamName] = useState("")
const handleSaveConfig = async () => {
setError("")
setSuccess("")
setIsGenerating(true)
try {
const response = await fetch(`/api/tournaments/${tournamentId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
teamDurability,
partnerRotation,
allowByes,
}),
})
if (!response.ok) {
try {
const errorData = await response.json()
setError(errorData.error || "Failed to save configuration")
} catch {
setError(`Failed to save configuration: ${response.status} ${response.statusText}`)
}
return
}
setSuccess("Configuration saved successfully!")
router.refresh()
} catch (err) {
if (err instanceof Error) {
setError(err.message)
} else {
setError("An unknown error occurred")
}
} finally {
setIsGenerating(false)
}
}
const handleGenerateSchedule = async () => {
if (participants.length < 2) {
setError("At least 2 participants are required to generate a schedule")
return
}
if (participants.length % 2 !== 0 && !allowByes) {
setError("Odd number of participants. Enable 'Allow Byes' to proceed.")
return
}
setError("")
setSuccess("")
setIsGenerating(true)
try {
const response = await fetch(`/api/tournaments/${tournamentId}/schedule`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
})
if (!response.ok) {
try {
const errorData = await response.json()
setError(errorData.error || "Failed to generate schedule")
} catch {
setError(`Failed to generate schedule: ${response.status} ${response.statusText}`)
}
setIsGenerating(false)
return
}
const data = await response.json()
// Update teams from the generated schedule
// Extract teams from round matchups
const generatedTeams: Team[] = []
for (const round of data.rounds) {
for (const matchup of round.bracketMatchups) {
// Create unique keys based on player IDs
const team1Key = `${matchup.player1P1.id}-${matchup.player1P2.id}`
const team2Key = `${matchup.player2P1.id}-${matchup.player2P2.id}`
// Add unique teams
const team1 = {
id: matchup.player1P1.id * 10000 + matchup.player1P2.id,
teamName: `${matchup.player1P1.name} & ${matchup.player1P2.name}`,
player1: matchup.player1P1,
player2: matchup.player1P2,
}
const team2 = {
id: matchup.player2P1.id * 10000 + matchup.player2P2.id,
teamName: `${matchup.player2P1.name} & ${matchup.player2P2.name}`,
player1: matchup.player2P1,
player2: matchup.player2P2,
}
// Check if team already exists
const existingTeam1 = generatedTeams.find(
t => t.player1.id === team1.player1.id && t.player2.id === team1.player2.id
)
if (!existingTeam1) generatedTeams.push(team1)
const existingTeam2 = generatedTeams.find(
t => t.player1.id === team2.player1.id && t.player2.id === team2.player2.id
)
if (!existingTeam2) generatedTeams.push(team2)
}
}
setTeams(generatedTeams)
setSuccess(`Successfully generated ${data.roundsCreated} rounds with ${data.matchupsCreated} matchups!`)
router.refresh()
} catch (err) {
if (err instanceof Error) {
setError(err.message)
} else {
setError("An unknown error occurred")
}
} finally {
setIsGenerating(false)
}
}
const handleDeleteTeams = async () => {
if (!confirm("Are you sure you want to delete the schedule? This will remove all rounds and matchups.")) {
return
}
setError("")
setSuccess("")
setIsGenerating(true)
try {
// Delete schedule (which includes matchups/rounds)
const response = await fetch(`/api/tournaments/${tournamentId}/schedule`, {
method: "DELETE",
})
if (!response.ok) {
try {
const errorData = await response.json()
setError(errorData.error || "Failed to delete schedule")
} catch {
setError(`Failed to delete schedule: ${response.status} ${response.statusText}`)
}
return
}
setTeams([])
setSuccess("Schedule deleted successfully!")
router.refresh()
} catch (err) {
if (err instanceof Error) {
setError(err.message)
} else {
setError("An unknown error occurred")
}
} finally {
setIsGenerating(false)
}
}
// Handle adding a manual team
const handleAddManualTeam = () => {
if (!selectedPlayer1 || !selectedPlayer2) {
setError("Please select two players for the team")
return
}
if (selectedPlayer1 === selectedPlayer2) {
setError("A player cannot be on a team with themselves")
return
}
// Check if player is already in a team
const player1InTeam = teams.some(t => t.player1.id === selectedPlayer1 || t.player2.id === selectedPlayer1)
const player2InTeam = teams.some(t => t.player1.id === selectedPlayer2 || t.player2.id === selectedPlayer2)
if (player1InTeam || player2InTeam) {
setError("One or both players are already on a team")
return
}
const player1 = participants.find(p => p.id === selectedPlayer1)
const player2 = participants.find(p => p.id === selectedPlayer2)
if (!player1 || !player2) {
setError("Invalid player selection")
return
}
const newTeam: Team = {
id: player1.id * 10000 + player2.id,
teamName: newTeamName || `${player1.name} & ${player2.name}`,
player1,
player2,
}
setTeams([...teams, newTeam])
setSelectedPlayer1(null)
setSelectedPlayer2(null)
setNewTeamName("")
setError("")
setSuccess("Team added!")
// Clear success message after 2 seconds
setTimeout(() => setSuccess(""), 2000)
}
// Handle removing a manual team
const handleRemoveTeam = (teamId: number) => {
setTeams(teams.filter(t => t.id !== teamId))
setSuccess("Team removed!")
setTimeout(() => setSuccess(""), 2000)
}
// Handle saving manual teams
const handleSaveManualTeams = async () => {
if (teams.length === 0) {
setError("Please add at least one team")
return
}
setError("")
setSuccess("")
setIsGenerating(true)
try {
// For permanent teams with manual entry, we need to save the team list
// This would require a new API endpoint or extending the tournament update
const response = await fetch(`/api/tournaments/${tournamentId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
teamDurability: "permanent",
manualTeams: teams.map(t => ({
player1Id: t.player1.id,
player2Id: t.player2.id,
teamName: t.teamName,
})),
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || "Failed to save teams")
}
setSuccess("Teams saved successfully!")
router.refresh()
} catch (err) {
if (err instanceof Error) {
setError(err.message)
} else {
setError("An unknown error occurred")
}
} finally {
setIsGenerating(false)
}
}
return (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">
Matchups {teams.length > 0 && `(${teams.length})`}
</h2>
{/* Configuration Panel */}
<div className="bg-gray-50 rounded-lg p-4 mb-6">
<h3 className="text-sm font-medium text-gray-700 mb-3">Team Configuration</h3>
{/* Team Durability */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-600 mb-2">
Team Durability
</label>
<div className="flex gap-4">
<label className="flex items-center">
<input
type="radio"
name="teamDurability"
value="permanent"
checked={teamDurability === 'permanent'}
onChange={(e) => setTeamDurability(e.target.value as TeamDurabilityOption)}
className="mr-2"
/>
<span className="text-sm">Permanent Teams</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="teamDurability"
value="variable"
checked={teamDurability === 'variable'}
onChange={(e) => setTeamDurability(e.target.value as TeamDurabilityOption)}
className="mr-2"
/>
<span className="text-sm">Pre-Planned Variable</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="teamDurability"
value="per_round"
checked={teamDurability === 'per_round'}
onChange={(e) => setTeamDurability(e.target.value as TeamDurabilityOption)}
className="mr-2"
/>
<span className="text-sm">Dynamic/Progressive</span>
</label>
</div>
<p className="text-xs text-gray-500 mt-1">
{teamDurability === 'permanent'
? 'Teams formed once and stay fixed throughout the tournament.'
: teamDurability === 'variable'
? 'Fresh teams each round with partner rotation. Schedule is pre-planned.'
: 'Teams formed based on results. Schedule progresses as rounds complete.'}
</p>
</div>
{/* Partner Rotation (for variable/per_round teams) */}
{(teamDurability === 'variable' || teamDurability === 'per_round') && (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-600 mb-2">
Partner Rotation Strategy
</label>
<div className="flex gap-4 flex-wrap">
<label className="flex items-center">
<input
type="radio"
name="partnerRotation"
value="none"
checked={partnerRotation === 'none'}
onChange={(e) => setPartnerRotation(e.target.value as PartnerRotationOption)}
className="mr-2"
/>
<span className="text-sm">None (Random)</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="partnerRotation"
value="minimize_repeat"
checked={partnerRotation === 'minimize_repeat'}
onChange={(e) => setPartnerRotation(e.target.value as PartnerRotationOption)}
className="mr-2"
/>
<span className="text-sm">Minimize Repeat</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="partnerRotation"
value="maximize_even"
checked={partnerRotation === 'maximize_even'}
onChange={(e) => setPartnerRotation(e.target.value as PartnerRotationOption)}
className="mr-2"
/>
<span className="text-sm">Maximize Even</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="partnerRotation"
value="elo_based"
checked={partnerRotation === 'elo_based'}
onChange={(e) => setPartnerRotation(e.target.value as PartnerRotationOption)}
className="mr-2"
/>
<span className="text-sm">ELO-Based</span>
</label>
</div>
</div>
)}
{/* Manual Team Entry Toggle (for permanent teams) */}
{teamDurability === 'permanent' && (
<div className="mb-4">
<label className="flex items-center">
<input
type="checkbox"
checked={manualTeamMode}
onChange={(e) => setManualTeamMode(e.target.checked)}
className="mr-2"
/>
<span className="text-sm font-medium text-gray-600">Enter teams manually</span>
</label>
<p className="text-xs text-gray-500 mt-1 ml-6">
Check this to manually create teams instead of auto-generating them.
</p>
</div>
)}
{/* Allow Byes (for odd participant counts) */}
<div className="mb-4">
<label className="flex items-center">
<input
type="checkbox"
checked={allowByes}
onChange={(e) => setAllowByes(e.target.checked)}
className="mr-2"
/>
<span className="text-sm font-medium text-gray-600">Allow Byes (for odd number of participants)</span>
</label>
<p className="text-xs text-gray-500 mt-1 ml-6">
When enabled, one player will have a bye each round if there's an odd number of participants.
</p>
</div>
{/* Action Buttons */}
<div className="flex gap-3 mt-4">
<button
onClick={handleSaveConfig}
disabled={isGenerating}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 text-sm"
>
{isGenerating ? "Saving..." : "Save Configuration"}
</button>
</div>
</div>
{/* Error/Success Messages */}
{error && (
<div className="rounded-md bg-red-50 p-4 mb-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{success && (
<div className="rounded-md bg-green-50 p-4 mb-4">
<div className="text-sm text-green-700">{success}</div>
</div>
)}
{/* Manual Team Entry Form */}
{manualTeamMode && teamDurability === 'permanent' && (
<div className="bg-gray-50 rounded-lg p-4 mb-6 border-2 border-dashed border-gray-300">
<h3 className="text-sm font-medium text-gray-700 mb-3">Manual Team Entry</h3>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-600 mb-2">
Player 1
</label>
<select
value={selectedPlayer1 || ""}
onChange={(e) => setSelectedPlayer1(e.target.value ? parseInt(e.target.value) : null)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
>
<option value="">Select player...</option>
{participants
.filter(p => !teams.some(t => t.player1.id === p.id || t.player2.id === p.id))
.map(p => (
<option key={p.id} value={p.id}>{p.name} (Elo: {p.currentElo})</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-600 mb-2">
Player 2
</label>
<select
value={selectedPlayer2 || ""}
onChange={(e) => setSelectedPlayer2(e.target.value ? parseInt(e.target.value) : null)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
>
<option value="">Select player...</option>
{participants
.filter(p => p.id !== selectedPlayer1 && !teams.some(t => t.player1.id === p.id || t.player2.id === p.id))
.map(p => (
<option key={p.id} value={p.id}>{p.name} (Elo: {p.currentElo})</option>
))}
</select>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-600 mb-2">
Team Name (optional)
</label>
<input
type="text"
value={newTeamName}
onChange={(e) => setNewTeamName(e.target.value)}
placeholder="e.g., The Aces"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
/>
</div>
<div className="flex gap-3">
<button
onClick={handleAddManualTeam}
disabled={!selectedPlayer1 || !selectedPlayer2}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 text-sm"
>
Add Team
</button>
<button
onClick={() => {
setSelectedPlayer1(null)
setSelectedPlayer2(null)
setNewTeamName("")
}}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-sm"
>
Clear
</button>
</div>
</div>
)}
{/* Team Generation Controls */}
<div className="flex gap-3 mb-4">
{!manualTeamMode && (
<>
<button
onClick={handleGenerateSchedule}
disabled={isGenerating || participants.length < 2}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 text-sm"
>
{isGenerating ? "Generating..." : `Generate Schedule (${participants.length} participants)`}
</button>
{teams.length > 0 && (
<button
onClick={handleDeleteTeams}
disabled={isGenerating}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 text-sm"
>
Delete Schedule
</button>
)}
</>
)}
{manualTeamMode && teams.length > 0 && (
<button
onClick={handleSaveManualTeams}
disabled={isGenerating}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 text-sm"
>
{isGenerating ? "Saving..." : `Save ${teams.length} Team(s)`}
</button>
)}
</div>
{/* Teams Display */}
{teams.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{teams.map((team) => (
<div
key={`${team.player1.id}-${team.player2.id}`}
className="bg-gray-50 rounded p-3 flex justify-between items-center"
>
<div>
<p className="font-medium text-gray-900">
{team.player1.name} + {team.player2.name}
</p>
<p className="text-sm text-gray-500">
{team.teamName || `Team ${team.id}`}
</p>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<p className="text-sm text-gray-500">
ELO: {team.player1.currentElo} + {team.player2.currentElo} = {team.player1.currentElo + team.player2.currentElo}
</p>
</div>
{manualTeamMode && (
<button
onClick={() => handleRemoveTeam(team.id)}
className="text-red-600 hover:text-red-800 text-sm"
>
Remove
</button>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-gray-500">
{manualTeamMode
? "No teams created yet. Use the form above to add teams manually."
: "No teams created yet. Configure options above and click Generate Schedule."}
</p>
)}
{/* Participants Summary */}
<div className="mt-6 pt-4 border-t border-gray-200">
<h3 className="text-sm font-medium text-gray-700 mb-2">
Available Participants ({participants.length})
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{participants.map((player) => (
<div key={player.id} className="text-sm text-gray-600 bg-gray-100 rounded px-2 py-1">
{player.name} ({player.currentElo})
</div>
))}
</div>
</div>
</div>
)
}