feat(tournaments): add partnership tracking and tournament management

Implement comprehensive partnership tracking system and tournament
management features including round-robin, single/double elimination,
and Swiss system pairings.

Changes:
- Add partnership_games and partnership_stats tables
- Implement PartnershipTracker service for recording partnership data
- Add tournament generator with multiple formats
- Create tournament management actions and views
- Add acceptance tests for partnership tracking
- Fix PartnershipTracker to use single snapshot per player per match
- Fix round-robin test to query by round_number instead of hardcoded ID

Note: All 8 acceptance tests now passing
This commit is contained in:
2026-03-27 12:25:34 -07:00
parent cb47b9da99
commit ac858f1e6d
55 changed files with 2591 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# frozen_string_literal: true
module EuchreCamp
module Actions
module Admin
module Tournaments
class Complete < EuchreCamp::Action
include Deps[events_repo: 'repositories.events']
def handle(request, response)
tournament_id = request.params[:id]
events_repo.update(tournament_id, status: 'completed')
response.flash[:message] = "Tournament completed"
response.redirect_to("/admin/tournaments/#{tournament_id}")
rescue StandardError => e
response.flash[:error] = "Failed to complete tournament: #{e.message}"
response.redirect_to("/admin/tournaments/#{tournament_id}")
end
end
end
end
end
end
+34
View File
@@ -0,0 +1,34 @@
# frozen_string_literal: true
module EuchreCamp
module Actions
module Admin
module Tournaments
class Create < EuchreCamp::Action
include Deps[repo: 'repositories.events']
def handle(request, response)
params = request.params.to_h
event_params = {
name: params[:name],
description: params[:description],
event_date: params[:event_date] ? Time.parse(params[:event_date]) : nil,
event_type: 'tournament',
format: params[:format] || 'single_elim',
status: 'planned',
max_participants: params[:max_participants]&.to_i
}
event = repo.create(event_params)
response.redirect_to("/admin/tournaments/#{event[:id]}")
rescue StandardError => e
response.flash[:error] = "Failed to create tournament: #{e.message}"
response.redirect_to("/admin/tournaments/new")
end
end
end
end
end
end
+23
View File
@@ -0,0 +1,23 @@
# frozen_string_literal: true
module EuchreCamp
module Actions
module Admin
module Tournaments
class Destroy < EuchreCamp::Action
include Deps[repo: 'repositories.events']
def handle(request, response)
tournament_id = request.params[:id]
repo.delete(tournament_id)
response.flash[:message] = "Tournament deleted"
response.redirect_to("/admin/tournaments")
rescue StandardError => e
response.flash[:error] = "Failed to delete: #{e.message}"
response.redirect_to("/admin/tournaments")
end
end
end
end
end
end
+19
View File
@@ -0,0 +1,19 @@
# frozen_string_literal: true
module EuchreCamp
module Actions
module Admin
module Tournaments
class Edit < EuchreCamp::Action
include Deps[repo: 'repositories.events']
def handle(request, response)
tournament_id = request.params[:id]
tournament = repo.by_id(tournament_id)
response.render(view, tournament: tournament)
end
end
end
end
end
end
+19
View File
@@ -0,0 +1,19 @@
# frozen_string_literal: true
module EuchreCamp
module Actions
module Admin
module Tournaments
class Index < EuchreCamp::Action
include Deps[repo: 'repositories.events']
def handle(_request, response)
upcoming = repo.upcoming
completed = repo.completed
response.render(view, upcoming: upcoming, completed: completed)
end
end
end
end
end
end
+15
View File
@@ -0,0 +1,15 @@
# frozen_string_literal: true
module EuchreCamp
module Actions
module Admin
module Tournaments
class New < EuchreCamp::Action
def handle(_request, response)
response.render(view)
end
end
end
end
end
end
@@ -0,0 +1,34 @@
# frozen_string_literal: true
module EuchreCamp
module Actions
module Admin
module Tournaments
module Participants
class Create < EuchreCamp::Action
include Deps[participants_repo: 'repositories.event_participants']
def handle(request, response)
tournament_id = request.params[:id]
player_id = request.params[:player_id]
if player_id.nil? || player_id.empty?
response.flash[:error] = "Please select a player"
response.redirect_to("/admin/tournaments/#{tournament_id}")
return
end
participants_repo.register_player(tournament_id, player_id.to_i)
response.flash[:message] = "Player added to tournament"
response.redirect_to("/admin/tournaments/#{tournament_id}")
rescue StandardError => e
response.flash[:error] = "Failed to add player: #{e.message}"
response.redirect_to("/admin/tournaments/#{tournament_id}")
end
end
end
end
end
end
end
@@ -0,0 +1,28 @@
# frozen_string_literal: true
module EuchreCamp
module Actions
module Admin
module Tournaments
module Participants
class Destroy < EuchreCamp::Action
include Deps[participants_repo: 'repositories.event_participants']
def handle(request, response)
tournament_id = request.params[:id]
player_id = request.params[:player_id]
participants_repo.remove_player(tournament_id, player_id.to_i)
response.flash[:message] = "Player removed from tournament"
response.redirect_to("/admin/tournaments/#{tournament_id}")
rescue StandardError => e
response.flash[:error] = "Failed to remove player: #{e.message}"
response.redirect_to("/admin/tournaments/#{tournament_id}")
end
end
end
end
end
end
end
+41
View File
@@ -0,0 +1,41 @@
# frozen_string_literal: true
module EuchreCamp
module Actions
module Admin
module Tournaments
class Results < EuchreCamp::Action
include Deps[
events_repo: 'repositories.events',
rounds_repo: 'repositories.tournament_rounds',
matchups_repo: 'repositories.bracket_matchups',
teams_repo: 'repositories.teams'
]
def handle(request, response)
tournament_id = request.params[:id]
tournament = events_repo.by_id(tournament_id)
current_round = rounds_repo.current_round(tournament_id)
if current_round.nil?
response.flash[:error] = "No active round to enter results"
response.redirect_to("/admin/tournaments/#{tournament_id}")
return
end
matchups = matchups_repo.by_round(current_round[:id])
teams = teams_repo.by_event(tournament_id)
response.render(view,
tournament: tournament,
round: current_round,
matchups: matchups,
teams: teams
)
end
end
end
end
end
end
@@ -0,0 +1,37 @@
# frozen_string_literal: true
module EuchreCamp
module Actions
module Admin
module Tournaments
module Rounds
class Show < EuchreCamp::Action
include Deps[
events_repo: 'repositories.events',
rounds_repo: 'repositories.tournament_rounds',
matchups_repo: 'repositories.bracket_matchups',
teams_repo: 'repositories.teams'
]
def handle(request, response)
tournament_id = request.params[:id]
round_id = request.params[:round_id]
tournament = events_repo.by_id(tournament_id)
round = rounds_repo.by_event(tournament_id).find { |r| r[:id] == round_id.to_i }
matchups = matchups_repo.by_round(round_id)
teams = teams_repo.by_event(tournament_id)
response.render(view,
tournament: tournament,
round: round,
matchups: matchups,
teams: teams
)
end
end
end
end
end
end
end
@@ -0,0 +1,75 @@
# frozen_string_literal: true
module EuchreCamp
module Actions
module Admin
module Tournaments
class SaveResults < EuchreCamp::Action
include Deps[
events_repo: 'repositories.events',
rounds_repo: 'repositories.tournament_rounds',
matchups_repo: 'repositories.bracket_matchups',
teams_repo: 'repositories.teams',
matches_repo: 'repositories.matches'
]
def handle(request, response)
tournament_id = request.params[:id]
params = request.params.to_h
# Process each matchup result
params.each do |key, value|
key_str = key.to_s
if key_str.start_with?('matchup_')
matchup_id = key_str.gsub('matchup_', '').to_i
team_1_score = params["team_1_score_#{matchup_id}"]&.to_i || 0
team_2_score = params["team_2_score_#{matchup_id}"]&.to_i || 0
matchup = matchups_repo.bracket_matchups.by_pk(matchup_id).one
unless matchup && matchup[:team_1_id] && matchup[:team_2_id]
next
end
# Create match record
team_1 = teams_repo.teams.by_pk(matchup[:team_1_id]).one
team_2 = teams_repo.teams.by_pk(matchup[:team_2_id]).one
next if team_1.nil? || team_2.nil?
match = matches_repo.create(
event_id: tournament_id,
team_1_p1_id: team_1.player_1_id,
team_1_p2_id: team_1.player_2_id,
team_2_p1_id: team_2.player_1_id,
team_2_p2_id: team_2.player_2_id,
team_1_score: team_1_score,
team_2_score: team_2_score,
status: 'completed'
)
# Update matchup with match and winner
winner_id = team_1_score > team_2_score ? matchup[:team_1_id] : matchup[:team_2_id]
loser_id = team_1_score > team_2_score ? matchup[:team_2_id] : matchup[:team_1_id]
matchups_repo.update(matchup_id,
match_id: match[:id],
winner_team_id: winner_id,
loser_team_id: loser_id
)
# Enqueue Elo calculation
EuchreCamp::Jobs::CalculateEloJob.perform_later(match[:id])
end
end
response.flash[:message] = "Results saved"
response.redirect_to("/admin/tournaments/#{tournament_id}")
rescue StandardError => e
response.flash[:error] = "Failed to save results: #{e.message}"
response.redirect_to("/admin/tournaments/#{tournament_id}/results")
end
end
end
end
end
end
+126
View File
@@ -0,0 +1,126 @@
# frozen_string_literal: true
module EuchreCamp
module Actions
module Admin
module Tournaments
class Schedule < EuchreCamp::Action
include Deps[
events_repo: 'repositories.events',
teams_repo: 'repositories.teams',
rounds_repo: 'repositories.tournament_rounds',
matchups_repo: 'repositories.bracket_matchups'
]
def handle(request, response)
tournament_id = request.params[:id]
tournament = events_repo.by_id(tournament_id)
teams = teams_repo.by_event(tournament_id)
if teams.empty?
response.flash[:error] = "No teams to schedule. Add teams first."
response.redirect_to("/admin/tournaments/#{tournament_id}")
return
end
# Generate matchups based on format
teams_data = teams.map { |t| { id: t[:id], seed: t[:id] } }
format = tournament[:format]
begin
case format
when 'round_robin'
rounds = EuchreCamp::Services::TournamentGenerator.round_robin(teams_data)
create_round_robin_schedule(tournament_id, rounds)
when 'single_elim'
bracket = EuchreCamp::Services::TournamentGenerator.single_elimination(teams_data)
create_single_elim_schedule(tournament_id, bracket, teams)
when 'swiss'
# For Swiss, create first round
rounds = EuchreCamp::Services::TournamentGenerator.swiss(teams_data, 1)
create_swiss_schedule(tournament_id, rounds, 1)
else
response.flash[:error] = "Format '#{format}' not yet implemented"
response.redirect_to("/admin/tournaments/#{tournament_id}")
return
end
# Update tournament status
events_repo.update(tournament_id, status: 'active')
response.flash[:message] = "Schedule generated successfully"
response.redirect_to("/admin/tournaments/#{tournament_id}")
rescue StandardError => e
response.flash[:error] = "Failed to generate schedule: #{e.message}"
response.redirect_to("/admin/tournaments/#{tournament_id}")
end
end
private
def create_round_robin_schedule(tournament_id, rounds)
rounds.each do |round_data|
round = rounds_repo.create(
event_id: tournament_id,
round_number: round_data[:round_number],
status: round_data[:round_number] == 1 ? 'active' : 'pending'
)
round_data[:matchups].each_with_index do |matchup, index|
matchups_repo.create(
event_id: tournament_id,
round_id: round[:id],
team_1_id: matchup[:team_1_id],
team_2_id: matchup[:team_2_id],
bracket_position: index + 1
)
end
end
end
def create_single_elim_schedule(tournament_id, bracket, teams)
# Create first round
round = rounds_repo.create(
event_id: tournament_id,
round_number: 1,
status: 'active'
)
bracket[:first_round_matchups].each do |matchup|
# Skip if team is nil (BYE)
next if matchup[:team_1_id].nil? && matchup[:team_2_id].nil?
matchups_repo.create(
event_id: tournament_id,
round_id: round[:id],
team_1_id: matchup[:team_1_id],
team_2_id: matchup[:team_2_id],
bracket_position: matchup[:bracket_position]
)
end
end
def create_swiss_schedule(tournament_id, rounds, round_number)
round = rounds_repo.create(
event_id: tournament_id,
round_number: round_number,
status: 'active'
)
rounds.each do |round_data|
round_data[:matchups].each_with_index do |matchup, index|
matchups_repo.create(
event_id: tournament_id,
round_id: round[:id],
team_1_id: matchup[:team_1_id],
team_2_id: matchup[:team_2_id],
bracket_position: index + 1
)
end
end
end
end
end
end
end
end
+54
View File
@@ -0,0 +1,54 @@
# frozen_string_literal: true
module EuchreCamp
module Actions
module Admin
module Tournaments
class Show < EuchreCamp::Action
include Deps[
events_repo: 'repositories.events',
participants_repo: 'repositories.event_participants',
teams_repo: 'repositories.teams',
rounds_repo: 'repositories.tournament_rounds',
players_repo: 'repositories.players'
]
def handle(request, response)
tournament_id = request.params[:id]
tournament = events_repo.by_id(tournament_id)
# Get all registered participants for this tournament
participants = participants_repo.by_event(tournament_id)
participant_ids = participants.map { |p| p[:player_id] }
# Get registered players details
players = if participant_ids.any?
players_repo.players.where(id: participant_ids).to_a
else
[]
end
# Get ALL players for the registration dropdown (not yet registered)
all_players = players_repo.all
available_players = all_players.reject { |p| participant_ids.include?(p[:id]) }
# Get teams
teams = teams_repo.by_event(tournament_id)
# Get rounds
rounds = rounds_repo.by_event(tournament_id)
response.render(view,
tournament: tournament,
players: players,
available_players: available_players,
teams: teams,
rounds: rounds,
participants: participants
)
end
end
end
end
end
end
@@ -0,0 +1,62 @@
# frozen_string_literal: true
module EuchreCamp
module Actions
module Admin
module Tournaments
module Teams
class Create < EuchreCamp::Action
include Deps[
teams_repo: 'repositories.teams',
players_repo: 'repositories.players',
participants_repo: 'repositories.event_participants'
]
def handle(request, response)
tournament_id = request.params[:id]
params = request.params.to_h
player_1_id = params[:player_1_id]&.to_i
player_2_id = params[:player_2_id]&.to_i
team_name = params[:team_name]
if player_1_id.nil? || player_2_id.nil?
response.flash[:error] = "Please select both players"
response.redirect_to("/admin/tournaments/#{tournament_id}")
return
end
if player_1_id == player_2_id
response.flash[:error] = "Players must be different"
response.redirect_to("/admin/tournaments/#{tournament_id}")
return
end
# Ensure players are registered as participants
[player_1_id, player_2_id].each do |player_id|
existing = participants_repo.by_event(tournament_id).find { |p| p[:player_id] == player_id }
unless existing
participants_repo.register_player(tournament_id, player_id)
end
end
# Create the team
team = teams_repo.create(
event_id: tournament_id,
player_1_id: player_1_id,
player_2_id: player_2_id,
team_name: team_name || "Team #{player_1_id}-#{player_2_id}"
)
response.flash[:message] = "Team created successfully"
response.redirect_to("/admin/tournaments/#{tournament_id}")
rescue StandardError => e
response.flash[:error] = "Failed to create team: #{e.message}"
response.redirect_to("/admin/tournaments/#{tournament_id}")
end
end
end
end
end
end
end
@@ -0,0 +1,28 @@
# frozen_string_literal: true
module EuchreCamp
module Actions
module Admin
module Tournaments
module Teams
class Destroy < EuchreCamp::Action
include Deps[teams_repo: 'repositories.teams']
def handle(request, response)
tournament_id = request.params[:id]
team_id = request.params[:team_id]
teams_repo.delete(team_id.to_i)
response.flash[:message] = "Team removed"
response.redirect_to("/admin/tournaments/#{tournament_id}")
rescue StandardError => e
response.flash[:error] = "Failed to remove team: #{e.message}"
response.redirect_to("/admin/tournaments/#{tournament_id}")
end
end
end
end
end
end
end
@@ -0,0 +1,34 @@
# frozen_string_literal: true
module EuchreCamp
module Actions
module Admin
module Tournaments
module Teams
class Edit < EuchreCamp::Action
include Deps[
teams_repo: 'repositories.teams',
players_repo: 'repositories.players',
events_repo: 'repositories.events'
]
def handle(request, response)
tournament_id = request.params[:id]
team_id = request.params[:team_id]
tournament = events_repo.by_id(tournament_id)
team = teams_repo.teams.by_pk(team_id).one
players = players_repo.all
response.render(view,
tournament: tournament,
team: team,
players: players
)
end
end
end
end
end
end
end
@@ -0,0 +1,35 @@
# frozen_string_literal: true
module EuchreCamp
module Actions
module Admin
module Tournaments
module Teams
class Update < EuchreCamp::Action
include Deps[teams_repo: 'repositories.teams']
def handle(request, response)
tournament_id = request.params[:id]
team_id = request.params[:team_id]
params = request.params.to_h
update_params = {
team_name: params[:team_name],
player_1_id: params[:player_1_id]&.to_i,
player_2_id: params[:player_2_id]&.to_i
}
teams_repo.update(team_id.to_i, update_params)
response.flash[:message] = "Team updated"
response.redirect_to("/admin/tournaments/#{tournament_id}")
rescue StandardError => e
response.flash[:error] = "Failed to update team: #{e.message}"
response.redirect_to("/admin/tournaments/#{tournament_id}/teams/#{team_id}/edit")
end
end
end
end
end
end
end
+34
View File
@@ -0,0 +1,34 @@
# frozen_string_literal: true
module EuchreCamp
module Actions
module Admin
module Tournaments
class Update < EuchreCamp::Action
include Deps[repo: 'repositories.events']
def handle(request, response)
tournament_id = request.params[:id]
params = request.params.to_h
update_params = {
name: params[:name],
description: params[:description],
event_date: params[:event_date] ? Time.parse(params[:event_date]) : nil,
format: params[:format],
max_participants: params[:max_participants]&.to_i
}
repo.update(tournament_id, update_params)
response.flash[:message] = "Tournament updated"
response.redirect_to("/admin/tournaments/#{tournament_id}")
rescue StandardError => e
response.flash[:error] = "Failed to update: #{e.message}"
response.redirect_to("/admin/tournaments/#{tournament_id}/edit")
end
end
end
end
end
end