From e67ee92838db7d03d79f9884feb2f6a9b2eccbd6 Mon Sep 17 00:00:00 2001 From: sto Date: Tue, 18 Nov 2025 09:18:18 +0100 Subject: [PATCH] Add contest duration & complete ranking mode implementation --- app/controllers/concerns/contestants_concern.rb | 16 ++++++++++------ app/controllers/contests_controller.rb | 2 +- app/models/completion.rb | 12 +++++++++--- app/models/contest.rb | 12 ++++++++++++ app/views/contests/scoreboard.html.slim | 2 +- .../contests/settings_general_edit.html.slim | 6 ++++++ config/locales/en.yml | 4 ++++ config/locales/fr.yml | 4 ++++ .../20251118074900_add_duration_to_contest.rb | 5 +++++ ...1118074914_add_duration_seconds_to_contest.rb | 5 +++++ db/schema.rb | 4 +++- spec/factories/contests.rb | 2 ++ 12 files changed, 62 insertions(+), 12 deletions(-) create mode 100644 db/migrate/20251118074900_add_duration_to_contest.rb create mode 100644 db/migrate/20251118074914_add_duration_seconds_to_contest.rb diff --git a/app/controllers/concerns/contestants_concern.rb b/app/controllers/concerns/contestants_concern.rb index 5d60eb8..e8d3f8b 100644 --- a/app/controllers/concerns/contestants_concern.rb +++ b/app/controllers/concerns/contestants_concern.rb @@ -2,11 +2,15 @@ module ContestantsConcern extend ActiveSupport::Concern def ranked_contestants(contest) - contest.contestants.sort_by { |contestant| [ - -contestant.completions.where(remaining_pieces: nil).size, - (contestant.completions.where(remaining_pieces: nil).size == @contest.puzzles.length ? 1 : 0) * contestant.time_seconds, - contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? contestant.completions[-1].remaining_pieces : 1000000, - contestant.time_seconds - ] } + if contest.ranking_mode == "actual" + contest.contestants.sort_by { |contestant| [ + -contestant.completions.where(remaining_pieces: nil).size, + (contestant.completions.where(remaining_pieces: nil).size == @contest.puzzles.length ? 1 : 0) * contestant.time_seconds, + contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? contestant.completions[-1].remaining_pieces : 1000000, + contestant.time_seconds + ] } + elsif contest.ranking_mode == "theorical" + contest.contestants.sort_by { |contestant| contestant.completions.map { |completion| completion.projected_time }.sum } + end end end diff --git a/app/controllers/contests_controller.rb b/app/controllers/contests_controller.rb index 1dee732..8628bc5 100644 --- a/app/controllers/contests_controller.rb +++ b/app/controllers/contests_controller.rb @@ -204,7 +204,7 @@ class ContestsController < ApplicationController end def settings_general_params - params.expect(contest: [ :lang, :name, :public, :ranking_mode, :team, :allow_registration ]) + params.expect(contest: [ :lang, :name, :duration, :public, :ranking_mode, :team, :allow_registration ]) end def settings_offline_params diff --git a/app/models/completion.rb b/app/models/completion.rb index 00fe893..a024860 100644 --- a/app/models/completion.rb +++ b/app/models/completion.rb @@ -32,6 +32,8 @@ # puzzle_id (puzzle_id => puzzles.id) # class Completion < ApplicationRecord + include ContestsHelper + belongs_to :contest belongs_to :contestant belongs_to :puzzle @@ -42,7 +44,7 @@ class Completion < ApplicationRecord before_save :clean_pieces before_save :compute_projected_time - validates :display_time_from_start, presence: true, format: { with: /\A(((\d\d|\d):\d\d|\d\d|\d):\d\d|\d\d|\d)\z/ } + validates :display_time_from_start, presence: true, format: { with: /\A(((\d\d|\d):\d\d|\d\d|\d):\d\d|\d\d|\d)\z/ }, if: -> { completed || offline.present? } validates :remaining_pieces, presence: true, if: -> { !completed } validates :contestant_id, uniqueness: { scope: :puzzle }, if: -> { contest.puzzles.size == 1 } validates :puzzle_id, uniqueness: { scope: :contestant }, if: -> { contest.puzzles.size > 1 } @@ -66,7 +68,8 @@ class Completion < ApplicationRecord self.time_seconds = arr[0].to_i end else - self.time_seconds = 1 + self.time_seconds = self.contest.duration_seconds + self.display_time_from_start = display_time(self.time_seconds) end end @@ -75,6 +78,9 @@ class Completion < ApplicationRecord self.remaining_pieces = nil else self.missing_pieces = nil + if !self.offline.present? + self.display_time_from_start = nil + end end end @@ -82,7 +88,7 @@ class Completion < ApplicationRecord add_time_seconds if self.completed self.projected_time = self.time_seconds - elsif self.offline.present? + else assembled_time = self.time_seconds assembled_pieces = self.puzzle.pieces - self.remaining_pieces pieces_per_second = assembled_pieces.to_f / assembled_time.to_f diff --git a/app/models/contest.rb b/app/models/contest.rb index 7cd3488..8001c30 100644 --- a/app/models/contest.rb +++ b/app/models/contest.rb @@ -4,6 +4,8 @@ # # id :integer not null, primary key # allow_registration :boolean default(FALSE) +# duration :string +# duration_seconds :integer # lang :string default("en") # name :string # offline_form :boolean default(FALSE) @@ -37,9 +39,19 @@ class Contest < ApplicationRecord friendly_id :name, use: :slugged + before_save :add_duration_seconds + validates :name, presence: true validates :lang, inclusion: { in: Languages::AVAILABLE_LANGUAGES.map { |lang| lang[:id] } } validates :ranking_mode, inclusion: { in: Ranking::AVAILABLE_RANKING_MODES.map { |lang| lang[:id] } } + validates :duration, format: { with: /\A(\d\d:\d\d|\d:\d\d)\z/ } generates_token_for :token + + def add_duration_seconds + arr = self.duration.split(":") + if arr.size == 2 + self.duration_seconds = arr[0].to_i * 3600 + arr[1].to_i * 60 + end + end end diff --git a/app/views/contests/scoreboard.html.slim b/app/views/contests/scoreboard.html.slim index 989b17f..537636b 100644 --- a/app/views/contests/scoreboard.html.slim +++ b/app/views/contests/scoreboard.html.slim @@ -48,7 +48,7 @@ css: = contestant.completions.where(remaining_pieces: nil).length td style="position: relative" - if index > 0 && contestant.time_seconds > 0 && contestant.completions.where(completed: true).size > 0 - .relative-time style="position:absolute; margin: 1px 0 0 100px; font-size: 14px; color: grey" + .relative-time style="position:absolute; margin: 1px 0 0 112px; font-size: 14px; color: grey" |> + = display_time(contestant.time_seconds - @contestants[index - 1].time_seconds) = contestant.display_time diff --git a/app/views/contests/settings_general_edit.html.slim b/app/views/contests/settings_general_edit.html.slim index b73bc27..28f1813 100644 --- a/app/views/contests/settings_general_edit.html.slim +++ b/app/views/contests/settings_general_edit.html.slim @@ -4,6 +4,12 @@ .form-floating = form.text_field :name, autocomplete: "off", class: "form-control" = form.label :name, class: "required" + .row.mb-3 + .col + .form-floating + = form.text_field :duration, autocomplete: "off", class: "form-control" + = form.label :duration, class: "required" + .form-text = t("activerecord.attributes.contest.duration_description") .row.mb-3 .col .form-floating diff --git a/config/locales/en.yml b/config/locales/en.yml index e2bad72..529900c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -53,6 +53,8 @@ en: projected_time: Projected time remaining_pieces: Remaining pieces (not completed puzzle) contest: + duration: Duration + duration_description: Format h:mm or hh:mm lang: Language for the public scoreboard name: Name offline_form: Enable the offline participation form @@ -114,6 +116,8 @@ en: not_a_number: This is not an integer contest: attributes: + duration: + invalid: Invalid duration name: blank: The contest name cannot be empty contestant: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 711ec36..a166f44 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -24,6 +24,8 @@ fr: projected_time: Temps projeté remaining_pieces: Pièces restantes (puzzle non fini) contest: + duration: Durée + duration_description: Format h:mm ou hh:mm lang: Langue pour le classement public name: Nom offline_form: Activer le formulaire de participation hors-ligne @@ -85,6 +87,8 @@ fr: not_a_number: Ce n'est pas un nombre entier contest: attributes: + duration: + invalid: Durée invalide name: blank: Le nom du concours ne peut pas être vide contestant: diff --git a/db/migrate/20251118074900_add_duration_to_contest.rb b/db/migrate/20251118074900_add_duration_to_contest.rb new file mode 100644 index 0000000..3933e08 --- /dev/null +++ b/db/migrate/20251118074900_add_duration_to_contest.rb @@ -0,0 +1,5 @@ +class AddDurationToContest < ActiveRecord::Migration[8.0] + def change + add_column :contests, :duration, :string + end +end diff --git a/db/migrate/20251118074914_add_duration_seconds_to_contest.rb b/db/migrate/20251118074914_add_duration_seconds_to_contest.rb new file mode 100644 index 0000000..4050e19 --- /dev/null +++ b/db/migrate/20251118074914_add_duration_seconds_to_contest.rb @@ -0,0 +1,5 @@ +class AddDurationSecondsToContest < ActiveRecord::Migration[8.0] + def change + add_column :contests, :duration_seconds, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index 0f91367..0005932 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_11_14_093213) do +ActiveRecord::Schema[8.0].define(version: 2025_11_18_074914) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -98,6 +98,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_14_093213) do t.boolean "public", default: false t.boolean "offline_form", default: false t.string "ranking_mode" + t.string "duration" + t.integer "duration_seconds" t.index ["slug"], name: "index_contests_on_slug", unique: true t.index ["user_id"], name: "index_contests_on_user_id" end diff --git a/spec/factories/contests.rb b/spec/factories/contests.rb index f67e22f..074468d 100644 --- a/spec/factories/contests.rb +++ b/spec/factories/contests.rb @@ -4,6 +4,8 @@ # # id :integer not null, primary key # allow_registration :boolean default(FALSE) +# duration :string +# duration_seconds :integer # lang :string default("en") # name :string # offline_form :boolean default(FALSE)