Add Elo rating system with match entry and CSV upload

- Implement EloCalculator service for 2v2 Euchre matches
- Create background job processing with GoodJob
- Add single match entry form
- Add CSV upload for batch match entry
- Create player rankings view
- Add database schema for matches and elo snapshots
This commit is contained in:
2026-03-26 16:28:24 -07:00
parent 1a9b3496e1
commit 1268889a78
31 changed files with 865 additions and 2 deletions
+31
View File
@@ -0,0 +1,31 @@
# frozen_string_literal: true
module EuchreCamp
module Actions
module Admin
module Matches
class Create < EuchreCamp::Action
include Deps[repo: 'repositories.matches']
def handle(request, response)
match_params = request.params.to_h
match_params[:played_at] = Time.parse(match_params[:played_at]) if match_params[:played_at]
# Create the match
match = repo.create(match_params)
# Enqueue background job to calculate Elo
EuchreCamp::Jobs::CalculateEloJob.perform_later(match[:id])
response.redirect_to("/admin/matches")
rescue StandardError => e
# Handle validation errors or other issues
puts "Error: #{e.message}"
puts e.backtrace.join("\n")
response.redirect_to("/admin/matches/new")
end
end
end
end
end
end
+18
View File
@@ -0,0 +1,18 @@
# frozen_string_literal: true
module EuchreCamp
module Actions
module Admin
module Matches
class Index < EuchreCamp::Action
include Deps[repo: 'repositories.matches']
def handle(_request, response)
matches = repo.all
response.render(view, matches: matches)
end
end
end
end
end
end
+18
View File
@@ -0,0 +1,18 @@
# frozen_string_literal: true
module EuchreCamp
module Actions
module Admin
module Matches
class New < EuchreCamp::Action
include Deps[repo: 'repositories.players']
def handle(_request, response)
players = repo.all
response.render(view, players: players)
end
end
end
end
end
end
@@ -0,0 +1,86 @@
# frozen_string_literal: true
require "csv"
module EuchreCamp
module Actions
module Admin
module Matches
class ProcessUpload < EuchreCamp::Action
include Deps[repo: 'repositories.matches']
def handle(request, response)
# In Hanami, file uploads are in request.params
file = request.params[:csv_file]
unless file && file[:tempfile]
response.redirect_to("/admin/matches/upload")
return
end
matches_created = 0
errors = []
# Parse CSV
CSV.foreach(file[:tempfile].path, headers: true, header_converters: :symbol) do |row|
begin
# Map CSV columns to our database structure
# Expected columns: played_at, team_1_p1_name, team_1_p2_name, team_1_score, team_2_p1_name, team_2_p2_name, team_2_score
# Find or create players by name
player_1 = find_or_create_player(row[:team_1_p1_name])
player_2 = find_or_create_player(row[:team_1_p2_name])
player_3 = find_or_create_player(row[:team_2_p1_name])
player_4 = find_or_create_player(row[:team_2_p2_name])
played_at = begin
Time.parse(row[:played_at])
rescue
Time.now
end
match_data = {
played_at: played_at,
team_1_p1_id: player_1[:id],
team_1_p2_id: player_2[:id],
team_1_score: row[:team_1_score].to_i,
team_2_p1_id: player_3[:id],
team_2_p2_id: player_4[:id],
team_2_score: row[:team_2_score].to_i,
status: 'completed'
}
match = repo.create(match_data)
EuchreCamp::Jobs::CalculateEloJob.perform_later(match[:id])
matches_created += 1
rescue => e
errors << "Row #{row}: #{e.message}"
end
end
response.flash[:message] = "Created #{matches_created} matches.#{' Errors: ' + errors.join(', ') if errors.any?}"
response.redirect_to("/admin/matches")
end
private
def find_or_create_player(name)
rom = EuchreCamp::App["persistence.rom"]
return nil unless name
name = name.strip
player = rom.relations[:players].where(name: name).one
if player
player
else
rom.relations[:players].insert(name: name, rating: 0, current_elo: 1000)
rom.relations[:players].where(name: name).one
end
end
end
end
end
end
end
+17
View File
@@ -0,0 +1,17 @@
# frozen_string_literal: true
require "csv"
module EuchreCamp
module Actions
module Admin
module Matches
class Upload < EuchreCamp::Action
def handle(request, response)
response.render(view)
end
end
end
end
end
end
+17
View File
@@ -0,0 +1,17 @@
# frozen_string_literal: true
module EuchreCamp
module Actions
module Players
class Rankings < EuchreCamp::Action
include Deps[repo: 'repositories.players']
def handle(_request, response)
# Get all players and sort by current Elo
players = repo.all.sort_by { |p| -p.current_elo }
response.render(view, players: players)
end
end
end
end
end
+86
View File
@@ -0,0 +1,86 @@
# frozen_string_literal: true
module EuchreCamp
module Jobs
class CalculateEloJob
def self.perform_later(match_id)
# Enqueue job using GoodJob's database directly
rom = EuchreCamp::App["persistence.rom"]
job_params = {
queue_name: "default",
priority: 0,
serialized_params: { "match_id" => match_id }.to_json,
scheduled_at: Time.now,
created_at: Time.now,
updated_at: Time.now,
job_class: "EuchreCamp::Jobs::CalculateEloJob",
executions_count: 0
}
rom.relations[:good_jobs].insert(job_params)
end
def self.perform(match_id)
# This is the actual job execution
match = find_match(match_id)
return unless match
# Get current player ratings
player_ratings = fetch_player_ratings(match)
# Calculate new ratings
calculation_results = EuchreCamp::Services::EloCalculator.calculate(
player_ratings: player_ratings,
team_1_players: [match[:team_1_p1_id], match[:team_1_p2_id]],
team_2_players: [match[:team_2_p1_id], match[:team_2_p2_id]],
team_1_score: match[:team_1_score],
team_2_score: match[:team_2_score]
)
# Update player ratings and create snapshots
update_player_ratings(calculation_results, match_id)
end
private
def self.find_match(match_id)
rom = EuchreCamp::App["persistence.rom"]
rom.relations[:matches].by_pk(match_id).one
end
def self.fetch_player_ratings(match)
rom = EuchreCamp::App["persistence.rom"]
player_ids = [
match[:team_1_p1_id],
match[:team_1_p2_id],
match[:team_2_p1_id],
match[:team_2_p2_id]
]
players = rom.relations[:players].where(id: player_ids).to_a
players.each_with_object({}) do |player, hash|
hash[player[:id]] = player[:current_elo] || 1000
end
end
def self.update_player_ratings(results, match_id)
rom = EuchreCamp::App["persistence.rom"]
results.each do |player_id, data|
# Update player's current Elo
rom.relations[:players].where(id: player_id).update(current_elo: data[:rating_after])
# Create Elo snapshot record
rom.relations[:elo_snapshots].insert(
player_id: player_id,
match_id: match_id,
rating_before: data[:rating_before],
rating_after: data[:rating_after],
rating_change: data[:rating_change]
)
end
end
end
end
end
+19
View File
@@ -0,0 +1,19 @@
# frozen_string_literal: true
require 'rom-repository'
module EuchreCamp
module Repositories
class Matches < ::EuchreCamp::Repository[:matches]
commands :create, update: :by_pk, delete: :by_pk
def all
matches.to_a
end
def by_id(id)
matches.by_pk(id).one!
end
end
end
end
+80
View File
@@ -0,0 +1,80 @@
# frozen_string_literal: true
module EuchreCamp
module Services
class EloCalculator
# Standard Elo K-factor
K_FACTOR = 32
# Calculate Elo ratings for a 2v2 Euchre match
#
# @param player_ratings [Hash] player_id => current_rating
# @param team_1_players [Array<Integer>] player IDs for team 1
# @param team_2_players [Array<Integer>] player IDs for team 2
# @param team_1_score [Integer] score for team 1
# @param team_2_score [Integer] score for team 2
# @return [Hash] player_id => { rating_before:, rating_after:, rating_change: }
def self.calculate(player_ratings:, team_1_players:, team_2_players:, team_1_score:, team_2_score:)
# Validate inputs
raise ArgumentError, "Team 1 must have exactly 2 players" unless team_1_players.length == 2
raise ArgumentError, "Team 2 must have exactly 2 players" unless team_2_players.length == 2
# Determine winner (first to 10 points)
team_1_wins = team_1_score >= 10
team_2_wins = team_2_score >= 10
unless team_1_wins || team_2_wins
raise ArgumentError, "One team must reach 10 points to win"
end
if team_1_wins && team_2_wins
raise ArgumentError, "Only one team can win (both reached 10 points)"
end
winner = team_1_wins ? :team_1 : :team_2
# Calculate average ratings for each team
team_1_avg = team_1_players.map { |id| player_ratings[id] }.sum / 2.0
team_2_avg = team_2_players.map { |id| player_ratings[id] }.sum / 2.0
# Calculate expected scores using Elo formula
# E = 1 / (1 + 10^((R_opp - R_team) / 400))
expected_team_1 = 1.0 / (1.0 + 10.0**((team_2_avg - team_1_avg) / 400.0))
expected_team_2 = 1.0 / (1.0 + 10.0**((team_1_avg - team_2_avg) / 400.0))
# Actual scores (1 for win, 0 for loss)
actual_team_1 = team_1_wins ? 1.0 : 0.0
actual_team_2 = team_2_wins ? 1.0 : 0.0
# Calculate rating changes for each player
results = {}
# Team 1 players
team_1_players.each do |player_id|
old_rating = player_ratings[player_id]
rating_change = K_FACTOR * (actual_team_1 - expected_team_1)
new_rating = (old_rating + rating_change).round
results[player_id] = {
rating_before: old_rating,
rating_after: new_rating,
rating_change: rating_change.round
}
end
# Team 2 players
team_2_players.each do |player_id|
old_rating = player_ratings[player_id]
rating_change = K_FACTOR * (actual_team_2 - expected_team_2)
new_rating = (old_rating + rating_change).round
results[player_id] = {
rating_before: old_rating,
rating_after: new_rating,
rating_change: rating_change.round
}
end
results
end
end
end
end
@@ -0,0 +1,36 @@
<h1>Match Administration</h1>
<p><a href="/admin/matches/new">Enter New Match</a> | <a href="/admin/matches/upload">Upload CSV</a></p>
<% if matches.any? %>
<table>
<thead>
<tr>
<th>ID</th>
<th>Date</th>
<th>Team 1</th>
<th>Score</th>
<th>Team 2</th>
<th>Score</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<% matches.each do |match| %>
<tr>
<td><%= match.id %></td>
<td><%= match.played_at&.strftime('%Y-%m-%d %H:%M') %></td>
<td><%= match.team_1_p1_id %>/<%= match.team_1_p2_id %></td>
<td><%= match.team_1_score %></td>
<td><%= match.team_2_p1_id %>/<%= match.team_2_p2_id %></td>
<td><%= match.team_2_score %></td>
<td><%= match.status %></td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p>No matches entered yet.</p>
<% end %>
<p><a href="/admin/players">Manage Players</a></p>
+66
View File
@@ -0,0 +1,66 @@
<h1>Enter New Match</h1>
<form action="/admin/matches" method="post">
<input type="hidden" name="_csrf_token" value="<%= csrf_token %>">
<div>
<label for="played_at">Date/Time:</label>
<input type="datetime-local" id="played_at" name="played_at" required value="<%= Time.now.strftime('%Y-%m-%dT%H:%M') %>">
</div>
<fieldset>
<legend>Team 1</legend>
<div>
<label for="team_1_p1_id">Player 1:</label>
<select id="team_1_p1_id" name="team_1_p1_id" required>
<option value="">Select player...</option>
<% players.each do |player| %>
<option value="<%= player.id %>"><%= player.name %></option>
<% end %>
</select>
</div>
<div>
<label for="team_1_p2_id">Player 2:</label>
<select id="team_1_p2_id" name="team_1_p2_id" required>
<option value="">Select player...</option>
<% players.each do |player| %>
<option value="<%= player.id %>"><%= player.name %></option>
<% end %>
</select>
</div>
<div>
<label for="team_1_score">Score:</label>
<input type="number" id="team_1_score" name="team_1_score" min="0" max="10" required value="10">
</div>
</fieldset>
<fieldset>
<legend>Team 2</legend>
<div>
<label for="team_2_p1_id">Player 1:</label>
<select id="team_2_p1_id" name="team_2_p1_id" required>
<option value="">Select player...</option>
<% players.each do |player| %>
<option value="<%= player.id %>"><%= player.name %></option>
<% end %>
</select>
</div>
<div>
<label for="team_2_p2_id">Player 2:</label>
<select id="team_2_p2_id" name="team_2_p2_id" required>
<option value="">Select player...</option>
<% players.each do |player| %>
<option value="<%= player.id %>"><%= player.name %></option>
<% end %>
</select>
</div>
<div>
<label for="team_2_score">Score:</label>
<input type="number" id="team_2_score" name="team_2_score" min="0" max="10" required value="7">
</div>
</fieldset>
<button type="submit">Save Match</button>
</form>
<p><a href="/admin/matches">Back to Matches</a></p>
@@ -0,0 +1,26 @@
<h1>Upload Matches (CSV)</h1>
<p>Upload a CSV file with the following columns:</p>
<pre>
played_at, team_1_p1_name, team_1_p2_name, team_1_score, team_2_p1_name, team_2_p2_name, team_2_score
</pre>
<form action="/admin/matches/process_upload" method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf_token" value="<%= csrf_token %>">
<div>
<label for="csv_file">CSV File:</label>
<input type="file" id="csv_file" name="csv_file" accept=".csv" required>
</div>
<button type="submit">Upload</button>
</form>
<p><a href="/admin/matches">Back to Matches</a></p>
<h2>Example CSV:</h2>
<pre>
played_at,team_1_p1_name,team_1_p2_name,team_1_score,team_2_p1_name,team_2_p2_name,team_2_score
2026-03-26 10:00,Alice,Bob,10,Charlie,David,7
2026-03-26 10:30,Alice,Bob,10,Charlie,David,7
</pre>
+28
View File
@@ -0,0 +1,28 @@
<h1>Player Rankings</h1>
<% if players.any? %>
<table>
<thead>
<tr>
<th>Rank</th>
<th>Player</th>
<th>Elo Rating</th>
<th>Original Rating</th>
</tr>
</thead>
<tbody>
<% players.each_with_index do |player, index| %>
<tr>
<td><%= index + 1 %></td>
<td><%= player.name %></td>
<td><strong><%= player.current_elo %></strong></td>
<td><%= player.rating %></td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p>No players found.</p>
<% end %>
<p><a href="/admin/players">Admin</a></p>
+13
View File
@@ -0,0 +1,13 @@
# frozen_string_literal: true
module EuchreCamp
module Views
module Admin
module Matches
class Index < EuchreCamp::View
expose :matches
end
end
end
end
end
+13
View File
@@ -0,0 +1,13 @@
# frozen_string_literal: true
module EuchreCamp
module Views
module Admin
module Matches
class New < EuchreCamp::View
expose :players
end
end
end
end
end
+12
View File
@@ -0,0 +1,12 @@
# frozen_string_literal: true
module EuchreCamp
module Views
module Admin
module Matches
class Upload < EuchreCamp::View
end
end
end
end
end
+11
View File
@@ -0,0 +1,11 @@
# frozen_string_literal: true
module EuchreCamp
module Views
module Players
class Rankings < EuchreCamp::View
expose :players
end
end
end
end