From 1268889a7870c3e270d6154abb5772fff0b4a2a1 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Thu, 26 Mar 2026 16:28:24 -0700 Subject: [PATCH] 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 --- Gemfile | 3 +- app/actions/admin/matches/create.rb | 31 +++++++ app/actions/admin/matches/index.rb | 18 ++++ app/actions/admin/matches/new.rb | 18 ++++ app/actions/admin/matches/process_upload.rb | 86 ++++++++++++++++++ app/actions/admin/matches/upload.rb | 17 ++++ app/actions/players/rankings.rb | 17 ++++ app/jobs/calculate_elo_job.rb | 86 ++++++++++++++++++ app/repositories/matches.rb | 19 ++++ app/services/elo_calculator.rb | 80 ++++++++++++++++ app/templates/admin/matches/index.html.erb | 36 ++++++++ app/templates/admin/matches/new.html.erb | 66 ++++++++++++++ app/templates/admin/matches/upload.html.erb | 26 ++++++ app/templates/players/rankings.html.erb | 28 ++++++ app/views/admin/matches/index.rb | 13 +++ app/views/admin/matches/new.rb | 13 +++ app/views/admin/matches/upload.rb | 12 +++ app/views/players/rankings.rb | 11 +++ bin/worker.rb | 57 ++++++++++++ config/app.rb | 5 + config/initializers/active_job.rb | 6 ++ config/initializers/good_job.rb | 21 +++++ config/routes.rb | 10 ++ cookies.txt | 5 + .../20240915000000_create_good_job_tables.rb | 53 +++++++++++ ...000001_create_matches_and_elo_snapshots.rb | 42 +++++++++ euchre_camp.db | Bin 32768 -> 69632 bytes .../persistence/relations/elo_snapshots.rb | 19 ++++ .../persistence/relations/good_jobs.rb | 31 +++++++ .../persistence/relations/matches.rb | 29 ++++++ .../persistence/relations/players.rb | 9 +- 31 files changed, 865 insertions(+), 2 deletions(-) create mode 100644 app/actions/admin/matches/create.rb create mode 100644 app/actions/admin/matches/index.rb create mode 100644 app/actions/admin/matches/new.rb create mode 100644 app/actions/admin/matches/process_upload.rb create mode 100644 app/actions/admin/matches/upload.rb create mode 100644 app/actions/players/rankings.rb create mode 100644 app/jobs/calculate_elo_job.rb create mode 100644 app/repositories/matches.rb create mode 100644 app/services/elo_calculator.rb create mode 100644 app/templates/admin/matches/index.html.erb create mode 100644 app/templates/admin/matches/new.html.erb create mode 100644 app/templates/admin/matches/upload.html.erb create mode 100644 app/templates/players/rankings.html.erb create mode 100644 app/views/admin/matches/index.rb create mode 100644 app/views/admin/matches/new.rb create mode 100644 app/views/admin/matches/upload.rb create mode 100644 app/views/players/rankings.rb create mode 100644 bin/worker.rb create mode 100644 config/initializers/active_job.rb create mode 100644 config/initializers/good_job.rb create mode 100644 cookies.txt create mode 100644 db/migrate/20240915000000_create_good_job_tables.rb create mode 100644 db/migrate/20240915000001_create_matches_and_elo_snapshots.rb create mode 100644 lib/euchre_camp/persistence/relations/elo_snapshots.rb create mode 100644 lib/euchre_camp/persistence/relations/good_jobs.rb create mode 100644 lib/euchre_camp/persistence/relations/matches.rb diff --git a/Gemfile b/Gemfile index 9cf42a3..bd101aa 100644 --- a/Gemfile +++ b/Gemfile @@ -17,8 +17,9 @@ gem "rom", "~> 5.3" gem "rom-sql", "~> 3.6" gem "sqlite3" +gem "good_job", "~> 4.13" + group :development do - gem "hanami-webconsole", "~> 2.1" gem "guard-puma" end diff --git a/app/actions/admin/matches/create.rb b/app/actions/admin/matches/create.rb new file mode 100644 index 0000000..3a385f7 --- /dev/null +++ b/app/actions/admin/matches/create.rb @@ -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 diff --git a/app/actions/admin/matches/index.rb b/app/actions/admin/matches/index.rb new file mode 100644 index 0000000..ab7412b --- /dev/null +++ b/app/actions/admin/matches/index.rb @@ -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 diff --git a/app/actions/admin/matches/new.rb b/app/actions/admin/matches/new.rb new file mode 100644 index 0000000..8a0120e --- /dev/null +++ b/app/actions/admin/matches/new.rb @@ -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 diff --git a/app/actions/admin/matches/process_upload.rb b/app/actions/admin/matches/process_upload.rb new file mode 100644 index 0000000..2d1b2e8 --- /dev/null +++ b/app/actions/admin/matches/process_upload.rb @@ -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 diff --git a/app/actions/admin/matches/upload.rb b/app/actions/admin/matches/upload.rb new file mode 100644 index 0000000..21d5334 --- /dev/null +++ b/app/actions/admin/matches/upload.rb @@ -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 diff --git a/app/actions/players/rankings.rb b/app/actions/players/rankings.rb new file mode 100644 index 0000000..0d09ba9 --- /dev/null +++ b/app/actions/players/rankings.rb @@ -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 diff --git a/app/jobs/calculate_elo_job.rb b/app/jobs/calculate_elo_job.rb new file mode 100644 index 0000000..da5e125 --- /dev/null +++ b/app/jobs/calculate_elo_job.rb @@ -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 diff --git a/app/repositories/matches.rb b/app/repositories/matches.rb new file mode 100644 index 0000000..5e016f1 --- /dev/null +++ b/app/repositories/matches.rb @@ -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 diff --git a/app/services/elo_calculator.rb b/app/services/elo_calculator.rb new file mode 100644 index 0000000..07079c2 --- /dev/null +++ b/app/services/elo_calculator.rb @@ -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] player IDs for team 1 + # @param team_2_players [Array] 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 diff --git a/app/templates/admin/matches/index.html.erb b/app/templates/admin/matches/index.html.erb new file mode 100644 index 0000000..887460a --- /dev/null +++ b/app/templates/admin/matches/index.html.erb @@ -0,0 +1,36 @@ +

Match Administration

+ +

Enter New Match | Upload CSV

+ +<% if matches.any? %> + + + + + + + + + + + + + + <% matches.each do |match| %> + + + + + + + + + + <% end %> + +
IDDateTeam 1ScoreTeam 2ScoreStatus
<%= match.id %><%= match.played_at&.strftime('%Y-%m-%d %H:%M') %><%= match.team_1_p1_id %>/<%= match.team_1_p2_id %><%= match.team_1_score %><%= match.team_2_p1_id %>/<%= match.team_2_p2_id %><%= match.team_2_score %><%= match.status %>
+<% else %> +

No matches entered yet.

+<% end %> + +

Manage Players

diff --git a/app/templates/admin/matches/new.html.erb b/app/templates/admin/matches/new.html.erb new file mode 100644 index 0000000..45bd54d --- /dev/null +++ b/app/templates/admin/matches/new.html.erb @@ -0,0 +1,66 @@ +

Enter New Match

+ +
+ + +
+ + +
+ +
+ Team 1 +
+ + +
+
+ + +
+
+ + +
+
+ +
+ Team 2 +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +

Back to Matches

diff --git a/app/templates/admin/matches/upload.html.erb b/app/templates/admin/matches/upload.html.erb new file mode 100644 index 0000000..6378429 --- /dev/null +++ b/app/templates/admin/matches/upload.html.erb @@ -0,0 +1,26 @@ +

Upload Matches (CSV)

+ +

Upload a CSV file with the following 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
+
+ +
+ + +
+ + +
+ + +
+ +

Back to Matches

+ +

Example CSV:

+
+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
+
diff --git a/app/templates/players/rankings.html.erb b/app/templates/players/rankings.html.erb new file mode 100644 index 0000000..0ed675f --- /dev/null +++ b/app/templates/players/rankings.html.erb @@ -0,0 +1,28 @@ +

Player Rankings

+ +<% if players.any? %> + + + + + + + + + + + <% players.each_with_index do |player, index| %> + + + + + + + <% end %> + +
RankPlayerElo RatingOriginal Rating
<%= index + 1 %><%= player.name %><%= player.current_elo %><%= player.rating %>
+<% else %> +

No players found.

+<% end %> + +

Admin

diff --git a/app/views/admin/matches/index.rb b/app/views/admin/matches/index.rb new file mode 100644 index 0000000..a9d843b --- /dev/null +++ b/app/views/admin/matches/index.rb @@ -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 diff --git a/app/views/admin/matches/new.rb b/app/views/admin/matches/new.rb new file mode 100644 index 0000000..93f8ddf --- /dev/null +++ b/app/views/admin/matches/new.rb @@ -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 diff --git a/app/views/admin/matches/upload.rb b/app/views/admin/matches/upload.rb new file mode 100644 index 0000000..1f15fb7 --- /dev/null +++ b/app/views/admin/matches/upload.rb @@ -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 diff --git a/app/views/players/rankings.rb b/app/views/players/rankings.rb new file mode 100644 index 0000000..b6a4e41 --- /dev/null +++ b/app/views/players/rankings.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module EuchreCamp + module Views + module Players + class Rankings < EuchreCamp::View + expose :players + end + end + end +end diff --git a/bin/worker.rb b/bin/worker.rb new file mode 100644 index 0000000..f2539bf --- /dev/null +++ b/bin/worker.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require_relative "../config/app" + +# Boot the Hanami app to ensure persistence is available +EuchreCamp::App.boot + +# Simple worker that polls for jobs and executes them +def poll_and_execute_jobs + rom = EuchreCamp::App["persistence.rom"] + + puts "Worker started, waiting for jobs..." + + loop do + # Find a pending job + job = rom.relations[:good_jobs].where(finished_at: nil).order(:scheduled_at).limit(1).one + + if job + begin + # Mark as performed + rom.relations[:good_jobs].where(id: job[:id]).update( + performed_at: Time.now, + executions_count: (job[:executions_count] || 0) + 1 + ) + + # Execute the job + params = JSON.parse(job[:serialized_params]) + job_class = Object.const_get(job[:job_class]) + + if params["match_id"] + job_class.perform(params["match_id"]) + else + # Legacy format for backwards compatibility + job_class.perform(*params["arguments"]) + end + + # Mark as finished + rom.relations[:good_jobs].where(id: job[:id]).update(finished_at: Time.now) + + puts "Executed job #{job[:id]}: #{job[:job_class]}" + rescue => e + # Mark as failed + rom.relations[:good_jobs].where(id: job[:id]).update( + finished_at: Time.now, + error: e.message + ) + puts "Failed job #{job[:id]}: #{e.message}" + puts e.backtrace.first(5).join("\n") + end + else + # No jobs, wait a bit + sleep 1 + end + end +end + +poll_and_execute_jobs diff --git a/config/app.rb b/config/app.rb index a326611..6e0a186 100644 --- a/config/app.rb +++ b/config/app.rb @@ -1,8 +1,13 @@ # frozen_string_literal: true require "hanami" +require "good_job" module EuchreCamp class App < Hanami::App + config.root = File.expand_path("..", __dir__) + + # Enable sessions + config.actions.sessions = :cookie, {secret: ENV.fetch("SESSION_SECRET", "change_me_in_production")} end end diff --git a/config/initializers/active_job.rb b/config/initializers/active_job.rb new file mode 100644 index 0000000..17b72ca --- /dev/null +++ b/config/initializers/active_job.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require "active_job" + +# Configure Active Job to use GoodJob +ActiveJob::Base.queue_adapter = :good_job diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb new file mode 100644 index 0000000..081ece7 --- /dev/null +++ b/config/initializers/good_job.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "good_job" + +# GoodJob configuration for Hanami +# Since GoodJob is primarily designed for Rails, we configure it directly + +GoodJob.preserve_job_records = true +GoodJob.retry_on_unhandled_error = false +GoodJob.on_thread_error = ->(exception) { puts "GoodJob error: #{exception.message}" } + +# Set execution mode based on environment +if ENV["GOOD_JOB_EXECUTION_MODE"] + GoodJob.execution_mode = ENV["GOOD_JOB_EXECUTION_MODE"].to_sym +elsif ENV["RACK_ENV"] == "test" + GoodJob.execution_mode = :inline +elsif ENV["RACK_ENV"] == "development" + GoodJob.execution_mode = :async +else + GoodJob.execution_mode = :external +end diff --git a/config/routes.rb b/config/routes.rb index 8805065..dc2d739 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -4,6 +4,7 @@ module EuchreCamp class Routes < Hanami::Routes # Add your routes here. See https://guides.hanamirb.org/routing/overview/ for details. get "/players/:id", to: "players.show" + get "/rankings", to: "players.rankings" get "/admin/players/new", to: "admin.players.new" post "/admin/players", to: "admin.players.create" @@ -11,5 +12,14 @@ module EuchreCamp get "/admin/players/:id/edit", to: "admin.players.edit" patch "/admin/players/:id", to: "admin.players.update" delete "/admin/players/:id", to: "admin.players.destroy" + + # Match routes + get "/admin/matches/new", to: "admin.matches.new" + post "/admin/matches", to: "admin.matches.create" + get "/admin/matches", to: "admin.matches.index" + + # CSV Upload routes + get "/admin/matches/upload", to: "admin.matches.upload" + post "/admin/matches/process_upload", to: "admin.matches.process_upload" end end diff --git a/cookies.txt b/cookies.txt new file mode 100644 index 0000000..357703e --- /dev/null +++ b/cookies.txt @@ -0,0 +1,5 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +#HttpOnly_localhost FALSE / FALSE 0 rack.session ASyilKVTxn1iBCRZVUIw0JytfvCxi0S7cf1Cyrqsal0zKn_qN-XMmRdfPBJSFliFhkK0_LVlDU99FIVtgnT4FnLijl8lYpcQzF1dVtPOGvEQpYLM5-44U9nsm1angx6MrLhNuwwizUIUHTtJFpvM7iA5hU1S5PwHF5hXvTYy1zQAVMJOsiF1rUORTb-pbsImZButiYE1qw29AF6RxVn8LnaM3mGBfA7b8jBaSe6gnaEjeJ5gbQMrI8o0efXcmJW2ViovTvto-gIvElOL0H3cDV-AcvmBp_QUpL3buZUD-A65c2lT91s92R19pkS1AM_hHTN9v1gGlcY2eyCVoqC4RYI-RG_HYdp7fkWEnf0rb8uXDNh9GJdqg0TPMUu0P_klRs7Jdx0bFJcAjWMzpQhYRmisGJ8Mf7RN6XmtYDLyqopcMBS3U5mpDpk6p1Rf_yr6vQ%3D%3D diff --git a/db/migrate/20240915000000_create_good_job_tables.rb b/db/migrate/20240915000000_create_good_job_tables.rb new file mode 100644 index 0000000..733b41b --- /dev/null +++ b/db/migrate/20240915000000_create_good_job_tables.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +ROM::SQL.migration do + change do + create_table :good_jobs do + primary_key :id + column :queue_name, String + column :priority, Integer, default: 0 + column :serialized_params, String + column :scheduled_at, DateTime + column :performed_at, DateTime + column :finished_at, DateTime + column :error, String + column :created_at, DateTime, null: false + column :updated_at, DateTime, null: false + column :active_job_id, String + column :concurrency_key, String + column :sequence, Integer + column :executions_count, Integer + column :job_class, String + column :error_event, Integer + column :labels, String + column :locked_by_id, String + column :locked_at, DateTime + end + + add_index :good_jobs, [:scheduled_at, :priority, :id], name: "index_good_jobs_on_scheduled_at" + + create_table :good_job_processes do + primary_key :id + column :state, String + column :started_at, DateTime + column :singleton_key, String + column :created_at, DateTime, null: false + column :updated_at, DateTime, null: false + end + + create_table :good_job_execution_errors do + primary_key :id + column :active_job_id, String + column :error, String + column :created_at, DateTime, null: false + end + + create_table :good_job_settings do + primary_key :id + column :key, String + column :value, String + column :created_at, DateTime, null: false + column :updated_at, DateTime, null: false + end + end +end diff --git a/db/migrate/20240915000001_create_matches_and_elo_snapshots.rb b/db/migrate/20240915000001_create_matches_and_elo_snapshots.rb new file mode 100644 index 0000000..e25494e --- /dev/null +++ b/db/migrate/20240915000001_create_matches_and_elo_snapshots.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +ROM::SQL.migration do + change do + # Drop and recreate matches table with new structure + drop_table?(:matches) + + create_table :matches do + primary_key :id + foreign_key :event_id, :events + column :played_at, DateTime + column :team_1_p1_id, Integer + column :team_1_p2_id, Integer + column :team_2_p1_id, Integer + column :team_2_p2_id, Integer + column :team_1_score, Integer + column :team_2_score, Integer + column :status, String, default: 'completed' + end + + # Add current_elo to players table + alter_table :players do + add_column :current_elo, Integer, default: 1000 + end + + # Create elo_snapshots table + create_table :elo_snapshots do + primary_key :id + foreign_key :player_id, :players, null: false + foreign_key :match_id, :matches, null: false + column :rating_before, Integer, null: false + column :rating_after, Integer, null: false + column :rating_change, Integer, null: false + column :created_at, DateTime, null: false, default: Sequel::CURRENT_TIMESTAMP + end + + # Add indexes for performance + add_index :elo_snapshots, [:player_id, :match_id], unique: true + add_index :matches, :played_at + add_index :matches, :status + end +end diff --git a/euchre_camp.db b/euchre_camp.db index 4ed84d0b223745a40db2988fe6e09b22561bb7f1..a2ba1879f2a869db4a5a8cb41e4a2e41f2af4f1b 100644 GIT binary patch literal 69632 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 delta 417 zcmZozz|zpbG(k#;fscWKfdhzPfPJElkql5&ud0xj{|5s*_i6@yQ~s@dE_~B?XY&eh zuimUEV92fAl*GXbBPixh%f-CToQ{hVEc6%xR*#R)nJ2?aTcm8nJXhR8}M|7VsEfl3(R zk}$?4VS+B9ou85tUyu)UM={(rK$}y`QuEM-H8%_JwlT6a%CNFep2ep-*^o7NvI+kM z9v1!*2L3zzJNc*amuyxvNa3GcB0m8rS_l%|!9Rt+5Gb0+Ken*{@2 n@r!XWvM`7;rRL@)GDYwLLmWwtg^Q6{dh-u|9)-;;0e|=bYhHk& diff --git a/lib/euchre_camp/persistence/relations/elo_snapshots.rb b/lib/euchre_camp/persistence/relations/elo_snapshots.rb new file mode 100644 index 0000000..c1052b0 --- /dev/null +++ b/lib/euchre_camp/persistence/relations/elo_snapshots.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module EuchreCamp + module Persistence + module Relations + class EloSnapshots < ROM::Relation[:sql] + schema(:elo_snapshots, infer: true) do + attribute :id, ROM::Types::Integer, primary_key: true + attribute :player_id, ROM::Types::Integer + attribute :match_id, ROM::Types::Integer + attribute :rating_before, ROM::Types::Integer + attribute :rating_after, ROM::Types::Integer + attribute :rating_change, ROM::Types::Integer + attribute :created_at, ROM::Types::DateTime + end + end + end + end +end diff --git a/lib/euchre_camp/persistence/relations/good_jobs.rb b/lib/euchre_camp/persistence/relations/good_jobs.rb new file mode 100644 index 0000000..765b3b9 --- /dev/null +++ b/lib/euchre_camp/persistence/relations/good_jobs.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module EuchreCamp + module Persistence + module Relations + class GoodJobs < ROM::Relation[:sql] + schema(:good_jobs, infer: true) do + attribute :id, ROM::Types::Integer + attribute :queue_name, ROM::Types::String + attribute :priority, ROM::Types::Integer + attribute :serialized_params, ROM::Types::String + attribute :scheduled_at, ROM::Types::DateTime.optional + attribute :performed_at, ROM::Types::DateTime.optional + attribute :finished_at, ROM::Types::DateTime.optional + attribute :error, ROM::Types::String.optional + attribute :created_at, ROM::Types::DateTime + attribute :updated_at, ROM::Types::DateTime + attribute :active_job_id, ROM::Types::String.optional + attribute :concurrency_key, ROM::Types::String.optional + attribute :sequence, ROM::Types::Integer.optional + attribute :executions_count, ROM::Types::Integer.optional + attribute :job_class, ROM::Types::String.optional + attribute :error_event, ROM::Types::Integer.optional + attribute :labels, ROM::Types::String.optional + attribute :locked_by_id, ROM::Types::String.optional + attribute :locked_at, ROM::Types::DateTime.optional + end + end + end + end +end diff --git a/lib/euchre_camp/persistence/relations/matches.rb b/lib/euchre_camp/persistence/relations/matches.rb new file mode 100644 index 0000000..5f9cd29 --- /dev/null +++ b/lib/euchre_camp/persistence/relations/matches.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module EuchreCamp + module Persistence + module Relations + class Matches < ROM::Relation[:sql] + schema(:matches, infer: true) do + attribute :id, ROM::Types::Integer, primary_key: true + attribute :event_id, ROM::Types::Integer.optional + attribute :played_at, ROM::Types::DateTime.optional + attribute :team_1_p1_id, ROM::Types::Integer + attribute :team_1_p2_id, ROM::Types::Integer + attribute :team_2_p1_id, ROM::Types::Integer + attribute :team_2_p2_id, ROM::Types::Integer + attribute :team_1_score, ROM::Types::Integer + attribute :team_2_score, ROM::Types::Integer + attribute :status, ROM::Types::String.default('completed') + + associations do + belongs_to :team_1_player, relation: :players, foreign_key: :team_1_p1_id + belongs_to :team_2_player, relation: :players, foreign_key: :team_1_p2_id + belongs_to :team_3_player, relation: :players, foreign_key: :team_2_p1_id + belongs_to :team_4_player, relation: :players, foreign_key: :team_2_p2_id + end + end + end + end + end +end diff --git a/lib/euchre_camp/persistence/relations/players.rb b/lib/euchre_camp/persistence/relations/players.rb index f1fd95c..cebece8 100644 --- a/lib/euchre_camp/persistence/relations/players.rb +++ b/lib/euchre_camp/persistence/relations/players.rb @@ -1,8 +1,15 @@ +# frozen_string_literal: true + module EuchreCamp module Persistence module Relations class Players < ROM::Relation[:sql] - schema(:players, infer: true) + schema(:players, infer: true) do + attribute :id, ROM::Types::Integer, primary_key: true + attribute :name, ROM::Types::String + attribute :rating, ROM::Types::Integer + attribute :current_elo, ROM::Types::Integer.default(1000) + end end end end