nextjs-rewrite #5
@@ -17,8 +17,9 @@ gem "rom", "~> 5.3"
|
|||||||
gem "rom-sql", "~> 3.6"
|
gem "rom-sql", "~> 3.6"
|
||||||
gem "sqlite3"
|
gem "sqlite3"
|
||||||
|
|
||||||
|
gem "good_job", "~> 4.13"
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
gem "hanami-webconsole", "~> 2.1"
|
|
||||||
gem "guard-puma"
|
gem "guard-puma"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module EuchreCamp
|
||||||
|
module Views
|
||||||
|
module Players
|
||||||
|
class Rankings < EuchreCamp::View
|
||||||
|
expose :players
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require "hanami"
|
require "hanami"
|
||||||
|
require "good_job"
|
||||||
|
|
||||||
module EuchreCamp
|
module EuchreCamp
|
||||||
class App < Hanami::App
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "active_job"
|
||||||
|
|
||||||
|
# Configure Active Job to use GoodJob
|
||||||
|
ActiveJob::Base.queue_adapter = :good_job
|
||||||
@@ -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
|
||||||
@@ -4,6 +4,7 @@ module EuchreCamp
|
|||||||
class Routes < Hanami::Routes
|
class Routes < Hanami::Routes
|
||||||
# Add your routes here. See https://guides.hanamirb.org/routing/overview/ for details.
|
# Add your routes here. See https://guides.hanamirb.org/routing/overview/ for details.
|
||||||
get "/players/:id", to: "players.show"
|
get "/players/:id", to: "players.show"
|
||||||
|
get "/rankings", to: "players.rankings"
|
||||||
|
|
||||||
get "/admin/players/new", to: "admin.players.new"
|
get "/admin/players/new", to: "admin.players.new"
|
||||||
post "/admin/players", to: "admin.players.create"
|
post "/admin/players", to: "admin.players.create"
|
||||||
@@ -11,5 +12,14 @@ module EuchreCamp
|
|||||||
get "/admin/players/:id/edit", to: "admin.players.edit"
|
get "/admin/players/:id/edit", to: "admin.players.edit"
|
||||||
patch "/admin/players/:id", to: "admin.players.update"
|
patch "/admin/players/:id", to: "admin.players.update"
|
||||||
delete "/admin/players/:id", to: "admin.players.destroy"
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Binary file not shown.
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module EuchreCamp
|
module EuchreCamp
|
||||||
module Persistence
|
module Persistence
|
||||||
module Relations
|
module Relations
|
||||||
class Players < ROM::Relation[:sql]
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user